PMFlow/backend/app/services/signal/draft_generator_service.py

353 lines
14 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.

"""Draft generation service for write-operation confirmation."""
from __future__ import annotations
import asyncio
import json
from datetime import timedelta
from typing import Any, Optional
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from ...config.ai_prompts import PROMPT_DRAFT_GENERATOR
from ...core.logging import get_logger
from ...models import User, UserWorkload, WorkItem
from ...utils.timezone import now_cn_naive
from ..llm_service import llm_service
logger = get_logger(__name__)
_DRAFT_TIMEOUT_HOURS = 24
_FEASIBILITY_ALLOWED = {"合理", "待讨论", "不合理"}
class DraftGeneratorService:
"""Generate and persist draft payload for write intents."""
def __init__(self, db: AsyncSession, *, tenant_id: int) -> None:
self.db = db
self.tenant_id = int(tenant_id)
async def generate_and_persist(
self,
*,
work_item: WorkItem,
intent_name: str,
message_text: str,
world_model: dict[str, Any],
revision_note: Optional[str] = None,
) -> dict[str, Any]:
current_version = int(work_item.draft_version or 0)
next_version = current_version + 1
project = world_model.get("project") if isinstance(world_model.get("project"), dict) else {}
project_title = str(project.get("title") or project.get("name") or "未指定项目").strip() or "未指定项目"
project_description = str(project.get("description") or "").strip() or "暂无项目描述"
members = await self._load_members(world_model=world_model)
recent_signals = self._normalize_recent_signals(world_model.get("recent_signals"))
prompt = PROMPT_DRAFT_GENERATOR.format(
project_title=project_title,
project_description=project_description,
members_json=json.dumps(members, ensure_ascii=False),
recent_signals=json.dumps(recent_signals, ensure_ascii=False),
user_message=str(message_text or "")[:2000],
intent_name=str(intent_name or "")[:64],
revision_note=str(revision_note or "")[:500],
).strip()
payload = await self._generate_draft_payload(prompt=prompt)
draft = self._normalize_draft_payload(
payload=payload,
members=members,
original_text=message_text,
version=next_version,
)
expires_at = now_cn_naive() + timedelta(hours=_DRAFT_TIMEOUT_HOURS)
work_item.draft_content = draft
work_item.draft_version = next_version
work_item.draft_expires_at = expires_at
work_item.confirm_message_id = None
existing_execution = work_item.execution_result if isinstance(work_item.execution_result, dict) else {}
work_item.execution_result = {
**existing_execution,
"status": "draft_pending_confirm",
"draft": draft,
"draft_version": next_version,
"draft_expires_at": expires_at.isoformat(),
"revision_note": str(revision_note or "")[:500] or None,
}
return {
"draft": draft,
"draft_text": self.render_draft_message(draft),
"draft_expires_at": expires_at,
"draft_version": next_version,
}
async def _generate_draft_payload(self, *, prompt: str) -> dict[str, Any]:
try:
extraction = await asyncio.wait_for(
llm_service.generate_structured_object(
prompt,
db=self.db,
tenant_id=self.tenant_id,
function_name="draft_generator_v1",
use_prompt_profile=False,
temperature=0.0,
max_tokens=1400,
response_mode="fast",
),
timeout=35.0,
)
except Exception as exc:
logger.warning(
"draft_generator_llm_failed",
tenant_id=self.tenant_id,
error=str(exc),
)
return {}
if extraction is None or not isinstance(extraction.payload, dict):
return {}
return extraction.payload
async def _load_members(self, *, world_model: dict[str, Any]) -> list[dict[str, Any]]:
capacity = world_model.get("capacity") if isinstance(world_model.get("capacity"), list) else []
workload_map: dict[int, float] = {}
for row in capacity:
if not isinstance(row, dict):
continue
user_id = self._safe_int(row.get("user_id"))
if user_id is None:
continue
ratio = self._safe_float(row.get("workload_ratio"))
workload_map[user_id] = max(0.0, min(100.0, ratio * 100.0))
user_ids = sorted(workload_map.keys())
if user_ids:
rows = (
await self.db.execute(
select(User.id, User.name, User.username)
.where(
and_(
User.tenant_id == int(self.tenant_id),
User.id.in_(user_ids),
User.is_active.is_(True),
)
)
)
).all()
members = [
{
"id": int(row.id),
"name": str(row.name or row.username or f"成员#{int(row.id)}").strip()[:40],
"workload_percent": round(float(workload_map.get(int(row.id), 0.0)), 1),
}
for row in rows
]
members.sort(key=lambda item: (float(item.get("workload_percent") or 0.0), int(item.get("id") or 0)))
return members[:20]
rows = (
await self.db.execute(
select(User.id, User.name, User.username, UserWorkload.workload_ratio)
.outerjoin(
UserWorkload,
and_(
UserWorkload.tenant_id == User.tenant_id,
UserWorkload.user_id == User.id,
),
)
.where(
and_(
User.tenant_id == int(self.tenant_id),
User.is_active.is_(True),
)
)
.order_by(User.id.asc())
.limit(20)
)
).all()
members = []
for row in rows:
members.append(
{
"id": int(row.id),
"name": str(row.name or row.username or f"成员#{int(row.id)}").strip()[:40],
"workload_percent": round(max(0.0, min(100.0, self._safe_float(row.workload_ratio) * 100.0)), 1),
}
)
members.sort(key=lambda item: (float(item.get("workload_percent") or 0.0), int(item.get("id") or 0)))
return members
@staticmethod
def _normalize_recent_signals(value: Any) -> list[dict[str, Any]]:
if not isinstance(value, list):
return []
normalized: list[dict[str, Any]] = []
for row in value[:8]:
if not isinstance(row, dict):
continue
normalized.append(
{
"id": DraftGeneratorService._safe_int(row.get("id")),
"title": str(row.get("title") or "").strip()[:80],
"type": str(row.get("signal_type") or "").strip()[:30],
"status": str(row.get("status") or "").strip()[:20],
}
)
return normalized
def _normalize_draft_payload(
self,
*,
payload: dict[str, Any],
members: list[dict[str, Any]],
original_text: str,
version: int,
) -> dict[str, Any]:
title = self._normalize_title(str(payload.get("title") or ""))
summary = self._normalize_summary(str(payload.get("summary") or ""), fallback_text=original_text)
feasibility = str(payload.get("feasibility") or "").strip()
if feasibility not in _FEASIBILITY_ALLOWED:
feasibility = "待讨论"
feasibility_reason = str(payload.get("feasibility_reason") or "").strip()[:30]
if not feasibility_reason:
feasibility_reason = "信息仍需补充,建议先确认边界。"
assignee_map = {
int(item.get("id") or 0): item
for item in members
if self._safe_int(item.get("id")) is not None
}
assignee_id = self._safe_int(payload.get("recommended_assignee_id"))
if assignee_id is None or assignee_id not in assignee_map:
assignee_id = int(members[0]["id"]) if members else None
assignee = assignee_map.get(assignee_id) if assignee_id is not None else None
assignee_reason = str(payload.get("recommended_assignee_reason") or "").strip()[:80]
if not assignee_reason:
assignee_reason = "基于当前负载与任务匹配度给出的建议。"
followup_plan = self._normalize_followup_plan(payload.get("followup_plan"))
if not followup_plan:
followup_plan = [
{"when": "明天", "action": "确认触发时机与验收口径"},
{"when": "一周后", "action": "复核执行效果并决定是否扩展"},
]
workload = 0.0
if assignee is not None:
workload = float(assignee.get("workload_percent") or 0.0)
return {
"title": title,
"summary": summary,
"feasibility": feasibility,
"feasibility_reason": feasibility_reason,
"recommended_assignee_id": assignee_id,
"recommended_assignee_name": str((assignee or {}).get("name") or "待指定").strip()[:40] or "待指定",
"recommended_assignee_workload": round(max(0.0, min(100.0, workload)), 1),
"recommended_assignee_reason": assignee_reason,
"followup_plan": followup_plan,
"original_text": str(original_text or "")[:2000],
"version": int(version),
}
@staticmethod
def render_draft_message(draft: dict[str, Any]) -> str:
title = str(draft.get("title") or "未命名需求").strip() or "未命名需求"
summary = str(draft.get("summary") or "暂无摘要").strip() or "暂无摘要"
feasibility = str(draft.get("feasibility") or "待讨论").strip() or "待讨论"
feasibility_reason = str(draft.get("feasibility_reason") or "").strip()
assignee_name = str(draft.get("recommended_assignee_name") or "待指定").strip() or "待指定"
assignee_workload = DraftGeneratorService._safe_float(draft.get("recommended_assignee_workload"))
assignee_reason = str(draft.get("recommended_assignee_reason") or "").strip()
plan = draft.get("followup_plan") if isinstance(draft.get("followup_plan"), list) else []
plan_lines: list[str] = []
for row in plan[:2]:
if not isinstance(row, dict):
continue
when = str(row.get("when") or "待定").strip()[:20] or "待定"
action = str(row.get("action") or "补充执行动作").strip()[:80] or "补充执行动作"
plan_lines.append(f"· {when}:{action}")
if not plan_lines:
plan_lines = ["· 待定:补充跟进计划"]
workload_text = f"{assignee_workload:.1f}" if assignee_workload % 1 else f"{int(assignee_workload)}"
feasibility_reason_line = feasibility_reason if feasibility_reason else "需补充约束后再确认。"
assignee_reason_line = assignee_reason if assignee_reason else "建议结合成员技能与当前负载综合判断。"
return "\n".join(
[
"📋 已为你起草需求,请确认:",
"",
f"📌 {title}",
summary,
"",
f"✅ 可行性:{feasibility}",
feasibility_reason_line,
"",
f"👤 推荐负责人:{assignee_name} · 当前负载{workload_text}%",
assignee_reason_line,
"",
"📅 跟进计划:",
*plan_lines,
"",
"---",
"回复 确认 → 入库",
"回复 修改 [具体修改意见] → 重新起草",
"回复 取消 → 放弃",
"⏰ 24小时内未确认自动取消",
]
)
@staticmethod
def _normalize_title(raw_title: str) -> str:
title = str(raw_title or "").strip()
if not title:
return "需求草稿"
return title[:20]
@staticmethod
def _normalize_summary(raw_summary: str, *, fallback_text: str) -> str:
summary = " ".join(str(raw_summary or "").split()).strip()
if not summary:
summary = " ".join(str(fallback_text or "").split()).strip()
if not summary:
summary = "请确认该需求是否需要立即入库。"
return summary[:80]
@staticmethod
def _normalize_followup_plan(value: Any) -> list[dict[str, str]]:
if not isinstance(value, list):
return []
normalized: list[dict[str, str]] = []
for row in value[:3]:
if not isinstance(row, dict):
continue
when = str(row.get("when") or "").strip()[:20]
action = str(row.get("action") or "").strip()[:80]
if not when or not action:
continue
normalized.append({"when": when, "action": action})
return normalized
@staticmethod
def _safe_int(value: Any) -> Optional[int]:
try:
if value is None or value == "":
return None
return int(value)
except Exception:
return None
@staticmethod
def _safe_float(value: Any) -> float:
try:
return float(value or 0.0)
except Exception:
return 0.0