guanlan_cloud_qa.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """观澜云端版 · 服务端 /api/qa 代理 (草案, 未部署; 部署包 CLAUDE_CODE_部署提示词.md §安全边界 / §账号与权限 / §B 云端可用性; 交接 2026-09-06).
  4. 边界 (逐条对应部署包):
  5. - 密钥只在服务端环境变量 DEEPSEEK_API_KEY; 缺失 → 模式 "不可用" + 明确文案, **不伪造回答**, 不落任何文件/日志/回包.
  6. - 浏览器不直连 DeepSeek: 本进程绑 127.0.0.1, 前面放 nginx (HTTPS); 上游 401 / 429 / 超时 / 5xx → 回包 ok=false + 脱敏错误码, answer 恒 null.
  7. - 鉴权: 服务端会话 (HttpOnly cookie, 随机 token, 服务端过期); 账号 = 拼音显示名 + 临时密码 (PBKDF2, 10 天有效, 可撤销, 建/撤销/登录全部进审计).
  8. - 限流 (按用户 + 按 IP, 固定窗) · 请求长度 (body / 问题字数) · 上游超时 · 审计日志 (JSONL: 只记 问题 sha256+长度, 不记原文, 不记密钥) · 错误脱敏 (上游原文只进服务端 stderr 且先去密钥).
  9. - 模式标签: 每个回包带 mode ∈ {云端 DeepSeek, 本机 DeepSeek, 演示回答, 不可用}; 演示回答只在 GUANLAN_QA_DEMO=1 且无密钥时出现, 且 answer 前缀明示 "[演示回答]".
  10. - 出口脱敏: 模型答案过 src/windscada/deid_public.scrub (与 windscada_ask_serve 同款; 导入失败 = 拒绝启动).
  11. - 接地: 问题先在云端脱敏面孔 (outputs/rudong/guanlan/cloud/claims_public.json) 里按关键词取 ≤5 条作 system 上下文, 回包 refs 列 claim_id; 面孔缺失 → 不带引用, refs=[] (不伪造引用).
  12. 不做: 用户管理 HTTP 接口 (只走 CLI, 缩小攻击面) · 多轮对话 · 流式.
  13. 用法: serve [--port 8090] | user add <pinyin> [--role user|admin] [--days 10] | user revoke <pinyin> | user list | selftest
  14. 环境变量 (名, 不含值): DEEPSEEK_API_KEY · DEEPSEEK_BASE_URL (默认 https://api.deepseek.com) · DEEPSEEK_MODEL (默认 deepseek-chat) · GUANLAN_QA_USERS (users.json 路径) · GUANLAN_QA_AUDIT (audit.jsonl 路径) · GUANLAN_QA_DEMO · GUANLAN_QA_INSECURE_COOKIE (=1 时 cookie 不带 Secure, 仅本机 http 测试)
  15. 上游调用经 transport 注入 (ask(q, user, transport=...)), 测试用桩模拟 401/429/超时/5xx, 不真调云.
  16. """
  17. import argparse, base64, datetime as dt, hashlib, hmac, http.client, http.cookies, json, os, pathlib, re, secrets, socket, sys, threading, time, urllib.parse, uuid
  18. from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
  19. ROOT = pathlib.Path(__file__).resolve().parents[1]
  20. sys.path.insert(0, str(ROOT / "src"))
  21. from windscada import deid_public as DP # noqa: E402 ★出口脱敏不可用 = 拒绝启动 (看着有闸实则没有, 比没闸更坏)
  22. MODES = ("云端 DeepSeek", "本机 DeepSeek", "演示回答", "不可用")
  23. LIMITS = dict(max_body_bytes=16 * 1024, max_q_chars=2000, rate_user=(10, 300), rate_ip=(30, 300), upstream_timeout_s=60, max_answer_chars=6000, temp_pw_days=10, session_hours=12, max_refs=5)
  24. CLOUD_CLAIMS = ROOT / "outputs/rudong/guanlan/cloud/claims_public.json"
  25. USERS_PATH = pathlib.Path(os.environ.get("GUANLAN_QA_USERS", str(ROOT / "outputs/rudong/guanlan/cloud/_private/users.json")))
  26. # 审计流水统一落 logs/audit/ (用户令 2): 与本体模型闸的 llm_audit.jsonl 同一处, 便于一起查;
  27. # 云上部署若要把审计留在私有区, 用 GUANLAN_QA_AUDIT 指回去即可 (仍受同一行格式约束)。
  28. AUDIT_PATH = pathlib.Path(os.environ.get("GUANLAN_QA_AUDIT", str(ROOT / "logs/audit/cloud_qa.jsonl")))
  29. SYS_PROMPT = ("你是风电场 SCADA/CMS 分析结论的解释助手。只能依据下面给出的『契约结论』回答; 结论里没有的数字不许编, 没有依据就说『契约里没有这条』。"
  30. "不得批准检修、不得替代工程师判断、不得补造缺失数据。回答用中文, 引用结论时写 claim_id。")
  31. ERR_TEXT = {"no_key": "云端模型未配置 (服务端缺 DEEPSEEK_API_KEY), 问答不可用", "upstream_401": "云端模型凭证无效 (服务端配置问题), 问答不可用", "upstream_429": "云端模型限流, 请稍后再试",
  32. "upstream_timeout": "云端模型超时, 本次未得到回答", "upstream_5xx": "云端模型服务异常, 本次未得到回答", "upstream_net": "云端模型不可达, 本次未得到回答", "upstream_bad": "云端模型回包无法解析, 本次未得到回答"}
  33. _KEY_RE = re.compile(r"sk-[A-Za-z0-9]{8,}|Bearer\s+\S+")
  34. # ── 密钥 / 脱敏 ────────────────────────────────────────────────────
  35. def api_key(): return (os.environ.get("DEEPSEEK_API_KEY") or "").strip()
  36. def redact(s):
  37. """任何要落日志/回包的字符串先过这里: 去掉密钥形态串与真实密钥值."""
  38. s = str(s); k = api_key()
  39. if k: s = s.replace(k, "[REDACTED]")
  40. return _KEY_RE.sub("[REDACTED]", s)
  41. def mode_now():
  42. if api_key(): return "云端 DeepSeek"
  43. return "演示回答" if os.environ.get("GUANLAN_QA_DEMO") == "1" else "不可用"
  44. # ── 审计 ──────────────────────────────────────────────────────────
  45. _AUDIT_LOCK = threading.Lock()
  46. def audit(event, **kw):
  47. rec = {"ts": dt.datetime.now().isoformat(timespec="seconds"), "event": event}
  48. rec.update({k: (redact(v) if isinstance(v, str) else v) for k, v in kw.items()})
  49. AUDIT_PATH.parent.mkdir(parents=True, exist_ok=True)
  50. with _AUDIT_LOCK, AUDIT_PATH.open("a", encoding="utf-8") as f: f.write(json.dumps(rec, ensure_ascii=False) + "\n")
  51. return rec
  52. # ── 账号 (服务端管理; 拼音显示名 + 临时密码) ──────────────────────
  53. PBKDF2_ITERS = 200_000
  54. def _hash_pw(pw, salt): return hashlib.pbkdf2_hmac("sha256", pw.encode("utf-8"), bytes.fromhex(salt), PBKDF2_ITERS).hex()
  55. class Users:
  56. def __init__(self, path=USERS_PATH):
  57. self.path = pathlib.Path(path); self.d = json.loads(self.path.read_text(encoding="utf-8")) if self.path.is_file() else {"users": {}}
  58. def _save(self):
  59. self.path.parent.mkdir(parents=True, exist_ok=True); self.path.write_text(json.dumps(self.d, ensure_ascii=False, indent=1), encoding="utf-8")
  60. try: os.chmod(self.path, 0o600)
  61. except OSError: pass
  62. def add(self, name, role="user", days=LIMITS["temp_pw_days"], by="cli"):
  63. """建/重置账号 → 临时密码 (只返回这一次, 不落盘明文). 最小权限: 默认 user."""
  64. if not re.fullmatch(r"[a-z][a-z0-9_]{1,31}", name): raise ValueError("显示名须为拼音小写 [a-z0-9_], 2~32 字")
  65. if role not in ("user", "admin"): raise ValueError("role ∈ user|admin")
  66. pw = base64.b32encode(secrets.token_bytes(10)).decode().rstrip("=").lower(); salt = secrets.token_hex(16)
  67. self.d["users"][name] = {"role": role, "salt": salt, "hash": _hash_pw(pw, salt), "created": dt.datetime.now().isoformat(timespec="seconds"),
  68. "expires": (dt.datetime.now() + dt.timedelta(days=days)).isoformat(timespec="seconds"), "revoked": False}
  69. self._save(); audit("user_add", user=name, role=role, days=days, by=by); return pw
  70. def revoke(self, name, by="cli"):
  71. if name not in self.d["users"]: raise KeyError(name)
  72. self.d["users"][name]["revoked"] = True; self._save(); audit("user_revoke", user=name, by=by)
  73. def verify(self, name, pw, now=None):
  74. """→ (user dict | None, reason ∈ ok|no_user|bad_pw|expired|revoked). 不区分文案给客户端 (统一 401), reason 只进审计."""
  75. u = self.d["users"].get(name or "")
  76. if not u: _hash_pw(pw or "", "00" * 16); return None, "no_user" # 等时: 无账号也算一次 hash
  77. if not hmac.compare_digest(_hash_pw(pw or "", u["salt"]), u["hash"]): return None, "bad_pw"
  78. if u.get("revoked"): return None, "revoked"
  79. if (now or dt.datetime.now()) > dt.datetime.fromisoformat(u["expires"]): return None, "expired"
  80. return {"name": name, "role": u["role"], "expires": u["expires"]}, "ok"
  81. def list(self): return [{"name": n, "role": u["role"], "expires": u["expires"], "revoked": u.get("revoked", False), "created": u["created"]} for n, u in self.d["users"].items()]
  82. class Sessions:
  83. def __init__(self, hours=LIMITS["session_hours"]): self.s = {}; self.hours = hours; self.lock = threading.Lock()
  84. def create(self, user):
  85. tok = secrets.token_urlsafe(32)
  86. with self.lock: self.s[tok] = dict(user, exp=time.time() + self.hours * 3600)
  87. return tok
  88. def get(self, tok):
  89. with self.lock:
  90. u = self.s.get(tok or "")
  91. if u and (u["exp"] < time.time() or dt.datetime.now() > dt.datetime.fromisoformat(u["expires"])): self.s.pop(tok, None); return None
  92. return u
  93. def drop(self, tok):
  94. with self.lock: self.s.pop(tok or "", None)
  95. class RateLimiter:
  96. """固定窗: key 在 window 秒内最多 n 次 → (ok, retry_after_s)."""
  97. def __init__(self): self.w = {}; self.lock = threading.Lock()
  98. def allow(self, key, n, window, now=None):
  99. now = now if now is not None else time.time()
  100. with self.lock:
  101. t0, c = self.w.get(key, (now, 0))
  102. if now - t0 >= window: t0, c = now, 0
  103. if c >= n: return False, int(window - (now - t0)) + 1
  104. self.w[key] = (t0, c + 1); return True, 0
  105. # ── 接地引用 (云端脱敏面孔) ─────────────────────────────────────────
  106. _CLAIMS = {"key": None, "rows": []}
  107. def claims_public():
  108. if not CLOUD_CLAIMS.is_file(): return []
  109. st = CLOUD_CLAIMS.stat(); key = (st.st_mtime_ns, st.st_size)
  110. if _CLAIMS["key"] != key: _CLAIMS.update(key=key, rows=json.loads(CLOUD_CLAIMS.read_text(encoding="utf-8")).get("claims", []))
  111. return _CLAIMS["rows"]
  112. def pick_refs(q, k=LIMITS["max_refs"]):
  113. """关键词重叠取 top-k 契约条 (只用脱敏面孔; 面孔缺 → []). 不是检索系统, 只是把回答钉在契约上."""
  114. toks = {t for t in re.findall(r"[一-鿿]{2,}|[A-Za-z][A-Za-z0-9\-]{2,}", q or "")}
  115. grams = {t[i:i + 2] for t in toks for i in range(len(t) - 1)} | toks
  116. if not grams: return []
  117. scored = []
  118. for c in claims_public():
  119. text = (c.get("title_public") or "") + " " + (c.get("missing_evidence") or "")
  120. sc = sum(1 for g in grams if g in text) + (2 if c.get("claim_id", "").lower() in (q or "").lower() else 0)
  121. if sc: scored.append((sc, c))
  122. scored.sort(key=lambda x: (-x[0], x[1]["claim_id"]))
  123. return [c for _, c in scored[:k]]
  124. def build_messages(q, refs):
  125. ctx = "\n".join(f"- {c['claim_id']} [{c['verdict']}] {c['title_public'][:200]}" + (f" | 缺失证据: {c['missing_evidence'][:120]}" if c.get("missing_evidence") else "") for c in refs) or "(契约里没有与本问题相关的结论)"
  126. return [{"role": "system", "content": SYS_PROMPT + "\n\n契约结论:\n" + ctx}, {"role": "user", "content": q}]
  127. # ── 上游 ──────────────────────────────────────────────────────────
  128. class UpstreamError(Exception):
  129. def __init__(self, code, detail=""): super().__init__(code); self.code = code; self.detail = redact(detail)[:300]
  130. def deepseek_transport(messages, timeout=LIMITS["upstream_timeout_s"]):
  131. """真上游 (只此一处发网络请求). → 答案文本; 失败 raise UpstreamError(code ∈ upstream_401|upstream_429|upstream_timeout|upstream_5xx|upstream_net|upstream_bad)."""
  132. key = api_key()
  133. if not key: raise UpstreamError("no_key")
  134. base = urllib.parse.urlparse(os.environ.get("DEEPSEEK_BASE_URL") or "https://api.deepseek.com")
  135. body = json.dumps({"model": os.environ.get("DEEPSEEK_MODEL") or "deepseek-chat", "messages": messages, "stream": False, "max_tokens": 1200}).encode("utf-8")
  136. try:
  137. conn = (http.client.HTTPSConnection if base.scheme == "https" else http.client.HTTPConnection)(base.hostname, base.port, timeout=timeout)
  138. conn.request("POST", (base.path.rstrip("/") or "") + "/chat/completions", body, {"Content-Type": "application/json", "Authorization": "Bearer " + key})
  139. r = conn.getresponse(); raw = r.read(); conn.close()
  140. except socket.timeout as e: raise UpstreamError("upstream_timeout", str(e))
  141. except (OSError, http.client.HTTPException) as e: raise UpstreamError("upstream_net", str(e))
  142. if r.status == 401 or r.status == 403: raise UpstreamError("upstream_401", raw[:200].decode("utf-8", "replace"))
  143. if r.status == 429: raise UpstreamError("upstream_429", raw[:200].decode("utf-8", "replace"))
  144. if r.status >= 500: raise UpstreamError("upstream_5xx", f"{r.status} " + raw[:200].decode("utf-8", "replace"))
  145. if r.status != 200: raise UpstreamError("upstream_bad", f"{r.status} " + raw[:200].decode("utf-8", "replace"))
  146. try: return json.loads(raw)["choices"][0]["message"]["content"]
  147. except Exception as e: raise UpstreamError("upstream_bad", f"{type(e).__name__}: {raw[:200].decode('utf-8', 'replace')}")
  148. # ── 核心 (纯函数, transport 可注入) ─────────────────────────────────
  149. def ask(q, user, transport=None, ip="-"):
  150. """→ {ok, mode, answer|None, err|None, err_code|None, refs, model, secs, request_id}. 任何失败 answer 恒 None; 绝不在失败时给出貌似回答的文本."""
  151. rid = uuid.uuid4().hex[:12]; t0 = time.time(); q = (q or "").strip(); qsha = hashlib.sha256(q.encode("utf-8")).hexdigest()[:16]
  152. base = dict(request_id=rid, refs=[], model=os.environ.get("DEEPSEEK_MODEL") or "deepseek-chat", answer=None, err=None, err_code=None)
  153. def done(**kw):
  154. out = dict(base, **kw); out["secs"] = round(time.time() - t0, 2)
  155. audit("qa", request_id=rid, user=user.get("name"), role=user.get("role"), ip=ip, q_sha16=qsha, q_len=len(q), mode=out["mode"], ok=out["ok"], err_code=out["err_code"], secs=out["secs"], n_refs=len(out["refs"]))
  156. return out
  157. if not q: return done(ok=False, mode=mode_now(), err="问题为空", err_code="empty")
  158. if len(q) > LIMITS["max_q_chars"]: return done(ok=False, mode=mode_now(), err=f"问题超长 (>{LIMITS['max_q_chars']} 字)", err_code="too_long")
  159. refs = pick_refs(q); base["refs"] = [c["claim_id"] for c in refs]
  160. if not api_key():
  161. if os.environ.get("GUANLAN_QA_DEMO") == "1":
  162. demo = "[演示回答] 云端模型未配置, 以下不是模型输出, 只是契约里与问题最接近的结论原文:\n" + ("\n".join(f"- {c['claim_id']} [{c['verdict']}] {c['title_public'][:160]}" for c in refs) or "- (契约里没有相关结论)")
  163. return done(ok=True, mode="演示回答", answer=demo, model=None)
  164. return done(ok=False, mode="不可用", err=ERR_TEXT["no_key"], err_code="no_key", model=None)
  165. try:
  166. txt = (transport or deepseek_transport)(build_messages(q, refs))
  167. except UpstreamError as e:
  168. print(f"[qa] {rid} upstream {e.code}: {e.detail}", file=sys.stderr, flush=True) # 上游原文只进服务端 stderr (已去密钥), 不进回包
  169. return done(ok=False, mode="不可用", err=ERR_TEXT.get(e.code, ERR_TEXT["upstream_bad"]), err_code=e.code)
  170. except Exception as e:
  171. print(f"[qa] {rid} internal {type(e).__name__}: {redact(str(e))[:200]}", file=sys.stderr, flush=True)
  172. return done(ok=False, mode="不可用", err="服务端内部错误, 本次未得到回答", err_code="internal")
  173. txt = re.sub(r"<think>.*?</think>", "", str(txt or ""), flags=re.S).strip()
  174. if not txt: return done(ok=False, mode="不可用", err=ERR_TEXT["upstream_bad"], err_code="upstream_bad")
  175. return done(ok=True, mode="云端 DeepSeek", answer=DP.scrub(redact(txt))[:LIMITS["max_answer_chars"]])
  176. # ── HTTP ──────────────────────────────────────────────────────────
  177. USERS = None; SESS = Sessions(); RL = RateLimiter(); COOKIE = "guanlan_sid"
  178. class H(BaseHTTPRequestHandler):
  179. server_version = "guanlan-qa/0.1"; sys_version = ""
  180. def log_message(self, *a): pass
  181. def _json(self, obj, code=200, extra=None):
  182. b = json.dumps(obj, ensure_ascii=False).encode("utf-8")
  183. self.send_response(code); self.send_header("Content-Type", "application/json; charset=utf-8"); self.send_header("Content-Length", str(len(b)))
  184. self.send_header("Cache-Control", "no-store"); self.send_header("X-Content-Type-Options", "nosniff")
  185. for k, v in (extra or {}).items(): self.send_header(k, v)
  186. self.end_headers(); self.wfile.write(b)
  187. def _ip(self): return self.headers.get("X-Forwarded-For", self.client_address[0]).split(",")[0].strip()
  188. def _sid(self):
  189. c = http.cookies.SimpleCookie(self.headers.get("Cookie", "")); return c[COOKIE].value if COOKIE in c else None
  190. def _user(self): return SESS.get(self._sid())
  191. def _body(self):
  192. n = int(self.headers.get("Content-Length") or 0)
  193. if n > LIMITS["max_body_bytes"]: return None
  194. try: return json.loads(self.rfile.read(n) or b"{}")
  195. except Exception: return {}
  196. def do_GET(self):
  197. p = urllib.parse.urlparse(self.path).path
  198. if p == "/healthz": return self._json({"ok": True, "mode": mode_now(), "service": "guanlan-qa"})
  199. u = self._user()
  200. if not u: return self._json({"ok": False, "err": "未登录", "err_code": "unauthorized", "mode": mode_now()}, 401)
  201. if p == "/api/me": return self._json({"ok": True, "user": u["name"], "role": u["role"], "expires": u["expires"], "mode": mode_now()})
  202. if p == "/api/qa/status": return self._json({"ok": True, "mode": mode_now(), "model": (os.environ.get("DEEPSEEK_MODEL") or "deepseek-chat") if api_key() else None, "limits": {k: v for k, v in LIMITS.items() if k in ("max_q_chars", "rate_user", "upstream_timeout_s")}, "refs_available": bool(claims_public())})
  203. if p == "/api/admin/users":
  204. if u["role"] != "admin": audit("forbidden", user=u["name"], path=p, ip=self._ip()); return self._json({"ok": False, "err": "权限不足", "err_code": "forbidden"}, 403)
  205. return self._json({"ok": True, "users": USERS.list()})
  206. return self._json({"ok": False, "err": "not found"}, 404)
  207. def do_POST(self):
  208. p = urllib.parse.urlparse(self.path).path; ip = self._ip()
  209. ok, wait = RL.allow("ip:" + ip, *LIMITS["rate_ip"])
  210. if not ok: audit("rate_limited", ip=ip, path=p); return self._json({"ok": False, "err": "请求过于频繁", "err_code": "rate_limited", "mode": mode_now()}, 429, {"Retry-After": str(wait)})
  211. body = self._body()
  212. if body is None: return self._json({"ok": False, "err": "请求体过大", "err_code": "too_large", "mode": mode_now()}, 413)
  213. if p == "/api/login":
  214. u, why = USERS.verify(body.get("name"), body.get("password")); audit("login", user=body.get("name"), ok=why == "ok", reason=why, ip=ip)
  215. if not u: return self._json({"ok": False, "err": "用户名或密码错误, 或账号已过期/撤销", "err_code": "unauthorized"}, 401)
  216. tok = SESS.create(u); flags = "; HttpOnly; SameSite=Strict; Path=/" + ("" if os.environ.get("GUANLAN_QA_INSECURE_COOKIE") == "1" else "; Secure")
  217. return self._json({"ok": True, "user": u["name"], "role": u["role"], "expires": u["expires"], "mode": mode_now()}, 200, {"Set-Cookie": f"{COOKIE}={tok}{flags}"})
  218. u = self._user()
  219. if not u: return self._json({"ok": False, "err": "未登录", "err_code": "unauthorized", "mode": mode_now()}, 401)
  220. if p == "/api/logout": SESS.drop(self._sid()); audit("logout", user=u["name"], ip=ip); return self._json({"ok": True}, 200, {"Set-Cookie": f"{COOKIE}=; Max-Age=0; Path=/"})
  221. if p == "/api/qa":
  222. ok, wait = RL.allow("user:" + u["name"], *LIMITS["rate_user"])
  223. if not ok: audit("rate_limited", user=u["name"], ip=ip); return self._json({"ok": False, "err": "本账号提问过于频繁, 请稍后", "err_code": "rate_limited", "mode": mode_now()}, 429, {"Retry-After": str(wait)})
  224. r = ask(body.get("q"), u, ip=ip)
  225. return self._json(r, 200 if r["ok"] else {"too_long": 413, "empty": 400}.get(r["err_code"], 503))
  226. return self._json({"ok": False, "err": "not found"}, 404)
  227. def selftest():
  228. """不联网: 用桩走一遍 无密钥 / 演示 / 401 / 429 / 超时 / 5xx / 成功, 打印每种模式标签."""
  229. saved = {k: os.environ.get(k) for k in ("DEEPSEEK_API_KEY", "GUANLAN_QA_DEMO")}; u = {"name": "selftest", "role": "user"}; out = []
  230. try:
  231. os.environ.pop("DEEPSEEK_API_KEY", None); os.environ.pop("GUANLAN_QA_DEMO", None); out.append(("无密钥", ask("叶根螺栓", u)))
  232. os.environ["GUANLAN_QA_DEMO"] = "1"; out.append(("演示", ask("叶根螺栓", u))); os.environ.pop("GUANLAN_QA_DEMO")
  233. os.environ["DEEPSEEK_API_KEY"] = "sk-selftest-not-a-real-key-000000"
  234. for code in ("upstream_401", "upstream_429", "upstream_timeout", "upstream_5xx"):
  235. def bad(m, code=code): raise UpstreamError(code, "Bearer sk-selftest-not-a-real-key-000000 detail")
  236. out.append((code, ask("叶根螺栓", u, transport=bad)))
  237. out.append(("成功", ask("叶根螺栓", u, transport=lambda m: "如东 WTG31 叶根螺栓断裂仍在发生 (RD-2026-08-003)")))
  238. finally:
  239. for k, v in saved.items():
  240. if v is None: os.environ.pop(k, None)
  241. else: os.environ[k] = v
  242. for n, r in out: print(f"{n:16} mode={r['mode']:12} ok={r['ok']!s:5} err_code={r['err_code']} answer={(r['answer'] or '')[:60]!r} refs={r['refs'][:3]}")
  243. return out
  244. def main():
  245. global USERS
  246. ap = argparse.ArgumentParser(); sub = ap.add_subparsers(dest="cmd", required=True)
  247. s = sub.add_parser("serve"); s.add_argument("--port", type=int, default=8090); s.add_argument("--bind", default="127.0.0.1")
  248. uu = sub.add_parser("user"); uu.add_argument("op", choices=["add", "revoke", "list"]); uu.add_argument("name", nargs="?"); uu.add_argument("--role", default="user", choices=["user", "admin"]); uu.add_argument("--days", type=int, default=LIMITS["temp_pw_days"])
  249. sub.add_parser("selftest"); a = ap.parse_args(); USERS = Users()
  250. if a.cmd == "user":
  251. if a.op == "add": print(f"账号 {a.name} ({a.role}) 临时密码 (只显示这一次, {a.days} 天有效):", USERS.add(a.name, a.role, a.days)); return 0
  252. if a.op == "revoke": USERS.revoke(a.name); print("已撤销", a.name); return 0
  253. for r in USERS.list(): print(json.dumps(r, ensure_ascii=False))
  254. return 0
  255. if a.cmd == "selftest": selftest(); return 0
  256. print(f"[qa] :{a.port} mode={mode_now()} users={len(USERS.list())} refs={len(claims_public())} audit={AUDIT_PATH}", file=sys.stderr, flush=True)
  257. if not api_key(): print("[qa] 警告: 未配置 DEEPSEEK_API_KEY, /api/qa 全部返回 不可用" + (" (演示回答)" if mode_now() == "演示回答" else ""), file=sys.stderr, flush=True)
  258. ThreadingHTTPServer((a.bind, a.port), H).serve_forever()
  259. if __name__ == "__main__":
  260. sys.exit(main() or 0)