970 lines
37 KiB
Python
970 lines
37 KiB
Python
"""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)
|