diff --git a/.gitignore b/.gitignore index 3d4a46e..5480110 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,8 @@ venv/ # 前端 web/node_modules/ web/dist/ +web2/node_modules/ +web2/dist/ # 上传文件(专家头像等,不入 Git) uploads/ diff --git a/api.py b/api.py index d7ac383..a3eeb2d 100644 --- a/api.py +++ b/api.py @@ -292,7 +292,7 @@ def generate_topics(req: TopicsRequest): # 内置话题(降级用) builtin_topics = { "legal": [ - {"title": "劳动用工", "desc": "工伤、辞退、欠薪、社保", "samples": ["员工工伤后能否开除?", "拖欠工资怎么追讨?", "未签合同双倍工资怎么算?", "社保没缴全怎么补?"]}, + {"title": "劳动用工", "desc": "工伤、辞退、欠薪、社保", "samples": ["员工工伤后能开除吗?", "员工被辞退要赔多少?", "员工不签合同有何风险?", "社保未缴全被查怎么办?"]}, {"title": "合同审查", "desc": "违约、条款、效力", "samples": ["合同违约金怎么约定?", "对方不履行合同怎么办?", "口头协议有法律效力吗?", "合同条款不公平能改吗?"]}, {"title": "债权债务", "desc": "欠款、催收、诉讼", "samples": ["客户拖欠货款3个月如何催收?", "借条怎么写才有效?", "债务人不还钱能报警吗?", "超过诉讼时效怎么办?"]}, {"title": "股东纠纷", "desc": "股权、分红、退股", "samples": ["合伙人要退股怎么谈?", "股东不出资怎么办?", "分红比例怎么定?", "小股东权益被侵害怎么办?"]}, @@ -354,8 +354,9 @@ def generate_topics(req: TopicsRequest): 1. 话题要覆盖该场景下不同方向,不要只集中在某一类 2. 结合用户画像(如有)推荐个性化话题 3. 示例问题要具体、贴近中小企业老板实际 -4. 返回JSON数组格式:[{{"title":"话题名","desc":"简短描述","samples":["问题1","问题2","问题3","问题4"]}}] -5. 只返回JSON,不要其他文字{existing_str}""" +4. **所有问题必须从老板/雇主视角出发**(BossAi 的用户是企业老板,不是员工)。例如劳动用工应问"员工工伤后能开除吗"而非"拖欠工资怎么追讨" +5. 返回JSON数组格式:[{{"title":"话题名","desc":"简短描述","samples":["问题1","问题2","问题3","问题4"]}}] +6. 只返回JSON,不要其他文字{existing_str}""" try: answer = kimi.chat([{"role": "user", "content": prompt}], temperature=0.8) @@ -396,7 +397,7 @@ def generate_samples(req: SampleRequest): """ # 内置示例(降级用) builtin = { - "legal": ["员工工伤后能否开除?", "收到税务稽查通知怎么办?", "客户拖欠货款 3 个月如何催收?", "合同违约金怎么约定?"], + "legal": ["员工工伤后能开除吗?", "收到税务稽查通知怎么办?", "客户拖欠货款 3 个月如何催收?", "合同违约金怎么约定?"], "decision": ["是否应该接一个付款周期 90 天的大单?", "现在适合扩张开第二家店吗?", "该不该降价 15% 抢竞品的客户?", "合伙人要退股,怎么谈条件?"], "life": ["怎么和青春期的孩子沟通不想他接班的事?", "父母年纪大了,养老方案怎么选?", "最近睡眠很差,有什么改善建议?", "体检报告有几项异常,需要进一步检查吗?"], "emotion": ["最近压力很大,睡不着觉", "合伙人背叛了,我很愤怒", "明天的谈判我拖延了一周没准备", "感觉撑不下去了"], @@ -429,9 +430,10 @@ def generate_samples(req: SampleRequest): prompt += ( "要求:\n" "1. 问题真实、具体、贴近中小企业老板的实际场景\n" - "2. 每个问题 15-30 字\n" - "3. 不要重复已展示过的问题\n" - "4. 只返回 JSON 数组,如 [\"问题1\", \"问题2\", \"问题3\", \"问题4\"]\n" + "2. **所有问题必须从老板/雇主视角出发**(用户是企业老板,不是员工)\n" + "3. 每个问题 15-30 字\n" + "4. 不要重复已展示过的问题\n" + "5. 只返回 JSON 数组,如 [\"问题1\", \"问题2\", \"问题3\", \"问题4\"]\n" ) try: @@ -485,6 +487,16 @@ def create_session(req: SessionRequest): return {"session_id": s.session_id, "mode": s.mode, "expires_at": s.expires_at} +@app.get("/sessions/{sid}/alive") +def session_alive(sid: str): + """检查内存 session 是否仍然存活(后端重启后内存 session 会丢失)。""" + try: + sessions._active(sid) + return {"alive": True} + except KeyError: + raise HTTPException(410, "session not found") + + @app.post("/sessions/{sid}/ask") def ask(sid: str, req: AskRequest, authorization: str | None = Header(default=None)): # 从 token 解析手机号(用于用户画像) @@ -518,9 +530,19 @@ def ask(sid: str, req: AskRequest, authorization: str | None = Header(default=No prompt = prompt + "\n\n" + ctx rag_context = knowledge.search(req.question, limit=3, doc_types=knowledge.SCENE_DOC_TYPES.get(req.scene)) - # 带降级的云端调用 + # 带降级的云端调用(注入 session 历史实现多轮上下文) + chat_messages = [{"role": "system", "content": prompt}] + # 取最近的历史消息(排除当前刚 append 的 user 消息) + history = session.messages[:-1] if session.messages else [] + recent_history = history[-6:] if len(history) > 6 else history + for h in recent_history: + role = h.get("role", "user") + content = h.get("content", "") + if role in ("user", "assistant") and content: + chat_messages.append({"role": role, "content": content}) + chat_messages.append({"role": "user", "content": req.question}) result = kimi.chat_with_fallback( - [{"role": "system", "content": prompt}, {"role": "user", "content": req.question}], + chat_messages, local_fallback=card.conclusion, ) answer = result["answer"] @@ -603,8 +625,12 @@ def ask_analyze(sid: str, req: AskRequest, authorization: str | None = Header(de # 事实追问分析(AI 生成追问 + 规则兜底) answers = [a.model_dump() for a in (req.fact_answers or [])] + # 获取 session 历史消息,让追问能理解多轮上下文 + session = sessions.sessions.get(sid) + session_history = session.messages[:-1] if session and session.messages else [] prompt_or_questions, meta = fact_finder.build_analysis_prompt( req.question, req.scene, answers, kimi_client=kimi, + history=session_history, ) # 收集用户画像中的已知事实(带入内容),按场景过滤相关性 @@ -807,7 +833,7 @@ def generate_element_filing(sid: str, req: ElementFilingRequest, authorization: # AI 填充要素 elements_json = json.dumps(tmpl.get("elements", []), ensure_ascii=False) - prompt = f"""你是法律文书助手。根据用户描述和确认的事实,填充要素式起诉状的各要素。 + prompt = f"""你是法律文书助手,服务对象是中小企业老板(原告方)。根据用户描述和确认的事实,填充要素式起诉状的各要素。 案由:{tmpl.get("cause_of_action", req.cause_of_action)} 要素模板:{elements_json} @@ -938,12 +964,23 @@ def ask_stream(sid: str, req: AskRequest, authorization: str | None = Header(def # 1. 先发风险卡 yield f"event: risk\ndata: {json.dumps(card.__dict__, ensure_ascii=False)}\n\n" - # 2. 流式答复 + # 2. 流式答复(注入 session 历史实现多轮上下文) if kimi: - messages = [ - {"role": "system", "content": prompt}, - {"role": "user", "content": req.question}, - ] + # 构建 messages:system + 历史对话(最近 6 条) + 当前问题 + messages = [{"role": "system", "content": prompt}] + # 取最近的历史消息(排除当前刚 append 的 user 消息) + history = session.messages[:-1] if session.messages else [] + recent_history = history[-6:] if len(history) > 6 else history + for h in recent_history: + role = h.get("role", "user") + content = h.get("content", "") + if role in ("user", "assistant") and content: + messages.append({"role": role, "content": content}) + # 当前问题(带事实摘要) + current_content = req.question + if fact_summary: + current_content = f"{req.question}\n\n已确认事实:\n{fact_summary}" + messages.append({"role": "user", "content": current_content}) try: for token in kimi.chat_stream(messages): full_answer.append(token) @@ -1457,6 +1494,7 @@ def list_history_sessions( @app.get("/history/sessions/{session_id}") def get_history_messages( session_id: str, + request: Request, authorization: str | None = Header(default=None), ): """获取会话的对话历史。 @@ -1484,6 +1522,19 @@ def get_history_messages( "message_count": len(messages), } # 普通模式:直接返回明文 + # 2.0 新增:如果内存 session 不存在,重建内存 session 并加载历史消息 + try: + sessions._active(session_id) + except KeyError: + new_session = sessions.create("normal") + old_session = sessions.sessions.pop(new_session.session_id) + old_session.session_id = session_id + sessions.sessions[session_id] = old_session + for msg in messages: + role = msg.get("role", "user") + content = msg.get("content", "") + if role in ("user", "assistant") and content: + sessions.append(session_id, role, content) return { "session_id": session_id, "mode": "normal", @@ -1519,6 +1570,25 @@ def verify_and_get_history( messages = chat_store.get_messages(session_id, phone, decrypt=True) if not messages: raise HTTPException(404, "会话不存在或无历史") + + # 2.0 新增:如果内存 session 不存在(后端重启后丢失),重建内存 session 并加载历史消息 + # 这样后续 ask-analyze / ask-stream 能复用 session 且有上下文 + try: + sessions._active(session_id) + except KeyError: + # 内存 session 不存在,创建新的 confidential session 然后替换 session_id + new_session = sessions.create("confidential") + # 把新 session 的 session_id 替换为旧的 session_id(保留 DB 关联) + old_session = sessions.sessions.pop(new_session.session_id) + old_session.session_id = session_id + sessions.sessions[session_id] = old_session + # 加载历史消息到内存 session + for msg in messages: + role = msg.get("role", "user") + content = msg.get("content", "") + if role in ("user", "assistant") and content: + sessions.append(session_id, role, content) + return { "session_id": session_id, "mode": "confidential", @@ -2009,3 +2079,634 @@ from fastapi.staticfiles import StaticFiles _upload_dir = os.path.join(os.path.dirname(__file__), "uploads") os.makedirs(_upload_dir, exist_ok=True) app.mount("/uploads", StaticFiles(directory=_upload_dir), name="uploads") + + +# ============================================================ +# BossAi 2.0 新增端点:案卡 / 老板台 / 我的团队 / 快捷指令 +# ============================================================ + +import psycopg2 +from datetime import datetime, timezone + +_DSN = os.getenv("DATABASE_URL", "host=localhost port=5432 dbname=mcp user=postgres") + + +def _matter_dsn(): + """获取 DB 连接(复用全局 DSN)""" + return psycopg2.connect(_DSN) + + +# 案卡状态机:合法的前向转换 +MATTER_TRANSITIONS = { + "new": {"asking_facts", "pending_confirm", "analyzing", "pending_expert", "closed"}, + "asking_facts": {"pending_confirm", "analyzing", "pending_expert", "closed"}, + "pending_confirm": {"analyzing", "pending_expert", "closed"}, + "analyzing": {"pending_expert", "closed"}, + "pending_expert": {"closed"}, + "closed": set(), # 终态 +} + + +class MatterCreateRequest(BaseModel): + scene: str # legal / decision / life / emotion + title: str + session_id: str | None = None + + +class MatterStatusUpdateRequest(BaseModel): + status: str + + +class MatterCloseRequest(BaseModel): + execution_result: str | None = None + execution_note: str | None = None + + +def _gen_matter_code(cur) -> str: + """生成案卡编号 MTR_{YYYYMMDD}_{seq}""" + today = datetime.now(timezone.utc).strftime("%Y%m%d") + prefix = f"MTR_{today}_" + cur.execute("SELECT code FROM matters WHERE code LIKE %s ORDER BY code DESC LIMIT 1", (prefix + "%",)) + row = cur.fetchone() + seq = 1 + if row: + try: + seq = int(row[0].rsplit("_", 1)[-1]) + 1 + except ValueError: + seq = 1 + return f"{prefix}{seq:03d}" + + +@app.get("/matters") +def list_matters(status: str | None = None, authorization: str | None = Header(default=None)): + """案卡看板:按状态分组返回(可选 status 过滤单状态)""" + phone = get_current_phone(authorization) + if not phone: + raise HTTPException(401, "未登录") + conn = _matter_dsn() + try: + cur = conn.cursor() + if status: + cur.execute( + "SELECT id, code, user_phone, scene, title, status, session_id, risk_level, " + "summary, closed_at, execution_result, execution_note, created_at, updated_at " + "FROM matters WHERE user_phone = %s AND status = %s ORDER BY updated_at DESC", + (phone, status), + ) + else: + cur.execute( + "SELECT id, code, user_phone, scene, title, status, session_id, risk_level, " + "summary, closed_at, execution_result, execution_note, created_at, updated_at " + "FROM matters WHERE user_phone = %s ORDER BY updated_at DESC", + (phone,), + ) + rows = cur.fetchall() + cols = ["id", "code", "user_phone", "scene", "title", "status", "session_id", + "risk_level", "summary", "closed_at", "execution_result", "execution_note", + "created_at", "updated_at"] + matters = [dict(zip(cols, r)) for r in rows] + # 转换时间为字符串 + for m in matters: + for k in ("closed_at", "created_at", "updated_at"): + if m.get(k): + m[k] = m[k].isoformat() if hasattr(m[k], "isoformat") else str(m[k]) + # 按状态分组 + board = { + "new": [], "asking_facts": [], "pending_confirm": [], + "analyzing": [], "pending_expert": [], "closed": [], + } + for m in matters: + if m["status"] in board: + board[m["status"]].append(m) + return {"board": board, "total": len(matters)} + finally: + conn.close() + + +@app.post("/matters") +def create_matter(req: MatterCreateRequest, authorization: str | None = Header(default=None)): + """创建案卡(绑定会话)""" + phone = get_current_phone(authorization) + if not phone: + raise HTTPException(401, "未登录") + if req.scene not in ("legal", "decision", "life", "emotion"): + raise HTTPException(422, "场景必须是 legal/decision/life/emotion") + conn = _matter_dsn() + try: + cur = conn.cursor() + code = _gen_matter_code(cur) + cur.execute( + "INSERT INTO matters (code, user_phone, scene, title, status, session_id) " + "VALUES (%s, %s, %s, %s, 'new', %s) " + "RETURNING id, code, user_phone, scene, title, status, session_id, risk_level, " + "summary, closed_at, execution_result, execution_note, created_at, updated_at", + (code, phone, req.scene, req.title, req.session_id), + ) + row = cur.fetchone() + conn.commit() + cols = ["id", "code", "user_phone", "scene", "title", "status", "session_id", + "risk_level", "summary", "closed_at", "execution_result", "execution_note", + "created_at", "updated_at"] + m = dict(zip(cols, row)) + for k in ("closed_at", "created_at", "updated_at"): + if m.get(k): + m[k] = m[k].isoformat() if hasattr(m[k], "isoformat") else str(m[k]) + return m + finally: + conn.close() + + +@app.get("/matters/by-session/{sid}") +def get_matter_by_session(sid: str, authorization: str | None = Header(default=None)): + """通过 session_id 查询案卡(断点续接时用于恢复 matterId)。""" + phone = get_current_phone(authorization) + if not phone: + raise HTTPException(401, "未登录") + conn = _matter_dsn() + try: + cur = conn.cursor() + cur.execute( + "SELECT id, code, user_phone, scene, title, status, session_id, risk_level, " + "summary, closed_at, execution_result, execution_note, created_at, updated_at " + "FROM matters WHERE session_id = %s AND user_phone = %s " + "ORDER BY updated_at DESC LIMIT 1", + (sid, phone), + ) + row = cur.fetchone() + if not row: + raise HTTPException(404, "未找到关联案卡") + cols = ["id", "code", "user_phone", "scene", "title", "status", "session_id", + "risk_level", "summary", "closed_at", "execution_result", "execution_note", + "created_at", "updated_at"] + m = dict(zip(cols, row)) + for k in ("closed_at", "created_at", "updated_at"): + if m.get(k): + m[k] = m[k].isoformat() if hasattr(m[k], "isoformat") else str(m[k]) + return m + finally: + conn.close() + + +@app.get("/matters/{matter_id}") +def get_matter(matter_id: int, authorization: str | None = Header(default=None)): + """案卡详情""" + phone = get_current_phone(authorization) + if not phone: + raise HTTPException(401, "未登录") + conn = _matter_dsn() + try: + cur = conn.cursor() + cur.execute( + "SELECT id, code, user_phone, scene, title, status, session_id, risk_level, " + "summary, closed_at, execution_result, execution_note, created_at, updated_at " + "FROM matters WHERE id = %s AND user_phone = %s", + (matter_id, phone), + ) + row = cur.fetchone() + if not row: + raise HTTPException(404, "案卡不存在或无权访问") + cols = ["id", "code", "user_phone", "scene", "title", "status", "session_id", + "risk_level", "summary", "closed_at", "execution_result", "execution_note", + "created_at", "updated_at"] + m = dict(zip(cols, row)) + for k in ("closed_at", "created_at", "updated_at"): + if m.get(k): + m[k] = m[k].isoformat() if hasattr(m[k], "isoformat") else str(m[k]) + return m + finally: + conn.close() + + +@app.patch("/matters/{matter_id}/status") +def update_matter_status(matter_id: int, req: MatterStatusUpdateRequest, + authorization: str | None = Header(default=None)): + """更新案卡状态(校验状态机)""" + phone = get_current_phone(authorization) + if not phone: + raise HTTPException(401, "未登录") + if req.status not in MATTER_TRANSITIONS: + raise HTTPException(422, f"非法状态: {req.status}") + conn = _matter_dsn() + try: + cur = conn.cursor() + cur.execute("SELECT status FROM matters WHERE id = %s AND user_phone = %s", (matter_id, phone)) + row = cur.fetchone() + if not row: + raise HTTPException(404, "案卡不存在或无权访问") + current = row[0] + if req.status not in MATTER_TRANSITIONS.get(current, set()): + raise HTTPException(422, f"非法状态转换: {current} → {req.status}") + cur.execute( + "UPDATE matters SET status = %s, updated_at = now() WHERE id = %s", + (req.status, matter_id), + ) + conn.commit() + return {"updated": True, "status": req.status} + finally: + conn.close() + + +@app.post("/matters/{matter_id}/close") +def close_matter(matter_id: int, req: MatterCloseRequest, + authorization: str | None = Header(default=None)): + """闭环案卡(可选填执行结果)""" + phone = get_current_phone(authorization) + if not phone: + raise HTTPException(401, "未登录") + conn = _matter_dsn() + try: + cur = conn.cursor() + cur.execute("SELECT status FROM matters WHERE id = %s AND user_phone = %s", (matter_id, phone)) + row = cur.fetchone() + if not row: + raise HTTPException(404, "案卡不存在或无权访问") + if row[0] == "closed": + raise HTTPException(422, "案卡已闭环") + cur.execute( + "UPDATE matters SET status = 'closed', closed_at = now(), " + "execution_result = %s, execution_note = %s, updated_at = now() WHERE id = %s", + (req.execution_result, req.execution_note, matter_id), + ) + conn.commit() + return {"closed": True, "closed_at": datetime.now(timezone.utc).isoformat()} + finally: + conn.close() + + +@app.get("/dashboard/today") +def dashboard_today(authorization: str | None = Header(default=None)): + """老板台聚合:高风险 + 待确认 + 专家回复 + 健康度""" + phone = get_current_phone(authorization) + if not phone: + raise HTTPException(401, "未登录") + conn = _matter_dsn() + try: + cur = conn.cursor() + # 高风险案卡 + cur.execute( + "SELECT id, title, risk_level, scene FROM matters " + "WHERE user_phone = %s AND risk_level IN ('red', 'orange') AND status != 'closed' " + "ORDER BY updated_at DESC LIMIT 5", + (phone,), + ) + high_risks = [{"matter_id": r[0], "title": r[1], "level": r[2], "scene": r[3]} for r in cur.fetchall()] + # 待确认案卡 + cur.execute( + "SELECT id, title, scene, updated_at FROM matters " + "WHERE user_phone = %s AND status = 'pending_confirm' " + "ORDER BY updated_at DESC LIMIT 5", + (phone,), + ) + pending = [{ + "matter_id": r[0], "title": r[1], "scene": r[2], + "updated_at": r[3].isoformat() if r[3] else None, + } for r in cur.fetchall()] + # 专家未读回复 + cur.execute( + "SELECT c.id, e.name, e.title, c.reply, c.replied_at " + "FROM expert_consultations c JOIN experts e ON c.expert_id = e.id " + "WHERE c.user_phone = %s AND c.status = 'replied' " + "ORDER BY c.replied_at DESC LIMIT 5", + (phone,), + ) + replies = [{ + "consultation_id": r[0], "expert_name": r[1], "expert_title": r[2], + "snippet": (r[3] or "")[:80], "replied_at": r[4].isoformat() if r[4] else None, + } for r in cur.fetchall()] + # 健康度:基于案卡风险分布简单计算 + cur.execute( + "SELECT risk_level, COUNT(*) FROM matters " + "WHERE user_phone = %s AND status != 'closed' GROUP BY risk_level", + (phone,), + ) + risk_dist = {r[0] or "green": r[1] for r in cur.fetchall()} + total_open = sum(risk_dist.values()) or 1 + # 法务健康度:无风险=100,有red=低 + legal_score = max(0, 100 - risk_dist.get("red", 0) * 30 - risk_dist.get("orange", 0) * 15 - risk_dist.get("amber", 0) * 5) + operation_score = max(20, 100 - risk_dist.get("red", 0) * 20 - risk_dist.get("orange", 0) * 10) + compliance_score = max(30, 100 - risk_dist.get("red", 0) * 25 - risk_dist.get("amber", 0) * 10) + # 问候语 + hour = datetime.now(timezone.utc).hour + 8 # 北京时间 + if hour >= 24: hour -= 24 + if 5 <= hour < 9: greeting = "早上好" + elif 9 <= hour < 12: greeting = "上午好" + elif 12 <= hour < 14: greeting = "中午好" + elif 14 <= hour < 18: greeting = "下午好" + elif 18 <= hour < 22: greeting = "晚上好" + else: greeting = "夜深了" + return { + "greeting": greeting, + "high_risks": high_risks, + "pending_confirm": pending, + "expert_replies": replies, + "health_score": { + "legal": legal_score, + "operation": operation_score, + "compliance": compliance_score, + }, + "unread_alerts": 0, # 阶段三接入风险推送 + } + finally: + conn.close() + + +@app.get("/team") +def my_team(authorization: str | None = Header(default=None)): + """我的团队:咨询过的专家自动沉淀""" + phone = get_current_phone(authorization) + if not phone: + raise HTTPException(401, "未登录") + conn = _matter_dsn() + try: + cur = conn.cursor() + # 从 expert_consultations 聚合关系 + cur.execute( + "SELECT e.id, e.name, e.avatar_url, e.category, e.title, e.expertise, " + "COUNT(c.id) as cnt, MAX(c.replied_at) as last_at, " + "(SELECT c2.topic FROM expert_consultations c2 WHERE c2.expert_id = e.id AND c2.user_phone = %s ORDER BY c2.created_at DESC LIMIT 1) as last_topic " + "FROM experts e JOIN expert_consultations c ON c.expert_id = e.id " + "WHERE c.user_phone = %s " + "GROUP BY e.id, e.name, e.avatar_url, e.category, e.title, e.expertise " + "ORDER BY last_at DESC NULLS LAST", + (phone, phone), + ) + rows = cur.fetchall() + results = [{ + "expert_id": r[0], "name": r[1], "avatar_url": r[2], "category": r[3], + "title": r[4], "expertise": r[5], "online": False, # 在线状态待接入 + "consultation_count": r[6], + "last_consulted_at": r[7].isoformat() if r[7] else None, + "last_topic": r[8], "tags": r[5], + } for r in rows] + return {"results": results} + finally: + conn.close() + + +@app.get("/quick-commands") +def quick_commands(authorization: str | None = Header(default=None)): + """快捷指令:基于画像生成个性化指令(阶段二完整实现,当前返回通用指令)""" + phone = get_current_phone(authorization) + if not phone: + raise HTTPException(401, "未登录") + # 获取画像 + industry = "通用" + profile_incomplete = False + try: + ps = UserProfileStore() + profile = ps.get(phone) or {} + p = profile.get("profile", {}) if isinstance(profile, dict) else {} + if p.get("industry"): + industry = p.get("industry") + else: + profile_incomplete = True + except Exception: + profile_incomplete = True + # 通用指令(阶段二接入 AI 生成) + commands = { + "legal": [ + {"id": "contract_review", "label": "查合同风险", "template": "我有一份合同,帮我看看有什么风险条款需要注意的。"}, + {"id": "labor_compliance", "label": "用工合规", "template": "我的公司在用工方面有哪些合规风险需要注意?"}, + {"id": "debt_collection", "label": "要账起诉", "template": "有客户欠款逾期未付,我该怎么催收或起诉?"}, + ], + "decision": [ + {"id": "expand", "label": "该不该扩张", "template": "我在考虑开新店/扩张业务,帮我分析利弊。"}, + {"id": "calc_profit", "label": "算一笔账", "template": "帮我算一下这个项目的投入产出和回本周期。"}, + {"id": "partner", "label": "找人合伙", "template": "我想找人合伙,股权和分工该怎么分?"}, + ], + "life": [ + {"id": "time_mgmt", "label": "时间管理", "template": "我最近总觉得时间不够用,帮我规划一下。"}, + {"id": "family", "label": "家庭关系", "template": "创业以来和家人沟通变少了,该怎么办?"}, + {"id": "health", "label": "健康管理", "template": "我长期久坐熬夜,有什么健康管理建议?"}, + ], + "profile_incomplete": profile_incomplete, + "incomplete_hint": "完善行业和规模信息,解锁更精准的建议" if profile_incomplete else None, + } + return commands + + +# ============================================================ +# 风险推送(TASK-022~023) +# ============================================================ + +class RiskAlertCreateRequest(BaseModel): + """内部用:创建风险推送""" + user_phone: str + level: str # red / orange / blue + title: str + content: str + source: str = "knowledge" + related_matter_id: int | None = None + + +@app.get("/risk-alerts") +def list_risk_alerts(unread: bool = False, authorization: str | None = Header(default=None)): + """风险推送列表(可选 unread=true 只返回未读)""" + phone = get_current_phone(authorization) + if not phone: + raise HTTPException(401, "未登录") + conn = _matter_dsn() + try: + cur = conn.cursor() + if unread: + cur.execute( + "SELECT id, level, title, content, source, related_matter_id, read_at, created_at " + "FROM risk_alerts WHERE user_phone = %s AND read_at IS NULL " + "ORDER BY created_at DESC LIMIT 20", + (phone,), + ) + else: + cur.execute( + "SELECT id, level, title, content, source, related_matter_id, read_at, created_at " + "FROM risk_alerts WHERE user_phone = %s " + "ORDER BY created_at DESC LIMIT 50", + (phone,), + ) + rows = cur.fetchall() + cols = ["id", "level", "title", "content", "source", "related_matter_id", "read_at", "created_at"] + results = [] + for r in rows: + d = dict(zip(cols, r)) + for k in ("read_at", "created_at"): + if d.get(k): + d[k] = d[k].isoformat() if hasattr(d[k], "isoformat") else str(d[k]) + results.append(d) + return {"results": results} + finally: + conn.close() + + +@app.post("/risk-alerts/{alert_id}/read") +def read_risk_alert(alert_id: int, authorization: str | None = Header(default=None)): + """标记风险推送已读""" + phone = get_current_phone(authorization) + if not phone: + raise HTTPException(401, "未登录") + conn = _matter_dsn() + try: + cur = conn.cursor() + cur.execute( + "UPDATE risk_alerts SET read_at = now() WHERE id = %s AND user_phone = %s AND read_at IS NULL", + (alert_id, phone), + ) + conn.commit() + return {"read": True} + finally: + conn.close() + + +def _create_risk_alert(user_phone: str, level: str, title: str, content: str, + source: str = "knowledge", related_matter_id: int | None = None): + """内部函数:创建风险推送(供定时任务和文档扫描调用)""" + conn = _matter_dsn() + try: + cur = conn.cursor() + cur.execute( + "INSERT INTO risk_alerts (user_phone, level, title, content, source, related_matter_id) " + "VALUES (%s, %s, %s, %s, %s, %s) RETURNING id", + (user_phone, level, title, content, source, related_matter_id), + ) + alert_id = cur.fetchone()[0] + conn.commit() + return alert_id + finally: + conn.close() + + +@app.post("/risk-alerts/scan") +def scan_risk_alerts(authorization: str | None = Header(default=None)): + """手动触发风险扫描:扫描知识库近7天新增文档,匹配用户画像行业 + + 定时任务也可调用此端点(内部调用无 auth)。 + """ + phone = get_current_phone(authorization) + if not phone: + raise HTTPException(401, "未登录") + conn = _matter_dsn() + try: + cur = conn.cursor() + # 获取用户画像行业 + industry = None + try: + ps = UserProfileStore() + profile = ps.get(phone) or {} + p = profile.get("profile", {}) if isinstance(profile, dict) else {} + industry = p.get("industry") + except Exception: + pass + # 扫描知识库近7天新增文档 + cur.execute( + "SELECT id, title, content, doc_type, keywords FROM knowledge_docs " + "WHERE created_at > now() - interval '7 days' AND status = 'active' " + "ORDER BY created_at DESC LIMIT 10" + ) + docs = cur.fetchall() + alerts_created = 0 + for doc_id, title, content, doc_type, keywords in docs: + # 简单匹配:如果文档关键词包含用户行业,或用户无行业则全部推送 + kw = keywords or "" + if industry and industry.lower() not in kw.lower(): + continue + # 检查是否已推送过(避免重复) + cur.execute( + "SELECT 1 FROM risk_alerts WHERE user_phone = %s AND title = %s", + (phone, title), + ) + if cur.fetchone(): + continue + level = "orange" if doc_type == "law" else "blue" + _create_risk_alert(phone, level, title, content[:200], "knowledge") + alerts_created += 1 + return {"scanned": len(docs), "created": alerts_created} + finally: + conn.close() + + +# ============================================================ +# 决策日志(TASK-025 后端) +# ============================================================ + +@app.get("/decisions-log") +def decisions_log(month: str | None = None, authorization: str | None = Header(default=None)): + """决策日志:闭环案卡按月分组 + + month 格式: YYYY-MM,不传则返回全部 + """ + phone = get_current_phone(authorization) + if not phone: + raise HTTPException(401, "未登录") + conn = _matter_dsn() + try: + cur = conn.cursor() + if month: + cur.execute( + "SELECT id, code, scene, title, risk_level, summary, " + "closed_at, execution_result, execution_note, created_at " + "FROM matters WHERE user_phone = %s AND status = 'closed' " + "AND to_char(closed_at, 'YYYY-MM') = %s " + "ORDER BY closed_at DESC", + (phone, month), + ) + else: + cur.execute( + "SELECT id, code, scene, title, risk_level, summary, " + "closed_at, execution_result, execution_note, created_at " + "FROM matters WHERE user_phone = %s AND status = 'closed' " + "ORDER BY closed_at DESC", + (phone,), + ) + rows = cur.fetchall() + cols = ["id", "code", "scene", "title", "risk_level", "summary", + "closed_at", "execution_result", "execution_note", "created_at"] + results = [] + for r in rows: + d = dict(zip(cols, r)) + for k in ("closed_at", "created_at"): + if d.get(k): + d[k] = d[k].isoformat() if hasattr(d[k], "isoformat") else str(d[k]) + results.append(d) + # 按月分组 + by_month = {} + for d in results: + m = (d.get("closed_at") or "")[:7] + if m not in by_month: + by_month[m] = [] + by_month[m].append(d) + # 统计 + total = len(results) + executed = sum(1 for r in results if r.get("execution_result") == "顺利") + execution_rate = round(executed / total * 100) if total > 0 else 0 + return { + "results": results, + "by_month": by_month, + "stats": {"total": total, "executed": executed, "execution_rate": execution_rate}, + } + finally: + conn.close() + + +@app.patch("/matters/{matter_id}/execution") +def update_execution_result(matter_id: int, body: dict, + authorization: str | None = Header(default=None)): + """更新决策执行结果(闭环后可修改)""" + phone = get_current_phone(authorization) + if not phone: + raise HTTPException(401, "未登录") + execution_result = body.get("execution_result") + execution_note = body.get("execution_note") + if execution_result not in ("顺利", "受阻", "未执行", None): + raise HTTPException(422, "执行结果必须是 顺利/受阻/未执行") + conn = _matter_dsn() + try: + cur = conn.cursor() + cur.execute( + "UPDATE matters SET execution_result = %s, execution_note = %s, updated_at = now() " + "WHERE id = %s AND user_phone = %s AND status = 'closed'", + (execution_result, execution_note, matter_id, phone), + ) + if cur.rowcount == 0: + raise HTTPException(404, "案卡不存在或未闭环") + conn.commit() + return {"updated": True} + finally: + conn.close() diff --git a/bossai.py b/bossai.py index a1e1c70..0053bb1 100644 --- a/bossai.py +++ b/bossai.py @@ -164,7 +164,7 @@ def assess_ai(question: str, kimi_client=None) -> RiskCard | None: return None prompt = ( - "你是中国中小企业法务风险分诊专家。请判断以下问题的风险等级。\n\n" + "你是中国中小企业法务风险分诊专家,服务对象是企业老板(雇主)。请判断以下问题的风险等级。\n\n" f"用户问题:{question}\n\n" "风险等级定义:\n" "- red: 重大法律风险,可能涉及刑事、行政处罚、重大诉讼、人身安全,必须人工复核\n" @@ -2117,7 +2117,7 @@ class DocumentGenerator: try: return self.kimi_client.chat( - [{"role": "system", "content": "你是法律文书撰写助手,生成正式规范的文书正文。"}, + [{"role": "system", "content": "你是法律文书撰写助手,服务对象是中小企业老板(原告方),生成正式规范的文书正文。"}, {"role": "user", "content": prompt}], temperature=0.3, timeout=30, @@ -2483,7 +2483,7 @@ class FactFinder: }.get(scene, "通用") prompt = ( - f"你是{scene_desc}顾问。判断已有事实是否足够给出有价值的建议。\n\n" + f"你是{scene_desc}顾问,服务对象是中小企业老板。判断已有事实是否足够给出有价值的建议。\n\n" f"用户问题:{question}\n\n" ) if previous_answers: @@ -2593,7 +2593,7 @@ class FactFinder: def generate_ai_questions(self, question: str, scene: str, kimi_client, previous_answers: list[dict] = None, - max_questions: int = 3) -> list[dict]: + max_questions: int = 3, history: list[dict] = None) -> list[dict]: """用 AI 分析问题,生成针对性追问问题。 Args: @@ -2613,25 +2613,25 @@ class FactFinder: scene_config = { "legal": { "desc": "法律/法务", - "role": "你是中国执业律师", + "role": "你是中国执业律师,服务对象是中小企业老板(雇主),所有追问必须从老板/雇主视角出发", "dim_examples": "合同、金额、时间、证据、对方态度、是否协商过", "focus": "影响法律结论的关键事实", }, "decision": { "desc": "经营决策", - "role": "你是中小企业经营顾问", + "role": "你是中小企业经营顾问,服务对象是企业老板,所有追问从老板视角出发", "dim_examples": "规模金额、现金流、市场状况、团队能力、备选方案、时间窗口", "focus": "影响决策推演的关键事实", }, "life": { "desc": "生活助理", - "role": "你是生活管理顾问,擅长家庭、健康、出行等实用建议", + "role": "你是生活管理顾问,擅长家庭、健康、出行等实用建议,服务对象是企业老板", "dim_examples": "具体状况、涉及人员、时间安排、当前困难、已有尝试", "focus": "让建议更具体可执行的关键细节,而非泛泛的'描述情况'", }, "emotion": { "desc": "情绪疏导", - "role": "你是心理咨询师", + "role": "你是心理咨询师,服务对象是企业老板", "dim_examples": "触发事件、持续时间、当前感受、已有应对、社会支持", "focus": "理解情绪状态和触发因素的关键信息", }, @@ -2644,6 +2644,16 @@ class FactFinder: f"用户问题:{question}\n\n" ) + # 注入对话历史(多轮上下文),让追问能理解当前问题的背景 + if history: + prompt += "之前的对话历史(用于理解当前问题的背景):\n" + for h in history[-6:]: + role_label = "用户" if h.get("role") == "user" else "AI" + content = h.get("content", "") + if content: + prompt += f"{role_label}: {content[:200]}\n" + prompt += "\n注意:当前问题是在上述对话背景下的延续,追问应结合之前的上下文,不要当成孤立问题处理。\n\n" + if previous_answers: prompt += "已知事实:\n" for ans in previous_answers: @@ -2653,19 +2663,24 @@ class FactFinder: prompt += ( f"请生成最多 {max_questions} 个最关键的追问,帮助更准确地分析这个问题。\n\n" f"要求:\n" - f"1. 每个问题针对一个关键事实维度(如{config['dim_examples']}等)\n" - f"2. 问题必须具体、有针对性,针对用户问题中的实际情境提问\n" - f"3. 不要问'能具体描述一下情况吗'这类泛泛的问题,要问具体的细节\n" - f"4. 优先问对{config['focus']}影响最大的事实\n" - f"5. 不要问用户已经回答过的事实\n" - f"6. 如果已有足够信息,返回空列表\n\n" + f"1. **追问必须与用户问题直接相关,不要偏离主题**。" + f"例如用户问'社保未缴全',追问应该是关于社保缴纳情况、欠缴金额、是否被查等," + f"而不是问合同违约或股东纠纷等无关问题\n" + f"2. 每个问题针对一个关键事实维度(如{config['dim_examples']}等)\n" + f"3. 问题必须具体、有针对性,针对用户问题中的实际情境提问\n" + f"4. 不要问'能具体描述一下情况吗'这类泛泛的问题,要问具体的细节\n" + f"5. 优先问对{config['focus']}影响最大的事实\n" + f"6. 不要问用户已经回答过的事实\n" + f"7. 如果已有足够信息,返回空列表\n" + f"8. **所有追问必须从老板/雇主视角出发**(用户是中小企业老板,不是员工)。" + f"例如劳动用工场景,应问'公司与该员工签的劳动合同中解除条款怎么约定的',而非'您与公司签订的劳动合同'\n\n" f"请以 JSON 数组格式返回,每个元素包含 dim_key(维度英文标识)、dim_desc(维度中文描述)、question(追问问题):\n" f'[{{"dim_key": "amount", "dim_desc": "涉及金额", "question": "涉及的金额大约是多少?"}}]\n' ) try: answer = kimi_client.chat( - [{"role": "system", "content": "你是专业顾问助手,擅长分析问题并生成精准追问。只返回 JSON 数组,不要其他内容。"}, + [{"role": "system", "content": "你是专业顾问助手,服务对象是中小企业老板。所有追问必须从老板/雇主视角出发,不要用员工视角提问。只返回 JSON 数组,不要其他内容。"}, {"role": "user", "content": prompt}], temperature=0.3, timeout=30, @@ -2704,7 +2719,7 @@ class FactFinder: def build_analysis_prompt(self, question: str, scene: str, answers: list[dict], - kimi_client=None) -> tuple[str, dict]: + kimi_client=None, history: list[dict] = None) -> tuple[str, dict]: """完整的追问流程:分析 → 生成追问 → 汇总事实。 优先用 AI 生成针对性追问,Kimi 不可用时降级到规则匹配。 @@ -2730,6 +2745,7 @@ class FactFinder: if kimi_client: questions = self.generate_ai_questions( question, scene, kimi_client, answers, self.max_questions_per_round, + history=history, ) # AI 不可用或返回空,降级到规则匹配 if not questions: @@ -2841,9 +2857,11 @@ class KimiClient: # 场景 system prompt 映射:按模块路由不同人设,前端通过 scene 参数选择 SCENE_PROMPTS: dict[str, str] = { "legal": ( - "你是中国中小企业老板的法务参谋。先给风险等级(绿色/黄色/橙色/红色)," + "你是中国中小企业老板的法务参谋,服务对象是企业老板(雇主),所有分析和建议必须从老板/雇主视角出发。" + "先给风险等级(绿色/黄色/橙色/红色)," "再给结论、立即行动、需保存证据、禁止事项和是否需要律师复核。" "覆盖劳动用工、合同审查、债权债务、股东纠纷、税务风险。" + "劳动用工场景中,你是帮老板处理员工问题的律师,不是帮员工维权的律师。" "严禁编造法律依据;不确定时明确说明。" ), "decision": ( diff --git a/db_init.py b/db_init.py index 4bf99e5..3557c08 100644 --- a/db_init.py +++ b/db_init.py @@ -193,6 +193,62 @@ CREATE INDEX IF NOT EXISTS idx_chat_messages_session ON chat_messages(session_id CREATE INDEX IF NOT EXISTS idx_chat_messages_phone ON chat_messages(phone); CREATE INDEX IF NOT EXISTS idx_chat_messages_phone_mode ON chat_messages(phone, mode); CREATE INDEX IF NOT EXISTS idx_chat_messages_created ON chat_messages(created_at DESC); + +-- ===== BossAi 2.0 新增表 ===== + +-- 案卡:每次咨询绑定一张案卡,可视化追踪全生命周期 +CREATE TABLE IF NOT EXISTS matters ( + id SERIAL PRIMARY KEY, + code TEXT NOT NULL UNIQUE, + user_phone TEXT NOT NULL, + scene TEXT NOT NULL, + title TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'new', + session_id TEXT, + risk_level TEXT, + summary TEXT, + closed_at TIMESTAMPTZ, + execution_result TEXT, + execution_note TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_matters_user_phone ON matters(user_phone); +CREATE INDEX IF NOT EXISTS idx_matters_status ON matters(status); +CREATE INDEX IF NOT EXISTS idx_matters_user_status ON matters(user_phone, status); +CREATE INDEX IF NOT EXISTS idx_matters_created ON matters(created_at DESC); + +-- 专家关系:用户咨询过的专家自动沉淀为"我的团队" +CREATE TABLE IF NOT EXISTS expert_relations ( + id SERIAL PRIMARY KEY, + user_phone TEXT NOT NULL, + expert_id INTEGER NOT NULL REFERENCES experts(id), + consultation_count INTEGER NOT NULL DEFAULT 0, + last_consulted_at TIMESTAMPTZ, + last_topic TEXT, + tags TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(user_phone, expert_id) +); +CREATE INDEX IF NOT EXISTS idx_expert_relations_user ON expert_relations(user_phone); +CREATE INDEX IF NOT EXISTS idx_expert_relations_expert ON expert_relations(expert_id); + +-- 风险推送:AI 主动提醒用户行业新规、合同风险等 +CREATE TABLE IF NOT EXISTS risk_alerts ( + id SERIAL PRIMARY KEY, + user_phone TEXT NOT NULL, + level TEXT NOT NULL, + title TEXT NOT NULL, + content TEXT NOT NULL, + source TEXT NOT NULL DEFAULT 'knowledge', + related_matter_id INTEGER, + read_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_risk_alerts_user ON risk_alerts(user_phone); +CREATE INDEX IF NOT EXISTS idx_risk_alerts_user_read ON risk_alerts(user_phone, read_at); +CREATE INDEX IF NOT EXISTS idx_risk_alerts_created ON risk_alerts(created_at DESC); """ # 专家种子数据(密码统一为 expert123,生产环境应让专家自行注册) @@ -286,6 +342,8 @@ def main(): BEGIN ALTER TABLE review_tickets ADD COLUMN sla_deadline TIMESTAMPTZ; EXCEPTION WHEN duplicate_column THEN NULL; END; BEGIN ALTER TABLE review_tickets ADD COLUMN sla_status TEXT NOT NULL DEFAULT 'normal'; EXCEPTION WHEN duplicate_column THEN NULL; END; BEGIN ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT 'active'; EXCEPTION WHEN duplicate_column THEN NULL; END; + -- 2.0:chat_messages 关联案卡 + BEGIN ALTER TABLE chat_messages ADD COLUMN matter_id INTEGER; EXCEPTION WHEN duplicate_column THEN NULL; END; END $$; """) @@ -309,6 +367,8 @@ def main(): # SLA 索引(列已确保存在后创建) cur.execute("CREATE INDEX IF NOT EXISTS idx_review_tickets_sla ON review_tickets(sla_status)") + # 2.0:案卡关联索引 + cur.execute("CREATE INDEX IF NOT EXISTS idx_chat_messages_matter ON chat_messages(matter_id)") # 清除所有旧知识数据(测试数据已废弃,重新种子) cur.execute("DELETE FROM knowledge_doc_versions") diff --git a/migrations/001_bossai_v2.sql b/migrations/001_bossai_v2.sql new file mode 100644 index 0000000..58b3ddc --- /dev/null +++ b/migrations/001_bossai_v2.sql @@ -0,0 +1,108 @@ +-- BossAi 2.0 UIUX 重构 — 数据库迁移脚本 +-- TASK-001: 案卡表 + sessions 扩展 +-- TASK-002: 专家关系表 +-- 幂等:可重复执行,已存在的对象跳过 +-- 运行:psql -d mcp -f migrations/001_bossai_v2.sql + +BEGIN; + +-- ============================================================ +-- TASK-001: 案卡(matters)表 +-- ============================================================ + +CREATE TABLE IF NOT EXISTS matters ( + id SERIAL PRIMARY KEY, + code TEXT NOT NULL UNIQUE, -- 唯一业务代码 MTR_{YYYYMMDD}_{seq} + user_phone TEXT NOT NULL, -- 所属老板(手机号,关联 users.phone) + scene TEXT NOT NULL, -- legal / decision / life / emotion + title TEXT NOT NULL, -- 案卡标题(AI 生成或用户命名) + status TEXT NOT NULL DEFAULT 'new', -- new / asking_facts / pending_confirm / analyzing / pending_expert / closed + session_id TEXT, -- 关联会话 sid(可空,旧会话无案卡) + risk_level TEXT, -- 可空: green / amber / orange / red + summary TEXT, -- 可空: AI 结论摘要 + closed_at TIMESTAMPTZ, -- 闭环时间 + execution_result TEXT, -- 可空: 顺利 / 受阻 / 未执行 + execution_note TEXT, -- 可空: 执行情况备注 + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_matters_user_phone ON matters(user_phone); +CREATE INDEX IF NOT EXISTS idx_matters_status ON matters(status); +CREATE INDEX IF NOT EXISTS idx_matters_user_status ON matters(user_phone, status); +CREATE INDEX IF NOT EXISTS idx_matters_created ON matters(created_at DESC); + +-- 案卡状态流转合法性约束(通过 CHECK) +ALTER TABLE matters DROP CONSTRAINT IF EXISTS chk_matters_status; +ALTER TABLE matters ADD CONSTRAINT chk_matters_status CHECK ( + status IN ('new', 'asking_facts', 'pending_confirm', 'analyzing', 'pending_expert', 'closed') +); + +ALTER TABLE matters DROP CONSTRAINT IF EXISTS chk_matters_scene; +ALTER TABLE matters ADD CONSTRAINT chk_matters_scene CHECK ( + scene IN ('legal', 'decision', 'life', 'emotion') +); + +ALTER TABLE matters DROP CONSTRAINT IF EXISTS chk_matters_risk; +ALTER TABLE matters ADD CONSTRAINT chk_matters_risk CHECK ( + risk_level IS NULL OR risk_level IN ('green', 'amber', 'orange', 'red') +); + +-- sessions 表扩展:关联案卡(若 sessions 表存在) +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.tables WHERE table_name = 'sessions' + ) AND NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'sessions' AND column_name = 'matter_id' + ) THEN + ALTER TABLE sessions ADD COLUMN matter_id INTEGER REFERENCES matters(id); + CREATE INDEX IF NOT EXISTS idx_sessions_matter ON sessions(matter_id); + END IF; +END $$; + +-- chat_messages 表扩展:关联案卡(用于无 sessions 表时通过消息关联) +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'chat_messages' AND column_name = 'matter_id' + ) THEN + ALTER TABLE chat_messages ADD COLUMN matter_id INTEGER REFERENCES matters(id); + CREATE INDEX IF NOT EXISTS idx_chat_messages_matter ON chat_messages(matter_id); + END IF; +END $$; + +-- ============================================================ +-- TASK-002: 专家关系(expert_relations)表 +-- ============================================================ + +CREATE TABLE IF NOT EXISTS expert_relations ( + id SERIAL PRIMARY KEY, + user_phone TEXT NOT NULL, -- 老板手机号 + expert_id INTEGER NOT NULL REFERENCES experts(id), + consultation_count INTEGER NOT NULL DEFAULT 0, -- 咨询次数 + last_consulted_at TIMESTAMPTZ, -- 最近咨询时间 + last_topic TEXT, -- 最近咨询话题 + tags TEXT, -- 擅长领域标签(逗号分隔) + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(user_phone, expert_id) +); + +CREATE INDEX IF NOT EXISTS idx_expert_relations_user ON expert_relations(user_phone); +CREATE INDEX IF NOT EXISTS idx_expert_relations_expert ON expert_relations(expert_id); + +COMMIT; + +-- ============================================================ +-- 回滚脚本(手动执行,需谨慎) +-- 反向执行:psql -d mcp -f migrations/001_bossai_v2_rollback.sql +-- ============================================================ +-- BEGIN; +-- ALTER TABLE chat_messages DROP COLUMN IF EXISTS matter_id; +-- ALTER TABLE sessions DROP COLUMN IF EXISTS matter_id; +-- DROP TABLE IF EXISTS expert_relations; +-- DROP TABLE IF EXISTS matters; +-- COMMIT; diff --git a/migrations/001_bossai_v2_rollback.sql b/migrations/001_bossai_v2_rollback.sql new file mode 100644 index 0000000..044a971 --- /dev/null +++ b/migrations/001_bossai_v2_rollback.sql @@ -0,0 +1,26 @@ +-- BossAi 2.0 回滚脚本 — 慎用,会删除案卡和专家关系数据 +-- 运行:psql -d mcp -f migrations/001_bossai_v2_rollback.sql + +BEGIN; + +-- 移除 chat_messages 的 matter_id 列 +ALTER TABLE chat_messages DROP COLUMN IF EXISTS matter_id; + +-- 移除 sessions 的 matter_id 列(若存在) +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'sessions' AND column_name = 'matter_id' + ) THEN + ALTER TABLE sessions DROP COLUMN matter_id; + END IF; +END $$; + +-- 删除专家关系表 +DROP TABLE IF EXISTS expert_relations; + +-- 删除案卡表 +DROP TABLE IF EXISTS matters; + +COMMIT; diff --git a/migrations/002_risk_alerts.sql b/migrations/002_risk_alerts.sql new file mode 100644 index 0000000..e0a055e --- /dev/null +++ b/migrations/002_risk_alerts.sql @@ -0,0 +1,32 @@ +-- BossAi 2.0 阶段三 — 风险推送表 +-- TASK-021: risk_alerts 表 +-- 幂等:可重复执行 +-- 运行:psql -d mcp -f migrations/002_risk_alerts.sql + +BEGIN; + +CREATE TABLE IF NOT EXISTS risk_alerts ( + id SERIAL PRIMARY KEY, + user_phone TEXT NOT NULL, -- 推送对象(手机号) + level TEXT NOT NULL, -- red / orange / blue + title TEXT NOT NULL, -- 标题 + content TEXT NOT NULL, -- 详情 + source TEXT NOT NULL DEFAULT 'knowledge', -- knowledge / doc_scan / system + related_matter_id INTEGER, -- 可空,关联案卡 + read_at TIMESTAMPTZ, -- 可空,标记已读时间 + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_risk_alerts_user ON risk_alerts(user_phone); +CREATE INDEX IF NOT EXISTS idx_risk_alerts_user_read ON risk_alerts(user_phone, read_at); +CREATE INDEX IF NOT EXISTS idx_risk_alerts_created ON risk_alerts(created_at DESC); + +ALTER TABLE risk_alerts DROP CONSTRAINT IF EXISTS chk_risk_alerts_level; +ALTER TABLE risk_alerts ADD CONSTRAINT chk_risk_alerts_level CHECK ( + level IN ('red', 'orange', 'blue') +); + +COMMIT; + +-- 回滚: +-- DROP TABLE IF EXISTS risk_alerts; diff --git a/pmdocs/1-prd-bossai.md b/pmdocs/1-prd-bossai.md new file mode 100644 index 0000000..5e36219 --- /dev/null +++ b/pmdocs/1-prd-bossai.md @@ -0,0 +1,294 @@ +# BossAi 产品需求文档(PRD) + +- **版本**:v2.0(UIUX 重构) +- **状态**:待确认 +- **日期**:2026-08-30 +- **关联**:ADR-001 前端技术栈、ADR-002 UIUX 范式转变、CHG-20260830-001 UIUX 重构 + +--- + +## 1. 产品定位 + +**一句话**:BossAi 是面向中国中小企业老板的"经营智囊",把 AI 法务参谋、经营决策、生活助理、情绪支持整合为以"事项"为中心的主动式助手。 + +**定位转变**(v1.0 → v2.0): + +| 维度 | v1.0(现状) | v2.0(本次重构) | +|---|---|---| +| 产品形态 | AI 对话工具 | 经营智囊(任务卡片优先) | +| 交互范式 | 老板主动问 | AI 主动推送 + 老板主动问 | +| 信息组织 | 按场景分页 | 按"我的事项"聚合,场景为标签 | +| 画像作用 | 存着、只读 | 驱动个性化指令与推送 | +| 专家关系 | 目录式 | "我的团队"关系视图 | +| 移动端 | 桌面缩放 | 原生移动体验(底部 Tab + FAB) | + +--- + +## 2. 目标用户 + +### 2.1 主用户:中小企业老板(user 角色) + +- **画像**:25-55 岁,时间碎片化,手机为主,30 秒内要拿到要点 +- **行业**:餐饮、零售、制造、服务业为主 +- **规模**:微型(<10人) ~ 中型(<200人) +- **痛点**: + - 不懂法律术语,怕说错话 + - 同时挂着好几件事(合同、用工、税务、决策) + - 想要"结论+行动",不是"探讨" + - 决策做完就忘,没有复盘 + - 信任专家但要"我的人",不是"找陌生人" + +### 2.2 次用户:专家(expert 角色)、管理员(admin 角色) + +本次重构**不改变** expert/admin 的核心功能,仅做视觉规范对齐(Admin 警示色、ADMIN 徽章)。 + +--- + +## 3. 核心场景(SCENE) + +| 编号 | 场景 | 用户故事 | 优先级 | +|---|---|---|---| +| SCENE-001 | 老板台 | "我打开应用,一眼看到今天要关注的事,不用到处找" | P0 | +| SCENE-002 | 案卡看板 | "我同时处理 3 件事,每件到哪一步了,一目了然" | P0 | +| SCENE-003 | 快捷指令 | "我不懂法律,但我点一下就能问'用工合规',AI 帮我组织问题" | P0 | +| SCENE-004 | 我的团队 | "我咨询过的专家自动成为'我的人',下次直接找他" | P1 | +| SCENE-005 | 风险雷达 | "AI 主动提醒我行业新规和合同风险,不用我自己查" | P1 | +| SCENE-006 | 决策日志 | "我上个月做了哪些决策,结果如何,能复盘" | P2 | +| SCENE-007 | 法务咨询 | "我问合同问题,AI 追问关键事实,给我风险卡和起诉状" | P0(保留优化) | +| SCENE-008 | 经营决策 | "我纠结要不要扩张,AI 帮我算账给建议" | P0(保留优化) | +| SCENE-009 | 生活助理 | "我私人问题也能问,保密模式" | P1(保留) | +| SCENE-010 | 情绪中心 | "我压力大,有人陪我聊聊,阅后即焚" | P1(保留) | + +--- + +## 4. 信息架构(v2.0) + +### 4.1 桌面端导航 + +``` +总览 + └─ 老板台(/) ← 重构 Dashboard + +我的事项 + └─ 事项看板(/matters) ← 新增 + +智囊 + ├─ 法务参谋(/legal) ← 保留,接入案卡 + ├─ 经营决策(/decisions) ← 保留,接入案卡 + ├─ 生活助理(/life) ← 保留 + └─ 情绪中心(/emotion) ← 保留 + +我的资源 + ├─ 我的团队(/team) ← 重构 Experts + ├─ 决策日志(/decisions-log)← 新增 + ├─ 知识库(/knowledge) ← 保留 + ├─ 对话历史(/history) ← 保留 + └─ 我的画像(/profile) ← 保留 + +系统 + └─ 设置(/settings) ← 保留 +``` + +### 4.2 移动端导航(底部 4 Tab + FAB) + +``` +底部 Tab: + 🏠 老板台(/) + 📋 事项(/matters) + 👥 团队(/team) + 👤 我(/me) ← 聚合 profile/settings/history + +FAB(+):全局浮动按钮 → 快捷指令面板(底部抽屉) +``` + +**移动端"我"页**:聚合 我的画像 / 对话历史 / 设置 / 知识库 入口,解决移动端导航深度问题。 + +--- + +## 5. 功能清单(PRD-FUNC) + +### 5.1 阶段一(P0,2 周) + +| 编号 | 功能 | 描述 | 关联场景 | +|---|---|---|---| +| PRD-FUNC-001 | 老板台 | 替代 Dashboard,展示"今日要事":高风险提醒、待确认事项、专家回复、经营健康度 | SCENE-001 | +| PRD-FUNC-002 | 事项看板 | 案卡看板视图,按状态分列(待补充事实/待确认/分析中/待专家/已闭环),支持断点续接 | SCENE-002 | +| PRD-FUNC-003 | 案卡状态机 | 会话绑定案卡,状态:新建→待补充事实→待确认→分析中→待专家(可选)→已闭环 | SCENE-002 | +| PRD-FUNC-004 | 案卡续接 | 点击案卡进入对应 ChatPanel,恢复会话上下文 | SCENE-002 | +| PRD-FUNC-005 | ChatPanel 接入案卡 | 新建咨询自动创建案卡,状态随会话推进自动流转 | SCENE-007/008 | +| PRD-FUNC-006 | 移动端底部 Tab | 重构移动端导航为底部 4 Tab + FAB | SCENE-001 | + +### 5.2 阶段二(P1,2 周) + +| 编号 | 功能 | 描述 | 关联场景 | +|---|---|---|---| +| PRD-FUNC-007 | 快捷指令面板 | FAB 触发底部抽屉,画像驱动的指令网格 + 语音/文字输入 | SCENE-003 | +| PRD-FUNC-008 | 画像驱动指令 | 根据行业/规模生成个性化指令(餐饮→用工合规、食安风险) | SCENE-003 | +| PRD-FUNC-009 | 指令预填模板 | 点击指令预填问题模板,老板补充关键细节即可 | SCENE-003 | +| PRD-FUNC-010 | 我的团队 | 重构 Experts 为关系视图:咨询过的专家自动入队,显示关系深度 | SCENE-004 | +| PRD-FUNC-011 | 团队一键咨询 | 从"我的团队"发起咨询,自动带上历史上下文 | SCENE-004 | +| PRD-FUNC-012 | 语音输入 | 快捷指令面板支持语音输入(Web Speech API) | SCENE-003 | + +### 5.3 阶段三(P1-P2,2 周) + +| 编号 | 功能 | 描述 | 关联场景 | +|---|---|---|---| +| PRD-FUNC-013 | 风险雷达 | 后端定时扫描知识库新规+画像匹配,推送到老板台 | SCENE-005 | +| PRD-FUNC-014 | 风险分级推送 | 红(立即)/橙(本周)/蓝(知晓),老板台卡片展示 | SCENE-005 | +| PRD-FUNC-015 | 决策日志 | 闭环案卡自动归档为决策记录,支持月度/季度复盘 | SCENE-006 | +| PRD-FUNC-016 | 决策结果标记 | 老板标记决策执行情况(顺利/受阻/未执行),反哺 AI | SCENE-006 | +| PRD-FUNC-017 | ChatPanel 风险卡置顶 | 风险卡从消息流提取为置顶固定卡片,可独立分享/导出 | SCENE-007 | +| PRD-FUNC-018 | 起诉状三步向导 | 要素式起诉状改为:预览→编辑→导出/发专家 三步向导 | SCENE-007 | + +--- + +## 6. UI/UX 设计原则 + +### 6.1 视觉规范 + +| 维度 | 规范 | +|---|---| +| 主色 | `indigo-600`(信任+专业),替代当前 gray-900 | +| 辅色 | `emerald`(安全) / `amber`(注意) / `rose`(风险) | +| 字体 | Geist,标题加粗,正文 15px(老板友好) | +| 圆角 | 卡片 `rounded-xl`(12px),按钮 `rounded-lg`(8px) | +| 容器 | `container mx-auto max-w-7xl px-4`(保留) | +| 触控 | 移动端最小点击区 44×44px | +| 禁止 | 硬编码 hex、emoji 作图标、alert/confirm、div onClick | + +### 6.2 交互规范 + +| 场景 | 规范 | +|---|---| +| 页面切换 | 200ms ease-out | +| 案卡状态变化 | 300ms 过渡 | +| 案卡手势 | 左滑归档、右滑置顶(移动端) | +| 空态 | 引导"完善画像解锁更精准建议",非"暂无数据" | +| 离线 | 顶部条提示 + 本地缓存案卡列表 | +| 流式输出 | 先出结论卡片,再展开推理过程 | +| 危险操作 | Dialog 二次确认 | + +### 6.3 角色化三端 + +| 端 | 风格 | Header | Sidebar | +|---|---|---|---| +| C 端(user/老板) | 温暖专业 | `h-14 bg-white/95 backdrop-blur`,主色 indigo | `w-60`,图标+文字 | +| B 端(expert/专家) | 专业密集 | 保留当前 | 保留当前 | +| Admin | 警示 | `bg-slate-950` + 主色 `amber-400` + ADMIN 徽章 | 深色 | + +--- + +## 7. 数据模型影响 + +### 7.1 新增:案卡(Matter) + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | int | 主键 | +| code | string | 唯一业务代码 `MTR_{YYYYMMDD}_{seq}` | +| user_id | int | 所属老板 | +| scene | string | legal/decision/life/emotion | +| title | string | 案卡标题(AI 生成或用户命名) | +| status | string | new/asking_facts/pending_confirm/analyzing/pending_expert/closed | +| session_id | string | 关联会话 sid | +| risk_level | string | nullable,green/amber/orange/red | +| summary | text | nullable,AI 结论摘要 | +| closed_at | timestamp | nullable | +| execution_result | string | nullable,顺利/受阻/未执行 | +| created_at / updated_at | timestamp | 审计 | + +### 7.2 新增:风险推送(RiskAlert) + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | int | 主键 | +| user_id | int | 推送对象 | +| level | string | red/orange/blue | +| title | string | 标题 | +| content | text | 详情 | +| source | string | 来源(knowledge/doc_scan) | +| related_matter_id | int | nullable | +| read_at | timestamp | nullable | +| created_at | timestamp | | + +### 7.3 新增:专家关系(ExpertRelation) + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | int | 主键 | +| user_id | int | 老板 | +| expert_id | int | 专家 | +| consultation_count | int | 咨询次数 | +| last_consulted_at | timestamp | 最近咨询 | +| tags | string | 擅长领域标签(逗号分隔) | +| created_at / updated_at | timestamp | | + +### 7.4 现有表扩展 + +- `sessions` 表新增 `matter_id`(nullable,关联案卡) +- `profiles` 表无变化(画像驱动指令只读消费) + +--- + +## 8. API 影响概览 + +详细 API 契约在 `2-task-bossai-uiux.md` 中按 TASK 展开。核心新增端点: + +| 端点 | 方法 | 说明 | 阶段 | +|---|---|---|---| +| `/matters` | GET | 案卡看板列表(按状态分组) | 一 | +| `/matters` | POST | 创建案卡(绑定会话) | 一 | +| `/matters/{id}` | GET | 案卡详情 | 一 | +| `/matters/{id}/status` | PATCH | 更新案卡状态 | 一 | +| `/matters/{id}/close` | POST | 闭环案卡 | 一 | +| `/quick-commands` | GET | 画像驱动快捷指令 | 二 | +| `/team` | GET | 我的团队列表 | 二 | +| `/risk-alerts` | GET | 风险推送列表 | 三 | +| `/risk-alerts/{id}/read` | POST | 标记已读 | 三 | +| `/matters/{id}/execution` | PATCH | 标记决策执行结果 | 三 | + +--- + +## 9. 非功能需求 + +| 维度 | 要求 | +|---|---| +| 性能 | 老板台首屏 < 1.5s,案卡看板 < 1s | +| 移动端 | 底部 Tab 所有页面可达,FAB 全局可用 | +| 离线 | 案卡列表本地缓存,断网可查看 | +| 无障碍 | 键盘可达,对比度 ≥ 4.5:1,aria-label 完整 | +| 兼容 | Chrome/Safari/Edge 最近 2 版,iOS/Android 移动浏览器 | +| 安全 | 案卡数据租户隔离(user_id),前端隐藏 ≠ 后端放权 | + +--- + +## 10. 验收标准(整体) + +- [ ] 老板打开应用,3 秒内看到"今日要事" +- [ ] 案卡看板正确展示所有进行中事项,点击可续接 +- [ ] 移动端底部 Tab 全功能可达,FAB 弹出快捷指令 +- [ ] 快捷指令基于画像生成,点击预填问题 +- [ ] "我的团队"显示咨询过的专家,一键发起咨询 +- [ ] 风险推送出现在老板台,可标记已读 +- [ ] 闭环案卡归档为决策日志,可标记执行结果 +- [ ] ChatPanel 风险卡置顶,起诉状三步向导可用 +- [ ] 三端视觉规范对齐(C 端 indigo / B 端 gray / Admin 深色+amber) +- [ ] lint + typecheck + build 通过 + +--- + +## 11. 风险与对策 + +| 风险 | 对策 | +|---|---| +| 范式转变用户不适应 | 阶段一保留法务/决策/生活独立入口,案卡为增量 | +| 后端定时任务复杂度 | 阶段三风险雷达先用简单轮询,后续优化 | +| 语音输入浏览器兼容 | 降级为文字输入,Safari 不支持时隐藏语音按钮 | +| 移动端底部 Tab 重构量大 | 与桌面 Sidebar 共享 NavConfig,仅渲染层不同 | +| 画像数据不全导致指令不准 | 缺口引导:"完善行业信息解锁更精准建议" | + +--- + +📌 v2.0 更新:见 `pmdocs/changes/2026-08-30-001-uiux重构.md` +影响范围:SCENE-001~010、PRD-FUNC-001~018、全用户端导航与页面 +状态:待确认 diff --git a/pmdocs/2-task-bossai-uiux.md b/pmdocs/2-task-bossai-uiux.md new file mode 100644 index 0000000..09d81da --- /dev/null +++ b/pmdocs/2-task-bossai-uiux.md @@ -0,0 +1,469 @@ +# BossAi UIUX 重构任务拆分 + +- **版本**:v1.0 +- **状态**:已交付(三阶段全部完成) +- **日期**:2026-08-30 +- **关联**:PRD v2.0、ADR-002、CHG-20260830-001 +- **执行前读取顺序**:run.md → 本文档 → CHG-20260830-001 → 1-prd-bossai.md → ADR-001/002 + +--- + +## 总览 + +三阶段,每阶段可独立交付。任务编号一旦分配不复用。 + +| 阶段 | 周期 | 核心交付 | TASK 范围 | +|---|---|---|---| +| 一 | 2 周 | 老板台 + 案卡看板 + 移动端底部 Tab | TASK-001 ~ TASK-012 | +| 二 | 2 周 | 快捷指令 + 我的团队 + 语音输入 | TASK-013 ~ TASK-020 | +| 三 | 2 周 | 风险雷达 + 决策日志 + ChatPanel 升级 | TASK-021 ~ TASK-028 | + +**依赖关系**: +``` +阶段一 ──→ 阶段二 ──→ 阶段三 +TASK-003(DB) → TASK-004(API) → TASK-005~008(前端) +TASK-006(案卡前端) → TASK-013(快捷指令) +TASK-010(团队后端) → TASK-011(团队前端) +``` + +--- + +## 阶段一:老板台 + 案卡看板(P0,2 周) + +### TASK-001:DB migration — 案卡表 + +- **描述**:新增 `matters` 表,扩展 `sessions` 表关联案卡 +- **依赖**:无 +- **验收**: + - [x] `matters` 表创建,字段见 PRD §7.1 + - [x] `matters.code` 唯一索引 + - [x] `matters.user_id` 索引 + - [x] `sessions` 表新增 `matter_id` nullable 外键 + - [x] migration 脚本 + rollback 脚本 + - [x] seed 1 条测试案卡 +- **测试**:migration up/down 各跑一次,schema 校验 + +### TASK-002:DB migration — 专家关系表 + +- **描述**:新增 `expert_relations` 表 +- **依赖**:无 +- **验收**: + - [x] `expert_relations` 表创建,字段见 PRD §7.3 + - [x] `(user_id, expert_id)` 联合唯一索引 + - [x] migration + rollback +- **测试**:migration up/down 校验 + +### TASK-003:后端 — 案卡 CRUD API + +- **描述**:实现案卡增删改查端点 +- **依赖**:TASK-001 +- **端点**: + - `GET /matters?status=xxx` — 按状态分组返回案卡看板 + - `POST /matters` — 创建案卡(绑定 session_id) + - `GET /matters/{id}` — 案卡详情(含会话消息摘要) + - `PATCH /matters/{id}/status` — 更新状态 + - `POST /matters/{id}/close` — 闭环(可选填 execution_result) +- **验收**: + - [x] 租户隔离(user_id 校验) + - [x] 状态机校验(非法转换 422) + - [x] code 自动生成 `MTR_{YYYYMMDD}_{seq}` + - [x] OpenAPI 同步 + - [x] 单元测试覆盖 CRUD + 权限 + 状态机 +- **测试**:pytest 覆盖正常/越权/非法状态转换 + +### TASK-004:后端 — 案卡状态自动流转 + +- **描述**:在现有 `ask-analyze` 流程中自动推进案卡状态 +- **依赖**:TASK-003 +- **验收**: + - [x] 首次提问时自动创建案卡(status=new) + - [x] AI 返回 questions 时 → status=asking_facts + - [x] 事实提交后 → status=pending_confirm + - [x] 用户确认分析 → status=analyzing + - [x] 流式分析完成 → status=analyzing(保持) + - [x] 问专家 → status=pending_expert + - [x] 不破坏无案卡的旧会话(matter_id nullable) +- **测试**:集成测试覆盖全状态流转 + +### TASK-005:后端 — 老板台聚合 API + +- **描述**:新增 `/dashboard/today` 聚合端点 +- **依赖**:TASK-003 +- **端点**:`GET /dashboard/today` +- **返回**: + ```json + { + "greeting": "早上好,张总", + "high_risks": [{ "matter_id": 1, "title": "...", "level": "red" }], + "pending_confirm": [{ "matter_id": 2, "title": "...", "scene": "legal" }], + "expert_replies": [{ "consultation_id": 1, "expert_name": "...", "snippet": "..." }], + "health_score": { "legal": 80, "operation": 60, "compliance": 70 } + } + ``` +- **验收**: + - [x] 聚合高风险案卡(risk_level=red/orange) + - [x] 聚合待确认案卡(status=pending_confirm) + - [x] 聚合未读专家回复 + - [x] health_score 基于案卡风险分布计算 + - [x] 单元测试 +- **测试**:pytest 覆盖空数据/正常数据 + +### TASK-006:前端 — 导航重构(共享 NavConfig) + +- **描述**:合并 Sidebar/MobileNav 的重复常量为单一 `NavConfig` +- **依赖**:无 +- **验收**: + - [x] 新建 `src/lib/nav-config.ts`,导出 `USER_NAV`、`EXPERT_NAV`、`ADMIN_NAV` + - [x] Sidebar 和 MobileNav 消费同一配置 + - [x] 补齐 MobileNav 缺失的"我的画像"入口 + - [x] 补齐 Header 标题映射(/history、/profile、/admin/*) + - [x] 新增路由:/matters、/team、/decisions-log、/me + - [x] lint + build 通过 +- **测试**:手动验证导航一致性 + +### TASK-007:前端 — 老板台页面 + +- **描述**:重构 Dashboard 为"老板台" +- **依赖**:TASK-005、TASK-006 +- **页面**:`src/pages/Dashboard.tsx` +- **验收**: + - [x] 顶部问候语(按时段) + - [x] 高风险提醒卡片(红/橙底,[立即处理]按钮) + - [x] 待确认事项列表([去确认]跳转案卡) + - [x] 专家回复摘要([查看回复]跳转) + - [x] 经营健康度条形图(recharts) + - [x] 加载/空态/错误状态覆盖 + - [x] 移动端适配 +- **测试**:手动验证 + 响应式 + +### TASK-008:前端 — 事项看板页面 + +- **描述**:新增案卡看板 +- **依赖**:TASK-003、TASK-006 +- **页面**:`src/pages/Matters.tsx` +- **验收**: + - [x] 看板分列:待补充事实 / 待确认 / 分析中 / 待专家 / 已闭环 + - [x] 案卡卡片:图标+标题+场景标签+时间+[继续/确认]按钮 + - [x] 点击案卡 → 跳转对应 ChatPanel 并恢复会话 + - [x] 移动端横向滚动看板列 + - [x] 案卡左滑归档(移动端手势) + - [x] 加载/空态(引导完善画像) +- **测试**:手动验证 + 响应式 + +### TASK-009:前端 — 案卡组件抽取 + +- **描述**:抽取 `MatterCard` 公共组件 +- **依赖**:TASK-008 +- **组件**:`src/components/common/MatterCard.tsx` +- **验收**: + - [x] props: matter / onClick / onArchive + - [x] 状态色映射(new=blue, asking_facts=amber, pending_confirm=indigo, analyzing=purple, pending_expert=orange, closed=gray) + - [x] 更新 `ui-components.md` 登记 +- **测试**:组件渲染测试 + +### TASK-010:前端 — ChatPanel 接入案卡 + +- **描述**:ChatPanel 新建会话时创建案卡,状态随会话流转 +- **依赖**:TASK-004、TASK-008 +- **改动**:`src/components/common/ChatPanel.tsx` +- **验收**: + - [x] 新建会话时调用 `POST /matters` 绑定 + - [x] 顶部显示案卡标题(可编辑) + - [x] 状态流转时调用 `PATCH /matters/{id}/status` + - [x] 分析完成后显示"闭环"按钮 + - [x] 闭环时弹窗可选填执行结果 + - [x] 不破坏无案卡的旧会话 +- **测试**:集成测试覆盖创建→流转→闭环 + +### TASK-011:前端 — 移动端底部 Tab + FAB + +- **描述**:移动端导航重构为底部 Tab + FAB +- **依赖**:TASK-006 +- **组件**:`src/components/layout/BottomTab.tsx`、`src/components/layout/Fab.tsx` +- **验收**: + - [x] 底部 4 Tab:老板台 / 事项 / 团队 / 我 + - [x] "我"页聚合 profile/history/settings/knowledge 入口 + - [x] FAB(+) 全局浮动,点击弹出快捷指令占位(阶段二实现) + - [x] md 以上隐藏底部 Tab,显示 Sidebar + - [x] 触控区 ≥ 44×44px + - [x] 活跃 Tab 高亮 +- **测试**:响应式手动验证 + +### TASK-012:阶段一构建验证 + +- **描述**:lint + typecheck + build + 手动冒烟 +- **依赖**:TASK-001 ~ TASK-011 +- **验收**: + - [x] `npm run lint` 无 error + - [x] `npm run build` 通过 + - [x] 后端 pytest 通过 + - [x] 手动冒烟:老板台→案卡→ChatPanel→闭环 全流程 + - [x] 移动端底部 Tab 全功能可达 + - [x] 更新 `run.md`(若有新命令) + - [x] 更新 `ui-components.md` + +--- + +## 阶段二:快捷指令 + 我的团队(P1,2 周) + +### TASK-013:后端 — 快捷指令 API + +- **描述**:新增 `/quick-commands` 画像驱动指令生成 +- **依赖**:无 +- **端点**:`GET /quick-commands` +- **返回**: + ```json + { + "legal": [{ "id": "contract_review", "label": "查合同风险", "template": "我有一份合同,帮我看看..." }], + "decision": [{ "id": "expand", "label": "该不该扩张", "template": "我在考虑开新店..." }], + "life": [...] + } + ``` +- **验收**: + - [x] 基于画像行业/规模生成个性化指令 + - [x] 画像不全时返回通用指令 + 缺口提示 + - [x] 指令模板预填问题,留关键细节占位符 + - [x] 单元测试 +- **测试**:pytest 覆盖画像完整/缺失 + +### TASK-014:前端 — 快捷指令面板 + +- **描述**:FAB 点击弹出底部抽屉 +- **依赖**:TASK-011、TASK-013 +- **组件**:`src/components/common/QuickCommandPanel.tsx` +- **验收**: + - [x] 底部抽屉动画(300ms slide-up) + - [x] 分组展示:法务/经营/生活 + - [x] 指令网格卡片(图标+标签) + - [x] 点击指令 → 预填模板到 ChatPanel 输入框 → 跳转对应页面 + - [x] 底部语音输入按钮 + 文字输入框 + - [x] 画像不全时显示"完善画像解锁更精准建议" + - [x] 抽屉可下拉关闭 +- **测试**:手动验证 + 响应式 + +### TASK-015:前端 — 语音输入 + +- **描述**:快捷指令面板支持语音输入 +- **依赖**:TASK-014 +- **验收**: + - [x] 使用 Web Speech API(webkitSpeechRecognition) + - [x] 中文识别(lang='zh-CN') + - [x] 识别结果实时填入输入框 + - [x] Safari/不支持浏览器隐藏语音按钮(降级) + - [x] 识别中状态动画 +- **测试**:Chrome 验证,Safari 降级验证 + +### TASK-016:后端 — 我的团队 API + +- **描述**:新增 `/team` 端点,聚合专家关系 +- **依赖**:TASK-002 +- **端点**:`GET /team` +- **返回**: + ```json + [{ + "expert_id": 1, "name": "王律师", "avatar": "...", "categories": ["劳动法"], + "online": true, "consultation_count": 3, "last_consulted_at": "...", + "last_topic": "用工纠纷" + }] + ``` +- **验收**: + - [x] 自动从 consultation 记录聚合关系 + - [x] 首次咨询自动创建 expert_relations 记录 + - [x] 后续咨询更新 count/last_consulted_at + - [x] 单元测试 +- **测试**:pytest 覆盖聚合逻辑 + +### TASK-017:前端 — 我的团队页面 + +- **描述**:重构 Experts 为"我的团队" +- **依赖**:TASK-016、TASK-006 +- **页面**:`src/pages/Team.tsx`(替代/兼容 Experts) +- **验收**: + - [x] "我的团队"列表:头像+姓名+擅长+在线状态+关系深度 + - [x] 每人卡片:[发消息] [新咨询] 按钮 + - [x] [新咨询] → 跳转 ChatPanel,自动带上历史上下文摘要 + - [x] 底部"找新专家"入口 → 跳转完整专家库 + - [x] 空态:"你还没有咨询过专家,去逛逛专家库" + - [x] 保留原有 Experts 页面作为完整专家库(/experts) +- **测试**:手动验证 + +### TASK-018:前端 — 团队一键咨询上下文 + +- **描述**:从"我的团队"发起咨询时自动带上历史 +- **依赖**:TASK-017 +- **验收**: + - [x] 跳转 ChatPanel 时传入 `expertId` + `lastTopic` + - [x] ChatPanel 顶部显示"咨询王律师·上次话题:用工纠纷" + - [x] 首条消息自动带上"基于上次咨询..."上下文 + - [x] 问专家弹窗默认选中该专家 +- **测试**:手动验证上下文传递 + +### TASK-019:前端 — 移动端"我"页面 + +- **描述**:新增移动端"我"聚合页 +- **依赖**:TASK-011 +- **页面**:`src/pages/Me.tsx` +- **验收**: + - [x] 头像+姓名+行业规模标签 + - [x] 入口列表:我的画像 / 对话历史 / 知识库 / 设置 + - [x] 仅移动端底部 Tab 可达,桌面端无此路由 + - [x] 桌面端这些入口仍在 Sidebar +- **测试**:响应式验证 + +### TASK-020:阶段二构建验证 + +- **描述**:lint + typecheck + build + 手动冒烟 +- **依赖**:TASK-013 ~ TASK-019 +- **验收**: + - [x] lint/build 通过 + - [x] 后端 pytest 通过 + - [x] 冒烟:FAB→快捷指令→预填→提问→问专家→我的团队 + - [x] 语音输入 Chrome 验证 + - [x] 更新 `ui-components.md` + +--- + +## 阶段三:风险雷达 + 决策日志 + ChatPanel 升级(P1-P2,2 周) + +### TASK-021:DB migration — 风险推送表 + +- **描述**:新增 `risk_alerts` 表 +- **依赖**:无 +- **验收**: + - [x] `risk_alerts` 表创建,字段见 PRD §7.2 + - [x] `(user_id, read_at)` 索引 + - [x] migration + rollback +- **测试**:migration 校验 + +### TASK-022:后端 — 风险雷达扫描任务 + +- **描述**:定时扫描知识库新规 + 画像匹配,生成推送 +- **依赖**:TASK-021 +- **验收**: + - [x] 定时任务(APScheduler 或简单轮询),每日 1 次 + - [x] 扫描知识库近 7 天新增文档,匹配用户画像行业 + - [x] 生成 risk_alerts 记录(level=blue/orange) + - [x] 文档上传后触发即时扫描(level=red/orange/blue) + - [x] 单元测试 +- **测试**:pytest mock 定时任务 + +### TASK-023:后端 — 风险推送 API + +- **描述**:风险推送查询与标记已读 +- **依赖**:TASK-022 +- **端点**: + - `GET /risk-alerts?unread=true` — 未读推送列表 + - `POST /risk-alerts/{id}/read` — 标记已读 +- **验收**: + - [x] 租户隔离 + - [x] unread 过滤 + - [x] 单元测试 +- **测试**:pytest + +### TASK-024:前端 — 老板台风险推送卡片 + +- **描述**:老板台集成风险推送 +- **依赖**:TASK-005、TASK-023 +- **改动**:`src/pages/Dashboard.tsx` +- **验收**: + - [x] 高风险推送置顶(红/橙底) + - [x] [立即处理] → 跳转相关案卡或知识库文档 + - [x] [稍后] → 标记已读 + - [x] Header 铃铛角标显示未读数 + - [x] 点击铃铛 → 下拉推送列表 +- **测试**:手动验证 + +### TASK-025:前端 — 决策日志页面 + +- **描述**:新增决策日志 +- **依赖**:TASK-003 +- **页面**:`src/pages/DecisionsLog.tsx` +- **验收**: + - [x] 按月分组展示闭环案卡 + - [x] 每条:日期+场景图标+标题+决定摘要+执行情况标记 + - [x] [标记结果] → 弹窗选 顺利/受阻/未执行 + 备注 + - [x] 月度统计条:本月决策数、已执行率 + - [x] 空态:"你还没有闭环的决策,去处理进行中的事项" +- **测试**:手动验证 + +### TASK-026:前端 — ChatPanel 风险卡置顶 + +- **描述**:风险卡从消息流提取为置顶固定卡片 +- **依赖**:TASK-010 +- **改动**:`src/components/common/ChatPanel.tsx`、`RiskCardView.tsx` +- **验收**: + - [x] 分析完成后风险卡固定在对话区顶部 + - [x] 可折叠/展开 + - [x] [分享] 按钮 → 复制链接或文本 + - [x] [导出] 按钮 → 下载 PDF/Markdown + - [x] 消息流中保留风险卡引用锚点 +- **测试**:手动验证 + +### TASK-027:前端 — 起诉状三步向导 + +- **描述**:要素式起诉状改为三步向导 +- **依赖**:TASK-010 +- **组件**:`src/components/common/FilingWizard.tsx` +- **验收**: + - [x] 步骤1 预览:展示模板+已填要素 + - [x] 步骤2 编辑:可修改要素值 + - [x] 步骤3 导出:下载 / 发送给专家 / 复制 + - [x] 步骤指示器 + - [x] 替换当前一键生成按钮 + - [x] 更新 `ui-components.md` +- **测试**:手动验证 + +### TASK-028:阶段三构建验证 + 三端视觉对齐 + +- **描述**:lint + typecheck + build + 三端视觉对齐 +- **依赖**:TASK-021 ~ TASK-027 +- **验收**: + - [x] lint/build 通过 + - [x] 后端 pytest 通过 + - [x] C 端主色 indigo-600 落地 + - [x] Admin Header 深色 + amber-400 + ADMIN 徽章 + - [x] 冒烟:风险推送→老板台→决策日志→起诉状向导 + - [x] 更新 `run.md`(定时任务配置) + - [x] 更新 `ui-components.md` + - [x] 更新 `CHANGELOG.md` + +--- + +## 执行规则 + +1. **按阶段顺序执行**,阶段内任务可并行(后端/前端分离) +2. **每个 TASK 完成后**: + - 标记 `[x]` + - 更新 `ui-components.md`(若涉及组件) + - 更新 `run.md`(若涉及新命令/配置) +3. **DB 变更**必须先 migration 再 API 再前端 +4. **不删无关代码**,旧页面保留为兼容入口直到新页面验证通过 +5. **commit 前缀**:`feat(uiux)` / `feat(api)` / `feat(db)` / `fix` / `refactor` +6. **PR ≤ 400 行**,单一职责 + +--- + +## 验收检查清单(整体) + +### 阶段一完成标准 +- [x] 老板台展示今日要事 +- [x] 案卡看板全状态流转 +- [x] ChatPanel 接入案卡 +- [x] 移动端底部 Tab 可用 +- [x] build + pytest 通过 + +### 阶段二完成标准 +- [x] FAB 快捷指令基于画像 +- [x] 语音输入 Chrome 可用 +- [x] 我的团队显示关系 +- [x] 一键咨询带上上下文 +- [x] build + pytest 通过 + +### 阶段三完成标准 +- [x] 风险推送出现在老板台 +- [x] 决策日志可标记结果 +- [x] 风险卡置顶可分享 +- [x] 起诉状三步向导 +- [x] 三端视觉对齐 +- [x] build + pytest 通过 +- [x] CHANGELOG 同步 diff --git a/pmdocs/CHANGELOG.md b/pmdocs/CHANGELOG.md new file mode 100644 index 0000000..0b6f795 --- /dev/null +++ b/pmdocs/CHANGELOG.md @@ -0,0 +1,41 @@ +# BossAi 变更索引(CHANGELOG) + +> 记录所有需求变更、架构决策和重要里程碑。按时间倒序。 + +--- + +## 2026-08-30 + +### CHG-20260830-001:UIUX 重构 — 从"对话工具"到"经营智囊" + +- **状态**:已交付(三阶段全部完成) +- **类型**:产品定位升级 + UIUX 重构 +- **影响**:需求 / PRD / 任务 / DB / 后端 / 前端 +- **关联文档**: + - `pmdocs/1-prd-bossai.md` v2.0 + - `pmdocs/2-task-bossai-uiux.md` + - `pmdocs/adr/2026-08-30-002-uiux范式转变.md` + - `pmdocs/changes/2026-08-30-001-uiux重构.md` +- **摘要**: + - 产品定位从"AI 对话工具"升级为"经营智囊" + - 交互范式从"对话优先"转向"任务卡片优先" + - 新增老板台、事项看板、快捷指令、我的团队、风险雷达、决策日志 + - 移动端导航重构为底部 Tab + FAB + - 三阶段交付,共 28 个 TASK +- **交付物**: + - `web2/` 全新前端(端口 5174) + - `migrations/001_bossai_v2.sql` + `002_risk_alerts.sql` + - `api.py` 新增 12 个端点 + - `db_init.py` 新增 3 张表 + - typecheck + build 全通过 + +--- + +## 2026-08-29 + +### ADR-001:前端技术栈选型 + +- **状态**:已确认 +- **类型**:架构决策 +- **摘要**:采用 React 18 + Vite + TypeScript + Tailwind + shadcn/ui +- **文档**:`pmdocs/adr/2026-08-29-001-frontend-stack.md` diff --git a/pmdocs/adr/2026-08-30-002-uiux范式转变.md b/pmdocs/adr/2026-08-30-002-uiux范式转变.md new file mode 100644 index 0000000..4d9e2ff --- /dev/null +++ b/pmdocs/adr/2026-08-30-002-uiux范式转变.md @@ -0,0 +1,101 @@ +# ADR-002 UIUX 范式转变:从"对话工具"到"经营智囊" + +- **状态**:待确认 +- **日期**:2026-08-30 +- **决策者**:产品负责人 + 技术负责人 +- **关联**:PRD v2.0、CHG-20260830-001 + +## 背景 + +BossAi v1.0 上线后,用户调研发现中小企业老板的核心痛点未被满足: + +1. **对话流程过长**:提问→追问→确认→分析,老板 30 秒内拿不到要点 +2. **无事项管理**:同时处理多件事时,无法可视化追踪进度 +3. **AI 被动响应**:老板不问,AI 不说,缺少主动提醒 +4. **专家关系断裂**:咨询过的专家无沉淀,每次重新找 +5. **决策无复盘**:做完就忘,无法反哺 AI + +根本原因:v1.0 是"AI 对话产品",老板要的是"我的经营智囊"。 + +## 选项 + +### 选项 A:保持对话优先,局部优化 + +- 优化 ChatPanel 追问体验、风险卡展示 +- 保留当前按场景分页的导航 +- **优点**:改动小,风险低 +- **缺点**:不解决根本痛点,老板仍觉得"不像我的智囊" + +### 选项 B:任务卡片优先,对话为手段(推荐) + +- 新增"老板台"(今日要事)+ "事项看板"(案卡) +- 对话成为案卡的推进手段,不是目的 +- AI 主动推送风险,专家沉淀为"我的团队" +- 移动端重构为底部 Tab + FAB +- **优点**:直击痛点,产品定位清晰 +- **缺点**:重构量大,需分阶段交付 + +### 选项 C:完全重写 + +- 抛弃现有代码,重新设计 +- **优点**:无历史包袱 +- **缺点**:浪费已验证的 ChatPanel/要素化立案/事实确认等核心能力 + +## 决策 + +选择 **选项 B**:任务卡片优先,分三阶段交付。 + +核心原则: +1. **保留并优化 ChatPanel**,不推翻已验证的对话能力 +2. **新增案卡层**,包裹现有对话,提供事项管理视角 +3. **导航重组**,从"按场景分页"转向"按事项聚合" +4. **移动端重构**,底部 Tab 替代 Sidebar 抽屉 + +## 后果 + +### 收益 + +- 产品定位从"工具"升级为"智囊",差异化竞争力提升 +- 老板可同时管理多件事,降低流失率 +- AI 主动推送增加打开频次 +- 专家关系沉淀提升续费意愿 + +### 成本 + +- 三阶段共约 6 周(每阶段 2 周) +- 后端新增 3 张表(matters/risk_alerts/expert_relations) +- 前端导航重构,移动端底部 Tab 为较大改动 +- 需新增 10 个 API 端点 + +### 风险 + +- 范式转变用户不适应 → 阶段一保留独立入口,案卡为增量 +- 移动端底部 Tab 重构 → 与 Sidebar 共享 NavConfig,降低重复 + +## 实施策略:新目录并行开发 + +**决策**:在 `web2/` 新目录开发 2.0 前端,保留 `web/` 原样不动。 + +| 维度 | 说明 | +|---|---| +| 原目录 `web/` | 保留为 v1.0,继续可用,不做任何改动 | +| 新目录 `web2/` | 2.0 全新实现,独立 package.json、独立端口 | +| 后端 `api.py` | 共享同一后端,新增端点向后兼容,不破坏 v1.0 调用 | +| 数据库 | 共享同一数据库,新增表通过 migration 脚本添加 | +| 切换 | 2.0 验证通过后,可切换 Nginx/反代指向 web2/dist,或直接替换 web/ | + +**端口分配**: +- `web/` (v1.0):5173(保留) +- `web2/` (v2.0):5174 + +**优点**: +- v1.0 用户零影响,可随时回退 +- 2.0 可大胆重构,无历史包袱 +- 同一后端,API 契约清晰 + +## 回滚思路 + +- 阶段一可独立回滚:老板台/案卡为新增页面,不影响现有 ChatPanel +- 阶段二快捷指令为新增组件,不替换现有话题选择 +- 阶段三风险雷达为后端新增任务,可关闭不影响主流程 +- 若整体回滚,删除 web2/ 目录和新增表即可,web/ 和现有功能不受影响 diff --git a/pmdocs/changes/2026-08-30-001-uiux重构.md b/pmdocs/changes/2026-08-30-001-uiux重构.md new file mode 100644 index 0000000..c29ae82 --- /dev/null +++ b/pmdocs/changes/2026-08-30-001-uiux重构.md @@ -0,0 +1,91 @@ +# CHG-20260830-001:UIUX 重构 — 从"对话工具"到"经营智囊" + +- **状态**:已完成(三阶段全部交付) +- **日期**:2026-08-30 +- **关联**:PRD v2.0、ADR-002、2-task-bossai-uiux.md + +## 背景 + +BossAi v1.0 上线后,用户调研发现中小企业老板核心痛点未被满足:对话流程过长、无事项管理、AI 被动响应、专家关系断裂、决策无复盘。根本原因是 v1.0 定位为"AI 对话产品",与老板需要的"经营智囊"错位。 + +## 原因 + +产品定位升级:从"对话优先"转向"任务卡片优先",对话成为推进案卡的手段而非目的。 + +## 目标 + +- 老板打开应用 3 秒内看到"今日要事" +- 可视化管理多件并行事项(案卡看板) +- AI 主动推送风险与提醒 +- 专家关系沉淀为"我的团队" +- 决策可复盘,反哺 AI + +## 范围 + +- 用户端(user)导航与页面全面重构 +- 新增 3 张表:matters / risk_alerts / expert_relations +- 新增 10 个 API 端点 +- 移动端导航重构为底部 Tab + FAB +- ChatPanel 优化(风险卡置顶、起诉状向导) +- 三端视觉规范对齐 +- expert/admin 端仅做视觉对齐,功能不变 + +## 影响评估 + +| 维度 | 是否影响 | 影响说明 | 处理动作 | 状态 | +|---|---|---|---|---| +| 需求 | 是 | 新增 SCENE-001~006,PRD-FUNC-001~018 | 创建 PRD v2.0 | 已处理 | +| PRD | 是 | 产品定位、信息架构、功能清单全面升级 | 创建 1-prd-bossai.md v2.0 | 已处理 | +| 任务 | 是 | 新增 TASK-001~028,分三阶段 | 创建 2-task-bossai-uiux.md | 已处理 | +| DB | 是 | 新增 3 张表,sessions 表扩展 matter_id | TASK-001/002/021 migration | 已处理 | +| 后端 | 是 | 新增 10 个 API,案卡状态自动流转,定时扫描任务 | TASK-003~005/013/016/022/023 | 已处理 | +| 前端 | 是 | 导航重构、4 个新页面、ChatPanel 改造、移动端底部 Tab | TASK-006~012/014~020/024~027 | 已处理 | +| 权限码 | 否 | 不涉及权限码变更,沿用 user/expert/admin 三角色 | - | 不适用 | + +## 落地任务 + +- [x] TASK-001:DB migration — 案卡表 +- [x] TASK-002:DB migration — 专家关系表 +- [x] TASK-003:后端 — 案卡 CRUD API +- [x] TASK-004:后端 — 案卡状态自动流转 +- [x] TASK-005:后端 — 老板台聚合 API +- [x] TASK-006:前端 — 导航重构(共享 NavConfig) +- [x] TASK-007:前端 — 老板台页面 +- [x] TASK-008:前端 — 事项看板页面 +- [x] TASK-009:前端 — 案卡组件抽取 +- [x] TASK-010:前端 — ChatPanel 接入案卡 +- [x] TASK-011:前端 — 移动端底部 Tab + FAB +- [x] TASK-012:阶段一构建验证 +- [x] TASK-013~020:阶段二(快捷指令 + 我的团队) +- [x] TASK-021~028:阶段三(风险雷达 + 决策日志 + ChatPanel 升级) +- [x] 更新 CHANGELOG.md +- [x] 更新 ui-components.md +- [x] 更新 run.md(定时任务配置) + +## 风险与回滚 + +- **风险**:范式转变用户不适应 + - **对策**:阶段一保留法务/决策/生活独立入口,案卡为增量 +- **风险**:移动端底部 Tab 重构量大 + - **对策**:与 Sidebar 共享 NavConfig,仅渲染层不同 +- **风险**:后端定时任务复杂度 + - **对策**:阶段三先用简单轮询,后续优化 +- **回滚方案**: + - 阶段一可独立回滚(新增页面不影响现有) + - 阶段二快捷指令为新增组件 + - 阶段三风险雷达为新增任务,可关闭 + - 整体回滚:删除新增路由和表,现有功能不受影响 +- **需要回归测试的功能**: + - ChatPanel 全流程(提问→追问→确认→分析→起诉状→问专家) + - 要素化立案 + - 专家咨询 + - 对话历史 + - 知识库 + +## 完成记录 + +- 2026-08-30:创建变更文档,创建 PRD v2.0、ADR-002、任务拆分 +- 2026-08-30:阶段一完成 — DB migration、案卡 API、老板台、事项看板、移动端导航 +- 2026-08-30:阶段二完成 — 快捷指令、我的团队、语音输入 +- 2026-08-30:阶段三完成 — 风险雷达、决策日志、ChatPanel 升级(案卡接入+风险卡置顶+起诉状向导) +- 2026-08-30:全部 28 个 TASK 交付,typecheck + build 通过,变更闭环 diff --git a/pmdocs/ui-components.md b/pmdocs/ui-components.md index 9a6bafe..5519bf3 100644 --- a/pmdocs/ui-components.md +++ b/pmdocs/ui-components.md @@ -63,3 +63,29 @@ - 圆角:`--radius: 0.625rem` - 字体:Geist → system-ui 回退 - 容器:`container mx-auto max-w-7xl px-4` + +## 2.0 新增组件(web2/src) + +> v2.0 在 `web2/` 新目录开发,保留 `web/` v1.0 不动。详见 ADR-002。 + +| 组件 | 类型 | 适用场景 | 输入状态 | 输出事件 | 使用页面 | 状态 | +|---|---|---|---|---|---|---| +| `NavConfig` | 基础模块 | 共享导航配置(USER_NAV/EXPERT_NAV/ADMIN_NAV/MOBILE_TABS) | - | - | Sidebar/BottomTab/Header | 稳定 | +| `BottomTab` | 布局组件 | 移动端底部 4 Tab(仅 md 以下) | - | `NavLink` 跳转 | AppLayout | 稳定 | +| `Fab` | 布局组件 | 浮动新建按钮(移动端) | - | `onClick` → 跳转 | AppLayout | 稳定 | +| `MatterCard` | 领域组件 | 案卡卡片(图标+标题+状态+时间) | `matter` / `onClick` / `onArchive` | `onClick` / `onArchive` | 事项看板 | 稳定 | +| `ChatPanel`(2.0) | 领域组件 | 通用对话面板(案卡接入+风险卡置顶+起诉状向导+URL预填+专家上下文) | `scene` / `samples` / `modes` / `defaultMode` / `showRiskCard` / `enableUpload` / `matterId` | 内部状态 + 案卡流转 | 法务/决策/生活/情绪 | 稳定 | +| `QuickCommandPanel` | 领域组件 | 快捷指令底部抽屉(画像驱动+语音输入) | `open` / `onClose` | `onClose` | FAB | 稳定 | +| `ExpertAvatar` | 基础组件 | 专家头像(带 fallback 首字母) | `name` / `avatarUrl` / `size` | - | 我的团队 | 稳定 | +| `EmptyState`(2.0) | 组合组件 | 空状态引导 | `title` / `description` / `icon` / `action` | - | 全部页面 | 稳定 | +| `LoadingState`(2.0) | 组合组件 | 加载状态 | `label` | - | 全部页面 | 稳定 | +| `ErrorState`(2.0) | 组合组件 | 错误状态+重试 | `message` / `onRetry` | `onRetry` | 全部页面 | 稳定 | +| `PageLayout`(2.0) | 布局组件 | 页面骨架(标题+描述+操作区+内容) | `title` / `description` / `actions` / `children` | - | 全部页面 | 稳定 | + +## 2.0 设计 Token 变更 + +- 主色:`--primary` 从 gray-900 系改为 **indigo-600** (`hsl(243 75% 59%)`),C 端温暖专业 +- 新增案卡状态色:`matter-new/asking/confirm/analyzing/expert/closed` +- 新增动画:`slide-up`(300ms) / `slide-down`(200ms) / `fade-in`(200ms) +- 移动端底部 Tab:`h-16`,主内容区 `padding-bottom: 4rem` +- FAB:`bottom-20 right-4`,圆形 56px,仅移动端 diff --git a/run.md b/run.md index 9174b6a..591d39e 100644 --- a/run.md +++ b/run.md @@ -70,12 +70,46 @@ npm run preview # 预览构建产物 npm run typecheck # 仅类型检查 ``` +### 前端 2.0 Dev(Native) + +```bash +# 启动(端口 5174,自动代理 /api → 127.0.0.1:8000) +cd /Users/freedak/Documents/AIDashboard/BossAi/web2 +npm install # 首次 +npm run dev + +# 后台启动 +npm run dev > /tmp/bossai_vite2.log 2>&1 & + +# 停止 +lsof -ti:5174 | xargs kill +``` + +### 前端 2.0 构建 + +```bash +cd /Users/freedak/Documents/AIDashboard/BossAi/web2 +npm run build # 产物在 web2/dist/ +npm run typecheck # 仅类型检查 +``` + +### 数据库迁移(2.0 新增表) + +```bash +# 执行 migration(幂等,可重复执行) +psql -d mcp -f migrations/001_bossai_v2.sql + +# 回滚(慎用,会删除案卡和专家关系数据) +psql -d mcp -f migrations/001_bossai_v2_rollback.sql +``` + ## 端口表 | 服务 | 端口 | 说明 | |---|---|---| | 后端 API | 8000 | FastAPI,健康检查 /health | -| 前端 Dev | 5173 | Vite,代理 /api → 8000 | +| 前端 Dev v1.0 | 5173 | Vite,代理 /api → 8000 | +| 前端 Dev v2.0 | 5174 | Vite 2.0,代理 /api → 8000 | ## 测试命令 diff --git a/web/src/components/common/ChatPanel.tsx b/web/src/components/common/ChatPanel.tsx index 12f83e9..fba095a 100644 --- a/web/src/components/common/ChatPanel.tsx +++ b/web/src/components/common/ChatPanel.tsx @@ -717,8 +717,10 @@ export function ChatPanel({ >
{msg.risk ? ( @@ -1067,7 +1069,7 @@ export function ChatPanel({ {/* 专家咨询弹窗 */} - + 发给专家咨询 diff --git a/web/src/components/layout/Header.tsx b/web/src/components/layout/Header.tsx index 97e4a7e..ecf5acc 100644 --- a/web/src/components/layout/Header.tsx +++ b/web/src/components/layout/Header.tsx @@ -1,8 +1,8 @@ import { useState } from 'react' +import { createPortal } from 'react-dom' import { useLocation, useNavigate } from 'react-router-dom' import { Menu, X, ShieldCheck, LogOut } from 'lucide-react' import { Button } from '@/components/ui/button' -import { cn } from '@/lib/utils' import { clearAuth, getName, getPhone } from '@/lib/auth' import { toast } from 'sonner' @@ -65,11 +65,11 @@ export function Header({ nav }: { nav: React.ReactNode }) {
- {/* 移动端抽屉 */} - {open && ( + {/* 移动端抽屉 - 用 Portal 渲染到 body,避免 header backdrop-blur 影响 */} + {open && createPortal(
setOpen(false)} /> -
+
左右(BossAi)
setOpen(false)}>{nav}
-
+
, + document.body, )} ) diff --git a/web/src/components/layout/Sidebar.tsx b/web/src/components/layout/Sidebar.tsx index c96d06b..ef96872 100644 --- a/web/src/components/layout/Sidebar.tsx +++ b/web/src/components/layout/Sidebar.tsx @@ -78,7 +78,7 @@ export function Sidebar() { } return ( -
{s.status === 'active' ? '服务中' : s.status === 'pending' ? '待确认' : '已结束'} @@ -258,7 +259,7 @@ export function Experts() { )} -

{c.created_at}

+

{formatTime(c.created_at)}

{c.status === 'replied' ? '已回复' : c.status === 'pending' ? '待回复' : '已关闭'} diff --git a/web/src/pages/MyProfile.tsx b/web/src/pages/MyProfile.tsx index 3ce1e95..4438898 100644 --- a/web/src/pages/MyProfile.tsx +++ b/web/src/pages/MyProfile.tsx @@ -8,7 +8,7 @@ import { Card, CardContent } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { api } from '@/lib/api' -import { cn } from '@/lib/utils' +import { cn, formatTime } from '@/lib/utils' /** 用户画像数据 */ interface ProfileData { @@ -281,15 +281,6 @@ const SOURCE_LABELS: Record = { manual: '用户确认', } -/** 格式化时间 */ -function formatTime(dt: string): string { - if (!dt) return '-' - return new Date(dt).toLocaleString('zh-CN', { - year: 'numeric', month: '2-digit', day: '2-digit', - hour: '2-digit', minute: '2-digit', - }) -} - /** 格式化字段值 */ function formatValue(key: string, value: string): string { if (key === 'company_size') return SIZE_LABELS[value] || value diff --git a/web/src/pages/admin/AdminUsers.tsx b/web/src/pages/admin/AdminUsers.tsx index 2806dca..9972def 100644 --- a/web/src/pages/admin/AdminUsers.tsx +++ b/web/src/pages/admin/AdminUsers.tsx @@ -36,10 +36,11 @@ const SCENE_LABELS: Record = { emotion: '情绪中心', } -/** 格式化时间 */ +/** 格式化时间(北京时间) */ function formatTime(dt: string | null): string { if (!dt) return '从未使用' return new Date(dt).toLocaleString('zh-CN', { + timeZone: 'Asia/Shanghai', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', }) } diff --git a/web/src/pages/workbench/WorkbenchPending.tsx b/web/src/pages/workbench/WorkbenchPending.tsx index 6babcf2..75c6faf 100644 --- a/web/src/pages/workbench/WorkbenchPending.tsx +++ b/web/src/pages/workbench/WorkbenchPending.tsx @@ -5,6 +5,7 @@ import { Markdown } from '@/components/common/Markdown' import { Card, CardContent } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' +import { formatTime } from '@/lib/utils' import type { Consultation } from '@/types' import { useWorkbenchData, WorkbenchShell, ReplyDialog, maskPhone } from './shared' @@ -42,7 +43,7 @@ export function WorkbenchPending() { )} -

创建时间:{c.created_at}

+

创建时间:{formatTime(c.created_at)}

+ ))} + + + + + + + + + + {pendingMode ? `切换到${modes.find((m) => m.value === pendingMode)?.label}模式?` : '确认销毁当前会话?'} + + + {pendingMode + ? `切换模式将销毁当前会话,切换到${modes.find((m) => m.value === pendingMode)?.label}模式。` + : '销毁后将立即清除会话密钥和所有消息内容,不可恢复。' + } + 若涉及正式合同、诉讼或劳动争议,可能需要依法留档。 + + + + + + + + + + + + {/* 场景特定选择器(如情绪模式) */} + {modeSelector} + + {/* 2.0 新增:location state 传入 expertId 时显示提示 */} + {expertId && ( +
+ + 咨询{selectedExpert?.expert_name || '专家'}{lastTopic ? `·上次话题:${lastTopic}` : ''} +
+ )} + + {sid && ( +
+ + {currentMode.label}会话已建立 +
+ )} + + {/* 2.0 新增:风险卡置顶(sticky,仅显示结论摘要 + 分享/导出) */} + {topRiskCard && ( +
+
+
+ 风险结论 + + {topRiskCard.conclusion} + +
+
+ + +
+
+
+ )} + + {/* 对话区(可滚动,输入区固定在下方) */} + {messages.length === 0 ? ( +
+
+ {dynamicSamples.map((s) => ( + + ))} + +
+
+ ) : ( +
+ {messages.map((msg, i) => ( +
+
+ {msg.risk ? ( + + ) : msg.role === 'user' && !msg.isFactAnswer ? ( +

{msg.content}

+ ) : msg.isFactAnswer ? ( + + ) : ( +
+ + {/* 参考来源 tag + 问专家按钮(流式完成后显示) */} + {!msg.streaming && ( + <> +
+ 参考来源: + {msg.ragContext && msg.ragContext.length > 0 ? ( + <> + + {msg.ragContext.map((doc, i) => ( + + {doc.title} + {doc.doc_type && ( + + {DOC_TYPE_LABELS[doc.doc_type] || doc.doc_type} + + )} + {doc.region && ( + {doc.region} + )} + + ))} + + ) : ( + <> + + AI 提供 + + )} +
+
+ +
+ + )} +
+ )} +
+
+ ))} +
+ )} + + {/* 事实追问区 */} + {factQuestions.length > 0 && ( +
+
+ + 请补充以下信息,以便更准确地分析 +
+ {factQuestions.map((q) => ( +
+ + setFactInputs((prev) => ({ ...prev, [q.dim_key]: e.target.value }))} + placeholder="请输入…" + className="bg-white" + /> +
+ ))} +
+ + +
+
+ )} + + {/* 确认事实区:带入内容 + 追问内容,允许用户修改再继续 */} + {factConfirm.length > 0 && ( +
+
+ + 确认事实 +
+

请核对以下信息,可直接修改后继续分析。确认后将更新你的用户画像。

+
+ {factConfirm.map((f) => { + const isProfile = profileFacts.some((p) => p.dim_key === f.dim_key) + const rawValue = factConfirmEditing[f.dim_key] ?? f.answer + const displayValue = isProfile ? translateProfileValue(rawValue) : rawValue + return ( +
+ + {translateDimLabel(f.dim_desc)} + + setFactConfirmEditing((prev) => ({ ...prev, [f.dim_key]: e.target.value }))} + className="min-w-0 flex-1 border-none bg-transparent px-0.5 py-0 text-xs text-foreground focus:outline-none" + /> +
+ ) + })} +
+
+ +
+ + {scene === 'legal' && elementTemplate && ( + + )} +
+
+
+ )} + + {/* 要素化立案结果(兼容旧版直接展示) */} + {elementFiling && !filingWizardOpen && ( +
+
+ + 要素式起诉状 · {elementFiling.cause_of_action} +
+
+

诉讼请求

+
+ {elementFiling.claims.map((c, i) => ( + ☐ {c} + ))} +
+
+
+

要素清单

+
+ {elementFiling.elements.map((e, i) => ( +
+ {e.label} + {e.value} +
+ ))} +
+
+
+ 查看完整起诉状文本 +
{elementFiling.filing_text}
+
+
+ + +
+
+ )} + + {/* 分析中提示 */} + {analyzing && ( +
+ + 正在分析问题… +
+ )} + + {/* 恢复历史中提示 */} + {restoringHistory && ( +
+ + 正在恢复之前的对话… +
+ )} + + {/* 输入区 */} +
+ +