PMFlow/backend/app/services/ai_steward.py

657 lines
24 KiB
Python
Raw Permalink 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.

"""AI Steward Service - 主动式 AI 管家核心服务。"""
from __future__ import annotations
import asyncio
import json
from typing import Any, Dict, List
from sqlalchemy.ext.asyncio import AsyncSession
from ..core.logging import get_logger
from ..database import get_db
from ..models import CognitiveConflict, Project, ProjectSignalLink, Signal, TruthNode
from ..utils.project_members import ensure_project_default_members
from ..utils.sequence import ensure_project_id_sequence
from .adaptive_rag_service import AdaptiveRAGService
from .llm import LLMService
from .task_persistence import (
build_task_create_draft,
normalize_skill_names,
persist_task,
resolve_skill_tag_ids_by_names,
)
logger = get_logger(__name__)
PROJECT_CREATION_DRAFT_TIMEOUT_SECONDS = 8.0
class AIStewardService:
"""主动式 AI 管家服务。"""
def __init__(self, db: AsyncSession):
self.db = db
self.llm = LLMService()
async def create_project_from_natural_language(
self,
input_text: str,
creator_id: int,
tenant_id: int = 1,
) -> Dict[str, Any]:
"""零配置项目创建,统一走 draft -> normalize -> persist 三段式。"""
analysis = await self._analyze_project_input(input_text, tenant_id)
draft = await self._generate_project_creation_draft(
input_text=input_text,
analysis=analysis,
tenant_id=tenant_id,
)
normalized = await self._normalize_project_creation_draft(
draft=draft,
input_text=input_text,
creator_id=creator_id,
tenant_id=tenant_id,
)
payload = await self._persist_project_creation_draft(
normalized=normalized,
input_text=input_text,
creator_id=creator_id,
tenant_id=tenant_id,
)
await self.db.commit()
try:
charter = payload["charter"]
project = payload["project"]
asyncio.create_task(
AdaptiveRAGService.upsert_pmflow_record_async(
tenant_id=tenant_id,
record_type="charter",
record_id=int(charter["id"]),
project_id=int(project["id"]),
title=str(charter.get("title") or "项目宪章"),
content=json.dumps(charter.get("content") or {}, ensure_ascii=False),
)
)
for task in payload.get("tasks", []):
task_id = int(str(task.get("id") or "0") or 0)
if task_id <= 0:
continue
asyncio.create_task(
AdaptiveRAGService.upsert_pmflow_record_async(
tenant_id=tenant_id,
record_type="task",
record_id=task_id,
project_id=int(project["id"]),
title=str(task.get("title") or "任务"),
content=str(task.get("description") or ""),
)
)
except Exception:
pass
return payload
async def _generate_project_creation_draft(
self,
*,
input_text: str,
analysis: Dict[str, Any],
tenant_id: int,
) -> dict[str, Any]:
signal_draft, charter_draft, task_drafts = await asyncio.gather(
self._generate_competitive_signal_draft(analysis=analysis, tenant_id=tenant_id),
self._generate_charter_draft(analysis=analysis, tenant_id=tenant_id),
self._generate_task_chain_draft(analysis=analysis, tenant_id=tenant_id),
)
return {
"analysis": analysis,
"project": {
"name": analysis.get("project_name") or input_text[:20] or "未命名项目",
"title": analysis.get("project_title") or input_text[:50] or "未命名项目",
"description": input_text,
"assigned_role": analysis.get("primary_role") or "backend",
"domain_id": analysis.get("domain_id"),
},
"signal": signal_draft,
"charter": charter_draft,
"tasks": task_drafts,
"risks": self._build_risk_alert_drafts(analysis=analysis),
}
async def _generate_project_creation_text(
self,
*,
prompt: str,
tenant_id: int,
function_name: str,
timeout_seconds: float = PROJECT_CREATION_DRAFT_TIMEOUT_SECONDS,
) -> str:
try:
return await asyncio.wait_for(
self.llm.generate(
prompt,
db=self.db,
tenant_id=tenant_id,
function_name=function_name,
),
timeout=timeout_seconds,
)
except Exception as exc:
logger.warning(
"ai_steward_project_creation_step_fallback",
tenant_id=int(tenant_id),
function_name=function_name,
error=str(exc),
)
return ""
async def _normalize_project_creation_draft(
self,
*,
draft: dict[str, Any],
input_text: str,
creator_id: int,
tenant_id: int,
) -> dict[str, Any]:
analysis = draft.get("analysis") if isinstance(draft.get("analysis"), dict) else {}
project_draft = draft.get("project") if isinstance(draft.get("project"), dict) else {}
charter_draft = draft.get("charter") if isinstance(draft.get("charter"), dict) else {}
signal_draft = draft.get("signal") if isinstance(draft.get("signal"), dict) else {}
risk_drafts = draft.get("risks") if isinstance(draft.get("risks"), list) else []
normalized_task_drafts = []
task_audit: list[dict[str, Any]] = []
for raw_task in (draft.get("tasks") if isinstance(draft.get("tasks"), list) else [])[:12]:
if not isinstance(raw_task, dict):
continue
raw_required_skills = normalize_skill_names(raw_task.get("required_skills"))
resolution = await resolve_skill_tag_ids_by_names(
self.db,
tenant_id=tenant_id,
skill_names=raw_required_skills,
)
validation_error = None
try:
task_draft = build_task_create_draft(
title=str(raw_task.get("title") or ""),
description=str(raw_task.get("description") or "").strip() or None,
required_skill_tag_ids=resolution.matched_ids,
priority=raw_task.get("priority"),
status="todo",
estimated_hours=raw_task.get("estimated_hours"),
due_date=raw_task.get("due_date"),
)
except ValueError as exc:
validation_error = str(exc)
task_draft = build_task_create_draft(
title=str(raw_task.get("title") or ""),
description=str(raw_task.get("description") or "").strip() or None,
required_skill_tag_ids=resolution.matched_ids,
priority=raw_task.get("priority"),
status="todo",
estimated_hours=None,
due_date=None,
)
normalized_task_drafts.append(task_draft)
task_audit.append(
{
"raw_task_draft": raw_task,
"normalized_title": task_draft.title,
"required_skills_input": raw_required_skills,
"required_skill_tag_ids": resolution.matched_ids,
"skill_resolution": resolution.matched_items,
"dropped_required_skills": resolution.unmatched_names,
"validation_error": validation_error,
}
)
normalized_project = {
"name": str(project_draft.get("name") or "未命名项目").strip()[:50] or "未命名项目",
"title": str(project_draft.get("title") or input_text[:50] or "未命名项目").strip()[:50] or "未命名项目",
"description": str(project_draft.get("description") or input_text).strip()[:4000] or input_text[:4000],
"assigned_role": str(project_draft.get("assigned_role") or "backend").strip()[:50] or "backend",
"domain_id": analysis.get("domain_id"),
}
normalized_charter_content = charter_draft.get("content") if isinstance(charter_draft.get("content"), dict) else {
"rationale": str(charter_draft.get("content") or ""),
"success_criteria": charter_draft.get("success_criteria") or [],
"constraints": charter_draft.get("constraints") or [],
"signal_ids": [],
}
normalized_signal = {
"content": str(signal_draft.get("content") or "").strip()[:2000] or f"竞品分析:{normalized_project['title']}",
"impact_score": float(signal_draft.get("impact_score") or 0.8),
"priority": str(signal_draft.get("priority") or "high").strip().lower() or "high",
"strategic_analysis": signal_draft.get("strategic_analysis") if isinstance(signal_draft.get("strategic_analysis"), dict) else {},
}
normalized_risks = [risk for risk in risk_drafts if isinstance(risk, dict)]
return {
"analysis": analysis,
"project": normalized_project,
"signal": normalized_signal,
"charter": {
"title": str(charter_draft.get("title") or f"{normalized_project['name']}项目宪章").strip()[:200] or "项目宪章",
"content": normalized_charter_content,
},
"tasks": normalized_task_drafts,
"task_audit": task_audit,
"risks": normalized_risks,
"creator_id": int(creator_id),
}
async def _persist_project_creation_draft(
self,
*,
normalized: dict[str, Any],
input_text: str,
creator_id: int,
tenant_id: int,
) -> dict[str, Any]:
project_data = normalized["project"]
await ensure_project_id_sequence(self.db)
project = Project(
name=project_data["name"],
title=project_data["title"],
description=project_data["description"],
owner_id=int(creator_id),
status="active",
domain_id=project_data.get("domain_id"),
assigned_role=project_data["assigned_role"],
signal_count=0,
tenant_id=int(tenant_id),
)
self.db.add(project)
await self.db.flush()
await ensure_project_default_members(
self.db,
tenant_id=int(tenant_id),
project_id=int(project.id),
owner_id=int(creator_id),
assigned_role=project.assigned_role,
)
signal_data = normalized["signal"]
signal = Signal(
source="AI-Steward",
content=signal_data["content"],
impact_score=signal_data["impact_score"],
priority=signal_data["priority"],
processed=True,
strategic_analysis=json.dumps(signal_data.get("strategic_analysis") or {}, ensure_ascii=False),
tasks_generated=json.dumps([]),
tenant_id=int(tenant_id),
)
self.db.add(signal)
await self.db.flush()
self.db.add(
ProjectSignalLink(
tenant_id=int(tenant_id),
project_id=int(project.id),
signal_id=int(signal.id),
link_type="competitive_analysis",
relevance_score=0.9,
)
)
charter_data = normalized["charter"]
charter = TruthNode(
node_type="charter",
project_id=int(project.id),
created_by=int(creator_id),
title=charter_data["title"],
content=charter_data["content"],
version=1,
status="active",
tenant_id=int(tenant_id),
)
self.db.add(charter)
await self.db.flush()
persisted_tasks: list[dict[str, Any]] = []
persisted_task_ids: list[int] = []
for task_draft in normalized["tasks"]:
task = await persist_task(
self.db,
tenant_id=int(tenant_id),
truth_node_id=int(charter.id),
project_id=int(project.id),
draft=task_draft,
actor_user_id=int(creator_id),
action="task.create",
remark="ai_steward.project_from_text",
commit=False,
refresh_workload=False,
)
persisted_task_ids.append(int(task.id))
persisted_tasks.append(
{
"id": str(task.id),
"title": task.title,
"description": task.description or "",
"estimated_hours": float(task.estimated_hours or 0),
"priority": task.priority,
"status": task.status,
}
)
risks_payload: list[dict[str, Any]] = []
for risk in normalized["risks"]:
description = str(risk.get("description") or "").strip()
conflict_type = str(risk.get("type") or "risk").strip() or "risk"
if not description:
continue
self.db.add(
CognitiveConflict(
truth_node_id=int(charter.id),
conflict_type=conflict_type,
description=description,
involved_user_ids=[],
status="active",
tenant_id=int(tenant_id),
)
)
risks_payload.append(
{
"type": conflict_type,
"description": description,
"threshold_hours": risk.get("threshold_hours"),
"threshold": risk.get("threshold"),
"severity": risk.get("severity"),
}
)
logger.info(
"ai_steward_project_from_text_persisted",
tenant_id=int(tenant_id),
creator_id=int(creator_id),
project_id=int(project.id),
signal_id=int(signal.id),
charter_id=int(charter.id),
persisted_task_ids=persisted_task_ids,
task_audit=normalized["task_audit"],
input_text=input_text[:500],
)
return {
"project": {
"id": str(project.id),
"name": project.name,
"title": project.title,
"description": project.description,
"status": project.status,
},
"signal": {
"id": str(signal.id),
"content": signal.content,
"impact_score": signal.impact_score,
},
"charter": {
"id": str(charter.id),
"title": charter.title,
"content": charter.content,
},
"tasks": persisted_tasks,
"risks": risks_payload,
"analysis": normalized["analysis"],
}
async def _analyze_project_input(self, input_text: str, tenant_id: int) -> Dict[str, Any]:
"""使用 AI 分析项目输入,提取关键信息。"""
prompt = f"""分析以下项目需求,提取关键信息并以 JSON 格式返回:
用户输入:{input_text}
请提取以下信息:
{{
"project_name": "项目名称(简短)",
"project_title": "项目标题",
"tech_stack": ["技术栈列表"],
"target_audience": "目标用户",
"timeline": "时间周期",
"primary_role": "主要角色类型(ai_engineer/backend/frontend)",
"domain_id": 1,
"key_features": ["核心功能列表"],
"competitors": ["竞品列表"],
"success_metrics": ["成功指标"]
}}
只返回 JSON,不要其他内容。"""
response = await self._generate_project_creation_text(
prompt=prompt,
tenant_id=tenant_id,
function_name="project_from_text_analysis",
)
try:
response = response.strip()
if response.startswith("```"):
response = response.split("```")[1]
if response.startswith("json"):
response = response[4:]
analysis = json.loads(response.strip())
except Exception:
analysis = {
"project_name": input_text[:20],
"project_title": input_text[:50],
"tech_stack": ["GPT-4o-mini"],
"target_audience": "通用",
"timeline": "3周",
"primary_role": "ai_engineer",
"domain_id": 1,
"key_features": [],
"competitors": [],
"success_metrics": [],
}
return analysis
async def _generate_competitive_signal_draft(
self,
*,
analysis: Dict[str, Any],
tenant_id: int,
) -> Dict[str, Any]:
competitors = analysis.get("competitors", [])
if not competitors:
competitors = ["行业同类产品"]
prompt = f"""基于以下项目信息,生成竞品分析信号:
项目:{analysis.get('project_title')}
技术栈:{', '.join(analysis.get('tech_stack', []))}
竞品:{', '.join(competitors)}
请生成:
1. 竞品现状分析
2. 差异化机会
3. 技术风险评估
以 JSON 格式返回:
{{
"content": "信号内容(200字以内)",
"impact_score": 0.9,
"priority": "high",
"strategic_analysis": {{"is_strategic": true, "threat_level": "MEDIUM"}}
}}"""
response = await self._generate_project_creation_text(
prompt=prompt,
tenant_id=tenant_id,
function_name="project_from_text_signal",
)
try:
response = response.strip()
if response.startswith("```"):
response = response.split("```")[1]
if response.startswith("json"):
response = response[4:]
return json.loads(response.strip())
except Exception:
return {
"content": f"竞品分析:{', '.join(competitors)} 的技术现状和差异化机会",
"impact_score": 0.8,
"priority": "high",
"strategic_analysis": {"is_strategic": True},
}
async def _generate_charter_draft(
self,
*,
analysis: Dict[str, Any],
tenant_id: int,
) -> Dict[str, Any]:
prompt = f"""基于以下项目信息,生成项目宪章:
项目:{analysis.get('project_title')}
描述:{analysis.get('project_title')}
技术栈:{', '.join(analysis.get('tech_stack', []))}
核心功能:{', '.join(analysis.get('key_features', []))}
成功指标:{', '.join(analysis.get('success_metrics', []))}
请生成包含以下内容的宪章:
1. 项目目标(明确、可衡量)
2. 成功标准(具体指标)
3. 技术约束
4. 时间节点
以 JSON 格式返回:
{{
"title": "宪章标题",
"content": "宪章完整内容(包含上述4个方面)",
"success_criteria": ["标准1", "标准2"],
"constraints": ["约束1", "约束2"]
}}"""
response = await self._generate_project_creation_text(
prompt=prompt,
tenant_id=tenant_id,
function_name="project_from_text_charter",
)
try:
response = response.strip()
if response.startswith("```"):
response = response.split("```")[1]
if response.startswith("json"):
response = response[4:]
charter_data = json.loads(response.strip())
except Exception:
charter_data = {
"title": f"{analysis.get('project_name')}项目宪章",
"content": f"项目目标:{analysis.get('project_title')}\n成功标准:按时上线",
"success_criteria": ["按时交付", "功能完整"],
"constraints": ["时间限制"],
}
raw_content = charter_data.get("content", "")
if isinstance(raw_content, dict):
content = dict(raw_content)
else:
content = {
"rationale": str(raw_content),
"success_criteria": charter_data.get("success_criteria", []),
"constraints": charter_data.get("constraints", []),
"signal_ids": [],
}
return {
"title": charter_data.get("title", "项目宪章"),
"content": content,
}
async def _generate_task_chain_draft(
self,
*,
analysis: Dict[str, Any],
tenant_id: int,
) -> List[Dict[str, Any]]:
prompt = f"""基于以下信息,生成完整的任务链:
项目:{analysis.get('project_title')}
技术栈:{', '.join(analysis.get('tech_stack', []))}
时间周期:{analysis.get('timeline', '3周')}
请生成:
1. 任务列表(按优先级排序)
2. 每个任务包含:名称、类型、预估工时、依赖关系、技能要求
以 JSON 格式返回:
{{
"tasks": [
{{
"title": "任务名称",
"type": "backend/frontend/ai_engineer",
"estimated_hours": 8,
"description": "任务描述",
"priority": "high/medium/low",
"required_skills": ["技能1", "技能2"]
}}
]
}}"""
response = await self._generate_project_creation_text(
prompt=prompt,
tenant_id=tenant_id,
function_name="project_from_text_task_chain",
)
try:
response = response.strip()
if response.startswith("```"):
response = response.split("```")[1]
if response.startswith("json"):
response = response[4:]
tasks_data = json.loads(response.strip())
tasks_list = tasks_data.get("tasks", [])
if isinstance(tasks_list, list) and tasks_list:
return tasks_list
except Exception:
pass
return [
{
"title": "技术方案设计",
"type": analysis.get("primary_role", "backend"),
"estimated_hours": 16,
"description": "完成项目技术方案设计",
"priority": "high",
"required_skills": ["系统设计"],
},
{
"title": "核心功能开发",
"type": analysis.get("primary_role", "backend"),
"estimated_hours": 40,
"description": "完成核心功能开发",
"priority": "high",
"required_skills": ["开发"],
},
]
@staticmethod
def _build_risk_alert_drafts(*, analysis: Dict[str, Any]) -> List[Dict[str, Any]]:
estimated_hours = analysis.get("estimated_hours", 40)
return [
{
"type": "schedule_delay",
"description": "任务超时预警",
"threshold_hours": estimated_hours * 1.2,
"severity": "medium",
},
{
"type": "quality_drop",
"description": "质量下降预警",
"threshold": 0.85,
"severity": "high",
},
{
"type": "skill_mismatch",
"description": "技能匹配度不足",
"threshold": 0.6,
"severity": "medium",
},
]
def get_ai_steward_service(db: AsyncSession) -> AIStewardService:
"""获取 AI Steward 服务实例。"""
return AIStewardService(db)