1525 lines
63 KiB
Python
1525 lines
63 KiB
Python
"""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._looks_like_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 _looks_like_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
|