463 lines
17 KiB
Python
463 lines
17 KiB
Python
"""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],
|
||
)
|