1053 lines
44 KiB
Python
1053 lines
44 KiB
Python
from __future__ import annotations
|
||
|
||
from .shared import *
|
||
from ...config.ai_prompts import PROMPT_PROJECT_REQUIREMENT_EXTRACTION
|
||
|
||
|
||
class AIStewardDraftGenerationMixin:
|
||
@staticmethod
|
||
def _task_chain_generation_routing_meta(*, tier_alias: str = "low") -> dict[str, Any]:
|
||
tier = "high" if str(tier_alias or "").strip().lower() == "high" else "low"
|
||
return {
|
||
"tier_alias": tier,
|
||
"planned_tier": tier,
|
||
"routing_decision": "force_low_first" if tier == "low" else "explicit_high_retry",
|
||
"response_contract": "structured",
|
||
"execution_mode": "local_planner",
|
||
}
|
||
|
||
@staticmethod
|
||
def _task_generation_ready_state() -> dict[str, Any]:
|
||
return {
|
||
"status": "ready",
|
||
"error_message": None,
|
||
}
|
||
|
||
@staticmethod
|
||
def _task_generation_pending_state(error: Exception | str) -> dict[str, Any]:
|
||
return {
|
||
"status": "pending",
|
||
"error_message": str(error or "").strip()[:240] or "task_generation_pending",
|
||
}
|
||
|
||
@staticmethod
|
||
def _normalize_generation_text_list(values: Any, *, max_items: int = 8, max_len: int = 120) -> list[str]:
|
||
if not isinstance(values, list):
|
||
return []
|
||
normalized: list[str] = []
|
||
for item in values:
|
||
if isinstance(item, dict):
|
||
text = (
|
||
str(item.get("title") or item.get("pain") or item.get("role") or item.get("source_name") or "").strip()
|
||
)
|
||
else:
|
||
text = str(item or "").strip()
|
||
if not text:
|
||
continue
|
||
normalized.append(text[:max_len])
|
||
return normalized[:max_items]
|
||
|
||
@staticmethod
|
||
def _normalize_requirement_items_for_prompt(items: Any, *, max_items: int = 12) -> list[dict[str, Any]]:
|
||
if not isinstance(items, list):
|
||
return []
|
||
normalized: list[dict[str, Any]] = []
|
||
for item in items:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
title = str(item.get("title") or "").strip()
|
||
description = str(item.get("description") or "").strip()
|
||
section = str(item.get("section") or "").strip()
|
||
if not any((title, description, section)):
|
||
continue
|
||
normalized.append(
|
||
{
|
||
"section": section[:40],
|
||
"title": title[:120],
|
||
"description": description[:200],
|
||
"priority": str(item.get("priority") or "").strip()[:20],
|
||
"source_kind": str(item.get("source_kind") or "").strip()[:40],
|
||
"source_name": str(item.get("source_name") or "").strip()[:120],
|
||
}
|
||
)
|
||
return normalized[:max_items]
|
||
|
||
@staticmethod
|
||
def _normalize_team_setup_for_prompt(items: Any, *, max_items: int = 8) -> list[dict[str, str]]:
|
||
if not isinstance(items, list):
|
||
return []
|
||
normalized: list[dict[str, str]] = []
|
||
for item in items:
|
||
if isinstance(item, dict):
|
||
role = str(item.get("role") or "").strip()
|
||
notes = str(item.get("notes") or "").strip()
|
||
else:
|
||
role = str(item or "").strip()
|
||
notes = ""
|
||
if not role and not notes:
|
||
continue
|
||
normalized.append({"role": role[:60], "notes": notes[:160]})
|
||
return normalized[:max_items]
|
||
|
||
@staticmethod
|
||
def _compact_generation_text(value: Any, *, max_len: int = 240) -> str:
|
||
text = str(value or "").strip()
|
||
if not text:
|
||
return ""
|
||
return re.sub(r"\s+", " ", text)[:max_len]
|
||
|
||
@staticmethod
|
||
def _bulletize_generation_items(items: list[str]) -> str:
|
||
cleaned = [str(item or "").strip() for item in items if str(item or "").strip()]
|
||
if not cleaned:
|
||
return ""
|
||
return "\n".join(f"- {item}" for item in cleaned[:8])
|
||
|
||
@classmethod
|
||
def _has_six_module_charter_content(cls, content: Any) -> bool:
|
||
if not isinstance(content, dict):
|
||
return False
|
||
modules = content.get("modules")
|
||
return isinstance(modules, dict) and all(f"module_{index}" in modules for index in range(1, 7))
|
||
|
||
@classmethod
|
||
def _build_six_module_charter_content_from_brief(cls, source: dict[str, Any]) -> dict[str, Any]:
|
||
if cls._has_six_module_charter_content(source):
|
||
return dict(source)
|
||
|
||
summary = cls._compact_generation_text(
|
||
source.get("summary")
|
||
or source.get("positioning_one_liner")
|
||
or source.get("background")
|
||
or source.get("scope")
|
||
)
|
||
target_customers = cls._compact_generation_text(
|
||
source.get("target_customers") or source.get("target_audience")
|
||
)
|
||
core_pain_points = cls._normalize_generation_text_list(source.get("core_pain_points"))
|
||
mvp_items = cls._normalize_generation_text_list(
|
||
source.get("mvp_items")
|
||
if isinstance(source.get("mvp_items"), list)
|
||
else source.get("key_features")
|
||
if isinstance(source.get("key_features"), list)
|
||
else source.get("objectives")
|
||
if isinstance(source.get("objectives"), list)
|
||
else source.get("mvp_features")
|
||
)
|
||
success_metrics = cls._normalize_generation_text_list(
|
||
source.get("success_metrics")
|
||
if isinstance(source.get("success_metrics"), list)
|
||
else source.get("success_criteria")
|
||
if isinstance(source.get("success_criteria"), list)
|
||
else source.get("validation_metrics", {}).get("product")
|
||
if isinstance(source.get("validation_metrics"), dict)
|
||
else []
|
||
)
|
||
team_setup = cls._normalize_team_setup_for_prompt(source.get("team_setup"))
|
||
requirement_items = cls._normalize_requirement_items_for_prompt(source.get("requirement_items"))
|
||
tech_stack = cls._normalize_generation_text_list(source.get("tech_stack"))
|
||
milestones = cls._normalize_generation_text_list(source.get("milestones"))
|
||
risks = cls._normalize_generation_text_list(source.get("risks"))
|
||
missing_info = cls._normalize_generation_text_list(source.get("missing_info"))
|
||
|
||
requirement_titles: list[str] = []
|
||
attachment_rows: list[str] = []
|
||
for item in requirement_items:
|
||
title = cls._compact_generation_text(item.get("title"), max_len=120)
|
||
description = cls._compact_generation_text(item.get("description"), max_len=160)
|
||
section = cls._compact_generation_text(item.get("section"), max_len=40)
|
||
source_name = cls._compact_generation_text(item.get("source_name"), max_len=80)
|
||
source_kind = cls._compact_generation_text(item.get("source_kind"), max_len=40)
|
||
if title:
|
||
requirement_titles.append(f"{section} - {title}" if section else title)
|
||
if description:
|
||
requirement_titles.append(description)
|
||
if source_name or source_kind:
|
||
attachment_rows.append(" / ".join(part for part in (source_kind, source_name) if part))
|
||
|
||
team_lines = []
|
||
for item in team_setup:
|
||
role = cls._compact_generation_text(item.get("role"), max_len=60)
|
||
notes = cls._compact_generation_text(item.get("notes"), max_len=160)
|
||
if role and notes:
|
||
team_lines.append(f"{role}:{notes}")
|
||
elif role:
|
||
team_lines.append(role)
|
||
|
||
problem_parts: list[str] = []
|
||
if target_customers and summary:
|
||
problem_parts.append(f"{target_customers}当前面临:{summary}")
|
||
elif target_customers:
|
||
problem_parts.append(target_customers)
|
||
elif summary:
|
||
problem_parts.append(summary)
|
||
if core_pain_points:
|
||
problem_parts.append(f"核心痛点:{';'.join(core_pain_points[:3])}")
|
||
problem_statement = "\n".join(problem_parts)
|
||
|
||
in_scope_items = mvp_items + requirement_titles
|
||
out_of_scope_items = missing_info
|
||
alignment_sections = []
|
||
if problem_statement:
|
||
alignment_sections.append(f"问题定义:{problem_statement}")
|
||
if in_scope_items:
|
||
alignment_sections.append(f"首期范围:{';'.join(in_scope_items[:4])}")
|
||
if success_metrics:
|
||
alignment_sections.append(f"验收指标:{';'.join(success_metrics[:4])}")
|
||
if team_lines:
|
||
alignment_sections.append(f"团队协同:{';'.join(team_lines[:4])}")
|
||
alignment_qa = "\n".join(alignment_sections)
|
||
|
||
dependency_map = cls._bulletize_generation_items(tech_stack)
|
||
milestones_text = cls._bulletize_generation_items(milestones)
|
||
if not milestones_text and cls._compact_generation_text(source.get("timeline"), max_len=80):
|
||
milestones_text = f"- {cls._compact_generation_text(source.get('timeline'), max_len=80)}"
|
||
|
||
risk_items = risks or core_pain_points
|
||
trigger_rules = success_metrics[:3] if success_metrics else []
|
||
search_tags = []
|
||
if target_customers:
|
||
search_tags.append(target_customers)
|
||
search_tags.extend(mvp_items[:3])
|
||
search_tags.extend(tech_stack[:3])
|
||
|
||
modules = {
|
||
"module_1": {
|
||
"problem_statement": problem_statement,
|
||
"goal_statement": cls._bulletize_generation_items(mvp_items),
|
||
"kpis": cls._bulletize_generation_items(success_metrics),
|
||
},
|
||
"module_2": {
|
||
"in_scope": cls._bulletize_generation_items(in_scope_items),
|
||
"out_of_scope": cls._bulletize_generation_items(out_of_scope_items),
|
||
"alignment_qa": alignment_qa,
|
||
},
|
||
"module_3": {
|
||
"primary_model": cls._compact_generation_text(
|
||
source.get("primary_model") or ";".join(tech_stack[:2]) or summary
|
||
),
|
||
"fallback_model": cls._compact_generation_text(source.get("fallback_model")),
|
||
"capability_table": cls._compact_generation_text(source.get("capability_table") or summary),
|
||
"dependency_map": dependency_map,
|
||
"business_view": cls._compact_generation_text(source.get("business_view") or summary),
|
||
"engineering_view": cls._compact_generation_text(
|
||
source.get("engineering_view") or ";".join(tech_stack[:4]) or ";".join(team_lines[:4])
|
||
),
|
||
},
|
||
"module_4": {
|
||
"milestones": milestones_text,
|
||
"role_effort_estimates": cls._bulletize_generation_items(team_lines),
|
||
"capability_owner_assignment": cls._bulletize_generation_items(team_lines),
|
||
"buffer_policy": cls._compact_generation_text(source.get("buffer_policy") or source.get("timeline")),
|
||
},
|
||
"module_5": {
|
||
"risk_register": cls._bulletize_generation_items(risk_items),
|
||
"trigger_rules": cls._bulletize_generation_items(trigger_rules),
|
||
"execution_rule": cls._compact_generation_text(source.get("scope") or summary),
|
||
},
|
||
"module_6": {
|
||
"attachment_metadata": cls._bulletize_generation_items(attachment_rows),
|
||
"attachment_summary": cls._compact_generation_text(source.get("attachment_summary") or summary),
|
||
"search_tags": cls._bulletize_generation_items(search_tags),
|
||
"governance_rule": cls._compact_generation_text(source.get("governance_rule")),
|
||
},
|
||
}
|
||
|
||
required_fields = {
|
||
"module_1": ("problem_statement", "goal_statement", "kpis"),
|
||
"module_2": ("in_scope", "out_of_scope", "alignment_qa"),
|
||
"module_3": ("primary_model", "fallback_model", "capability_table", "dependency_map"),
|
||
"module_4": ("milestones", "role_effort_estimates", "capability_owner_assignment"),
|
||
"module_5": ("risk_register", "trigger_rules", "execution_rule"),
|
||
"module_6": ("attachment_metadata", "attachment_summary", "search_tags"),
|
||
}
|
||
field_status: dict[str, dict[str, str]] = {}
|
||
field_evidence: dict[str, dict[str, list[dict[str, Any]]]] = {}
|
||
blocking_fields: list[str] = []
|
||
blocking_reasons: list[str] = []
|
||
for module_key, fields in required_fields.items():
|
||
field_status[module_key] = {}
|
||
field_evidence[module_key] = {}
|
||
for field_key in fields:
|
||
value = str(modules.get(module_key, {}).get(field_key) or "").strip()
|
||
status = "valid" if value else "evidence_insufficient"
|
||
field_status[module_key][field_key] = status
|
||
field_evidence[module_key][field_key] = []
|
||
if status != "valid":
|
||
blocking_fields.append(f"{module_key}.{field_key}")
|
||
blocking_reasons.append(f"{module_key}.{field_key} 缺少可验证内容")
|
||
|
||
return {
|
||
"schema_version": "charter_ai_first_v1",
|
||
"auto_generated_by_ai": True,
|
||
"metadata": {
|
||
"status": "draft",
|
||
"confidence_score": 0.8 if problem_statement and in_scope_items else 0.6,
|
||
},
|
||
"modules": modules,
|
||
"field_status": field_status,
|
||
"field_evidence": field_evidence,
|
||
"readiness": {
|
||
"ready_for_confirm": not blocking_fields,
|
||
"blocking_fields": blocking_fields,
|
||
"blocking_reasons": blocking_reasons,
|
||
},
|
||
"derived_artifacts": {
|
||
"task_seeds": [
|
||
{
|
||
"title": item,
|
||
"description": item,
|
||
"evidence_refs": ["module_2.in_scope"],
|
||
}
|
||
for item in in_scope_items[:4]
|
||
],
|
||
"verification_metrics": [
|
||
{
|
||
"name": item,
|
||
"expected": item,
|
||
"source_field": "module_1.kpis",
|
||
}
|
||
for item in success_metrics[:4]
|
||
],
|
||
"risk_gates": [
|
||
{
|
||
"name": item,
|
||
"action": modules["module_5"]["execution_rule"],
|
||
"source_fields": ["module_5.trigger_rules"],
|
||
}
|
||
for item in trigger_rules[:3]
|
||
],
|
||
},
|
||
"summary": summary,
|
||
"objectives": mvp_items,
|
||
"scope": alignment_qa,
|
||
"in_scope": in_scope_items,
|
||
"out_of_scope": out_of_scope_items,
|
||
"success_criteria": success_metrics,
|
||
"constraints": missing_info,
|
||
"milestones": milestones,
|
||
"risks": risk_items,
|
||
"signal_ids": [],
|
||
}
|
||
|
||
async def _load_available_skill_names(self, *, tenant_id: int, limit: int = 24) -> list[str]:
|
||
from sqlalchemy import select
|
||
|
||
from ...models import SkillTag
|
||
|
||
rows = (
|
||
await self.db.execute(
|
||
select(SkillTag)
|
||
.where(SkillTag.tenant_id == int(tenant_id))
|
||
.order_by(SkillTag.sort_order.asc(), SkillTag.id.asc())
|
||
)
|
||
).scalars().all()
|
||
names: list[str] = []
|
||
seen: set[str] = set()
|
||
for row in rows:
|
||
name = str(row.name or "").strip()
|
||
lowered = name.lower()
|
||
if not name or lowered in seen:
|
||
continue
|
||
seen.add(lowered)
|
||
names.append(name[:80])
|
||
if len(names) >= limit:
|
||
break
|
||
return names
|
||
|
||
def _build_task_chain_prompt(
|
||
self,
|
||
*,
|
||
analysis: Dict[str, Any],
|
||
available_skill_names: list[str],
|
||
retry_feedback: str | None = None,
|
||
) -> str:
|
||
context_payload = {
|
||
"project_title": str(analysis.get("project_title") or analysis.get("project_name") or "").strip(),
|
||
"target_audience": str(
|
||
analysis.get("target_audience") or analysis.get("target_customers") or ""
|
||
).strip(),
|
||
"timeline": str(analysis.get("timeline") or "3周").strip() or "3周",
|
||
"summary": str(analysis.get("summary") or analysis.get("positioning_one_liner") or "").strip(),
|
||
"key_features": self._normalize_generation_text_list(
|
||
analysis.get("key_features")
|
||
if isinstance(analysis.get("key_features"), list)
|
||
else analysis.get("mvp_features")
|
||
if isinstance(analysis.get("mvp_features"), list)
|
||
else analysis.get("mvp_items"),
|
||
),
|
||
"success_metrics": self._normalize_generation_text_list(analysis.get("success_metrics")),
|
||
"core_pain_points": self._normalize_generation_text_list(analysis.get("core_pain_points")),
|
||
"team_setup": self._normalize_team_setup_for_prompt(analysis.get("team_setup")),
|
||
"requirement_items": self._normalize_requirement_items_for_prompt(analysis.get("requirement_items")),
|
||
"available_skill_tags": list(available_skill_names or []),
|
||
}
|
||
retry_section = ""
|
||
if retry_feedback:
|
||
retry_section = f"""
|
||
上轮结果存在问题,请完整重生任务链,不要沿用错误任务:
|
||
{retry_feedback}
|
||
""".rstrip()
|
||
|
||
return f"""基于以下项目信息,生成完整的任务链:
|
||
|
||
项目信息 JSON:
|
||
{json.dumps(context_payload, ensure_ascii=False, indent=2)}
|
||
|
||
任务规划要求:
|
||
1. 任务必须直接支撑 key_features、requirement_items、success_metrics,优先围绕 MVP 主链与数据闭环。
|
||
2. 若 requirement_items 已给出 Shopify、TikTok Shop、飞书群聊等来源,任务必须体现对应的数据接入、分析或可视化工作。
|
||
3. 除非 requirement_items 明确要求,禁止输出登录、权限、RBAC、后台管理、通用 CMS、CI/CD 等泛化基础设施任务。
|
||
4. required_skills 只能从 available_skill_tags 中选择,且每个任务至少包含 1 个可映射技能。
|
||
5. 优先生成当前团队能立即开干的任务,避免虚构 Node.js、JWT、Database Design、API Integration、ECharts 等不在技能池内的技能。
|
||
6. 任务数量控制在 2-4 条,按优先级排序;每个 description 控制在 60 字以内,不要换行。
|
||
7. 任务标题要具体到业务动作,不要写成“系统优化”“功能完善”这类空泛表述。
|
||
|
||
{retry_section}
|
||
|
||
以 JSON 格式返回:
|
||
{{
|
||
"tasks": [
|
||
{{
|
||
"title": "任务名称",
|
||
"type": "backend|frontend|ai_engineer|pm",
|
||
"estimated_hours": 8,
|
||
"description": "任务描述",
|
||
"priority": "high|medium|low",
|
||
"dependencies": ["依赖任务名称"],
|
||
"required_skills": ["Python", "FastAPI"]
|
||
}}
|
||
]
|
||
}}
|
||
|
||
只返回 JSON,不要 markdown,不要额外解释。"""
|
||
|
||
def _build_task_chain_charter_content(self, *, analysis: Dict[str, Any]) -> str:
|
||
charter_payload = {
|
||
"project_title": str(analysis.get("project_title") or analysis.get("project_name") or "").strip(),
|
||
"summary": str(analysis.get("summary") or analysis.get("positioning_one_liner") or "").strip(),
|
||
"target_audience": str(
|
||
analysis.get("target_audience") or analysis.get("target_customers") or ""
|
||
).strip(),
|
||
"objectives": self._normalize_generation_text_list(
|
||
analysis.get("key_features")
|
||
if isinstance(analysis.get("key_features"), list)
|
||
else analysis.get("mvp_features")
|
||
if isinstance(analysis.get("mvp_features"), list)
|
||
else analysis.get("mvp_items"),
|
||
),
|
||
"success_criteria": self._normalize_generation_text_list(analysis.get("success_metrics")),
|
||
"core_pain_points": self._normalize_generation_text_list(analysis.get("core_pain_points")),
|
||
"team_setup": self._normalize_team_setup_for_prompt(analysis.get("team_setup")),
|
||
"requirement_items": self._normalize_requirement_items_for_prompt(analysis.get("requirement_items")),
|
||
}
|
||
return json.dumps(charter_payload, ensure_ascii=False)
|
||
|
||
def _coerce_task_chain_charter_content(
|
||
self,
|
||
*,
|
||
analysis: Dict[str, Any],
|
||
charter_content: Any = None,
|
||
) -> str:
|
||
if self._has_six_module_charter_content(charter_content):
|
||
return json.dumps(charter_content, ensure_ascii=False)
|
||
if isinstance(charter_content, dict) and charter_content:
|
||
normalized = self._build_six_module_charter_content_from_brief(charter_content)
|
||
return json.dumps(normalized, ensure_ascii=False)
|
||
return self._build_task_chain_charter_content(analysis=analysis)
|
||
|
||
@classmethod
|
||
def _split_charter_text_items(
|
||
cls,
|
||
value: Any,
|
||
*,
|
||
max_items: int = 8,
|
||
max_len: int = 120,
|
||
) -> list[str]:
|
||
if isinstance(value, list):
|
||
return cls._normalize_generation_text_list(value, max_items=max_items, max_len=max_len)
|
||
text = str(value or "").strip()
|
||
if not text:
|
||
return []
|
||
|
||
items: list[str] = []
|
||
seen: set[str] = set()
|
||
raw_blocks = re.split(r"[\r\n]+", text)
|
||
for raw_block in raw_blocks:
|
||
block = re.sub(r"^[\-\*\u2022\d\.\)\s、]+", "", str(raw_block or "").strip()).strip()
|
||
if not block:
|
||
continue
|
||
candidate_parts = [block]
|
||
if "\n" not in block and any(sep in block for sep in (";", ";")):
|
||
split_parts = [part.strip() for part in re.split(r"[;;]+", block) if part.strip()]
|
||
if len(split_parts) > 1:
|
||
candidate_parts = split_parts
|
||
for part in candidate_parts:
|
||
normalized = re.sub(r"\s+", " ", str(part or "").strip())[:max_len]
|
||
key = normalized.lower()
|
||
if not normalized or key in seen:
|
||
continue
|
||
seen.add(key)
|
||
items.append(normalized)
|
||
if len(items) >= max_items:
|
||
return items
|
||
return items
|
||
|
||
@staticmethod
|
||
def _normalize_skill_match_key(value: Any) -> str:
|
||
return re.sub(r"[\s_\-/]+", "", str(value or "").strip()).lower()
|
||
|
||
@classmethod
|
||
def _match_available_skills_by_hints(
|
||
cls,
|
||
*,
|
||
available_skill_names: list[str],
|
||
hints: list[str],
|
||
max_items: int = 3,
|
||
) -> list[str]:
|
||
normalized_available = [
|
||
(str(name).strip(), cls._normalize_skill_match_key(name))
|
||
for name in (available_skill_names or [])
|
||
if str(name).strip()
|
||
]
|
||
matched: list[str] = []
|
||
seen: set[str] = set()
|
||
for hint in hints:
|
||
hint_key = cls._normalize_skill_match_key(hint)
|
||
if not hint_key:
|
||
continue
|
||
for skill_name, skill_key in normalized_available:
|
||
if not skill_key or skill_key in seen:
|
||
continue
|
||
if hint_key in skill_key or skill_key in hint_key:
|
||
seen.add(skill_key)
|
||
matched.append(skill_name)
|
||
if len(matched) >= max_items:
|
||
return matched
|
||
break
|
||
return matched
|
||
|
||
@staticmethod
|
||
def _infer_local_task_type(*, title: str, description: str) -> str:
|
||
title_lower = str(title or "").lower()
|
||
merged = f"{title} {description}".lower()
|
||
if any(token in title_lower for token in ("看板", "页面", "前端", "可视化", "dashboard", "ui", "面板", "展示")):
|
||
return "frontend"
|
||
if any(token in title_lower for token in ("需求澄清", "范围对齐", "验收", "排期", "评审")):
|
||
return "pm"
|
||
if any(token in merged for token in ("prompt", "rag", "embedding", "向量", "模型", "智能体", "llm")):
|
||
return "ai_engineer"
|
||
return "backend"
|
||
|
||
@classmethod
|
||
def _infer_local_task_required_skills(
|
||
cls,
|
||
*,
|
||
title: str,
|
||
description: str,
|
||
task_type: str,
|
||
available_skill_names: list[str],
|
||
charter_content: dict[str, Any],
|
||
) -> list[str]:
|
||
modules = charter_content.get("modules") if isinstance(charter_content.get("modules"), dict) else {}
|
||
module_3 = modules.get("module_3") if isinstance(modules.get("module_3"), dict) else {}
|
||
engineering_context = " ".join(
|
||
str(module_3.get(field) or "").strip()
|
||
for field in ("primary_model", "fallback_model", "capability_table", "dependency_map", "engineering_view")
|
||
)
|
||
merged = f"{title} {description} {engineering_context}"
|
||
direct_matches = cls._match_available_skills_by_hints(
|
||
available_skill_names=available_skill_names,
|
||
hints=[merged],
|
||
max_items=3,
|
||
)
|
||
|
||
role_hints_map = {
|
||
"frontend": ["React", "TypeScript", "前端", "可视化"],
|
||
"pm": ["项目管理", "产品管理", "需求分析", "产品"],
|
||
"ai_engineer": ["LangChain", "LLM 调优", "LLM调优", "RAG构建", "Python", "数据分析"],
|
||
"backend": ["Python", "FastAPI", "SQLAlchemy", "数据分析"],
|
||
}
|
||
role_matches = cls._match_available_skills_by_hints(
|
||
available_skill_names=available_skill_names,
|
||
hints=role_hints_map.get(task_type, role_hints_map["backend"]),
|
||
max_items=3,
|
||
)
|
||
|
||
selected: list[str] = []
|
||
seen: set[str] = set()
|
||
for skill_name in role_matches + direct_matches:
|
||
normalized = cls._normalize_skill_match_key(skill_name)
|
||
if not normalized or normalized in seen:
|
||
continue
|
||
seen.add(normalized)
|
||
selected.append(skill_name)
|
||
if len(selected) >= 3:
|
||
return selected
|
||
|
||
if not selected and task_type == "ai_engineer":
|
||
selected = cls._match_available_skills_by_hints(
|
||
available_skill_names=available_skill_names,
|
||
hints=role_hints_map["backend"],
|
||
max_items=3,
|
||
)
|
||
if not selected and available_skill_names:
|
||
selected = [str(name).strip() for name in available_skill_names[:3] if str(name).strip()]
|
||
return selected[:3]
|
||
|
||
@staticmethod
|
||
def _normalize_decomposed_task_rows(tasks: Any) -> list[dict[str, Any]]:
|
||
if not isinstance(tasks, list):
|
||
return []
|
||
normalized: list[dict[str, Any]] = []
|
||
for item in tasks:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
title = str(item.get("title") or "").strip()
|
||
if not title:
|
||
continue
|
||
normalized.append(
|
||
{
|
||
"title": title,
|
||
"type": str(item.get("type") or "backend").strip() or "backend",
|
||
"estimated_hours": item.get("estimated_hours"),
|
||
"description": str(item.get("description") or "").strip(),
|
||
"priority": str(item.get("priority") or "medium").strip() or "medium",
|
||
"dependencies": item.get("depends_on_task_keys") if isinstance(item.get("depends_on_task_keys"), list) else [],
|
||
"required_skills": normalize_skill_names(
|
||
item.get("required_skill_names")
|
||
if isinstance(item.get("required_skill_names"), list)
|
||
else item.get("required_skills")
|
||
),
|
||
}
|
||
)
|
||
return normalized
|
||
|
||
async def _generate_task_chain_via_charter_decomposition(
|
||
self,
|
||
*,
|
||
analysis: Dict[str, Any],
|
||
tenant_id: int,
|
||
charter_content: Any = None,
|
||
routing_meta: dict[str, Any] | None = None,
|
||
available_skill_names: list[str] | None = None,
|
||
) -> list[dict[str, Any]]:
|
||
from ..ai_service_core.service import AIService
|
||
|
||
charter_content_text = self._coerce_task_chain_charter_content(
|
||
analysis=analysis,
|
||
charter_content=charter_content,
|
||
)
|
||
try:
|
||
payload = await AIService().decompose_tasks_from_charter(
|
||
charter_content=charter_content_text,
|
||
project_id=0,
|
||
tenant_id=int(tenant_id),
|
||
db=self.db,
|
||
routing_meta=routing_meta or self._task_chain_generation_routing_meta(),
|
||
)
|
||
tasks = self._normalize_decomposed_task_rows(payload.get("tasks"))
|
||
if not tasks:
|
||
raise RuntimeError("AI 管家任务链生成失败:charter decomposition returned empty tasks")
|
||
report = await self._inspect_task_chain_candidate(tasks=tasks, tenant_id=int(tenant_id))
|
||
requires_skill_gate = bool(available_skill_names)
|
||
if not requires_skill_gate:
|
||
return tasks
|
||
if not report["invalid_tasks"] and int(report.get("mappable_task_count") or 0) > 0:
|
||
return tasks
|
||
raise RuntimeError("AI 管家任务链生成失败:charter decomposition returned low-quality tasks")
|
||
except Exception as exc:
|
||
logger.warning(
|
||
"ai_steward_charter_task_chain_decomposition_failed",
|
||
tenant_id=int(tenant_id),
|
||
project_title=str(analysis.get("project_title") or analysis.get("project_name") or "").strip()[:120],
|
||
tier_alias=str((routing_meta or {}).get("tier_alias") or "").strip() or "default",
|
||
error=str(exc),
|
||
)
|
||
raise
|
||
|
||
async def _inspect_task_chain_candidate(
|
||
self,
|
||
*,
|
||
tasks: list[dict[str, Any]],
|
||
tenant_id: int,
|
||
) -> dict[str, Any]:
|
||
invalid_tasks: list[dict[str, Any]] = []
|
||
mappable_count = 0
|
||
for task in tasks:
|
||
raw_required_skills = normalize_skill_names(task.get("required_skills"))
|
||
if not raw_required_skills:
|
||
invalid_tasks.append(
|
||
{
|
||
"title": str(task.get("title") or "").strip(),
|
||
"required_skills": [],
|
||
"reason": "required_skills_empty",
|
||
}
|
||
)
|
||
continue
|
||
resolution = await resolve_skill_tag_ids_by_names(
|
||
self.db,
|
||
tenant_id=int(tenant_id),
|
||
skill_names=raw_required_skills,
|
||
)
|
||
if resolution.matched_ids:
|
||
mappable_count += 1
|
||
continue
|
||
invalid_tasks.append(
|
||
{
|
||
"title": str(task.get("title") or "").strip(),
|
||
"required_skills": raw_required_skills,
|
||
"reason": "required_skills_unmappable",
|
||
}
|
||
)
|
||
return {
|
||
"tasks": tasks,
|
||
"invalid_tasks": invalid_tasks,
|
||
"mappable_task_count": mappable_count,
|
||
"total_task_count": len(tasks),
|
||
}
|
||
|
||
@staticmethod
|
||
def _build_task_chain_retry_feedback(
|
||
*,
|
||
invalid_tasks: list[dict[str, Any]],
|
||
available_skill_names: list[str],
|
||
) -> str:
|
||
lines: list[str] = []
|
||
for item in invalid_tasks[:6]:
|
||
title = str(item.get("title") or "未命名任务").strip() or "未命名任务"
|
||
skills = ", ".join(str(skill).strip() for skill in (item.get("required_skills") or []) if str(skill).strip())
|
||
reason = str(item.get("reason") or "").strip()
|
||
if skills:
|
||
lines.append(f"- {title}:技能 {skills} 无法映射到当前技能池({reason})")
|
||
else:
|
||
lines.append(f"- {title}:缺少可映射技能({reason})")
|
||
if available_skill_names:
|
||
lines.append(f"- 当前可用技能池:{', '.join(available_skill_names)}")
|
||
return "\n".join(lines)
|
||
|
||
@staticmethod
|
||
def _task_chain_candidate_score(report: dict[str, Any]) -> tuple[int, int, int]:
|
||
return (
|
||
int(report.get("mappable_task_count") or 0),
|
||
-len(report.get("invalid_tasks") or []),
|
||
int(report.get("total_task_count") or 0),
|
||
)
|
||
|
||
@staticmethod
|
||
def _task_chain_candidate_is_acceptable(
|
||
report: dict[str, Any],
|
||
*,
|
||
available_skill_names: list[str],
|
||
) -> bool:
|
||
if not available_skill_names:
|
||
return True
|
||
return not report.get("invalid_tasks") and int(report.get("mappable_task_count") or 0) > 0
|
||
|
||
async def _generate_task_chain_from_primary_prompt(
|
||
self,
|
||
*,
|
||
analysis: Dict[str, Any],
|
||
tenant_id: int,
|
||
available_skill_names: list[str],
|
||
routing_meta: dict[str, Any],
|
||
) -> list[dict[str, Any]]:
|
||
prompt = self._build_task_chain_prompt(
|
||
analysis=analysis,
|
||
available_skill_names=available_skill_names,
|
||
)
|
||
tasks_data = await self._generate_project_creation_object(
|
||
prompt=prompt,
|
||
tenant_id=tenant_id,
|
||
function_name="project_from_text_task_chain",
|
||
stage_label="AI 管家任务链生成失败",
|
||
timeout_seconds=PROJECT_CREATION_TASK_CHAIN_TIMEOUT_SECONDS,
|
||
routing_meta=routing_meta,
|
||
)
|
||
tasks_list = tasks_data.get("tasks", [])
|
||
if not isinstance(tasks_list, list) or not tasks_list:
|
||
raise RuntimeError("AI 管家任务链生成失败:返回 tasks 为空")
|
||
|
||
report = await self._inspect_task_chain_candidate(tasks=tasks_list, tenant_id=int(tenant_id))
|
||
best_tasks = tasks_list
|
||
best_report = report
|
||
if self._task_chain_candidate_is_acceptable(report, available_skill_names=available_skill_names):
|
||
return best_tasks
|
||
|
||
retry_feedback = self._build_task_chain_retry_feedback(
|
||
invalid_tasks=report["invalid_tasks"],
|
||
available_skill_names=available_skill_names,
|
||
)
|
||
retry_prompt = self._build_task_chain_prompt(
|
||
analysis=analysis,
|
||
available_skill_names=available_skill_names,
|
||
retry_feedback=retry_feedback,
|
||
)
|
||
retry_data = await self._generate_project_creation_object(
|
||
prompt=retry_prompt,
|
||
tenant_id=tenant_id,
|
||
function_name="project_from_text_task_chain",
|
||
stage_label="AI 管家任务链生成失败",
|
||
timeout_seconds=PROJECT_CREATION_TASK_CHAIN_TIMEOUT_SECONDS,
|
||
routing_meta=routing_meta,
|
||
)
|
||
retry_tasks = retry_data.get("tasks", [])
|
||
if isinstance(retry_tasks, list) and retry_tasks:
|
||
retry_report = await self._inspect_task_chain_candidate(tasks=retry_tasks, tenant_id=int(tenant_id))
|
||
if self._task_chain_candidate_score(retry_report) >= self._task_chain_candidate_score(best_report):
|
||
best_tasks = retry_tasks
|
||
best_report = retry_report
|
||
if self._task_chain_candidate_is_acceptable(retry_report, available_skill_names=available_skill_names):
|
||
return retry_tasks
|
||
|
||
raise RuntimeError("AI 管家任务链生成失败:prompt route returned low-quality tasks")
|
||
|
||
async def _generate_task_chain_bundle(
|
||
self,
|
||
*,
|
||
analysis: Dict[str, Any],
|
||
tenant_id: int,
|
||
charter_content: Any = None,
|
||
) -> dict[str, Any]:
|
||
try:
|
||
tasks = await self._generate_task_chain_draft(
|
||
analysis=analysis,
|
||
tenant_id=int(tenant_id),
|
||
charter_content=charter_content,
|
||
)
|
||
except Exception as exc:
|
||
logger.warning(
|
||
"ai_steward_task_chain_generation_deferred",
|
||
tenant_id=int(tenant_id),
|
||
project_title=str(analysis.get("project_title") or analysis.get("project_name") or "").strip()[:120],
|
||
error=str(exc),
|
||
)
|
||
return {
|
||
"tasks": [],
|
||
"task_generation": self._task_generation_pending_state(exc),
|
||
}
|
||
return {
|
||
"tasks": tasks if isinstance(tasks, list) else [],
|
||
"task_generation": self._task_generation_ready_state(),
|
||
}
|
||
|
||
async def _analyze_project_input(self, input_text: str, tenant_id: int) -> Dict[str, Any]:
|
||
"""使用 AI 分析项目输入,提取关键信息。"""
|
||
prompt = PROMPT_PROJECT_REQUIREMENT_EXTRACTION.format(
|
||
source_label="项目描述",
|
||
source_context="AI Steward 零配置项目创建输入",
|
||
input_text=input_text[:2000],
|
||
)
|
||
|
||
analysis = await self._generate_project_creation_object(
|
||
prompt=prompt,
|
||
tenant_id=tenant_id,
|
||
function_name="project_requirement_extraction",
|
||
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_from_text_signal",
|
||
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_from_text_charter",
|
||
stage_label="AI 管家项目宪章生成失败",
|
||
)
|
||
|
||
raw_content = charter_data.get("content", "")
|
||
if isinstance(raw_content, dict):
|
||
content_seed = {**dict(raw_content), **charter_data}
|
||
else:
|
||
content_seed = {
|
||
"summary": charter_data.get("summary") or str(raw_content),
|
||
"success_criteria": charter_data.get("success_criteria", []),
|
||
"constraints": charter_data.get("constraints", []),
|
||
"milestones": charter_data.get("milestones", []),
|
||
"objectives": raw_content if isinstance(raw_content, list) else analysis.get("key_features", []),
|
||
"target_customers": analysis.get("target_audience") or analysis.get("target_customers"),
|
||
"core_pain_points": analysis.get("core_pain_points"),
|
||
"mvp_items": analysis.get("key_features") or analysis.get("mvp_items"),
|
||
"team_setup": analysis.get("team_setup"),
|
||
"validation_metrics": analysis.get("validation_metrics"),
|
||
"requirement_items": analysis.get("requirement_items"),
|
||
"timeline": analysis.get("timeline"),
|
||
"tech_stack": analysis.get("tech_stack"),
|
||
}
|
||
content = self._build_six_module_charter_content_from_brief(content_seed)
|
||
return {
|
||
"title": charter_data.get("title", "项目宪章"),
|
||
"content": content,
|
||
}
|
||
|
||
async def _generate_task_chain_draft(
|
||
self,
|
||
*,
|
||
analysis: Dict[str, Any],
|
||
tenant_id: int,
|
||
charter_content: Any = None,
|
||
) -> List[Dict[str, Any]]:
|
||
available_skill_names = await self._load_available_skill_names(tenant_id=int(tenant_id))
|
||
if self._has_six_module_charter_content(charter_content):
|
||
last_error: Exception | None = None
|
||
for tier_alias in ("low", "high"):
|
||
try:
|
||
return await self._generate_task_chain_via_charter_decomposition(
|
||
analysis=analysis,
|
||
tenant_id=int(tenant_id),
|
||
charter_content=charter_content,
|
||
routing_meta=self._task_chain_generation_routing_meta(tier_alias=tier_alias),
|
||
available_skill_names=available_skill_names,
|
||
)
|
||
except Exception as exc:
|
||
last_error = exc
|
||
logger.warning(
|
||
"ai_steward_task_chain_charter_tier_attempt_failed",
|
||
tenant_id=int(tenant_id),
|
||
project_title=str(analysis.get("project_title") or analysis.get("project_name") or "").strip()[:120],
|
||
tier_alias=tier_alias,
|
||
error=str(exc),
|
||
)
|
||
if last_error is not None:
|
||
raise last_error
|
||
raise RuntimeError("AI 管家任务链生成失败:charter decomposition unavailable")
|
||
|
||
last_error: Exception | None = None
|
||
for tier_alias in ("low", "high"):
|
||
try:
|
||
return await self._generate_task_chain_from_primary_prompt(
|
||
analysis=analysis,
|
||
tenant_id=int(tenant_id),
|
||
available_skill_names=available_skill_names,
|
||
routing_meta=self._task_chain_generation_routing_meta(tier_alias=tier_alias),
|
||
)
|
||
except Exception as exc:
|
||
last_error = exc
|
||
logger.warning(
|
||
"ai_steward_task_chain_prompt_tier_attempt_failed",
|
||
tenant_id=int(tenant_id),
|
||
project_title=str(analysis.get("project_title") or analysis.get("project_name") or "").strip()[:120],
|
||
tier_alias=tier_alias,
|
||
error=str(exc),
|
||
)
|
||
|
||
for tier_alias in ("low", "high"):
|
||
try:
|
||
return await self._generate_task_chain_via_charter_decomposition(
|
||
analysis=analysis,
|
||
tenant_id=int(tenant_id),
|
||
charter_content=charter_content,
|
||
routing_meta=self._task_chain_generation_routing_meta(tier_alias=tier_alias),
|
||
available_skill_names=available_skill_names,
|
||
)
|
||
except Exception as exc:
|
||
last_error = exc
|
||
logger.warning(
|
||
"ai_steward_task_chain_charter_fallback_attempt_failed",
|
||
tenant_id=int(tenant_id),
|
||
project_title=str(analysis.get("project_title") or analysis.get("project_name") or "").strip()[:120],
|
||
tier_alias=tier_alias,
|
||
error=str(exc),
|
||
)
|
||
|
||
if last_error is not None:
|
||
raise last_error
|
||
raise RuntimeError("AI 管家任务链生成失败:no task generation route available")
|
||
|
||
@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",
|
||
},
|
||
]
|