PMFlow/backend/scripts/repair_requirement_plan_his...

181 lines
6.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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