PMFlow/backend/app/services/signal_planner/mixin_utils.py

125 lines
4.3 KiB
Python

"""Utility helpers for signal planner service."""
from __future__ import annotations
from .shared import Any, Optional, Signal, json
class SignalPlannerUtilsMixin:
"""Type-safe conversion and fallback builder helpers."""
@staticmethod
def _as_dict(value: Any) -> dict[str, Any]:
if isinstance(value, dict):
return dict(value)
if isinstance(value, str):
try:
parsed = json.loads(value)
return parsed if isinstance(parsed, dict) else {}
except Exception:
return {}
return {}
@staticmethod
def _safe_int(value: Any) -> Optional[int]:
try:
return int(str(value).strip())
except (TypeError, ValueError, AttributeError):
return None
@staticmethod
def _safe_float(value: Any) -> Optional[float]:
try:
return float(value)
except (TypeError, ValueError):
return None
@classmethod
def _resolve_outbox_targets(
cls,
*,
signal: Signal,
source_message_id: Optional[str],
source_chat_id: Optional[str],
) -> tuple[Optional[str], Optional[str]]:
strategic = cls._as_dict(signal.strategic_analysis)
source_context = (
strategic.get("source_context")
if isinstance(strategic.get("source_context"), dict)
else {}
)
resolved_chat_id = (
str(source_chat_id or "").strip()
or str(source_context.get("source_chat_id") or "").strip()
or str(source_context.get("chat_id") or "").strip()
or str(source_context.get("channel_ref") or "").strip()
or str(source_context.get("group_chat_id") or "").strip()
or None
)
resolved_source_message_id = (
str(source_message_id or "").strip()
or str(source_context.get("source_message_id") or "").strip()
or str(source_context.get("message_id") or "").strip()
or None
)
return resolved_chat_id, resolved_source_message_id
@classmethod
def _pick_fallback_assignee_user_id(
cls,
*,
members: list[dict[str, Any]],
) -> Optional[int]:
candidates: list[tuple[float, int]] = []
for item in members:
uid = cls._safe_int(item.get("user_id"))
workload_ratio = cls._safe_float(item.get("workload_ratio"))
if uid is None:
continue
candidates.append((float(workload_ratio if workload_ratio is not None else 1000.0), int(uid)))
if not candidates:
return None
candidates.sort(key=lambda row: (row[0], row[1]))
return int(candidates[0][1])
@classmethod
def _build_fallback_planner_result(
cls,
*,
signal: Signal,
members: list[dict[str, Any]],
reason: str,
) -> dict[str, Any]:
fallback_assignee = cls._pick_fallback_assignee_user_id(members=members)
summary_seed = (
str(signal.summary or "").strip()
or str(signal.title or "").strip()
or str(signal.content or "").strip()
or "信号已入库,模型结构化输出异常,已自动降级生成初始执行建议。"
)
execution_plan: list[dict[str, Any]] = [
{
"step": "澄清信号目标与边界",
"owner_user_id": fallback_assignee,
"eta": "4h",
},
{
"step": "拆解最小可执行任务并确认优先级",
"owner_user_id": fallback_assignee,
"eta": "1d",
},
]
return {
"summary": summary_seed[:180],
"priority": str(signal.priority or "medium").strip() or "medium",
"execution_plan": execution_plan,
"risks": [
"模型返回非预期结构,已使用降级策略生成建议,请人工复核。",
],
"confidence": 0.2,
"assignee_user_id": fallback_assignee,
"assignee_reason": "按当前最低负载成员自动指派(降级模式)",
"fallback_reason": str(reason or "structured_schema_invalid")[:200],
"fallback_mode": True,
}