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

919 lines
37 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 ..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"
_CONFIRM_HINTS = ("确认", "好的", "就这样", "行", "ok", "okay", "同意", "没问题")
_REVISE_HINTS = ("修改", "改成", "改为", "改", "换成")
_CANCEL_HINTS = ("取消", "不要", "算了", "放弃")
_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]:
text = str(message_text or "").strip()
lowered = text.lower()
if any(hint in text for hint in cls._REVISE_HINTS):
return cls._ACTION_REVISE, cls._extract_revision_note(text)
if any(hint in text for hint in cls._CANCEL_HINTS):
return cls._ACTION_CANCEL, ""
if any(hint in text for hint in cls._CONFIRM_HINTS) or lowered in {"y", "yes"}:
return cls._ACTION_CONFIRM, ""
return cls._ACTION_UNKNOWN, ""
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 {}
try:
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,
}
signal_id = _safe_int(getattr(signal, "id", None))
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),
)
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),
"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 "")),
}
ingest_payload = target_ingest.payload if isinstance(target_ingest.payload, dict) else {}
original_message = str(ingest_payload.get("message_text") or "").strip()
if not original_message:
original_message = str((locked.draft_content or {}).get("original_text") or "").strip()
intent_name = RouteResolver.canonical_intent_for_route(RouteResolver.normalize_route_key(str(locked.intent or "")))
generated = await self.draft_generator.generate_and_persist(
work_item=locked,
intent_name=intent_name,
message_text=original_message,
world_model=locked.context_snapshot if isinstance(locked.context_snapshot, dict) else {},
revision_note=revision_note,
)
locked.state = STATE_DRAFT_PENDING_CONFIRM
locked.reply_template_key = "draft_pending_confirm"
await self.db.flush()
draft_text = str(generated.get("draft_text") or "").strip()
if not draft_text:
draft_text = DraftGeneratorService.render_draft_message(locked.draft_content if isinstance(locked.draft_content, dict) else {})
followup_reply = "🔄 正在重新起草,请稍候...\n\n" + draft_text
return {
"handled": True,
"status": "draft_pending_confirm",
"followup_reply": followup_reply,
"target_work_item_id": int(locked.id),
"target_state": STATE_DRAFT_PENDING_CONFIRM,
"target_intent": str(locked.intent or "").strip() or None,
"project_id": _safe_int(locked.project_id),
"signal_id": _safe_int(locked.signal_id),
"workflow_route": RouteResolver.normalize_route_key(str(locked.intent or "")),
"draft_version": int(locked.draft_version or 0),
"draft_content": locked.draft_content if isinstance(locked.draft_content, dict) else None,
}
async def cancel_draft(
self,
*,
target_work_item: WorkItem,
reason: str,
) -> dict[str, Any]:
locked = (
await self.db.execute(
select(WorkItem)
.where(
and_(
WorkItem.tenant_id == int(self.tenant_id),
WorkItem.id == int(target_work_item.id),
)
)
.with_for_update()
.limit(1)
)
).scalar_one_or_none()
if locked is None:
return {
"handled": False,
"status": "failed_safe",
"followup_reply": "草稿不存在或已被删除。",
"target_work_item_id": int(target_work_item.id),
"target_state": None,
"target_intent": None,
"project_id": None,
"signal_id": None,
"workflow_route": None,
}
if str(locked.state or "").strip() == STATE_CANCELLED:
return {
"handled": True,
"status": "cancelled",
"followup_reply": "已取消,如需重新提交请再次发送。",
"target_work_item_id": int(locked.id),
"target_state": STATE_CANCELLED,
"target_intent": str(locked.intent or "").strip() or None,
"project_id": _safe_int(locked.project_id),
"signal_id": _safe_int(locked.signal_id),
"workflow_route": RouteResolver.normalize_route_key(str(locked.intent or "")),
}
if str(locked.state or "").strip() != STATE_DRAFT_PENDING_CONFIRM:
return {
"handled": True,
"status": "failed_safe",
"followup_reply": "当前草稿已不在待确认状态,无法取消。",
"target_work_item_id": int(locked.id),
"target_state": str(locked.state or "").strip() or None,
"target_intent": str(locked.intent or "").strip() or None,
"project_id": _safe_int(locked.project_id),
"signal_id": _safe_int(locked.signal_id),
"workflow_route": RouteResolver.normalize_route_key(str(locked.intent or "")),
}
locked.state = STATE_CANCELLED
locked.draft_expires_at = None
locked.reply_template_key = "draft_cancelled"
locked.execution_result = {
**(locked.execution_result if isinstance(locked.execution_result, dict) else {}),
"status": "cancelled",
"cancel_reason": str(reason or "用户取消草稿")[:120],
"cancelled_at": _utc_now_naive().isoformat(),
}
await self._sync_decision_outcome(
work_item_id=int(locked.id),
execution_result=locked.execution_result,
final_outcome=STATE_CANCELLED,
)
await self.db.flush()
return {
"handled": True,
"status": "cancelled",
"followup_reply": "已取消,如需重新提交请再次发送。",
"target_work_item_id": int(locked.id),
"target_state": STATE_CANCELLED,
"target_intent": str(locked.intent or "").strip() or None,
"project_id": _safe_int(locked.project_id),
"signal_id": _safe_int(locked.signal_id),
"workflow_route": RouteResolver.normalize_route_key(str(locked.intent or "")),
}
async def _resolve_pending_draft(
self,
*,
sender: str,
source_system: str,
channel_ref: Optional[str],
source_sender_open_id: Optional[str],
explicit_work_item_id: Optional[int],
thread_context: Optional[dict[str, Any]],
) -> Optional[tuple[WorkItem, IngestEvent]]:
stmt = (
select(WorkItem, IngestEvent)
.join(IngestEvent, IngestEvent.id == WorkItem.ingest_event_id)
.where(
and_(
WorkItem.tenant_id == int(self.tenant_id),
WorkItem.state == STATE_DRAFT_PENDING_CONFIRM,
)
)
.order_by(WorkItem.id.desc())
.limit(30)
)
rows = (await self.db.execute(stmt)).all()
if explicit_work_item_id is not None:
for work_item, ingest in rows:
if int(work_item.id) == int(explicit_work_item_id):
return work_item, ingest
return None
normalized_sender = str(sender or "").strip()
normalized_channel = str(channel_ref or "").strip()
normalized_open_id = str(source_sender_open_id or "").strip()
anchor_context = self._normalize_thread_context(thread_context)
if not anchor_context:
return None
source_message_ids = sorted(
{
str(getattr(work_item, "source_message_id", "") or "").strip()
for work_item, _ in rows
if str(getattr(work_item, "source_message_id", "") or "").strip()
}
)
sent_message_id_map: dict[str, str] = {}
if source_message_ids:
outbox_rows = (
await self.db.execute(
select(
FeishuReplyOutbox.source_message_id,
FeishuReplyOutbox.sent_message_id,
)
.where(
and_(
FeishuReplyOutbox.tenant_id == int(self.tenant_id),
FeishuReplyOutbox.source_message_id.in_(source_message_ids),
FeishuReplyOutbox.status == "sent",
FeishuReplyOutbox.sent_message_id.is_not(None),
)
)
.order_by(FeishuReplyOutbox.id.desc())
)
).all()
for source_message_id, sent_message_id in outbox_rows:
source_key = str(source_message_id or "").strip()
sent_key = str(sent_message_id or "").strip()
if source_key and sent_key and source_key not in sent_message_id_map:
sent_message_id_map[source_key] = sent_key
candidates: list[tuple[WorkItem, IngestEvent]] = []
for work_item, ingest in rows:
payload = ingest.payload if isinstance(ingest.payload, dict) else {}
same_sender = normalized_sender and str(payload.get("sender") or "").strip() == normalized_sender
same_channel = normalized_channel and str(payload.get("channel_ref") or "").strip() == normalized_channel
same_open_id = normalized_open_id and str(payload.get("source_sender_open_id") or "").strip() == normalized_open_id
if str(source_system or "").strip().lower() == "feishu":
if not (same_open_id or same_channel or same_sender):
continue
else:
if not (same_sender or same_channel):
continue
if not self._matches_thread_anchor(
work_item=work_item,
ingest=ingest,
anchor_context=anchor_context,
draft_reply_message_id=sent_message_id_map.get(str(getattr(work_item, "source_message_id", "") or "").strip()),
):
continue
candidates.append((work_item, ingest))
if not candidates:
return None
return candidates[0]
@staticmethod
def _normalize_thread_context(
thread_context: Optional[dict[str, Any]],
) -> dict[str, str]:
if not isinstance(thread_context, dict):
return {}
normalized: dict[str, str] = {}
for key in ("reply_to_message_id", "root_message_id", "thread_id"):
value = str(thread_context.get(key) or "").strip()
if value:
normalized[key] = value[:128]
return normalized
@classmethod
def _matches_thread_anchor(
cls,
*,
work_item: WorkItem,
ingest: IngestEvent,
anchor_context: dict[str, str],
draft_reply_message_id: Optional[str] = None,
) -> bool:
if not anchor_context:
return False
payload = ingest.payload if isinstance(ingest.payload, dict) else {}
candidate_values = {
str(payload.get("reply_to_message_id") or "").strip(),
str(payload.get("root_message_id") or "").strip(),
str(getattr(ingest, "source_message_id", "") or "").strip(),
str(getattr(work_item, "source_message_id", "") or "").strip(),
str(draft_reply_message_id or "").strip(),
}
candidate_values.discard("")
return any(
str(value or "").strip() in candidate_values
for value in anchor_context.values()
)
async def _sync_decision_outcome(
self,
*,
work_item_id: int,
execution_result: dict[str, Any],
final_outcome: str,
) -> None:
decision = (
await self.db.execute(
select(DecisionRecord)
.where(
and_(
DecisionRecord.tenant_id == int(self.tenant_id),
DecisionRecord.work_item_id == int(work_item_id),
)
)
.order_by(DecisionRecord.id.desc())
.limit(1)
)
).scalar_one_or_none()
if decision is None:
return
decision.execution_result = execution_result
decision.final_outcome = str(final_outcome or "")[:32] or None
await self.db.flush()
@classmethod
def _extract_work_item_id(cls, text: str) -> Optional[int]:
matched = cls._WORK_ITEM_ID_RE.search(str(text or ""))
if matched is None:
return None
return _safe_int(matched.group(1))
@classmethod
def _extract_revision_note(cls, text: str) -> str:
normalized = str(text or "").strip()
for hint in cls._REVISE_HINTS:
if normalized.startswith(hint):
note = normalized[len(hint):].strip(" ::")
return note[:500]
return ""
@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]) -> str:
title = str(draft.get("title") or "需求").strip() or "需求"
assignee_name = str(draft.get("recommended_assignee_name") or "待指定").strip() or "待指定"
lines = ["✅ 已入库", "", f"📌 {title}"]
if signal_id is not None:
lines.append(f"信号 #{int(signal_id)} 已创建")
else:
lines.append("已完成入库并开始执行")
lines.append(f"负责人:{assignee_name}")
return "\n".join(lines)
async def _persist_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),
},
)