181 lines
6.4 KiB
Python
181 lines
6.4 KiB
Python
#!/usr/bin/env python3
|
||
"""预览或修复历史 requirement-plan draft/topic 污染。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import asyncio
|
||
from dataclasses import dataclass
|
||
from typing import Any
|
||
|
||
from sqlalchemy import select
|
||
|
||
from app.core.database import AsyncSessionLocal
|
||
from app.models import MountedDraftWorkspace, MountedTopicWorkspace
|
||
from app.services.ai_pmo.ai_pmo_typed_executor_execute.mixin_execute_query_project import ExecuteQueryProjectMixin
|
||
|
||
|
||
@dataclass
|
||
class RepairCandidate:
|
||
draft_id: int
|
||
topic_id: int
|
||
reasons: list[str]
|
||
before_card: dict[str, Any]
|
||
after_card: dict[str, Any]
|
||
before_facts: dict[str, Any]
|
||
after_facts: dict[str, Any]
|
||
before_goals: Any
|
||
after_goals: list[str]
|
||
before_constraints: Any
|
||
after_constraints: dict[str, Any]
|
||
|
||
|
||
def _repair_candidate(
|
||
*,
|
||
draft: MountedDraftWorkspace,
|
||
topic: MountedTopicWorkspace,
|
||
) -> RepairCandidate | None:
|
||
before_card = dict(draft.requirement_card or {}) if isinstance(draft.requirement_card, dict) else {}
|
||
if not before_card:
|
||
return None
|
||
after_card = ExecuteQueryProjectMixin._sanitize_requirement_plan_card_runtime_context(card=before_card)
|
||
before_facts = dict(topic.facts_json or {}) if isinstance(topic.facts_json, dict) else {}
|
||
after_facts = ExecuteQueryProjectMixin._build_requirement_plan_topic_index_facts(card=after_card)
|
||
before_goals = topic.goals_json
|
||
after_goals = [after_card.get("goal")] if str(after_card.get("goal") or "").strip() else []
|
||
before_constraints = topic.constraints_json
|
||
after_constraints = {"first_scope": after_card.get("first_scope")}
|
||
|
||
reasons: list[str] = []
|
||
if after_card != before_card:
|
||
reasons.append("requirement_card")
|
||
if before_facts != after_facts:
|
||
reasons.append("facts_json")
|
||
if before_goals != after_goals:
|
||
reasons.append("goals_json")
|
||
if before_constraints != after_constraints:
|
||
reasons.append("constraints_json")
|
||
if not reasons:
|
||
return None
|
||
return RepairCandidate(
|
||
draft_id=int(draft.id),
|
||
topic_id=int(topic.id),
|
||
reasons=reasons,
|
||
before_card=before_card,
|
||
after_card=after_card,
|
||
before_facts=before_facts,
|
||
after_facts=after_facts,
|
||
before_goals=before_goals,
|
||
after_goals=after_goals,
|
||
before_constraints=before_constraints,
|
||
after_constraints=after_constraints,
|
||
)
|
||
|
||
|
||
async def _load_candidates(
|
||
*,
|
||
tenant_id: int,
|
||
topic_id: int | None,
|
||
draft_id: int | None,
|
||
) -> list[RepairCandidate]:
|
||
async with AsyncSessionLocal() as db:
|
||
stmt = (
|
||
select(MountedDraftWorkspace, MountedTopicWorkspace)
|
||
.join(
|
||
MountedTopicWorkspace,
|
||
MountedTopicWorkspace.id == MountedDraftWorkspace.topic_workspace_id,
|
||
)
|
||
.where(MountedDraftWorkspace.tenant_id == int(tenant_id))
|
||
.order_by(MountedDraftWorkspace.id.asc())
|
||
)
|
||
if topic_id is not None:
|
||
stmt = stmt.where(MountedDraftWorkspace.topic_workspace_id == int(topic_id))
|
||
if draft_id is not None:
|
||
stmt = stmt.where(MountedDraftWorkspace.id == int(draft_id))
|
||
rows = (await db.execute(stmt)).all()
|
||
candidates: list[RepairCandidate] = []
|
||
for draft, topic in rows:
|
||
candidate = _repair_candidate(draft=draft, topic=topic)
|
||
if candidate is not None:
|
||
candidates.append(candidate)
|
||
return candidates
|
||
|
||
|
||
async def _apply_candidates(
|
||
*,
|
||
tenant_id: int,
|
||
topic_id: int | None,
|
||
draft_id: int | None,
|
||
) -> int:
|
||
async with AsyncSessionLocal() as db:
|
||
stmt = (
|
||
select(MountedDraftWorkspace, MountedTopicWorkspace)
|
||
.join(
|
||
MountedTopicWorkspace,
|
||
MountedTopicWorkspace.id == MountedDraftWorkspace.topic_workspace_id,
|
||
)
|
||
.where(MountedDraftWorkspace.tenant_id == int(tenant_id))
|
||
.order_by(MountedDraftWorkspace.id.asc())
|
||
)
|
||
if topic_id is not None:
|
||
stmt = stmt.where(MountedDraftWorkspace.topic_workspace_id == int(topic_id))
|
||
if draft_id is not None:
|
||
stmt = stmt.where(MountedDraftWorkspace.id == int(draft_id))
|
||
rows = (await db.execute(stmt)).all()
|
||
updated = 0
|
||
for draft, topic in rows:
|
||
candidate = _repair_candidate(draft=draft, topic=topic)
|
||
if candidate is None:
|
||
continue
|
||
draft.requirement_card = candidate.after_card
|
||
topic.facts_json = candidate.after_facts
|
||
topic.goals_json = candidate.after_goals
|
||
topic.constraints_json = candidate.after_constraints
|
||
updated += 1
|
||
if updated:
|
||
await db.commit()
|
||
return updated
|
||
|
||
|
||
async def _main() -> None:
|
||
parser = argparse.ArgumentParser(description="Preview/apply requirement-plan history cleanup")
|
||
parser.add_argument("--tenant-id", type=int, default=1, help="目标租户 ID,默认 1")
|
||
parser.add_argument("--topic-id", type=int, default=None, help="仅检查指定 topic_workspace_id")
|
||
parser.add_argument("--draft-id", type=int, default=None, help="仅检查指定 draft_workspace_id")
|
||
parser.add_argument("--apply", action="store_true", help="执行修复写入;默认仅预览")
|
||
args = parser.parse_args()
|
||
|
||
candidates = await _load_candidates(
|
||
tenant_id=args.tenant_id,
|
||
topic_id=args.topic_id,
|
||
draft_id=args.draft_id,
|
||
)
|
||
print(f"[preview] tenant_id={args.tenant_id} candidate_count={len(candidates)}")
|
||
for item in candidates[:20]:
|
||
title = str(item.after_card.get("title") or item.before_card.get("title") or "")[:80]
|
||
print(
|
||
f"- draft_id={item.draft_id} topic_id={item.topic_id} reasons={','.join(item.reasons)} title={title}"
|
||
)
|
||
if len(candidates) > 20:
|
||
print(f"... ({len(candidates) - 20} more)")
|
||
|
||
if not args.apply:
|
||
print("[dry-run] 未执行写入。加 --apply 以修复历史 requirement-plan 污染。")
|
||
return
|
||
|
||
updated = await _apply_candidates(
|
||
tenant_id=args.tenant_id,
|
||
topic_id=args.topic_id,
|
||
draft_id=args.draft_id,
|
||
)
|
||
remaining = await _load_candidates(
|
||
tenant_id=args.tenant_id,
|
||
topic_id=args.topic_id,
|
||
draft_id=args.draft_id,
|
||
)
|
||
print(f"[apply] updated={updated} remaining_candidates={len(remaining)}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
asyncio.run(_main())
|