--- production/wechat_rpa/admin_api.py +++ release/wechat_rpa/admin_api.py @@ -42,6 +42,7 @@ import admin_backend as backend import app_version import archive_api +import knowledge_api import zyt_auth # 账号下拉框里「全部」的取值。用 -1 而不是 0:0 早就有含义了(旧版未归属数据), @@ -1277,12 +1278,19 @@ ) -> dict: # 这一份的结构由 `admin_backend.desktop_config_payload` 统一给出。 # 两处各写一份的话迟早漂移,表现成"某台客户端少了个字段",极难查。 - return backend.desktop_config_payload( + payload = backend.desktop_config_payload( database, scheme=request.headers.get("x-forwarded-proto", request.url.scheme), host=request.headers.get("host", ""), desktop_account=account, ) + knowledge = app.state.knowledge_store + tenant = str(account["tenant_id"]) + counts = await asyncio.to_thread(knowledge.stats, tenant) + settings = await asyncio.to_thread(knowledge.settings, tenant) + payload["knowledge"] = {"published": counts.get("published", 0), + "enabled": bool(settings["enabled"]), "updated_at": settings["updated_at"]} + return payload # 桌面端回流一次编排调用。补上这条之前,客户端改指 8766 会丢掉全部调用留痕 # ——配置照常同步、回复照常发,唯独调用记录一条不进库,而那恰恰是出事后用来 @@ -1325,6 +1333,10 @@ resolve_admin_tenant=resolve_admin_tenant, zyt_api_url=lambda: desktop_login_api_url()[0], zyt_patient_searcher=zyt_patient_searcher, + ) + + knowledge_api.register_knowledge_routes( + app, database, require, client_ip, resolve_admin_tenant, current_desktop, ) # ── 前端静态资源 ───────────────────────────────────────────────────── --- production/wechat_rpa/model_gateway.py +++ release/wechat_rpa/model_gateway.py @@ -44,6 +44,8 @@ import model_protocol import admin_backend +from knowledge_store import KnowledgeStore +from knowledge_retriever import KnowledgeRetriever, inject_references # ── 超时分层 ───────────────────────────────────────────────────────────────── # 整体必须小于桌面端的超时,否则桌面端先超时重发,网关还在跑,双倍扣费。 @@ -477,6 +479,9 @@ app = FastAPI(title="模型统一网关", version="1.0") database = admin_backend.Database(db_path) database.migrate() + knowledge_store = KnowledgeStore(database) + knowledge_store.initialize() + knowledge_retriever = KnowledgeRetriever(knowledge_store) catalog = Catalog(db_path) idempotency: dict[str, tuple[float, dict]] = {} log_queue: asyncio.Queue = asyncio.Queue(maxsize=2000) @@ -522,6 +527,9 @@ record = await log_queue.get() try: await asyncio.to_thread(_write_log, db_path, record) + if record.get("knowledge_retrieval") is not None: + await asyncio.to_thread(knowledge_retriever.record, record["tenant_id"], + record["task_id"], record["knowledge_retrieval"]) except Exception: pass finally: @@ -584,7 +592,12 @@ ) if idempotency_key and idempotency_key in idempotency: # 桌面端重发(超时后重试)不该重复扣费 - return JSONResponse(idempotency[idempotency_key][1]) + cached = idempotency[idempotency_key][1] + cached_hits = (cached.get("knowledge") or {}).get("hits", []) + if not cached_hits or await asyncio.to_thread( + knowledge_retriever.references_current, str(account["tenant_id"]), cached_hits): + return JSONResponse(cached) + idempotency.pop(idempotency_key, None) body = await request.json() answers, judge, mode, version = catalog.plan() @@ -606,6 +619,32 @@ tools = body.get("tools") or None started = time.monotonic() + knowledge_result = None + knowledge_trace = {"enabled": False, "hits": [], "mode": "disabled", "elapsed_ms": 0} + tenant = str(account["tenant_id"]) + if str(body.get("purpose") or "chat") == "chat": + settings = await asyncio.to_thread(knowledge_store.settings, tenant) + if settings["enabled"]: + from knowledge_processing import latest_query + query = str(body.get("customer_text") or "") + if not query: + for turn in reversed(messages): + if turn.get("role") == "user" and isinstance(turn.get("content"), str): + query = turn["content"] + break + query = latest_query(query) or query + try: + knowledge_result = await asyncio.wait_for( + asyncio.to_thread(knowledge_retriever.search, tenant, query), timeout=4.0) + knowledge_trace = {"enabled": True, "mode": knowledge_result["mode"], + "elapsed_ms": knowledge_result["elapsed_ms"], "hits": [ + {k: h[k] for k in ("id", "revision", "score")} for h in knowledge_result["hits"]]} + messages = inject_references(messages, knowledge_result["hits"]) + except Exception: + knowledge_trace = {"enabled": True, "hits": [], "mode": "unavailable", "elapsed_ms": 4000} + from knowledge_processing import redact + knowledge_result = {**knowledge_trace, "query": redact(query)[:1000]} + messages = inject_references(messages, []) if tools: # 工具轮:只问主出口,不并发也不评审。 @@ -634,12 +673,24 @@ "judge": dict(_EMPTY_VERDICT), "judge_mode": mode, "roles_version": version, + "knowledge": knowledge_trace, "total_ms": int((time.monotonic() - started) * 1000), } if single.get("error") and not payload["reply"] and not payload["tool_calls"]: return JSONResponse(payload, status_code=502) - if x_idempotency_key: - idempotency[x_idempotency_key] = (now, payload) + if idempotency_key: + idempotency[idempotency_key] = (now, payload) + try: + log_queue.put_nowait({ + "desktop_account_id": int(account["id"]), "tenant_id": tenant, + "device_id": x_device_id, "task_id": str(body.get("task_id") or ""), + "roles_version": version, "judge_mode": mode, "chosen": payload["chosen"], + "judge": payload["judge"], "candidates": [single], "total_ms": payload["total_ms"], + "customer_text": str(body.get("customer_text") or ""), "reply_text": payload["reply"], + "purpose": str(body.get("purpose") or "chat"), "knowledge_retrieval": knowledge_result, + }) + except asyncio.QueueFull: + pass return JSONResponse(payload) results = await asyncio.gather( @@ -683,6 +734,7 @@ payload = { "reply": chosen["text"], + "knowledge": knowledge_trace, "tool_calls": [], "chosen": chosen["provider"], "candidates": results, @@ -708,6 +760,7 @@ "customer_text": str(body.get("customer_text") or ""), "reply_text": payload["reply"], "purpose": str(body.get("purpose") or "chat"), + "knowledge_retrieval": knowledge_result, }) except asyncio.QueueFull: pass # 观测数据丢一条,不能因此拖慢回复 --- production/wechat_rpa/rbac.py +++ release/wechat_rpa/rbac.py @@ -27,6 +27,11 @@ ("model:write", "增删改模型与编排", "模型"), ("agent:read", "查看智能体", "模型"), ("agent:write", "增删改智能体与协作方案", "模型"), + ("knowledge:read", "查看知识中心", "知识库"), + ("knowledge:write", "加工和编辑知识草稿", "知识库"), + ("knowledge:review", "审核知识", "知识库"), + ("knowledge:publish", "发布和停用知识", "知识库"), + ("knowledge:export", "导出已发布知识", "知识库"), ("review:read", "查看待审队列", "审核"), ("review:write", "通过/驳回待审回复", "审核"), ("stats:read", "查看调用统计", "运营"),