PMFlow/backend/app/services/ai_steward.py

970 lines
37 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.

"""AI Steward Service - 主动式 AI 管家核心服务。"""
from __future__ import annotations
import asyncio
import ast
import json
import os
import re
import time
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 ..utils.title import compact_title
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__)
def _env_float(name: str, default: float) -> float:
raw = os.getenv(name)
if raw is None:
return float(default)
try:
return float(str(raw).strip())
except Exception:
return float(default)
def _env_int(name: str, default: int) -> int:
raw = os.getenv(name)
if raw is None:
return int(default)
try:
return int(str(raw).strip())
except Exception:
return int(default)
PROJECT_CREATION_DRAFT_TIMEOUT_SECONDS = max(
8.0,
_env_float("AI_STEWARD_PROJECT_CREATION_STEP_TIMEOUT_SECONDS", 30.0),
)
PROJECT_CREATION_STEP_RETRY_COUNT = max(
0,
_env_int("AI_STEWARD_PROJECT_CREATION_STEP_RETRY_COUNT", 1),
)
PROJECT_CREATION_JSON_PARSE_RETRY_COUNT = max(
0,
_env_int("AI_STEWARD_PROJECT_CREATION_JSON_PARSE_RETRY_COUNT", 1),
)
PROJECT_CREATION_TASK_CHAIN_TIMEOUT_SECONDS = max(
PROJECT_CREATION_DRAFT_TIMEOUT_SECONDS,
_env_float("AI_STEWARD_PROJECT_CREATION_TASK_CHAIN_TIMEOUT_SECONDS", 60.0),
)
PROJECT_CREATION_STUB_MARKERS = (
"我暂时无法生成可靠回答",
"模型服务异常",
"请稍后重试",
)
PROJECT_CREATION_LOW_TO_HIGH_FUNCTION_MAP = {
"project_draft_analysis_glm47_fallback": "project_from_text_analysis",
"project_draft_signal_glm47_fallback": "project_from_text_signal",
"project_draft_charter_glm47_fallback": "project_from_text_charter",
"project_draft_task_chain_glm47_fallback": "project_from_text_task_chain",
}
PROJECT_CREATION_SERIAL_DRAFT_STEPS = _env_int("AI_STEWARD_PROJECT_CREATION_SERIAL_DRAFT_STEPS", 1) == 1
class AIStewardService:
"""主动式 AI 管家服务。"""
def __init__(self, db: AsyncSession):
self.db = db
self.llm = LLMService()
@staticmethod
def _normalize_project_title_candidate(candidate: str) -> str:
cleaned = re.sub(r"\s+", " ", str(candidate or "")).strip(" []【】()()::\"'“”")
if not cleaned:
return ""
cleaned = re.sub(
r"^(?:(?:项目|需求)(?:名字|名称|名)?(?:叫|是|为|名为|名称为|名字为)?|(?:项目|需求)?(?:名为|名称为|名字为|名称是|名字是|叫|为))\s*",
"",
cleaned,
flags=re.IGNORECASE,
).strip(" []【】()()::\"'“”")
if not cleaned:
return ""
normalized = re.sub(r"\s+", "", cleaned).lower()
generic_tokens = {
"项目",
"一个项目",
"新项目",
"项目列表",
"项目库",
"pmflow",
"pmflow项目列表",
}
if normalized in generic_tokens:
return ""
if any(token in normalized for token in ("创建项目", "新建项目", "新增项目", "不是创建信号")):
return ""
return compact_title(cleaned, max_len=24)
@classmethod
def _extract_project_title_from_instruction(cls, text: str) -> str:
cleaned = re.sub(r"\s+", " ", str(text or "")).strip()
if not cleaned:
return ""
# 优先去掉“不是X,是Y”中的前置纠正片段,避免把纠正话术落到标题里。
cleaned = re.sub(r"^.*?不是[^,。,;;]*信号[^,。,;;]*[,,;; ]*是", "", cleaned)
quoted_match = re.search(r"(?:项目|新项目)[^\"“”']{0,10}[\"“”']([^\"“”']{2,40})[\"“”']", cleaned)
if quoted_match:
return cls._normalize_project_title_candidate(quoted_match.group(1))
for pattern in (
r"(?:项目|需求)(?:名字|名称|名)?(?:叫|名为|名称是|名称为|是|为)[::,,\s]*(.+)",
r"(?:帮我|请|麻烦|给我)?(?:新增|增加|添加|创建|新建|建立|立项|开个|开一个|建个|建一个|做个|做一个|加个|加一个)(?:一个|个)?项目(?:叫|名为|名称是|名称为|是|为)?[::,,\s]*(.+)",
):
match = re.search(pattern, cleaned, flags=re.IGNORECASE)
if not match:
continue
candidate = re.sub(r"[,。,.;;!??].*$", "", match.group(1)).strip(" []【】()()::\"'“”")
candidate = re.sub(r"^(?:一个|个|新项目|项目|需求)\s*", "", candidate, flags=re.IGNORECASE)
candidate = re.sub(
r"^(?:(?:项目|需求)(?:名字|名称|名)?(?:叫|是|为|名为|名称为|名字为)?|(?:项目|需求)?(?:名为|名称为|名字为|名称是|名字是|叫|为))\s*",
"",
candidate,
flags=re.IGNORECASE,
)
candidate = re.sub(r"(?:因为|所以|用来|为了|方便).*$", "", candidate).strip()
normalized = cls._normalize_project_title_candidate(candidate)
if normalized:
return normalized
return ""
@staticmethod
def _looks_like_instruction_sentence(text: str) -> bool:
normalized = re.sub(r"\s+", "", str(text or "")).lower()
if not normalized:
return False
return "项目" in normalized and any(
token in normalized
for token in ("创建", "新建", "新增", "增加", "添加", "帮我", "给我", "不是", "信号")
)
@classmethod
def _compact_project_description(cls, *, input_text: str, title: str, draft_description: str) -> str:
raw = str(draft_description or "").strip()
if raw and not cls._looks_like_instruction_sentence(raw):
return raw[:4000]
if title:
return f"项目:{title}"[:4000]
return str(input_text or "").strip()[:4000]
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]:
if PROJECT_CREATION_SERIAL_DRAFT_STEPS:
signal_draft = await self._generate_competitive_signal_draft(analysis=analysis, tenant_id=tenant_id)
charter_draft = await self._generate_charter_draft(analysis=analysis, tenant_id=tenant_id)
task_drafts = await self._generate_task_chain_draft(analysis=analysis, tenant_id=tenant_id)
else:
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),
}
@staticmethod
def _looks_like_project_creation_stub_response(text: str) -> bool:
normalized = re.sub(r"\s+", "", str(text or "")).lower()
if not normalized:
return True
return any(marker in normalized for marker in PROJECT_CREATION_STUB_MARKERS)
@staticmethod
def _project_creation_high_tier_function(function_name: str) -> str:
return str(PROJECT_CREATION_LOW_TO_HIGH_FUNCTION_MAP.get(str(function_name or "").strip(), "") or "").strip()
@staticmethod
def _strip_code_fence(text: str) -> str:
raw = str(text or "").strip()
if not raw.startswith("```"):
return raw
chunks = raw.split("```")
if len(chunks) < 2:
return raw
candidate = chunks[1].strip()
if candidate.lower().startswith("json"):
candidate = candidate[4:].strip()
return candidate
@staticmethod
def _extract_balanced_json_segment(text: str, open_char: str, close_char: str) -> str:
raw = str(text or "")
start = raw.find(open_char)
if start < 0:
return ""
depth = 0
in_str = False
escaped = False
for idx in range(start, len(raw)):
ch = raw[idx]
if in_str:
if escaped:
escaped = False
elif ch == "\\":
escaped = True
elif ch == '"':
in_str = False
continue
if ch == '"':
in_str = True
continue
if ch == open_char:
depth += 1
elif ch == close_char:
depth -= 1
if depth == 0:
return raw[start : idx + 1].strip()
return ""
def _extract_json_candidate(self, text: str) -> str:
raw = self._strip_code_fence(text)
if raw.startswith("{") or raw.startswith("["):
return raw
object_candidate = self._extract_balanced_json_segment(raw, "{", "}")
array_candidate = self._extract_balanced_json_segment(raw, "[", "]")
if object_candidate and array_candidate:
return object_candidate if raw.find("{") <= raw.find("[") else array_candidate
return object_candidate or array_candidate or raw
@staticmethod
def _repair_json_text(raw: str) -> str:
text = str(raw or "")
if not text:
return text
buf: list[str] = []
in_str = False
escaped = False
for ch in text:
if in_str:
if escaped:
escaped = False
elif ch == "\\":
escaped = True
elif ch == '"':
in_str = False
elif ch == "\n":
buf.append("\\n")
continue
elif ch == "\r":
buf.append("\\r")
continue
elif ch == "\t":
buf.append("\\t")
continue
else:
if ch == '"':
in_str = True
buf.append(ch)
repaired = "".join(buf)
repaired = re.sub(r",\s*([}\]])", r"\1", repaired)
return repaired
def _parse_json_object_response(self, response_text: str, *, stage_label: str) -> Dict[str, Any]:
normalized = self._extract_json_candidate(response_text)
try:
payload = json.loads(normalized)
except Exception as exc:
repaired = self._repair_json_text(normalized)
try:
payload = json.loads(repaired)
except Exception:
try:
literal_payload = ast.literal_eval(normalized)
except Exception:
raise RuntimeError(f"{stage_label}:{exc}") from exc
payload = literal_payload
if not isinstance(payload, dict):
raise RuntimeError(f"{stage_label}:返回内容不是 JSON 对象")
return payload
async def _generate_project_creation_object(
self,
*,
prompt: str,
tenant_id: int,
function_name: str,
stage_label: str,
timeout_seconds: float | None = None,
) -> Dict[str, Any]:
max_rounds = PROJECT_CREATION_JSON_PARSE_RETRY_COUNT + 1
last_error: Exception | None = None
for round_index in range(max_rounds):
response = await self._generate_project_creation_text(
prompt=prompt,
tenant_id=tenant_id,
function_name=function_name,
timeout_seconds=float(timeout_seconds) if timeout_seconds is not None else PROJECT_CREATION_DRAFT_TIMEOUT_SECONDS,
)
try:
return self._parse_json_object_response(response, stage_label=stage_label)
except Exception as exc:
last_error = exc if isinstance(exc, Exception) else RuntimeError(str(exc))
logger.warning(
"ai_steward_project_creation_json_retry",
tenant_id=int(tenant_id),
function_name=function_name,
stage_label=stage_label,
round=round_index + 1,
error_type=type(last_error).__name__,
error=str(last_error),
)
raise RuntimeError(str(last_error or f"{stage_label}:unknown_error")) from last_error
async def _generate_project_creation_text(
self,
*,
prompt: str,
tenant_id: int,
function_name: str,
timeout_seconds: float = PROJECT_CREATION_DRAFT_TIMEOUT_SECONDS,
) -> str:
attempts_per_function = PROJECT_CREATION_STEP_RETRY_COUNT + 1
last_error: Exception | None = None
for attempt_index in range(attempts_per_function):
started_at = time.perf_counter()
try:
response = await asyncio.wait_for(
self.llm.generate(
prompt,
tenant_id=tenant_id,
function_name=function_name,
response_mode="fast",
use_prompt_profile=False,
temperature=0.0,
max_tokens=2200,
),
timeout=timeout_seconds,
)
cleaned_response = str(response or "").strip()
elapsed_seconds = round(time.perf_counter() - started_at, 2)
if self._looks_like_project_creation_stub_response(cleaned_response):
raise RuntimeError("project_creation_stub_response")
logger.info(
"ai_steward_project_creation_step_ok",
tenant_id=int(tenant_id),
function_name=function_name,
attempt=attempt_index + 1,
elapsed_seconds=elapsed_seconds,
)
return cleaned_response
except Exception as exc:
last_error = exc if isinstance(exc, Exception) else RuntimeError(str(exc))
high_tier_function = self._project_creation_high_tier_function(function_name)
if high_tier_function:
high_started_at = time.perf_counter()
try:
fallback_response = await asyncio.wait_for(
self.llm.generate(
prompt,
tenant_id=tenant_id,
function_name=high_tier_function,
response_mode="fast",
use_prompt_profile=False,
temperature=0.0,
max_tokens=2200,
),
timeout=timeout_seconds,
)
cleaned_fallback = str(fallback_response or "").strip()
high_elapsed_seconds = round(time.perf_counter() - high_started_at, 2)
if self._looks_like_project_creation_stub_response(cleaned_fallback):
raise RuntimeError("project_creation_stub_response")
logger.info(
"ai_steward_project_creation_step_escalated_ok",
tenant_id=int(tenant_id),
function_name=function_name,
high_tier_function=high_tier_function,
attempt=attempt_index + 1,
elapsed_seconds=high_elapsed_seconds,
)
return cleaned_fallback
except Exception as high_exc:
last_error = high_exc if isinstance(high_exc, Exception) else RuntimeError(str(high_exc))
logger.warning(
"ai_steward_project_creation_step_escalated_retry",
tenant_id=int(tenant_id),
function_name=function_name,
high_tier_function=high_tier_function,
attempt=attempt_index + 1,
elapsed_seconds=round(time.perf_counter() - high_started_at, 2),
error_type=type(high_exc).__name__,
error=str(high_exc),
)
logger.warning(
"ai_steward_project_creation_step_retry",
tenant_id=int(tenant_id),
function_name=function_name,
attempt=attempt_index + 1,
elapsed_seconds=round(time.perf_counter() - started_at, 2),
error_type=type(exc).__name__,
error=str(exc),
)
raise RuntimeError(
f"AI 管家项目创建步骤失败:function={function_name}, reason={last_error or 'unknown_error'}"
) from last_error
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,
}
)
extracted_title = self._extract_project_title_from_instruction(input_text)
draft_title = str(project_draft.get("title") or "").strip()
if self._looks_like_instruction_sentence(draft_title):
draft_title = ""
normalized_title = (
self._normalize_project_title_candidate(draft_title)
or extracted_title
or compact_title(str(input_text or "").strip(), max_len=24)
or "未命名项目"
)
draft_name = str(project_draft.get("name") or "").strip()
normalized_name = self._normalize_project_title_candidate(draft_name) or normalized_title
normalized_project = {
"name": normalized_name[:50] or "未命名项目",
"title": normalized_title[:50] or "未命名项目",
"description": self._compact_project_description(
input_text=input_text,
title=normalized_title,
draft_description=str(project_draft.get("description") or ""),
),
"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,不要其他内容。必须返回可被 json.loads 直接解析的单个对象,不要 markdown。"""
analysis = await self._generate_project_creation_object(
prompt=prompt,
tenant_id=tenant_id,
function_name="project_draft_analysis_glm47_fallback",
stage_label="AI 管家项目需求分析失败",
)
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"}}
}}
只返回 JSON,不要 markdown,不要额外解释。"""
payload = await self._generate_project_creation_object(
prompt=prompt,
tenant_id=tenant_id,
function_name="project_draft_signal_glm47_fallback",
stage_label="AI 管家竞品信号生成失败",
)
return payload
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": {{
"objectives": ["目标1", "目标2"],
"success_criteria": ["标准1", "标准2"],
"constraints": ["约束1", "约束2"],
"milestones": ["里程碑1", "里程碑2"]
}},
"success_criteria": ["标准1", "标准2"],
"constraints": ["约束1", "约束2"]
}}
只返回 JSON,不要 markdown,不要额外解释。"""
charter_data = await self._generate_project_creation_object(
prompt=prompt,
tenant_id=tenant_id,
function_name="project_draft_charter_glm47_fallback",
stage_label="AI 管家项目宪章生成失败",
)
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. 任务列表(按优先级排序,最多 4 条)
2. 每个任务包含:名称、类型、预估工时、依赖关系、技能要求
3. 每个 description 必须控制在 60 字以内,不要换行
以 JSON 格式返回:
{{
"tasks": [
{{
"title": "任务名称",
"type": "backend/frontend/ai_engineer",
"estimated_hours": 8,
"description": "任务描述",
"priority": "high/medium/low",
"required_skills": ["技能1", "技能2"]
}}
]
}}
只返回 JSON,不要 markdown,不要额外解释。"""
tasks_data = await self._generate_project_creation_object(
prompt=prompt,
tenant_id=tenant_id,
function_name="project_draft_task_chain_glm47_fallback",
stage_label="AI 管家任务链生成失败",
timeout_seconds=PROJECT_CREATION_TASK_CHAIN_TIMEOUT_SECONDS,
)
tasks_list = tasks_data.get("tasks", [])
if isinstance(tasks_list, list) and tasks_list:
return tasks_list
raise RuntimeError("AI 管家任务链生成失败:返回 tasks 为空")
@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)