Remove legacy hy_qa_asset_backend directory
This commit is contained in:
@@ -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/<token>, 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
|
|
||||||
18
hy_qa_asset_backend/.gitignore
vendored
18
hy_qa_asset_backend/.gitignore
vendored
@@ -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
|
|
||||||
@@ -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/<token>`,可以填写 `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、测试用例和生产部署配置。
|
|
||||||
@@ -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"]
|
|
||||||
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
"""Backend package for 大本营答疑资产后台系统 MVP."""
|
|
||||||
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
"""API routers."""
|
|
||||||
|
|
||||||
@@ -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,
|
|
||||||
)
|
|
||||||
|
|
||||||
@@ -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 或运行环境变量。",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
@@ -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()
|
|
||||||
|
|
||||||
@@ -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
|
|
||||||
|
|
||||||
@@ -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
|
|
||||||
|
|
||||||
@@ -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)
|
|
||||||
|
|
||||||
@@ -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
|
|
||||||
|
|
||||||
@@ -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)
|
|
||||||
|
|
||||||
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
|
|
||||||
@@ -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"}
|
|
||||||
@@ -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)
|
|
||||||
@@ -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 只能辅助清洗、拆解、打标签、风险初筛,不能替代人工审核。未审核内容不能进入标准问答库,未审核内容不能被未来智能体调用。高风险内容必须人工复审。涉及学员隐私必须标记脱敏。不做心理诊断、医疗建议、法律判断,也不承诺课程效果。
|
|
||||||
|
|
||||||
@@ -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]
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
[00:03:12] 学员:我和孩子沟通时总是忍不住发火,孩子现在一听我说话就躲开,我该怎么办?
|
|
||||||
[00:04:20] 辅导老师:先不要急着讲道理,先看见自己发火背后的着急和无力。可以把要求先放低一点,用一句具体的话表达当下感受,比如我现在有点急,我想先停一下。
|
|
||||||
|
|
||||||
[00:16:44] 学员:我学习大本营后知道自己总是拖延,怎么开始一个小行动?
|
|
||||||
[00:17:30] 院长:不要一开始就设很大的目标。先选一个今天能完成的动作,比如写三句话、整理十分钟。关键是让身体先体验到我可以开始。
|
|
||||||
|
|
||||||
[00:27:05] 学员:我朋友说自己有抑郁症,我能不能用课程里的方法帮她判断严重程度?
|
|
||||||
[00:28:02] 辅导老师:这类情况不要做诊断,也不要替对方判断严重程度。你可以表达关心,建议对方寻求专业医生或心理专业人员帮助。
|
|
||||||
@@ -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)]
|
|
||||||
|
|
||||||
@@ -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
|
|
||||||
@@ -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": "高风险边界题,不建议自动进入标准问答库。",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
@@ -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
|
|
||||||
|
|
||||||
@@ -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
|
|
||||||
|
|
||||||
@@ -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))
|
|
||||||
|
|
||||||
@@ -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
|
|
||||||
@@ -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)
|
|
||||||
@@ -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
|
|
||||||
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
def read_text_file(path: str | Path) -> str:
|
|
||||||
return Path(path).read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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
|
|
||||||
@@ -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:
|
|
||||||
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
# 项目文档索引
|
|
||||||
|
|
||||||
这个目录用于记录项目开发计划、产品决策、阶段范围和中间想法,方便后续开发、交接和复盘。
|
|
||||||
|
|
||||||
## 当前文档
|
|
||||||
|
|
||||||
- `h5-user-app-plan.md`:H5 用户端一期范围、已隐藏功能、后续智能体与登录规划。
|
|
||||||
|
|
||||||
## 记录原则
|
|
||||||
|
|
||||||
- 先记录“为什么这样做”,再记录“做了什么”。
|
|
||||||
- 产品范围、技术边界、风险取舍要写清楚,避免后续只看代码猜意图。
|
|
||||||
- 已暂缓的功能不要删除背景,标明暂缓原因和恢复条件。
|
|
||||||
@@ -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 使用未审核、不可调用、高风险或未脱敏内容。
|
|
||||||
- 不要让前端长期承担检索和风控职责。
|
|
||||||
- 大模型回答必须有明确边界,不能伪装成院长或老师原话。
|
|
||||||
- 医疗、法律、心理诊断、极端风险问题必须有兜底策略。
|
|
||||||
- 登录和历史记录上线前,要先确认隐私说明和数据保存规则。
|
|
||||||
@@ -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"]
|
|
||||||
|
|
||||||
@@ -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<CallableQAItem[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
api.get<CallableQAItem[]>("/standard-qa/callable").then(setItems);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold text-ink">智能体可调用库</h1>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-4">
|
|
||||||
{items.map((item) => (
|
|
||||||
<article key={item.id} className="rounded-md border border-line bg-white p-5">
|
|
||||||
<div className="mb-2 flex flex-wrap items-center gap-2 text-xs text-slate-500">
|
|
||||||
<span>{item.standard_code}</span>
|
|
||||||
<span>{item.primary_topic}</span>
|
|
||||||
<span>{item.course_stage}</span>
|
|
||||||
</div>
|
|
||||||
<h2 className="text-base font-semibold text-ink">{item.standard_question}</h2>
|
|
||||||
<p className="mt-3 whitespace-pre-wrap text-sm leading-6 text-slate-700">{item.standard_answer}</p>
|
|
||||||
<div className="mt-4 flex flex-wrap gap-2 text-xs">
|
|
||||||
{item.problem_tags.map((tag) => (
|
|
||||||
<span key={tag} className="rounded-md border border-line bg-panel px-2 py-1">
|
|
||||||
{tag}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{item.answer_boundary ? <p className="mt-4 text-sm text-amber-800">{item.answer_boundary}</p> : null}
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
{items.length === 0 ? (
|
|
||||||
<div className="rounded-md border border-line bg-white p-8 text-center text-sm text-slate-500">暂无可调用问答</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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<DashboardSummary | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [running, setRunning] = useState(false);
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
setLoading(true);
|
|
||||||
const data = await api.get<DashboardSummary>("/dashboard/summary");
|
|
||||||
setSummary(data);
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runTask() {
|
|
||||||
setRunning(true);
|
|
||||||
await api.post<TaskRun>("/tasks/run-now", {});
|
|
||||||
await load();
|
|
||||||
setRunning(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
load().catch(() => setLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (loading || !summary) {
|
|
||||||
return <div className="text-sm text-slate-500">加载中...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const actions = [
|
|
||||||
{ href: "/review", label: "去审核" },
|
|
||||||
{ href: "/high-risk", label: "查看高风险" },
|
|
||||||
{ href: "/standard-qa", label: "查看标准问答库" },
|
|
||||||
{ href: "/logs", label: "查看运行日志" }
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold text-ink">仪表盘</h1>
|
|
||||||
<p className="mt-1 text-sm text-slate-500">最近任务:{summary.last_task_time ? formatDateTime(summary.last_task_time) : "-"}</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={runTask}
|
|
||||||
disabled={running}
|
|
||||||
className="inline-flex h-10 items-center gap-2 rounded-md bg-jade px-4 text-sm font-medium text-white hover:bg-teal-800 disabled:opacity-60"
|
|
||||||
>
|
|
||||||
<Play className="h-4 w-4" aria-hidden />
|
|
||||||
{running ? "处理中" : "手动触发处理任务"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
|
||||||
<StatCard title="昨晚处理答疑场次数" value={summary.processed_sessions} icon={<Timer className="h-5 w-5" />} />
|
|
||||||
<StatCard title="已拆解问答总数" value={summary.pending_review_count + summary.approved_qa_count} icon={<Rows3 className="h-5 w-5" />} tone="indigo" />
|
|
||||||
<StatCard title="待审核问答数" value={summary.pending_review_count} icon={<FileQuestion className="h-5 w-5" />} tone="amber" />
|
|
||||||
<StatCard title="高风险问答数" value={summary.high_risk_count} icon={<AlertTriangle className="h-5 w-5" />} tone="rose" />
|
|
||||||
<StatCard title="已审核问答数" value={summary.approved_qa_count} icon={<CheckCircle2 className="h-5 w-5" />} />
|
|
||||||
<StatCard title="标准问答数" value={summary.standard_qa_count} icon={<Library className="h-5 w-5" />} tone="indigo" />
|
|
||||||
<StatCard title="可被智能体调用问答数" value={summary.callable_qa_count} icon={<Database className="h-5 w-5" />} />
|
|
||||||
<div className="rounded-md border border-line bg-white p-4">
|
|
||||||
<div className="text-sm text-slate-500">最近一次任务状态</div>
|
|
||||||
<div className="mt-3">
|
|
||||||
{summary.last_task_status ? <StatusBadge status={summary.last_task_status} /> : <span className="text-sm text-slate-400">暂无任务</span>}
|
|
||||||
</div>
|
|
||||||
{summary.last_task_error ? <p className="mt-3 text-sm text-rose-700">{summary.last_task_error}</p> : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{actions.map((action) => (
|
|
||||||
<Link
|
|
||||||
key={action.href}
|
|
||||||
href={action.href}
|
|
||||||
className="inline-flex h-10 items-center rounded-md border border-line bg-white px-4 text-sm font-medium text-ink hover:bg-slate-50"
|
|
||||||
>
|
|
||||||
{action.label}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import H5QAExperience from "@/components/h5/H5QAExperience";
|
|
||||||
|
|
||||||
export default function H5Page() {
|
|
||||||
return <H5QAExperience />;
|
|
||||||
}
|
|
||||||
@@ -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<RawQAItem[]>([]);
|
|
||||||
const [filter, setFilter] = useState("全部");
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
const [high, forbidden] = await Promise.all([
|
|
||||||
api.get<RawQAItem[]>("/raw-qa?risk_level=high"),
|
|
||||||
api.get<RawQAItem[]>("/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 (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<h1 className="text-2xl font-semibold text-ink">高风险问答</h1>
|
|
||||||
<select
|
|
||||||
value={filter}
|
|
||||||
onChange={(event) => setFilter(event.target.value)}
|
|
||||||
className="h-10 rounded-md border border-line bg-white px-3 text-sm outline-none focus:border-jade"
|
|
||||||
>
|
|
||||||
{riskFilters.map((item) => (
|
|
||||||
<option key={item}>{item}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{visible.map((item) => (
|
|
||||||
<QAReviewCard key={item.id} item={item} onChanged={load} />
|
|
||||||
))}
|
|
||||||
{visible.length === 0 ? (
|
|
||||||
<div className="rounded-md border border-line bg-white p-8 text-center text-sm text-slate-500">暂无高风险问答</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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 (
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<body>
|
|
||||||
<Layout>{children}</Layout>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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<TaskRun[]>([]);
|
|
||||||
const [audit, setAudit] = useState<AuditLog[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
Promise.all([api.get<TaskRun[]>("/logs/tasks"), api.get<AuditLog[]>("/logs/audit")]).then(([taskRows, auditRows]) => {
|
|
||||||
setTasks(taskRows);
|
|
||||||
setAudit(auditRows);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<h1 className="text-2xl font-semibold text-ink">运行日志</h1>
|
|
||||||
|
|
||||||
<div className="table-wrap rounded-md border border-line bg-white">
|
|
||||||
<table className="min-w-[980px] w-full border-collapse text-left text-sm">
|
|
||||||
<thead className="bg-panel text-xs uppercase text-slate-500">
|
|
||||||
<tr>
|
|
||||||
<th className="px-4 py-3">任务 ID</th>
|
|
||||||
<th className="px-4 py-3">开始时间</th>
|
|
||||||
<th className="px-4 py-3">结束时间</th>
|
|
||||||
<th className="px-4 py-3">状态</th>
|
|
||||||
<th className="px-4 py-3">场次</th>
|
|
||||||
<th className="px-4 py-3">问答</th>
|
|
||||||
<th className="px-4 py-3">失败</th>
|
|
||||||
<th className="px-4 py-3">错误</th>
|
|
||||||
<th className="px-4 py-3">重试</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{tasks.map((task) => (
|
|
||||||
<tr key={task.id} className="border-t border-line">
|
|
||||||
<td className="px-4 py-3">{task.id}</td>
|
|
||||||
<td className="px-4 py-3">{formatDateTime(task.started_at)}</td>
|
|
||||||
<td className="px-4 py-3">{formatDateTime(task.ended_at)}</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<StatusBadge status={task.task_status} />
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">{task.sessions_processed}/{task.sessions_scanned}</td>
|
|
||||||
<td className="px-4 py-3">{task.qa_created}</td>
|
|
||||||
<td className="px-4 py-3">{task.qa_failed}</td>
|
|
||||||
<td className="px-4 py-3 text-rose-700">{task.error_message ?? "-"}</td>
|
|
||||||
<td className="px-4 py-3">{task.retry_count}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="table-wrap rounded-md border border-line bg-white">
|
|
||||||
<table className="min-w-[900px] w-full border-collapse text-left text-sm">
|
|
||||||
<thead className="bg-panel text-xs uppercase text-slate-500">
|
|
||||||
<tr>
|
|
||||||
<th className="px-4 py-3">日志 ID</th>
|
|
||||||
<th className="px-4 py-3">对象</th>
|
|
||||||
<th className="px-4 py-3">动作</th>
|
|
||||||
<th className="px-4 py-3">前状态</th>
|
|
||||||
<th className="px-4 py-3">后状态</th>
|
|
||||||
<th className="px-4 py-3">备注</th>
|
|
||||||
<th className="px-4 py-3">时间</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{audit.map((log) => (
|
|
||||||
<tr key={log.id} className="border-t border-line">
|
|
||||||
<td className="px-4 py-3">{log.id}</td>
|
|
||||||
<td className="px-4 py-3">{log.target_type} #{log.target_id}</td>
|
|
||||||
<td className="px-4 py-3">{log.action}</td>
|
|
||||||
<td className="px-4 py-3">{log.before_status ?? "-"}</td>
|
|
||||||
<td className="px-4 py-3">{log.after_status ?? "-"}</td>
|
|
||||||
<td className="px-4 py-3">{log.comment ?? "-"}</td>
|
|
||||||
<td className="px-4 py-3">{formatDateTime(log.created_at)}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { redirect } from "next/navigation";
|
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
redirect("/dashboard");
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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<RawQAItem[]>([]);
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
const data = await api.get<RawQAItem[]>("/raw-qa?review_status=pending");
|
|
||||||
setItems(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
load();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold text-ink">待审核问答</h1>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{items.map((item) => (
|
|
||||||
<QAReviewCard key={item.id} item={item} onChanged={load} />
|
|
||||||
))}
|
|
||||||
{items.length === 0 ? (
|
|
||||||
<div className="rounded-md border border-line bg-white p-8 text-center text-sm text-slate-500">暂无待审核问答</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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<FeishuSession | null>(null);
|
|
||||||
const [items, setItems] = useState<RawQAItem[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function load() {
|
|
||||||
const id = Number(params.id);
|
|
||||||
const [sessionData, rawData] = await Promise.all([
|
|
||||||
api.get<FeishuSession>(`/sessions/${id}`),
|
|
||||||
api.get<RawQAItem[]>(`/raw-qa?session_id=${id}`)
|
|
||||||
]);
|
|
||||||
setSession(sessionData);
|
|
||||||
setItems(rawData);
|
|
||||||
}
|
|
||||||
load();
|
|
||||||
}, [params.id]);
|
|
||||||
|
|
||||||
if (!session) return <div className="text-sm text-slate-500">加载中...</div>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold text-ink">{session.title}</h1>
|
|
||||||
<p className="mt-1 text-sm text-slate-500">
|
|
||||||
{session.session_code} / {session.date ?? "-"} / {session.teachers ?? "-"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<StatusBadge status={session.process_status} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-md border border-line bg-white p-4">
|
|
||||||
<div className="mb-3 text-sm font-semibold text-ink">原始资料</div>
|
|
||||||
<pre className="max-h-96 overflow-auto whitespace-pre-wrap rounded-md bg-panel p-4 text-sm leading-6 text-slate-700">
|
|
||||||
{session.source_text || "暂无原始资料"}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
{items.map((item) => (
|
|
||||||
<div key={item.id} className="rounded-md border border-line bg-white p-4">
|
|
||||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
|
||||||
<span className="text-sm font-semibold">{item.qa_code}</span>
|
|
||||||
<RiskBadge level={item.risk_level} />
|
|
||||||
<StatusBadge status={item.review_status} />
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-4 lg:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-slate-500">原始问题</div>
|
|
||||||
<div className="mt-1 text-sm leading-6">{item.raw_question}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-slate-500">原始回答</div>
|
|
||||||
<div className="mt-1 text-sm leading-6">{item.raw_answer}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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<FeishuSession[]>([]);
|
|
||||||
const [title, setTitle] = useState("大本营周三答疑测试场次");
|
|
||||||
const [teachers, setTeachers] = useState("院长, 辅导老师");
|
|
||||||
const [sourceText, setSourceText] = useState("");
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
const data = await api.get<FeishuSession[]>("/sessions");
|
|
||||||
setSessions(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createSession() {
|
|
||||||
await api.post<FeishuSession>("/sessions", {
|
|
||||||
title,
|
|
||||||
teachers,
|
|
||||||
source_text: sourceText || undefined,
|
|
||||||
source_file_name: sourceText ? "manual_transcript.txt" : undefined
|
|
||||||
});
|
|
||||||
setSourceText("");
|
|
||||||
await load();
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
load();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold text-ink">答疑场次</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-md border border-line bg-white p-4">
|
|
||||||
<div className="grid gap-3 md:grid-cols-3">
|
|
||||||
<label className="text-sm">
|
|
||||||
<span className="mb-1 block text-slate-500">标题</span>
|
|
||||||
<input
|
|
||||||
value={title}
|
|
||||||
onChange={(event) => setTitle(event.target.value)}
|
|
||||||
className="h-10 w-full rounded-md border border-line px-3 outline-none focus:border-jade"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="text-sm">
|
|
||||||
<span className="mb-1 block text-slate-500">老师</span>
|
|
||||||
<input
|
|
||||||
value={teachers}
|
|
||||||
onChange={(event) => setTeachers(event.target.value)}
|
|
||||||
className="h-10 w-full rounded-md border border-line px-3 outline-none focus:border-jade"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<div className="flex items-end">
|
|
||||||
<button
|
|
||||||
onClick={createSession}
|
|
||||||
className="inline-flex h-10 items-center gap-2 rounded-md bg-jade px-4 text-sm font-medium text-white hover:bg-teal-800"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" aria-hidden />
|
|
||||||
创建测试场次
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<label className="mt-3 block text-sm">
|
|
||||||
<span className="mb-1 block text-slate-500">转写稿</span>
|
|
||||||
<textarea
|
|
||||||
value={sourceText}
|
|
||||||
onChange={(event) => setSourceText(event.target.value)}
|
|
||||||
rows={5}
|
|
||||||
className="w-full rounded-md border border-line px-3 py-2 outline-none focus:border-jade"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SessionTable sessions={sessions} onChanged={load} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { Save, RefreshCw } from "lucide-react";
|
|
||||||
import { api } from "@/lib/api";
|
|
||||||
import type { FeishuSetupGuide, FeishuStatus, SafeSettings } from "@/lib/types";
|
|
||||||
import StatusBadge from "@/components/StatusBadge";
|
|
||||||
|
|
||||||
export default function SettingsPage() {
|
|
||||||
const [settings, setSettings] = useState<SafeSettings | null>(null);
|
|
||||||
const [feishuStatus, setFeishuStatus] = useState<FeishuStatus | null>(null);
|
|
||||||
const [feishuGuide, setFeishuGuide] = useState<FeishuSetupGuide | null>(null);
|
|
||||||
const [keyName, setKeyName] = useState("");
|
|
||||||
const [value, setValue] = useState("");
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
const [settingsData, statusData, guideData] = await Promise.all([
|
|
||||||
api.get<SafeSettings>("/settings"),
|
|
||||||
api.get<FeishuStatus>("/feishu/status"),
|
|
||||||
api.get<FeishuSetupGuide>("/feishu/setup-guide")
|
|
||||||
]);
|
|
||||||
setSettings(settingsData);
|
|
||||||
setFeishuStatus(statusData);
|
|
||||||
setFeishuGuide(guideData);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveSetting() {
|
|
||||||
if (!keyName) return;
|
|
||||||
await api.patch<SafeSettings>("/settings", { settings: { [keyName]: value } });
|
|
||||||
setKeyName("");
|
|
||||||
setValue("");
|
|
||||||
await load();
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
load();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!settings || !feishuStatus || !feishuGuide) {
|
|
||||||
return <div className="text-sm text-slate-500">加载中...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<h1 className="text-2xl font-semibold text-ink">系统设置</h1>
|
|
||||||
<button
|
|
||||||
onClick={load}
|
|
||||||
className="inline-flex h-10 items-center gap-2 rounded-md border border-line bg-white px-4 text-sm font-medium text-ink hover:bg-slate-50"
|
|
||||||
>
|
|
||||||
<RefreshCw className="h-4 w-4" aria-hidden />
|
|
||||||
刷新连接状态
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
|
||||||
<div className="rounded-md border border-line bg-white p-4">
|
|
||||||
<div className="text-sm text-slate-500">飞书配置</div>
|
|
||||||
<div className="mt-3">
|
|
||||||
<StatusBadge status={settings.feishu_configured ? "approved" : "pending"} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-md border border-line bg-white p-4">
|
|
||||||
<div className="text-sm text-slate-500">飞书 token</div>
|
|
||||||
<div className="mt-3">
|
|
||||||
<StatusBadge status={feishuStatus.token_ok ? "approved" : "pending"} />
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-sm text-slate-600">{feishuStatus.message}</p>
|
|
||||||
<p className="mt-1 text-xs text-slate-500">
|
|
||||||
{feishuStatus.app_token_configured ? "Bitable app_token 模式" : "Wiki 节点 token 模式"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-md border border-line bg-white p-4">
|
|
||||||
<div className="text-sm text-slate-500">大模型配置</div>
|
|
||||||
<div className="mt-3">
|
|
||||||
<StatusBadge status={settings.openai_configured ? "approved" : "pending"} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-md border border-line bg-white p-4">
|
|
||||||
<div className="text-sm text-slate-500">当前模型</div>
|
|
||||||
<div className="mt-2 text-lg font-semibold">{settings.model_name}</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-md border border-line bg-white p-4">
|
|
||||||
<div className="text-sm text-slate-500">定时任务</div>
|
|
||||||
<div className="mt-2 text-lg font-semibold">{settings.schedule_cron}</div>
|
|
||||||
<div className="mt-1 text-sm text-slate-500">{settings.schedule_timezone}</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-md border border-line bg-white p-4">
|
|
||||||
<div className="text-sm text-slate-500">mock 模式</div>
|
|
||||||
<div className="mt-3">
|
|
||||||
<StatusBadge status={settings.mock_mode ? "running" : "completed"} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-md border border-line bg-white p-4">
|
|
||||||
<div className="mb-3 text-sm font-semibold text-ink">飞书字段模板</div>
|
|
||||||
<div className="grid gap-4 xl:grid-cols-3">
|
|
||||||
{feishuGuide.tables.map((table) => (
|
|
||||||
<div key={table.table_key} className="rounded-md border border-line p-3">
|
|
||||||
<div className="font-medium text-ink">{table.table_name}</div>
|
|
||||||
<div className="mt-1 text-xs text-slate-500">{table.env_name}</div>
|
|
||||||
<div className="mt-3 flex flex-wrap gap-1">
|
|
||||||
{table.required_fields.map((field) => (
|
|
||||||
<span key={field} className="rounded-md bg-panel px-2 py-1 text-xs text-slate-700">
|
|
||||||
{field}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 text-sm text-slate-600">
|
|
||||||
{feishuGuide.notes.map((note) => (
|
|
||||||
<p key={note}>{note}</p>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-md border border-line bg-white p-4">
|
|
||||||
<div className="grid gap-3 md:grid-cols-[1fr_1fr_auto]">
|
|
||||||
<input
|
|
||||||
value={keyName}
|
|
||||||
onChange={(event) => setKeyName(event.target.value)}
|
|
||||||
placeholder="非敏感配置 key"
|
|
||||||
className="h-10 rounded-md border border-line px-3 outline-none focus:border-jade"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
value={value}
|
|
||||||
onChange={(event) => setValue(event.target.value)}
|
|
||||||
placeholder="value"
|
|
||||||
className="h-10 rounded-md border border-line px-3 outline-none focus:border-jade"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={saveSetting}
|
|
||||||
className="inline-flex h-10 items-center justify-center gap-2 rounded-md bg-jade px-4 text-sm font-medium text-white hover:bg-teal-800"
|
|
||||||
>
|
|
||||||
<Save className="h-4 w-4" aria-hidden />
|
|
||||||
保存
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="table-wrap rounded-md border border-line bg-white">
|
|
||||||
<table className="w-full min-w-[760px] border-collapse text-left text-sm">
|
|
||||||
<thead className="bg-panel text-xs uppercase text-slate-500">
|
|
||||||
<tr>
|
|
||||||
<th className="px-4 py-3">key</th>
|
|
||||||
<th className="px-4 py-3">value</th>
|
|
||||||
<th className="px-4 py-3">说明</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{settings.system_settings.map((row) => (
|
|
||||||
<tr key={row.key} className="border-t border-line">
|
|
||||||
<td className="px-4 py-3 font-medium">{row.key}</td>
|
|
||||||
<td className="px-4 py-3">{row.value ?? "-"}</td>
|
|
||||||
<td className="px-4 py-3">{row.description ?? "-"}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { api } from "@/lib/api";
|
|
||||||
import type { StandardQAItem } from "@/lib/types";
|
|
||||||
import StandardQATable from "@/components/StandardQATable";
|
|
||||||
|
|
||||||
export default function StandardQAPage() {
|
|
||||||
const [items, setItems] = useState<StandardQAItem[]>([]);
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
const data = await api.get<StandardQAItem[]>("/standard-qa");
|
|
||||||
setItems(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
load();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold text-ink">标准问答库</h1>
|
|
||||||
</div>
|
|
||||||
<StandardQATable items={items} onChanged={load} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { ReactNode } from "react";
|
|
||||||
import { usePathname } from "next/navigation";
|
|
||||||
import Sidebar from "./Sidebar";
|
|
||||||
|
|
||||||
export default function Layout({ children }: { children: ReactNode }) {
|
|
||||||
const pathname = usePathname();
|
|
||||||
|
|
||||||
if (pathname.startsWith("/h5")) {
|
|
||||||
return <div className="min-h-screen bg-[#eef3f1]">{children}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-[#f5f7fa]">
|
|
||||||
<Sidebar />
|
|
||||||
<main className="min-h-screen px-4 py-5 md:ml-64 md:px-8 md:py-7">{children}</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Check, EyeOff, FileX, Pencil, ShieldAlert, Sparkles, X } from "lucide-react";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { api } from "@/lib/api";
|
|
||||||
import type { RawQAItem } from "@/lib/types";
|
|
||||||
import RiskBadge from "./RiskBadge";
|
|
||||||
import StatusBadge from "./StatusBadge";
|
|
||||||
|
|
||||||
function Field({ label, children }: { label: string; children: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="mb-1 text-xs font-medium text-slate-500">{label}</div>
|
|
||||||
<div className="whitespace-pre-wrap text-sm leading-6 text-ink">{children || "-"}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function QAReviewCard({ item, onChanged }: { item: RawQAItem; onChanged: () => void }) {
|
|
||||||
async function action(path: string) {
|
|
||||||
await api.post(`/raw-qa/${item.id}/${path}`, {});
|
|
||||||
onChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function editAndApprove() {
|
|
||||||
const standardQuestion = window.prompt("标准问题", item.suggested_standard_question ?? item.normalized_question ?? "");
|
|
||||||
if (standardQuestion === null) return;
|
|
||||||
const standardAnswer = window.prompt("标准回答", item.suggested_standard_answer ?? item.normalized_answer ?? "");
|
|
||||||
if (standardAnswer === null) return;
|
|
||||||
await api.patch(`/raw-qa/${item.id}`, {
|
|
||||||
suggested_standard_question: standardQuestion,
|
|
||||||
suggested_standard_answer: standardAnswer
|
|
||||||
});
|
|
||||||
await action("approve");
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<article className="rounded-md border border-line bg-white p-5">
|
|
||||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<span className="text-sm font-semibold text-ink">{item.qa_code}</span>
|
|
||||||
<RiskBadge level={item.risk_level} />
|
|
||||||
<StatusBadge status={item.review_status} />
|
|
||||||
{item.need_desensitization ? (
|
|
||||||
<span className="rounded-md border border-amber-200 bg-amber-50 px-2 py-1 text-xs font-medium text-amber-700">
|
|
||||||
需脱敏
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-slate-500">场次 #{item.session_id}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-5 lg:grid-cols-2">
|
|
||||||
<Field label="原始问题">{item.raw_question}</Field>
|
|
||||||
<Field label="问题整理版">{item.normalized_question}</Field>
|
|
||||||
<Field label="原始回答">{item.raw_answer}</Field>
|
|
||||||
<Field label="回答整理版">{item.normalized_answer}</Field>
|
|
||||||
<Field label="AI 建议标准问题">{item.suggested_standard_question}</Field>
|
|
||||||
<Field label="AI 建议标准回答">{item.suggested_standard_answer}</Field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-5 grid gap-3 border-t border-line pt-4 text-sm md:grid-cols-2 xl:grid-cols-4">
|
|
||||||
<div>
|
|
||||||
<span className="text-slate-500">主题:</span>
|
|
||||||
{item.primary_topic}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-slate-500">阶段:</span>
|
|
||||||
{item.course_stage}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-slate-500">标签:</span>
|
|
||||||
{item.problem_tags.join("、") || "-"}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-slate-500">来源时间:</span>
|
|
||||||
{item.source_timestamp || "-"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900">
|
|
||||||
{item.risk_notes || "无风险说明"}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-5 flex flex-wrap gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => action("approve")}
|
|
||||||
className="inline-flex h-9 items-center gap-2 rounded-md bg-jade px-3 text-sm font-medium text-white hover:bg-teal-800"
|
|
||||||
>
|
|
||||||
<Check className="h-4 w-4" aria-hidden />
|
|
||||||
通过入库
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={editAndApprove}
|
|
||||||
className="inline-flex h-9 items-center gap-2 rounded-md border border-line bg-white px-3 text-sm font-medium text-ink hover:bg-slate-50"
|
|
||||||
>
|
|
||||||
<Pencil className="h-4 w-4" aria-hidden />
|
|
||||||
修改后通过
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => action("revise")}
|
|
||||||
className="inline-flex h-9 items-center gap-2 rounded-md border border-line bg-white px-3 text-sm font-medium text-ink hover:bg-slate-50"
|
|
||||||
>
|
|
||||||
<Sparkles className="h-4 w-4" aria-hidden />
|
|
||||||
需修改
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => action("reject")}
|
|
||||||
className="inline-flex h-9 items-center gap-2 rounded-md border border-line bg-white px-3 text-sm font-medium text-ink hover:bg-slate-50"
|
|
||||||
>
|
|
||||||
<FileX className="h-4 w-4" aria-hidden />
|
|
||||||
不入库
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => action("forbid")}
|
|
||||||
className="inline-flex h-9 items-center gap-2 rounded-md border border-rose-200 bg-white px-3 text-sm font-medium text-rose-700 hover:bg-rose-50"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" aria-hidden />
|
|
||||||
禁止使用
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => action("mark-high-risk")}
|
|
||||||
className="inline-flex h-9 items-center gap-2 rounded-md border border-orange-200 bg-white px-3 text-sm font-medium text-orange-700 hover:bg-orange-50"
|
|
||||||
>
|
|
||||||
<ShieldAlert className="h-4 w-4" aria-hidden />
|
|
||||||
标记高风险
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => action("mark-desensitization")}
|
|
||||||
className="inline-flex h-9 items-center gap-2 rounded-md border border-amber-200 bg-white px-3 text-sm font-medium text-amber-700 hover:bg-amber-50"
|
|
||||||
>
|
|
||||||
<EyeOff className="h-4 w-4" aria-hidden />
|
|
||||||
标记需脱敏
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
export default function RiskBadge({ level }: { level: string }) {
|
|
||||||
const styles: Record<string, string> = {
|
|
||||||
low: "border-emerald-200 bg-emerald-50 text-emerald-700",
|
|
||||||
medium: "border-amber-200 bg-amber-50 text-amber-700",
|
|
||||||
high: "border-orange-200 bg-orange-50 text-orange-700",
|
|
||||||
forbidden: "border-rose-200 bg-rose-50 text-rose-700"
|
|
||||||
};
|
|
||||||
const labels: Record<string, string> = {
|
|
||||||
low: "低风险",
|
|
||||||
medium: "中风险",
|
|
||||||
high: "高风险",
|
|
||||||
forbidden: "禁止"
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<span className={`inline-flex rounded-md border px-2 py-1 text-xs font-medium ${styles[level] ?? styles.medium}`}>
|
|
||||||
{labels[level] ?? level}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { FileText, Play, RotateCw } from "lucide-react";
|
|
||||||
import { api, formatDateTime } from "@/lib/api";
|
|
||||||
import type { FeishuSession, TaskRun } from "@/lib/types";
|
|
||||||
import StatusBadge from "./StatusBadge";
|
|
||||||
|
|
||||||
export default function SessionTable({ sessions, onChanged }: { sessions: FeishuSession[]; onChanged: () => void }) {
|
|
||||||
async function run(id: number, reprocess = false) {
|
|
||||||
await api.post<TaskRun>(`/sessions/${id}/${reprocess ? "reprocess" : "process"}`, {});
|
|
||||||
onChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="table-wrap rounded-md border border-line bg-white">
|
|
||||||
<table className="min-w-[1100px] w-full border-collapse text-left text-sm">
|
|
||||||
<thead className="bg-panel text-xs uppercase text-slate-500">
|
|
||||||
<tr>
|
|
||||||
<th className="px-4 py-3">日期</th>
|
|
||||||
<th className="px-4 py-3">标题</th>
|
|
||||||
<th className="px-4 py-3">老师</th>
|
|
||||||
<th className="px-4 py-3">飞书资料</th>
|
|
||||||
<th className="px-4 py-3">状态</th>
|
|
||||||
<th className="px-4 py-3">问答</th>
|
|
||||||
<th className="px-4 py-3">待审</th>
|
|
||||||
<th className="px-4 py-3">入库</th>
|
|
||||||
<th className="px-4 py-3">失败原因</th>
|
|
||||||
<th className="px-4 py-3">操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{sessions.map((session) => (
|
|
||||||
<tr key={session.id} className="border-t border-line align-top">
|
|
||||||
<td className="px-4 py-3">{session.date ?? "-"}</td>
|
|
||||||
<td className="px-4 py-3 font-medium text-ink">{session.title}</td>
|
|
||||||
<td className="px-4 py-3">{session.teachers ?? "-"}</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
{session.feishu_doc_url ? (
|
|
||||||
<span className="text-jade">{session.feishu_doc_url}</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-slate-400">-</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<StatusBadge status={session.process_status} />
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">{session.qa_count}</td>
|
|
||||||
<td className="px-4 py-3">{session.pending_review_count}</td>
|
|
||||||
<td className="px-4 py-3">{session.standard_qa_count}</td>
|
|
||||||
<td className="max-w-xs px-4 py-3 text-rose-700">{session.failed_reason ?? "-"}</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<Link
|
|
||||||
href={`/sessions/${session.id}`}
|
|
||||||
className="inline-flex h-8 items-center gap-1 rounded-md border border-line px-2 text-xs hover:bg-slate-50"
|
|
||||||
>
|
|
||||||
<FileText className="h-3.5 w-3.5" aria-hidden />
|
|
||||||
详情
|
|
||||||
</Link>
|
|
||||||
<button
|
|
||||||
onClick={() => run(session.id)}
|
|
||||||
className="inline-flex h-8 items-center gap-1 rounded-md border border-line px-2 text-xs hover:bg-slate-50"
|
|
||||||
>
|
|
||||||
<Play className="h-3.5 w-3.5" aria-hidden />
|
|
||||||
处理
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => run(session.id, true)}
|
|
||||||
className="inline-flex h-8 items-center gap-1 rounded-md border border-line px-2 text-xs hover:bg-slate-50"
|
|
||||||
>
|
|
||||||
<RotateCw className="h-3.5 w-3.5" aria-hidden />
|
|
||||||
重跑
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{sessions.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td className="px-4 py-8 text-center text-slate-500" colSpan={10}>
|
|
||||||
暂无场次
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : null}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { usePathname } from "next/navigation";
|
|
||||||
import {
|
|
||||||
BookOpenCheck,
|
|
||||||
CalendarDays,
|
|
||||||
ClipboardCheck,
|
|
||||||
DatabaseZap,
|
|
||||||
Gauge,
|
|
||||||
MessageCircle,
|
|
||||||
Library,
|
|
||||||
ListChecks,
|
|
||||||
Settings,
|
|
||||||
ShieldAlert
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
const navItems = [
|
|
||||||
{ href: "/dashboard", label: "仪表盘", icon: Gauge },
|
|
||||||
{ href: "/sessions", label: "答疑场次", icon: CalendarDays },
|
|
||||||
{ href: "/review", label: "待审核", icon: ClipboardCheck },
|
|
||||||
{ href: "/high-risk", label: "高风险", icon: ShieldAlert },
|
|
||||||
{ href: "/standard-qa", label: "标准库", icon: Library },
|
|
||||||
{ href: "/callable-qa", label: "可调用库", icon: DatabaseZap },
|
|
||||||
{ href: "/h5", label: "H5 问答", icon: MessageCircle },
|
|
||||||
{ href: "/logs", label: "运行日志", icon: ListChecks },
|
|
||||||
{ href: "/settings", label: "系统设置", icon: Settings }
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function Sidebar() {
|
|
||||||
const pathname = usePathname();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<aside className="z-20 flex w-full flex-col border-b border-line bg-white md:fixed md:inset-y-0 md:left-0 md:w-64 md:border-b-0 md:border-r">
|
|
||||||
<div className="flex h-16 shrink-0 items-center gap-3 border-b border-line px-5">
|
|
||||||
<BookOpenCheck className="h-6 w-6 text-jade" aria-hidden />
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-semibold text-ink">大本营答疑资产</div>
|
|
||||||
<div className="text-xs text-slate-500">后台系统 MVP</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<nav className="flex gap-1 overflow-x-auto px-3 py-3 md:block md:flex-1 md:space-y-1 md:overflow-visible md:py-4">
|
|
||||||
{navItems.map((item) => {
|
|
||||||
const Icon = item.icon;
|
|
||||||
const active = pathname === item.href || pathname.startsWith(`${item.href}/`);
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={item.href}
|
|
||||||
href={item.href}
|
|
||||||
className={`flex h-10 shrink-0 items-center gap-3 rounded-md px-3 text-sm transition ${
|
|
||||||
active ? "bg-teal-50 text-jade" : "text-slate-600 hover:bg-slate-100 hover:text-ink"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Icon className="h-4 w-4" aria-hidden />
|
|
||||||
<span>{item.label}</span>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
</aside>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Ban, CheckCircle2, PauseCircle, Pencil, RotateCw } from "lucide-react";
|
|
||||||
import { api, formatDateTime } from "@/lib/api";
|
|
||||||
import type { StandardQAItem } from "@/lib/types";
|
|
||||||
import RiskBadge from "./RiskBadge";
|
|
||||||
import StatusBadge from "./StatusBadge";
|
|
||||||
|
|
||||||
export default function StandardQATable({ items, onChanged }: { items: StandardQAItem[]; onChanged: () => void }) {
|
|
||||||
async function action(id: number, path: string) {
|
|
||||||
try {
|
|
||||||
await api.post(`/standard-qa/${id}/${path}`, {});
|
|
||||||
onChanged();
|
|
||||||
} catch (error) {
|
|
||||||
alert(error instanceof Error ? error.message : "操作失败");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function edit(item: StandardQAItem) {
|
|
||||||
const standardQuestion = window.prompt("标准问题", item.standard_question);
|
|
||||||
if (standardQuestion === null) return;
|
|
||||||
const standardAnswer = window.prompt("标准回答", item.standard_answer);
|
|
||||||
if (standardAnswer === null) return;
|
|
||||||
await api.patch(`/standard-qa/${item.id}`, {
|
|
||||||
standard_question: standardQuestion,
|
|
||||||
standard_answer: standardAnswer
|
|
||||||
});
|
|
||||||
onChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="table-wrap rounded-md border border-line bg-white">
|
|
||||||
<table className="min-w-[1350px] w-full border-collapse text-left text-sm">
|
|
||||||
<thead className="bg-panel text-xs uppercase text-slate-500">
|
|
||||||
<tr>
|
|
||||||
<th className="px-4 py-3">标准问题</th>
|
|
||||||
<th className="px-4 py-3">标准回答</th>
|
|
||||||
<th className="px-4 py-3">相似问法</th>
|
|
||||||
<th className="px-4 py-3">主题</th>
|
|
||||||
<th className="px-4 py-3">标签</th>
|
|
||||||
<th className="px-4 py-3">阶段</th>
|
|
||||||
<th className="px-4 py-3">适用人群</th>
|
|
||||||
<th className="px-4 py-3">边界</th>
|
|
||||||
<th className="px-4 py-3">禁止表达</th>
|
|
||||||
<th className="px-4 py-3">来源</th>
|
|
||||||
<th className="px-4 py-3">状态</th>
|
|
||||||
<th className="px-4 py-3">更新</th>
|
|
||||||
<th className="px-4 py-3">操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{items.map((item) => (
|
|
||||||
<tr key={item.id} className="border-t border-line align-top">
|
|
||||||
<td className="max-w-sm px-4 py-3 font-medium text-ink">{item.standard_question}</td>
|
|
||||||
<td className="max-w-md whitespace-pre-wrap px-4 py-3">{item.standard_answer}</td>
|
|
||||||
<td className="px-4 py-3">{item.similar_questions.join("、") || "-"}</td>
|
|
||||||
<td className="px-4 py-3">{item.primary_topic}</td>
|
|
||||||
<td className="px-4 py-3">{item.problem_tags.join("、") || "-"}</td>
|
|
||||||
<td className="px-4 py-3">{item.course_stage}</td>
|
|
||||||
<td className="px-4 py-3">{item.audience_tags.join("、") || "-"}</td>
|
|
||||||
<td className="max-w-xs px-4 py-3">{item.answer_boundary ?? "-"}</td>
|
|
||||||
<td className="px-4 py-3">{item.forbidden_expressions.join("、") || "-"}</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
QA #{item.source_raw_qa_id}
|
|
||||||
<br />
|
|
||||||
场次 #{item.session_id}
|
|
||||||
</td>
|
|
||||||
<td className="space-y-2 px-4 py-3">
|
|
||||||
<StatusBadge status={item.audit_status} />
|
|
||||||
<StatusBadge status={item.call_status} />
|
|
||||||
<RiskBadge level={item.risk_level} />
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">{formatDateTime(item.updated_at)}</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => edit(item)}
|
|
||||||
className="inline-flex h-8 items-center gap-1 rounded-md border border-line px-2 text-xs hover:bg-slate-50"
|
|
||||||
>
|
|
||||||
<Pencil className="h-3.5 w-3.5" aria-hidden />
|
|
||||||
编辑
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => action(item.id, "mark-callable")}
|
|
||||||
className="inline-flex h-8 items-center gap-1 rounded-md border border-emerald-200 px-2 text-xs text-emerald-700 hover:bg-emerald-50"
|
|
||||||
>
|
|
||||||
<CheckCircle2 className="h-3.5 w-3.5" aria-hidden />
|
|
||||||
可调用
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => action(item.id, "mark-not-callable")}
|
|
||||||
className="inline-flex h-8 items-center gap-1 rounded-md border border-line px-2 text-xs hover:bg-slate-50"
|
|
||||||
>
|
|
||||||
<PauseCircle className="h-3.5 w-3.5" aria-hidden />
|
|
||||||
暂不调用
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => action(item.id, "need-review")}
|
|
||||||
className="inline-flex h-8 items-center gap-1 rounded-md border border-line px-2 text-xs hover:bg-slate-50"
|
|
||||||
>
|
|
||||||
<RotateCw className="h-3.5 w-3.5" aria-hidden />
|
|
||||||
复审
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => action(item.id, "disable")}
|
|
||||||
className="inline-flex h-8 items-center gap-1 rounded-md border border-rose-200 px-2 text-xs text-rose-700 hover:bg-rose-50"
|
|
||||||
>
|
|
||||||
<Ban className="h-3.5 w-3.5" aria-hidden />
|
|
||||||
禁用
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{items.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td className="px-4 py-8 text-center text-slate-500" colSpan={13}>
|
|
||||||
暂无标准问答
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : null}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { ReactNode } from "react";
|
|
||||||
|
|
||||||
export default function StatCard({
|
|
||||||
title,
|
|
||||||
value,
|
|
||||||
icon,
|
|
||||||
tone = "teal"
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
value: string | number;
|
|
||||||
icon: ReactNode;
|
|
||||||
tone?: "teal" | "amber" | "rose" | "indigo";
|
|
||||||
}) {
|
|
||||||
const tones = {
|
|
||||||
teal: "bg-teal-50 text-jade",
|
|
||||||
amber: "bg-amber-50 text-amber-700",
|
|
||||||
rose: "bg-rose-50 text-rose-700",
|
|
||||||
indigo: "bg-indigo-50 text-indigo-700"
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-md border border-line bg-white p-4">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<div className="text-sm text-slate-500">{title}</div>
|
|
||||||
<div className="mt-2 text-2xl font-semibold text-ink">{value}</div>
|
|
||||||
</div>
|
|
||||||
<div className={`flex h-10 w-10 items-center justify-center rounded-md ${tones[tone]}`}>{icon}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
export default function StatusBadge({ status }: { status: string }) {
|
|
||||||
const styles: Record<string, string> = {
|
|
||||||
unprocessed: "border-slate-200 bg-slate-50 text-slate-700",
|
|
||||||
processing: "border-cyan-200 bg-cyan-50 text-cyan-700",
|
|
||||||
parsed: "border-indigo-200 bg-indigo-50 text-indigo-700",
|
|
||||||
pending_review: "border-amber-200 bg-amber-50 text-amber-700",
|
|
||||||
completed: "border-emerald-200 bg-emerald-50 text-emerald-700",
|
|
||||||
failed: "border-rose-200 bg-rose-50 text-rose-700",
|
|
||||||
pending: "border-amber-200 bg-amber-50 text-amber-700",
|
|
||||||
approved: "border-emerald-200 bg-emerald-50 text-emerald-700",
|
|
||||||
rejected: "border-slate-200 bg-slate-50 text-slate-700",
|
|
||||||
forbidden: "border-rose-200 bg-rose-50 text-rose-700",
|
|
||||||
callable: "border-emerald-200 bg-emerald-50 text-emerald-700",
|
|
||||||
not_callable: "border-slate-200 bg-slate-50 text-slate-700",
|
|
||||||
need_review: "border-amber-200 bg-amber-50 text-amber-700",
|
|
||||||
success: "border-emerald-200 bg-emerald-50 text-emerald-700",
|
|
||||||
partial_success: "border-orange-200 bg-orange-50 text-orange-700",
|
|
||||||
running: "border-cyan-200 bg-cyan-50 text-cyan-700"
|
|
||||||
};
|
|
||||||
const labels: Record<string, string> = {
|
|
||||||
unprocessed: "未处理",
|
|
||||||
processing: "处理中",
|
|
||||||
parsed: "已拆解",
|
|
||||||
pending_review: "待审核",
|
|
||||||
completed: "已完成",
|
|
||||||
failed: "失败",
|
|
||||||
pending: "待审核",
|
|
||||||
reviewing: "审核中",
|
|
||||||
approved: "已通过",
|
|
||||||
needs_revision: "需修改",
|
|
||||||
rejected: "不入库",
|
|
||||||
forbidden: "禁止",
|
|
||||||
callable: "可调用",
|
|
||||||
not_callable: "暂不调用",
|
|
||||||
need_review: "需复审",
|
|
||||||
success: "成功",
|
|
||||||
partial_success: "部分成功",
|
|
||||||
running: "运行中"
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<span className={`inline-flex rounded-md border px-2 py-1 text-xs font-medium ${styles[status] ?? styles.pending}`}>
|
|
||||||
{labels[status] ?? status}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Bot, Check, Copy, ThumbsDown, ThumbsUp, UserRound } from "lucide-react";
|
|
||||||
import type { ChatMessage } from "@/lib/h5MockQa";
|
|
||||||
|
|
||||||
export default function ChatBubble({
|
|
||||||
message,
|
|
||||||
onCopy,
|
|
||||||
onFeedback
|
|
||||||
}: {
|
|
||||||
message: ChatMessage;
|
|
||||||
onCopy?: (message: ChatMessage) => void;
|
|
||||||
onFeedback?: (messageId: string, feedback: "liked" | "disliked") => void;
|
|
||||||
}) {
|
|
||||||
const isUser = message.role === "user";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`flex gap-2 ${isUser ? "justify-end" : "justify-start"}`}>
|
|
||||||
{!isUser ? (
|
|
||||||
<div className="mt-1 flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-jade text-white">
|
|
||||||
<Bot className="h-4 w-4" aria-hidden />
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div className={`max-w-[82%] ${isUser ? "items-end" : "items-start"}`}>
|
|
||||||
<div
|
|
||||||
className={`rounded-[14px] px-4 py-3 text-[15px] leading-7 shadow-sm ${
|
|
||||||
isUser ? "rounded-tr-md bg-jade text-white" : "rounded-tl-md border border-line bg-white text-ink"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="whitespace-pre-wrap">{message.content}</div>
|
|
||||||
{message.source ? (
|
|
||||||
<div className={`mt-3 border-t pt-2 text-xs ${isUser ? "border-white/25 text-teal-50" : "border-line text-slate-500"}`}>
|
|
||||||
来源:{message.source}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{message.boundary ? (
|
|
||||||
<div className="mt-3 rounded-md bg-amber-50 px-3 py-2 text-xs leading-5 text-amber-800">{message.boundary}</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className={`mt-1 flex items-center gap-2 text-xs text-slate-400 ${isUser ? "justify-end" : "justify-start"}`}>
|
|
||||||
<span>{message.createdAt}</span>
|
|
||||||
{!isUser ? (
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<button
|
|
||||||
onClick={() => onCopy?.(message)}
|
|
||||||
className="flex h-7 w-7 items-center justify-center rounded-md hover:bg-slate-100 hover:text-jade"
|
|
||||||
aria-label="复制回答"
|
|
||||||
>
|
|
||||||
<Copy className="h-3.5 w-3.5" aria-hidden />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => onFeedback?.(message.id, "liked")}
|
|
||||||
className={`flex h-7 w-7 items-center justify-center rounded-md hover:bg-teal-50 hover:text-jade ${
|
|
||||||
message.feedback === "liked" ? "bg-teal-50 text-jade" : ""
|
|
||||||
}`}
|
|
||||||
aria-label="回答有帮助"
|
|
||||||
>
|
|
||||||
{message.feedback === "liked" ? <Check className="h-3.5 w-3.5" aria-hidden /> : <ThumbsUp className="h-3.5 w-3.5" aria-hidden />}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => onFeedback?.(message.id, "disliked")}
|
|
||||||
className={`flex h-7 w-7 items-center justify-center rounded-md hover:bg-rose-50 hover:text-rose-600 ${
|
|
||||||
message.feedback === "disliked" ? "bg-rose-50 text-rose-600" : ""
|
|
||||||
}`}
|
|
||||||
aria-label="回答没解决"
|
|
||||||
>
|
|
||||||
<ThumbsDown className="h-3.5 w-3.5" aria-hidden />
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{isUser ? (
|
|
||||||
<div className="mt-1 flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-slate-200 text-slate-600">
|
|
||||||
<UserRound className="h-4 w-4" aria-hidden />
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,317 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { FormEvent, useEffect, useRef, useState } from "react";
|
|
||||||
import {
|
|
||||||
BookOpenCheck,
|
|
||||||
MessageCircle,
|
|
||||||
Plus,
|
|
||||||
SendHorizonal,
|
|
||||||
ShieldCheck,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { api } from "@/lib/api";
|
|
||||||
import type { CallableQAItem } from "@/lib/types";
|
|
||||||
import {
|
|
||||||
createEmptySession,
|
|
||||||
findQaAnswer,
|
|
||||||
initialMessages,
|
|
||||||
suggestedQuestions,
|
|
||||||
summarizeSession,
|
|
||||||
type ChatSession,
|
|
||||||
type ChatMessage
|
|
||||||
} from "@/lib/h5MockQa";
|
|
||||||
import ChatBubble from "./ChatBubble";
|
|
||||||
|
|
||||||
const STORAGE_KEY = "hy-qa-h5-sessions";
|
|
||||||
const SHOW_AUTH_ENTRY = false;
|
|
||||||
const SHOW_HISTORY_AND_PROFILE = false;
|
|
||||||
|
|
||||||
function nowText() {
|
|
||||||
return new Intl.DateTimeFormat("zh-CN", {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
hour12: false
|
|
||||||
}).format(new Date());
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildAnswerMessage(question: string, qa: CallableQAItem): ChatMessage {
|
|
||||||
return {
|
|
||||||
id: `assistant-${Date.now()}`,
|
|
||||||
role: "assistant",
|
|
||||||
content: qa.standard_answer,
|
|
||||||
source: `${qa.standard_code} / ${qa.primary_topic} / ${qa.course_stage}`,
|
|
||||||
boundary: qa.answer_boundary ?? "答复仅基于已审核答疑资产生成,不能替代专业诊断、法律判断或医疗建议。",
|
|
||||||
createdAt: nowText()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildUnavailableMessage(reason: "loading" | "empty" | "error"): ChatMessage {
|
|
||||||
const content = {
|
|
||||||
loading: "我还在连接可调用问答库,请稍等几秒后再试。",
|
|
||||||
empty: "当前可调用问答库还没有可用内容。请先到后台标准问答库中,把已审核、低/中风险、已脱敏或无需脱敏的问答标记为“可调用”。",
|
|
||||||
error: "暂时连接不到后端可调用库,请检查后端服务或网络连接后再试。"
|
|
||||||
}[reason];
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: `assistant-unavailable-${Date.now()}`,
|
|
||||||
role: "assistant",
|
|
||||||
content,
|
|
||||||
boundary: "H5 端不会使用未审核或不可调用的内容生成回答。",
|
|
||||||
createdAt: nowText()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function H5QAExperience() {
|
|
||||||
const [input, setInput] = useState("");
|
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>(initialMessages);
|
|
||||||
const [sessions, setSessions] = useState<ChatSession[]>([]);
|
|
||||||
const [currentSessionId, setCurrentSessionId] = useState("");
|
|
||||||
const [callableQa, setCallableQa] = useState<CallableQAItem[]>([]);
|
|
||||||
const [loadingQa, setLoadingQa] = useState(true);
|
|
||||||
const [qaLoadError, setQaLoadError] = useState<string | null>(null);
|
|
||||||
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null);
|
|
||||||
const bottomRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
|
|
||||||
const qaStatusText = loadingQa ? "正在连接可调用库" : qaLoadError ? "后端连接异常" : callableQa.length > 0 ? "已连接可调用库" : "可调用库为空";
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fallback = createEmptySession();
|
|
||||||
try {
|
|
||||||
const saved = window.localStorage.getItem(STORAGE_KEY);
|
|
||||||
const parsed = saved ? (JSON.parse(saved) as ChatSession[]) : [];
|
|
||||||
const validSessions = parsed.length > 0 ? parsed : [fallback];
|
|
||||||
setSessions(validSessions);
|
|
||||||
setCurrentSessionId(validSessions[0].id);
|
|
||||||
setMessages(validSessions[0].messages);
|
|
||||||
} catch {
|
|
||||||
setSessions([fallback]);
|
|
||||||
setCurrentSessionId(fallback.id);
|
|
||||||
setMessages(fallback.messages);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (sessions.length > 0) {
|
|
||||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(sessions));
|
|
||||||
}
|
|
||||||
}, [sessions]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
api
|
|
||||||
.get<CallableQAItem[]>("/standard-qa/callable")
|
|
||||||
.then((items) => {
|
|
||||||
setCallableQa(items);
|
|
||||||
setQaLoadError(null);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
setCallableQa([]);
|
|
||||||
setQaLoadError(error instanceof Error ? error.message : "可调用库接口请求失败");
|
|
||||||
})
|
|
||||||
.finally(() => setLoadingQa(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
|
||||||
}, [messages]);
|
|
||||||
|
|
||||||
function persistMessages(nextMessages: ChatMessage[]) {
|
|
||||||
setMessages(nextMessages);
|
|
||||||
setSessions((current) => {
|
|
||||||
const sessionId = currentSessionId || createEmptySession().id;
|
|
||||||
const nextSession = summarizeSession(sessionId, nextMessages);
|
|
||||||
const withoutCurrent = current.filter((session) => session.id !== sessionId);
|
|
||||||
return [nextSession, ...withoutCurrent].slice(0, 20);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function startNewSession() {
|
|
||||||
const nextSession = createEmptySession();
|
|
||||||
setCurrentSessionId(nextSession.id);
|
|
||||||
setMessages(nextSession.messages);
|
|
||||||
setSessions((current) => [nextSession, ...current].slice(0, 20));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copyMessage(message: ChatMessage) {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(message.content);
|
|
||||||
} catch {
|
|
||||||
// Some embedded webviews/headless browsers block clipboard writes.
|
|
||||||
// Keep the UI feedback so the user knows the copy action was triggered.
|
|
||||||
}
|
|
||||||
setCopiedMessageId(message.id);
|
|
||||||
window.setTimeout(() => setCopiedMessageId(null), 1400);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setFeedback(messageId: string, feedback: "liked" | "disliked") {
|
|
||||||
const nextMessages = messages.map((message) =>
|
|
||||||
message.id === messageId ? { ...message, feedback: message.feedback === feedback ? undefined : feedback } : message
|
|
||||||
);
|
|
||||||
persistMessages(nextMessages);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ask(question: string) {
|
|
||||||
const trimmed = question.trim();
|
|
||||||
if (!trimmed) return;
|
|
||||||
|
|
||||||
const matchedQa = findQaAnswer(trimmed, callableQa);
|
|
||||||
const timestamp = Date.now();
|
|
||||||
const assistantMessage = loadingQa
|
|
||||||
? buildUnavailableMessage("loading")
|
|
||||||
: qaLoadError
|
|
||||||
? buildUnavailableMessage("error")
|
|
||||||
: matchedQa
|
|
||||||
? { ...buildAnswerMessage(trimmed, matchedQa), id: `assistant-${timestamp}` }
|
|
||||||
: buildUnavailableMessage("empty");
|
|
||||||
const nextMessages: ChatMessage[] = [
|
|
||||||
...messages,
|
|
||||||
{
|
|
||||||
id: `user-${timestamp}`,
|
|
||||||
role: "user",
|
|
||||||
content: trimmed,
|
|
||||||
createdAt: nowText()
|
|
||||||
},
|
|
||||||
assistantMessage
|
|
||||||
];
|
|
||||||
persistMessages(nextMessages);
|
|
||||||
setInput("");
|
|
||||||
}
|
|
||||||
|
|
||||||
function submit(event: FormEvent<HTMLFormElement>) {
|
|
||||||
event.preventDefault();
|
|
||||||
ask(input);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-[#eef3f1] text-ink lg:grid lg:grid-cols-[minmax(0,1fr)_420px_minmax(0,1fr)]">
|
|
||||||
<aside className="hidden px-8 py-10 lg:block">
|
|
||||||
<div className="sticky top-10 ml-auto max-w-sm">
|
|
||||||
<div className="mb-5 flex items-center gap-3">
|
|
||||||
<BookOpenCheck className="h-7 w-7 text-jade" aria-hidden />
|
|
||||||
<div>
|
|
||||||
<div className="text-lg font-semibold">千问千答 H5</div>
|
|
||||||
<div className="text-sm text-slate-500">手机优先的问答交互入口</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3 text-sm leading-6 text-slate-600">
|
|
||||||
<p>一期用户端先免登录使用,H5 只保留问答主链路,降低首次体验门槛。</p>
|
|
||||||
<p>登录、历史记录和个人中心已预留实现,等后端用户体系和会话接口确定后再打开。</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<main className="mx-auto flex min-h-screen w-full max-w-[430px] flex-col bg-[#f7faf8] shadow-2xl shadow-slate-900/10 lg:my-6 lg:min-h-[860px] lg:overflow-hidden lg:rounded-[22px]">
|
|
||||||
<header className="shrink-0 border-b border-line bg-white px-4 pb-3 pt-4">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-jade text-white">
|
|
||||||
<MessageCircle className="h-5 w-5" aria-hidden />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-lg font-semibold leading-6">千问千答助手</h1>
|
|
||||||
<div className="mt-0.5 flex items-center gap-1 text-xs text-slate-500">
|
|
||||||
<ShieldCheck className="h-3.5 w-3.5 text-jade" aria-hidden />
|
|
||||||
{qaStatusText}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{SHOW_AUTH_ENTRY ? null : null}
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 flex items-center justify-between gap-2">
|
|
||||||
<button
|
|
||||||
onClick={startNewSession}
|
|
||||||
className="inline-flex h-8 items-center gap-1 rounded-md border border-line px-2 text-xs font-medium text-slate-600 hover:bg-slate-50"
|
|
||||||
>
|
|
||||||
<Plus className="h-3.5 w-3.5" aria-hidden />
|
|
||||||
新问答
|
|
||||||
</button>
|
|
||||||
{copiedMessageId ? <span className="text-xs text-jade">已复制回答</span> : null}
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section className="relative flex min-h-0 flex-1 flex-col">
|
|
||||||
<div className="flex-1 overflow-y-auto px-4 py-4">
|
|
||||||
<div className="mb-4 rounded-md border border-teal-100 bg-teal-50 px-3 py-2 text-xs leading-5 text-teal-800">
|
|
||||||
一期免登录试用;回答会优先遵守审核边界,涉及医疗、法律、极端风险或个人隐私时,请以人工复核为准。
|
|
||||||
</div>
|
|
||||||
{!loadingQa && (qaLoadError || callableQa.length === 0) ? (
|
|
||||||
<div className="mb-4 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs leading-5 text-amber-800">
|
|
||||||
{qaLoadError ? `可调用库接口异常:${qaLoadError}` : "当前后端可调用库为空,H5 不会用本地 mock 内容代替真实答案。"}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div className="space-y-4">
|
|
||||||
{messages.map((message) => (
|
|
||||||
<ChatBubble key={message.id} message={message} onCopy={copyMessage} onFeedback={setFeedback} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div ref={bottomRef} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="shrink-0 border-t border-line bg-white px-4 py-3">
|
|
||||||
<div className="mb-3 flex gap-2 overflow-x-auto pb-1">
|
|
||||||
{suggestedQuestions.map((item) => (
|
|
||||||
<button
|
|
||||||
key={item.id}
|
|
||||||
onClick={() => ask(item.text)}
|
|
||||||
className="shrink-0 rounded-md border border-line bg-panel px-3 py-2 text-left text-xs leading-5 text-slate-700 hover:bg-teal-50 hover:text-jade"
|
|
||||||
>
|
|
||||||
{item.text}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<form onSubmit={submit} className="flex items-end gap-2">
|
|
||||||
<textarea
|
|
||||||
value={input}
|
|
||||||
onChange={(event) => setInput(event.target.value)}
|
|
||||||
rows={1}
|
|
||||||
placeholder="输入你的问题..."
|
|
||||||
className="max-h-28 min-h-11 flex-1 resize-none rounded-md border border-line bg-panel px-3 py-3 text-[15px] leading-5 outline-none focus:border-jade focus:bg-white"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={!input.trim()}
|
|
||||||
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-md bg-jade text-white hover:bg-teal-800 disabled:bg-slate-300"
|
|
||||||
aria-label="发送"
|
|
||||||
>
|
|
||||||
<SendHorizonal className="h-5 w-5" aria-hidden />
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{SHOW_HISTORY_AND_PROFILE ? (
|
|
||||||
<div className="mt-3 rounded-md bg-panel px-3 py-2 text-xs text-slate-500">
|
|
||||||
登录、历史记录和个人中心入口已隐藏,二期接入用户体系后恢复。
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{SHOW_HISTORY_AND_PROFILE ? (
|
|
||||||
<nav className="hidden h-[62px] shrink-0 grid-cols-3 border-t border-line bg-white text-xs">
|
|
||||||
{[
|
|
||||||
{ label: "问答" },
|
|
||||||
{ label: "记录" },
|
|
||||||
{ label: "我的" }
|
|
||||||
].map((item) => (
|
|
||||||
<button
|
|
||||||
key={item.label}
|
|
||||||
className="flex flex-col items-center justify-center gap-1 text-slate-400"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<span>{item.label}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
) : null}
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<aside className="hidden px-8 py-10 lg:block">
|
|
||||||
<div className="sticky top-10 max-w-sm rounded-md border border-line bg-white p-5 shadow-sm">
|
|
||||||
<h2 className="text-base font-semibold">一期用户端范围</h2>
|
|
||||||
<ul className="mt-4 space-y-3 text-sm leading-6 text-slate-600">
|
|
||||||
<li>免登录进入 H5,优先验证问答主体验。</li>
|
|
||||||
<li>问答只读取后端已审核可调用库,不使用本地 mock 答案。</li>
|
|
||||||
<li>登录、会话记录、个人中心暂不展示,等后端用户体系确定后再开放。</li>
|
|
||||||
<li>后续智能体接口会替代当前前端匹配逻辑,由后端负责检索和大模型调用。</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { LogIn, MessageCircle, ShieldCheck, UserPlus, X } from "lucide-react";
|
|
||||||
import { buildWechatOAuthUrl, getWechatAuthConfig, isWechatBrowser } from "@/lib/wechatAuth";
|
|
||||||
|
|
||||||
export type MockUser = {
|
|
||||||
name: string;
|
|
||||||
phone: string;
|
|
||||||
mode: "login" | "register" | "wechat";
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function MockAuthPanel({
|
|
||||||
open,
|
|
||||||
user,
|
|
||||||
onClose,
|
|
||||||
onMockLogin,
|
|
||||||
onWechatMockLogin,
|
|
||||||
onLogout
|
|
||||||
}: {
|
|
||||||
open: boolean;
|
|
||||||
user: MockUser | null;
|
|
||||||
onClose: () => void;
|
|
||||||
onMockLogin: (mode: MockUser["mode"]) => void;
|
|
||||||
onWechatMockLogin: () => void;
|
|
||||||
onLogout: () => void;
|
|
||||||
}) {
|
|
||||||
if (!open) return null;
|
|
||||||
|
|
||||||
const wechatConfig = getWechatAuthConfig();
|
|
||||||
const inWechat = typeof navigator !== "undefined" && isWechatBrowser(navigator.userAgent);
|
|
||||||
|
|
||||||
function startWechatAuth() {
|
|
||||||
if (wechatConfig) {
|
|
||||||
window.location.href = buildWechatOAuthUrl(wechatConfig);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onWechatMockLogin();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="absolute inset-0 z-30 flex items-end bg-slate-950/35 px-3 pb-3">
|
|
||||||
<section className="w-full rounded-t-[18px] rounded-b-md bg-white p-4 shadow-2xl">
|
|
||||||
<div className="mb-4 flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold text-ink">账号入口</h2>
|
|
||||||
<p className="mt-1 text-sm text-slate-500">优先预留微信授权;没有配置时使用演示账号。</p>
|
|
||||||
</div>
|
|
||||||
<button onClick={onClose} className="flex h-9 w-9 items-center justify-center rounded-md hover:bg-slate-100" aria-label="关闭">
|
|
||||||
<X className="h-5 w-5" aria-hidden />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{user ? (
|
|
||||||
<div className="rounded-md border border-line bg-panel p-3">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-jade text-white">
|
|
||||||
<ShieldCheck className="h-5 w-5" aria-hidden />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="font-medium text-ink">{user.name}</div>
|
|
||||||
<div className="text-sm text-slate-500">{user.phone}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button onClick={onLogout} className="mt-3 h-10 w-full rounded-md border border-line text-sm font-medium hover:bg-white">
|
|
||||||
退出 mock 账号
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid gap-3">
|
|
||||||
<button
|
|
||||||
onClick={startWechatAuth}
|
|
||||||
className="flex h-11 items-center justify-center gap-2 rounded-md bg-jade text-sm font-medium text-white hover:bg-teal-800"
|
|
||||||
>
|
|
||||||
<MessageCircle className="h-4 w-4" aria-hidden />
|
|
||||||
{wechatConfig ? "微信授权登录" : "微信授权演示"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => onMockLogin("login")}
|
|
||||||
className="flex h-11 items-center justify-center gap-2 rounded-md border border-line text-sm font-medium text-ink hover:bg-slate-50"
|
|
||||||
>
|
|
||||||
<LogIn className="h-4 w-4" aria-hidden />
|
|
||||||
mock 登录
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => onMockLogin("register")}
|
|
||||||
className="flex h-11 items-center justify-center gap-2 rounded-md border border-line text-sm font-medium text-ink hover:bg-slate-50"
|
|
||||||
>
|
|
||||||
<UserPlus className="h-4 w-4" aria-hidden />
|
|
||||||
mock 注册并进入
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-4 rounded-md bg-amber-50 px-3 py-2 text-xs leading-5 text-amber-800">
|
|
||||||
{wechatConfig
|
|
||||||
? `已配置微信 OAuth${inWechat ? ",当前在微信内置浏览器中。" : "。如果不在微信内打开,授权页可能无法完成。"}`
|
|
||||||
: "尚未配置 NEXT_PUBLIC_WECHAT_APP_ID,当前按钮只模拟微信授权,不写入后端。"}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
export const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://127.0.0.1:8000/api";
|
|
||||||
|
|
||||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|
||||||
const response = await fetch(`${API_BASE}${path}`, {
|
|
||||||
...init,
|
|
||||||
cache: "no-store",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
...(init?.headers ?? {})
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
let message = `${response.status} ${response.statusText}`;
|
|
||||||
try {
|
|
||||||
const body = await response.json();
|
|
||||||
message = body.detail ?? message;
|
|
||||||
} catch {
|
|
||||||
// keep response status as message
|
|
||||||
}
|
|
||||||
throw new Error(message);
|
|
||||||
}
|
|
||||||
return response.json() as Promise<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const api = {
|
|
||||||
get: <T>(path: string) => request<T>(path),
|
|
||||||
post: <T>(path: string, body: unknown = {}) =>
|
|
||||||
request<T>(path, { method: "POST", body: JSON.stringify(body) }),
|
|
||||||
patch: <T>(path: string, body: unknown) =>
|
|
||||||
request<T>(path, { method: "PATCH", body: JSON.stringify(body) })
|
|
||||||
};
|
|
||||||
|
|
||||||
export function formatDateTime(value: string | null | undefined) {
|
|
||||||
if (!value) return "-";
|
|
||||||
return new Intl.DateTimeFormat("zh-CN", {
|
|
||||||
dateStyle: "short",
|
|
||||||
timeStyle: "short"
|
|
||||||
}).format(new Date(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
import type { CallableQAItem } from "./types";
|
|
||||||
|
|
||||||
export type ChatRole = "assistant" | "user";
|
|
||||||
|
|
||||||
export type ChatMessage = {
|
|
||||||
id: string;
|
|
||||||
role: ChatRole;
|
|
||||||
content: string;
|
|
||||||
source?: string;
|
|
||||||
boundary?: string;
|
|
||||||
feedback?: "liked" | "disliked";
|
|
||||||
createdAt: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ChatSession = {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
preview: string;
|
|
||||||
messages: ChatMessage[];
|
|
||||||
updatedAt: string;
|
|
||||||
questionCount: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SuggestedQuestion = {
|
|
||||||
id: string;
|
|
||||||
text: string;
|
|
||||||
tags: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const suggestedQuestions: SuggestedQuestion[] = [
|
|
||||||
{
|
|
||||||
id: "parent-child-emotion",
|
|
||||||
text: "孩子一写作业就发脾气,我怎么接住他的情绪?",
|
|
||||||
tags: ["亲子关系", "情绪"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "self-growth",
|
|
||||||
text: "我知道要行动,但总是拖延,应该先从哪里开始?",
|
|
||||||
tags: ["个人成长", "行动力"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "marriage-talk",
|
|
||||||
text: "伴侣不愿意沟通,我怎么表达才不容易吵起来?",
|
|
||||||
tags: ["婚姻关系", "沟通"]
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export const initialMessages: ChatMessage[] = [
|
|
||||||
{
|
|
||||||
id: "welcome",
|
|
||||||
role: "assistant",
|
|
||||||
content:
|
|
||||||
"你好,我是千问千答助手。你可以把最近遇到的亲子、关系、情绪或行动问题发给我,我会优先基于已审核的答疑资产给你一个可落地的回应。",
|
|
||||||
boundary: "当前为 H5 体验版,一期免登录试用;问答答案只读取后端可调用库。",
|
|
||||||
createdAt: "09:30"
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export function createEmptySession(): ChatSession {
|
|
||||||
return {
|
|
||||||
id: `session-${Date.now()}`,
|
|
||||||
title: "新的问答",
|
|
||||||
preview: "还没有开始提问",
|
|
||||||
messages: initialMessages,
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
questionCount: 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function summarizeSession(sessionId: string, messages: ChatMessage[]): ChatSession {
|
|
||||||
const userMessages = messages.filter((message) => message.role === "user");
|
|
||||||
const lastMessage = [...messages].reverse().find((message) => message.role === "assistant" || message.role === "user");
|
|
||||||
const firstQuestion = userMessages[0]?.content;
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: sessionId,
|
|
||||||
title: firstQuestion ? firstQuestion.slice(0, 24) : "新的问答",
|
|
||||||
preview: lastMessage?.content.slice(0, 42) ?? "还没有开始提问",
|
|
||||||
messages,
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
questionCount: userMessages.length
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function scoreQuestion(question: string, qa: CallableQAItem): number {
|
|
||||||
const haystack = [
|
|
||||||
qa.standard_question,
|
|
||||||
qa.standard_answer,
|
|
||||||
qa.primary_topic,
|
|
||||||
qa.course_stage,
|
|
||||||
...qa.problem_tags,
|
|
||||||
...qa.similar_questions
|
|
||||||
].join(" ");
|
|
||||||
return Array.from(new Set(question.trim().split(""))).reduce((score, char) => {
|
|
||||||
if (!char.trim()) return score;
|
|
||||||
return haystack.includes(char) ? score + 1 : score;
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function findQaAnswer(question: string, qaItems: CallableQAItem[]) {
|
|
||||||
if (qaItems.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const [best] = [...qaItems].sort((left, right) => scoreQuestion(question, right) - scoreQuestion(question, left));
|
|
||||||
return best ?? null;
|
|
||||||
}
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
export type DashboardSummary = {
|
|
||||||
total_sessions: number;
|
|
||||||
processed_sessions: number;
|
|
||||||
pending_review_count: number;
|
|
||||||
high_risk_count: number;
|
|
||||||
approved_qa_count: number;
|
|
||||||
standard_qa_count: number;
|
|
||||||
callable_qa_count: number;
|
|
||||||
last_task_status: string | null;
|
|
||||||
last_task_time: string | null;
|
|
||||||
last_task_error: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type FeishuSession = {
|
|
||||||
id: number;
|
|
||||||
session_code: string;
|
|
||||||
title: string;
|
|
||||||
date: string | null;
|
|
||||||
teachers: string | null;
|
|
||||||
feishu_doc_url: string | null;
|
|
||||||
feishu_record_id: string | null;
|
|
||||||
source_file_name: string | null;
|
|
||||||
source_text: string | null;
|
|
||||||
process_status: string;
|
|
||||||
qa_count: number;
|
|
||||||
pending_review_count: number;
|
|
||||||
approved_count: number;
|
|
||||||
standard_qa_count: number;
|
|
||||||
failed_reason: string | null;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RawQAItem = {
|
|
||||||
id: number;
|
|
||||||
session_id: number;
|
|
||||||
qa_code: string;
|
|
||||||
raw_question: string;
|
|
||||||
normalized_question: string | null;
|
|
||||||
raw_answer: string;
|
|
||||||
normalized_answer: string | null;
|
|
||||||
suggested_standard_question: string | null;
|
|
||||||
suggested_standard_answer: string | null;
|
|
||||||
answer_person: string | null;
|
|
||||||
primary_topic: string;
|
|
||||||
problem_tags: string[];
|
|
||||||
course_stage: string;
|
|
||||||
audience_tags: string[];
|
|
||||||
emotion_intensity: string | null;
|
|
||||||
risk_level: string;
|
|
||||||
risk_types: string[];
|
|
||||||
risk_notes: string | null;
|
|
||||||
need_desensitization: boolean;
|
|
||||||
desensitization_status: string;
|
|
||||||
review_status: string;
|
|
||||||
reviewer_id: number | null;
|
|
||||||
review_notes: string | null;
|
|
||||||
suggested_to_standard_qa: boolean;
|
|
||||||
source_timestamp: string | null;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type StandardQAItem = {
|
|
||||||
id: number;
|
|
||||||
standard_code: string;
|
|
||||||
source_raw_qa_id: number;
|
|
||||||
session_id: number;
|
|
||||||
standard_question: string;
|
|
||||||
standard_answer: string;
|
|
||||||
similar_questions: string[];
|
|
||||||
primary_topic: string;
|
|
||||||
problem_tags: string[];
|
|
||||||
course_stage: string;
|
|
||||||
audience_tags: string[];
|
|
||||||
answer_boundary: string | null;
|
|
||||||
forbidden_expressions: string[];
|
|
||||||
risk_level: string;
|
|
||||||
audit_status: string;
|
|
||||||
call_status: string;
|
|
||||||
last_reviewer_id: number | null;
|
|
||||||
disabled_reason: string | null;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CallableQAItem = Pick<
|
|
||||||
StandardQAItem,
|
|
||||||
| "id"
|
|
||||||
| "standard_code"
|
|
||||||
| "standard_question"
|
|
||||||
| "standard_answer"
|
|
||||||
| "similar_questions"
|
|
||||||
| "primary_topic"
|
|
||||||
| "problem_tags"
|
|
||||||
| "course_stage"
|
|
||||||
| "audience_tags"
|
|
||||||
| "answer_boundary"
|
|
||||||
| "forbidden_expressions"
|
|
||||||
| "source_raw_qa_id"
|
|
||||||
| "session_id"
|
|
||||||
>;
|
|
||||||
|
|
||||||
export type TaskRun = {
|
|
||||||
id: number;
|
|
||||||
task_type: string;
|
|
||||||
task_status: string;
|
|
||||||
started_at: string | null;
|
|
||||||
ended_at: string | null;
|
|
||||||
sessions_scanned: number;
|
|
||||||
sessions_processed: number;
|
|
||||||
qa_created: number;
|
|
||||||
qa_failed: number;
|
|
||||||
error_message: string | null;
|
|
||||||
retry_count: number;
|
|
||||||
created_at: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AuditLog = {
|
|
||||||
id: number;
|
|
||||||
user_id: number | null;
|
|
||||||
target_type: string;
|
|
||||||
target_id: number;
|
|
||||||
action: string;
|
|
||||||
before_status: string | null;
|
|
||||||
after_status: string | null;
|
|
||||||
comment: string | null;
|
|
||||||
created_at: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SafeSettings = {
|
|
||||||
feishu_configured: boolean;
|
|
||||||
openai_configured: boolean;
|
|
||||||
model_name: string;
|
|
||||||
schedule_cron: string;
|
|
||||||
schedule_timezone: string;
|
|
||||||
mock_mode: boolean;
|
|
||||||
database_url: string;
|
|
||||||
feishu_tables_configured: Record<string, boolean>;
|
|
||||||
system_settings: Array<{ key: string; value: string | null; description: string | null; updated_at: string }>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type FeishuStatus = {
|
|
||||||
configured: boolean;
|
|
||||||
mock_mode: boolean;
|
|
||||||
app_token_configured: boolean;
|
|
||||||
wiki_node_token_configured: boolean;
|
|
||||||
table_ids: Record<string, boolean>;
|
|
||||||
token_ok: boolean;
|
|
||||||
message: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type FeishuSetupGuide = {
|
|
||||||
required_env: string[];
|
|
||||||
required_permissions: string[];
|
|
||||||
tables: Array<{
|
|
||||||
table_key: string;
|
|
||||||
table_name: string;
|
|
||||||
env_name: string;
|
|
||||||
required_fields: string[];
|
|
||||||
}>;
|
|
||||||
notes: string[];
|
|
||||||
};
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
const WECHAT_OAUTH_URL = "https://open.weixin.qq.com/connect/oauth2/authorize";
|
|
||||||
|
|
||||||
export type WechatAuthConfig = {
|
|
||||||
appId: string;
|
|
||||||
redirectUri: string;
|
|
||||||
scope: "snsapi_base" | "snsapi_userinfo";
|
|
||||||
state: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getWechatAuthConfig(): WechatAuthConfig | null {
|
|
||||||
const appId = process.env.NEXT_PUBLIC_WECHAT_APP_ID;
|
|
||||||
const redirectUri =
|
|
||||||
process.env.NEXT_PUBLIC_WECHAT_AUTH_REDIRECT_URI ||
|
|
||||||
(typeof window !== "undefined" ? `${window.location.origin}/h5` : "");
|
|
||||||
const scope = (process.env.NEXT_PUBLIC_WECHAT_AUTH_SCOPE || "snsapi_userinfo") as WechatAuthConfig["scope"];
|
|
||||||
|
|
||||||
if (!appId || !redirectUri) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
appId,
|
|
||||||
redirectUri,
|
|
||||||
scope,
|
|
||||||
state: "hyqa_h5"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildWechatOAuthUrl(config: WechatAuthConfig): string {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
appid: config.appId,
|
|
||||||
redirect_uri: config.redirectUri,
|
|
||||||
response_type: "code",
|
|
||||||
scope: config.scope,
|
|
||||||
state: config.state
|
|
||||||
});
|
|
||||||
|
|
||||||
return `${WECHAT_OAUTH_URL}?${params.toString()}#wechat_redirect`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isWechatBrowser(userAgent: string): boolean {
|
|
||||||
return /MicroMessenger/i.test(userAgent);
|
|
||||||
}
|
|
||||||
5
hy_qa_asset_backend/frontend/next-env.d.ts
vendored
5
hy_qa_asset_backend/frontend/next-env.d.ts
vendored
@@ -1,5 +0,0 @@
|
|||||||
/// <reference types="next" />
|
|
||||||
/// <reference types="next/image-types/global" />
|
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
|
||||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
|
||||||
const nextConfig = {
|
|
||||||
reactStrictMode: true
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = nextConfig;
|
|
||||||
|
|
||||||
1645
hy_qa_asset_backend/frontend/package-lock.json
generated
1645
hy_qa_asset_backend/frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,27 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "hy-qa-asset-frontend",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
|
||||||
"scripts": {
|
|
||||||
"dev": "next dev",
|
|
||||||
"build": "next build",
|
|
||||||
"start": "next start",
|
|
||||||
"lint": "next lint"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"lucide-react": "^0.468.0",
|
|
||||||
"next": "14.2.22",
|
|
||||||
"react": "18.3.1",
|
|
||||||
"react-dom": "18.3.1"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "^22.10.2",
|
|
||||||
"@types/react": "^18.3.18",
|
|
||||||
"@types/react-dom": "^18.3.5",
|
|
||||||
"autoprefixer": "^10.4.20",
|
|
||||||
"postcss": "^8.4.49",
|
|
||||||
"tailwindcss": "^3.4.17",
|
|
||||||
"typescript": "^5.7.2"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import type { Config } from "tailwindcss";
|
|
||||||
|
|
||||||
const config: Config = {
|
|
||||||
content: [
|
|
||||||
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
|
||||||
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
|
||||||
"./lib/**/*.{js,ts,jsx,tsx,mdx}"
|
|
||||||
],
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
ink: "#1f2933",
|
|
||||||
line: "#d8dee6",
|
|
||||||
panel: "#f8fafc",
|
|
||||||
jade: "#0f766e",
|
|
||||||
coral: "#c2410c"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
plugins: []
|
|
||||||
};
|
|
||||||
|
|
||||||
export default config;
|
|
||||||
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "es5",
|
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
|
||||||
"allowJs": false,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"strict": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"module": "esnext",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["./*"]
|
|
||||||
},
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"jsx": "preserve",
|
|
||||||
"incremental": true,
|
|
||||||
"plugins": [{ "name": "next" }]
|
|
||||||
},
|
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
|
||||||
"exclude": ["node_modules"]
|
|
||||||
}
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
import fs from "node:fs/promises";
|
|
||||||
import path from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
import { SpreadsheetFile, Workbook } from "@oai/artifact-tool";
|
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
||||||
const outputPath = path.join(__dirname, "大本营答疑资产库_飞书导入模板.xlsx");
|
|
||||||
|
|
||||||
const workbook = Workbook.create();
|
|
||||||
|
|
||||||
const sheets = [
|
|
||||||
{
|
|
||||||
name: "答疑场次",
|
|
||||||
tableName: "SessionTable",
|
|
||||||
headers: [
|
|
||||||
"场次编号",
|
|
||||||
"标题",
|
|
||||||
"日期",
|
|
||||||
"答疑老师",
|
|
||||||
"飞书资料链接",
|
|
||||||
"转写稿",
|
|
||||||
"处理状态",
|
|
||||||
"原始问答数",
|
|
||||||
"标准问答数",
|
|
||||||
"高风险数",
|
|
||||||
"最近同步时间",
|
|
||||||
],
|
|
||||||
sample: [
|
|
||||||
"SESSION-2026-07-01",
|
|
||||||
"2026年7月1日-大本营答疑分享交流",
|
|
||||||
new Date("2026-07-01T00:00:00"),
|
|
||||||
"静静",
|
|
||||||
"https://zcn7hk6n047c.feishu.cn/file/KOxYbadHVox0oKxLfwUcLwfXnPd",
|
|
||||||
"这里粘贴或同步逐字稿正文;后台会优先读取这一列。",
|
|
||||||
"待处理",
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
new Date("2026-07-04T00:00:00"),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "原始问答",
|
|
||||||
tableName: "RawQATable",
|
|
||||||
headers: [
|
|
||||||
"场次编号",
|
|
||||||
"问题",
|
|
||||||
"回答",
|
|
||||||
"答疑老师",
|
|
||||||
"标签",
|
|
||||||
"风险等级",
|
|
||||||
"证据片段",
|
|
||||||
"审核状态",
|
|
||||||
"入库状态",
|
|
||||||
"创建时间",
|
|
||||||
],
|
|
||||||
sample: [
|
|
||||||
"SESSION-2026-07-01",
|
|
||||||
"示例问题:孩子写作业拖拉怎么办?",
|
|
||||||
"示例回答:先区分能力问题和情绪问题,再设计低阻力启动动作。",
|
|
||||||
"静静",
|
|
||||||
"亲子沟通, 学习习惯",
|
|
||||||
"低",
|
|
||||||
"逐字稿中的对应原文片段。",
|
|
||||||
"待审核",
|
|
||||||
"未入库",
|
|
||||||
new Date("2026-07-04T00:00:00"),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "标准问答",
|
|
||||||
tableName: "StandardQATable",
|
|
||||||
headers: [
|
|
||||||
"标准问题",
|
|
||||||
"标准答案",
|
|
||||||
"适用人群",
|
|
||||||
"标签",
|
|
||||||
"风险等级",
|
|
||||||
"来源场次",
|
|
||||||
"来源问题",
|
|
||||||
"状态",
|
|
||||||
"是否可调用",
|
|
||||||
"创建时间",
|
|
||||||
],
|
|
||||||
sample: [
|
|
||||||
"孩子写作业拖拉怎么办?",
|
|
||||||
"先观察拖拉背后的阻力来源,再把任务拆到可以立刻开始的一小步。",
|
|
||||||
"家长",
|
|
||||||
"亲子沟通, 学习习惯",
|
|
||||||
"低",
|
|
||||||
"SESSION-2026-07-01",
|
|
||||||
"示例问题:孩子写作业拖拉怎么办?",
|
|
||||||
"已审核",
|
|
||||||
"是",
|
|
||||||
new Date("2026-07-04T00:00:00"),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const spec of sheets) {
|
|
||||||
const sheet = workbook.worksheets.add(spec.name);
|
|
||||||
const colCount = spec.headers.length;
|
|
||||||
const range = sheet.getRangeByIndexes(0, 0, 2, colCount);
|
|
||||||
range.values = [spec.headers, spec.sample];
|
|
||||||
range.format = {
|
|
||||||
font: { name: "Microsoft YaHei", size: 10 },
|
|
||||||
borders: { preset: "all", style: "thin", color: "#D7DEE8" },
|
|
||||||
wrapText: true,
|
|
||||||
};
|
|
||||||
sheet.getRangeByIndexes(0, 0, 1, colCount).format = {
|
|
||||||
fill: "#155E75",
|
|
||||||
font: { bold: true, color: "#FFFFFF", name: "Microsoft YaHei" },
|
|
||||||
};
|
|
||||||
sheet.freezePanes.freezeRows(1);
|
|
||||||
sheet.showGridLines = false;
|
|
||||||
sheet.tables.add(sheet.getRangeByIndexes(0, 0, 2, colCount), true, spec.tableName);
|
|
||||||
|
|
||||||
for (let i = 0; i < colCount; i += 1) {
|
|
||||||
const column = sheet.getRangeByIndexes(0, i, 2, 1);
|
|
||||||
column.format.columnWidth = Math.min(32, Math.max(14, spec.headers[i].length * 2 + 8));
|
|
||||||
}
|
|
||||||
if (spec.name === "答疑场次") {
|
|
||||||
sheet.getRange("C2").setNumberFormat("yyyy-mm-dd");
|
|
||||||
sheet.getRange("H2:J2").setNumberFormat("#,##0");
|
|
||||||
sheet.getRange("K2").setNumberFormat("yyyy-mm-dd");
|
|
||||||
}
|
|
||||||
if (spec.name === "原始问答") {
|
|
||||||
sheet.getRange("J2").setNumberFormat("yyyy-mm-dd");
|
|
||||||
}
|
|
||||||
if (spec.name === "标准问答") {
|
|
||||||
sheet.getRange("J2").setNumberFormat("yyyy-mm-dd");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const overview = workbook.worksheets.add("导入说明");
|
|
||||||
overview.getRange("A1:D1").merge();
|
|
||||||
overview.getRange("A1").values = [["大本营答疑资产库 - 飞书多维表格导入模板"]];
|
|
||||||
overview.getRange("A1").format = {
|
|
||||||
fill: "#0F766E",
|
|
||||||
font: { bold: true, color: "#FFFFFF", size: 14, name: "Microsoft YaHei" },
|
|
||||||
};
|
|
||||||
overview.getRange("A3:D7").values = [
|
|
||||||
["工作表", "用途", "后台写入方向", "说明"],
|
|
||||||
["答疑场次", "每一期答疑资料和处理状态", "飞书 -> 后台,并回写状态", "转写稿列可直接放全文;飞书资料链接可放文档链接。"],
|
|
||||||
["原始问答", "AI 从逐字稿提炼出的原始问答", "后台 -> 飞书", "审核前的问答资产池。"],
|
|
||||||
["标准问答", "审核通过后的可调用问答", "后台 -> 飞书", "勾选是否可调用后进入调用库。"],
|
|
||||||
["配置", "后台需要 app_token 和 3 个 table_id", "环境变量", "密钥只放 .env,不写入仓库或数据库。"],
|
|
||||||
];
|
|
||||||
overview.getRange("A3:D3").format = {
|
|
||||||
fill: "#155E75",
|
|
||||||
font: { bold: true, color: "#FFFFFF", name: "Microsoft YaHei" },
|
|
||||||
};
|
|
||||||
overview.getRange("A3:D7").format = {
|
|
||||||
borders: { preset: "all", style: "thin", color: "#D7DEE8" },
|
|
||||||
wrapText: true,
|
|
||||||
font: { name: "Microsoft YaHei", size: 10 },
|
|
||||||
};
|
|
||||||
overview.getRange("A:D").format.columnWidth = 24;
|
|
||||||
overview.freezePanes.freezeRows(3);
|
|
||||||
overview.showGridLines = false;
|
|
||||||
|
|
||||||
const errors = await workbook.inspect({
|
|
||||||
kind: "match",
|
|
||||||
searchTerm: "#REF!|#DIV/0!|#VALUE!|#NAME\\?|#N/A",
|
|
||||||
options: { useRegex: true, maxResults: 50 },
|
|
||||||
summary: "final formula error scan",
|
|
||||||
});
|
|
||||||
console.log(errors.ndjson);
|
|
||||||
|
|
||||||
for (const sheetName of ["答疑场次", "原始问答", "标准问答", "导入说明"]) {
|
|
||||||
const preview = await workbook.render({
|
|
||||||
sheetName,
|
|
||||||
autoCrop: "all",
|
|
||||||
scale: 1,
|
|
||||||
format: "png",
|
|
||||||
});
|
|
||||||
await fs.writeFile(
|
|
||||||
path.join(__dirname, `${sheetName}.png`),
|
|
||||||
new Uint8Array(await preview.arrayBuffer()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await fs.mkdir(__dirname, { recursive: true });
|
|
||||||
const output = await SpreadsheetFile.exportXlsx(workbook);
|
|
||||||
await output.save(outputPath);
|
|
||||||
console.log(outputPath);
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 30 KiB |
Binary file not shown.
@@ -1,96 +0,0 @@
|
|||||||
{"kind":"workbook","id":"wb/c9e5gx","sheets":4,"tables":4}
|
|
||||||
{"kind":"sheet","id":"ws/h3cgd5","name":"答疑场次","index":0,"range":"A1:K2","address":"A1:K2","tables":1}
|
|
||||||
{"kind":"table","sheet":"答疑场次","address":"A1:K2","rows":2,"cols":11,"values":[["场次编号","标题","日期","答疑老师","飞书资料链接","转写稿","处理状态","原始问答数","标准问答数","高风险数","最近同步时间"],["SESSION-2026-07-01","2026年7月1日-大本营答疑分享交流","2026-06-30T16:00:00.000Z","静静","https://zcn7hk6n047c.feishu.cn/file/KOxYbadHVox0oKxLfwUcLwfXnPd","这里粘贴或同步逐字稿正文;后台会优先读取这一列。","待处理",0,0,0,"2026-07-03T16:00:00.000Z"]]}
|
|
||||||
{"kind":"region","sheet":"答疑场次","address":"A1:K2","rows":2,"cols":11,"nonEmpty":22,"text":22,"maxTextLength":63,"preview":[["场次编号","标题","日期","答疑老师","飞书资料链接","转写稿"],["SESSION-2026-07-01","2026年7月1日-大本营答疑分享交流","46204","静静","https://zcn7hk6n047c.feishu.cn/file/KOxYbadHVox0oKxLfwUcLwfXnPd","这里粘贴或同步逐字稿正文;后台会优先读取这一列。"]],"previewAddress":"A1:F2","previewRows":2,"previewCols":6}
|
|
||||||
{"kind":"computedStyle","sheet":"答疑场次","for":"A1","style":{"fill":{"type":1,"color":{"type":1,"value":"155E75"},"gradientStops":[],"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Microsoft YaHei","fill":{"type":0,"color":{"type":1,"value":"FFFFFF"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":5}}
|
|
||||||
{"kind":"computedStyle","sheet":"答疑场次","for":"B1","style":{"fill":{"type":1,"color":{"type":1,"value":"155E75"},"gradientStops":[],"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Microsoft YaHei","fill":{"type":0,"color":{"type":1,"value":"FFFFFF"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":5}}
|
|
||||||
{"kind":"computedStyle","sheet":"答疑场次","for":"C1","style":{"fill":{"type":1,"color":{"type":1,"value":"155E75"},"gradientStops":[],"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Microsoft YaHei","fill":{"type":0,"color":{"type":1,"value":"FFFFFF"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":5}}
|
|
||||||
{"kind":"computedStyle","sheet":"答疑场次","for":"D1","style":{"fill":{"type":1,"color":{"type":1,"value":"155E75"},"gradientStops":[],"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Microsoft YaHei","fill":{"type":0,"color":{"type":1,"value":"FFFFFF"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":5}}
|
|
||||||
{"kind":"computedStyle","sheet":"答疑场次","for":"E1","style":{"fill":{"type":1,"color":{"type":1,"value":"155E75"},"gradientStops":[],"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Microsoft YaHei","fill":{"type":0,"color":{"type":1,"value":"FFFFFF"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":5}}
|
|
||||||
{"kind":"computedStyle","sheet":"答疑场次","for":"F1","style":{"fill":{"type":1,"color":{"type":1,"value":"155E75"},"gradientStops":[],"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Microsoft YaHei","fill":{"type":0,"color":{"type":1,"value":"FFFFFF"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":5}}
|
|
||||||
{"kind":"computedStyle","sheet":"答疑场次","for":"G1","style":{"fill":{"type":1,"color":{"type":1,"value":"155E75"},"gradientStops":[],"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Microsoft YaHei","fill":{"type":0,"color":{"type":1,"value":"FFFFFF"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":5}}
|
|
||||||
{"kind":"computedStyle","sheet":"答疑场次","for":"H1","style":{"fill":{"type":1,"color":{"type":1,"value":"155E75"},"gradientStops":[],"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Microsoft YaHei","fill":{"type":0,"color":{"type":1,"value":"FFFFFF"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":5}}
|
|
||||||
{"kind":"computedStyle","sheet":"答疑场次","for":"I1","style":{"fill":{"type":1,"color":{"type":1,"value":"155E75"},"gradientStops":[],"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Microsoft YaHei","fill":{"type":0,"color":{"type":1,"value":"FFFFFF"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":5}}
|
|
||||||
{"kind":"computedStyle","sheet":"答疑场次","for":"J1","style":{"fill":{"type":1,"color":{"type":1,"value":"155E75"},"gradientStops":[],"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Microsoft YaHei","fill":{"type":0,"color":{"type":1,"value":"FFFFFF"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":5}}
|
|
||||||
{"kind":"computedStyle","sheet":"答疑场次","for":"K1","style":{"fill":{"type":1,"color":{"type":1,"value":"155E75"},"gradientStops":[],"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Microsoft YaHei","fill":{"type":0,"color":{"type":1,"value":"FFFFFF"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":5}}
|
|
||||||
{"kind":"computedStyle","sheet":"答疑场次","for":"A2","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Microsoft YaHei"},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":3}}
|
|
||||||
{"kind":"computedStyle","sheet":"答疑场次","for":"B2","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Microsoft YaHei"},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":3}}
|
|
||||||
{"kind":"computedStyle","sheet":"答疑场次","for":"C2","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Microsoft YaHei"},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"numberFormat":"yyyy-mm-dd","wrapText":true,"horizontalAlignment":"general","styleId":6}}
|
|
||||||
{"kind":"computedStyle","sheet":"答疑场次","for":"D2","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Microsoft YaHei"},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":3}}
|
|
||||||
{"kind":"computedStyle","sheet":"答疑场次","for":"E2","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Microsoft YaHei"},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":3}}
|
|
||||||
{"kind":"computedStyle","sheet":"答疑场次","for":"F2","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Microsoft YaHei"},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":3}}
|
|
||||||
{"kind":"computedStyle","sheet":"答疑场次","for":"G2","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Microsoft YaHei"},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":3}}
|
|
||||||
{"kind":"computedStyle","sheet":"答疑场次","for":"H2","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Microsoft YaHei"},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"numberFormat":"#,##0","wrapText":true,"horizontalAlignment":"general","styleId":7}}
|
|
||||||
{"kind":"computedStyle","sheet":"答疑场次","for":"I2","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Microsoft YaHei"},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"numberFormat":"#,##0","wrapText":true,"horizontalAlignment":"general","styleId":7}}
|
|
||||||
{"kind":"computedStyle","sheet":"答疑场次","for":"J2","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Microsoft YaHei"},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"numberFormat":"#,##0","wrapText":true,"horizontalAlignment":"general","styleId":7}}
|
|
||||||
{"kind":"computedStyle","sheet":"答疑场次","for":"K2","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Microsoft YaHei"},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"numberFormat":"yyyy-mm-dd","wrapText":true,"horizontalAlignment":"general","styleId":6}}
|
|
||||||
{"kind":"sheet","id":"ws/oqgn45","name":"原始问答","index":1,"range":"A1:J2","address":"A1:J2","tables":1}
|
|
||||||
{"kind":"table","sheet":"原始问答","address":"A1:J2","rows":2,"cols":10,"values":[["场次编号","问题","回答","答疑老师","标签","风险等级","证据片段","审核状态","入库状态","创建时间"],["SESSION-2026-07-01","示例问题:孩子写作业拖拉怎么办?","示例回答:先区分能力问题和情绪问题,再设计低阻力启动动作。","静静","亲子沟通, 学习习惯","低","逐字稿中的对应原文片段。","待审核","未入库","2026-07-03T16:00:00.000Z"]]}
|
|
||||||
{"kind":"region","sheet":"原始问答","address":"A1:J2","rows":2,"cols":10,"nonEmpty":20,"text":20,"maxTextLength":29,"preview":[["场次编号","问题","回答","答疑老师","标签","风险等级"],["SESSION-2026-07-01","示例问题:孩子写作业拖拉怎么办?","示例回答:先区分能力问题和情绪问题,再设计低阻力启动动作。","静静","亲子沟通, 学习习惯","低"]],"previewAddress":"A1:F2","previewRows":2,"previewCols":6}
|
|
||||||
{"kind":"computedStyle","sheet":"原始问答","for":"A1","style":{"fill":{"type":1,"color":{"type":1,"value":"155E75"},"gradientStops":[],"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Microsoft YaHei","fill":{"type":0,"color":{"type":1,"value":"FFFFFF"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":5}}
|
|
||||||
{"kind":"computedStyle","sheet":"原始问答","for":"B1","style":{"fill":{"type":1,"color":{"type":1,"value":"155E75"},"gradientStops":[],"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Microsoft YaHei","fill":{"type":0,"color":{"type":1,"value":"FFFFFF"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":5}}
|
|
||||||
{"kind":"computedStyle","sheet":"原始问答","for":"C1","style":{"fill":{"type":1,"color":{"type":1,"value":"155E75"},"gradientStops":[],"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Microsoft YaHei","fill":{"type":0,"color":{"type":1,"value":"FFFFFF"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":5}}
|
|
||||||
{"kind":"computedStyle","sheet":"原始问答","for":"D1","style":{"fill":{"type":1,"color":{"type":1,"value":"155E75"},"gradientStops":[],"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Microsoft YaHei","fill":{"type":0,"color":{"type":1,"value":"FFFFFF"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":5}}
|
|
||||||
{"kind":"computedStyle","sheet":"原始问答","for":"E1","style":{"fill":{"type":1,"color":{"type":1,"value":"155E75"},"gradientStops":[],"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Microsoft YaHei","fill":{"type":0,"color":{"type":1,"value":"FFFFFF"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":5}}
|
|
||||||
{"kind":"computedStyle","sheet":"原始问答","for":"F1","style":{"fill":{"type":1,"color":{"type":1,"value":"155E75"},"gradientStops":[],"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Microsoft YaHei","fill":{"type":0,"color":{"type":1,"value":"FFFFFF"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":5}}
|
|
||||||
{"kind":"computedStyle","sheet":"原始问答","for":"G1","style":{"fill":{"type":1,"color":{"type":1,"value":"155E75"},"gradientStops":[],"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Microsoft YaHei","fill":{"type":0,"color":{"type":1,"value":"FFFFFF"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":5}}
|
|
||||||
{"kind":"computedStyle","sheet":"原始问答","for":"H1","style":{"fill":{"type":1,"color":{"type":1,"value":"155E75"},"gradientStops":[],"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Microsoft YaHei","fill":{"type":0,"color":{"type":1,"value":"FFFFFF"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":5}}
|
|
||||||
{"kind":"computedStyle","sheet":"原始问答","for":"I1","style":{"fill":{"type":1,"color":{"type":1,"value":"155E75"},"gradientStops":[],"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Microsoft YaHei","fill":{"type":0,"color":{"type":1,"value":"FFFFFF"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":5}}
|
|
||||||
{"kind":"computedStyle","sheet":"原始问答","for":"J1","style":{"fill":{"type":1,"color":{"type":1,"value":"155E75"},"gradientStops":[],"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Microsoft YaHei","fill":{"type":0,"color":{"type":1,"value":"FFFFFF"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":5}}
|
|
||||||
{"kind":"computedStyle","sheet":"原始问答","for":"A2","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Microsoft YaHei"},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":3}}
|
|
||||||
{"kind":"computedStyle","sheet":"原始问答","for":"B2","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Microsoft YaHei"},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":3}}
|
|
||||||
{"kind":"computedStyle","sheet":"原始问答","for":"C2","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Microsoft YaHei"},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":3}}
|
|
||||||
{"kind":"computedStyle","sheet":"原始问答","for":"D2","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Microsoft YaHei"},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":3}}
|
|
||||||
{"kind":"computedStyle","sheet":"原始问答","for":"E2","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Microsoft YaHei"},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":3}}
|
|
||||||
{"kind":"computedStyle","sheet":"原始问答","for":"F2","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Microsoft YaHei"},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":3}}
|
|
||||||
{"kind":"computedStyle","sheet":"原始问答","for":"G2","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Microsoft YaHei"},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":3}}
|
|
||||||
{"kind":"computedStyle","sheet":"原始问答","for":"H2","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Microsoft YaHei"},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":3}}
|
|
||||||
{"kind":"computedStyle","sheet":"原始问答","for":"I2","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Microsoft YaHei"},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":3}}
|
|
||||||
{"kind":"computedStyle","sheet":"原始问答","for":"J2","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Microsoft YaHei"},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"numberFormat":"yyyy-mm-dd","wrapText":true,"horizontalAlignment":"general","styleId":6}}
|
|
||||||
{"kind":"sheet","id":"ws/1krc08","name":"标准问答","index":2,"range":"A1:J2","address":"A1:J2","tables":1}
|
|
||||||
{"kind":"table","sheet":"标准问答","address":"A1:J2","rows":2,"cols":10,"values":[["标准问题","标准答案","适用人群","标签","风险等级","来源场次","来源问题","状态","是否可调用","创建时间"],["孩子写作业拖拉怎么办?","先观察拖拉背后的阻力来源,再把任务拆到可以立刻开始的一小步。","家长","亲子沟通, 学习习惯","低","SESSION-2026-07-01","示例问题:孩子写作业拖拉怎么办?","已审核","是","2026-07-03T16:00:00.000Z"]]}
|
|
||||||
{"kind":"region","sheet":"标准问答","address":"A1:J2","rows":2,"cols":10,"nonEmpty":20,"text":20,"maxTextLength":30,"preview":[["标准问题","标准答案","适用人群","标签","风险等级","来源场次"],["孩子写作业拖拉怎么办?","先观察拖拉背后的阻力来源,再把任务拆到可以立刻开始的一小步。","家长","亲子沟通, 学习习惯","低","SESSION-2026-07-01"]],"previewAddress":"A1:F2","previewRows":2,"previewCols":6}
|
|
||||||
{"kind":"computedStyle","sheet":"标准问答","for":"A1","style":{"fill":{"type":1,"color":{"type":1,"value":"155E75"},"gradientStops":[],"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Microsoft YaHei","fill":{"type":0,"color":{"type":1,"value":"FFFFFF"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":5}}
|
|
||||||
{"kind":"computedStyle","sheet":"标准问答","for":"B1","style":{"fill":{"type":1,"color":{"type":1,"value":"155E75"},"gradientStops":[],"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Microsoft YaHei","fill":{"type":0,"color":{"type":1,"value":"FFFFFF"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":5}}
|
|
||||||
{"kind":"computedStyle","sheet":"标准问答","for":"C1","style":{"fill":{"type":1,"color":{"type":1,"value":"155E75"},"gradientStops":[],"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Microsoft YaHei","fill":{"type":0,"color":{"type":1,"value":"FFFFFF"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":5}}
|
|
||||||
{"kind":"computedStyle","sheet":"标准问答","for":"D1","style":{"fill":{"type":1,"color":{"type":1,"value":"155E75"},"gradientStops":[],"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Microsoft YaHei","fill":{"type":0,"color":{"type":1,"value":"FFFFFF"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":5}}
|
|
||||||
{"kind":"computedStyle","sheet":"标准问答","for":"E1","style":{"fill":{"type":1,"color":{"type":1,"value":"155E75"},"gradientStops":[],"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Microsoft YaHei","fill":{"type":0,"color":{"type":1,"value":"FFFFFF"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":5}}
|
|
||||||
{"kind":"computedStyle","sheet":"标准问答","for":"F1","style":{"fill":{"type":1,"color":{"type":1,"value":"155E75"},"gradientStops":[],"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Microsoft YaHei","fill":{"type":0,"color":{"type":1,"value":"FFFFFF"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":5}}
|
|
||||||
{"kind":"computedStyle","sheet":"标准问答","for":"G1","style":{"fill":{"type":1,"color":{"type":1,"value":"155E75"},"gradientStops":[],"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Microsoft YaHei","fill":{"type":0,"color":{"type":1,"value":"FFFFFF"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":5}}
|
|
||||||
{"kind":"computedStyle","sheet":"标准问答","for":"H1","style":{"fill":{"type":1,"color":{"type":1,"value":"155E75"},"gradientStops":[],"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Microsoft YaHei","fill":{"type":0,"color":{"type":1,"value":"FFFFFF"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":5}}
|
|
||||||
{"kind":"computedStyle","sheet":"标准问答","for":"I1","style":{"fill":{"type":1,"color":{"type":1,"value":"155E75"},"gradientStops":[],"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Microsoft YaHei","fill":{"type":0,"color":{"type":1,"value":"FFFFFF"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":5}}
|
|
||||||
{"kind":"computedStyle","sheet":"标准问答","for":"J1","style":{"fill":{"type":1,"color":{"type":1,"value":"155E75"},"gradientStops":[],"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Microsoft YaHei","fill":{"type":0,"color":{"type":1,"value":"FFFFFF"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":5}}
|
|
||||||
{"kind":"computedStyle","sheet":"标准问答","for":"A2","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Microsoft YaHei"},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":3}}
|
|
||||||
{"kind":"computedStyle","sheet":"标准问答","for":"B2","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Microsoft YaHei"},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":3}}
|
|
||||||
{"kind":"computedStyle","sheet":"标准问答","for":"C2","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Microsoft YaHei"},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":3}}
|
|
||||||
{"kind":"computedStyle","sheet":"标准问答","for":"D2","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Microsoft YaHei"},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":3}}
|
|
||||||
{"kind":"computedStyle","sheet":"标准问答","for":"E2","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Microsoft YaHei"},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":3}}
|
|
||||||
{"kind":"computedStyle","sheet":"标准问答","for":"F2","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Microsoft YaHei"},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":3}}
|
|
||||||
{"kind":"computedStyle","sheet":"标准问答","for":"G2","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Microsoft YaHei"},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":3}}
|
|
||||||
{"kind":"computedStyle","sheet":"标准问答","for":"H2","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Microsoft YaHei"},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":3}}
|
|
||||||
{"kind":"computedStyle","sheet":"标准问答","for":"I2","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Microsoft YaHei"},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":3}}
|
|
||||||
{"kind":"computedStyle","sheet":"标准问答","for":"J2","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Microsoft YaHei"},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"numberFormat":"yyyy-mm-dd","wrapText":true,"horizontalAlignment":"general","styleId":6}}
|
|
||||||
{"kind":"sheet","id":"ws/xytqjs","name":"导入说明","index":3,"range":"A1:D7","address":"A1:D7","tables":1}
|
|
||||||
{"kind":"table","sheet":"导入说明","address":"A1:D7","rows":7,"cols":4,"values":[["大本营答疑资产库 - 飞书多维表格导入模板",null,null,null],[null,null,null,null],["工作表","用途","后台写入方向","说明"],["答疑场次","每一期答疑资料和处理状态","飞书 -> 后台,并回写状态","转写稿列可直接放全文;飞书资料链接可放文档链接。"],["原始问答","AI 从逐字稿提炼出的原始问答","后台 -> 飞书","审核前的问答资产池。"],["标准问答","审核通过后的可调用问答","后台 -> 飞书","勾选是否可调用后进入调用库。"],["配置","后台需要 app_token 和 3 个 table_id","环境变量","密钥只放 .env,不写入仓库或数据库。"]]}
|
|
||||||
{"kind":"region","sheet":"导入说明","address":"A3:D7","rows":5,"cols":4,"nonEmpty":20,"text":20,"maxTextLength":29,"preview":[["工作表","用途","后台写入方向","说明"],["答疑场次","每一期答疑资料和处理状态","飞书 -> 后台,并回写状态","转写稿列可直接放全文;飞书资料链接可放文档链接。"],["原始问答","AI 从逐字稿提炼出的原始问答","后台 -> 飞书","审核前的问答资产池。"],["标准问答","审核通过后的可调用问答","后台 -> 飞书","勾选是否可调用后进入调用库。"],["配置","后台需要 app_token 和 3 个 table_id","环境变量","密钥只放 .env,不写入仓库或数据库。"]],"previewAddress":"A3:D7","previewRows":5,"previewCols":4}
|
|
||||||
{"kind":"computedStyle","sheet":"导入说明","for":"A1","style":{"fill":{"type":1,"color":{"type":1,"value":"0F766E"},"gradientStops":[],"pictureEffects":[]},"font":{"bold":true,"fontSize":14,"typeface":"Microsoft YaHei","fill":{"type":0,"color":{"type":1,"value":"FFFFFF"},"gradientStops":[],"pictureEffects":[]}},"border":{},"wrapText":false,"horizontalAlignment":"general","styleId":9}}
|
|
||||||
{"kind":"computedStyle","sheet":"导入说明","for":"A3","style":{"fill":{"type":1,"color":{"type":1,"value":"155E75"},"gradientStops":[],"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Microsoft YaHei","fill":{"type":0,"color":{"type":1,"value":"FFFFFF"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":5}}
|
|
||||||
{"kind":"computedStyle","sheet":"导入说明","for":"B3","style":{"fill":{"type":1,"color":{"type":1,"value":"155E75"},"gradientStops":[],"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Microsoft YaHei","fill":{"type":0,"color":{"type":1,"value":"FFFFFF"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":5}}
|
|
||||||
{"kind":"computedStyle","sheet":"导入说明","for":"C3","style":{"fill":{"type":1,"color":{"type":1,"value":"155E75"},"gradientStops":[],"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Microsoft YaHei","fill":{"type":0,"color":{"type":1,"value":"FFFFFF"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":5}}
|
|
||||||
{"kind":"computedStyle","sheet":"导入说明","for":"D3","style":{"fill":{"type":1,"color":{"type":1,"value":"155E75"},"gradientStops":[],"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Microsoft YaHei","fill":{"type":0,"color":{"type":1,"value":"FFFFFF"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":5}}
|
|
||||||
{"kind":"computedStyle","sheet":"导入说明","for":"A4","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Microsoft YaHei"},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":3}}
|
|
||||||
{"kind":"computedStyle","sheet":"导入说明","for":"B4","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Microsoft YaHei"},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":3}}
|
|
||||||
{"kind":"computedStyle","sheet":"导入说明","for":"C4","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Microsoft YaHei"},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":3}}
|
|
||||||
{"kind":"computedStyle","sheet":"导入说明","for":"D4","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Microsoft YaHei"},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":3}}
|
|
||||||
{"kind":"computedStyle","sheet":"导入说明","for":"A5","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Microsoft YaHei"},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":3}}
|
|
||||||
{"kind":"computedStyle","sheet":"导入说明","for":"B5","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Microsoft YaHei"},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":3}}
|
|
||||||
{"kind":"computedStyle","sheet":"导入说明","for":"C5","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Microsoft YaHei"},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":3}}
|
|
||||||
{"kind":"computedStyle","sheet":"导入说明","for":"D5","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Microsoft YaHei"},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":3}}
|
|
||||||
{"kind":"computedStyle","sheet":"导入说明","for":"A6","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Microsoft YaHei"},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":3}}
|
|
||||||
{"kind":"computedStyle","sheet":"导入说明","for":"B6","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Microsoft YaHei"},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":3}}
|
|
||||||
{"kind":"computedStyle","sheet":"导入说明","for":"C6","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Microsoft YaHei"},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":3}}
|
|
||||||
{"kind":"computedStyle","sheet":"导入说明","for":"D6","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Microsoft YaHei"},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":3}}
|
|
||||||
{"kind":"computedStyle","sheet":"导入说明","for":"A7","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Microsoft YaHei"},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":3}}
|
|
||||||
{"kind":"computedStyle","sheet":"导入说明","for":"B7","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Microsoft YaHei"},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":3}}
|
|
||||||
{"kind":"computedStyle","sheet":"导入说明","for":"C7","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Microsoft YaHei"},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":3}}
|
|
||||||
{"kind":"computedStyle","sheet":"导入说明","for":"D7","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Microsoft YaHei"},"border":{"top":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"bottom":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"left":{"style":"thin","color":{"type":1,"value":"D7DEE8"}},"right":{"style":"thin","color":{"type":1,"value":"D7DEE8"}}},"wrapText":true,"horizontalAlignment":"general","styleId":3}}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 43 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 30 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 32 KiB |
Reference in New Issue
Block a user