PMFlow/backend/app/services/signal_planner_service.py

463 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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