Files
BossAi/api.py
T
selfrelease 798bb40885
CI / 后端测试 (push) Has been cancelled
CI / 前端构建 (push) Has been cancelled
fix: /samples超时从15s增至30s,同步.env模型名
2026-08-31 17:31:52 +08:00

3081 lines
125 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,
)
# ===== 日志脱敏 =====
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"):
valid_topics.append({
"title": str(t["title"])[:10],
"desc": str(t.get("desc", ""))[:30],
"samples": [str(s) for s in t["samples"] if isinstance(s, str) and s.strip()][:4],
})
if len(valid_topics) < 3:
return {"topics": builtin_topics.get(req.scene, builtin_topics["legal"])}
return {"topics": valid_topics[:8]}
except Exception as e:
import logging
logging.warning(f"/topics AI 生成失败: {type(e).__name__}: {e}")
return {"topics": builtin_topics.get(req.scene, builtin_topics["legal"])}
@app.post("/samples")
def generate_samples(req: SampleRequest):
"""用 AI 生成场景示例问题,供前端"换一批"使用。
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"
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"
)
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 = [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)}