windscada_ask_serve.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434
  1. # -*- coding: utf-8 -*-
  2. """问答微服务 — 只暴露 /api/ask 与 /api/ask_status。
  3. 为什么不直接把 windscada_serve.py 搬上云:
  4. 那份要 numpy/pandas + src.windscada 下 taxonomy/subsys/perf/design 一整套, 而问答只用到
  5. fast_agent → mcp_server(pandas) + audit.grounding(re)。云上少装一个包就少一处装不上的风险,
  6. 2 核机器编译依赖的代价也是实打实的。静态页由 nginx 直接服务, 与本进程无关。
  7. 模型档: deepseek-chat / qwen-plus(阿里云百炼免费额度) / kimi-fast —— 见 fast_agent._online,
  8. 密钥一律从环境变量读, 不落盘。哪个档没配密钥, 前端就看不到它。
  9. """
  10. import json, os, pathlib, re, sys, threading, time
  11. import urllib.parse
  12. from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
  13. sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
  14. sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
  15. from src.ontology import fast_agent as F # noqa: E402
  16. MODELS = {
  17. 'deepseek-chat': dict(nm='DeepSeek', loc='云端', t='约 15~40 秒', env='DEEPSEEK_API_KEY'),
  18. 'qwen-plus': dict(nm='通义千问 Plus', loc='云端', t='约 15~40 秒', env='DASHSCOPE_API_KEY'),
  19. 'kimi-fast': dict(nm='Kimi', loc='云端', t='约 20~50 秒', env='MOONSHOT_API_KEY'),
  20. }
  21. # ── 汇报编排 (2026-09-01 移植上云) ──────────────────────────────────
  22. # ★为什么能移植: rpt_compose 唯一的重依赖是 fleet_view() —— 那是整套分析栈
  23. # (taxonomy/subsys/perf + 全部 parquet), 2 核 1G 跑不动, 当初正因此没上云。
  24. # 但 compose **只要它的输出不要它的计算**, 而输出只有 170~186 KB。
  25. # 所以: 把 fleet_view 的结果 dump 成 JSON 传上来, compose 读文件。
  26. # ★模型不再打 Ollama: 原实现写死 http://127.0.0.1:11434 (本地 Ollama), 云上没有。
  27. # 改走 fast_agent._chat —— 与问答助手同一个云端客户端, 不重写。
  28. RPT_BLOCK_KEYS = ['kpi', 'water', 'emon', 'blame', 'ram', 'parts', 'attrib',
  29. 'pareto', 'quad', 'frate', 'units', 'chain', 'loop', 'plan', 'caliber']
  30. RPT_SYS = ('你是风电场报告编排助手。根据用户要求做两件事, 严格按格式输出, 不要解释:\n'
  31. 'BLOCKS: 用逗号分隔的块名 (只能从给定清单里选, 按报告顺序排)\n'
  32. 'TEXT: 一段中文总结 (150~300 字, 可分 2~3 句一组)\n'
  33. '铁律: TEXT 里出现的每个数字都必须来自"可用数据"里, 不许自己算不许估计; '
  34. '没有依据的话就不写。不要写套话, 不要写"综上所述"。')
  35. RPT_SYS_EN = ('You compose wind-farm reports. Do two things and output strictly in this '
  36. 'format, with no explanation:\n'
  37. 'BLOCKS: comma-separated block names (only from the given list, in report order)\n'
  38. 'TEXT: one English summary of 150-300 words\n'
  39. 'Hard rule: every number in TEXT must come from the supplied data — do not compute '
  40. 'or estimate any number yourself; if there is no basis, leave it out. No filler.')
  41. FLEET_DIR = pathlib.Path(__file__).resolve().parent.parent / 'data' / 'fleet'
  42. def _rpt_facts(fl):
  43. E = (fl.get('control') or {}).get('energy') or {}
  44. R = fl.get('rel') or {}
  45. M = ((fl.get('m8') or {}).get('mtbf')) or {}
  46. F = fl.get('faults') or {}
  47. BD = ((fl.get('fus') or {}).get('链盘')) or ((fl.get('fus') or {}).get('chain')) or {}
  48. return [
  49. f"统计期 {fl.get('win')}; 38 台 4.0MW",
  50. f"上网电量 {E.get('act')} MWh; 理论可发 {E.get('theo')}; 损失 {E.get('loss')} MWh; 损失率 {E.get('loss_pct')}%",
  51. f"等效满发 {E.get('eflh')} h; 时间可用率 {(fl.get('control') or {}).get('avail')}%",
  52. f"总停机 {R.get('总停机h')} h = 设备类 {R.get('设备类停机h')} + 外部类 {R.get('外部类停机h')}",
  53. f"平均停机间隔 {M.get('MTBF_h')} h; 停机 {M.get('停机事件数')} 起; 单次平均 {M.get('MDT_h')} h",
  54. f"报警 {F.get('total')} 条 / {F.get('n_codes')} 个码",
  55. f"需行动机组 {len(BD.get('rows') or [])} 台; 卡点 {BD.get('stuck')}",
  56. # 占比直接给出: 不给模型就得自己算, 算对了也会被接地闸判为"未接地数字"
  57. "损失构成: " + '; '.join(
  58. f"{x.get('k')} {x.get('loss')} MWh (占损失 "
  59. f"{round((x.get('loss') or 0) / max(E.get('loss') or 1, 1) * 100, 1)}%)"
  60. for x in (E.get('items') or [])),
  61. f"端到端闭环 {len(BD.get('closed') or [])} 例",
  62. ]
  63. def rpt_compose(need, canvas, model, lang='zh'):
  64. import time as _t, re as _re
  65. f = FLEET_DIR / ('fleet_%s.json' % ('en' if lang == 'en' else 'zh'))
  66. if not f.is_file():
  67. return dict(err='fleet 数据未上传 (%s)' % f.name if lang != 'en'
  68. else 'Fleet data not uploaded (%s)' % f.name)
  69. fl = json.loads(f.read_text(encoding='utf-8'))
  70. facts = _rpt_facts(fl)
  71. prompt = ('可用数据:\n' + '\n'.join('- ' + x for x in facts) +
  72. '\n\n可选块 (只能从这里选): ' + ', '.join(RPT_BLOCK_KEYS) +
  73. '\n当前画布: ' + (', '.join(canvas) if canvas else '(空)') +
  74. '\n用户要求: ' + need)
  75. t0 = _t.time()
  76. try:
  77. from src.ontology import fast_agent as _F
  78. msgs = [dict(role='system', content=RPT_SYS_EN if lang == 'en' else RPT_SYS),
  79. dict(role='user', content=prompt)]
  80. txt = _F._chat(model, msgs, None, npred=900) or ''
  81. if isinstance(txt, dict):
  82. txt = txt.get('content') or txt.get('text') or ''
  83. except Exception as e:
  84. return dict(err='%s: %s' % (type(e).__name__, str(e)[:140]))
  85. txt = _re.sub(r'<think>.*?</think>', '', str(txt), flags=_re.S).strip()
  86. mb = _re.search(r'BLOCKS\s*[::]\s*(.+)', txt)
  87. mt = _re.search(r'TEXT\s*[::]\s*(.+)', txt, _re.S)
  88. blocks = [b.strip() for b in _re.split(r'[,,\s]+', mb.group(1))] if mb else []
  89. blocks = [b for b in blocks if b in RPT_BLOCK_KEYS]
  90. narrative = (mt.group(1).strip() if mt else '').strip()
  91. bad = []
  92. try: # 接地闸: 叙述里的数字必须在 facts 里出现
  93. from src.windscada.audit import grounding as _gr
  94. ok, b = _gr(narrative, '\n'.join(facts))
  95. bad = (b or {}).get('numbers') or []
  96. except Exception:
  97. pass
  98. return dict(blocks=blocks, narrative=narrative, model=model,
  99. secs=round(_t.time() - t0, 1), ungrounded=bad, raw=txt[:900])
  100. # ── 本体实时查询路由 ────────────────────────────────────────────────
  101. # 只收**纯读**的几个: 抢修链 / 故障树 / 预防链 / 对象取值 / 检索。
  102. # 不收写操作, 也不收需要重算的重接口 —— 这台是 2 核 1G, 边界要自己划清。
  103. _ONT = {'/api/ont_chain', '/api/ont_obj', '/api/ont_query', '/api/rpt_compose'}
  104. # ── 公网出口统一脱敏 ────────────────────────────────────────────────
  105. # 本服务在 nginx `/api/` 后面**裸奔**: 任何人 curl 即可调, 不需要任何凭证。
  106. # 而上云闸 (windscada_cloud_pack) 只管住**烤进 HTML 的那份** —— 页面的"实时优先"
  107. # 策略正好从闸旁边绕过去。2026-09-03 实逮:
  108. # curl http://<公网IP>/api/ont_chain?kind=fault&code=3225
  109. # → OEM 实名「上海电气」· 机型号 SWT-4.0-130 · 业主资料目录全路径
  110. # 「4.0MW各部件资料/SB540-A06 液压盘式制动器用户手册.pdf」
  111. # ⇒ 出口必须自己带闸, 不能指望调用方脱敏。
  112. # ★导入失败 = 拒绝启动。安全闸不可用时放行, 比没有闸更坏 (看着有闸, 实则没有)。
  113. from src.windscada import deid_public as _dp
  114. def _scrub_deep(o):
  115. """递归脱敏 value (不动 key —— key 是结构, 换了下游取不到)。"""
  116. if isinstance(o, str):
  117. return _dp.scrub(o)
  118. if isinstance(o, list):
  119. return [_scrub_deep(x) for x in o]
  120. if isinstance(o, dict):
  121. return {k: _scrub_deep(v) for k, v in o.items()}
  122. return o
  123. def _ont_call(path, q, lang='zh'):
  124. """按 path+参数派发到 mcp_server; 语言投影与主服务同口径。"""
  125. from src.ontology import mcp_server as _m
  126. if path == '/api/rpt_compose':
  127. if q.get('probe'):
  128. return dict(ok=True) # 前端探活: 返 200 即表示本部署有编排能力
  129. return rpt_compose(q.get('need', ''),
  130. [x for x in (q.get('canvas') or '').split(',') if x],
  131. q.get('model') or next(iter(_avail()), ''), lang)
  132. if path == '/api/ont_chain':
  133. kind = q.get('kind', 'fault')
  134. if kind == 'fault':
  135. r = _m.ont_chain_fault(code=q.get('code', ''))
  136. elif kind == 'tree':
  137. r = _m.ont_fault_tree(system=q.get('system', ''))
  138. elif kind == 'prev':
  139. r = _m.ont_chain_preventive(system=q.get('system', ''))
  140. elif kind == 'plan':
  141. r = _m.ont_maint_plan(top=int(q.get('top', 12)))
  142. else:
  143. return dict(err='unknown kind %r' % kind)
  144. elif path == '/api/ont_obj':
  145. r = _m.ont_get(id=q.get('id', ''))
  146. else:
  147. r = _m.ont_query(**{k: v for k, v in q.items() if k in ('type', 'q', 'limit')})
  148. if lang and lang != 'zh':
  149. try:
  150. from src.windscada import ui_en as _ue # 数据层英文投影
  151. r = _json_walk(r, _ue)
  152. except Exception:
  153. pass
  154. return r
  155. def _json_walk(o, ue):
  156. """英文投影。**先做 key_en → key 的字段投影, 再做字符串替换。**
  157. ★只做字符串替换是不够的: 本体里工单等对象**自带 name_en / action_en**, 那是
  158. 权威译名 (来自厂商对译表与构词表), 比任何事后翻译都准。首版漏了这一步, 于是
  159. 工单名一路是中文 ("叶片维修"), 而 name_en 明明就在同一个 dict 里。
  160. """
  161. if isinstance(o, dict):
  162. # ① 字段投影: 有 x_en 就用它顶掉 x, 并丢掉 _zh 副本 (与主服务 _proj 同口径)
  163. out = {}
  164. # ★只有 k_en **有值**时才让裸 k 让位。首版无条件让位, 于是 name_en 为空的行
  165. # 直接显示成 "None" —— 比留中文糟得多。空就回落原文, 再走字符串替换。
  166. langed = {k[:-3] for k in o if k.endswith('_en') and o[k] not in (None, '', [])}
  167. for k, v in o.items():
  168. if k.endswith('_zh'):
  169. continue
  170. if k.endswith('_en'):
  171. if v not in (None, '', []):
  172. out[k[:-3]] = _json_walk(v, ue)
  173. continue
  174. if k in langed: # 已有非空 k_en, 裸 k 让位
  175. continue
  176. out[k] = _json_walk(v, ue)
  177. return out
  178. return _json_walk_str(o, ue)
  179. def _json_walk_str(o, ue):
  180. """字符串与列表的英文替换; 与主服务 _en_deep 同语义。"""
  181. if isinstance(o, str):
  182. if not any('\u4e00' <= c <= '\u9fff' for c in o):
  183. return o
  184. # 与主服务 _data_text_en 同顺序: 显示层值表 → 数据层整串/模板 → 短语层
  185. v = ue.VALUES.get(o)
  186. if v:
  187. return v
  188. try:
  189. from src.windscada import data_tpl_en as _t
  190. v = _t.NOTES.get(o) or _t.render(o)
  191. if v:
  192. return v
  193. except Exception:
  194. pass
  195. return ue.apply(o)
  196. if isinstance(o, list):
  197. return [_json_walk(x, ue) for x in o]
  198. return o
  199. ASK = dict(state='idle', q='', model='', answer='', err='', t0=0, step='', steps=[], verify=None, lang='zh')
  200. LOCK = threading.Lock()
  201. def _avail():
  202. return {k: v for k, v in MODELS.items() if os.environ.get(v['env'], '').strip()}
  203. _CJK_RE = __import__('re').compile(r'[\u3400-\u9fff\u3000-\u303f\uff00-\uffef]')
  204. _REF_RE = __import__('re').compile(r'\[\s*(?:object id|source|id|对象id|对象|来源|出处)?\s*[::]?\s*([a-z]+/[A-Za-z0-9_/.\-]+)\s*\]', __import__('re').I)
  205. _STEP_KEY_EN = {
  206. '启动': 'Starting',
  207. '预取': 'Prefetch',
  208. '推理': 'Reasoning',
  209. '取证': 'Evidence retrieval',
  210. '成文': 'Drafting',
  211. '校闸': 'Grounding gate',
  212. '交叉审核': 'Cross-review',
  213. '完成': 'Done',
  214. '失败': 'Failed',
  215. }
  216. def _verify_answer(ans, lang):
  217. try:
  218. from ontology_p2_verify import verify as _verify
  219. return _verify(ans or '', lang='en' if lang == 'en' else 'zh')
  220. except Exception as e:
  221. refs = list(dict.fromkeys(_REF_RE.findall(ans or '')))
  222. return dict(引用id数=len(refs), 全通过=False, 引用了判级对象=False, 接地闸出现=False,
  223. 明细=[], err=str(e)[:120])
  224. def _force_english_answer(ans, model):
  225. """Last-mile English QA: rewrite only if the model leaked CJK into the public answer."""
  226. if not _CJK_RE.search(ans or ''):
  227. return ans
  228. refs0 = set(_REF_RE.findall(ans or ''))
  229. msgs = [
  230. dict(role='system', content=(
  231. 'Rewrite the supplied wind-turbine diagnostic answer for a public English page. '
  232. 'Output English only. Do not output Chinese, Japanese or Korean characters. '
  233. 'Preserve every square-bracket object id, unit id, code number, date and numeric value exactly. '
  234. 'Do not add new claims.')),
  235. dict(role='user', content=ans or ''),
  236. ]
  237. txt = F._chat(model, msgs, tools=[], npred=2600) or {}
  238. if isinstance(txt, dict):
  239. txt = txt.get('content') or txt.get('text') or ''
  240. txt = str(txt).strip()
  241. refs1 = set(_REF_RE.findall(txt))
  242. if txt and not _CJK_RE.search(txt) and refs0.issubset(refs1):
  243. return txt
  244. return ans
  245. def _public_step_text(k, v='', lang='zh'):
  246. """Render live model progress for the selected UI language.
  247. The public English page polls /api/ask_status while the model is running.
  248. Those short progress labels used to bypass answer-level translation and
  249. surfaced Chinese such as "推理 · 第 2/5 轮". Keep this helper deliberately
  250. small and conservative: it translates known progress labels and replaces
  251. unknown Chinese detail with a neutral English progress marker.
  252. """
  253. k = '' if k is None else str(k)
  254. v = '' if v is None else str(v)
  255. if lang != 'en':
  256. return f'{k} · {v}' if v else k
  257. key = _STEP_KEY_EN.get(k, k)
  258. detail = v.strip()
  259. m = re.search(r'第\s*(\d+\s*/\s*\d+)\s*轮', detail)
  260. if m:
  261. detail = 'round ' + m.group(1).replace(' ', '')
  262. else:
  263. m = re.search(r'(\d{1,2})\s*一站式证据包', detail)
  264. if m:
  265. alias = 'focus unit'
  266. try:
  267. from src.windscada.deid import unitize_answer
  268. alias = unitize_answer(f"WTG{int(m.group(1)):02d}")
  269. except Exception:
  270. pass
  271. detail = f'{alias} evidence pack'
  272. elif '闭环' in detail and '证据' in detail:
  273. n = ''.join(re.findall(r'\d+', detail))
  274. detail = f'closure and trend evidence: {n} records' if n else 'closure and trend evidence'
  275. elif '全场' in detail and '判级' in detail:
  276. n = ''.join(re.findall(r'\d+', detail))
  277. detail = f'fleet ratings: {n} units' if n else 'fleet ratings'
  278. elif '换模型' in detail or '复核' in detail:
  279. detail = 'reviewing with another model'
  280. elif _CJK_RE.search(detail):
  281. detail = ''
  282. if _CJK_RE.search(key):
  283. key = 'Working'
  284. if _CJK_RE.search(detail):
  285. detail = ''
  286. return f'{key} · {detail}' if detail else key
  287. def _worker(q, model, lang='zh'):
  288. def on_step(k, v):
  289. text = _public_step_text(k, v, lang)
  290. ASK['step'] = text
  291. ASK['steps'].append(text)
  292. try:
  293. if lang == 'en':
  294. try:
  295. from src.windscada.deid import ununitize_question
  296. q = ununitize_question(q)
  297. except Exception as e:
  298. print(f'[ask] 代号反解失败: {e}', flush=True)
  299. # 语言指令走 F.answer 的 lang 参数 (进 system 消息); 问题本身不加前缀 ——
  300. # 前缀在用户侧压不过中文 SYSTEM, 实测无效。
  301. r = F.answer(q, model=model, max_rounds=5, on_step=on_step, lang=lang)
  302. ans = r.get('answer') or r.get('text') or ''
  303. # 返回前换成代号: 页面显示 A56, 答案里也必须是 A56。只在**出口**换 ——
  304. # 发给模型的消息必须保留原始台号, 否则它调检索工具查不到东西。
  305. try:
  306. from src.windscada.deid import unitize_answer
  307. ans = unitize_answer(ans)
  308. except Exception as e:
  309. print(f'[ask] 代号映射失败: {e}', flush=True)
  310. if lang == 'en' and _CJK_RE.search(ans):
  311. ASK['step'] = 'English QA · rewriting CJK leakage'
  312. ans = _force_english_answer(ans, model)
  313. ver = _verify_answer(ans, lang)
  314. if lang == 'en' and _CJK_RE.search(ans):
  315. ASK.update(state='error', err='English QA failed: the model returned non-English text, so the answer was withheld.',
  316. answer='', verify=ver, grounded=r.get('grounded'), step='English QA failed')
  317. return
  318. ASK.update(state='done', answer=ans, verify=ver, grounded=r.get('grounded'), step='完成' if lang != 'en' else 'Done')
  319. except Exception as e:
  320. ASK.update(state='error', err=f'{type(e).__name__}: {str(e)[:200]}', step='失败')
  321. class H(BaseHTTPRequestHandler):
  322. def _send(self, obj, code=200):
  323. b = json.dumps(_scrub_deep(obj), ensure_ascii=False).encode() # 公网出口: 一律过脱敏
  324. self.send_response(code)
  325. self.send_header('Content-Type', 'application/json; charset=utf-8')
  326. self.send_header('Content-Length', str(len(b)))
  327. self.end_headers()
  328. self.wfile.write(b)
  329. def log_message(self, *a):
  330. pass # 默认日志把每条请求打到 stderr, systemd 里没意义
  331. def do_GET(self):
  332. u = urllib.parse.urlparse(self.path)
  333. if u.path == '/api/ask_status':
  334. d = dict(ASK)
  335. # ★单槽位设计 (2 核机器, 一次一问) 的副作用: 上一问的答案**不分语言**
  336. # 两个页面都看得到 —— 中文页上会显示英文测试留下的答案 (2026-09-01 中文版
  337. # 点检活逮)。这里按语言隔离: 语言不匹配就当没问过, 不把别人的答案端给你。
  338. # 不改单槽位本身 —— 那是为 2 核机器有意选的, 改成多槽会互相拖到都超时。
  339. _want = (urllib.parse.parse_qs(u.query).get('lang') or ['zh'])[0]
  340. if d.get('answer') and (d.get('lang') or 'zh') != _want:
  341. d.update(state='idle', q='', answer='', err='', step='', steps=[], verify=None)
  342. d['elapsed'] = int(time.time() - ASK['t0']) if ASK['t0'] else 0
  343. d['models'] = _avail()
  344. self._send(d)
  345. elif u.path == '/api/ask_models':
  346. self._send(dict(models=_avail()))
  347. elif u.path in _ONT:
  348. # ★本体实时查询 (2026-09-01)。此前这些只走**快照预烤**, 而预烤的报警码
  349. # 被 limit=60 截断 —— 全场 555 个码只烤了按码号排序的前 60 个, 页面上
  350. # "点一下就查"的示例码 (3225/64000/64001/4110/13121) 恰好都不在里面
  351. # ⇒ 抢修链点下去整条没反应。接成实时查就不再受"烤了多少"限制。
  352. # 纯读 objects.json + 已装的 pandas, 2 核 1G 扛得住 (实测 64001 秒级返回)。
  353. q = {k: v[0] for k, v in urllib.parse.parse_qs(u.query).items()}
  354. lang = q.pop('lang', 'zh')
  355. try:
  356. self._send(_ont_call(u.path, q, lang))
  357. except Exception as e:
  358. self._send(dict(err='%s: %s' % (type(e).__name__, str(e)[:160])), 500)
  359. else:
  360. self._send(dict(err='not found'), 404)
  361. def do_POST(self):
  362. if urllib.parse.urlparse(self.path).path != '/api/ask':
  363. return self._send(dict(err='not found'), 404)
  364. n = int(self.headers.get('Content-Length') or 0)
  365. body = json.loads(self.rfile.read(n) or b'{}')
  366. q = (body.get('q') or '').strip()
  367. model = body.get('model') or next(iter(_avail()), '')
  368. _qs = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
  369. lang = (body.get('lang') or (_qs.get('lang') or [''])[0] or 'zh').strip()
  370. if not q:
  371. return self._send(dict(err='问题为空' if lang != 'en' else 'The question is empty'))
  372. if model not in _avail():
  373. msg = (f'Model {model} is not configured; available: {list(_avail())}' if lang == 'en'
  374. else f'模型 {model} 未配置密钥; 可用 {list(_avail())}')
  375. return self._send(dict(err=msg))
  376. with LOCK: # 串行锁: 2 核机器, 并发推理会互相拖慢到都超时
  377. if ASK['state'] == 'running':
  378. return self._send(dict(err='已有问题在推理中 (一次一问)', state='running'))
  379. ASK.update(state='running', q=body.get('display_q') or q, model=model, answer='', err='',
  380. t0=time.time(), step='启动' if lang != 'en' else 'Starting', steps=[], lang=lang, verify=None)
  381. threading.Thread(target=_worker, args=(q, model, lang), daemon=True).start()
  382. self._send(dict(state='running', model=model))
  383. if __name__ == '__main__':
  384. port = int(sys.argv[1]) if len(sys.argv) > 1 else 8033
  385. av = _avail()
  386. if not av:
  387. print('[ask] 警告: 没有任何模型配了密钥, /api/ask 会全部拒绝', file=sys.stderr)
  388. print(f'[ask] :{port} 可用模型 {list(av)}', file=sys.stderr, flush=True)
  389. ThreadingHTTPServer(('127.0.0.1', port), H).serve_forever()