PMFlow/backend/app/services/signal_planner_service.py

482 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, async_sessionmaker
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_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,
)