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

188 lines
6.5 KiB
Python

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