Files
leaudit-platform-backend/docs/superpowers/plans/2026-04-28-fix-double-finalize-and-bindings-api.md
T

25 KiB

Fix Double Finalize + Rule Type Bindings API Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Fix two blocking issues: (1) eliminate the duplicate result_status / finished_at write in save_evaluation_results, and (2) add full CRUD API for the leaudit_rule_type_bindings table.

Architecture: Fix 1 is a one-line removal in storage_adapter.py — strip the premature run summary UPDATE from save_evaluation_results so finalize_run is the single source of truth for terminal state. Fix 2 follows the existing RuleController → IRuleService → RuleServiceImpl layered pattern, adding DTO/VO types and 4 endpoints for binding management.

Tech Stack: Python, FastAPI, SQLAlchemy async, PostgreSQL


File Map

File Action Responsibility
fastapi_modules/fastapi_leaudit/leaudit_bridge/storage_adapter.py Modify Remove premature UPDATE from save_evaluation_results
fastapi_modules/fastapi_leaudit/domian/Dto/ruleBindingDto.py Create RuleBindingCreateDTO, RuleBindingUpdateDTO
fastapi_modules/fastapi_leaudit/domian/vo/ruleVo.py Modify Add RuleBindingVO
fastapi_modules/fastapi_leaudit/services/ruleService.py Modify Add 4 abstract methods
fastapi_modules/fastapi_leaudit/services/impl/ruleServiceImpl.py Modify Add 4 method implementations
fastapi_modules/fastapi_leaudit/controllers/ruleController.py Modify Add 4 endpoints

Task 1: Fix Double Finalize — Strip Premature UPDATE from save_evaluation_results

Files:

  • Modify: fastapi_modules/fastapi_leaudit/leaudit_bridge/storage_adapter.py:167-181

  • Step 1: Remove result_status and finished_at from the UPDATE clause

Replace lines 167-181 of storage_adapter.py:

            # Update audit_runs summary
            await session.execute(
                text("""UPDATE leaudit_audit_runs SET
                    total_score = :ts, passed_count = :pc, failed_count = :fc,
                    skipped_count = :sc, result_status = :rs, finished_at = now(), update_time = now()
                    WHERE id = :rid"""),
                {
                    "ts": evaluation.total_score,
                    "pc": evaluation.passed_count,
                    "fc": evaluation.failed_count,
                    "sc": evaluation.skipped_count,
                    "rs": "pass" if evaluation.failed_count == 0 else "fail",
                    "rid": resolved_run_id,
                },
            )

With:

            # Update audit_runs summary (scores only — terminal state set by finalize_run)
            await session.execute(
                text("""UPDATE leaudit_audit_runs SET
                    total_score = :ts, passed_count = :pc, failed_count = :fc,
                    skipped_count = :sc, update_time = now()
                    WHERE id = :rid"""),
                {
                    "ts": evaluation.total_score,
                    "pc": evaluation.passed_count,
                    "fc": evaluation.failed_count,
                    "sc": evaluation.skipped_count,
                    "rid": resolved_run_id,
                },
            )
  • Step 2: Verify finalize_run is still the last writer in persist_result

Read nativeRunner.py:149-157 to confirm finalize_run runs after all other persist steps, including save_evaluation_results. The order is:

save_ocr_result → save_extraction_result → save_evaluation_results → save_run_errors → save_rescue_outcomes → save_run_metrics → finalize_run

Confirmed: finalize_run is the LAST call in persist_result(), so it will always set the definitive terminal state.

  • Step 3: Syntax check
cd /home/wren-dev/Porject/leaudit-platform && python -m compileall fastapi_modules/fastapi_leaudit/leaudit_bridge/storage_adapter.py

Expected: Compile successful, no errors.

  • Step 4: Commit
