commit 95b5e09fd43ca70ce1afc9774c11567444eb654e Author: zyy_dl888 Date: Sun Jul 5 17:44:15 2026 +0800 Initial MVP for QA asset backend diff --git a/hy_qa_asset_backend/.env.example b/hy_qa_asset_backend/.env.example new file mode 100644 index 0000000..3b0939c --- /dev/null +++ b/hy_qa_asset_backend/.env.example @@ -0,0 +1,18 @@ +DATABASE_URL= +OPENAI_API_KEY= +OPENAI_BASE_URL= +MODEL_NAME=gpt-4.1-mini + +FEISHU_APP_ID= +FEISHU_APP_SECRET= +FEISHU_APP_TOKEN= +# If the Base URL is under /wiki/, set FEISHU_WIKI_NODE_TOKEN instead of FEISHU_APP_TOKEN. +FEISHU_WIKI_NODE_TOKEN= +FEISHU_TABLE_ID_SESSION= +FEISHU_TABLE_ID_RAW_QA= +FEISHU_TABLE_ID_STANDARD_QA= + +SCHEDULE_CRON=0 1 * * * +SCHEDULE_TIMEZONE=Asia/Shanghai + +MOCK_MODE=true diff --git a/hy_qa_asset_backend/.gitignore b/hy_qa_asset_backend/.gitignore new file mode 100644 index 0000000..3981ec9 --- /dev/null +++ b/hy_qa_asset_backend/.gitignore @@ -0,0 +1,18 @@ +.env +.env.local +.venv/ +venv/ +__pycache__/ +*.pyc +*.pyo +*.db +*.sqlite +.pytest_cache/ +.mypy_cache/ +node_modules/ +.next/ +out/ +dist/ +coverage/ +*.log +.DS_Store diff --git a/hy_qa_asset_backend/README.md b/hy_qa_asset_backend/README.md new file mode 100644 index 0000000..ccc809d --- /dev/null +++ b/hy_qa_asset_backend/README.md @@ -0,0 +1,172 @@ +# 大本营答疑资产后台系统 MVP + +这是一个用于沉淀“大本营每周三答疑内容”的后台系统 MVP。第一阶段跑通闭环:资料进入、系统发现、AI 清洗、数据库沉淀、人工审核、标准问答入库、人工标记可调用。 + +## 系统架构 + +- 前端:Next.js + React + TypeScript + TailwindCSS,提供独立网页后台。 +- 后端:FastAPI + SQLAlchemy + Pydantic + APScheduler。 +- 数据库:优先 PostgreSQL;未配置 `DATABASE_URL` 时自动使用本地 SQLite。 +- AI:兼容 OpenAI Chat Completions API;没有 `OPENAI_API_KEY` 时走 mock AI。 +- 飞书:保留真实 API 适配器;没有飞书密钥时走 mock mode。 + +## 第一阶段功能 + +- 答疑场次管理,支持手动创建测试场次。 +- 手动触发或定时触发黑灯任务。 +- mock 飞书扫描与 mock 转写稿读取。 +- AI 清洗模块,真实 OpenAI 兼容 API 与 mock 输出双通道。 +- 原始问答拆解、标签、课程阶段、风险等级、脱敏状态入库。 +- 待审核卡片页,支持通过入库、修改后通过、需修改、不入库、禁止使用、标记高风险、标记需脱敏。 +- 标准问答库,审核通过后默认 `not_callable`,必须人工标记后才能进入可调用库。 +- 智能体可调用库接口只返回 `approved + callable + low/medium + 已脱敏/无需脱敏` 的标准问答。 +- 任务日志和审核日志可追溯。 + +## 不做什么 + +- 不做正式“千问千答智能体”。 +- 不做学员端问答界面。 +- 不绕过人工审核。 +- 不让未审核内容进入标准问答库或可调用库。 +- 不把 AI 生成内容伪装成院长或辅导老师原话。 +- 不做心理诊断、医疗建议、法律判断或课程效果承诺。 + +## 本地启动 + +后端: + +```bash +cd hy_qa_asset_backend/backend +python -m venv .venv +.venv\Scripts\activate +pip install -r requirements.txt +uvicorn app.main:app --reload --host 127.0.0.1 --port 8000 +``` + +前端: + +```bash +cd hy_qa_asset_backend/frontend +npm install +npm run dev +``` + +访问: + +- 前端后台:http://localhost:3000 +- 后端 API:http://127.0.0.1:8000/docs + +## 环境变量 + +复制 `.env.example` 为 `.env`,按需填写: + +- `DATABASE_URL`:为空时使用 SQLite;PostgreSQL 示例:`postgresql+psycopg://hyqa:hyqa@localhost:5432/hyqa` +- `OPENAI_API_KEY`:为空时使用 mock AI。 +- `OPENAI_BASE_URL`:OpenAI 兼容服务地址,可选。 +- `MODEL_NAME`:默认 `gpt-4.1-mini`。 +- `FEISHU_APP_ID`、`FEISHU_APP_SECRET`、`FEISHU_APP_TOKEN`、`FEISHU_TABLE_ID_SESSION`、`FEISHU_TABLE_ID_RAW_QA`、`FEISHU_TABLE_ID_STANDARD_QA`:真实飞书接入所需。若多维表格 URL 是 `/wiki/`,可以填写 `FEISHU_WIKI_NODE_TOKEN` 代替 `FEISHU_APP_TOKEN`,后台会自动解析真实 bitable app_token。 +- `SCHEDULE_CRON`:默认 `0 1 * * *`。 +- `SCHEDULE_TIMEZONE`:默认 `Asia/Shanghai`。 +- `MOCK_MODE`:默认 `true`。 + +敏感密钥只从环境变量读取,不写入数据库,也不会在设置页明文展示。 + +## Mock 模式测试 + +没有飞书和 OpenAI 密钥也可以跑通: + +1. 启动后端和前端。 +2. 打开 `/dashboard`,点击“手动触发处理任务”。 +3. 后端会创建 mock 场次,读取 `backend/app/seed/sample_transcript_001.txt`。 +4. `ai_cleaner` 在没有 `OPENAI_API_KEY` 时返回 mock 拆解结果。 +5. 进入 `/review` 查看待审核问答卡片。 + +也可以在 `/sessions` 手动创建场次,填写转写稿,再点击“处理”或“重跑”。 + +## 手动触发黑灯任务 + +前端:在 `/dashboard` 点击“手动触发处理任务”。 + +API: + +```bash +curl -X POST http://127.0.0.1:8000/api/tasks/run-now +``` + +定时任务由 APScheduler 在后端启动时注册,默认每天 `Asia/Shanghai` 凌晨 1 点运行一次。 + +## 审核流程 + +1. `/review` 展示 `review_status=pending` 的原始问答。 +2. 点击“通过入库”后,原始问答变为 `approved`。 +3. 若 `suggested_to_standard_qa=true`,且不是高风险/禁止、且无需脱敏或已完成脱敏,系统创建 `standard_qa_items`。 +4. 标准问答默认 `audit_status=approved`、`call_status=not_callable`。 +5. 审核人进入 `/standard-qa`,手动点击“可调用”。 +6. `/callable-qa` 只展示满足强制筛选规则的内容。 + +高风险内容、禁止内容、未脱敏内容不会自动进入可调用库。 + +## 风险边界 + +系统逻辑和 Prompt 共同遵守: + +1. AI 只能辅助清洗、拆解、打标签、风险初筛,不能替代人工审核。 +2. 原始问题和问题整理版分开保存。 +3. 原始回答、回答整理版、标准回答分开保存。 +4. 未审核内容不能进入标准问答库。 +5. 未审核内容不能被未来智能体调用。 +6. 高风险内容必须人工复审。 +7. 涉及学员隐私必须标记脱敏。 +8. 不做心理诊断、医疗建议、法律判断或课程效果承诺。 +9. 涉及孩子、婚姻、家庭矛盾、姓名、电话、城市、学校、单位等信息,默认标记风险。 +10. 不确定是否有风险时,默认中风险并进入人工审核。 + +## 飞书接入说明 + +第一版 `backend/app/services/feishu_client.py` 已预留: + +- `scan_unprocessed_sessions` +- `fetch_transcript` +- `update_session_status` +- `write_raw_qa_items` +- `write_standard_qa_items` + +真实飞书 API 已接入:后台会获取 `tenant_access_token`,扫描多维表格场次表,读取 `转写稿` 字段或尝试通过 docx 纯文本接口读取 `飞书资料链接`,处理完成后回写场次状态、原始问答表和标准问答表。 + +建议在飞书多维表格中建立 3 张表: + +- `答疑场次`:`场次编号`、`标题`、`日期`、`答疑老师`、`飞书资料链接`、`转写稿`、`处理状态`、`问答总数`、`待审核数`、`已审核数`、`已入库数`、`失败原因` +- `原始问答`:`问答编号`、`场次编号`、`原始问题`、`问题整理版`、`原始回答`、`回答整理版`、`AI建议标准问题`、`AI建议标准回答`、`回答人`、`一级主题`、`问题标签`、`课程阶段`、`适用人群`、`情绪强度`、`风险等级`、`风险类型`、`风险说明`、`是否需脱敏`、`脱敏状态`、`审核状态`、`建议入库`、`来源时间戳`、`审核备注` +- `标准问答`:`标准编号`、`来源原始问答ID`、`来源场次ID`、`标准问题`、`标准回答`、`相似问法`、`一级主题`、`问题标签`、`课程阶段`、`适用人群`、`回答边界`、`禁止表达`、`风险等级`、`审核状态`、`调用状态`、`禁用原因` + +可打开 `GET /api/feishu/setup-guide` 查看字段模板,打开 `GET /api/feishu/status` 检查飞书 token 是否可用。应用需要具备多维表格读写、文档读取等权限;如果要读取已有飞书文档,需要将飞书应用/机器人加入该文档协作者。 + +## AI 接入说明 + +`backend/app/services/ai_cleaner.py` 会读取 `backend/app/prompts/qa_cleaning_prompt.md`,调用 OpenAI 兼容 `/chat/completions`,解析 JSON 数组并校验字段。 + +接入真实 OpenAI 兼容 API 时,填写: + +- `OPENAI_API_KEY` +- `MODEL_NAME` +- `OPENAI_BASE_URL`,如果使用非官方兼容服务 + +如果 AI 返回非法 JSON,任务会记录失败原因。 + +## Docker + +```bash +cd hy_qa_asset_backend +docker compose up --build +``` + +包含 PostgreSQL、backend、frontend 三个服务。默认仍使用 mock mode。 + +## 后续迭代建议 + +- 接入真实飞书多维表格和文档 API。 +- 增加登录鉴权与角色权限。 +- 增加更完整的标准问答编辑表单和批量审核。 +- 增加脱敏处理工作台。 +- 增加 AI 结果二次校验和人工差异对比。 +- 增加迁移工具 Alembic、测试用例和生产部署配置。 diff --git a/hy_qa_asset_backend/backend/Dockerfile b/hy_qa_asset_backend/backend/Dockerfile new file mode 100644 index 0000000..ff2f389 --- /dev/null +++ b/hy_qa_asset_backend/backend/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app ./app + +EXPOSE 8000 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] + diff --git a/hy_qa_asset_backend/backend/app/__init__.py b/hy_qa_asset_backend/backend/app/__init__.py new file mode 100644 index 0000000..dd72e73 --- /dev/null +++ b/hy_qa_asset_backend/backend/app/__init__.py @@ -0,0 +1,2 @@ +"""Backend package for 大本营答疑资产后台系统 MVP.""" + diff --git a/hy_qa_asset_backend/backend/app/api/__init__.py b/hy_qa_asset_backend/backend/app/api/__init__.py new file mode 100644 index 0000000..2364578 --- /dev/null +++ b/hy_qa_asset_backend/backend/app/api/__init__.py @@ -0,0 +1,2 @@ +"""API routers.""" + diff --git a/hy_qa_asset_backend/backend/app/api/dashboard.py b/hy_qa_asset_backend/backend/app/api/dashboard.py new file mode 100644 index 0000000..a5d3e82 --- /dev/null +++ b/hy_qa_asset_backend/backend/app/api/dashboard.py @@ -0,0 +1,42 @@ +from fastapi import APIRouter, Depends +from sqlalchemy import or_ +from sqlalchemy.orm import Session + +from app import models, schemas +from app.database import get_db + +router = APIRouter(prefix="/dashboard", tags=["dashboard"]) + + +@router.get("/summary", response_model=schemas.DashboardSummary) +def dashboard_summary(db: Session = Depends(get_db)) -> schemas.DashboardSummary: + last_task = db.query(models.TaskRun).order_by(models.TaskRun.created_at.desc()).first() + return schemas.DashboardSummary( + total_sessions=db.query(models.FeishuSession).count(), + processed_sessions=db.query(models.FeishuSession) + .filter(models.FeishuSession.process_status.in_([models.ProcessStatus.pending_review, models.ProcessStatus.completed])) + .count(), + pending_review_count=db.query(models.RawQAItem) + .filter(models.RawQAItem.review_status.in_([models.ReviewStatus.pending, models.ReviewStatus.reviewing])) + .count(), + high_risk_count=db.query(models.RawQAItem) + .filter(or_(models.RawQAItem.risk_level == models.RiskLevel.high, models.RawQAItem.risk_level == models.RiskLevel.forbidden)) + .count(), + approved_qa_count=db.query(models.RawQAItem).filter(models.RawQAItem.review_status == models.ReviewStatus.approved).count(), + standard_qa_count=db.query(models.StandardQAItem).count(), + callable_qa_count=db.query(models.StandardQAItem) + .join(models.RawQAItem) + .filter( + models.StandardQAItem.audit_status == models.AuditStatus.approved, + models.StandardQAItem.call_status == models.CallStatus.callable, + models.StandardQAItem.risk_level.in_([models.RiskLevel.low, models.RiskLevel.medium]), + models.RawQAItem.desensitization_status.in_( + [models.DesensitizationStatus.done, models.DesensitizationStatus.not_needed] + ), + ) + .count(), + last_task_status=last_task.task_status.value if last_task else None, + last_task_time=last_task.ended_at if last_task else None, + last_task_error=last_task.error_message if last_task else None, + ) + diff --git a/hy_qa_asset_backend/backend/app/api/feishu.py b/hy_qa_asset_backend/backend/app/api/feishu.py new file mode 100644 index 0000000..4292eac --- /dev/null +++ b/hy_qa_asset_backend/backend/app/api/feishu.py @@ -0,0 +1,112 @@ +from fastapi import APIRouter + +from app import schemas +from app.services.feishu_client import FeishuClient + +router = APIRouter(prefix="/feishu", tags=["feishu"]) + + +@router.get("/status", response_model=schemas.FeishuStatus) +def feishu_status() -> dict: + return FeishuClient().connection_status() + + +@router.get("/setup-guide", response_model=schemas.FeishuSetupGuide) +def setup_guide() -> schemas.FeishuSetupGuide: + return schemas.FeishuSetupGuide( + required_env=[ + "FEISHU_APP_ID", + "FEISHU_APP_SECRET", + "FEISHU_APP_TOKEN 或 FEISHU_WIKI_NODE_TOKEN", + "FEISHU_TABLE_ID_SESSION", + "FEISHU_TABLE_ID_RAW_QA", + "FEISHU_TABLE_ID_STANDARD_QA", + "MOCK_MODE=false", + ], + required_permissions=[ + "bitable:app:readonly", + "bitable:app:readwrite", + "docx:document:readonly", + "drive:drive:readonly", + "如果要读取文档链接里的内容,需要把飞书应用/机器人加入目标文档协作者。", + ], + tables=[ + schemas.FeishuTableTemplate( + table_key="session", + table_name="答疑场次", + env_name="FEISHU_TABLE_ID_SESSION", + required_fields=[ + "场次编号", + "标题", + "日期", + "答疑老师", + "飞书资料链接", + "转写稿", + "处理状态", + "问答总数", + "待审核数", + "已审核数", + "已入库数", + "失败原因", + ], + ), + schemas.FeishuTableTemplate( + table_key="raw_qa", + table_name="原始问答", + env_name="FEISHU_TABLE_ID_RAW_QA", + required_fields=[ + "问答编号", + "场次编号", + "原始问题", + "问题整理版", + "原始回答", + "回答整理版", + "AI建议标准问题", + "AI建议标准回答", + "回答人", + "一级主题", + "问题标签", + "课程阶段", + "适用人群", + "情绪强度", + "风险等级", + "风险类型", + "风险说明", + "是否需脱敏", + "脱敏状态", + "审核状态", + "建议入库", + "来源时间戳", + "审核备注", + ], + ), + schemas.FeishuTableTemplate( + table_key="standard_qa", + table_name="标准问答", + env_name="FEISHU_TABLE_ID_STANDARD_QA", + required_fields=[ + "标准编号", + "来源原始问答ID", + "来源场次ID", + "标准问题", + "标准回答", + "相似问法", + "一级主题", + "问题标签", + "课程阶段", + "适用人群", + "回答边界", + "禁止表达", + "风险等级", + "审核状态", + "调用状态", + "禁用原因", + ], + ), + ], + notes=[ + "场次表中只有处理状态为空、unprocessed 或 未处理 的记录会被后台扫描处理。", + "如果暂时不接文档 API,可以直接把完整文字稿粘到场次表的 转写稿 字段。", + "敏感密钥不要写入 system_settings,请写入 backend/.env 或运行环境变量。", + ], + ) diff --git a/hy_qa_asset_backend/backend/app/api/logs.py b/hy_qa_asset_backend/backend/app/api/logs.py new file mode 100644 index 0000000..2197954 --- /dev/null +++ b/hy_qa_asset_backend/backend/app/api/logs.py @@ -0,0 +1,18 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app import models, schemas +from app.database import get_db + +router = APIRouter(prefix="/logs", tags=["logs"]) + + +@router.get("/audit", response_model=list[schemas.AuditLogRead]) +def audit_logs(db: Session = Depends(get_db)) -> list[models.AuditLog]: + return db.query(models.AuditLog).order_by(models.AuditLog.created_at.desc()).limit(200).all() + + +@router.get("/tasks", response_model=list[schemas.TaskRunRead]) +def task_logs(db: Session = Depends(get_db)) -> list[models.TaskRun]: + return db.query(models.TaskRun).order_by(models.TaskRun.created_at.desc()).limit(200).all() + diff --git a/hy_qa_asset_backend/backend/app/api/raw_qa.py b/hy_qa_asset_backend/backend/app/api/raw_qa.py new file mode 100644 index 0000000..ca64d37 --- /dev/null +++ b/hy_qa_asset_backend/backend/app/api/raw_qa.py @@ -0,0 +1,150 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app import models, schemas +from app.database import get_db +from app.services.standard_qa_service import add_audit_log, approve_raw_qa, refresh_session_counts, set_raw_status + +router = APIRouter(prefix="/raw-qa", tags=["raw_qa"]) + + +def _get_raw(db: Session, raw_id: int) -> models.RawQAItem: + raw = db.get(models.RawQAItem, raw_id) + if not raw: + raise HTTPException(status_code=404, detail="原始问答不存在") + return raw + + +@router.get("", response_model=list[schemas.RawQAItemRead]) +def list_raw_qa( + review_status: str | None = None, + risk_level: str | None = None, + session_id: int | None = None, + primary_topic: str | None = None, + course_stage: str | None = None, + db: Session = Depends(get_db), +) -> list[models.RawQAItem]: + query = db.query(models.RawQAItem) + if review_status: + query = query.filter(models.RawQAItem.review_status == review_status) + if risk_level: + query = query.filter(models.RawQAItem.risk_level == risk_level) + if session_id: + query = query.filter(models.RawQAItem.session_id == session_id) + if primary_topic: + query = query.filter(models.RawQAItem.primary_topic == primary_topic) + if course_stage: + query = query.filter(models.RawQAItem.course_stage == course_stage) + return query.order_by(models.RawQAItem.created_at.desc()).all() + + +@router.get("/{raw_id}", response_model=schemas.RawQAItemRead) +def get_raw_qa(raw_id: int, db: Session = Depends(get_db)) -> models.RawQAItem: + return _get_raw(db, raw_id) + + +@router.patch("/{raw_id}", response_model=schemas.RawQAItemRead) +def update_raw_qa(raw_id: int, payload: schemas.RawQAItemUpdate, db: Session = Depends(get_db)) -> models.RawQAItem: + raw = _get_raw(db, raw_id) + for key, value in payload.model_dump(exclude_unset=True).items(): + setattr(raw, key, value) + db.commit() + db.refresh(raw) + return raw + + +@router.post("/{raw_id}/approve", response_model=schemas.RawQAItemRead) +def approve(raw_id: int, payload: schemas.ReviewActionRequest | None = None, db: Session = Depends(get_db)) -> models.RawQAItem: + payload = payload or schemas.ReviewActionRequest() + return approve_raw_qa(db, _get_raw(db, raw_id), payload.reviewer_id, payload.comment) + + +@router.post("/{raw_id}/revise", response_model=schemas.RawQAItemRead) +def revise(raw_id: int, payload: schemas.ReviewActionRequest | None = None, db: Session = Depends(get_db)) -> models.RawQAItem: + payload = payload or schemas.ReviewActionRequest() + return set_raw_status( + db, + _get_raw(db, raw_id), + models.ReviewStatus.needs_revision, + models.AuditAction.revise, + payload.reviewer_id, + payload.comment, + ) + + +@router.post("/{raw_id}/reject", response_model=schemas.RawQAItemRead) +def reject(raw_id: int, payload: schemas.ReviewActionRequest | None = None, db: Session = Depends(get_db)) -> models.RawQAItem: + payload = payload or schemas.ReviewActionRequest() + return set_raw_status( + db, + _get_raw(db, raw_id), + models.ReviewStatus.rejected, + models.AuditAction.reject, + payload.reviewer_id, + payload.comment, + ) + + +@router.post("/{raw_id}/forbid", response_model=schemas.RawQAItemRead) +def forbid(raw_id: int, payload: schemas.ReviewActionRequest | None = None, db: Session = Depends(get_db)) -> models.RawQAItem: + payload = payload or schemas.ReviewActionRequest() + raw = _get_raw(db, raw_id) + raw.suggested_to_standard_qa = False + return set_raw_status( + db, + raw, + models.ReviewStatus.forbidden, + models.AuditAction.forbid, + payload.reviewer_id, + payload.comment, + ) + + +@router.post("/{raw_id}/mark-high-risk", response_model=schemas.RawQAItemRead) +def mark_high_risk( + raw_id: int, payload: schemas.ReviewActionRequest | None = None, db: Session = Depends(get_db) +) -> models.RawQAItem: + payload = payload or schemas.ReviewActionRequest() + raw = _get_raw(db, raw_id) + before = raw.risk_level.value + raw.risk_level = models.RiskLevel.high + raw.suggested_to_standard_qa = False + add_audit_log( + db, + user_id=payload.reviewer_id, + target_type=models.TargetType.raw_qa, + target_id=raw.id, + action=models.AuditAction.mark_high_risk, + before_status=before, + after_status=raw.risk_level.value, + comment=payload.comment, + ) + refresh_session_counts(db, raw.session_id) + db.commit() + db.refresh(raw) + return raw + + +@router.post("/{raw_id}/mark-desensitization", response_model=schemas.RawQAItemRead) +def mark_desensitization( + raw_id: int, payload: schemas.ReviewActionRequest | None = None, db: Session = Depends(get_db) +) -> models.RawQAItem: + payload = payload or schemas.ReviewActionRequest() + raw = _get_raw(db, raw_id) + before = raw.desensitization_status.value + raw.need_desensitization = True + raw.desensitization_status = models.DesensitizationStatus.needed + add_audit_log( + db, + user_id=payload.reviewer_id, + target_type=models.TargetType.raw_qa, + target_id=raw.id, + action=models.AuditAction.mark_desensitization, + before_status=before, + after_status=raw.desensitization_status.value, + comment=payload.comment, + ) + db.commit() + db.refresh(raw) + return raw + diff --git a/hy_qa_asset_backend/backend/app/api/sessions.py b/hy_qa_asset_backend/backend/app/api/sessions.py new file mode 100644 index 0000000..7ef3b58 --- /dev/null +++ b/hy_qa_asset_backend/backend/app/api/sessions.py @@ -0,0 +1,72 @@ +from datetime import date + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app import models, schemas +from app.database import get_db +from app.services.task_runner import TaskRunner + +router = APIRouter(prefix="/sessions", tags=["sessions"]) + + +@router.get("", response_model=list[schemas.FeishuSessionRead]) +def list_sessions(db: Session = Depends(get_db)) -> list[models.FeishuSession]: + return db.query(models.FeishuSession).order_by(models.FeishuSession.created_at.desc()).all() + + +@router.get("/{session_id}", response_model=schemas.FeishuSessionRead) +def get_session(session_id: int, db: Session = Depends(get_db)) -> models.FeishuSession: + session = db.get(models.FeishuSession, session_id) + if not session: + raise HTTPException(status_code=404, detail="场次不存在") + return session + + +@router.post("", response_model=schemas.FeishuSessionRead) +def create_session(payload: schemas.FeishuSessionCreate, db: Session = Depends(get_db)) -> models.FeishuSession: + next_id = db.query(models.FeishuSession).count() + 1 + session = models.FeishuSession( + session_code=payload.session_code or f"MANUAL-{next_id:04d}", + title=payload.title, + date=payload.date or date.today(), + teachers=payload.teachers, + feishu_doc_url=payload.feishu_doc_url, + feishu_record_id=payload.feishu_record_id, + source_file_name=payload.source_file_name, + source_text=payload.source_text, + process_status=models.ProcessStatus.unprocessed, + ) + db.add(session) + db.commit() + db.refresh(session) + return session + + +@router.patch("/{session_id}", response_model=schemas.FeishuSessionRead) +def update_session( + session_id: int, payload: schemas.FeishuSessionUpdate, db: Session = Depends(get_db) +) -> models.FeishuSession: + session = db.get(models.FeishuSession, session_id) + if not session: + raise HTTPException(status_code=404, detail="场次不存在") + for key, value in payload.model_dump(exclude_unset=True).items(): + setattr(session, key, value) + db.commit() + db.refresh(session) + return session + + +@router.post("/{session_id}/process", response_model=schemas.TaskRunRead) +async def process_session(session_id: int, db: Session = Depends(get_db)) -> models.TaskRun: + runner = TaskRunner() + task = await runner.run(db, task_type=models.TaskType.manual_process, session_id=session_id) + return task + + +@router.post("/{session_id}/reprocess", response_model=schemas.TaskRunRead) +async def reprocess_session(session_id: int, db: Session = Depends(get_db)) -> models.TaskRun: + runner = TaskRunner() + task = await runner.run(db, task_type=models.TaskType.manual_process, session_id=session_id, reprocess=True) + return task + diff --git a/hy_qa_asset_backend/backend/app/api/settings.py b/hy_qa_asset_backend/backend/app/api/settings.py new file mode 100644 index 0000000..82d7fda --- /dev/null +++ b/hy_qa_asset_backend/backend/app/api/settings.py @@ -0,0 +1,51 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app import models, schemas +from app.config import get_settings +from app.database import get_db + +router = APIRouter(prefix="/settings", tags=["settings"]) + +SENSITIVE_MARKERS = {"secret", "token", "key", "password", "api_key"} + + +@router.get("", response_model=schemas.SafeSettingsRead) +def read_settings(db: Session = Depends(get_db)) -> schemas.SafeSettingsRead: + settings = get_settings() + rows = db.query(models.SystemSetting).order_by(models.SystemSetting.key.asc()).all() + driver = settings.resolved_database_url.split(":", 1)[0] + return schemas.SafeSettingsRead( + feishu_configured=settings.feishu_configured, + openai_configured=settings.openai_configured, + model_name=settings.model_name, + schedule_cron=settings.schedule_cron, + schedule_timezone=settings.schedule_timezone, + mock_mode=settings.mock_mode or not settings.feishu_configured, + database_url=f"{driver}://***", + feishu_tables_configured={ + "session": bool(settings.feishu_table_id_session), + "raw_qa": bool(settings.feishu_table_id_raw_qa), + "standard_qa": bool(settings.feishu_table_id_standard_qa), + }, + system_settings=[ + {"key": row.key, "value": row.value, "description": row.description, "updated_at": row.updated_at} + for row in rows + ], + ) + + +@router.patch("", response_model=schemas.SafeSettingsRead) +def patch_settings(payload: schemas.SettingsPatch, db: Session = Depends(get_db)) -> schemas.SafeSettingsRead: + for key, value in payload.settings.items(): + lower = key.lower() + if any(marker in lower for marker in SENSITIVE_MARKERS): + raise HTTPException(status_code=400, detail="敏感密钥不能写入 system_settings,请使用 .env") + row = db.query(models.SystemSetting).filter(models.SystemSetting.key == key).first() + if row: + row.value = value + else: + db.add(models.SystemSetting(key=key, value=value, description="后台页面写入的非敏感配置")) + db.commit() + return read_settings(db) + diff --git a/hy_qa_asset_backend/backend/app/api/standard_qa.py b/hy_qa_asset_backend/backend/app/api/standard_qa.py new file mode 100644 index 0000000..60c322e --- /dev/null +++ b/hy_qa_asset_backend/backend/app/api/standard_qa.py @@ -0,0 +1,146 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app import models, schemas +from app.database import get_db +from app.services.standard_qa_service import add_audit_log, mark_standard_call_status + +router = APIRouter(prefix="/standard-qa", tags=["standard_qa"]) + + +def _get_standard(db: Session, standard_id: int) -> models.StandardQAItem: + standard = db.get(models.StandardQAItem, standard_id) + if not standard: + raise HTTPException(status_code=404, detail="标准问答不存在") + return standard + + +@router.get("/callable", response_model=list[schemas.CallableQARead]) +def callable_qa(db: Session = Depends(get_db)) -> list[models.StandardQAItem]: + return ( + db.query(models.StandardQAItem) + .join(models.RawQAItem) + .filter( + models.StandardQAItem.audit_status == models.AuditStatus.approved, + models.StandardQAItem.call_status == models.CallStatus.callable, + models.StandardQAItem.risk_level.in_([models.RiskLevel.low, models.RiskLevel.medium]), + models.RawQAItem.desensitization_status.in_( + [models.DesensitizationStatus.not_needed, models.DesensitizationStatus.done] + ), + ) + .order_by(models.StandardQAItem.updated_at.desc()) + .all() + ) + + +@router.get("", response_model=list[schemas.StandardQARead]) +def list_standard_qa( + audit_status: str | None = None, + call_status: str | None = None, + risk_level: str | None = None, + primary_topic: str | None = None, + course_stage: str | None = None, + db: Session = Depends(get_db), +) -> list[models.StandardQAItem]: + query = db.query(models.StandardQAItem) + if audit_status: + query = query.filter(models.StandardQAItem.audit_status == audit_status) + if call_status: + query = query.filter(models.StandardQAItem.call_status == call_status) + if risk_level: + query = query.filter(models.StandardQAItem.risk_level == risk_level) + if primary_topic: + query = query.filter(models.StandardQAItem.primary_topic == primary_topic) + if course_stage: + query = query.filter(models.StandardQAItem.course_stage == course_stage) + return query.order_by(models.StandardQAItem.updated_at.desc()).all() + + +@router.get("/{standard_id}", response_model=schemas.StandardQARead) +def get_standard_qa(standard_id: int, db: Session = Depends(get_db)) -> models.StandardQAItem: + return _get_standard(db, standard_id) + + +@router.patch("/{standard_id}", response_model=schemas.StandardQARead) +def update_standard_qa( + standard_id: int, payload: schemas.StandardQAUpdate, db: Session = Depends(get_db) +) -> models.StandardQAItem: + standard = _get_standard(db, standard_id) + for key, value in payload.model_dump(exclude_unset=True).items(): + setattr(standard, key, value) + db.commit() + db.refresh(standard) + return standard + + +@router.post("/{standard_id}/mark-callable", response_model=schemas.StandardQARead) +def mark_callable( + standard_id: int, payload: schemas.ReviewActionRequest | None = None, db: Session = Depends(get_db) +) -> models.StandardQAItem: + payload = payload or schemas.ReviewActionRequest() + try: + return mark_standard_call_status( + db, _get_standard(db, standard_id), models.CallStatus.callable, payload.reviewer_id, payload.comment + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.post("/{standard_id}/mark-not-callable", response_model=schemas.StandardQARead) +def mark_not_callable( + standard_id: int, payload: schemas.ReviewActionRequest | None = None, db: Session = Depends(get_db) +) -> models.StandardQAItem: + payload = payload or schemas.ReviewActionRequest() + return mark_standard_call_status( + db, _get_standard(db, standard_id), models.CallStatus.not_callable, payload.reviewer_id, payload.comment + ) + + +@router.post("/{standard_id}/disable", response_model=schemas.StandardQARead) +def disable_standard( + standard_id: int, payload: schemas.ReviewActionRequest | None = None, db: Session = Depends(get_db) +) -> models.StandardQAItem: + payload = payload or schemas.ReviewActionRequest() + standard = _get_standard(db, standard_id) + before = standard.audit_status.value + standard.audit_status = models.AuditStatus.disabled + standard.call_status = models.CallStatus.forbidden + standard.disabled_reason = payload.comment + add_audit_log( + db, + user_id=payload.reviewer_id, + target_type=models.TargetType.standard_qa, + target_id=standard.id, + action=models.AuditAction.disable, + before_status=before, + after_status=standard.audit_status.value, + comment=payload.comment, + ) + db.commit() + db.refresh(standard) + return standard + + +@router.post("/{standard_id}/need-review", response_model=schemas.StandardQARead) +def need_review( + standard_id: int, payload: schemas.ReviewActionRequest | None = None, db: Session = Depends(get_db) +) -> models.StandardQAItem: + payload = payload or schemas.ReviewActionRequest() + standard = _get_standard(db, standard_id) + before = standard.audit_status.value + standard.audit_status = models.AuditStatus.needs_revision + standard.call_status = models.CallStatus.need_review + add_audit_log( + db, + user_id=payload.reviewer_id, + target_type=models.TargetType.standard_qa, + target_id=standard.id, + action=models.AuditAction.revise, + before_status=before, + after_status=standard.audit_status.value, + comment=payload.comment, + ) + db.commit() + db.refresh(standard) + return standard + diff --git a/hy_qa_asset_backend/backend/app/api/tasks.py b/hy_qa_asset_backend/backend/app/api/tasks.py new file mode 100644 index 0000000..04e31ae --- /dev/null +++ b/hy_qa_asset_backend/backend/app/api/tasks.py @@ -0,0 +1,28 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app import models, schemas +from app.database import get_db +from app.services.task_runner import TaskRunner + +router = APIRouter(prefix="/tasks", tags=["tasks"]) + + +@router.get("/runs", response_model=list[schemas.TaskRunRead]) +def list_task_runs(db: Session = Depends(get_db)) -> list[models.TaskRun]: + return db.query(models.TaskRun).order_by(models.TaskRun.created_at.desc()).limit(100).all() + + +@router.get("/runs/{task_id}", response_model=schemas.TaskRunRead) +def get_task_run(task_id: int, db: Session = Depends(get_db)) -> models.TaskRun: + task = db.get(models.TaskRun, task_id) + if not task: + raise HTTPException(status_code=404, detail="任务不存在") + return task + + +@router.post("/run-now", response_model=schemas.TaskRunRead) +async def run_now(db: Session = Depends(get_db)) -> models.TaskRun: + runner = TaskRunner() + return await runner.run(db, task_type=models.TaskType.manual_scan) + diff --git a/hy_qa_asset_backend/backend/app/config.py b/hy_qa_asset_backend/backend/app/config.py new file mode 100644 index 0000000..5dfe843 --- /dev/null +++ b/hy_qa_asset_backend/backend/app/config.py @@ -0,0 +1,61 @@ +from functools import lru_cache +from pathlib import Path + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + app_name: str = "大本营答疑资产后台系统 MVP" + database_url: str | None = Field(default=None, alias="DATABASE_URL") + openai_api_key: str | None = Field(default=None, alias="OPENAI_API_KEY") + openai_base_url: str | None = Field(default=None, alias="OPENAI_BASE_URL") + model_name: str = Field(default="gpt-4.1-mini", alias="MODEL_NAME") + + feishu_app_id: str | None = Field(default=None, alias="FEISHU_APP_ID") + feishu_app_secret: str | None = Field(default=None, alias="FEISHU_APP_SECRET") + feishu_app_token: str | None = Field(default=None, alias="FEISHU_APP_TOKEN") + feishu_wiki_node_token: str | None = Field(default=None, alias="FEISHU_WIKI_NODE_TOKEN") + feishu_table_id_session: str | None = Field(default=None, alias="FEISHU_TABLE_ID_SESSION") + feishu_table_id_raw_qa: str | None = Field(default=None, alias="FEISHU_TABLE_ID_RAW_QA") + feishu_table_id_standard_qa: str | None = Field(default=None, alias="FEISHU_TABLE_ID_STANDARD_QA") + + schedule_cron: str = Field(default="0 1 * * *", alias="SCHEDULE_CRON") + schedule_timezone: str = Field(default="Asia/Shanghai", alias="SCHEDULE_TIMEZONE") + mock_mode: bool = Field(default=True, alias="MOCK_MODE") + + model_config = SettingsConfigDict( + env_file=(".env", "../.env", "../../.env"), + env_file_encoding="utf-8", + extra="ignore", + populate_by_name=True, + ) + + @property + def resolved_database_url(self) -> str: + if self.database_url: + return self.database_url + db_path = Path(__file__).resolve().parents[2] / "hy_qa_asset.db" + return f"sqlite:///{db_path.as_posix()}" + + @property + def openai_configured(self) -> bool: + return bool(self.openai_api_key) + + @property + def feishu_configured(self) -> bool: + return all( + [ + self.feishu_app_id, + self.feishu_app_secret, + self.feishu_app_token or self.feishu_wiki_node_token, + self.feishu_table_id_session, + self.feishu_table_id_raw_qa, + self.feishu_table_id_standard_qa, + ] + ) + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/hy_qa_asset_backend/backend/app/database.py b/hy_qa_asset_backend/backend/app/database.py new file mode 100644 index 0000000..b37aaf2 --- /dev/null +++ b/hy_qa_asset_backend/backend/app/database.py @@ -0,0 +1,49 @@ +from collections.abc import Generator + +from sqlalchemy import create_engine +from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker + +from app.config import get_settings + + +class Base(DeclarativeBase): + pass + + +settings = get_settings() +connect_args = {"check_same_thread": False} if settings.resolved_database_url.startswith("sqlite") else {} +engine = create_engine(settings.resolved_database_url, connect_args=connect_args, future=True) +SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + + +def get_db() -> Generator[Session, None, None]: + db = SessionLocal() + try: + yield db + finally: + db.close() + + +def init_db() -> None: + from app import models + + Base.metadata.create_all(bind=engine) + with SessionLocal() as db: + if not db.query(models.User).first(): + db.add( + models.User( + name="默认审核员", + email="reviewer@example.local", + role=models.UserRole.reviewer, + ) + ) + defaults = { + "risk_policy": "AI 只做初筛,未审核内容不能进入标准问答库或可调用库。", + "mock_mode": "true" if settings.mock_mode else "false", + } + for key, value in defaults.items(): + exists = db.query(models.SystemSetting).filter(models.SystemSetting.key == key).first() + if not exists: + db.add(models.SystemSetting(key=key, value=value, description="系统内置非敏感配置")) + db.commit() + diff --git a/hy_qa_asset_backend/backend/app/main.py b/hy_qa_asset_backend/backend/app/main.py new file mode 100644 index 0000000..6da0cb0 --- /dev/null +++ b/hy_qa_asset_backend/backend/app/main.py @@ -0,0 +1,52 @@ +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.api import dashboard, feishu, logs, raw_qa, sessions, settings, standard_qa, tasks +from app.config import get_settings +from app.database import init_db +from app.services.scheduler import start_scheduler + + +@asynccontextmanager +async def lifespan(app: FastAPI): + init_db() + start_scheduler() + yield + + +settings_obj = get_settings() +app = FastAPI(title=settings_obj.app_name, version="0.1.0", lifespan=lifespan) + +app.add_middleware( + CORSMiddleware, + allow_origins=[ + "http://localhost:3000", + "http://127.0.0.1:3000", + "http://localhost:3001", + "http://127.0.0.1:3001", + ], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(dashboard.router, prefix="/api") +app.include_router(sessions.router, prefix="/api") +app.include_router(raw_qa.router, prefix="/api") +app.include_router(standard_qa.router, prefix="/api") +app.include_router(tasks.router, prefix="/api") +app.include_router(logs.router, prefix="/api") +app.include_router(settings.router, prefix="/api") +app.include_router(feishu.router, prefix="/api") + + +@app.get("/") +def root() -> dict[str, str]: + return {"name": settings_obj.app_name, "status": "ok"} + + +@app.get("/health") +def health() -> dict[str, str]: + return {"status": "ok"} diff --git a/hy_qa_asset_backend/backend/app/models.py b/hy_qa_asset_backend/backend/app/models.py new file mode 100644 index 0000000..9b0ad40 --- /dev/null +++ b/hy_qa_asset_backend/backend/app/models.py @@ -0,0 +1,270 @@ +from __future__ import annotations + +import enum +from datetime import date as DateType, datetime as DateTimeType + +from sqlalchemy import Boolean, Date, DateTime, Enum, ForeignKey, Integer, JSON, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +def enum_values(enum_cls: type[enum.Enum]) -> Enum: + return Enum(enum_cls, values_callable=lambda members: [member.value for member in members], native_enum=False) + + +class UserRole(str, enum.Enum): + admin = "admin" + reviewer = "reviewer" + operator = "operator" + + +class ProcessStatus(str, enum.Enum): + unprocessed = "unprocessed" + processing = "processing" + parsed = "parsed" + pending_review = "pending_review" + completed = "completed" + failed = "failed" + + +class RiskLevel(str, enum.Enum): + low = "low" + medium = "medium" + high = "high" + forbidden = "forbidden" + + +class ReviewStatus(str, enum.Enum): + pending = "pending" + reviewing = "reviewing" + approved = "approved" + needs_revision = "needs_revision" + rejected = "rejected" + forbidden = "forbidden" + + +class DesensitizationStatus(str, enum.Enum): + not_needed = "not_needed" + needed = "needed" + done = "done" + unknown = "unknown" + + +class CourseStage(str, enum.Enum): + awareness = "觉知" + origin = "原生" + unity = "合一" + vitality = "生机" + heart_light = "心光" + camp_general = "大本营综合" + unknown = "不确定" + + +class PrimaryTopic(str, enum.Enum): + parent_child = "亲子关系" + marriage = "婚姻关系" + family_of_origin = "原生家庭" + emotion = "情绪模式" + course = "课程学习" + growth = "个人成长" + family_system = "家族系统" + relationship_drain = "关系内耗" + action = "行动力与拖延" + other = "其他" + + +class AuditStatus(str, enum.Enum): + draft = "draft" + pending = "pending" + approved = "approved" + needs_revision = "needs_revision" + disabled = "disabled" + + +class CallStatus(str, enum.Enum): + callable = "callable" + not_callable = "not_callable" + need_review = "need_review" + forbidden = "forbidden" + + +class TaskType(str, enum.Enum): + scheduled_scan = "scheduled_scan" + manual_scan = "manual_scan" + manual_process = "manual_process" + ai_cleaning = "ai_cleaning" + feishu_sync = "feishu_sync" + + +class TaskStatus(str, enum.Enum): + pending = "pending" + running = "running" + success = "success" + failed = "failed" + partial_success = "partial_success" + + +class TargetType(str, enum.Enum): + raw_qa = "raw_qa" + standard_qa = "standard_qa" + session = "session" + + +class AuditAction(str, enum.Enum): + approve = "approve" + revise = "revise" + reject = "reject" + forbid = "forbid" + mark_high_risk = "mark_high_risk" + mark_desensitization = "mark_desensitization" + mark_callable = "mark_callable" + disable = "disable" + + +class TimestampMixin: + created_at: Mapped[DateTimeType] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at: Mapped[DateTimeType] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False + ) + + +class User(Base, TimestampMixin): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + name: Mapped[str] = mapped_column(String(120), nullable=False) + email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False) + role: Mapped[UserRole] = mapped_column(enum_values(UserRole), default=UserRole.reviewer, nullable=False) + + +class FeishuSession(Base, TimestampMixin): + __tablename__ = "feishu_sessions" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + session_code: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False) + title: Mapped[str] = mapped_column(String(255), nullable=False) + date: Mapped[DateType | None] = mapped_column(Date, nullable=True) + teachers: Mapped[str | None] = mapped_column(String(255), nullable=True) + feishu_doc_url: Mapped[str | None] = mapped_column(String(1024), nullable=True) + feishu_record_id: Mapped[str | None] = mapped_column(String(128), nullable=True) + source_file_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + source_text: Mapped[str | None] = mapped_column(Text, nullable=True) + process_status: Mapped[ProcessStatus] = mapped_column( + enum_values(ProcessStatus), default=ProcessStatus.unprocessed, nullable=False, index=True + ) + qa_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + pending_review_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + approved_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + standard_qa_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + failed_reason: Mapped[str | None] = mapped_column(Text, nullable=True) + + raw_items: Mapped[list["RawQAItem"]] = relationship(back_populates="session", cascade="all, delete-orphan") + standard_items: Mapped[list["StandardQAItem"]] = relationship(back_populates="session") + + +class RawQAItem(Base, TimestampMixin): + __tablename__ = "raw_qa_items" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + session_id: Mapped[int] = mapped_column(ForeignKey("feishu_sessions.id"), index=True, nullable=False) + qa_code: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False) + raw_question: Mapped[str] = mapped_column(Text, nullable=False) + normalized_question: Mapped[str | None] = mapped_column(Text, nullable=True) + raw_answer: Mapped[str] = mapped_column(Text, nullable=False) + normalized_answer: Mapped[str | None] = mapped_column(Text, nullable=True) + suggested_standard_question: Mapped[str | None] = mapped_column(Text, nullable=True) + suggested_standard_answer: Mapped[str | None] = mapped_column(Text, nullable=True) + answer_person: Mapped[str | None] = mapped_column(String(120), nullable=True) + primary_topic: Mapped[PrimaryTopic] = mapped_column(enum_values(PrimaryTopic), default=PrimaryTopic.other, nullable=False) + problem_tags: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False) + course_stage: Mapped[CourseStage] = mapped_column(enum_values(CourseStage), default=CourseStage.unknown, nullable=False) + audience_tags: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False) + emotion_intensity: Mapped[str | None] = mapped_column(String(32), nullable=True) + risk_level: Mapped[RiskLevel] = mapped_column(enum_values(RiskLevel), default=RiskLevel.medium, nullable=False, index=True) + risk_types: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False) + risk_notes: Mapped[str | None] = mapped_column(Text, nullable=True) + need_desensitization: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + desensitization_status: Mapped[DesensitizationStatus] = mapped_column( + enum_values(DesensitizationStatus), default=DesensitizationStatus.unknown, nullable=False + ) + review_status: Mapped[ReviewStatus] = mapped_column( + enum_values(ReviewStatus), default=ReviewStatus.pending, nullable=False, index=True + ) + reviewer_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True) + review_notes: Mapped[str | None] = mapped_column(Text, nullable=True) + suggested_to_standard_qa: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + source_timestamp: Mapped[str | None] = mapped_column(String(64), nullable=True) + + session: Mapped[FeishuSession] = relationship(back_populates="raw_items") + reviewer: Mapped[User | None] = relationship() + standard_item: Mapped["StandardQAItem | None"] = relationship(back_populates="source_raw_qa", uselist=False) + + +class StandardQAItem(Base, TimestampMixin): + __tablename__ = "standard_qa_items" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + standard_code: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False) + source_raw_qa_id: Mapped[int] = mapped_column(ForeignKey("raw_qa_items.id"), index=True, nullable=False) + session_id: Mapped[int] = mapped_column(ForeignKey("feishu_sessions.id"), index=True, nullable=False) + standard_question: Mapped[str] = mapped_column(Text, nullable=False) + standard_answer: Mapped[str] = mapped_column(Text, nullable=False) + similar_questions: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False) + primary_topic: Mapped[PrimaryTopic] = mapped_column(enum_values(PrimaryTopic), default=PrimaryTopic.other, nullable=False) + problem_tags: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False) + course_stage: Mapped[CourseStage] = mapped_column(enum_values(CourseStage), default=CourseStage.unknown, nullable=False) + audience_tags: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False) + answer_boundary: Mapped[str | None] = mapped_column(Text, nullable=True) + forbidden_expressions: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False) + risk_level: Mapped[RiskLevel] = mapped_column(enum_values(RiskLevel), default=RiskLevel.medium, nullable=False, index=True) + audit_status: Mapped[AuditStatus] = mapped_column(enum_values(AuditStatus), default=AuditStatus.approved, nullable=False) + call_status: Mapped[CallStatus] = mapped_column(enum_values(CallStatus), default=CallStatus.not_callable, nullable=False) + last_reviewer_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True) + disabled_reason: Mapped[str | None] = mapped_column(Text, nullable=True) + + source_raw_qa: Mapped[RawQAItem] = relationship(back_populates="standard_item") + session: Mapped[FeishuSession] = relationship(back_populates="standard_items") + last_reviewer: Mapped[User | None] = relationship() + + +class TaskRun(Base): + __tablename__ = "task_runs" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + task_type: Mapped[TaskType] = mapped_column(enum_values(TaskType), nullable=False, index=True) + task_status: Mapped[TaskStatus] = mapped_column(enum_values(TaskStatus), default=TaskStatus.pending, nullable=False, index=True) + started_at: Mapped[DateTimeType | None] = mapped_column(DateTime(timezone=True), nullable=True) + ended_at: Mapped[DateTimeType | None] = mapped_column(DateTime(timezone=True), nullable=True) + sessions_scanned: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + sessions_processed: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + qa_created: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + qa_failed: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + error_message: Mapped[str | None] = mapped_column(Text, nullable=True) + retry_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + created_at: Mapped[DateTimeType] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + + +class AuditLog(Base): + __tablename__ = "audit_logs" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True) + target_type: Mapped[TargetType] = mapped_column(enum_values(TargetType), nullable=False, index=True) + target_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False) + action: Mapped[AuditAction] = mapped_column(enum_values(AuditAction), nullable=False) + before_status: Mapped[str | None] = mapped_column(String(64), nullable=True) + after_status: Mapped[str | None] = mapped_column(String(64), nullable=True) + comment: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[DateTimeType] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + + user: Mapped[User | None] = relationship() + + +class SystemSetting(Base, TimestampMixin): + __tablename__ = "system_settings" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + key: Mapped[str] = mapped_column(String(128), unique=True, index=True, nullable=False) + value: Mapped[str | None] = mapped_column(Text, nullable=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/hy_qa_asset_backend/backend/app/prompts/qa_cleaning_prompt.md b/hy_qa_asset_backend/backend/app/prompts/qa_cleaning_prompt.md new file mode 100644 index 0000000..bb7c70d --- /dev/null +++ b/hy_qa_asset_backend/backend/app/prompts/qa_cleaning_prompt.md @@ -0,0 +1,147 @@ +你是“大本营答疑问答资产库”的 AI 清洗助手。 + +你的任务是把一份大本营周三答疑转写稿,拆解成结构化的一问一答 JSON 数组。 + +你必须严格遵守以下规则: + +一、原文保护 + +1. 必须保留学员原始问题。 +2. 必须保留院长或辅导老师原始回答。 +3. raw_question 必须来自原文,不得改写。 +4. raw_answer 必须来自原文,不得改写。 +5. normalized_question 可以去口语化,但不能改变原意。 +6. normalized_answer 可以去口语化,但不能新增原回答没有的信息。 +7. suggested_standard_answer 可以整理得更适合复用,但不能新增原回答没有的核心观点。 +8. 不能把 AI 补充内容伪装成院长或辅导老师原话。 + +二、问答拆解 + +1. 只拆有效问答。 +2. 如果没有明确问题和回答,不要强行拆。 +3. 如果一个问题对应多个回答,可以合并为一条。 +4. 如果多个问题高度相似,可以分别保留,但在标签中体现相似主题。 +5. 每条问答必须能追溯到原始文本。 + +三、标签生成 + +一级主题只能从以下选项中选择: + +亲子关系 +婚姻关系 +原生家庭 +情绪模式 +课程学习 +个人成长 +家族系统 +关系内耗 +行动力与拖延 +其他 + +课程阶段只能从以下选项中选择: + +觉知 +原生 +合一 +生机 +心光 +大本营综合 +不确定 + +如果无法判断,填“不确定”。 + +四、风险识别 + +以下情况必须标记风险: + +1. 出现学员姓名、电话、城市、学校、单位、具体身份信息。 +2. 涉及孩子、未成年人、婚姻冲突、家庭矛盾等敏感细节。 +3. 涉及心理诊断、医疗判断、法律建议。 +4. 涉及课程效果确定性承诺。 +5. 涉及可能引发误解的绝对化表达。 +6. 涉及老师、院长、团队表达边界。 +7. 无法判断是否安全时,默认 medium 风险。 + +risk_level 只能是: + +low +medium +high +forbidden + +五、脱敏判断 + +如果涉及以下信息,need_desensitization 必须为 true: + +姓名 +电话 +微信 +城市 +学校 +单位 +具体住址 +具体家庭成员身份 +孩子具体信息 +婚姻个案细节 +可识别个人身份的组合信息 + +六、输出格式 + +你只能输出 JSON 数组,不要输出解释文字,不要输出 Markdown。 + +每条 JSON 必须包含以下字段: + +session_id +qa_id +raw_question +normalized_question +raw_answer +normalized_answer +suggested_standard_question +suggested_standard_answer +answer_person +primary_topic +problem_tags +course_stage +audience_tags +emotion_intensity +risk_level +risk_types +risk_notes +need_desensitization +desensitization_status +suggested_review_status +suggested_to_standard_qa +source_timestamp +review_notes + +字段说明: + +session_id:场次 ID,如果不知道可为空字符串。 +qa_id:问答 ID,如果不知道可为空字符串。 +raw_question:原始问题。 +normalized_question:整理后的问题。 +raw_answer:原始回答。 +normalized_answer:整理后的回答。 +suggested_standard_question:建议标准问题。 +suggested_standard_answer:建议标准回答。 +answer_person:回答人,如院长、辅导老师、不确定。 +primary_topic:一级主题。 +problem_tags:具体问题标签数组。 +course_stage:课程阶段。 +audience_tags:适用人群标签数组。 +emotion_intensity:情绪强度,low / medium / high。 +risk_level:风险等级,low / medium / high / forbidden。 +risk_types:风险类型数组。 +risk_notes:风险说明。 +need_desensitization:是否需要脱敏,true / false。 +desensitization_status:not_needed / needed / done / unknown。 +suggested_review_status:建议审核状态,pending / approved / needs_revision / rejected / forbidden。 +suggested_to_standard_qa:是否建议进入标准问答库,true / false。 +source_timestamp:原始时间戳,没有则为空。 +review_notes:给人工审核人的备注。 + +系统边界: + +AI 只能辅助清洗、拆解、打标签、风险初筛,不能替代人工审核。未审核内容不能进入标准问答库,未审核内容不能被未来智能体调用。高风险内容必须人工复审。涉及学员隐私必须标记脱敏。不做心理诊断、医疗建议、法律判断,也不承诺课程效果。 + diff --git a/hy_qa_asset_backend/backend/app/schemas.py b/hy_qa_asset_backend/backend/app/schemas.py new file mode 100644 index 0000000..691b812 --- /dev/null +++ b/hy_qa_asset_backend/backend/app/schemas.py @@ -0,0 +1,255 @@ +from __future__ import annotations + +from datetime import date as DateType, datetime as DateTime +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + + +class ORMModel(BaseModel): + model_config = ConfigDict(from_attributes=True, use_enum_values=True) + + +class UserRead(ORMModel): + id: int + name: str + email: str + role: str + created_at: DateTime + updated_at: DateTime + + +class FeishuSessionBase(BaseModel): + title: str + date: DateType | None = None + teachers: str | None = None + feishu_doc_url: str | None = None + feishu_record_id: str | None = None + source_file_name: str | None = None + source_text: str | None = None + + +class FeishuSessionCreate(FeishuSessionBase): + session_code: str | None = None + + +class FeishuSessionUpdate(BaseModel): + title: str | None = None + date: DateType | None = None + teachers: str | None = None + feishu_doc_url: str | None = None + source_file_name: str | None = None + source_text: str | None = None + process_status: str | None = None + failed_reason: str | None = None + + +class FeishuSessionRead(ORMModel): + id: int + session_code: str + title: str + date: DateType | None + teachers: str | None + feishu_doc_url: str | None + feishu_record_id: str | None + source_file_name: str | None + source_text: str | None + process_status: str + qa_count: int + pending_review_count: int + approved_count: int + standard_qa_count: int + failed_reason: str | None + created_at: DateTime + updated_at: DateTime + + +class RawQAItemUpdate(BaseModel): + normalized_question: str | None = None + normalized_answer: str | None = None + suggested_standard_question: str | None = None + suggested_standard_answer: str | None = None + answer_person: str | None = None + primary_topic: str | None = None + problem_tags: list[str] | None = None + course_stage: str | None = None + audience_tags: list[str] | None = None + emotion_intensity: str | None = None + risk_level: str | None = None + risk_types: list[str] | None = None + risk_notes: str | None = None + need_desensitization: bool | None = None + desensitization_status: str | None = None + review_status: str | None = None + review_notes: str | None = None + suggested_to_standard_qa: bool | None = None + + +class ReviewActionRequest(BaseModel): + reviewer_id: int | None = None + comment: str | None = None + + +class RawQAItemRead(ORMModel): + id: int + session_id: int + qa_code: str + raw_question: str + normalized_question: str | None + raw_answer: str + normalized_answer: str | None + suggested_standard_question: str | None + suggested_standard_answer: str | None + answer_person: str | None + primary_topic: str + problem_tags: list[str] + course_stage: str + audience_tags: list[str] + emotion_intensity: str | None + risk_level: str + risk_types: list[str] + risk_notes: str | None + need_desensitization: bool + desensitization_status: str + review_status: str + reviewer_id: int | None + review_notes: str | None + suggested_to_standard_qa: bool + source_timestamp: str | None + created_at: DateTime + updated_at: DateTime + + +class StandardQAUpdate(BaseModel): + standard_question: str | None = None + standard_answer: str | None = None + similar_questions: list[str] | None = None + primary_topic: str | None = None + problem_tags: list[str] | None = None + course_stage: str | None = None + audience_tags: list[str] | None = None + answer_boundary: str | None = None + forbidden_expressions: list[str] | None = None + risk_level: str | None = None + audit_status: str | None = None + call_status: str | None = None + disabled_reason: str | None = None + + +class StandardQARead(ORMModel): + id: int + standard_code: str + source_raw_qa_id: int + session_id: int + standard_question: str + standard_answer: str + similar_questions: list[str] + primary_topic: str + problem_tags: list[str] + course_stage: str + audience_tags: list[str] + answer_boundary: str | None + forbidden_expressions: list[str] + risk_level: str + audit_status: str + call_status: str + last_reviewer_id: int | None + disabled_reason: str | None + created_at: DateTime + updated_at: DateTime + + +class CallableQARead(ORMModel): + id: int + standard_code: str + standard_question: str + standard_answer: str + similar_questions: list[str] + primary_topic: str + problem_tags: list[str] + course_stage: str + audience_tags: list[str] + answer_boundary: str | None + forbidden_expressions: list[str] + source_raw_qa_id: int + session_id: int + + +class TaskRunRead(ORMModel): + id: int + task_type: str + task_status: str + started_at: DateTime | None + ended_at: DateTime | None + sessions_scanned: int + sessions_processed: int + qa_created: int + qa_failed: int + error_message: str | None + retry_count: int + created_at: DateTime + + +class AuditLogRead(ORMModel): + id: int + user_id: int | None + target_type: str + target_id: int + action: str + before_status: str | None + after_status: str | None + comment: str | None + created_at: DateTime + + +class DashboardSummary(BaseModel): + total_sessions: int + processed_sessions: int + pending_review_count: int + high_risk_count: int + approved_qa_count: int + standard_qa_count: int + callable_qa_count: int + last_task_status: str | None + last_task_time: DateTime | None + last_task_error: str | None + + +class SafeSettingsRead(BaseModel): + feishu_configured: bool + openai_configured: bool + model_name: str + schedule_cron: str + schedule_timezone: str + mock_mode: bool + database_url: str = Field(description="敏感连接串不原样返回,仅返回驱动类型") + feishu_tables_configured: dict[str, bool] + system_settings: list[dict[str, Any]] + + +class SettingsPatch(BaseModel): + settings: dict[str, str] + + +class FeishuStatus(BaseModel): + configured: bool + mock_mode: bool + app_token_configured: bool + wiki_node_token_configured: bool = False + table_ids: dict[str, bool] + token_ok: bool + message: str + + +class FeishuTableTemplate(BaseModel): + table_key: str + table_name: str + env_name: str + required_fields: list[str] + + +class FeishuSetupGuide(BaseModel): + required_env: list[str] + required_permissions: list[str] + tables: list[FeishuTableTemplate] + notes: list[str] diff --git a/hy_qa_asset_backend/backend/app/seed/sample_transcript_001.txt b/hy_qa_asset_backend/backend/app/seed/sample_transcript_001.txt new file mode 100644 index 0000000..40ce131 --- /dev/null +++ b/hy_qa_asset_backend/backend/app/seed/sample_transcript_001.txt @@ -0,0 +1,8 @@ +[00:03:12] 学员:我和孩子沟通时总是忍不住发火,孩子现在一听我说话就躲开,我该怎么办? +[00:04:20] 辅导老师:先不要急着讲道理,先看见自己发火背后的着急和无力。可以把要求先放低一点,用一句具体的话表达当下感受,比如我现在有点急,我想先停一下。 + +[00:16:44] 学员:我学习大本营后知道自己总是拖延,怎么开始一个小行动? +[00:17:30] 院长:不要一开始就设很大的目标。先选一个今天能完成的动作,比如写三句话、整理十分钟。关键是让身体先体验到我可以开始。 + +[00:27:05] 学员:我朋友说自己有抑郁症,我能不能用课程里的方法帮她判断严重程度? +[00:28:02] 辅导老师:这类情况不要做诊断,也不要替对方判断严重程度。你可以表达关心,建议对方寻求专业医生或心理专业人员帮助。 diff --git a/hy_qa_asset_backend/backend/app/services/ai_cleaner.py b/hy_qa_asset_backend/backend/app/services/ai_cleaner.py new file mode 100644 index 0000000..b160397 --- /dev/null +++ b/hy_qa_asset_backend/backend/app/services/ai_cleaner.py @@ -0,0 +1,49 @@ +import json +from pathlib import Path +from typing import Any + +import httpx + +from app.config import get_settings +from app.services.mock_data_service import mock_ai_cleaning_result +from app.services.qa_parser import normalize_ai_item +from app.utils.json_validator import validate_ai_qa_items + + +PROMPT_PATH = Path(__file__).resolve().parents[1] / "prompts" / "qa_cleaning_prompt.md" + + +class AICleaner: + def __init__(self) -> None: + self.settings = get_settings() + + def _prompt(self) -> str: + return PROMPT_PATH.read_text(encoding="utf-8") + + async def clean_transcript(self, session_id: int, transcript: str) -> list[dict[str, Any]]: + if not self.settings.openai_configured: + return [normalize_ai_item(item) for item in mock_ai_cleaning_result(session_id)] + + payload = { + "model": self.settings.model_name, + "messages": [ + {"role": "system", "content": self._prompt()}, + { + "role": "user", + "content": f"场次 ID: {session_id}\n\n以下是转写稿:\n{transcript}", + }, + ], + "temperature": 0.1, + } + base_url = (self.settings.openai_base_url or "https://api.openai.com/v1").rstrip("/") + headers = {"Authorization": f"Bearer {self.settings.openai_api_key}"} + async with httpx.AsyncClient(timeout=60) as client: + response = await client.post(f"{base_url}/chat/completions", headers=headers, json=payload) + response.raise_for_status() + content = response.json()["choices"][0]["message"]["content"] + try: + parsed = json.loads(content) + except json.JSONDecodeError as exc: + raise ValueError(f"AI 返回非法 JSON: {exc}") from exc + return [normalize_ai_item(item) for item in validate_ai_qa_items(parsed)] + diff --git a/hy_qa_asset_backend/backend/app/services/feishu_client.py b/hy_qa_asset_backend/backend/app/services/feishu_client.py new file mode 100644 index 0000000..d7dfc32 --- /dev/null +++ b/hy_qa_asset_backend/backend/app/services/feishu_client.py @@ -0,0 +1,600 @@ +from __future__ import annotations + +import io +import re +import zipfile +from datetime import date, datetime +from typing import Any +from xml.etree import ElementTree as ET + +import httpx +from sqlalchemy.orm import Session + +from app import models +from app.config import get_settings +from app.services.mock_data_service import read_sample_transcript + + +FEISHU_OPEN_API = "https://open.feishu.cn/open-apis" + +STATUS_TO_FEISHU = { + models.ProcessStatus.unprocessed.value: "unprocessed", + models.ProcessStatus.processing.value: "processing", + models.ProcessStatus.parsed.value: "parsed", + models.ProcessStatus.pending_review.value: "pending_review", + models.ProcessStatus.completed.value: "completed", + models.ProcessStatus.failed.value: "failed", +} + +FEISHU_TO_STATUS = { + "unprocessed": models.ProcessStatus.unprocessed, + "未处理": models.ProcessStatus.unprocessed, + "processing": models.ProcessStatus.processing, + "处理中": models.ProcessStatus.processing, + "parsed": models.ProcessStatus.parsed, + "已拆解": models.ProcessStatus.parsed, + "pending_review": models.ProcessStatus.pending_review, + "待审核": models.ProcessStatus.pending_review, + "completed": models.ProcessStatus.completed, + "已完成": models.ProcessStatus.completed, + "failed": models.ProcessStatus.failed, + "失败": models.ProcessStatus.failed, +} + +SESSION_FIELD_ALIASES = { + "session_code": ["session_code", "场次编号", "编号"], + "title": ["title", "标题", "答疑标题", "场次标题"], + "date": ["date", "日期", "答疑日期"], + "teachers": ["teachers", "答疑老师", "老师"], + "feishu_doc_url": ["feishu_doc_url", "飞书资料链接", "文档链接", "资料链接"], + "source_file_name": ["source_file_name", "文件名", "来源文件名"], + "source_text": ["source_text", "转写稿", "文字稿", "原始资料", "答疑文字稿"], + "process_status": ["process_status", "处理状态", "状态"], + "failed_reason": ["failed_reason", "失败原因"], +} + +SESSION_WRITE_FIELDS = { + "process_status": ["处理状态", "process_status"], + "qa_count": ["问答总数", "qa_count"], + "pending_review_count": ["待审核数", "pending_review_count"], + "approved_count": ["已审核数", "approved_count"], + "standard_qa_count": ["已入库数", "standard_qa_count"], + "failed_reason": ["失败原因", "failed_reason"], +} + + +def _first_field(fields: dict[str, Any], names: list[str], default: Any = None) -> Any: + for name in names: + if name in fields and fields[name] not in (None, ""): + return fields[name] + return default + + +def _stringify(value: Any) -> str | None: + if value is None: + return None + if isinstance(value, str): + return value + if isinstance(value, list): + parts: list[str] = [] + for item in value: + if isinstance(item, dict): + parts.append(str(item.get("text") or item.get("name") or item.get("url") or item)) + else: + parts.append(str(item)) + return " ".join(parts) + if isinstance(value, dict): + return str(value.get("text") or value.get("name") or value.get("url") or value) + return str(value) + + +def _parse_date(value: Any) -> date | None: + if value in (None, ""): + return None + if isinstance(value, (int, float)): + # Bitable date fields are often millisecond timestamps. + return datetime.fromtimestamp(value / 1000).date() + text = _stringify(value) + if not text: + return None + for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%Y.%m.%d"): + try: + return datetime.strptime(text[:10], fmt).date() + except ValueError: + continue + return None + + +def _extract_url(value: Any) -> str | None: + if not value: + return None + if isinstance(value, list): + for item in value: + url = _extract_url(item) + if url: + return url + return None + if isinstance(value, dict): + for key in ("link", "url", "text"): + url = _extract_url(value.get(key)) + if url: + return url + return None + text = str(value) + match = re.search(r"https?://\S+", text) + return match.group(0) if match else text if text.startswith("http") else None + + +def _extract_doc_id(url: str | None) -> str | None: + if not url: + return None + patterns = [ + r"/docx/([A-Za-z0-9]+)", + r"/docs/([A-Za-z0-9]+)", + r"/wiki/([A-Za-z0-9]+)", + ] + for pattern in patterns: + match = re.search(pattern, url) + if match: + return match.group(1) + tail = url.rstrip("/").split("/")[-1] + return tail if re.match(r"^[A-Za-z0-9]{8,}$", tail) else None + + +def _extract_drive_file_token(url: str | None) -> str | None: + if not url: + return None + match = re.search(r"/file/([A-Za-z0-9]+)", url) + return match.group(1) if match else None + + +class FeishuAPIError(RuntimeError): + pass + + +class FeishuClient: + """Feishu adapter used by the black-light workflow. + + Product boundary: Feishu is the material entrance. AI only cleans and + pre-screens; every reusable QA still must pass human review in this system. + """ + + def __init__(self) -> None: + self.settings = get_settings() + self._tenant_access_token: str | None = None + self._bitable_app_token: str | None = None + + @property + def is_mock_mode(self) -> bool: + return self.settings.mock_mode or not self.settings.feishu_configured + + def connection_status(self) -> dict[str, Any]: + configured = self.settings.feishu_configured + status = { + "configured": configured, + "mock_mode": self.is_mock_mode, + "app_token_configured": bool(self.settings.feishu_app_token), + "wiki_node_token_configured": bool(self.settings.feishu_wiki_node_token), + "table_ids": { + "session": bool(self.settings.feishu_table_id_session), + "raw_qa": bool(self.settings.feishu_table_id_raw_qa), + "standard_qa": bool(self.settings.feishu_table_id_standard_qa), + }, + "token_ok": False, + "message": "未配置飞书密钥,当前使用 mock mode。", + } + if not configured: + return status + try: + self._get_tenant_access_token() + self._get_bitable_app_token() + status["token_ok"] = True + status["message"] = "飞书 tenant_access_token 获取成功。" + except Exception as exc: # noqa: BLE001 + status["message"] = f"飞书连接失败:{exc}" + return status + + def scan_unprocessed_sessions(self, db: Session) -> list[models.FeishuSession]: + if self.is_mock_mode: + return ( + db.query(models.FeishuSession) + .filter(models.FeishuSession.process_status == models.ProcessStatus.unprocessed) + .order_by(models.FeishuSession.created_at.asc()) + .all() + ) + + records = self._list_records(self.settings.feishu_table_id_session) + sessions: list[models.FeishuSession] = [] + for record in records: + fields = record.get("fields", {}) + record_id = record.get("record_id") + status_text = _stringify(_first_field(fields, SESSION_FIELD_ALIASES["process_status"], "unprocessed")) + process_status = FEISHU_TO_STATUS.get(status_text or "", models.ProcessStatus.unprocessed) + if process_status != models.ProcessStatus.unprocessed: + continue + + session_code = _stringify(_first_field(fields, SESSION_FIELD_ALIASES["session_code"])) or f"FS-{record_id}" + session = ( + db.query(models.FeishuSession) + .filter(models.FeishuSession.feishu_record_id == record_id) + .one_or_none() + ) + if not session: + session = models.FeishuSession(session_code=session_code, title=session_code) + db.add(session) + session.session_code = session_code + session.title = _stringify(_first_field(fields, SESSION_FIELD_ALIASES["title"], session_code)) or session_code + session.date = _parse_date(_first_field(fields, SESSION_FIELD_ALIASES["date"])) + session.teachers = _stringify(_first_field(fields, SESSION_FIELD_ALIASES["teachers"])) + session.feishu_doc_url = _extract_url(_first_field(fields, SESSION_FIELD_ALIASES["feishu_doc_url"])) + session.feishu_record_id = record_id + session.source_file_name = _stringify(_first_field(fields, SESSION_FIELD_ALIASES["source_file_name"])) + session.source_text = _stringify(_first_field(fields, SESSION_FIELD_ALIASES["source_text"])) + session.process_status = process_status + session.failed_reason = _stringify(_first_field(fields, SESSION_FIELD_ALIASES["failed_reason"])) + sessions.append(session) + db.commit() + for session in sessions: + db.refresh(session) + return sessions + + def fetch_transcript(self, session: models.FeishuSession) -> str: + if self.is_mock_mode: + return session.source_text or read_sample_transcript() + if session.source_text: + return session.source_text + file_token = _extract_drive_file_token(session.feishu_doc_url) + if file_token: + return self._get_drive_docx_text(file_token) + doc_id = _extract_doc_id(session.feishu_doc_url) + if doc_id: + return self._get_docx_raw_content(doc_id) + raise FeishuAPIError("场次没有转写稿文本,也没有可识别的飞书 docx 文档链接") + + def update_session_status(self, session: models.FeishuSession, status: str) -> None: + if self.is_mock_mode or not session.feishu_record_id: + return + fields = self._build_session_update_fields(session, status) + self._update_record(self.settings.feishu_table_id_session, session.feishu_record_id, fields) + + def write_raw_qa_items(self, session: models.FeishuSession, items: list[models.RawQAItem]) -> None: + if self.is_mock_mode or not self.settings.feishu_table_id_raw_qa or not items: + return + self._write_raw_qa_items_compatible(session, items) + return + records = [] + for item in items: + records.append( + { + "fields": { + "问答编号": item.qa_code, + "场次编号": session.session_code, + "原始问题": item.raw_question, + "问题整理版": item.normalized_question, + "原始回答": item.raw_answer, + "回答整理版": item.normalized_answer, + "AI建议标准问题": item.suggested_standard_question, + "AI建议标准回答": item.suggested_standard_answer, + "回答人": item.answer_person, + "一级主题": item.primary_topic.value, + "问题标签": "、".join(item.problem_tags or []), + "课程阶段": item.course_stage.value, + "适用人群": "、".join(item.audience_tags or []), + "情绪强度": item.emotion_intensity, + "风险等级": item.risk_level.value, + "风险类型": "、".join(item.risk_types or []), + "风险说明": item.risk_notes, + "是否需脱敏": "是" if item.need_desensitization else "否", + "脱敏状态": item.desensitization_status.value, + "审核状态": item.review_status.value, + "建议入库": "是" if item.suggested_to_standard_qa else "否", + "来源时间戳": item.source_timestamp, + "审核备注": item.review_notes, + } + } + ) + self._batch_create_records(self.settings.feishu_table_id_raw_qa, records) + + def write_standard_qa_items(self, items: list[models.StandardQAItem]) -> None: + if self.is_mock_mode or not self.settings.feishu_table_id_standard_qa or not items: + return + self._write_standard_qa_items_compatible(items) + return + records = [] + for item in items: + records.append( + { + "fields": { + "标准编号": item.standard_code, + "来源原始问答ID": str(item.source_raw_qa_id), + "来源场次ID": str(item.session_id), + "标准问题": item.standard_question, + "标准回答": item.standard_answer, + "相似问法": "、".join(item.similar_questions or []), + "一级主题": item.primary_topic.value, + "问题标签": "、".join(item.problem_tags or []), + "课程阶段": item.course_stage.value, + "适用人群": "、".join(item.audience_tags or []), + "回答边界": item.answer_boundary, + "禁止表达": "、".join(item.forbidden_expressions or []), + "风险等级": item.risk_level.value, + "审核状态": item.audit_status.value, + "调用状态": item.call_status.value, + "禁用原因": item.disabled_reason, + } + } + ) + self._batch_create_records(self.settings.feishu_table_id_standard_qa, records) + + def _get_tenant_access_token(self) -> str: + if self._tenant_access_token: + return self._tenant_access_token + payload = {"app_id": self.settings.feishu_app_id, "app_secret": self.settings.feishu_app_secret} + with httpx.Client(timeout=20) as client: + response = client.post(f"{FEISHU_OPEN_API}/auth/v3/tenant_access_token/internal", json=payload) + data = response.json() + if response.status_code >= 400 or data.get("code") != 0: + raise FeishuAPIError(data.get("msg") or response.text) + self._tenant_access_token = data["tenant_access_token"] + return self._tenant_access_token + + def _headers(self) -> dict[str, str]: + return {"Authorization": f"Bearer {self._get_tenant_access_token()}", "Content-Type": "application/json"} + + def _get_bitable_app_token(self) -> str: + if self._bitable_app_token: + return self._bitable_app_token + if self.settings.feishu_app_token: + self._bitable_app_token = self.settings.feishu_app_token + return self._bitable_app_token + if not self.settings.feishu_wiki_node_token: + raise FeishuAPIError("缺少 FEISHU_APP_TOKEN 或 FEISHU_WIKI_NODE_TOKEN") + data = self._request( + "GET", + "/wiki/v2/spaces/get_node", + params={"token": self.settings.feishu_wiki_node_token}, + ) + node = data.get("node", {}) + if node.get("obj_type") != "bitable": + raise FeishuAPIError(f"FEISHU_WIKI_NODE_TOKEN 指向的对象不是 bitable:{node.get('obj_type')}") + obj_token = node.get("obj_token") + if not obj_token: + raise FeishuAPIError("wiki 节点信息没有返回 obj_token") + self._bitable_app_token = obj_token + return self._bitable_app_token + + def _request(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]: + with httpx.Client(timeout=30) as client: + response = client.request(method, f"{FEISHU_OPEN_API}{path}", headers=self._headers(), **kwargs) + data = response.json() + if response.status_code >= 400 or data.get("code") != 0: + raise FeishuAPIError(data.get("msg") or response.text) + return data.get("data", {}) + + def _table_field_names(self, table_id: str | None) -> set[str]: + if not table_id: + return set() + cache = getattr(self, "_table_field_name_cache", None) + if cache is None: + cache = {} + self._table_field_name_cache = cache + if table_id in cache: + return cache[table_id] + names: set[str] = set() + page_token: str | None = None + while True: + params = {"page_size": 100} + if page_token: + params["page_token"] = page_token + data = self._request( + "GET", + f"/bitable/v1/apps/{self._get_bitable_app_token()}/tables/{table_id}/fields", + params=params, + ) + names.update(item.get("field_name") for item in data.get("items", []) if item.get("field_name")) + if not data.get("has_more"): + break + page_token = data.get("page_token") + cache[table_id] = names + return names + + def _filter_existing_fields(self, table_id: str | None, fields: dict[str, Any]) -> dict[str, Any]: + names = self._table_field_names(table_id) + return {key: value for key, value in fields.items() if key in names and value is not None} + + @staticmethod + def _join_values(values: list[str] | None) -> str | None: + return "、".join(values or []) or None + + @staticmethod + def _enum_value(value: Any) -> str: + return getattr(value, "value", value) + + def _risk_label(self, value: Any) -> str: + return {"low": "低风险", "medium": "中风险", "high": "高风险"}.get(self._enum_value(value), str(value)) + + def _review_label(self, value: Any) -> str: + return { + "pending": "待审核", + "approved": "已通过", + "rejected": "已拒绝", + "needs_revision": "需修改", + }.get(self._enum_value(value), str(value)) + + def _build_session_update_fields_compatible(self, session: models.FeishuSession, status: str) -> dict[str, Any]: + status_label = { + models.ProcessStatus.unprocessed.value: "待处理", + models.ProcessStatus.processing.value: "处理中", + models.ProcessStatus.parsed.value: "处理中", + models.ProcessStatus.pending_review.value: "待审核", + models.ProcessStatus.completed.value: "已完成", + models.ProcessStatus.failed.value: "失败", + }.get(status, status) + high_risk_count = sum(1 for item in session.raw_items if item.risk_level == models.RiskLevel.high) + fields = { + "处理状态": status_label, + "问答总数": session.qa_count, + "原始问答数": session.qa_count, + "待审核数": session.pending_review_count, + "已审核数": session.approved_count, + "已入库数": session.standard_qa_count, + "标准问答数": session.standard_qa_count, + "高风险数": high_risk_count, + "失败原因": session.failed_reason, + "最近同步时间": int(datetime.now().timestamp() * 1000), + } + return self._filter_existing_fields(self.settings.feishu_table_id_session, fields) + + def _write_raw_qa_items_compatible(self, session: models.FeishuSession, items: list[models.RawQAItem]) -> None: + table_id = self.settings.feishu_table_id_raw_qa + records = [] + for item in items: + tags = (item.problem_tags or []) + [self._enum_value(item.primary_topic)] + fields = { + "问答编号": item.qa_code, + "场次编号": session.session_code, + "原始问题": item.raw_question, + "问题": item.normalized_question or item.raw_question, + "问题整理版": item.normalized_question, + "原始回答": item.raw_answer, + "回答": item.normalized_answer or item.raw_answer, + "回答整理版": item.normalized_answer, + "AI建议标准问题": item.suggested_standard_question, + "AI建议标准答案": item.suggested_standard_answer, + "答疑老师": item.answer_person or session.teachers, + "标签": self._join_values(tags), + "风险等级": self._risk_label(item.risk_level), + "证据片段": item.source_timestamp or (item.raw_answer or "")[:240], + "审核状态": self._review_label(item.review_status), + "入库状态": "建议入库" if item.suggested_to_standard_qa else "未入库", + } + filtered = self._filter_existing_fields(table_id, fields) + if filtered: + records.append({"fields": filtered}) + self._batch_create_records(table_id, records) + + def _write_standard_qa_items_compatible(self, items: list[models.StandardQAItem]) -> None: + table_id = self.settings.feishu_table_id_standard_qa + records = [] + for item in items: + fields = { + "标准编号": item.standard_code, + "来源原始问答ID": str(item.source_raw_qa_id), + "来源场次ID": str(item.session_id), + "标准问题": item.standard_question, + "标准答案": item.standard_answer, + "相似问法": self._join_values(item.similar_questions), + "一级主题": self._enum_value(item.primary_topic), + "问题标签": self._join_values(item.problem_tags), + "适用人群": self._join_values(item.audience_tags), + "标签": self._join_values(item.problem_tags), + "回答边界": item.answer_boundary, + "禁止表达": self._join_values(item.forbidden_expressions), + "风险等级": self._risk_label(item.risk_level), + "审核状态": self._enum_value(item.audit_status), + "调用状态": self._enum_value(item.call_status), + "状态": self._enum_value(item.audit_status), + "是否可调用": "是" if item.call_status == models.CallStatus.callable else "否", + "来源场次": str(item.session_id), + "来源问题": str(item.source_raw_qa_id), + "禁用原因": item.disabled_reason, + } + filtered = self._filter_existing_fields(table_id, fields) + if filtered: + records.append({"fields": filtered}) + self._batch_create_records(table_id, records) + + def _list_records(self, table_id: str | None) -> list[dict[str, Any]]: + if not table_id: + raise FeishuAPIError("缺少飞书场次表 table_id") + records: list[dict[str, Any]] = [] + page_token: str | None = None + while True: + params = {"page_size": 500} + if page_token: + params["page_token"] = page_token + data = self._request( + "GET", + f"/bitable/v1/apps/{self._get_bitable_app_token()}/tables/{table_id}/records", + params=params, + ) + records.extend(data.get("items", [])) + if not data.get("has_more"): + break + page_token = data.get("page_token") + return records + + def _update_record(self, table_id: str | None, record_id: str, fields: dict[str, Any]) -> None: + if not table_id: + return + self._request( + "PUT", + f"/bitable/v1/apps/{self._get_bitable_app_token()}/tables/{table_id}/records/{record_id}", + json={"fields": fields}, + ) + + def _batch_create_records(self, table_id: str | None, records: list[dict[str, Any]]) -> None: + if not table_id: + return + for start in range(0, len(records), 500): + chunk = records[start : start + 500] + self._request( + "POST", + f"/bitable/v1/apps/{self._get_bitable_app_token()}/tables/{table_id}/records/batch_create", + json={"records": chunk}, + ) + + def _get_docx_raw_content(self, document_id: str) -> str: + data = self._request("GET", f"/docx/v1/documents/{document_id}/raw_content") + content = data.get("content") or data.get("raw_content") or data.get("text") + if not content: + raise FeishuAPIError("飞书文档纯文本接口没有返回 content") + return content + + def _get_drive_docx_text(self, file_token: str) -> str: + with httpx.Client(timeout=60, follow_redirects=True) as client: + response = client.get( + f"{FEISHU_OPEN_API}/drive/v1/files/{file_token}/download", + headers=self._headers(), + ) + if response.status_code >= 400: + try: + data = response.json() + message = data.get("msg") or response.text + except Exception: # noqa: BLE001 + message = response.text + raise FeishuAPIError(message) + return self._extract_docx_text(response.content) + + @staticmethod + def _extract_docx_text(content: bytes) -> str: + try: + with zipfile.ZipFile(io.BytesIO(content)) as docx: + document_xml = docx.read("word/document.xml") + except Exception as exc: # noqa: BLE001 + raise FeishuAPIError(f"飞书上传文件不是可解析的 docx:{exc}") from exc + + root = ET.fromstring(document_xml) + namespace = {"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"} + paragraphs: list[str] = [] + for paragraph in root.findall(".//w:p", namespace): + text = "".join(node.text or "" for node in paragraph.findall(".//w:t", namespace)).strip() + if text: + paragraphs.append(text) + extracted = "\n".join(paragraphs).strip() + if not extracted: + raise FeishuAPIError("飞书上传 docx 没有提取到正文") + return extracted + + def _build_session_update_fields(self, session: models.FeishuSession, status: str) -> dict[str, Any]: + return self._build_session_update_fields_compatible(session, status) + fields: dict[str, Any] = { + SESSION_WRITE_FIELDS["process_status"][0]: STATUS_TO_FEISHU.get(status, status), + SESSION_WRITE_FIELDS["qa_count"][0]: session.qa_count, + SESSION_WRITE_FIELDS["pending_review_count"][0]: session.pending_review_count, + SESSION_WRITE_FIELDS["approved_count"][0]: session.approved_count, + SESSION_WRITE_FIELDS["standard_qa_count"][0]: session.standard_qa_count, + } + if session.failed_reason: + fields[SESSION_WRITE_FIELDS["failed_reason"][0]] = session.failed_reason + return fields diff --git a/hy_qa_asset_backend/backend/app/services/mock_data_service.py b/hy_qa_asset_backend/backend/app/services/mock_data_service.py new file mode 100644 index 0000000..2a0708f --- /dev/null +++ b/hy_qa_asset_backend/backend/app/services/mock_data_service.py @@ -0,0 +1,115 @@ +from datetime import date +from pathlib import Path +from typing import Any + +from sqlalchemy.orm import Session + +from app import models + + +SEED_DIR = Path(__file__).resolve().parents[1] / "seed" +SAMPLE_TRANSCRIPT_PATH = SEED_DIR / "sample_transcript_001.txt" + + +def read_sample_transcript() -> str: + return SAMPLE_TRANSCRIPT_PATH.read_text(encoding="utf-8") + + +def create_mock_session(db: Session) -> models.FeishuSession: + index = db.query(models.FeishuSession).count() + 1 + session = models.FeishuSession( + session_code=f"MOCK-{index:04d}", + title="大本营周三答疑 mock 场次", + date=date.today(), + teachers="院长, 辅导老师", + feishu_doc_url="mock://feishu/sample_transcript_001", + feishu_record_id=f"mock-record-{index:04d}", + source_file_name="sample_transcript_001.txt", + source_text=read_sample_transcript(), + process_status=models.ProcessStatus.unprocessed, + ) + db.add(session) + db.commit() + db.refresh(session) + return session + + +def mock_ai_cleaning_result(session_id: int) -> list[dict[str, Any]]: + return [ + { + "session_id": str(session_id), + "qa_id": "mock-qa-001", + "raw_question": "我和孩子沟通时总是忍不住发火,孩子现在一听我说话就躲开,我该怎么办?", + "normalized_question": "和孩子沟通时容易发火,孩子开始回避,应该如何调整?", + "raw_answer": "先不要急着讲道理,先看见自己发火背后的着急和无力。可以把要求先放低一点,用一句具体的话表达当下感受,比如我现在有点急,我想先停一下。", + "normalized_answer": "先觉察自己发火背后的着急和无力,暂停说教,把要求放低,用具体语言表达当下感受。", + "suggested_standard_question": "亲子沟通中总忍不住发火,如何先做自我调整?", + "suggested_standard_answer": "可以先暂停说教,觉察自己发火背后的着急和无力,再用具体、低压力的语言表达当下感受。这个回答不能替代个体咨询,也不承诺亲子关系会立即改善。", + "answer_person": "辅导老师", + "primary_topic": "亲子关系", + "problem_tags": ["亲子沟通", "情绪觉察", "发火"], + "course_stage": "觉知", + "audience_tags": ["家长", "亲子关系困扰者"], + "emotion_intensity": "medium", + "risk_level": "medium", + "risk_types": ["未成年人信息", "家庭矛盾"], + "risk_notes": "涉及孩子和家庭互动,需要人工确认是否包含可识别隐私。", + "need_desensitization": True, + "desensitization_status": "needed", + "suggested_review_status": "pending", + "suggested_to_standard_qa": True, + "source_timestamp": "00:03:12", + "review_notes": "建议审核时确认是否需要进一步脱敏孩子细节。", + }, + { + "session_id": str(session_id), + "qa_id": "mock-qa-002", + "raw_question": "我学习大本营后知道自己总是拖延,怎么开始一个小行动?", + "normalized_question": "意识到自己经常拖延后,如何启动一个小行动?", + "raw_answer": "不要一开始就设很大的目标。先选一个今天能完成的动作,比如写三句话、整理十分钟。关键是让身体先体验到我可以开始。", + "normalized_answer": "先设置当天能完成的小动作,让身体体验到可以开始,而不是直接设很大目标。", + "suggested_standard_question": "面对拖延时,如何启动一个足够小的行动?", + "suggested_standard_answer": "可以先选择一个今天就能完成的小动作,例如写三句话或整理十分钟。重点不是追求完美,而是让自己先体验到可以开始。", + "answer_person": "院长", + "primary_topic": "行动力与拖延", + "problem_tags": ["拖延", "小行动", "自我启动"], + "course_stage": "大本营综合", + "audience_tags": ["大本营学员"], + "emotion_intensity": "low", + "risk_level": "low", + "risk_types": [], + "risk_notes": "未发现明显隐私或边界风险。", + "need_desensitization": False, + "desensitization_status": "not_needed", + "suggested_review_status": "pending", + "suggested_to_standard_qa": True, + "source_timestamp": "00:16:44", + "review_notes": "可作为标准问答候选,但仍需人工审核。", + }, + { + "session_id": str(session_id), + "qa_id": "mock-qa-003", + "raw_question": "我朋友说自己有抑郁症,我能不能用课程里的方法帮她判断严重程度?", + "normalized_question": "朋友提到抑郁症时,能否用课程方法判断严重程度?", + "raw_answer": "这类情况不要做诊断,也不要替对方判断严重程度。你可以表达关心,建议对方寻求专业医生或心理专业人员帮助。", + "normalized_answer": "不要做诊断或严重程度判断,可以表达关心,并建议对方寻求专业医生或心理专业人员帮助。", + "suggested_standard_question": "遇到疑似心理健康问题时,课程学习者应该如何把握边界?", + "suggested_standard_answer": "不做心理诊断,不判断严重程度;可以表达关心,并建议对方寻求专业医生或心理专业人员帮助。", + "answer_person": "辅导老师", + "primary_topic": "其他", + "problem_tags": ["心理边界", "专业转介"], + "course_stage": "不确定", + "audience_tags": ["大本营学员"], + "emotion_intensity": "high", + "risk_level": "high", + "risk_types": ["心理 / 医疗边界"], + "risk_notes": "涉及心理诊断边界,必须人工复审,默认不可自动入库。", + "need_desensitization": False, + "desensitization_status": "not_needed", + "suggested_review_status": "pending", + "suggested_to_standard_qa": False, + "source_timestamp": "00:27:05", + "review_notes": "高风险边界题,不建议自动进入标准问答库。", + }, + ] + diff --git a/hy_qa_asset_backend/backend/app/services/qa_parser.py b/hy_qa_asset_backend/backend/app/services/qa_parser.py new file mode 100644 index 0000000..38dbc4f --- /dev/null +++ b/hy_qa_asset_backend/backend/app/services/qa_parser.py @@ -0,0 +1,41 @@ +from typing import Any + +from app.services.risk_detector import reinforce_risk + + +PRIMARY_TOPICS = { + "亲子关系", + "婚姻关系", + "原生家庭", + "情绪模式", + "课程学习", + "个人成长", + "家族系统", + "关系内耗", + "行动力与拖延", + "其他", +} +COURSE_STAGES = {"觉知", "原生", "合一", "生机", "心光", "大本营综合", "不确定"} +DESENSITIZATION_STATUSES = {"not_needed", "needed", "done", "unknown"} +REVIEW_STATUSES = {"pending", "reviewing", "approved", "needs_revision", "rejected", "forbidden"} + + +def normalize_ai_item(item: dict[str, Any]) -> dict[str, Any]: + item = reinforce_risk(dict(item)) + item["primary_topic"] = item.get("primary_topic") if item.get("primary_topic") in PRIMARY_TOPICS else "其他" + item["course_stage"] = item.get("course_stage") if item.get("course_stage") in COURSE_STAGES else "不确定" + item["desensitization_status"] = ( + item.get("desensitization_status") + if item.get("desensitization_status") in DESENSITIZATION_STATUSES + else "unknown" + ) + item["suggested_review_status"] = ( + item.get("suggested_review_status") if item.get("suggested_review_status") in REVIEW_STATUSES else "pending" + ) + for key in ("problem_tags", "audience_tags", "risk_types"): + if not isinstance(item.get(key), list): + item[key] = [] + item["suggested_to_standard_qa"] = bool(item.get("suggested_to_standard_qa")) + item["need_desensitization"] = bool(item.get("need_desensitization")) + return item + diff --git a/hy_qa_asset_backend/backend/app/services/risk_detector.py b/hy_qa_asset_backend/backend/app/services/risk_detector.py new file mode 100644 index 0000000..784287c --- /dev/null +++ b/hy_qa_asset_backend/backend/app/services/risk_detector.py @@ -0,0 +1,35 @@ +from typing import Any + + +HIGH_RISK_KEYWORDS = ["抑郁", "自杀", "诊断", "医生", "法律", "诉讼", "离婚协议"] +PRIVACY_KEYWORDS = ["姓名", "电话", "微信", "学校", "单位", "城市", "住址", "孩子"] + + +def reinforce_risk(item: dict[str, Any]) -> dict[str, Any]: + text = " ".join( + [ + str(item.get("raw_question", "")), + str(item.get("raw_answer", "")), + str(item.get("normalized_question", "")), + str(item.get("normalized_answer", "")), + ] + ) + risk_types = list(item.get("risk_types") or []) + if any(keyword in text for keyword in HIGH_RISK_KEYWORDS): + item["risk_level"] = "high" if item.get("risk_level") != "forbidden" else "forbidden" + if "心理 / 医疗边界" not in risk_types: + risk_types.append("心理 / 医疗边界") + if any(keyword in text for keyword in PRIVACY_KEYWORDS): + item["need_desensitization"] = True + if item.get("desensitization_status") == "not_needed": + item["desensitization_status"] = "needed" + if "学员隐私" not in risk_types: + risk_types.append("学员隐私") + if item.get("risk_level") == "low": + item["risk_level"] = "medium" + if item.get("risk_level") not in {"low", "medium", "high", "forbidden"}: + item["risk_level"] = "medium" + risk_types.append("不确定风险") + item["risk_types"] = risk_types + return item + diff --git a/hy_qa_asset_backend/backend/app/services/scheduler.py b/hy_qa_asset_backend/backend/app/services/scheduler.py new file mode 100644 index 0000000..7889abc --- /dev/null +++ b/hy_qa_asset_backend/backend/app/services/scheduler.py @@ -0,0 +1,35 @@ +from apscheduler.schedulers.background import BackgroundScheduler + +from app import models +from app.config import get_settings +from app.database import SessionLocal +from app.services.task_runner import TaskRunner +from app.utils.time_utils import split_cron + + +scheduler = BackgroundScheduler() + + +def start_scheduler() -> None: + settings = get_settings() + if scheduler.running: + return + cron_kwargs = split_cron(settings.schedule_cron) + scheduler.configure(timezone=settings.schedule_timezone) + scheduler.add_job( + scheduled_scan, + "cron", + id="scheduled_feishu_scan", + replace_existing=True, + **cron_kwargs, + ) + scheduler.start() + + +def scheduled_scan() -> None: + import asyncio + + with SessionLocal() as db: + runner = TaskRunner() + asyncio.run(runner.run(db, task_type=models.TaskType.scheduled_scan)) + diff --git a/hy_qa_asset_backend/backend/app/services/standard_qa_service.py b/hy_qa_asset_backend/backend/app/services/standard_qa_service.py new file mode 100644 index 0000000..42eddb3 --- /dev/null +++ b/hy_qa_asset_backend/backend/app/services/standard_qa_service.py @@ -0,0 +1,237 @@ +from sqlalchemy import func +from sqlalchemy.orm import Session + +from app import models +from app.services.feishu_client import FeishuClient + + +def _default_reviewer_id(db: Session, reviewer_id: int | None) -> int | None: + if reviewer_id: + return reviewer_id + user = db.query(models.User).filter(models.User.role == models.UserRole.reviewer).first() + return user.id if user else None + + +def add_audit_log( + db: Session, + *, + user_id: int | None, + target_type: models.TargetType, + target_id: int, + action: models.AuditAction, + before_status: str | None, + after_status: str | None, + comment: str | None = None, +) -> models.AuditLog: + log = models.AuditLog( + user_id=user_id, + target_type=target_type, + target_id=target_id, + action=action, + before_status=before_status, + after_status=after_status, + comment=comment, + ) + db.add(log) + return log + + +def refresh_session_counts(db: Session, session_id: int) -> None: + session = db.get(models.FeishuSession, session_id) + if not session: + return + session.qa_count = db.query(models.RawQAItem).filter(models.RawQAItem.session_id == session_id).count() + session.pending_review_count = ( + db.query(models.RawQAItem) + .filter( + models.RawQAItem.session_id == session_id, + models.RawQAItem.review_status.in_([models.ReviewStatus.pending, models.ReviewStatus.reviewing]), + ) + .count() + ) + session.approved_count = ( + db.query(models.RawQAItem) + .filter(models.RawQAItem.session_id == session_id, models.RawQAItem.review_status == models.ReviewStatus.approved) + .count() + ) + session.standard_qa_count = ( + db.query(models.StandardQAItem).filter(models.StandardQAItem.session_id == session_id).count() + ) + if session.pending_review_count == 0 and session.qa_count > 0: + session.process_status = models.ProcessStatus.completed + elif session.qa_count > 0: + session.process_status = models.ProcessStatus.pending_review + + +def create_standard_from_raw( + db: Session, raw: models.RawQAItem, reviewer_id: int | None +) -> models.StandardQAItem | None: + if raw.standard_item: + return raw.standard_item + if not raw.suggested_to_standard_qa: + return None + # 高风险与禁止内容即使被标记状态,也不能自动进入标准问答库。 + if raw.risk_level in {models.RiskLevel.high, models.RiskLevel.forbidden}: + return None + if raw.desensitization_status not in { + models.DesensitizationStatus.not_needed, + models.DesensitizationStatus.done, + }: + return None + + next_id = (db.query(func.count(models.StandardQAItem.id)).scalar() or 0) + 1 + standard = models.StandardQAItem( + standard_code=f"STD-{next_id:05d}", + source_raw_qa_id=raw.id, + session_id=raw.session_id, + standard_question=raw.suggested_standard_question or raw.normalized_question or raw.raw_question, + standard_answer=raw.suggested_standard_answer or raw.normalized_answer or raw.raw_answer, + similar_questions=[raw.normalized_question] if raw.normalized_question else [], + primary_topic=raw.primary_topic, + problem_tags=raw.problem_tags, + course_stage=raw.course_stage, + audience_tags=raw.audience_tags, + answer_boundary="只基于已审核答疑内容复用;不做心理诊断、医疗建议、法律判断或课程效果承诺。", + forbidden_expressions=["保证有效", "一定改变", "诊断为", "法律上必然"], + risk_level=raw.risk_level, + audit_status=models.AuditStatus.approved, + call_status=models.CallStatus.not_callable, + last_reviewer_id=reviewer_id, + ) + db.add(standard) + return standard + + +def approve_raw_qa( + db: Session, raw: models.RawQAItem, reviewer_id: int | None = None, comment: str | None = None +) -> models.RawQAItem: + reviewer_id = _default_reviewer_id(db, reviewer_id) + before = raw.review_status.value + raw.review_status = models.ReviewStatus.approved + raw.reviewer_id = reviewer_id + if comment: + raw.review_notes = comment + add_audit_log( + db, + user_id=reviewer_id, + target_type=models.TargetType.raw_qa, + target_id=raw.id, + action=models.AuditAction.approve, + before_status=before, + after_status=raw.review_status.value, + comment=comment, + ) + standard = create_standard_from_raw(db, raw, reviewer_id) + if standard: + db.flush() + add_audit_log( + db, + user_id=reviewer_id, + target_type=models.TargetType.standard_qa, + target_id=standard.id, + action=models.AuditAction.approve, + before_status="none", + after_status=models.AuditStatus.approved.value, + comment="由原始问答审核通过后生成,默认 not_callable。", + ) + refresh_session_counts(db, raw.session_id) + db.commit() + feishu = FeishuClient() + if standard: + try: + feishu.write_standard_qa_items([standard]) + except Exception as exc: # noqa: BLE001 + add_audit_log( + db, + user_id=reviewer_id, + target_type=models.TargetType.standard_qa, + target_id=standard.id, + action=models.AuditAction.revise, + before_status="feishu_write", + after_status="failed", + comment=f"飞书标准问答回写失败:{exc}", + ) + db.commit() + session = db.get(models.FeishuSession, raw.session_id) + if session: + try: + feishu.update_session_status(session, session.process_status.value) + except Exception as exc: # noqa: BLE001 + add_audit_log( + db, + user_id=reviewer_id, + target_type=models.TargetType.session, + target_id=session.id, + action=models.AuditAction.revise, + before_status="feishu_session_sync", + after_status="failed", + comment=f"飞书场次统计回写失败:{exc}", + ) + db.commit() + db.refresh(raw) + return raw + + +def set_raw_status( + db: Session, + raw: models.RawQAItem, + status: models.ReviewStatus, + action: models.AuditAction, + reviewer_id: int | None = None, + comment: str | None = None, +) -> models.RawQAItem: + reviewer_id = _default_reviewer_id(db, reviewer_id) + before = raw.review_status.value + raw.review_status = status + raw.reviewer_id = reviewer_id + if comment: + raw.review_notes = comment + add_audit_log( + db, + user_id=reviewer_id, + target_type=models.TargetType.raw_qa, + target_id=raw.id, + action=action, + before_status=before, + after_status=status.value, + comment=comment, + ) + refresh_session_counts(db, raw.session_id) + db.commit() + db.refresh(raw) + return raw + + +def mark_standard_call_status( + db: Session, + standard: models.StandardQAItem, + status: models.CallStatus, + reviewer_id: int | None = None, + comment: str | None = None, +) -> models.StandardQAItem: + reviewer_id = _default_reviewer_id(db, reviewer_id) + before = standard.call_status.value + if status == models.CallStatus.callable: + raw = standard.source_raw_qa + if ( + standard.audit_status != models.AuditStatus.approved + or standard.risk_level not in {models.RiskLevel.low, models.RiskLevel.medium} + or raw.desensitization_status + not in {models.DesensitizationStatus.not_needed, models.DesensitizationStatus.done} + ): + raise ValueError("只有已审核、低/中风险、且已完成脱敏或无需脱敏的标准问答可以标记为可调用") + standard.call_status = status + standard.last_reviewer_id = reviewer_id + add_audit_log( + db, + user_id=reviewer_id, + target_type=models.TargetType.standard_qa, + target_id=standard.id, + action=models.AuditAction.mark_callable if status == models.CallStatus.callable else models.AuditAction.disable, + before_status=before, + after_status=status.value, + comment=comment, + ) + db.commit() + db.refresh(standard) + return standard diff --git a/hy_qa_asset_backend/backend/app/services/task_runner.py b/hy_qa_asset_backend/backend/app/services/task_runner.py new file mode 100644 index 0000000..2736b7e --- /dev/null +++ b/hy_qa_asset_backend/backend/app/services/task_runner.py @@ -0,0 +1,124 @@ +from sqlalchemy.orm import Session + +from app import models +from app.services.ai_cleaner import AICleaner +from app.services.feishu_client import FeishuClient +from app.services.mock_data_service import create_mock_session +from app.services.standard_qa_service import refresh_session_counts +from app.utils.time_utils import utc_now + + +class TaskRunner: + def __init__(self) -> None: + self.feishu = FeishuClient() + self.ai = AICleaner() + + async def run( + self, + db: Session, + *, + task_type: models.TaskType = models.TaskType.manual_scan, + session_id: int | None = None, + reprocess: bool = False, + ) -> models.TaskRun: + task = models.TaskRun(task_type=task_type, task_status=models.TaskStatus.running, started_at=utc_now()) + db.add(task) + db.commit() + db.refresh(task) + try: + sessions = self._resolve_sessions(db, session_id=session_id) + if not sessions and self.feishu.is_mock_mode and session_id is None: + sessions = [create_mock_session(db)] + task.sessions_scanned = len(sessions) + + for session in sessions: + try: + created_count = await self._process_session(db, session, reprocess=reprocess) + task.sessions_processed += 1 + task.qa_created += created_count + except Exception as exc: # noqa: BLE001 + task.qa_failed += 1 + session.process_status = models.ProcessStatus.failed + session.failed_reason = str(exc) + task.error_message = str(exc) + db.add(session) + db.commit() + self.feishu.update_session_status(session, session.process_status.value) + + task.task_status = ( + models.TaskStatus.success + if task.qa_failed == 0 + else models.TaskStatus.partial_success + if task.sessions_processed > 0 + else models.TaskStatus.failed + ) + except Exception as exc: # noqa: BLE001 + task.task_status = models.TaskStatus.failed + task.error_message = str(exc) + finally: + task.ended_at = utc_now() + db.add(task) + db.commit() + db.refresh(task) + return task + + def _resolve_sessions(self, db: Session, session_id: int | None) -> list[models.FeishuSession]: + if session_id is not None: + session = db.get(models.FeishuSession, session_id) + return [session] if session else [] + return self.feishu.scan_unprocessed_sessions(db) + + async def _process_session(self, db: Session, session: models.FeishuSession, *, reprocess: bool = False) -> int: + if reprocess: + db.query(models.StandardQAItem).filter(models.StandardQAItem.session_id == session.id).delete() + db.query(models.RawQAItem).filter(models.RawQAItem.session_id == session.id).delete() + db.commit() + + session.process_status = models.ProcessStatus.processing + session.failed_reason = None + db.add(session) + db.commit() + self.feishu.update_session_status(session, session.process_status.value) + + transcript = self.feishu.fetch_transcript(session) + session.source_text = transcript + ai_items = await self.ai.clean_transcript(session.id, transcript) + + created: list[models.RawQAItem] = [] + for index, item in enumerate(ai_items, start=1): + raw = models.RawQAItem( + session_id=session.id, + qa_code=f"{session.session_code}-QA-{index:03d}", + raw_question=item["raw_question"], + normalized_question=item.get("normalized_question"), + raw_answer=item["raw_answer"], + normalized_answer=item.get("normalized_answer"), + suggested_standard_question=item.get("suggested_standard_question"), + suggested_standard_answer=item.get("suggested_standard_answer"), + answer_person=item.get("answer_person"), + primary_topic=item.get("primary_topic", "其他"), + problem_tags=item.get("problem_tags", []), + course_stage=item.get("course_stage", "不确定"), + audience_tags=item.get("audience_tags", []), + emotion_intensity=item.get("emotion_intensity"), + risk_level=item.get("risk_level", "medium"), + risk_types=item.get("risk_types", []), + risk_notes=item.get("risk_notes"), + need_desensitization=item.get("need_desensitization", False), + desensitization_status=item.get("desensitization_status", "unknown"), + review_status=models.ReviewStatus.pending, + review_notes=item.get("review_notes"), + suggested_to_standard_qa=item.get("suggested_to_standard_qa", True), + source_timestamp=item.get("source_timestamp"), + ) + db.add(raw) + created.append(raw) + + session.process_status = models.ProcessStatus.pending_review + db.add(session) + db.commit() + refresh_session_counts(db, session.id) + db.commit() + self.feishu.write_raw_qa_items(session, created) + self.feishu.update_session_status(session, session.process_status.value) + return len(created) diff --git a/hy_qa_asset_backend/backend/app/utils/json_validator.py b/hy_qa_asset_backend/backend/app/utils/json_validator.py new file mode 100644 index 0000000..36938cc --- /dev/null +++ b/hy_qa_asset_backend/backend/app/utils/json_validator.py @@ -0,0 +1,43 @@ +from typing import Any + + +REQUIRED_QA_FIELDS = { + "session_id", + "qa_id", + "raw_question", + "normalized_question", + "raw_answer", + "normalized_answer", + "suggested_standard_question", + "suggested_standard_answer", + "answer_person", + "primary_topic", + "problem_tags", + "course_stage", + "audience_tags", + "emotion_intensity", + "risk_level", + "risk_types", + "risk_notes", + "need_desensitization", + "desensitization_status", + "suggested_review_status", + "suggested_to_standard_qa", + "source_timestamp", + "review_notes", +} + + +def validate_ai_qa_items(payload: Any) -> list[dict[str, Any]]: + if not isinstance(payload, list): + raise ValueError("AI 返回必须是 JSON 数组") + valid_items: list[dict[str, Any]] = [] + for index, item in enumerate(payload, start=1): + if not isinstance(item, dict): + raise ValueError(f"第 {index} 条 AI 结果不是对象") + missing = REQUIRED_QA_FIELDS.difference(item) + if missing: + raise ValueError(f"第 {index} 条 AI 结果缺少字段: {', '.join(sorted(missing))}") + valid_items.append(item) + return valid_items + diff --git a/hy_qa_asset_backend/backend/app/utils/text_reader.py b/hy_qa_asset_backend/backend/app/utils/text_reader.py new file mode 100644 index 0000000..3ea9e08 --- /dev/null +++ b/hy_qa_asset_backend/backend/app/utils/text_reader.py @@ -0,0 +1,6 @@ +from pathlib import Path + + +def read_text_file(path: str | Path) -> str: + return Path(path).read_text(encoding="utf-8") + diff --git a/hy_qa_asset_backend/backend/app/utils/time_utils.py b/hy_qa_asset_backend/backend/app/utils/time_utils.py new file mode 100644 index 0000000..2d46592 --- /dev/null +++ b/hy_qa_asset_backend/backend/app/utils/time_utils.py @@ -0,0 +1,20 @@ +from datetime import datetime, timezone + + +def utc_now() -> datetime: + return datetime.now(timezone.utc) + + +def split_cron(cron: str) -> dict[str, str]: + parts = cron.split() + if len(parts) != 5: + raise ValueError("SCHEDULE_CRON 需要使用 5 段 cron 表达式,例如 0 1 * * *") + minute, hour, day, month, day_of_week = parts + return { + "minute": minute, + "hour": hour, + "day": day, + "month": month, + "day_of_week": day_of_week, + } + diff --git a/hy_qa_asset_backend/backend/requirements.txt b/hy_qa_asset_backend/backend/requirements.txt new file mode 100644 index 0000000..3623687 --- /dev/null +++ b/hy_qa_asset_backend/backend/requirements.txt @@ -0,0 +1,10 @@ +fastapi>=0.116.0 +uvicorn>=0.35.0 +SQLAlchemy>=2.0.40 +psycopg[binary]>=3.2.9 +pydantic>=2.11.0 +pydantic-settings>=2.10.0 +python-dotenv>=1.1.0 +APScheduler>=3.11.0 +httpx>=0.28.1 +python-multipart>=0.0.20 diff --git a/hy_qa_asset_backend/docker-compose.yml b/hy_qa_asset_backend/docker-compose.yml new file mode 100644 index 0000000..1dce4cd --- /dev/null +++ b/hy_qa_asset_backend/docker-compose.yml @@ -0,0 +1,48 @@ +services: + postgres: + image: postgres:16-alpine + container_name: hy_qa_postgres + environment: + POSTGRES_DB: hyqa + POSTGRES_USER: hyqa + POSTGRES_PASSWORD: hyqa + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U hyqa -d hyqa"] + interval: 5s + timeout: 5s + retries: 10 + + backend: + build: + context: ./backend + container_name: hy_qa_backend + environment: + DATABASE_URL: postgresql+psycopg://hyqa:hyqa@postgres:5432/hyqa + MODEL_NAME: gpt-4.1-mini + SCHEDULE_CRON: "0 1 * * *" + SCHEDULE_TIMEZONE: Asia/Shanghai + MOCK_MODE: "true" + ports: + - "8000:8000" + depends_on: + postgres: + condition: service_healthy + + frontend: + build: + context: ./frontend + container_name: hy_qa_frontend + environment: + NEXT_PUBLIC_API_BASE_URL: http://localhost:8000/api + ports: + - "3000:3000" + depends_on: + - backend + +volumes: + postgres_data: + diff --git a/hy_qa_asset_backend/frontend/Dockerfile b/hy_qa_asset_backend/frontend/Dockerfile new file mode 100644 index 0000000..ce3153b --- /dev/null +++ b/hy_qa_asset_backend/frontend/Dockerfile @@ -0,0 +1,13 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package.json ./ +RUN npm install + +COPY . . +RUN npm run build + +EXPOSE 3000 +CMD ["npm", "run", "start"] + diff --git a/hy_qa_asset_backend/frontend/app/callable-qa/page.tsx b/hy_qa_asset_backend/frontend/app/callable-qa/page.tsx new file mode 100644 index 0000000..14527e7 --- /dev/null +++ b/hy_qa_asset_backend/frontend/app/callable-qa/page.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { api } from "@/lib/api"; +import type { CallableQAItem } from "@/lib/types"; + +export default function CallableQAPage() { + const [items, setItems] = useState([]); + + useEffect(() => { + api.get("/standard-qa/callable").then(setItems); + }, []); + + return ( +
+
+

