evaluation_report_parser.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593
  1. from __future__ import annotations
  2. import json
  3. import re
  4. from dataclasses import dataclass
  5. from typing import Any, Literal
  6. EvaluationKind = Literal["multipath", "overall", "unknown"]
  7. @dataclass(frozen=True)
  8. class _Section:
  9. title: str
  10. body: str
  11. class EvaluationReportParser:
  12. """Parse evaluator Markdown into business-safe, deterministic fields.
  13. The parser deliberately does not accept a build or round identifier. Runtime
  14. metadata remains the only source for association; identifiers written inside
  15. a report are treated as technical prose and ignored.
  16. """
  17. VERSION = 1
  18. def parse(
  19. self,
  20. value: Any,
  21. *,
  22. kind: EvaluationKind = "unknown",
  23. ) -> dict[str, Any]:
  24. report = _report_text(value)
  25. visible = _remove_code_fences(report)
  26. sections = _sections(visible)
  27. tables = _tables(visible)
  28. branch_evaluations = _branch_evaluations(sections)
  29. comparison_rows = _comparison_rows(tables)
  30. subjects = _subjects(sections)
  31. criteria = _collect_sections(
  32. sections,
  33. ("评审标准", "评估标准", "评价标准", "评审维度", "评估维度", "评价维度", "维度评估", "领域评估维度"),
  34. )
  35. achievements = _collect_positive_sections(
  36. sections,
  37. ("已达成", "达成情况", "目标达成", "目标推进", "完成情况", "通过项", "达成项", "主要优点"),
  38. )
  39. problems = _problem_sections(sections)
  40. if not problems:
  41. inline_problem = _labeled_value(
  42. visible,
  43. ("问题详情", "主要问题", "未通过项", "遗留问题", "存疑项", "问题"),
  44. )
  45. if inline_problem:
  46. problems.append({"summary": inline_problem})
  47. item_conclusions = _item_conclusions(sections)
  48. if branch_evaluations:
  49. item_conclusions = [
  50. {
  51. "subject": item["subject"],
  52. "conclusion": item.get("conclusion"),
  53. "achievements": item.get("achievements") or [],
  54. "problems": item.get("problems") or [],
  55. }
  56. for item in branch_evaluations
  57. ]
  58. for table in tables:
  59. _merge_table(
  60. table,
  61. criteria=criteria,
  62. item_conclusions=item_conclusions,
  63. achievements=achievements,
  64. problems=problems,
  65. )
  66. problems = [
  67. item
  68. for item in problems
  69. if not _means_no_problem(str(item.get("summary") or ""))
  70. ]
  71. for item in item_conclusions:
  72. item["problems"] = [
  73. problem
  74. for problem in item.get("problems") or []
  75. if not _means_no_problem(str(problem))
  76. ]
  77. conclusion = _labeled_value(
  78. visible,
  79. ("整体结论", "总结论", "评审结论", "评估结论"),
  80. )
  81. recommendation = _section_value(
  82. sections,
  83. ("评审建议", "对比与建议", "跨方案对比", "方案对比", "采纳建议", "组合建议", "建议采用", "建议合入", "推荐"),
  84. )
  85. next_goal = _section_value(
  86. sections,
  87. ("下一步引领目标", "下一轮引领目标", "下一轮目标", "下一步目标", "引领目标"),
  88. )
  89. # Some reports use bold inline labels without a Markdown heading.
  90. if not recommendation:
  91. recommendation = _labeled_value(
  92. visible,
  93. ("采纳建议", "组合建议", "评审建议", "建议采用", "建议合入", "推荐"),
  94. )
  95. if not next_goal:
  96. next_goal = _labeled_value(
  97. visible,
  98. ("下一步引领目标", "下一轮引领目标", "下一轮目标", "下一步目标", "引领目标"),
  99. )
  100. result = {
  101. "parserVersion": self.VERSION,
  102. "kind": kind,
  103. "subjects": _unique(subjects),
  104. "criteria": _unique(criteria),
  105. "itemConclusions": _unique_dicts(item_conclusions),
  106. "achievements": _unique(achievements),
  107. "problems": _unique_dicts(problems),
  108. "conclusion": _business_value(conclusion),
  109. "recommendation": _business_value(recommendation),
  110. "nextGoal": _business_value(next_goal),
  111. "notices": [],
  112. }
  113. if branch_evaluations:
  114. result["branchEvaluations"] = branch_evaluations
  115. if comparison_rows:
  116. result["comparisonRows"] = comparison_rows
  117. if not any(
  118. result[key]
  119. for key in (
  120. "subjects",
  121. "criteria",
  122. "itemConclusions",
  123. "achievements",
  124. "problems",
  125. "conclusion",
  126. "recommendation",
  127. "nextGoal",
  128. )
  129. ):
  130. result["notices"].append("unparsed-report")
  131. return result
  132. def _means_no_problem(value: str) -> bool:
  133. sentences = [
  134. re.sub(r"\s+", "", item)
  135. for item in re.split(r"[。!?!?;;]+", value)
  136. if item.strip()
  137. ]
  138. if not sentences:
  139. return False
  140. marker = re.fullmatch(
  141. r"(?:无|没有|无(?:显著|明显)?(?:质量)?问题|无硬伤|无未通过项|未发现问题|未发现硬伤)",
  142. sentences[0],
  143. )
  144. if not marker:
  145. return False
  146. remainder = "".join(sentences[1:])
  147. return not re.search(r"(?:但|不过|仍|需|建议|风险|问题|不足|缺失|待|未)", remainder)
  148. def _report_text(value: Any) -> str:
  149. if value is None:
  150. return ""
  151. if isinstance(value, dict):
  152. summary = value.get("summary")
  153. return str(summary) if isinstance(summary, str) else ""
  154. text = str(value).replace("\r\n", "\n").replace("\r", "\n").strip()
  155. if text.startswith("{"):
  156. try:
  157. payload = json.loads(text)
  158. except (TypeError, ValueError):
  159. return text
  160. if isinstance(payload, dict) and isinstance(payload.get("summary"), str):
  161. return payload["summary"]
  162. return text.replace("\\n", "\n")
  163. def _remove_code_fences(text: str) -> str:
  164. return re.sub(r"(?ms)^\s*```[^\n]*\n.*?^\s*```\s*$", "", text)
  165. def _sections(text: str) -> list[_Section]:
  166. lines = text.splitlines()
  167. sections: list[_Section] = []
  168. current_title = ""
  169. body: list[str] = []
  170. def flush() -> None:
  171. nonlocal body
  172. if current_title or any(line.strip() for line in body):
  173. sections.append(_Section(_clean_heading(current_title), "\n".join(body).strip()))
  174. body = []
  175. for line in lines:
  176. heading = re.match(r"^\s*#{1,6}\s+(.+?)\s*#*\s*$", line)
  177. if heading:
  178. flush()
  179. current_title = heading.group(1)
  180. continue
  181. bold_heading = re.match(
  182. r"^\s*(?:[-*+]\s*)?\*\*([^*\n]{2,40})\*\*\s*(?:[((][^))\n]{0,40}[))])?\s*[::]?\s*$",
  183. line,
  184. )
  185. if bold_heading:
  186. flush()
  187. current_title = bold_heading.group(1)
  188. continue
  189. body.append(line)
  190. flush()
  191. return sections
  192. def _tables(text: str) -> list[list[dict[str, str]]]:
  193. lines = text.splitlines()
  194. tables: list[list[dict[str, str]]] = []
  195. index = 0
  196. while index + 1 < len(lines):
  197. headers = _table_cells(lines[index])
  198. separator = _table_cells(lines[index + 1])
  199. if not headers or not separator or not all(re.fullmatch(r":?-{3,}:?", cell) for cell in separator):
  200. index += 1
  201. continue
  202. rows: list[dict[str, str]] = []
  203. index += 2
  204. while index < len(lines):
  205. cells = _table_cells(lines[index])
  206. if not cells:
  207. break
  208. cells += [""] * max(0, len(headers) - len(cells))
  209. rows.append(dict(zip((_plain(cell) for cell in headers), cells)))
  210. index += 1
  211. if rows:
  212. tables.append(rows)
  213. return tables
  214. def _table_cells(line: str) -> list[str]:
  215. stripped = line.strip()
  216. if "|" not in stripped:
  217. return []
  218. return [cell.strip() for cell in stripped.strip("|").split("|")]
  219. def _subjects(sections: list[_Section]) -> list[str]:
  220. values: list[str] = []
  221. for section in sections:
  222. title = _business_value(section.title)
  223. if not title:
  224. continue
  225. match = re.search(r"(?:分支评估|方案评估|候选评审)\s*(?:方案\s*)?(\d+)", title)
  226. if not match:
  227. match = re.search(r"方案\s*(\d+)\b", title)
  228. if match:
  229. values.append(f"方案 {match.group(1)}")
  230. elif re.search(r"主脚本|整体评审|整体评估", title):
  231. values.append("当前主脚本")
  232. return values
  233. def _collect_sections(sections: list[_Section], labels: tuple[str, ...]) -> list[str]:
  234. values: list[str] = []
  235. for section in sections:
  236. if not _matches_label(section.title, labels):
  237. continue
  238. values.extend(_business_items(section.body))
  239. return values
  240. def _collect_positive_sections(sections: list[_Section], labels: tuple[str, ...]) -> list[str]:
  241. values: list[str] = []
  242. for section in sections:
  243. if _is_problem_title(section.title) or not _matches_label(section.title, labels):
  244. continue
  245. values.extend(_business_items(section.body))
  246. return values
  247. def _branch_evaluations(sections: list[_Section]) -> list[dict[str, Any]]:
  248. values: list[dict[str, Any]] = []
  249. current: dict[str, Any] | None = None
  250. for section in sections:
  251. subject = _branch_section_subject(section.title)
  252. if subject:
  253. current = {
  254. "subject": subject,
  255. "conclusion": _business_value(
  256. _labeled_value(section.body, ("该支结论", "评审结论", "结论"))
  257. ),
  258. "achievements": [],
  259. "problems": [],
  260. "evidence": _business_value(_labeled_value(section.body, ("有据扎实度",))),
  261. }
  262. inline_achievement = _labeled_value(section.body, ("目标推进", "已达成", "主要优点"))
  263. inline_problem = _labeled_value(section.body, ("未通过项", "部分通过项", "主要问题", "问题", "存疑项"))
  264. if inline_achievement:
  265. current["achievements"].append(inline_achievement)
  266. if inline_problem and not _means_no_problem(inline_problem):
  267. current["problems"].append(inline_problem)
  268. values.append(current)
  269. continue
  270. if current is None:
  271. continue
  272. if _is_comparison_section(section.title):
  273. current = None
  274. continue
  275. evidence = _labeled_value(section.body, ("有据扎实度",))
  276. if evidence:
  277. current["evidence"] = _business_value(evidence)
  278. body = _without_labeled_lines(section.body, ("有据扎实度",))
  279. if _is_problem_title(section.title):
  280. current["problems"].extend(
  281. item for item in _business_items(body) if not _means_no_problem(item)
  282. )
  283. elif _is_positive_section_title(section.title):
  284. current["achievements"].extend(_business_items(body))
  285. for item in values:
  286. item["achievements"] = _unique(item.get("achievements") or [])
  287. item["problems"] = _unique(item.get("problems") or [])
  288. return values
  289. def _comparison_rows(tables: list[list[dict[str, str]]]) -> list[dict[str, Any]]:
  290. values: list[dict[str, Any]] = []
  291. for rows in tables:
  292. if not rows:
  293. continue
  294. headers = list(rows[0])
  295. criterion_key = _first_header(headers, ("维度", "标准"))
  296. candidate_keys = [
  297. header for header in headers
  298. if re.search(r"(?:branch|方案|分支)\s*#?\s*\d+", header, re.I)
  299. ]
  300. basis_key = _first_header(headers, ("差异依据", "比较依据", "依据"))
  301. if not criterion_key or not candidate_keys:
  302. continue
  303. for row in rows:
  304. criterion = _business_value(row.get(criterion_key))
  305. if not criterion:
  306. continue
  307. values.append(
  308. {
  309. "criterion": criterion,
  310. "candidates": [
  311. {
  312. "subject": _business_value(header),
  313. "value": _business_value(row.get(header)),
  314. }
  315. for header in candidate_keys
  316. if _business_value(row.get(header))
  317. ],
  318. "basis": _business_value(row.get(basis_key)) if basis_key else None,
  319. }
  320. )
  321. return values
  322. def _is_positive_section_title(value: str) -> bool:
  323. return not _is_problem_title(value) and _matches_label(
  324. value,
  325. ("已达成", "达成情况", "目标达成", "目标推进", "完成情况", "通过项", "达成项", "主要优点"),
  326. )
  327. def _is_problem_title(value: str) -> bool:
  328. normalized = _clean_heading(value)
  329. return any(label in normalized for label in ("未通过", "部分通过项", "问题", "存疑", "遗留", "风险"))
  330. def _is_comparison_section(value: str) -> bool:
  331. normalized = _clean_heading(value)
  332. return any(label in normalized for label in ("对比与建议", "跨方案对比", "方案对比", "综合建议", "执行统计"))
  333. def _without_labeled_lines(text: str, labels: tuple[str, ...]) -> str:
  334. pattern = "|".join(re.escape(label) for label in labels)
  335. return "\n".join(
  336. line for line in text.splitlines()
  337. if not re.match(rf"^\s*(?:[-*+]\s*)?(?:\*\*)?(?:{pattern})(?:\*\*)?\s*[::]", line)
  338. )
  339. def _problem_sections(sections: list[_Section]) -> list[dict[str, str]]:
  340. values: list[dict[str, str]] = []
  341. for section in sections:
  342. if not _is_problem_title(section.title):
  343. continue
  344. body = _without_labeled_lines(section.body, ("有据扎实度",))
  345. for item in _business_items(body):
  346. problem: dict[str, str] = {"summary": item}
  347. severity = _severity(item)
  348. if severity:
  349. problem["severity"] = severity
  350. values.append(problem)
  351. return values
  352. def _item_conclusions(sections: list[_Section]) -> list[dict[str, Any]]:
  353. values: list[dict[str, Any]] = []
  354. for section in sections:
  355. subject = _subject_from_title(section.title)
  356. if not subject:
  357. continue
  358. conclusion = _labeled_value(section.body, ("该支结论", "评审结论", "结论"))
  359. achievements = _inline_or_section_items(section.body, ("目标推进", "已达成", "主要优点"))
  360. problems = _inline_or_section_items(section.body, ("未通过项", "主要问题", "问题"))
  361. if conclusion or achievements or problems:
  362. values.append(
  363. {
  364. "subject": subject,
  365. "conclusion": _business_value(conclusion),
  366. "achievements": _unique(achievements),
  367. "problems": _unique(problems),
  368. }
  369. )
  370. return values
  371. def _merge_table(
  372. rows: list[dict[str, str]],
  373. *,
  374. criteria: list[str],
  375. item_conclusions: list[dict[str, Any]],
  376. achievements: list[str],
  377. problems: list[dict[str, str]],
  378. ) -> None:
  379. if not rows:
  380. return
  381. headers = list(rows[0])
  382. subject_key = _first_header(headers, ("方案", "分支", "对象", "候选", "维度", "标准"))
  383. conclusion_key = _first_header(headers, ("结论", "结果", "判断"))
  384. achievement_key = _first_header(headers, ("已达成", "达成", "优点", "通过项"))
  385. problem_key = _first_header(headers, ("问题", "未通过", "遗留", "风险"))
  386. for row in rows:
  387. subject = _business_value(row.get(subject_key)) if subject_key else None
  388. conclusion = _business_value(row.get(conclusion_key)) if conclusion_key else None
  389. row_achievements = _business_items(row.get(achievement_key, "")) if achievement_key else []
  390. row_problems = _business_items(row.get(problem_key, "")) if problem_key else []
  391. if subject_key and any(term in subject_key for term in ("维度", "标准")) and subject:
  392. criteria.append(subject)
  393. if subject and conclusion:
  394. item_conclusions.append(
  395. {
  396. "subject": subject,
  397. "conclusion": conclusion,
  398. "achievements": row_achievements,
  399. "problems": row_problems,
  400. }
  401. )
  402. achievements.extend(row_achievements)
  403. for item in row_problems:
  404. problem: dict[str, str] = {"summary": item}
  405. severity = _severity(item)
  406. if severity:
  407. problem["severity"] = severity
  408. problems.append(problem)
  409. def _section_value(sections: list[_Section], labels: tuple[str, ...]) -> str | None:
  410. for section in sections:
  411. if _matches_label(section.title, labels):
  412. items = _business_items(section.body)
  413. return ";".join(items) if items else None
  414. return None
  415. def _labeled_value(text: str, labels: tuple[str, ...]) -> str | None:
  416. if not text:
  417. return None
  418. label_pattern = "|".join(re.escape(label) for label in labels)
  419. match = re.search(
  420. rf"(?im)^\s*(?:#{{1,6}}\s+)?(?:[-*+]\s*)?(?:\*\*)?(?:{label_pattern})(?:\*\*)?\s*[::]\s*(.+?)\s*$",
  421. text,
  422. )
  423. return _business_value(match.group(1)) if match else None
  424. def _inline_or_section_items(text: str, labels: tuple[str, ...]) -> list[str]:
  425. value = _labeled_value(text, labels)
  426. return [value] if value else []
  427. def _business_items(text: str) -> list[str]:
  428. values: list[str] = []
  429. for paragraph in re.split(r"\n\s*\n", text or ""):
  430. lines = paragraph.splitlines()
  431. for line in lines:
  432. if re.match(r"^\s*\|.*\|\s*$", line):
  433. continue
  434. value = re.sub(r"^\s*(?:[-*+]\s+|\d+[.)、]\s*)", "", line).strip()
  435. value = _business_value(value)
  436. if value:
  437. values.append(value)
  438. return values
  439. def _business_value(value: Any) -> str | None:
  440. text = _plain(str(value or ""))
  441. if not text:
  442. return None
  443. # Remove only named technical assignments. Never remove bare numbers or
  444. # generic "ID" tokens: business content such as "领域信息 31/32" must survive.
  445. text = re.sub(r"(?i)\bscript_build_id\s*[=::]\s*\d+\s*[·|,,;;]?", "", text)
  446. text = re.sub(r"(?i)\bround(?:_index)?\s*[=::]\s*\d+\s*[·|,,;;]?", "", text)
  447. text = re.sub(r"(?i)\b(?:paragraph|element|post)_id\s*[=::]\s*[^\s::,,;;))]+\s*[·|,,;;]?", "", text)
  448. text = re.sub(r"(?i)\bbranch_id\s*[=::]\s*(\d+)", r"方案 \1", text)
  449. text = re.sub(r"(?i)\bbranch\s*#?\s*(\d+)\b", r"方案 \1", text)
  450. text = re.sub(r"^(?:D|P)\d+\s*[::|·-]?\s*(?=\D)", "", text, flags=re.I)
  451. text = re.sub(r"(?i)\b(?:action|target|path_type|dimension_pass)\s*[=::]\s*[^,,;;]+", "", text)
  452. text = re.sub(r"^\s*[::·|,,;;-]+\s*", "", text)
  453. text = re.sub(r"\s+([,。;!?、:,.])", r"\1", text)
  454. text = re.sub(r"\s{2,}", " ", text).strip(" ::,,;;|-_")
  455. return text or None
  456. def _plain(text: str) -> str:
  457. text = text.replace("\\n", "\n")
  458. text = re.sub(r"`([^`]*)`", r"\1", text)
  459. text = re.sub(r"[*#]", "", text)
  460. return text.strip()
  461. def _clean_heading(value: str) -> str:
  462. return _plain(value).strip(" ::")
  463. def _matches_label(value: str, labels: tuple[str, ...]) -> bool:
  464. normalized = _clean_heading(value)
  465. return any(label in normalized for label in labels)
  466. def _subject_from_title(title: str) -> str | None:
  467. cleaned = _business_value(title)
  468. if not cleaned:
  469. return None
  470. match = re.search(r"(?:分支评估|方案评估|候选评审)?\s*方案\s*(\d+)\b", cleaned)
  471. if match:
  472. return f"方案 {match.group(1)}"
  473. return None
  474. def _branch_section_subject(title: str) -> str | None:
  475. cleaned = _business_value(title)
  476. if not cleaned or not re.search(r"分支评估|方案评估|候选评审", cleaned):
  477. return None
  478. return _subject_from_title(title)
  479. def _first_header(headers: list[str], labels: tuple[str, ...]) -> str | None:
  480. return next((header for header in headers if any(label in header for label in labels)), None)
  481. def _severity(value: str) -> str | None:
  482. match = re.search(r"(?:严重程度|级别|等级)\s*[::]\s*(严重|高|中|低)", value)
  483. if match:
  484. return match.group(1)
  485. for label in ("严重", "高", "中", "低"):
  486. if re.match(rf"^(?:[【\[]{label}[】\]]|{label}\s*[::])", value):
  487. return label
  488. return None
  489. def _unique(values: list[str]) -> list[str]:
  490. seen: set[str] = set()
  491. result: list[str] = []
  492. for value in values:
  493. if value and value not in seen:
  494. seen.add(value)
  495. result.append(value)
  496. return result
  497. def _unique_dicts(values: list[dict[str, Any]]) -> list[dict[str, Any]]:
  498. seen: set[str] = set()
  499. result: list[dict[str, Any]] = []
  500. for value in values:
  501. marker = json.dumps(value, ensure_ascii=False, sort_keys=True)
  502. if marker not in seen:
  503. seen.add(marker)
  504. result.append(value)
  505. return result