server.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598
  1. # -*- coding: utf-8 -*-
  2. """搜索评估案例查看 server。
  3. 沿用 图文排版搜索评估.html 的版式(卡片 + dialog 详情 + rubric 评分条),
  4. 数据实时扫描 runs_full/*/form_*.json —— runs_full 下每新增一个 q 文件夹,刷新即出现。
  5. 分页:query → 三种形式(A/B/C) → 三个渠道 三行从上到下。
  6. 用法:python server.py [port] 默认 8770,浏览器开 http://0.0.0.0:8770
  7. """
  8. import json, re, glob, sys, pathlib, subprocess
  9. from datetime import datetime
  10. from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
  11. try: # Windows 控制台默认 cp1252,中文 print 会崩,统一切 utf-8
  12. sys.stdout.reconfigure(encoding="utf-8")
  13. except Exception:
  14. pass
  15. HERE = pathlib.Path(__file__).parent
  16. PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8770
  17. PLAT = {"xhs": "小红书", "gzh": "公众号", "zhihu": "知乎", "x": "X", "bili": "B站", "douyin": "抖音",
  18. "sph": "视频号", "youtube": "YouTube", "github": "GitHub", "toutiao": "头条", "weibo": "微博"}
  19. KT = {"procedure": "工序", "step": "步骤", "tool": "工具"}
  20. # 从 taxonomy 取动作叶子/类型名,用于把 original_q 解析回原始维度(动作×类型 正交)
  21. # 路径优先级:search_eval/evaluation/(主源,IDE 编辑那份就是 runtime 实际读的)
  22. # → test_script/evaluation/(历史副本兜底)→ script/evaluation/(更老兜底)
  23. # 谁也找不到时整目录扫空,server 仍能起。
  24. EVALDIR = HERE / "evaluation"
  25. if not EVALDIR.exists():
  26. EVALDIR = HERE.parent.parent / "test_script" / "evaluation"
  27. if not EVALDIR.exists():
  28. EVALDIR = HERE.parent / "evaluation"
  29. try:
  30. _jm = json.load(open(EVALDIR / "judged_matrix.json", encoding="utf-8"))
  31. ACT_L1 = {a["name"]: a["l1"] for a in _jm["actions"]}
  32. ACTION_SET = set(ACT_L1)
  33. TYPE_SET = {t["name"] for t in _jm["types"]}
  34. ACTIONS_TAX = [{"name": a["name"], "l1": a["l1"], "l2": a.get("l2", "")} for a in _jm["actions"]]
  35. TYPES_TAX = [{"name": t["name"], "l1": t["l1"]} for t in _jm["types"]]
  36. # taxonomy 顺序沿用 judged_matrix(严格版);矩阵分值改用 type_action_scores(宽松版) —
  37. # 两份是同一组 27×50 cell 的独立 gemini judging,前者只 53 格到 tier3,后者 156 格到 score3
  38. _tas = json.load(open(EVALDIR / "type_action_scores.json", encoding="utf-8"))["scores"]
  39. _MATRIX = []
  40. for a in _jm["actions"]:
  41. row = []
  42. for t in _jm["types"]:
  43. rec = _tas.get(t["name"], {}).get(a["name"])
  44. row.append({"tier": rec["score"], "r": rec.get("reason", "")} if rec else {})
  45. _MATRIX.append(row)
  46. except Exception:
  47. ACT_L1, ACTION_SET, TYPE_SET, ACTIONS_TAX, TYPES_TAX, _MATRIX = {}, set(), set(), [], [], []
  48. MODSET = {"文", "图", "视频", "音频"}
  49. TOOLQUAL = {"AI": "AI 模型", "软件": "桌面 APP", "电脑端": "桌面 APP", "在线": "云端 Web",
  50. "网页版": "云端 Web", "代码": "API·CLI", "命令行": "API·CLI", "插件": "插件扩展"}
  51. def parse_dims(oq):
  52. """把组合 query(如 '文 元素生成 提示词 教程')解析回 {动作, 类型, 动作L1, 约束}。"""
  53. toks = (oq or "").split()
  54. action = next((t for t in toks if t in ACTION_SET), None)
  55. type_ = next((t for t in toks if t in TYPE_SET), None)
  56. cons = None
  57. if toks:
  58. t0 = toks[0]
  59. if t0 in MODSET:
  60. cons = {"kind": "模态", "value": t0}
  61. elif t0 in TOOLQUAL:
  62. cons = {"kind": "工具类型", "value": TOOLQUAL[t0]}
  63. return {"action": action, "type": type_, "action_l1": ACT_L1.get(action, ""), "constraint": cons}
  64. def flat_scores(sc):
  65. f = {}
  66. for k, v in (sc or {}).items():
  67. if isinstance(v, dict):
  68. for kk, vv in v.items():
  69. try: f[kk] = int(vv)
  70. except Exception: pass
  71. else:
  72. try: f[k] = int(v)
  73. except Exception: pass
  74. return f
  75. def _recency_hard(date_str):
  76. """按 publish_timestamp 头 10 字符(YYYY-MM-DD)算硬时效:半年内=3 / 两年内=2 / 更早=1。
  77. 取代原 LLM 评的 recency 维度——脚本算更稳,发布时间在帖子抓取时就有,无需 LLM token。
  78. """
  79. try:
  80. d = datetime.strptime((date_str or "")[:10], "%Y-%m-%d")
  81. except (ValueError, TypeError):
  82. return None
  83. days = (datetime.now() - d).days
  84. if days <= 180: return 3
  85. if days <= 730: return 2
  86. return 1
  87. def adapt(r, run, form_name=None):
  88. p = r.get("post", {}); e = r.get("llm_evaluation", {})
  89. # 1. 解析 知识类型 (knowledge_type)
  90. kt = []
  91. kt_raw = e.get("知识类型") or e.get("knowledge_type") or []
  92. for k in kt_raw:
  93. if k in ("工序", "procedure"): kt.append("procedure")
  94. elif k in ("能力", "步骤", "step"): kt.append("step")
  95. elif k in ("工具", "tool"): kt.append("tool")
  96. fs = {}
  97. score_reasons = {}
  98. # 检测是否为 eval_prompt_sample-mod 里的新版 0-10 分数 schema
  99. is_mod_schema = "相关性" in e and isinstance(e["相关性"], dict) and ("和内容制作知识相关" in e["相关性"] or "和 query 相关" in e["相关性"])
  100. if is_mod_schema:
  101. # 新版 0-10 分数格式解析
  102. # 1. 相关性
  103. rel = e.get("相关性") or {}
  104. for subkey, item in rel.items():
  105. if isinstance(item, dict):
  106. score_val = item.get("得分")
  107. reason_val = item.get("理由")
  108. code_key = None
  109. if "内容制作" in subkey or "知识" in subkey:
  110. code_key = "relevance_production"
  111. elif "query" in subkey or "检索" in subkey:
  112. code_key = "relevance_query"
  113. if code_key and score_val is not None:
  114. try:
  115. fs[code_key] = float(score_val)
  116. if reason_val:
  117. score_reasons[code_key] = reason_val
  118. except Exception:
  119. pass
  120. # 2. 质量
  121. q_block = e.get("质量") or {}
  122. fixed = q_block.get("固定维度") or {}
  123. # 固定维度
  124. fixed_keys = {
  125. "时效性": "recency",
  126. "热度性": "popularity",
  127. "评论反馈": "feedback"
  128. }
  129. for cn, code in fixed_keys.items():
  130. item = fixed.get(cn)
  131. if isinstance(item, dict):
  132. score_val = item.get("得分")
  133. reason_val = item.get("理由")
  134. if score_val is not None:
  135. try:
  136. fs[code] = float(score_val)
  137. if reason_val:
  138. score_reasons[code] = reason_val
  139. except Exception:
  140. pass
  141. # 用例 (真实感, 表现力)
  142. usecase = fixed.get("用例") or {}
  143. usecase_keys = {
  144. "真实感": "realism",
  145. "表现力": "expressiveness"
  146. }
  147. for cn, code in usecase_keys.items():
  148. item = usecase.get(cn)
  149. if isinstance(item, dict):
  150. score_val = item.get("得分")
  151. reason_val = item.get("理由")
  152. if score_val is not None:
  153. try:
  154. fs[code] = float(score_val)
  155. if reason_val:
  156. score_reasons[code] = reason_val
  157. except Exception:
  158. pass
  159. # 动态维度
  160. dynamic = q_block.get("动态维度") or {}
  161. # 工序
  162. proc = dynamic.get("工序") or {}
  163. if proc:
  164. item = proc.get("流程完整性")
  165. if isinstance(item, dict):
  166. score_val = item.get("得分")
  167. reason_val = item.get("理由")
  168. if score_val is not None:
  169. try:
  170. fs["procedure_completeness"] = float(score_val)
  171. if reason_val:
  172. score_reasons["procedure_completeness"] = reason_val
  173. except Exception:
  174. pass
  175. field = proc.get("字段完整性") or {}
  176. field_keys = {
  177. "输入完整性": "procedure_input",
  178. "实现完整性": "procedure_implementation",
  179. "输出完整性": "procedure_output"
  180. }
  181. for cn, code in field_keys.items():
  182. item = field.get(cn)
  183. if isinstance(item, dict):
  184. score_val = item.get("得分")
  185. reason_val = item.get("理由")
  186. if score_val is not None:
  187. try:
  188. fs[code] = float(score_val)
  189. if reason_val:
  190. score_reasons[code] = reason_val
  191. except Exception:
  192. pass
  193. item = proc.get("泛化性")
  194. if isinstance(item, dict):
  195. score_val = item.get("得分")
  196. reason_val = item.get("理由")
  197. if score_val is not None:
  198. try:
  199. fs["procedure_generality"] = float(score_val)
  200. if reason_val:
  201. score_reasons["procedure_generality"] = reason_val
  202. except Exception:
  203. pass
  204. # 能力
  205. cap = dynamic.get("能力") or dynamic.get("步骤") or {}
  206. if cap:
  207. field = cap.get("字段完整性") or {}
  208. field_keys = {
  209. "输入完整性": "step_input",
  210. "实现完整性": "step_implementation",
  211. "输出完整性": "step_output"
  212. }
  213. for cn, code in field_keys.items():
  214. item = field.get(cn)
  215. if isinstance(item, dict):
  216. score_val = item.get("得分")
  217. reason_val = item.get("理由")
  218. if score_val is not None:
  219. try:
  220. fs[code] = float(score_val)
  221. if reason_val:
  222. score_reasons[code] = reason_val
  223. except Exception:
  224. pass
  225. item = cap.get("泛化性")
  226. if isinstance(item, dict):
  227. score_val = item.get("得分")
  228. reason_val = item.get("理由")
  229. if score_val is not None:
  230. try:
  231. fs["step_generality"] = float(score_val)
  232. if reason_val:
  233. score_reasons["step_generality"] = reason_val
  234. except Exception:
  235. pass
  236. # 工具
  237. tool = dynamic.get("工具") or {}
  238. if tool:
  239. tool_keys = {
  240. "能力边界覆盖": "tool_boundary",
  241. "有效比较": "tool_comparison",
  242. "参数/接口具体性": "tool_specificity",
  243. "实操示例": "tool_example",
  244. "版本&限制": "tool_limits"
  245. }
  246. for cn, code in tool_keys.items():
  247. item = tool.get(cn)
  248. if isinstance(item, dict):
  249. score_val = item.get("得分")
  250. reason_val = item.get("理由")
  251. if score_val is not None:
  252. try:
  253. fs[code] = float(score_val)
  254. if reason_val:
  255. score_reasons[code] = reason_val
  256. except Exception:
  257. pass
  258. else:
  259. # 兼容老版 1-5 分数 schema (带 "评分" 或 old-style flatness)
  260. is_new_schema = "评分" in e or "知识类型" in e or "制作相关性" in e
  261. CN_TO_EN = {
  262. "相关性": "relevance",
  263. "成品质量": "result_quality",
  264. "可信度": "credibility",
  265. "具体用例": "concrete_use_case",
  266. "完整性": "completeness",
  267. "步骤结构": "step_structure",
  268. "步骤可复现": "step_reproducibility",
  269. "步骤可复现性": "step_reproducibility",
  270. "能力定义": "capability_definition",
  271. "实现深度": "implementation_depth",
  272. "边界失败": "boundary_failure_eval",
  273. "通用性": "generality",
  274. "能力覆盖": "capability_coverage",
  275. "有效对比": "effective_comparison",
  276. "参数具体": "param_specificity",
  277. "实操示例": "worked_example",
  278. "实操用例": "worked_example",
  279. "示例完整": "worked_example",
  280. "版本限制": "version_limits",
  281. "版本说明": "version_limits",
  282. "限制说明": "version_limits",
  283. }
  284. if is_new_schema:
  285. pf = e.get("评分") or {}
  286. for cat, metrics in pf.items():
  287. if isinstance(metrics, dict):
  288. for metric, val in metrics.items():
  289. en_key = CN_TO_EN.get(metric, metric)
  290. if isinstance(val, dict) and "得分" in val:
  291. try: fs[en_key] = int(val["得分"])
  292. except Exception: pass
  293. elif isinstance(val, (int, float)):
  294. fs[en_key] = int(val)
  295. if isinstance(val, dict) and "理由" in val:
  296. score_reasons[en_key] = val["理由"]
  297. else:
  298. fs = flat_scores(e.get("scores", {}))
  299. # 计算均分 (overall)
  300. if is_mod_schema:
  301. rel_keys = {"relevance_production", "relevance_query"}
  302. rel_vals = [v for k, v in fs.items() if k in rel_keys]
  303. qual_vals = [v for k, v in fs.items() if k not in rel_keys]
  304. rel_avg = sum(rel_vals) / len(rel_vals) if rel_vals else None
  305. qual_avg = sum(qual_vals) / len(qual_vals) if qual_vals else None
  306. if rel_avg is not None and qual_avg is not None:
  307. overall = round((rel_avg + qual_avg) / 2, 1)
  308. elif rel_avg is not None:
  309. overall = round(rel_avg, 1)
  310. elif qual_avg is not None:
  311. overall = round(qual_avg, 1)
  312. else:
  313. overall = 0.0
  314. else:
  315. overall = round(sum(fs.values()) / len(fs), 1) if fs else 0
  316. anomaly = bool(e.get("error")) or not fs
  317. grade = p.get("_quality_grade", "")
  318. fb = r.get("found_by_queries", [])
  319. # 4. 解析 制作相关性 (production_relevance)
  320. if is_mod_schema:
  321. # 新版使用 "相关性" 中的 "和内容制作知识相关" 代表制作相关性
  322. production_relevance = fs.get("relevance_production")
  323. else:
  324. if is_new_schema:
  325. pr_block = e.get("制作相关性") or {}
  326. pr_raw = pr_block.get("得分") if isinstance(pr_block, dict) else pr_block
  327. if isinstance(pr_block, dict) and "理由" in pr_block:
  328. score_reasons["production_relevance"] = pr_block["理由"]
  329. else:
  330. pr_raw = e.get("production_relevance")
  331. try: production_relevance = int(float(pr_raw)) if pr_raw is not None else None
  332. except (TypeError, ValueError): production_relevance = None
  333. recency_hard = _recency_hard(p.get("publish_timestamp", ""))
  334. # 5. 解析 判定决策 (decision) 和 理由 (reason)
  335. reason = e.get("判定理由") or e.get("reason") or ""
  336. # 根据过滤指标决定是否保留 (过滤指标判定逻辑优先,不依赖文字匹配)
  337. is_discard = False
  338. # 制作相关性低于阈值则丢弃 (新版 0-10 满分,因此低于 4 丢弃;老版低于 2 丢弃)
  339. if production_relevance is not None:
  340. threshold = 4 if is_mod_schema else 2
  341. if production_relevance < threshold:
  342. is_discard = True
  343. # 时效性低于 2 被丢弃(发布时间超两年的老帖)
  344. if recency_hard is not None and recency_hard < 2:
  345. is_discard = True
  346. # 综合均分低于阈值被丢弃 (新版低于 6 丢弃;老版低于 3 丢弃)
  347. if overall is not None:
  348. threshold_ov = 6 if is_mod_schema else 3
  349. if overall < threshold_ov:
  350. is_discard = True
  351. decision = "discard" if is_discard else "report"
  352. # Find matching procedure html
  353. procedure_html = None
  354. case_id = r.get("case_id", "")
  355. title = p.get("title", "")
  356. run_dir = HERE / "runs_full" / run
  357. if run_dir.is_dir():
  358. # 1. 优先扫描该帖子对应的文件夹下的任何 HTML 文件 (不限名称)
  359. # 文件夹名格式: {form}_{platform}_{channel_content_id[:8]}
  360. content_id = r.get("channel_content_id") or ""
  361. if not content_id and case_id and "_" in case_id:
  362. content_id = case_id.split("_", 1)[1]
  363. plat_key = r.get("platform") or ""
  364. if form_name and plat_key and content_id:
  365. folder_name = f"{form_name}_{plat_key}_{content_id[:8]}"
  366. case_dir = run_dir / "procedures" / folder_name
  367. if case_dir.is_dir():
  368. html_files = list(case_dir.glob("*.html"))
  369. if html_files:
  370. procedure_html = f"runs_full/{run}/procedures/{folder_name}/{html_files[0].name}"
  371. # 2. 其次匹配标准文件名: case-{case_id}.html 或 {case_id}.html
  372. candidate_dirs = [run_dir, run_dir / "procedures"]
  373. if not procedure_html and case_id:
  374. named_files = [f"case-{case_id}.html", f"{case_id}.html"]
  375. for d_dir in candidate_dirs:
  376. if d_dir.is_dir():
  377. for name in named_files:
  378. if (d_dir / name).is_file():
  379. procedure_html = f"runs_full/{run}/procedures/{name}" if d_dir.name == "procedures" else f"runs_full/{run}/{name}"
  380. break
  381. if procedure_html:
  382. break
  383. # 3. 再次匹配 HTML 内部的标准声明 (meta 标签或 HTML 注释)
  384. if not procedure_html and case_id:
  385. for d_dir in candidate_dirs:
  386. if d_dir.is_dir():
  387. for html_path in d_dir.glob("*.html"):
  388. try:
  389. content = html_path.read_text(encoding="utf-8")
  390. if f'name="case-id" content="{case_id}"' in content or \
  391. f'name="case_id" content="{case_id}"' in content or \
  392. f'<!-- case_id: {case_id} -->' in content or \
  393. f'<!-- case-id: {case_id} -->' in content:
  394. procedure_html = f"runs_full/{run}/procedures/{html_path.name}" if d_dir.name == "procedures" else f"runs_full/{run}/{html_path.name}"
  395. break
  396. except Exception:
  397. continue
  398. if procedure_html:
  399. break
  400. # 4. 最后使用标题作为兜底模糊匹配
  401. if not procedure_html and title:
  402. for d_dir in candidate_dirs:
  403. if d_dir.is_dir():
  404. for html_path in d_dir.glob("*.html"):
  405. try:
  406. content = html_path.read_text(encoding="utf-8")
  407. if title in content:
  408. procedure_html = f"runs_full/{run}/procedures/{html_path.name}" if d_dir.name == "procedures" else f"runs_full/{run}/{html_path.name}"
  409. break
  410. except Exception:
  411. continue
  412. if procedure_html:
  413. break
  414. return {
  415. "platform": PLAT.get(r.get("platform"), r.get("platform")), "platformKey": r.get("platform"),
  416. "title": p.get("title", "") or "(无标题)", "date": (p.get("publish_timestamp", "") or "")[:10],
  417. "url": r.get("source_url", ""), "engagement": f'{p.get("like_count", 0)} 赞',
  418. "knowledge_type": kt, "decision": decision,
  419. "tools": [KT.get(k, k) for k in kt] + ([f"质量 {grade}"] if grade else []), "found_by": fb,
  420. "images": (p.get("images") or [])[:6], "text": p.get("body_text", "") or "",
  421. "scores": fs, "overall": overall, "reason": reason, "score_reasons": score_reasons,
  422. "grade": grade, "qscore": p.get("_quality_score", 0), "anomaly": anomaly,
  423. "production_relevance": production_relevance, "recency_hard": recency_hard,
  424. "run": run, "procedure_html": procedure_html,
  425. }
  426. def scan_runs():
  427. runs = {}
  428. for f in sorted(glob.glob(str(HERE / "runs_full" / "*" / "form_*.json"))):
  429. try:
  430. d = json.load(open(f, encoding="utf-8"))
  431. except Exception:
  432. continue
  433. run = pathlib.Path(f).parent.name
  434. form_name = d.get("form") or ""
  435. results = [adapt(r, run, form_name) for r in d.get("results", [])]
  436. report_val = sum(1 for r in results if r.get("decision") == "report" and not r.get("anomaly"))
  437. discard_val = sum(1 for r in results if r.get("decision") == "discard" and not r.get("anomaly"))
  438. runs.setdefault(run, []).append({
  439. "form": d.get("form"), "query": d.get("query"), "original_q": d.get("original_q", ""),
  440. "requirement": d.get("requirement", ""),
  441. "platforms": d.get("platforms", []), "total": d.get("total"),
  442. "report": report_val, "discard": discard_val,
  443. "results": results,
  444. })
  445. for v in runs.values():
  446. v.sort(key=lambda x: x.get("form") or "")
  447. def _qnum(name): # "q156" → 156,按数字排,避免 "q156" < "q99" 的字符串误排
  448. m = re.search(r"\d+", name)
  449. return (int(m.group()) if m else 0, name)
  450. out = []
  451. for k, v in sorted(runs.items(), key=lambda kv: _qnum(kv[0])):
  452. oq = v[0].get("original_q") or v[0].get("query") or ""
  453. seen, hits = set(), 0 # 知识命中数 = 各形式采纳(report)且非异常、按 url 去重后的帖子数
  454. for f in v:
  455. for r in f.get("results", []):
  456. if r.get("decision") == "report" and not r.get("anomaly") and r.get("url") not in seen:
  457. seen.add(r.get("url")); hits += 1
  458. out.append({"key": k, "forms": v, "dims": parse_dims(oq), "original_q": oq,
  459. "hits": hits, "tot": sum((f.get("total") or 0) for f in v)})
  460. return {"queries": out, "actions": ACTIONS_TAX, "types": TYPES_TAX, "matrix": _MATRIX}
  461. class H(BaseHTTPRequestHandler):
  462. def _send(self, code, body, ctype):
  463. b = body.encode("utf-8") if isinstance(body, str) else body
  464. self.send_response(code)
  465. if ctype.startswith("text/") or ctype == "application/json" or ctype == "application/javascript":
  466. self.send_header("Content-Type", ctype + "; charset=utf-8")
  467. else:
  468. self.send_header("Content-Type", ctype)
  469. self.send_header("Content-Length", str(len(b))); self.end_headers(); self.wfile.write(b)
  470. def do_GET(self):
  471. if self.path in ("/", "/index.html"):
  472. try:
  473. page = (HERE / "index.html").read_text(encoding="utf-8")
  474. self._send(200, page, "text/html")
  475. except Exception as e:
  476. self._send(500, f"Error reading index.html: {e}", "text/plain")
  477. elif self.path.startswith("/api/data"):
  478. self._send(200, json.dumps(scan_runs(), ensure_ascii=False), "application/json")
  479. elif self.path.startswith("/runs_full/"):
  480. try:
  481. clean_path = self.path.split("?")[0]
  482. parts = clean_path.strip("/").split("/")
  483. target_file = HERE
  484. for part in parts:
  485. target_file = target_file / part
  486. runs_dir = HERE / "runs_full"
  487. if runs_dir.resolve() in target_file.resolve().parents and target_file.is_file():
  488. content = target_file.read_bytes()
  489. ext = target_file.suffix.lower()
  490. ctype = "text/html"
  491. if ext in (".png", ".webp"):
  492. ctype = f"image/{ext[1:]}"
  493. elif ext in (".jpg", ".jpeg"):
  494. ctype = "image/jpeg"
  495. elif ext == ".json":
  496. ctype = "application/json"
  497. elif ext == ".js":
  498. ctype = "application/javascript"
  499. elif ext == ".css":
  500. ctype = "text/css"
  501. self._send(200, content, ctype)
  502. else:
  503. self._send(404, "not found", "text/plain")
  504. except Exception as e:
  505. self._send(500, f"Error: {e}", "text/plain")
  506. else:
  507. self._send(404, "not found", "text/plain")
  508. def do_POST(self):
  509. # /api/reeval —— 后台启动 batch_3forms.py 只对指定 q 复评,立即返回(不等结果)
  510. # 复评是 LLM 调用、几十秒到几分钟;浏览器侧用 fetch 启动 + 提示用户稍后刷新,不阻塞
  511. if self.path != "/api/reeval":
  512. self._send(404, json.dumps({"error": "not found"}), "application/json"); return
  513. length = int(self.headers.get("Content-Length") or 0)
  514. raw = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
  515. try:
  516. payload = json.loads(raw)
  517. except Exception as e:
  518. self._send(400, json.dumps({"error": f"bad json: {e}"}), "application/json"); return
  519. q = (payload.get("q") or "").strip()
  520. # 限定 qNN 形式避免路径注入
  521. if not re.match(r"^q\d+$", q):
  522. self._send(400, json.dumps({"error": f"bad q (expect 'qNN'): {q!r}"},
  523. ensure_ascii=False), "application/json"); return
  524. q_dir = HERE / "runs_full" / q
  525. if not q_dir.is_dir():
  526. self._send(404, json.dumps({"error": f"runs_full/{q} not found"}, ensure_ascii=False),
  527. "application/json"); return
  528. # 后台跑 batch_3forms.py,stdout/stderr 合并写到 q_dir/_reeval.log(可 tail 看进度)
  529. log_path = q_dir / "_reeval.log"
  530. try:
  531. log_fh = open(log_path, "w", encoding="utf-8", buffering=1)
  532. cmd = [sys.executable, "-u", str(HERE / "batch_3forms.py"),
  533. "--reeval", "--reeval-q", q, "--output-dir", str(HERE / "runs_full")]
  534. flags = subprocess.CREATE_NEW_PROCESS_GROUP if sys.platform == "win32" else 0
  535. proc = subprocess.Popen(cmd, stdout=log_fh, stderr=subprocess.STDOUT,
  536. cwd=str(HERE), creationflags=flags)
  537. self._send(200, json.dumps(
  538. {"status": "started", "pid": proc.pid, "q": q,
  539. "log": str(log_path.relative_to(HERE))},
  540. ensure_ascii=False), "application/json")
  541. except Exception as e:
  542. self._send(500, json.dumps({"error": f"failed to start: {e}"},
  543. ensure_ascii=False), "application/json")
  544. def log_message(self, *a): pass
  545. if __name__ == "__main__":
  546. n = len(scan_runs()["queries"])
  547. print(f"搜索评估查看 server:http://0.0.0.0:{PORT} (runs_full/ 下 {n} 个 query,实时扫描)")
  548. ThreadingHTTPServer(("0.0.0.0", PORT), H).serve_forever()