智能体可调用库

+
+
+ {items.map((item) => ( +
+
+ {item.standard_code} + {item.primary_topic} + {item.course_stage} +
+

{item.standard_question}

+

{item.standard_answer}

+
+ {item.problem_tags.map((tag) => ( + + {tag} + + ))} +
+ {item.answer_boundary ?

{item.answer_boundary}

: null} +
+ ))} + {items.length === 0 ? ( +
暂无可调用问答
+ ) : null} +
+
+ ); +} + diff --git a/hy_qa_asset_backend/frontend/app/dashboard/page.tsx b/hy_qa_asset_backend/frontend/app/dashboard/page.tsx new file mode 100644 index 0000000..fe89e31 --- /dev/null +++ b/hy_qa_asset_backend/frontend/app/dashboard/page.tsx @@ -0,0 +1,93 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { AlertTriangle, CheckCircle2, Database, FileQuestion, Library, Play, Rows3, Timer } from "lucide-react"; +import { api, formatDateTime } from "@/lib/api"; +import type { DashboardSummary, TaskRun } from "@/lib/types"; +import StatCard from "@/components/StatCard"; +import StatusBadge from "@/components/StatusBadge"; + +export default function DashboardPage() { + const [summary, setSummary] = useState(null); + const [loading, setLoading] = useState(true); + const [running, setRunning] = useState(false); + + async function load() { + setLoading(true); + const data = await api.get("/dashboard/summary"); + setSummary(data); + setLoading(false); + } + + async function runTask() { + setRunning(true); + await api.post("/tasks/run-now", {}); + await load(); + setRunning(false); + } + + useEffect(() => { + load().catch(() => setLoading(false)); + }, []); + + if (loading || !summary) { + return
加载中...
; + } + + const actions = [ + { href: "/review", label: "去审核" }, + { href: "/high-risk", label: "查看高风险" }, + { href: "/standard-qa", label: "查看标准问答库" }, + { href: "/logs", label: "查看运行日志" } + ]; + + return ( +
+
+
+

仪表盘

+

最近任务:{summary.last_task_time ? formatDateTime(summary.last_task_time) : "-"}

+
+ +
+ +
+ } /> + } tone="indigo" /> + } tone="amber" /> + } tone="rose" /> + } /> + } tone="indigo" /> + } /> +
+
最近一次任务状态
+
+ {summary.last_task_status ? : 暂无任务} +
+ {summary.last_task_error ?

{summary.last_task_error}

: null} +
+
+ +
+ {actions.map((action) => ( + + {action.label} + + ))} +
+
+ ); +} + diff --git a/hy_qa_asset_backend/frontend/app/globals.css b/hy_qa_asset_backend/frontend/app/globals.css new file mode 100644 index 0000000..ab57819 --- /dev/null +++ b/hy_qa_asset_backend/frontend/app/globals.css @@ -0,0 +1,26 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: #f5f7fa; + color: #1f2933; +} + +button, +input, +textarea, +select { + font: inherit; +} + +.table-wrap { + width: 100%; + overflow-x: auto; +} + diff --git a/hy_qa_asset_backend/frontend/app/high-risk/page.tsx b/hy_qa_asset_backend/frontend/app/high-risk/page.tsx new file mode 100644 index 0000000..b7cd736 --- /dev/null +++ b/hy_qa_asset_backend/frontend/app/high-risk/page.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { api } from "@/lib/api"; +import type { RawQAItem } from "@/lib/types"; +import QAReviewCard from "@/components/QAReviewCard"; + +const riskFilters = [ + "全部", + "学员隐私", + "未成年人信息", + "婚姻家庭敏感内容", + "心理 / 医疗边界", + "法律边界", + "课程效果承诺", + "团队表达风险", + "其他" +]; + +export default function HighRiskPage() { + const [items, setItems] = useState([]); + const [filter, setFilter] = useState("全部"); + + async function load() { + const [high, forbidden] = await Promise.all([ + api.get("/raw-qa?risk_level=high"), + api.get("/raw-qa?risk_level=forbidden") + ]); + setItems([...high, ...forbidden]); + } + + useEffect(() => { + load(); + }, []); + + const visible = useMemo(() => { + if (filter === "全部") return items; + if (filter === "其他") return items.filter((item) => item.risk_types.length === 0); + return items.filter((item) => item.risk_types.includes(filter)); + }, [filter, items]); + + return ( +
+
+

