report_metadata.py 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175
  1. """由应用代码维护的可信报告生命周期与语义约束。
  2. 模型负责撰写业务内容,应用代码负责不能靠语言模型猜测的部分:运行日期、历史
  3. 报告隔离,以及风险等级与证据状态的基本一致性。这里的校验是轻量确定性门禁,
  4. 不是完整的 Markdown 解析器或业务评审器。
  5. """
  6. from __future__ import annotations
  7. import re
  8. from datetime import date, datetime
  9. from pathlib import Path
  10. from uuid import uuid4
  11. # 模型生成的标题可能带阿拉伯数字、中文序号或英文后缀,例如:
  12. # ``### 2.1 安全域 — 风险等级:medium``、
  13. # ``### 3.1 安全审查(Security Review)``。
  14. # 这里只定位三级安全章节,后续再从该章节中检查表头和风险等级。
  15. _SECURITY_SECTION_HEADING = re.compile(
  16. r"^###\s+"
  17. r"(?:(?:\d+(?:\.\d+)*|[一二三四五六七八九十]+)[.、))]?\s*)?"
  18. r"安全(?:域|审查)",
  19. re.MULTILINE,
  20. )
  21. _LEVEL_THREE_HEADING = re.compile(r"^###\s+", re.MULTILINE)
  22. def archive_existing_report(
  23. project_root: Path,
  24. report_path: Path,
  25. archived_at: datetime,
  26. ) -> Path | None:
  27. """运行前归档旧报告,避免历史审批结果进入新一轮 Agent 上下文。
  28. 旧报告会移动到 ``report-history/<供应商>/``,文件名包含带时区时间和随机
  29. 后缀。移动而不是复制可以保证固定目标路径在 Agent 启动时确实不存在。
  30. 历史目录同时由文件权限规则禁止 Agent 读取,但仍可供人工追溯。
  31. """
  32. if not report_path.is_file():
  33. return None
  34. if archived_at.tzinfo is None:
  35. raise ValueError("报告归档时间必须包含时区")
  36. project_root = project_root.resolve()
  37. reports_root = (project_root / "reports").resolve()
  38. resolved_report = report_path.resolve()
  39. try:
  40. relative_report = resolved_report.relative_to(reports_root)
  41. except ValueError as error:
  42. raise ValueError("只能归档项目 reports 目录内的报告") from error
  43. # Windows 文件名不能包含冒号,因此使用紧凑时间格式;随机后缀避免同一
  44. # 微秒内的并发或重试产生文件名冲突。
  45. timestamp = archived_at.strftime("%Y%m%dT%H%M%S.%f%z")
  46. archive_directory = (
  47. project_root / "report-history" / relative_report.parent
  48. )
  49. archive_directory.mkdir(parents=True, exist_ok=True)
  50. archive_path = archive_directory / (
  51. f"{resolved_report.stem}-{timestamp}-{uuid4().hex[:8]}"
  52. f"{resolved_report.suffix}"
  53. )
  54. resolved_report.replace(archive_path)
  55. return archive_path
  56. def stamp_report_date(report_path: Path, report_date: date) -> bool:
  57. """用应用提供的日期替换报告日期,避免模型猜测系统时间。
  58. 优先替换已有日期行;如果模型遗漏日期,则插入一级标题之后。返回值表示
  59. 文件是否发生变化,使调用方和测试能够验证该操作的幂等性。
  60. """
  61. if not report_path.is_file():
  62. raise FileNotFoundError(f"找不到待写入日期的报告:{report_path}")
  63. content = report_path.read_text(encoding="utf-8")
  64. lines = content.splitlines(keepends=True)
  65. canonical_line = f"**报告日期:** {report_date.isoformat()}"
  66. # 同时兼容中文和英文冒号,但统一写回一种规范格式。
  67. for index, line in enumerate(lines):
  68. if line.strip().startswith(("**报告日期:", "**报告日期:")):
  69. newline = "\r\n" if line.endswith("\r\n") else "\n"
  70. if not line.endswith(("\n", "\r")):
  71. newline = ""
  72. replacement = canonical_line + newline
  73. if line == replacement:
  74. return False
  75. lines[index] = replacement
  76. report_path.write_text("".join(lines), encoding="utf-8", newline="")
  77. return True
  78. heading_index = next(
  79. (index for index, line in enumerate(lines) if line.lstrip().startswith("# ")),
  80. None,
  81. )
  82. if heading_index is None:
  83. raise ValueError("报告缺少一级标题,无法写入报告日期")
  84. lines.insert(heading_index + 1, f"\n{canonical_line}\n")
  85. report_path.write_text("".join(lines), encoding="utf-8", newline="")
  86. return True
  87. def validate_report_risk_semantics(report_path: Path) -> None:
  88. """拒绝风险等级与证据状态混用或安全域汇总自相矛盾的报告。
  89. 当前门禁检查两件事:
  90. 1. 安全明细必须包含独立的“证据状态”列;
  91. 2. 安全域总评为 low 时,明细中不能仍有 medium/high/critical。
  92. 该函数故意不对报告做自动修复,因为应用代码无法安全推断模型遗漏的业务
  93. 事实;发现矛盾时应中止审批,让生成协议或证据处理逻辑得到修正。
  94. """
  95. content = report_path.read_text(encoding="utf-8")
  96. security_heading = _SECURITY_SECTION_HEADING.search(content)
  97. if security_heading is None:
  98. raise ValueError("报告缺少安全域风险明细")
  99. # 截取当前三级标题到下一个三级标题之间的内容,避免把法务或财务表格中的
  100. # “证据状态”误当成安全域已经满足要求。
  101. next_heading = _LEVEL_THREE_HEADING.search(content, security_heading.end())
  102. security_section = (
  103. content[security_heading.start():]
  104. if next_heading is None
  105. else content[security_heading.start():next_heading.start()]
  106. )
  107. if "证据状态" not in security_section:
  108. raise ValueError("安全域风险表必须将风险等级与证据状态分列")
  109. # 汇总表的第一列存在中英文和“安全域/安全审查”多种写法。这里只寻找安全
  110. # 汇总行,第二列才是该专业域的总体风险等级。
  111. security_summary = next(
  112. (
  113. line
  114. for line in content.splitlines()
  115. if line.lstrip().startswith("|")
  116. and (
  117. "安全 (Security)" in line
  118. or "安全(Security)" in line
  119. or re.search(r"\|\s*\**安全(?:域|审查)\**\s*\|", line)
  120. )
  121. ),
  122. "",
  123. )
  124. summary_cells = [
  125. cell.strip().strip("*").strip().lower()
  126. for cell in security_summary.strip().strip("|").split("|")
  127. ]
  128. overall_level = summary_cells[1] if len(summary_cells) > 1 else ""
  129. # 聚合冲突只需要在总评为 low 时继续扫描。总评本身已是 medium 以上或
  130. # unknown 时,即使明细存在高风险,也不存在“被错误降级为 low”的问题。
  131. if overall_level not in {"low", "低"}:
  132. return
  133. blocking_levels = {"medium", "high", "critical", "中", "高", "严重"}
  134. detailed_levels: set[str] = set()
  135. for line in security_section.splitlines():
  136. if not line.lstrip().startswith("|"):
  137. continue
  138. cells = [
  139. cell.strip().strip("*").strip().lower()
  140. for cell in line.strip().strip("|").split("|")
  141. ]
  142. if len(cells) > 1:
  143. detailed_levels.add(cells[1])
  144. conflicts = sorted(detailed_levels & blocking_levels)
  145. if conflicts:
  146. raise ValueError(
  147. "安全域总评为 low,但存在未整改的更高风险项:"
  148. + ", ".join(conflicts)
  149. )