1436 lines
60 KiB
Python
1436 lines
60 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)
|
||
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))
|
||
|
||
if not anchor_context:
|
||
for candidate in sender_scope_candidates:
|
||
candidate_work_item, _ = candidate
|
||
candidate_route = RouteResolver.normalize_route_key(str(getattr(candidate_work_item, "intent", "") or ""))
|
||
if candidate_route != ROUTE_FOLLOWUP_CONFIRM:
|
||
return candidate
|
||
return sender_scope_candidates[0] if sender_scope_candidates else None
|
||
|
||
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:
|
||
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)
|
||
|
||
@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)
|
||
|
||
_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 _looks_like_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 _looks_like_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")):
|
||
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)
|
||
|
||
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
|
||
),
|
||
)
|
||
fallback_draft["tasks"] = generated_tasks if isinstance(generated_tasks, list) else []
|
||
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._looks_like_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._looks_like_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
|
||
|
||
@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),
|
||
},
|
||
)
|