cd /home/wren-dev/Porject/leaudit-platform
git add fastapi_modules/fastapi_leaudit/leaudit_bridge/storage_adapter.py
git commit -m "fix: remove premature result_status/finished_at from save_evaluation_results

finalize_run() is the single source of truth for terminal run state.
Previously save_evaluation_results wrote a binary pass/fail status and
finished_at BEFORE rescue outcomes/metrics were saved, then finalize_run
overwrote it. Now scores only are written here; terminal state is set
once by finalize_run after all sub-results are persisted."

Task 2: Create RuleBinding DTOs

Files:

  • Create: fastapi_modules/fastapi_leaudit/domian/Dto/ruleBindingDto.py

  • Step 1: Create the DTO file

"""规则类型绑定 DTO。"""

from pydantic import BaseModel, Field


class RuleBindingCreateDTO(BaseModel):
    """创建规则类型绑定请求。"""

    docTypeId: int = Field(..., description="文档类型ID → leaudit_document_types.id")
    docTypeCode: str | None = Field(None, description="文档类型编码(冗余快速匹配)")
    ruleSetId: int = Field(..., description="规则集ID → leaudit_rule_sets.id")
    bindingMode: str = Field("explicit", description="绑定模式: explicit / wildcard / fallback")
    priority: int = Field(0, description="优先级(数值越大优先级越高)")
    note: str | None = Field(None, description="备注说明")


class RuleBindingUpdateDTO(BaseModel):
    """更新规则类型绑定请求。"""

    isActive: bool | None = Field(None, description="是否激活")
    priority: int | None = Field(None, description="优先级")
    bindingMode: str | None = Field(None, description="绑定模式")
    note: str | None = Field(None, description="备注说明")
  • Step 2: Syntax check
cd /home/wren-dev/Porject/leaudit-platform && python -m compileall fastapi_modules/fastapi_leaudit/domian/Dto/ruleBindingDto.py

Expected: Compile successful.

  • Step 3: Commit
cd /home/wren-dev/Porject/leaudit-platform
git add fastapi_modules/fastapi_leaudit/domian/Dto/ruleBindingDto.py
git commit -m "feat: add RuleBindingCreateDTO and RuleBindingUpdateDTO"

Task 3: Add RuleBindingVO to ruleVo.py

Files:

  • Modify: fastapi_modules/fastapi_leaudit/domian/vo/ruleVo.py

  • Step 1: Append RuleBindingVO class at end of file

Add after line 49 (after the RuleValidationVO class):



class RuleBindingVO(BaseModel):
    """规则类型绑定响应。"""

    id: int = Field(..., description="绑定ID")
    docTypeId: int = Field(..., description="文档类型ID")
    docTypeCode: str | None = Field(None, description="文档类型编码")
    ruleSetId: int = Field(..., description="规则集ID")
    ruleType: str | None = Field(None, description="规则类型编码(来自关联查询)")
    ruleName: str | None = Field(None, description="规则集名称(来自关联查询)")
    bindingMode: str = Field(..., description="绑定模式: explicit / wildcard / fallback")
    priority: int = Field(0, description="优先级")
    isActive: bool = Field(True, description="是否激活")
    note: str | None = Field(None, description="备注说明")
  • Step 2: Syntax check
cd /home/wren-dev/Porject/leaudit-platform && python -m compileall fastapi_modules/fastapi_leaudit/domian/vo/ruleVo.py

Expected: Compile successful.

  • Step 3: Commit
cd /home/wren-dev/Porject/leaudit-platform
git add fastapi_modules/fastapi_leaudit/domian/vo/ruleVo.py
git commit -m "feat: add RuleBindingVO for rule type bindings response"

Task 4: Add Binding Methods to IRuleService Interface

Files:

  • Modify: fastapi_modules/fastapi_leaudit/services/ruleService.py

  • Step 1: Add import for RuleBindingVO at top of file

Add RuleBindingVO to the existing import block (line 5-10):

