"""Draft generation service for write-operation confirmation.""" from __future__ import annotations import asyncio import json import re from difflib import SequenceMatcher 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 "暂无项目描述" active_projects = self._normalize_active_projects(world_model.get("active_projects")) similar_project_candidates = self._build_similar_project_candidates( project_title=project_title, project_description=project_description, message_text=str(message_text or ""), active_projects=active_projects, ) 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, active_projects_json=json.dumps(active_projects, ensure_ascii=False), similar_project_candidates_json=json.dumps(similar_project_candidates, ensure_ascii=False), 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, active_projects=active_projects, similar_project_candidates=similar_project_candidates, 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]], active_projects: list[dict[str, Any]], similar_project_candidates: 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 = "基于当前负载与任务匹配度给出的建议。" similar_projects = self._normalize_similar_projects( payload.get("similar_projects"), fallback_candidates=similar_project_candidates, active_projects=active_projects, original_text=original_text, title=title, ) mvp_items = self._normalize_string_list( payload.get("mvp_items"), fallback=[ "明确项目目标与边界", "完成同名/相似项目查重", "交付最小可验证闭环", ], max_items=5, max_length=80, ) prospect_assessment = str(payload.get("prospect_assessment") or "").strip()[:240] if not prospect_assessment: prospect_assessment = "该项目具备落地价值,但建议先确认范围边界和现有项目复用情况,再决定是否推进。" requirement_items = self._normalize_string_list( payload.get("requirement_items"), fallback=[ "项目定位与目标", "MVP 功能范围", "需求边界与验收标准", ], max_items=8, max_length=100, ) missing_info = self._normalize_string_list( payload.get("missing_info"), fallback=[], max_items=5, max_length=80, ) 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, "similar_projects": similar_projects, "mvp_items": mvp_items, "prospect_assessment": prospect_assessment, "requirement_items": requirement_items, "missing_info": missing_info, "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 "暂无摘要" similar_projects = draft.get("similar_projects") if isinstance(draft.get("similar_projects"), list) else [] mvp_items = draft.get("mvp_items") if isinstance(draft.get("mvp_items"), list) else [] prospect_assessment = str(draft.get("prospect_assessment") or "").strip() requirement_items = draft.get("requirement_items") if isinstance(draft.get("requirement_items"), list) else [] missing_info = draft.get("missing_info") if isinstance(draft.get("missing_info"), list) else [] 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 "建议结合成员技能与当前负载综合判断。" similar_lines = DraftGeneratorService._render_bullet_items( similar_projects, default_line="· 未发现同名或高相似项目,可作为新立项继续评审。", formatter=DraftGeneratorService._render_similar_project_line, max_items=3, ) mvp_lines = DraftGeneratorService._render_bullet_items( mvp_items, default_line="· 待定:先明确目标后补齐 MVP 范围", formatter=DraftGeneratorService._render_text_item_line, max_items=5, ) requirement_lines = DraftGeneratorService._render_bullet_items( requirement_items, default_line="· 待定:补充需求清单", formatter=DraftGeneratorService._render_text_item_line, max_items=8, ) missing_info_lines = DraftGeneratorService._render_bullet_items( missing_info, default_line="· 暂无", formatter=DraftGeneratorService._render_text_item_line, max_items=5, ) prospect_lines = DraftGeneratorService._split_paragraph_lines( prospect_assessment or "该项目前景尚可,但建议先确认范围边界。" ) return "\n".join( [ "📋 我先按产品经理视角起草一版评审建议,请确认:", "", f"📌 {title}", summary, "", "🔎 同名 / 相似项目:", *similar_lines, "", "🚀 MVP 建议:", *mvp_lines, "", "📈 前景判断:", *prospect_lines, "", "🧩 需求清单:", *requirement_lines, "", "❓ 还缺的信息:", *missing_info_lines, "", 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 _normalize_active_projects(value: Any) -> list[dict[str, Any]]: if not isinstance(value, list): return [] normalized: list[dict[str, Any]] = [] for row in value[:20]: if not isinstance(row, dict): continue project_id = DraftGeneratorService._safe_int(row.get("id")) title = str(row.get("title") or row.get("name") or "").strip()[:120] if project_id is None or not title: continue normalized.append( { "project_id": project_id, "title": title, "name": str(row.get("name") or title).strip()[:120], "description": str(row.get("description") or "").strip()[:300], "status": str(row.get("status") or "").strip()[:20], } ) return normalized def _build_similar_project_candidates( self, *, project_title: str, project_description: str, message_text: str, active_projects: list[dict[str, Any]], ) -> list[dict[str, Any]]: query = " ".join( part for part in [str(project_title or "").strip(), str(project_description or "").strip(), str(message_text or "").strip()] if part ) if not query: return [] candidates: list[dict[str, Any]] = [] for row in active_projects: project_id = self._safe_int(row.get("project_id")) title = str(row.get("title") or row.get("name") or "").strip() if project_id is None or not title: continue description = str(row.get("description") or "").strip() candidate_text = f"{title} {description}".strip() score = self._score_similarity(query, candidate_text) if score < 0.22: continue reuse_suggestion = "建议先评估是否复用现有项目,避免重复建设。" if score >= 0.55: reuse_suggestion = "名称或目标高度接近,优先确认是否复用或合并。" difference = "与当前需求主题接近,建议重点确认范围边界。" if description and len(description) > 20: difference = "现有项目已有一定描述,建议对比当前需求的目标、范围和交付边界。" risk = "若范围重叠,可能造成重复建设或维护分散。" candidates.append( { "project_id": project_id, "title": title, "difference": difference, "risk": risk, "reuse_suggestion": reuse_suggestion, "match_score": round(min(score, 1.0), 3), } ) candidates.sort(key=lambda item: (-float(item.get("match_score") or 0.0), int(item.get("project_id") or 0))) return candidates[:3] @staticmethod def _score_similarity(left: str, right: str) -> float: left_norm = re.sub(r"\s+", "", str(left or "").lower()) right_norm = re.sub(r"\s+", "", str(right or "").lower()) if not left_norm or not right_norm: return 0.0 base = SequenceMatcher(None, left_norm, right_norm).ratio() if left_norm in right_norm or right_norm in left_norm: base = max(base, 0.75) shared = set(re.findall(r"[\u4e00-\u9fff]{2,}", left_norm)) & set(re.findall(r"[\u4e00-\u9fff]{2,}", right_norm)) if shared: base = min(1.0, base + min(len(shared) * 0.08, 0.2)) return base @staticmethod def _normalize_similar_projects( value: Any, *, fallback_candidates: list[dict[str, Any]], active_projects: list[dict[str, Any]], original_text: str, title: str, ) -> list[dict[str, Any]]: normalized: list[dict[str, Any]] = [] if isinstance(value, list): for row in value[:5]: if isinstance(row, dict): project_id = DraftGeneratorService._safe_int(row.get("project_id") or row.get("id")) project_title = str(row.get("title") or row.get("name") or "").strip()[:120] if project_id is None or not project_title: continue normalized.append( { "project_id": project_id, "title": project_title, "difference": str(row.get("difference") or "").strip()[:160] or "需确认范围差异。", "risk": str(row.get("risk") or "").strip()[:160] or "可能存在重复建设风险。", "reuse_suggestion": str(row.get("reuse_suggestion") or row.get("suggestion") or "").strip()[:160] or "建议先评估是否复用或合并。", } ) elif isinstance(row, str) and row.strip(): normalized.append( { "project_id": None, "title": row.strip()[:120], "difference": "模型未提供详细差异,建议人工确认。", "risk": "存在重复建设的可能。", "reuse_suggestion": "建议先核对项目是否同名或高度相似。", } ) if normalized: return normalized[:3] if fallback_candidates: return [ { "project_id": int(row.get("project_id")), "title": str(row.get("title") or "").strip()[:120], "difference": str(row.get("difference") or "").strip()[:160] or "需确认范围差异。", "risk": str(row.get("risk") or "").strip()[:160] or "可能存在重复建设风险。", "reuse_suggestion": str(row.get("reuse_suggestion") or "").strip()[:160] or "建议先评估是否复用或合并。", } for row in fallback_candidates[:3] if DraftGeneratorService._safe_int(row.get("project_id")) is not None ] query = " ".join(part for part in [title, original_text] if str(part or "").strip()) for row in active_projects[:3]: project_title = str(row.get("title") or row.get("name") or "").strip() if not project_title: continue if query and (project_title in query or query in project_title): normalized.append( { "project_id": int(row.get("project_id") or 0), "title": project_title, "difference": "名称高度接近,建议确认是否为同一项目范围。", "risk": "若直接新建,容易造成重复建设。", "reuse_suggestion": "建议复用现有项目或先合并范围。", } ) return normalized[:3] @staticmethod def _normalize_string_list( value: Any, *, fallback: list[str], max_items: int, max_length: int, ) -> list[str]: items: list[str] = [] if isinstance(value, list): for row in value: if isinstance(row, str): text = " ".join(row.split()).strip()[:max_length] elif isinstance(row, dict): text = " ".join( str(row.get(key) or "").strip() for key in ("text", "title", "content", "summary", "name") if str(row.get(key) or "").strip() ).strip()[:max_length] if not text: text = " ".join(str(row.get("value") or "").split()).strip()[:max_length] else: text = "" if text: items.append(text) if len(items) >= max_items: break if items: return items[:max_items] return [str(item).strip()[:max_length] for item in fallback if str(item).strip()][:max_items] @staticmethod def _render_bullet_items( items: list[Any], *, default_line: str, formatter: Any, max_items: int, ) -> list[str]: lines: list[str] = [] if isinstance(items, list): for item in items[:max_items]: line = formatter(item) if line: lines.append(line) if not lines: lines = [default_line] return lines @staticmethod def _render_text_item_line(item: Any) -> str: if isinstance(item, str): text = " ".join(item.split()).strip() elif isinstance(item, dict): text = " ".join( str(item.get(key) or "").strip() for key in ("text", "title", "content", "summary", "name") if str(item.get(key) or "").strip() ).strip() if not text: text = " ".join(str(item.get("value") or "").split()).strip() else: text = "" if not text: return "" return f"· {text[:120]}" @staticmethod def _render_similar_project_line(item: Any) -> str: if not isinstance(item, dict): return DraftGeneratorService._render_text_item_line(item) project_id = DraftGeneratorService._safe_int(item.get("project_id")) title = str(item.get("title") or "").strip()[:60] difference = str(item.get("difference") or "").strip()[:120] risk = str(item.get("risk") or "").strip()[:80] reuse = str(item.get("reuse_suggestion") or "").strip()[:100] parts = [] if project_id is not None: parts.append(f"#{project_id}") if title: parts.append(title) prefix = " ".join(parts) or "相似项目" detail_parts = [part for part in [difference, risk, reuse] if part] detail = ";".join(detail_parts) if detail_parts else "建议先确认范围边界。" return f"· {prefix}:{detail}" @staticmethod def _split_paragraph_lines(text: str) -> list[str]: normalized = " ".join(str(text or "").split()).strip() if not normalized: return ["· 暂无"] return [f"· {chunk.strip()}" for chunk in re.split(r"[。;;]\s*", normalized) if chunk.strip()][:3] @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