"""Validate and persist requirement revisions. Never starts planning or a run.""" from __future__ import annotations from typing import Any from uuid import uuid4 from app.report_collaboration.contracts.clarifications import ClarificationRecord from app.report_collaboration.contracts.requirements import ReportRequirementSnapshot from app.report_collaboration.requirement.defaults import COORDINATOR_NAME, DEFAULT_TITLE from app.report_collaboration.requirement.intent_router import PrePlanDecision, route_pre_plan_turn from deerflow.config.app_config import get_app_config from deerflow.persistence.report_collaboration import ReportCollaborationStore from deerflow.persistence.report_collaboration.codec import iso_now def _state_from_session(record: dict[str, Any]) -> dict[str, Any]: raw = record.get("requirement_json") if isinstance(raw, dict) and ("snapshot" in raw or "clarifications" in raw): return { "snapshot": raw.get("snapshot"), "clarifications": list(raw.get("clarifications") or []), "assumptions": list(raw.get("assumptions") or []), "clarification_round": int(raw.get("clarification_round") or 0), "ready_to_plan": bool(raw.get("ready_to_plan")), } if isinstance(raw, dict) and raw.get("topic"): return { "snapshot": raw, "clarifications": [], "assumptions": [], "clarification_round": 0, "ready_to_plan": False, } return {"snapshot": None, "clarifications": [], "assumptions": [], "clarification_round": 0, "ready_to_plan": False} def _dump_state( *, snapshot: ReportRequirementSnapshot, clarifications: list[dict[str, Any]], assumptions: list[str], clarification_round: int, ready_to_plan: bool, ) -> dict[str, Any]: return { "snapshot": snapshot.model_dump(), "clarifications": clarifications, "assumptions": assumptions, "clarification_round": clarification_round, "ready_to_plan": ready_to_plan, } def format_clarification_message(questions: list[str], *, allow_skip: bool = True) -> str: numbered = "\n".join(f"{index}. {question}" for index, question in enumerate(questions, start=1)) suffix = "\n\n也可以直接回复「直接开始」,我会按默认假设记录需求。澄清完成不会自动生成方案。" if allow_skip else "" return f"收到您的报告需求。为了给出最合适的协作方案,请先确认:\n\n{numbered}{suffix}" def format_ready_message(decision: PrePlanDecision) -> str: req = decision.requirement angles = "、".join(req.required_angles) if req.required_angles else "(未指定)" lines = [ f"已写入需求版本 v{req.revision}。", f"主题:{req.topic}", f"读者:{req.audience or '未指定'}", f"角度:{angles}", ] if decision.assumptions: lines.append("默认假设:" + ";".join(decision.assumptions) + "。") lines.append("澄清完成不等于已经生成候选方案,需要显式发起规划后才会出现方案卡。") return "\n".join(lines) def _title_from_topic(topic: str, current: str | None) -> str | None: if current and current != DEFAULT_TITLE: return None cleaned = (topic or "").strip() if not cleaned or cleaned == "未指定主题": return None return cleaned[:40] class RequirementResolver: """Writes requirement revisions and clarification records. Does not enqueue plans.""" def __init__(self, store: ReportCollaborationStore) -> None: self._store = store async def handle_user_message( self, session_id: str, *, user_message: dict[str, Any], idempotency_key: str, max_clarification_rounds: int | None = None, ) -> PrePlanDecision | None: record = await self._store.get_session_record(session_id) if record is None: return None status = str(record.get("status") or "empty") if status not in {"empty", "clarifying"}: return None messages = await self._store.list_messages(session_id) if any((item.get("metadata") or {}).get("preplan_for") == user_message["message_id"] for item in messages): return None state = _state_from_session(record) cfg_rounds = max_clarification_rounds if cfg_rounds is None: cfg_rounds = int(get_app_config().report_collaboration.max_clarification_rounds) decision = route_pre_plan_turn( str(user_message.get("content") or ""), previous_snapshot=state["snapshot"], previous_revision=int(record.get("requirement_revision") or 0), clarification_round=int(state["clarification_round"] or 0), max_clarification_rounds=cfg_rounds, ) await self._apply(session_id, record, state, decision, user_message=user_message, idempotency_key=idempotency_key) return decision async def _apply( self, session_id: str, record: dict[str, Any], state: dict[str, Any], decision: PrePlanDecision, *, user_message: dict[str, Any], idempotency_key: str, ) -> None: clarifications = [dict(item) for item in state["clarifications"]] round_count = int(state["clarification_round"] or 0) if decision.action == "clarify": round_count += 1 for item in clarifications: if not item.get("resolved"): item["resolved"] = True clarification = ClarificationRecord( id=f"clr_{uuid4().hex[:12]}", questions=list(decision.questions), impact=list(decision.impact), resolved=False, created_at=iso_now(), ) clarifications.append(clarification.model_dump()) content = format_clarification_message(decision.questions) metadata = { "preplan_for": user_message["message_id"], "clarification": clarification.model_dump(), } ready = False else: for item in clarifications: item["resolved"] = True content = format_ready_message(decision) metadata = {"preplan_for": user_message["message_id"], "ready_to_plan": True} ready = True payload = _dump_state( snapshot=decision.requirement, clarifications=clarifications, assumptions=list(decision.assumptions), clarification_round=round_count, ready_to_plan=ready, ) await self._store.save_requirement_state( session_id, requirement_json=payload, requirement_revision=decision.requirement.revision, status="clarifying", title=_title_from_topic(decision.requirement.topic, record.get("title")), ) await self._store.create_message( session_id, role="ai", content=content, idempotency_key=f"{idempotency_key}:preplan", name=COORDINATOR_NAME, metadata=metadata, completed=True, )