Initial MVP for QA asset backend
This commit is contained in:
2
hy_qa_asset_backend/backend/app/__init__.py
Normal file
2
hy_qa_asset_backend/backend/app/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Backend package for 大本营答疑资产后台系统 MVP."""
|
||||
|
||||
2
hy_qa_asset_backend/backend/app/api/__init__.py
Normal file
2
hy_qa_asset_backend/backend/app/api/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""API routers."""
|
||||
|
||||
42
hy_qa_asset_backend/backend/app/api/dashboard.py
Normal file
42
hy_qa_asset_backend/backend/app/api/dashboard.py
Normal 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,
|
||||
)
|
||||
|
||||
112
hy_qa_asset_backend/backend/app/api/feishu.py
Normal file
112
hy_qa_asset_backend/backend/app/api/feishu.py
Normal 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 或运行环境变量。",
|
||||
],
|
||||
)
|
||||
18
hy_qa_asset_backend/backend/app/api/logs.py
Normal file
18
hy_qa_asset_backend/backend/app/api/logs.py
Normal 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()
|
||||
|
||||
150
hy_qa_asset_backend/backend/app/api/raw_qa.py
Normal file
150
hy_qa_asset_backend/backend/app/api/raw_qa.py
Normal 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
|
||||
|
||||
72
hy_qa_asset_backend/backend/app/api/sessions.py
Normal file
72
hy_qa_asset_backend/backend/app/api/sessions.py
Normal 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
|
||||
|
||||
51
hy_qa_asset_backend/backend/app/api/settings.py
Normal file
51
hy_qa_asset_backend/backend/app/api/settings.py
Normal 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)
|
||||
|
||||
146
hy_qa_asset_backend/backend/app/api/standard_qa.py
Normal file
146
hy_qa_asset_backend/backend/app/api/standard_qa.py
Normal 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
|
||||
|
||||
28
hy_qa_asset_backend/backend/app/api/tasks.py
Normal file
28
hy_qa_asset_backend/backend/app/api/tasks.py
Normal 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)
|
||||
|
||||
61
hy_qa_asset_backend/backend/app/config.py
Normal file
61
hy_qa_asset_backend/backend/app/config.py
Normal 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()
|
||||
49
hy_qa_asset_backend/backend/app/database.py
Normal file
49
hy_qa_asset_backend/backend/app/database.py
Normal 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()
|
||||
|
||||
52
hy_qa_asset_backend/backend/app/main.py
Normal file
52
hy_qa_asset_backend/backend/app/main.py
Normal 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"}
|
||||
270
hy_qa_asset_backend/backend/app/models.py
Normal file
270
hy_qa_asset_backend/backend/app/models.py
Normal 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)
|
||||
147
hy_qa_asset_backend/backend/app/prompts/qa_cleaning_prompt.md
Normal file
147
hy_qa_asset_backend/backend/app/prompts/qa_cleaning_prompt.md
Normal 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_status:not_needed / needed / done / unknown。
|
||||
suggested_review_status:建议审核状态,pending / approved / needs_revision / rejected / forbidden。
|
||||
suggested_to_standard_qa:是否建议进入标准问答库,true / false。
|
||||
source_timestamp:原始时间戳,没有则为空。
|
||||
review_notes:给人工审核人的备注。
|
||||
|
||||
系统边界:
|
||||
|
||||
AI 只能辅助清洗、拆解、打标签、风险初筛,不能替代人工审核。未审核内容不能进入标准问答库,未审核内容不能被未来智能体调用。高风险内容必须人工复审。涉及学员隐私必须标记脱敏。不做心理诊断、医疗建议、法律判断,也不承诺课程效果。
|
||||
|
||||
255
hy_qa_asset_backend/backend/app/schemas.py
Normal file
255
hy_qa_asset_backend/backend/app/schemas.py
Normal 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]
|
||||
@@ -0,0 +1,8 @@
|
||||
[00:03:12] 学员:我和孩子沟通时总是忍不住发火,孩子现在一听我说话就躲开,我该怎么办?
|
||||
[00:04:20] 辅导老师:先不要急着讲道理,先看见自己发火背后的着急和无力。可以把要求先放低一点,用一句具体的话表达当下感受,比如我现在有点急,我想先停一下。
|
||||
|
||||
[00:16:44] 学员:我学习大本营后知道自己总是拖延,怎么开始一个小行动?
|
||||
[00:17:30] 院长:不要一开始就设很大的目标。先选一个今天能完成的动作,比如写三句话、整理十分钟。关键是让身体先体验到我可以开始。
|
||||
|
||||
[00:27:05] 学员:我朋友说自己有抑郁症,我能不能用课程里的方法帮她判断严重程度?
|
||||
[00:28:02] 辅导老师:这类情况不要做诊断,也不要替对方判断严重程度。你可以表达关心,建议对方寻求专业医生或心理专业人员帮助。
|
||||
49
hy_qa_asset_backend/backend/app/services/ai_cleaner.py
Normal file
49
hy_qa_asset_backend/backend/app/services/ai_cleaner.py
Normal 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)]
|
||||
|
||||
600
hy_qa_asset_backend/backend/app/services/feishu_client.py
Normal file
600
hy_qa_asset_backend/backend/app/services/feishu_client.py
Normal 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
|
||||
115
hy_qa_asset_backend/backend/app/services/mock_data_service.py
Normal file
115
hy_qa_asset_backend/backend/app/services/mock_data_service.py
Normal 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": "高风险边界题,不建议自动进入标准问答库。",
|
||||
},
|
||||
]
|
||||
|
||||
41
hy_qa_asset_backend/backend/app/services/qa_parser.py
Normal file
41
hy_qa_asset_backend/backend/app/services/qa_parser.py
Normal 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
|
||||
|
||||
35
hy_qa_asset_backend/backend/app/services/risk_detector.py
Normal file
35
hy_qa_asset_backend/backend/app/services/risk_detector.py
Normal 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
|
||||
|
||||
35
hy_qa_asset_backend/backend/app/services/scheduler.py
Normal file
35
hy_qa_asset_backend/backend/app/services/scheduler.py
Normal 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))
|
||||
|
||||
237
hy_qa_asset_backend/backend/app/services/standard_qa_service.py
Normal file
237
hy_qa_asset_backend/backend/app/services/standard_qa_service.py
Normal 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
|
||||
124
hy_qa_asset_backend/backend/app/services/task_runner.py
Normal file
124
hy_qa_asset_backend/backend/app/services/task_runner.py
Normal 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)
|
||||
43
hy_qa_asset_backend/backend/app/utils/json_validator.py
Normal file
43
hy_qa_asset_backend/backend/app/utils/json_validator.py
Normal 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
|
||||
|
||||
6
hy_qa_asset_backend/backend/app/utils/text_reader.py
Normal file
6
hy_qa_asset_backend/backend/app/utils/text_reader.py
Normal 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")
|
||||
|
||||
20
hy_qa_asset_backend/backend/app/utils/time_utils.py
Normal file
20
hy_qa_asset_backend/backend/app/utils/time_utils.py
Normal 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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user