From 5bd737b064ddce92e19e03f227055322449783e7 Mon Sep 17 00:00:00 2001 From: Nelson <1475262689@qq.com> Date: Thu, 9 Jul 2026 15:12:47 +0800 Subject: [PATCH] Remove legacy hy_qa_asset_backend directory --- hy_qa_asset_backend/.env.example | 23 - hy_qa_asset_backend/.gitignore | 18 - hy_qa_asset_backend/README.md | 172 -- hy_qa_asset_backend/backend/Dockerfile | 12 - hy_qa_asset_backend/backend/app/__init__.py | 2 - .../backend/app/api/__init__.py | 2 - .../backend/app/api/dashboard.py | 42 - hy_qa_asset_backend/backend/app/api/feishu.py | 112 -- hy_qa_asset_backend/backend/app/api/logs.py | 18 - hy_qa_asset_backend/backend/app/api/raw_qa.py | 150 -- .../backend/app/api/sessions.py | 72 - .../backend/app/api/settings.py | 51 - .../backend/app/api/standard_qa.py | 146 -- hy_qa_asset_backend/backend/app/api/tasks.py | 28 - hy_qa_asset_backend/backend/app/config.py | 61 - hy_qa_asset_backend/backend/app/database.py | 49 - hy_qa_asset_backend/backend/app/main.py | 52 - hy_qa_asset_backend/backend/app/models.py | 270 --- .../backend/app/prompts/qa_cleaning_prompt.md | 147 -- hy_qa_asset_backend/backend/app/schemas.py | 255 --- .../app/seed/sample_transcript_001.txt | 8 - .../backend/app/services/ai_cleaner.py | 49 - .../backend/app/services/feishu_client.py | 600 ------ .../backend/app/services/mock_data_service.py | 115 -- .../backend/app/services/qa_parser.py | 41 - .../backend/app/services/risk_detector.py | 35 - .../backend/app/services/scheduler.py | 35 - .../app/services/standard_qa_service.py | 237 --- .../backend/app/services/task_runner.py | 124 -- .../backend/app/utils/json_validator.py | 43 - .../backend/app/utils/text_reader.py | 6 - .../backend/app/utils/time_utils.py | 20 - hy_qa_asset_backend/backend/requirements.txt | 10 - hy_qa_asset_backend/docker-compose.yml | 48 - hy_qa_asset_backend/docs/README.md | 13 - hy_qa_asset_backend/docs/h5-user-app-plan.md | 110 -- hy_qa_asset_backend/frontend/Dockerfile | 13 - .../frontend/app/callable-qa/page.tsx | 46 - .../frontend/app/dashboard/page.tsx | 93 - hy_qa_asset_backend/frontend/app/globals.css | 26 - hy_qa_asset_backend/frontend/app/h5/page.tsx | 5 - .../frontend/app/high-risk/page.tsx | 67 - hy_qa_asset_backend/frontend/app/layout.tsx | 19 - .../frontend/app/logs/page.tsx | 89 - hy_qa_asset_backend/frontend/app/page.tsx | 6 - .../frontend/app/review/page.tsx | 36 - .../frontend/app/sessions/[id]/page.tsx | 73 - .../frontend/app/sessions/page.tsx | 84 - .../frontend/app/settings/page.tsx | 166 -- .../frontend/app/standard-qa/page.tsx | 29 - .../frontend/components/Layout.tsx | 20 - .../frontend/components/QAReviewCard.tsx | 138 -- .../frontend/components/RiskBadge.tsx | 20 - .../frontend/components/SessionTable.tsx | 91 - .../frontend/components/Sidebar.tsx | 62 - .../frontend/components/StandardQATable.tsx | 126 -- .../frontend/components/StatCard.tsx | 33 - .../frontend/components/StatusBadge.tsx | 46 - .../frontend/components/h5/ChatBubble.tsx | 80 - .../frontend/components/h5/H5QAExperience.tsx | 317 ---- .../frontend/components/h5/MockAuthPanel.tsx | 102 - hy_qa_asset_backend/frontend/lib/api.ts | 40 - hy_qa_asset_backend/frontend/lib/h5MockQa.ts | 106 -- hy_qa_asset_backend/frontend/lib/types.ts | 163 -- .../frontend/lib/wechatAuth.ts | 43 - hy_qa_asset_backend/frontend/next-env.d.ts | 5 - hy_qa_asset_backend/frontend/next.config.js | 7 - .../frontend/package-lock.json | 1645 ----------------- hy_qa_asset_backend/frontend/package.json | 27 - .../frontend/postcss.config.js | 7 - .../frontend/tailwind.config.ts | 24 - hy_qa_asset_backend/frontend/tsconfig.json | 24 - .../feishu_import_template/build_template.mjs | 186 -- .../feishu_import_template/原始问答.png | Bin 30699 -> 0 bytes .../大本营答疑资产库_飞书导入模板.xlsx | Bin 10202 -> 0 bytes ...答疑资产库_飞书导入模板.xlsx.inspect.ndjson | 96 - .../feishu_import_template/导入说明.png | Bin 43653 -> 0 bytes .../feishu_import_template/标准问答.png | Bin 30458 -> 0 bytes .../feishu_import_template/答疑场次.png | Bin 33016 -> 0 bytes 79 files changed, 7336 deletions(-) delete mode 100644 hy_qa_asset_backend/.env.example delete mode 100644 hy_qa_asset_backend/.gitignore delete mode 100644 hy_qa_asset_backend/README.md delete mode 100644 hy_qa_asset_backend/backend/Dockerfile delete mode 100644 hy_qa_asset_backend/backend/app/__init__.py delete mode 100644 hy_qa_asset_backend/backend/app/api/__init__.py delete mode 100644 hy_qa_asset_backend/backend/app/api/dashboard.py delete mode 100644 hy_qa_asset_backend/backend/app/api/feishu.py delete mode 100644 hy_qa_asset_backend/backend/app/api/logs.py delete mode 100644 hy_qa_asset_backend/backend/app/api/raw_qa.py delete mode 100644 hy_qa_asset_backend/backend/app/api/sessions.py delete mode 100644 hy_qa_asset_backend/backend/app/api/settings.py delete mode 100644 hy_qa_asset_backend/backend/app/api/standard_qa.py delete mode 100644 hy_qa_asset_backend/backend/app/api/tasks.py delete mode 100644 hy_qa_asset_backend/backend/app/config.py delete mode 100644 hy_qa_asset_backend/backend/app/database.py delete mode 100644 hy_qa_asset_backend/backend/app/main.py delete mode 100644 hy_qa_asset_backend/backend/app/models.py delete mode 100644 hy_qa_asset_backend/backend/app/prompts/qa_cleaning_prompt.md delete mode 100644 hy_qa_asset_backend/backend/app/schemas.py delete mode 100644 hy_qa_asset_backend/backend/app/seed/sample_transcript_001.txt delete mode 100644 hy_qa_asset_backend/backend/app/services/ai_cleaner.py delete mode 100644 hy_qa_asset_backend/backend/app/services/feishu_client.py delete mode 100644 hy_qa_asset_backend/backend/app/services/mock_data_service.py delete mode 100644 hy_qa_asset_backend/backend/app/services/qa_parser.py delete mode 100644 hy_qa_asset_backend/backend/app/services/risk_detector.py delete mode 100644 hy_qa_asset_backend/backend/app/services/scheduler.py delete mode 100644 hy_qa_asset_backend/backend/app/services/standard_qa_service.py delete mode 100644 hy_qa_asset_backend/backend/app/services/task_runner.py delete mode 100644 hy_qa_asset_backend/backend/app/utils/json_validator.py delete mode 100644 hy_qa_asset_backend/backend/app/utils/text_reader.py delete mode 100644 hy_qa_asset_backend/backend/app/utils/time_utils.py delete mode 100644 hy_qa_asset_backend/backend/requirements.txt delete mode 100644 hy_qa_asset_backend/docker-compose.yml delete mode 100644 hy_qa_asset_backend/docs/README.md delete mode 100644 hy_qa_asset_backend/docs/h5-user-app-plan.md delete mode 100644 hy_qa_asset_backend/frontend/Dockerfile delete mode 100644 hy_qa_asset_backend/frontend/app/callable-qa/page.tsx delete mode 100644 hy_qa_asset_backend/frontend/app/dashboard/page.tsx delete mode 100644 hy_qa_asset_backend/frontend/app/globals.css delete mode 100644 hy_qa_asset_backend/frontend/app/h5/page.tsx delete mode 100644 hy_qa_asset_backend/frontend/app/high-risk/page.tsx delete mode 100644 hy_qa_asset_backend/frontend/app/layout.tsx delete mode 100644 hy_qa_asset_backend/frontend/app/logs/page.tsx delete mode 100644 hy_qa_asset_backend/frontend/app/page.tsx delete mode 100644 hy_qa_asset_backend/frontend/app/review/page.tsx delete mode 100644 hy_qa_asset_backend/frontend/app/sessions/[id]/page.tsx delete mode 100644 hy_qa_asset_backend/frontend/app/sessions/page.tsx delete mode 100644 hy_qa_asset_backend/frontend/app/settings/page.tsx delete mode 100644 hy_qa_asset_backend/frontend/app/standard-qa/page.tsx delete mode 100644 hy_qa_asset_backend/frontend/components/Layout.tsx delete mode 100644 hy_qa_asset_backend/frontend/components/QAReviewCard.tsx delete mode 100644 hy_qa_asset_backend/frontend/components/RiskBadge.tsx delete mode 100644 hy_qa_asset_backend/frontend/components/SessionTable.tsx delete mode 100644 hy_qa_asset_backend/frontend/components/Sidebar.tsx delete mode 100644 hy_qa_asset_backend/frontend/components/StandardQATable.tsx delete mode 100644 hy_qa_asset_backend/frontend/components/StatCard.tsx delete mode 100644 hy_qa_asset_backend/frontend/components/StatusBadge.tsx delete mode 100644 hy_qa_asset_backend/frontend/components/h5/ChatBubble.tsx delete mode 100644 hy_qa_asset_backend/frontend/components/h5/H5QAExperience.tsx delete mode 100644 hy_qa_asset_backend/frontend/components/h5/MockAuthPanel.tsx delete mode 100644 hy_qa_asset_backend/frontend/lib/api.ts delete mode 100644 hy_qa_asset_backend/frontend/lib/h5MockQa.ts delete mode 100644 hy_qa_asset_backend/frontend/lib/types.ts delete mode 100644 hy_qa_asset_backend/frontend/lib/wechatAuth.ts delete mode 100644 hy_qa_asset_backend/frontend/next-env.d.ts delete mode 100644 hy_qa_asset_backend/frontend/next.config.js delete mode 100644 hy_qa_asset_backend/frontend/package-lock.json delete mode 100644 hy_qa_asset_backend/frontend/package.json delete mode 100644 hy_qa_asset_backend/frontend/postcss.config.js delete mode 100644 hy_qa_asset_backend/frontend/tailwind.config.ts delete mode 100644 hy_qa_asset_backend/frontend/tsconfig.json delete mode 100644 hy_qa_asset_backend/outputs/feishu_import_template/build_template.mjs delete mode 100644 hy_qa_asset_backend/outputs/feishu_import_template/原始问答.png delete mode 100644 hy_qa_asset_backend/outputs/feishu_import_template/大本营答疑资产库_飞书导入模板.xlsx delete mode 100644 hy_qa_asset_backend/outputs/feishu_import_template/大本营答疑资产库_飞书导入模板.xlsx.inspect.ndjson delete mode 100644 hy_qa_asset_backend/outputs/feishu_import_template/导入说明.png delete mode 100644 hy_qa_asset_backend/outputs/feishu_import_template/标准问答.png delete mode 100644 hy_qa_asset_backend/outputs/feishu_import_template/答疑场次.png diff --git a/hy_qa_asset_backend/.env.example b/hy_qa_asset_backend/.env.example deleted file mode 100644 index a58170d..0000000 --- a/hy_qa_asset_backend/.env.example +++ /dev/null @@ -1,23 +0,0 @@ -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 - -# H5 WeChat OAuth. Leave empty to use the frontend demo authorization flow. -NEXT_PUBLIC_WECHAT_APP_ID= -NEXT_PUBLIC_WECHAT_AUTH_REDIRECT_URI= -NEXT_PUBLIC_WECHAT_AUTH_SCOPE=snsapi_userinfo diff --git a/hy_qa_asset_backend/.gitignore b/hy_qa_asset_backend/.gitignore deleted file mode 100644 index 3981ec9..0000000 --- a/hy_qa_asset_backend/.gitignore +++ /dev/null @@ -1,18 +0,0 @@ -.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 deleted file mode 100644 index ccc809d..0000000 --- a/hy_qa_asset_backend/README.md +++ /dev/null @@ -1,172 +0,0 @@ -# 大本营答疑资产后台系统 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 deleted file mode 100644 index ff2f389..0000000 --- a/hy_qa_asset_backend/backend/Dockerfile +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index dd72e73..0000000 --- a/hy_qa_asset_backend/backend/app/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -"""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 deleted file mode 100644 index 2364578..0000000 --- a/hy_qa_asset_backend/backend/app/api/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -"""API routers.""" - diff --git a/hy_qa_asset_backend/backend/app/api/dashboard.py b/hy_qa_asset_backend/backend/app/api/dashboard.py deleted file mode 100644 index a5d3e82..0000000 --- a/hy_qa_asset_backend/backend/app/api/dashboard.py +++ /dev/null @@ -1,42 +0,0 @@ -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 deleted file mode 100644 index 4292eac..0000000 --- a/hy_qa_asset_backend/backend/app/api/feishu.py +++ /dev/null @@ -1,112 +0,0 @@ -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 deleted file mode 100644 index 2197954..0000000 --- a/hy_qa_asset_backend/backend/app/api/logs.py +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index ca64d37..0000000 --- a/hy_qa_asset_backend/backend/app/api/raw_qa.py +++ /dev/null @@ -1,150 +0,0 @@ -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 deleted file mode 100644 index 7ef3b58..0000000 --- a/hy_qa_asset_backend/backend/app/api/sessions.py +++ /dev/null @@ -1,72 +0,0 @@ -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 deleted file mode 100644 index 82d7fda..0000000 --- a/hy_qa_asset_backend/backend/app/api/settings.py +++ /dev/null @@ -1,51 +0,0 @@ -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 deleted file mode 100644 index 60c322e..0000000 --- a/hy_qa_asset_backend/backend/app/api/standard_qa.py +++ /dev/null @@ -1,146 +0,0 @@ -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 deleted file mode 100644 index 04e31ae..0000000 --- a/hy_qa_asset_backend/backend/app/api/tasks.py +++ /dev/null @@ -1,28 +0,0 @@ -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 deleted file mode 100644 index 5dfe843..0000000 --- a/hy_qa_asset_backend/backend/app/config.py +++ /dev/null @@ -1,61 +0,0 @@ -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 deleted file mode 100644 index b37aaf2..0000000 --- a/hy_qa_asset_backend/backend/app/database.py +++ /dev/null @@ -1,49 +0,0 @@ -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 deleted file mode 100644 index 6da0cb0..0000000 --- a/hy_qa_asset_backend/backend/app/main.py +++ /dev/null @@ -1,52 +0,0 @@ -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 deleted file mode 100644 index 9b0ad40..0000000 --- a/hy_qa_asset_backend/backend/app/models.py +++ /dev/null @@ -1,270 +0,0 @@ -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 deleted file mode 100644 index bb7c70d..0000000 --- a/hy_qa_asset_backend/backend/app/prompts/qa_cleaning_prompt.md +++ /dev/null @@ -1,147 +0,0 @@ -你是“大本营答疑问答资产库”的 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 deleted file mode 100644 index 691b812..0000000 --- a/hy_qa_asset_backend/backend/app/schemas.py +++ /dev/null @@ -1,255 +0,0 @@ -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 deleted file mode 100644 index 40ce131..0000000 --- a/hy_qa_asset_backend/backend/app/seed/sample_transcript_001.txt +++ /dev/null @@ -1,8 +0,0 @@ -[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 deleted file mode 100644 index b160397..0000000 --- a/hy_qa_asset_backend/backend/app/services/ai_cleaner.py +++ /dev/null @@ -1,49 +0,0 @@ -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 deleted file mode 100644 index d7dfc32..0000000 --- a/hy_qa_asset_backend/backend/app/services/feishu_client.py +++ /dev/null @@ -1,600 +0,0 @@ -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 deleted file mode 100644 index 2a0708f..0000000 --- a/hy_qa_asset_backend/backend/app/services/mock_data_service.py +++ /dev/null @@ -1,115 +0,0 @@ -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 deleted file mode 100644 index 38dbc4f..0000000 --- a/hy_qa_asset_backend/backend/app/services/qa_parser.py +++ /dev/null @@ -1,41 +0,0 @@ -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 deleted file mode 100644 index 784287c..0000000 --- a/hy_qa_asset_backend/backend/app/services/risk_detector.py +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index 7889abc..0000000 --- a/hy_qa_asset_backend/backend/app/services/scheduler.py +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index 42eddb3..0000000 --- a/hy_qa_asset_backend/backend/app/services/standard_qa_service.py +++ /dev/null @@ -1,237 +0,0 @@ -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 deleted file mode 100644 index 2736b7e..0000000 --- a/hy_qa_asset_backend/backend/app/services/task_runner.py +++ /dev/null @@ -1,124 +0,0 @@ -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 deleted file mode 100644 index 36938cc..0000000 --- a/hy_qa_asset_backend/backend/app/utils/json_validator.py +++ /dev/null @@ -1,43 +0,0 @@ -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 deleted file mode 100644 index 3ea9e08..0000000 --- a/hy_qa_asset_backend/backend/app/utils/text_reader.py +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 2d46592..0000000 --- a/hy_qa_asset_backend/backend/app/utils/time_utils.py +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index 3623687..0000000 --- a/hy_qa_asset_backend/backend/requirements.txt +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index 1dce4cd..0000000 --- a/hy_qa_asset_backend/docker-compose.yml +++ /dev/null @@ -1,48 +0,0 @@ -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/docs/README.md b/hy_qa_asset_backend/docs/README.md deleted file mode 100644 index 019be34..0000000 --- a/hy_qa_asset_backend/docs/README.md +++ /dev/null @@ -1,13 +0,0 @@ -# 项目文档索引 - -这个目录用于记录项目开发计划、产品决策、阶段范围和中间想法,方便后续开发、交接和复盘。 - -## 当前文档 - -- `h5-user-app-plan.md`:H5 用户端一期范围、已隐藏功能、后续智能体与登录规划。 - -## 记录原则 - -- 先记录“为什么这样做”,再记录“做了什么”。 -- 产品范围、技术边界、风险取舍要写清楚,避免后续只看代码猜意图。 -- 已暂缓的功能不要删除背景,标明暂缓原因和恢复条件。 diff --git a/hy_qa_asset_backend/docs/h5-user-app-plan.md b/hy_qa_asset_backend/docs/h5-user-app-plan.md deleted file mode 100644 index 85b01c7..0000000 --- a/hy_qa_asset_backend/docs/h5-user-app-plan.md +++ /dev/null @@ -1,110 +0,0 @@ -# H5 用户端开发计划与决策记录 - -更新时间:2026-07-05 - -## 当前目标 - -H5 用户端先作为“千问千答”用户入口,优先验证用户提问和系统回答这条主链路。第一期降低使用门槛,用户不登录也能进入页面并发起提问。 - -当前 H5 不是后台管理页,也不是知识库生产工具;它是未来小程序/用户端的交互雏形。 - -## 一期范围 - -一期只保留: - -- 免登录进入 H5。 -- 展示问答聊天界面。 -- 用户输入问题。 -- 前端调用后端已审核可调用库。 -- 展示答案、来源和边界提示。 -- 保留“新问答”按钮,方便用户重新开始。 -- 保留复制和答案反馈的前端交互骨架。 - -一期明确不展示: - -- 登录入口。 -- 微信授权入口。 -- 会话记录页。 -- “我的”个人中心。 -- 用户资料、手机号、微信昵称等身份信息。 - -## 当前隐藏决策 - -登录功能、微信授权、会话记录和个人中心此前已经做了部分前端预留,但一期先隐藏。 - -原因: - -- 一期先验证问答能力,不让登录流程阻断用户体验。 -- 后端还没有正式用户体系、会话历史接口和微信 code 换登录态接口。 -- 如果展示半成品登录/记录/我的,会让测试用户误以为这些能力已经可用。 -- 用户端要先把“能问、能答、答案可靠”打磨清楚。 - -恢复条件: - -- 后端有正式用户登录接口。 -- 后端支持微信授权登录或小程序登录。 -- 后端支持用户会话保存和历史查询。 -- 后端支持反馈入库。 - -## 当前接口绑定 - -H5 当前只适合直接绑定: - -```http -GET /api/standard-qa/callable -``` - -这个接口只返回: - -- 已审核标准问答。 -- 已标记可调用。 -- 低/中风险。 -- 已脱敏或无需脱敏。 - -H5 不应该直接使用后台审核、编辑、任务、飞书、日志类接口。 - -## 后续后端方向 - -中长期不建议 H5 直接拉取全部可调用库并在前端匹配。更合理的结构是后端新增智能体接口: - -```http -POST /api/h5/chat -``` - -后端智能体负责: - -- 接收用户问题。 -- 做风险初筛。 -- 从标准问答可调用库中检索相关知识。 -- 组装提示词。 -- 调用大模型 API。 -- 返回自然语言答案、来源、边界和命中状态。 -- 保存会话和反馈。 - -H5 只负责: - -- 输入问题。 -- 展示答案。 -- 展示来源和风险边界。 -- 收集反馈。 -- 管理用户交互状态。 - -## 后续建议开发顺序 - -1. 后端新增 `POST /api/h5/chat`。 -2. 后端新增智能体 prompt 和知识库检索模块。 -3. H5 从直接调用 `/standard-qa/callable` 改成调用 `/h5/chat`。 -4. 后端新增用户会话和消息表。 -5. H5 恢复会话历史页。 -6. 后端新增反馈入库接口。 -7. H5 恢复点赞、点踩、没解决等反馈入库。 -8. 后端新增微信登录或小程序登录。 -9. H5 恢复登录入口和“我的”页。 - -## 风险提醒 - -- 不要让 H5 使用未审核、不可调用、高风险或未脱敏内容。 -- 不要让前端长期承担检索和风控职责。 -- 大模型回答必须有明确边界,不能伪装成院长或老师原话。 -- 医疗、法律、心理诊断、极端风险问题必须有兜底策略。 -- 登录和历史记录上线前,要先确认隐私说明和数据保存规则。 diff --git a/hy_qa_asset_backend/frontend/Dockerfile b/hy_qa_asset_backend/frontend/Dockerfile deleted file mode 100644 index ce3153b..0000000 --- a/hy_qa_asset_backend/frontend/Dockerfile +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index 14527e7..0000000 --- a/hy_qa_asset_backend/frontend/app/callable-qa/page.tsx +++ /dev/null @@ -1,46 +0,0 @@ -"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 deleted file mode 100644 index fe89e31..0000000 --- a/hy_qa_asset_backend/frontend/app/dashboard/page.tsx +++ /dev/null @@ -1,93 +0,0 @@ -"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 deleted file mode 100644 index ab57819..0000000 --- a/hy_qa_asset_backend/frontend/app/globals.css +++ /dev/null @@ -1,26 +0,0 @@ -@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/h5/page.tsx b/hy_qa_asset_backend/frontend/app/h5/page.tsx deleted file mode 100644 index 042ee2b..0000000 --- a/hy_qa_asset_backend/frontend/app/h5/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import H5QAExperience from "@/components/h5/H5QAExperience"; - -export default function H5Page() { - return ; -} diff --git a/hy_qa_asset_backend/frontend/app/high-risk/page.tsx b/hy_qa_asset_backend/frontend/app/high-risk/page.tsx deleted file mode 100644 index b7cd736..0000000 --- a/hy_qa_asset_backend/frontend/app/high-risk/page.tsx +++ /dev/null @@ -1,67 +0,0 @@ -"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 deleted file mode 100644 index 81b08bb..0000000 --- a/hy_qa_asset_backend/frontend/app/layout.tsx +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index 46effd5..0000000 --- a/hy_qa_asset_backend/frontend/app/logs/page.tsx +++ /dev/null @@ -1,89 +0,0 @@ -"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 deleted file mode 100644 index eb68262..0000000 --- a/hy_qa_asset_backend/frontend/app/page.tsx +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 37791ed..0000000 --- a/hy_qa_asset_backend/frontend/app/review/page.tsx +++ /dev/null @@ -1,36 +0,0 @@ -"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 deleted file mode 100644 index dc3fa97..0000000 --- a/hy_qa_asset_backend/frontend/app/sessions/[id]/page.tsx +++ /dev/null @@ -1,73 +0,0 @@ -"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 deleted file mode 100644 index 025ec91..0000000 --- a/hy_qa_asset_backend/frontend/app/sessions/page.tsx +++ /dev/null @@ -1,84 +0,0 @@ -"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 ( -
-
-

答疑场次

-
- -
-
- - -
- -
-
-