#!/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())