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

702 lines
29 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 User, UserWorkload, WorkItem
from ...utils.timezone import now_cn_naive
from ..llm_service import llm_service
logger = get_logger(__name__)
_DRAFT_TIMEOUT_HOURS = 24
_FEASIBILITY_ALLOWED = {"合理", "待讨论", "不合理"}
class DraftGeneratorService:
"""Generate and persist draft payload for write intents."""
def __init__(self, db: AsyncSession, *, tenant_id: int) -> None:
self.db = db
self.tenant_id = int(tenant_id)
async def generate_and_persist(
self,
*,
work_item: WorkItem,
intent_name: str,
message_text: str,
world_model: dict[str, Any],
revision_note: Optional[str] = None,
) -> dict[str, Any]:
current_version = int(work_item.draft_version or 0)
next_version = current_version + 1
project = world_model.get("project") if isinstance(world_model.get("project"), dict) else {}
project_title = str(project.get("title") or project.get("name") or "未指定项目").strip() or "未指定项目"
project_description = str(project.get("description") or "").strip() or "暂无项目描述"
active_projects = self._normalize_active_projects(world_model.get("active_projects"))
similar_project_candidates = self._build_similar_project_candidates(
project_title=project_title,
project_description=project_description,
message_text=str(message_text or ""),
active_projects=active_projects,
)
members = await self._load_members(world_model=world_model)
recent_signals = self._normalize_recent_signals(world_model.get("recent_signals"))
prompt = PROMPT_DRAFT_GENERATOR.format(
project_title=project_title,
project_description=project_description,
active_projects_json=json.dumps(active_projects, ensure_ascii=False),
similar_project_candidates_json=json.dumps(similar_project_candidates, ensure_ascii=False),
members_json=json.dumps(members, ensure_ascii=False),
recent_signals=json.dumps(recent_signals, ensure_ascii=False),
user_message=str(message_text or "")[:2000],
intent_name=str(intent_name or "")[:64],
revision_note=str(revision_note or "")[:500],
).strip()
payload = await self._generate_draft_payload(prompt=prompt)
draft = self._normalize_draft_payload(
payload=payload,
members=members,
active_projects=active_projects,
similar_project_candidates=similar_project_candidates,
original_text=message_text,
version=next_version,
)
expires_at = now_cn_naive() + timedelta(hours=_DRAFT_TIMEOUT_HOURS)
work_item.draft_content = draft
work_item.draft_version = next_version
work_item.draft_expires_at = expires_at
work_item.confirm_message_id = None
existing_execution = work_item.execution_result if isinstance(work_item.execution_result, dict) else {}
work_item.execution_result = {
**existing_execution,
"status": "draft_pending_confirm",
"draft": draft,
"draft_version": next_version,
"draft_expires_at": expires_at.isoformat(),
"revision_note": str(revision_note or "")[:500] or None,
}
return {
"draft": draft,
"draft_text": self.render_draft_message(draft),
"draft_expires_at": expires_at,
"draft_version": next_version,
}
async def _generate_draft_payload(self, *, prompt: str) -> dict[str, Any]:
try:
extraction = await asyncio.wait_for(
llm_service.generate_structured_object(
prompt,
db=self.db,
tenant_id=self.tenant_id,
function_name="draft_generator_v1",
use_prompt_profile=False,
temperature=0.0,
max_tokens=1400,
response_mode="fast",
),
timeout=35.0,
)
except Exception as exc:
logger.warning(
"draft_generator_llm_failed",
tenant_id=self.tenant_id,
error=str(exc),
)
return {}
if extraction is None or not isinstance(extraction.payload, dict):
return {}
return extraction.payload
async def _load_members(self, *, world_model: dict[str, Any]) -> list[dict[str, Any]]:
capacity = world_model.get("capacity") if isinstance(world_model.get("capacity"), list) else []
workload_map: dict[int, float] = {}
for row in capacity:
if not isinstance(row, dict):
continue
user_id = self._safe_int(row.get("user_id"))
if user_id is None:
continue
ratio = self._safe_float(row.get("workload_ratio"))
workload_map[user_id] = max(0.0, min(100.0, ratio * 100.0))
user_ids = sorted(workload_map.keys())
if user_ids:
rows = (
await self.db.execute(
select(User.id, User.name, User.username)
.where(
and_(
User.tenant_id == int(self.tenant_id),
User.id.in_(user_ids),
User.is_active.is_(True),
)
)
)
).all()
members = [
{
"id": int(row.id),
"name": str(row.name or row.username or f"成员#{int(row.id)}").strip()[:40],
"workload_percent": round(float(workload_map.get(int(row.id), 0.0)), 1),
}
for row in rows
]
members.sort(key=lambda item: (float(item.get("workload_percent") or 0.0), int(item.get("id") or 0)))
return members[:20]
rows = (
await self.db.execute(
select(User.id, User.name, User.username, UserWorkload.workload_ratio)
.outerjoin(
UserWorkload,
and_(
UserWorkload.tenant_id == User.tenant_id,
UserWorkload.user_id == User.id,
),
)
.where(
and_(
User.tenant_id == int(self.tenant_id),
User.is_active.is_(True),
)
)
.order_by(User.id.asc())
.limit(20)
)
).all()
members = []
for row in rows:
members.append(
{
"id": int(row.id),
"name": str(row.name or row.username or f"成员#{int(row.id)}").strip()[:40],
"workload_percent": round(max(0.0, min(100.0, self._safe_float(row.workload_ratio) * 100.0)), 1),
}
)
members.sort(key=lambda item: (float(item.get("workload_percent") or 0.0), int(item.get("id") or 0)))
return members
@staticmethod
def _normalize_recent_signals(value: Any) -> list[dict[str, Any]]:
if not isinstance(value, list):
return []
normalized: list[dict[str, Any]] = []
for row in value[:8]:
if not isinstance(row, dict):
continue
normalized.append(
{
"id": DraftGeneratorService._safe_int(row.get("id")),
"title": str(row.get("title") or "").strip()[:80],
"type": str(row.get("signal_type") or "").strip()[:30],
"status": str(row.get("status") or "").strip()[:20],
}
)
return normalized
def _normalize_draft_payload(
self,
*,
payload: dict[str, Any],
members: list[dict[str, Any]],
active_projects: list[dict[str, Any]],
similar_project_candidates: list[dict[str, Any]],
original_text: str,
version: int,
) -> dict[str, Any]:
title = self._normalize_title(str(payload.get("title") or ""))
summary = self._normalize_summary(str(payload.get("summary") or ""), fallback_text=original_text)
feasibility = str(payload.get("feasibility") or "").strip()
if feasibility not in _FEASIBILITY_ALLOWED:
feasibility = "待讨论"
feasibility_reason = str(payload.get("feasibility_reason") or "").strip()[:30]
if not feasibility_reason:
feasibility_reason = "信息仍需补充,建议先确认边界。"
assignee_map = {
int(item.get("id") or 0): item
for item in members
if self._safe_int(item.get("id")) is not None
}
assignee_id = self._safe_int(payload.get("recommended_assignee_id"))
if assignee_id is None or assignee_id not in assignee_map:
assignee_id = int(members[0]["id"]) if members else None
assignee = assignee_map.get(assignee_id) if assignee_id is not None else None
assignee_reason = str(payload.get("recommended_assignee_reason") or "").strip()[:80]
if not assignee_reason:
assignee_reason = "基于当前负载与任务匹配度给出的建议。"
similar_projects = self._normalize_similar_projects(
payload.get("similar_projects"),
fallback_candidates=similar_project_candidates,
active_projects=active_projects,
original_text=original_text,
title=title,
)
mvp_items = self._normalize_string_list(
payload.get("mvp_items"),
fallback=[
"明确项目目标与边界",
"完成同名/相似项目查重",
"交付最小可验证闭环",
],
max_items=5,
max_length=80,
)
prospect_assessment = str(payload.get("prospect_assessment") or "").strip()[:240]
if not prospect_assessment:
prospect_assessment = "该项目具备落地价值,但建议先确认范围边界和现有项目复用情况,再决定是否推进。"
requirement_items = self._normalize_string_list(
payload.get("requirement_items"),
fallback=[
"项目定位与目标",
"MVP 功能范围",
"需求边界与验收标准",
],
max_items=8,
max_length=100,
)
missing_info = self._normalize_string_list(
payload.get("missing_info"),
fallback=[],
max_items=5,
max_length=80,
)
followup_plan = self._normalize_followup_plan(payload.get("followup_plan"))
if not followup_plan:
followup_plan = [
{"when": "明天", "action": "确认触发时机与验收口径"},
{"when": "一周后", "action": "复核执行效果并决定是否扩展"},
]
workload = 0.0
if assignee is not None:
workload = float(assignee.get("workload_percent") or 0.0)
return {
"title": title,
"summary": summary,
"similar_projects": similar_projects,
"mvp_items": mvp_items,
"prospect_assessment": prospect_assessment,
"requirement_items": requirement_items,
"missing_info": missing_info,
"feasibility": feasibility,
"feasibility_reason": feasibility_reason,
"recommended_assignee_id": assignee_id,
"recommended_assignee_name": str((assignee or {}).get("name") or "待指定").strip()[:40] or "待指定",
"recommended_assignee_workload": round(max(0.0, min(100.0, workload)), 1),
"recommended_assignee_reason": assignee_reason,
"followup_plan": followup_plan,
"original_text": str(original_text or "")[:2000],
"version": int(version),
}
@staticmethod
def render_draft_message(draft: dict[str, Any]) -> str:
title = str(draft.get("title") or "未命名需求").strip() or "未命名需求"
summary = str(draft.get("summary") or "暂无摘要").strip() or "暂无摘要"
similar_projects = draft.get("similar_projects") if isinstance(draft.get("similar_projects"), list) else []
mvp_items = draft.get("mvp_items") if isinstance(draft.get("mvp_items"), list) else []
prospect_assessment = str(draft.get("prospect_assessment") or "").strip()
requirement_items = draft.get("requirement_items") if isinstance(draft.get("requirement_items"), list) else []
missing_info = draft.get("missing_info") if isinstance(draft.get("missing_info"), list) else []
feasibility = str(draft.get("feasibility") or "待讨论").strip() or "待讨论"
feasibility_reason = str(draft.get("feasibility_reason") or "").strip()
assignee_name = str(draft.get("recommended_assignee_name") or "待指定").strip() or "待指定"
assignee_workload = DraftGeneratorService._safe_float(draft.get("recommended_assignee_workload"))
assignee_reason = str(draft.get("recommended_assignee_reason") or "").strip()
plan = draft.get("followup_plan") if isinstance(draft.get("followup_plan"), list) else []
plan_lines: list[str] = []
for row in plan[:2]:
if not isinstance(row, dict):
continue
when = str(row.get("when") or "待定").strip()[:20] or "待定"
action = str(row.get("action") or "补充执行动作").strip()[:80] or "补充执行动作"
plan_lines.append(f"· {when}:{action}")
if not plan_lines:
plan_lines = ["· 待定:补充跟进计划"]
workload_text = f"{assignee_workload:.1f}" if assignee_workload % 1 else f"{int(assignee_workload)}"
feasibility_reason_line = feasibility_reason if feasibility_reason else "需补充约束后再确认。"
assignee_reason_line = assignee_reason if assignee_reason else "建议结合成员技能与当前负载综合判断。"
similar_lines = DraftGeneratorService._render_bullet_items(
similar_projects,
default_line="· 未发现同名或高相似项目,可作为新立项继续评审。",
formatter=DraftGeneratorService._render_similar_project_line,
max_items=3,
)
mvp_lines = DraftGeneratorService._render_bullet_items(
mvp_items,
default_line="· 待定:先明确目标后补齐 MVP 范围",
formatter=DraftGeneratorService._render_text_item_line,
max_items=5,
)
requirement_lines = DraftGeneratorService._render_bullet_items(
requirement_items,
default_line="· 待定:补充需求清单",
formatter=DraftGeneratorService._render_text_item_line,
max_items=8,
)
missing_info_lines = DraftGeneratorService._render_bullet_items(
missing_info,
default_line="· 暂无",
formatter=DraftGeneratorService._render_text_item_line,
max_items=5,
)
prospect_lines = DraftGeneratorService._split_paragraph_lines(
prospect_assessment or "该项目前景尚可,但建议先确认范围边界。"
)
return "\n".join(
[
"📋 我先按产品经理视角起草一版评审建议,请确认:",
"",
f"📌 {title}",
summary,
"",
"🔎 同名 / 相似项目:",
*similar_lines,
"",
"🚀 MVP 建议:",
*mvp_lines,
"",
"📈 前景判断:",
*prospect_lines,
"",
"🧩 需求清单:",
*requirement_lines,
"",
"❓ 还缺的信息:",
*missing_info_lines,
"",
f"✅ 可行性:{feasibility}",
feasibility_reason_line,
"",
f"👤 推荐负责人:{assignee_name} · 当前负载{workload_text}%",
assignee_reason_line,
"",
"📅 跟进计划:",
*plan_lines,
"",
"---",
"回复 确认 → 入库",
"回复 修改 [具体修改意见] → 重新起草",
"回复 取消 → 放弃",
"⏰ 24小时内未确认自动取消",
]
)
@staticmethod
def _normalize_title(raw_title: str) -> str:
title = str(raw_title or "").strip()
if not title:
return "需求草稿"
return title[:20]
@staticmethod
def _normalize_summary(raw_summary: str, *, fallback_text: str) -> str:
summary = " ".join(str(raw_summary or "").split()).strip()
if not summary:
summary = " ".join(str(fallback_text or "").split()).strip()
if not summary:
summary = "请确认该需求是否需要立即入库。"
return summary[:80]
@staticmethod
def _normalize_followup_plan(value: Any) -> list[dict[str, str]]:
if not isinstance(value, list):
return []
normalized: list[dict[str, str]] = []
for row in value[:3]:
if not isinstance(row, dict):
continue
when = str(row.get("when") or "").strip()[:20]
action = str(row.get("action") or "").strip()[:80]
if not when or not action:
continue
normalized.append({"when": when, "action": action})
return normalized
@staticmethod
def _normalize_active_projects(value: Any) -> list[dict[str, Any]]:
if not isinstance(value, list):
return []
normalized: list[dict[str, Any]] = []
for row in value[:20]:
if not isinstance(row, dict):
continue
project_id = DraftGeneratorService._safe_int(row.get("id"))
title = str(row.get("title") or row.get("name") or "").strip()[:120]
if project_id is None or not title:
continue
normalized.append(
{
"project_id": project_id,
"title": title,
"name": str(row.get("name") or title).strip()[:120],
"description": str(row.get("description") or "").strip()[:300],
"status": str(row.get("status") or "").strip()[:20],
}
)
return normalized
def _build_similar_project_candidates(
self,
*,
project_title: str,
project_description: str,
message_text: str,
active_projects: list[dict[str, Any]],
) -> list[dict[str, Any]]:
query = " ".join(
part
for part in [str(project_title or "").strip(), str(project_description or "").strip(), str(message_text or "").strip()]
if part
)
if not query:
return []
candidates: list[dict[str, Any]] = []
for row in active_projects:
project_id = self._safe_int(row.get("project_id"))
title = str(row.get("title") or row.get("name") or "").strip()
if project_id is None or not title:
continue
description = str(row.get("description") or "").strip()
candidate_text = f"{title} {description}".strip()
score = self._score_similarity(query, candidate_text)
if score < 0.22:
continue
reuse_suggestion = "建议先评估是否复用现有项目,避免重复建设。"
if score >= 0.55:
reuse_suggestion = "名称或目标高度接近,优先确认是否复用或合并。"
difference = "与当前需求主题接近,建议重点确认范围边界。"
if description and len(description) > 20:
difference = "现有项目已有一定描述,建议对比当前需求的目标、范围和交付边界。"
risk = "若范围重叠,可能造成重复建设或维护分散。"
candidates.append(
{
"project_id": project_id,
"title": title,
"difference": difference,
"risk": risk,
"reuse_suggestion": reuse_suggestion,
"match_score": round(min(score, 1.0), 3),
}
)
candidates.sort(key=lambda item: (-float(item.get("match_score") or 0.0), int(item.get("project_id") or 0)))
return candidates[:3]
@staticmethod
def _score_similarity(left: str, right: str) -> float:
left_norm = re.sub(r"\s+", "", str(left or "").lower())
right_norm = re.sub(r"\s+", "", str(right or "").lower())
if not left_norm or not right_norm:
return 0.0
base = SequenceMatcher(None, left_norm, right_norm).ratio()
if left_norm in right_norm or right_norm in left_norm:
base = max(base, 0.75)
shared = set(re.findall(r"[\u4e00-\u9fff]{2,}", left_norm)) & set(re.findall(r"[\u4e00-\u9fff]{2,}", right_norm))
if shared:
base = min(1.0, base + min(len(shared) * 0.08, 0.2))
return base
@staticmethod
def _normalize_similar_projects(
value: Any,
*,
fallback_candidates: list[dict[str, Any]],
active_projects: list[dict[str, Any]],
original_text: str,
title: str,
) -> list[dict[str, Any]]:
normalized: list[dict[str, Any]] = []
if isinstance(value, list):
for row in value[:5]:
if isinstance(row, dict):
project_id = DraftGeneratorService._safe_int(row.get("project_id") or row.get("id"))
project_title = str(row.get("title") or row.get("name") or "").strip()[:120]
if project_id is None or not project_title:
continue
normalized.append(
{
"project_id": project_id,
"title": project_title,
"difference": str(row.get("difference") or "").strip()[:160] or "需确认范围差异。",
"risk": str(row.get("risk") or "").strip()[:160] or "可能存在重复建设风险。",
"reuse_suggestion": str(row.get("reuse_suggestion") or row.get("suggestion") or "").strip()[:160]
or "建议先评估是否复用或合并。",
}
)
elif isinstance(row, str) and row.strip():
normalized.append(
{
"project_id": None,
"title": row.strip()[:120],
"difference": "模型未提供详细差异,建议人工确认。",
"risk": "存在重复建设的可能。",
"reuse_suggestion": "建议先核对项目是否同名或高度相似。",
}
)
if normalized:
return normalized[:3]
if fallback_candidates:
return [
{
"project_id": int(row.get("project_id")),
"title": str(row.get("title") or "").strip()[:120],
"difference": str(row.get("difference") or "").strip()[:160] or "需确认范围差异。",
"risk": str(row.get("risk") or "").strip()[:160] or "可能存在重复建设风险。",
"reuse_suggestion": str(row.get("reuse_suggestion") or "").strip()[:160] or "建议先评估是否复用或合并。",
}
for row in fallback_candidates[:3]
if DraftGeneratorService._safe_int(row.get("project_id")) is not None
]
query = " ".join(part for part in [title, original_text] if str(part or "").strip())
for row in active_projects[:3]:
project_title = str(row.get("title") or row.get("name") or "").strip()
if not project_title:
continue
if query and (project_title in query or query in project_title):
normalized.append(
{
"project_id": int(row.get("project_id") or 0),
"title": project_title,
"difference": "名称高度接近,建议确认是否为同一项目范围。",
"risk": "若直接新建,容易造成重复建设。",
"reuse_suggestion": "建议复用现有项目或先合并范围。",
}
)
return normalized[:3]
@staticmethod
def _normalize_string_list(
value: Any,
*,
fallback: list[str],
max_items: int,
max_length: int,
) -> list[str]:
items: list[str] = []
if isinstance(value, list):
for row in value:
if isinstance(row, str):
text = " ".join(row.split()).strip()[:max_length]
elif isinstance(row, dict):
text = " ".join(
str(row.get(key) or "").strip()
for key in ("text", "title", "content", "summary", "name")
if str(row.get(key) or "").strip()
).strip()[:max_length]
if not text:
text = " ".join(str(row.get("value") or "").split()).strip()[:max_length]
else:
text = ""
if text:
items.append(text)
if len(items) >= max_items:
break
if items:
return items[:max_items]
return [str(item).strip()[:max_length] for item in fallback if str(item).strip()][:max_items]
@staticmethod
def _render_bullet_items(
items: list[Any],
*,
default_line: str,
formatter: Any,
max_items: int,
) -> list[str]:
lines: list[str] = []
if isinstance(items, list):
for item in items[:max_items]:
line = formatter(item)
if line:
lines.append(line)
if not lines:
lines = [default_line]
return lines
@staticmethod
def _render_text_item_line(item: Any) -> str:
if isinstance(item, str):
text = " ".join(item.split()).strip()
elif isinstance(item, dict):
text = " ".join(
str(item.get(key) or "").strip()
for key in ("text", "title", "content", "summary", "name")
if str(item.get(key) or "").strip()
).strip()
if not text:
text = " ".join(str(item.get("value") or "").split()).strip()
else:
text = ""
if not text:
return ""
return f"· {text[:120]}"
@staticmethod
def _render_similar_project_line(item: Any) -> str:
if not isinstance(item, dict):
return DraftGeneratorService._render_text_item_line(item)
project_id = DraftGeneratorService._safe_int(item.get("project_id"))
title = str(item.get("title") or "").strip()[:60]
difference = str(item.get("difference") or "").strip()[:120]
risk = str(item.get("risk") or "").strip()[:80]
reuse = str(item.get("reuse_suggestion") or "").strip()[:100]
parts = []
if project_id is not None:
parts.append(f"#{project_id}")
if title:
parts.append(title)
prefix = " ".join(parts) or "相似项目"
detail_parts = [part for part in [difference, risk, reuse] if part]
detail = ";".join(detail_parts) if detail_parts else "建议先确认范围边界。"
return f"· {prefix}:{detail}"
@staticmethod
def _split_paragraph_lines(text: str) -> list[str]:
normalized = " ".join(str(text or "").split()).strip()
if not normalized:
return ["· 暂无"]
return [f"· {chunk.strip()}" for chunk in re.split(r"[。;;]\s*", normalized) if chunk.strip()][:3]
@staticmethod
def _safe_int(value: Any) -> Optional[int]:
try:
if value is None or value == "":
return None
return int(value)
except Exception:
return None
@staticmethod
def _safe_float(value: Any) -> float:
try:
return float(value or 0.0)
except Exception:
return 0.0