feat: Agentic QE Fleet v2.0.0 - 14-agent quality engineering platform
- 14 specialized AI agents across 5 battle zones (Prepare/Analyze/Design/Review/Monitor) - New: risk-assessor, test-strategist, data-builder, coverage-auditor, quality-gatekeeper, execution-analyst, knowledge-curator - New: fleet_runner.py orchestrator with multi-zone manifest pipeline - New: fleet_config.yml for centralized configuration - New: knowledge activation system (keyword + semantic matching) - New: semantic conflict detection with severity grading (P0-P3) - New: three-tier quality gate (PASS/PASS_WITH_FIX/BLOCKED) - New: monitor zone for test execution analysis and auto knowledge curation - Backward compatible: /case_generate alias, case_pipeline.py preserved - Comprehensive docs: USER_GUIDE.md + MAINTENANCE_GUIDE.md
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,359 @@
|
||||
import argparse
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Alignment, Font
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
DEFAULT_INPUT_DIR = REPO_ROOT / "output" / "test_cases"
|
||||
DEFAULT_OUTPUT_DIR = REPO_ROOT / "output" / "excel_reports"
|
||||
REQUIRED_COLUMNS = [
|
||||
"用例编号",
|
||||
"模块",
|
||||
"用例标题",
|
||||
"优先级",
|
||||
"类型",
|
||||
"前置条件",
|
||||
"测试步骤",
|
||||
"测试数据",
|
||||
"预期结果",
|
||||
"备注",
|
||||
]
|
||||
LEGACY_COLUMNS = [
|
||||
"用例编号",
|
||||
"模块",
|
||||
"用例标题",
|
||||
"优先级",
|
||||
"前置条件",
|
||||
"测试步骤",
|
||||
"测试数据",
|
||||
"预期结果",
|
||||
"备注",
|
||||
]
|
||||
SUPPORTED_COLUMN_SETS = [REQUIRED_COLUMNS, LEGACY_COLUMNS]
|
||||
YUNXIAO_HEADERS = [
|
||||
"标题",
|
||||
"编号",
|
||||
"目录",
|
||||
"创建时间",
|
||||
"前置条件",
|
||||
"步骤描述",
|
||||
"预期结果",
|
||||
"优先级",
|
||||
"类型",
|
||||
"URL",
|
||||
]
|
||||
TYPE_ENUMS = [
|
||||
"功能测试",
|
||||
"性能测试",
|
||||
"兼容性测试",
|
||||
"易用性测试",
|
||||
"安全性测试",
|
||||
"冒烟测试",
|
||||
"回归测试",
|
||||
"其他",
|
||||
]
|
||||
SEPARATOR_RE = re.compile(r"^\|\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?$")
|
||||
MODULE_PATH_SEPARATOR_RE = re.compile(r"\s*[--—–]\s*")
|
||||
BR_TAG_RE = re.compile(r"<br\s*/?>", re.IGNORECASE)
|
||||
|
||||
|
||||
def headers_supported(headers: list[str]) -> bool:
|
||||
return headers in SUPPORTED_COLUMN_SETS
|
||||
|
||||
|
||||
def find_latest_markdown_file(directory: Path) -> Path | None:
|
||||
files = sorted(directory.glob("*.md"), key=lambda item: item.stat().st_mtime)
|
||||
return files[-1] if files else None
|
||||
|
||||
|
||||
def split_markdown_row(line: str) -> list[str]:
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("|"):
|
||||
stripped = stripped[1:]
|
||||
if stripped.endswith("|"):
|
||||
stripped = stripped[:-1]
|
||||
|
||||
cells: list[str] = []
|
||||
current: list[str] = []
|
||||
escaped = False
|
||||
|
||||
for char in stripped:
|
||||
if escaped:
|
||||
current.append(char)
|
||||
escaped = False
|
||||
continue
|
||||
if char == "\\":
|
||||
escaped = True
|
||||
continue
|
||||
if char == "|":
|
||||
cells.append("".join(current).strip())
|
||||
current = []
|
||||
continue
|
||||
current.append(char)
|
||||
|
||||
if escaped:
|
||||
current.append("\\")
|
||||
|
||||
cells.append("".join(current).strip())
|
||||
return cells
|
||||
|
||||
|
||||
def normalize_module_path(value: str) -> str:
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
return stripped
|
||||
|
||||
if "|" in stripped:
|
||||
parts = [part.strip() for part in stripped.split("|") if part.strip()]
|
||||
return "|".join(parts)
|
||||
|
||||
if MODULE_PATH_SEPARATOR_RE.search(stripped):
|
||||
parts = [part.strip() for part in MODULE_PATH_SEPARATOR_RE.split(stripped) if part.strip()]
|
||||
if len(parts) > 1:
|
||||
return "|".join(parts)
|
||||
|
||||
return stripped
|
||||
|
||||
|
||||
def load_markdown_table(source_file: Path) -> tuple[list[str], list[list[str]]]:
|
||||
content = source_file.read_text(encoding="utf-8")
|
||||
table_lines: list[str] = []
|
||||
|
||||
for line in content.splitlines():
|
||||
if line.strip().startswith("|"):
|
||||
table_lines.append(line.rstrip())
|
||||
elif table_lines:
|
||||
break
|
||||
|
||||
if len(table_lines) < 3:
|
||||
raise ValueError(f"未在 {source_file} 中找到有效的 Markdown 表格。")
|
||||
|
||||
headers = split_markdown_row(table_lines[0])
|
||||
if not SEPARATOR_RE.match(table_lines[1].strip()):
|
||||
raise ValueError(f"{source_file} 的第二行不是合法的 Markdown 表头分隔线。")
|
||||
|
||||
module_index = headers.index("模块") if "模块" in headers else None
|
||||
|
||||
rows: list[list[str]] = []
|
||||
for line in table_lines[2:]:
|
||||
row = split_markdown_row(line)
|
||||
if len(row) != len(headers):
|
||||
raise ValueError(
|
||||
f"{source_file} 中存在列数不一致的行,表头 {len(headers)} 列,实际 {len(row)} 列。"
|
||||
)
|
||||
if module_index is not None:
|
||||
row[module_index] = normalize_module_path(row[module_index])
|
||||
rows.append(row)
|
||||
|
||||
if not rows:
|
||||
raise ValueError(f"{source_file} 的 Markdown 表格没有数据行。")
|
||||
|
||||
return headers, rows
|
||||
|
||||
|
||||
def infer_case_type(case: dict[str, str]) -> str:
|
||||
explicit = case.get("类型", "").strip()
|
||||
if explicit in TYPE_ENUMS:
|
||||
return explicit
|
||||
|
||||
combined = " ".join(
|
||||
[
|
||||
case.get("用例标题", ""),
|
||||
case.get("模块", ""),
|
||||
case.get("备注", ""),
|
||||
case.get("测试步骤", ""),
|
||||
case.get("预期结果", ""),
|
||||
]
|
||||
)
|
||||
|
||||
if re.search(r"性能|RT|响应时间|P95|吞吐|压测|并发量", combined, re.IGNORECASE):
|
||||
return "性能测试"
|
||||
if re.search(r"越权|鉴权|权限|抓包|篡改|注入|XSS|CSRF|SQL", combined, re.IGNORECASE):
|
||||
return "安全性测试"
|
||||
if re.search(r"兼容|浏览器|小程序|H5|多端", combined, re.IGNORECASE):
|
||||
return "兼容性测试"
|
||||
if re.search(r"易用|交互|文案|可读|展示", combined, re.IGNORECASE):
|
||||
return "易用性测试"
|
||||
if re.search(r"冒烟|主流程", combined, re.IGNORECASE):
|
||||
return "冒烟测试"
|
||||
if re.search(r"历史缺陷|回归|AI修正", combined, re.IGNORECASE):
|
||||
return "回归测试"
|
||||
return "功能测试"
|
||||
|
||||
|
||||
def normalize_multiline_text(value: str) -> str:
|
||||
text = BR_TAG_RE.sub("\n", value or "")
|
||||
return text.replace("\\n", "\n").strip()
|
||||
|
||||
|
||||
def normalize_topic_name(text: str) -> str:
|
||||
normalized = text.strip()
|
||||
changed = True
|
||||
while changed:
|
||||
changed = False
|
||||
for token in ("测试用例", "需求", "分析"):
|
||||
if normalized.endswith(token):
|
||||
normalized = normalized[: -len(token)].strip()
|
||||
changed = True
|
||||
normalized = re.sub(r"[_\-\s|]+$", "", normalized)
|
||||
return normalized or text.strip()
|
||||
|
||||
|
||||
def build_directory_name(source_path: Path, module_value: str) -> str:
|
||||
topic = normalize_topic_name(source_path.stem)
|
||||
module = normalize_module_path(module_value)
|
||||
if not module:
|
||||
return topic
|
||||
if module.startswith(topic):
|
||||
return module
|
||||
return f"{topic}|{module}"
|
||||
|
||||
|
||||
def normalize_case_rows(headers: list[str], rows: list[list[str]]) -> list[dict[str, str]]:
|
||||
cases: list[dict[str, str]] = []
|
||||
for row in rows:
|
||||
current = {header: (row[index] or "").strip() for index, header in enumerate(headers)}
|
||||
if "类型" not in current:
|
||||
current["类型"] = infer_case_type(current)
|
||||
elif current["类型"] not in TYPE_ENUMS:
|
||||
current["类型"] = infer_case_type(current)
|
||||
cases.append(current)
|
||||
return cases
|
||||
|
||||
|
||||
def build_yunxiao_rows(source_path: Path, headers: list[str], rows: list[list[str]]) -> list[list[object]]:
|
||||
created_time = datetime.fromtimestamp(source_path.stat().st_mtime).replace(microsecond=0)
|
||||
cases = normalize_case_rows(headers, rows)
|
||||
output_rows: list[list[object]] = []
|
||||
|
||||
for case in cases:
|
||||
steps_text = normalize_multiline_text(case.get("测试步骤", ""))
|
||||
data_text = normalize_multiline_text(case.get("测试数据", ""))
|
||||
if data_text:
|
||||
steps_text = f"{steps_text}\n[测试数据]\n{data_text}" if steps_text else f"[测试数据]\n{data_text}"
|
||||
|
||||
output_rows.append(
|
||||
[
|
||||
case.get("用例标题", ""),
|
||||
case.get("用例编号", ""),
|
||||
build_directory_name(source_path, case.get("模块", "")),
|
||||
created_time,
|
||||
normalize_multiline_text(case.get("前置条件", "")),
|
||||
steps_text,
|
||||
normalize_multiline_text(case.get("预期结果", "")),
|
||||
case.get("优先级", ""),
|
||||
infer_case_type(case),
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
return output_rows
|
||||
|
||||
|
||||
def resolve_source_file(source_file: str | None, base_name: str | None, input_dir: Path) -> Path:
|
||||
if source_file:
|
||||
path = Path(source_file)
|
||||
return path if path.is_absolute() else REPO_ROOT / path
|
||||
|
||||
if base_name:
|
||||
return input_dir / f"{base_name}_测试用例.md"
|
||||
|
||||
latest = find_latest_markdown_file(input_dir)
|
||||
if latest is None:
|
||||
raise FileNotFoundError(f"在 {input_dir} 下没有找到任何 Markdown 测试用例文件。")
|
||||
return latest
|
||||
|
||||
|
||||
def build_current_excel_path(source_path: Path, output_dir: Path) -> Path:
|
||||
return output_dir / f"{source_path.stem}.xlsx"
|
||||
|
||||
|
||||
def export_markdown_to_excel(
|
||||
source_path: Path,
|
||||
output_dir: Path,
|
||||
output_path: Path | None = None,
|
||||
) -> Path:
|
||||
headers, rows = load_markdown_table(source_path)
|
||||
if not headers_supported(headers):
|
||||
raise ValueError(
|
||||
"测试用例表头不符合支持格式。\n"
|
||||
f"支持: {REQUIRED_COLUMNS} 或 {LEGACY_COLUMNS}\n"
|
||||
f"实际: {headers}"
|
||||
)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
if output_path is None:
|
||||
output_path = build_current_excel_path(source_path, output_dir)
|
||||
|
||||
workbook = Workbook()
|
||||
worksheet = workbook.active
|
||||
worksheet.title = "testcase items"
|
||||
|
||||
worksheet.append(YUNXIAO_HEADERS)
|
||||
for row in build_yunxiao_rows(source_path, headers, rows):
|
||||
worksheet.append(row)
|
||||
|
||||
header_font = Font(bold=True)
|
||||
wrap_alignment = Alignment(vertical="top", wrap_text=True)
|
||||
|
||||
for cell in worksheet[1]:
|
||||
cell.font = header_font
|
||||
cell.alignment = wrap_alignment
|
||||
|
||||
for row in worksheet.iter_rows(min_row=2):
|
||||
for cell in row:
|
||||
cell.alignment = wrap_alignment
|
||||
|
||||
worksheet.freeze_panes = "A2"
|
||||
worksheet.auto_filter.ref = f"A1:J{worksheet.max_row}"
|
||||
|
||||
for column_cells in worksheet.columns:
|
||||
max_length = max(len(str(cell.value or "")) for cell in column_cells)
|
||||
adjusted_width = min(max_length + 4, 60)
|
||||
worksheet.column_dimensions[column_cells[0].column_letter].width = adjusted_width
|
||||
|
||||
workbook.save(output_path)
|
||||
return output_path
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="将 Markdown 测试用例表格导出为 Excel。")
|
||||
parser.add_argument("--source-file", help="指定测试用例 Markdown 文件路径。")
|
||||
parser.add_argument("--base-name", help="按 BASE_NAME 查找 output/test_cases/{BASE_NAME}_测试用例.md。")
|
||||
parser.add_argument(
|
||||
"--input-dir",
|
||||
default=str(DEFAULT_INPUT_DIR),
|
||||
help="测试用例 Markdown 所在目录,默认 output/test_cases。",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-dir",
|
||||
default=str(DEFAULT_OUTPUT_DIR),
|
||||
help="Excel 输出目录,默认 output/excel_reports。",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
input_dir = Path(args.input_dir)
|
||||
if not input_dir.is_absolute():
|
||||
input_dir = REPO_ROOT / input_dir
|
||||
|
||||
output_dir = Path(args.output_dir)
|
||||
if not output_dir.is_absolute():
|
||||
output_dir = REPO_ROOT / output_dir
|
||||
|
||||
source_path = resolve_source_file(args.source_file, args.base_name, input_dir)
|
||||
if not source_path.exists():
|
||||
raise FileNotFoundError(f"测试用例文件不存在:{source_path}")
|
||||
|
||||
output_path = export_markdown_to_excel(source_path, output_dir)
|
||||
headers, rows = load_markdown_table(source_path)
|
||||
print(f"📄 来源文件: {source_path}")
|
||||
print(f"🧩 Markdown 表头字段: {', '.join(headers)}")
|
||||
print(f"📊 用例数量: {len(rows)}")
|
||||
print(f"✅ Excel 已生成: {output_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,212 @@
|
||||
"""
|
||||
Agentic QE Fleet — Agent 加载和上下文注入模块
|
||||
|
||||
负责:
|
||||
1. 加载 Agent prompt 文件
|
||||
2. 注入战区上下文(前序 manifest 数据)
|
||||
3. 渲染最终可执行的 Agent prompt
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
AGENTS_DIR = REPO_ROOT / "agents"
|
||||
|
||||
# Agent 注册表: agent_id → (战区, 文件路径, 描述)
|
||||
AGENT_REGISTRY: dict[str, dict[str, Any]] = {
|
||||
# ── Prepare ──
|
||||
"document-parser": {
|
||||
"zone": "prepare",
|
||||
"path": "prepare/document_parser.md",
|
||||
"name": "文档解析专家",
|
||||
"description": "解析原始需求文档为标准 Markdown,自动识别技术方案",
|
||||
},
|
||||
"knowledge-activator": {
|
||||
"zone": "prepare",
|
||||
"path": "prepare/knowledge_activator.md",
|
||||
"name": "知识激活专家",
|
||||
"description": "按需求内容自动激活术语/规则/历史缺陷/最佳实践",
|
||||
},
|
||||
# ── Analyze ──
|
||||
"requirement-analyzer": {
|
||||
"zone": "analyze",
|
||||
"path": "analyze/requirement_analyzer.md",
|
||||
"name": "需求分析专家",
|
||||
"description": "结构化需求模型 + 歧义标注",
|
||||
},
|
||||
"conflict-detector": {
|
||||
"zone": "analyze",
|
||||
"path": "analyze/conflict_detector.md",
|
||||
"name": "冲突检测专家",
|
||||
"description": "语义级历史需求规则冲突检测",
|
||||
},
|
||||
"risk-assessor": {
|
||||
"zone": "analyze",
|
||||
"path": "analyze/risk_assessor.md",
|
||||
"name": "风险评估专家",
|
||||
"description": "多维度风险矩阵量化",
|
||||
},
|
||||
# ── Design ──
|
||||
"test-strategist": {
|
||||
"zone": "design",
|
||||
"path": "design/test_strategist.md",
|
||||
"name": "测试策略师",
|
||||
"description": "分层测试策略 + 优先级矩阵",
|
||||
},
|
||||
"testpoint-designer": {
|
||||
"zone": "design",
|
||||
"path": "design/testpoint_designer.md",
|
||||
"name": "测试点设计师",
|
||||
"description": "全面测试点矩阵 + 来源标注",
|
||||
},
|
||||
"case-designer": {
|
||||
"zone": "design",
|
||||
"path": "design/case_designer.md",
|
||||
"name": "用例设计师",
|
||||
"description": "可执行测试用例 + 双验证预期结果",
|
||||
},
|
||||
"data-builder": {
|
||||
"zone": "design",
|
||||
"path": "design/data_builder.md",
|
||||
"name": "数据构造师",
|
||||
"description": "精确测试数据集构造",
|
||||
},
|
||||
# ── Review ──
|
||||
"case-reviewer": {
|
||||
"zone": "review",
|
||||
"path": "review/case_reviewer.md",
|
||||
"name": "用例评审师",
|
||||
"description": "用例质量/规范性/可执行性逐项评审",
|
||||
},
|
||||
"coverage-auditor": {
|
||||
"zone": "review",
|
||||
"path": "review/coverage_auditor.md",
|
||||
"name": "覆盖率审计师",
|
||||
"description": "需求→测试点→用例三级追溯覆盖审计",
|
||||
},
|
||||
"quality-gatekeeper": {
|
||||
"zone": "review",
|
||||
"path": "review/quality_gatekeeper.md",
|
||||
"name": "质量门禁裁决官",
|
||||
"description": "三级裁决: PASS / PASS_WITH_FIX / BLOCKED",
|
||||
},
|
||||
# ── Monitor ──
|
||||
"execution-analyst": {
|
||||
"zone": "monitor",
|
||||
"path": "monitor/execution_analyst.md",
|
||||
"name": "执行结果分析师",
|
||||
"description": "失败归类 + 模式识别 + 根因推测",
|
||||
},
|
||||
"knowledge-curator": {
|
||||
"zone": "monitor",
|
||||
"path": "monitor/knowledge_curator.md",
|
||||
"name": "知识沉淀师",
|
||||
"description": "自动回写知识库 + 去重保护",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_agent_path(agent_id: str) -> Path:
|
||||
"""返回 Agent prompt 文件的绝对路径。"""
|
||||
if agent_id not in AGENT_REGISTRY:
|
||||
raise ValueError(f"未知 Agent: {agent_id},可用: {list(AGENT_REGISTRY)}")
|
||||
return AGENTS_DIR / AGENT_REGISTRY[agent_id]["path"]
|
||||
|
||||
|
||||
def load_agent_prompt(agent_id: str) -> str:
|
||||
"""加载 Agent prompt 原始内容。"""
|
||||
path = get_agent_path(agent_id)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Agent prompt 不存在: {path}")
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def list_zone_agents(zone: str) -> list[str]:
|
||||
"""列出指定战区的所有 Agent ID。"""
|
||||
return sorted([
|
||||
agent_id for agent_id, info in AGENT_REGISTRY.items()
|
||||
if info["zone"] == zone
|
||||
])
|
||||
|
||||
|
||||
def list_all_agents() -> dict[str, list[str]]:
|
||||
"""按战区分组列出所有 Agent。"""
|
||||
result: dict[str, list[str]] = {}
|
||||
for zone in ["prepare", "analyze", "design", "review", "monitor"]:
|
||||
result[zone] = list_zone_agents(zone)
|
||||
return result
|
||||
|
||||
|
||||
def inject_context(prompt: str, context: dict[str, Any]) -> str:
|
||||
"""向 prompt 注入运行时上下文变量。
|
||||
|
||||
支持的占位符:
|
||||
{{BASE_NAME}} → 需求基础名
|
||||
{{MANIFEST_DIR}} → manifest 目录
|
||||
{{PREPARE_MANIFEST}} → prepare manifest 路径
|
||||
{{ANALYZE_MANIFEST}} → analyze manifest 路径
|
||||
{{DESIGN_MANIFEST}} → design manifest 路径
|
||||
{{PROJECT_PROFILE}} → 项目画像路径
|
||||
{{FLEET_CONFIG}} → fleet_config.yml 路径
|
||||
"""
|
||||
replacements = {
|
||||
"{{BASE_NAME}}": context.get("base_name", ""),
|
||||
"{{MANIFEST_DIR}}": str(REPO_ROOT / "output" / "manifests"),
|
||||
"{{PREPARE_MANIFEST}}": str(REPO_ROOT / "output" / "manifests" / f"{context.get('base_name', '')}_prepare.json"),
|
||||
"{{ANALYZE_MANIFEST}}": str(REPO_ROOT / "output" / "manifests" / f"{context.get('base_name', '')}_analyze.json"),
|
||||
"{{DESIGN_MANIFEST}}": str(REPO_ROOT / "output" / "manifests" / f"{context.get('base_name', '')}_design.json"),
|
||||
"{{PROJECT_PROFILE}}": str(REPO_ROOT / "knowledge_base" / "00_project" / "project_profile.md"),
|
||||
"{{FLEET_CONFIG}}": str(REPO_ROOT / "fleet_config.yml"),
|
||||
"{{OUTPUT_DIR}}": str(REPO_ROOT / "output"),
|
||||
"{{KNOWLEDGE_BASE_DIR}}": str(REPO_ROOT / "knowledge_base"),
|
||||
"{{REQUIREMENT_FILE}}": context.get("requirement_file", ""),
|
||||
}
|
||||
result = prompt
|
||||
for placeholder, value in replacements.items():
|
||||
result = result.replace(placeholder, value)
|
||||
return result
|
||||
|
||||
|
||||
def render_agent_prompt(agent_id: str, context: dict[str, Any]) -> str:
|
||||
"""加载并渲染 Agent prompt(注入上下文)。"""
|
||||
raw = load_agent_prompt(agent_id)
|
||||
return inject_context(raw, context)
|
||||
|
||||
|
||||
def get_agent_frontmatter(agent_id: str) -> dict[str, Any]:
|
||||
"""提取 Agent prompt 的 YAML frontmatter。"""
|
||||
content = load_agent_prompt(agent_id)
|
||||
frontmatter: dict[str, Any] = {}
|
||||
if content.startswith("---"):
|
||||
parts = content.split("---", 2)
|
||||
if len(parts) >= 3:
|
||||
for line in parts[1].strip().split("\n"):
|
||||
line = line.strip()
|
||||
if ":" in line:
|
||||
key, value = line.split(":", 1)
|
||||
frontmatter[key.strip()] = value.strip()
|
||||
return frontmatter
|
||||
|
||||
|
||||
def get_agent_short_description(agent_id: str) -> str:
|
||||
"""返回 Agent 的一句话描述。"""
|
||||
info = AGENT_REGISTRY.get(agent_id, {})
|
||||
return info.get("description", agent_id)
|
||||
|
||||
|
||||
def validate_all_agents() -> dict[str, Any]:
|
||||
"""验证所有 Agent prompt 文件是否存在且非空。"""
|
||||
result: dict[str, Any] = {"valid": True, "missing": [], "empty": [], "total": len(AGENT_REGISTRY)}
|
||||
for agent_id in AGENT_REGISTRY:
|
||||
path = get_agent_path(agent_id)
|
||||
if not path.exists():
|
||||
result["missing"].append(agent_id)
|
||||
result["valid"] = False
|
||||
elif path.stat().st_size == 0:
|
||||
result["empty"].append(agent_id)
|
||||
result["valid"] = False
|
||||
return result
|
||||
@@ -0,0 +1,180 @@
|
||||
"""
|
||||
Agentic QE Fleet — Manifest 管理模块
|
||||
|
||||
负责跨战区 manifest JSON 的读写、合并、验证和迁移。
|
||||
每个战区产出一个 manifest_<zone>.json,fleet_runner.py 消费。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
MANIFESTS_DIR = REPO_ROOT / "output" / "manifests"
|
||||
|
||||
# 战区顺序
|
||||
ZONE_ORDER = ["prepare", "analyze", "design", "review", "monitor"]
|
||||
|
||||
|
||||
def ensure_manifests_dir() -> Path:
|
||||
MANIFESTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
return MANIFESTS_DIR
|
||||
|
||||
|
||||
def manifest_path(base_name: str, zone: str) -> Path:
|
||||
"""返回战区 manifest 文件路径。"""
|
||||
return MANIFESTS_DIR / f"{base_name}_{zone}.json"
|
||||
|
||||
|
||||
def load_manifest(base_name: str, zone: str) -> dict[str, Any]:
|
||||
"""加载指定战区的 manifest。"""
|
||||
path = manifest_path(base_name, zone)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Manifest 不存在: {path}")
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def load_manifest_safe(base_name: str, zone: str) -> dict[str, Any] | None:
|
||||
"""安全加载 manifest,不存在时返回 None。"""
|
||||
path = manifest_path(base_name, zone)
|
||||
if not path.exists():
|
||||
return None
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def save_manifest(base_name: str, zone: str, data: dict[str, Any]) -> Path:
|
||||
"""保存战区 manifest。"""
|
||||
ensure_manifests_dir()
|
||||
path = manifest_path(base_name, zone)
|
||||
data.setdefault("_meta", {})
|
||||
data["_meta"]["zone"] = zone
|
||||
data["_meta"]["base_name"] = base_name
|
||||
data["_meta"]["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def build_merged_manifest(base_name: str, zones: list[str] | None = None) -> dict[str, Any]:
|
||||
"""合并多个战区的 manifest 为统一视图。"""
|
||||
if zones is None:
|
||||
zones = ZONE_ORDER
|
||||
|
||||
merged: dict[str, Any] = {
|
||||
"_meta": {
|
||||
"base_name": base_name,
|
||||
"merged_zones": [],
|
||||
"merged_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
}
|
||||
|
||||
for zone in zones:
|
||||
manifest = load_manifest_safe(base_name, zone)
|
||||
if manifest is None:
|
||||
continue
|
||||
merged["_meta"]["merged_zones"].append(zone)
|
||||
# 战区数据按 zone 命名空间隔离
|
||||
merged[zone] = manifest
|
||||
# 同时摊平顶层便捷字段(后者覆盖前者)
|
||||
for key, value in manifest.items():
|
||||
if key.startswith("_"):
|
||||
continue
|
||||
merged[key] = value
|
||||
|
||||
return merged
|
||||
|
||||
|
||||
def get_zone_status(base_name: str) -> dict[str, str]:
|
||||
"""返回各战区完成状态。"""
|
||||
status: dict[str, str] = {}
|
||||
for zone in ZONE_ORDER:
|
||||
manifest = load_manifest_safe(base_name, zone)
|
||||
if manifest is None:
|
||||
status[zone] = "pending"
|
||||
elif manifest.get("_meta", {}).get("status") == "completed":
|
||||
status[zone] = "completed"
|
||||
else:
|
||||
status[zone] = "in_progress"
|
||||
return status
|
||||
|
||||
|
||||
def mark_zone_completed(base_name: str, zone: str) -> Path:
|
||||
"""标记战区为已完成。"""
|
||||
manifest = load_manifest(base_name, zone)
|
||||
manifest.setdefault("_meta", {})["status"] = "completed"
|
||||
return save_manifest(base_name, zone, manifest)
|
||||
|
||||
|
||||
def get_confirmation_gate(base_name: str) -> dict[str, Any] | None:
|
||||
"""获取当前确认门禁状态(分析战区产出)。"""
|
||||
manifest = load_manifest_safe(base_name, "analyze")
|
||||
if manifest is None:
|
||||
return None
|
||||
return manifest.get("confirmation_gate")
|
||||
|
||||
|
||||
def get_quality_verdict(base_name: str) -> dict[str, Any] | None:
|
||||
"""获取质量裁决(评审战区产出)。"""
|
||||
manifest = load_manifest_safe(base_name, "review")
|
||||
if manifest is None:
|
||||
return None
|
||||
return manifest.get("quality_verdict")
|
||||
|
||||
|
||||
def get_latest_zone(base_name: str) -> str | None:
|
||||
"""返回最新已完成的战区名称。"""
|
||||
for zone in reversed(ZONE_ORDER):
|
||||
manifest = load_manifest_safe(base_name, zone)
|
||||
if manifest and manifest.get("_meta", {}).get("status") == "completed":
|
||||
return zone
|
||||
return None
|
||||
|
||||
|
||||
def get_next_zone(base_name: str) -> str | None:
|
||||
"""返回下一个待执行的战区名称。"""
|
||||
for zone in ZONE_ORDER:
|
||||
manifest = load_manifest_safe(base_name, zone)
|
||||
if manifest is None or manifest.get("_meta", {}).get("status") != "completed":
|
||||
return zone
|
||||
# 全部完成
|
||||
return None
|
||||
|
||||
|
||||
def migrate_legacy_manifest(base_name: str) -> dict[str, Any]:
|
||||
"""从旧的单 manifest JSON 迁移到新的多战区 manifest 结构。"""
|
||||
legacy_path = MANIFESTS_DIR / f"{base_name}.json"
|
||||
if not legacy_path.exists():
|
||||
raise FileNotFoundError(f"旧版 manifest 不存在: {legacy_path}")
|
||||
|
||||
legacy = json.loads(legacy_path.read_text(encoding="utf-8"))
|
||||
|
||||
# 从旧 manifest 推断 prepare 战区数据
|
||||
prepare_data = {
|
||||
"_meta": {"zone": "prepare", "base_name": base_name, "status": "completed",
|
||||
"migrated_from_legacy": True},
|
||||
"base_name": legacy.get("base_name", base_name),
|
||||
"normalized_requirement_file": legacy.get("normalized_requirement_file"),
|
||||
"normalized_technical_solution_files": legacy.get("normalized_technical_solution_files", []),
|
||||
"technical_solution_files": legacy.get("technical_solution_files", []),
|
||||
"project_profile_file": legacy.get("project_profile_file"),
|
||||
"effective_terminology_files": legacy.get("effective_terminology_files", []),
|
||||
"optional_terminology_files": legacy.get("optional_terminology_files", []),
|
||||
"knowledge_base_files": legacy.get("knowledge_base_files", []),
|
||||
}
|
||||
save_manifest(base_name, "prepare", prepare_data)
|
||||
|
||||
# 从旧 manifest 推断 analyze 战区数据
|
||||
analyze_data = {
|
||||
"_meta": {"zone": "analyze", "base_name": base_name, "status": "completed",
|
||||
"migrated_from_legacy": True},
|
||||
"related_requirements": legacy.get("related_requirements", []),
|
||||
"conflict_candidates_count": legacy.get("conflict_candidates_count", 0),
|
||||
"confirmation_gate": legacy.get("confirmation_gate", {}),
|
||||
"analysis_file": legacy.get("analysis_file"),
|
||||
"relation_report_file": legacy.get("relation_report_file"),
|
||||
}
|
||||
save_manifest(base_name, "analyze", analyze_data)
|
||||
|
||||
return build_merged_manifest(base_name)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,252 @@
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
WORKFLOW_TRIGGER_PATHS = {
|
||||
"AGENTS.md",
|
||||
"README.md",
|
||||
"操作手册.md",
|
||||
"scripts/case_pipeline.py",
|
||||
"scripts/export_excel.py",
|
||||
"scripts/governance_audit.py",
|
||||
"knowledge_base/01_standards/test_case_template.md",
|
||||
"knowledge_base/01_standards/definition_of_done.md",
|
||||
"knowledge_base/01_standards/review_checklist.md",
|
||||
"knowledge_base/01_standards/terminology.md",
|
||||
"knowledge_base/01_standards/terminology_optional_saas.md",
|
||||
}
|
||||
WORKFLOW_TRIGGER_PREFIXES = (
|
||||
".claude/",
|
||||
"agents/",
|
||||
"scripts/",
|
||||
"knowledge_base/01_standards/",
|
||||
)
|
||||
STRUCTURE_TRIGGER_PREFIXES = (
|
||||
".claude/",
|
||||
"agents/",
|
||||
"scripts/",
|
||||
"knowledge_base/01_standards/",
|
||||
)
|
||||
IGNORED_PREFIXES = (
|
||||
"output/",
|
||||
"requirements/",
|
||||
"knowledge_base/02_history/",
|
||||
"knowledge_base/03_best_practices/",
|
||||
)
|
||||
|
||||
CONSISTENCY_EXPECTATIONS = {
|
||||
"AGENTS.md": [
|
||||
"python3 scripts/case_pipeline.py prepare --requirement",
|
||||
"python3 scripts/case_pipeline.py verify --requirement",
|
||||
"python3 scripts/case_pipeline.py export --requirement",
|
||||
"output/manifests/{BASE_NAME}.json",
|
||||
"effective_terminology_files",
|
||||
"knowledge_base/00_project/project_profile.md",
|
||||
"knowledge_base/01_standards/review_checklist.md",
|
||||
"normalized_requirement_file",
|
||||
],
|
||||
".claude/commands/case_generate.md": [
|
||||
"python3 scripts/case_pipeline.py prepare --requirement",
|
||||
"python3 scripts/case_pipeline.py verify --requirement",
|
||||
"python3 scripts/case_pipeline.py export --requirement",
|
||||
"output/manifests/{BASE_NAME}.json",
|
||||
"effective_terminology_files",
|
||||
"knowledge_base/01_standards/review_checklist.md",
|
||||
"normalized_requirement_file",
|
||||
],
|
||||
".claude/skills/case_generate/SKILL.md": [
|
||||
"python3 scripts/case_pipeline.py prepare --requirement",
|
||||
"python3 scripts/case_pipeline.py verify --requirement",
|
||||
"python3 scripts/case_pipeline.py export --requirement",
|
||||
"output/manifests/{BASE_NAME}.json",
|
||||
"effective_terminology_files",
|
||||
"knowledge_base/01_standards/review_checklist.md",
|
||||
"normalized_requirement_file",
|
||||
],
|
||||
".claude/instructions.md": [
|
||||
"scripts/case_pipeline.py",
|
||||
"effective_terminology_files",
|
||||
"knowledge_base/00_project/project_profile.md",
|
||||
"knowledge_base/01_standards/review_checklist.md",
|
||||
"normalized_requirement_file",
|
||||
],
|
||||
"README.md": [
|
||||
"output/manifests/{BASE_NAME}.json",
|
||||
"术语文件按需求内容自动识别生效范围",
|
||||
"knowledge_base/00_project/project_profile.md",
|
||||
"review_checklist.md",
|
||||
"source_docs/requirements_raw",
|
||||
"output/versions/{BASE_NAME}/v1/",
|
||||
"VERSION_INDEX.md",
|
||||
"最近 `3` 个版本",
|
||||
],
|
||||
"操作手册.md": [
|
||||
"terminology_optional_saas.md",
|
||||
"自动识别",
|
||||
"project_profile.md",
|
||||
"review_checklist.md",
|
||||
"source_docs/",
|
||||
"output/versions/{BASE_NAME}/v1/",
|
||||
"snapshot_meta.json",
|
||||
"最近 `3` 个版本",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def read_text(relative_path: str) -> str:
|
||||
return (REPO_ROOT / relative_path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def git_status_paths() -> list[tuple[str, str]]:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "status", "--porcelain"],
|
||||
cwd=REPO_ROOT,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
raise RuntimeError(exc.stderr.strip() or "无法读取 git status。") from exc
|
||||
|
||||
items: list[tuple[str, str]] = []
|
||||
for raw_line in result.stdout.splitlines():
|
||||
if not raw_line:
|
||||
continue
|
||||
status = raw_line[:2]
|
||||
payload = raw_line[3:]
|
||||
path = payload.split(" -> ")[-1].strip()
|
||||
if path:
|
||||
items.append((status, path))
|
||||
return items
|
||||
|
||||
|
||||
def matches_any_prefix(path: str, prefixes: tuple[str, ...]) -> bool:
|
||||
return any(path.startswith(prefix) for prefix in prefixes)
|
||||
|
||||
|
||||
def should_ignore(path: str) -> bool:
|
||||
return matches_any_prefix(path, IGNORED_PREFIXES)
|
||||
|
||||
|
||||
def is_workflow_trigger(path: str) -> bool:
|
||||
return path in WORKFLOW_TRIGGER_PATHS or matches_any_prefix(path, WORKFLOW_TRIGGER_PREFIXES)
|
||||
|
||||
|
||||
def is_structure_trigger(path: str, status: str) -> bool:
|
||||
if "?" in status or "A" in status or "D" in status or "R" in status:
|
||||
return matches_any_prefix(path, STRUCTURE_TRIGGER_PREFIXES)
|
||||
return False
|
||||
|
||||
|
||||
def classify_changed_files(items: list[tuple[str, str]]) -> dict[str, list[str]]:
|
||||
workflow_hits: list[str] = []
|
||||
structure_hits: list[str] = []
|
||||
ignored_hits: list[str] = []
|
||||
|
||||
for status, path in items:
|
||||
if should_ignore(path):
|
||||
ignored_hits.append(path)
|
||||
continue
|
||||
if is_workflow_trigger(path):
|
||||
workflow_hits.append(path)
|
||||
if is_structure_trigger(path, status):
|
||||
structure_hits.append(path)
|
||||
|
||||
return {
|
||||
"workflow_hits": sorted(set(workflow_hits)),
|
||||
"structure_hits": sorted(set(structure_hits)),
|
||||
"ignored_hits": sorted(set(ignored_hits)),
|
||||
}
|
||||
|
||||
|
||||
def audit_consistency() -> list[str]:
|
||||
issues: list[str] = []
|
||||
|
||||
for relative_path, snippets in CONSISTENCY_EXPECTATIONS.items():
|
||||
content = read_text(relative_path)
|
||||
for snippet in snippets:
|
||||
if snippet not in content:
|
||||
issues.append(f"{relative_path} 缺少关键片段: {snippet}")
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def print_trigger_report(classification: dict[str, list[str]]) -> bool:
|
||||
workflow_hits = classification["workflow_hits"]
|
||||
structure_hits = classification["structure_hits"]
|
||||
should_run = bool(workflow_hits or structure_hits)
|
||||
|
||||
print(f"should_run={str(should_run).lower()}")
|
||||
if workflow_hits:
|
||||
print("workflow_hits:")
|
||||
for path in workflow_hits:
|
||||
print(f" - {path}")
|
||||
if structure_hits:
|
||||
print("structure_hits:")
|
||||
for path in structure_hits:
|
||||
print(f" - {path}")
|
||||
return should_run
|
||||
|
||||
|
||||
def command_should_run() -> int:
|
||||
classification = classify_changed_files(git_status_paths())
|
||||
should_run = print_trigger_report(classification)
|
||||
return 0 if should_run else 1
|
||||
|
||||
|
||||
def command_audit() -> int:
|
||||
issues = audit_consistency()
|
||||
if issues:
|
||||
print("audit_status=fail")
|
||||
for issue in issues:
|
||||
print(f"- {issue}")
|
||||
return 1
|
||||
|
||||
print("audit_status=pass")
|
||||
return 0
|
||||
|
||||
|
||||
def command_auto() -> int:
|
||||
classification = classify_changed_files(git_status_paths())
|
||||
should_run = print_trigger_report(classification)
|
||||
if not should_run:
|
||||
print("audit_skipped=true")
|
||||
return 0
|
||||
|
||||
issues = audit_consistency()
|
||||
if issues:
|
||||
print("audit_status=fail")
|
||||
for issue in issues:
|
||||
print(f"- {issue}")
|
||||
return 1
|
||||
|
||||
print("audit_status=pass")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="流程一致性与文档滞后审计。")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
subparsers.add_parser("should-run", help="根据当前 git 变更判断是否需要触发治理审计。")
|
||||
subparsers.add_parser("audit", help="执行一致性与文档滞后检查。")
|
||||
subparsers.add_parser("auto", help="自动判断是否触发,并在需要时执行审计。")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == "should-run":
|
||||
return command_should_run()
|
||||
if args.command == "audit":
|
||||
return command_audit()
|
||||
if args.command == "auto":
|
||||
return command_auto()
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user