from fastapi_modules.fastapi_leaudit.domian.vo.ruleVo import (
    RuleBindingVO,
    RuleContentVO,
    RuleSetVO,
    RuleValidationVO,
    RuleVersionVO,
)
  • Step 2: Add 4 abstract methods before the closing of the class

Add after the Rollback method (before the last blank line of the class):

    @abstractmethod
    async def ListBindings(self, RuleType: str | None = None) -> list[RuleBindingVO]:
        """列出规则类型绑定。可按规则类型过滤。"""
        ...

    @abstractmethod
    async def CreateBinding(
        self,
        DocTypeId: int,
        RuleSetId: int,
        BindingMode: str = "explicit",
        Priority: int = 0,
        DocTypeCode: str | None = None,
        Note: str | None = None,
    ) -> RuleBindingVO:
        """创建规则类型绑定。"""
        ...

    @abstractmethod
    async def UpdateBinding(
        self,
        BindingId: int,
        IsActive: bool | None = None,
        Priority: int | None = None,
        BindingMode: str | None = None,
        Note: str | None = None,
    ) -> RuleBindingVO:
        """更新规则类型绑定。"""
        ...

    @abstractmethod
    async def DeleteBinding(self, BindingId: int) -> None:
        """删除规则类型绑定。"""
        ...
  • Step 3: Syntax check
cd /home/wren-dev/Porject/leaudit-platform && python -m compileall fastapi_modules/fastapi_leaudit/services/ruleService.py

Expected: Compile successful.

  • Step 4: Commit
cd /home/wren-dev/Porject/leaudit-platform
git add fastapi_modules/fastapi_leaudit/services/ruleService.py
git commit -m "feat: add binding CRUD methods to IRuleService interface"

Task 5: Implement Binding Methods in RuleServiceImpl

Files:

  • Modify: fastapi_modules/fastapi_leaudit/services/impl/ruleServiceImpl.py

  • Step 1: Add RuleBindingVO import

Add RuleBindingVO to the import from ruleVo (line 12-17):

from fastapi_modules.fastapi_leaudit.domian.vo.ruleVo import (
    RuleBindingVO,
    RuleContentVO,
    RuleSetVO,
    RuleValidationVO,
    RuleVersionVO,
)
  • Step 2: Add 4 method implementations after Rollback method (before _SwitchVersion)

