PMFlow/backend/app/services/ai_pmo/draft_confirm_handler.py

2310 lines
99 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.

"""Draft confirmation/revision/cancel handler for AI PMO write intents."""
from __future__ import annotations
from datetime import datetime, timedelta
from typing import Any, Optional
from . import ai_pmo_kernel_common_core as _common
for _name, _value in vars(_common).items():
if not _name.startswith("__"):
globals()[_name] = _value
from ..ai_steward_service import AIStewardService
from ..mounted_ai_runtime_write_service import MountedAIRuntimeWriteService
from .ai_pmo_typed_executor_execute import mixin_execute_query_project
from .semantic_context.assembler import SemanticContextAssembler
from .semantic_context.gatekeeper import SemanticDecisionGatekeeper
from .semantic_context.semantic_decision_service import SemanticDecisionService
from ..signal.draft_generator_service import DraftGeneratorService
from ...models import MountedDraftWorkspace, MountedTopicWorkspace
from ...models.feishu_reply_outbox import FeishuReplyOutbox
class DraftConfirmHandler:
"""Handle draft decision from Feishu text or inbox API."""
_ACTION_CONFIRM = "confirm"
_ACTION_REVISE = "revise"
_ACTION_REGENERATE = "regenerate"
_ACTION_CANCEL = "cancel"
_ACTION_ANSWER_ONLY = "answer_only"
_ACTION_UNKNOWN = "unknown"
_SEMANTIC_FOLLOWUP_ACTIONS = (
_ACTION_CONFIRM,
_ACTION_REVISE,
_ACTION_REGENERATE,
_ACTION_CANCEL,
_ACTION_ANSWER_ONLY,
)
_EXPLICIT_CONFIRM_COMMANDS = frozenset({"确认", "确认本版", "确认入库", "confirm", "ok", "好的", "好", "yes", "y"})
_EXPLICIT_REVISE_COMMANDS = frozenset({"修改", "修改本版", "修改意见", "修改你的意见", "修改一下"})
_EXPLICIT_REGENERATE_COMMANDS = frozenset({"重出", "重出一版", "再出", "再出一版", "重新出", "重新出一版"})
_EXPLICIT_CANCEL_COMMANDS = frozenset({"取消", "取消本版", "cancel", "不用了", "算了"})
_PROTOCOL_STRIP_RE = re.compile(r"[\s,,。;;::!!??、++]+")
_DEFAULT_DISPLAY_FEEDBACK_REPLY = (
"收到,这是展示形式反馈:卡片可以更完整,聊天直发我会控制得更短;当前草稿内容我先不重起。"
)
@classmethod
def is_explicit_protocol_followup_text(cls, text: str) -> bool:
normalized_text = cls._normalize_protocol_text(text)
if not normalized_text:
return False
return normalized_text in (
cls._normalized_protocol_commands(cls._EXPLICIT_CONFIRM_COMMANDS)
| cls._normalized_protocol_commands(cls._EXPLICIT_REVISE_COMMANDS)
| cls._normalized_protocol_commands(cls._EXPLICIT_REGENERATE_COMMANDS)
| cls._normalized_protocol_commands(cls._EXPLICIT_CANCEL_COMMANDS)
)
@classmethod
def _resolve_explicit_protocol_action(cls, text: str) -> str:
normalized_text = cls._normalize_protocol_text(text)
if not normalized_text:
return cls._ACTION_UNKNOWN
if normalized_text in cls._normalized_protocol_commands(cls._EXPLICIT_CONFIRM_COMMANDS):
return cls._ACTION_CONFIRM
if normalized_text in cls._normalized_protocol_commands(cls._EXPLICIT_REVISE_COMMANDS):
return cls._ACTION_REVISE
if normalized_text in cls._normalized_protocol_commands(cls._EXPLICIT_REGENERATE_COMMANDS):
return cls._ACTION_REGENERATE
if normalized_text in cls._normalized_protocol_commands(cls._EXPLICIT_CANCEL_COMMANDS):
return cls._ACTION_CANCEL
return cls._ACTION_UNKNOWN
@classmethod
def _normalized_protocol_commands(cls, commands: frozenset[str]) -> set[str]:
return {cls._normalize_protocol_text(command) for command in commands if str(command or "").strip()}
@classmethod
def _normalize_protocol_text(cls, value: Any) -> str:
return cls._PROTOCOL_STRIP_RE.sub("", str(value or "")).strip().lower()
def __init__(self, kernel: Any) -> None:
self.kernel = kernel
self.db = kernel.db
self.tenant_id = int(kernel.tenant_id)
self.draft_generator = DraftGeneratorService(self.db, tenant_id=self.tenant_id)
async def handle_text_reply(
self,
*,
message_text: str,
sender: str,
source_system: str,
source_message_id: str,
channel_ref: Optional[str],
source_sender_open_id: Optional[str],
expected_version: Optional[int] = None,
thread_context: Optional[dict[str, Any]] = None,
semantic_followup_override: Optional[dict[str, str]] = None,
) -> dict[str, Any]:
explicit_work_item_id = self._extract_work_item_id(message_text)
normalized_thread_context = self._normalize_thread_context(thread_context)
has_reply_anchor = bool(str(normalized_thread_context.get("reply_to_message_id") or "").strip())
normalized_text = self._normalize_protocol_text(message_text)
target = await self._resolve_pending_draft(
sender=sender,
source_system=source_system,
channel_ref=channel_ref,
source_sender_open_id=source_sender_open_id,
explicit_work_item_id=explicit_work_item_id,
thread_context=thread_context,
)
if target is None:
if explicit_work_item_id is not None or (has_reply_anchor and self.is_explicit_protocol_followup_text(normalized_text)):
return {
"handled": True,
"status": "failed_safe",
"followup_reply": "当前没有可处理草稿。请先在当前会话重新生成最新草稿,再回复“确认”、“修改”、“重出一版”或“取消”。",
"target_work_item_id": None,
"target_state": None,
"target_intent": None,
"project_id": None,
"signal_id": None,
"workflow_route": None,
}
return {
"handled": False,
"status": "ignored",
"followup_reply": "",
"target_work_item_id": None,
"target_state": None,
"target_intent": None,
"project_id": None,
"signal_id": None,
"workflow_route": None,
}
target_work_item, target_ingest = target
action = self._ACTION_UNKNOWN
revision_note = ""
active_mounted = await self._resolve_target_mounted_draft(
sender=sender,
source_system=source_system,
channel_ref=channel_ref,
source_sender_open_id=source_sender_open_id,
thread_context=thread_context,
target_work_item_id=int(target_work_item.id),
)
semantic_followup = (
{
"action": str((semantic_followup_override or {}).get("action") or "").strip().lower(),
"revision_note": str((semantic_followup_override or {}).get("revision_note") or "").strip(),
"assistant_reply": str((semantic_followup_override or {}).get("assistant_reply") or "").strip(),
}
if isinstance(semantic_followup_override, dict)
else await self._resolve_semantic_followup_decision(
message_text=message_text,
source_system=source_system,
thread_context=thread_context,
active_mounted=active_mounted,
)
)
if semantic_followup is not None:
action = str(semantic_followup.get("action") or action).strip().lower() or action
revision_note = str(semantic_followup.get("revision_note") or revision_note).strip()
if action == self._ACTION_ANSWER_ONLY:
return {
"handled": True,
"status": "pending_manual",
"followup_reply": str(semantic_followup.get("assistant_reply") or "").strip()
or self._DEFAULT_DISPLAY_FEEDBACK_REPLY,
"target_work_item_id": int(target_work_item.id),
"target_state": str(target_work_item.state or "").strip() or None,
"target_intent": str(target_work_item.intent or "").strip() or None,
"project_id": _safe_int(target_work_item.project_id),
"signal_id": _safe_int(target_work_item.signal_id),
"workflow_route": RouteResolver.normalize_route_key(str(target_work_item.intent or "")),
}
if action == self._ACTION_UNKNOWN:
action = self._resolve_explicit_protocol_action(message_text)
if action == self._ACTION_CONFIRM:
return await self.confirm_draft(
target_work_item=target_work_item,
target_ingest=target_ingest,
sender=sender,
source_system=source_system,
source_message_id=source_message_id,
message_text=message_text,
expected_version=expected_version,
)
if action == self._ACTION_REVISE:
return await self.revise_draft(
target_work_item=target_work_item,
target_ingest=target_ingest,
revision_note=revision_note,
expected_version=expected_version,
)
if action == self._ACTION_REGENERATE:
return await self.regenerate_draft(
target_work_item=target_work_item,
target_ingest=target_ingest,
message_text=message_text,
expected_version=expected_version,
)
if action == self._ACTION_CANCEL:
return await self.cancel_draft(
target_work_item=target_work_item,
reason="用户取消草稿",
)
return {
"handled": True,
"status": "pending_manual",
"followup_reply": "当前正在处理这版待确认草稿。请回复“确认”、“修改 + 你的意见”、“重出一版”或“取消”来处理当前草稿。",
"target_work_item_id": int(target_work_item.id),
"target_state": str(target_work_item.state or "").strip() or None,
"target_intent": str(target_work_item.intent or "").strip() or None,
"project_id": _safe_int(target_work_item.project_id),
"signal_id": _safe_int(target_work_item.signal_id),
"workflow_route": RouteResolver.normalize_route_key(str(target_work_item.intent or "")),
}
async def _resolve_target_mounted_draft(
self,
*,
sender: str,
source_system: str,
channel_ref: Optional[str],
source_sender_open_id: Optional[str],
thread_context: Optional[dict[str, Any]],
target_work_item_id: int,
) -> Optional[tuple[MountedDraftWorkspace, MountedTopicWorkspace]]:
if not hasattr(self.db, "execute"):
return None
active_mounted = await self._resolve_active_mounted_draft(
sender=sender,
source_system=source_system,
channel_ref=channel_ref,
source_sender_open_id=source_sender_open_id,
thread_context=thread_context,
)
if active_mounted is not None:
return active_mounted
return await self._resolve_mounted_draft_by_work_item_id(work_item_id=int(target_work_item_id))
async def _resolve_semantic_followup_decision(
self,
*,
message_text: str,
source_system: str,
thread_context: Optional[dict[str, Any]],
active_mounted: Optional[tuple[MountedDraftWorkspace, MountedTopicWorkspace]],
) -> Optional[dict[str, str]]:
if active_mounted is None:
return None
_, mounted_topic = active_mounted
try:
packet = await SemanticContextAssembler(self.db).assemble(
tenant_id=int(self.tenant_id),
operator_user_id=0,
topic_workspace_id=int(mounted_topic.id),
latest_user_message=message_text,
ingress_context={
"candidate_kind": "draft_followup",
"source_system": source_system,
"reply_anchor_present": bool(self._normalize_thread_context(thread_context)),
"allowed_followup_actions": list(self._SEMANTIC_FOLLOWUP_ACTIONS),
"thread_context": self._normalize_thread_context(thread_context),
},
)
decision = await SemanticDecisionService(llm_service).decide(packet=packet)
followup_action = str(getattr(decision, "followup_action", "") or "").strip().lower()
if followup_action not in self._SEMANTIC_FOLLOWUP_ACTIONS:
return None
gate_result = SemanticDecisionGatekeeper.evaluate(
mode_card=packet.mode_card,
decision=decision,
)
if not bool(gate_result.accepted):
return None
allowed_actions = {
str(item).strip()
for item in (getattr(packet.mode_card, "allowed_actions", None) or [])
if str(item or "").strip()
}
action_code = (
"draft.followup.answer_only"
if followup_action == self._ACTION_ANSWER_ONLY
else f"draft.followup.{followup_action}"
)
if action_code not in allowed_actions:
return None
revision_note = str(getattr(decision, "revision_note", "") or "").strip()
assistant_reply = str(getattr(decision, "assistant_reply", "") or "").strip()
except Exception as exc:
logger.warning(
"draft_confirm_semantic_followup_failed",
tenant_id=self.tenant_id,
topic_workspace_id=_safe_int(getattr(mounted_topic, "id", None)),
error=str(exc),
)
return None
if followup_action == self._ACTION_REVISE and not revision_note:
return {
"action": self._ACTION_REVISE,
"revision_note": "",
"assistant_reply": assistant_reply,
}
if followup_action == self._ACTION_ANSWER_ONLY and not assistant_reply:
assistant_reply = self._DEFAULT_DISPLAY_FEEDBACK_REPLY
return {
"action": followup_action,
"revision_note": revision_note,
"assistant_reply": assistant_reply,
}
async def _resolve_mounted_draft_by_work_item_id(
self,
*,
work_item_id: int,
) -> Optional[tuple[MountedDraftWorkspace, MountedTopicWorkspace]]:
if not hasattr(self.db, "execute"):
return None
row = (
await self.db.execute(
select(MountedDraftWorkspace, MountedTopicWorkspace)
.join(
MountedTopicWorkspace,
MountedTopicWorkspace.id == MountedDraftWorkspace.topic_workspace_id,
)
.where(
and_(
MountedDraftWorkspace.tenant_id == int(self.tenant_id),
MountedTopicWorkspace.tenant_id == int(self.tenant_id),
MountedDraftWorkspace.active_work_item_id == int(work_item_id),
MountedTopicWorkspace.status == "active",
)
)
.order_by(MountedDraftWorkspace.id.desc())
.limit(1)
)
).first()
if row is None:
return None
return row
async def handle_api_decision(
self,
*,
work_item_id: int,
action: str,
operator_sender: str,
source_message_id: Optional[str],
revision_note: Optional[str],
draft_version: Optional[int],
) -> dict[str, Any]:
row = (
await self.db.execute(
select(WorkItem, IngestEvent)
.join(IngestEvent, IngestEvent.id == WorkItem.ingest_event_id)
.where(
and_(
WorkItem.tenant_id == int(self.tenant_id),
WorkItem.id == int(work_item_id),
)
)
.limit(1)
)
).first()
if row is None:
return {
"handled": False,
"status": "failed_safe",
"followup_reply": "草稿不存在或已被删除。",
"target_work_item_id": int(work_item_id),
"target_state": None,
"target_intent": None,
"project_id": None,
"signal_id": None,
"workflow_route": None,
}
target_work_item, target_ingest = row
normalized_action = str(action or "").strip().lower()
normalized_source_message_id = (
str(source_message_id or "").strip()
or f"draft-api-{normalized_action}-{int(work_item_id)}-{int(datetime.now().timestamp() * 1000)}"
)[:128]
if normalized_action == self._ACTION_CONFIRM:
return await self.confirm_draft(
target_work_item=target_work_item,
target_ingest=target_ingest,
sender=operator_sender,
source_system="ai_pmo_ui",
source_message_id=normalized_source_message_id,
message_text="确认",
expected_version=draft_version,
)
if normalized_action == self._ACTION_REVISE:
return await self.revise_draft(
target_work_item=target_work_item,
target_ingest=target_ingest,
revision_note=str(revision_note or "").strip(),
expected_version=draft_version,
)
if normalized_action == self._ACTION_CANCEL:
return await self.cancel_draft(
target_work_item=target_work_item,
reason="用户在消息台取消草稿",
)
return {
"handled": False,
"status": "failed_safe",
"followup_reply": "不支持的草稿操作。",
"target_work_item_id": int(work_item_id),
"target_state": str(target_work_item.state or "").strip() or None,
"target_intent": str(target_work_item.intent or "").strip() or None,
"project_id": _safe_int(target_work_item.project_id),
"signal_id": _safe_int(target_work_item.signal_id),
"workflow_route": RouteResolver.normalize_route_key(str(target_work_item.intent or "")),
}
async def confirm_draft(
self,
*,
target_work_item: WorkItem,
target_ingest: IngestEvent,
sender: str,
source_system: str,
source_message_id: str,
message_text: str,
expected_version: Optional[int],
) -> dict[str, Any]:
locked = (
await self.db.execute(
select(WorkItem)
.where(
and_(
WorkItem.tenant_id == int(self.tenant_id),
WorkItem.id == int(target_work_item.id),
)
)
.with_for_update()
.limit(1)
)
).scalar_one_or_none()
if locked is None:
return {
"handled": False,
"status": "failed_safe",
"followup_reply": "草稿不存在或已被删除。",
"target_work_item_id": int(target_work_item.id),
"target_state": None,
"target_intent": None,
"project_id": None,
"signal_id": None,
"workflow_route": None,
}
state = str(locked.state or "").strip()
if state != STATE_DRAFT_PENDING_CONFIRM:
if state == STATE_SUCCEEDED:
followup_reply = "已入库成功,无需重复确认。"
elif state == STATE_CANCELLED:
followup_reply = "该草稿已取消,无需重复确认。"
else:
followup_reply = "该草稿已处理,无需重复确认。"
return {
"handled": True,
"status": "succeeded" if state == STATE_SUCCEEDED else "failed_safe",
"followup_reply": followup_reply,
"target_work_item_id": int(locked.id),
"target_state": state or None,
"target_intent": str(locked.intent or "").strip() or None,
"project_id": _safe_int(locked.project_id),
"signal_id": _safe_int(locked.signal_id),
"workflow_route": RouteResolver.normalize_route_key(str(locked.intent or "")),
}
current_version = int(locked.draft_version or 0)
if expected_version is not None and int(expected_version) != current_version:
return {
"handled": True,
"status": "pending_manual",
"followup_reply": f"草稿版本已更新,请基于最新版本 v{current_version} 再确认。",
"target_work_item_id": int(locked.id),
"target_state": state,
"target_intent": str(locked.intent or "").strip() or None,
"project_id": _safe_int(locked.project_id),
"signal_id": _safe_int(locked.signal_id),
"workflow_route": RouteResolver.normalize_route_key(str(locked.intent or "")),
}
now_at = _utc_now_naive()
if isinstance(locked.draft_expires_at, datetime) and locked.draft_expires_at <= now_at:
locked.state = STATE_CANCELLED
locked.execution_result = {
**(locked.execution_result if isinstance(locked.execution_result, dict) else {}),
"status": "cancelled",
"cancel_reason": "draft_expired",
"cancelled_at": now_at.isoformat(),
}
await self._sync_decision_outcome(
work_item_id=int(locked.id),
execution_result=locked.execution_result,
final_outcome=STATE_CANCELLED,
)
await self._release_mounted_draft_binding(
work_item_id=int(locked.id),
final_state=STATE_CANCELLED,
)
return {
"handled": True,
"status": "cancelled",
"followup_reply": "⏰ 草稿已超时取消,请重新发送需求。",
"target_work_item_id": int(locked.id),
"target_state": STATE_CANCELLED,
"target_intent": str(locked.intent or "").strip() or None,
"project_id": _safe_int(locked.project_id),
"signal_id": _safe_int(locked.signal_id),
"workflow_route": RouteResolver.normalize_route_key(str(locked.intent or "")),
}
existing_confirm_id = str(locked.confirm_message_id or "").strip()
if existing_confirm_id and existing_confirm_id != str(source_message_id or "").strip():
return {
"handled": True,
"status": "failed_safe",
"followup_reply": "该草稿已被其他确认消息处理,请刷新状态后查看结果。",
"target_work_item_id": int(locked.id),
"target_state": str(locked.state or "").strip() or None,
"target_intent": str(locked.intent or "").strip() or None,
"project_id": _safe_int(locked.project_id),
"signal_id": _safe_int(locked.signal_id),
"workflow_route": RouteResolver.normalize_route_key(str(locked.intent or "")),
}
locked.confirm_message_id = str(source_message_id or "").strip()[:128] or None
locked.state = STATE_EXECUTING
locked.reply_template_key = "action_processing"
await self.db.flush()
route_key = RouteResolver.normalize_route_key(str(locked.intent or ""))
_, route_action = RouteResolver._split_route_key(route_key)
draft_payload = locked.draft_content if isinstance(locked.draft_content, dict) else {}
existing_execution = locked.execution_result if isinstance(locked.execution_result, dict) else {}
canonical_intent = RouteResolver.canonical_intent_for_route(route_key)
creator_user_id = _safe_int(getattr(locked, "owner_user_id", None))
if creator_user_id is None and isinstance(target_ingest.payload, dict):
creator_user_id = _safe_int(target_ingest.payload.get("owner_user_id"))
project_payload: Optional[dict[str, Any]] = None
signal_source_count = 0
try:
if canonical_intent == INTENT_PROJECT_CREATE:
project_payload = await self._persist_project_from_draft(
work_item=locked,
draft=draft_payload,
source_system=source_system,
source_message_id=source_message_id,
sender=sender,
message_text=message_text,
creator_user_id=creator_user_id,
)
signal_source_count = len(
project_payload.get("signal_sources")
if isinstance(project_payload.get("signal_sources"), list)
else []
)
signal = project_payload.get("signal") if isinstance(project_payload.get("signal"), dict) else None
else:
signal = await self._persist_signal_from_draft(
work_item=locked,
draft=draft_payload,
source_system=source_system,
source_message_id=source_message_id,
sender=sender,
message_text=message_text,
)
except Exception as exc:
locked.state = STATE_FAILED
locked.draft_expires_at = None
locked.reply_template_key = "safe_error"
error_message = f"草稿确认入库失败:{str(exc)[:240]}"
locked.execution_result = {
**existing_execution,
"status": "failed_safe",
"workflow_route": route_key,
"workflow_action": route_action,
"error_message": error_message,
"committed_from_draft": False,
}
await self._sync_decision_outcome(
work_item_id=int(locked.id),
execution_result=locked.execution_result,
final_outcome=STATE_FAILED,
)
await self._release_mounted_draft_binding(
work_item_id=int(locked.id),
final_state=STATE_FAILED,
)
await self.db.flush()
return {
"handled": True,
"status": "failed_safe",
"followup_reply": error_message,
"target_work_item_id": int(locked.id),
"target_state": STATE_FAILED,
"target_intent": str(locked.intent or "").strip() or None,
"project_id": _safe_int(locked.project_id),
"signal_id": _safe_int(locked.signal_id),
"workflow_route": route_key,
}
project_data = project_payload.get("project") if isinstance(project_payload, dict) and isinstance(project_payload.get("project"), dict) else {}
project_id = _safe_int(project_payload.get("project_id")) if isinstance(project_payload, dict) else None
if project_id is None:
project_id = _safe_int(project_data.get("id"))
signal_id = _safe_int(project_payload.get("signal_id")) if isinstance(project_payload, dict) else None
if signal_id is None and isinstance(signal, dict):
signal_id = _safe_int(signal.get("id"))
if project_id is not None:
locked.project_id = int(project_id)
if signal_id is not None:
locked.signal_id = int(signal_id)
locked.state = STATE_SUCCEEDED
locked.draft_expires_at = None
locked.reply_template_key = "action_succeeded"
reply_text = self._build_confirm_success_text(
draft=draft_payload,
signal_id=_safe_int(locked.signal_id),
project_id=_safe_int(locked.project_id),
signal_source_count=signal_source_count,
task_generation=project_payload.get("task_generation") if isinstance(project_payload, dict) else None,
)
locked.execution_result = {
**existing_execution,
"status": "succeeded",
"workflow_route": route_key,
"workflow_action": route_action,
"signal_id": _safe_int(locked.signal_id),
"project_id": _safe_int(locked.project_id),
"signal_source_count": signal_source_count,
"signal_sources": project_payload.get("signal_sources") if isinstance(project_payload, dict) else [],
"task_generation": project_payload.get("task_generation") if isinstance(project_payload, dict) else None,
"created_project": project_payload.get("project") if isinstance(project_payload, dict) else None,
"created_signal": project_payload.get("signal") if isinstance(project_payload, dict) else None,
"draft_version": current_version,
"followup_reply": reply_text,
"committed_from_draft": True,
}
await self._sync_decision_outcome(
work_item_id=int(locked.id),
execution_result=locked.execution_result,
final_outcome=STATE_SUCCEEDED,
)
await self._release_mounted_draft_binding(
work_item_id=int(locked.id),
final_state=STATE_SUCCEEDED,
)
await self.db.flush()
return {
"handled": True,
"status": "succeeded",
"followup_reply": reply_text,
"target_work_item_id": int(locked.id),
"target_state": STATE_SUCCEEDED,
"target_intent": str(locked.intent or "").strip() or None,
"project_id": _safe_int(locked.project_id),
"signal_id": _safe_int(locked.signal_id),
"workflow_route": route_key,
}
async def revise_draft(
self,
*,
target_work_item: WorkItem,
target_ingest: IngestEvent,
revision_note: str,
expected_version: Optional[int],
) -> dict[str, Any]:
return await self._rerender_draft(
target_work_item=target_work_item,
target_ingest=target_ingest,
rewrite_note=str(revision_note or "").strip(),
expected_version=expected_version,
require_note=True,
missing_note_reply="请先补充修改意见再继续,例如:修改 目标客户改成北美 DTC 品牌。",
invalid_state_reply="当前草稿已不在待确认状态,无法再修改。",
version_conflict_reply_prefix="草稿版本已更新,请刷新后基于",
)
async def regenerate_draft(
self,
*,
target_work_item: WorkItem,
target_ingest: IngestEvent,
message_text: str,
expected_version: Optional[int],
) -> dict[str, Any]:
regenerate_note = self._build_regenerate_revision_note(message_text=message_text)
return await self._rerender_draft(
target_work_item=target_work_item,
target_ingest=target_ingest,
rewrite_note=regenerate_note,
expected_version=expected_version,
require_note=False,
missing_note_reply="",
invalid_state_reply="当前草稿已不在待确认状态,无法再重出一版。",
version_conflict_reply_prefix="草稿版本已更新,请刷新后基于",
)
async def _rerender_draft(
self,
*,
target_work_item: WorkItem,
target_ingest: IngestEvent,
rewrite_note: str,
expected_version: Optional[int],
require_note: bool,
missing_note_reply: str,
invalid_state_reply: str,
version_conflict_reply_prefix: str,
) -> dict[str, Any]:
locked = (
await self.db.execute(
select(WorkItem)
.where(
and_(
WorkItem.tenant_id == int(self.tenant_id),
WorkItem.id == int(target_work_item.id),
)
)
.with_for_update()
.limit(1)
)
).scalar_one_or_none()
if locked is None:
return {
"handled": False,
"status": "failed_safe",
"followup_reply": "草稿不存在或已被删除。",
"target_work_item_id": int(target_work_item.id),
"target_state": None,
"target_intent": None,
"project_id": None,
"signal_id": None,
"workflow_route": None,
}
state = str(locked.state or "").strip()
if state != STATE_DRAFT_PENDING_CONFIRM:
return {
"handled": True,
"status": "failed_safe",
"followup_reply": invalid_state_reply,
"target_work_item_id": int(locked.id),
"target_state": state or None,
"target_intent": str(locked.intent or "").strip() or None,
"project_id": _safe_int(locked.project_id),
"signal_id": _safe_int(locked.signal_id),
"workflow_route": RouteResolver.normalize_route_key(str(locked.intent or "")),
}
current_version = int(locked.draft_version or 0)
if expected_version is not None and int(expected_version) != current_version:
return {
"handled": True,
"status": "pending_manual",
"followup_reply": f"{version_conflict_reply_prefix} v{current_version} 再继续。",
"target_work_item_id": int(locked.id),
"target_state": state,
"target_intent": str(locked.intent or "").strip() or None,
"project_id": _safe_int(locked.project_id),
"signal_id": _safe_int(locked.signal_id),
"workflow_route": RouteResolver.normalize_route_key(str(locked.intent or "")),
}
if require_note and not str(rewrite_note or "").strip():
return {
"handled": True,
"status": "draft_pending_confirm",
"followup_reply": missing_note_reply,
"target_work_item_id": int(locked.id),
"target_state": STATE_DRAFT_PENDING_CONFIRM,
"target_intent": str(locked.intent or "").strip() or None,
"project_id": _safe_int(locked.project_id),
"signal_id": _safe_int(locked.signal_id),
"workflow_route": RouteResolver.normalize_route_key(str(locked.intent or "")),
"draft_version": current_version,
"draft_content": locked.draft_content if isinstance(locked.draft_content, dict) else None,
}
ingest_payload = target_ingest.payload if isinstance(target_ingest.payload, dict) else {}
original_message = str(ingest_payload.get("message_text") or "").strip()
if not original_message:
original_message = str((locked.draft_content or {}).get("original_text") or "").strip()
intent_name = RouteResolver.canonical_intent_for_route(RouteResolver.normalize_route_key(str(locked.intent or "")))
if self._is_requirement_plan_draft_payload(locked.draft_content):
generated = await self._revise_requirement_plan_draft(
locked=locked,
target_ingest=target_ingest,
revision_note=str(rewrite_note or "").strip(),
)
else:
generated = await self.draft_generator.generate_and_persist(
work_item=locked,
intent_name=intent_name,
message_text=original_message,
world_model=locked.context_snapshot if isinstance(getattr(locked, "context_snapshot", None), dict) else {},
revision_note=str(rewrite_note or "").strip(),
)
locked.state = STATE_DRAFT_PENDING_CONFIRM
locked.reply_template_key = "draft_pending_confirm"
await self.db.flush()
draft_text = str(generated.get("draft_text") or "").strip()
if not draft_text:
draft_text = DraftGeneratorService.render_draft_message(
locked.draft_content if isinstance(locked.draft_content, dict) else {}
)
followup_reply = "🔄 正在重新起草,请稍候...\n\n" + draft_text
return {
"handled": True,
"status": "draft_pending_confirm",
"followup_reply": followup_reply,
"target_work_item_id": int(locked.id),
"target_state": STATE_DRAFT_PENDING_CONFIRM,
"target_intent": str(locked.intent or "").strip() or None,
"project_id": _safe_int(locked.project_id),
"signal_id": _safe_int(locked.signal_id),
"workflow_route": RouteResolver.normalize_route_key(str(locked.intent or "")),
"draft_version": int(locked.draft_version or 0),
"draft_content": locked.draft_content if isinstance(locked.draft_content, dict) else None,
}
@staticmethod
def _build_regenerate_revision_note(*, message_text: str) -> str:
normalized_text = str(message_text or "").strip()
if normalized_text:
return (
"请基于当前已经收敛出的事实重出一版草稿,不新增虚构事实,不把展示层反馈误当成字段修改。"
f"用户原话:{normalized_text}"
)
return "请基于当前已经收敛出的事实重出一版草稿,不新增虚构事实,只重写组织方式和表达。"
@staticmethod
def _is_requirement_plan_draft_payload(draft: Any) -> bool:
return isinstance(draft, dict) and str(draft.get("draft_source") or "").strip() == "requirement_plan"
async def _revise_requirement_plan_draft(
self,
*,
locked: WorkItem,
target_ingest: IngestEvent,
revision_note: str,
) -> dict[str, Any]:
draft_payload = locked.draft_content if isinstance(locked.draft_content, dict) else {}
base_card = draft_payload.get("requirement_card") if isinstance(draft_payload.get("requirement_card"), dict) else {}
if not base_card:
raise RuntimeError("requirement_plan_draft_missing_card")
runtime = _RequirementPlanDraftRuntime(self.db, tenant_id=int(self.tenant_id))
merged_card = dict(base_card)
merged_card.update(runtime._extract_requirement_plan_updates(message_text=revision_note, card=merged_card))
if not str(merged_card.get("title") or "").strip():
merged_card["title"] = runtime._derive_requirement_plan_title(message_text=revision_note, card=merged_card)
if not str(merged_card.get("goal") or "").strip():
merged_card["goal"] = runtime._derive_requirement_plan_goal(message_text=revision_note, card=merged_card)
rendered_text, _, project_creation_draft = await runtime._generate_requirement_plan_confirmation_draft(
card=merged_card,
revision_note=revision_note,
)
next_version = int(locked.draft_version or 0) + 1
locked.draft_content = dict(project_creation_draft)
locked.draft_version = next_version
locked.draft_expires_at = _utc_now_naive() + timedelta(hours=24)
locked.execution_result = {
**(locked.execution_result if isinstance(locked.execution_result, dict) else {}),
"status": "draft_pending_confirm",
"draft": dict(project_creation_draft),
"draft_version": next_version,
"draft_expires_at": locked.draft_expires_at.isoformat() if locked.draft_expires_at else None,
"revision_note": str(revision_note or "")[:500] or None,
}
mounted_draft = (
await self.db.execute(
select(MountedDraftWorkspace)
.where(
and_(
MountedDraftWorkspace.tenant_id == int(self.tenant_id),
MountedDraftWorkspace.active_work_item_id == int(locked.id),
)
)
.order_by(MountedDraftWorkspace.id.desc())
.limit(1)
)
).scalar_one_or_none()
if mounted_draft is not None:
mounted_draft.state = "ready"
mounted_draft.draft_version = next_version
mounted_draft.requirement_card = dict(merged_card)
mounted_draft.draft_payload = dict(project_creation_draft)
mounted_draft.last_ready_at = _utc_now_naive()
return {
"draft": dict(project_creation_draft),
"draft_text": rendered_text,
"draft_version": next_version,
}
async def cancel_draft(
self,
*,
target_work_item: WorkItem,
reason: str,
) -> dict[str, Any]:
locked = (
await self.db.execute(
select(WorkItem)
.where(
and_(
WorkItem.tenant_id == int(self.tenant_id),
WorkItem.id == int(target_work_item.id),
)
)
.with_for_update()
.limit(1)
)
).scalar_one_or_none()
if locked is None:
return {
"handled": False,
"status": "failed_safe",
"followup_reply": "草稿不存在或已被删除。",
"target_work_item_id": int(target_work_item.id),
"target_state": None,
"target_intent": None,
"project_id": None,
"signal_id": None,
"workflow_route": None,
}
if str(locked.state or "").strip() == STATE_CANCELLED:
return {
"handled": True,
"status": "cancelled",
"followup_reply": "已取消,如需重新提交请再次发送。",
"target_work_item_id": int(locked.id),
"target_state": STATE_CANCELLED,
"target_intent": str(locked.intent or "").strip() or None,
"project_id": _safe_int(locked.project_id),
"signal_id": _safe_int(locked.signal_id),
"workflow_route": RouteResolver.normalize_route_key(str(locked.intent or "")),
}
if str(locked.state or "").strip() != STATE_DRAFT_PENDING_CONFIRM:
return {
"handled": True,
"status": "failed_safe",
"followup_reply": "当前草稿已不在待确认状态,无法取消。",
"target_work_item_id": int(locked.id),
"target_state": str(locked.state or "").strip() or None,
"target_intent": str(locked.intent or "").strip() or None,
"project_id": _safe_int(locked.project_id),
"signal_id": _safe_int(locked.signal_id),
"workflow_route": RouteResolver.normalize_route_key(str(locked.intent or "")),
}
locked.state = STATE_CANCELLED
locked.draft_expires_at = None
locked.reply_template_key = "draft_cancelled"
locked.execution_result = {
**(locked.execution_result if isinstance(locked.execution_result, dict) else {}),
"status": "cancelled",
"cancel_reason": str(reason or "用户取消草稿")[:120],
"cancelled_at": _utc_now_naive().isoformat(),
}
await self._sync_decision_outcome(
work_item_id=int(locked.id),
execution_result=locked.execution_result,
final_outcome=STATE_CANCELLED,
)
await self._release_mounted_draft_binding(
work_item_id=int(locked.id),
final_state=STATE_CANCELLED,
)
await self.db.flush()
return {
"handled": True,
"status": "cancelled",
"followup_reply": "已取消,如需重新提交请再次发送。",
"target_work_item_id": int(locked.id),
"target_state": STATE_CANCELLED,
"target_intent": str(locked.intent or "").strip() or None,
"project_id": _safe_int(locked.project_id),
"signal_id": _safe_int(locked.signal_id),
"workflow_route": RouteResolver.normalize_route_key(str(locked.intent or "")),
}
async def _resolve_pending_draft(
self,
*,
sender: str,
source_system: str,
channel_ref: Optional[str],
source_sender_open_id: Optional[str],
explicit_work_item_id: Optional[int],
thread_context: Optional[dict[str, Any]],
) -> Optional[tuple[WorkItem, IngestEvent]]:
if explicit_work_item_id is not None:
return await self._load_work_item_target_by_id(work_item_id=int(explicit_work_item_id))
anchor_context = self._normalize_thread_context(thread_context)
active_mounted = await self._resolve_active_mounted_draft(
sender=sender,
source_system=source_system,
channel_ref=channel_ref,
source_sender_open_id=source_sender_open_id,
thread_context=thread_context,
)
if active_mounted is not None:
mounted_draft, _ = active_mounted
active_work_item_id = _safe_int(getattr(mounted_draft, "active_work_item_id", None))
if active_work_item_id is not None:
target = await self._load_work_item_target_by_id(work_item_id=int(active_work_item_id))
if target is not None and str(getattr(target[0], "state", "") or "").strip() == STATE_DRAFT_PENDING_CONFIRM:
return target if anchor_context else None
await self._release_mounted_draft_binding(
work_item_id=int(active_work_item_id),
final_state=str(getattr(target[0], "state", "") or "").strip() if target is not None else "missing",
mounted=active_mounted,
)
if not anchor_context:
return None
stmt = (
select(WorkItem, IngestEvent)
.join(IngestEvent, IngestEvent.id == WorkItem.ingest_event_id)
.where(
and_(
WorkItem.tenant_id == int(self.tenant_id),
WorkItem.state == STATE_DRAFT_PENDING_CONFIRM,
)
)
.order_by(WorkItem.id.desc())
.limit(30)
)
rows = (await self.db.execute(stmt)).all()
normalized_sender = str(sender or "").strip()
normalized_channel = str(channel_ref or "").strip()
normalized_open_id = str(source_sender_open_id or "").strip()
sender_scope_candidates: list[tuple[WorkItem, IngestEvent]] = []
for work_item, ingest in rows:
payload = ingest.payload if isinstance(ingest.payload, dict) else {}
same_sender = normalized_sender and str(payload.get("sender") or "").strip() == normalized_sender
same_channel = normalized_channel and str(payload.get("channel_ref") or "").strip() == normalized_channel
same_open_id = normalized_open_id and str(payload.get("source_sender_open_id") or "").strip() == normalized_open_id
if str(source_system or "").strip().lower() == "feishu":
if not (same_open_id or same_channel or same_sender):
continue
else:
if not (same_sender or same_channel):
continue
sender_scope_candidates.append((work_item, ingest))
source_message_ids = sorted(
{
str(getattr(work_item, "source_message_id", "") or "").strip()
for work_item, _ in sender_scope_candidates
if str(getattr(work_item, "source_message_id", "") or "").strip()
}
)
sent_message_id_map: dict[str, str] = {}
if source_message_ids:
outbox_rows = (
await self.db.execute(
select(
FeishuReplyOutbox.source_message_id,
FeishuReplyOutbox.sent_message_id,
)
.where(
and_(
FeishuReplyOutbox.tenant_id == int(self.tenant_id),
FeishuReplyOutbox.source_message_id.in_(source_message_ids),
FeishuReplyOutbox.status == "sent",
FeishuReplyOutbox.sent_message_id.is_not(None),
)
)
.order_by(FeishuReplyOutbox.id.desc())
)
).all()
for source_message_id, sent_message_id in outbox_rows:
source_key = str(source_message_id or "").strip()
sent_key = str(sent_message_id or "").strip()
if source_key and sent_key and source_key not in sent_message_id_map:
sent_message_id_map[source_key] = sent_key
candidates: list[tuple[WorkItem, IngestEvent]] = []
for work_item, ingest in sender_scope_candidates:
if not self._matches_thread_anchor(
work_item=work_item,
ingest=ingest,
anchor_context=anchor_context,
draft_reply_message_id=sent_message_id_map.get(str(getattr(work_item, "source_message_id", "") or "").strip()),
):
continue
candidates.append((work_item, ingest))
if not candidates:
if active_mounted is not None:
return await self._materialize_retryable_pending_draft_from_mounted(
mounted=active_mounted,
sender=sender,
source_system=source_system,
channel_ref=channel_ref,
source_sender_open_id=source_sender_open_id,
thread_context=thread_context,
)
return None
if active_mounted is not None:
mounted_draft, _ = active_mounted
preferred_source_message_id = str(
getattr(mounted_draft, "latest_source_message_id", "") or ""
).strip()
preferred_reply_message_id = str(
getattr(mounted_draft, "latest_reply_message_id", "") or ""
).strip()
if preferred_source_message_id:
for candidate in candidates:
candidate_work_item, _ = candidate
candidate_source_message_id = str(
getattr(candidate_work_item, "source_message_id", "") or ""
).strip()
if candidate_source_message_id == preferred_source_message_id:
return candidate
elif preferred_reply_message_id:
for candidate in candidates:
candidate_work_item, _ = candidate
candidate_source_message_id = str(
getattr(candidate_work_item, "source_message_id", "") or ""
).strip()
candidate_reply_message_id = str(
sent_message_id_map.get(candidate_source_message_id) or ""
).strip()
if candidate_reply_message_id == preferred_reply_message_id:
return candidate
for candidate in candidates:
candidate_work_item, _ = candidate
if self._candidate_matches_mounted_draft(
work_item=candidate_work_item,
mounted_draft=mounted_draft,
):
return candidate
return await self._materialize_retryable_pending_draft_from_mounted(
mounted=active_mounted,
sender=sender,
source_system=source_system,
channel_ref=channel_ref,
source_sender_open_id=source_sender_open_id,
thread_context=thread_context,
)
return candidates[0]
@staticmethod
def _extract_draft_title(payload: Any) -> str:
if not isinstance(payload, dict):
return ""
return str(payload.get("title") or "").strip()
@classmethod
def _candidate_matches_mounted_draft(
cls,
*,
work_item: WorkItem,
mounted_draft: MountedDraftWorkspace,
) -> bool:
candidate_payload = work_item.draft_content if isinstance(getattr(work_item, "draft_content", None), dict) else {}
mounted_payload = (
mounted_draft.draft_payload
if isinstance(getattr(mounted_draft, "draft_payload", None), dict)
else {}
)
mounted_card = (
mounted_draft.requirement_card
if isinstance(getattr(mounted_draft, "requirement_card", None), dict)
else {}
)
candidate_title = cls._extract_draft_title(candidate_payload)
mounted_title = cls._extract_draft_title(mounted_payload) or cls._extract_draft_title(mounted_card)
if candidate_title and mounted_title and candidate_title == mounted_title:
return True
return False
async def _materialize_retryable_pending_draft_from_mounted(
self,
*,
mounted: tuple[MountedDraftWorkspace, MountedTopicWorkspace],
sender: str,
source_system: str,
channel_ref: Optional[str],
source_sender_open_id: Optional[str],
thread_context: Optional[dict[str, Any]],
) -> Optional[tuple[WorkItem, IngestEvent]]:
if not hasattr(self.db, "add") or not hasattr(self.db, "flush"):
return None
mounted_draft, mounted_topic = mounted
draft_payload = (
mounted_draft.draft_payload
if isinstance(getattr(mounted_draft, "draft_payload", None), dict)
else {}
)
if not draft_payload:
return None
normalized_source_system = str(source_system or "").strip() or "unknown"
owner_user_id = await self._resolve_retryable_draft_owner_user_id(
mounted_draft=mounted_draft,
mounted_topic=mounted_topic,
)
retry_source_message_id = (
f"draft-retry-{int(getattr(mounted_draft, 'id', 0) or 0)}-{int(datetime.now().timestamp() * 1000)}"
)[:128]
normalized_thread_context = self._normalize_thread_context(thread_context)
ingest_payload = {
"message_text": str(draft_payload.get("draft_text") or draft_payload.get("title") or "mounted draft retry"),
"sender": str(sender or "").strip() or None,
"channel_ref": str(channel_ref or getattr(mounted_topic, "channel_ref", "") or "").strip() or None,
"source_sender_open_id": str(source_sender_open_id or "").strip() or None,
"owner_user_id": owner_user_id,
"reply_to_message_id": str(normalized_thread_context.get("reply_to_message_id") or "").strip() or None,
"root_message_id": str(
normalized_thread_context.get("root_message_id")
or getattr(mounted_topic, "root_message_id", "")
or ""
).strip()
or None,
"mounted_draft_id": _safe_int(getattr(mounted_draft, "id", None)),
"mounted_topic_workspace_id": _safe_int(getattr(mounted_topic, "id", None)),
"resumed_from": "draft_pending_confirm",
}
ingest_event = IngestEvent(
tenant_id=int(self.tenant_id),
channel=f"{normalized_source_system}_message",
event_key=retry_source_message_id,
status="processing",
payload=ingest_payload,
retry_count=0,
error_message=None,
processed_at=None,
)
self.db.add(ingest_event)
await self.db.flush()
work_item = WorkItem(
tenant_id=int(self.tenant_id),
ingest_event_id=int(ingest_event.id),
source_system=normalized_source_system,
source_message_id=retry_source_message_id,
state=STATE_DRAFT_PENDING_CONFIRM,
intent=ROUTE_PROJECT_CREATE,
owner_user_id=owner_user_id,
dedupe_key=f"{normalized_source_system}:{retry_source_message_id}",
)
work_item.draft_content = dict(draft_payload)
work_item.draft_version = int(getattr(mounted_draft, "draft_version", 0) or 0)
work_item.reply_template_key = "draft_pending_confirm"
work_item.execution_result = {
"status": "draft_pending_confirm",
"workflow_route": ROUTE_PROJECT_CREATE,
"workflow_action": ROUTE_ACTION_CREATE,
"draft": dict(draft_payload),
"draft_version": int(getattr(mounted_draft, "draft_version", 0) or 0),
"resumed_from": "mounted_draft_retry",
"topic_workspace_id": _safe_int(getattr(mounted_topic, "id", None)),
"mounted_draft_id": _safe_int(getattr(mounted_draft, "id", None)),
}
self.db.add(work_item)
await self.db.flush()
mounted_draft.active_work_item_id = int(work_item.id)
mounted_draft.confirm_message_id = None
mounted_draft.state = STATE_DRAFT_PENDING_CONFIRM
mounted_topic.status = "active"
mounted_topic.plan_status = STATE_DRAFT_PENDING_CONFIRM
mounted_topic.mode = "plan"
mounted_topic.metadata_json = {
**(mounted_topic.metadata_json if isinstance(mounted_topic.metadata_json, dict) else {}),
"active_draft_work_item_id": int(work_item.id),
"draft_lifecycle_state": STATE_DRAFT_PENDING_CONFIRM,
}
await self.db.flush()
return work_item, ingest_event
async def _resolve_retryable_draft_owner_user_id(
self,
*,
mounted_draft: MountedDraftWorkspace,
mounted_topic: MountedTopicWorkspace,
) -> Optional[int]:
if not hasattr(self.db, "execute"):
return None
candidate_source_message_ids = [
str(getattr(mounted_draft, "latest_source_message_id", "") or "").strip(),
str(getattr(mounted_topic, "last_message_id", "") or "").strip(),
str(getattr(mounted_topic, "root_message_id", "") or "").strip(),
]
seen: set[str] = set()
normalized_candidates = []
for value in candidate_source_message_ids:
if not value or value in seen:
continue
seen.add(value)
normalized_candidates.append(value)
if not normalized_candidates:
return None
row = (
await self.db.execute(
select(WorkItem.owner_user_id)
.where(
and_(
WorkItem.tenant_id == int(self.tenant_id),
WorkItem.source_message_id.in_(normalized_candidates),
WorkItem.owner_user_id.is_not(None),
)
)
.order_by(WorkItem.id.desc())
.limit(1)
)
).scalar_one_or_none()
return _safe_int(row)
async def has_active_draft_session(
self,
*,
sender: str,
source_system: str,
channel_ref: Optional[str],
source_sender_open_id: Optional[str],
thread_context: Optional[dict[str, Any]],
) -> bool:
if not hasattr(self.db, "execute"):
return False
mounted = await self._resolve_active_mounted_draft(
sender=sender,
source_system=source_system,
channel_ref=channel_ref,
source_sender_open_id=source_sender_open_id,
thread_context=thread_context,
)
return mounted is not None
async def _load_work_item_target_by_id(
self,
*,
work_item_id: int,
) -> Optional[tuple[WorkItem, IngestEvent]]:
row = (
await self.db.execute(
select(WorkItem, IngestEvent)
.join(IngestEvent, IngestEvent.id == WorkItem.ingest_event_id)
.where(
and_(
WorkItem.tenant_id == int(self.tenant_id),
WorkItem.id == int(work_item_id),
)
)
.limit(1)
)
).first()
if row is None:
return None
return row
async def _release_mounted_draft_binding(
self,
*,
work_item_id: int,
final_state: Optional[str],
mounted: Optional[tuple[MountedDraftWorkspace, MountedTopicWorkspace]] = None,
) -> None:
if not hasattr(self.db, "execute"):
return
if mounted is not None:
rows = [mounted]
else:
result = await self.db.execute(
select(MountedDraftWorkspace, MountedTopicWorkspace)
.join(
MountedTopicWorkspace,
MountedTopicWorkspace.id == MountedDraftWorkspace.topic_workspace_id,
)
.where(
and_(
MountedDraftWorkspace.tenant_id == int(self.tenant_id),
MountedTopicWorkspace.tenant_id == int(self.tenant_id),
MountedDraftWorkspace.active_work_item_id == int(work_item_id),
)
)
)
rows = result.all() if hasattr(result, "all") else []
normalized_final_state = str(final_state or "").strip().lower() or None
for draft_row, topic_row in rows:
draft_row.active_work_item_id = None
draft_row.confirm_message_id = None
retryable_failure = normalized_final_state == STATE_FAILED and bool(getattr(draft_row, "draft_payload", None))
if retryable_failure:
draft_row.state = STATE_DRAFT_PENDING_CONFIRM
topic_row.status = "active"
topic_row.plan_status = STATE_DRAFT_PENDING_CONFIRM
topic_row.pending_actions = ["confirm_requirement_draft"]
else:
draft_row.state = "ready" if getattr(draft_row, "draft_payload", None) else "collecting"
topic_row.status = "suspended"
topic_row.plan_status = "suspended"
topic_row.pending_actions = []
topic_row.metadata_json = {
**(topic_row.metadata_json if isinstance(topic_row.metadata_json, dict) else {}),
"active_draft_work_item_id": None,
"draft_lifecycle_state": normalized_final_state,
}
await self.db.flush()
async def _resolve_active_mounted_draft(
self,
*,
sender: str,
source_system: str,
channel_ref: Optional[str],
source_sender_open_id: Optional[str],
thread_context: Optional[dict[str, Any]],
) -> Optional[tuple[MountedDraftWorkspace, MountedTopicWorkspace]]:
normalized_source = str(source_system or "").strip().lower()
if not normalized_source:
return None
conversation_scope_key = MountedAIRuntimeWriteService.build_conversation_scope_key(
source_system=normalized_source,
channel_ref=channel_ref,
sender=sender,
source_sender_open_id=source_sender_open_id,
)
anchor_context = self._normalize_thread_context(thread_context)
rows = (
await self.db.execute(
select(MountedDraftWorkspace, MountedTopicWorkspace)
.join(
MountedTopicWorkspace,
MountedTopicWorkspace.id == MountedDraftWorkspace.topic_workspace_id,
)
.where(
and_(
MountedDraftWorkspace.tenant_id == int(self.tenant_id),
MountedTopicWorkspace.tenant_id == int(self.tenant_id),
MountedTopicWorkspace.source_system == normalized_source,
MountedTopicWorkspace.mode.in_(("plan", "draft_ready")),
MountedTopicWorkspace.status == "active",
MountedDraftWorkspace.state.in_(("ready", STATE_DRAFT_PENDING_CONFIRM)),
)
)
.order_by(MountedTopicWorkspace.updated_at.desc(), MountedDraftWorkspace.id.desc())
)
).all()
if not rows:
return None
normalized_channel = str(channel_ref or "").strip()
best: Optional[tuple[MountedDraftWorkspace, MountedTopicWorkspace]] = None
best_score = -1
for draft_row, topic_row in rows:
if not hasattr(draft_row, "topic_workspace_id") or not hasattr(topic_row, "mode"):
continue
active_work_item_id = _safe_int(getattr(draft_row, "active_work_item_id", None))
if active_work_item_id is not None:
target = await self._load_work_item_target_by_id(work_item_id=int(active_work_item_id))
if target is None:
await self._release_mounted_draft_binding(
work_item_id=int(active_work_item_id),
final_state="missing",
mounted=(draft_row, topic_row),
)
continue
target_work_item, _ = target
target_state = str(getattr(target_work_item, "state", "") or "").strip().lower()
if target_state != STATE_DRAFT_PENDING_CONFIRM:
await self._release_mounted_draft_binding(
work_item_id=int(active_work_item_id),
final_state=target_state,
mounted=(draft_row, topic_row),
)
continue
if anchor_context and not self._matches_mounted_draft_anchor(
draft=draft_row,
topic=topic_row,
anchor_context=anchor_context,
):
continue
score = 0
if normalized_channel and str(getattr(topic_row, "channel_ref", "") or "").strip() == normalized_channel:
score += 30
if conversation_scope_key and str(getattr(topic_row, "conversation_scope_key", "") or "").strip() == str(conversation_scope_key).strip():
score += 60
if anchor_context and self._matches_mounted_draft_anchor(
draft=draft_row,
topic=topic_row,
anchor_context=anchor_context,
):
score += 120
if str(getattr(topic_row, "plan_status", "") or "").strip().lower() in {
STATE_DRAFT_PENDING_CONFIRM,
"awaiting_execute_confirm",
"draft_ready",
}:
score += 20
score += 10
if score > best_score:
best = (draft_row, topic_row)
best_score = score
if best_score <= 0:
return None
return best
@classmethod
def _matches_mounted_draft_anchor(
cls,
*,
draft: MountedDraftWorkspace,
topic: MountedTopicWorkspace,
anchor_context: dict[str, str],
) -> bool:
if not anchor_context:
return False
candidate_values = {
str(getattr(draft, "latest_reply_message_id", "") or "").strip(),
str(getattr(draft, "latest_source_message_id", "") or "").strip(),
str(getattr(topic, "root_message_id", "") or "").strip(),
str(getattr(topic, "thread_key", "") or "").strip(),
str(getattr(topic, "last_message_id", "") or "").strip(),
}
candidate_values.discard("")
return any(str(value or "").strip() in candidate_values for value in anchor_context.values())
@staticmethod
def _normalize_thread_context(
thread_context: Optional[dict[str, Any]],
) -> dict[str, str]:
if not isinstance(thread_context, dict):
return {}
normalized: dict[str, str] = {}
for key in ("reply_to_message_id", "root_message_id", "thread_id"):
value = str(thread_context.get(key) or "").strip()
if value:
normalized[key] = value[:128]
return normalized
@classmethod
def _matches_thread_anchor(
cls,
*,
work_item: WorkItem,
ingest: IngestEvent,
anchor_context: dict[str, str],
draft_reply_message_id: Optional[str] = None,
) -> bool:
if not anchor_context:
return False
payload = ingest.payload if isinstance(ingest.payload, dict) else {}
candidate_values = {
str(payload.get("reply_to_message_id") or "").strip(),
str(payload.get("root_message_id") or "").strip(),
str(getattr(ingest, "source_message_id", "") or "").strip(),
str(getattr(work_item, "source_message_id", "") or "").strip(),
str(draft_reply_message_id or "").strip(),
}
candidate_values.discard("")
return any(
str(value or "").strip() in candidate_values
for value in anchor_context.values()
)
async def _sync_decision_outcome(
self,
*,
work_item_id: int,
execution_result: dict[str, Any],
final_outcome: str,
) -> None:
decision = (
await self.db.execute(
select(DecisionRecord)
.where(
and_(
DecisionRecord.tenant_id == int(self.tenant_id),
DecisionRecord.work_item_id == int(work_item_id),
)
)
.order_by(DecisionRecord.id.desc())
.limit(1)
)
).scalar_one_or_none()
if decision is None:
return
decision.execution_result = execution_result
decision.final_outcome = str(final_outcome or "")[:32] or None
await self.db.flush()
@classmethod
def _extract_work_item_id(cls, text: str) -> Optional[int]:
return extract_followup_work_item_id(text)
@staticmethod
def _safe_float(value: Any) -> Optional[float]:
try:
if value is None or value == "":
return None
return float(value)
except (TypeError, ValueError):
return None
@staticmethod
def _build_confirm_success_text(
*,
draft: dict[str, Any],
signal_id: Optional[int],
project_id: Optional[int] = None,
signal_source_count: int = 0,
task_generation: Optional[dict[str, Any]] = None,
) -> str:
title = str(draft.get("title") or "需求").strip() or "需求"
assignee_name = str(draft.get("recommended_assignee_name") or "待指定").strip() or "待指定"
if project_id is not None:
lines = ["✅ 已入库", "", f"📌 {title}", f"项目 #{int(project_id)} 已创建"]
if signal_id is not None:
lines.append(f"主信号 #{int(signal_id)} 已创建")
if signal_source_count > 0:
lines.append(f"信号源:{int(signal_source_count)} 个")
else:
lines.append("信号源:已生成")
else:
lines = ["✅ 已入库", "", f"📌 {title}"]
if signal_id is not None:
lines.append(f"信号 #{int(signal_id)} 已创建")
else:
lines.append("已完成入库并开始执行")
task_generation_status = (
str((task_generation or {}).get("status") or "").strip().lower()
if isinstance(task_generation, dict)
else ""
)
if task_generation_status == "pending":
lines.append("任务拆解:待补全(可稍后重试)")
lines.append(f"负责人:{assignee_name}")
return "\n".join(lines)
@staticmethod
def _build_project_creation_input_text(
*,
draft: dict[str, Any],
fallback_message: str,
) -> str:
lines: list[str] = []
def _append_line(label: str, value: Any) -> None:
text = str(value or "").strip()
if text:
lines.append(f"{label}:{text}")
def _append_list_section(label: str, values: list[str]) -> None:
normalized = [str(item or "").strip() for item in values if str(item or "").strip()]
if not normalized:
return
lines.append(f"{label}:")
lines.extend(f"- {item}" for item in normalized)
_append_line("项目标题", draft.get("title"))
_append_line("项目摘要", draft.get("summary"))
_append_line("一句话定位", draft.get("positioning_one_liner"))
_append_line("目标客户", draft.get("target_customers"))
core_pain_points = draft.get("core_pain_points") if isinstance(draft.get("core_pain_points"), list) else []
pain_lines: list[str] = []
for item in core_pain_points[:5]:
if not isinstance(item, dict):
text = str(item or "").strip()
if text:
pain_lines.append(text)
continue
pain = str(item.get("pain") or "").strip()
if not pain:
continue
extra_parts = []
status = str(item.get("status") or "").strip()
if status:
extra_parts.append(f"现状:{status}")
opportunity = str(item.get("opportunity") or "").strip()
if opportunity:
extra_parts.append(f"机会:{opportunity}")
suffix = f";{';'.join(extra_parts)}" if extra_parts else ""
pain_lines.append(f"{pain}{suffix}")
_append_list_section("核心痛点", pain_lines)
_append_list_section(
"MVP 功能",
draft.get("mvp_items") if isinstance(draft.get("mvp_items"), list) else [],
)
_append_list_section(
"MVP Phase 1",
draft.get("mvp_phase_1") if isinstance(draft.get("mvp_phase_1"), list) else [],
)
_append_list_section(
"MVP Phase 2",
draft.get("mvp_phase_2") if isinstance(draft.get("mvp_phase_2"), list) else [],
)
_append_list_section(
"技术架构",
draft.get("technical_architecture") if isinstance(draft.get("technical_architecture"), list) else [],
)
business_model = draft.get("business_model") if isinstance(draft.get("business_model"), list) else []
business_lines: list[str] = []
for item in business_model[:5]:
if not isinstance(item, dict):
text = str(item or "").strip()
if text:
business_lines.append(text)
continue
plan = str(item.get("plan") or item.get("name") or "").strip()
price = str(item.get("price") or "").strip()
features = item.get("features")
if isinstance(features, list):
feature_text = "、".join(str(part or "").strip() for part in features if str(part or "").strip())
else:
feature_text = str(features or "").strip()
parts = [part for part in (plan, price, feature_text) if part]
if parts:
business_lines.append(";".join(parts))
_append_list_section("商业模式", business_lines)
validation_metrics = draft.get("validation_metrics") if isinstance(draft.get("validation_metrics"), dict) else {}
_append_list_section(
"产品验证指标",
validation_metrics.get("product") if isinstance(validation_metrics.get("product"), list) else [],
)
_append_list_section(
"市场验证指标",
validation_metrics.get("market") if isinstance(validation_metrics.get("market"), list) else [],
)
team_setup = draft.get("team_setup") if isinstance(draft.get("team_setup"), list) else []
team_lines: list[str] = []
for item in team_setup[:8]:
if not isinstance(item, dict):
text = str(item or "").strip()
if text:
team_lines.append(text)
continue
role = str(item.get("role") or item.get("title") or item.get("name") or "").strip()
notes = str(item.get("notes") or item.get("description") or "").strip()
if role and notes:
team_lines.append(f"{role}:{notes}")
elif role:
team_lines.append(role)
_append_list_section("团队配置", team_lines)
requirement_items = draft.get("requirement_items") if isinstance(draft.get("requirement_items"), list) else []
requirement_lines: list[str] = []
for item in requirement_items[:12]:
if not isinstance(item, dict):
text = str(item or "").strip()
if text:
requirement_lines.append(text)
continue
section = str(item.get("section") or "").strip()
title = str(item.get("title") or "").strip()
description = str(item.get("description") or "").strip()
prefix = f"[{section}] " if section else ""
body = title or description
if body and description and description != body:
body = f"{body}:{description}"
if body:
requirement_lines.append(f"{prefix}{body}")
_append_list_section("需求清单", requirement_lines)
external_signal_sources = (
draft.get("external_signal_sources") if isinstance(draft.get("external_signal_sources"), list) else []
)
external_signal_lines: list[str] = []
for item in external_signal_sources[:5]:
if not isinstance(item, dict):
continue
name = str(item.get("name") or "").strip()
purpose = str(item.get("purpose") or "").strip()
phase = str(item.get("phase") or "").strip()
if name and purpose and phase:
external_signal_lines.append(f"{name}:{purpose}({phase})")
elif name and purpose:
external_signal_lines.append(f"{name}:{purpose}")
_append_list_section("外部信号源", external_signal_lines)
_append_list_section(
"待确认信息",
draft.get("missing_info") if isinstance(draft.get("missing_info"), list) else [],
)
_append_line("可行性", draft.get("feasibility"))
_append_line("可行性说明", draft.get("feasibility_reason"))
_append_line("推荐负责人", draft.get("recommended_assignee_name"))
_append_line("原始用户需求", draft.get("original_text") or fallback_message)
return "\n".join(lines).strip()
@staticmethod
def _is_project_creation_reextract_failure(error: Exception) -> bool:
raw = str(error or "").strip()
normalized = re.sub(r"\s+", " ", raw).lower()
return any(
marker in normalized
for marker in (
"project_requirement_extraction",
"provider_circuit",
"provider circuit",
"structured_http_error",
"模型调用失败",
)
)
@staticmethod
def _build_structured_project_creation_fallback_draft(
*,
draft: dict[str, Any],
input_text: str,
) -> dict[str, Any]:
project_title = str(draft.get("title") or draft.get("project_title") or "").strip() or "未命名项目"
summary = str(draft.get("summary") or "").strip()
positioning = str(draft.get("positioning_one_liner") or "").strip()
signal_content = summary or positioning or str(input_text or "").strip()[:2000] or project_title
return {
"analysis": {
"project_name": project_title,
"project_title": project_title,
"primary_role": "backend",
"requirement_items": (
draft.get("requirement_items")
if isinstance(draft.get("requirement_items"), list)
else []
),
},
"project": {
"name": project_title,
"title": project_title,
"description": summary or str(input_text or "").strip() or project_title,
"assigned_role": "backend",
"domain_id": None,
},
"signal": {
"content": signal_content,
"impact_score": 0.8,
"priority": "high",
"strategic_analysis": {},
},
"charter": {
"title": f"{project_title}项目宪章",
"content": {
"summary": summary,
"positioning_one_liner": positioning,
"target_customers": str(draft.get("target_customers") or "").strip(),
"core_pain_points": (
draft.get("core_pain_points")
if isinstance(draft.get("core_pain_points"), list)
else []
),
"mvp_items": draft.get("mvp_items") if isinstance(draft.get("mvp_items"), list) else [],
"team_setup": draft.get("team_setup") if isinstance(draft.get("team_setup"), list) else [],
"validation_metrics": (
draft.get("validation_metrics")
if isinstance(draft.get("validation_metrics"), dict)
else {}
),
"missing_info": (
draft.get("missing_info")
if isinstance(draft.get("missing_info"), list)
else []
),
},
},
"tasks": draft.get("tasks") if isinstance(draft.get("tasks"), list) else [],
"risks": draft.get("risks") if isinstance(draft.get("risks"), list) else [],
}
@staticmethod
def _build_six_module_charter_content_from_rich_draft(draft: dict[str, Any]) -> dict[str, Any]:
from app.services.ai_steward_service.service import AIStewardService as RealAIStewardService
raw_content = draft.get("content") if isinstance(draft.get("content"), dict) else {}
if RealAIStewardService._has_six_module_charter_content(raw_content):
return dict(raw_content)
seed = dict(draft)
if isinstance(raw_content, dict):
seed = {**raw_content, **seed}
return RealAIStewardService._build_six_module_charter_content_from_brief(seed)
@staticmethod
def _has_meaningful_task_drafts(tasks: Any) -> bool:
if not isinstance(tasks, list) or not tasks:
return False
for task in tasks:
if not isinstance(task, dict):
continue
if str(task.get("title") or "").strip():
return True
return False
@staticmethod
def _has_meaningful_project_creation_section(value: Any) -> bool:
if isinstance(value, str):
return bool(value.strip())
if isinstance(value, dict):
return any(
DraftConfirmHandler._has_meaningful_project_creation_section(item)
for item in value.values()
)
if isinstance(value, list):
return any(
DraftConfirmHandler._has_meaningful_project_creation_section(item)
for item in value
)
return False
@classmethod
def _is_rich_project_creation_draft(cls, draft: dict[str, Any]) -> bool:
if not isinstance(draft, dict):
return False
title = str(draft.get("title") or draft.get("project_title") or "").strip()
summary = str(draft.get("summary") or draft.get("positioning_one_liner") or "").strip()
if not title or not summary:
return False
richness_score = 0
rich_fields = (
draft.get("target_customers"),
draft.get("requirement_items"),
draft.get("core_pain_points"),
draft.get("mvp_items"),
draft.get("mvp_phase_1"),
draft.get("team_setup"),
draft.get("validation_metrics"),
)
for field in rich_fields:
if cls._has_meaningful_project_creation_section(field):
richness_score += 1
return richness_score >= 2
@staticmethod
def _merge_validation_metrics_for_task_chain(draft: dict[str, Any]) -> list[str]:
metrics = draft.get("validation_metrics")
if not isinstance(metrics, dict):
return []
flattened: list[str] = []
for section in metrics.values():
if isinstance(section, list):
for item in section:
text = str(item or "").strip()
if text:
flattened.append(text)
elif isinstance(section, str):
text = section.strip()
if text:
flattened.append(text)
return flattened[:8]
async def _ensure_structured_project_creation_fallback_tasks(
self,
*,
steward: AIStewardService,
fallback_draft: dict[str, Any],
) -> dict[str, Any]:
if self._has_meaningful_task_drafts(fallback_draft.get("tasks")):
fallback_draft["task_generation"] = {
"status": "ready",
"error_message": None,
}
return fallback_draft
analysis = fallback_draft.get("analysis") if isinstance(fallback_draft.get("analysis"), dict) else {}
if not analysis:
analysis = {}
fallback_draft["analysis"] = analysis
project = fallback_draft.get("project") if isinstance(fallback_draft.get("project"), dict) else {}
analysis.setdefault("project_name", str(project.get("name") or project.get("title") or "").strip())
analysis.setdefault("project_title", str(project.get("title") or project.get("name") or "").strip())
analysis.setdefault("primary_role", str(project.get("assigned_role") or "backend").strip() or "backend")
if not isinstance(analysis.get("key_features"), list) or not analysis.get("key_features"):
analysis["key_features"] = [
str(item).strip()
for item in (fallback_draft.get("mvp_items") if isinstance(fallback_draft.get("mvp_items"), list) else [])
if str(item).strip()
][:8]
if not isinstance(analysis.get("success_metrics"), list) or not analysis.get("success_metrics"):
analysis["success_metrics"] = self._merge_validation_metrics_for_task_chain(fallback_draft)
generate_task_chain_bundle = getattr(steward, "_generate_task_chain_bundle", None)
if callable(generate_task_chain_bundle):
task_bundle = await generate_task_chain_bundle(
analysis=analysis,
tenant_id=int(self.tenant_id),
charter_content=(
fallback_draft.get("charter", {}).get("content")
if isinstance(fallback_draft.get("charter"), dict)
else None
),
)
fallback_draft["tasks"] = task_bundle.get("tasks") if isinstance(task_bundle.get("tasks"), list) else []
fallback_draft["task_generation"] = (
task_bundle.get("task_generation")
if isinstance(task_bundle.get("task_generation"), dict)
else steward._task_generation_ready_state()
)
return fallback_draft
try:
generated_tasks = await steward._generate_task_chain_draft(
analysis=analysis,
tenant_id=int(self.tenant_id),
charter_content=(
fallback_draft.get("charter", {}).get("content")
if isinstance(fallback_draft.get("charter"), dict)
else None
),
)
except Exception as exc:
logger.warning(
"draft_confirm_structured_task_chain_generation_deferred",
tenant_id=int(self.tenant_id),
error=str(exc),
)
fallback_draft["tasks"] = []
fallback_draft["task_generation"] = {
"status": "pending",
"error_message": str(exc)[:240],
}
return fallback_draft
fallback_draft["tasks"] = generated_tasks if isinstance(generated_tasks, list) else []
fallback_draft["task_generation"] = {
"status": "ready",
"error_message": None,
}
return fallback_draft
async def _persist_project_from_structured_draft(
self,
*,
steward: AIStewardService,
draft: dict[str, Any],
project_input_text: str,
creator_id: int,
requested_member_user_ids: list[int] | None = None,
) -> dict[str, Any]:
fallback_draft = self._build_structured_project_creation_fallback_draft(
draft=draft,
input_text=project_input_text,
)
fallback_draft["charter"]["content"] = self._build_six_module_charter_content_from_rich_draft(draft)
fallback_draft = await self._ensure_structured_project_creation_fallback_tasks(
steward=steward,
fallback_draft=fallback_draft,
)
normalized = await steward._normalize_project_creation_draft(
draft=fallback_draft,
input_text=project_input_text,
creator_id=int(creator_id),
tenant_id=int(self.tenant_id),
)
return await steward._persist_project_creation_draft(
normalized=normalized,
input_text=project_input_text,
creator_id=int(creator_id),
tenant_id=int(self.tenant_id),
requested_member_user_ids=requested_member_user_ids or None,
)
async def _persist_project_from_draft(
self,
*,
work_item: WorkItem,
draft: dict[str, Any],
source_system: str,
source_message_id: str,
sender: str,
message_text: str,
creator_user_id: Optional[int],
) -> dict[str, Any]:
_ = work_item, source_system, source_message_id, sender
creator_id = _safe_int(creator_user_id)
if creator_id is None or creator_id <= 0:
raise RuntimeError("草稿缺少创建人信息,无法创建项目。")
project_input_text = self._build_project_creation_input_text(
draft=draft,
fallback_message=message_text,
)
if not project_input_text:
project_input_text = str(draft.get("original_text") or message_text or "").strip()
steward = AIStewardService(self.db)
requested_member_user_ids: list[int] = []
assignee_id = _safe_int(draft.get("recommended_assignee_id"))
if assignee_id is not None and assignee_id > 0:
requested_member_user_ids = [int(assignee_id)]
if self._is_rich_project_creation_draft(draft):
logger.info(
"draft_confirm_project_creation_use_structured_draft_directly",
tenant_id=int(self.tenant_id),
work_item_id=int(getattr(work_item, "id", 0) or 0),
)
payload = await self._persist_project_from_structured_draft(
steward=steward,
draft=draft,
project_input_text=project_input_text or str(message_text or "").strip(),
creator_id=int(creator_id),
requested_member_user_ids=requested_member_user_ids or None,
)
else:
try:
payload = await steward.create_project_from_natural_language(
input_text=project_input_text or str(message_text or "").strip(),
creator_id=int(creator_id),
tenant_id=int(self.tenant_id),
requested_member_user_ids=requested_member_user_ids or None,
)
except Exception as exc:
if not self._is_project_creation_reextract_failure(exc):
raise
logger.warning(
"draft_confirm_project_creation_reextract_failed_fallback",
tenant_id=int(self.tenant_id),
work_item_id=int(getattr(work_item, "id", 0) or 0),
error=str(exc),
)
payload = await self._persist_project_from_structured_draft(
steward=steward,
draft=draft,
project_input_text=project_input_text or str(message_text or "").strip(),
creator_id=int(creator_id),
requested_member_user_ids=requested_member_user_ids or None,
)
if not isinstance(payload, dict):
raise RuntimeError("项目创建返回结果无效。")
project = payload.get("project") if isinstance(payload.get("project"), dict) else {}
signal = payload.get("signal") if isinstance(payload.get("signal"), dict) else {}
project_id = _safe_int(project.get("id"))
signal_id = _safe_int(signal.get("id"))
signal_sources = payload.get("signal_sources") if isinstance(payload.get("signal_sources"), list) else []
return {
**payload,
"creator_user_id": int(creator_id),
"project_id": project_id,
"signal_id": signal_id,
"signal_source_count": len(signal_sources),
"project_input_text": project_input_text or str(message_text or "").strip(),
}
async def _persist_signal_from_draft(
self,
*,
work_item: WorkItem,
draft: dict[str, Any],
source_system: str,
source_message_id: str,
sender: str,
message_text: str,
) -> Signal:
title = str(draft.get("title") or "").strip()[:200]
if not title:
title = str(draft.get("summary") or "").strip()[:50]
if not title:
title = "新需求"
summary = str(draft.get("summary") or "").strip()[:300] or title
content = str(draft.get("original_text") or "").strip()[:2000] or summary
assignee_id = _safe_int(draft.get("recommended_assignee_id"))
assignee_name = str(draft.get("recommended_assignee_name") or "").strip()[:40]
workload = self._safe_float(draft.get("recommended_assignee_workload"))
feasibility = str(draft.get("feasibility") or "").strip()[:20]
feasibility_reason = str(draft.get("feasibility_reason") or "").strip()[:120]
followup_plan = draft.get("followup_plan") if isinstance(draft.get("followup_plan"), list) else []
project_id = _safe_int(work_item.project_id)
strategy_payload = {
"source_context": {
"source_message_id": str(source_message_id or "").strip()[:128],
"source_system": str(source_system or "").strip()[:32],
"from_work_item_id": int(work_item.id),
"followup_sender": str(sender or "").strip()[:80],
},
"ai_analysis": {
"summary": summary,
"intent": RouteResolver.canonical_intent_for_route(
RouteResolver.normalize_route_key(str(work_item.intent or ""))
),
"draft_confirmed_direct_commit": True,
"feasibility": feasibility,
"feasibility_reason": feasibility_reason,
"followup_plan": followup_plan[:3],
},
"assignee": {
"user_id": assignee_id,
"name": assignee_name,
"workload_percent": workload,
},
}
reasoning_items = [
"来源:草稿确认后直接入库(不重新分析)。",
f"可行性:{feasibility or '待讨论'}",
f"原因:{feasibility_reason or '无'}",
]
signal = Signal(
tenant_id=int(self.tenant_id),
source="PMFlow",
title=title,
summary=summary,
content=content,
signal_type="新需求",
reasoning=json.dumps(reasoning_items, ensure_ascii=False),
strategic_analysis=json.dumps(strategy_payload, ensure_ascii=False),
confidence=0.78,
recommended_action="enter_only",
matched_project_id=project_id,
impact_score=0.7,
priority="medium",
processed=False,
tasks_generated=json.dumps([], ensure_ascii=False),
status="待处理",
planner_assignee_user_id=assignee_id,
)
self.db.add(signal)
await self.db.flush()
if project_id is not None:
link_exists = (
await self.db.execute(
select(ProjectSignalLink.id)
.where(
and_(
ProjectSignalLink.tenant_id == int(self.tenant_id),
ProjectSignalLink.project_id == int(project_id),
ProjectSignalLink.signal_id == int(signal.id),
)
)
.limit(1)
)
).scalar_one_or_none()
if link_exists is None:
self.db.add(
ProjectSignalLink(
tenant_id=int(self.tenant_id),
project_id=int(project_id),
signal_id=int(signal.id),
link_type="related",
relevance_score=1.0,
)
)
await self.db.flush()
if not str(message_text or "").strip():
message_text = summary
return signal
class _RequirementPlanDraftRuntime(mixin_execute_query_project.ExecuteQueryProjectMixin):
def __init__(self, db: Any, *, tenant_id: int) -> None:
self.db = db
self.tenant_id = int(tenant_id)
@staticmethod
def _build_execution_route(*, work_item: WorkItem) -> WorkflowRoute:
normalized_key = RouteResolver.normalize_route_key(str(work_item.intent or ""))
family, action = RouteResolver._split_route_key(normalized_key)
execution_result = work_item.execution_result if isinstance(work_item.execution_result, dict) else {}
requested_count = _safe_int(execution_result.get("requested_count")) or 1
if family != ROUTE_FAMILY_PROJECT_SIGNAL:
requested_count = 1
return WorkflowRoute(
family=family,
action=action,
project_id=_safe_int(work_item.project_id),
requested_count=requested_count,
requires_confirmation=True,
metadata={
"workflow_route": normalized_key,
"workflow_family": family,
"workflow_action": action,
"resumed_from": "draft_pending_confirm",
"target_work_item_id": int(work_item.id),
},
)