Initial MVP for QA asset backend

This commit is contained in:
2026-07-05 17:44:15 +08:00
commit 95b5e09fd4
71 changed files with 6546 additions and 0 deletions

View File

@@ -0,0 +1,18 @@
DATABASE_URL=
OPENAI_API_KEY=
OPENAI_BASE_URL=
MODEL_NAME=gpt-4.1-mini
FEISHU_APP_ID=
FEISHU_APP_SECRET=
FEISHU_APP_TOKEN=
# If the Base URL is under /wiki/<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

18
hy_qa_asset_backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,18 @@
.env
.env.local
.venv/
venv/
__pycache__/
*.pyc
*.pyo
*.db
*.sqlite
.pytest_cache/
.mypy_cache/
node_modules/
.next/
out/
dist/
coverage/
*.log
.DS_Store

View File

@@ -0,0 +1,172 @@
# 大本营答疑资产后台系统 MVP
这是一个用于沉淀“大本营每周三答疑内容”的后台系统 MVP。第一阶段跑通闭环资料进入、系统发现、AI 清洗、数据库沉淀、人工审核、标准问答入库、人工标记可调用。
## 系统架构
- 前端Next.js + React + TypeScript + TailwindCSS提供独立网页后台。
- 后端FastAPI + SQLAlchemy + Pydantic + APScheduler。
- 数据库:优先 PostgreSQL未配置 `DATABASE_URL` 时自动使用本地 SQLite。
- AI兼容 OpenAI Chat Completions API没有 `OPENAI_API_KEY` 时走 mock AI。
- 飞书:保留真实 API 适配器;没有飞书密钥时走 mock mode。
## 第一阶段功能
- 答疑场次管理,支持手动创建测试场次。
- 手动触发或定时触发黑灯任务。
- mock 飞书扫描与 mock 转写稿读取。
- AI 清洗模块,真实 OpenAI 兼容 API 与 mock 输出双通道。
- 原始问答拆解、标签、课程阶段、风险等级、脱敏状态入库。
- 待审核卡片页,支持通过入库、修改后通过、需修改、不入库、禁止使用、标记高风险、标记需脱敏。
- 标准问答库,审核通过后默认 `not_callable`,必须人工标记后才能进入可调用库。
- 智能体可调用库接口只返回 `approved + callable + low/medium + 已脱敏/无需脱敏` 的标准问答。
- 任务日志和审核日志可追溯。
## 不做什么
- 不做正式“千问千答智能体”。
- 不做学员端问答界面。
- 不绕过人工审核。
- 不让未审核内容进入标准问答库或可调用库。
- 不把 AI 生成内容伪装成院长或辅导老师原话。
- 不做心理诊断、医疗建议、法律判断或课程效果承诺。
## 本地启动
后端:
```bash
cd hy_qa_asset_backend/backend
python -m venv .venv
.venv\Scripts\activate
pip install -r requirements.txt
uvicorn app.main:app --reload --host 127.0.0.1 --port 8000
```
前端:
```bash
cd hy_qa_asset_backend/frontend
npm install
npm run dev
```
访问:
- 前端后台http://localhost:3000
- 后端 APIhttp://127.0.0.1:8000/docs
## 环境变量
复制 `.env.example``.env`,按需填写:
- `DATABASE_URL`:为空时使用 SQLitePostgreSQL 示例:`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、测试用例和生产部署配置。

View File

@@ -0,0 +1,12 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -0,0 +1,2 @@
"""Backend package for 大本营答疑资产后台系统 MVP."""

View File

@@ -0,0 +1,2 @@
"""API routers."""

View File

@@ -0,0 +1,42 @@
from fastapi import APIRouter, Depends
from sqlalchemy import or_
from sqlalchemy.orm import Session
from app import models, schemas
from app.database import get_db
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
@router.get("/summary", response_model=schemas.DashboardSummary)
def dashboard_summary(db: Session = Depends(get_db)) -> schemas.DashboardSummary:
last_task = db.query(models.TaskRun).order_by(models.TaskRun.created_at.desc()).first()
return schemas.DashboardSummary(
total_sessions=db.query(models.FeishuSession).count(),
processed_sessions=db.query(models.FeishuSession)
.filter(models.FeishuSession.process_status.in_([models.ProcessStatus.pending_review, models.ProcessStatus.completed]))
.count(),
pending_review_count=db.query(models.RawQAItem)
.filter(models.RawQAItem.review_status.in_([models.ReviewStatus.pending, models.ReviewStatus.reviewing]))
.count(),
high_risk_count=db.query(models.RawQAItem)
.filter(or_(models.RawQAItem.risk_level == models.RiskLevel.high, models.RawQAItem.risk_level == models.RiskLevel.forbidden))
.count(),
approved_qa_count=db.query(models.RawQAItem).filter(models.RawQAItem.review_status == models.ReviewStatus.approved).count(),
standard_qa_count=db.query(models.StandardQAItem).count(),
callable_qa_count=db.query(models.StandardQAItem)
.join(models.RawQAItem)
.filter(
models.StandardQAItem.audit_status == models.AuditStatus.approved,
models.StandardQAItem.call_status == models.CallStatus.callable,
models.StandardQAItem.risk_level.in_([models.RiskLevel.low, models.RiskLevel.medium]),
models.RawQAItem.desensitization_status.in_(
[models.DesensitizationStatus.done, models.DesensitizationStatus.not_needed]
),
)
.count(),
last_task_status=last_task.task_status.value if last_task else None,
last_task_time=last_task.ended_at if last_task else None,
last_task_error=last_task.error_message if last_task else None,
)

View File

@@ -0,0 +1,112 @@
from fastapi import APIRouter
from app import schemas
from app.services.feishu_client import FeishuClient
router = APIRouter(prefix="/feishu", tags=["feishu"])
@router.get("/status", response_model=schemas.FeishuStatus)
def feishu_status() -> dict:
return FeishuClient().connection_status()
@router.get("/setup-guide", response_model=schemas.FeishuSetupGuide)
def setup_guide() -> schemas.FeishuSetupGuide:
return schemas.FeishuSetupGuide(
required_env=[
"FEISHU_APP_ID",
"FEISHU_APP_SECRET",
"FEISHU_APP_TOKEN 或 FEISHU_WIKI_NODE_TOKEN",
"FEISHU_TABLE_ID_SESSION",
"FEISHU_TABLE_ID_RAW_QA",
"FEISHU_TABLE_ID_STANDARD_QA",
"MOCK_MODE=false",
],
required_permissions=[
"bitable:app:readonly",
"bitable:app:readwrite",
"docx:document:readonly",
"drive:drive:readonly",
"如果要读取文档链接里的内容,需要把飞书应用/机器人加入目标文档协作者。",
],
tables=[
schemas.FeishuTableTemplate(
table_key="session",
table_name="答疑场次",
env_name="FEISHU_TABLE_ID_SESSION",
required_fields=[
"场次编号",
"标题",
"日期",
"答疑老师",
"飞书资料链接",
"转写稿",
"处理状态",
"问答总数",
"待审核数",
"已审核数",
"已入库数",
"失败原因",
],
),
schemas.FeishuTableTemplate(
table_key="raw_qa",
table_name="原始问答",
env_name="FEISHU_TABLE_ID_RAW_QA",
required_fields=[
"问答编号",
"场次编号",
"原始问题",
"问题整理版",
"原始回答",
"回答整理版",
"AI建议标准问题",
"AI建议标准回答",
"回答人",
"一级主题",
"问题标签",
"课程阶段",
"适用人群",
"情绪强度",
"风险等级",
"风险类型",
"风险说明",
"是否需脱敏",
"脱敏状态",
"审核状态",
"建议入库",
"来源时间戳",
"审核备注",
],
),
schemas.FeishuTableTemplate(
table_key="standard_qa",
table_name="标准问答",
env_name="FEISHU_TABLE_ID_STANDARD_QA",
required_fields=[
"标准编号",
"来源原始问答ID",
"来源场次ID",
"标准问题",
"标准回答",
"相似问法",
"一级主题",
"问题标签",
"课程阶段",
"适用人群",
"回答边界",
"禁止表达",
"风险等级",
"审核状态",
"调用状态",
"禁用原因",
],
),
],
notes=[
"场次表中只有处理状态为空、unprocessed 或 未处理 的记录会被后台扫描处理。",
"如果暂时不接文档 API可以直接把完整文字稿粘到场次表的 转写稿 字段。",
"敏感密钥不要写入 system_settings请写入 backend/.env 或运行环境变量。",
],
)

View File

@@ -0,0 +1,18 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app import models, schemas
from app.database import get_db
router = APIRouter(prefix="/logs", tags=["logs"])
@router.get("/audit", response_model=list[schemas.AuditLogRead])
def audit_logs(db: Session = Depends(get_db)) -> list[models.AuditLog]:
return db.query(models.AuditLog).order_by(models.AuditLog.created_at.desc()).limit(200).all()
@router.get("/tasks", response_model=list[schemas.TaskRunRead])
def task_logs(db: Session = Depends(get_db)) -> list[models.TaskRun]:
return db.query(models.TaskRun).order_by(models.TaskRun.created_at.desc()).limit(200).all()

View File

@@ -0,0 +1,150 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app import models, schemas
from app.database import get_db
from app.services.standard_qa_service import add_audit_log, approve_raw_qa, refresh_session_counts, set_raw_status
router = APIRouter(prefix="/raw-qa", tags=["raw_qa"])
def _get_raw(db: Session, raw_id: int) -> models.RawQAItem:
raw = db.get(models.RawQAItem, raw_id)
if not raw:
raise HTTPException(status_code=404, detail="原始问答不存在")
return raw
@router.get("", response_model=list[schemas.RawQAItemRead])
def list_raw_qa(
review_status: str | None = None,
risk_level: str | None = None,
session_id: int | None = None,
primary_topic: str | None = None,
course_stage: str | None = None,
db: Session = Depends(get_db),
) -> list[models.RawQAItem]:
query = db.query(models.RawQAItem)
if review_status:
query = query.filter(models.RawQAItem.review_status == review_status)
if risk_level:
query = query.filter(models.RawQAItem.risk_level == risk_level)
if session_id:
query = query.filter(models.RawQAItem.session_id == session_id)
if primary_topic:
query = query.filter(models.RawQAItem.primary_topic == primary_topic)
if course_stage:
query = query.filter(models.RawQAItem.course_stage == course_stage)
return query.order_by(models.RawQAItem.created_at.desc()).all()
@router.get("/{raw_id}", response_model=schemas.RawQAItemRead)
def get_raw_qa(raw_id: int, db: Session = Depends(get_db)) -> models.RawQAItem:
return _get_raw(db, raw_id)
@router.patch("/{raw_id}", response_model=schemas.RawQAItemRead)
def update_raw_qa(raw_id: int, payload: schemas.RawQAItemUpdate, db: Session = Depends(get_db)) -> models.RawQAItem:
raw = _get_raw(db, raw_id)
for key, value in payload.model_dump(exclude_unset=True).items():
setattr(raw, key, value)
db.commit()
db.refresh(raw)
return raw
@router.post("/{raw_id}/approve", response_model=schemas.RawQAItemRead)
def approve(raw_id: int, payload: schemas.ReviewActionRequest | None = None, db: Session = Depends(get_db)) -> models.RawQAItem:
payload = payload or schemas.ReviewActionRequest()
return approve_raw_qa(db, _get_raw(db, raw_id), payload.reviewer_id, payload.comment)
@router.post("/{raw_id}/revise", response_model=schemas.RawQAItemRead)
def revise(raw_id: int, payload: schemas.ReviewActionRequest | None = None, db: Session = Depends(get_db)) -> models.RawQAItem:
payload = payload or schemas.ReviewActionRequest()
return set_raw_status(
db,
_get_raw(db, raw_id),
models.ReviewStatus.needs_revision,
models.AuditAction.revise,
payload.reviewer_id,
payload.comment,
)
@router.post("/{raw_id}/reject", response_model=schemas.RawQAItemRead)
def reject(raw_id: int, payload: schemas.ReviewActionRequest | None = None, db: Session = Depends(get_db)) -> models.RawQAItem:
payload = payload or schemas.ReviewActionRequest()
return set_raw_status(
db,
_get_raw(db, raw_id),
models.ReviewStatus.rejected,
models.AuditAction.reject,
payload.reviewer_id,
payload.comment,
)
@router.post("/{raw_id}/forbid", response_model=schemas.RawQAItemRead)
def forbid(raw_id: int, payload: schemas.ReviewActionRequest | None = None, db: Session = Depends(get_db)) -> models.RawQAItem:
payload = payload or schemas.ReviewActionRequest()
raw = _get_raw(db, raw_id)
raw.suggested_to_standard_qa = False
return set_raw_status(
db,
raw,
models.ReviewStatus.forbidden,
models.AuditAction.forbid,
payload.reviewer_id,
payload.comment,
)
@router.post("/{raw_id}/mark-high-risk", response_model=schemas.RawQAItemRead)
def mark_high_risk(
raw_id: int, payload: schemas.ReviewActionRequest | None = None, db: Session = Depends(get_db)
) -> models.RawQAItem:
payload = payload or schemas.ReviewActionRequest()
raw = _get_raw(db, raw_id)
before = raw.risk_level.value
raw.risk_level = models.RiskLevel.high
raw.suggested_to_standard_qa = False
add_audit_log(
db,
user_id=payload.reviewer_id,
target_type=models.TargetType.raw_qa,
target_id=raw.id,
action=models.AuditAction.mark_high_risk,
before_status=before,
after_status=raw.risk_level.value,
comment=payload.comment,
)
refresh_session_counts(db, raw.session_id)
db.commit()
db.refresh(raw)
return raw
@router.post("/{raw_id}/mark-desensitization", response_model=schemas.RawQAItemRead)
def mark_desensitization(
raw_id: int, payload: schemas.ReviewActionRequest | None = None, db: Session = Depends(get_db)
) -> models.RawQAItem:
payload = payload or schemas.ReviewActionRequest()
raw = _get_raw(db, raw_id)
before = raw.desensitization_status.value
raw.need_desensitization = True
raw.desensitization_status = models.DesensitizationStatus.needed
add_audit_log(
db,
user_id=payload.reviewer_id,
target_type=models.TargetType.raw_qa,
target_id=raw.id,
action=models.AuditAction.mark_desensitization,
before_status=before,
after_status=raw.desensitization_status.value,
comment=payload.comment,
)
db.commit()
db.refresh(raw)
return raw

View File

@@ -0,0 +1,72 @@
from datetime import date
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app import models, schemas
from app.database import get_db
from app.services.task_runner import TaskRunner
router = APIRouter(prefix="/sessions", tags=["sessions"])
@router.get("", response_model=list[schemas.FeishuSessionRead])
def list_sessions(db: Session = Depends(get_db)) -> list[models.FeishuSession]:
return db.query(models.FeishuSession).order_by(models.FeishuSession.created_at.desc()).all()
@router.get("/{session_id}", response_model=schemas.FeishuSessionRead)
def get_session(session_id: int, db: Session = Depends(get_db)) -> models.FeishuSession:
session = db.get(models.FeishuSession, session_id)
if not session:
raise HTTPException(status_code=404, detail="场次不存在")
return session
@router.post("", response_model=schemas.FeishuSessionRead)
def create_session(payload: schemas.FeishuSessionCreate, db: Session = Depends(get_db)) -> models.FeishuSession:
next_id = db.query(models.FeishuSession).count() + 1
session = models.FeishuSession(
session_code=payload.session_code or f"MANUAL-{next_id:04d}",
title=payload.title,
date=payload.date or date.today(),
teachers=payload.teachers,
feishu_doc_url=payload.feishu_doc_url,
feishu_record_id=payload.feishu_record_id,
source_file_name=payload.source_file_name,
source_text=payload.source_text,
process_status=models.ProcessStatus.unprocessed,
)
db.add(session)
db.commit()
db.refresh(session)
return session
@router.patch("/{session_id}", response_model=schemas.FeishuSessionRead)
def update_session(
session_id: int, payload: schemas.FeishuSessionUpdate, db: Session = Depends(get_db)
) -> models.FeishuSession:
session = db.get(models.FeishuSession, session_id)
if not session:
raise HTTPException(status_code=404, detail="场次不存在")
for key, value in payload.model_dump(exclude_unset=True).items():
setattr(session, key, value)
db.commit()
db.refresh(session)
return session
@router.post("/{session_id}/process", response_model=schemas.TaskRunRead)
async def process_session(session_id: int, db: Session = Depends(get_db)) -> models.TaskRun:
runner = TaskRunner()
task = await runner.run(db, task_type=models.TaskType.manual_process, session_id=session_id)
return task
@router.post("/{session_id}/reprocess", response_model=schemas.TaskRunRead)
async def reprocess_session(session_id: int, db: Session = Depends(get_db)) -> models.TaskRun:
runner = TaskRunner()
task = await runner.run(db, task_type=models.TaskType.manual_process, session_id=session_id, reprocess=True)
return task

View File

@@ -0,0 +1,51 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app import models, schemas
from app.config import get_settings
from app.database import get_db
router = APIRouter(prefix="/settings", tags=["settings"])
SENSITIVE_MARKERS = {"secret", "token", "key", "password", "api_key"}
@router.get("", response_model=schemas.SafeSettingsRead)
def read_settings(db: Session = Depends(get_db)) -> schemas.SafeSettingsRead:
settings = get_settings()
rows = db.query(models.SystemSetting).order_by(models.SystemSetting.key.asc()).all()
driver = settings.resolved_database_url.split(":", 1)[0]
return schemas.SafeSettingsRead(
feishu_configured=settings.feishu_configured,
openai_configured=settings.openai_configured,
model_name=settings.model_name,
schedule_cron=settings.schedule_cron,
schedule_timezone=settings.schedule_timezone,
mock_mode=settings.mock_mode or not settings.feishu_configured,
database_url=f"{driver}://***",
feishu_tables_configured={
"session": bool(settings.feishu_table_id_session),
"raw_qa": bool(settings.feishu_table_id_raw_qa),
"standard_qa": bool(settings.feishu_table_id_standard_qa),
},
system_settings=[
{"key": row.key, "value": row.value, "description": row.description, "updated_at": row.updated_at}
for row in rows
],
)
@router.patch("", response_model=schemas.SafeSettingsRead)
def patch_settings(payload: schemas.SettingsPatch, db: Session = Depends(get_db)) -> schemas.SafeSettingsRead:
for key, value in payload.settings.items():
lower = key.lower()
if any(marker in lower for marker in SENSITIVE_MARKERS):
raise HTTPException(status_code=400, detail="敏感密钥不能写入 system_settings请使用 .env")
row = db.query(models.SystemSetting).filter(models.SystemSetting.key == key).first()
if row:
row.value = value
else:
db.add(models.SystemSetting(key=key, value=value, description="后台页面写入的非敏感配置"))
db.commit()
return read_settings(db)

View File

@@ -0,0 +1,146 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app import models, schemas
from app.database import get_db
from app.services.standard_qa_service import add_audit_log, mark_standard_call_status
router = APIRouter(prefix="/standard-qa", tags=["standard_qa"])
def _get_standard(db: Session, standard_id: int) -> models.StandardQAItem:
standard = db.get(models.StandardQAItem, standard_id)
if not standard:
raise HTTPException(status_code=404, detail="标准问答不存在")
return standard
@router.get("/callable", response_model=list[schemas.CallableQARead])
def callable_qa(db: Session = Depends(get_db)) -> list[models.StandardQAItem]:
return (
db.query(models.StandardQAItem)
.join(models.RawQAItem)
.filter(
models.StandardQAItem.audit_status == models.AuditStatus.approved,
models.StandardQAItem.call_status == models.CallStatus.callable,
models.StandardQAItem.risk_level.in_([models.RiskLevel.low, models.RiskLevel.medium]),
models.RawQAItem.desensitization_status.in_(
[models.DesensitizationStatus.not_needed, models.DesensitizationStatus.done]
),
)
.order_by(models.StandardQAItem.updated_at.desc())
.all()
)
@router.get("", response_model=list[schemas.StandardQARead])
def list_standard_qa(
audit_status: str | None = None,
call_status: str | None = None,
risk_level: str | None = None,
primary_topic: str | None = None,
course_stage: str | None = None,
db: Session = Depends(get_db),
) -> list[models.StandardQAItem]:
query = db.query(models.StandardQAItem)
if audit_status:
query = query.filter(models.StandardQAItem.audit_status == audit_status)
if call_status:
query = query.filter(models.StandardQAItem.call_status == call_status)
if risk_level:
query = query.filter(models.StandardQAItem.risk_level == risk_level)
if primary_topic:
query = query.filter(models.StandardQAItem.primary_topic == primary_topic)
if course_stage:
query = query.filter(models.StandardQAItem.course_stage == course_stage)
return query.order_by(models.StandardQAItem.updated_at.desc()).all()
@router.get("/{standard_id}", response_model=schemas.StandardQARead)
def get_standard_qa(standard_id: int, db: Session = Depends(get_db)) -> models.StandardQAItem:
return _get_standard(db, standard_id)
@router.patch("/{standard_id}", response_model=schemas.StandardQARead)
def update_standard_qa(
standard_id: int, payload: schemas.StandardQAUpdate, db: Session = Depends(get_db)
) -> models.StandardQAItem:
standard = _get_standard(db, standard_id)
for key, value in payload.model_dump(exclude_unset=True).items():
setattr(standard, key, value)
db.commit()
db.refresh(standard)
return standard
@router.post("/{standard_id}/mark-callable", response_model=schemas.StandardQARead)
def mark_callable(
standard_id: int, payload: schemas.ReviewActionRequest | None = None, db: Session = Depends(get_db)
) -> models.StandardQAItem:
payload = payload or schemas.ReviewActionRequest()
try:
return mark_standard_call_status(
db, _get_standard(db, standard_id), models.CallStatus.callable, payload.reviewer_id, payload.comment
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/{standard_id}/mark-not-callable", response_model=schemas.StandardQARead)
def mark_not_callable(
standard_id: int, payload: schemas.ReviewActionRequest | None = None, db: Session = Depends(get_db)
) -> models.StandardQAItem:
payload = payload or schemas.ReviewActionRequest()
return mark_standard_call_status(
db, _get_standard(db, standard_id), models.CallStatus.not_callable, payload.reviewer_id, payload.comment
)
@router.post("/{standard_id}/disable", response_model=schemas.StandardQARead)
def disable_standard(
standard_id: int, payload: schemas.ReviewActionRequest | None = None, db: Session = Depends(get_db)
) -> models.StandardQAItem:
payload = payload or schemas.ReviewActionRequest()
standard = _get_standard(db, standard_id)
before = standard.audit_status.value
standard.audit_status = models.AuditStatus.disabled
standard.call_status = models.CallStatus.forbidden
standard.disabled_reason = payload.comment
add_audit_log(
db,
user_id=payload.reviewer_id,
target_type=models.TargetType.standard_qa,
target_id=standard.id,
action=models.AuditAction.disable,
before_status=before,
after_status=standard.audit_status.value,
comment=payload.comment,
)
db.commit()
db.refresh(standard)
return standard
@router.post("/{standard_id}/need-review", response_model=schemas.StandardQARead)
def need_review(
standard_id: int, payload: schemas.ReviewActionRequest | None = None, db: Session = Depends(get_db)
) -> models.StandardQAItem:
payload = payload or schemas.ReviewActionRequest()
standard = _get_standard(db, standard_id)
before = standard.audit_status.value
standard.audit_status = models.AuditStatus.needs_revision
standard.call_status = models.CallStatus.need_review
add_audit_log(
db,
user_id=payload.reviewer_id,
target_type=models.TargetType.standard_qa,
target_id=standard.id,
action=models.AuditAction.revise,
before_status=before,
after_status=standard.audit_status.value,
comment=payload.comment,
)
db.commit()
db.refresh(standard)
return standard

View File

@@ -0,0 +1,28 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app import models, schemas
from app.database import get_db
from app.services.task_runner import TaskRunner
router = APIRouter(prefix="/tasks", tags=["tasks"])
@router.get("/runs", response_model=list[schemas.TaskRunRead])
def list_task_runs(db: Session = Depends(get_db)) -> list[models.TaskRun]:
return db.query(models.TaskRun).order_by(models.TaskRun.created_at.desc()).limit(100).all()
@router.get("/runs/{task_id}", response_model=schemas.TaskRunRead)
def get_task_run(task_id: int, db: Session = Depends(get_db)) -> models.TaskRun:
task = db.get(models.TaskRun, task_id)
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
return task
@router.post("/run-now", response_model=schemas.TaskRunRead)
async def run_now(db: Session = Depends(get_db)) -> models.TaskRun:
runner = TaskRunner()
return await runner.run(db, task_type=models.TaskType.manual_scan)

View File

@@ -0,0 +1,61 @@
from functools import lru_cache
from pathlib import Path
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
app_name: str = "大本营答疑资产后台系统 MVP"
database_url: str | None = Field(default=None, alias="DATABASE_URL")
openai_api_key: str | None = Field(default=None, alias="OPENAI_API_KEY")
openai_base_url: str | None = Field(default=None, alias="OPENAI_BASE_URL")
model_name: str = Field(default="gpt-4.1-mini", alias="MODEL_NAME")
feishu_app_id: str | None = Field(default=None, alias="FEISHU_APP_ID")
feishu_app_secret: str | None = Field(default=None, alias="FEISHU_APP_SECRET")
feishu_app_token: str | None = Field(default=None, alias="FEISHU_APP_TOKEN")
feishu_wiki_node_token: str | None = Field(default=None, alias="FEISHU_WIKI_NODE_TOKEN")
feishu_table_id_session: str | None = Field(default=None, alias="FEISHU_TABLE_ID_SESSION")
feishu_table_id_raw_qa: str | None = Field(default=None, alias="FEISHU_TABLE_ID_RAW_QA")
feishu_table_id_standard_qa: str | None = Field(default=None, alias="FEISHU_TABLE_ID_STANDARD_QA")
schedule_cron: str = Field(default="0 1 * * *", alias="SCHEDULE_CRON")
schedule_timezone: str = Field(default="Asia/Shanghai", alias="SCHEDULE_TIMEZONE")
mock_mode: bool = Field(default=True, alias="MOCK_MODE")
model_config = SettingsConfigDict(
env_file=(".env", "../.env", "../../.env"),
env_file_encoding="utf-8",
extra="ignore",
populate_by_name=True,
)
@property
def resolved_database_url(self) -> str:
if self.database_url:
return self.database_url
db_path = Path(__file__).resolve().parents[2] / "hy_qa_asset.db"
return f"sqlite:///{db_path.as_posix()}"
@property
def openai_configured(self) -> bool:
return bool(self.openai_api_key)
@property
def feishu_configured(self) -> bool:
return all(
[
self.feishu_app_id,
self.feishu_app_secret,
self.feishu_app_token or self.feishu_wiki_node_token,
self.feishu_table_id_session,
self.feishu_table_id_raw_qa,
self.feishu_table_id_standard_qa,
]
)
@lru_cache
def get_settings() -> Settings:
return Settings()

View File

@@ -0,0 +1,49 @@
from collections.abc import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from app.config import get_settings
class Base(DeclarativeBase):
pass
settings = get_settings()
connect_args = {"check_same_thread": False} if settings.resolved_database_url.startswith("sqlite") else {}
engine = create_engine(settings.resolved_database_url, connect_args=connect_args, future=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
finally:
db.close()
def init_db() -> None:
from app import models
Base.metadata.create_all(bind=engine)
with SessionLocal() as db:
if not db.query(models.User).first():
db.add(
models.User(
name="默认审核员",
email="reviewer@example.local",
role=models.UserRole.reviewer,
)
)
defaults = {
"risk_policy": "AI 只做初筛,未审核内容不能进入标准问答库或可调用库。",
"mock_mode": "true" if settings.mock_mode else "false",
}
for key, value in defaults.items():
exists = db.query(models.SystemSetting).filter(models.SystemSetting.key == key).first()
if not exists:
db.add(models.SystemSetting(key=key, value=value, description="系统内置非敏感配置"))
db.commit()

View File

@@ -0,0 +1,52 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api import dashboard, feishu, logs, raw_qa, sessions, settings, standard_qa, tasks
from app.config import get_settings
from app.database import init_db
from app.services.scheduler import start_scheduler
@asynccontextmanager
async def lifespan(app: FastAPI):
init_db()
start_scheduler()
yield
settings_obj = get_settings()
app = FastAPI(title=settings_obj.app_name, version="0.1.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=[
"http://localhost:3000",
"http://127.0.0.1:3000",
"http://localhost:3001",
"http://127.0.0.1:3001",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(dashboard.router, prefix="/api")
app.include_router(sessions.router, prefix="/api")
app.include_router(raw_qa.router, prefix="/api")
app.include_router(standard_qa.router, prefix="/api")
app.include_router(tasks.router, prefix="/api")
app.include_router(logs.router, prefix="/api")
app.include_router(settings.router, prefix="/api")
app.include_router(feishu.router, prefix="/api")
@app.get("/")
def root() -> dict[str, str]:
return {"name": settings_obj.app_name, "status": "ok"}
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}

View File

@@ -0,0 +1,270 @@
from __future__ import annotations
import enum
from datetime import date as DateType, datetime as DateTimeType
from sqlalchemy import Boolean, Date, DateTime, Enum, ForeignKey, Integer, JSON, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
def enum_values(enum_cls: type[enum.Enum]) -> Enum:
return Enum(enum_cls, values_callable=lambda members: [member.value for member in members], native_enum=False)
class UserRole(str, enum.Enum):
admin = "admin"
reviewer = "reviewer"
operator = "operator"
class ProcessStatus(str, enum.Enum):
unprocessed = "unprocessed"
processing = "processing"
parsed = "parsed"
pending_review = "pending_review"
completed = "completed"
failed = "failed"
class RiskLevel(str, enum.Enum):
low = "low"
medium = "medium"
high = "high"
forbidden = "forbidden"
class ReviewStatus(str, enum.Enum):
pending = "pending"
reviewing = "reviewing"
approved = "approved"
needs_revision = "needs_revision"
rejected = "rejected"
forbidden = "forbidden"
class DesensitizationStatus(str, enum.Enum):
not_needed = "not_needed"
needed = "needed"
done = "done"
unknown = "unknown"
class CourseStage(str, enum.Enum):
awareness = "觉知"
origin = "原生"
unity = "合一"
vitality = "生机"
heart_light = "心光"
camp_general = "大本营综合"
unknown = "不确定"
class PrimaryTopic(str, enum.Enum):
parent_child = "亲子关系"
marriage = "婚姻关系"
family_of_origin = "原生家庭"
emotion = "情绪模式"
course = "课程学习"
growth = "个人成长"
family_system = "家族系统"
relationship_drain = "关系内耗"
action = "行动力与拖延"
other = "其他"
class AuditStatus(str, enum.Enum):
draft = "draft"
pending = "pending"
approved = "approved"
needs_revision = "needs_revision"
disabled = "disabled"
class CallStatus(str, enum.Enum):
callable = "callable"
not_callable = "not_callable"
need_review = "need_review"
forbidden = "forbidden"
class TaskType(str, enum.Enum):
scheduled_scan = "scheduled_scan"
manual_scan = "manual_scan"
manual_process = "manual_process"
ai_cleaning = "ai_cleaning"
feishu_sync = "feishu_sync"
class TaskStatus(str, enum.Enum):
pending = "pending"
running = "running"
success = "success"
failed = "failed"
partial_success = "partial_success"
class TargetType(str, enum.Enum):
raw_qa = "raw_qa"
standard_qa = "standard_qa"
session = "session"
class AuditAction(str, enum.Enum):
approve = "approve"
revise = "revise"
reject = "reject"
forbid = "forbid"
mark_high_risk = "mark_high_risk"
mark_desensitization = "mark_desensitization"
mark_callable = "mark_callable"
disable = "disable"
class TimestampMixin:
created_at: Mapped[DateTimeType] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at: Mapped[DateTimeType] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
)
class User(Base, TimestampMixin):
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
name: Mapped[str] = mapped_column(String(120), nullable=False)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False)
role: Mapped[UserRole] = mapped_column(enum_values(UserRole), default=UserRole.reviewer, nullable=False)
class FeishuSession(Base, TimestampMixin):
__tablename__ = "feishu_sessions"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
session_code: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
title: Mapped[str] = mapped_column(String(255), nullable=False)
date: Mapped[DateType | None] = mapped_column(Date, nullable=True)
teachers: Mapped[str | None] = mapped_column(String(255), nullable=True)
feishu_doc_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
feishu_record_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
source_file_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
source_text: Mapped[str | None] = mapped_column(Text, nullable=True)
process_status: Mapped[ProcessStatus] = mapped_column(
enum_values(ProcessStatus), default=ProcessStatus.unprocessed, nullable=False, index=True
)
qa_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
pending_review_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
approved_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
standard_qa_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
failed_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
raw_items: Mapped[list["RawQAItem"]] = relationship(back_populates="session", cascade="all, delete-orphan")
standard_items: Mapped[list["StandardQAItem"]] = relationship(back_populates="session")
class RawQAItem(Base, TimestampMixin):
__tablename__ = "raw_qa_items"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
session_id: Mapped[int] = mapped_column(ForeignKey("feishu_sessions.id"), index=True, nullable=False)
qa_code: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
raw_question: Mapped[str] = mapped_column(Text, nullable=False)
normalized_question: Mapped[str | None] = mapped_column(Text, nullable=True)
raw_answer: Mapped[str] = mapped_column(Text, nullable=False)
normalized_answer: Mapped[str | None] = mapped_column(Text, nullable=True)
suggested_standard_question: Mapped[str | None] = mapped_column(Text, nullable=True)
suggested_standard_answer: Mapped[str | None] = mapped_column(Text, nullable=True)
answer_person: Mapped[str | None] = mapped_column(String(120), nullable=True)
primary_topic: Mapped[PrimaryTopic] = mapped_column(enum_values(PrimaryTopic), default=PrimaryTopic.other, nullable=False)
problem_tags: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
course_stage: Mapped[CourseStage] = mapped_column(enum_values(CourseStage), default=CourseStage.unknown, nullable=False)
audience_tags: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
emotion_intensity: Mapped[str | None] = mapped_column(String(32), nullable=True)
risk_level: Mapped[RiskLevel] = mapped_column(enum_values(RiskLevel), default=RiskLevel.medium, nullable=False, index=True)
risk_types: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
risk_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
need_desensitization: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
desensitization_status: Mapped[DesensitizationStatus] = mapped_column(
enum_values(DesensitizationStatus), default=DesensitizationStatus.unknown, nullable=False
)
review_status: Mapped[ReviewStatus] = mapped_column(
enum_values(ReviewStatus), default=ReviewStatus.pending, nullable=False, index=True
)
reviewer_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
review_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
suggested_to_standard_qa: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
source_timestamp: Mapped[str | None] = mapped_column(String(64), nullable=True)
session: Mapped[FeishuSession] = relationship(back_populates="raw_items")
reviewer: Mapped[User | None] = relationship()
standard_item: Mapped["StandardQAItem | None"] = relationship(back_populates="source_raw_qa", uselist=False)
class StandardQAItem(Base, TimestampMixin):
__tablename__ = "standard_qa_items"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
standard_code: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
source_raw_qa_id: Mapped[int] = mapped_column(ForeignKey("raw_qa_items.id"), index=True, nullable=False)
session_id: Mapped[int] = mapped_column(ForeignKey("feishu_sessions.id"), index=True, nullable=False)
standard_question: Mapped[str] = mapped_column(Text, nullable=False)
standard_answer: Mapped[str] = mapped_column(Text, nullable=False)
similar_questions: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
primary_topic: Mapped[PrimaryTopic] = mapped_column(enum_values(PrimaryTopic), default=PrimaryTopic.other, nullable=False)
problem_tags: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
course_stage: Mapped[CourseStage] = mapped_column(enum_values(CourseStage), default=CourseStage.unknown, nullable=False)
audience_tags: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
answer_boundary: Mapped[str | None] = mapped_column(Text, nullable=True)
forbidden_expressions: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
risk_level: Mapped[RiskLevel] = mapped_column(enum_values(RiskLevel), default=RiskLevel.medium, nullable=False, index=True)
audit_status: Mapped[AuditStatus] = mapped_column(enum_values(AuditStatus), default=AuditStatus.approved, nullable=False)
call_status: Mapped[CallStatus] = mapped_column(enum_values(CallStatus), default=CallStatus.not_callable, nullable=False)
last_reviewer_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
disabled_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
source_raw_qa: Mapped[RawQAItem] = relationship(back_populates="standard_item")
session: Mapped[FeishuSession] = relationship(back_populates="standard_items")
last_reviewer: Mapped[User | None] = relationship()
class TaskRun(Base):
__tablename__ = "task_runs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
task_type: Mapped[TaskType] = mapped_column(enum_values(TaskType), nullable=False, index=True)
task_status: Mapped[TaskStatus] = mapped_column(enum_values(TaskStatus), default=TaskStatus.pending, nullable=False, index=True)
started_at: Mapped[DateTimeType | None] = mapped_column(DateTime(timezone=True), nullable=True)
ended_at: Mapped[DateTimeType | None] = mapped_column(DateTime(timezone=True), nullable=True)
sessions_scanned: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
sessions_processed: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
qa_created: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
qa_failed: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
retry_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
created_at: Mapped[DateTimeType] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
class AuditLog(Base):
__tablename__ = "audit_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
target_type: Mapped[TargetType] = mapped_column(enum_values(TargetType), nullable=False, index=True)
target_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False)
action: Mapped[AuditAction] = mapped_column(enum_values(AuditAction), nullable=False)
before_status: Mapped[str | None] = mapped_column(String(64), nullable=True)
after_status: Mapped[str | None] = mapped_column(String(64), nullable=True)
comment: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[DateTimeType] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
user: Mapped[User | None] = relationship()
class SystemSetting(Base, TimestampMixin):
__tablename__ = "system_settings"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
key: Mapped[str] = mapped_column(String(128), unique=True, index=True, nullable=False)
value: Mapped[str | None] = mapped_column(Text, nullable=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)

View File

@@ -0,0 +1,147 @@
你是“大本营答疑问答资产库”的 AI 清洗助手。
你的任务是把一份大本营周三答疑转写稿,拆解成结构化的一问一答 JSON 数组。
你必须严格遵守以下规则:
一、原文保护
1. 必须保留学员原始问题。
2. 必须保留院长或辅导老师原始回答。
3. raw_question 必须来自原文,不得改写。
4. raw_answer 必须来自原文,不得改写。
5. normalized_question 可以去口语化,但不能改变原意。
6. normalized_answer 可以去口语化,但不能新增原回答没有的信息。
7. suggested_standard_answer 可以整理得更适合复用,但不能新增原回答没有的核心观点。
8. 不能把 AI 补充内容伪装成院长或辅导老师原话。
二、问答拆解
1. 只拆有效问答。
2. 如果没有明确问题和回答,不要强行拆。
3. 如果一个问题对应多个回答,可以合并为一条。
4. 如果多个问题高度相似,可以分别保留,但在标签中体现相似主题。
5. 每条问答必须能追溯到原始文本。
三、标签生成
一级主题只能从以下选项中选择:
亲子关系
婚姻关系
原生家庭
情绪模式
课程学习
个人成长
家族系统
关系内耗
行动力与拖延
其他
课程阶段只能从以下选项中选择:
觉知
原生
合一
生机
心光
大本营综合
不确定
如果无法判断,填“不确定”。
四、风险识别
以下情况必须标记风险:
1. 出现学员姓名、电话、城市、学校、单位、具体身份信息。
2. 涉及孩子、未成年人、婚姻冲突、家庭矛盾等敏感细节。
3. 涉及心理诊断、医疗判断、法律建议。
4. 涉及课程效果确定性承诺。
5. 涉及可能引发误解的绝对化表达。
6. 涉及老师、院长、团队表达边界。
7. 无法判断是否安全时,默认 medium 风险。
risk_level 只能是:
low
medium
high
forbidden
五、脱敏判断
如果涉及以下信息need_desensitization 必须为 true
姓名
电话
微信
城市
学校
单位
具体住址
具体家庭成员身份
孩子具体信息
婚姻个案细节
可识别个人身份的组合信息
六、输出格式
你只能输出 JSON 数组,不要输出解释文字,不要输出 Markdown。
每条 JSON 必须包含以下字段:
session_id
qa_id
raw_question
normalized_question
raw_answer
normalized_answer
suggested_standard_question
suggested_standard_answer
answer_person
primary_topic
problem_tags
course_stage
audience_tags
emotion_intensity
risk_level
risk_types
risk_notes
need_desensitization
desensitization_status
suggested_review_status
suggested_to_standard_qa
source_timestamp
review_notes
字段说明:
session_id场次 ID如果不知道可为空字符串。
qa_id问答 ID如果不知道可为空字符串。
raw_question原始问题。
normalized_question整理后的问题。
raw_answer原始回答。
normalized_answer整理后的回答。
suggested_standard_question建议标准问题。
suggested_standard_answer建议标准回答。
answer_person回答人如院长、辅导老师、不确定。
primary_topic一级主题。
problem_tags具体问题标签数组。
course_stage课程阶段。
audience_tags适用人群标签数组。
emotion_intensity情绪强度low / medium / high。
risk_level风险等级low / medium / high / forbidden。
risk_types风险类型数组。
risk_notes风险说明。
need_desensitization是否需要脱敏true / false。
desensitization_statusnot_needed / needed / done / unknown。
suggested_review_status建议审核状态pending / approved / needs_revision / rejected / forbidden。
suggested_to_standard_qa是否建议进入标准问答库true / false。
source_timestamp原始时间戳没有则为空。
review_notes给人工审核人的备注。
系统边界:
AI 只能辅助清洗、拆解、打标签、风险初筛,不能替代人工审核。未审核内容不能进入标准问答库,未审核内容不能被未来智能体调用。高风险内容必须人工复审。涉及学员隐私必须标记脱敏。不做心理诊断、医疗建议、法律判断,也不承诺课程效果。

View File

@@ -0,0 +1,255 @@
from __future__ import annotations
from datetime import date as DateType, datetime as DateTime
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
class ORMModel(BaseModel):
model_config = ConfigDict(from_attributes=True, use_enum_values=True)
class UserRead(ORMModel):
id: int
name: str
email: str
role: str
created_at: DateTime
updated_at: DateTime
class FeishuSessionBase(BaseModel):
title: str
date: DateType | None = None
teachers: str | None = None
feishu_doc_url: str | None = None
feishu_record_id: str | None = None
source_file_name: str | None = None
source_text: str | None = None
class FeishuSessionCreate(FeishuSessionBase):
session_code: str | None = None
class FeishuSessionUpdate(BaseModel):
title: str | None = None
date: DateType | None = None
teachers: str | None = None
feishu_doc_url: str | None = None
source_file_name: str | None = None
source_text: str | None = None
process_status: str | None = None
failed_reason: str | None = None
class FeishuSessionRead(ORMModel):
id: int
session_code: str
title: str
date: DateType | None
teachers: str | None
feishu_doc_url: str | None
feishu_record_id: str | None
source_file_name: str | None
source_text: str | None
process_status: str
qa_count: int
pending_review_count: int
approved_count: int
standard_qa_count: int
failed_reason: str | None
created_at: DateTime
updated_at: DateTime
class RawQAItemUpdate(BaseModel):
normalized_question: str | None = None
normalized_answer: str | None = None
suggested_standard_question: str | None = None
suggested_standard_answer: str | None = None
answer_person: str | None = None
primary_topic: str | None = None
problem_tags: list[str] | None = None
course_stage: str | None = None
audience_tags: list[str] | None = None
emotion_intensity: str | None = None
risk_level: str | None = None
risk_types: list[str] | None = None
risk_notes: str | None = None
need_desensitization: bool | None = None
desensitization_status: str | None = None
review_status: str | None = None
review_notes: str | None = None
suggested_to_standard_qa: bool | None = None
class ReviewActionRequest(BaseModel):
reviewer_id: int | None = None
comment: str | None = None
class RawQAItemRead(ORMModel):
id: int
session_id: int
qa_code: str
raw_question: str
normalized_question: str | None
raw_answer: str
normalized_answer: str | None
suggested_standard_question: str | None
suggested_standard_answer: str | None
answer_person: str | None
primary_topic: str
problem_tags: list[str]
course_stage: str
audience_tags: list[str]
emotion_intensity: str | None
risk_level: str
risk_types: list[str]
risk_notes: str | None
need_desensitization: bool
desensitization_status: str
review_status: str
reviewer_id: int | None
review_notes: str | None
suggested_to_standard_qa: bool
source_timestamp: str | None
created_at: DateTime
updated_at: DateTime
class StandardQAUpdate(BaseModel):
standard_question: str | None = None
standard_answer: str | None = None
similar_questions: list[str] | None = None
primary_topic: str | None = None
problem_tags: list[str] | None = None
course_stage: str | None = None
audience_tags: list[str] | None = None
answer_boundary: str | None = None
forbidden_expressions: list[str] | None = None
risk_level: str | None = None
audit_status: str | None = None
call_status: str | None = None
disabled_reason: str | None = None
class StandardQARead(ORMModel):
id: int
standard_code: str
source_raw_qa_id: int
session_id: int
standard_question: str
standard_answer: str
similar_questions: list[str]
primary_topic: str
problem_tags: list[str]
course_stage: str
audience_tags: list[str]
answer_boundary: str | None
forbidden_expressions: list[str]
risk_level: str
audit_status: str
call_status: str
last_reviewer_id: int | None
disabled_reason: str | None
created_at: DateTime
updated_at: DateTime
class CallableQARead(ORMModel):
id: int
standard_code: str
standard_question: str
standard_answer: str
similar_questions: list[str]
primary_topic: str
problem_tags: list[str]
course_stage: str
audience_tags: list[str]
answer_boundary: str | None
forbidden_expressions: list[str]
source_raw_qa_id: int
session_id: int
class TaskRunRead(ORMModel):
id: int
task_type: str
task_status: str
started_at: DateTime | None
ended_at: DateTime | None
sessions_scanned: int
sessions_processed: int
qa_created: int
qa_failed: int
error_message: str | None
retry_count: int
created_at: DateTime
class AuditLogRead(ORMModel):
id: int
user_id: int | None
target_type: str
target_id: int
action: str
before_status: str | None
after_status: str | None
comment: str | None
created_at: DateTime
class DashboardSummary(BaseModel):
total_sessions: int
processed_sessions: int
pending_review_count: int
high_risk_count: int
approved_qa_count: int
standard_qa_count: int
callable_qa_count: int
last_task_status: str | None
last_task_time: DateTime | None
last_task_error: str | None
class SafeSettingsRead(BaseModel):
feishu_configured: bool
openai_configured: bool
model_name: str
schedule_cron: str
schedule_timezone: str
mock_mode: bool
database_url: str = Field(description="敏感连接串不原样返回,仅返回驱动类型")
feishu_tables_configured: dict[str, bool]
system_settings: list[dict[str, Any]]
class SettingsPatch(BaseModel):
settings: dict[str, str]
class FeishuStatus(BaseModel):
configured: bool
mock_mode: bool
app_token_configured: bool
wiki_node_token_configured: bool = False
table_ids: dict[str, bool]
token_ok: bool
message: str
class FeishuTableTemplate(BaseModel):
table_key: str
table_name: str
env_name: str
required_fields: list[str]
class FeishuSetupGuide(BaseModel):
required_env: list[str]
required_permissions: list[str]
tables: list[FeishuTableTemplate]
notes: list[str]

View File

@@ -0,0 +1,8 @@
[00:03:12] 学员:我和孩子沟通时总是忍不住发火,孩子现在一听我说话就躲开,我该怎么办?
[00:04:20] 辅导老师:先不要急着讲道理,先看见自己发火背后的着急和无力。可以把要求先放低一点,用一句具体的话表达当下感受,比如我现在有点急,我想先停一下。
[00:16:44] 学员:我学习大本营后知道自己总是拖延,怎么开始一个小行动?
[00:17:30] 院长:不要一开始就设很大的目标。先选一个今天能完成的动作,比如写三句话、整理十分钟。关键是让身体先体验到我可以开始。
[00:27:05] 学员:我朋友说自己有抑郁症,我能不能用课程里的方法帮她判断严重程度?
[00:28:02] 辅导老师:这类情况不要做诊断,也不要替对方判断严重程度。你可以表达关心,建议对方寻求专业医生或心理专业人员帮助。

View File

@@ -0,0 +1,49 @@
import json
from pathlib import Path
from typing import Any
import httpx
from app.config import get_settings
from app.services.mock_data_service import mock_ai_cleaning_result
from app.services.qa_parser import normalize_ai_item
from app.utils.json_validator import validate_ai_qa_items
PROMPT_PATH = Path(__file__).resolve().parents[1] / "prompts" / "qa_cleaning_prompt.md"
class AICleaner:
def __init__(self) -> None:
self.settings = get_settings()
def _prompt(self) -> str:
return PROMPT_PATH.read_text(encoding="utf-8")
async def clean_transcript(self, session_id: int, transcript: str) -> list[dict[str, Any]]:
if not self.settings.openai_configured:
return [normalize_ai_item(item) for item in mock_ai_cleaning_result(session_id)]
payload = {
"model": self.settings.model_name,
"messages": [
{"role": "system", "content": self._prompt()},
{
"role": "user",
"content": f"场次 ID: {session_id}\n\n以下是转写稿:\n{transcript}",
},
],
"temperature": 0.1,
}
base_url = (self.settings.openai_base_url or "https://api.openai.com/v1").rstrip("/")
headers = {"Authorization": f"Bearer {self.settings.openai_api_key}"}
async with httpx.AsyncClient(timeout=60) as client:
response = await client.post(f"{base_url}/chat/completions", headers=headers, json=payload)
response.raise_for_status()
content = response.json()["choices"][0]["message"]["content"]
try:
parsed = json.loads(content)
except json.JSONDecodeError as exc:
raise ValueError(f"AI 返回非法 JSON: {exc}") from exc
return [normalize_ai_item(item) for item in validate_ai_qa_items(parsed)]

View File

@@ -0,0 +1,600 @@
from __future__ import annotations
import io
import re
import zipfile
from datetime import date, datetime
from typing import Any
from xml.etree import ElementTree as ET
import httpx
from sqlalchemy.orm import Session
from app import models
from app.config import get_settings
from app.services.mock_data_service import read_sample_transcript
FEISHU_OPEN_API = "https://open.feishu.cn/open-apis"
STATUS_TO_FEISHU = {
models.ProcessStatus.unprocessed.value: "unprocessed",
models.ProcessStatus.processing.value: "processing",
models.ProcessStatus.parsed.value: "parsed",
models.ProcessStatus.pending_review.value: "pending_review",
models.ProcessStatus.completed.value: "completed",
models.ProcessStatus.failed.value: "failed",
}
FEISHU_TO_STATUS = {
"unprocessed": models.ProcessStatus.unprocessed,
"未处理": models.ProcessStatus.unprocessed,
"processing": models.ProcessStatus.processing,
"处理中": models.ProcessStatus.processing,
"parsed": models.ProcessStatus.parsed,
"已拆解": models.ProcessStatus.parsed,
"pending_review": models.ProcessStatus.pending_review,
"待审核": models.ProcessStatus.pending_review,
"completed": models.ProcessStatus.completed,
"已完成": models.ProcessStatus.completed,
"failed": models.ProcessStatus.failed,
"失败": models.ProcessStatus.failed,
}
SESSION_FIELD_ALIASES = {
"session_code": ["session_code", "场次编号", "编号"],
"title": ["title", "标题", "答疑标题", "场次标题"],
"date": ["date", "日期", "答疑日期"],
"teachers": ["teachers", "答疑老师", "老师"],
"feishu_doc_url": ["feishu_doc_url", "飞书资料链接", "文档链接", "资料链接"],
"source_file_name": ["source_file_name", "文件名", "来源文件名"],
"source_text": ["source_text", "转写稿", "文字稿", "原始资料", "答疑文字稿"],
"process_status": ["process_status", "处理状态", "状态"],
"failed_reason": ["failed_reason", "失败原因"],
}
SESSION_WRITE_FIELDS = {
"process_status": ["处理状态", "process_status"],
"qa_count": ["问答总数", "qa_count"],
"pending_review_count": ["待审核数", "pending_review_count"],
"approved_count": ["已审核数", "approved_count"],
"standard_qa_count": ["已入库数", "standard_qa_count"],
"failed_reason": ["失败原因", "failed_reason"],
}
def _first_field(fields: dict[str, Any], names: list[str], default: Any = None) -> Any:
for name in names:
if name in fields and fields[name] not in (None, ""):
return fields[name]
return default
def _stringify(value: Any) -> str | None:
if value is None:
return None
if isinstance(value, str):
return value
if isinstance(value, list):
parts: list[str] = []
for item in value:
if isinstance(item, dict):
parts.append(str(item.get("text") or item.get("name") or item.get("url") or item))
else:
parts.append(str(item))
return " ".join(parts)
if isinstance(value, dict):
return str(value.get("text") or value.get("name") or value.get("url") or value)
return str(value)
def _parse_date(value: Any) -> date | None:
if value in (None, ""):
return None
if isinstance(value, (int, float)):
# Bitable date fields are often millisecond timestamps.
return datetime.fromtimestamp(value / 1000).date()
text = _stringify(value)
if not text:
return None
for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%Y.%m.%d"):
try:
return datetime.strptime(text[:10], fmt).date()
except ValueError:
continue
return None
def _extract_url(value: Any) -> str | None:
if not value:
return None
if isinstance(value, list):
for item in value:
url = _extract_url(item)
if url:
return url
return None
if isinstance(value, dict):
for key in ("link", "url", "text"):
url = _extract_url(value.get(key))
if url:
return url
return None
text = str(value)
match = re.search(r"https?://\S+", text)
return match.group(0) if match else text if text.startswith("http") else None
def _extract_doc_id(url: str | None) -> str | None:
if not url:
return None
patterns = [
r"/docx/([A-Za-z0-9]+)",
r"/docs/([A-Za-z0-9]+)",
r"/wiki/([A-Za-z0-9]+)",
]
for pattern in patterns:
match = re.search(pattern, url)
if match:
return match.group(1)
tail = url.rstrip("/").split("/")[-1]
return tail if re.match(r"^[A-Za-z0-9]{8,}$", tail) else None
def _extract_drive_file_token(url: str | None) -> str | None:
if not url:
return None
match = re.search(r"/file/([A-Za-z0-9]+)", url)
return match.group(1) if match else None
class FeishuAPIError(RuntimeError):
pass
class FeishuClient:
"""Feishu adapter used by the black-light workflow.
Product boundary: Feishu is the material entrance. AI only cleans and
pre-screens; every reusable QA still must pass human review in this system.
"""
def __init__(self) -> None:
self.settings = get_settings()
self._tenant_access_token: str | None = None
self._bitable_app_token: str | None = None
@property
def is_mock_mode(self) -> bool:
return self.settings.mock_mode or not self.settings.feishu_configured
def connection_status(self) -> dict[str, Any]:
configured = self.settings.feishu_configured
status = {
"configured": configured,
"mock_mode": self.is_mock_mode,
"app_token_configured": bool(self.settings.feishu_app_token),
"wiki_node_token_configured": bool(self.settings.feishu_wiki_node_token),
"table_ids": {
"session": bool(self.settings.feishu_table_id_session),
"raw_qa": bool(self.settings.feishu_table_id_raw_qa),
"standard_qa": bool(self.settings.feishu_table_id_standard_qa),
},
"token_ok": False,
"message": "未配置飞书密钥,当前使用 mock mode。",
}
if not configured:
return status
try:
self._get_tenant_access_token()
self._get_bitable_app_token()
status["token_ok"] = True
status["message"] = "飞书 tenant_access_token 获取成功。"
except Exception as exc: # noqa: BLE001
status["message"] = f"飞书连接失败:{exc}"
return status
def scan_unprocessed_sessions(self, db: Session) -> list[models.FeishuSession]:
if self.is_mock_mode:
return (
db.query(models.FeishuSession)
.filter(models.FeishuSession.process_status == models.ProcessStatus.unprocessed)
.order_by(models.FeishuSession.created_at.asc())
.all()
)
records = self._list_records(self.settings.feishu_table_id_session)
sessions: list[models.FeishuSession] = []
for record in records:
fields = record.get("fields", {})
record_id = record.get("record_id")
status_text = _stringify(_first_field(fields, SESSION_FIELD_ALIASES["process_status"], "unprocessed"))
process_status = FEISHU_TO_STATUS.get(status_text or "", models.ProcessStatus.unprocessed)
if process_status != models.ProcessStatus.unprocessed:
continue
session_code = _stringify(_first_field(fields, SESSION_FIELD_ALIASES["session_code"])) or f"FS-{record_id}"
session = (
db.query(models.FeishuSession)
.filter(models.FeishuSession.feishu_record_id == record_id)
.one_or_none()
)
if not session:
session = models.FeishuSession(session_code=session_code, title=session_code)
db.add(session)
session.session_code = session_code
session.title = _stringify(_first_field(fields, SESSION_FIELD_ALIASES["title"], session_code)) or session_code
session.date = _parse_date(_first_field(fields, SESSION_FIELD_ALIASES["date"]))
session.teachers = _stringify(_first_field(fields, SESSION_FIELD_ALIASES["teachers"]))
session.feishu_doc_url = _extract_url(_first_field(fields, SESSION_FIELD_ALIASES["feishu_doc_url"]))
session.feishu_record_id = record_id
session.source_file_name = _stringify(_first_field(fields, SESSION_FIELD_ALIASES["source_file_name"]))
session.source_text = _stringify(_first_field(fields, SESSION_FIELD_ALIASES["source_text"]))
session.process_status = process_status
session.failed_reason = _stringify(_first_field(fields, SESSION_FIELD_ALIASES["failed_reason"]))
sessions.append(session)
db.commit()
for session in sessions:
db.refresh(session)
return sessions
def fetch_transcript(self, session: models.FeishuSession) -> str:
if self.is_mock_mode:
return session.source_text or read_sample_transcript()
if session.source_text:
return session.source_text
file_token = _extract_drive_file_token(session.feishu_doc_url)
if file_token:
return self._get_drive_docx_text(file_token)
doc_id = _extract_doc_id(session.feishu_doc_url)
if doc_id:
return self._get_docx_raw_content(doc_id)
raise FeishuAPIError("场次没有转写稿文本,也没有可识别的飞书 docx 文档链接")
def update_session_status(self, session: models.FeishuSession, status: str) -> None:
if self.is_mock_mode or not session.feishu_record_id:
return
fields = self._build_session_update_fields(session, status)
self._update_record(self.settings.feishu_table_id_session, session.feishu_record_id, fields)
def write_raw_qa_items(self, session: models.FeishuSession, items: list[models.RawQAItem]) -> None:
if self.is_mock_mode or not self.settings.feishu_table_id_raw_qa or not items:
return
self._write_raw_qa_items_compatible(session, items)
return
records = []
for item in items:
records.append(
{
"fields": {
"问答编号": item.qa_code,
"场次编号": session.session_code,
"原始问题": item.raw_question,
"问题整理版": item.normalized_question,
"原始回答": item.raw_answer,
"回答整理版": item.normalized_answer,
"AI建议标准问题": item.suggested_standard_question,
"AI建议标准回答": item.suggested_standard_answer,
"回答人": item.answer_person,
"一级主题": item.primary_topic.value,
"问题标签": "".join(item.problem_tags or []),
"课程阶段": item.course_stage.value,
"适用人群": "".join(item.audience_tags or []),
"情绪强度": item.emotion_intensity,
"风险等级": item.risk_level.value,
"风险类型": "".join(item.risk_types or []),
"风险说明": item.risk_notes,
"是否需脱敏": "" if item.need_desensitization else "",
"脱敏状态": item.desensitization_status.value,
"审核状态": item.review_status.value,
"建议入库": "" if item.suggested_to_standard_qa else "",
"来源时间戳": item.source_timestamp,
"审核备注": item.review_notes,
}
}
)
self._batch_create_records(self.settings.feishu_table_id_raw_qa, records)
def write_standard_qa_items(self, items: list[models.StandardQAItem]) -> None:
if self.is_mock_mode or not self.settings.feishu_table_id_standard_qa or not items:
return
self._write_standard_qa_items_compatible(items)
return
records = []
for item in items:
records.append(
{
"fields": {
"标准编号": item.standard_code,
"来源原始问答ID": str(item.source_raw_qa_id),
"来源场次ID": str(item.session_id),
"标准问题": item.standard_question,
"标准回答": item.standard_answer,
"相似问法": "".join(item.similar_questions or []),
"一级主题": item.primary_topic.value,
"问题标签": "".join(item.problem_tags or []),
"课程阶段": item.course_stage.value,
"适用人群": "".join(item.audience_tags or []),
"回答边界": item.answer_boundary,
"禁止表达": "".join(item.forbidden_expressions or []),
"风险等级": item.risk_level.value,
"审核状态": item.audit_status.value,
"调用状态": item.call_status.value,
"禁用原因": item.disabled_reason,
}
}
)
self._batch_create_records(self.settings.feishu_table_id_standard_qa, records)
def _get_tenant_access_token(self) -> str:
if self._tenant_access_token:
return self._tenant_access_token
payload = {"app_id": self.settings.feishu_app_id, "app_secret": self.settings.feishu_app_secret}
with httpx.Client(timeout=20) as client:
response = client.post(f"{FEISHU_OPEN_API}/auth/v3/tenant_access_token/internal", json=payload)
data = response.json()
if response.status_code >= 400 or data.get("code") != 0:
raise FeishuAPIError(data.get("msg") or response.text)
self._tenant_access_token = data["tenant_access_token"]
return self._tenant_access_token
def _headers(self) -> dict[str, str]:
return {"Authorization": f"Bearer {self._get_tenant_access_token()}", "Content-Type": "application/json"}
def _get_bitable_app_token(self) -> str:
if self._bitable_app_token:
return self._bitable_app_token
if self.settings.feishu_app_token:
self._bitable_app_token = self.settings.feishu_app_token
return self._bitable_app_token
if not self.settings.feishu_wiki_node_token:
raise FeishuAPIError("缺少 FEISHU_APP_TOKEN 或 FEISHU_WIKI_NODE_TOKEN")
data = self._request(
"GET",
"/wiki/v2/spaces/get_node",
params={"token": self.settings.feishu_wiki_node_token},
)
node = data.get("node", {})
if node.get("obj_type") != "bitable":
raise FeishuAPIError(f"FEISHU_WIKI_NODE_TOKEN 指向的对象不是 bitable{node.get('obj_type')}")
obj_token = node.get("obj_token")
if not obj_token:
raise FeishuAPIError("wiki 节点信息没有返回 obj_token")
self._bitable_app_token = obj_token
return self._bitable_app_token
def _request(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
with httpx.Client(timeout=30) as client:
response = client.request(method, f"{FEISHU_OPEN_API}{path}", headers=self._headers(), **kwargs)
data = response.json()
if response.status_code >= 400 or data.get("code") != 0:
raise FeishuAPIError(data.get("msg") or response.text)
return data.get("data", {})
def _table_field_names(self, table_id: str | None) -> set[str]:
if not table_id:
return set()
cache = getattr(self, "_table_field_name_cache", None)
if cache is None:
cache = {}
self._table_field_name_cache = cache
if table_id in cache:
return cache[table_id]
names: set[str] = set()
page_token: str | None = None
while True:
params = {"page_size": 100}
if page_token:
params["page_token"] = page_token
data = self._request(
"GET",
f"/bitable/v1/apps/{self._get_bitable_app_token()}/tables/{table_id}/fields",
params=params,
)
names.update(item.get("field_name") for item in data.get("items", []) if item.get("field_name"))
if not data.get("has_more"):
break
page_token = data.get("page_token")
cache[table_id] = names
return names
def _filter_existing_fields(self, table_id: str | None, fields: dict[str, Any]) -> dict[str, Any]:
names = self._table_field_names(table_id)
return {key: value for key, value in fields.items() if key in names and value is not None}
@staticmethod
def _join_values(values: list[str] | None) -> str | None:
return "".join(values or []) or None
@staticmethod
def _enum_value(value: Any) -> str:
return getattr(value, "value", value)
def _risk_label(self, value: Any) -> str:
return {"low": "低风险", "medium": "中风险", "high": "高风险"}.get(self._enum_value(value), str(value))
def _review_label(self, value: Any) -> str:
return {
"pending": "待审核",
"approved": "已通过",
"rejected": "已拒绝",
"needs_revision": "需修改",
}.get(self._enum_value(value), str(value))
def _build_session_update_fields_compatible(self, session: models.FeishuSession, status: str) -> dict[str, Any]:
status_label = {
models.ProcessStatus.unprocessed.value: "待处理",
models.ProcessStatus.processing.value: "处理中",
models.ProcessStatus.parsed.value: "处理中",
models.ProcessStatus.pending_review.value: "待审核",
models.ProcessStatus.completed.value: "已完成",
models.ProcessStatus.failed.value: "失败",
}.get(status, status)
high_risk_count = sum(1 for item in session.raw_items if item.risk_level == models.RiskLevel.high)
fields = {
"处理状态": status_label,
"问答总数": session.qa_count,
"原始问答数": session.qa_count,
"待审核数": session.pending_review_count,
"已审核数": session.approved_count,
"已入库数": session.standard_qa_count,
"标准问答数": session.standard_qa_count,
"高风险数": high_risk_count,
"失败原因": session.failed_reason,
"最近同步时间": int(datetime.now().timestamp() * 1000),
}
return self._filter_existing_fields(self.settings.feishu_table_id_session, fields)
def _write_raw_qa_items_compatible(self, session: models.FeishuSession, items: list[models.RawQAItem]) -> None:
table_id = self.settings.feishu_table_id_raw_qa
records = []
for item in items:
tags = (item.problem_tags or []) + [self._enum_value(item.primary_topic)]
fields = {
"问答编号": item.qa_code,
"场次编号": session.session_code,
"原始问题": item.raw_question,
"问题": item.normalized_question or item.raw_question,
"问题整理版": item.normalized_question,
"原始回答": item.raw_answer,
"回答": item.normalized_answer or item.raw_answer,
"回答整理版": item.normalized_answer,
"AI建议标准问题": item.suggested_standard_question,
"AI建议标准答案": item.suggested_standard_answer,
"答疑老师": item.answer_person or session.teachers,
"标签": self._join_values(tags),
"风险等级": self._risk_label(item.risk_level),
"证据片段": item.source_timestamp or (item.raw_answer or "")[:240],
"审核状态": self._review_label(item.review_status),
"入库状态": "建议入库" if item.suggested_to_standard_qa else "未入库",
}
filtered = self._filter_existing_fields(table_id, fields)
if filtered:
records.append({"fields": filtered})
self._batch_create_records(table_id, records)
def _write_standard_qa_items_compatible(self, items: list[models.StandardQAItem]) -> None:
table_id = self.settings.feishu_table_id_standard_qa
records = []
for item in items:
fields = {
"标准编号": item.standard_code,
"来源原始问答ID": str(item.source_raw_qa_id),
"来源场次ID": str(item.session_id),
"标准问题": item.standard_question,
"标准答案": item.standard_answer,
"相似问法": self._join_values(item.similar_questions),
"一级主题": self._enum_value(item.primary_topic),
"问题标签": self._join_values(item.problem_tags),
"适用人群": self._join_values(item.audience_tags),
"标签": self._join_values(item.problem_tags),
"回答边界": item.answer_boundary,
"禁止表达": self._join_values(item.forbidden_expressions),
"风险等级": self._risk_label(item.risk_level),
"审核状态": self._enum_value(item.audit_status),
"调用状态": self._enum_value(item.call_status),
"状态": self._enum_value(item.audit_status),
"是否可调用": "" if item.call_status == models.CallStatus.callable else "",
"来源场次": str(item.session_id),
"来源问题": str(item.source_raw_qa_id),
"禁用原因": item.disabled_reason,
}
filtered = self._filter_existing_fields(table_id, fields)
if filtered:
records.append({"fields": filtered})
self._batch_create_records(table_id, records)
def _list_records(self, table_id: str | None) -> list[dict[str, Any]]:
if not table_id:
raise FeishuAPIError("缺少飞书场次表 table_id")
records: list[dict[str, Any]] = []
page_token: str | None = None
while True:
params = {"page_size": 500}
if page_token:
params["page_token"] = page_token
data = self._request(
"GET",
f"/bitable/v1/apps/{self._get_bitable_app_token()}/tables/{table_id}/records",
params=params,
)
records.extend(data.get("items", []))
if not data.get("has_more"):
break
page_token = data.get("page_token")
return records
def _update_record(self, table_id: str | None, record_id: str, fields: dict[str, Any]) -> None:
if not table_id:
return
self._request(
"PUT",
f"/bitable/v1/apps/{self._get_bitable_app_token()}/tables/{table_id}/records/{record_id}",
json={"fields": fields},
)
def _batch_create_records(self, table_id: str | None, records: list[dict[str, Any]]) -> None:
if not table_id:
return
for start in range(0, len(records), 500):
chunk = records[start : start + 500]
self._request(
"POST",
f"/bitable/v1/apps/{self._get_bitable_app_token()}/tables/{table_id}/records/batch_create",
json={"records": chunk},
)
def _get_docx_raw_content(self, document_id: str) -> str:
data = self._request("GET", f"/docx/v1/documents/{document_id}/raw_content")
content = data.get("content") or data.get("raw_content") or data.get("text")
if not content:
raise FeishuAPIError("飞书文档纯文本接口没有返回 content")
return content
def _get_drive_docx_text(self, file_token: str) -> str:
with httpx.Client(timeout=60, follow_redirects=True) as client:
response = client.get(
f"{FEISHU_OPEN_API}/drive/v1/files/{file_token}/download",
headers=self._headers(),
)
if response.status_code >= 400:
try:
data = response.json()
message = data.get("msg") or response.text
except Exception: # noqa: BLE001
message = response.text
raise FeishuAPIError(message)
return self._extract_docx_text(response.content)
@staticmethod
def _extract_docx_text(content: bytes) -> str:
try:
with zipfile.ZipFile(io.BytesIO(content)) as docx:
document_xml = docx.read("word/document.xml")
except Exception as exc: # noqa: BLE001
raise FeishuAPIError(f"飞书上传文件不是可解析的 docx{exc}") from exc
root = ET.fromstring(document_xml)
namespace = {"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"}
paragraphs: list[str] = []
for paragraph in root.findall(".//w:p", namespace):
text = "".join(node.text or "" for node in paragraph.findall(".//w:t", namespace)).strip()
if text:
paragraphs.append(text)
extracted = "\n".join(paragraphs).strip()
if not extracted:
raise FeishuAPIError("飞书上传 docx 没有提取到正文")
return extracted
def _build_session_update_fields(self, session: models.FeishuSession, status: str) -> dict[str, Any]:
return self._build_session_update_fields_compatible(session, status)
fields: dict[str, Any] = {
SESSION_WRITE_FIELDS["process_status"][0]: STATUS_TO_FEISHU.get(status, status),
SESSION_WRITE_FIELDS["qa_count"][0]: session.qa_count,
SESSION_WRITE_FIELDS["pending_review_count"][0]: session.pending_review_count,
SESSION_WRITE_FIELDS["approved_count"][0]: session.approved_count,
SESSION_WRITE_FIELDS["standard_qa_count"][0]: session.standard_qa_count,
}
if session.failed_reason:
fields[SESSION_WRITE_FIELDS["failed_reason"][0]] = session.failed_reason
return fields

View File

@@ -0,0 +1,115 @@
from datetime import date
from pathlib import Path
from typing import Any
from sqlalchemy.orm import Session
from app import models
SEED_DIR = Path(__file__).resolve().parents[1] / "seed"
SAMPLE_TRANSCRIPT_PATH = SEED_DIR / "sample_transcript_001.txt"
def read_sample_transcript() -> str:
return SAMPLE_TRANSCRIPT_PATH.read_text(encoding="utf-8")
def create_mock_session(db: Session) -> models.FeishuSession:
index = db.query(models.FeishuSession).count() + 1
session = models.FeishuSession(
session_code=f"MOCK-{index:04d}",
title="大本营周三答疑 mock 场次",
date=date.today(),
teachers="院长, 辅导老师",
feishu_doc_url="mock://feishu/sample_transcript_001",
feishu_record_id=f"mock-record-{index:04d}",
source_file_name="sample_transcript_001.txt",
source_text=read_sample_transcript(),
process_status=models.ProcessStatus.unprocessed,
)
db.add(session)
db.commit()
db.refresh(session)
return session
def mock_ai_cleaning_result(session_id: int) -> list[dict[str, Any]]:
return [
{
"session_id": str(session_id),
"qa_id": "mock-qa-001",
"raw_question": "我和孩子沟通时总是忍不住发火,孩子现在一听我说话就躲开,我该怎么办?",
"normalized_question": "和孩子沟通时容易发火,孩子开始回避,应该如何调整?",
"raw_answer": "先不要急着讲道理,先看见自己发火背后的着急和无力。可以把要求先放低一点,用一句具体的话表达当下感受,比如我现在有点急,我想先停一下。",
"normalized_answer": "先觉察自己发火背后的着急和无力,暂停说教,把要求放低,用具体语言表达当下感受。",
"suggested_standard_question": "亲子沟通中总忍不住发火,如何先做自我调整?",
"suggested_standard_answer": "可以先暂停说教,觉察自己发火背后的着急和无力,再用具体、低压力的语言表达当下感受。这个回答不能替代个体咨询,也不承诺亲子关系会立即改善。",
"answer_person": "辅导老师",
"primary_topic": "亲子关系",
"problem_tags": ["亲子沟通", "情绪觉察", "发火"],
"course_stage": "觉知",
"audience_tags": ["家长", "亲子关系困扰者"],
"emotion_intensity": "medium",
"risk_level": "medium",
"risk_types": ["未成年人信息", "家庭矛盾"],
"risk_notes": "涉及孩子和家庭互动,需要人工确认是否包含可识别隐私。",
"need_desensitization": True,
"desensitization_status": "needed",
"suggested_review_status": "pending",
"suggested_to_standard_qa": True,
"source_timestamp": "00:03:12",
"review_notes": "建议审核时确认是否需要进一步脱敏孩子细节。",
},
{
"session_id": str(session_id),
"qa_id": "mock-qa-002",
"raw_question": "我学习大本营后知道自己总是拖延,怎么开始一个小行动?",
"normalized_question": "意识到自己经常拖延后,如何启动一个小行动?",
"raw_answer": "不要一开始就设很大的目标。先选一个今天能完成的动作,比如写三句话、整理十分钟。关键是让身体先体验到我可以开始。",
"normalized_answer": "先设置当天能完成的小动作,让身体体验到可以开始,而不是直接设很大目标。",
"suggested_standard_question": "面对拖延时,如何启动一个足够小的行动?",
"suggested_standard_answer": "可以先选择一个今天就能完成的小动作,例如写三句话或整理十分钟。重点不是追求完美,而是让自己先体验到可以开始。",
"answer_person": "院长",
"primary_topic": "行动力与拖延",
"problem_tags": ["拖延", "小行动", "自我启动"],
"course_stage": "大本营综合",
"audience_tags": ["大本营学员"],
"emotion_intensity": "low",
"risk_level": "low",
"risk_types": [],
"risk_notes": "未发现明显隐私或边界风险。",
"need_desensitization": False,
"desensitization_status": "not_needed",
"suggested_review_status": "pending",
"suggested_to_standard_qa": True,
"source_timestamp": "00:16:44",
"review_notes": "可作为标准问答候选,但仍需人工审核。",
},
{
"session_id": str(session_id),
"qa_id": "mock-qa-003",
"raw_question": "我朋友说自己有抑郁症,我能不能用课程里的方法帮她判断严重程度?",
"normalized_question": "朋友提到抑郁症时,能否用课程方法判断严重程度?",
"raw_answer": "这类情况不要做诊断,也不要替对方判断严重程度。你可以表达关心,建议对方寻求专业医生或心理专业人员帮助。",
"normalized_answer": "不要做诊断或严重程度判断,可以表达关心,并建议对方寻求专业医生或心理专业人员帮助。",
"suggested_standard_question": "遇到疑似心理健康问题时,课程学习者应该如何把握边界?",
"suggested_standard_answer": "不做心理诊断,不判断严重程度;可以表达关心,并建议对方寻求专业医生或心理专业人员帮助。",
"answer_person": "辅导老师",
"primary_topic": "其他",
"problem_tags": ["心理边界", "专业转介"],
"course_stage": "不确定",
"audience_tags": ["大本营学员"],
"emotion_intensity": "high",
"risk_level": "high",
"risk_types": ["心理 / 医疗边界"],
"risk_notes": "涉及心理诊断边界,必须人工复审,默认不可自动入库。",
"need_desensitization": False,
"desensitization_status": "not_needed",
"suggested_review_status": "pending",
"suggested_to_standard_qa": False,
"source_timestamp": "00:27:05",
"review_notes": "高风险边界题,不建议自动进入标准问答库。",
},
]

View File

@@ -0,0 +1,41 @@
from typing import Any
from app.services.risk_detector import reinforce_risk
PRIMARY_TOPICS = {
"亲子关系",
"婚姻关系",
"原生家庭",
"情绪模式",
"课程学习",
"个人成长",
"家族系统",
"关系内耗",
"行动力与拖延",
"其他",
}
COURSE_STAGES = {"觉知", "原生", "合一", "生机", "心光", "大本营综合", "不确定"}
DESENSITIZATION_STATUSES = {"not_needed", "needed", "done", "unknown"}
REVIEW_STATUSES = {"pending", "reviewing", "approved", "needs_revision", "rejected", "forbidden"}
def normalize_ai_item(item: dict[str, Any]) -> dict[str, Any]:
item = reinforce_risk(dict(item))
item["primary_topic"] = item.get("primary_topic") if item.get("primary_topic") in PRIMARY_TOPICS else "其他"
item["course_stage"] = item.get("course_stage") if item.get("course_stage") in COURSE_STAGES else "不确定"
item["desensitization_status"] = (
item.get("desensitization_status")
if item.get("desensitization_status") in DESENSITIZATION_STATUSES
else "unknown"
)
item["suggested_review_status"] = (
item.get("suggested_review_status") if item.get("suggested_review_status") in REVIEW_STATUSES else "pending"
)
for key in ("problem_tags", "audience_tags", "risk_types"):
if not isinstance(item.get(key), list):
item[key] = []
item["suggested_to_standard_qa"] = bool(item.get("suggested_to_standard_qa"))
item["need_desensitization"] = bool(item.get("need_desensitization"))
return item

View File

@@ -0,0 +1,35 @@
from typing import Any
HIGH_RISK_KEYWORDS = ["抑郁", "自杀", "诊断", "医生", "法律", "诉讼", "离婚协议"]
PRIVACY_KEYWORDS = ["姓名", "电话", "微信", "学校", "单位", "城市", "住址", "孩子"]
def reinforce_risk(item: dict[str, Any]) -> dict[str, Any]:
text = " ".join(
[
str(item.get("raw_question", "")),
str(item.get("raw_answer", "")),
str(item.get("normalized_question", "")),
str(item.get("normalized_answer", "")),
]
)
risk_types = list(item.get("risk_types") or [])
if any(keyword in text for keyword in HIGH_RISK_KEYWORDS):
item["risk_level"] = "high" if item.get("risk_level") != "forbidden" else "forbidden"
if "心理 / 医疗边界" not in risk_types:
risk_types.append("心理 / 医疗边界")
if any(keyword in text for keyword in PRIVACY_KEYWORDS):
item["need_desensitization"] = True
if item.get("desensitization_status") == "not_needed":
item["desensitization_status"] = "needed"
if "学员隐私" not in risk_types:
risk_types.append("学员隐私")
if item.get("risk_level") == "low":
item["risk_level"] = "medium"
if item.get("risk_level") not in {"low", "medium", "high", "forbidden"}:
item["risk_level"] = "medium"
risk_types.append("不确定风险")
item["risk_types"] = risk_types
return item

View File

@@ -0,0 +1,35 @@
from apscheduler.schedulers.background import BackgroundScheduler
from app import models
from app.config import get_settings
from app.database import SessionLocal
from app.services.task_runner import TaskRunner
from app.utils.time_utils import split_cron
scheduler = BackgroundScheduler()
def start_scheduler() -> None:
settings = get_settings()
if scheduler.running:
return
cron_kwargs = split_cron(settings.schedule_cron)
scheduler.configure(timezone=settings.schedule_timezone)
scheduler.add_job(
scheduled_scan,
"cron",
id="scheduled_feishu_scan",
replace_existing=True,
**cron_kwargs,
)
scheduler.start()
def scheduled_scan() -> None:
import asyncio
with SessionLocal() as db:
runner = TaskRunner()
asyncio.run(runner.run(db, task_type=models.TaskType.scheduled_scan))

View File

@@ -0,0 +1,237 @@
from sqlalchemy import func
from sqlalchemy.orm import Session
from app import models
from app.services.feishu_client import FeishuClient
def _default_reviewer_id(db: Session, reviewer_id: int | None) -> int | None:
if reviewer_id:
return reviewer_id
user = db.query(models.User).filter(models.User.role == models.UserRole.reviewer).first()
return user.id if user else None
def add_audit_log(
db: Session,
*,
user_id: int | None,
target_type: models.TargetType,
target_id: int,
action: models.AuditAction,
before_status: str | None,
after_status: str | None,
comment: str | None = None,
) -> models.AuditLog:
log = models.AuditLog(
user_id=user_id,
target_type=target_type,
target_id=target_id,
action=action,
before_status=before_status,
after_status=after_status,
comment=comment,
)
db.add(log)
return log
def refresh_session_counts(db: Session, session_id: int) -> None:
session = db.get(models.FeishuSession, session_id)
if not session:
return
session.qa_count = db.query(models.RawQAItem).filter(models.RawQAItem.session_id == session_id).count()
session.pending_review_count = (
db.query(models.RawQAItem)
.filter(
models.RawQAItem.session_id == session_id,
models.RawQAItem.review_status.in_([models.ReviewStatus.pending, models.ReviewStatus.reviewing]),
)
.count()
)
session.approved_count = (
db.query(models.RawQAItem)
.filter(models.RawQAItem.session_id == session_id, models.RawQAItem.review_status == models.ReviewStatus.approved)
.count()
)
session.standard_qa_count = (
db.query(models.StandardQAItem).filter(models.StandardQAItem.session_id == session_id).count()
)
if session.pending_review_count == 0 and session.qa_count > 0:
session.process_status = models.ProcessStatus.completed
elif session.qa_count > 0:
session.process_status = models.ProcessStatus.pending_review
def create_standard_from_raw(
db: Session, raw: models.RawQAItem, reviewer_id: int | None
) -> models.StandardQAItem | None:
if raw.standard_item:
return raw.standard_item
if not raw.suggested_to_standard_qa:
return None
# 高风险与禁止内容即使被标记状态,也不能自动进入标准问答库。
if raw.risk_level in {models.RiskLevel.high, models.RiskLevel.forbidden}:
return None
if raw.desensitization_status not in {
models.DesensitizationStatus.not_needed,
models.DesensitizationStatus.done,
}:
return None
next_id = (db.query(func.count(models.StandardQAItem.id)).scalar() or 0) + 1
standard = models.StandardQAItem(
standard_code=f"STD-{next_id:05d}",
source_raw_qa_id=raw.id,
session_id=raw.session_id,
standard_question=raw.suggested_standard_question or raw.normalized_question or raw.raw_question,
standard_answer=raw.suggested_standard_answer or raw.normalized_answer or raw.raw_answer,
similar_questions=[raw.normalized_question] if raw.normalized_question else [],
primary_topic=raw.primary_topic,
problem_tags=raw.problem_tags,
course_stage=raw.course_stage,
audience_tags=raw.audience_tags,
answer_boundary="只基于已审核答疑内容复用;不做心理诊断、医疗建议、法律判断或课程效果承诺。",
forbidden_expressions=["保证有效", "一定改变", "诊断为", "法律上必然"],
risk_level=raw.risk_level,
audit_status=models.AuditStatus.approved,
call_status=models.CallStatus.not_callable,
last_reviewer_id=reviewer_id,
)
db.add(standard)
return standard
def approve_raw_qa(
db: Session, raw: models.RawQAItem, reviewer_id: int | None = None, comment: str | None = None
) -> models.RawQAItem:
reviewer_id = _default_reviewer_id(db, reviewer_id)
before = raw.review_status.value
raw.review_status = models.ReviewStatus.approved
raw.reviewer_id = reviewer_id
if comment:
raw.review_notes = comment
add_audit_log(
db,
user_id=reviewer_id,
target_type=models.TargetType.raw_qa,
target_id=raw.id,
action=models.AuditAction.approve,
before_status=before,
after_status=raw.review_status.value,
comment=comment,
)
standard = create_standard_from_raw(db, raw, reviewer_id)
if standard:
db.flush()
add_audit_log(
db,
user_id=reviewer_id,
target_type=models.TargetType.standard_qa,
target_id=standard.id,
action=models.AuditAction.approve,
before_status="none",
after_status=models.AuditStatus.approved.value,
comment="由原始问答审核通过后生成,默认 not_callable。",
)
refresh_session_counts(db, raw.session_id)
db.commit()
feishu = FeishuClient()
if standard:
try:
feishu.write_standard_qa_items([standard])
except Exception as exc: # noqa: BLE001
add_audit_log(
db,
user_id=reviewer_id,
target_type=models.TargetType.standard_qa,
target_id=standard.id,
action=models.AuditAction.revise,
before_status="feishu_write",
after_status="failed",
comment=f"飞书标准问答回写失败:{exc}",
)
db.commit()
session = db.get(models.FeishuSession, raw.session_id)
if session:
try:
feishu.update_session_status(session, session.process_status.value)
except Exception as exc: # noqa: BLE001
add_audit_log(
db,
user_id=reviewer_id,
target_type=models.TargetType.session,
target_id=session.id,
action=models.AuditAction.revise,
before_status="feishu_session_sync",
after_status="failed",
comment=f"飞书场次统计回写失败:{exc}",
)
db.commit()
db.refresh(raw)
return raw
def set_raw_status(
db: Session,
raw: models.RawQAItem,
status: models.ReviewStatus,
action: models.AuditAction,
reviewer_id: int | None = None,
comment: str | None = None,
) -> models.RawQAItem:
reviewer_id = _default_reviewer_id(db, reviewer_id)
before = raw.review_status.value
raw.review_status = status
raw.reviewer_id = reviewer_id
if comment:
raw.review_notes = comment
add_audit_log(
db,
user_id=reviewer_id,
target_type=models.TargetType.raw_qa,
target_id=raw.id,
action=action,
before_status=before,
after_status=status.value,
comment=comment,
)
refresh_session_counts(db, raw.session_id)
db.commit()
db.refresh(raw)
return raw
def mark_standard_call_status(
db: Session,
standard: models.StandardQAItem,
status: models.CallStatus,
reviewer_id: int | None = None,
comment: str | None = None,
) -> models.StandardQAItem:
reviewer_id = _default_reviewer_id(db, reviewer_id)
before = standard.call_status.value
if status == models.CallStatus.callable:
raw = standard.source_raw_qa
if (
standard.audit_status != models.AuditStatus.approved
or standard.risk_level not in {models.RiskLevel.low, models.RiskLevel.medium}
or raw.desensitization_status
not in {models.DesensitizationStatus.not_needed, models.DesensitizationStatus.done}
):
raise ValueError("只有已审核、低/中风险、且已完成脱敏或无需脱敏的标准问答可以标记为可调用")
standard.call_status = status
standard.last_reviewer_id = reviewer_id
add_audit_log(
db,
user_id=reviewer_id,
target_type=models.TargetType.standard_qa,
target_id=standard.id,
action=models.AuditAction.mark_callable if status == models.CallStatus.callable else models.AuditAction.disable,
before_status=before,
after_status=status.value,
comment=comment,
)
db.commit()
db.refresh(standard)
return standard

View File

@@ -0,0 +1,124 @@
from sqlalchemy.orm import Session
from app import models
from app.services.ai_cleaner import AICleaner
from app.services.feishu_client import FeishuClient
from app.services.mock_data_service import create_mock_session
from app.services.standard_qa_service import refresh_session_counts
from app.utils.time_utils import utc_now
class TaskRunner:
def __init__(self) -> None:
self.feishu = FeishuClient()
self.ai = AICleaner()
async def run(
self,
db: Session,
*,
task_type: models.TaskType = models.TaskType.manual_scan,
session_id: int | None = None,
reprocess: bool = False,
) -> models.TaskRun:
task = models.TaskRun(task_type=task_type, task_status=models.TaskStatus.running, started_at=utc_now())
db.add(task)
db.commit()
db.refresh(task)
try:
sessions = self._resolve_sessions(db, session_id=session_id)
if not sessions and self.feishu.is_mock_mode and session_id is None:
sessions = [create_mock_session(db)]
task.sessions_scanned = len(sessions)
for session in sessions:
try:
created_count = await self._process_session(db, session, reprocess=reprocess)
task.sessions_processed += 1
task.qa_created += created_count
except Exception as exc: # noqa: BLE001
task.qa_failed += 1
session.process_status = models.ProcessStatus.failed
session.failed_reason = str(exc)
task.error_message = str(exc)
db.add(session)
db.commit()
self.feishu.update_session_status(session, session.process_status.value)
task.task_status = (
models.TaskStatus.success
if task.qa_failed == 0
else models.TaskStatus.partial_success
if task.sessions_processed > 0
else models.TaskStatus.failed
)
except Exception as exc: # noqa: BLE001
task.task_status = models.TaskStatus.failed
task.error_message = str(exc)
finally:
task.ended_at = utc_now()
db.add(task)
db.commit()
db.refresh(task)
return task
def _resolve_sessions(self, db: Session, session_id: int | None) -> list[models.FeishuSession]:
if session_id is not None:
session = db.get(models.FeishuSession, session_id)
return [session] if session else []
return self.feishu.scan_unprocessed_sessions(db)
async def _process_session(self, db: Session, session: models.FeishuSession, *, reprocess: bool = False) -> int:
if reprocess:
db.query(models.StandardQAItem).filter(models.StandardQAItem.session_id == session.id).delete()
db.query(models.RawQAItem).filter(models.RawQAItem.session_id == session.id).delete()
db.commit()
session.process_status = models.ProcessStatus.processing
session.failed_reason = None
db.add(session)
db.commit()
self.feishu.update_session_status(session, session.process_status.value)
transcript = self.feishu.fetch_transcript(session)
session.source_text = transcript
ai_items = await self.ai.clean_transcript(session.id, transcript)
created: list[models.RawQAItem] = []
for index, item in enumerate(ai_items, start=1):
raw = models.RawQAItem(
session_id=session.id,
qa_code=f"{session.session_code}-QA-{index:03d}",
raw_question=item["raw_question"],
normalized_question=item.get("normalized_question"),
raw_answer=item["raw_answer"],
normalized_answer=item.get("normalized_answer"),
suggested_standard_question=item.get("suggested_standard_question"),
suggested_standard_answer=item.get("suggested_standard_answer"),
answer_person=item.get("answer_person"),
primary_topic=item.get("primary_topic", "其他"),
problem_tags=item.get("problem_tags", []),
course_stage=item.get("course_stage", "不确定"),
audience_tags=item.get("audience_tags", []),
emotion_intensity=item.get("emotion_intensity"),
risk_level=item.get("risk_level", "medium"),
risk_types=item.get("risk_types", []),
risk_notes=item.get("risk_notes"),
need_desensitization=item.get("need_desensitization", False),
desensitization_status=item.get("desensitization_status", "unknown"),
review_status=models.ReviewStatus.pending,
review_notes=item.get("review_notes"),
suggested_to_standard_qa=item.get("suggested_to_standard_qa", True),
source_timestamp=item.get("source_timestamp"),
)
db.add(raw)
created.append(raw)
session.process_status = models.ProcessStatus.pending_review
db.add(session)
db.commit()
refresh_session_counts(db, session.id)
db.commit()
self.feishu.write_raw_qa_items(session, created)
self.feishu.update_session_status(session, session.process_status.value)
return len(created)

View File

@@ -0,0 +1,43 @@
from typing import Any
REQUIRED_QA_FIELDS = {
"session_id",
"qa_id",
"raw_question",
"normalized_question",
"raw_answer",
"normalized_answer",
"suggested_standard_question",
"suggested_standard_answer",
"answer_person",
"primary_topic",
"problem_tags",
"course_stage",
"audience_tags",
"emotion_intensity",
"risk_level",
"risk_types",
"risk_notes",
"need_desensitization",
"desensitization_status",
"suggested_review_status",
"suggested_to_standard_qa",
"source_timestamp",
"review_notes",
}
def validate_ai_qa_items(payload: Any) -> list[dict[str, Any]]:
if not isinstance(payload, list):
raise ValueError("AI 返回必须是 JSON 数组")
valid_items: list[dict[str, Any]] = []
for index, item in enumerate(payload, start=1):
if not isinstance(item, dict):
raise ValueError(f"{index} 条 AI 结果不是对象")
missing = REQUIRED_QA_FIELDS.difference(item)
if missing:
raise ValueError(f"{index} 条 AI 结果缺少字段: {', '.join(sorted(missing))}")
valid_items.append(item)
return valid_items

View File

@@ -0,0 +1,6 @@
from pathlib import Path
def read_text_file(path: str | Path) -> str:
return Path(path).read_text(encoding="utf-8")

View File

@@ -0,0 +1,20 @@
from datetime import datetime, timezone
def utc_now() -> datetime:
return datetime.now(timezone.utc)
def split_cron(cron: str) -> dict[str, str]:
parts = cron.split()
if len(parts) != 5:
raise ValueError("SCHEDULE_CRON 需要使用 5 段 cron 表达式,例如 0 1 * * *")
minute, hour, day, month, day_of_week = parts
return {
"minute": minute,
"hour": hour,
"day": day,
"month": month,
"day_of_week": day_of_week,
}

View File

@@ -0,0 +1,10 @@
fastapi>=0.116.0
uvicorn>=0.35.0
SQLAlchemy>=2.0.40
psycopg[binary]>=3.2.9
pydantic>=2.11.0
pydantic-settings>=2.10.0
python-dotenv>=1.1.0
APScheduler>=3.11.0
httpx>=0.28.1
python-multipart>=0.0.20

View File

@@ -0,0 +1,48 @@
services:
postgres:
image: postgres:16-alpine
container_name: hy_qa_postgres
environment:
POSTGRES_DB: hyqa
POSTGRES_USER: hyqa
POSTGRES_PASSWORD: hyqa
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U hyqa -d hyqa"]
interval: 5s
timeout: 5s
retries: 10
backend:
build:
context: ./backend
container_name: hy_qa_backend
environment:
DATABASE_URL: postgresql+psycopg://hyqa:hyqa@postgres:5432/hyqa
MODEL_NAME: gpt-4.1-mini
SCHEDULE_CRON: "0 1 * * *"
SCHEDULE_TIMEZONE: Asia/Shanghai
MOCK_MODE: "true"
ports:
- "8000:8000"
depends_on:
postgres:
condition: service_healthy
frontend:
build:
context: ./frontend
container_name: hy_qa_frontend
environment:
NEXT_PUBLIC_API_BASE_URL: http://localhost:8000/api
ports:
- "3000:3000"
depends_on:
- backend
volumes:
postgres_data:

View File

@@ -0,0 +1,13 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["npm", "run", "start"]

View File

@@ -0,0 +1,46 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import type { CallableQAItem } from "@/lib/types";
export default function CallableQAPage() {
const [items, setItems] = useState<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>
);
}

View File

@@ -0,0 +1,93 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { AlertTriangle, CheckCircle2, Database, FileQuestion, Library, Play, Rows3, Timer } from "lucide-react";
import { api, formatDateTime } from "@/lib/api";
import type { DashboardSummary, TaskRun } from "@/lib/types";
import StatCard from "@/components/StatCard";
import StatusBadge from "@/components/StatusBadge";
export default function DashboardPage() {
const [summary, setSummary] = useState<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>
);
}

View File

@@ -0,0 +1,26 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
* {
box-sizing: border-box;
}
body {
margin: 0;
background: #f5f7fa;
color: #1f2933;
}
button,
input,
textarea,
select {
font: inherit;
}
.table-wrap {
width: 100%;
overflow-x: auto;
}

View File

@@ -0,0 +1,67 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { api } from "@/lib/api";
import type { RawQAItem } from "@/lib/types";
import QAReviewCard from "@/components/QAReviewCard";
const riskFilters = [
"全部",
"学员隐私",
"未成年人信息",
"婚姻家庭敏感内容",
"心理 / 医疗边界",
"法律边界",
"课程效果承诺",
"团队表达风险",
"其他"
];
export default function HighRiskPage() {
const [items, setItems] = useState<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>
);
}

View File

@@ -0,0 +1,19 @@
import type { Metadata } from "next";
import "./globals.css";
import Layout from "@/components/Layout";
export const metadata: Metadata = {
title: "大本营答疑资产后台系统 MVP",
description: "答疑转写清洗、审核与标准问答沉淀后台"
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="zh-CN">
<body>
<Layout>{children}</Layout>
</body>
</html>
);
}

View File

@@ -0,0 +1,89 @@
"use client";
import { useEffect, useState } from "react";
import { api, formatDateTime } from "@/lib/api";
import type { AuditLog, TaskRun } from "@/lib/types";
import StatusBadge from "@/components/StatusBadge";
export default function LogsPage() {
const [tasks, setTasks] = useState<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>
);
}

View File

@@ -0,0 +1,6 @@
import { redirect } from "next/navigation";
export default function Home() {
redirect("/dashboard");
}

View File

@@ -0,0 +1,36 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import type { RawQAItem } from "@/lib/types";
import QAReviewCard from "@/components/QAReviewCard";
export default function ReviewPage() {
const [items, setItems] = useState<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>
);
}

View File

@@ -0,0 +1,73 @@
"use client";
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { api } from "@/lib/api";
import type { FeishuSession, RawQAItem } from "@/lib/types";
import RiskBadge from "@/components/RiskBadge";
import StatusBadge from "@/components/StatusBadge";
export default function SessionDetailPage() {
const params = useParams<{ id: string }>();
const [session, setSession] = useState<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>
);
}

View File

@@ -0,0 +1,84 @@
"use client";
import { useEffect, useState } from "react";
import { Plus } from "lucide-react";
import { api } from "@/lib/api";
import type { FeishuSession } from "@/lib/types";
import SessionTable from "@/components/SessionTable";
export default function SessionsPage() {
const [sessions, setSessions] = useState<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>
);
}

View File

@@ -0,0 +1,166 @@
"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>
);
}

View File

@@ -0,0 +1,29 @@
"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>
);
}

View File

@@ -0,0 +1,13 @@
"use client";
import { ReactNode } from "react";
import Sidebar from "./Sidebar";
export default function Layout({ children }: { children: ReactNode }) {
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>
);
}

View File

@@ -0,0 +1,138 @@
"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>
);
}

View File

@@ -0,0 +1,20 @@
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>
);
}

View File

@@ -0,0 +1,91 @@
"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>
);
}

View File

@@ -0,0 +1,60 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
BookOpenCheck,
CalendarDays,
ClipboardCheck,
DatabaseZap,
Gauge,
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: "/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>
);
}

View File

@@ -0,0 +1,126 @@
"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>
);
}

View File

@@ -0,0 +1,33 @@
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>
);
}

View File

@@ -0,0 +1,46 @@
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>
);
}

View File

@@ -0,0 +1,40 @@
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));
}

View File

@@ -0,0 +1,163 @@
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[];
};

View File

@@ -0,0 +1,5 @@
/// <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.

View File

@@ -0,0 +1,7 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true
};
module.exports = nextConfig;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,27 @@
{
"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"
}
}

View File

@@ -0,0 +1,7 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

View File

@@ -0,0 +1,24 @@
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;

View File

@@ -0,0 +1,24 @@
{
"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"]
}

View File

@@ -0,0 +1,186 @@
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.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -0,0 +1,96 @@
{"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.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB