feat: BossAi v2.0 UI/UX 重构 + 看板断点续接 + 多轮上下文
- 新增 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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user