Insert before the _SwitchVersion method (before line 342):

    async def ListBindings(self, RuleType: str | None = None) -> list[RuleBindingVO]:
        """列出规则类型绑定,可按规则类型过滤。"""
        async with GetAsyncSession() as Session:
            if RuleType:
                Result = await Session.execute(
                    text(
                        """
                        SELECT
                            b.id,
                            b.doc_type_id,
                            b.doc_type_code,
                            b.rule_set_id,
                            b.binding_mode,
                            b.priority,
                            b.is_active,
                            b.note,
                            rs.rule_type,
                            rs.rule_name
                        FROM leaudit_rule_type_bindings b
                        JOIN leaudit_rule_sets rs ON rs.id = b.rule_set_id
                        WHERE rs.rule_type = :rule_type
                          AND rs.delete_time IS NULL
                        ORDER BY b.priority DESC, b.id DESC
                        """
                    ),
                    {"rule_type": RuleType},
                )
            else:
                Result = await Session.execute(
                    text(
                        """
                        SELECT
                            b.id,
                            b.doc_type_id,
                            b.doc_type_code,
                            b.rule_set_id,
                            b.binding_mode,
                            b.priority,
                            b.is_active,
                            b.note,
                            rs.rule_type,
                            rs.rule_name
                        FROM leaudit_rule_type_bindings b
                        JOIN leaudit_rule_sets rs ON rs.id = b.rule_set_id
                        WHERE rs.delete_time IS NULL
                        ORDER BY rs.rule_type, b.priority DESC, b.id DESC
                        """
                    ),
                )
            return [
                RuleBindingVO(
                    id=int(Row["id"]),
                    docTypeId=int(Row["doc_type_id"]),
                    docTypeCode=Row["doc_type_code"],
                    ruleSetId=int(Row["rule_set_id"]),
                    ruleType=Row["rule_type"],
                    ruleName=Row["rule_name"],
                    bindingMode=Row["binding_mode"],
                    priority=int(Row["priority"]),
                    isActive=bool(Row["is_active"]),
                    note=Row["note"],
                )
                for Row in Result.mappings().all()
            ]

    async def CreateBinding(
        self,
        DocTypeId: int,
        RuleSetId: int,
        BindingMode: str = "explicit",
        Priority: int = 0,
        DocTypeCode: str | None = None,
        Note: str | None = None,
    ) -> RuleBindingVO:
        """创建规则类型绑定。"""
        async with GetAsyncSession() as Session:
            RuleSet = await Session.execute(
                text("SELECT id, rule_type, rule_name FROM leaudit_rule_sets WHERE id = :rid AND delete_time IS NULL LIMIT 1"),
                {"rid": RuleSetId},
            )
            if not RuleSet.mappings().first():
                raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "规则集不存在")

            Existing = await Session.execute(
                text(
                    """
                    SELECT id FROM leaudit_rule_type_bindings
                    WHERE doc_type_id = :dtid AND rule_set_id = :rsid
                    LIMIT 1
                    """
                ),
                {"dtid": DocTypeId, "rsid": RuleSetId},
            )
            if Existing.mappings().first():
                raise LeauditException(StatusCodeEnum.HTTP_409_CONFLICT, "该文档类型已绑定此规则集")

            Result = await Session.execute(
                text(
                    """
                    INSERT INTO leaudit_rule_type_bindings (
                        doc_type_id,
                        doc_type_code,
                        rule_set_id,
                        binding_mode,
                        priority,
                        is_active,
                        note
                    ) VALUES (
                        :doc_type_id,
                        :doc_type_code,
                        :rule_set_id,
                        :binding_mode,
                        :priority,
                        true,
                        :note
                    )
                    RETURNING id, doc_type_id, doc_type_code, rule_set_id,
                              binding_mode, priority, is_active, note
                    """
                ),
                {
                    "doc_type_id": DocTypeId,
                    "doc_type_code": DocTypeCode,
                    "rule_set_id": RuleSetId,
                    "binding_mode": BindingMode,
                    "priority": Priority,
                    "note": Note,
                },
            )
            await Session.commit()
            Row = Result.mappings().first()
            RsRow = RuleSet.mappings().first()
            return RuleBindingVO(
                id=int(Row["id"]),
                docTypeId=int(Row["doc_type_id"]),
                docTypeCode=Row["doc_type_code"],
                ruleSetId=int(Row["rule_set_id"]),
                ruleType=RsRow["rule_type"],
                ruleName=RsRow["rule_name"],
                bindingMode=Row["binding_mode"],
                priority=int(Row["priority"]),
                isActive=bool(Row["is_active"]),
                note=Row["note"],
            )

    async def UpdateBinding(
        self,
        BindingId: int,
        IsActive: bool | None = None,
        Priority: int | None = None,
        BindingMode: str | None = None,
        Note: str | None = None,
    ) -> RuleBindingVO:
        """更新规则类型绑定。"""
        async with GetAsyncSession() as Session:
            Existing = await Session.execute(
                text(
                    """
                    SELECT
                        b.id, b.doc_type_id, b.doc_type_code, b.rule_set_id,
                        b.binding_mode, b.priority, b.is_active, b.note,
                        rs.rule_type, rs.rule_name
                    FROM leaudit_rule_type_bindings b
                    JOIN leaudit_rule_sets rs ON rs.id = b.rule_set_id
                    WHERE b.id = :bid
                    LIMIT 1
                    """
                ),
                {"bid": BindingId},
            )
            Row = Existing.mappings().first()
            if not Row:
                raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "绑定记录不存在")

            SetClauses: list[str] = []
            Params: dict[str, object] = {"bid": BindingId}

            if IsActive is not None:
                SetClauses.append("is_active = :is_active")
                Params["is_active"] = IsActive
            if Priority is not None:
                SetClauses.append("priority = :priority")
                Params["priority"] = Priority
            if BindingMode is not None:
                SetClauses.append("binding_mode = :binding_mode")
                Params["binding_mode"] = BindingMode
            if Note is not None:
                SetClauses.append("note = :note")
                Params["note"] = Note

            if SetClauses:
                SetClauses.append("update_time = now()")
                await Session.execute(
                    text(f"UPDATE leaudit_rule_type_bindings SET {', '.join(SetClauses)} WHERE id = :bid"),
                    Params,
                )
                await Session.commit()

            Result = await Session.execute(
                text(
                    """
                    SELECT
                        b.id, b.doc_type_id, b.doc_type_code, b.rule_set_id,
                        b.binding_mode, b.priority, b.is_active, b.note,
                        rs.rule_type, rs.rule_name
                    FROM leaudit_rule_type_bindings b
                    JOIN leaudit_rule_sets rs ON rs.id = b.rule_set_id
                    WHERE b.id = :bid
                    LIMIT 1
                    """
                ),
                {"bid": BindingId},
            )
            Row = Result.mappings().first()
            return RuleBindingVO(
                id=int(Row["id"]),
                docTypeId=int(Row["doc_type_id"]),
                docTypeCode=Row["doc_type_code"],
                ruleSetId=int(Row["rule_set_id"]),
                ruleType=Row["rule_type"],
                ruleName=Row["rule_name"],
                bindingMode=Row["binding_mode"],
                priority=int(Row["priority"]),
                isActive=bool(Row["is_active"]),
                note=Row["note"],
            )

    async def DeleteBinding(self, BindingId: int) -> None:
        """删除规则类型绑定。"""
        async with GetAsyncSession() as Session:
            Result = await Session.execute(
                text("DELETE FROM leaudit_rule_type_bindings WHERE id = :bid"),
                {"bid": BindingId},
            )
            await Session.commit()
            if Result.rowcount == 0:
                raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "绑定记录不存在")
  • Step 2: Syntax check
