353 lines
14 KiB
Python
353 lines
14 KiB
Python
"""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
|