185 lines
7.2 KiB
Python
185 lines
7.2 KiB
Python
"""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,
|
||
)
|