feat(govdoc): 新增内部公文模块全链路(后端58+前端11文件)
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
"""Govdoc 公文模块控制器。
|
||||
|
||||
提供公文上传、列表、详情、审查运行、结果与报告、规则查看等接口。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Depends, File, Form, Query, UploadFile
|
||||
|
||||
from fastapi_common.fastapi_common_security.security import verify_access_token
|
||||
from fastapi_common.fastapi_common_web.controller import BaseController
|
||||
from fastapi_common.fastapi_common_web.domain.responses import Result
|
||||
|
||||
from fastapi_modules.fastapi_leaudit.services import IGovdocService
|
||||
from fastapi_modules.fastapi_leaudit.services.impl.govdocServiceImpl import GovdocServiceImpl
|
||||
|
||||
|
||||
class GovdocController(BaseController):
|
||||
"""公文处理与格式审查控制器。"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(prefix="/govdoc", tags=["内部公文"])
|
||||
self.GovdocService: IGovdocService = GovdocServiceImpl()
|
||||
|
||||
# ── 文档 ──────────────────────────────────────────
|
||||
|
||||
@self.router.post("/documents")
|
||||
async def UploadDocument(
|
||||
file: UploadFile = File(...),
|
||||
typeId: int | None = Form(default=None),
|
||||
region: str = Form(default="default"),
|
||||
autoRun: bool = Form(default=False),
|
||||
speed: str = Form(default="normal"),
|
||||
ruleVersionId: int | None = Form(default=None),
|
||||
payload: dict[str, Any] = Depends(verify_access_token),
|
||||
):
|
||||
"""上传公文文档。
|
||||
|
||||
创建文档主档记录,engine_type 标记为 govdoc,可选自动触发审查。
|
||||
"""
|
||||
result = await self.GovdocService.UploadDocument(
|
||||
file=file,
|
||||
typeId=typeId,
|
||||
region=region,
|
||||
autoRun=autoRun,
|
||||
speed=speed,
|
||||
ruleVersionId=ruleVersionId,
|
||||
createdBy=int(payload["user_id"]),
|
||||
)
|
||||
return Result.success(data=result)
|
||||
|
||||
@self.router.get("/documents")
|
||||
async def ListDocuments(
|
||||
page: int = Query(default=1, ge=1),
|
||||
pageSize: int = Query(default=20, ge=1, le=100),
|
||||
keyword: str | None = Query(default=None),
|
||||
region: str | None = Query(default=None),
|
||||
status: str | None = Query(default=None),
|
||||
resultStatus: str | None = Query(default=None),
|
||||
createdBy: int | None = Query(default=None),
|
||||
dateFrom: str | None = Query(default=None),
|
||||
dateTo: str | None = Query(default=None),
|
||||
payload: dict[str, Any] = Depends(verify_access_token),
|
||||
):
|
||||
"""获取公文模块文档列表。
|
||||
|
||||
后端自动附加 engine_type='govdoc' 过滤条件。
|
||||
"""
|
||||
result = await self.GovdocService.ListDocuments(
|
||||
page=page,
|
||||
pageSize=pageSize,
|
||||
keyword=keyword,
|
||||
region=region,
|
||||
status=status,
|
||||
resultStatus=resultStatus,
|
||||
createdBy=createdBy,
|
||||
dateFrom=dateFrom,
|
||||
dateTo=dateTo,
|
||||
userId=int(payload["user_id"]),
|
||||
)
|
||||
return Result.success(data=result)
|
||||
|
||||
@self.router.get("/documents/{documentId}")
|
||||
async def GetDocumentDetail(
|
||||
documentId: int,
|
||||
payload: dict[str, Any] = Depends(verify_access_token),
|
||||
):
|
||||
"""获取公文详情:文档基础信息 + 最新 run 摘要 + 报告引用。"""
|
||||
result = await self.GovdocService.GetDocumentDetail(
|
||||
documentId=documentId,
|
||||
userId=int(payload["user_id"]),
|
||||
)
|
||||
return Result.success(data=result)
|
||||
|
||||
@self.router.patch("/documents/{documentId}")
|
||||
async def UpdateDocument(
|
||||
documentId: int,
|
||||
body: dict[str, Any],
|
||||
payload: dict[str, Any] = Depends(verify_access_token),
|
||||
):
|
||||
"""修改公文标题、文号、备注等基础信息。"""
|
||||
result = await self.GovdocService.UpdateDocument(
|
||||
documentId=documentId,
|
||||
body=body,
|
||||
userId=int(payload["user_id"]),
|
||||
)
|
||||
return Result.success(data=result)
|
||||
|
||||
@self.router.delete("/documents/{documentId}")
|
||||
async def DeleteDocument(
|
||||
documentId: int,
|
||||
payload: dict[str, Any] = Depends(verify_access_token),
|
||||
):
|
||||
"""软删除公文文档。"""
|
||||
result = await self.GovdocService.DeleteDocument(
|
||||
documentId=documentId,
|
||||
userId=int(payload["user_id"]),
|
||||
)
|
||||
return Result.success(data=result)
|
||||
|
||||
# ── 审查运行 ──────────────────────────────────────
|
||||
|
||||
@self.router.post("/runs")
|
||||
async def CreateRun(
|
||||
documentId: int = Form(...),
|
||||
ruleVersionId: int | None = Form(default=None),
|
||||
speed: str = Form(default="normal"),
|
||||
force: bool = Form(default=False),
|
||||
payload: dict[str, Any] = Depends(verify_access_token),
|
||||
):
|
||||
"""对已存在文档发起一次公文审查 run。
|
||||
|
||||
创建 govdoc_runs 记录,投递 Celery worker。
|
||||
"""
|
||||
result = await self.GovdocService.CreateRun(
|
||||
documentId=documentId,
|
||||
ruleVersionId=ruleVersionId,
|
||||
speed=speed,
|
||||
force=force,
|
||||
triggerUserId=int(payload["user_id"]),
|
||||
)
|
||||
return Result.success(data=result)
|
||||
|
||||
@self.router.get("/runs/{runId}")
|
||||
async def GetRunStatus(
|
||||
runId: int,
|
||||
payload: dict[str, Any] = Depends(verify_access_token),
|
||||
):
|
||||
"""查询 run 状态、阶段、耗时、错误摘要。"""
|
||||
result = await self.GovdocService.GetRunStatus(runId=runId)
|
||||
return Result.success(data=result)
|
||||
|
||||
# ── 结果与报告 ────────────────────────────────────
|
||||
|
||||
@self.router.get("/runs/{runId}/result")
|
||||
async def GetRunResult(
|
||||
runId: int,
|
||||
payload: dict[str, Any] = Depends(verify_access_token),
|
||||
):
|
||||
"""获取审查结果摘要:summary + checked rules + findings 统计 + entities 摘要。"""
|
||||
result = await self.GovdocService.GetRunResult(runId=runId)
|
||||
return Result.success(data=result)
|
||||
|
||||
@self.router.get("/runs/{runId}/findings")
|
||||
async def GetRunFindings(
|
||||
runId: int,
|
||||
payload: dict[str, Any] = Depends(verify_access_token),
|
||||
):
|
||||
"""获取段落级 findings 明细列表。"""
|
||||
result = await self.GovdocService.GetRunFindings(runId=runId)
|
||||
return Result.success(data=result)
|
||||
|
||||
@self.router.get("/runs/{runId}/entities")
|
||||
async def GetRunEntities(
|
||||
runId: int,
|
||||
payload: dict[str, Any] = Depends(verify_access_token),
|
||||
):
|
||||
"""获取识别出的标题、文号、署名等实体。"""
|
||||
result = await self.GovdocService.GetRunEntities(runId=runId)
|
||||
return Result.success(data=result)
|
||||
|
||||
@self.router.get("/runs/{runId}/paragraphs")
|
||||
async def GetRunParagraphs(
|
||||
runId: int,
|
||||
payload: dict[str, Any] = Depends(verify_access_token),
|
||||
):
|
||||
"""获取前端文档联动视图所需的段落 HTML。"""
|
||||
result = await self.GovdocService.GetRunParagraphs(runId=runId)
|
||||
return Result.success(data=result)
|
||||
|
||||
@self.router.get("/runs/{runId}/report/html")
|
||||
async def GetReportHtml(
|
||||
runId: int,
|
||||
payload: dict[str, Any] = Depends(verify_access_token),
|
||||
):
|
||||
"""获取 HTML 报告内容或下载地址。"""
|
||||
result = await self.GovdocService.GetReportHtml(runId=runId)
|
||||
return Result.success(data=result)
|
||||
|
||||
@self.router.get("/runs/{runId}/report/docx")
|
||||
async def GetReportDocx(
|
||||
runId: int,
|
||||
payload: dict[str, Any] = Depends(verify_access_token),
|
||||
):
|
||||
"""获取批注 DOCX 下载地址。"""
|
||||
result = await self.GovdocService.GetReportDocx(runId=runId)
|
||||
return Result.success(data=result)
|
||||
|
||||
@self.router.get("/documents/{documentId}/original")
|
||||
async def DownloadOriginal(
|
||||
documentId: int,
|
||||
payload: dict[str, Any] = Depends(verify_access_token),
|
||||
):
|
||||
"""获取原始上传文档下载地址。"""
|
||||
result = await self.GovdocService.DownloadOriginal(documentId=documentId)
|
||||
return Result.success(data=result)
|
||||
|
||||
# ── 规则 ──────────────────────────────────────────
|
||||
|
||||
@self.router.get("/rules")
|
||||
async def ListRules(
|
||||
payload: dict[str, Any] = Depends(verify_access_token),
|
||||
):
|
||||
"""获取当前生效规则集摘要。"""
|
||||
result = await self.GovdocService.ListRules()
|
||||
return Result.success(data=result)
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Govdoc 执行桥接层 —— govdoc_engine ↔ leaudit-platform 适配器。
|
||||
|
||||
本层负责把 govdoc_engine 接入当前平台的基础设施:
|
||||
- OSS/MinIO 文件下载与上传
|
||||
- 文档主档 (leaudit_documents / leaudit_document_files) 查询
|
||||
- GovdocRun / GovdocRuleResult / GovdocReportArtifact 持久化
|
||||
- Celery 异步任务调度
|
||||
- 临时文件管理与安全校验
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Govdoc Bridge — 输入文件解析器。
|
||||
|
||||
从 leaudit_document_files 中定位输入文件,从 OSS 下载到本地临时路径。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi_common.fastapi_common_logger import logger
|
||||
from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession
|
||||
from fastapi_common.fastapi_common_storage.oss_client import OssClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from fastapi_modules.fastapi_leaudit.models.leauditDocumentFile import LeauditDocumentFile
|
||||
|
||||
log = logger
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InputPayload:
|
||||
"""Govdoc 引擎执行所需的输入载荷。"""
|
||||
|
||||
fileName: str
|
||||
fileExt: str
|
||||
localPath: str
|
||||
sha256: str | None = None
|
||||
fileSize: int | None = None
|
||||
documentFileId: int | None = None
|
||||
tempDir: str | None = None # 需调用方在任务结束时清理
|
||||
|
||||
|
||||
class InputResolver:
|
||||
"""解析 govdoc 引擎输入文件。
|
||||
|
||||
从 leaudit_document_files 中定位输入文件 (file_role='original'),
|
||||
优先使用本地缓存路径,否则从 OSS 下载到临时目录。
|
||||
"""
|
||||
|
||||
def __init__(self, Oss: OssClient | None = None) -> None:
|
||||
self.Oss = Oss or OssClient()
|
||||
|
||||
async def ResolveForDocument(self, documentId: int) -> InputPayload:
|
||||
"""为指定文档解析输入文件载荷。
|
||||
|
||||
查找该文档最近一次激活的 original 文件记录。
|
||||
"""
|
||||
async with GetAsyncSession() as session:
|
||||
result = await session.execute(
|
||||
select(LeauditDocumentFile)
|
||||
.where(
|
||||
LeauditDocumentFile.documentId == documentId,
|
||||
LeauditDocumentFile.fileRole == "original",
|
||||
LeauditDocumentFile.isActive.is_(True),
|
||||
)
|
||||
.order_by(LeauditDocumentFile.Id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
fileRow = result.scalar_one_or_none()
|
||||
|
||||
if fileRow is None:
|
||||
raise ValueError(f"未找到文档 {documentId} 的原始文件记录")
|
||||
|
||||
return await self.ResolveFromRow(fileRow)
|
||||
|
||||
async def ResolveFromRow(self, FileRow: LeauditDocumentFile) -> InputPayload:
|
||||
"""从文件记录解析输入载荷。"""
|
||||
# 优先本地路径
|
||||
if FileRow.localPath:
|
||||
LocalPath = Path(FileRow.localPath)
|
||||
if LocalPath.is_file():
|
||||
return InputPayload(
|
||||
fileName=FileRow.fileName,
|
||||
fileExt=FileRow.fileExt or _ext_from_name(FileRow.fileName),
|
||||
localPath=str(LocalPath),
|
||||
sha256=FileRow.sha256,
|
||||
fileSize=FileRow.fileSize,
|
||||
documentFileId=FileRow.Id,
|
||||
)
|
||||
|
||||
# 否则从 OSS 下载
|
||||
if FileRow.ossUrl:
|
||||
return await self._DownloadFromOss(FileRow)
|
||||
|
||||
raise ValueError(
|
||||
f"文件 {FileRow.Id} ({FileRow.fileName}) 既无可用 localPath 也无 ossUrl"
|
||||
)
|
||||
|
||||
async def _DownloadFromOss(self, FileRow: LeauditDocumentFile) -> InputPayload:
|
||||
"""从 OSS 下载文件到临时目录。"""
|
||||
try:
|
||||
content = self.Oss.DownloadBytes(FileRow.ossUrl)
|
||||
except Exception as e:
|
||||
log.error(f"从 OSS 下载文件失败: url={FileRow.ossUrl}, error={e}")
|
||||
raise
|
||||
|
||||
tempDir = tempfile.mkdtemp(prefix="govdoc_input_")
|
||||
ext = FileRow.fileExt or _ext_from_name(FileRow.fileName)
|
||||
safeName = f"input_{FileRow.Id}{ext}"
|
||||
localPath = os.path.join(tempDir, safeName)
|
||||
|
||||
with open(localPath, "wb") as f:
|
||||
f.write(content)
|
||||
|
||||
computedSha = hashlib.sha256(content).hexdigest()
|
||||
if FileRow.sha256 and computedSha != FileRow.sha256:
|
||||
log.warning(
|
||||
f"文件 SHA256 不匹配: expected={FileRow.sha256}, computed={computedSha}"
|
||||
)
|
||||
|
||||
log.info(
|
||||
f"从 OSS 下载文件: {FileRow.fileName} → {localPath} ({len(content)} bytes)"
|
||||
)
|
||||
|
||||
return InputPayload(
|
||||
fileName=FileRow.fileName,
|
||||
fileExt=ext,
|
||||
localPath=localPath,
|
||||
sha256=computedSha,
|
||||
fileSize=len(content),
|
||||
documentFileId=FileRow.Id,
|
||||
tempDir=tempDir,
|
||||
)
|
||||
|
||||
|
||||
def _ext_from_name(fileName: str) -> str:
|
||||
"""从文件名提取扩展名。"""
|
||||
_, ext = os.path.splitext(fileName)
|
||||
return ext if ext else ".docx"
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Govdoc Bridge — 结果适配器。
|
||||
|
||||
将 govdoc_engine 原始结果对象 (AuditResult / Finding / SemanticEntity)
|
||||
映射为 ORM 模型字段和前端 VO 字典。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi_modules.fastapi_leaudit.govdoc_engine.engine.result import AuditResult
|
||||
|
||||
|
||||
class ResultAdapter:
|
||||
"""Govdoc 引擎结果 → 平台数据模型适配器。
|
||||
|
||||
负责将 govdoc_engine 的原始执行结果转换为:
|
||||
- GovdocRun 状态字段更新
|
||||
- GovdocRuleResult 列表
|
||||
- GovdocReportArtifact 清单
|
||||
- 前端 VO 字典
|
||||
"""
|
||||
|
||||
def AdaptRunSummary(self, EngineResult: AuditResult) -> dict[str, Any]:
|
||||
"""从 AuditResult.summary 提取 run 汇总字段。"""
|
||||
s = EngineResult.summary
|
||||
return {
|
||||
"totalScore": s.score,
|
||||
"passedCount": s.passed_count,
|
||||
"failedCount": s.failed_count,
|
||||
"skippedCount": s.skipped_count,
|
||||
"resultStatus": "pass" if s.failed_count == 0 else "fail" if s.passed_count == 0 else "partial",
|
||||
"resultSummaryJson": None, # 可为后续扩展预留
|
||||
}
|
||||
|
||||
def AdaptRuleResults(self, EngineResult: AuditResult) -> list[dict[str, Any]]:
|
||||
"""从 AuditResult.findings 提取规则执行明细列表。"""
|
||||
results: list[dict[str, Any]] = []
|
||||
for f in EngineResult.findings:
|
||||
results.append({
|
||||
"ruleId": f.rule_id,
|
||||
"ruleName": f.rule_name,
|
||||
"severity": f.severity,
|
||||
"category": f.category,
|
||||
"message": f.message,
|
||||
"suggestion": f.suggestion,
|
||||
"actual": f.actual,
|
||||
"expected": f.expected,
|
||||
"evidence": f.evidence,
|
||||
"paragraphIndex": f.location.paragraph_index if f.location else None,
|
||||
"paragraphText": f.location.context if f.location else None,
|
||||
"locationPath": f.location.role if f.location else None,
|
||||
"result": "fail",
|
||||
"score": None,
|
||||
})
|
||||
return results
|
||||
|
||||
def AdaptCheckedRules(self, EngineResult: AuditResult) -> list[dict[str, Any]]:
|
||||
"""从 AuditResult.checked_rules 提取规则检查状态列表。"""
|
||||
results: list[dict[str, Any]] = []
|
||||
for cr in EngineResult.checked_rules:
|
||||
results.append({
|
||||
"ruleId": cr.rule_id,
|
||||
"ruleName": cr.name,
|
||||
"severity": cr.severity,
|
||||
"category": cr.category,
|
||||
"result": cr.status, # pass/fail/skipped
|
||||
"skipReason": cr.skip_reason,
|
||||
"score": None,
|
||||
})
|
||||
return results
|
||||
|
||||
def AdaptEntities(self, EngineResult: AuditResult) -> list[dict[str, Any]]:
|
||||
"""从 AuditResult.entities 提取实体识别结果。"""
|
||||
entities: list[dict[str, Any]] = []
|
||||
for name, entity in EngineResult.entities.items():
|
||||
if entity is None:
|
||||
continue
|
||||
entities.append({
|
||||
"name": entity.name,
|
||||
"text": entity.text,
|
||||
"paragraphIndices": entity.paragraph_indices,
|
||||
"primaryRole": entity.primary_role,
|
||||
"source": entity.source,
|
||||
"confidence": entity.confidence,
|
||||
})
|
||||
return entities
|
||||
|
||||
def AdaptArtifacts(self, _EngineResult: AuditResult, _RunId: int) -> list[dict[str, Any]]:
|
||||
"""从引擎结果提取报告产物清单。
|
||||
|
||||
报告文件由 reporter 模块生成后上传 OSS。
|
||||
当前返回空列表,待 report_adapter 实现后补齐。
|
||||
"""
|
||||
return []
|
||||
|
||||
def BuildDetailVO(
|
||||
self,
|
||||
Document: Any,
|
||||
Run: Any,
|
||||
RuleResults: list[dict[str, Any]],
|
||||
Entities: list[dict[str, Any]],
|
||||
Artifacts: list[dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
"""构建前端详情页 VO。"""
|
||||
return {
|
||||
"document": Document,
|
||||
"run": Run,
|
||||
"findings": RuleResults,
|
||||
"entities": Entities,
|
||||
"reports": Artifacts,
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Govdoc Bridge — 执行编排器。
|
||||
|
||||
负责组织一次完整的 govdoc 审查执行链路:
|
||||
1. 解析输入文件 (input_resolver)
|
||||
2. 调用 govdoc_engine.pipeline 执行审查
|
||||
3. 收集并适配结果 (result_adapter)
|
||||
4. 持久化结果 (storage_adapter)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from fastapi_common.fastapi_common_logger import logger
|
||||
|
||||
from fastapi_modules.fastapi_leaudit.govdoc_bridge.input_resolver import InputResolver
|
||||
from fastapi_modules.fastapi_leaudit.govdoc_bridge.result_adapter import ResultAdapter
|
||||
from fastapi_modules.fastapi_leaudit.govdoc_bridge.storage_adapter import StorageAdapter
|
||||
|
||||
log = logger
|
||||
|
||||
|
||||
@dataclass
|
||||
class GovdocRunner:
|
||||
"""Govdoc 引擎一次完整审查的执行编排器。
|
||||
|
||||
当前为 Phase 1 骨架:
|
||||
- engine 集成点已标注
|
||||
- 可先跑通 run 创建 → 状态更新 → 结果持久化 的完整生命周期
|
||||
- 引擎执行部分待 govdoc_engine 迁入后接入
|
||||
"""
|
||||
|
||||
InputResolver: InputResolver = field(default_factory=InputResolver)
|
||||
Storage: StorageAdapter = field(default_factory=StorageAdapter)
|
||||
ResultAdapter: ResultAdapter = field(default_factory=ResultAdapter)
|
||||
|
||||
async def Execute(
|
||||
self,
|
||||
DocumentId: int,
|
||||
RunId: int,
|
||||
RulesPath: str,
|
||||
TriggerUserId: int | None = None,
|
||||
Speed: str = "normal",
|
||||
) -> dict[str, Any]:
|
||||
"""执行一次完整的 govdoc 审查。
|
||||
|
||||
Args:
|
||||
DocumentId: 文档 ID。
|
||||
RunId: 已创建的 govdoc_runs.id。
|
||||
TriggerUserId: 触发人。
|
||||
Speed: 执行速度 ('normal' / 'urgent')。
|
||||
|
||||
Returns:
|
||||
执行摘要 dict。
|
||||
"""
|
||||
log.info(f"[Govdoc] Starting execution: runId={RunId}, documentId={DocumentId}")
|
||||
|
||||
# 1. 更新 run 状态 → processing
|
||||
await self.Storage.UpdateRunStatus(RunId, "processing", phase="parsing")
|
||||
await self.Storage.UpdateDocumentStatus(DocumentId, "processing", RunId)
|
||||
|
||||
# 2. 解析输入文件
|
||||
inputPayload = await self.InputResolver.ResolveForDocument(DocumentId)
|
||||
log.info(f"[Govdoc] Input resolved: {inputPayload.fileName} → {inputPayload.localPath}")
|
||||
|
||||
# 3. 调用 govdoc_engine 执行审查
|
||||
from fastapi_modules.fastapi_leaudit.govdoc_engine.pipeline import run as engine_run
|
||||
|
||||
engineResult = await engine_run(
|
||||
file_path=inputPayload.localPath,
|
||||
rules_path=RulesPath,
|
||||
llm_client=None, # 使用默认 LlmClient (从平台配置加载)
|
||||
)
|
||||
|
||||
# 4. 适配引擎结果
|
||||
runSummary = self.ResultAdapter.AdaptRunSummary(engineResult)
|
||||
ruleResults = self.ResultAdapter.AdaptRuleResults(engineResult)
|
||||
entities = self.ResultAdapter.AdaptEntities(engineResult)
|
||||
artifacts = self.ResultAdapter.AdaptArtifacts(engineResult, RunId)
|
||||
|
||||
# 5. 持久化结果
|
||||
await self.Storage.UpdateRunResult(RunId, runSummary)
|
||||
await self.Storage.SaveRuleResults(RunId, ruleResults)
|
||||
await self.Storage.SaveArtifacts(RunId, artifacts)
|
||||
|
||||
# 6. 更新终态
|
||||
await self.Storage.UpdateRunStatus(RunId, "completed", phase="reporting")
|
||||
await self.Storage.UpdateDocumentStatus(DocumentId, "completed", RunId)
|
||||
|
||||
log.info(f"[Govdoc] Execution completed: runId={RunId}")
|
||||
|
||||
return {
|
||||
"runId": RunId,
|
||||
"documentId": DocumentId,
|
||||
"status": "completed",
|
||||
"ruleResultsCount": len(ruleResults),
|
||||
"artifactCount": len(artifacts),
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
"""Govdoc Bridge — 存储适配器。
|
||||
|
||||
将 govdoc_engine 执行结果写入 govdoc_runs / govdoc_rule_results /
|
||||
govdoc_report_artifacts 表,并更新 leaudit_documents 状态。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi_common.fastapi_common_logger import logger
|
||||
from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession
|
||||
from sqlalchemy import text
|
||||
|
||||
log = logger
|
||||
|
||||
|
||||
class StorageAdapter:
|
||||
"""Govdoc 结果持久化适配器。
|
||||
|
||||
将 bridge 层产生的结构化结果写入 PostgreSQL。
|
||||
使用原生 SQL (text) 以保持与现有 leaudit_bridge/storage_adapter 风格一致。
|
||||
"""
|
||||
|
||||
# ── Run 状态 ─────────────────────────────────────────
|
||||
|
||||
async def CreateRun(self, RunData: dict[str, Any]) -> int:
|
||||
"""创建 govdoc_runs 记录,返回 run_id。"""
|
||||
async with GetAsyncSession() as session:
|
||||
result = await session.execute(
|
||||
text(
|
||||
"""INSERT INTO govdoc_runs
|
||||
(document_id, document_file_id, run_no, trigger_source,
|
||||
trigger_user_id, status, phase, created_at, updated_at)
|
||||
VALUES (:document_id, :document_file_id, :run_no, :trigger_source,
|
||||
:trigger_user_id, 'pending', 'parsing', now(), now())
|
||||
RETURNING id"""
|
||||
),
|
||||
{
|
||||
"document_id": RunData["documentId"],
|
||||
"document_file_id": RunData.get("documentFileId"),
|
||||
"run_no": RunData.get("runNo", 1),
|
||||
"trigger_source": RunData.get("triggerSource", "manual"),
|
||||
"trigger_user_id": RunData.get("triggerUserId"),
|
||||
},
|
||||
)
|
||||
row = result.fetchone()
|
||||
run_id = row[0] if row else 0
|
||||
await session.commit()
|
||||
log.info(f"[Govdoc] Run created: runId={run_id}, documentId={RunData['documentId']}")
|
||||
return run_id
|
||||
|
||||
async def UpdateRunStatus(self, RunId: int, Status: str, Phase: str | None = None, **Extra: Any) -> None:
|
||||
"""更新 run 状态和阶段。"""
|
||||
set_clauses = ["status = :status", "updated_at = now()"]
|
||||
params: dict[str, Any] = {"rid": RunId, "status": Status}
|
||||
|
||||
if Phase is not None:
|
||||
set_clauses.append("phase = :phase")
|
||||
params["phase"] = Phase
|
||||
|
||||
if Status == "completed" or Status == "failed":
|
||||
set_clauses.append("finished_at = :finished_at")
|
||||
params["finished_at"] = datetime.now(timezone.utc)
|
||||
|
||||
async with GetAsyncSession() as session:
|
||||
await session.execute(
|
||||
text(f"UPDATE govdoc_runs SET {', '.join(set_clauses)} WHERE id = :rid"),
|
||||
params,
|
||||
)
|
||||
await session.commit()
|
||||
log.info(f"[Govdoc] Run status updated: runId={RunId}, status={Status}")
|
||||
|
||||
async def UpdateRunResult(self, RunId: int, Summary: dict[str, Any]) -> None:
|
||||
"""写入 run 结果汇总字段。"""
|
||||
async with GetAsyncSession() as session:
|
||||
await session.execute(
|
||||
text(
|
||||
"""UPDATE govdoc_runs SET
|
||||
total_score = :total_score,
|
||||
passed_count = :passed_count,
|
||||
failed_count = :failed_count,
|
||||
skipped_count = :skipped_count,
|
||||
result_status = :result_status,
|
||||
result_summary_json = :result_summary_json,
|
||||
updated_at = now()
|
||||
WHERE id = :rid"""
|
||||
),
|
||||
{
|
||||
"rid": RunId,
|
||||
"total_score": Summary.get("totalScore"),
|
||||
"passed_count": Summary.get("passedCount", 0),
|
||||
"failed_count": Summary.get("failedCount", 0),
|
||||
"skipped_count": Summary.get("skippedCount", 0),
|
||||
"result_status": Summary.get("resultStatus"),
|
||||
"result_summary_json": Summary.get("resultSummaryJson"),
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
log.info(f"[Govdoc] Run result saved: runId={RunId}")
|
||||
|
||||
async def UpdateRunError(self, RunId: int, ErrorMessage: str) -> None:
|
||||
"""记录运行失败的错误信息。"""
|
||||
async with GetAsyncSession() as session:
|
||||
await session.execute(
|
||||
text(
|
||||
"""UPDATE govdoc_runs SET
|
||||
status = 'failed',
|
||||
error_message = :error_message,
|
||||
finished_at = now(),
|
||||
updated_at = now()
|
||||
WHERE id = :rid"""
|
||||
),
|
||||
{"rid": RunId, "error_message": ErrorMessage},
|
||||
)
|
||||
await session.commit()
|
||||
log.error(f"[Govdoc] Run failed: runId={RunId}, error={ErrorMessage[:200]}")
|
||||
|
||||
# ── 规则结果 ─────────────────────────────────────────
|
||||
|
||||
async def SaveRuleResults(self, RunId: int, Results: list[dict[str, Any]]) -> None:
|
||||
"""批量写入 govdoc_rule_results。"""
|
||||
if not Results:
|
||||
return
|
||||
|
||||
async with GetAsyncSession() as session:
|
||||
for row in Results:
|
||||
await session.execute(
|
||||
text(
|
||||
"""INSERT INTO govdoc_rule_results
|
||||
(run_id, rule_id, rule_name, severity, category,
|
||||
message, suggestion, actual, expected, evidence,
|
||||
paragraph_index, paragraph_text, location_path,
|
||||
result, score, created_at, updated_at)
|
||||
VALUES (:run_id, :rule_id, :rule_name, :severity, :category,
|
||||
:message, :suggestion, :actual, :expected, :evidence,
|
||||
:paragraph_index, :paragraph_text, :location_path,
|
||||
:result, :score, now(), now())"""
|
||||
),
|
||||
{
|
||||
"run_id": RunId,
|
||||
"rule_id": row.get("ruleId"),
|
||||
"rule_name": row.get("ruleName"),
|
||||
"severity": row.get("severity"),
|
||||
"category": row.get("category"),
|
||||
"message": row.get("message"),
|
||||
"suggestion": row.get("suggestion"),
|
||||
"actual": row.get("actual"),
|
||||
"expected": row.get("expected"),
|
||||
"evidence": row.get("evidence"),
|
||||
"paragraph_index": row.get("paragraphIndex"),
|
||||
"paragraph_text": row.get("paragraphText"),
|
||||
"location_path": row.get("locationPath"),
|
||||
"result": row.get("result", "pass"),
|
||||
"score": row.get("score"),
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
log.info(f"[Govdoc] Rule results saved: runId={RunId}, count={len(Results)}")
|
||||
|
||||
# ── 报告产物 ─────────────────────────────────────────
|
||||
|
||||
async def SaveArtifacts(self, RunId: int, Artifacts: list[dict[str, Any]]) -> None:
|
||||
"""批量写入 govdoc_report_artifacts。"""
|
||||
if not Artifacts:
|
||||
return
|
||||
|
||||
async with GetAsyncSession() as session:
|
||||
for row in Artifacts:
|
||||
await session.execute(
|
||||
text(
|
||||
"""INSERT INTO govdoc_report_artifacts
|
||||
(run_id, artifact_type, file_name, file_ext, mime_type,
|
||||
file_size, sha256, oss_url, storage_provider, description,
|
||||
created_at, updated_at)
|
||||
VALUES (:run_id, :artifact_type, :file_name, :file_ext, :mime_type,
|
||||
:file_size, :sha256, :oss_url, :storage_provider, :description,
|
||||
now(), now())"""
|
||||
),
|
||||
{
|
||||
"run_id": RunId,
|
||||
"artifact_type": row.get("artifactType"),
|
||||
"file_name": row.get("fileName"),
|
||||
"file_ext": row.get("fileExt"),
|
||||
"mime_type": row.get("mimeType"),
|
||||
"file_size": row.get("fileSize"),
|
||||
"sha256": row.get("sha256"),
|
||||
"oss_url": row.get("ossUrl"),
|
||||
"storage_provider": row.get("storageProvider"),
|
||||
"description": row.get("description"),
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
log.info(f"[Govdoc] Artifacts saved: runId={RunId}, count={len(Artifacts)}")
|
||||
|
||||
# ── 文档状态 ─────────────────────────────────────────
|
||||
|
||||
async def UpdateDocumentStatus(self, DocumentId: int, ProcessingStatus: str, RunId: int | None = None) -> None:
|
||||
"""更新 leaudit_documents 的处理状态和当前 run_id。"""
|
||||
async with GetAsyncSession() as session:
|
||||
if RunId is not None:
|
||||
await session.execute(
|
||||
text(
|
||||
"""UPDATE leaudit_documents SET
|
||||
processing_status = :s, current_run_id = :rid, updated_at = now()
|
||||
WHERE id = :did"""
|
||||
),
|
||||
{"s": ProcessingStatus, "rid": RunId, "did": DocumentId},
|
||||
)
|
||||
else:
|
||||
await session.execute(
|
||||
text(
|
||||
"""UPDATE leaudit_documents SET
|
||||
processing_status = :s, updated_at = now()
|
||||
WHERE id = :did"""
|
||||
),
|
||||
{"s": ProcessingStatus, "did": DocumentId},
|
||||
)
|
||||
await session.commit()
|
||||
log.info(f"[Govdoc] Document status updated: documentId={DocumentId}, status={ProcessingStatus}")
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Govdoc Bridge — Celery 任务入口。
|
||||
|
||||
将 govdoc 审查执行投递到 Celery worker 队列。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from fastapi_common.fastapi_common_logger import logger
|
||||
|
||||
from fastapi_admin.celery_app import celery_app
|
||||
from fastapi_modules.fastapi_leaudit.govdoc_bridge.runner import GovdocRunner
|
||||
from fastapi_modules.fastapi_leaudit.govdoc_bridge.storage_adapter import StorageAdapter
|
||||
|
||||
log = logger
|
||||
|
||||
GOVDOC_WORKER_QUEUE = "govdoc"
|
||||
GOVDOC_WORKER_QUEUE_URGENT = "govdoc_urgent"
|
||||
|
||||
|
||||
def resolve_govdoc_queue(speed: str = "normal") -> str:
|
||||
"""根据优先级返回对应的 worker 队列名。"""
|
||||
if (speed or "").strip().lower() in {"urgent", "high", "fast", "紧急"}:
|
||||
return GOVDOC_WORKER_QUEUE_URGENT
|
||||
return GOVDOC_WORKER_QUEUE
|
||||
|
||||
|
||||
def dispatch_govdoc_task(
|
||||
documentId: int,
|
||||
runId: int,
|
||||
triggerUserId: int | None = None,
|
||||
speed: str = "normal",
|
||||
) -> Any:
|
||||
"""投递 govdoc 审查任务到 Celery 队列。
|
||||
|
||||
Args:
|
||||
documentId: 文档 ID。
|
||||
runId: 已创建的 govdoc_runs.id。
|
||||
triggerUserId: 触发人。
|
||||
speed: 优先级 ('normal' / 'urgent')。
|
||||
|
||||
Returns:
|
||||
Celery AsyncResult。
|
||||
"""
|
||||
queue = resolve_govdoc_queue(speed)
|
||||
log.info(
|
||||
f"[Govdoc] Dispatching task: runId={runId}, documentId={documentId}, queue={queue}"
|
||||
)
|
||||
return govdoc_execute_task.apply_async(
|
||||
kwargs={
|
||||
"documentId": documentId,
|
||||
"runId": runId,
|
||||
"triggerUserId": triggerUserId,
|
||||
"speed": speed,
|
||||
},
|
||||
queue=queue,
|
||||
)
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
bind=True,
|
||||
name="govdoc_execute_task",
|
||||
max_retries=0,
|
||||
default_retry_delay=60,
|
||||
acks_late=True,
|
||||
reject_on_worker_lost=True,
|
||||
task_time_limit=30 * 60, # 单次执行 30 分钟超时
|
||||
task_soft_time_limit=25 * 60,
|
||||
)
|
||||
def govdoc_execute_task(
|
||||
self,
|
||||
documentId: int,
|
||||
runId: int,
|
||||
triggerUserId: int | None = None,
|
||||
speed: str = "normal",
|
||||
) -> dict[str, Any]:
|
||||
"""Celery 任务:执行一次 govdoc 公文格式审查。
|
||||
|
||||
此任务由 dispatch_govdoc_task 投递,worker 消费后执行完整审查链路。
|
||||
"""
|
||||
taskId = self.request.id or "unknown"
|
||||
log.info(f"[Govdoc] Task started: taskId={taskId}, runId={runId}, documentId={documentId}")
|
||||
|
||||
storage = StorageAdapter()
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
try:
|
||||
# 更新 run 状态 → running
|
||||
loop.run_until_complete(storage.UpdateRunStatus(runId, "processing", phase="parsing"))
|
||||
|
||||
# 执行完整审查链路
|
||||
runner = GovdocRunner()
|
||||
result = loop.run_until_complete(
|
||||
runner.Execute(
|
||||
DocumentId=documentId,
|
||||
RunId=runId,
|
||||
TriggerUserId=triggerUserId,
|
||||
Speed=speed,
|
||||
)
|
||||
)
|
||||
log.info(f"[Govdoc] Task completed: taskId={taskId}, runId={runId}")
|
||||
return result
|
||||
|
||||
except Exception as exc:
|
||||
errorMessage = str(exc)[:2000]
|
||||
log.exception(f"[Govdoc] Task failed: taskId={taskId}, runId={runId}, error={errorMessage[:200]}")
|
||||
loop.run_until_complete(storage.UpdateRunError(runId, errorMessage))
|
||||
loop.run_until_complete(storage.UpdateDocumentStatus(documentId, "failed", runId))
|
||||
raise
|
||||
|
||||
finally:
|
||||
loop.close()
|
||||
@@ -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.xxx;2.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 分类统计)+ outline(heading 层级树)。"""
|
||||
|
||||
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):
|
||||
"""公文中的一个语义单元(标题 / 发文字号 / 主送机关 / ...)。
|
||||
|
||||
- structural:name 与某个 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发〔YYYY〕N号 形式的发文字号",
|
||||
"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,
|
||||
)
|
||||
|
||||
# ② attachments:attachment_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,
|
||||
)
|
||||
@@ -14,6 +14,9 @@ from fastapi_modules.fastapi_leaudit.models.leauditRagChatApp import LeauditRagC
|
||||
from fastapi_modules.fastapi_leaudit.models.leauditRagConversation import LeauditRagConversation
|
||||
from fastapi_modules.fastapi_leaudit.models.leauditRagMessage import LeauditRagMessage
|
||||
from fastapi_modules.fastapi_leaudit.models.usageLoginEvent import UsageLoginEvent
|
||||
from fastapi_modules.fastapi_leaudit.models.govdocRun import GovdocRun
|
||||
from fastapi_modules.fastapi_leaudit.models.govdocRuleResult import GovdocRuleResult
|
||||
from fastapi_modules.fastapi_leaudit.models.govdocReportArtifact import GovdocReportArtifact
|
||||
|
||||
__all__ = [
|
||||
"LeauditDocument",
|
||||
@@ -30,4 +33,7 @@ __all__ = [
|
||||
"LeauditRagConversation",
|
||||
"LeauditRagMessage",
|
||||
"UsageLoginEvent",
|
||||
"GovdocRun",
|
||||
"GovdocRuleResult",
|
||||
"GovdocReportArtifact",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Govdoc 报告产物模型 —— govdoc_report_artifacts 表。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import BigInteger, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from fastapi_common.fastapi_common_web.models import BaseModel
|
||||
|
||||
|
||||
class GovdocReportArtifact(BaseModel):
|
||||
"""公文审查报告产物索引表。"""
|
||||
|
||||
__tablename__ = "govdoc_report_artifacts"
|
||||
|
||||
Id: Mapped[int] = mapped_column("id", BigInteger, primary_key=True, autoincrement=True)
|
||||
runId: Mapped[int] = mapped_column("run_id", BigInteger, comment="关联 govdoc_runs.id")
|
||||
|
||||
artifactType: Mapped[str] = mapped_column("artifact_type", String(64), comment="产物类型:html_report/annotated_docx/paragraph_html/json_report/original")
|
||||
fileName: Mapped[str] = mapped_column("file_name", String(512), comment="文件名")
|
||||
fileExt: Mapped[str | None] = mapped_column("file_ext", String(32), comment="扩展名")
|
||||
mimeType: Mapped[str | None] = mapped_column("mime_type", String(128), comment="MIME 类型")
|
||||
fileSize: Mapped[int | None] = mapped_column("file_size", BigInteger, comment="文件大小(字节)")
|
||||
sha256: Mapped[str | None] = mapped_column("sha256", String(64), comment="文件 SHA256")
|
||||
ossUrl: Mapped[str | None] = mapped_column("oss_url", String(2048), comment="OSS 访问地址")
|
||||
storageProvider: Mapped[str | None] = mapped_column("storage_provider", String(32), comment="存储提供商:oss/minio/local")
|
||||
description: Mapped[str | None] = mapped_column("description", String(512), comment="产物说明")
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Govdoc 规则结果模型 —— govdoc_rule_results 表。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import BigInteger, Integer, Numeric, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from fastapi_common.fastapi_common_web.models import BaseModel
|
||||
|
||||
|
||||
class GovdocRuleResult(BaseModel):
|
||||
"""公文规则执行结果明细表。"""
|
||||
|
||||
__tablename__ = "govdoc_rule_results"
|
||||
|
||||
Id: Mapped[int] = mapped_column("id", BigInteger, primary_key=True, autoincrement=True)
|
||||
runId: Mapped[int] = mapped_column("run_id", BigInteger, comment="关联 govdoc_runs.id")
|
||||
|
||||
# 规则标识
|
||||
ruleId: Mapped[str] = mapped_column("rule_id", String(128), comment="规则标识")
|
||||
ruleName: Mapped[str | None] = mapped_column("rule_name", String(256), comment="规则名称")
|
||||
severity: Mapped[str | None] = mapped_column("severity", String(32), comment="严重等级:error/warning/info")
|
||||
category: Mapped[str | None] = mapped_column("category", String(128), comment="规则分类")
|
||||
|
||||
# 结果内容
|
||||
message: Mapped[str | None] = mapped_column("message", Text, comment="结果描述")
|
||||
suggestion: Mapped[str | None] = mapped_column("suggestion", Text, comment="修改建议")
|
||||
actual: Mapped[str | None] = mapped_column("actual", Text, comment="实际值")
|
||||
expected: Mapped[str | None] = mapped_column("expected", Text, comment="期望值")
|
||||
evidence: Mapped[str | None] = mapped_column("evidence", Text, comment="证据文本")
|
||||
|
||||
# 文档定位
|
||||
paragraphIndex: Mapped[int | None] = mapped_column("paragraph_index", Integer, comment="段落索引")
|
||||
paragraphText: Mapped[str | None] = mapped_column("paragraph_text", Text, comment="段落原文")
|
||||
locationPath: Mapped[str | None] = mapped_column("location_path", String(512), comment="文档结构位置路径")
|
||||
|
||||
# 判定
|
||||
result: Mapped[str] = mapped_column("result", String(32), default="pass", comment="执行结果:pass/fail/skipped/error")
|
||||
score: Mapped[float | None] = mapped_column("score", Numeric(10, 2), comment="本条得分")
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Govdoc 审查运行模型 —— govdoc_runs 表。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BigInteger, DateTime, Integer, Numeric, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from fastapi_common.fastapi_common_web.models import BaseModel
|
||||
|
||||
|
||||
class GovdocRun(BaseModel):
|
||||
"""公文审查运行主表。"""
|
||||
|
||||
__tablename__ = "govdoc_runs"
|
||||
|
||||
Id: Mapped[int] = mapped_column("id", BigInteger, primary_key=True, autoincrement=True)
|
||||
documentId: Mapped[int] = mapped_column("document_id", BigInteger, comment="关联 leaudit_documents.id")
|
||||
documentFileId: Mapped[int | None] = mapped_column("document_file_id", BigInteger, comment="输入文件 ID,关联 leaudit_document_files.id")
|
||||
runNo: Mapped[int] = mapped_column("run_no", Integer, default=1, comment="同一文档第几次执行")
|
||||
triggerSource: Mapped[str] = mapped_column("trigger_source", String(64), default="upload", comment="触发来源:upload/manual/retry/migration")
|
||||
triggerUserId: Mapped[int | None] = mapped_column("trigger_user_id", BigInteger, comment="触发人 user_id")
|
||||
taskId: Mapped[str | None] = mapped_column("task_id", String(128), comment="Celery 任务 ID")
|
||||
|
||||
# 运行状态
|
||||
status: Mapped[str] = mapped_column("status", String(64), default="pending", comment="pending/processing/completed/failed/cancelled")
|
||||
phase: Mapped[str | None] = mapped_column("phase", String(32), comment="当前阶段:parsing/executing/reporting")
|
||||
|
||||
# 引擎快照
|
||||
engineVersion: Mapped[str | None] = mapped_column("engine_version", String(64), comment="引擎版本号")
|
||||
llmProvider: Mapped[str | None] = mapped_column("llm_provider", String(64), comment="LLM 提供商")
|
||||
llmModel: Mapped[str | None] = mapped_column("llm_model", String(128), comment="LLM 模型名")
|
||||
|
||||
# 结果汇总
|
||||
totalScore: Mapped[float | None] = mapped_column("total_score", Numeric(10, 2), comment="总分")
|
||||
passedCount: Mapped[int | None] = mapped_column("passed_count", Integer, comment="通过规则数")
|
||||
failedCount: Mapped[int | None] = mapped_column("failed_count", Integer, comment="未通过规则数")
|
||||
skippedCount: Mapped[int | None] = mapped_column("skipped_count", Integer, comment="跳过规则数")
|
||||
resultStatus: Mapped[str | None] = mapped_column("result_status", String(32), comment="综合结果:pass/fail/partial/error")
|
||||
resultSummaryJson: Mapped[str | None] = mapped_column("result_summary_json", Text, comment="结构化结果摘要 JSON")
|
||||
errorMessage: Mapped[str | None] = mapped_column("error_message", Text, comment="运行失败时错误描述")
|
||||
|
||||
# 时间
|
||||
startedAt: Mapped[datetime | None] = mapped_column("started_at", DateTime(timezone=True), comment="开始执行时间")
|
||||
finishedAt: Mapped[datetime | None] = mapped_column("finished_at", DateTime(timezone=True), comment="结束执行时间")
|
||||
@@ -16,6 +16,7 @@ from fastapi_modules.fastapi_leaudit.services.ruleConfigService import IRuleConf
|
||||
from fastapi_modules.fastapi_leaudit.services.ruleService import IRuleService
|
||||
from fastapi_modules.fastapi_leaudit.services.ragDatasetService import IRagDatasetService
|
||||
from fastapi_modules.fastapi_leaudit.services.ragChatService import IRagChatService
|
||||
from fastapi_modules.fastapi_leaudit.services.govdocService import IGovdocService
|
||||
from fastapi_modules.fastapi_leaudit.services.usageStatsService import IUsageStatsService
|
||||
|
||||
__all__ = [
|
||||
@@ -36,4 +37,5 @@ __all__ = [
|
||||
"IRagDatasetService",
|
||||
"IRagChatService",
|
||||
"IUsageStatsService",
|
||||
"IGovdocService",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Govdoc 公文模块服务接口。"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
from fastapi import UploadFile
|
||||
|
||||
|
||||
class IGovdocService(ABC):
|
||||
"""公文处理与格式审查服务抽象接口。"""
|
||||
|
||||
# ── 文档 ──────────────────────────────────────────────
|
||||
|
||||
@abstractmethod
|
||||
async def UploadDocument(
|
||||
self,
|
||||
file: UploadFile,
|
||||
typeId: int | None = None,
|
||||
region: str = "default",
|
||||
autoRun: bool = False,
|
||||
speed: str = "normal",
|
||||
ruleVersionId: int | None = None,
|
||||
createdBy: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""上传公文文档,创建主档记录,可选自动触发审查。"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def ListDocuments(
|
||||
self,
|
||||
page: int = 1,
|
||||
pageSize: int = 20,
|
||||
keyword: str | None = None,
|
||||
region: str | None = None,
|
||||
status: str | None = None,
|
||||
resultStatus: str | None = None,
|
||||
createdBy: int | None = None,
|
||||
dateFrom: str | None = None,
|
||||
dateTo: str | None = None,
|
||||
userId: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""获取公文模块文档列表,自动限制 engine_type='govdoc'。"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def GetDocumentDetail(self, documentId: int, userId: int | None = None) -> dict[str, Any]:
|
||||
"""获取公文详情:文档基础信息 + 最新 run 摘要 + 报告引用。"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def UpdateDocument(self, documentId: int, body: dict[str, Any], userId: int | None = None) -> dict[str, Any]:
|
||||
"""修改公文标题、文号、备注等基础信息。"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def DeleteDocument(self, documentId: int, userId: int | None = None) -> dict[str, Any]:
|
||||
"""软删除文档。"""
|
||||
...
|
||||
|
||||
# ── 审查运行 ──────────────────────────────────────────
|
||||
|
||||
@abstractmethod
|
||||
async def CreateRun(
|
||||
self,
|
||||
documentId: int,
|
||||
ruleVersionId: int | None = None,
|
||||
speed: str = "normal",
|
||||
force: bool = False,
|
||||
triggerUserId: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""对已存在文档发起一次公文审查 run。"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def GetRunStatus(self, runId: int) -> dict[str, Any]:
|
||||
"""查询 run 状态、阶段、耗时、错误摘要。"""
|
||||
...
|
||||
|
||||
# ── 结果与报告 ────────────────────────────────────────
|
||||
|
||||
@abstractmethod
|
||||
async def GetRunResult(self, runId: int) -> dict[str, Any]:
|
||||
"""获取审查结果摘要:summary + checked rules + findings 统计 + entities 摘要。"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def GetRunFindings(self, runId: int) -> dict[str, Any]:
|
||||
"""获取段落级 findings 明细列表。"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def GetRunEntities(self, runId: int) -> dict[str, Any]:
|
||||
"""获取识别出的标题、文号、署名等实体。"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def GetRunParagraphs(self, runId: int) -> dict[str, Any]:
|
||||
"""获取前端文档联动视图所需的段落 HTML。"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def GetReportHtml(self, runId: int) -> dict[str, Any]:
|
||||
"""获取 HTML 报告内容或下载地址。"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def GetReportDocx(self, runId: int) -> dict[str, Any]:
|
||||
"""获取批注 DOCX 下载地址。"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def DownloadOriginal(self, documentId: int) -> dict[str, Any]:
|
||||
"""获取原始上传文档下载地址。"""
|
||||
...
|
||||
|
||||
# ── 规则 ──────────────────────────────────────────────
|
||||
|
||||
@abstractmethod
|
||||
async def ListRules(self) -> dict[str, Any]:
|
||||
"""获取当前生效规则集摘要。"""
|
||||
...
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Govdoc 公文模块服务实现(阶段骨架)。
|
||||
|
||||
本文件为 Phase 1 骨架实现,所有方法暂返回占位结果。
|
||||
后续步骤将逐步接入:
|
||||
- govdoc_bridge 执行桥接
|
||||
- govdoc_engine 引擎内核
|
||||
- 文档主档复用
|
||||
- OSS / Celery 集成
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import UploadFile
|
||||
|
||||
from fastapi_common.fastapi_common_logger import logger
|
||||
from fastapi_modules.fastapi_leaudit.services import IGovdocService
|
||||
|
||||
|
||||
class GovdocServiceImpl(IGovdocService):
|
||||
"""公文处理与格式审查服务实现。"""
|
||||
|
||||
# ── 文档 ──────────────────────────────────────────────
|
||||
|
||||
async def UploadDocument(
|
||||
self,
|
||||
file: UploadFile,
|
||||
typeId: int | None = None,
|
||||
region: str = "default",
|
||||
autoRun: bool = False,
|
||||
speed: str = "normal",
|
||||
ruleVersionId: int | None = None,
|
||||
createdBy: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
logger.info("[Govdoc] UploadDocument placeholder — file=%s region=%s", file.filename, region)
|
||||
return {
|
||||
"documentId": 0,
|
||||
"fileId": 0,
|
||||
"fileName": file.filename,
|
||||
"region": region,
|
||||
"engineType": "govdoc",
|
||||
"autoRunTriggered": autoRun,
|
||||
}
|
||||
|
||||
async def ListDocuments(
|
||||
self,
|
||||
page: int = 1,
|
||||
pageSize: int = 20,
|
||||
keyword: str | None = None,
|
||||
region: str | None = None,
|
||||
status: str | None = None,
|
||||
resultStatus: str | None = None,
|
||||
createdBy: int | None = None,
|
||||
dateFrom: str | None = None,
|
||||
dateTo: str | None = None,
|
||||
userId: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
logger.info("[Govdoc] ListDocuments placeholder — page=%s pageSize=%s", page, pageSize)
|
||||
return {"items": [], "total": 0, "page": page, "pageSize": pageSize}
|
||||
|
||||
async def GetDocumentDetail(self, documentId: int, userId: int | None = None) -> dict[str, Any]:
|
||||
logger.info("[Govdoc] GetDocumentDetail placeholder — id=%s", documentId)
|
||||
return {"documentId": documentId}
|
||||
|
||||
async def UpdateDocument(self, documentId: int, body: dict[str, Any], userId: int | None = None) -> dict[str, Any]:
|
||||
logger.info("[Govdoc] UpdateDocument placeholder — id=%s", documentId)
|
||||
return {"documentId": documentId, **body}
|
||||
|
||||
async def DeleteDocument(self, documentId: int, userId: int | None = None) -> dict[str, Any]:
|
||||
logger.info("[Govdoc] DeleteDocument placeholder — id=%s", documentId)
|
||||
return {"documentId": documentId, "deleted": True}
|
||||
|
||||
# ── 审查运行 ──────────────────────────────────────────
|
||||
|
||||
async def CreateRun(
|
||||
self,
|
||||
documentId: int,
|
||||
ruleVersionId: int | None = None,
|
||||
speed: str = "normal",
|
||||
force: bool = False,
|
||||
triggerUserId: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
logger.info("[Govdoc] CreateRun placeholder — documentId=%s", documentId)
|
||||
return {
|
||||
"runId": 0,
|
||||
"documentId": documentId,
|
||||
"status": "queued",
|
||||
"phase": "dispatch",
|
||||
}
|
||||
|
||||
async def GetRunStatus(self, runId: int) -> dict[str, Any]:
|
||||
logger.info("[Govdoc] GetRunStatus placeholder — runId=%s", runId)
|
||||
return {"runId": runId, "status": "pending"}
|
||||
|
||||
# ── 结果与报告 ────────────────────────────────────────
|
||||
|
||||
async def GetRunResult(self, runId: int) -> dict[str, Any]:
|
||||
logger.info("[Govdoc] GetRunResult placeholder — runId=%s", runId)
|
||||
return {"runId": runId, "summary": {}}
|
||||
|
||||
async def GetRunFindings(self, runId: int) -> dict[str, Any]:
|
||||
logger.info("[Govdoc] GetRunFindings placeholder — runId=%s", runId)
|
||||
return {"runId": runId, "findings": []}
|
||||
|
||||
async def GetRunEntities(self, runId: int) -> dict[str, Any]:
|
||||
logger.info("[Govdoc] GetRunEntities placeholder — runId=%s", runId)
|
||||
return {"runId": runId, "entities": []}
|
||||
|
||||
async def GetRunParagraphs(self, runId: int) -> dict[str, Any]:
|
||||
logger.info("[Govdoc] GetRunParagraphs placeholder — runId=%s", runId)
|
||||
return {"runId": runId, "paragraphs": []}
|
||||
|
||||
async def GetReportHtml(self, runId: int) -> dict[str, Any]:
|
||||
logger.info("[Govdoc] GetReportHtml placeholder — runId=%s", runId)
|
||||
return {"runId": runId, "htmlUrl": ""}
|
||||
|
||||
async def GetReportDocx(self, runId: int) -> dict[str, Any]:
|
||||
logger.info("[Govdoc] GetReportDocx placeholder — runId=%s", runId)
|
||||
return {"runId": runId, "docxUrl": ""}
|
||||
|
||||
async def DownloadOriginal(self, documentId: int) -> dict[str, Any]:
|
||||
logger.info("[Govdoc] DownloadOriginal placeholder — documentId=%s", documentId)
|
||||
return {"documentId": documentId, "downloadUrl": ""}
|
||||
|
||||
# ── 规则 ──────────────────────────────────────────────
|
||||
|
||||
async def ListRules(self) -> dict[str, Any]:
|
||||
logger.info("[Govdoc] ListRules placeholder")
|
||||
return {"rules": []}
|
||||
Reference in New Issue
Block a user