cd /home/wren-dev/Porject/leaudit-platform && python -m compileall fastapi_modules/fastapi_leaudit/services/impl/ruleServiceImpl.py

Expected: Compile successful.

  • Step 3: Commit
cd /home/wren-dev/Porject/leaudit-platform
git add fastapi_modules/fastapi_leaudit/services/impl/ruleServiceImpl.py
git commit -m "feat: implement binding CRUD in RuleServiceImpl"

Task 6: Add Binding Endpoints to RuleController

Files:

  • Modify: fastapi_modules/fastapi_leaudit/controllers/ruleController.py

  • Step 1: Add imports for new types

Update the imports (lines 3-16) to include binding DTOs and VO:

"""规则管理控制器。"""

from fastapi_common.fastapi_common_web.controller import BaseController
from fastapi_common.fastapi_common_web.domain.responses import Result

from fastapi_modules.fastapi_leaudit.domian.Dto.ruleBindingDto import (
    RuleBindingCreateDTO,
    RuleBindingUpdateDTO,
)
from fastapi_modules.fastapi_leaudit.domian.Dto.rulePublishDto import RulePublishDTO
from fastapi_modules.fastapi_leaudit.domian.Dto.ruleValidateDto import RuleValidateDTO
from fastapi_modules.fastapi_leaudit.domian.Dto.ruleVersionCreateDto import RuleVersionCreateDTO
from fastapi_modules.fastapi_leaudit.domian.vo.ruleVo import (
    RuleBindingVO,
    RuleContentVO,
    RuleSetVO,
    RuleValidationVO,
    RuleVersionVO,
)
from fastapi_modules.fastapi_leaudit.services import IRuleService
from fastapi_modules.fastapi_leaudit.services.impl.ruleServiceImpl import RuleServiceImpl
  • Step 2: Add 4 endpoint definitions inside init

