feat(govdoc): 新增内部公文模块全链路(后端58+前端11文件)

This commit is contained in:
wren
2026-05-13 14:37:12 +08:00
parent 99699e20e1
commit 5d777599bf
63 changed files with 7608 additions and 0 deletions
@@ -0,0 +1,35 @@
"""Govdoc 公文格式审查引擎内核。
从旧 govdoc-audit 项目裁剪迁入,去除独立 API 层、SQLite 存储层、
本地运行记录器 (RunRecorder) 和旧配置系统。
导出:
- pipeline.run() — 异步审查入口 (bridge 层主调用)
- pipeline.audit_file() — 同步审查入口 (兼容)
- models — 核心数据模型 (Pydantic)
- parser — 文档解析与实体抽取
- dsl — YAML 规则 DSL 定义与加载
- engine — 规则执行引擎与结果模型
- reporter — 报告生成 (HTML/DOCX/JSON)
- llm — LLM 客户端 (OpenAI 兼容协议)
"""
from __future__ import annotations
from fastapi_modules.fastapi_leaudit.govdoc_engine.pipeline import (
audit_file,
run,
)
from fastapi_modules.fastapi_leaudit.govdoc_engine.engine.result import (
AuditResult,
AuditSummary,
CheckedRule,
)
__all__ = [
"audit_file",
"run",
"AuditResult",
"AuditSummary",
"CheckedRule",
]
@@ -0,0 +1,24 @@
"""加载并校验 rules.yaml。"""
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
import yaml
from fastapi_modules.fastapi_leaudit.govdoc_engine.dsl.schema import RuleSet
def _load_uncached(path: Path) -> RuleSet:
with open(path, encoding="utf-8") as f:
data = yaml.safe_load(f)
return RuleSet.model_validate(data)
@lru_cache(maxsize=32)
def _load_cached(path_str: str, mtime: float) -> RuleSet:
return _load_uncached(Path(path_str))
def load_rules(path: str | Path) -> RuleSet:
path = Path(path)
return _load_cached(str(path.resolve()), path.stat().st_mtime)
@@ -0,0 +1,141 @@
"""规则文件的 Pydantic schema。"""
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, Field, model_validator
CheckType = Literal[
"required", "font", "style_match", "line_spacing",
"attachment_marker_style",
"regex_require", "regex_forbid",
"confused_pair", "forbid_phrase", "forbid_chars",
"punctuation", "wenzhong_whitelist",
"hierarchy", "cross_role", "ai",
]
class AppliesTo(BaseModel):
role: str | None = None
roles: list[str] | None = None
paragraph_index: int | None = None
class RuleStage(BaseModel):
id: str | None = None
check: CheckType
when: str | None = None
field: str | None = None
expect: dict[str, Any] | None = None
pattern: str | None = None
chars: list[str] | None = None
pairs: list[dict[str, Any]] | None = None
phrases: list[str] | None = None
rules: list[dict[str, Any]] | None = None
expected_order: list[dict[str, Any]] | None = None
forbid_patterns: list[str] | None = None
prompt: str | None = None
format: str | None = None
model_config = {"extra": "allow"}
class Messages(BaseModel):
pass_msg: str = Field(alias="pass", default="")
fail: str = ""
model_config = {"populate_by_name": True}
class Rule(BaseModel):
rule_id: str
name: str
severity: Literal["error", "warning", "info"] = "warning"
category: str
score: int | None = None
# 二选一:target 通道(推荐,绑定语义实体)或 applies_to 通道(旧,按 role 选段)
applies_to: AppliesTo | None = None
target: str | None = None
on_missing: Literal["pass", "warn", "fail", "skip"] = "skip"
activate_if: str | None = None
stages: list[RuleStage]
messages: Messages
@model_validator(mode="after")
def _check_at_least_one_target(self) -> "Rule":
if self.applies_to is None and self.target is None:
raise ValueError(
f"Rule {self.rule_id}: 必须声明 target 或 applies_to 之一"
)
return self
class RuleGroup(BaseModel):
group: str
rules: list[Rule]
# 8 个内置语义实体名(与 entity_builder.BUILTIN_ENTITY_NAMES 同步)
_BUILTIN_ENTITY_NAMES = frozenset({
"title", "doc_number", "recipient", "date",
"signature", "attachments", "wenzhong", "issuer",
})
class EntitySpec(BaseModel):
"""声明用户自定义语义实体(builtin 实体由代码自动产出,无需在 yaml 出现)。"""
name: str
type: Literal["string", "number", "list"] = "string"
description: str = ""
@model_validator(mode="after")
def _no_clash_with_builtin(self) -> "EntitySpec":
if self.name in _BUILTIN_ENTITY_NAMES:
raise ValueError(
f"entity '{self.name}' 与内置实体重名,"
f"去掉该声明即可(内置实体自动产出)"
)
return self
class ExtractSpec(BaseModel):
entities: list[EntitySpec] = Field(default_factory=list)
class RuleSetMetadata(BaseModel):
type_id: str
name: str
version: str
source: str | None = None
description: str | None = None
class RuleSet(BaseModel):
metadata: RuleSetMetadata
extract: ExtractSpec = Field(default_factory=ExtractSpec)
rules: list[RuleGroup]
@model_validator(mode="after")
def _check_unique_ids(self) -> "RuleSet":
seen: set[str] = set()
for g in self.rules:
for r in g.rules:
if r.rule_id in seen:
raise ValueError(f"duplicate rule_id: {r.rule_id}")
seen.add(r.rule_id)
return self
def all_rules(self) -> list[Rule]:
return [r for g in self.rules for r in g.rules]
class FontCheck(BaseModel):
eastasia: str | None = None
ascii: str | None = None
size_pt: float | None = None
class RegexForbidCheck(BaseModel):
pattern: str
message: str | None = None
@@ -0,0 +1,24 @@
"""Check 原语注册中心:通过 register 装饰器收集,runner 通过 get 查找。"""
from __future__ import annotations
from fastapi_modules.fastapi_leaudit.govdoc_engine.engine.checks.base import CheckBase
_REGISTRY: dict[str, type[CheckBase]] = {}
def register(name: str):
def deco(cls):
cls.name = name
_REGISTRY[name] = cls
return cls
return deco
def get_check(name: str) -> type[CheckBase]:
if name not in _REGISTRY:
raise KeyError(f"unknown check: {name}; known: {list(_REGISTRY)}")
return _REGISTRY[name]
def all_checks() -> list[str]:
return list(_REGISTRY.keys())
@@ -0,0 +1,151 @@
"""LLM 语义检查。三级输出:pass / warn / fail。"""
import logging
import re
from typing import Any
from pydantic import BaseModel
from fastapi_modules.fastapi_leaudit.govdoc_engine.engine.checks import register
from fastapi_modules.fastapi_leaudit.govdoc_engine.engine.checks.base import (
CheckBase, CheckContext, CheckHit, CheckResult,
)
from fastapi_modules.fastapi_leaudit.govdoc_engine.llm.client import LlmClient, LlmJsonError, _format_exc
_log = logging.getLogger(__name__)
_OUT_FORMAT = """
请以 JSON 输出:
{"result": "pass|warn|fail", "reason": "<简短理由>", "suggestion": "<改进建议;pass 时填空>"}
"""
_VAR_RE = re.compile(r"\{\{\s*([^}]+?)\s*\}\}")
def _resolve_dot_path(root: Any, path: str) -> str:
"""点语法属性访问:title.style.font_eastasia → entities['title'].style.font_eastasia。"""
cur: Any = root
for seg in path.split("."):
if cur is None:
return ""
if isinstance(cur, dict):
cur = cur.get(seg)
elif isinstance(cur, BaseModel):
cur = getattr(cur, seg, None)
else:
cur = getattr(cur, seg, None)
if cur is None:
return ""
if isinstance(cur, (dict, list)):
return str(cur)
return str(cur)
def _interpolate(
template: str,
paragraphs: list,
entities: dict | None = None,
target: Any = None,
) -> str:
"""渲染顺序:① paragraphs[N] ② entities 点语法
③ target 隐式(无前缀时视为 target.<key>)。"""
entities = entities or {}
def repl(m):
key = m.group(1).strip()
# ① paragraphs[N] 索引
if key.startswith("paragraphs["):
try:
idx = int(key[len("paragraphs["):].rstrip("]"))
return paragraphs[idx].text
except (ValueError, IndexError):
return ""
# ② entities 点语法:title.text / title.style.font_eastasia
head, _, rest = key.partition(".")
if head in entities:
entity = entities[head]
if entity is None:
return ""
return _resolve_dot_path(entity, rest) if rest else entity.text
# ③ target 隐式:未带前缀且 target 存在
if target is not None:
v = _resolve_dot_path(target, key)
if v:
return v
return ""
return _VAR_RE.sub(repl, template)
@register("ai")
class AiCheck(CheckBase):
def __init__(self, llm_client: LlmClient | None = None):
self.client = llm_client or LlmClient()
def _build_prompt(self, ctx: CheckContext) -> str:
prompt = _interpolate(
ctx.stage.prompt or "",
ctx.paragraphs,
ctx.entities,
ctx.target,
)
return prompt + "\n\n" + _OUT_FORMAT
def _interpret(self, ctx: CheckContext, resp: dict) -> CheckResult:
result = resp.get("result", "fail")
reason = resp.get("reason", "")
suggestion = resp.get("suggestion", "")
if result == "pass":
return CheckResult(passed=True, hits=[])
target_p = ctx.paragraphs[0] if ctx.paragraphs else None
confidence = 0.95 if result == "fail" else 0.7
return CheckResult(passed=False, hits=[CheckHit(
paragraph=target_p,
char_start=0,
char_end=len(target_p.text) if target_p else 0,
actual={"llm_reason": reason, "llm_suggestion": suggestion},
expected={},
message=reason or "LLM 判定不通过",
confidence=confidence,
)])
def run(self, ctx: CheckContext) -> CheckResult:
label = f"ai_{ctx.rule_id or 'unknown'}"
try:
resp = self.client.chat_json(
[{"role": "user", "content": self._build_prompt(ctx)}],
label=label,
)
except LlmJsonError as e:
_log.warning("AI check skipped (LLM JSON error): %s", _format_exc(e))
return CheckResult(
passed=True, hits=[], skipped=True,
skip_reason=f"LLM 返回内容无法解析为 JSON{e}",
)
except Exception as e:
_log.warning("AI check skipped (LLM error): %s", _format_exc(e))
return CheckResult(
passed=True, hits=[], skipped=True,
skip_reason=f"LLM 调用失败:{e}",
)
return self._interpret(ctx, resp)
async def run_async(self, ctx: CheckContext) -> CheckResult:
label = f"ai_{ctx.rule_id or 'unknown'}"
try:
resp = await self.client.chat_json_async(
[{"role": "user", "content": self._build_prompt(ctx)}],
label=label,
)
except LlmJsonError as e:
_log.warning("AI check skipped (LLM JSON error): %s", _format_exc(e))
return CheckResult(
passed=True, hits=[], skipped=True,
skip_reason=f"LLM 返回内容无法解析为 JSON{e}",
)
except Exception as e:
_log.warning("AI check skipped (LLM error): %s", _format_exc(e))
return CheckResult(
passed=True, hits=[], skipped=True,
skip_reason=f"LLM 调用失败:{e}",
)
return self._interpret(ctx, resp)
@@ -0,0 +1,48 @@
"""Check 原语基类与上下文。"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, TYPE_CHECKING
from fastapi_modules.fastapi_leaudit.govdoc_engine.models import Document, Paragraph
from fastapi_modules.fastapi_leaudit.govdoc_engine.dsl.schema import RuleStage
if TYPE_CHECKING:
from fastapi_modules.fastapi_leaudit.govdoc_engine.parser.entities import SemanticEntity
@dataclass
class CheckContext:
document: Document
paragraphs: list[Paragraph]
stage: RuleStage
entities: dict[str, "SemanticEntity | None"] = field(default_factory=dict)
target: "SemanticEntity | None" = None
rule_id: str = ""
@dataclass
class CheckHit:
paragraph: Paragraph | None
char_start: int = 0
char_end: int = 0
actual: dict[str, Any] | None = None
expected: dict[str, Any] | None = None
message: str | None = None
confidence: float = 1.0
@dataclass
class CheckResult:
passed: bool
hits: list[CheckHit] = field(default_factory=list)
skipped: bool = False
skip_reason: str = ""
class CheckBase:
"""所有 check 原语的抽象基类。"""
name: str = ""
def run(self, ctx: CheckContext) -> CheckResult:
raise NotImplementedError
@@ -0,0 +1,34 @@
"""易混淆词对(字面 + 正则)。"""
import re
from fastapi_modules.fastapi_leaudit.govdoc_engine.engine.checks import register
from fastapi_modules.fastapi_leaudit.govdoc_engine.engine.checks.base import CheckBase, CheckContext, CheckHit, CheckResult
@register("confused_pair")
class ConfusedPairCheck(CheckBase):
def run(self, ctx: CheckContext) -> CheckResult:
pairs = ctx.stage.pairs or []
hits: list[CheckHit] = []
for p in ctx.paragraphs:
for pair in pairs:
wrong = pair.get("wrong")
wrong_pat = pair.get("wrong_pattern")
correct = pair.get("correct") or pair.get("suggest", "")
reason = pair.get("reason", "")
if wrong and wrong in p.text:
start = p.text.find(wrong)
hits.append(CheckHit(
paragraph=p, char_start=start, char_end=start + len(wrong),
actual={"text": wrong}, expected={"text": correct},
message=f"\"{wrong}\" 应为 \"{correct}\"{reason}",
))
elif wrong_pat:
for m in re.finditer(wrong_pat, p.text):
hits.append(CheckHit(
paragraph=p, char_start=m.start(), char_end=m.end(),
actual={"text": m.group(0)},
expected={"text": correct},
message=f"\"{m.group(0)}\" 应为 \"{correct}\"{reason}",
))
return CheckResult(passed=not hits, hits=hits)
@@ -0,0 +1,69 @@
"""跨段关系 check:例如二级标题以句号结尾后又新起一段。"""
import re
from fastapi_modules.fastapi_leaudit.govdoc_engine.engine.checks import register
from fastapi_modules.fastapi_leaudit.govdoc_engine.engine.checks.base import CheckBase, CheckContext, CheckHit, CheckResult
# 单个附件项末尾的标点:编号(数字+点) + 内容 + 末尾标点
_ATTACH_ITEM_TRAIL_PUNCT = re.compile(r"\d+[\.][^\d;。,;,.]+?[;。,;,.]")
# 整段是一个附件项
_ATTACH_ITEM_LINE = re.compile(r"^\d+[\.].+[;。,;,.]\s*$")
@register("cross_role")
class CrossRoleCheck(CheckBase):
def run(self, ctx: CheckContext) -> CheckResult:
rules = ctx.stage.rules or []
paras = ctx.document.paragraphs
hits: list[CheckHit] = []
for r in rules:
t = r.get("type")
if t == "h2_no_period_then_break":
for i, p in enumerate(paras):
if p.role == "heading_2" and p.text.rstrip().endswith(("", ".")):
if i + 1 < len(paras) and paras[i + 1].text.strip():
hits.append(CheckHit(
paragraph=p,
char_start=len(p.text) - 1, char_end=len(p.text),
actual={"text": p.text},
message="二级标题在换行分段时不应使用句号;如使用句号则应紧接正文",
))
elif t == "attachment_item_no_trailing_punct":
hits.extend(_attachment_item_hits(paras))
return CheckResult(passed=not hits, hits=hits)
def _attachment_item_hits(paras):
"""从 attachment_marker 起扫描附件区块,找末尾带标点的附件项。"""
hits: list[CheckHit] = []
in_attachment = False
for p in paras:
text = p.text.strip()
if not text:
continue
if p.role == "attachment_marker":
in_attachment = True
# 同段内可能出现 "附件:1.xxx2.yyy。" 多项一行
for m in _ATTACH_ITEM_TRAIL_PUNCT.finditer(text):
hits.append(CheckHit(
paragraph=p,
char_start=m.start(), char_end=m.end(),
actual={"snippet": m.group(0)},
message=f'附件项末尾不应有标点:"{m.group(0)}"',
))
continue
if p.role in ("signature", "date", "heading_1"):
in_attachment = False
continue
if in_attachment and _ATTACH_ITEM_LINE.match(text):
hits.append(CheckHit(
paragraph=p,
char_start=len(p.text) - 1, char_end=len(p.text),
actual={"text": p.text},
message=f'附件项末尾不应有标点:"{text}"',
))
return hits
@@ -0,0 +1,162 @@
"""字体/字号/复合样式/行距 check。"""
import re
from fastapi_modules.fastapi_leaudit.govdoc_engine.engine.checks import register
from fastapi_modules.fastapi_leaudit.govdoc_engine.engine.checks.base import CheckBase, CheckContext, CheckHit, CheckResult
from fastapi_modules.fastapi_leaudit.govdoc_engine.models import Paragraph, ParagraphStyle
def _font_match(actual: str | None, expect: str) -> bool:
if not actual:
return False
return expect in actual or actual in expect
def _size_match(actual: float | None, expect: float, tol: float = 0.5) -> bool:
if actual is None:
return False
return abs(actual - expect) <= tol
def _style_matches(style: ParagraphStyle, expect: dict) -> bool:
if "eastasia" in expect and not _font_match(style.font_eastasia, expect["eastasia"]):
return False
if "size_pt" in expect and not _size_match(
style.font_size_pt, float(expect["size_pt"])
):
return False
if "bold" in expect and bool(style.bold) != bool(expect["bold"]):
return False
return True
@register("font")
class FontCheck(CheckBase):
def run(self, ctx: CheckContext) -> CheckResult:
expect = ctx.stage.expect or {}
hits: list[CheckHit] = []
for p in ctx.paragraphs:
ok = True
actual = {
"font": p.style.font_eastasia,
"size": p.style.font_size_pt,
}
if "eastasia" in expect and not _font_match(p.style.font_eastasia, expect["eastasia"]):
ok = False
if "size_pt" in expect and not _size_match(
p.style.font_size_pt, float(expect["size_pt"])
):
ok = False
if not ok:
hits.append(CheckHit(
paragraph=p, char_start=0, char_end=len(p.text),
actual=actual, expected=expect,
message=f"字体或字号不符合(实际 {actual['font']} {actual['size']}pt,期望 {expect}",
))
return CheckResult(passed=not hits, hits=hits)
@register("style_match")
class StyleMatchCheck(CheckBase):
def run(self, ctx: CheckContext) -> CheckResult:
expect = ctx.stage.expect or {}
hits: list[CheckHit] = []
for p in ctx.paragraphs:
ok = True
actual = {
"font": p.style.font_eastasia,
"size": p.style.font_size_pt,
"bold": p.style.bold,
"italic": p.style.italic,
"alignment": p.style.alignment,
}
if "eastasia" in expect and not _font_match(p.style.font_eastasia, expect["eastasia"]):
ok = False
if "size_pt" in expect and not _size_match(
p.style.font_size_pt, float(expect["size_pt"])
):
ok = False
if "bold" in expect and bool(p.style.bold) != bool(expect["bold"]):
ok = False
if "alignment" in expect and p.style.alignment != expect["alignment"]:
ok = False
if not ok:
hits.append(CheckHit(
paragraph=p, char_start=0, char_end=len(p.text),
actual=actual, expected=expect, message="样式不符合",
))
return CheckResult(passed=not hits, hits=hits)
_ATTACHMENT_MARKER_RE = re.compile(r"^\s*(附件[:]|附件\d+)")
@register("attachment_marker_style")
class AttachmentMarkerStyleCheck(CheckBase):
"""只校验“附件:”或“附件1”等标记本身,不校验后续附件名称。"""
DEFAULT_EXPECT = {"eastasia": "黑体", "size_pt": 16, "bold": False}
def run(self, ctx: CheckContext) -> CheckResult:
expect = ctx.stage.expect or self.DEFAULT_EXPECT
hits: list[CheckHit] = []
for p in ctx.paragraphs:
match = _ATTACHMENT_MARKER_RE.match(p.text)
if not match:
continue
marker_end = match.end(1)
marker_styles = _marker_run_styles(p, marker_end)
if not marker_styles:
marker_styles = [p.style]
bad_style = next(
(style for style in marker_styles if not _style_matches(style, expect)),
None,
)
if bad_style is not None:
hits.append(CheckHit(
paragraph=p,
char_start=match.start(1),
char_end=marker_end,
actual={
"font": bad_style.font_eastasia,
"size": bad_style.font_size_pt,
"bold": bad_style.bold,
},
expected=expect,
message="附件标记样式不符合",
))
return CheckResult(passed=not hits, hits=hits)
def _marker_run_styles(p: Paragraph, marker_end: int) -> list[ParagraphStyle]:
styles: list[ParagraphStyle] = []
cursor = 0
for run in p.runs:
run_start = cursor
run_end = cursor + len(run.text)
cursor = run_end
if run_end <= 0 or run_start >= marker_end:
continue
if run.text.strip():
styles.append(run.style)
return styles
@register("line_spacing")
class LineSpacingCheck(CheckBase):
def run(self, ctx: CheckContext) -> CheckResult:
expect = ctx.stage.expect or {}
target = float(expect.get("multiple", 1.5))
tol = float(expect.get("tol", 0.05))
hits: list[CheckHit] = []
for p in ctx.paragraphs:
actual = p.style.line_spacing
if actual is None or abs(actual - target) > tol:
hits.append(CheckHit(
paragraph=p, char_start=0, char_end=len(p.text),
actual={"line_spacing": actual},
expected={"line_spacing": target},
message=f"行距应为 {target},实际 {actual}",
))
return CheckResult(passed=not hits, hits=hits)
@@ -0,0 +1,42 @@
"""短语/字符黑名单。"""
from fastapi_modules.fastapi_leaudit.govdoc_engine.engine.checks import register
from fastapi_modules.fastapi_leaudit.govdoc_engine.engine.checks.base import CheckBase, CheckContext, CheckHit, CheckResult
@register("forbid_phrase")
class ForbidPhraseCheck(CheckBase):
def run(self, ctx: CheckContext) -> CheckResult:
phrases = ctx.stage.phrases or []
hits: list[CheckHit] = []
for p in ctx.paragraphs:
for phr in phrases:
start = p.text.find(phr)
if start >= 0:
hits.append(CheckHit(
paragraph=p, char_start=start, char_end=start + len(phr),
actual={"text": phr}, expected={"forbid": phr},
message=f"出现禁用短语 \"{phr}\"",
))
return CheckResult(passed=not hits, hits=hits)
@register("forbid_chars")
class ForbidCharsCheck(CheckBase):
def run(self, ctx: CheckContext) -> CheckResult:
chars = ctx.stage.chars or []
hits: list[CheckHit] = []
for p in ctx.paragraphs:
for c in chars:
start = 0
while True:
idx = p.text.find(c, start)
if idx < 0:
break
hits.append(CheckHit(
paragraph=p, char_start=idx, char_end=idx + len(c),
actual={"char": c}, expected={"forbid": c},
message=f"禁用字符 \"{c}\" 出现在 idx {idx}",
))
start = idx + len(c)
return CheckResult(passed=not hits, hits=hits)
@@ -0,0 +1,29 @@
"""层级序号格式 check。"""
import re
from fastapi_modules.fastapi_leaudit.govdoc_engine.engine.checks import register
from fastapi_modules.fastapi_leaudit.govdoc_engine.engine.checks.base import CheckBase, CheckContext, CheckHit, CheckResult
@register("hierarchy")
class HierarchyCheck(CheckBase):
"""检查层级序号格式:
- expected_order: 各级允许的模式(正向白名单,按 level 升序)
- forbid_patterns: 禁用模式(黑名单,命中即报错)
"""
def run(self, ctx: CheckContext) -> CheckResult:
forbid = [re.compile(p) for p in (ctx.stage.forbid_patterns or [])]
hits: list[CheckHit] = []
for p in ctx.paragraphs:
text = p.text.strip()
for f in forbid:
m = f.search(text)
if m:
hits.append(CheckHit(
paragraph=p, char_start=m.start(), char_end=m.end(),
actual={"text": m.group(0)},
expected={"forbid_pattern": f.pattern},
message=f"层级序号格式错误:命中禁用模式 {f.pattern}",
))
return CheckResult(passed=not hits, hits=hits)
@@ -0,0 +1,46 @@
"""标点符号专项规则。"""
import re
from fastapi_modules.fastapi_leaudit.govdoc_engine.engine.checks import register
from fastapi_modules.fastapi_leaudit.govdoc_engine.engine.checks.base import CheckBase, CheckContext, CheckHit, CheckResult
# 多书名号或引号并列时不应用顿号分隔(中文/中文标点)
_QUOTE_DUNHAO_RE = re.compile(r"([”》])、([“《])")
# 句内括号末尾(除问号/叹号/省略号外)不应有标点
_PAREN_PUNCT_RE = re.compile(r"[(][^)]*?[,。;:、][)]")
# 引号嵌套:双引号内含单引号包裹的强调短语(如 "卓'粤'创一流"
_NESTED_QUOTE_RE = re.compile(r"“[^“”]*?[^‘’]+[^“”]*?”")
@register("punctuation")
class PunctuationCheck(CheckBase):
def run(self, ctx: CheckContext) -> CheckResult:
rules = ctx.stage.rules or []
hits: list[CheckHit] = []
for p in ctx.paragraphs:
for r in rules:
t = r.get("type")
if t == "no_dunhao_between_quotes":
for m in _QUOTE_DUNHAO_RE.finditer(p.text):
hits.append(CheckHit(
paragraph=p, char_start=m.start(), char_end=m.end(),
actual={"text": m.group(0)},
expected={"text": m.group(0).replace("", "")},
message="多个引号/书名号并列不应用顿号分隔",
))
elif t == "no_punct_inside_inline_paren":
for m in _PAREN_PUNCT_RE.finditer(p.text):
hits.append(CheckHit(
paragraph=p, char_start=m.start(), char_end=m.end(),
actual={"text": m.group(0)},
message="句内括号末尾通常不应含标点",
))
elif t == "no_outer_quote_when_inner_quote":
for m in _NESTED_QUOTE_RE.finditer(p.text):
hits.append(CheckHit(
paragraph=p, char_start=m.start(), char_end=m.end(),
actual={"text": m.group(0)},
message="双引号内已含单引号强调时,外层不应再加双引号",
))
return CheckResult(passed=not hits, hits=hits)
@@ -0,0 +1,36 @@
"""regex_require / regex_forbid。"""
import re
from fastapi_modules.fastapi_leaudit.govdoc_engine.engine.checks import register
from fastapi_modules.fastapi_leaudit.govdoc_engine.engine.checks.base import CheckBase, CheckContext, CheckHit, CheckResult
@register("regex_require")
class RegexRequireCheck(CheckBase):
def run(self, ctx: CheckContext) -> CheckResult:
pat = re.compile(ctx.stage.pattern or "")
hits: list[CheckHit] = []
for p in ctx.paragraphs:
if not pat.search(p.text):
hits.append(CheckHit(
paragraph=p, char_start=0, char_end=len(p.text),
actual={"text": p.text}, expected={"pattern": ctx.stage.pattern},
message=f"未匹配模式 {ctx.stage.pattern}",
))
return CheckResult(passed=not hits, hits=hits)
@register("regex_forbid")
class RegexForbidCheck(CheckBase):
def run(self, ctx: CheckContext) -> CheckResult:
pat = re.compile(ctx.stage.pattern or "")
hits: list[CheckHit] = []
for p in ctx.paragraphs:
for m in pat.finditer(p.text):
hits.append(CheckHit(
paragraph=p, char_start=m.start(), char_end=m.end(),
actual={"text": m.group(0)},
expected={"forbid_pattern": ctx.stage.pattern},
message=f"出现禁止模式 {ctx.stage.pattern}(命中 \"{m.group(0)}\"",
))
return CheckResult(passed=not hits, hits=hits)
@@ -0,0 +1,28 @@
"""required check:目标实体或选中段落必须有非空文本。"""
from fastapi_modules.fastapi_leaudit.govdoc_engine.engine.checks import register
from fastapi_modules.fastapi_leaudit.govdoc_engine.engine.checks.base import (
CheckBase, CheckContext, CheckHit, CheckResult,
)
@register("required")
class RequiredCheck(CheckBase):
def run(self, ctx: CheckContext) -> CheckResult:
# target 通道:检查实体 text 是否非空
if ctx.target is not None:
if ctx.target.text and ctx.target.text.strip():
return CheckResult(passed=True, hits=[])
anchor = ctx.paragraphs[0] if ctx.paragraphs else None
return CheckResult(passed=False, hits=[
CheckHit(paragraph=anchor, message=f"实体 {ctx.target.name} 缺失或为空")
])
# applies_to 通道:所有段落必须非空
empty = [p for p in ctx.paragraphs if not p.text.strip()]
if empty:
return CheckResult(
passed=False,
hits=[CheckHit(paragraph=p, message="段落为空") for p in empty],
)
return CheckResult(passed=True, hits=[])
@@ -0,0 +1,42 @@
"""文种白名单(15 种法定公文文种)。"""
from fastapi_modules.fastapi_leaudit.govdoc_engine.engine.checks import register
from fastapi_modules.fastapi_leaudit.govdoc_engine.engine.checks.base import (
CheckBase, CheckContext, CheckHit, CheckResult,
)
LEGAL_WENZHONG = {
"决议", "决定", "命令", "", "公报", "公告", "通告",
"意见", "通知", "通报", "报告", "请示", "批复",
"议案", "", "纪要",
}
@register("wenzhong_whitelist")
class WenzhongWhitelistCheck(CheckBase):
"""检查文种是否在 15 种法定文种白名单内。
数据来源:
1. ctx.entities["wenzhong"].text ← 推荐
2. ctx.target.text (当 rule.target = wenzhong 时)
"""
def run(self, ctx: CheckContext) -> CheckResult:
wz = ""
wz_entity = ctx.entities.get("wenzhong") if ctx.entities else None
if wz_entity is not None:
wz = (wz_entity.text or "").strip()
elif ctx.target is not None and ctx.target.name == "wenzhong":
wz = (ctx.target.text or "").strip()
if not wz:
return CheckResult(passed=True, hits=[])
if wz in LEGAL_WENZHONG:
return CheckResult(passed=True, hits=[])
return CheckResult(passed=False, hits=[CheckHit(
paragraph=None,
actual={"wenzhong": wz},
expected={"wenzhong_whitelist": sorted(LEGAL_WENZHONG)},
message=f"非法定文种 \"{wz}\",应为 15 种法定公文文种之一",
)])
@@ -0,0 +1,81 @@
"""审查结果数据结构。"""
from __future__ import annotations
from collections import Counter
from typing import Literal
from pydantic import BaseModel, Field
from fastapi_modules.fastapi_leaudit.govdoc_engine.models import Finding
from fastapi_modules.fastapi_leaudit.govdoc_engine.parser.entities import SemanticEntity
class CheckedRule(BaseModel):
rule_id: str
name: str
severity: str
category: str
status: Literal["pass", "fail", "skipped"]
skip_reason: str = ""
class StructureItem(BaseModel):
"""文档结构里一种 role 的统计。"""
role: str
label: str
count: int
expected: bool
paragraph_indices: list[int] = Field(default_factory=list)
samples: list[str] = Field(default_factory=list)
char_total: int = 0
dominant_font: str | None = None
dominant_size_pt: float | None = None
style_uniform: bool = True
class OutlineNode(BaseModel):
"""大纲节点(heading_1~4 的层级树)。"""
paragraph_index: int
level: int
text: str
children: list["OutlineNode"] = Field(default_factory=list)
class AuditSummary(BaseModel):
score: int = 100
total_findings: int = 0
by_severity: dict[str, int] = Field(default_factory=dict)
by_category: dict[str, int] = Field(default_factory=dict)
passed_count: int = 0
failed_count: int = 0
skipped_count: int = 0
class AuditResult(BaseModel):
audit_id: str
document: dict = Field(default_factory=dict)
summary: AuditSummary = Field(default_factory=AuditSummary)
findings: list[Finding] = Field(default_factory=list)
checked_rules: list[CheckedRule] = Field(default_factory=list)
structure: list[StructureItem] = Field(default_factory=list)
outline: list[OutlineNode] = Field(default_factory=list)
entities: dict[str, SemanticEntity | None] = Field(default_factory=dict)
def compute_summary(self) -> None:
sev_count = Counter(f.severity for f in self.findings)
cat_count = Counter(f.category for f in self.findings)
score = 100
score -= 10 * sev_count.get("error", 0)
score -= 3 * sev_count.get("warning", 0)
passed = sum(1 for r in self.checked_rules if r.status == "pass")
failed = sum(1 for r in self.checked_rules if r.status == "fail")
skipped = sum(1 for r in self.checked_rules if r.status == "skipped")
self.summary = AuditSummary(
score=max(0, score),
total_findings=len(self.findings),
by_severity=dict(sev_count),
by_category=dict(cat_count),
passed_count=passed,
failed_count=failed,
skipped_count=skipped,
)
@@ -0,0 +1,242 @@
"""规则评估引擎:跑一条规则的多 stage。"""
from __future__ import annotations
import asyncio
import uuid
from dataclasses import dataclass, field
from fastapi_modules.fastapi_leaudit.govdoc_engine.models import Document, Finding, Location
from fastapi_modules.fastapi_leaudit.govdoc_engine.dsl.schema import Rule
from fastapi_modules.fastapi_leaudit.govdoc_engine.engine.checks import get_check # noqa: F401 (确保注册)
from fastapi_modules.fastapi_leaudit.govdoc_engine.engine.checks.base import CheckContext, CheckResult, CheckHit
from fastapi_modules.fastapi_leaudit.govdoc_engine.engine.checks.ai_check import AiCheck
from fastapi_modules.fastapi_leaudit.govdoc_engine.engine.selector import select_paragraphs
from fastapi_modules.fastapi_leaudit.govdoc_engine.parser.entities import SemanticEntity
from fastapi_modules.fastapi_leaudit.govdoc_engine.llm.client import LlmClient
# 触发所有 check 类的 @register
from fastapi_modules.fastapi_leaudit.govdoc_engine.engine.checks import required as _r # noqa: F401
from fastapi_modules.fastapi_leaudit.govdoc_engine.engine.checks import font as _f # noqa: F401
from fastapi_modules.fastapi_leaudit.govdoc_engine.engine.checks import regex_check as _rc # noqa: F401
from fastapi_modules.fastapi_leaudit.govdoc_engine.engine.checks import confused_pair as _cp # noqa: F401
from fastapi_modules.fastapi_leaudit.govdoc_engine.engine.checks import forbid as _fb # noqa: F401
from fastapi_modules.fastapi_leaudit.govdoc_engine.engine.checks import wenzhong as _wz # noqa: F401
from fastapi_modules.fastapi_leaudit.govdoc_engine.engine.checks import hierarchy as _h # noqa: F401
from fastapi_modules.fastapi_leaudit.govdoc_engine.engine.checks import punctuation as _p # noqa: F401
from fastapi_modules.fastapi_leaudit.govdoc_engine.engine.checks import cross_role as _cr # noqa: F401
from fastapi_modules.fastapi_leaudit.govdoc_engine.engine.checks import ai_check as _ai # noqa: F401
@dataclass
class RuleOutcome:
"""单条规则的执行结果(含 skipped 状态)。"""
rule: Rule
findings: list[Finding] = field(default_factory=list)
skipped: bool = False
skip_reason: str = ""
class RuleRunner:
def __init__(self, llm_client: LlmClient | None = None):
self.llm = llm_client
# -- 上下文装配 -----------------------------------------------------
def _resolve_target(
self,
rule: Rule,
doc: Document,
entities: dict[str, SemanticEntity | None],
) -> tuple[list, SemanticEntity | None, RuleOutcome | None]:
"""根据 rule.target 或 rule.applies_to 选段落。
返回 (paragraphs, target_entity, early_outcome)
若 early_outcome 非 None,调用方应直接返回(命中 on_missing 提前结束)。
"""
if rule.target:
target_entity = entities.get(rule.target)
if target_entity is None:
return [], None, self._handle_missing(rule)
paragraphs = [
doc.paragraphs[i]
for i in target_entity.paragraph_indices
if 0 <= i < len(doc.paragraphs)
]
return paragraphs, target_entity, None
# applies_to 通道(多段扫描)
return select_paragraphs(doc, rule.applies_to), None, None
def _handle_missing(self, rule: Rule) -> RuleOutcome:
mode = rule.on_missing
if mode == "pass":
return RuleOutcome(rule=rule)
reason = f"目标实体「{rule.target}」未识别到"
if mode == "skip":
return RuleOutcome(rule=rule, skipped=True, skip_reason=reason)
severity = "error" if mode == "fail" else "warning"
finding = Finding(
finding_id=f"F-{uuid.uuid4().hex[:8]}",
rule_id=rule.rule_id,
rule_name=rule.name,
severity=severity,
category=rule.category,
location=Location(paragraph_index=-1),
message=reason,
suggestion=rule.messages.fail or "",
evidence="", confidence=0.9,
)
return RuleOutcome(rule=rule, findings=[finding])
@staticmethod
def _merge_skip(outcome: RuleOutcome, result: CheckResult) -> None:
if not outcome.skip_reason:
outcome.skip_reason = result.skip_reason or "stage skipped"
outcome.skipped = True
# -- 同步路径 -------------------------------------------------------
def run_rule(
self,
rule: Rule,
doc: Document,
entities: dict[str, SemanticEntity | None] | None = None,
) -> RuleOutcome:
entities = entities or {}
paragraphs, target, early = self._resolve_target(rule, doc, entities)
if early is not None:
return early
outcome = RuleOutcome(rule=rule)
for stage in rule.stages:
if stage.check == "ai":
check = AiCheck(llm_client=self.llm)
else:
check_cls = get_check(stage.check)
check = check_cls()
ctx = CheckContext(
document=doc,
paragraphs=paragraphs,
stage=stage,
entities=entities,
target=target,
rule_id=rule.rule_id,
)
result: CheckResult = check.run(ctx)
if result.skipped:
self._merge_skip(outcome, result)
continue
if not result.passed:
outcome.findings = [self._hit_to_finding(rule, h) for h in result.hits]
outcome.skipped = False
outcome.skip_reason = ""
return outcome
return outcome
def run_all(
self,
rules: list[Rule],
doc: Document,
entities: dict[str, SemanticEntity | None] | None = None,
) -> list[Finding]:
flat, _ = self.evaluate(rules, doc, entities)
return flat
def evaluate(
self,
rules: list[Rule],
doc: Document,
entities: dict[str, SemanticEntity | None] | None = None,
) -> tuple[list[Finding], list[RuleOutcome]]:
flat: list[Finding] = []
outcomes: list[RuleOutcome] = []
for r in rules:
o = self.run_rule(r, doc, entities)
flat.extend(o.findings)
outcomes.append(o)
return flat, outcomes
# -- 异步路径 -------------------------------------------------------
async def run_rule_async(
self,
rule: Rule,
doc: Document,
entities: dict[str, SemanticEntity | None] | None = None,
) -> RuleOutcome:
entities = entities or {}
paragraphs, target, early = self._resolve_target(rule, doc, entities)
if early is not None:
return early
outcome = RuleOutcome(rule=rule)
for stage in rule.stages:
ctx = CheckContext(
document=doc,
paragraphs=paragraphs,
stage=stage,
entities=entities,
target=target,
rule_id=rule.rule_id,
)
if stage.check == "ai":
result = await AiCheck(llm_client=self.llm).run_async(ctx)
else:
check_cls = get_check(stage.check)
result = check_cls().run(ctx)
if result.skipped:
self._merge_skip(outcome, result)
continue
if not result.passed:
outcome.findings = [self._hit_to_finding(rule, h) for h in result.hits]
outcome.skipped = False
outcome.skip_reason = ""
return outcome
return outcome
async def run_all_async(
self,
rules: list[Rule],
doc: Document,
entities: dict[str, SemanticEntity | None] | None = None,
) -> list[Finding]:
flat, _ = await self.evaluate_async(rules, doc, entities)
return flat
async def evaluate_async(
self,
rules: list[Rule],
doc: Document,
entities: dict[str, SemanticEntity | None] | None = None,
) -> tuple[list[Finding], list[RuleOutcome]]:
outcomes_list = await asyncio.gather(
*(self.run_rule_async(r, doc, entities) for r in rules)
)
flat: list[Finding] = []
outcomes: list[RuleOutcome] = []
for o in outcomes_list:
flat.extend(o.findings)
outcomes.append(o)
return flat, outcomes
def _hit_to_finding(self, rule: Rule, hit: CheckHit) -> Finding:
para = hit.paragraph
loc = Location(
paragraph_index=para.index if para else -1,
role=para.role if para else None,
char_start=hit.char_start,
char_end=hit.char_end,
context=para.text if para else "",
)
msg = hit.message or rule.messages.fail
return Finding(
finding_id=f"F-{uuid.uuid4().hex[:8]}",
rule_id=rule.rule_id,
rule_name=rule.name,
severity=rule.severity,
category=rule.category,
location=loc,
actual=hit.actual or {},
expected=hit.expected or {},
message=msg,
suggestion=rule.messages.fail or "",
evidence=rule.messages.fail or "",
confidence=hit.confidence,
)
@@ -0,0 +1,27 @@
"""applies_to → 段落集合。"""
from __future__ import annotations
from fastapi_modules.fastapi_leaudit.govdoc_engine.models import Document, Paragraph
from fastapi_modules.fastapi_leaudit.govdoc_engine.dsl.schema import AppliesTo
def select_paragraphs(doc: Document, applies_to: AppliesTo) -> list[Paragraph]:
if applies_to.paragraph_index is not None:
idx = applies_to.paragraph_index
if 0 <= idx < len(doc.paragraphs):
return [doc.paragraphs[idx]]
return []
if applies_to.role == "any":
return list(doc.paragraphs)
targets: set[str] = set()
if applies_to.role:
targets.add(applies_to.role)
if applies_to.roles:
targets.update(applies_to.roles)
if not targets:
return list(doc.paragraphs)
return [p for p in doc.paragraphs if p.role in targets]
@@ -0,0 +1,93 @@
"""从 Document 派生出 structure(按 role 分类统计)+ outlineheading 层级树)。"""
from __future__ import annotations
from collections import Counter
from fastapi_modules.fastapi_leaudit.govdoc_engine.models import Document, Paragraph
from fastapi_modules.fastapi_leaudit.govdoc_engine.engine.result import OutlineNode, StructureItem
_ROLE_LABELS: list[tuple[str, str, bool]] = [
# (role, 中文标签, 是否常规公文必备)
("title", "标题", True),
("doc_number", "发文字号", True),
("recipient", "主送机关", True),
("heading_1", "一级标题", False),
("heading_2", "二级标题", False),
("heading_3", "三级标题", False),
("heading_4", "四级标题", False),
("body", "正文", True),
("attachment_marker", "附件标记", False),
("attachment_title", "附件标题", False),
("signature", "署名", True),
("date", "成文日期", True),
("no_text_marker", "(此页无正文)", False),
("unknown", "未识别", False),
]
_HEADING_LEVELS = {
"heading_1": 1,
"heading_2": 2,
"heading_3": 3,
"heading_4": 4,
}
def _dominant_style(paragraphs: list[Paragraph]) -> tuple[str | None, float | None, bool]:
"""返回 (字体众数, 字号众数, 是否所有段落样式一致)。"""
if not paragraphs:
return None, None, True
fonts = Counter(p.style.font_eastasia for p in paragraphs if p.style.font_eastasia)
sizes = Counter(p.style.font_size_pt for p in paragraphs if p.style.font_size_pt is not None)
dom_font = fonts.most_common(1)[0][0] if fonts else None
dom_size = sizes.most_common(1)[0][0] if sizes else None
uniform = len(fonts) <= 1 and len(sizes) <= 1
return dom_font, dom_size, uniform
def build_structure(doc: Document) -> list[StructureItem]:
items: list[StructureItem] = []
for role, label, expected in _ROLE_LABELS:
paragraphs = [p for p in doc.paragraphs if p.role == role]
if not paragraphs and not expected:
# 非必备 role 没出现就不展示,保持面板紧凑
continue
samples = [p.text[:60] for p in paragraphs[:3]]
font, size, uniform = _dominant_style(paragraphs)
items.append(StructureItem(
role=role,
label=label,
count=len(paragraphs),
expected=expected,
paragraph_indices=[p.index for p in paragraphs],
samples=samples,
char_total=sum(len(p.text) for p in paragraphs),
dominant_font=font,
dominant_size_pt=size,
style_uniform=uniform,
))
return items
def build_outline(doc: Document) -> list[OutlineNode]:
"""按段落顺序 + heading 层级生成树。"""
headings = [
(p.index, _HEADING_LEVELS[p.role], p.text)
for p in doc.paragraphs
if p.role in _HEADING_LEVELS
]
if not headings:
return []
roots: list[OutlineNode] = []
stack: list[OutlineNode] = []
for idx, level, text in headings:
node = OutlineNode(paragraph_index=idx, level=level, text=text)
# 弹出比当前 level 更深的祖先
while stack and stack[-1].level >= level:
stack.pop()
if stack:
stack[-1].children.append(node)
else:
roots.append(node)
stack.append(node)
return roots
@@ -0,0 +1,101 @@
"""LLM 响应缓存(SQLite)。
缓存键 = sha256(model + canonical_json(messages, temperature, top_p, max_tokens))。
仅缓存成功返回的文本;JSON 解析失败、API 错误、超时一律不入库。
"""
from __future__ import annotations
import hashlib
import json
import logging
import sqlite3
import time
from pathlib import Path
from threading import Lock
from typing import Any
_log = logging.getLogger(__name__)
_SCHEMA = """
CREATE TABLE IF NOT EXISTS llm_cache (
cache_key TEXT PRIMARY KEY,
model TEXT NOT NULL,
response_text TEXT NOT NULL,
created_at REAL NOT NULL,
hit_count INTEGER NOT NULL DEFAULT 0,
last_hit_at REAL
);
CREATE INDEX IF NOT EXISTS idx_llm_cache_created ON llm_cache(created_at);
"""
# 影响响应的关键参数。其他 kwargs 不入 hash(如 stream/timeout)。
_KEY_PARAMS = ("temperature", "top_p", "max_tokens", "response_format")
def _canonical(messages: list[dict[str, str]], **kwargs: Any) -> str:
payload = {
"messages": messages,
"params": {k: kwargs.get(k) for k in _KEY_PARAMS},
}
return json.dumps(payload, sort_keys=True, ensure_ascii=False)
def make_key(model: str, messages: list[dict[str, str]], **kwargs: Any) -> str:
raw = model + "\x00" + _canonical(messages, **kwargs)
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
class LlmCache:
def __init__(self, path: str | Path):
self.path = Path(path)
self.path.parent.mkdir(parents=True, exist_ok=True)
self._lock = Lock()
self._conn = sqlite3.connect(str(self.path), check_same_thread=False)
self._conn.executescript(_SCHEMA)
self._conn.commit()
def get(self, key: str) -> str | None:
with self._lock:
row = self._conn.execute(
"SELECT response_text FROM llm_cache WHERE cache_key = ?",
(key,),
).fetchone()
if row is None:
return None
self._conn.execute(
"UPDATE llm_cache "
"SET hit_count = hit_count + 1, last_hit_at = ? "
"WHERE cache_key = ?",
(time.time(), key),
)
self._conn.commit()
return row[0]
def put(self, key: str, model: str, response_text: str) -> None:
if not response_text:
return
with self._lock:
self._conn.execute(
"INSERT OR IGNORE INTO llm_cache "
"(cache_key, model, response_text, created_at) "
"VALUES (?, ?, ?, ?)",
(key, model, response_text, time.time()),
)
self._conn.commit()
def stats(self) -> dict[str, int]:
with self._lock:
row = self._conn.execute(
"SELECT COUNT(*), COALESCE(SUM(hit_count), 0) FROM llm_cache"
).fetchone()
return {"entries": int(row[0] or 0), "total_hits": int(row[1] or 0)}
def clear(self) -> int:
with self._lock:
cur = self._conn.execute("DELETE FROM llm_cache")
self._conn.commit()
return cur.rowcount
def close(self) -> None:
with self._lock:
self._conn.close()
@@ -0,0 +1,258 @@
"""Qwen LLM 客户端(OpenAI 兼容协议)。
包含:超时(asyncio.wait_for)、重试(指数退避)、并发上限(Semaphore)。
"""
from __future__ import annotations
import asyncio
import json
import logging
import re
import time
from typing import Any
from openai import AsyncOpenAI, OpenAI, APIError, APIConnectionError, RateLimitError
from fastapi_admin.config import (
LLM_API_KEY,
LLM_BASE_URL,
LLM_MODEL,
LEAUDIT_LLM_MAX_CONCURRENCY,
LEAUDIT_LLM_REQUEST_TIMEOUT,
LEAUDIT_LLM_RETRY_MAX_ATTEMPTS,
LEAUDIT_LLM_RETRY_BACKOFF_BASE_SECONDS,
)
from fastapi_modules.fastapi_leaudit.govdoc_engine.llm.cache import LlmCache, make_key
_log = logging.getLogger(__name__)
_FENCE_RE = re.compile(r"```(?:json)?\s*([\s\S]+?)\s*```", re.MULTILINE)
# 这些异常会触发重试;JSON 解析错误等业务错误不重试
_RETRYABLE = (
asyncio.TimeoutError,
TimeoutError,
APIConnectionError,
RateLimitError,
)
class LlmJsonError(Exception):
"""LLM 返回内容无法解析为 JSON。"""
class LlmConfigError(Exception):
"""LLM 客户端缺少必要配置。"""
def _parse_json_text(text: str) -> dict[str, Any]:
text = text.strip()
m = _FENCE_RE.search(text)
if m:
text = m.group(1)
try:
return json.loads(text)
except json.JSONDecodeError:
start = text.find("{")
end = text.rfind("}")
if start >= 0 and end > start:
try:
return json.loads(text[start : end + 1])
except json.JSONDecodeError as e:
raise LlmJsonError(f"failed to parse LLM JSON: {text!r}") from e
raise LlmJsonError(f"LLM returned non-JSON content: {text!r}")
def _is_retryable_status(exc: Exception) -> bool:
"""APIError 中只重试 5xx 与 429。"""
if isinstance(exc, RateLimitError):
return True
if isinstance(exc, APIError):
status = getattr(exc, "status_code", None)
return status is not None and (status >= 500 or status == 429)
return False
def _clip_text(value: Any, limit: int = 400) -> str:
text = str(value).strip()
if len(text) <= limit:
return text
return text[: limit - 3] + "..."
def _format_exc(exc: Exception) -> str:
text = str(exc).strip()
parts = [exc.__class__.__name__]
if text:
parts.append(text)
status = getattr(exc, "status_code", None)
if status is not None:
parts.append(f"status={status}")
body = getattr(exc, "body", None)
if body not in (None, "", b""):
parts.append(f"body={_clip_text(body)}")
response = getattr(exc, "response", None)
if response is not None:
try:
request = getattr(response, "request", None)
if request is not None and getattr(request, "url", None):
parts.append(f"url={request.url}")
except Exception:
pass
request = getattr(exc, "request", None)
if request is not None and getattr(request, "url", None):
parts.append(f"url={request.url}")
return ": ".join(parts[:2]) + ("" if len(parts) <= 2 else " | " + " | ".join(parts[2:]))
class LlmClient:
def __init__(
self,
api_key: str | None = None,
base_url: str | None = None,
model: str | None = None,
max_concurrency: int | None = None,
timeout_seconds: float | None = None,
max_retries: int | None = None,
cache: LlmCache | None = None,
cache_enabled: bool | None = None,
):
key = api_key or LLM_API_KEY
self._misconfigured_error: LlmConfigError | None = None
if not key:
self._client = None
self._aclient = None
self._misconfigured_error = LlmConfigError(
"LLM_API_KEY is not configured. Set LLM_API_KEY in platform config."
)
else:
self._client = OpenAI(api_key=key, base_url=base_url or LLM_BASE_URL)
self._aclient = AsyncOpenAI(api_key=key, base_url=base_url or LLM_BASE_URL)
self.model = model or LLM_MODEL
self.timeout = timeout_seconds if timeout_seconds is not None else LEAUDIT_LLM_REQUEST_TIMEOUT
self.max_retries = max_retries if max_retries is not None else LEAUDIT_LLM_RETRY_MAX_ATTEMPTS
conc = max_concurrency if max_concurrency is not None else LEAUDIT_LLM_MAX_CONCURRENCY
self._sem = asyncio.Semaphore(conc)
# 缓存:cache 显式传入则用之;否则默认关闭。
if cache is not None:
self.cache: LlmCache | None = cache
elif cache_enabled is not False:
self.cache = None
else:
self.cache = None
def _ensure_ready(self) -> None:
if self._misconfigured_error is not None:
raise self._misconfigured_error
@staticmethod
def _prompt_text(messages: list[dict[str, str]]) -> str:
return "\n\n".join(
f"[{m.get('role', 'user')}]\n{m.get('content', '')}"
for m in messages
)
# -- 同步路径 -------------------------------------------------------
def chat(self, messages: list[dict[str, str]], **kwargs) -> str:
self._ensure_ready()
use_cache = kwargs.pop("use_cache", True)
label = kwargs.pop("label", "llm_call")
cache_kwargs = {k: kwargs.get(k) for k in ("temperature", "top_p", "max_tokens", "response_format")}
cache_key: str | None = None
prompt_text = self._prompt_text(messages)
t0 = time.monotonic()
if use_cache and self.cache is not None:
cache_key = make_key(self.model, messages, **cache_kwargs)
hit = self.cache.get(cache_key)
if hit is not None:
_log.debug("LLM cache HIT key=%s", cache_key[:12])
return hit
kwargs.setdefault("timeout", self.timeout)
last_exc: Exception | None = None
for attempt in range(self.max_retries + 1):
try:
resp = self._client.chat.completions.create(
model=self.model, messages=messages, **kwargs
)
content = resp.choices[0].message.content or ""
if cache_key is not None and content:
self.cache.put(cache_key, self.model, content)
return content
except _RETRYABLE as e:
last_exc = e
except APIError as e:
if not _is_retryable_status(e):
raise
last_exc = e
if attempt < self.max_retries:
wait = min(8.0, 2 ** attempt)
_log.warning(
"LLM call failed (%s); retry %d/%d after %.1fs",
_format_exc(last_exc), attempt + 1, self.max_retries, wait,
)
time.sleep(wait)
assert last_exc is not None
raise last_exc
def chat_json(self, messages: list[dict[str, str]], **kwargs) -> dict[str, Any]:
return _parse_json_text(self.chat(messages, **kwargs))
# -- 异步路径 -------------------------------------------------------
async def chat_async(self, messages: list[dict[str, str]], **kwargs) -> str:
self._ensure_ready()
use_cache = kwargs.pop("use_cache", True)
label = kwargs.pop("label", "llm_call")
cache_kwargs = {k: kwargs.get(k) for k in ("temperature", "top_p", "max_tokens", "response_format")}
cache_key: str | None = None
prompt_text = self._prompt_text(messages)
t0 = time.monotonic()
if use_cache and self.cache is not None:
cache_key = make_key(self.model, messages, **cache_kwargs)
hit = self.cache.get(cache_key)
if hit is not None:
_log.debug("LLM cache HIT key=%s", cache_key[:12])
return hit
last_exc: Exception | None = None
for attempt in range(self.max_retries + 1):
try:
async with self._sem:
resp = await asyncio.wait_for(
self._aclient.chat.completions.create(
model=self.model, messages=messages, **kwargs,
),
timeout=self.timeout,
)
content = resp.choices[0].message.content or ""
if cache_key is not None and content:
self.cache.put(cache_key, self.model, content)
return content
except _RETRYABLE as e:
last_exc = e
except APIError as e:
if not _is_retryable_status(e):
raise
last_exc = e
if attempt < self.max_retries:
wait = min(8.0, 2 ** attempt)
_log.warning(
"LLM async call failed (%s); retry %d/%d after %.1fs",
_format_exc(last_exc), attempt + 1, self.max_retries, wait,
)
await asyncio.sleep(wait)
assert last_exc is not None
raise last_exc
async def chat_json_async(
self, messages: list[dict[str, str]], **kwargs
) -> dict[str, Any]:
return _parse_json_text(await self.chat_async(messages, **kwargs))
@@ -0,0 +1,77 @@
"""公文审查的核心数据模型。"""
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, Field
Role = Literal[
"title", "doc_number", "recipient",
"heading_1", "heading_2", "heading_3", "heading_4",
"body", "attachment_marker", "attachment_title", "signature", "date",
"no_text_marker", "unknown", "any",
]
Severity = Literal["error", "warning", "info"]
class ParagraphStyle(BaseModel):
font_eastasia: str | None = None
font_ascii: str | None = None
font_size_pt: float | None = None
bold: bool = False
italic: bool = False
line_spacing: float | None = None
line_spacing_rule: str | None = None
alignment: str = "left"
first_line_indent_pt: float = 0.0
class Run(BaseModel):
text: str
style: ParagraphStyle
class Paragraph(BaseModel):
index: int
text: str
runs: list[Run]
style: ParagraphStyle
role: Role | None = None
role_confidence: float = 1.0
in_table: bool = False
in_header: bool = False
in_footer: bool = False
class Table(BaseModel):
index: int
rows: list[list[str]]
class Document(BaseModel):
meta: dict[str, Any] = Field(default_factory=dict)
paragraphs: list[Paragraph]
tables: list[Table] = Field(default_factory=list)
class Location(BaseModel):
paragraph_index: int
role: Role | None = None
char_start: int = 0
char_end: int = 0
context: str = ""
class Finding(BaseModel):
finding_id: str
rule_id: str
rule_name: str
severity: Severity
category: str
location: Location
actual: dict[str, Any] = Field(default_factory=dict)
expected: dict[str, Any] = Field(default_factory=dict)
message: str
suggestion: str = ""
evidence: str = ""
confidence: float = 1.0
@@ -0,0 +1,152 @@
"""解析 .docx → Document 对象。
文档顺序遍历 body:顶级段落 + 表格内段落都纳入 paragraphs
后续 role tagging 与规则评估都能扫到表格内的内容。
"""
from __future__ import annotations
from pathlib import Path
from docx import Document as DocxDocument
from docx.oxml.ns import qn
from docx.text.paragraph import Paragraph as DocxParagraph
from lxml import etree
from fastapi_modules.fastapi_leaudit.govdoc_engine.models import Document, Paragraph, ParagraphStyle, Run, Table
from fastapi_modules.fastapi_leaudit.govdoc_engine.parser.style_resolver import StyleResolver
_ALIGN_MAP = {0: "left", 1: "center", 2: "right", 3: "justify"}
def _read_run_style(run, p_elem, resolver: StyleResolver) -> ParagraphStyle:
rs = resolver.resolve_run(p_elem, run._element)
return ParagraphStyle(
font_eastasia=rs.font_eastasia,
font_ascii=rs.font_ascii,
font_size_pt=rs.size_pt,
bold=bool(rs.bold) if rs.bold is not None else False,
italic=bool(rs.italic) if rs.italic is not None else False,
)
def _read_paragraph_style(p, resolver: StyleResolver) -> ParagraphStyle:
pf = p.paragraph_format
alignment = (
_ALIGN_MAP.get(pf.alignment, "left") if pf.alignment is not None else "left"
)
spacing_pt = float(pf.line_spacing) if pf.line_spacing is not None else None
indent = pf.first_line_indent
indent_pt = float(indent.pt) if indent is not None else 0.0
if p.runs:
base = _read_run_style(p.runs[0], p._element, resolver)
else:
rs = resolver.resolve_paragraph(p._element)
base = ParagraphStyle(
font_eastasia=rs.font_eastasia,
font_ascii=rs.font_ascii,
font_size_pt=rs.size_pt,
bold=bool(rs.bold) if rs.bold is not None else False,
italic=bool(rs.italic) if rs.italic is not None else False,
)
base.alignment = alignment
base.line_spacing = spacing_pt
base.first_line_indent_pt = indent_pt
return base
def _is_in_table(p_elem) -> bool:
parent = p_elem.getparent()
while parent is not None:
if etree.QName(parent).localname == "tbl":
return True
parent = parent.getparent()
return False
def _iter_body_paragraphs(docx):
"""文档顺序遍历 body 下所有 w:p(含表格内)。"""
for p_elem in docx.element.body.iter(qn("w:p")):
yield p_elem
def _iter_header_footer_paragraphs(docx):
"""yield (DocxParagraph, p_elem, in_header, in_footer),跨 section 去重。"""
seen: set[int] = set()
for section in docx.sections:
targets = [
("header", section.header),
("first_header", section.first_page_header),
("even_header", section.even_page_header),
("footer", section.footer),
("first_footer", section.first_page_footer),
("even_footer", section.even_page_footer),
]
for kind, hf in targets:
if hf is None:
continue
try:
if hf.is_linked_to_previous:
continue
except Exception:
pass
in_header = "header" in kind
for p in hf.paragraphs:
pid = id(p._element)
if pid in seen:
continue
seen.add(pid)
yield p, p._element, in_header, not in_header
def parse_docx(path: str | Path) -> Document:
path = Path(path)
docx = DocxDocument(path)
resolver = StyleResolver(docx)
paragraphs: list[Paragraph] = []
idx = 0
# 1) body:含表格内段落
for p_elem in _iter_body_paragraphs(docx):
p = DocxParagraph(p_elem, docx.part)
runs = [
Run(text=r.text, style=_read_run_style(r, p_elem, resolver))
for r in p.runs
]
style = _read_paragraph_style(p, resolver)
paragraphs.append(Paragraph(
index=idx,
text=p.text,
runs=runs,
style=style,
in_table=_is_in_table(p_elem),
))
idx += 1
# 2) headers / footers:附在末尾,role tagger 也能扫到
for p, p_elem, in_header, in_footer in _iter_header_footer_paragraphs(docx):
runs = [
Run(text=r.text, style=_read_run_style(r, p_elem, resolver))
for r in p.runs
]
style = _read_paragraph_style(p, resolver)
paragraphs.append(Paragraph(
index=idx,
text=p.text,
runs=runs,
style=style,
in_table=_is_in_table(p_elem),
in_header=in_header,
in_footer=in_footer,
))
idx += 1
tables = []
for tidx, t in enumerate(docx.tables):
rows = [[cell.text for cell in row.cells] for row in t.rows]
tables.append(Table(index=tidx, rows=rows))
return Document(
meta={"path": str(path), "page_count": len(docx.sections)},
paragraphs=paragraphs,
tables=tables,
)
@@ -0,0 +1,27 @@
"""语义实体:把段落 + 字段值 + 样式合在一起。"""
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, Field
from fastapi_modules.fastapi_leaudit.govdoc_engine.models import ParagraphStyle
EntitySource = Literal["structural", "llm", "derived"]
class SemanticEntity(BaseModel):
"""公文中的一个语义单元(标题 / 发文字号 / 主送机关 / ...)。
- structuralname 与某个 role 一一对应,paragraph_indices 非空,style 可用。
- derived:从其他实体推导(如 wenzhong 从 title 末尾),paragraph_indices 借用源段落。
- llm:仅当结构 / 派生路径都失败时启用,paragraph_indices 可能为空。
"""
name: str
text: str = ""
paragraph_indices: list[int] = Field(default_factory=list)
primary_role: str | None = None
style: ParagraphStyle | None = None
extra: dict[str, Any] = Field(default_factory=dict)
source: EntitySource = "structural"
confidence: float = 1.0
@@ -0,0 +1,195 @@
"""从已 tag 的 Document 抽取语义实体(结构化优先)。"""
from __future__ import annotations
import re
from fastapi_modules.fastapi_leaudit.govdoc_engine.models import Document
from fastapi_modules.fastapi_leaudit.govdoc_engine.parser.entities import SemanticEntity
# 8 个内置实体名(也用于 schema 校验冲突)
BUILTIN_ENTITY_NAMES: frozenset[str] = frozenset({
"title", "doc_number", "recipient", "date",
"signature", "attachments", "wenzhong", "issuer",
})
# 内置实体的 LLM 兜底 prompt 描述(Phase B 使用)
BUILTIN_LLM_DESCRIPTION: dict[str, str] = {
"title": "公文主标题(不含发文字号)",
"doc_number": "X发〔YYYYN号 形式的发文字号",
"recipient": "公文抬头的接收机关名称",
"date": "末尾的成文日期原文",
"signature": "末尾的发文机关署名",
"attachments": "附件清单(数组,每项含 序号 与 名称)",
"wenzhong": "公文文种(决议/决定/通知/通报/请示/批复 等 15 种之一)",
"issuer": "发文机关全称",
}
# role → entity name 的 1:1 映射
_ROLE_ENTITY_MAP = {
"title": "title",
"doc_number": "doc_number",
"recipient": "recipient",
"date": "date",
"signature": "signature",
}
_ATTACHMENT_HEAD_RE = re.compile(r"^附件\d*[:]\s*")
_ATTACHMENT_ITEM_RE = re.compile(r"^\s*(\d+)[\..、)]\s*(.+)$")
# 15 种法定文种(参照《党政机关公文处理工作条例》)
_WENZHONG_LIST = (
"决议", "决定", "命令", "公报", "公告", "通告",
"意见", "通知", "通报", "报告", "请示", "批复",
"议案", "", "纪要",
)
_WENZHONG_RE = re.compile("(" + "|".join(_WENZHONG_LIST) + ")$")
# 「XX关于...的YY」 → issuer = XX
_ISSUER_PREFIX_RE = re.compile(r"^(.+?)关于")
class EntityBuilder:
"""从已 tag 的 Document 抽取 8 个内置语义实体。"""
def build(self, doc: Document) -> dict[str, SemanticEntity | None]:
entities: dict[str, SemanticEntity | None] = {
name: None for name in BUILTIN_ENTITY_NAMES
}
# ① 一对一 role → entity
for role, name in _ROLE_ENTITY_MAP.items():
paras = [p for p in doc.paragraphs if p.role == role]
if not paras:
continue
target = paras[-1] if name == "signature" else paras[0]
entities[name] = SemanticEntity(
name=name,
text=target.text.strip(),
paragraph_indices=[target.index],
primary_role=role,
style=target.style,
source="structural",
confidence=target.role_confidence,
)
# ② attachmentsattachment_marker + 跟随行
entities["attachments"] = self._build_attachments(doc)
# ③ 派生:wenzhong / issuer
title_e = entities.get("title")
if title_e:
entities["wenzhong"] = self._derive_wenzhong(title_e)
entities["issuer"] = self._derive_issuer(
title_e, entities.get("signature")
)
elif entities.get("signature"):
entities["issuer"] = self._derive_issuer(
None, entities["signature"]
)
return entities
# ---------- attachments ----------
def _build_attachments(self, doc: Document) -> SemanticEntity | None:
markers = [
i for i, p in enumerate(doc.paragraphs)
if p.role == "attachment_marker"
]
if not markers:
return None
m = markers[0]
items: list[dict] = []
para_idxs: list[int] = [m]
first = doc.paragraphs[m].text.strip()
head = _ATTACHMENT_HEAD_RE.sub("", first)
if head:
mt = _ATTACHMENT_ITEM_RE.match(head)
if mt:
items.append(
{"序号": int(mt.group(1)), "名称": mt.group(2).strip()}
)
else:
items.append({"序号": 1, "名称": head})
# 后续顺序行:直到遇到非 body / unknown 的段
for j in range(m + 1, len(doc.paragraphs)):
p = doc.paragraphs[j]
if p.role and p.role not in ("body", "unknown", "attachment_marker"):
break
t = p.text.strip()
if not t:
continue
mt = _ATTACHMENT_ITEM_RE.match(t)
if not mt:
break
items.append(
{"序号": int(mt.group(1)), "名称": mt.group(2).strip()}
)
para_idxs.append(p.index)
if not items:
return None
text = "; ".join(f"{it['序号']}. {it['名称']}" for it in items)
return SemanticEntity(
name="attachments",
text=text,
paragraph_indices=para_idxs,
primary_role="attachment_marker",
style=doc.paragraphs[m].style,
extra={"items": items},
source="structural",
confidence=0.9,
)
# ---------- 派生 ----------
def _derive_wenzhong(
self, title: SemanticEntity
) -> SemanticEntity | None:
m = _WENZHONG_RE.search(title.text)
if not m:
return None
return SemanticEntity(
name="wenzhong",
text=m.group(1),
paragraph_indices=list(title.paragraph_indices),
primary_role="title",
extra={"derived_from": "title.suffix"},
source="derived",
confidence=0.95,
)
def _derive_issuer(
self,
title: SemanticEntity | None,
signature: SemanticEntity | None,
) -> SemanticEntity | None:
if title:
m = _ISSUER_PREFIX_RE.match(title.text)
if m:
return SemanticEntity(
name="issuer",
text=m.group(1),
paragraph_indices=list(title.paragraph_indices),
primary_role="title",
extra={"derived_from": "title.prefix"},
source="derived",
confidence=0.9,
)
if signature:
return SemanticEntity(
name="issuer",
text=signature.text,
paragraph_indices=list(signature.paragraph_indices),
primary_role="signature",
style=signature.style,
extra={"derived_from": "signature"},
source="derived",
confidence=0.8,
)
return None
@@ -0,0 +1,104 @@
"""LLM 字段抽取:差量模式(仅对未知字段构造 prompt)。"""
from __future__ import annotations
import logging
from typing import Any
from fastapi_modules.fastapi_leaudit.govdoc_engine.models import Document
from fastapi_modules.fastapi_leaudit.govdoc_engine.llm.client import LlmClient, _format_exc
_log = logging.getLogger(__name__)
_PROMPT_HEAD = """从下面的公文中抽取以下指定字段,仅以 JSON 输出。
【公文内容(顺序段落)】
{text}
【需要抽取的字段】
{spec_block}
【输出格式】
仅 JSON{{{example}}}
未识别的字段填 ""list 类型填 [])。
"""
def _build_doc_text(doc: Document) -> str:
return "\n".join(f"[{p.index}] {p.text}" for p in doc.paragraphs)
def _example_for(spec: dict[str, dict]) -> str:
parts = []
for name, meta in spec.items():
t = meta.get("type", "string")
if t == "list":
parts.append(f'"{name}": []')
else:
parts.append(f'"{name}": ""')
return ", ".join(parts)
class FieldExtractor:
"""LLM 差量字段抽取。
extract_missing(doc, spec): spec 指定需要抽哪些字段;空 spec 不调 LLM。
"""
def __init__(self, llm_client: LlmClient):
self.client = llm_client
def _build_messages_for_spec(
self, doc: Document, spec: dict[str, dict]
) -> list[dict[str, str]]:
spec_lines = [
f"- {name}: {meta.get('description', name)}"
f"{meta.get('type', 'string')}"
for name, meta in spec.items()
]
prompt = _PROMPT_HEAD.format(
text=_build_doc_text(doc),
spec_block="\n".join(spec_lines) or "(无)",
example=_example_for(spec),
)
return [{"role": "user", "content": prompt}]
def _shape_missing(
self, spec: dict[str, dict], resp: dict
) -> dict[str, Any]:
out: dict[str, Any] = {}
for name, meta in spec.items():
if meta.get("type") == "list":
out[name] = resp.get(name) or []
else:
out[name] = resp.get(name) or ""
return out
def extract_missing(
self, doc: Document | None, spec: dict[str, dict]
) -> dict[str, Any]:
if not spec or doc is None:
return {}
label = "extract_missing__" + ",".join(spec.keys())
try:
resp = self.client.chat_json(
self._build_messages_for_spec(doc, spec), label=label,
)
except Exception as e:
_log.warning("Differential extraction failed: %s", _format_exc(e))
resp = {}
return self._shape_missing(spec, resp)
async def extract_missing_async(
self, doc: Document | None, spec: dict[str, dict]
) -> dict[str, Any]:
if not spec or doc is None:
return {}
label = "extract_missing__" + ",".join(spec.keys())
try:
resp = await self.client.chat_json_async(
self._build_messages_for_spec(doc, spec), label=label,
)
except Exception as e:
_log.warning("Differential extraction failed: %s", _format_exc(e))
resp = {}
return self._shape_missing(spec, resp)
@@ -0,0 +1,83 @@
"""doc / wps → docx 转换。"""
from __future__ import annotations
import shutil
import subprocess
from pathlib import Path
from fastapi_modules.fastapi_leaudit.govdoc_engine.config import get_settings
class UnsupportedFormat(Exception):
pass
class ConversionError(Exception):
pass
_SUPPORTED_DIRECT = {".docx"}
_SUPPORTED_CONVERT = {".doc", ".wps"}
_SOFFICE_FALLBACK_PATHS = (
"/opt/homebrew/bin/soffice",
"/usr/local/bin/soffice",
"/Applications/LibreOffice.app/Contents/MacOS/soffice",
"/usr/bin/soffice",
)
def load_to_docx(src: Path) -> Path:
"""统一返回 .docx 路径。.doc/.wps 调 soffice 转换。"""
ext = src.suffix.lower()
if ext in _SUPPORTED_DIRECT:
return src
if ext in _SUPPORTED_CONVERT:
return _convert_via_soffice(src)
raise UnsupportedFormat(f"unsupported file type: {ext}")
def _convert_via_soffice(src: Path) -> Path:
soffice = _resolve_soffice_path(get_settings().soffice_path)
out_dir = src.parent
cmd = [
soffice, "--headless", "--convert-to", "docx",
"--outdir", str(out_dir), str(src),
]
try:
result = subprocess.run(
cmd, capture_output=True, timeout=60,
)
except subprocess.TimeoutExpired as e:
raise ConversionError("soffice timeout") from e
if result.returncode != 0:
raise ConversionError(
f"soffice exit {result.returncode}: {result.stderr.decode(errors='ignore')}"
)
out = out_dir / (src.stem + ".docx")
if not out.exists():
raise ConversionError(f"expected output not found: {out}")
return out
def _resolve_soffice_path(configured: str) -> str:
candidates = [configured, *_SOFFICE_FALLBACK_PATHS]
checked: list[str] = []
for candidate in candidates:
if candidate in checked:
continue
checked.append(candidate)
resolved = shutil.which(candidate)
if resolved:
return resolved
if Path(candidate).exists():
return candidate
raise ConversionError(
f"soffice not found; checked: {', '.join(checked)}. "
"Install LibreOffice or set SOFFICE_PATH."
)
@@ -0,0 +1,50 @@
"""组合规则 tagger + LLM tagger 的总入口。"""
from __future__ import annotations
import asyncio
from fastapi_modules.fastapi_leaudit.govdoc_engine.models import Document
from fastapi_modules.fastapi_leaudit.govdoc_engine.parser.role_tagger_rule import RuleBasedTagger
from fastapi_modules.fastapi_leaudit.govdoc_engine.parser.role_tagger_llm import LlmTagger
from fastapi_modules.fastapi_leaudit.govdoc_engine.llm.client import LlmClient
class RoleTagger:
"""两段式:先规则打标,置信度 < threshold 的段落送 LLM 兜底。"""
def __init__(
self,
llm_client: LlmClient | None = None,
threshold: float = 0.8,
):
self.rule = RuleBasedTagger()
self.llm = LlmTagger(llm_client) if llm_client else None
self.threshold = threshold
def _low_conf_indices(self, doc: Document) -> list[int]:
return [
i for i, p in enumerate(doc.paragraphs)
if p.role_confidence < self.threshold
]
def tag(self, doc: Document) -> None:
self.rule.tag(doc)
if self.llm is None:
return
for i in self._low_conf_indices(doc):
role, conf = self.llm.disambiguate(doc, i)
doc.paragraphs[i].role = role
doc.paragraphs[i].role_confidence = conf
async def tag_async(self, doc: Document) -> None:
self.rule.tag(doc)
if self.llm is None:
return
targets = self._low_conf_indices(doc)
if not targets:
return
results = await asyncio.gather(
*(self.llm.disambiguate_async(doc, i) for i in targets)
)
for i, (role, conf) in zip(targets, results):
doc.paragraphs[i].role = role
doc.paragraphs[i].role_confidence = conf
@@ -0,0 +1,90 @@
"""LLM 兜底打 role:对低置信段落做二次确认。"""
from __future__ import annotations
import logging
from fastapi_modules.fastapi_leaudit.govdoc_engine.models import Document, Role
from fastapi_modules.fastapi_leaudit.govdoc_engine.llm.client import LlmClient, _format_exc
_log = logging.getLogger(__name__)
VALID_ROLES = [
"title", "doc_number", "recipient",
"heading_1", "heading_2", "heading_3", "heading_4",
"body", "attachment_marker", "signature", "date",
"no_text_marker", "unknown",
]
_PROMPT = """你是公文格式专家。下面是一份公文的段落列表,请为指定的"待定段落"判断其角色。
【全文段落(带索引和当前规则推测)】
{context}
【待定段落 idx={idx}
文本: {text}
当前推测角色: {current_role}(置信度 {conf:.2f}
【角色取值范围】
{roles}
请综合公文结构判断该段落最可能的角色。
仅以 JSON 输出:
{{"role": "<角色>", "confidence": <0-1 浮点数>, "reason": "<简短理由>"}}
"""
class LlmTagger:
def __init__(self, client: LlmClient):
self.client = client
def _build_prompt(self, doc: Document, target_idx: int) -> tuple[str, "object"]:
ctx_lines = []
for p in doc.paragraphs:
tag = "← 待定" if p.index == target_idx else ""
ctx_lines.append(f"[{p.index}] role={p.role} text={p.text[:60]} {tag}")
ctx = "\n".join(ctx_lines)
target = doc.paragraphs[target_idx]
prompt = _PROMPT.format(
context=ctx,
idx=target_idx,
text=target.text,
current_role=target.role or "unknown",
conf=target.role_confidence,
roles=", ".join(VALID_ROLES),
)
return prompt, target
def _interpret(self, resp: dict, target) -> tuple[Role, float]:
role = resp.get("role", "unknown")
if role not in VALID_ROLES:
role = "unknown"
conf = float(resp.get("confidence", 0.5))
return role, conf # type: ignore[return-value]
def disambiguate(self, doc: Document, target_idx: int) -> tuple[Role, float]:
prompt, target = self._build_prompt(doc, target_idx)
label = f"role_tag_p{target_idx}"
try:
resp = self.client.chat_json(
[{"role": "user", "content": prompt}], label=label,
)
except Exception as e:
_log.warning("Role disambiguation skipped (LLM error): %s", _format_exc(e))
return target.role or "unknown", target.role_confidence # type: ignore[return-value]
return self._interpret(resp, target)
async def disambiguate_async(
self, doc: Document, target_idx: int
) -> tuple[Role, float]:
prompt, target = self._build_prompt(doc, target_idx)
label = f"role_tag_p{target_idx}"
try:
resp = await self.client.chat_json_async(
[{"role": "user", "content": prompt}], label=label,
)
except Exception as e:
_log.warning("Role disambiguation skipped (LLM error): %s", _format_exc(e))
return target.role or "unknown", target.role_confidence # type: ignore[return-value]
return self._interpret(resp, target)
@@ -0,0 +1,132 @@
"""基于位置 + 文字模式 + 字体样式的段落角色识别。"""
from __future__ import annotations
import re
from fastapi_modules.fastapi_leaudit.govdoc_engine.models import Document, Paragraph, Role
HEADING_1_RE = re.compile(r"^[一二三四五六七八九十百]+、")
HEADING_2_RE = re.compile(r"^[一二三四五六七八九十]+")
HEADING_3_RE = re.compile(r"^\d+[\.]")
HEADING_4_RE = re.compile(r"^\d+")
DOC_NUMBER_RE = re.compile(r"[一-龥]+[\[]\d{4}[\]]第?\d+号")
DATE_RE = re.compile(
r"^\d{4}\d{1,2}月\d{1,2}日$"
r"|^[一二三四五六七八九十○〇零]+年[一二三四五六七八九十○〇零]+月[一二三四五六七八九十○〇零]+日$"
)
ATTACHMENT_RE = re.compile(r"^附件[:1-9]")
NO_TEXT_RE = re.compile(r"^[\(]\s*此页无正文\s*[\)]")
RECIPIENT_TAIL_RE = re.compile(r"[:]\s*$")
RECIPIENT_HINTS = (
"", "", "", "", "", "公司", "", "处室",
"委员会", "", "", "", "", "",
)
RECIPIENT_BLOCKLIST = (
"现将", "", "经研究", "为做好", "为深入", "为进一步",
"根据", "如下", "汇报", "通知如下", "请示如下",
)
class RuleBasedTagger:
def tag(self, doc: Document) -> None:
n = len(doc.paragraphs)
for i, p in enumerate(doc.paragraphs):
role, conf = self._classify(p, i, n, doc)
p.role = role
p.role_confidence = conf
def _classify(
self, p: Paragraph, idx: int, total: int, doc: Document
) -> tuple[Role, float]:
text = p.text.strip()
if not text:
return ("unknown", 0.5)
if NO_TEXT_RE.match(text):
return ("no_text_marker", 1.0)
if ATTACHMENT_RE.match(text):
return ("attachment_marker", 0.95)
if DATE_RE.match(text):
return ("date", 0.9)
if DOC_NUMBER_RE.search(text) and idx <= 5:
return ("doc_number", 0.95)
if idx == 0 or (
idx <= 2
and p.style.alignment == "center"
and (p.style.font_size_pt or 0) >= 18
):
return ("title", 0.95)
font = (p.style.font_eastasia or "").strip()
size = p.style.font_size_pt or 0
if self._is_attachment_title(p, idx, doc):
return ("attachment_title", 0.9)
if HEADING_1_RE.match(text):
conf = 0.95 if "黑体" in font else 0.7
return ("heading_1", conf)
if HEADING_2_RE.match(text):
conf = 0.95 if "楷体" in font else 0.7
return ("heading_2", conf)
if HEADING_3_RE.match(text):
conf = 0.9 if "仿宋" in font else 0.65
return ("heading_3", conf)
if HEADING_4_RE.match(text):
return ("heading_4", 0.85)
if (
idx <= 6
and 3 <= len(text) <= 50
and RECIPIENT_TAIL_RE.search(text)
and any(kw in text for kw in RECIPIENT_HINTS)
and not any(kw in text for kw in RECIPIENT_BLOCKLIST)
):
return ("recipient", 0.9)
if total - idx <= 3 and 5 <= len(text) <= 30 and any(
kw in text
for kw in ["", "公司", "委员会", "人民政府", "办公厅", "办公室"]
):
return ("signature", 0.7)
if size >= 14 or font:
return ("body", 0.85)
return ("unknown", 0.4)
@staticmethod
def _is_attachment_title(p: Paragraph, idx: int, doc: Document) -> bool:
"""识别附件正文首页标题,避免按普通正文套用 GW-F-004。"""
if idx <= 0:
return False
text = p.text.strip()
font = (p.style.font_eastasia or "").strip()
if (
p.style.alignment != "center"
or (p.style.font_size_pt or 0) < 18
or "小标宋" not in font
):
return False
marker_index = None
marker_text = ""
for prev in reversed(doc.paragraphs[:idx]):
if prev.role == "attachment_marker" or ATTACHMENT_RE.match(prev.text.strip()):
marker_index = prev.index
marker_text = prev.text.strip()
break
if marker_index is None or idx - marker_index > 12:
return False
attachment_name = re.sub(r"^附件\d*[:]\s*", "", marker_text).strip()
attachment_name = re.sub(r"^\d+[\..、)]\s*", "", attachment_name).strip()
return not attachment_name or text == attachment_name or text in attachment_name or attachment_name in text
@@ -0,0 +1,241 @@
"""OOXML 字体解析:处理样式继承链 + 主题字体。
Word 把字体属性分散在四个层级:
1. 直接 run rPr`<w:r><w:rPr><w:rFonts/></w:rPr>...`
2. 段落 rPr(段落标记字体):`<w:p><w:pPr><w:rPr><w:rFonts/></w:rPr></w:pPr>`
3. 段落引用样式:`<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr>`
样式定义在 styles.xml,可经 `<w:basedOn>` 链向上继承
4. 全局默认:styles.xml 的 `<w:docDefaults>`
此外 `<w:rFonts>` 的 `*Theme` 属性指向 theme1.xml 中的字体方案
majorEastAsia / minorEastAsia 等),需要做二次解析。
"""
from __future__ import annotations
from dataclasses import dataclass
from docx.oxml.ns import qn
from lxml import etree
# theme1.xml 命名空间
_DML_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
@dataclass
class ResolvedRunStyle:
font_eastasia: str | None = None
font_ascii: str | None = None
size_pt: float | None = None
bold: bool | None = None
italic: bool | None = None
def _empty_to_none(s: str | None) -> str | None:
if s is None:
return None
s = s.strip()
return s or None
class StyleResolver:
"""构造时一次性解析样式表 + 主题;之后 resolve_run() 是 O(链长)。"""
def __init__(self, docx):
self._theme = self._load_theme(docx)
self._styles, self._doc_defaults = self._load_styles(docx)
# ---- 主题 ---------------------------------------------------------
def _load_theme(self, docx) -> dict[tuple[str, str], str | None]:
"""返回 {(axis, scheme_attr): font_name}。
scheme_attr 形如 'majorEastAsia' / 'minorAscii'axis 是 rFonts 的轴。
"""
out: dict[tuple[str, str], str | None] = {}
try:
theme_part = next(
p for p in docx.part.package.parts
if p.partname.endswith("/theme/theme1.xml")
)
except StopIteration:
return out
try:
root = etree.fromstring(theme_part.blob)
except etree.XMLSyntaxError:
return out
ns = {"a": _DML_NS}
for kind, font_tag in (("major", "majorFont"), ("minor", "minorFont")):
font_elem = root.find(f".//a:fontScheme/a:{font_tag}", ns)
if font_elem is None:
continue
latin = font_elem.find("a:latin", ns)
ea = font_elem.find("a:ea", ns)
cs = font_elem.find("a:cs", ns)
# ea 为空时用简中 Hans 兜底
ea_val = _empty_to_none(ea.get("typeface")) if ea is not None else None
if ea_val is None:
hans = font_elem.find('a:font[@script="Hans"]', ns)
if hans is not None:
ea_val = _empty_to_none(hans.get("typeface"))
latin_val = _empty_to_none(latin.get("typeface")) if latin is not None else None
cs_val = _empty_to_none(cs.get("typeface")) if cs is not None else None
out[("ascii", f"{kind}Ascii")] = latin_val
out[("ascii", f"{kind}HAnsi")] = latin_val # asciiTheme=majorHAnsi 也可能出现
out[("hAnsi", f"{kind}HAnsi")] = latin_val
out[("hAnsi", f"{kind}Ascii")] = latin_val
out[("eastAsia", f"{kind}EastAsia")] = ea_val
out[("cs", f"{kind}Bidi")] = cs_val
return out
# ---- 样式表 -------------------------------------------------------
def _load_styles(
self, docx
) -> tuple[dict[str, dict], ResolvedRunStyle | None]:
out: dict[str, dict] = {}
defaults: ResolvedRunStyle | None = None
try:
styles_root = docx.part._styles_part.element
except (AttributeError, KeyError):
return out, defaults
if styles_root is None:
return out, defaults
# docDefaults
ddef = styles_root.find(qn("w:docDefaults"))
if ddef is not None:
rdef = ddef.find(qn("w:rPrDefault"))
if rdef is not None:
defaults = self._read_rpr(rdef.find(qn("w:rPr")))
# 各 style
for style in styles_root.findall(qn("w:style")):
sid = style.get(qn("w:styleId"))
if not sid:
continue
rpr = style.find(qn("w:rPr"))
ppr = style.find(qn("w:pPr"))
ppr_rpr = ppr.find(qn("w:rPr")) if ppr is not None else None
based_on = None
bo = style.find(qn("w:basedOn"))
if bo is not None:
based_on = bo.get(qn("w:val"))
link = style.find(qn("w:link"))
link_id = link.get(qn("w:val")) if link is not None else None
out[sid] = {
"rpr": rpr,
"ppr_rpr": ppr_rpr,
"based_on": based_on,
"link": link_id,
}
return out, defaults
# ---- 读 rPr -------------------------------------------------------
def _read_rpr(self, rpr) -> ResolvedRunStyle | None:
if rpr is None:
return None
rs = ResolvedRunStyle()
rfonts = rpr.find(qn("w:rFonts"))
if rfonts is not None:
rs.font_eastasia = self._resolve_font_axis(rfonts, "eastAsia")
rs.font_ascii = self._resolve_font_axis(rfonts, "ascii")
sz = rpr.find(qn("w:sz"))
if sz is not None and sz.get(qn("w:val")):
try:
rs.size_pt = float(sz.get(qn("w:val"))) / 2.0
except ValueError:
pass
if rpr.find(qn("w:b")) is not None:
rs.bold = True
if rpr.find(qn("w:i")) is not None:
rs.italic = True
return rs
def _resolve_font_axis(self, rfonts, axis: str) -> str | None:
"""同一根 rFonts 上 explicit > theme。"""
explicit = _empty_to_none(rfonts.get(qn(f"w:{axis}")))
if explicit:
return explicit
theme_attr = "cstheme" if axis == "cs" else f"{axis}Theme"
theme = _empty_to_none(rfonts.get(qn(f"w:{theme_attr}")))
if theme:
return self._theme.get((axis, theme))
return None
# ---- 合并 ---------------------------------------------------------
@staticmethod
def _fill(target: ResolvedRunStyle, source: ResolvedRunStyle | None) -> None:
"""target 已有的字段保留;缺的从 source 取。"""
if source is None:
return
if target.font_eastasia is None:
target.font_eastasia = source.font_eastasia
if target.font_ascii is None:
target.font_ascii = source.font_ascii
if target.size_pt is None:
target.size_pt = source.size_pt
if target.bold is None:
target.bold = source.bold
if target.italic is None:
target.italic = source.italic
def _resolve_style_chain(
self, sid: str | None, _seen: set[str] | None = None
) -> ResolvedRunStyle | None:
"""段落样式 → 链向 basedOn → 沿途累积 rPr 与 pPr 的 rPr。"""
if sid is None:
return None
seen = _seen or set()
if sid in seen:
return None
seen = seen | {sid}
info = self._styles.get(sid)
if info is None:
return None
# 当前 style 的两个 rPr
rs = ResolvedRunStyle()
self._fill(rs, self._read_rpr(info.get("rpr")))
self._fill(rs, self._read_rpr(info.get("ppr_rpr")))
# 链接的 character style(如果有)
if info.get("link"):
self._fill(rs, self._resolve_style_chain(info["link"], seen))
# 父样式
if info.get("based_on"):
self._fill(rs, self._resolve_style_chain(info["based_on"], seen))
return rs
# ---- 主入口 -------------------------------------------------------
def resolve_run(self, p_elem, run_elem) -> ResolvedRunStyle:
"""解析单个 run 的最终样式。p_elem 可为 None。"""
rs = ResolvedRunStyle()
# 1. 直接 run rPr
if run_elem is not None:
self._fill(rs, self._read_rpr(run_elem.find(qn("w:rPr"))))
# 2. 段落 rPr(段落标记字体)+ pStyle 链
if p_elem is not None:
ppr = p_elem.find(qn("w:pPr"))
if ppr is not None:
self._fill(rs, self._read_rpr(ppr.find(qn("w:rPr"))))
pstyle = ppr.find(qn("w:pStyle"))
if pstyle is not None and pstyle.get(qn("w:val")):
self._fill(rs, self._resolve_style_chain(pstyle.get(qn("w:val"))))
# 3. 默认 style "Normal"(中文文档常见)
if "Normal" in self._styles:
self._fill(rs, self._resolve_style_chain("Normal"))
# 4. docDefaults
self._fill(rs, self._doc_defaults)
return rs
def resolve_paragraph(self, p_elem) -> ResolvedRunStyle:
"""段落整体样式(不读 run,仅 pPr/style/默认)。"""
rs = ResolvedRunStyle()
if p_elem is not None:
ppr = p_elem.find(qn("w:pPr"))
if ppr is not None:
self._fill(rs, self._read_rpr(ppr.find(qn("w:rPr"))))
pstyle = ppr.find(qn("w:pStyle"))
if pstyle is not None and pstyle.get(qn("w:val")):
self._fill(rs, self._resolve_style_chain(pstyle.get(qn("w:val"))))
if "Normal" in self._styles:
self._fill(rs, self._resolve_style_chain("Normal"))
self._fill(rs, self._doc_defaults)
return rs
@@ -0,0 +1,248 @@
"""Govdoc 引擎主编排入口。
将旧 govdoc-audit 的 audit_file() 函数适配为异步 Pipeline 接口,
供 govdoc_bridge.runner 调用。
迁移自: govdoc-audit/src/govdoc_audit/pipeline.py
移除依赖: RunRecorder, config.py (local file logging)
适配平台: 异步执行、直接返回 AuditResult
"""
from __future__ import annotations
import logging
import uuid
from pathlib import Path
from typing import Any
from fastapi_modules.fastapi_leaudit.govdoc_engine.parser.docx_parser import parse_docx
from fastapi_modules.fastapi_leaudit.govdoc_engine.parser.role_tagger import RoleTagger
from fastapi_modules.fastapi_leaudit.govdoc_engine.parser.extractor import FieldExtractor
from fastapi_modules.fastapi_leaudit.govdoc_engine.parser.entity_builder import (
EntityBuilder,
BUILTIN_LLM_DESCRIPTION,
)
from fastapi_modules.fastapi_leaudit.govdoc_engine.parser.entities import SemanticEntity
from fastapi_modules.fastapi_leaudit.govdoc_engine.dsl.loader import load_rules
from fastapi_modules.fastapi_leaudit.govdoc_engine.dsl.schema import EntitySpec, RuleSet
from fastapi_modules.fastapi_leaudit.govdoc_engine.engine.runner import RuleRunner
from fastapi_modules.fastapi_leaudit.govdoc_engine.engine.result import AuditResult, CheckedRule
from fastapi_modules.fastapi_leaudit.govdoc_engine.engine.structure import build_outline, build_structure
from fastapi_modules.fastapi_leaudit.govdoc_engine.llm.client import LlmClient
_log = logging.getLogger(__name__)
# ── 辅助函数 ────────────────────────────────────────────
def _outcomes_to_checked(outcomes) -> list[CheckedRule]:
"""将规则执行结果汇总为 CheckedRule 列表。"""
rows: list[CheckedRule] = []
for o in outcomes:
if o.skipped:
status = "skipped"
elif o.findings:
status = "fail"
else:
status = "pass"
rows.append(
CheckedRule(
rule_id=o.rule.rule_id,
name=o.rule.name,
severity=o.rule.severity,
category=o.rule.category,
status=status,
skip_reason=o.skip_reason,
)
)
return rows
def _build_result(
docx_path: Path, doc, findings, entities, outcomes,
) -> AuditResult:
"""从审查产物构建 AuditResult。"""
document_meta = {
"filename": docx_path.name,
"path": str(docx_path),
"page_count": doc.meta.get("page_count", 1),
"paragraph_count": len(doc.paragraphs),
}
result = AuditResult(
audit_id=f"A-{uuid.uuid4().hex[:8]}",
document=document_meta,
findings=findings,
entities=entities,
checked_rules=_outcomes_to_checked(outcomes),
structure=build_structure(doc),
outline=build_outline(doc),
)
result.compute_summary()
return result
def _compute_missing_spec(
entities: dict[str, SemanticEntity | None],
custom_entities: list[EntitySpec],
) -> dict[str, dict]:
"""计算哪些实体需要送 LLM 抽取。"""
spec: dict[str, dict] = {}
for name, desc in BUILTIN_LLM_DESCRIPTION.items():
if entities.get(name) is None:
spec[name] = {
"description": desc,
"type": "list" if name == "attachments" else "string",
}
for s in custom_entities:
spec[s.name] = {"description": s.description or s.name, "type": s.type}
return spec
def _merge_llm_into_entities(
entities: dict[str, SemanticEntity | None],
llm_values: dict[str, Any],
) -> None:
"""将 LLM 抽取结果合并进 entities。"""
for name, val in llm_values.items():
if val in (None, "", []):
continue
if isinstance(val, list):
text = "; ".join(
f"{it.get('序号', i + 1)}. {it.get('名称', '')}"
if isinstance(it, dict) else str(it)
for i, it in enumerate(val)
)
extra = {"items": val}
else:
text = str(val)
extra = {}
entities[name] = SemanticEntity(
name=name,
text=text,
paragraph_indices=[],
primary_role=None,
source="llm",
confidence=0.7,
extra=extra,
)
# ── 实体构建 (同步,供 sync 入口使用) ──────────────────
def _build_entities(
doc, ruleset: RuleSet, llm: LlmClient,
) -> dict[str, SemanticEntity | None]:
"""构建实体 + 差量 LLM 抽取(同步)。"""
entities = EntityBuilder().build(doc)
spec = _compute_missing_spec(entities, ruleset.extract.entities)
if spec:
llm_vals = FieldExtractor(llm).extract_missing(doc, spec)
_merge_llm_into_entities(entities, llm_vals)
return entities
# ── 实体构建 (异步,供 async 入口使用) ──────────────────
async def _build_entities_async(
doc, ruleset: RuleSet, llm: LlmClient,
) -> dict[str, SemanticEntity | None]:
"""构建实体 + 差量 LLM 抽取(异步)。"""
entities = EntityBuilder().build(doc)
spec = _compute_missing_spec(entities, ruleset.extract.entities)
if spec:
llm_vals = await FieldExtractor(llm).extract_missing_async(doc, spec)
_merge_llm_into_entities(entities, llm_vals)
return entities
# ── 同步入口 (保留兼容) ─────────────────────────────────
def audit_file(
docx_path: str | Path,
rules_path: str | Path,
llm_client: LlmClient | None = None,
) -> AuditResult:
"""同步审查单个公文文件。
Args:
docx_path: DOCX 文件路径。
rules_path: YAML 规则文件路径。
llm_client: 可选 LLM 客户端实例。
Returns:
AuditResult 包含 findings, entities, checked_rules, summary 等。
"""
docx_path = Path(docx_path)
rules_path = Path(rules_path)
llm = llm_client or LlmClient()
doc = parse_docx(docx_path)
RoleTagger(llm_client=llm).tag(doc)
ruleset = load_rules(rules_path)
entities = _build_entities(doc, ruleset, llm)
findings, outcomes = RuleRunner(llm_client=llm).evaluate(
ruleset.all_rules(), doc, entities
)
return _build_result(docx_path, doc, findings, entities, outcomes)
# ── 异步入口 (推荐,供 bridge 调用) ──────────────────────
async def run(
file_path: str | Path,
rules_path: str | Path,
llm_client: LlmClient | None = None,
) -> AuditResult:
"""异步审查单个公文文件。
这是 govdoc_bridge 的主要调用入口。
Args:
file_path: 文档文件路径 (DOCX 或 PDF)。
rules_path: YAML 规则文件路径。
llm_client: 可选 LLM 客户端实例。
Returns:
AuditResult 包含 findings, entities, checked_rules, summary 等。
"""
file_path = Path(file_path)
rules_path = Path(rules_path)
llm = llm_client or LlmClient()
_log.info("Govdoc pipeline start: %s", file_path.name)
# 1. 解析文档
doc = parse_docx(file_path)
_log.info(" parsed: %d paragraphs", len(doc.paragraphs))
# 2. 段落角色标注
RoleTagger(llm_client=llm).tag(doc)
# 3. 加载规则
ruleset = load_rules(rules_path)
_log.info(" rules: %d groups, %d rules", len(ruleset.groups), len(ruleset.all_rules()))
# 4. 实体抽取 (含差量 LLM)
entities = await _build_entities_async(doc, ruleset, llm)
_log.info(" entities: %d/%d resolved", sum(1 for v in entities.values() if v), len(entities))
# 5. 规则评估
findings, outcomes = RuleRunner(llm_client=llm).evaluate(
ruleset.all_rules(), doc, entities
)
_log.info(" evaluated: %d findings from %d rules", len(findings), len(outcomes))
# 6. 构建结果
result = _build_result(file_path, doc, findings, entities, outcomes)
_log.info(
"Govdoc pipeline complete: score=%d, pass=%d, fail=%d, skip=%d",
result.summary.score,
result.summary.passed_count,
result.summary.failed_count,
result.summary.skipped_count,
)
return result
@@ -0,0 +1,105 @@
"""docx 标注:在原文加高亮 + 文末追加审核报告附页。"""
from __future__ import annotations
from pathlib import Path
from docx import Document as DocxDocument
from docx.enum.text import WD_BREAK
from docx.shared import Pt
from docx.oxml.ns import qn
from lxml import etree
from fastapi_modules.fastapi_leaudit.govdoc_engine.engine.result import AuditResult
_HIGHLIGHT_NAME = {
"error": "red",
"warning": "yellow",
"info": "cyan",
}
def _highlight_run(run, color_name: str) -> None:
rpr = run._element.get_or_add_rPr()
hl = rpr.find(qn("w:highlight"))
if hl is None:
hl = etree.SubElement(rpr, qn("w:highlight"))
hl.set(qn("w:val"), color_name)
def _highlight_paragraph_range(paragraph, start: int, end: int, color_name: str) -> None:
"""简化策略:高亮整段(精准 char range 留 v0.2 实现)。"""
for run in paragraph.runs:
_highlight_run(run, color_name)
def _add_heading_with_fallback(doc, text: str, level: int = 1):
try:
return doc.add_heading(text, level=level)
except KeyError:
# Some uploaded documents don't include Word's built-in heading styles.
p = doc.add_paragraph()
run = p.add_run(text)
run.bold = True
if level == 1:
run.font.size = Pt(16)
elif level == 2:
run.font.size = Pt(13)
else:
run.font.size = Pt(12)
return p
def _append_appendix(doc, result: AuditResult) -> None:
p = doc.add_paragraph()
p.add_run().add_break(WD_BREAK.PAGE)
_add_heading_with_fallback(doc, "审核报告附页", level=1)
s = result.summary
doc.add_paragraph(
f"得分: {s.score}/100 错误: {s.by_severity.get('error', 0)} "
f"警告: {s.by_severity.get('warning', 0)} 提示: {s.by_severity.get('info', 0)}"
)
if not result.findings:
doc.add_paragraph("未发现问题。")
return
table = doc.add_table(rows=1, cols=5)
try:
table.style = "Light Grid"
except KeyError:
# Some source documents don't ship with the built-in table style set.
pass
hdr = table.rows[0].cells
for i, h in enumerate(["编号", "规则", "严重度", "类别", "位置 / 说明"]):
hdr[i].text = h
for f in result.findings:
row = table.add_row().cells
row[0].text = f.finding_id
row[1].text = f.rule_id
row[2].text = f.severity
row[3].text = f.category
loc = f.location
ctx = (loc.context or "")[:30]
row[4].text = f"P{loc.paragraph_index} ({loc.role}): {f.message}\n 原文: {ctx}"
def annotate_docx(src: str | Path, dst: str | Path, result: AuditResult) -> None:
src = Path(src)
dst = Path(dst)
doc = DocxDocument(src)
for f in result.findings:
idx = f.location.paragraph_index
if 0 <= idx < len(doc.paragraphs):
color = _HIGHLIGHT_NAME.get(f.severity, "yellow")
_highlight_paragraph_range(
doc.paragraphs[idx], f.location.char_start, f.location.char_end, color
)
_append_appendix(doc, result)
dst.parent.mkdir(parents=True, exist_ok=True)
doc.save(dst)
@@ -0,0 +1,42 @@
"""把 Document 渲染为带 inline style 的 HTML 段落,给前端用。"""
from __future__ import annotations
from html import escape
from fastapi_modules.fastapi_leaudit.govdoc_engine.models import Document
def _style(p) -> str:
s = p.style
parts = []
if s.font_size_pt:
sz = s.font_size_pt
sz_str = str(int(sz)) if sz == int(sz) else str(sz)
parts.append(f"font-size:{sz_str}pt")
if s.font_eastasia:
parts.append(f"font-family:'{s.font_eastasia}',serif")
if s.alignment and s.alignment != "left":
parts.append(f"text-align:{s.alignment}")
if s.bold:
parts.append("font-weight:700")
if s.first_line_indent_pt:
parts.append(f"text-indent:{s.first_line_indent_pt}pt")
return ";".join(parts)
def paragraphs_to_html(doc: Document, finding_map: dict[int, list[str]]) -> str:
"""把 doc 每个段落渲染成 <p> 带 data-pi / data-role / data-finding-ids。"""
out = ['<div class="doc-view">']
for p in doc.paragraphs:
style = _style(p)
finding_ids = finding_map.get(p.index, [])
attrs = [
f'data-pi="{p.index}"',
f'data-role="{escape(p.role or "")}"',
]
if finding_ids:
attrs.append(f'data-finding-ids="{escape(",".join(finding_ids))}"')
if style:
attrs.append(f'style="{escape(style)}"')
out.append(f"<p {' '.join(attrs)}>{escape(p.text)}</p>")
out.append("</div>")
return "\n".join(out)
@@ -0,0 +1,76 @@
"""把 AuditResult 渲染成单文件 HTML 报告。"""
from __future__ import annotations
from html import escape
from fastapi_modules.fastapi_leaudit.govdoc_engine.engine.result import AuditResult
_CSS = """
body { font-family: -apple-system, "PingFang SC", sans-serif; margin: 0; padding: 24px;
background: #f7f7f9; color: #1a1a1a; }
.header { display: flex; align-items: center; gap: 16px; margin-bottom: 24px; }
.score { width: 96px; height: 96px; border-radius: 50%;
background: conic-gradient(#22c55e var(--p), #e5e7eb var(--p));
display: grid; place-items: center; font-weight: 700; font-size: 22px; color: #111; }
.score-inner { background: white; width: 76px; height: 76px; border-radius: 50%;
display: grid; place-items: center; }
.tag { padding: 2px 8px; border-radius: 999px; font-size: 12px; }
.error { background: #fee2e2; color: #b91c1c; }
.warning { background: #fef9c3; color: #a16207; }
.info { background: #dbeafe; color: #1d4ed8; }
table { width: 100%; border-collapse: collapse; background: white; border-radius: 8px;
overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid #f1f5f9; vertical-align: top; }
th { background: #f8fafc; font-size: 13px; }
td.msg { max-width: 480px; }
.context { color: #64748b; font-size: 12px; margin-top: 4px; }
"""
def render_html(result: AuditResult) -> str:
s = result.summary
score = s.score
pct = f"{score}%"
rows = []
for f in result.findings:
loc = f.location
suggest = (
f'<div style="color:#0369a1">建议: {escape(f.suggestion)}</div>'
if f.suggestion else ""
)
rows.append(f"""
<tr>
<td>{escape(f.finding_id)}</td>
<td>{escape(f.rule_id)}<br><span style="color:#64748b;font-size:12px">{escape(f.rule_name)}</span></td>
<td><span class="tag {f.severity}">{f.severity}</span></td>
<td>{escape(f.category)}</td>
<td>P{loc.paragraph_index} ({escape(loc.role or '')})</td>
<td class="msg">{escape(f.message)}
<div class="context">原文: {escape((loc.context or '')[:80])}</div>
{suggest}
</td>
</tr>""")
body = f"""<!doctype html>
<html lang="zh"><head><meta charset="utf-8"><title>公文审核报告</title>
<style>{_CSS}</style></head><body>
<div class="header">
<div class="score" style="--p:{pct}"><div class="score-inner">{score}</div></div>
<div>
<h1 style="margin:0">公文格式审核报告</h1>
<div style="color:#64748b">{escape(result.document.get('filename', ''))} · 共 {s.total_findings} 项</div>
<div style="margin-top:6px">
<span class="tag error">错误 {s.by_severity.get('error', 0)}</span>
<span class="tag warning">警告 {s.by_severity.get('warning', 0)}</span>
<span class="tag info">提示 {s.by_severity.get('info', 0)}</span>
</div>
</div>
</div>
<table>
<thead><tr>
<th>编号</th><th>规则</th><th>严重度</th><th>类别</th><th>位置</th><th>说明</th>
</tr></thead>
<tbody>{''.join(rows) or '<tr><td colspan=6>未发现问题</td></tr>'}</tbody>
</table>
</body></html>"""
return body
@@ -0,0 +1,12 @@
"""把 AuditResult 序列化为 JSON 字符串。"""
import json
from fastapi_modules.fastapi_leaudit.govdoc_engine.engine.result import AuditResult
def to_json(result: AuditResult, indent: int = 2) -> str:
return json.dumps(
result.model_dump(mode="json"),
ensure_ascii=False,
indent=indent,
)