"""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)