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

1525 lines
63 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
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