"""Analyze flow for signal planner service.""" from __future__ import annotations from .shared import ( AsyncSession, AsyncSessionLocal, FeishuOutboxService, ModelRoutingService, Optional, PLANNER_MAX_WAIT_SECONDS, Signal, SignalStrategicAnalysisService, async_sessionmaker, asyncio, llm_service, logger, ) class SignalPlannerAnalyzeMixin: """Executes planner LLM flow and failure handling.""" @classmethod async def _analyze_with_session( cls, *, db: AsyncSession, signal_id: int, tenant_id: int, source_message_id: Optional[str], source_chat_id: Optional[str], dedupe_seed: Optional[str], ) -> None: signal: Signal | None = None try: signal = await cls._load_signal(db, tenant_id=int(tenant_id), signal_id=int(signal_id)) if signal is None: logger.warning( "signal_planner_signal_missing", tenant_id=int(tenant_id), signal_id=int(signal_id), ) return project_id = cls._safe_int(getattr(signal, "matched_project_id", None)) project_title = await cls._load_project_title( db, tenant_id=int(tenant_id), project_id=project_id, ) recent_signals = await cls._load_recent_signal_summaries( db, tenant_id=int(tenant_id), project_id=project_id, exclude_signal_id=int(signal.id), ) members = await cls._load_member_profiles( db, tenant_id=int(tenant_id), project_id=project_id, ) prompt = cls._build_prompt( signal=signal, project_title=project_title, recent_signals=recent_signals, members=members, ) route = await ModelRoutingService.get_effective_routing( db, tenant_id=int(tenant_id), function_name="signal_planner_analyze", ) if str(route.get("source") or "") != "override": logger.warning( "signal_planner_routing_override_missing", tenant_id=int(tenant_id), signal_id=int(signal.id), fallback_provider=str(route.get("provider") or ""), fallback_model=str(route.get("model") or ""), ) extraction = None try: extraction = await asyncio.wait_for( llm_service.generate_structured_object( prompt, db=db, tenant_id=int(tenant_id), function_name="signal_planner_analyze", temperature=0.0, max_tokens=1600, ), timeout=PLANNER_MAX_WAIT_SECONDS, ) except asyncio.TimeoutError: raise RuntimeError( f"signal planner timeout after {int(PLANNER_MAX_WAIT_SECONDS)}s" ) from None payload = ( extraction.payload if extraction is not None and isinstance(extraction.payload, dict) else None ) if payload is None: raise RuntimeError("signal planner empty payload") await SignalStrategicAnalysisService.merge_and_save( db, signal_id=int(signal.id), planner_result=payload, tenant_id=int(tenant_id), ) await db.commit() logger.info( "signal_planner_completed", tenant_id=int(tenant_id), signal_id=int(signal.id), ) except Exception as exc: try: if signal is None: signal = await cls._load_signal(db, tenant_id=int(tenant_id), signal_id=int(signal_id)) if signal is not None: await SignalStrategicAnalysisService.mark_failed( db, signal_id=int(signal.id), tenant_id=int(tenant_id), reason=str(exc), ) chat_id, source_mid = cls._resolve_outbox_targets( signal=signal, source_message_id=source_message_id, source_chat_id=source_chat_id, ) if chat_id: await FeishuOutboxService.enqueue( db, tenant_id=int(tenant_id), chat_id=chat_id, text="信号已入库,但 AI 分析失败,请检查模型配置或稍后手动重试。", source_message_id=source_mid, dedupe_seed=dedupe_seed, ) await db.commit() except Exception: await db.rollback() logger.warning( "signal_planner_failed", tenant_id=int(tenant_id), signal_id=int(signal_id), error=str(exc)[:1000], ) @classmethod async def analyze( cls, *, signal_id: int, tenant_id: int, source_message_id: Optional[str] = None, source_chat_id: Optional[str] = None, dedupe_seed: Optional[str] = None, session_factory: Optional[async_sessionmaker[AsyncSession]] = None, ) -> None: if session_factory is not None: async with session_factory() as db: await cls._analyze_with_session( db=db, signal_id=signal_id, tenant_id=tenant_id, source_message_id=source_message_id, source_chat_id=source_chat_id, dedupe_seed=dedupe_seed, ) return async with AsyncSessionLocal() as db: await cls._analyze_with_session( db=db, signal_id=signal_id, tenant_id=tenant_id, source_message_id=source_message_id, source_chat_id=source_chat_id, dedupe_seed=dedupe_seed, )