"""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, }