194 lines
6.7 KiB
Python
194 lines
6.7 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,
|
|
)
|
|
charter_json = await cls._load_latest_charter_payload(
|
|
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,
|
|
charter_json=charter_json,
|
|
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,
|
|
)
|