1022 lines
43 KiB
Python
1022 lines
43 KiB
Python
"""Draft confirmation/revision/cancel handler for AI PMO write intents."""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import datetime
|
||
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 .workflow_semantic_planner import WorkflowSemanticPlanner
|
||
from ..signal.draft_generator_service import DraftGeneratorService
|
||
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_CANCEL = "cancel"
|
||
_ACTION_UNKNOWN = "unknown"
|
||
|
||
_WORK_ITEM_ID_RE = re.compile(r"(?:work[_ -]?item|工作项|草稿)\s*(?:id|ID)?\s*[:#:]?\s*(\d+)", re.IGNORECASE)
|
||
|
||
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)
|
||
|
||
@classmethod
|
||
def parse_action_from_text(cls, *, message_text: str) -> tuple[str, str]:
|
||
action, note = WorkflowSemanticPlanner.parse_draft_action(message_text=message_text)
|
||
return action, note
|
||
|
||
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,
|
||
) -> dict[str, Any]:
|
||
action, revision_note = self.parse_action_from_text(message_text=message_text)
|
||
explicit_work_item_id = self._extract_work_item_id(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:
|
||
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,
|
||
}
|
||
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
|
||
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_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 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,
|
||
)
|
||
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.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,
|
||
)
|
||
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 [],
|
||
"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.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]:
|
||
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": "当前草稿已不在待确认状态,无法再修改。",
|
||
"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 "")),
|
||
}
|
||
|
||
if not str(revision_note or "").strip():
|
||
return {
|
||
"handled": True,
|
||
"status": "draft_pending_confirm",
|
||
"followup_reply": "请先补充修改意见再继续,例如:修改 目标客户改成北美 DTC 品牌。",
|
||
"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 "")))
|
||
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(locked.context_snapshot, dict) else {},
|
||
revision_note=revision_note,
|
||
)
|
||
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,
|
||
}
|
||
|
||
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.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]]:
|
||
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()
|
||
if explicit_work_item_id is not None:
|
||
for work_item, ingest in rows:
|
||
if int(work_item.id) == int(explicit_work_item_id):
|
||
return work_item, ingest
|
||
return None
|
||
|
||
normalized_sender = str(sender or "").strip()
|
||
normalized_channel = str(channel_ref or "").strip()
|
||
normalized_open_id = str(source_sender_open_id or "").strip()
|
||
anchor_context = self._normalize_thread_context(thread_context)
|
||
if not anchor_context:
|
||
return None
|
||
|
||
source_message_ids = sorted(
|
||
{
|
||
str(getattr(work_item, "source_message_id", "") or "").strip()
|
||
for work_item, _ in rows
|
||
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 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
|
||
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:
|
||
return None
|
||
return candidates[0]
|
||
|
||
@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]:
|
||
matched = cls._WORK_ITEM_ID_RE.search(str(text or ""))
|
||
if matched is None:
|
||
return None
|
||
return _safe_int(matched.group(1))
|
||
|
||
@classmethod
|
||
def _extract_revision_note(cls, text: str) -> str:
|
||
action, note = WorkflowSemanticPlanner.parse_draft_action(message_text=text)
|
||
if action != cls._ACTION_REVISE:
|
||
return ""
|
||
return str(note or "")[:500]
|
||
|
||
@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,
|
||
) -> 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("已完成入库并开始执行")
|
||
lines.append(f"负责人:{assignee_name}")
|
||
return "\n".join(lines)
|
||
|
||
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 = DraftGeneratorService.render_draft_message(draft).split("\n---\n", 1)[0].strip()
|
||
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)]
|
||
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,
|
||
)
|
||
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
|
||
|
||
@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),
|
||
},
|
||
)
|