"""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 AICallLog, User, UserWorkload, WorkItem from ...utils.timezone import now_cn_naive from ..llm_service import llm_service logger = get_logger(__name__) _DRAFT_TIMEOUT_HOURS = 24 _DRAFT_GENERATOR_INITIAL_MAX_TOKENS = 2_200 _DRAFT_GENERATOR_MAX_TOKENS = 100_000 _DRAFT_GENERATOR_TIMEOUT_SECONDS = 180.0 _FEASIBILITY_ALLOWED = {"合理", "待讨论", "不合理"} _DEFAULT_DRAFT_TITLE = "需求草稿" _DEFAULT_DRAFT_MVP_ITEMS = [ "明确项目目标与边界", "完成同名/相似项目查重", "交付最小可验证闭环", ] _DEFAULT_DRAFT_FOLLOWUP_PLAN = [ {"when": "明天", "action": "确认触发时机与验收口径"}, {"when": "一周后", "action": "复核执行效果并决定是否扩展"}, ] _DEFAULT_DRAFT_FEASIBILITY_REASON = "信息仍需补充,建议先确认边界。" _DEFAULT_DRAFT_PROSPECT_ASSESSMENT = "该项目具备落地价值,但建议先确认范围边界和现有项目复用情况,再决定是否推进。" 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 previous_draft = work_item.draft_content if isinstance(work_item.draft_content, dict) else {} 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")) revision_context = self._compose_revision_context( revision_note=revision_note, previous_draft=previous_draft, ) 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_context or "")[:2500], ).strip() payload = await self._generate_draft_payload( prompt=prompt, is_revision=bool(str(revision_note or "").strip()), ) 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, ) if revision_note and previous_draft: draft = self._preserve_previous_draft_on_revision_regression( previous_draft=previous_draft, candidate_draft=draft, ) 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, is_revision: bool = False) -> dict[str, Any]: best_payload: dict[str, Any] = {} best_richness = 0 for max_tokens in self._build_token_ladder(is_revision=is_revision): 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=max_tokens, response_mode="fast", ), timeout=_DRAFT_GENERATOR_TIMEOUT_SECONDS, ) except Exception as exc: logger.warning( "draft_generator_llm_failed", tenant_id=self.tenant_id, max_tokens=max_tokens, error=str(exc), ) if self._is_provider_budget_rejection(exc): break continue payload = extraction.payload if extraction is not None and isinstance(extraction.payload, dict) else {} recovered_payload = await self._recover_payload_from_logged_response( prompt=prompt, current_payload=payload, ) candidate_payload = recovered_payload if isinstance(recovered_payload, dict) else payload candidate_richness = self._payload_richness(candidate_payload) if candidate_richness > best_richness: best_payload = candidate_payload best_richness = candidate_richness if self._is_substantive_draft_payload(candidate_payload): return candidate_payload return best_payload @staticmethod def _build_token_ladder(*, is_revision: bool = False) -> list[int]: if is_revision: budgets = [ 4_000, 8_000, 16_000, 32_000, 64_000, _DRAFT_GENERATOR_MAX_TOKENS, ] else: budgets = [ _DRAFT_GENERATOR_INITIAL_MAX_TOKENS, 4_000, 8_000, 16_000, 32_000, 64_000, _DRAFT_GENERATOR_MAX_TOKENS, ] ordered: list[int] = [] for item in budgets: value = max(256, min(int(item), _DRAFT_GENERATOR_MAX_TOKENS)) if value not in ordered: ordered.append(value) return ordered @staticmethod def _is_provider_budget_rejection(exc: Exception) -> bool: lowered = str(exc or "").lower() return "400 bad request" in lowered and any( token in lowered for token in ( "dashscope", "max_tokens", "context", "token", "length", ) ) @classmethod def _is_substantive_draft_payload(cls, payload: dict[str, Any]) -> bool: if not isinstance(payload, dict): return False if cls._payload_richness(payload) < 80: return False title = str(payload.get("title") or "").strip() if not title or title == _DEFAULT_DRAFT_TITLE: return False section_hits = 0 if str(payload.get("target_customers") or "").strip(): section_hits += 1 if isinstance(payload.get("core_pain_points"), list) and payload.get("core_pain_points"): section_hits += 1 if cls._has_meaningful_list(payload.get("mvp_items"), placeholder=_DEFAULT_DRAFT_MVP_ITEMS): section_hits += 1 if cls._has_meaningful_list(payload.get("team_setup")): section_hits += 1 if cls._has_meaningful_list(payload.get("technical_architecture")): section_hits += 1 validation_metrics = payload.get("validation_metrics") if isinstance(payload.get("validation_metrics"), dict) else {} if any(isinstance(validation_metrics.get(key), list) and validation_metrics.get(key) for key in ("product", "market")): section_hits += 1 return section_hits >= 3 @staticmethod def _has_meaningful_list(value: Any, *, placeholder: Optional[list[Any]] = None) -> bool: if not isinstance(value, list) or not value: return False normalized = json.dumps(value, ensure_ascii=False, sort_keys=True) if placeholder is not None and normalized == json.dumps(placeholder, ensure_ascii=False, sort_keys=True): return False return True @staticmethod def _compose_revision_context( *, revision_note: Optional[str], previous_draft: dict[str, Any], ) -> str: note = str(revision_note or "").strip() if not note: return "" if not previous_draft: return note revision_base = { "title": previous_draft.get("title"), "summary": previous_draft.get("summary"), "positioning_one_liner": previous_draft.get("positioning_one_liner"), "target_customers": previous_draft.get("target_customers"), "core_pain_points": previous_draft.get("core_pain_points"), "mvp_items": previous_draft.get("mvp_items"), "mvp_phase_1": previous_draft.get("mvp_phase_1"), "mvp_phase_2": previous_draft.get("mvp_phase_2"), "technical_architecture": previous_draft.get("technical_architecture"), "business_model": previous_draft.get("business_model"), "validation_metrics": previous_draft.get("validation_metrics"), "quick_start_suggestions": previous_draft.get("quick_start_suggestions"), "team_setup": previous_draft.get("team_setup"), "prospect_assessment": previous_draft.get("prospect_assessment"), "requirement_items": previous_draft.get("requirement_items"), "missing_info": previous_draft.get("missing_info"), "feasibility": previous_draft.get("feasibility"), } previous_json = json.dumps(revision_base, ensure_ascii=False, separators=(",", ":")) return ( "请基于上一版草稿增量修改,未提及字段尽量保留。\n" f"上一版草稿JSON:{previous_json[:1800]}\n" f"本轮修改意见:{note}" ) @classmethod def _preserve_previous_draft_on_revision_regression( cls, *, previous_draft: dict[str, Any], candidate_draft: dict[str, Any], ) -> dict[str, Any]: if not isinstance(previous_draft, dict) or not isinstance(candidate_draft, dict): return candidate_draft merged = dict(candidate_draft) for key, previous_value in previous_draft.items(): if key in {"version", "original_text"}: continue current_value = merged.get(key) if cls._should_preserve_previous_value(key=key, current_value=current_value, previous_value=previous_value): merged[key] = previous_value return merged @classmethod def _should_preserve_previous_value( cls, *, key: str, current_value: Any, previous_value: Any, ) -> bool: if previous_value in (None, "", [], {}): return False if current_value in (None, "", [], {}): return True if key == "title": return str(current_value or "").strip() == _DEFAULT_DRAFT_TITLE if key == "mvp_items": return not cls._has_meaningful_list(current_value, placeholder=_DEFAULT_DRAFT_MVP_ITEMS) if key == "followup_plan": return json.dumps(current_value, ensure_ascii=False, sort_keys=True) == json.dumps( _DEFAULT_DRAFT_FOLLOWUP_PLAN, ensure_ascii=False, sort_keys=True, ) if key == "feasibility_reason": return str(current_value or "").strip() == _DEFAULT_DRAFT_FEASIBILITY_REASON if key == "prospect_assessment": return str(current_value or "").strip() == _DEFAULT_DRAFT_PROSPECT_ASSESSMENT if key in { "target_customers", "core_pain_points", "mvp_phase_1", "mvp_phase_2", "technical_architecture", "business_model", "validation_metrics", "quick_start_suggestions", "team_setup", "requirement_items", "similar_projects", "missing_info", }: return not cls._payload_richness(current_value) or cls._payload_richness(current_value) < cls._payload_richness(previous_value) return False async def _recover_payload_from_logged_response( self, *, prompt: str, current_payload: dict[str, Any], ) -> dict[str, Any]: if not hasattr(self.db, "execute"): return current_payload current_richness = self._payload_richness(current_payload) try: rows = ( await self.db.execute( select(AICallLog.response) .where( and_( AICallLog.tenant_id == int(self.tenant_id), AICallLog.function_name == "draft_generator_v1", AICallLog.prompt == str(prompt or "")[:8000], ) ) .order_by(AICallLog.id.desc()) .limit(5) ) ).scalars().all() except Exception as exc: logger.warning( "draft_generator_logged_response_lookup_failed", tenant_id=self.tenant_id, error=str(exc), ) return current_payload best_payload = current_payload best_richness = current_richness for response_text in rows: logged_payload = self._parse_logged_draft_payload(response_text) logged_richness = self._payload_richness(logged_payload) if logged_richness > best_richness: best_payload = logged_payload best_richness = logged_richness return best_payload @staticmethod def _parse_logged_draft_payload(response_text: Any) -> dict[str, Any]: text = str(response_text or "").strip() if not text: return {} normalized = llm_service._strip_code_fence(text) if not llm_service._looks_like_json_container(normalized): return {} candidate = llm_service._extract_json_candidate(normalized) if not candidate: return {} try: parsed: Any = json.loads(candidate) except Exception: return {} parsed = llm_service._unwrap_structured_payload_once(parsed) parsed = llm_service._coerce_structured_payload( function_name="draft_generator_v1", payload=parsed, ) return parsed if isinstance(parsed, dict) else {} @classmethod def _payload_richness(cls, payload: Any) -> int: if isinstance(payload, dict): score = 0 for key, value in payload.items(): if str(key or "").strip(): score += 1 score += cls._payload_richness(value) return score if isinstance(payload, list): return len(payload) + sum(cls._payload_richness(item) for item in payload) if isinstance(payload, str): return len(str(payload).strip()) if isinstance(payload, (int, float)) and not isinstance(payload, bool): return 1 if isinstance(payload, bool): return 1 return 0 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": "复核执行效果并决定是否扩展"}, ] positioning_one_liner = str( payload.get("positioning_one_liner") or payload.get("positioning") or "" ).strip()[:160] target_customers = str(payload.get("target_customers") or "").strip()[:160] core_pain_points = self._normalize_core_pain_points(payload.get("core_pain_points")) mvp_phase_1, mvp_phase_2 = self._normalize_mvp_phases(payload) technical_architecture = self._normalize_string_list( payload.get("technical_architecture"), fallback=[], max_items=6, max_length=100, ) business_model = self._normalize_business_model_items(payload.get("business_model")) validation_metrics = self._normalize_section_map( payload.get("validation_metrics"), keys=("product", "market"), ) quick_start_suggestions = self._normalize_section_map( payload.get("quick_start_suggestions"), keys=("week_1_2", "week_3_4"), ) team_setup = self._normalize_team_setup_items(payload.get("team_setup")) requirement_items = self._normalize_requirement_items( payload.get("requirement_items"), core_pain_points=core_pain_points, mvp_phase_1=mvp_phase_1, mvp_phase_2=mvp_phase_2, technical_architecture=technical_architecture, business_model=business_model, validation_metrics=validation_metrics, quick_start_suggestions=quick_start_suggestions, team_setup=team_setup, default_source_kind="custom", ) workload = 0.0 if assignee is not None: workload = float(assignee.get("workload_percent") or 0.0) return { "title": title, "summary": summary, "positioning_one_liner": positioning_one_liner or summary, "target_customers": target_customers, "similar_projects": similar_projects, "mvp_items": mvp_items, "core_pain_points": core_pain_points, "mvp_phase_1": mvp_phase_1, "mvp_phase_2": mvp_phase_2, "technical_architecture": technical_architecture, "business_model": business_model, "validation_metrics": validation_metrics, "quick_start_suggestions": quick_start_suggestions, "team_setup": team_setup, "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 [] positioning_one_liner = str(draft.get("positioning_one_liner") or summary).strip() or summary target_customers = str(draft.get("target_customers") or "").strip() or "待补充" core_pain_points = draft.get("core_pain_points") if isinstance(draft.get("core_pain_points"), list) else [] mvp_phase_1 = draft.get("mvp_phase_1") if isinstance(draft.get("mvp_phase_1"), list) else [] mvp_phase_2 = draft.get("mvp_phase_2") if isinstance(draft.get("mvp_phase_2"), list) else [] technical_architecture = draft.get("technical_architecture") if isinstance(draft.get("technical_architecture"), list) else [] business_model = draft.get("business_model") if isinstance(draft.get("business_model"), list) else [] validation_metrics = draft.get("validation_metrics") if isinstance(draft.get("validation_metrics"), dict) else {} quick_start_suggestions = draft.get("quick_start_suggestions") if isinstance(draft.get("quick_start_suggestions"), dict) else {} team_setup = draft.get("team_setup") if isinstance(draft.get("team_setup"), 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 = ["· 待定:补充跟进计划"] if not mvp_phase_1 and mvp_items: mvp_phase_1 = mvp_items[:3] if not mvp_phase_2 and len(mvp_items) > 3: mvp_phase_2 = mvp_items[3:6] def _format_text_items(items: list[Any], *, default_line: str, max_items: int = 4) -> list[str]: lines = DraftGeneratorService._render_bullet_items( items, default_line=default_line, formatter=DraftGeneratorService._render_text_item_line, max_items=max_items, ) return lines def _format_pain_point_table(items: list[Any]) -> list[str]: rows: list[list[str]] = [] for item in items[:3]: if not isinstance(item, dict): continue pain = str(item.get("pain") or "").strip()[:40] status = str(item.get("status") or "").strip()[:60] or "待补充" opportunity = str(item.get("opportunity") or "").strip()[:60] or "待补充" if pain: rows.append([pain, status, opportunity]) if not rows: return [] lines = [ "痛点 | 现状 | 机会", "---|---|---", ] for pain, status, opportunity in rows: lines.append(f"{pain} | {status} | {opportunity}") return lines def _format_business_model_lines(items: list[Any]) -> list[str]: rows: list[str] = [] for item in items[:3]: if not isinstance(item, dict): text = str(item or "").strip() if text: rows.append(f"· {text[:120]}") continue plan_name = str(item.get("plan") or item.get("name") or "").strip()[:40] price = str(item.get("price") or "").strip()[:40] or "待定" features = str(item.get("features") or item.get("value") or "").strip()[:120] or "待补充" if plan_name: rows.append(f"· {plan_name}:{price};{features}") if not rows: rows = ["· 待补充商业模式"] return rows def _format_section_map_lines(title_text: str, section_map: dict[str, Any], keys: tuple[str, ...]) -> list[str]: lines: list[str] = [title_text] has_any = False for key in keys: values = section_map.get(key) if isinstance(section_map.get(key), list) else [] if not values: continue has_any = True label = { "product": "产品验证", "market": "市场验证", "week_1_2": "Week 1-2", "week_3_4": "Week 3-4", }.get(key, key) lines.append(f"{label}:") lines.extend(_format_text_items(values, default_line="· 待补充", max_items=3)) if not has_any: lines.append("· 待补充") return lines def _format_team_lines(items: list[Any]) -> list[str]: rows: list[str] = [] for item in items[:5]: if not isinstance(item, dict): text = str(item or "").strip() if text: rows.append(f"· {text[:120]}") continue role = str(item.get("role") or item.get("title") or item.get("name") or "").strip()[:60] notes = str(item.get("notes") or item.get("description") or "").strip()[:120] or "待补充" if role: rows.append(f"· {role}:{notes}") if not rows: rows = ["· 待补充团队配置"] return rows pain_table_lines = _format_pain_point_table(core_pain_points) if not pain_table_lines: requirement_pain_items = [ item for item in requirement_items if isinstance(item, dict) and str(item.get("section") or "").strip() == "核心痛点" ] pain_table_lines = _format_pain_point_table( [ { "pain": str(item.get("title") or "").strip(), "status": str(item.get("description") or "").strip(), "opportunity": "", } for item in requirement_pain_items ] ) if not pain_table_lines: pain_table_lines = ["· 待补充核心痛点"] phase_1_lines = _format_text_items( mvp_phase_1, default_line="· 待补充:先明确核心闭环", max_items=4, ) phase_2_lines = _format_text_items( mvp_phase_2, default_line="· 待补充:确认差异化方向后再展开", max_items=3, ) tech_lines = _format_text_items( technical_architecture, default_line="· 待补充技术架构", max_items=6, ) business_lines = _format_business_model_lines(business_model) validation_lines = _format_section_map_lines( "验证指标:", validation_metrics, keys=("product", "market"), ) quick_start_lines = _format_section_map_lines( "快速启动建议:", quick_start_suggestions, keys=("week_1_2", "week_3_4"), ) team_lines = _format_team_lines(team_setup) 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 "该项目前景尚可,但建议先确认范围边界。" ) current_judgement_lines = [ f"可行性:{feasibility}", feasibility_reason_line, *prospect_lines[:1], ] implementation_reference_lines = [ *tech_lines[:2], *business_lines[:1], *team_lines[:2], ] return "\n".join( [ "📋 已为你起草需求,请确认:", "", f"📌 {title}", summary, "", "🔎 相似项目", *similar_lines, "", "🚀 建议先做", *mvp_lines[:3], f"· 定位:{positioning_one_liner}", f"· 目标客户:{target_customers}", "", "✅ 当前判断", *current_judgement_lines, "", "⚠️ 仍需确认", *missing_info_lines, *requirement_lines[:2], "", "🧱 实施参考", *implementation_reference_lines, "", "👤 推荐负责人", 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_core_pain_points(value: Any) -> list[dict[str, str]]: if not isinstance(value, list): return [] normalized: list[dict[str, str]] = [] for row in value[:5]: if isinstance(row, dict): pain = "" for key in ("pain", "title", "name"): candidate = str(row.get(key) or "").strip() if candidate: pain = candidate[:80] break if not pain: pain = " ".join(str(row.get("value") or "").split()).strip()[:80] if not pain: continue normalized.append( { "pain": pain, "status": str(row.get("status") or row.get("current_status") or "").strip()[:120], "opportunity": str(row.get("opportunity") or row.get("chance") or "").strip()[:120], } ) elif isinstance(row, str): pain = " ".join(row.split()).strip()[:80] if pain: normalized.append({"pain": pain, "status": "", "opportunity": ""}) return normalized @staticmethod def _normalize_business_model_items(value: Any) -> list[dict[str, str]]: if not isinstance(value, list): return [] normalized: list[dict[str, str]] = [] for row in value[:5]: if isinstance(row, dict): plan = "" for key in ("plan", "name"): candidate = str(row.get(key) or "").strip() if candidate: plan = candidate[:40] break price = str(row.get("price") or "").strip()[:40] features = "" for key in ("features", "value"): candidate = str(row.get(key) or "").strip() if candidate: features = candidate[:160] break if not any((plan, price, features)): continue normalized.append({"plan": plan, "price": price, "features": features}) elif isinstance(row, str): text = " ".join(row.split()).strip()[:120] if text: normalized.append({"plan": text, "price": "", "features": ""}) return normalized @staticmethod def _normalize_section_map(value: Any, *, keys: tuple[str, ...]) -> dict[str, list[str]]: result = {key: [] for key in keys} if not isinstance(value, dict): return result for key in keys: result[key] = DraftGeneratorService._normalize_string_list( value.get(key), fallback=[], max_items=5, max_length=120, ) return result @staticmethod def _normalize_team_setup_items(value: Any) -> list[dict[str, str]]: if not isinstance(value, list): return [] normalized: list[dict[str, str]] = [] for row in value[:6]: if isinstance(row, dict): role = "" for key in ("role", "title", "name"): candidate = str(row.get(key) or "").strip() if candidate: role = candidate[:60] break notes = "" for key in ("notes", "description"): candidate = str(row.get(key) or "").strip() if candidate: notes = candidate[:120] break if not any((role, notes)): continue normalized.append({"role": role, "notes": notes}) elif isinstance(row, str): text = " ".join(row.split()).strip()[:120] if text: normalized.append({"role": text, "notes": ""}) return normalized @staticmethod def _normalize_mvp_phases(payload: dict[str, Any]) -> tuple[list[str], list[str]]: mvp_phases = payload.get("mvp_phases") if isinstance(payload.get("mvp_phases"), dict) else {} phase_1 = DraftGeneratorService._normalize_string_list( payload.get("mvp_phase_1") or payload.get("phase_1") or mvp_phases.get("phase_1") or mvp_phases.get("week_1_4"), fallback=[], max_items=5, max_length=100, ) phase_2 = DraftGeneratorService._normalize_string_list( payload.get("mvp_phase_2") or payload.get("phase_2") or mvp_phases.get("phase_2") or mvp_phases.get("week_5_8"), fallback=[], max_items=5, max_length=100, ) return phase_1, phase_2 @staticmethod def _normalize_requirement_items( requirement_items: Any, *, core_pain_points: list[dict[str, str]], mvp_phase_1: list[str], mvp_phase_2: list[str], technical_architecture: list[str], business_model: list[dict[str, str]], validation_metrics: dict[str, list[str]], quick_start_suggestions: dict[str, list[str]], team_setup: list[dict[str, str]], default_source_kind: str, ) -> list[dict[str, Any]]: normalized: list[dict[str, Any]] = [] if isinstance(requirement_items, list): for raw_item in requirement_items: if not isinstance(raw_item, dict): continue section = str(raw_item.get("section") or raw_item.get("category") or "").strip()[:40] or "MVP 功能" title = str(raw_item.get("title") or raw_item.get("name") or raw_item.get("feature") or "").strip()[:120] description = str(raw_item.get("description") or raw_item.get("summary") or title).strip()[:600] if not title: continue normalized.append( { "section": section, "title": title, "description": description or title, "priority": str(raw_item.get("priority") or "medium").strip().lower() or "medium", "source_kind": str(raw_item.get("source_kind") or default_source_kind).strip() or default_source_kind, "source_name": str(raw_item.get("source_name") or f"{section} - {title}").strip()[:120], "source_identifier": str(raw_item.get("source_identifier") or "").strip()[:180], } ) if normalized: return normalized[:8] derived: list[dict[str, Any]] = [] for point in core_pain_points[:3]: title = str(point.get("pain") or "").strip()[:120] if not title: continue description_parts: list[str] = [] status_text = str(point.get("status") or "").strip()[:120] if status_text: description_parts.append(f"现状:{status_text}") opportunity_text = str(point.get("opportunity") or "").strip()[:120] if opportunity_text: description_parts.append(f"机会:{opportunity_text}") derived.append( { "section": "核心痛点", "title": title, "description": ";".join(description_parts) or title, "priority": "high", "source_kind": default_source_kind, "source_name": f"核心痛点 - {title}"[:120], "source_identifier": "", } ) for item in mvp_phase_1[:3]: if not item: continue derived.append( { "section": "MVP 功能", "title": item[:120], "description": item[:600], "priority": "medium", "source_kind": default_source_kind, "source_name": f"MVP 功能 - {item}"[:120], "source_identifier": "", } ) for item in mvp_phase_2[:3]: if not item: continue derived.append( { "section": "差异化功能", "title": item[:120], "description": item[:600], "priority": "low", "source_kind": default_source_kind, "source_name": f"差异化功能 - {item}"[:120], "source_identifier": "", } ) for item in technical_architecture[:3]: if not item: continue derived.append( { "section": "技术架构", "title": item[:120], "description": item[:600], "priority": "medium", "source_kind": default_source_kind, "source_name": f"技术架构 - {item}"[:120], "source_identifier": "", } ) for row in business_model[:3]: title = str(row.get("plan") or row.get("price") or "").strip()[:120] features = str(row.get("features") or "").strip()[:600] if not title: continue derived.append( { "section": "商业模式", "title": title, "description": features or title, "priority": "medium", "source_kind": default_source_kind, "source_name": f"商业模式 - {title}"[:120], "source_identifier": "", } ) for metric in validation_metrics.get("product", [])[:3]: if not metric: continue derived.append( { "section": "验证指标", "title": metric[:120], "description": metric[:600], "priority": "low", "source_kind": default_source_kind, "source_name": f"验证指标 - {metric}"[:120], "source_identifier": "", } ) for metric in validation_metrics.get("market", [])[:3]: if not metric: continue derived.append( { "section": "市场验证", "title": metric[:120], "description": metric[:600], "priority": "low", "source_kind": default_source_kind, "source_name": f"市场验证 - {metric}"[:120], "source_identifier": "", } ) for key, rows in quick_start_suggestions.items(): section = "快速启动建议" for item in rows[:2]: if not item: continue derived.append( { "section": section, "title": item[:120], "description": item[:600], "priority": "medium", "source_kind": default_source_kind, "source_name": f"{section} - {item}"[:120], "source_identifier": "", } ) for row in team_setup[:4]: title = str(row.get("role") or "").strip()[:120] notes = str(row.get("notes") or "").strip()[:600] if not title: continue derived.append( { "section": "团队配置", "title": title, "description": notes or title, "priority": "low", "source_kind": default_source_kind, "source_name": f"团队配置 - {title}"[:120], "source_identifier": "", } ) if derived: return derived[:8] return [ { "section": "项目定位", "title": "项目定位与目标", "description": "需确认项目定位与目标。", "priority": "medium", "source_kind": default_source_kind, "source_name": "项目定位 - 项目定位与目标", "source_identifier": "", }, { "section": "MVP 功能", "title": "MVP 功能范围", "description": "需确认 MVP 功能范围。", "priority": "medium", "source_kind": default_source_kind, "source_name": "MVP 功能 - MVP 功能范围", "source_identifier": "", }, ] @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