2310 lines
99 KiB
Python
2310 lines
99 KiB
Python
"""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),
|
||
},
|
||
)
|