Add after the RollbackRuleVersion endpoint (after line 82, before the closing of __init__):

        # ── Rule Type Bindings ──────────────────────────────────────

        @self.router.get("/bindings", response_model=Result[list[RuleBindingVO]])
        async def ListBindings(ruleType: str | None = None):
            """列出规则类型绑定。可按规则类型过滤。"""
            Data = await self.RuleService.ListBindings(RuleType=ruleType)
            return Result.success(data=Data)

        @self.router.post("/{RuleType}/bindings", response_model=Result[RuleBindingVO])
        async def CreateBinding(RuleType: str, body: RuleBindingCreateDTO):
            """创建规则类型绑定。"""
            Data = await self.RuleService.CreateBinding(
                DocTypeId=body.docTypeId,
                RuleSetId=body.ruleSetId,
                BindingMode=body.bindingMode,
                Priority=body.priority,
                DocTypeCode=body.docTypeCode,
                Note=body.note,
            )
            return Result.success(data=Data)

        @self.router.put("/bindings/{BindingId}", response_model=Result[RuleBindingVO])
        async def UpdateBinding(BindingId: int, body: RuleBindingUpdateDTO):
            """更新规则类型绑定。"""
            Data = await self.RuleService.UpdateBinding(
                BindingId=BindingId,
                IsActive=body.isActive,
                Priority=body.priority,
                BindingMode=body.bindingMode,
                Note=body.note,
            )
            return Result.success(data=Data)

        @self.router.delete("/bindings/{BindingId}", response_model=Result[None])
        async def DeleteBinding(BindingId: int):
            """删除规则类型绑定。"""
            await self.RuleService.DeleteBinding(BindingId=BindingId)
            return Result.success()
  • Step 3: Syntax check
cd /home/wren-dev/Porject/leaudit-platform && python -m compileall fastapi_modules/fastapi_leaudit/controllers/ruleController.py

Expected: Compile successful.

  • Step 4: Commit
cd /home/wren-dev/Porject/leaudit-platform
git add fastapi_modules/fastapi_leaudit/controllers/ruleController.py
git commit -m "feat: add rule type binding CRUD endpoints to RuleController"

Task 7: Verification — Cross-Module Import Check

Files: None (verification only)

  • Step 1: Verify all modified modules compile together
cd /home/wren-dev/Porject/leaudit-platform && python -c "
from fastapi_modules.fastapi_leaudit.domian.Dto.ruleBindingDto import RuleBindingCreateDTO, RuleBindingUpdateDTO
from fastapi_modules.fastapi_leaudit.domian.vo.ruleVo import RuleBindingVO
from fastapi_modules.fastapi_leaudit.services.ruleService import IRuleService
from fastapi_modules.fastapi_leaudit.services.impl.ruleServiceImpl import RuleServiceImpl
from fastapi_modules.fastapi_leaudit.leaudit_bridge.storage_adapter import StorageAdapter
print('All imports OK')
"

Expected: All imports OK

  • Step 2: Verify the double finalize fix — confirm finalize_run is the only terminal state writer
cd /home/wren-dev/Porject/leaudit-platform && grep -n "result_status\|finished_at" fastapi_modules/fastapi_leaudit/leaudit_bridge/storage_adapter.py

Expected output should show result_status and finished_at ONLY in finalize_run and fail_run, NOT in save_evaluation_results.

  • Step 3: Commit final verification
cd /home/wren-dev/Porject/leaudit-platform
git add -A
git diff --cached --stat
git commit -m "chore: verify cross-module imports and finalize consistency"