后端: - filter_boss_perspective(): 关键词过滤员工视角问题 覆盖:加班费/被辞退/拖欠工资/工伤认定/产假/劳动仲裁/降薪扣工资等 - /samples: prompt加禁止清单+正确视角示例,返回前过滤 画像注入加行业定向(有industry时明确要求生成行业相关问题) - /topics: prompt加禁止清单+正确视角示例,返回前过滤 - generate_daily_topics: prompt加禁止清单+正确视角示例,返回前过滤 前端: - 审查FALLBACK_SAMPLES: 三个场景兜底示例均为老板视角,无需修改 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
3114 lines
127 KiB
Python
3114 lines
127 KiB
Python
from dotenv import load_dotenv
|
|
load_dotenv() # 从 .env 加载 KIMI_API_KEY 等配置,密钥不进代码仓库
|
|
|
|
import os
|
|
import time
|
|
import json
|
|
import logging
|
|
from fastapi import FastAPI, HTTPException, UploadFile, File, Request, Response, Header
|
|
from fastapi.responses import StreamingResponse
|
|
from pydantic import BaseModel
|
|
from bossai import (
|
|
SessionManager, assess, action_summary, extract_text, KimiClient,
|
|
SCENE_PROMPTS, EMOTION_MODE_PREFIX,
|
|
build_dynamic_prompt, build_emotion_prefix,
|
|
extract_profile_signals, build_profile_context,
|
|
UserProfile, UserProfileStore, UserStore,
|
|
KnowledgeStore, SettingsStore, ActionSummaryStore,
|
|
ExpertStore, ConsultationStore, RatingStore,
|
|
DocumentGenerator, RegulationUpdateManager, FactFinder,
|
|
ChatStore, redact,
|
|
DailyTopicStore, generate_daily_topics, filter_boss_perspective,
|
|
)
|
|
|
|
# ===== 日志脱敏 =====
|
|
class RedactingFilter(logging.Filter):
|
|
"""日志过滤器:脱敏手机号、身份证号、银行卡号。"""
|
|
import re as _re
|
|
_PHONE_PAT = _re.compile(r'1[3-9]\d{9}')
|
|
_IDCARD_PAT = _re.compile(r'\b\d{17}[0-9Xx]\b')
|
|
_BANK_PAT = _re.compile(r'\b\d{15,19}\b')
|
|
|
|
def filter(self, record: logging.LogRecord) -> bool:
|
|
msg = str(record.getMessage())
|
|
msg = self._PHONE_PAT.sub('[手机号]', msg)
|
|
msg = self._IDCARD_PAT.sub('[证件号]', msg)
|
|
msg = self._BANK_PAT.sub('[账号]', msg)
|
|
record.msg = msg
|
|
record.args = ()
|
|
return True
|
|
|
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
|
|
logger = logging.getLogger("bossai")
|
|
logger.addFilter(RedactingFilter())
|
|
|
|
# ===== 可观测性指标 =====
|
|
class Metrics:
|
|
"""轻量内存指标,无需 Prometheus 依赖。"""
|
|
def __init__(self):
|
|
self.request_count = 0
|
|
self.request_latency_sum = 0.0
|
|
self.model_calls = {"cloud": 0, "local": 0}
|
|
self.destroy_count = 0
|
|
self.error_count = 0
|
|
|
|
def record_request(self, duration: float):
|
|
self.request_count += 1
|
|
self.request_latency_sum += duration
|
|
|
|
def record_model(self, location: str):
|
|
self.model_calls[location] = self.model_calls.get(location, 0) + 1
|
|
|
|
def record_destroy(self):
|
|
self.destroy_count += 1
|
|
|
|
def record_error(self):
|
|
self.error_count += 1
|
|
|
|
def snapshot(self) -> dict:
|
|
avg = self.request_latency_sum / self.request_count if self.request_count else 0
|
|
return {
|
|
"request_count": self.request_count,
|
|
"avg_latency_ms": round(avg * 1000, 1),
|
|
"model_calls": self.model_calls,
|
|
"destroy_count": self.destroy_count,
|
|
"error_count": self.error_count,
|
|
"destroy_stats": sessions.destroy_stats(),
|
|
}
|
|
|
|
|
|
app = FastAPI(title="左右(BossAi)", version="1.1.0")
|
|
sessions = SessionManager()
|
|
knowledge = KnowledgeStore()
|
|
settings_store = SettingsStore()
|
|
summary_store = ActionSummaryStore()
|
|
regulation_mgr = RegulationUpdateManager()
|
|
fact_finder = FactFinder(max_rounds=2, max_questions_per_round=3)
|
|
profile_store = UserProfileStore()
|
|
user_store = UserStore()
|
|
expert_store = ExpertStore()
|
|
consultation_store = ConsultationStore()
|
|
rating_store = RatingStore()
|
|
chat_store = ChatStore()
|
|
metrics = Metrics()
|
|
daily_topic_store = DailyTopicStore()
|
|
try:
|
|
kimi = KimiClient()
|
|
except RuntimeError:
|
|
kimi = None
|
|
|
|
|
|
# ===== 每日热点问题定时任务(每天凌晨3点) =====
|
|
def _daily_topics_scheduler():
|
|
"""后台线程:每天凌晨3点自动生成热点问题。
|
|
|
|
使用轮询方式实现,避免引入 APScheduler 依赖。
|
|
AI 不可用时跳过,保留已有数据。
|
|
"""
|
|
import threading
|
|
from datetime import datetime, time as dt_time
|
|
|
|
def run():
|
|
while True:
|
|
now = datetime.now()
|
|
# 计算到下一个凌晨3点的秒数
|
|
target = datetime.combine(now.date(), dt_time(3, 0))
|
|
if now >= target:
|
|
# 今天3点已过,等明天
|
|
from datetime import timedelta
|
|
target = target + timedelta(days=1)
|
|
wait_seconds = (target - now).total_seconds()
|
|
threading.Event().wait(timeout=wait_seconds)
|
|
# 执行生成
|
|
if kimi:
|
|
try:
|
|
logging.info("开始生成每日热点问题...")
|
|
results = generate_daily_topics(kimi, daily_topic_store)
|
|
logging.info(f"每日热点问题生成完成: {results}")
|
|
except Exception as e:
|
|
logging.warning(f"每日热点问题生成失败: {type(e).__name__}: {e}")
|
|
|
|
thread = threading.Thread(target=run, daemon=True, name="daily-topics-scheduler")
|
|
thread.start()
|
|
|
|
|
|
_daily_topics_scheduler()
|
|
|
|
|
|
# ===== 请求计时 + 日志脱敏中间件 =====
|
|
@app.middleware("http")
|
|
async def metrics_middleware(request: Request, call_next):
|
|
start = time.time()
|
|
response = await call_next(request)
|
|
duration = time.time() - start
|
|
metrics.record_request(duration)
|
|
# 日志脱敏:只记录路径和状态,不记录 body
|
|
logger.info(f"{request.method} {request.url.path} {response.status_code} {duration:.3f}s")
|
|
if response.status_code >= 500:
|
|
metrics.record_error()
|
|
return response
|
|
|
|
|
|
# ===== 简易限流(内存令牌桶,每 IP 每秒 10 请求) =====
|
|
_rate_buckets: dict[str, list[float]] = {}
|
|
RATE_LIMIT = int(os.getenv("RATE_LIMIT", "20")) # 请求/秒,生产建议 10
|
|
RATE_WINDOW = 1.0
|
|
|
|
|
|
@app.middleware("http")
|
|
async def rate_limit_middleware(request: Request, call_next):
|
|
if request.url.path.startswith("/health") or request.url.path.startswith("/metrics"):
|
|
return await call_next(request)
|
|
client_ip = request.client.host if request.client else "unknown"
|
|
now = time.time()
|
|
bucket = _rate_buckets.setdefault(client_ip, [])
|
|
# 清理过期
|
|
bucket[:] = [t for t in bucket if now - t < RATE_WINDOW]
|
|
if len(bucket) >= RATE_LIMIT:
|
|
return Response(content='{"detail":"rate limit exceeded"}', status_code=429,
|
|
media_type="application/json", headers={"Retry-After": "1"})
|
|
bucket.append(now)
|
|
return await call_next(request)
|
|
|
|
|
|
class SessionRequest(BaseModel):
|
|
mode: str = "normal"
|
|
|
|
|
|
# ===== 用户认证 =====
|
|
|
|
class RegisterRequest(BaseModel):
|
|
"""注册请求。"""
|
|
phone: str
|
|
password: str
|
|
name: str = ""
|
|
|
|
|
|
class LoginRequest(BaseModel):
|
|
"""登录请求。"""
|
|
phone: str
|
|
password: str
|
|
|
|
|
|
def get_current_phone(authorization: str | None = Header(default=None)) -> str | None:
|
|
"""从 Authorization header 解析 JWT token,返回手机号。
|
|
|
|
用于需要用户身份的端点(ask/ask-stream/profile)。
|
|
未登录时返回 None(端点自行处理)。
|
|
"""
|
|
if not authorization or not authorization.startswith("Bearer "):
|
|
return None
|
|
token = authorization[7:]
|
|
return user_store.verify_token(token)
|
|
|
|
|
|
@app.post("/auth/register")
|
|
def register(req: RegisterRequest):
|
|
"""手机号+密码注册,返回 JWT token。"""
|
|
try:
|
|
result = user_store.register(req.phone, req.password, req.name)
|
|
return result
|
|
except ValueError as e:
|
|
raise HTTPException(400, str(e))
|
|
|
|
|
|
@app.post("/auth/login")
|
|
def login(req: LoginRequest):
|
|
"""手机号+密码登录,返回 JWT token。支持用户、专家、管理员三种角色。"""
|
|
# 先查用户表
|
|
try:
|
|
return user_store.login(req.phone, req.password)
|
|
except ValueError:
|
|
pass
|
|
# 再查专家表
|
|
import bcrypt as _bcrypt
|
|
with user_store._conn() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"SELECT id, phone, password, name FROM experts WHERE phone = %s AND status = 'active'",
|
|
(req.phone,),
|
|
)
|
|
expert_row = cur.fetchone()
|
|
# 查管理员表
|
|
cur.execute(
|
|
"SELECT id, phone, password, name FROM admins WHERE phone = %s",
|
|
(req.phone,),
|
|
)
|
|
admin_row = cur.fetchone()
|
|
if expert_row and _bcrypt.checkpw(req.password.encode(), expert_row[2].encode()):
|
|
token = user_store._make_token(expert_row[1])
|
|
return {"token": token, "phone": expert_row[1], "name": expert_row[3] or "", "role": "expert", "expert_id": expert_row[0]}
|
|
if admin_row and _bcrypt.checkpw(req.password.encode(), admin_row[2].encode()):
|
|
token = user_store._make_token(admin_row[1])
|
|
return {"token": token, "phone": admin_row[1], "name": admin_row[3] or "", "role": "admin", "admin_id": admin_row[0]}
|
|
raise HTTPException(401, "手机号或密码错误")
|
|
|
|
|
|
class FactAnswer(BaseModel):
|
|
"""用户对追问的回答。"""
|
|
dim_key: str
|
|
dim_desc: str
|
|
answer: str
|
|
|
|
|
|
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 解析,用于用户画像)"""
|
|
phone: str | None = None
|
|
|
|
|
|
class KnowledgeAddRequest(BaseModel):
|
|
title: str
|
|
content: str
|
|
source: str = "manual"
|
|
doc_type: str = "law"
|
|
region: str | None = None
|
|
effective_date: str | None = None
|
|
keywords: str | None = None
|
|
|
|
|
|
class SettingsUpdateRequest(BaseModel):
|
|
settings: dict[str, str]
|
|
|
|
|
|
class TicketCreateRequest(BaseModel):
|
|
question: str
|
|
risk_level: str
|
|
conclusion: str | None = None
|
|
session_id: str | None = None
|
|
contact: str | None = None
|
|
priority: str = "normal"
|
|
|
|
|
|
class TicketUpdateRequest(BaseModel):
|
|
status: str
|
|
assignee: str | None = None
|
|
resolution: str | None = None
|
|
|
|
|
|
class ExpertRuleUpdateRequest(BaseModel):
|
|
expert_type: str | None = None
|
|
sla_hours: int | None = None
|
|
auto_assign: str | None = None
|
|
|
|
|
|
class SummarySaveRequest(BaseModel):
|
|
session_id: str | None = None
|
|
summary: dict
|
|
|
|
|
|
@app.get("/health")
|
|
def health():
|
|
return {"status": "ok", "service": "左右(BossAi)", "version": "1.1.0", "db": "connected"}
|
|
|
|
|
|
class SampleRequest(BaseModel):
|
|
"""生成示例问题请求。"""
|
|
scene: str = "legal"
|
|
topic: str | None = None
|
|
existing: list[str] | None = None # 已展示的示例,避免重复
|
|
phone: 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. **所有问题必须从老板/雇主视角出发**(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)
|
|
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"):
|
|
boss_samples = filter_boss_perspective(
|
|
[str(s) for s in t["samples"] if isinstance(s, str) and s.strip()]
|
|
)[:4]
|
|
if boss_samples:
|
|
valid_topics.append({
|
|
"title": str(t["title"])[:10],
|
|
"desc": str(t.get("desc", ""))[:30],
|
|
"samples": boss_samples,
|
|
})
|
|
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 生成场景示例问题,供前端"换一批"使用。
|
|
|
|
AI 不可用时降级到内置示例。
|
|
"""
|
|
# 内置示例(降级用)
|
|
builtin = {
|
|
"legal": ["员工工伤后能开除吗?", "收到税务稽查通知怎么办?", "客户拖欠货款 3 个月如何催收?", "合同违约金怎么约定?"],
|
|
"decision": ["是否应该接一个付款周期 90 天的大单?", "现在适合扩张开第二家店吗?", "该不该降价 15% 抢竞品的客户?", "合伙人要退股,怎么谈条件?"],
|
|
"life": ["怎么和青春期的孩子沟通不想他接班的事?", "父母年纪大了,养老方案怎么选?", "最近睡眠很差,有什么改善建议?", "体检报告有几项异常,需要进一步检查吗?"],
|
|
"emotion": ["最近压力很大,睡不着觉", "合伙人背叛了,我很愤怒", "明天的谈判我拖延了一周没准备", "感觉撑不下去了"],
|
|
}
|
|
|
|
if not kimi:
|
|
return {"samples": builtin.get(req.scene, builtin["legal"])}
|
|
|
|
scene_config = {
|
|
"legal": {"desc": "法律/法务", "focus": "劳动用工、合同审查、债权债务、股东纠纷、税务风险、诉讼仲裁等法律问题"},
|
|
"decision": {"desc": "经营决策", "focus": "扩张收缩、定价策略、招人裁员、融资合伙、项目承接、业务转型等经营决策"},
|
|
"life": {"desc": "生活助理", "focus": "家庭沟通、子女教育、父母养老、睡眠压力、出差日程、健康习惯等个人生活问题,不是企业经营问题"},
|
|
"emotion": {"desc": "情绪疏导", "focus": "压力、焦虑、愤怒、拖延、疲惫等情绪状态,老板可能倾诉的心理感受"},
|
|
}
|
|
config = scene_config.get(req.scene, scene_config["legal"])
|
|
|
|
existing = req.existing or []
|
|
prompt = (
|
|
f"你是{config['desc']}领域的专业顾问。请生成 4 个该领域的典型问题,"
|
|
f"用户(中小企业老板)可能会问的问题。\n\n"
|
|
f"问题方向:{config['focus']}\n\n"
|
|
)
|
|
if req.topic:
|
|
prompt += f"具体话题:{req.topic}\n\n"
|
|
# 注入用户画像,个性化推荐
|
|
if req.phone:
|
|
try:
|
|
profile = profile_store.get(req.phone)
|
|
profile_ctx = build_profile_context(profile)
|
|
if profile_ctx:
|
|
prompt += f"用户画像(请结合画像生成更贴合用户自身情况的问题):\n{profile_ctx}\n\n"
|
|
# 行业定向:如果有行业,加明确指令
|
|
if profile.industry:
|
|
industry_map = {
|
|
"retail": "零售/餐饮", "manufacturing": "制造业/工厂",
|
|
"tech": "科技/互联网", "service": "服务业",
|
|
"construction": "建筑/工程", "logistics": "物流/运输",
|
|
"finance": "金融/投资", "education": "教育/培训",
|
|
"healthcare": "医疗/健康", "agriculture": "农业/农产品",
|
|
}
|
|
industry_label = industry_map.get(profile.industry, profile.industry)
|
|
prompt += f"**用户行业为{industry_label},请生成与该行业密切相关的问题**\n\n"
|
|
except Exception:
|
|
pass
|
|
if existing:
|
|
prompt += "已展示过的问题(不要重复):\n"
|
|
for s in existing:
|
|
prompt += f"- {s}\n"
|
|
prompt += "\n"
|
|
prompt += (
|
|
"要求:\n"
|
|
"1. 问题真实、具体、贴近中小企业老板的实际场景\n"
|
|
"2. **所有问题必须从老板/雇主视角出发**(用户是企业老板,不是员工)\n"
|
|
"3. 每个问题 15-30 字\n"
|
|
"4. 不要重复已展示过的问题\n"
|
|
"5. 只返回 JSON 数组,如 [\"问题1\", \"问题2\", \"问题3\", \"问题4\"]\n\n"
|
|
"**严禁生成员工视角问题(如:加班费怎么算、被辞退怎么维权、拖欠工资怎么追讨、**\n"
|
|
"**请假被拒、工伤认定、产假怎么休、劳动仲裁怎么告公司、降我薪扣我工资等)**\n\n"
|
|
"**正确视角示例:**\n"
|
|
"- 老板问:员工工伤后能开除吗?(不是"工伤赔偿怎么申请")\n"
|
|
"- 老板问:辞退员工要赔多少?(不是"被辞退怎么维权")\n"
|
|
"- 老板问:客户拖欠货款如何催收?(不是"拖欠工资怎么追")\n"
|
|
)
|
|
|
|
try:
|
|
answer = kimi.chat(
|
|
[{"role": "system", "content": "你是问题生成助手,只返回 JSON 数组。"},
|
|
{"role": "user", "content": prompt}],
|
|
temperature=0.8,
|
|
timeout=30,
|
|
)
|
|
# 解析 JSON
|
|
answer = answer.strip()
|
|
# 去除 markdown 代码块标记
|
|
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()
|
|
# 修复中文引号(常见 LLM 输出问题)
|
|
answer = answer.replace("\u201c", '"').replace("\u201d", '"')
|
|
start = answer.find("[")
|
|
end = answer.rfind("]")
|
|
if start == -1 or end == -1:
|
|
return {"samples": builtin.get(req.scene, builtin["legal"])}
|
|
samples = json.loads(answer[start:end + 1])
|
|
# 过滤:老板视角 + 非字符串 + 去重
|
|
samples = filter_boss_perspective(
|
|
[s for s in samples if isinstance(s, str) and s.strip()]
|
|
)[:4]
|
|
if len(samples) < 2:
|
|
return {"samples": builtin.get(req.scene, builtin["legal"])}
|
|
return {"samples": samples}
|
|
except Exception as e:
|
|
import logging
|
|
logging.warning(f"/samples AI 生成失败: {type(e).__name__}: {e}")
|
|
return {"samples": builtin.get(req.scene, builtin["legal"])}
|
|
|
|
|
|
@app.get("/metrics")
|
|
def get_metrics():
|
|
"""可观测性指标:请求数/延迟/模型调用量/销毁统计。"""
|
|
return metrics.snapshot()
|
|
|
|
|
|
@app.get("/", include_in_schema=False)
|
|
def home():
|
|
from fastapi.responses import HTMLResponse
|
|
return HTMLResponse(open("web.html", encoding="utf-8").read())
|
|
|
|
|
|
@app.post("/sessions")
|
|
def create_session(req: SessionRequest):
|
|
s = sessions.create(req.mode)
|
|
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 解析手机号(用于用户画像)
|
|
req.phone = get_current_phone(authorization)
|
|
try:
|
|
sessions.append(sid, "user", req.question)
|
|
except KeyError as e:
|
|
raise HTTPException(410, str(e))
|
|
# 获取会话模式(用于决定是否落库)
|
|
session = sessions.sessions.get(sid)
|
|
session_mode = session.mode if session else "normal"
|
|
card = assess(req.question, kimi_client=kimi)
|
|
answer = None
|
|
rag_context = []
|
|
inference_location = "local"
|
|
model_name = "local-triage"
|
|
fallback_used = False
|
|
if kimi:
|
|
prompt = build_dynamic_prompt(req.scene, req.question, kimi_client=kimi)
|
|
# 注入用户画像
|
|
if req.phone:
|
|
profile = profile_store.get(req.phone)
|
|
profile_ctx = build_profile_context(profile)
|
|
if profile_ctx:
|
|
prompt = prompt + "\n\n" + profile_ctx
|
|
if req.scene == "emotion" and req.emotion_mode:
|
|
prompt = build_emotion_prefix(req.emotion_mode, req.question, kimi_client=kimi) + prompt
|
|
# RAG:按场景检索对应分类知识上下文
|
|
ctx = knowledge.build_context(req.question, limit=3, scene=req.scene)
|
|
if ctx:
|
|
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(
|
|
chat_messages,
|
|
local_fallback=card.conclusion,
|
|
)
|
|
answer = result["answer"]
|
|
inference_location = result["inference_location"]
|
|
model_name = result["model"]
|
|
fallback_used = result["fallback_used"]
|
|
metrics.record_model(inference_location)
|
|
assistant_content = answer or card.conclusion
|
|
sessions.append(sid, "assistant", assistant_content)
|
|
|
|
# 落库:普通/保密模式保存消息+风险卡+元数据,阅后即焚不落库
|
|
if req.phone and session_mode != "ephemeral":
|
|
try:
|
|
# 用户消息
|
|
chat_store.save_message(
|
|
session_id=sid, phone=req.phone, mode=session_mode, role="user",
|
|
content=req.question, scene=req.scene,
|
|
)
|
|
# AI 回复(含风险卡和元数据)
|
|
chat_store.save_message(
|
|
session_id=sid, phone=req.phone, mode=session_mode, role="assistant",
|
|
content=assistant_content, scene=req.scene,
|
|
risk_level=card.level, risk_conclusion=card.conclusion,
|
|
risk_actions=card.actions, risk_evidence=card.evidence,
|
|
risk_escalation=card.escalation,
|
|
inference_location=inference_location, model_name=model_name,
|
|
fallback_used=fallback_used,
|
|
rag_context=rag_context,
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f"chat_store save failed: {e}")
|
|
|
|
# 更新用户画像(提取抽象信号,非对话内容)
|
|
if req.phone:
|
|
try:
|
|
signals = extract_profile_signals(req.question, req.scene, kimi_client=kimi)
|
|
profile_store.update(req.phone, signals, scene=req.scene)
|
|
except Exception:
|
|
pass
|
|
|
|
return {
|
|
"risk": card.__dict__,
|
|
"answer": answer,
|
|
"action_summary": action_summary(card),
|
|
"model": model_name,
|
|
"scene": req.scene,
|
|
"rag_context": rag_context,
|
|
"inference_location": inference_location,
|
|
"fallback_used": fallback_used,
|
|
}
|
|
|
|
|
|
@app.post("/sessions/{sid}/ask-analyze")
|
|
def ask_analyze(sid: str, req: AskRequest, authorization: str | None = Header(default=None)):
|
|
req.phone = get_current_phone(authorization)
|
|
"""分析用户问题,返回追问问题或标记事实已充分。
|
|
|
|
前端流程:
|
|
1. 用户提问 → 调用 ask-analyze → 返回追问问题(如有)
|
|
2. 用户回答追问 → 再次调用 ask-analyze(带 fact_answers)→ 返回更多追问或 need_analysis=true
|
|
3. need_analysis=true 后 → 调用 ask-stream(带 fact_answers)→ 流式返回逐层分析结论
|
|
|
|
Returns:
|
|
{
|
|
"need_ask": bool, # 是否需要追问
|
|
"need_analysis": bool, # 是否可以进入分析阶段
|
|
"round": int, # 当前追问轮次
|
|
"questions": [...], # 追问问题(need_ask=true 时)
|
|
"question_type": str, # 问题类型
|
|
"risk_level": str, # 初步风险等级
|
|
}
|
|
"""
|
|
try:
|
|
sessions._active(sid)
|
|
except KeyError as e:
|
|
raise HTTPException(410, str(e))
|
|
|
|
# 初步风险分诊(AI 语义分诊 + 规则兜底)
|
|
card = assess(req.question, kimi_client=kimi)
|
|
|
|
# 事实追问分析(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,
|
|
)
|
|
|
|
# 收集用户画像中的已知事实(带入内容),按场景过滤相关性
|
|
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 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():
|
|
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
|
|
|
|
if meta["need_ask"]:
|
|
return {
|
|
"need_ask": True,
|
|
"need_analysis": False,
|
|
"round": meta["round"],
|
|
"questions": meta["questions"],
|
|
"question_type": meta["analysis"]["question_type"],
|
|
"risk_level": card.level,
|
|
"source": meta.get("source", "rule"),
|
|
"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,
|
|
"round": len(answers) // 3 + 1,
|
|
"fact_summary": meta["fact_summary"],
|
|
"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)
|
|
"""流式提问:SSE 返回风险卡 + 逐 token 答复。
|
|
|
|
事件格式:
|
|
- event: risk\ndata: {风险卡 JSON}\n\n
|
|
- event: delta\ndata: {\"content\": \"token\"}\n\n
|
|
- event: done\ndata: {完整响应 JSON}\n\n
|
|
- event: error\ndata: {\"detail\": \"错误信息\"}\n\n
|
|
"""
|
|
try:
|
|
sessions.append(sid, "user", req.question)
|
|
except KeyError as e:
|
|
raise HTTPException(410, str(e))
|
|
|
|
# 获取会话模式(用于决定是否落库)
|
|
session = sessions.sessions.get(sid)
|
|
session_mode = session.mode if session else "normal"
|
|
card = assess(req.question, kimi_client=kimi)
|
|
inference_location = "local"
|
|
model_name = "local-triage"
|
|
fallback_used = False
|
|
rag_context = []
|
|
|
|
# 构建 prompt(与非流式一致)
|
|
# 构建 prompt(动态定制 + 规则兜底)
|
|
fact_summary = None
|
|
if req.fact_answers:
|
|
answers = [a.model_dump() for a in req.fact_answers]
|
|
analysis = fact_finder.analyze(req.question, req.scene)
|
|
fact_summary = fact_finder.summarize_facts(req.question, analysis, answers)
|
|
|
|
prompt = build_dynamic_prompt(req.scene, req.question, kimi_client=kimi,
|
|
fact_summary=fact_summary)
|
|
# 注入用户画像
|
|
if req.phone:
|
|
profile = profile_store.get(req.phone)
|
|
profile_ctx = build_profile_context(profile)
|
|
if profile_ctx:
|
|
prompt = prompt + "\n\n" + profile_ctx
|
|
if req.scene == "emotion" and req.emotion_mode:
|
|
prompt = build_emotion_prefix(req.emotion_mode, req.question, kimi_client=kimi) + prompt
|
|
# RAG:按场景检索对应分类知识上下文
|
|
ctx = knowledge.build_context(req.question, limit=3, scene=req.scene)
|
|
if ctx:
|
|
prompt = prompt + "\n\n" + ctx
|
|
rag_context = knowledge.search(req.question, limit=3,
|
|
doc_types=knowledge.SCENE_DOC_TYPES.get(req.scene))
|
|
|
|
# 注入事实摘要(多轮追问后)
|
|
if fact_summary:
|
|
prompt = prompt + "\n\n" + fact_summary
|
|
|
|
def event_stream():
|
|
"""SSE 事件生成器。"""
|
|
nonlocal inference_location, model_name, fallback_used
|
|
full_answer = []
|
|
try:
|
|
# 1. 先发风险卡
|
|
yield f"event: risk\ndata: {json.dumps(card.__dict__, ensure_ascii=False)}\n\n"
|
|
|
|
# 2. 流式答复(注入 session 历史实现多轮上下文)
|
|
if kimi:
|
|
# 构建 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)
|
|
yield f"event: delta\ndata: {json.dumps({'content': token}, ensure_ascii=False)}\n\n"
|
|
inference_location = "cloud"
|
|
model_name = kimi.model
|
|
except Exception as e:
|
|
# 云端失败,降级到本地分诊结论
|
|
logging.warning("kimi stream failed, fallback: %s", e)
|
|
fallback_text = card.conclusion
|
|
full_answer.append(fallback_text)
|
|
yield f"event: delta\ndata: {json.dumps({'content': fallback_text}, ensure_ascii=False)}\n\n"
|
|
inference_location = "local"
|
|
model_name = "local-triage"
|
|
fallback_used = True
|
|
else:
|
|
# 无 Kimi 配置,用本地分诊结论
|
|
fallback_text = card.conclusion
|
|
full_answer.append(fallback_text)
|
|
yield f"event: delta\ndata: {json.dumps({'content': fallback_text}, ensure_ascii=False)}\n\n"
|
|
|
|
answer = "".join(full_answer)
|
|
sessions.append(sid, "assistant", answer)
|
|
metrics.record_model(inference_location)
|
|
|
|
# 落库:普通/保密模式保存消息+风险卡+元数据,阅后即焚不落库
|
|
if req.phone and session_mode != "ephemeral":
|
|
try:
|
|
chat_store.save_message(
|
|
session_id=sid, phone=req.phone, mode=session_mode, role="user",
|
|
content=req.question, scene=req.scene,
|
|
)
|
|
chat_store.save_message(
|
|
session_id=sid, phone=req.phone, mode=session_mode, role="assistant",
|
|
content=answer, scene=req.scene,
|
|
risk_level=card.level, risk_conclusion=card.conclusion,
|
|
risk_actions=card.actions, risk_evidence=card.evidence,
|
|
risk_escalation=card.escalation,
|
|
inference_location=inference_location, model_name=model_name,
|
|
fallback_used=fallback_used,
|
|
)
|
|
except Exception as e:
|
|
logging.warning(f"chat_store save failed: {e}")
|
|
|
|
# 3. 完成事件(含元数据)
|
|
done_data = {
|
|
"answer": answer,
|
|
"action_summary": action_summary(card),
|
|
"model": model_name,
|
|
"scene": req.scene,
|
|
"rag_context": rag_context,
|
|
"inference_location": inference_location,
|
|
"fallback_used": fallback_used,
|
|
"fact_summary": fact_summary,
|
|
}
|
|
|
|
# 更新用户画像(流式完成后,提取抽象信号)
|
|
if req.phone:
|
|
try:
|
|
signals = extract_profile_signals(
|
|
req.question, req.scene, kimi_client=kimi,
|
|
fact_answers=[a.model_dump() for a in req.fact_answers] if req.fact_answers else None,
|
|
)
|
|
profile_store.update(req.phone, signals, scene=req.scene)
|
|
except Exception:
|
|
pass
|
|
|
|
yield f"event: done\ndata: {json.dumps(done_data, ensure_ascii=False)}\n\n"
|
|
|
|
except Exception as e:
|
|
yield f"event: error\ndata: {json.dumps({'detail': str(e)}, ensure_ascii=False)}\n\n"
|
|
|
|
return StreamingResponse(
|
|
event_stream(),
|
|
media_type="text/event-stream",
|
|
headers={
|
|
"Cache-Control": "no-cache",
|
|
"Connection": "keep-alive",
|
|
"X-Accel-Buffering": "no", # nginx 不缓冲
|
|
},
|
|
)
|
|
|
|
|
|
@app.post("/sessions/{sid}/destroy")
|
|
def destroy(sid: str):
|
|
if not sessions.emergency_stop(sid):
|
|
raise HTTPException(404, "session unavailable")
|
|
metrics.record_destroy()
|
|
status = sessions.destroy_status(sid)
|
|
return {"destroyed": True, "session_id": sid, "destroy_log": status["destroy_log"] if status else []}
|
|
|
|
|
|
@app.get("/sessions/{sid}/destroy-status")
|
|
def destroy_status(sid: str):
|
|
"""查询会话销毁状态和日志(不含正文)。"""
|
|
status = sessions.destroy_status(sid)
|
|
if not status:
|
|
raise HTTPException(404, "session not found")
|
|
return status
|
|
|
|
|
|
@app.post("/sessions/{sid}/file")
|
|
async def inspect_file(sid: str, file: UploadFile = File(...)):
|
|
try:
|
|
sessions._active(sid)
|
|
except KeyError as e:
|
|
raise HTTPException(410, str(e))
|
|
data = await file.read()
|
|
if len(data) > 10 * 1024 * 1024:
|
|
raise HTTPException(413, "file too large")
|
|
text = extract_text(file.filename or "", data)
|
|
# 文件名脱敏后记录日志
|
|
logger.info(f"file uploaded: {redact(file.filename or '')} bytes={len(data)} extracted={len(text)}")
|
|
return {
|
|
"filename": file.filename,
|
|
"bytes": len(data),
|
|
"text_preview": text[:500],
|
|
"text_length": len(text),
|
|
"message": "文件已进入临时会话区" if text else "文件已接收,暂不支持该格式解析",
|
|
}
|
|
|
|
|
|
# ===== 脱敏行动摘要 =====
|
|
|
|
@app.post("/summaries/preview")
|
|
def preview_summary(req: SummarySaveRequest):
|
|
"""保存前预览:展示将保留的脱敏字段。"""
|
|
return {"savable_fields": summary_store.SAVABLE_FIELDS, "preview": summary_store.preview(req.summary)}
|
|
|
|
|
|
@app.post("/summaries")
|
|
def save_summary(req: SummarySaveRequest):
|
|
"""保存脱敏行动摘要(仅白名单字段,二次脱敏)。"""
|
|
return summary_store.save(req.session_id, req.summary)
|
|
|
|
|
|
@app.get("/summaries")
|
|
def list_summaries(limit: int = 20):
|
|
"""列出已保存的脱敏摘要。"""
|
|
return {"results": summary_store.list(limit=limit)}
|
|
|
|
|
|
# ===== 法规知识库 RAG =====
|
|
|
|
@app.get("/knowledge")
|
|
def list_knowledge(doc_type: str | None = None, limit: int = 50):
|
|
"""列出知识库文档,支持按类型筛选。返回各类型计数供前端展示。"""
|
|
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")
|
|
def add_knowledge(req: KnowledgeAddRequest):
|
|
"""新增法规/案例/模板文档。"""
|
|
return knowledge.add(
|
|
title=req.title, content=req.content, source=req.source,
|
|
doc_type=req.doc_type, region=req.region,
|
|
effective_date=req.effective_date, keywords=req.keywords,
|
|
)
|
|
|
|
|
|
@app.get("/knowledge/search")
|
|
def search_knowledge(q: str, doc_type: str | None = None, limit: int = 5):
|
|
"""RAG 检索:关键词匹配 + TF-IDF 排序。"""
|
|
return {"results": knowledge.search(q, limit=limit, doc_type=doc_type)}
|
|
|
|
|
|
@app.get("/knowledge/vector-search")
|
|
def vector_search_knowledge(q: str, limit: int = 5):
|
|
"""向量检索:TF-IDF 余弦相似度。"""
|
|
return {"results": knowledge.vector_search(q, limit=limit)}
|
|
|
|
|
|
@app.delete("/knowledge/{doc_id}")
|
|
def delete_knowledge(doc_id: int):
|
|
"""删除知识库文档。"""
|
|
if not knowledge.delete(doc_id):
|
|
raise HTTPException(404, "document not found")
|
|
return {"deleted": True, "id": doc_id}
|
|
|
|
|
|
# ===== 用户画像 =====
|
|
|
|
@app.get("/profile/me")
|
|
def get_my_profile(authorization: str | None = Header(default=None)):
|
|
"""获取当前登录用户的画像。"""
|
|
phone = get_current_phone(authorization)
|
|
if not phone:
|
|
raise HTTPException(401, "未登录或登录已过期")
|
|
profile = profile_store.get(phone)
|
|
return {
|
|
"phone": profile.phone,
|
|
"industry": profile.industry,
|
|
"company_size": profile.company_size,
|
|
"role": profile.role,
|
|
"common_scenes": profile.common_scenes,
|
|
"common_topics": profile.common_topics,
|
|
"risk_tolerance": profile.risk_tolerance,
|
|
"communication_style": profile.communication_style,
|
|
"decision_style": profile.decision_style,
|
|
"key_facts": profile.key_facts,
|
|
"interaction_count": profile.interaction_count,
|
|
"last_active": profile.last_active,
|
|
}
|
|
|
|
|
|
@app.put("/profile/me")
|
|
def update_my_profile(body: dict, authorization: str | None = Header(default=None)):
|
|
"""更新当前登录用户的画像(用户确认事实后调用)。
|
|
|
|
Body:
|
|
{
|
|
"industry": "...",
|
|
"company_size": "...",
|
|
"role": "...",
|
|
"risk_tolerance": "...",
|
|
"decision_style": "...",
|
|
"key_facts": {"事实名": "事实值", ...}
|
|
}
|
|
"""
|
|
phone = get_current_phone(authorization)
|
|
if not phone:
|
|
raise HTTPException(401, "未登录或登录已过期")
|
|
# 构造 signals,只更新非空字段
|
|
signals: dict = {}
|
|
for key in ("industry", "company_size", "role", "risk_tolerance",
|
|
"communication_style", "decision_style"):
|
|
val = body.get(key)
|
|
if val:
|
|
signals[key] = val
|
|
if body.get("key_facts") and isinstance(body["key_facts"], dict):
|
|
signals["key_facts"] = body["key_facts"]
|
|
if not signals:
|
|
return {"updated": False}
|
|
signals["_source"] = "manual" # 标记为用户手动确认
|
|
profile = profile_store.update(phone, signals)
|
|
return {
|
|
"updated": True,
|
|
"industry": profile.industry,
|
|
"company_size": profile.company_size,
|
|
"role": profile.role,
|
|
"risk_tolerance": profile.risk_tolerance,
|
|
"decision_style": profile.decision_style,
|
|
"key_facts": profile.key_facts,
|
|
}
|
|
|
|
|
|
@app.get("/profile/me/history")
|
|
def get_my_profile_history(authorization: str | None = Header(default=None)):
|
|
"""获取当前登录用户的画像历史版本。"""
|
|
phone = get_current_phone(authorization)
|
|
if not phone:
|
|
raise HTTPException(401, "未登录或登录已过期")
|
|
return {"results": profile_store.list_history(phone)}
|
|
|
|
|
|
# ===== 用户设置持久化 =====
|
|
|
|
@app.get("/settings")
|
|
def get_settings():
|
|
"""读取全部用户设置。"""
|
|
return {"settings": settings_store.get_all()}
|
|
|
|
|
|
@app.put("/settings")
|
|
def update_settings(req: SettingsUpdateRequest):
|
|
"""批量更新用户设置。"""
|
|
updated = settings_store.set_many(req.settings)
|
|
return {"updated": True, "settings": settings_store.get_all()}
|
|
|
|
|
|
# ===== 专家库(用户端) =====
|
|
|
|
class ConsultationRequest(BaseModel):
|
|
"""一键咨询:将 AI 对话话题发给专家。"""
|
|
expert_id: int
|
|
topic: str # 咨询话题
|
|
context: str = "" # 对话上下文(脱敏摘要)
|
|
question: str = "" # 给专家的具体问题
|
|
|
|
|
|
class RatingRequest(BaseModel):
|
|
"""服务评价。"""
|
|
consultation_id: int
|
|
rating: int # 1-5 星
|
|
comment: str = ""
|
|
|
|
|
|
@app.get("/experts")
|
|
def list_experts(category: str | None = None, authorization: str | None = Header(default=None)):
|
|
"""列出专家,支持按分类筛选(legal/decision/emotion/life)。
|
|
登录后额外返回 connected 字段(是否已建立服务联系)。
|
|
"""
|
|
results = expert_store.list_experts(category=category, status="active")
|
|
# 查询当前用户已建立联系的专家ID集合
|
|
phone = get_current_phone(authorization)
|
|
if phone and results:
|
|
conn = _matter_dsn()
|
|
try:
|
|
cur = conn.cursor()
|
|
expert_ids = [r["id"] for r in results]
|
|
cur.execute(
|
|
"SELECT DISTINCT expert_id FROM expert_consultations "
|
|
"WHERE user_phone = %s AND expert_id = ANY(%s)",
|
|
(phone, expert_ids),
|
|
)
|
|
connected_ids = {r[0] for r in cur.fetchall()}
|
|
for r in results:
|
|
r["connected"] = r["id"] in connected_ids
|
|
finally:
|
|
conn.close()
|
|
else:
|
|
for r in results:
|
|
r["connected"] = False
|
|
return {"results": results}
|
|
|
|
|
|
@app.get("/experts/{expert_id}")
|
|
def get_expert(expert_id: int):
|
|
"""获取专家详情 + 评价列表。"""
|
|
expert = expert_store.get_expert(expert_id)
|
|
if not expert:
|
|
raise HTTPException(404, "专家不存在")
|
|
ratings = rating_store.list_by_expert(expert_id, limit=10)
|
|
expert["ratings"] = ratings
|
|
return expert
|
|
|
|
|
|
@app.post("/experts/{expert_id}/select")
|
|
def select_expert(expert_id: int, authorization: str | None = Header(default=None)):
|
|
"""用户选定专家,发起服务请求(待专家确认)。"""
|
|
phone = get_current_phone(authorization)
|
|
if not phone:
|
|
raise HTTPException(401, "未登录或登录已过期")
|
|
expert = expert_store.get_expert(expert_id)
|
|
if not expert:
|
|
raise HTTPException(404, "专家不存在")
|
|
if expert["status"] != "active":
|
|
raise HTTPException(400, "该专家已下架")
|
|
try:
|
|
result = expert_store.create_service(phone, expert_id, expert["category"])
|
|
return result
|
|
except ValueError as e:
|
|
raise HTTPException(400, str(e))
|
|
|
|
|
|
@app.get("/my/services")
|
|
def my_services(authorization: str | None = Header(default=None)):
|
|
"""查看我的专家服务列表。"""
|
|
phone = get_current_phone(authorization)
|
|
if not phone:
|
|
raise HTTPException(401, "未登录或登录已过期")
|
|
return {"results": expert_store.list_user_services(phone)}
|
|
|
|
|
|
@app.get("/my/consultations")
|
|
def my_consultations(status: str | None = None,
|
|
authorization: str | None = Header(default=None)):
|
|
"""查看我的咨询列表。"""
|
|
phone = get_current_phone(authorization)
|
|
if not phone:
|
|
raise HTTPException(401, "未登录或登录已过期")
|
|
return {"results": consultation_store.list_user_consultations(phone, status=status)}
|
|
|
|
|
|
@app.post("/consultations")
|
|
def create_consultation(req: ConsultationRequest,
|
|
authorization: str | None = Header(default=None)):
|
|
"""一键咨询:将 AI 对话话题发给专家。"""
|
|
phone = get_current_phone(authorization)
|
|
if not phone:
|
|
raise HTTPException(401, "未登录或登录已过期")
|
|
expert = expert_store.get_expert(req.expert_id)
|
|
if not expert:
|
|
raise HTTPException(404, "专家不存在")
|
|
# 检查是否有 active 服务关系
|
|
services = expert_store.list_user_services(phone)
|
|
active_service = next(
|
|
(s for s in services if s["expert_id"] == req.expert_id and s["status"] == "active"),
|
|
None,
|
|
)
|
|
if not active_service:
|
|
raise HTTPException(400, "请先选定该专家并等待专家确认")
|
|
# 脱敏上下文
|
|
safe_context = redact(req.context) if req.context else ""
|
|
safe_question = redact(req.question) if req.question else ""
|
|
return consultation_store.create(
|
|
service_id=active_service["service_id"],
|
|
user_phone=phone, expert_id=req.expert_id,
|
|
category=expert["category"], topic=req.topic,
|
|
context=safe_context, question=safe_question,
|
|
)
|
|
|
|
|
|
@app.post("/consultations/{consultation_id}/rate")
|
|
def rate_consultation(consultation_id: int, req: RatingRequest,
|
|
authorization: str | None = Header(default=None)):
|
|
"""评价专家服务。"""
|
|
phone = get_current_phone(authorization)
|
|
if not phone:
|
|
raise HTTPException(401, "未登录或登录已过期")
|
|
consultation = consultation_store.get(consultation_id)
|
|
if not consultation:
|
|
raise HTTPException(404, "咨询不存在")
|
|
if consultation["user_phone"] != phone:
|
|
raise HTTPException(403, "无权评价他人的咨询")
|
|
if consultation["status"] != "replied":
|
|
raise HTTPException(400, "专家尚未回复,暂无法评价")
|
|
try:
|
|
return rating_store.create(
|
|
consultation_id=consultation_id, user_phone=phone,
|
|
expert_id=consultation["expert_id"],
|
|
rating=req.rating, comment=req.comment,
|
|
)
|
|
except ValueError as e:
|
|
raise HTTPException(400, str(e))
|
|
|
|
|
|
# ===== 专家工作台 =====
|
|
|
|
@app.get("/expert/workbench")
|
|
def expert_workbench(authorization: str | None = Header(default=None)):
|
|
"""专家工作台:查看自己的服务关系和待回复咨询。"""
|
|
phone = get_current_phone(authorization)
|
|
if not phone:
|
|
raise HTTPException(401, "未登录或登录已过期")
|
|
# 通过手机号查找专家
|
|
experts = expert_store.list_experts()
|
|
expert = next((e for e in experts if e["phone"] == phone), None)
|
|
if not expert:
|
|
raise HTTPException(403, "您不是注册专家")
|
|
services = expert_store.list_expert_services(expert["id"])
|
|
consultations = consultation_store.list_expert_consultations(expert["id"])
|
|
return {"expert": expert, "services": services, "consultations": consultations}
|
|
|
|
|
|
@app.post("/expert/services/{service_id}/accept")
|
|
def accept_service(service_id: int, authorization: str | None = Header(default=None)):
|
|
"""专家确认服务关系(签协议)。"""
|
|
phone = get_current_phone(authorization)
|
|
if not phone:
|
|
raise HTTPException(401, "未登录或登录已过期")
|
|
experts = expert_store.list_experts()
|
|
expert = next((e for e in experts if e["phone"] == phone), None)
|
|
if not expert:
|
|
raise HTTPException(403, "您不是注册专家")
|
|
if not expert_store.accept_service(service_id, expert["id"]):
|
|
raise HTTPException(400, "确认失败(服务不存在或已确认)")
|
|
return {"accepted": True}
|
|
|
|
|
|
@app.post("/my/services/{service_id}/cancel")
|
|
def cancel_service_user(service_id: int, authorization: str | None = Header(default=None)):
|
|
"""用户取消服务关系(无需专家确认)。"""
|
|
phone = get_current_phone(authorization)
|
|
if not phone:
|
|
raise HTTPException(401, "未登录或登录已过期")
|
|
if not expert_store.close_service(service_id, user_phone=phone):
|
|
raise HTTPException(400, "取消失败(服务不存在或已结束)")
|
|
return {"cancelled": True}
|
|
|
|
|
|
@app.post("/expert/services/{service_id}/cancel")
|
|
def cancel_service_expert(service_id: int, authorization: str | None = Header(default=None)):
|
|
"""专家取消服务关系(无需用户确认)。"""
|
|
phone = get_current_phone(authorization)
|
|
if not phone:
|
|
raise HTTPException(401, "未登录或登录已过期")
|
|
experts = expert_store.list_experts()
|
|
expert = next((e for e in experts if e["phone"] == phone), None)
|
|
if not expert:
|
|
raise HTTPException(403, "您不是注册专家")
|
|
if not expert_store.close_service(service_id, expert_id=expert["id"]):
|
|
raise HTTPException(400, "取消失败(服务不存在或已结束)")
|
|
return {"cancelled": True}
|
|
|
|
|
|
@app.post("/expert/consultations/{consultation_id}/reply")
|
|
def reply_consultation(consultation_id: int, body: dict,
|
|
authorization: str | None = Header(default=None)):
|
|
"""专家回复咨询。"""
|
|
phone = get_current_phone(authorization)
|
|
if not phone:
|
|
raise HTTPException(401, "未登录或登录已过期")
|
|
experts = expert_store.list_experts()
|
|
expert = next((e for e in experts if e["phone"] == phone), None)
|
|
if not expert:
|
|
raise HTTPException(403, "您不是注册专家")
|
|
reply_text = body.get("reply", "").strip()
|
|
if not reply_text:
|
|
raise HTTPException(400, "回复内容不能为空")
|
|
if not consultation_store.reply(consultation_id, expert["id"], reply_text):
|
|
raise HTTPException(400, "回复失败(咨询不存在或已回复)")
|
|
return {"replied": True}
|
|
|
|
|
|
# ===== 对话历史 =====
|
|
|
|
class HistoryVerifyRequest(BaseModel):
|
|
password: str
|
|
|
|
|
|
@app.get("/history/sessions")
|
|
def list_history_sessions(
|
|
mode: str | None = None,
|
|
authorization: str | None = Header(default=None),
|
|
):
|
|
"""列出用户的对话历史会话(按时间倒序)。
|
|
|
|
普通模式和保密模式的历史会话都会列出(仅摘要,不含正文)。
|
|
阅后即焚不落库,不会出现。
|
|
|
|
Args:
|
|
mode: 可选模式过滤(normal/confidential)
|
|
"""
|
|
phone = get_current_phone(authorization)
|
|
if not phone:
|
|
raise HTTPException(401, "未登录")
|
|
return {"results": chat_store.list_sessions(phone, mode=mode)}
|
|
|
|
|
|
@app.get("/history/sessions/{session_id}")
|
|
def get_history_messages(
|
|
session_id: str,
|
|
request: Request,
|
|
authorization: str | None = Header(default=None),
|
|
):
|
|
"""获取会话的对话历史。
|
|
|
|
- 普通模式:直接返回明文消息。
|
|
- 保密模式:返回加密标记,需调用 /history/sessions/{sid}/verify 验证密码后获取明文。
|
|
|
|
Args:
|
|
session_id: 会话 ID
|
|
"""
|
|
phone = get_current_phone(authorization)
|
|
if not phone:
|
|
raise HTTPException(401, "未登录")
|
|
# 先取消息判断模式
|
|
messages = chat_store.get_messages(session_id, phone, decrypt=False)
|
|
if not messages:
|
|
raise HTTPException(404, "会话不存在或无历史")
|
|
mode = messages[0].get("mode", "normal")
|
|
if mode == "confidential":
|
|
# 保密模式:不直接返回明文,前端需先验证密码
|
|
return {
|
|
"session_id": session_id,
|
|
"mode": "confidential",
|
|
"need_verify": True,
|
|
"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",
|
|
"need_verify": False,
|
|
"messages": messages,
|
|
}
|
|
|
|
|
|
@app.post("/history/sessions/{session_id}/verify")
|
|
def verify_and_get_history(
|
|
session_id: str,
|
|
req: HistoryVerifyRequest,
|
|
authorization: str | None = Header(default=None),
|
|
):
|
|
"""保密模式:验证登录密码后返回解密的历史消息。
|
|
|
|
二次验证用登录密码(bcrypt 校验),验证通过后解密返回明文。
|
|
"""
|
|
phone = get_current_phone(authorization)
|
|
if not phone:
|
|
raise HTTPException(401, "未登录")
|
|
# 二次验证登录密码
|
|
with user_store._conn() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute("SELECT password FROM users WHERE phone = %s", (phone,))
|
|
row = cur.fetchone()
|
|
if not row:
|
|
raise HTTPException(401, "用户不存在")
|
|
import bcrypt
|
|
if not bcrypt.checkpw(req.password.encode(), row[0].encode()):
|
|
raise HTTPException(403, "密码验证失败")
|
|
# 验证通过,返回解密后的消息
|
|
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",
|
|
"need_verify": False,
|
|
"verified": True,
|
|
"messages": messages,
|
|
}
|
|
|
|
|
|
@app.delete("/history/sessions/{session_id}")
|
|
def delete_history_session(
|
|
session_id: str,
|
|
authorization: str | None = Header(default=None),
|
|
):
|
|
"""删除会话的所有历史消息(用户主动清除历史)。"""
|
|
phone = get_current_phone(authorization)
|
|
if not phone:
|
|
raise HTTPException(401, "未登录")
|
|
deleted = chat_store.delete_session(session_id, phone)
|
|
if deleted == 0:
|
|
raise HTTPException(404, "会话不存在或无历史")
|
|
return {"deleted": True, "session_id": session_id, "count": deleted}
|
|
|
|
|
|
# ===== 管理后台 =====
|
|
|
|
@app.get("/admin/experts")
|
|
def admin_list_experts(status: str | None = None):
|
|
"""管理端:列出所有专家(含下架)。"""
|
|
return {"results": expert_store.list_experts(status=status)}
|
|
|
|
|
|
class ExpertCreateRequest(BaseModel):
|
|
"""创建专家请求。"""
|
|
name: str
|
|
phone: str
|
|
password: str
|
|
category: str
|
|
title: str = ""
|
|
bio: str = ""
|
|
expertise: str = ""
|
|
avatar_url: str | None = None
|
|
status: str = "active"
|
|
|
|
|
|
@app.post("/admin/experts")
|
|
def admin_create_expert(req: ExpertCreateRequest):
|
|
"""管理端:创建专家(含登录账号)。"""
|
|
if len(req.password) < 6:
|
|
raise HTTPException(400, "密码至少 6 位")
|
|
try:
|
|
expert = expert_store.create_expert(
|
|
name=req.name, phone=req.phone, password=req.password,
|
|
category=req.category, title=req.title, bio=req.bio,
|
|
expertise=req.expertise, avatar_url=req.avatar_url,
|
|
status=req.status,
|
|
)
|
|
except ValueError as e:
|
|
raise HTTPException(400, str(e))
|
|
return {"created": True, "expert": expert}
|
|
|
|
|
|
@app.delete("/admin/experts/{expert_id}")
|
|
def admin_delete_expert(expert_id: int):
|
|
"""管理端:删除专家(级联删除服务关系和咨询)。"""
|
|
if not expert_store.delete_expert(expert_id):
|
|
raise HTTPException(404, "专家不存在")
|
|
return {"deleted": True, "expert_id": expert_id}
|
|
|
|
|
|
@app.patch("/admin/experts/{expert_id}/status")
|
|
def admin_update_expert_status(expert_id: int, body: dict):
|
|
"""管理端:上架/下架专家。"""
|
|
status = body.get("status", "")
|
|
if status not in ("active", "inactive"):
|
|
raise HTTPException(400, "状态应为 active 或 inactive")
|
|
if not expert_store.update_status(expert_id, status):
|
|
raise HTTPException(404, "专家不存在")
|
|
return {"updated": True, "expert_id": expert_id, "status": status}
|
|
|
|
|
|
class ExpertUpdateRequest(BaseModel):
|
|
"""编辑专家信息请求(所有字段可选)。"""
|
|
name: str | None = None
|
|
category: str | None = None
|
|
title: str | None = None
|
|
bio: str | None = None
|
|
expertise: str | None = None
|
|
avatar_url: str | None = None
|
|
status: str | None = None
|
|
|
|
|
|
@app.patch("/admin/experts/{expert_id}")
|
|
def admin_update_expert(expert_id: int, req: ExpertUpdateRequest):
|
|
"""管理端:编辑专家信息(姓名/分类/职称/简介/专长/头像/状态)。"""
|
|
try:
|
|
updated = expert_store.update_expert(
|
|
expert_id,
|
|
name=req.name, category=req.category, title=req.title,
|
|
bio=req.bio, expertise=req.expertise,
|
|
avatar_url=req.avatar_url, status=req.status,
|
|
)
|
|
except ValueError as e:
|
|
raise HTTPException(400, str(e))
|
|
if not updated:
|
|
raise HTTPException(404, "专家不存在")
|
|
return {"updated": True, "expert": updated}
|
|
|
|
|
|
@app.post("/admin/experts/{expert_id}/avatar")
|
|
async def admin_upload_expert_avatar(expert_id: int, file: UploadFile = File(...)):
|
|
"""管理端:上传专家头像,保存到 uploads/avatars/,返回可访问 URL。"""
|
|
# 校验专家存在
|
|
if not expert_store.get_expert(expert_id):
|
|
raise HTTPException(404, "专家不存在")
|
|
data = await file.read()
|
|
if len(data) > 5 * 1024 * 1024:
|
|
raise HTTPException(413, "图片不能超过 5MB")
|
|
# 校验文件类型
|
|
allowed = {"image/jpeg", "image/png", "image/webp"}
|
|
if file.content_type not in allowed:
|
|
raise HTTPException(400, f"仅支持 {', '.join(allowed)} 格式")
|
|
# 保存到 uploads/avatars/
|
|
upload_dir = os.path.join(os.path.dirname(__file__), "uploads", "avatars")
|
|
os.makedirs(upload_dir, exist_ok=True)
|
|
ext = {"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}[file.content_type]
|
|
filename = f"expert_{expert_id}{ext}"
|
|
filepath = os.path.join(upload_dir, filename)
|
|
with open(filepath, "wb") as f:
|
|
f.write(data)
|
|
# 返回可访问 URL(经 Vite 代理或 nginx 反代)
|
|
avatar_url = f"/uploads/avatars/{filename}"
|
|
# 同步更新数据库
|
|
expert_store.update_expert(expert_id, avatar_url=avatar_url)
|
|
logger.info(f"expert {expert_id} avatar uploaded: {avatar_url}")
|
|
return {"avatar_url": avatar_url}
|
|
|
|
|
|
@app.get("/admin/users")
|
|
def admin_list_users(q: str | None = None, limit: int = 50):
|
|
"""管理端:列出所有注册用户,支持搜索,含对话统计和状态。"""
|
|
with user_store._conn() as conn:
|
|
with conn.cursor() as cur:
|
|
if q:
|
|
cur.execute(
|
|
"SELECT id, phone, name, status, created_at FROM users "
|
|
"WHERE phone LIKE %s OR name LIKE %s "
|
|
"ORDER BY created_at DESC LIMIT %s",
|
|
(f"%{q}%", f"%{q}%", limit),
|
|
)
|
|
else:
|
|
cur.execute(
|
|
"SELECT id, phone, name, status, created_at FROM users "
|
|
"ORDER BY created_at DESC LIMIT %s",
|
|
(limit,),
|
|
)
|
|
rows = cur.fetchall()
|
|
# 批量查询每个用户的对话数和最后活跃时间
|
|
results = []
|
|
for r in rows:
|
|
uid, phone, name, status, created_at = r
|
|
cur.execute(
|
|
"SELECT COUNT(*), MAX(created_at), "
|
|
"(array_agg(meta_data->>'scene' ORDER BY created_at DESC) FILTER (WHERE role = 'assistant'))[1] "
|
|
"FROM chat_messages WHERE phone = %s",
|
|
(phone,),
|
|
)
|
|
stat = cur.fetchone()
|
|
msg_count = stat[0] or 0
|
|
last_active = stat[1]
|
|
last_scene = stat[2]
|
|
results.append({
|
|
"id": uid,
|
|
"phone": phone[:3] + "****" + phone[-4:],
|
|
"name": name,
|
|
"status": status,
|
|
"created_at": str(created_at),
|
|
"message_count": msg_count,
|
|
"last_active": str(last_active) if last_active else None,
|
|
"last_scene": last_scene,
|
|
})
|
|
return {"results": results}
|
|
|
|
|
|
@app.post("/admin/users/{user_id}/reset-password")
|
|
def admin_reset_password(user_id: int, body: dict):
|
|
"""管理端:重置用户密码。"""
|
|
new_password = body.get("password", "")
|
|
if not new_password or len(new_password) < 6:
|
|
raise HTTPException(400, "密码至少 6 位")
|
|
if not user_store.reset_password(user_id, new_password):
|
|
raise HTTPException(404, "用户不存在")
|
|
return {"reset": True}
|
|
|
|
|
|
@app.post("/admin/users/{user_id}/status")
|
|
def admin_update_user_status(user_id: int, body: dict):
|
|
"""管理端:激活/停用用户。"""
|
|
status = body.get("status", "")
|
|
if status not in ("active", "inactive"):
|
|
raise HTTPException(400, "状态应为 active 或 inactive")
|
|
if not user_store.update_status(user_id, status):
|
|
raise HTTPException(404, "用户不存在")
|
|
return {"status": status}
|
|
|
|
|
|
@app.get("/admin/stats")
|
|
def admin_stats():
|
|
"""管理端:平台统计。"""
|
|
with user_store._conn() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute("SELECT count(*) FROM users")
|
|
user_count = cur.fetchone()[0]
|
|
cur.execute("SELECT count(*) FROM experts WHERE status = 'active'")
|
|
expert_active = cur.fetchone()[0]
|
|
cur.execute("SELECT count(*) FROM experts")
|
|
expert_total = cur.fetchone()[0]
|
|
cur.execute("SELECT count(*) FROM expert_services WHERE status = 'active'")
|
|
service_active = cur.fetchone()[0]
|
|
cur.execute("SELECT count(*) FROM expert_consultations")
|
|
consultation_total = cur.fetchone()[0]
|
|
cur.execute("SELECT count(*) FROM expert_consultations WHERE status = 'pending'")
|
|
consultation_pending = cur.fetchone()[0]
|
|
return {
|
|
"users": user_count,
|
|
"experts_active": expert_active,
|
|
"experts_total": expert_total,
|
|
"services_active": service_active,
|
|
"consultations_total": consultation_total,
|
|
"consultations_pending": consultation_pending,
|
|
}
|
|
|
|
|
|
@app.get("/admin/ai-usage")
|
|
def admin_ai_usage():
|
|
"""管理端:用户 AI 使用情况统计(不含具体对话内容)。
|
|
|
|
返回维度:
|
|
- 总提问数、活跃用户数
|
|
- 按场景分布(legal/decision/life/emotion)
|
|
- 按隐私模式分布(normal/confidential,ephemeral 不落库)
|
|
- 按风险等级分布(green/amber/orange/red)
|
|
- 按推理位置分布(cloud/local)
|
|
- 近 7 天每日提问趋势
|
|
- 按用户统计(脱敏手机号,提问数、最后活跃时间、常用场景)
|
|
"""
|
|
with user_store._conn() as conn:
|
|
with conn.cursor() as cur:
|
|
# 总提问数(用户消息)
|
|
cur.execute("SELECT count(*) FROM chat_messages WHERE role = 'user'")
|
|
total_questions = cur.fetchone()[0]
|
|
|
|
# 活跃用户数(有提问记录的不同手机号)
|
|
cur.execute("SELECT count(DISTINCT phone) FROM chat_messages WHERE role = 'user'")
|
|
active_users = cur.fetchone()[0]
|
|
|
|
# 按场景分布
|
|
cur.execute("""
|
|
SELECT meta_data->>'scene' AS scene, count(*) AS cnt
|
|
FROM chat_messages WHERE role = 'user'
|
|
GROUP BY scene ORDER BY cnt DESC
|
|
""")
|
|
by_scene = [{"scene": r[0] or "unknown", "count": r[1]} for r in cur.fetchall()]
|
|
|
|
# 按隐私模式分布
|
|
cur.execute("""
|
|
SELECT mode, count(*) AS cnt
|
|
FROM chat_messages WHERE role = 'user'
|
|
GROUP BY mode ORDER BY cnt DESC
|
|
""")
|
|
by_mode = [{"mode": r[0], "count": r[1]} for r in cur.fetchall()]
|
|
|
|
# 按风险等级分布(AI 回复中的风险卡)
|
|
cur.execute("""
|
|
SELECT risk_data->>'level' AS level, count(*) AS cnt
|
|
FROM chat_messages WHERE role = 'assistant'
|
|
GROUP BY level ORDER BY cnt DESC
|
|
""")
|
|
by_risk = [{"level": r[0] or "unknown", "count": r[1]} for r in cur.fetchall()]
|
|
|
|
# 按推理位置分布
|
|
cur.execute("""
|
|
SELECT meta_data->>'inference_location' AS loc, count(*) AS cnt
|
|
FROM chat_messages WHERE role = 'assistant'
|
|
GROUP BY loc ORDER BY cnt DESC
|
|
""")
|
|
by_inference = [{"location": r[0] or "unknown", "count": r[1]} for r in cur.fetchall()]
|
|
|
|
# 近 7 天每日提问趋势
|
|
cur.execute("""
|
|
SELECT date_trunc('day', created_at)::date AS d, count(*) AS cnt
|
|
FROM chat_messages WHERE role = 'user'
|
|
AND created_at >= now() - interval '7 days'
|
|
GROUP BY d ORDER BY d
|
|
""")
|
|
daily_trend = [{"date": r[0].isoformat(), "count": r[1]} for r in cur.fetchall()]
|
|
|
|
# 按用户统计(脱敏手机号)
|
|
cur.execute("""
|
|
SELECT phone,
|
|
count(*) FILTER (WHERE role = 'user') AS questions,
|
|
count(*) FILTER (WHERE role = 'assistant') AS replies,
|
|
max(created_at) AS last_active,
|
|
(array_agg(meta_data->>'scene' ORDER BY created_at DESC)
|
|
FILTER (WHERE role = 'user'))[1] AS top_scene
|
|
FROM chat_messages
|
|
GROUP BY phone
|
|
ORDER BY questions DESC
|
|
LIMIT 50
|
|
""")
|
|
by_user = []
|
|
for r in cur.fetchall():
|
|
phone = r[0]
|
|
# 脱敏:138****1234
|
|
masked = phone[:3] + "****" + phone[-4:] if len(phone) >= 8 else phone
|
|
by_user.append({
|
|
"phone_masked": masked,
|
|
"questions": r[1],
|
|
"replies": r[2],
|
|
"last_active": r[3].isoformat() if r[3] else None,
|
|
"top_scene": r[4],
|
|
})
|
|
|
|
return {
|
|
"total_questions": total_questions,
|
|
"active_users": active_users,
|
|
"by_scene": by_scene,
|
|
"by_mode": by_mode,
|
|
"by_risk": by_risk,
|
|
"by_inference": by_inference,
|
|
"daily_trend": daily_trend,
|
|
"by_user": by_user,
|
|
}
|
|
|
|
|
|
@app.get("/admin/expert-activity")
|
|
def admin_expert_activity():
|
|
"""管理端:用户与专家沟通情况统计(不含具体咨询内容)。
|
|
|
|
返回维度:
|
|
- 总咨询数、已回复数、待回复数、已关闭数
|
|
- 按分类分布(legal/decision/emotion/life)
|
|
- 按状态分布(pending/replied/closed)
|
|
- 按专家分布(专家名、咨询数、已回复数、待回复数、平均评分)
|
|
- 近 7 天每日咨询趋势
|
|
- 按用户分布(脱敏手机号,咨询数)
|
|
"""
|
|
with user_store._conn() as conn:
|
|
with conn.cursor() as cur:
|
|
# 按状态分布
|
|
cur.execute("""
|
|
SELECT status, count(*) AS cnt
|
|
FROM expert_consultations
|
|
GROUP BY status ORDER BY cnt DESC
|
|
""")
|
|
by_status = [{"status": r[0], "count": r[1]} for r in cur.fetchall()]
|
|
|
|
# 按分类分布
|
|
cur.execute("""
|
|
SELECT category, count(*) AS cnt
|
|
FROM expert_consultations
|
|
GROUP BY category ORDER BY cnt DESC
|
|
""")
|
|
by_category = [{"category": r[0], "count": r[1]} for r in cur.fetchall()]
|
|
|
|
# 按专家分布(含平均评分)
|
|
cur.execute("""
|
|
SELECT e.name, e.category, e.title,
|
|
count(c.id) AS total,
|
|
count(c.id) FILTER (WHERE c.status = 'replied') AS replied,
|
|
count(c.id) FILTER (WHERE c.status = 'pending') AS pending,
|
|
COALESCE(avg(r.rating), 0) AS avg_rating,
|
|
count(r.id) AS rating_count
|
|
FROM experts e
|
|
LEFT JOIN expert_consultations c ON c.expert_id = e.id
|
|
LEFT JOIN expert_ratings r ON r.consultation_id = c.id
|
|
GROUP BY e.id, e.name, e.category, e.title
|
|
ORDER BY total DESC
|
|
""")
|
|
by_expert = [{
|
|
"name": r[0], "category": r[1], "title": r[2],
|
|
"total": r[3], "replied": r[4], "pending": r[5],
|
|
"avg_rating": round(float(r[6]), 1), "rating_count": r[7],
|
|
} for r in cur.fetchall()]
|
|
|
|
# 近 7 天每日咨询趋势
|
|
cur.execute("""
|
|
SELECT date_trunc('day', created_at)::date AS d, count(*) AS cnt
|
|
FROM expert_consultations
|
|
WHERE created_at >= now() - interval '7 days'
|
|
GROUP BY d ORDER BY d
|
|
""")
|
|
daily_trend = [{"date": r[0].isoformat(), "count": r[1]} for r in cur.fetchall()]
|
|
|
|
# 按用户分布(脱敏)
|
|
cur.execute("""
|
|
SELECT user_phone, count(*) AS cnt, max(created_at) AS last_active
|
|
FROM expert_consultations
|
|
GROUP BY user_phone
|
|
ORDER BY cnt DESC
|
|
LIMIT 50
|
|
""")
|
|
by_user = []
|
|
for r in cur.fetchall():
|
|
phone = r[0]
|
|
masked = phone[:3] + "****" + phone[-4:] if len(phone) >= 8 else phone
|
|
by_user.append({
|
|
"phone_masked": masked,
|
|
"consultations": r[1],
|
|
"last_active": r[2].isoformat() if r[2] else None,
|
|
})
|
|
|
|
total = sum(s["count"] for s in by_status)
|
|
replied = next((s["count"] for s in by_status if s["status"] == "replied"), 0)
|
|
pending = next((s["count"] for s in by_status if s["status"] == "pending"), 0)
|
|
|
|
return {
|
|
"total": total,
|
|
"replied": replied,
|
|
"pending": pending,
|
|
"by_status": by_status,
|
|
"by_category": by_category,
|
|
"by_expert": by_expert,
|
|
"daily_trend": daily_trend,
|
|
"by_user": by_user,
|
|
}
|
|
|
|
|
|
# ===== 文件生成 =====
|
|
|
|
class DocumentGenerateRequest(BaseModel):
|
|
template: str # demand_letter/labor_notice/termination_notice/evidence_checklist/general_notice
|
|
fields: dict
|
|
session_id: str | None = None # 阅后即焚会话禁止导出
|
|
|
|
|
|
@app.get("/documents/templates")
|
|
def list_templates():
|
|
"""列出可用文书模板。"""
|
|
return {"templates": DocumentGenerator.TEMPLATES}
|
|
|
|
|
|
@app.post("/documents/generate")
|
|
def generate_document(req: DocumentGenerateRequest):
|
|
"""生成法律文书 DOCX。
|
|
|
|
阅后即焚(ephemeral)会话禁止导出。
|
|
"""
|
|
# 检查会话模式
|
|
ephemeral = False
|
|
if req.session_id:
|
|
session = sessions.sessions.get(req.session_id)
|
|
if session and session.mode == "ephemeral" and not session.destroyed:
|
|
ephemeral = True
|
|
if ephemeral:
|
|
raise HTTPException(403, "阅后即焚模式下禁止导出文件")
|
|
|
|
gen = DocumentGenerator(ephemeral_mode=ephemeral, kimi_client=kimi)
|
|
try:
|
|
data = gen.generate(req.template, req.fields)
|
|
except ValueError as e:
|
|
raise HTTPException(400, str(e))
|
|
|
|
from fastapi.responses import Response
|
|
# 文件名用 ASCII(中文文件名在 header 中会编码失败)
|
|
filename = f"{req.template}_{int(time.time())}.docx"
|
|
return Response(
|
|
content=data,
|
|
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
)
|
|
|
|
|
|
# ===== 法规更新管理 =====
|
|
|
|
class RegulationUpdateRequest(BaseModel):
|
|
doc_id: int
|
|
content: str
|
|
title: str | None = None
|
|
effective_date: str | None = None
|
|
keywords: str | None = None
|
|
snapshot_reason: str = "法规更新"
|
|
|
|
|
|
class RegulationRollbackRequest(BaseModel):
|
|
version_num: int
|
|
|
|
|
|
@app.get("/regulations/check-updates")
|
|
def check_regulation_updates():
|
|
"""扫描知识库,返回需要更新的法规列表。"""
|
|
return {"results": regulation_mgr.check_updates()}
|
|
|
|
|
|
@app.post("/regulations/update")
|
|
def update_regulation(req: RegulationUpdateRequest):
|
|
"""更新法规文档,自动创建更新前快照。"""
|
|
try:
|
|
result = regulation_mgr.update_document(
|
|
doc_id=req.doc_id, content=req.content, title=req.title,
|
|
effective_date=req.effective_date, keywords=req.keywords,
|
|
snapshot_reason=req.snapshot_reason,
|
|
)
|
|
return result
|
|
except ValueError as e:
|
|
raise HTTPException(404, str(e))
|
|
|
|
|
|
@app.get("/regulations/{doc_id}/versions")
|
|
def list_regulation_versions(doc_id: int):
|
|
"""列出法规文档的所有版本快照。"""
|
|
return {"results": regulation_mgr.list_versions(doc_id)}
|
|
|
|
|
|
@app.post("/regulations/{doc_id}/rollback")
|
|
def rollback_regulation(doc_id: int, req: RegulationRollbackRequest):
|
|
"""回滚法规文档到指定版本。"""
|
|
try:
|
|
return regulation_mgr.rollback(doc_id, req.version_num)
|
|
except ValueError as e:
|
|
raise HTTPException(404, str(e))
|
|
|
|
|
|
# ===== 静态文件托管:上传的专家头像等 =====
|
|
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()
|
|
|
|
|
|
# 场景中文映射(用于摘要)
|
|
SCENE_LABELS_MAP = {"legal": "法务", "decision": "决策", "life": "生活", "emotion": "情绪"}
|
|
|
|
|
|
@app.get("/dashboard/summary")
|
|
def dashboard_summary(authorization: str | None = Header(default=None)):
|
|
"""老板台执行摘要:AI 用一句话总结当前状态和建议行动。
|
|
降级策略:Kimi 不可用时返回规则摘要。
|
|
"""
|
|
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, status FROM matters "
|
|
"WHERE user_phone = %s AND status != 'closed' ORDER BY updated_at DESC LIMIT 10",
|
|
(phone,),
|
|
)
|
|
rows = cur.fetchall()
|
|
open_matters = [{"id": r[0], "title": r[1], "risk_level": r[2], "scene": r[3], "status": r[4]} for r in rows]
|
|
cur.execute(
|
|
"SELECT COUNT(*) FROM matters WHERE user_phone = %s AND status = 'pending_confirm'",
|
|
(phone,),
|
|
)
|
|
pending_count = cur.fetchone()[0]
|
|
cur.execute(
|
|
"SELECT COUNT(*) FROM expert_consultations WHERE user_phone = %s AND status = 'replied'",
|
|
(phone,),
|
|
)
|
|
reply_count = cur.fetchone()[0]
|
|
finally:
|
|
conn.close()
|
|
|
|
red_count = sum(1 for m in open_matters if m["risk_level"] == "red")
|
|
orange_count = sum(1 for m in open_matters if m["risk_level"] == "orange")
|
|
open_count = len(open_matters)
|
|
|
|
# 规则摘要(兜底)
|
|
parts = []
|
|
if red_count > 0:
|
|
parts.append(f"{red_count} 项高风险需立即处理")
|
|
if orange_count > 0:
|
|
parts.append(f"{orange_count} 项中风险需关注")
|
|
if pending_count > 0:
|
|
parts.append(f"{pending_count} 个待确认")
|
|
if reply_count > 0:
|
|
parts.append(f"{reply_count} 条专家回复待查看")
|
|
# 有进行中的案卡但无风险标记
|
|
ongoing = open_count - red_count - orange_count
|
|
if ongoing > 0 and not parts:
|
|
# 按场景归类
|
|
scene_counts: dict[str, int] = {}
|
|
for m in open_matters:
|
|
sc = SCENE_LABELS_MAP.get(m["scene"], m["scene"])
|
|
scene_counts[sc] = scene_counts.get(sc, 0) + 1
|
|
scene_desc = "、".join(f"{k}{v}项" for k, v in scene_counts.items())
|
|
parts.append(f"{ongoing} 项{scene_desc}进行中")
|
|
if not parts:
|
|
rule_summary = "当前无待办事项,经营状况良好。"
|
|
rule_action = "可主动发起咨询或浏览智囊"
|
|
else:
|
|
rule_summary = "、".join(parts) + "。"
|
|
if red_count > 0:
|
|
rule_action = "建议优先处理高风险事项"
|
|
elif pending_count > 0:
|
|
rule_action = "建议先确认待办事项"
|
|
elif reply_count > 0:
|
|
rule_action = "建议查看专家回复"
|
|
elif ongoing > 0:
|
|
# 取最近的案卡标题作为建议
|
|
latest = open_matters[0]["title"] if open_matters else ""
|
|
rule_action = f"继续跟进「{latest[:15]}」" if latest else "建议跟进进行中的事项"
|
|
else:
|
|
rule_action = "建议关注中风险事项"
|
|
|
|
# 尝试 AI 生成更自然的摘要
|
|
try:
|
|
if kimi and open_matters:
|
|
matter_desc = "\n".join(
|
|
f"- {m['title']}(场景:{SCENE_LABELS_MAP.get(m['scene'], m['scene'])},状态:{m['status']})"
|
|
for m in open_matters[:5]
|
|
)
|
|
prompt = (
|
|
f"你是中小企业老板的AI助手。请根据以下待办事项数据,生成今日要点摘要。\n"
|
|
f"要求:\n"
|
|
f"1. summary:一句话总结,必须包含具体数字和事项主题,不超过60字\n"
|
|
f"2. action:最优先的行动建议,必须引用具体事项标题,不超过25字\n"
|
|
f"3. 不要用'经营状态稳定'等空泛表述,要基于实际数据\n\n"
|
|
f"待办事项(共{open_count}项):\n{matter_desc}\n"
|
|
f"待确认:{pending_count}个,专家回复:{reply_count}条\n\n"
|
|
f"返回 JSON 格式:{{\"summary\": \"...\", \"action\": \"...\"}}"
|
|
)
|
|
answer = kimi.chat(
|
|
[{"role": "system", "content": "你是经营顾问助手,只返回JSON。"},
|
|
{"role": "user", "content": prompt}],
|
|
temperature=0.3, timeout=15,
|
|
)
|
|
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.replace("\u201c", '"').replace("\u201d", '"').strip()
|
|
start = answer.find("{")
|
|
end = answer.rfind("}")
|
|
if start != -1 and end != -1:
|
|
import json as _json
|
|
parsed = _json.loads(answer[start:end + 1])
|
|
return {
|
|
"summary": parsed.get("summary", rule_summary),
|
|
"action": parsed.get("action", rule_action),
|
|
"ai_generated": True,
|
|
}
|
|
except Exception:
|
|
pass
|
|
|
|
return {"summary": rule_summary, "action": rule_action, "ai_generated": False}
|
|
|
|
|
|
@app.get("/dashboard/timeline")
|
|
def dashboard_timeline(authorization: str | None = Header(default=None)):
|
|
"""老板台时间线:按时间倒序聚合最近活动(案卡变更、专家回复、风险推送)。"""
|
|
phone = get_current_phone(authorization)
|
|
if not phone:
|
|
raise HTTPException(401, "未登录")
|
|
conn = _matter_dsn()
|
|
try:
|
|
cur = conn.cursor()
|
|
items = []
|
|
# 案卡最近更新
|
|
cur.execute(
|
|
"SELECT id, title, scene, status, risk_level, updated_at "
|
|
"FROM matters WHERE user_phone = %s ORDER BY updated_at DESC LIMIT 8",
|
|
(phone,),
|
|
)
|
|
for r in cur.fetchall():
|
|
items.append({
|
|
"type": "matter",
|
|
"id": r[0],
|
|
"title": r[1],
|
|
"scene": r[2],
|
|
"status": r[3],
|
|
"risk_level": r[4],
|
|
"time": r[5].isoformat() if r[5] else None,
|
|
})
|
|
# 专家回复
|
|
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,),
|
|
)
|
|
for r in cur.fetchall():
|
|
items.append({
|
|
"type": "expert_reply",
|
|
"id": r[0],
|
|
"expert_name": r[1],
|
|
"expert_title": r[2],
|
|
"snippet": (r[3] or "")[:60],
|
|
"time": r[4].isoformat() if r[4] else None,
|
|
})
|
|
# 风险推送
|
|
try:
|
|
cur.execute(
|
|
"SELECT id, title, level, created_at FROM risk_alerts "
|
|
"WHERE user_phone = %s ORDER BY created_at DESC LIMIT 5",
|
|
(phone,),
|
|
)
|
|
for r in cur.fetchall():
|
|
items.append({
|
|
"type": "risk_alert",
|
|
"id": r[0],
|
|
"title": r[1],
|
|
"level": r[2],
|
|
"time": r[3].isoformat() if r[3] else None,
|
|
})
|
|
except Exception:
|
|
pass # risk_alerts 表可能不存在
|
|
# 按时间倒序
|
|
items.sort(key=lambda x: x.get("time") or "", reverse=True)
|
|
return {"results": items[:15]}
|
|
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()
|
|
|
|
|
|
# ===== 每日热点问题库 =====
|
|
|
|
@app.get("/topics/daily")
|
|
def get_daily_topics(scene: str):
|
|
"""获取当天热点问题(公开接口,无需登录)。
|
|
|
|
无当天数据时返回空数组,前端用硬编码兜底。
|
|
"""
|
|
if scene not in DailyTopicStore.SCENES:
|
|
raise HTTPException(400, f"场景应为 {DailyTopicStore.SCENES} 之一")
|
|
return {"scene": scene, "questions": daily_topic_store.get_today(scene)}
|
|
|
|
|
|
@app.post("/topics/click")
|
|
def record_sample_click(body: dict, authorization: str | None = Header(default=None)):
|
|
"""记录用户点击示例问题(埋点)。"""
|
|
scene = body.get("scene", "")
|
|
question = body.get("question", "")
|
|
if scene not in DailyTopicStore.SCENES or not question:
|
|
raise HTTPException(400, "缺少 scene 或 question")
|
|
phone = get_current_phone(authorization)
|
|
daily_topic_store.record_click(scene, question, phone)
|
|
return {"recorded": True}
|
|
|
|
|
|
# ===== 管理端:热点问题管理 =====
|
|
|
|
@app.get("/admin/daily-topics")
|
|
def admin_get_daily_topics(date: str | None = None):
|
|
"""管理端:获取指定日期(默认今天)所有场景的热点问题。"""
|
|
from datetime import date as date_cls
|
|
target = date or str(date_cls.today())
|
|
return {"results": daily_topic_store.get_by_date(target)}
|
|
|
|
|
|
class DailyTopicUpdateRequest(BaseModel):
|
|
"""管理员手动更新热点问题。"""
|
|
scene: str
|
|
questions: list[str]
|
|
date: str | None = None # 指定日期,默认今天
|
|
|
|
|
|
@app.post("/admin/daily-topics")
|
|
def admin_update_daily_topics(req: DailyTopicUpdateRequest):
|
|
"""管理端:手动更新某场景的热点问题。"""
|
|
if req.scene not in DailyTopicStore.SCENES:
|
|
raise HTTPException(400, f"场景应为 {DailyTopicStore.SCENES} 之一")
|
|
if not req.questions:
|
|
raise HTTPException(400, "问题列表不能为空")
|
|
result = daily_topic_store.upsert(
|
|
req.scene, req.questions, source="admin", date_str=req.date,
|
|
)
|
|
return {"updated": True, "topic": result}
|
|
|
|
|
|
@app.post("/admin/daily-topics/generate")
|
|
def admin_generate_daily_topics():
|
|
"""管理端:手动触发 AI 生成当天热点问题(4个场景)。"""
|
|
if not kimi:
|
|
raise HTTPException(503, "AI 服务不可用,请检查 KIMI_API_KEY 配置")
|
|
results = generate_daily_topics(kimi, daily_topic_store)
|
|
return {"generated": True, "results": results}
|
|
|
|
|
|
@app.get("/admin/sample-clicks")
|
|
def admin_sample_click_stats(days: int = 14):
|
|
"""管理端:获取示例问题点击统计。"""
|
|
return {"results": daily_topic_store.click_stats(days)}
|