diff --git a/api.py b/api.py index 5aab851..7eba9a9 100644 --- a/api.py +++ b/api.py @@ -76,7 +76,7 @@ class Metrics: } -app = FastAPI(title="BossAi MVP", version="1.1.0") +app = FastAPI(title="左右(BossAi)", version="1.1.0") sessions = SessionManager() knowledge = KnowledgeStore() settings_store = SettingsStore() @@ -264,7 +264,7 @@ class SummarySaveRequest(BaseModel): @app.get("/health") def health(): - return {"status": "ok", "service": "bossai-mvp", "version": "1.1.0", "db": "connected"} + return {"status": "ok", "service": "左右(BossAi)", "version": "1.1.0", "db": "connected"} class SampleRequest(BaseModel): @@ -434,6 +434,7 @@ def ask(sid: str, req: AskRequest, authorization: str | None = Header(default=No 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}") @@ -492,6 +493,27 @@ def ask_analyze(sid: str, req: AskRequest, authorization: str | None = Header(de req.question, req.scene, answers, kimi_client=kimi, ) + # 收集用户画像中的已知事实(带入内容) + profile_facts: list[dict] = [] + if req.phone: + try: + profile = profile_store.get(req.phone) + if profile.industry: + profile_facts.append({"dim_key": "industry", "dim_desc": "行业", "answer": profile.industry}) + if profile.company_size: + profile_facts.append({"dim_key": "company_size", "dim_desc": "企业规模", "answer": profile.company_size}) + if profile.role: + profile_facts.append({"dim_key": "role", "dim_desc": "角色", "answer": profile.role}) + if profile.risk_tolerance: + profile_facts.append({"dim_key": "risk_tolerance", "dim_desc": "风险偏好", "answer": profile.risk_tolerance}) + if profile.decision_style: + profile_facts.append({"dim_key": "decision_style", "dim_desc": "决策风格", "answer": profile.decision_style}) + if profile.key_facts: + for k, v in profile.key_facts.items(): + 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, @@ -501,6 +523,7 @@ def ask_analyze(sid: str, req: AskRequest, authorization: str | None = Header(de "question_type": meta["analysis"]["question_type"], "risk_level": card.level, "source": meta.get("source", "rule"), + "profile_facts": profile_facts, } else: return { @@ -510,6 +533,7 @@ def ask_analyze(sid: str, req: AskRequest, authorization: str | None = Header(de "fact_summary": meta["fact_summary"], "question_type": meta["analysis"]["question_type"], "risk_level": card.level, + "profile_facts": profile_facts, } @@ -786,6 +810,56 @@ def get_my_profile(authorization: str | None = Header(default=None)): } +@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") @@ -1194,18 +1268,71 @@ async def admin_upload_expert_avatar(expert_id: int, file: UploadFile = File(... @app.get("/admin/users") -def admin_list_users(): - """管理端:列出所有注册用户。""" +def admin_list_users(q: str | None = None, limit: int = 50): + """管理端:列出所有注册用户,支持搜索,含对话统计和状态。""" with user_store._conn() as conn: with conn.cursor() as cur: - cur.execute( - "SELECT id, phone, name, created_at FROM users ORDER BY created_at DESC" - ) + 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() - return {"results": [ - {"id": r[0], "phone": r[1][:3] + "****" + r[1][-4:], "name": r[2], "created_at": str(r[3])} - for r in rows - ]} + # 批量查询每个用户的对话数和最后活跃时间 + 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") diff --git a/bossai.py b/bossai.py index 90bc8d3..a1e1c70 100644 --- a/bossai.py +++ b/bossai.py @@ -967,8 +967,43 @@ class UserProfileStore: (phone, json.dumps(profile.to_dict(), ensure_ascii=False), profile.interaction_count), ) + # 写入历史快照(版本号递增) + cur.execute( + "SELECT COALESCE(MAX(version), 0) FROM user_profile_history WHERE phone = %s", + (phone,), + ) + next_version = cur.fetchone()[0] + 1 + source = signals.get("_source", "auto") + cur.execute( + "INSERT INTO user_profile_history (phone, version, profile, source) " + "VALUES (%s, %s, %s, %s)", + (phone, next_version, + json.dumps(profile.to_dict(), ensure_ascii=False), source), + ) return profile + def list_history(self, phone: str, limit: int = 20) -> List[dict]: + """列出用户画像历史版本(按版本号倒序)。""" + with self._conn() as conn: + with conn.cursor() as cur: + cur.execute( + "SELECT id, version, profile, source, created_at " + "FROM user_profile_history WHERE phone = %s " + "ORDER BY version DESC LIMIT %s", + (phone, limit), + ) + rows = cur.fetchall() + return [ + { + "id": r[0], + "version": r[1], + "profile": r[2], + "source": r[3], + "created_at": str(r[4]) if r[4] else "", + } + for r in rows + ] + class UserStore: """PostgreSQL 用户账号存储:手机号+密码注册登录。 @@ -1077,18 +1112,61 @@ class UserStore: {"phone": str, "name": str, "token": str} Raises: - ValueError: 手机号或密码错误 + ValueError: 手机号或密码错误 / 账号已停用 """ with self._conn() as conn: with conn.cursor() as cur: - cur.execute("SELECT password, name FROM users WHERE phone = %s", (phone,)) + cur.execute("SELECT password, name, status FROM users WHERE phone = %s", (phone,)) row = cur.fetchone() if not row: raise ValueError("手机号或密码错误") if not self._verify_password(password, row[0]): raise ValueError("手机号或密码错误") + if row[2] == "inactive": + raise ValueError("账号已被停用,请联系管理员") return {"phone": phone, "name": row[1] or "", "token": self._make_token(phone)} + def reset_password(self, user_id: int, new_password: str) -> bool: + """管理员重置用户密码。 + + Args: + user_id: 用户 ID + new_password: 新密码(明文,内部 bcrypt 哈希) + + Returns: + 是否成功 + """ + if len(new_password) < 6: + raise ValueError("密码至少 6 位") + hashed = self._hash_password(new_password) + with self._conn() as conn: + with conn.cursor() as cur: + cur.execute( + "UPDATE users SET password = %s, updated_at = now() WHERE id = %s", + (hashed, user_id), + ) + return cur.rowcount > 0 + + def update_status(self, user_id: int, status: str) -> bool: + """管理员激活/停用用户。 + + Args: + user_id: 用户 ID + status: 'active' 或 'inactive' + + Returns: + 是否成功 + """ + if status not in ("active", "inactive"): + raise ValueError("状态应为 active 或 inactive") + with self._conn() as conn: + with conn.cursor() as cur: + cur.execute( + "UPDATE users SET status = %s, updated_at = now() WHERE id = %s", + (status, user_id), + ) + return cur.rowcount > 0 + class TicketStore: """PostgreSQL 人工复核工单存储,含 SLA 和专家自动分配。""" @@ -3052,6 +3130,7 @@ class ChatStore: inference_location: str = "", model_name: str = "", fallback_used: bool = False, + rag_context: list | None = None, ) -> int | None: """保存一条对话消息。 @@ -3070,6 +3149,7 @@ class ChatStore: inference_location: 推理位置(cloud/local) model_name: 模型名称 fallback_used: 是否使用了降级 + rag_context: 知识库引用文档列表(仅 assistant 消息) Returns: 消息 ID,ephemeral 模式返回 None @@ -3093,11 +3173,22 @@ class ChatStore: "escalation": risk_escalation, }, ensure_ascii=False) + # 知识库引用摘要(只存标题/类型/地区,不存全文) + rag_summary = [] + if rag_context: + for doc in rag_context: + rag_summary.append({ + "title": doc.get("title", ""), + "doc_type": doc.get("doc_type", ""), + "region": doc.get("region"), + }) + meta_data = json.dumps({ "scene": scene, "inference_location": inference_location, "model": model_name, "fallback_used": fallback_used, + "rag_context": rag_summary, }, ensure_ascii=False) with self._conn() as conn: @@ -3127,7 +3218,8 @@ class ChatStore: COUNT(*) AS msg_count, MIN(created_at) AS first_at, MAX(created_at) AS last_at, - (array_agg(meta_data->>'scene' ORDER BY created_at DESC))[1] AS scene + (array_agg(meta_data->>'scene' ORDER BY created_at DESC))[1] AS scene, + (array_agg(content ORDER BY created_at ASC) FILTER (WHERE role = 'user'))[1] AS first_question FROM chat_messages WHERE phone = %s """ @@ -3140,17 +3232,30 @@ class ChatStore: with conn.cursor() as cur: cur.execute(query, params) rows = cur.fetchall() - return [ - { - "session_id": r[0], - "mode": r[1], - "message_count": r[2], - "first_message_at": r[3].isoformat() if r[3] else None, - "last_message_at": r[4].isoformat() if r[4] else None, - "scene": r[5], - } - for r in rows - ] + # 场景中文映射(保密模式无法解密内容,用场景标签生成标题) + SCENE_LABELS = {"legal": "法务参谋", "decision": "经营决策", + "life": "生活助理", "emotion": "情绪中心"} + results = [] + for r in rows: + sid, smode, msg_count, first_at, last_at, scene, first_q = r + if smode == "confidential": + # 保密模式:内容已加密,用场景标签 + 日期生成标题 + scene_label = SCENE_LABELS.get(scene or "", "保密对话") + date_str = first_at.strftime("%m/%d") if first_at else "" + title = f"{scene_label} · {date_str}" if date_str else scene_label + else: + # 普通模式:取首条用户消息前 30 字作为标题 + title = (first_q[:30] + "…") if first_q and len(first_q) > 30 else (first_q or "未命名对话") + results.append({ + "session_id": sid, + "mode": smode, + "message_count": msg_count, + "first_message_at": first_at.isoformat() if first_at else None, + "last_message_at": last_at.isoformat() if last_at else None, + "scene": scene, + "title": title, + }) + return results def get_messages(self, session_id: str, phone: str, decrypt: bool = True) -> list: """获取会话的所有消息(按时间正序)。 diff --git a/db_init.py b/db_init.py index 71f7286..4bf99e5 100644 --- a/db_init.py +++ b/db_init.py @@ -75,12 +75,25 @@ CREATE TABLE IF NOT EXISTS user_profiles ( updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); +-- 用户画像历史版本:每次更新时写入快照,支持版本回溯 +CREATE TABLE IF NOT EXISTS user_profile_history ( + id SERIAL PRIMARY KEY, + phone TEXT NOT NULL, + version INTEGER NOT NULL, -- 版本号(递增) + profile JSONB NOT NULL DEFAULT '{}', -- 画像快照 + source TEXT NOT NULL DEFAULT 'auto', -- auto(AI提取) / manual(用户确认) + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_profile_history_phone ON user_profile_history(phone); +CREATE INDEX IF NOT EXISTS idx_profile_history_phone_version ON user_profile_history(phone, version DESC); + -- 用户账号:手机号+密码注册登录 CREATE TABLE IF NOT EXISTS users ( id SERIAL PRIMARY KEY, phone TEXT NOT NULL UNIQUE, -- 手机号(登录账号) password TEXT NOT NULL, -- bcrypt 哈希 name TEXT, -- 昵称(可选) + status TEXT NOT NULL DEFAULT 'active', -- active/inactive(管理员终止) created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); @@ -272,6 +285,7 @@ def main(): BEGIN ALTER TABLE review_tickets ADD COLUMN expert_type TEXT; EXCEPTION WHEN duplicate_column THEN NULL; END; 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; END $$; """) diff --git a/web.html b/web.html index fc04eb4..24d9e8e 100644 --- a/web.html +++ b/web.html @@ -1,6 +1,6 @@ -BossAi MVP +左右(BossAi) -

BossAi

老板经营、法务与情绪支持助手(MVP)

+

左右(BossAi)

老板经营、法务与情绪支持助手

diff --git a/web/index.html b/web/index.html index ee282d2..3e50eba 100644 --- a/web/index.html +++ b/web/index.html @@ -5,19 +5,20 @@ - + - - BossAi · 老板经营助理 + + 左右(BossAi)