diff --git a/fastapi_modules/fastapi_leaudit/controllers/govdocController.py b/fastapi_modules/fastapi_leaudit/controllers/govdocController.py index 31e1efe..0161258 100644 --- a/fastapi_modules/fastapi_leaudit/controllers/govdocController.py +++ b/fastapi_modules/fastapi_leaudit/controllers/govdocController.py @@ -244,3 +244,12 @@ class GovdocController(BaseController): """获取当前生效规则集摘要。""" result = await self.GovdocService.ListRules() return Result.success(data=result) + + @self.router.get("/rules/{ruleId}") + async def GetRuleDetail( + ruleId: str, + payload: dict[str, Any] = Depends(verify_access_token), + ): + """获取单条规则完整详情。""" + result = await self.GovdocService.GetRuleDetail(ruleId=ruleId) + return Result.success(data=result) diff --git a/fastapi_modules/fastapi_leaudit/govdoc_bridge/result_adapter.py b/fastapi_modules/fastapi_leaudit/govdoc_bridge/result_adapter.py index 3c89d36..85b526d 100644 --- a/fastapi_modules/fastapi_leaudit/govdoc_bridge/result_adapter.py +++ b/fastapi_modules/fastapi_leaudit/govdoc_bridge/result_adapter.py @@ -21,16 +21,33 @@ class ResultAdapter: - 前端 VO 字典 """ - def AdaptRunSummary(self, EngineResult: AuditResult) -> dict[str, Any]: - """从 AuditResult.summary 提取 run 汇总字段。""" + def AdaptRunSummary( + self, + EngineResult: AuditResult, + Structure: list[dict[str, Any]] | None = None, + Outline: list[dict[str, Any]] | None = None, + ) -> dict[str, Any]: + """从 AuditResult.summary 提取 run 汇总字段。 + + 同时接受已适配的 structure / outline 列表,一并序列化进 + resultSummaryJson,供前端 structure-panel / outline-panel 读取。 + """ + import json + s = EngineResult.summary + aux: dict[str, Any] = {} + if Structure is not None: + aux["structure"] = Structure + if Outline is not None: + aux["outline"] = Outline + 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, # 可为后续扩展预留 + "resultSummaryJson": json.dumps(aux, ensure_ascii=False) if aux else None, } def AdaptRuleResults(self, EngineResult: AuditResult) -> list[dict[str, Any]]: diff --git a/fastapi_modules/fastapi_leaudit/govdoc_bridge/runner.py b/fastapi_modules/fastapi_leaudit/govdoc_bridge/runner.py index 55c8e77..b75022d 100644 --- a/fastapi_modules/fastapi_leaudit/govdoc_bridge/runner.py +++ b/fastapi_modules/fastapi_leaudit/govdoc_bridge/runner.py @@ -74,13 +74,20 @@ class GovdocRunner: ) # 4. 适配引擎结果 - runSummary = self.ResultAdapter.AdaptRunSummary(engineResult) - ruleResults = self.ResultAdapter.AdaptRuleResults(engineResult) - entities = self.ResultAdapter.AdaptEntities(engineResult) structure = self.ResultAdapter.AdaptStructure(engineResult) outline = self.ResultAdapter.AdaptOutline(engineResult) + runSummary = self.ResultAdapter.AdaptRunSummary( + engineResult, + Structure=structure, + Outline=outline, + ) + ruleResults = self.ResultAdapter.AdaptRuleResults(engineResult) + entities = self.ResultAdapter.AdaptEntities(engineResult) artifacts = self.ResultAdapter.AdaptArtifacts(engineResult, RunId) + # 将 rules_path 附带到 runSummary 中,供 GetRuleDetail 后续解析 + runSummary["rulesPath"] = RulesPath + # 5. 持久化结果 await self.Storage.UpdateRunResult(RunId, runSummary) await self.Storage.SaveRuleResults(RunId, ruleResults) diff --git a/fastapi_modules/fastapi_leaudit/govdoc_bridge/storage_adapter.py b/fastapi_modules/fastapi_leaudit/govdoc_bridge/storage_adapter.py index 81261b2..b0d716e 100644 --- a/fastapi_modules/fastapi_leaudit/govdoc_bridge/storage_adapter.py +++ b/fastapi_modules/fastapi_leaudit/govdoc_bridge/storage_adapter.py @@ -73,29 +73,34 @@ class StorageAdapter: log.info(f"[Govdoc] Run status updated: runId={RunId}, status={Status}") async def UpdateRunResult(self, RunId: int, Summary: dict[str, Any]) -> None: - """写入 run 结果汇总字段。""" + """写入 run 结果汇总字段(含 rules_path / structure / outline)。""" + rules_path = Summary.get("rulesPath") + set_clauses = [ + "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()", + ] + params: dict[str, Any] = { + "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"), + } + if rules_path: + set_clauses.append("rules_path = :rules_path") + params["rules_path"] = rules_path + 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"), - }, + text(f"UPDATE govdoc_runs SET {', '.join(set_clauses)} WHERE id = :rid"), + params, ) await session.commit() log.info(f"[Govdoc] Run result saved: runId={RunId}") diff --git a/fastapi_modules/fastapi_leaudit/services/govdocService.py b/fastapi_modules/fastapi_leaudit/services/govdocService.py index 20ba9aa..cca7f6d 100644 --- a/fastapi_modules/fastapi_leaudit/services/govdocService.py +++ b/fastapi_modules/fastapi_leaudit/services/govdocService.py @@ -126,6 +126,11 @@ class IGovdocService(ABC): # ── 规则 ────────────────────────────────────────────── @abstractmethod - async def ListRules(self) -> dict[str, Any]: + async def ListRules(self, rulesPath: str | None = None) -> dict[str, Any]: """获取当前生效规则集摘要。""" ... + + @abstractmethod + async def GetRuleDetail(self, ruleId: str, rulesPath: str | None = None) -> dict[str, Any]: + """获取单条规则完整详情(名称、严重度、分类、分组、stages、消息等)。""" + ... diff --git a/fastapi_modules/fastapi_leaudit/services/impl/documentServiceImpl.py b/fastapi_modules/fastapi_leaudit/services/impl/documentServiceImpl.py index bca5b1d..ecd8ed9 100644 --- a/fastapi_modules/fastapi_leaudit/services/impl/documentServiceImpl.py +++ b/fastapi_modules/fastapi_leaudit/services/impl/documentServiceImpl.py @@ -173,65 +173,65 @@ class DocumentServiceImpl(IDocumentService): else: versionGroupKey = uuid.uuid4().hex - document = await LeauditDocument.create_new( - Session, + document = await LeauditDocument.create_new( + Session, bizDocumentId=internalDocumentNo, typeId=resolvedTypeId, groupId=resolvedGroupId, region=normalizedRegion, processingStatus="waiting", versionGroupKey=versionGroupKey, - versionNo=versionNo, - previousVersionId=previousVersionId, - rootVersionId=rootVersionId, - isLatestVersion=True, - normalizedName=normalizedName, - ) - if document.rootVersionId is None: - document.rootVersionId = document.Id - rootVersionId = document.Id - else: - rootVersionId = document.rootVersionId + versionNo=versionNo, + previousVersionId=previousVersionId, + rootVersionId=rootVersionId, + isLatestVersion=True, + normalizedName=normalizedName, + ) + if document.rootVersionId is None: + document.rootVersionId = document.Id + rootVersionId = document.Id + else: + rootVersionId = document.rootVersionId - versionLabel = f"v{document.versionNo}" - objectKey = OssPathUtils.BuildBusinessDocKey( - Region=normalizedRegion, - TypeCode=resolvedTypeCode, - DocumentId=document.Id, - Version=versionLabel, - FileRole=normalizedFileRole, - FileName=FileName, - Year=uploadedAt.year, - Month=uploadedAt.month, - ) - ossUrl = await self.OssService.UploadBytes( - ObjectKey=objectKey, - Content=FileContent, - ContentType=mimeType, - ) + versionLabel = f"v{document.versionNo}" + objectKey = OssPathUtils.BuildBusinessDocKey( + Region=normalizedRegion, + TypeCode=resolvedTypeCode, + DocumentId=document.Id, + Version=versionLabel, + FileRole=normalizedFileRole, + FileName=FileName, + Year=uploadedAt.year, + Month=uploadedAt.month, + ) + ossUrl = await self.OssService.UploadBytes( + ObjectKey=objectKey, + Content=FileContent, + ContentType=mimeType, + ) - versionCount = await LeauditDocumentFile.count_by_document(Session, document.Id) - _ = versionCount # single-version-per-document in current model; kept for future extension - await LeauditDocumentFile.deactivate_active_by_document(Session, document.Id) - documentFile = LeauditDocumentFile( - documentId=document.Id, - fileRole=normalizedFileRole, - fileName=FileName, - fileExt=fileExt, - mimeType=mimeType, - fileSize=fileSize, - sha256=fileSha256, - localPath=None, - ossUrl=ossUrl, - storageProvider="minio", - isActive=True, - createdBy=CreatedBy, - ) - Session.add(documentFile) - await Session.flush() - await Session.commit() - await Session.refresh(document) - await Session.refresh(documentFile) + versionCount = await LeauditDocumentFile.count_by_document(Session, document.Id) + _ = versionCount # single-version-per-document in current model; kept for future extension + await LeauditDocumentFile.deactivate_active_by_document(Session, document.Id) + documentFile = LeauditDocumentFile( + documentId=document.Id, + fileRole=normalizedFileRole, + fileName=FileName, + fileExt=fileExt, + mimeType=mimeType, + fileSize=fileSize, + sha256=fileSha256, + localPath=None, + ossUrl=ossUrl, + storageProvider="minio", + isActive=True, + createdBy=CreatedBy, + ) + Session.add(documentFile) + await Session.flush() + await Session.commit() + await Session.refresh(document) + await Session.refresh(documentFile) if normalizedFileRole == "primary" and Attachments: await self._appendAttachmentFiles( diff --git a/fastapi_modules/fastapi_leaudit/services/impl/govdocServiceImpl.py b/fastapi_modules/fastapi_leaudit/services/impl/govdocServiceImpl.py index e7997c4..065479f 100644 --- a/fastapi_modules/fastapi_leaudit/services/impl/govdocServiceImpl.py +++ b/fastapi_modules/fastapi_leaudit/services/impl/govdocServiceImpl.py @@ -96,8 +96,103 @@ class GovdocServiceImpl(IGovdocService): # ── 结果与报告 ──────────────────────────────────────── async def GetRunResult(self, runId: int) -> dict[str, Any]: - logger.info("[Govdoc] GetRunResult placeholder — runId=%s", runId) - return {"runId": runId, "summary": {}} + """从 govdoc_runs + govdoc_rule_results 读取审查结果,含 structure/outline。""" + import json as _json + from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession + from sqlalchemy import text + + async with GetAsyncSession() as session: + run_row = await session.execute( + text( + """SELECT id, document_id, status, phase, total_score, passed_count, + failed_count, skipped_count, result_status, result_summary_json, + rules_path, started_at, finished_at + FROM govdoc_runs WHERE id = :rid""" + ), + {"rid": runId}, + ) + run_data = run_row.mappings().first() + if not run_data: + return {"runId": runId, "summary": {}, "checkedRules": [], "findings": [], + "structure": [], "outline": [], "entities": {}} + + rules_rows = await session.execute( + text( + """SELECT rule_id, rule_name, severity, category, result, skip_reason, + message, suggestion, actual, expected, evidence, + paragraph_index, paragraph_text, location_path, score + FROM govdoc_rule_results WHERE run_id = :rid""" + ), + {"rid": runId}, + ) + rule_results = [dict(r._mapping) for r in rules_rows.fetchall()] + + aux_raw = run_data.get("result_summary_json") + aux = {} + if aux_raw: + try: + aux = _json.loads(aux_raw) if isinstance(aux_raw, str) else aux_raw + except (TypeError, _json.JSONDecodeError): + pass + + findings = [] + for rr in rule_results: + loc = {} + if rr.get("paragraph_index") is not None: + loc["paragraph_index"] = rr["paragraph_index"] + if rr.get("paragraph_text"): + loc["context"] = rr["paragraph_text"] + if rr.get("location_path"): + loc["role"] = rr["location_path"] + findings.append({ + "finding_id": f"{rr['rule_id']}-{rr['paragraph_index'] or 0}", + "rule_id": rr["rule_id"], + "rule_name": rr["rule_name"], + "severity": rr["severity"], + "category": rr["category"], + "location": loc if loc else None, + "actual": rr.get("actual") or {}, + "expected": rr.get("expected") or {}, + "message": rr.get("message") or "", + "suggestion": rr.get("suggestion") or "", + "evidence": rr.get("evidence") or "", + "confidence": 1.0, + }) + + checked_rules = [] + seen = set() + for rr in rule_results: + rid = rr["rule_id"] + if rid in seen: + continue + seen.add(rid) + status = rr.get("result", "pass") + checked_rules.append({ + "rule_id": rid, + "name": rr["rule_name"], + "severity": rr["severity"], + "category": rr["category"], + "status": status if status in ("pass", "fail", "skipped") else "pass", + "skip_reason": rr.get("skip_reason"), + }) + + return { + "runId": runId, + "summary": { + "score": run_data.get("total_score", 100), + "total_findings": len(findings), + "by_severity": {}, + "by_category": {}, + "passed_count": run_data.get("passed_count", 0), + "failed_count": run_data.get("failed_count", 0), + "skipped_count": run_data.get("skipped_count", 0), + }, + "checkedRules": checked_rules, + "findings": findings, + "structure": aux.get("structure", []), + "outline": aux.get("outline", []), + "entities": {}, + } async def GetRunFindings(self, runId: int) -> dict[str, Any]: logger.info("[Govdoc] GetRunFindings placeholder — runId=%s", runId) @@ -133,6 +228,81 @@ class GovdocServiceImpl(IGovdocService): # ── 规则 ────────────────────────────────────────────── - async def ListRules(self) -> dict[str, Any]: - logger.info("[Govdoc] ListRules placeholder") - return {"rules": []} + async def ListRules(self, rulesPath: str | None = None) -> dict[str, Any]: + """从 govdoc 规则 YAML 文件加载规则清单。""" + rules = await self._load_rules_list(rulesPath) + return {"rules": rules, "total_rules": len(rules)} + + async def GetRuleDetail(self, ruleId: str, rulesPath: str | None = None) -> dict[str, Any]: + """获取单条规则完整详情(名称、严重度、stages、消息等)。""" + ruleset = await self._load_ruleset(rulesPath) + if ruleset is None: + return {"rule_id": ruleId, "name": ruleId, "severity": "info", "category": "", "group": ""} + for rule in ruleset.all_rules(): + if rule.rule_id == ruleId: + return { + "rule_id": rule.rule_id, + "name": rule.name, + "severity": rule.severity, + "category": rule.category, + "group": "", + "applies_to": rule.applies_to.model_dump() if rule.applies_to else None, + "target": rule.target, + "on_missing": rule.on_missing, + "stages": [s.model_dump(exclude_none=True) for s in (rule.stages or [])], + "messages": rule.messages.model_dump() if rule.messages else {}, + } + return {"rule_id": ruleId, "name": ruleId, "severity": "info", "category": "", "group": ""} + + # ── 规则加载助手 ──────────────────────────────────── + + async def _resolve_rules_path(self, rulesPath: str | None = None) -> str | None: + """解析规则 YAML 文件路径。 + + 优先级:传入参数 > govdoc_runs 表记录 > None + """ + if rulesPath: + return rulesPath + # 尝试从最近的 completed run 中获取 rules_path + try: + from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession + from sqlalchemy import text + async with GetAsyncSession() as session: + row = await session.execute( + text( + """SELECT rules_path FROM govdoc_runs + WHERE rules_path IS NOT NULL AND status = 'completed' + ORDER BY id DESC LIMIT 1""" + ) + ) + result = row.mappings().first() + if result and result.get("rules_path"): + return result["rules_path"] + except Exception: + pass + return None + + async def _load_ruleset(self, rulesPath: str | None = None): + """加载 rules.yaml 为 RuleSet 对象。""" + resolved = await self._resolve_rules_path(rulesPath) + if not resolved: + logger.warning("[Govdoc] Cannot resolve rules path for GetRuleDetail/ListRules") + return None + from fastapi_modules.fastapi_leaudit.govdoc_engine.dsl.loader import load_rules + return load_rules(resolved) + + async def _load_rules_list(self, rulesPath: str | None = None) -> list[dict[str, Any]]: + """加载规则列表(简要信息)。""" + ruleset = await self._load_ruleset(rulesPath) + if ruleset is None: + return [] + result = [] + for rule in ruleset.all_rules(): + result.append({ + "rule_id": rule.rule_id, + "name": rule.name, + "severity": rule.severity, + "category": rule.category, + "group": "", + }) + return result