"""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