"""Signal Planner 异步分析服务。""" from __future__ import annotations import asyncio import json from typing import Any, Optional from sqlalchemy import and_, select from sqlalchemy.ext.asyncio import AsyncSession from ..config.ai_prompts import PROMPT_SIGNAL_PLANNER from ..core.logging import get_logger from ..database import AsyncSessionLocal from ..models import Project, Signal, SkillTag, User, UserProject, UserSkillBinding, UserWorkload from ..utils.timezone import now_cn_naive from .feishu_outbox_service import FeishuOutboxService from .llm import llm_service from .model_routing_service import ModelRoutingService from .signal_strategic_analysis_service import SignalStrategicAnalysisService logger = get_logger(__name__) PLANNER_MAX_WAIT_SECONDS = 25.0 class SignalPlannerService: """对单条 signal 进行异步成员匹配与计划建议。""" @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 async def _load_signal(cls, db: AsyncSession, *, tenant_id: int, signal_id: int) -> Signal | None: return ( await db.execute( select(Signal).where( and_( Signal.tenant_id == int(tenant_id), Signal.id == int(signal_id), ) ).limit(1) ) ).scalar_one_or_none() @classmethod async def _load_project_title( cls, db: AsyncSession, *, tenant_id: int, project_id: Optional[int], ) -> str: if project_id is None: return "" row = ( await db.execute( select(Project.title).where( and_( Project.tenant_id == int(tenant_id), Project.id == int(project_id), ) ).limit(1) ) ).scalar_one_or_none() return str(row or "") @classmethod async def _load_recent_signal_summaries( cls, db: AsyncSession, *, tenant_id: int, project_id: Optional[int], exclude_signal_id: int, ) -> list[dict[str, Any]]: if project_id is None: return [] rows = ( await db.execute( select(Signal.id, Signal.title, Signal.summary, Signal.priority) .where( and_( Signal.tenant_id == int(tenant_id), Signal.matched_project_id == int(project_id), Signal.id != int(exclude_signal_id), ) ) .order_by(Signal.created_at.desc()) .limit(8) ) ).all() items: list[dict[str, Any]] = [] for row in rows: items.append( { "id": int(row[0]), "title": str(row[1] or "")[:120], "summary": str(row[2] or "")[:180], "priority": str(row[3] or "")[:20], } ) return items @classmethod async def _load_member_profiles( cls, db: AsyncSession, *, tenant_id: int, project_id: Optional[int], ) -> list[dict[str, Any]]: if project_id is None: return [] rows = ( await db.execute( select( User.id, User.name, User.role, UserWorkload.workload_ratio, SkillTag.name, UserSkillBinding.proficiency, ) .join( UserProject, and_( UserProject.tenant_id == int(tenant_id), UserProject.user_id == User.id, UserProject.project_id == int(project_id), ), ) .join( UserWorkload, and_( UserWorkload.tenant_id == int(tenant_id), UserWorkload.user_id == User.id, ), isouter=True, ) .join( UserSkillBinding, and_( UserSkillBinding.tenant_id == int(tenant_id), UserSkillBinding.user_id == User.id, ), isouter=True, ) .join( SkillTag, and_( SkillTag.tenant_id == int(tenant_id), SkillTag.id == UserSkillBinding.skill_tag_id, ), isouter=True, ) .where( and_( User.tenant_id == int(tenant_id), User.is_active == True, ) ) .order_by(User.id.asc()) ) ).all() by_user: dict[int, dict[str, Any]] = {} for row in rows: user_id = cls._safe_int(row[0]) if user_id is None: continue profile = by_user.setdefault( int(user_id), { "user_id": int(user_id), "name": str(row[1] or "成员"), "role": str(row[2] or ""), "workload_ratio": float(row[3] or 0.0), "skills": [], }, ) skill_name = str(row[4] or "").strip() proficiency = cls._safe_int(row[5]) if skill_name: profile["skills"].append( { "name": skill_name, "proficiency": proficiency, } ) items = list(by_user.values()) for item in items: item["skills"] = item["skills"][:10] return items @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 _build_prompt( cls, *, signal: Signal, project_title: str, recent_signals: list[dict[str, Any]], members: list[dict[str, Any]], ) -> str: strategic = cls._as_dict(signal.strategic_analysis) ai_analysis = strategic.get("ai_analysis") if isinstance(strategic.get("ai_analysis"), dict) else {} return PROMPT_SIGNAL_PLANNER.format( signal_id=int(signal.id), signal_title=str(signal.title or ""), signal_summary=str(signal.summary or ai_analysis.get("summary") or ""), signal_content=str(signal.content or ""), project_id=(str(signal.matched_project_id) if signal.matched_project_id is not None else ""), project_title=str(project_title or ""), project_scope=str(ai_analysis.get("requirement_project_scope") or ""), recent_signals=json.dumps(recent_signals, ensure_ascii=False), member_profiles=json.dumps(members, ensure_ascii=False), now_cn=now_cn_naive().isoformat(), ) @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, } @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, ) -> None: async with AsyncSessionLocal() as db: 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 fallback_reason = "" 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: fallback_reason = f"planner_wait_timeout_{int(PLANNER_MAX_WAIT_SECONDS)}s" payload = extraction.payload if extraction is not None and isinstance(extraction.payload, dict) else None if payload is None: if not fallback_reason: fallback_reason = str((extraction.error_code if extraction is not None else "") or "planner_empty_payload") payload = cls._build_fallback_planner_result( signal=signal, members=members, reason=fallback_reason, ) logger.warning( "signal_planner_schema_fallback", tenant_id=int(tenant_id), signal_id=int(signal.id), reason=fallback_reason, raw_text=str((extraction.raw_text if extraction is not None else "") or "")[:300], ) 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], )