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

1436 lines
60 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Draft confirmation/revision/cancel handler for AI PMO write intents."""
from __future__ import annotations
from datetime import datetime
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),
},
)