高风险问答

+ +
+
+ {visible.map((item) => ( + + ))} + {visible.length === 0 ? ( +
暂无高风险问答
+ ) : null} +
+
+ ); +} + diff --git a/hy_qa_asset_backend/frontend/app/layout.tsx b/hy_qa_asset_backend/frontend/app/layout.tsx new file mode 100644 index 0000000..81b08bb --- /dev/null +++ b/hy_qa_asset_backend/frontend/app/layout.tsx @@ -0,0 +1,19 @@ +import type { Metadata } from "next"; +import "./globals.css"; +import Layout from "@/components/Layout"; + +export const metadata: Metadata = { + title: "大本营答疑资产后台系统 MVP", + description: "答疑转写清洗、审核与标准问答沉淀后台" +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ); +} + diff --git a/hy_qa_asset_backend/frontend/app/logs/page.tsx b/hy_qa_asset_backend/frontend/app/logs/page.tsx new file mode 100644 index 0000000..46effd5 --- /dev/null +++ b/hy_qa_asset_backend/frontend/app/logs/page.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { api, formatDateTime } from "@/lib/api"; +import type { AuditLog, TaskRun } from "@/lib/types"; +import StatusBadge from "@/components/StatusBadge"; + +export default function LogsPage() { + const [tasks, setTasks] = useState([]); + const [audit, setAudit] = useState([]); + + useEffect(() => { + Promise.all([api.get("/logs/tasks"), api.get("/logs/audit")]).then(([taskRows, auditRows]) => { + setTasks(taskRows); + setAudit(auditRows); + }); + }, []); + + return ( +
+

运行日志

+ +
+ + + + + + + + + + + + + + + + {tasks.map((task) => ( + + + + + + + + + + + + ))} + +
任务 ID开始时间结束时间状态场次问答失败错误重试
{task.id}{formatDateTime(task.started_at)}{formatDateTime(task.ended_at)} + + {task.sessions_processed}/{task.sessions_scanned}{task.qa_created}{task.qa_failed}{task.error_message ?? "-"}{task.retry_count}
+
+ +
+ + + + + + + + + + + + + + {audit.map((log) => ( + + + + + + + + + + ))} + +
日志 ID对象动作前状态后状态备注时间
{log.id}{log.target_type} #{log.target_id}{log.action}{log.before_status ?? "-"}{log.after_status ?? "-"}{log.comment ?? "-"}{formatDateTime(log.created_at)}
+
+
+ ); +} + diff --git a/hy_qa_asset_backend/frontend/app/page.tsx b/hy_qa_asset_backend/frontend/app/page.tsx new file mode 100644 index 0000000..eb68262 --- /dev/null +++ b/hy_qa_asset_backend/frontend/app/page.tsx @@ -0,0 +1,6 @@ +import { redirect } from "next/navigation"; + +export default function Home() { + redirect("/dashboard"); +} + diff --git a/hy_qa_asset_backend/frontend/app/review/page.tsx b/hy_qa_asset_backend/frontend/app/review/page.tsx new file mode 100644 index 0000000..37791ed --- /dev/null +++ b/hy_qa_asset_backend/frontend/app/review/page.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { api } from "@/lib/api"; +import type { RawQAItem } from "@/lib/types"; +import QAReviewCard from "@/components/QAReviewCard"; + +export default function ReviewPage() { + const [items, setItems] = useState([]); + + async function load() { + const data = await api.get("/raw-qa?review_status=pending"); + setItems(data); + } + + useEffect(() => { + load(); + }, []); + + return ( +
+
+

待审核问答

+
+
+ {items.map((item) => ( + + ))} + {items.length === 0 ? ( +
暂无待审核问答
+ ) : null} +
+
+ ); +} + diff --git a/hy_qa_asset_backend/frontend/app/sessions/[id]/page.tsx b/hy_qa_asset_backend/frontend/app/sessions/[id]/page.tsx new file mode 100644 index 0000000..dc3fa97 --- /dev/null +++ b/hy_qa_asset_backend/frontend/app/sessions/[id]/page.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useParams } from "next/navigation"; +import { api } from "@/lib/api"; +import type { FeishuSession, RawQAItem } from "@/lib/types"; +import RiskBadge from "@/components/RiskBadge"; +import StatusBadge from "@/components/StatusBadge"; + +export default function SessionDetailPage() { + const params = useParams<{ id: string }>(); + const [session, setSession] = useState(null); + const [items, setItems] = useState([]); + + useEffect(() => { + async function load() { + const id = Number(params.id); + const [sessionData, rawData] = await Promise.all([ + api.get(`/sessions/${id}`), + api.get(`/raw-qa?session_id=${id}`) + ]); + setSession(sessionData); + setItems(rawData); + } + load(); + }, [params.id]); + + if (!session) return
加载中...
; + + return ( +
+
+
+

{session.title}

+

+ {session.session_code} / {session.date ?? "-"} / {session.teachers ?? "-"} +

+
+ +
+ +
+
原始资料
+
+          {session.source_text || "暂无原始资料"}
+        
+
+ +
+ {items.map((item) => ( +
+
+ {item.qa_code} + + +
+
+
+
原始问题
+
{item.raw_question}
+
+
+
原始回答
+
{item.raw_answer}
+
+
+
+ ))} +
+
+ ); +} + diff --git a/hy_qa_asset_backend/frontend/app/sessions/page.tsx b/hy_qa_asset_backend/frontend/app/sessions/page.tsx new file mode 100644 index 0000000..025ec91 --- /dev/null +++ b/hy_qa_asset_backend/frontend/app/sessions/page.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Plus } from "lucide-react"; +import { api } from "@/lib/api"; +import type { FeishuSession } from "@/lib/types"; +import SessionTable from "@/components/SessionTable"; + +export default function SessionsPage() { + const [sessions, setSessions] = useState([]); + const [title, setTitle] = useState("大本营周三答疑测试场次"); + const [teachers, setTeachers] = useState("院长, 辅导老师"); + const [sourceText, setSourceText] = useState(""); + + async function load() { + const data = await api.get("/sessions"); + setSessions(data); + } + + async function createSession() { + await api.post("/sessions", { + title, + teachers, + source_text: sourceText || undefined, + source_file_name: sourceText ? "manual_transcript.txt" : undefined + }); + setSourceText(""); + await load(); + } + + useEffect(() => { + load(); + }, []); + + return ( +
+
+

答疑场次

+
+ +
+
+ + +
+ +
+
+