feat: 话题动态化 + 要素化立案 + 知识库增强
CI / 后端测试 (push) Has been cancelled
CI / 前端构建 (push) Has been cancelled

话题动态化:
- 新增 POST /topics 端点,AI 根据场景+画像动态生成话题
- 前端3个助手页面改为兜底话题先行,换一批时AI生成
- 去除固化话题列表,支持个性化推荐

要素化立案(对接最高法67类要素表):
- 导入68类要素式起诉状模板到知识库(法〔2025〕82号)
- 覆盖民事/商事/知识产权/行政/刑事自诉/海事/环境/国家赔偿/执行9领域
- ask-analyze 法务场景自动匹配案由模板
- 新增 POST /element-filing 端点,AI填充要素生成要素式起诉状
- ChatPanel 确认事实后可一键生成要素式起诉状,支持复制全文

知识库增强:
- 要素表模板展示全中文化(type/required/标签翻译)
- 知识库页面要素表模板结构化展示(案由/要素清单/诉讼请求)
- 各类型tab显示数量和总数量

画像翻译完善:
- 补充90+个key_facts key中文翻译
- MyProfile多列紧凑布局

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-30 15:12:33 +08:00
co-authored by Devin
parent 4e2b4666aa
commit 7a788bf8dc
11 changed files with 2291 additions and 390 deletions
+360 -8
View File
@@ -216,6 +216,7 @@ class AskRequest(BaseModel):
question: str
scene: str = "legal"
emotion_mode: str | None = None
topic: str | None = None
"""用户补充的事实回答(多轮追问后传入)"""
fact_answers: list[FactAnswer] | None = None
"""手机号(从 JWT token 解析,用于用户画像)"""
@@ -274,6 +275,119 @@ class SampleRequest(BaseModel):
existing: list[str] | None = None # 已展示的示例,避免重复
class TopicsRequest(BaseModel):
"""动态话题生成请求。"""
scene: str = "legal"
phone: str | None = None # 用户手机号,用于读取画像个性化推荐
existing: list[str] | None = None # 已展示的话题,避免重复
@app.post("/topics")
def generate_topics(req: TopicsRequest):
"""AI 根据场景 + 用户画像动态生成话题列表。
不再固化话题,每次进入页面时 AI 生成 6-8 个话题,
结合用户画像(行业/规模/常问话题)个性化推荐。
"""
# 内置话题(降级用)
builtin_topics = {
"legal": [
{"title": "劳动用工", "desc": "工伤、辞退、欠薪、社保", "samples": ["员工工伤后能否开除?", "拖欠工资怎么追讨?", "未签合同双倍工资怎么算?", "社保没缴全怎么补?"]},
{"title": "合同审查", "desc": "违约、条款、效力", "samples": ["合同违约金怎么约定?", "对方不履行合同怎么办?", "口头协议有法律效力吗?", "合同条款不公平能改吗?"]},
{"title": "债权债务", "desc": "欠款、催收、诉讼", "samples": ["客户拖欠货款3个月如何催收?", "借条怎么写才有效?", "债务人不还钱能报警吗?", "超过诉讼时效怎么办?"]},
{"title": "股东纠纷", "desc": "股权、分红、退股", "samples": ["合伙人要退股怎么谈?", "股东不出资怎么办?", "分红比例怎么定?", "小股东权益被侵害怎么办?"]},
{"title": "税务风险", "desc": "稽查、发票、合规", "samples": ["收到税务稽查通知怎么办?", "发票开错了怎么处理?", "个体户怎么交税最合理?", "税务罚款能减免吗?"]},
{"title": "诉讼仲裁", "desc": "起诉、举证、执行", "samples": ["打官司要准备哪些证据?", "仲裁和诉讼哪个更快?", "赢了官司对方不执行怎么办?", "诉讼费用怎么算?"]},
],
"decision": [
{"title": "扩张收缩", "desc": "开店、关店、业务线", "samples": ["现在适合扩张开第二家店吗?", "要不要关闭亏损的业务线?", "新业务线该自营还是加盟?", "应该先扩张还是先巩固?"]},
{"title": "项目承接", "desc": "大单、长期合同", "samples": ["是否应该接付款周期90天的大单?", "长期框架合同要不要签?", "预付款比例低的项目能接吗?", "客户要求独家合作利弊?"]},
{"title": "定价策略", "desc": "涨价、降价、促销", "samples": ["该不该降价15%抢竞品客户?", "产品涨价怎么跟客户沟通?", "促销活动会不会损害品牌?", "成本上涨如何调整定价?"]},
{"title": "招人裁员", "desc": "招聘、解雇、绩效", "samples": ["现在该招人还是裁人?", "裁掉绩效差的员工怎么操作?", "关键岗位招人还是培养?", "外包和正式员工怎么选?"]},
{"title": "融资合伙", "desc": "融资、合伙、股权", "samples": ["现在适合融资吗?", "合伙人要退股怎么谈条件?", "出让多少股权合适?", "银行贷款还是股权融资?"]},
{"title": "转型外包", "desc": "转型、外包、调整", "samples": ["业务转型方向怎么选?", "哪些业务可以外包?", "转型期间现金流怎么管理?", "外包质量怎么控?"]},
],
"life": [
{"title": "家庭沟通", "desc": "亲子、夫妻、关系", "samples": ["怎么和青春期的孩子沟通?", "夫妻因工作吵架怎么办?", "家人不支持创业怎么说服?", "工作太忙忽略家人怎么办?"]},
{"title": "子女教育", "desc": "升学、兴趣、规划", "samples": ["孩子不想上学怎么办?", "兴趣班该报几个?", "要不要送孩子出国?", "孩子成绩下滑怎么沟通?"]},
{"title": "父母养老", "desc": "养老、医疗、陪伴", "samples": ["父母年纪大了养老方案怎么选?", "异地父母怎么照顾?", "养老院还是居家养老?", "父母医疗费用怎么分担?"]},
{"title": "睡眠与压力", "desc": "失眠、焦虑、调节", "samples": ["最近睡眠很差怎么改善?", "工作压力大怎么缓解?", "焦虑到影响工作怎么办?", "怎么判断需要看心理医生?"]},
{"title": "出差日程", "desc": "出行、时间管理", "samples": ["出差频繁怎么兼顾家庭?", "行程太满怎么优化?", "出差期间公司怎么管?", "如何减少无效出差?"]},
{"title": "健康习惯", "desc": "体检、饮食、运动", "samples": ["体检报告几项异常怎么办?", "久坐怎么改善健康?", "应酬多怎么控制饮食?", "没时间运动怎么办?"]},
],
"emotion": [
{"title": "冷静分析", "desc": "理性拆解问题", "samples": ["最近压力很大睡不着觉", "合伙人背叛了我很愤怒", "明天的谈判我拖延了一周没准备", "感觉撑不下去了"]},
{"title": "陪伴疏导", "desc": "倾听与共情", "samples": ["今天和合伙人吵了一架", "员工集体离职我很受挫", "家里人不理解我的压力", "感觉自己很孤独"]},
{"title": "强力推动", "desc": "直面问题推动行动", "samples": ["我知道该怎么做但就是拖延", "害怕做决定怎么办?", "明知道该辞退但下不了手", "想改变但总是回到老路"]},
],
}
if not kimi:
return {"topics": builtin_topics.get(req.scene, builtin_topics["legal"])}
# 读取用户画像,个性化话题
profile_ctx = ""
if req.phone:
try:
profile = profile_store.get(req.phone)
profile_ctx = build_profile_context(profile)
except Exception:
pass
scene_desc = {
"legal": "法律/法务问题,如劳动用工、合同审查、债权债务、股东纠纷、税务风险、诉讼仲裁、知识产权、交通事故、婚姻财产、房产纠纷、消费维权等",
"decision": "经营决策问题,如扩张收缩、定价策略、招人裁员、融资合伙、项目承接、业务转型、投资理财、风险管理等",
"life": "个人生活问题,如家庭沟通、子女教育、父母养老、健康习惯、时间管理、出行规划等(不是企业经营问题)",
"emotion": "情绪与心理问题,如压力疏导、关系困惑、自我认知、传统文化反思等",
}
existing_str = ""
if req.existing:
existing_str = f"\n已展示过的话题(避免重复):{','.join(req.existing)}"
prompt = f"""你是左右(BossAi)的{scene_desc.get(req.scene, scene_desc['legal'])}助手。
请生成6-8个话题,每个话题包含标题(2-4字)、简短描述(10-15字)、4个示例问题。
{profile_ctx}
要求:
1. 话题要覆盖该场景下不同方向,不要只集中在某一类
2. 结合用户画像(如有)推荐个性化话题
3. 示例问题要具体、贴近中小企业老板实际
4. 返回JSON数组格式:[{{"title":"话题名","desc":"简短描述","samples":["问题1","问题2","问题3","问题4"]}}]
5. 只返回JSON,不要其他文字{existing_str}"""
try:
answer = kimi.chat([{"role": "user", "content": prompt}], temperature=0.8)
answer = answer.strip()
if answer.startswith("```"):
answer = answer.split("\n", 1)[1] if "\n" in answer else answer[3:]
if answer.endswith("```"):
answer = answer[:-3]
answer = answer.strip().replace("\u201c", '"').replace("\u201d", '"')
start = answer.find("[")
end = answer.rfind("]")
if start == -1 or end == -1:
return {"topics": builtin_topics.get(req.scene, builtin_topics["legal"])}
topics = json.loads(answer[start:end + 1])
# 校验结构
valid_topics = []
for t in topics:
if isinstance(t, dict) and t.get("title") and t.get("samples"):
valid_topics.append({
"title": str(t["title"])[:10],
"desc": str(t.get("desc", ""))[:30],
"samples": [str(s) for s in t["samples"] if isinstance(s, str) and s.strip()][:4],
})
if len(valid_topics) < 3:
return {"topics": builtin_topics.get(req.scene, builtin_topics["legal"])}
return {"topics": valid_topics[:8]}
except Exception as e:
import logging
logging.warning(f"/topics AI 生成失败: {type(e).__name__}: {e}")
return {"topics": builtin_topics.get(req.scene, builtin_topics["legal"])}
@app.post("/samples")
def generate_samples(req: SampleRequest):
"""用 AI 生成场景示例问题,供前端"换一批"使用。
@@ -493,24 +607,103 @@ def ask_analyze(sid: str, req: AskRequest, authorization: str | None = Header(de
req.question, req.scene, answers, kimi_client=kimi,
)
# 收集用户画像中的已知事实(带入内容)
# 收集用户画像中的已知事实(带入内容),按场景过滤相关性
profile_facts: list[dict] = []
if req.phone:
try:
profile = profile_store.get(req.phone)
# 标准画像字段:所有场景通用
if profile.industry:
profile_facts.append({"dim_key": "industry", "dim_desc": "行业", "answer": profile.industry})
if profile.company_size:
profile_facts.append({"dim_key": "company_size", "dim_desc": "企业规模", "answer": profile.company_size})
if profile.role:
profile_facts.append({"dim_key": "role", "dim_desc": "角色", "answer": profile.role})
if profile.risk_tolerance:
profile_facts.append({"dim_key": "risk_tolerance", "dim_desc": "风险偏好", "answer": profile.risk_tolerance})
if profile.decision_style:
profile_facts.append({"dim_key": "decision_style", "dim_desc": "决策风格", "answer": profile.decision_style})
# 风险偏好和决策风格仅经营决策场景带入
if req.scene == "decision":
if profile.risk_tolerance:
profile_facts.append({"dim_key": "risk_tolerance", "dim_desc": "风险偏好", "answer": profile.risk_tolerance})
if profile.decision_style:
profile_facts.append({"dim_key": "decision_style", "dim_desc": "决策风格", "answer": profile.decision_style})
# key_facts:按话题相关性过滤(更精准)
if profile.key_facts:
# 话题 → 相关 key_facts 关键词映射
TOPIC_FACT_KEYWORDS = {
# ===== 法务参谋 =====
"劳动用工": {"region", "company_type", "team_ability", "communication",
"medical_proof", "contract_terms", "medical_period",
"injury_situation", "work_injury_details", "work_injury_insurance",
"performance_assessment", "relevant_legal_provisions",
"employee_count", "salary_status", "social_security", "severance",
"working_years", "probation_period", "termination_reason",
"overtime_status"},
"合同审查": {"contract_terms", "contract_dispute", "company_type",
"relevant_legal_provisions", "region", "partnership"},
"债权债务": {"debt_amount", "litigation_status", "financial_status",
"company_type", "relevant_legal_provisions"},
"股东纠纷": {"shareholder_structure", "partnership", "company_type",
"litigation_status", "relevant_legal_provisions"},
"税务风险": {"tax_risk", "financial_status", "company_type",
"relevant_legal_provisions"},
# ===== 经营决策 =====
"扩张收缩": {"market_potential", "financial_status", "team_ability",
"region", "company_type", "expansion_plan", "competitor",
"cash_flow", "alternative_option", "alternative_solution"},
"项目承接": {"financial_status", "cash_flow", "team_ability",
"contract_terms", "customer_concentration", "alternative_option"},
"招人用人": {"team_ability", "employee_count", "salary_status",
"social_security", "company_type", "region"},
"降价定价": {"financial_status", "competitor", "customer_concentration",
"supply_chain", "cash_flow"},
"业务线调整": {"market_potential", "financial_status", "competitor",
"alternative_option", "alternative_solution", "supply_chain",
"business_model"},
"融资合伙": {"funding_stage", "financial_status", "shareholder_structure",
"partnership", "company_type", "cash_flow", "asset_status",
"liability", "alternative_option"},
# ===== 生活助理 =====
"健康管理": {"health_status", "region"},
"家庭事务": {"family_situation", "region", "company_type"},
"出行规划": {"travel_plan", "region"},
"子女教育": {"education_background", "family_situation"},
"消费理财": {"financial_status", "revenue", "asset_status", "liability"},
# ===== 情绪中心 =====
"情绪倾诉": {"communication_style", "stress_source", "personality",
"family_situation"},
"压力疏导": {"stress_source", "communication_style", "personality"},
"关系困惑": {"communication_style", "family_situation", "personality"},
"传统文化": {"personality", "communication_style"},
}
# 先按话题匹配,匹配不到则按场景兜底
topic_keys = TOPIC_FACT_KEYWORDS.get(req.topic or "", set())
if not topic_keys:
# 场景兜底关键词
SCENE_FACT_KEYWORDS = {
"legal": {"region", "company_type", "team_ability", "communication",
"medical_proof", "contract_terms", "medical_period",
"injury_situation", "legal_procedures", "work_injury_details",
"work_injury_insurance", "performance_assessment",
"relevant_legal_provisions", "employee_count", "salary_status",
"social_security", "severance", "working_years", "probation_period",
"termination_reason", "overtime_status", "contract_dispute",
"debt_amount", "tax_risk", "litigation_status", "shareholder_structure",
"partnership"},
"decision": {"market_potential", "alternative_option", "alternative_solution",
"financial_status", "revenue", "profit_status", "cash_flow",
"asset_status", "liability", "business_model", "funding_stage",
"expansion_plan", "competitor", "customer_concentration",
"supply_chain", "export_capability", "team_ability",
"region", "company_type"},
"life": {"region", "company_type", "employee_count", "revenue",
"family_situation", "health_status", "travel_plan",
"important_event", "education_background"},
"emotion": {"communication_style", "stress_source", "personality",
"decision_style", "family_situation"},
}
topic_keys = SCENE_FACT_KEYWORDS.get(req.scene, set())
for k, v in profile.key_facts.items():
profile_facts.append({"dim_key": f"fact_{k}", "dim_desc": k, "answer": str(v)})
if k.lower() in topic_keys or k in topic_keys:
profile_facts.append({"dim_key": f"fact_{k}", "dim_desc": k, "answer": str(v)})
except Exception:
pass
@@ -526,6 +719,34 @@ def ask_analyze(sid: str, req: AskRequest, authorization: str | None = Header(de
"profile_facts": profile_facts,
}
else:
# 事实充分,法务场景尝试匹配要素表模板
element_template = None
if req.scene == "legal" and kimi:
try:
# 从知识库检索要素表模板
docs = knowledge.search(req.question, limit=3, doc_type="element_template")
if docs:
# AI 判断最匹配的案由
cause_names = [json.loads(d.get("content", "{}")).get("cause_of_action", d.get("title", "")) for d in docs]
prompt = f"""用户问题:{req.question}
事实摘要:{meta.get("fact_summary", "")}
候选案由:{json.dumps(cause_names, ensure_ascii=False)}
请判断用户的问题最匹配哪个案由,只返回案由名称,不要其他文字。"""
matched_cause = kimi.chat([{"role": "user", "content": prompt}], temperature=0.1).strip()
# 找到匹配的模板
for d in docs:
tmpl = json.loads(d.get("content", "{}"))
if tmpl.get("cause_of_action") == matched_cause or matched_cause in d.get("title", ""):
element_template = tmpl
break
# 没精确匹配就用第一个
if not element_template and docs:
element_template = json.loads(docs[0].get("content", "{}"))
except Exception:
pass
return {
"need_ask": False,
"need_analysis": True,
@@ -534,9 +755,127 @@ def ask_analyze(sid: str, req: AskRequest, authorization: str | None = Header(de
"question_type": meta["analysis"]["question_type"],
"risk_level": card.level,
"profile_facts": profile_facts,
"element_template": element_template,
}
class ElementFilingRequest(BaseModel):
"""要素化立案生成请求。"""
question: str
cause_of_action: str # 案由
fact_answers: list[FactAnswer] = [] # 用户确认的事实
template: dict | None = None # 要素表模板(前端传回)
@app.post("/sessions/{sid}/element-filing")
def generate_element_filing(sid: str, req: ElementFilingRequest, authorization: str | None = Header(default=None)):
"""根据确认的事实 + 要素表模板,AI 生成要素式起诉状。
流程:
1. 用户确认事实后调用此端点
2. AI 根据案由模板 + 确认事实,填充要素
3. 返回结构化要素式起诉状
Returns:
{
"cause_of_action": str, # 案由
"elements": [...], # 已填充的要素列表
"claims": [...], # 诉讼请求
"filing_text": str, # 可导出的要素式起诉状文本
}
"""
try:
sessions._active(sid)
except KeyError as e:
raise HTTPException(410, str(e))
if not kimi:
raise HTTPException(503, "AI 服务不可用")
# 获取要素表模板
tmpl = req.template
if not tmpl:
# 从知识库检索
docs = knowledge.search(req.cause_of_action, limit=1, doc_type="element_template")
if docs:
tmpl = json.loads(docs[0].get("content", "{}"))
if not tmpl:
raise HTTPException(404, f"未找到案由'{req.cause_of_action}'的要素表模板")
# 构造事实摘要
facts_str = "\n".join([f"- {a.dim_desc}: {a.answer}" for a in req.fact_answers])
# AI 填充要素
elements_json = json.dumps(tmpl.get("elements", []), ensure_ascii=False)
prompt = f"""你是法律文书助手。根据用户描述和确认的事实,填充要素式起诉状的各要素。
案由:{tmpl.get("cause_of_action", req.cause_of_action)}
要素模板:{elements_json}
用户问题:{req.question}
确认事实:
{facts_str}
请根据以上信息,为每个要素填充具体值。如果信息不足,填写"待补充"。
返回JSON数组格式:[{{"key":"要素key","label":"要素标签","value":"填充值"}}]
只返回JSON,不要其他文字。"""
try:
answer = kimi.chat([{"role": "user", "content": prompt}], temperature=0.1)
answer = answer.strip()
if answer.startswith("```"):
answer = answer.split("\n", 1)[1] if "\n" in answer else answer[3:]
if answer.endswith("```"):
answer = answer[:-3]
answer = answer.strip().replace("\u201c", '"').replace("\u201d", '"')
start = answer.find("[")
end = answer.rfind("]")
if start == -1 or end == -1:
raise ValueError("AI 返回格式错误")
filled_elements = json.loads(answer[start:end + 1])
except Exception as e:
raise HTTPException(500, f"要素填充失败: {e}")
# 生成可导出的要素式起诉状文本
cause = tmpl.get("cause_of_action", req.cause_of_action)
claims = tmpl.get("claims", [])
claims_str = "\n".join([f" ☐ {c}" for c in claims])
elements_text = "\n".join([
f" {e.get('label', e.get('key', ''))}: {e.get('value', '待补充')}"
for e in filled_elements
])
filing_text = f"""要素式起诉状
案由:{cause}
一、当事人信息
原告:待填写
被告:待填写
二、诉讼请求
{claims_str}
三、事实和理由(要素表)
{elements_text}
四、证据清单
待补充
五、对纠纷解决方式的意愿
☐ 同意调解 ☐ 不同意调解
说明:本起诉状依据最高人民法院《部分案件起诉状答辩状示范文本》(法〔2025〕82号)生成,
所填要素供参考,请根据实际情况修改后提交法院。"""
return {
"cause_of_action": cause,
"elements": filled_elements,
"claims": claims,
"filing_text": filing_text,
}
@app.post("/sessions/{sid}/ask-stream")
def ask_stream(sid: str, req: AskRequest, authorization: str | None = Header(default=None)):
req.phone = get_current_phone(authorization)
@@ -751,8 +1090,21 @@ def list_summaries(limit: int = 20):
@app.get("/knowledge")
def list_knowledge(doc_type: str | None = None, limit: int = 50):
"""列出知识库文档,支持按类型筛选。"""
return {"results": knowledge.list_all(doc_type=doc_type, limit=limit)}
"""列出知识库文档,支持按类型筛选。返回各类型计数供前端展示。"""
results = knowledge.list_all(doc_type=doc_type, limit=limit)
# 统计各类型数量
counts: dict[str, int] = {}
try:
with knowledge._conn() as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT doc_type, COUNT(*) FROM knowledge_docs GROUP BY doc_type"
)
counts = {row[0]: row[1] for row in cur.fetchall()}
except Exception:
pass
total = sum(counts.values())
return {"results": results, "counts": counts, "total": total}
@app.post("/knowledge")