feat: BossAi v2.0 UI/UX 重构 + 看板断点续接 + 多轮上下文
CI / 后端测试 (push) Has been cancelled
CI / 前端构建 (push) Has been cancelled

- 新增 web2/ 前端:任务卡看板、风险雷达、决策日志、我的团队、快捷指令
- ChatPanel 增强:风险卡置顶、案卡接入、起诉状向导、语音输入
- 事项看板:点击查看详情弹窗、断点续接(恢复历史对话+matterId)
- 保密模式恢复:密码验证后重建内存 session,保留多轮上下文
- 多轮对话:ask/ask-stream/ask-analyze 注入 session 历史,AI 有上下文
- 追问优化:紧扣用户问题,不再偏离主题(社保问社保,工伤问工伤)
- 对话区可滚动,输入区固定可见
- 状态机放宽:允许从任意非终态转到 pending_expert
- session 存活检查端点,后端重启后自动重建 session
- DB migration:风险推送表、案卡表

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
selfrelease
2026-08-31 08:38:33 +08:00
co-authored by Devin
parent 7a788bf8dc
commit 4de36547cd
88 changed files with 15237 additions and 62 deletions
+716 -15
View File
@@ -292,7 +292,7 @@ def generate_topics(req: TopicsRequest):
# 内置话题(降级用)
builtin_topics = {
"legal": [
{"title": "劳动用工", "desc": "工伤、辞退、欠薪、社保", "samples": ["员工工伤后能否开除?", "拖欠工资怎么追讨?", "未签合同双倍工资怎么算?", "社保没缴全怎么补?"]},
{"title": "劳动用工", "desc": "工伤、辞退、欠薪、社保", "samples": ["员工工伤后能开除吗?", "员工被辞退要赔多少?", "员工不签合同有何风险?", "社保未缴全被查怎么办?"]},
{"title": "合同审查", "desc": "违约、条款、效力", "samples": ["合同违约金怎么约定?", "对方不履行合同怎么办?", "口头协议有法律效力吗?", "合同条款不公平能改吗?"]},
{"title": "债权债务", "desc": "欠款、催收、诉讼", "samples": ["客户拖欠货款3个月如何催收?", "借条怎么写才有效?", "债务人不还钱能报警吗?", "超过诉讼时效怎么办?"]},
{"title": "股东纠纷", "desc": "股权、分红、退股", "samples": ["合伙人要退股怎么谈?", "股东不出资怎么办?", "分红比例怎么定?", "小股东权益被侵害怎么办?"]},
@@ -354,8 +354,9 @@ def generate_topics(req: TopicsRequest):
1. 话题要覆盖该场景下不同方向,不要只集中在某一类
2. 结合用户画像(如有)推荐个性化话题
3. 示例问题要具体、贴近中小企业老板实际
4. 返回JSON数组格式:[{{"title":"话题名","desc":"简短描述","samples":["问题1","问题2","问题3","问题4"]}}]
5. 只返回JSON,不要其他文字{existing_str}"""
4. **所有问题必须从老板/雇主视角出发**(BossAi 的用户是企业老板,不是员工)。例如劳动用工应问"员工工伤后能开除吗"而非"拖欠工资怎么追讨"
5. 返回JSON数组格式:[{{"title":"话题名","desc":"简短描述","samples":["问题1","问题2","问题3","问题4"]}}]
6. 只返回JSON,不要其他文字{existing_str}"""
try:
answer = kimi.chat([{"role": "user", "content": prompt}], temperature=0.8)
@@ -396,7 +397,7 @@ def generate_samples(req: SampleRequest):
"""
# 内置示例(降级用)
builtin = {
"legal": ["员工工伤后能否开除?", "收到税务稽查通知怎么办?", "客户拖欠货款 3 个月如何催收?", "合同违约金怎么约定?"],
"legal": ["员工工伤后能开除吗?", "收到税务稽查通知怎么办?", "客户拖欠货款 3 个月如何催收?", "合同违约金怎么约定?"],
"decision": ["是否应该接一个付款周期 90 天的大单?", "现在适合扩张开第二家店吗?", "该不该降价 15% 抢竞品的客户?", "合伙人要退股,怎么谈条件?"],
"life": ["怎么和青春期的孩子沟通不想他接班的事?", "父母年纪大了,养老方案怎么选?", "最近睡眠很差,有什么改善建议?", "体检报告有几项异常,需要进一步检查吗?"],
"emotion": ["最近压力很大,睡不着觉", "合伙人背叛了,我很愤怒", "明天的谈判我拖延了一周没准备", "感觉撑不下去了"],
@@ -429,9 +430,10 @@ def generate_samples(req: SampleRequest):
prompt += (
"要求:\n"
"1. 问题真实、具体、贴近中小企业老板的实际场景\n"
"2. 每个问题 15-30 字\n"
"3. 不要重复已展示过的问题\n"
"4. 只返回 JSON 数组,如 [\"问题1\", \"问题2\", \"问题3\", \"问题4\"]\n"
"2. **所有问题必须从老板/雇主视角出发**(用户是企业老板,不是员工)\n"
"3. 每个问题 15-30 字\n"
"4. 不要重复已展示过的问题\n"
"5. 只返回 JSON 数组,如 [\"问题1\", \"问题2\", \"问题3\", \"问题4\"]\n"
)
try:
@@ -485,6 +487,16 @@ def create_session(req: SessionRequest):
return {"session_id": s.session_id, "mode": s.mode, "expires_at": s.expires_at}
@app.get("/sessions/{sid}/alive")
def session_alive(sid: str):
"""检查内存 session 是否仍然存活(后端重启后内存 session 会丢失)。"""
try:
sessions._active(sid)
return {"alive": True}
except KeyError:
raise HTTPException(410, "session not found")
@app.post("/sessions/{sid}/ask")
def ask(sid: str, req: AskRequest, authorization: str | None = Header(default=None)):
# 从 token 解析手机号(用于用户画像)
@@ -518,9 +530,19 @@ def ask(sid: str, req: AskRequest, authorization: str | None = Header(default=No
prompt = prompt + "\n\n" + ctx
rag_context = knowledge.search(req.question, limit=3,
doc_types=knowledge.SCENE_DOC_TYPES.get(req.scene))
# 带降级的云端调用
# 带降级的云端调用(注入 session 历史实现多轮上下文)
chat_messages = [{"role": "system", "content": prompt}]
# 取最近的历史消息(排除当前刚 append 的 user 消息)
history = session.messages[:-1] if session.messages else []
recent_history = history[-6:] if len(history) > 6 else history
for h in recent_history:
role = h.get("role", "user")
content = h.get("content", "")
if role in ("user", "assistant") and content:
chat_messages.append({"role": role, "content": content})
chat_messages.append({"role": "user", "content": req.question})
result = kimi.chat_with_fallback(
[{"role": "system", "content": prompt}, {"role": "user", "content": req.question}],
chat_messages,
local_fallback=card.conclusion,
)
answer = result["answer"]
@@ -603,8 +625,12 @@ def ask_analyze(sid: str, req: AskRequest, authorization: str | None = Header(de
# 事实追问分析(AI 生成追问 + 规则兜底)
answers = [a.model_dump() for a in (req.fact_answers or [])]
# 获取 session 历史消息,让追问能理解多轮上下文
session = sessions.sessions.get(sid)
session_history = session.messages[:-1] if session and session.messages else []
prompt_or_questions, meta = fact_finder.build_analysis_prompt(
req.question, req.scene, answers, kimi_client=kimi,
history=session_history,
)
# 收集用户画像中的已知事实(带入内容),按场景过滤相关性
@@ -807,7 +833,7 @@ def generate_element_filing(sid: str, req: ElementFilingRequest, authorization:
# AI 填充要素
elements_json = json.dumps(tmpl.get("elements", []), ensure_ascii=False)
prompt = f"""你是法律文书助手。根据用户描述和确认的事实,填充要素式起诉状的各要素。
prompt = f"""你是法律文书助手,服务对象是中小企业老板(原告方)。根据用户描述和确认的事实,填充要素式起诉状的各要素。
案由:{tmpl.get("cause_of_action", req.cause_of_action)}
要素模板:{elements_json}
@@ -938,12 +964,23 @@ def ask_stream(sid: str, req: AskRequest, authorization: str | None = Header(def
# 1. 先发风险卡
yield f"event: risk\ndata: {json.dumps(card.__dict__, ensure_ascii=False)}\n\n"
# 2. 流式答复
# 2. 流式答复(注入 session 历史实现多轮上下文)
if kimi:
messages = [
{"role": "system", "content": prompt},
{"role": "user", "content": req.question},
]
# 构建 messages:system + 历史对话(最近 6 条) + 当前问题
messages = [{"role": "system", "content": prompt}]
# 取最近的历史消息(排除当前刚 append 的 user 消息)
history = session.messages[:-1] if session.messages else []
recent_history = history[-6:] if len(history) > 6 else history
for h in recent_history:
role = h.get("role", "user")
content = h.get("content", "")
if role in ("user", "assistant") and content:
messages.append({"role": role, "content": content})
# 当前问题(带事实摘要)
current_content = req.question
if fact_summary:
current_content = f"{req.question}\n\n已确认事实:\n{fact_summary}"
messages.append({"role": "user", "content": current_content})
try:
for token in kimi.chat_stream(messages):
full_answer.append(token)
@@ -1457,6 +1494,7 @@ def list_history_sessions(
@app.get("/history/sessions/{session_id}")
def get_history_messages(
session_id: str,
request: Request,
authorization: str | None = Header(default=None),
):
"""获取会话的对话历史。
@@ -1484,6 +1522,19 @@ def get_history_messages(
"message_count": len(messages),
}
# 普通模式:直接返回明文
# 2.0 新增:如果内存 session 不存在,重建内存 session 并加载历史消息
try:
sessions._active(session_id)
except KeyError:
new_session = sessions.create("normal")
old_session = sessions.sessions.pop(new_session.session_id)
old_session.session_id = session_id
sessions.sessions[session_id] = old_session
for msg in messages:
role = msg.get("role", "user")
content = msg.get("content", "")
if role in ("user", "assistant") and content:
sessions.append(session_id, role, content)
return {
"session_id": session_id,
"mode": "normal",
@@ -1519,6 +1570,25 @@ def verify_and_get_history(
messages = chat_store.get_messages(session_id, phone, decrypt=True)
if not messages:
raise HTTPException(404, "会话不存在或无历史")
# 2.0 新增:如果内存 session 不存在(后端重启后丢失),重建内存 session 并加载历史消息
# 这样后续 ask-analyze / ask-stream 能复用 session 且有上下文
try:
sessions._active(session_id)
except KeyError:
# 内存 session 不存在,创建新的 confidential session 然后替换 session_id
new_session = sessions.create("confidential")
# 把新 session 的 session_id 替换为旧的 session_id(保留 DB 关联)
old_session = sessions.sessions.pop(new_session.session_id)
old_session.session_id = session_id
sessions.sessions[session_id] = old_session
# 加载历史消息到内存 session
for msg in messages:
role = msg.get("role", "user")
content = msg.get("content", "")
if role in ("user", "assistant") and content:
sessions.append(session_id, role, content)
return {
"session_id": session_id,
"mode": "confidential",
@@ -2009,3 +2079,634 @@ from fastapi.staticfiles import StaticFiles
_upload_dir = os.path.join(os.path.dirname(__file__), "uploads")
os.makedirs(_upload_dir, exist_ok=True)
app.mount("/uploads", StaticFiles(directory=_upload_dir), name="uploads")
# ============================================================
# BossAi 2.0 新增端点:案卡 / 老板台 / 我的团队 / 快捷指令
# ============================================================
import psycopg2
from datetime import datetime, timezone
_DSN = os.getenv("DATABASE_URL", "host=localhost port=5432 dbname=mcp user=postgres")
def _matter_dsn():
"""获取 DB 连接(复用全局 DSN)"""
return psycopg2.connect(_DSN)
# 案卡状态机:合法的前向转换
MATTER_TRANSITIONS = {
"new": {"asking_facts", "pending_confirm", "analyzing", "pending_expert", "closed"},
"asking_facts": {"pending_confirm", "analyzing", "pending_expert", "closed"},
"pending_confirm": {"analyzing", "pending_expert", "closed"},
"analyzing": {"pending_expert", "closed"},
"pending_expert": {"closed"},
"closed": set(), # 终态
}
class MatterCreateRequest(BaseModel):
scene: str # legal / decision / life / emotion
title: str
session_id: str | None = None
class MatterStatusUpdateRequest(BaseModel):
status: str
class MatterCloseRequest(BaseModel):
execution_result: str | None = None
execution_note: str | None = None
def _gen_matter_code(cur) -> str:
"""生成案卡编号 MTR_{YYYYMMDD}_{seq}"""
today = datetime.now(timezone.utc).strftime("%Y%m%d")
prefix = f"MTR_{today}_"
cur.execute("SELECT code FROM matters WHERE code LIKE %s ORDER BY code DESC LIMIT 1", (prefix + "%",))
row = cur.fetchone()
seq = 1
if row:
try:
seq = int(row[0].rsplit("_", 1)[-1]) + 1
except ValueError:
seq = 1
return f"{prefix}{seq:03d}"
@app.get("/matters")
def list_matters(status: str | None = None, authorization: str | None = Header(default=None)):
"""案卡看板:按状态分组返回(可选 status 过滤单状态)"""
phone = get_current_phone(authorization)
if not phone:
raise HTTPException(401, "未登录")
conn = _matter_dsn()
try:
cur = conn.cursor()
if status:
cur.execute(
"SELECT id, code, user_phone, scene, title, status, session_id, risk_level, "
"summary, closed_at, execution_result, execution_note, created_at, updated_at "
"FROM matters WHERE user_phone = %s AND status = %s ORDER BY updated_at DESC",
(phone, status),
)
else:
cur.execute(
"SELECT id, code, user_phone, scene, title, status, session_id, risk_level, "
"summary, closed_at, execution_result, execution_note, created_at, updated_at "
"FROM matters WHERE user_phone = %s ORDER BY updated_at DESC",
(phone,),
)
rows = cur.fetchall()
cols = ["id", "code", "user_phone", "scene", "title", "status", "session_id",
"risk_level", "summary", "closed_at", "execution_result", "execution_note",
"created_at", "updated_at"]
matters = [dict(zip(cols, r)) for r in rows]
# 转换时间为字符串
for m in matters:
for k in ("closed_at", "created_at", "updated_at"):
if m.get(k):
m[k] = m[k].isoformat() if hasattr(m[k], "isoformat") else str(m[k])
# 按状态分组
board = {
"new": [], "asking_facts": [], "pending_confirm": [],
"analyzing": [], "pending_expert": [], "closed": [],
}
for m in matters:
if m["status"] in board:
board[m["status"]].append(m)
return {"board": board, "total": len(matters)}
finally:
conn.close()
@app.post("/matters")
def create_matter(req: MatterCreateRequest, authorization: str | None = Header(default=None)):
"""创建案卡(绑定会话)"""
phone = get_current_phone(authorization)
if not phone:
raise HTTPException(401, "未登录")
if req.scene not in ("legal", "decision", "life", "emotion"):
raise HTTPException(422, "场景必须是 legal/decision/life/emotion")
conn = _matter_dsn()
try:
cur = conn.cursor()
code = _gen_matter_code(cur)
cur.execute(
"INSERT INTO matters (code, user_phone, scene, title, status, session_id) "
"VALUES (%s, %s, %s, %s, 'new', %s) "
"RETURNING id, code, user_phone, scene, title, status, session_id, risk_level, "
"summary, closed_at, execution_result, execution_note, created_at, updated_at",
(code, phone, req.scene, req.title, req.session_id),
)
row = cur.fetchone()
conn.commit()
cols = ["id", "code", "user_phone", "scene", "title", "status", "session_id",
"risk_level", "summary", "closed_at", "execution_result", "execution_note",
"created_at", "updated_at"]
m = dict(zip(cols, row))
for k in ("closed_at", "created_at", "updated_at"):
if m.get(k):
m[k] = m[k].isoformat() if hasattr(m[k], "isoformat") else str(m[k])
return m
finally:
conn.close()
@app.get("/matters/by-session/{sid}")
def get_matter_by_session(sid: str, authorization: str | None = Header(default=None)):
"""通过 session_id 查询案卡(断点续接时用于恢复 matterId)。"""
phone = get_current_phone(authorization)
if not phone:
raise HTTPException(401, "未登录")
conn = _matter_dsn()
try:
cur = conn.cursor()
cur.execute(
"SELECT id, code, user_phone, scene, title, status, session_id, risk_level, "
"summary, closed_at, execution_result, execution_note, created_at, updated_at "
"FROM matters WHERE session_id = %s AND user_phone = %s "
"ORDER BY updated_at DESC LIMIT 1",
(sid, phone),
)
row = cur.fetchone()
if not row:
raise HTTPException(404, "未找到关联案卡")
cols = ["id", "code", "user_phone", "scene", "title", "status", "session_id",
"risk_level", "summary", "closed_at", "execution_result", "execution_note",
"created_at", "updated_at"]
m = dict(zip(cols, row))
for k in ("closed_at", "created_at", "updated_at"):
if m.get(k):
m[k] = m[k].isoformat() if hasattr(m[k], "isoformat") else str(m[k])
return m
finally:
conn.close()
@app.get("/matters/{matter_id}")
def get_matter(matter_id: int, authorization: str | None = Header(default=None)):
"""案卡详情"""
phone = get_current_phone(authorization)
if not phone:
raise HTTPException(401, "未登录")
conn = _matter_dsn()
try:
cur = conn.cursor()
cur.execute(
"SELECT id, code, user_phone, scene, title, status, session_id, risk_level, "
"summary, closed_at, execution_result, execution_note, created_at, updated_at "
"FROM matters WHERE id = %s AND user_phone = %s",
(matter_id, phone),
)
row = cur.fetchone()
if not row:
raise HTTPException(404, "案卡不存在或无权访问")
cols = ["id", "code", "user_phone", "scene", "title", "status", "session_id",
"risk_level", "summary", "closed_at", "execution_result", "execution_note",
"created_at", "updated_at"]
m = dict(zip(cols, row))
for k in ("closed_at", "created_at", "updated_at"):
if m.get(k):
m[k] = m[k].isoformat() if hasattr(m[k], "isoformat") else str(m[k])
return m
finally:
conn.close()
@app.patch("/matters/{matter_id}/status")
def update_matter_status(matter_id: int, req: MatterStatusUpdateRequest,
authorization: str | None = Header(default=None)):
"""更新案卡状态(校验状态机)"""
phone = get_current_phone(authorization)
if not phone:
raise HTTPException(401, "未登录")
if req.status not in MATTER_TRANSITIONS:
raise HTTPException(422, f"非法状态: {req.status}")
conn = _matter_dsn()
try:
cur = conn.cursor()
cur.execute("SELECT status FROM matters WHERE id = %s AND user_phone = %s", (matter_id, phone))
row = cur.fetchone()
if not row:
raise HTTPException(404, "案卡不存在或无权访问")
current = row[0]
if req.status not in MATTER_TRANSITIONS.get(current, set()):
raise HTTPException(422, f"非法状态转换: {current} → {req.status}")
cur.execute(
"UPDATE matters SET status = %s, updated_at = now() WHERE id = %s",
(req.status, matter_id),
)
conn.commit()
return {"updated": True, "status": req.status}
finally:
conn.close()
@app.post("/matters/{matter_id}/close")
def close_matter(matter_id: int, req: MatterCloseRequest,
authorization: str | None = Header(default=None)):
"""闭环案卡(可选填执行结果)"""
phone = get_current_phone(authorization)
if not phone:
raise HTTPException(401, "未登录")
conn = _matter_dsn()
try:
cur = conn.cursor()
cur.execute("SELECT status FROM matters WHERE id = %s AND user_phone = %s", (matter_id, phone))
row = cur.fetchone()
if not row:
raise HTTPException(404, "案卡不存在或无权访问")
if row[0] == "closed":
raise HTTPException(422, "案卡已闭环")
cur.execute(
"UPDATE matters SET status = 'closed', closed_at = now(), "
"execution_result = %s, execution_note = %s, updated_at = now() WHERE id = %s",
(req.execution_result, req.execution_note, matter_id),
)
conn.commit()
return {"closed": True, "closed_at": datetime.now(timezone.utc).isoformat()}
finally:
conn.close()
@app.get("/dashboard/today")
def dashboard_today(authorization: str | None = Header(default=None)):
"""老板台聚合:高风险 + 待确认 + 专家回复 + 健康度"""
phone = get_current_phone(authorization)
if not phone:
raise HTTPException(401, "未登录")
conn = _matter_dsn()
try:
cur = conn.cursor()
# 高风险案卡
cur.execute(
"SELECT id, title, risk_level, scene FROM matters "
"WHERE user_phone = %s AND risk_level IN ('red', 'orange') AND status != 'closed' "
"ORDER BY updated_at DESC LIMIT 5",
(phone,),
)
high_risks = [{"matter_id": r[0], "title": r[1], "level": r[2], "scene": r[3]} for r in cur.fetchall()]
# 待确认案卡
cur.execute(
"SELECT id, title, scene, updated_at FROM matters "
"WHERE user_phone = %s AND status = 'pending_confirm' "
"ORDER BY updated_at DESC LIMIT 5",
(phone,),
)
pending = [{
"matter_id": r[0], "title": r[1], "scene": r[2],
"updated_at": r[3].isoformat() if r[3] else None,
} for r in cur.fetchall()]
# 专家未读回复
cur.execute(
"SELECT c.id, e.name, e.title, c.reply, c.replied_at "
"FROM expert_consultations c JOIN experts e ON c.expert_id = e.id "
"WHERE c.user_phone = %s AND c.status = 'replied' "
"ORDER BY c.replied_at DESC LIMIT 5",
(phone,),
)
replies = [{
"consultation_id": r[0], "expert_name": r[1], "expert_title": r[2],
"snippet": (r[3] or "")[:80], "replied_at": r[4].isoformat() if r[4] else None,
} for r in cur.fetchall()]
# 健康度:基于案卡风险分布简单计算
cur.execute(
"SELECT risk_level, COUNT(*) FROM matters "
"WHERE user_phone = %s AND status != 'closed' GROUP BY risk_level",
(phone,),
)
risk_dist = {r[0] or "green": r[1] for r in cur.fetchall()}
total_open = sum(risk_dist.values()) or 1
# 法务健康度:无风险=100,有red=低
legal_score = max(0, 100 - risk_dist.get("red", 0) * 30 - risk_dist.get("orange", 0) * 15 - risk_dist.get("amber", 0) * 5)
operation_score = max(20, 100 - risk_dist.get("red", 0) * 20 - risk_dist.get("orange", 0) * 10)
compliance_score = max(30, 100 - risk_dist.get("red", 0) * 25 - risk_dist.get("amber", 0) * 10)
# 问候语
hour = datetime.now(timezone.utc).hour + 8 # 北京时间
if hour >= 24: hour -= 24
if 5 <= hour < 9: greeting = "早上好"
elif 9 <= hour < 12: greeting = "上午好"
elif 12 <= hour < 14: greeting = "中午好"
elif 14 <= hour < 18: greeting = "下午好"
elif 18 <= hour < 22: greeting = "晚上好"
else: greeting = "夜深了"
return {
"greeting": greeting,
"high_risks": high_risks,
"pending_confirm": pending,
"expert_replies": replies,
"health_score": {
"legal": legal_score,
"operation": operation_score,
"compliance": compliance_score,
},
"unread_alerts": 0, # 阶段三接入风险推送
}
finally:
conn.close()
@app.get("/team")
def my_team(authorization: str | None = Header(default=None)):
"""我的团队:咨询过的专家自动沉淀"""
phone = get_current_phone(authorization)
if not phone:
raise HTTPException(401, "未登录")
conn = _matter_dsn()
try:
cur = conn.cursor()
# 从 expert_consultations 聚合关系
cur.execute(
"SELECT e.id, e.name, e.avatar_url, e.category, e.title, e.expertise, "
"COUNT(c.id) as cnt, MAX(c.replied_at) as last_at, "
"(SELECT c2.topic FROM expert_consultations c2 WHERE c2.expert_id = e.id AND c2.user_phone = %s ORDER BY c2.created_at DESC LIMIT 1) as last_topic "
"FROM experts e JOIN expert_consultations c ON c.expert_id = e.id "
"WHERE c.user_phone = %s "
"GROUP BY e.id, e.name, e.avatar_url, e.category, e.title, e.expertise "
"ORDER BY last_at DESC NULLS LAST",
(phone, phone),
)
rows = cur.fetchall()
results = [{
"expert_id": r[0], "name": r[1], "avatar_url": r[2], "category": r[3],
"title": r[4], "expertise": r[5], "online": False, # 在线状态待接入
"consultation_count": r[6],
"last_consulted_at": r[7].isoformat() if r[7] else None,
"last_topic": r[8], "tags": r[5],
} for r in rows]
return {"results": results}
finally:
conn.close()
@app.get("/quick-commands")
def quick_commands(authorization: str | None = Header(default=None)):
"""快捷指令:基于画像生成个性化指令(阶段二完整实现,当前返回通用指令)"""
phone = get_current_phone(authorization)
if not phone:
raise HTTPException(401, "未登录")
# 获取画像
industry = "通用"
profile_incomplete = False
try:
ps = UserProfileStore()
profile = ps.get(phone) or {}
p = profile.get("profile", {}) if isinstance(profile, dict) else {}
if p.get("industry"):
industry = p.get("industry")
else:
profile_incomplete = True
except Exception:
profile_incomplete = True
# 通用指令(阶段二接入 AI 生成)
commands = {
"legal": [
{"id": "contract_review", "label": "查合同风险", "template": "我有一份合同,帮我看看有什么风险条款需要注意的。"},
{"id": "labor_compliance", "label": "用工合规", "template": "我的公司在用工方面有哪些合规风险需要注意?"},
{"id": "debt_collection", "label": "要账起诉", "template": "有客户欠款逾期未付,我该怎么催收或起诉?"},
],
"decision": [
{"id": "expand", "label": "该不该扩张", "template": "我在考虑开新店/扩张业务,帮我分析利弊。"},
{"id": "calc_profit", "label": "算一笔账", "template": "帮我算一下这个项目的投入产出和回本周期。"},
{"id": "partner", "label": "找人合伙", "template": "我想找人合伙,股权和分工该怎么分?"},
],
"life": [
{"id": "time_mgmt", "label": "时间管理", "template": "我最近总觉得时间不够用,帮我规划一下。"},
{"id": "family", "label": "家庭关系", "template": "创业以来和家人沟通变少了,该怎么办?"},
{"id": "health", "label": "健康管理", "template": "我长期久坐熬夜,有什么健康管理建议?"},
],
"profile_incomplete": profile_incomplete,
"incomplete_hint": "完善行业和规模信息,解锁更精准的建议" if profile_incomplete else None,
}
return commands
# ============================================================
# 风险推送(TASK-022~023)
# ============================================================
class RiskAlertCreateRequest(BaseModel):
"""内部用:创建风险推送"""
user_phone: str
level: str # red / orange / blue
title: str
content: str
source: str = "knowledge"
related_matter_id: int | None = None
@app.get("/risk-alerts")
def list_risk_alerts(unread: bool = False, authorization: str | None = Header(default=None)):
"""风险推送列表(可选 unread=true 只返回未读)"""
phone = get_current_phone(authorization)
if not phone:
raise HTTPException(401, "未登录")
conn = _matter_dsn()
try:
cur = conn.cursor()
if unread:
cur.execute(
"SELECT id, level, title, content, source, related_matter_id, read_at, created_at "
"FROM risk_alerts WHERE user_phone = %s AND read_at IS NULL "
"ORDER BY created_at DESC LIMIT 20",
(phone,),
)
else:
cur.execute(
"SELECT id, level, title, content, source, related_matter_id, read_at, created_at "
"FROM risk_alerts WHERE user_phone = %s "
"ORDER BY created_at DESC LIMIT 50",
(phone,),
)
rows = cur.fetchall()
cols = ["id", "level", "title", "content", "source", "related_matter_id", "read_at", "created_at"]
results = []
for r in rows:
d = dict(zip(cols, r))
for k in ("read_at", "created_at"):
if d.get(k):
d[k] = d[k].isoformat() if hasattr(d[k], "isoformat") else str(d[k])
results.append(d)
return {"results": results}
finally:
conn.close()
@app.post("/risk-alerts/{alert_id}/read")
def read_risk_alert(alert_id: int, authorization: str | None = Header(default=None)):
"""标记风险推送已读"""
phone = get_current_phone(authorization)
if not phone:
raise HTTPException(401, "未登录")
conn = _matter_dsn()
try:
cur = conn.cursor()
cur.execute(
"UPDATE risk_alerts SET read_at = now() WHERE id = %s AND user_phone = %s AND read_at IS NULL",
(alert_id, phone),
)
conn.commit()
return {"read": True}
finally:
conn.close()
def _create_risk_alert(user_phone: str, level: str, title: str, content: str,
source: str = "knowledge", related_matter_id: int | None = None):
"""内部函数:创建风险推送(供定时任务和文档扫描调用)"""
conn = _matter_dsn()
try:
cur = conn.cursor()
cur.execute(
"INSERT INTO risk_alerts (user_phone, level, title, content, source, related_matter_id) "
"VALUES (%s, %s, %s, %s, %s, %s) RETURNING id",
(user_phone, level, title, content, source, related_matter_id),
)
alert_id = cur.fetchone()[0]
conn.commit()
return alert_id
finally:
conn.close()
@app.post("/risk-alerts/scan")
def scan_risk_alerts(authorization: str | None = Header(default=None)):
"""手动触发风险扫描:扫描知识库近7天新增文档,匹配用户画像行业
定时任务也可调用此端点(内部调用无 auth)。
"""
phone = get_current_phone(authorization)
if not phone:
raise HTTPException(401, "未登录")
conn = _matter_dsn()
try:
cur = conn.cursor()
# 获取用户画像行业
industry = None
try:
ps = UserProfileStore()
profile = ps.get(phone) or {}
p = profile.get("profile", {}) if isinstance(profile, dict) else {}
industry = p.get("industry")
except Exception:
pass
# 扫描知识库近7天新增文档
cur.execute(
"SELECT id, title, content, doc_type, keywords FROM knowledge_docs "
"WHERE created_at > now() - interval '7 days' AND status = 'active' "
"ORDER BY created_at DESC LIMIT 10"
)
docs = cur.fetchall()
alerts_created = 0
for doc_id, title, content, doc_type, keywords in docs:
# 简单匹配:如果文档关键词包含用户行业,或用户无行业则全部推送
kw = keywords or ""
if industry and industry.lower() not in kw.lower():
continue
# 检查是否已推送过(避免重复)
cur.execute(
"SELECT 1 FROM risk_alerts WHERE user_phone = %s AND title = %s",
(phone, title),
)
if cur.fetchone():
continue
level = "orange" if doc_type == "law" else "blue"
_create_risk_alert(phone, level, title, content[:200], "knowledge")
alerts_created += 1
return {"scanned": len(docs), "created": alerts_created}
finally:
conn.close()
# ============================================================
# 决策日志(TASK-025 后端)
# ============================================================
@app.get("/decisions-log")
def decisions_log(month: str | None = None, authorization: str | None = Header(default=None)):
"""决策日志:闭环案卡按月分组
month 格式: YYYY-MM,不传则返回全部
"""
phone = get_current_phone(authorization)
if not phone:
raise HTTPException(401, "未登录")
conn = _matter_dsn()
try:
cur = conn.cursor()
if month:
cur.execute(
"SELECT id, code, scene, title, risk_level, summary, "
"closed_at, execution_result, execution_note, created_at "
"FROM matters WHERE user_phone = %s AND status = 'closed' "
"AND to_char(closed_at, 'YYYY-MM') = %s "
"ORDER BY closed_at DESC",
(phone, month),
)
else:
cur.execute(
"SELECT id, code, scene, title, risk_level, summary, "
"closed_at, execution_result, execution_note, created_at "
"FROM matters WHERE user_phone = %s AND status = 'closed' "
"ORDER BY closed_at DESC",
(phone,),
)
rows = cur.fetchall()
cols = ["id", "code", "scene", "title", "risk_level", "summary",
"closed_at", "execution_result", "execution_note", "created_at"]
results = []
for r in rows:
d = dict(zip(cols, r))
for k in ("closed_at", "created_at"):
if d.get(k):
d[k] = d[k].isoformat() if hasattr(d[k], "isoformat") else str(d[k])
results.append(d)
# 按月分组
by_month = {}
for d in results:
m = (d.get("closed_at") or "")[:7]
if m not in by_month:
by_month[m] = []
by_month[m].append(d)
# 统计
total = len(results)
executed = sum(1 for r in results if r.get("execution_result") == "顺利")
execution_rate = round(executed / total * 100) if total > 0 else 0
return {
"results": results,
"by_month": by_month,
"stats": {"total": total, "executed": executed, "execution_rate": execution_rate},
}
finally:
conn.close()
@app.patch("/matters/{matter_id}/execution")
def update_execution_result(matter_id: int, body: dict,
authorization: str | None = Header(default=None)):
"""更新决策执行结果(闭环后可修改)"""
phone = get_current_phone(authorization)
if not phone:
raise HTTPException(401, "未登录")
execution_result = body.get("execution_result")
execution_note = body.get("execution_note")
if execution_result not in ("顺利", "受阻", "未执行", None):
raise HTTPException(422, "执行结果必须是 顺利/受阻/未执行")
conn = _matter_dsn()
try:
cur = conn.cursor()
cur.execute(
"UPDATE matters SET execution_result = %s, execution_note = %s, updated_at = now() "
"WHERE id = %s AND user_phone = %s AND status = 'closed'",
(execution_result, execution_note, matter_id, phone),
)
if cur.rowcount == 0:
raise HTTPException(404, "案卡不存在或未闭环")
conn.commit()
return {"updated": True}
finally:
conn.close()