init the project

This commit is contained in:
evyzacq
2026-05-25 15:09:42 +08:00
commit 7fc0e7852e
122 changed files with 14557 additions and 0 deletions
View File
View File
View File
+237
View File
@@ -0,0 +1,237 @@
# Chat API — loads prompt template, injects dynamic context, calls LLM
import json
import logging
import os
import re
from pathlib import Path
import yaml
from fastapi import APIRouter
from pydantic import BaseModel
from server.config import settings
from server.core.llm_provider.router import router as llm_router
logger = logging.getLogger("testflow")
router = APIRouter()
class ChatRequest(BaseModel):
message: str
context: dict = {}
history: list[dict] = []
# ============================================================================
# Prompt template
# ============================================================================
PROMPT_PATH = Path(__file__).parent.parent.parent / "services" / "prompts" / "chat_system.md"
PROMPT_TEMPLATE = PROMPT_PATH.read_text(encoding="utf-8") if PROMPT_PATH.exists() else "{persona}\n{principles}\n{page_context}\n{data_context}\n{action_format}"
# ============================================================================
# Personality & principles
# ============================================================================
def _load_persona() -> str:
parts = []
try:
p_path = settings.SOUL_DIR / "persona.yaml"
if p_path.exists():
data = yaml.safe_load(p_path.open(encoding="utf-8")) or {}
p = data.get("persona", {})
parts.append(f"## 你的身份\n你是 {p.get('name','Zeeker')},一名{p.get('role','资深测试架构师')}")
parts.append(f"经验: {p.get('experience','')}")
parts.append(f"语气: {p.get('tone','专业、严谨、耐心')}")
traits = p.get("traits", [])
if traits: parts.append("特点: " + "; ".join(traits))
limits = p.get("limitations", [])
if limits: parts.append("限制: " + "; ".join(limits))
except Exception as e:
logger.warning("persona load failed: %s", e)
return "\n".join(parts)
def _load_principles() -> str:
parts = ["## 不可违背的原则"]
try:
p_path = settings.SOUL_DIR / "principles.yaml"
if p_path.exists():
data = yaml.safe_load(p_path.open(encoding="utf-8")) or {}
for pr in data.get("principles", []):
parts.append(f"- [{pr.get('priority','?')}] {pr.get('rule','')}")
except Exception as e:
logger.warning("principles load failed: %s", e)
return "\n".join(parts)
# ============================================================================
# Dynamic context builders (page-aware)
# ============================================================================
def _build_page_context(ctx: dict) -> str:
"""Describe what the user is looking at on the left panel."""
page = ctx.get("current_page", "unknown")
lines = []
if page == "home":
lines.append("- 用户正在主页,可以上传 PRD 文件")
if ctx.get("has_prd") and not ctx.get("has_ir"):
lines.append("- PRD 已上传,IR 尚未生成。建议用户点击生成 IR。")
elif ctx.get("has_prd") and ctx.get("has_ir"):
lines.append("- PRD 和 IR 已生成。用户可以前往 IR 确认或查看测试用例。")
elif page == "ir-confirm":
n = len(ctx.get("ir_rules_summary", "").split("\n")) - 2 # skip header rows
lines.append(f"- 用户正在 **IR 确认页**,当前 IR 含 {max(0,n)} 条规则")
if ctx.get("ir_id"):
lines.append(f"- IR ID: {ctx['ir_id']}")
lines.append("- 用户可以查看思维导图、编辑 YAML、点击节点定位代码")
lines.append("- 你可以帮用户审查规则完整性、增删改规则、调整优先级")
elif page == "cases":
lines.append(f"- 用户正在 **测试用例页**,当前 {ctx.get('testcase_count', 0)} 条用例")
if ctx.get("tc_set_id"):
lines.append(f"- 用例集 ID: {ctx['tc_set_id']}")
if ctx.get("ir_id"):
lines.append(f"- 这些用例基于 IR: {ctx['ir_id']}")
lines.append("- 你可以帮用户增加测试用例、检查覆盖率、修改用例内容、导出用例")
existing = ctx.get("existing_tc_ids", [])
if existing:
lines.append(f"\n已有用例 ID: {', '.join(existing[:20])}" + (f" ...共{len(existing)}" if len(existing) > 20 else ""))
sel = ctx.get("selected_case")
if sel and isinstance(sel, dict) and sel.get("id"):
lines.append(f"\n⚠️ **用户当前选中的测试用例:**")
lines.append(f"- ID: {sel['id']} | 标题: {sel.get('title','')}")
lines.append(f"- 步骤: {sel.get('steps','')[:200]}")
lines.append(f"- 预期: {sel.get('expected','')}")
lines.append(f"用户说\"这条\"/\"这个\"时,指的是测试用例 {sel['id']},不是 IR 规则。在 cases 页面操作的是测试用例!")
return "\n".join(lines)
def _build_data_context(ctx: dict) -> str:
"""Inject the actual data from the left panel."""
parts = []
if ctx.get("prd_text"):
parts.append(f"### PRD 文本(前 2000 字符)\n```\n{ctx['prd_text'][:2000]}\n```")
if ctx.get("ir_rules_summary"):
parts.append(f"### IR 规则列表\n{ctx['ir_rules_summary']}")
if ctx.get("testcase_count", 0) > 0 and ctx.get("testcase_summary"):
parts.append(f"### 测试用例概览 ({ctx['testcase_count']} 条)\n```\n{ctx['testcase_summary']}\n```")
return "\n".join(parts) if parts else "(暂无左侧数据)"
def _build_action_format(ctx: dict) -> str:
"""Page-specific action instructions."""
page = ctx.get("current_page", "unknown")
lines = ["修改 IR 规则时使用 rule_id 精准定位 (格式 IR-{FEATURE}-{section}-{NNN})",
' {"action": "add_rule", "rule": {...}}',
' {"action": "delete_rule", "rule_id": "IR-ZWM-4.2.1-001"}',
' {"action": "modify_rule", "rule_id": "IR-ZWM-4.2.1-001", "changes": {"priority": "P0"}}',
' {"action": "generate_cases", "ir_id": "<ir_id>"}']
if page == "cases":
lines.append("\n在测试用例页面时,使用 add_case/delete_case/modify_case")
lines.append(' {"action": "add_case", "rule": {"case_title":"...","steps":"Given..When..Then..","expected_result":"...","priority":"P0|P1|P2","tags":["正向"],"module":"模块名"}}')
lines.append(' {"action": "modify_case", "case_id": "TC-ZEEKER-002", "changes": {"case_title": "新标题"}}')
lines.append(' {"action": "delete_case", "case_id": "TC-ZEEKER-002"}')
lines.append("注意: add_case 时不要填写 id 字段(系统自动分配)。module 字段尽量填写。delete_case 的 case_id 必须是页面上已有的真实 ID。")
lines.append("用户要求增改用例时,必须输出 action!只描述不输出 action = 什么都没做。")
lines.append("\n用户每次要求增删改时,必须在回复末尾输出对应 action 块。不输出 = 没执行。")
return "\n".join(lines)
# ============================================================================
# Memory
# ============================================================================
def _load_memory(session_id: str) -> list[dict]:
f = settings.MEMORY_DIR / f"chat_{session_id}.json"
if f.exists():
try: return json.loads(f.read_text(encoding="utf-8"))
except: pass
return []
def _save_memory(session_id: str, messages: list[dict]):
os.makedirs(settings.MEMORY_DIR, exist_ok=True)
f = settings.MEMORY_DIR / f"chat_{session_id}.json"
try:
to_save = messages[-30:] if len(messages) > 30 else messages
f.write_text(json.dumps(to_save, ensure_ascii=False, indent=2), encoding="utf-8")
except Exception as e:
logger.warning("memory save failed: %s", e)
# ============================================================================
# Build full system prompt
# ============================================================================
def _build_system_prompt(context: dict) -> str:
return PROMPT_TEMPLATE.replace("{persona}", _load_persona()) \
.replace("{principles}", _load_principles()) \
.replace("{page_context}", _build_page_context(context)) \
.replace("{data_context}", _build_data_context(context)) \
.replace("{action_format}", _build_action_format(context))
# ============================================================================
# Chat endpoint
# ============================================================================
@router.post("")
@router.post("/")
async def chat(request: ChatRequest):
session_id = request.context.get("session_id", "default")
memory = _load_memory(session_id)
system_msg = _build_system_prompt(request.context)
messages = [{"role": "system", "content": system_msg}]
messages.extend(memory[-8:])
for h in (request.history or [])[-6:]:
messages.append(h)
messages.append({"role": "user", "content": request.message})
logger.info("[CHAT] session=%s page=%s memory=%d prompt=%d chars",
session_id, request.context.get("current_page","?"),
len(memory)//2, len(system_msg))
client = llm_router.get_chat_client()
try:
raw = client.chat(model=llm_router.text_model, messages=messages, temperature=0.3)
except Exception as e:
logger.error("[CHAT] LLM failed: %s", e)
return {"reply": f"LLM 调用失败: {e}", "actions": []}
reply, actions = _extract_actions(raw)
memory.append({"role": "user", "content": request.message})
memory.append({"role": "assistant", "content": reply})
_save_memory(session_id, memory)
logger.info("[CHAT] reply=%d chars actions=%d", len(reply), len(actions))
return {"reply": reply, "actions": actions}
def _extract_actions(text: str) -> tuple[str, list[dict]]:
actions, clean = [], text
for m in re.findall(r"```action\s*\n([\s\S]*?)```", text):
for line in m.strip().split("\n"):
try:
a = json.loads(line.strip())
if isinstance(a, dict) and "action" in a: actions.append(a)
except: pass
clean = re.sub(r"```action\s*\n[\s\S]*?```", "", clean)
for s in re.findall(r'\{"action":\s*"[^"]+"[^}]*\}', text):
try:
a = json.loads(s)
if a not in actions: actions.append(a)
except: pass
return clean.strip(), actions
+191
View File
@@ -0,0 +1,191 @@
# IR 生成、验证、Diff 接口
import asyncio
import json
import logging
from concurrent.futures import ThreadPoolExecutor
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import StreamingResponse
from server.services.ir_engine.generator import ir_generator
from server.services.ir_engine.pipeline import ir_pipeline
from server.services.ir_engine.validator import ir_validator
from server.services.ir_engine.diff import ir_diff
from server.services.prd_manager.service import prd_service
logger = logging.getLogger(__name__)
router = APIRouter()
_pipeline_executor = ThreadPoolExecutor(max_workers=2)
@router.get("/generate-stream")
async def generate_ir_stream(prd_id: str = Query(...), skill_name: str = "default"):
"""基于 PRD 生成 IR,通过 SSE 流式返回进度"""
prd = await prd_service.get_prd(prd_id)
if not prd:
raise HTTPException(404, "PRD not found")
async def event_stream():
# Wait for background parsing if still in progress
if prd.get("status") == "parsing":
yield f"data: {json.dumps({'stage': 0, 'status': 'running', 'message': '文档解析中,请稍候...', 'detail': '图片分析可能需要 30-60 秒', 'stage_total': 4})}\n\n"
waited = 0
while waited < 120:
await asyncio.sleep(1)
waited += 1
p = await prd_service.get_prd(prd_id)
if not p or p.get("status") in ("ready", "error"):
break
if waited % 5 == 0:
yield f"data: {json.dumps({'stage': 0, 'status': 'running', 'message': f'文档解析中...({waited}s)', 'stage_total': 4})}\n\n"
p = await prd_service.get_prd(prd_id)
if p and p.get("status") == "error":
yield f"data: {json.dumps({'error': p.get('error', '解析失败'), 'stage_total': 4})}\n\n"
return
# Reload after parsing
prd2 = await prd_service.get_prd(prd_id)
parsed_doc = _load_parsed_doc(prd2 or prd)
if not parsed_doc:
yield f"data: {json.dumps({'error': '无法加载解析后的文档'})}\n\n"
return
yield f"data: {json.dumps({'stage': 0, 'status': 'done', 'message': '文档解析完成', 'stage_total': 4})}\n\n"
# Run pipeline in thread → Queue → SSE
queue: asyncio.Queue = asyncio.Queue()
def _run_in_thread():
try:
for event in ir_pipeline.run_streaming(prd_id, parsed_doc):
queue.put_nowait(event)
except Exception as e:
logger.exception("Pipeline error")
queue.put_nowait({"error": str(e), "done": True})
finally:
queue.put_nowait(None)
loop = asyncio.get_event_loop()
loop.run_in_executor(_pipeline_executor, _run_in_thread)
while True:
event = await queue.get()
if event is None:
break
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
yield "data: {\"done\": true}\n\n"
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
def _load_parsed_doc(prd: dict) -> dict | None:
"""Load parsed document from disk or build from PRD data."""
import os as _os
parsed_path = prd.get("parsed_path", "")
if parsed_path and _os.path.isfile(parsed_path):
try:
with open(parsed_path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
pass
sections = prd.get("sections", [])
if not sections and prd.get("full_text"):
sections = [{"source": "正文", "blocks": [{"type": "para", "index": 1, "text": prd["full_text"]}], "images": []}]
if sections or prd.get("full_text"):
return {
"source": "",
"sections": sections,
"image_sources": {},
"image_analysis": prd.get("images", []),
"resolved_conflicts": [],
}
return None
@router.post("/generate")
async def generate_ir(prd_id: str, skill_name: str = "default"):
"""基于 PRD 和 Skill 生成 IR"""
prd = await prd_service.get_prd(prd_id)
if not prd:
raise HTTPException(404, "PRD not found")
prd_text = prd.get("full_text", "")
if not prd_text:
raise HTTPException(400, "PRD has no text content")
result = await ir_generator.generate(prd_id, prd_text, skill_name)
if "error" in result:
raise HTTPException(500, result["error"])
return {
"ir_id": result["ir_id"],
"prd_id": result["prd_id"],
"yaml_content": result["yaml_content"],
"ir_json": result.get("ir_json", {}),
"audit": result.get("audit", {}),
"audit_report": result.get("audit_report", ""),
"skill_used": result["skill_used"],
"created_at": result["created_at"],
"pipeline_stats": result.get("pipeline_stats", {}),
}
@router.get("/{ir_id}")
async def get_ir(ir_id: str):
"""获取 IR 详情"""
ir = await ir_generator.get_ir(ir_id)
if not ir:
raise HTTPException(404, "IR not found")
return ir
@router.put("/{ir_id}")
async def update_ir(ir_id: str, body: dict):
"""更新 IR 内容(编辑后同步回后端)"""
ir = await ir_generator.get_ir(ir_id)
if not ir:
raise HTTPException(404, "IR not found")
new_content = body.get("yaml_content", "")
if new_content:
ir["yaml_content"] = new_content
try:
ir["ir_json"] = json.loads(new_content)
except json.JSONDecodeError:
pass
return {"ir_id": ir_id, "status": "updated"}
@router.post("/{ir_id}/validate")
async def validate_ir(ir_id: str):
"""验证 IR 是否符合 Schema 和 Principles"""
ir = await ir_generator.get_ir(ir_id)
if not ir:
raise HTTPException(404, "IR not found")
result = await ir_validator.validate(ir["yaml_content"], ir.get("skill_used", "default"))
return {
"ir_id": ir_id,
"valid": result["valid"],
"issues": result["issues"],
}
@router.get("/diff")
async def diff_ir(ir_id_a: str, ir_id_b: str):
"""对比两个 IR 版本的差异"""
result = await ir_diff.diff(ir_id_a, ir_id_b)
if "error" in result:
raise HTTPException(400, result["error"])
return result
+81
View File
@@ -0,0 +1,81 @@
# PRD 上传与解析接口
import logging
from concurrent.futures import ThreadPoolExecutor
from fastapi import APIRouter, UploadFile, File, HTTPException
from server.services.prd_manager.service import prd_service
logger = logging.getLogger(__name__)
router = APIRouter()
ALLOWED_EXTENSIONS = {".md", ".txt", ".docx", ".pdf"}
_parse_executor = ThreadPoolExecutor(max_workers=2)
@router.post("/upload")
async def upload_prd(file: UploadFile = File(...)):
"""上传 PRD 文件,立即返回 PRD ID,后台异步解析"""
ext = "." + file.filename.rsplit(".", 1)[-1].lower() if "." in file.filename else ""
if ext not in ALLOWED_EXTENSIONS:
raise HTTPException(400, f"不支持的文件格式: {ext},支持: {', '.join(ALLOWED_EXTENSIONS)}")
content = await file.read()
if len(content) == 0:
raise HTTPException(400, "文件为空")
try:
# Quick: save and return immediately
prd_version = await prd_service.upload_quick(file.filename, content)
# Background: parse async (image analysis may take 60s+)
_parse_executor.submit(_parse_in_background, prd_version["prd_id"], file.filename, content)
return {
"prd_id": prd_version["prd_id"],
"filename": prd_version["filename"],
"status": "parsing",
"uploaded_at": prd_version["uploaded_at"],
}
except Exception as e:
logger.error("PRD upload failed: %s", e)
raise HTTPException(500, f"上传失败: {e}")
def _parse_in_background(prd_id: str, filename: str, content: bytes):
"""Run full parsing (text + images + vision analysis) in background thread."""
import asyncio
try:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(prd_service.parse_async(prd_id, filename, content))
except Exception as e:
logger.error("Background parse failed for %s: %s", prd_id, e)
prd_service.set_status(prd_id, "error", str(e))
@router.get("/{prd_id}/status")
async def get_prd_status(prd_id: str):
"""获取 PRD 解析状态"""
status = await prd_service.get_status(prd_id)
if not status:
raise HTTPException(404, "PRD not found")
return status
@router.get("/{prd_id}")
async def get_prd(prd_id: str):
"""获取 PRD 详情与版本"""
prd = await prd_service.get_prd(prd_id)
if not prd:
raise HTTPException(404, "PRD not found")
return {
"prd_id": prd["prd_id"],
"filename": prd["filename"],
"status": prd.get("status", "ready"),
"full_text": prd.get("full_text", ""),
"sections": prd.get("sections", []),
"images": prd.get("images", []),
"uploaded_at": prd["uploaded_at"],
}
+26
View File
@@ -0,0 +1,26 @@
# Skill 列表与热加载接口
import logging
from fastapi import APIRouter
from server.services.skill_manager.service import skill_manager
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("")
@router.get("/")
async def list_skills():
"""列出所有可用 Skill"""
skills = skill_manager.list_skills()
return {"skills": skills, "count": len(skills)}
@router.post("/reload")
async def reload_skills():
"""热重新加载 Skill(无需重启服务)"""
skill_manager.reload()
skills = skill_manager.list_skills()
return {"skills": skills, "count": len(skills), "message": "Skills reloaded"}
+47
View File
@@ -0,0 +1,47 @@
# 测试用例生成与导出接口
import logging
from fastapi import APIRouter, HTTPException
from fastapi.responses import Response
from server.services.testcase_engine.generator import tc_generator
from server.services.testcase_engine.exporter import tc_exporter
logger = logging.getLogger(__name__)
router = APIRouter()
@router.post("/generate")
async def generate_testcases(ir_id: str):
"""基于 IR 生成测试用例集"""
result = await tc_generator.generate(ir_id)
if "error" in result:
raise HTTPException(500, result["error"])
return {
"tc_set_id": result["tc_set_id"],
"ir_id": result["ir_id"],
"case_count": result["case_count"],
"cases": result["cases"],
"created_at": result["created_at"],
}
@router.get("/export/{format}")
async def export_testcases(tc_set_id: str, format: str):
"""导出测试用例为指定格式 (yaml/csv/json)"""
if format not in ("yaml", "csv", "json"):
raise HTTPException(400, f"Unsupported format: {format}")
try:
content, mime_type, filename = await tc_exporter.export(tc_set_id, format)
except ValueError as e:
raise HTTPException(404, str(e))
content_bytes = content.encode("utf-8") if isinstance(content, str) else content
return Response(
content=content_bytes,
media_type=mime_type,
headers={"Content-Disposition": f"attachment; filename={filename}"},
)
+60
View File
@@ -0,0 +1,60 @@
# ZeekerWatchman - Configuration Management
import os
from pathlib import Path
from dotenv import load_dotenv
from pydantic_settings import BaseSettings
import logging
# Load .env into os.environ so non-prefixed keys (API keys) are also available
load_dotenv(Path(__file__).parent.parent / ".env")
# Configure testflow logger — used by all services
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s.%(msecs)03d %(levelname)-5s %(message)s",
datefmt="%H:%M:%S",
)
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("openai").setLevel(logging.WARNING)
class Settings(BaseSettings):
APP_NAME: str = "ZeekerWatchman"
VERSION: str = "0.1.0"
DEBUG: bool = True
# Paths
ZEEKERWATCHMEN_DIR: Path = Path(__file__).parent.parent / ".zeekerwatchmen"
SKILLS_DIR: Path = ZEEKERWATCHMEN_DIR / "skills"
MEMORY_DIR: Path = ZEEKERWATCHMEN_DIR / "memory"
SOUL_DIR: Path = ZEEKERWATCHMEN_DIR / "soul"
OUTPUT_DIR: Path = Path(__file__).parent.parent / "output"
# LLM Configuration
LLM_PROVIDER: str = "deepseek"
TEXT_MODEL: str = "deepseek-chat"
IMAGE_MODEL: str = "qwen3-vl-plus"
USE_MOCK: bool = True # True=左侧流水线用Mock / False=真实LLM
CHAT_USE_MOCK: bool = False # True=右侧AI助手用Mock / False=真实LLM
# CORS
CORS_ORIGINS: list = ["http://localhost:3000"]
# Upload
MAX_UPLOAD_SIZE_MB: int = 10
class Config:
env_file = ".env"
env_prefix = "ZEEKER_"
extra = "ignore"
settings = Settings()
# API Keys loaded from env directly (no prefix)
DEEPSEEK_API_KEY = os.environ.get("DEEPSEEK_API_KEY", "")
DEEPSEEK_BASE_URL = os.environ.get("DEEPSEEK_BASE_URL", "https://api.deepseek.com/v1")
DASHSCOPE_API_KEY = os.environ.get("DASHSCOPE_API_KEY", "")
DASHSCOPE_BASE_URL = os.environ.get("DASHSCOPE_BASE_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1")
View File
+5
View File
@@ -0,0 +1,5 @@
from server.core.llm_provider.base import LLMClient
from server.core.llm_provider.router import ModelRouter, router
from server.core.llm_provider.mock_client import MockLLMClient
__all__ = ["LLMClient", "ModelRouter", "router", "MockLLMClient"]
+145
View File
@@ -0,0 +1,145 @@
# OpenAI-compatible LLM client with retry, token tracking, and vision support.
import base64
import logging
import os
import time
from typing import Optional
from openai import OpenAI
logger = logging.getLogger("testflow")
class LLMClient:
"""Generic OpenAI-compatible LLM client.
Usage::
client = LLMClient(api_key="sk-xxx", base_url="https://api.deepseek.com/v1")
text = client.chat("deepseek-chat", [{"role": "user", "content": "Hello"}])
print(client.usage)
"""
TIMEOUT = 120
MAX_RETRIES = 3
def __init__(
self,
*,
api_key: str = "",
base_url: str = "",
model: str = "",
timeout: int | None = None,
):
if not api_key:
raise ValueError(f"API key is required for LLMClient")
self._client = OpenAI(api_key=api_key, base_url=base_url)
self._timeout = timeout or self.TIMEOUT
self._model = model
self._prompt_tokens = 0
self._completion_tokens = 0
@property
def usage(self) -> dict:
return {
"prompt_tokens": self._prompt_tokens,
"completion_tokens": self._completion_tokens,
"total_tokens": self._prompt_tokens + self._completion_tokens,
}
@staticmethod
def estimate_tokens(text: str) -> int:
cjk = sum(1 for c in text if '' <= c <= '鿿' or ' ' <= c <= '')
other = len(text) - cjk
return max(1, int(cjk / 1.7 + other / 3.0))
@staticmethod
def estimate_image_tokens() -> int:
return 500
def chat(
self,
model: str,
messages: list[dict],
*,
timeout: int | None = None,
response_format: dict | None = None,
temperature: float = 0.0,
) -> str:
# Estimate prompt size
prompt_chars = sum(len(str(m.get("content", ""))) for m in messages)
label = f"chat({model})"
def _call():
t0 = time.time()
kwargs = dict(
model=model,
messages=messages,
timeout=timeout or self._timeout,
temperature=temperature,
)
if response_format is not None:
kwargs["response_format"] = response_format
logger.info("[LLM] → %s (prompt ~%d chars / ~%d tokens)",
model, prompt_chars, prompt_chars // 3)
resp = self._client.chat.completions.create(**kwargs)
content = resp.choices[0].message.content
usg = resp.usage
if usg:
self._prompt_tokens += usg.prompt_tokens
self._completion_tokens += usg.completion_tokens
elapsed = time.time() - t0
logger.info("[LLM] ← %s: %d chars, p:%d c:%d tokens, %.1fs",
model, len(content) if content else 0,
usg.prompt_tokens if usg else 0,
usg.completion_tokens if usg else 0,
elapsed)
if not content:
raise RuntimeError("Empty response from LLM")
return content
return self._retry(_call, label)
def chat_with_image(
self,
model: str,
image_path: str,
prompt: str,
*,
timeout: int | None = None,
) -> str:
"""Send a prompt with an image attachment (for vision models)."""
with open(image_path, "rb") as f:
img_b64 = base64.b64encode(f.read()).decode()
mime = self._mime_type(image_path)
messages = [{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": f"data:{mime};base64,{img_b64}"}},
{"type": "text", "text": prompt},
],
}]
return self.chat(model, messages, timeout=timeout)
@staticmethod
def _mime_type(image_path: str) -> str:
ext = os.path.splitext(image_path)[1].lstrip(".").lower()
return {
"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg",
"gif": "image/gif", "bmp": "image/bmp",
"webp": "image/webp", "svg": "image/svg+xml", "tiff": "image/tiff",
}.get(ext, "image/png")
def _retry(self, fn, label: str) -> str:
last_error: Optional[Exception] = None
for attempt in range(self.MAX_RETRIES):
try:
return fn()
except Exception as e:
last_error = e
logger.warning("%s error (attempt %d/%d): %s", label, attempt + 1, self.MAX_RETRIES, e)
if attempt < self.MAX_RETRIES - 1:
time.sleep(2 ** attempt)
raise RuntimeError(f"{label}: all retries exhausted") from last_error
+463
View File
@@ -0,0 +1,463 @@
# Mock LLM client for offline development/testing.
import json
import logging
from server.core.llm_provider.base import LLMClient
logger = logging.getLogger("testflow")
MOCK_IR_YAML = """meta:
prd_title: ZeekerWatchman 产品需求文档
extraction_date: "2026-05-22"
skill_used: default
features:
- module: PRD管理
feature_name: PRD文件上传
description: 支持拖拽上传或文本粘贴,解析.md/.txt/.docx/.pdf格式文件
inputs:
- PRD文件 (.md/.txt/.docx/.pdf)
outputs:
- PRD_Version记录
- 纯文本摘要
preconditions:
- 用户已登录平台
constraints:
- 文件大小不超过10MB
- 仅支持指定格式
priority: P0
dependencies: []
- module: PRD管理
feature_name: PRD版本快照
description: 上传后即时保存为不可变版本,支持历史回溯
inputs:
- 已上传的PRD
outputs:
- 版本快照记录
preconditions:
- PRD已成功上传
constraints:
- 版本不可修改
priority: P1
dependencies:
- PRD文件上传
- module: IR引擎
feature_name: IR生成
description: 调用LLM利用Skill的extract_ir_prompt将PRD转化为符合IR Schema的YAML
inputs:
- PRD文本
- Skill名称
outputs:
- IR YAML
preconditions:
- PRD解析完成
- Skill已选择
constraints:
- 必须符合ir_schema.json
- 必须满足principles.yaml约束
priority: P0
dependencies:
- PRD文件上传
- module: IR引擎
feature_name: IR可视化确认
description: 双栏展示,左侧YAML编辑器,右侧思维导图实时渲染
inputs:
- IR YAML
outputs:
- 用户确认/编辑后的IR
preconditions:
- IR已生成
constraints:
- 支持实时编辑保存
priority: P1
dependencies:
- IR生成
- module: 用例引擎
feature_name: 测试用例生成
description: 基于确认后的IR调用gen_cases_prompt生成JSON格式用例集
inputs:
- 最终IR YAML
- Skill的gen_cases_prompt
outputs:
- TestCase_Set
preconditions:
- IR已确认
constraints:
- P0功能必须覆盖异常和边界场景
- 用例标题使用Given-When-Then结构
priority: P0
dependencies:
- IR生成
- module: 用例引擎
feature_name: 测试用例导出
description: 支持一键导出为YAML/CSV/XMind格式
inputs:
- TestCase_Set
- 目标格式
outputs:
- 下载文件
preconditions:
- 用例已生成
constraints:
- YAML用于自动化,CSV用于评审,XMind用于展示
priority: P1
dependencies:
- 测试用例生成"""
MOCK_TESTCASES = [
{
"id": "TC-ZWM-001",
"module": "PRD管理",
"feature": "PRD文件上传",
"case_title": "正向-上传md格式PRD成功",
"preconditions": "用户已登录,文件为有效md格式",
"steps": "Given 用户在主页面\nWhen 拖拽或选择一个.md文件上传\nThen 系统解析成功并显示PRD摘要",
"expected_result": "返回PRD ID,状态为ready,显示文本摘要",
"priority": "P0",
"tags": ["正向", "冒烟"]
},
{
"id": "TC-ZWM-002",
"module": "PRD管理",
"feature": "PRD文件上传",
"case_title": "异常-上传不支持的文件格式",
"preconditions": "用户已登录",
"steps": "Given 用户在主页面\nWhen 上传一个.exe文件\nThen 系统返回错误提示",
"expected_result": "返回400错误,提示仅支持.md/.txt/.docx/.pdf",
"priority": "P0",
"tags": ["异常"]
},
{
"id": "TC-ZWM-003",
"module": "PRD管理",
"feature": "PRD文件上传",
"case_title": "边界-上传超过10MB的文件",
"preconditions": "用户已登录",
"steps": "Given 用户在主页面\nWhen 上传一个11MB的md文件\nThen 系统拒绝并提示文件过大",
"expected_result": "返回错误,提示文件大小不超过10MB",
"priority": "P1",
"tags": ["边界"]
},
{
"id": "TC-ZWM-004",
"module": "IR引擎",
"feature": "IR生成",
"case_title": "正向-从PRD生成IR成功",
"preconditions": "PRD已解析,Skill已选择",
"steps": "Given PRD文本可用\nWhen 调用IR生成接口\nThen 返回符合IR Schema的YAML",
"expected_result": "生成包含features列表的YAML,功能点齐全",
"priority": "P0",
"tags": ["正向"]
},
{
"id": "TC-ZWM-005",
"module": "IR引擎",
"feature": "IR生成",
"case_title": "异常-空PRD文本生成IR",
"preconditions": "PRD文本为空",
"steps": "Given PRD内容为空\nWhen 调用IR生成接口\nThen 返回错误提示",
"expected_result": "返回400错误,提示PRD无文本内容",
"priority": "P0",
"tags": ["异常"]
},
{
"id": "TC-ZWM-006",
"module": "用例引擎",
"feature": "测试用例生成",
"case_title": "正向-基于IR生成测试用例",
"preconditions": "IR已确认",
"steps": "Given IR YAML可用\nWhen 调用用例生成接口\nThen 返回JSON格式的用例集",
"expected_result": "用例集包含P0功能的正向和异常用例,步骤为Given-When-Then格式",
"priority": "P0",
"tags": ["正向"]
},
{
"id": "TC-ZWM-007",
"module": "用例引擎",
"feature": "测试用例导出",
"case_title": "正向-导出用例为YAML格式",
"preconditions": "用例集已生成",
"steps": "Given 用例集可用\nWhen 选择YAML格式导出\nThen 下载YAML文件",
"expected_result": "下载的YAML文件包含所有用例及元数据",
"priority": "P1",
"tags": ["正向"]
},
{
"id": "TC-ZWM-008",
"module": "用例引擎",
"feature": "测试用例导出",
"case_title": "正向-导出用例为CSV格式",
"preconditions": "用例集已生成",
"steps": "Given 用例集可用\nWhen 选择CSV格式导出\nThen 下载CSV文件",
"expected_result": "CSV文件包含用例ID、模块、标题、步骤、预期结果等列",
"priority": "P1",
"tags": ["正向"]
}
]
MOCK_SEMANTIC_INDEX = {
"feature_name": "ZeekerWatchman 测试用例智能管理平台",
"concepts": [
{"name": "PRD", "aliases": ["产品需求文档", "需求文档"], "defined_in": ["1"]},
{"name": "IR", "aliases": ["中间表示", "Intermediate Representation"], "defined_in": ["1", "4.2.2"]},
{"name": "Skill", "aliases": ["技能包", "测试方法论"], "defined_in": ["4.1"]},
{"name": "TestCase", "aliases": ["测试用例", "用例"], "defined_in": ["4.2.3"]},
],
"function_units": [
{
"unit_id": "FU-001",
"name": "PRD文件上传与解析",
"description": "用户上传.md/.txt/.docx/.pdf格式的PRD文件,系统调用解析器提取纯文本和图片,生成版本快照",
"sources": [{"section": "4.2.1 PRD 输入与解析", "type": "para", "text_snippet": "支持拖拽上传或直接粘贴文本。服务端调用LLM或本地解析库提取纯文本"}],
},
{
"unit_id": "FU-002",
"name": "IR生成",
"description": "基于PRD文本和选定的Skill,调用LLM生成符合IR Schema的YAML中间表示",
"sources": [{"section": "4.2.2 中间表示 IR 生成与确认", "type": "para", "text_snippet": "reasoning模块调用llm_provider,利用Skill的extract_ir_prompt.j2生成符合ir_schema.json的YAML"}],
},
{
"unit_id": "FU-003",
"name": "IR验证与自检",
"description": "对生成的IR进行JSON Schema校验和Principles规则检查,输出审核意见",
"sources": [{"section": "4.2.2 中间表示 IR 生成与确认", "type": "para", "text_snippet": "LangGraph节点会校验IR是否满足schema和soul/principles.yaml"}],
},
{
"unit_id": "FU-004",
"name": "IR人工确认与编辑",
"description": "用户可在双栏界面编辑YAML并保存,保存触发新版本生成",
"sources": [{"section": "4.2.2 中间表示 IR 生成与确认", "type": "para", "text_snippet": "用户可直接编辑YAML并保存,保存操作触发新IR_Version生成"}],
},
{
"unit_id": "FU-005",
"name": "测试用例生成",
"description": "基于确认后的IR调用gen_cases_prompt生成JSON格式用例集,P0功能覆盖正向和异常场景",
"sources": [{"section": "4.2.3 测试用例生成与导出", "type": "para", "text_snippet": "基于确认后的最终IR,调用gen_cases_prompt.j2生成JSON格式的用例集"}],
},
{
"unit_id": "FU-006",
"name": "测试用例导出",
"description": "支持将用例集导出为YAML/CSV/XMind三种格式",
"sources": [{"section": "4.2.3 测试用例生成与导出", "type": "para", "text_snippet": "YAML带语法高亮的代码预览和文件下载,CSV文件,XMind服务端生成.xmind文件下载"}],
},
],
}
MOCK_IR_RULES = [
{
"description": "用户上传PRD文件时,系统检查文件格式(.md/.txt/.docx/.pdf)和大小(≤10MB),通过后调用解析器提取纯文本并创建不可变版本快照",
"priority": "P0",
"sources": [{"type": "para", "section": "4.2.1 PRD 输入与解析", "text_snippet": "支持拖拽上传或直接粘贴文本"}],
"precondition": {"app_type": "Web应用", "app_state": "已登录"},
"trigger": {
"operator": "AND",
"conditions": [
{"signal": "文件格式", "operator": "in", "value": [".md", ".txt", ".docx", ".pdf"]},
{"signal": "文件大小", "operator": "<=", "value": 10, "unit": "MB"},
],
},
"actions": [
{"type": "system", "description": "保存原始文件"},
{"type": "system", "description": "调用解析器提取纯文本"},
{"type": "system", "description": "创建PRD_Version快照"},
],
},
{
"description": "上传不支持的文件格式时,系统拒绝并返回错误提示,仅支持.md/.txt/.docx/.pdf",
"priority": "P0",
"sources": [{"type": "para", "section": "4.2.1", "text_snippet": "支持拖拽上传"}],
"precondition": {},
"trigger": {
"operator": "OR",
"conditions": [
{"signal": "文件格式", "operator": "not_in", "value": [".md", ".txt", ".docx", ".pdf"]},
{"signal": "文件大小", "operator": ">", "value": 10, "unit": "MB"},
],
},
"actions": [
{"type": "user_interaction", "description": "显示错误提示", "content": "不支持的文件格式,仅支持.md/.txt/.docx/.pdf,且不超过10MB"},
],
},
{
"description": "基于PRD文本和选定Skill调用DeepSeek生成IR YAML,结果需符合ir_schema.json和principles.yaml",
"priority": "P0",
"sources": [{"type": "para", "section": "4.2.2", "text_snippet": "reasoning模块调用llm_provider生成IR"}],
"precondition": {"app_state": "PRD已解析"},
"trigger": {
"operator": "AND",
"conditions": [
{"signal": "PRD文本", "operator": "exists", "value": True},
{"signal": "Skill", "operator": "selected", "value": True},
],
},
"actions": [
{"type": "system", "description": "加载Skill的extract_ir_prompt.j2模板"},
{"type": "system", "description": "调用LLM生成IR YAML"},
{"type": "system", "description": "持久化IR_Version"},
],
},
{
"description": "IR生成后自动进行JSON Schema校验和Principles规则检查,输出审核意见包含error和warning",
"priority": "P1",
"sources": [{"type": "para", "section": "4.2.2", "text_snippet": "LangGraph节点会校验IR"}],
"precondition": {"app_state": "IR已生成"},
"trigger": {"operator": "AND", "conditions": [{"signal": "IR", "operator": "exists", "value": True}]},
"actions": [
{"type": "system", "description": "JSON Schema校验"},
{"type": "system", "description": "Principles规则检查"},
{"type": "user_interaction", "description": "展示审核意见列表"},
],
},
{
"description": "基于确认后的IR生成JSON格式测试用例集,每个P0功能点至少包含正向和异常各1条用例,使用Given-When-Then结构",
"priority": "P0",
"sources": [{"type": "para", "section": "4.2.3", "text_snippet": "基于确认后的最终IR生成JSON格式的用例集"}],
"precondition": {"app_state": "IR已确认"},
"trigger": {"operator": "AND", "conditions": [{"signal": "IR已确认", "operator": "==", "value": True}]},
"actions": [
{"type": "system", "description": "加载Skill的gen_cases_prompt.j2模板"},
{"type": "system", "description": "调用LLM生成用例JSON"},
{"type": "system", "description": "创建TestCase_Set并关联IR_Version"},
],
},
{
"description": "用例导出功能:YAML格式用于自动化执行,CSV用于Excel评审归档",
"priority": "P1",
"sources": [{"type": "para", "section": "4.2.3", "text_snippet": "支持一键导出YAML/CSV/XMind"}],
"precondition": {"app_state": "用例已生成"},
"trigger": {"operator": "OR", "conditions": [
{"signal": "导出格式", "operator": "==", "value": "yaml"},
{"signal": "导出格式", "operator": "==", "value": "csv"},
{"signal": "导出格式", "operator": "==", "value": "xmind"},
]},
"actions": [
{"type": "system", "description": "格式化用例为指定格式"},
{"type": "user_interaction", "description": "触发文件下载"},
],
},
]
def _mock_chat_reply(prompt: str) -> str:
"""Generate a mock chat reply based on user message content."""
# Extract the last user message
user_msg = ""
for line in prompt.split("\n"):
if line.startswith("## 当前上下文"):
break
if line and not line.startswith("#") and not line.startswith("```"):
user_msg += line + " "
if "分析" in user_msg or "功能点" in user_msg:
return (
"根据 PRD 内容分析,我发现了以下功能点:\n\n"
"1. **用户登录** - 支持邮箱/手机号登录,包含密码验证和错误锁定机制\n"
"2. **用户注册** - 新用户注册流程,需要邮箱验证\n"
"3. **密码重置** - 通过邮箱验证码重置密码\n\n"
"建议操作:\n"
"- 点击「生成 IR」将这些功能点转换为结构化 IR\n"
"- 我可以帮你检查是否有遗漏的功能点"
)
if "用例" in user_msg or "测试" in user_msg:
return (
"当前测试用例包含以下覆盖:\n\n"
"- P0 正向用例:覆盖核心登录、注册流程\n"
"- P0 异常用例:密码错误、账号锁定\n"
"- P1 边界用例:连续错误锁定 15 分钟\n\n"
"建议:增加并发登录和 Token 过期测试"
)
return (
"你好!我是 ZeekerWatchman 助手。我可以帮你:\n\n"
"- 分析 PRD 文档,提取功能点\n"
"- 审查和修改 IR(中间表示)\n"
"- 生成和优化测试用例\n"
"- 导出测试用例为 YAML/CSV 格式\n\n"
"请上传一个 PRD 文档开始,或者告诉我你需要什么帮助。"
)
class MockLLMClient(LLMClient):
"""Mock client that returns realistic dummy responses for demo/testing."""
def __init__(self, model_name: str = "mock"):
self._client = None
self._timeout = 60
self._model = model_name
self._prompt_tokens = 0
self._completion_tokens = 0
def chat(
self,
model: str,
messages: list[dict],
*,
timeout: int | None = None,
response_format: dict | None = None,
temperature: float = 0.0,
) -> str:
# Check both system prompt and last message for signal phrases
full_prompt = ""
sys_prompt = ""
last_msg = ""
if messages:
for m in messages:
content = m.get("content", "")
content = content if isinstance(content, str) else str(content)
full_prompt += content + "\n"
if m.get("role") == "system":
sys_prompt = content
last_msg = messages[-1].get("content", "")
last_msg = last_msg if isinstance(last_msg, str) else str(last_msg)
# Detect mode: check system prompt first, then last user message
is_chat = (
"测试用例管理助手" in full_prompt
or "## 当前上下文" in full_prompt
)
is_semantic = (
"语义索引" in full_prompt
or "function_units" in full_prompt
or "semantic" in full_prompt.lower()
)
is_tc = (
"生成完整的测试用例集" in full_prompt
or "Given-When-Then" in full_prompt
or "## IR 内容" in full_prompt
)
is_stage2 = (
"精准上下文包" in full_prompt
or "IR Schema" in full_prompt
or "unit_id" in full_prompt
)
if is_chat:
result = _mock_chat_reply(last_msg)
elif is_semantic:
result = json.dumps(MOCK_SEMANTIC_INDEX, ensure_ascii=False)
elif is_stage2:
result = json.dumps(MOCK_IR_RULES, ensure_ascii=False)
elif is_tc:
result = json.dumps(MOCK_TESTCASES, ensure_ascii=False)
else:
result = MOCK_IR_YAML
logger.info("[MOCK] → %d chars (mode=%s)", len(result),
"chat" if is_chat else "semantic" if is_semantic else "stage2" if is_stage2 else "testcases" if is_tc else "ir_yaml")
return result
def chat_with_image(
self,
model: str,
image_path: str,
prompt: str,
*,
timeout: int | None = None,
) -> str:
return "type: other\nMock image analysis - this is a demo response."
+67
View File
@@ -0,0 +1,67 @@
# Model router: creates LLM clients based on provider configuration.
import logging
from server.config import settings, DEEPSEEK_API_KEY, DEEPSEEK_BASE_URL, DASHSCOPE_API_KEY, DASHSCOPE_BASE_URL
from server.core.llm_provider.base import LLMClient
from server.core.llm_provider.mock_client import MockLLMClient
logger = logging.getLogger("testflow")
class ModelRouter:
"""Creates and caches LLM client instances.
Two independent mock toggles:
- USE_MOCK → controls pipeline (left panel): text + image clients
- CHAT_USE_MOCK → controls AI assistant (right panel): chat client
"""
def __init__(self):
self._clients: dict[str, LLMClient] = {}
self._log_status()
def _log_status(self):
pipe = "MOCK" if settings.USE_MOCK else "REAL"
chat = "MOCK" if settings.CHAT_USE_MOCK else "REAL"
logger.info("[ROUTER] 流水线=%s | AI助手=%s (DeepSeek %s / Qwen %s)",
pipe, chat, settings.TEXT_MODEL, settings.IMAGE_MODEL)
def get_text_client(self) -> LLMClient:
"""Pipeline text client (controlled by USE_MOCK)."""
return self._get_or_create("text", settings.USE_MOCK, settings.TEXT_MODEL,
DEEPSEEK_API_KEY, DEEPSEEK_BASE_URL)
def get_image_client(self) -> LLMClient:
"""Pipeline image client (controlled by USE_MOCK)."""
return self._get_or_create("image", settings.USE_MOCK, settings.IMAGE_MODEL,
DASHSCOPE_API_KEY, DASHSCOPE_BASE_URL)
def get_chat_client(self) -> LLMClient:
"""AI assistant chat client (controlled by CHAT_USE_MOCK)."""
return self._get_or_create("chat", settings.CHAT_USE_MOCK, settings.TEXT_MODEL,
DEEPSEEK_API_KEY, DEEPSEEK_BASE_URL)
def _get_or_create(self, key: str, use_mock: bool, model: str,
api_key: str, base_url: str) -> LLMClient:
if key not in self._clients:
if use_mock:
logger.info("[ROUTER] %s client → MOCK", key)
self._clients[key] = MockLLMClient(model_name=model)
else:
logger.info("[ROUTER] %s client → REAL (%s)", key, model)
self._clients[key] = LLMClient(
api_key=api_key, base_url=base_url, model=model,
)
return self._clients[key]
@property
def text_model(self) -> str:
return settings.TEXT_MODEL
@property
def image_model(self) -> str:
return settings.IMAGE_MODEL
router = ModelRouter()
View File
+41
View File
@@ -0,0 +1,41 @@
# 动态人格装配器:从 .zeekerwatchmen/soul/ 加载 Agent 人格
import yaml
from pathlib import Path
from server.config import settings
class PersonalityLoader:
"""从 .zeekerwatchmen/soul/ 加载 principles 和 persona 配置"""
def __init__(self, soul_dir: Path | None = None):
self.soul_dir = soul_dir or settings.SOUL_DIR
def load_principles(self) -> dict:
"""加载 principles.yaml,返回原则列表"""
path = self.soul_dir / "principles.yaml"
if not path.exists():
return {"principles": []}
with open(path, "r", encoding="utf-8") as f:
return yaml.safe_load(f)
def load_persona(self) -> dict:
"""加载 persona.yaml,返回人格配置"""
path = self.soul_dir / "persona.yaml"
if not path.exists():
return {"persona": {}}
with open(path, "r", encoding="utf-8") as f:
return yaml.safe_load(f)
def build_system_context(self) -> dict:
"""组装完整的人格上下文,用于注入 LLM 系统提示词"""
principles = self.load_principles()
persona = self.load_persona()
return {
"principles": principles.get("principles", []),
"persona": persona.get("persona", {}),
}
personality = PersonalityLoader()
@@ -0,0 +1 @@
你是一个精确的 JSON 输出引擎。只输出合法的 JSON,不输出任何其他文字。
@@ -0,0 +1,6 @@
请按以下要求输出结构化内容:
1. 使用明确的字段和值,不要模糊表述
2. 数值必须精确,带上单位
3. 列举所有条件,不要用"等"省略
4. 如果信息不足,标注为"待确认"而非猜测
View File
+17
View File
@@ -0,0 +1,17 @@
# LangGraph 推理编排:定义 PRD → IR → TestCase 的 Agent 工作流
"""
LangGraph 流程占位,Phase 3 实现。
预期工作流节点:
1. parse_prd: 解析上传的 PRD 文档
2. extract_ir: 调用 Skill 的 extract_ir_prompt 生成 IR
3. validate_ir: 校验 IR 是否符合 Schema 与 Principles
4. human_review: 等待人工确认(中断点)
5. generate_cases: 调用 Skill 的 gen_cases_prompt 生成用例
6. export: 格式化导出
"""
# TODO: Phase 3 实现 LangGraph StateGraph
# from langgraph.graph import StateGraph, END
# from langgraph.checkpointing import MemorySaver
+9
View File
@@ -0,0 +1,9 @@
# Mock 推理引擎:Phase 2 空壳运行时的占位
async def mock_reasoning_chain(prd_text: str, skill_name: str = "default") -> dict:
"""模拟完整的推理链路,返回占位数据"""
return {
"ir_yaml": f"# Mock IR generated for PRD ({len(prd_text)} chars)\nfeatures: []",
"validation": {"valid": True, "issues": []},
"testcases": [],
}
+71
View File
@@ -0,0 +1,71 @@
# YAML/JSON 差异对比工具
import yaml
def compute_diff(doc_a: dict | str, doc_b: dict | str) -> dict:
"""Compute diff between two dicts or YAML strings."""
if isinstance(doc_a, str):
try:
doc_a = yaml.safe_load(doc_a) or {}
except yaml.YAMLError:
doc_a = {}
if isinstance(doc_b, str):
try:
doc_b = yaml.safe_load(doc_b) or {}
except yaml.YAMLError:
doc_b = {}
added, removed, modified = _recursive_diff(doc_a, doc_b, "")
return {
"added": added,
"removed": removed,
"modified": modified,
}
def _recursive_diff(a: dict | list, b: dict | list, path: str) -> tuple[list, list, list]:
added, removed, modified = [], [], []
if isinstance(a, dict) and isinstance(b, dict):
all_keys = set(a.keys()) | set(b.keys())
for key in sorted(all_keys):
new_path = f"{path}.{key}" if path else key
if key not in a:
added.append({"path": new_path, "value": b[key]})
elif key not in b:
removed.append({"path": new_path, "value": a[key]})
elif a[key] != b[key]:
if isinstance(a[key], (dict, list)) and isinstance(b[key], (dict, list)):
a_sub, r_sub, m_sub = _recursive_diff(a[key], b[key], new_path)
added.extend(a_sub)
removed.extend(r_sub)
modified.extend(m_sub)
else:
modified.append({
"path": new_path,
"old_value": a[key],
"new_value": b[key],
})
elif isinstance(a, list) and isinstance(b, list):
for i in range(max(len(a), len(b))):
new_path = f"{path}[{i}]"
if i >= len(a):
added.append({"path": new_path, "value": b[i]})
elif i >= len(b):
removed.append({"path": new_path, "value": a[i]})
elif a[i] != b[i]:
if isinstance(a[i], (dict, list)) and isinstance(b[i], (dict, list)):
a_sub, r_sub, m_sub = _recursive_diff(a[i], b[i], new_path)
added.extend(a_sub)
removed.extend(r_sub)
modified.extend(m_sub)
else:
modified.append({
"path": new_path,
"old_value": a[i],
"new_value": b[i],
})
return added, removed, modified
+29
View File
@@ -0,0 +1,29 @@
# 导出格式化工具:将测试用例转换为 YAML/CSV/XMind 格式
import yaml
import csv
import io
from typing import Any
def to_yaml(cases: list[dict]) -> str:
"""将用例集序列化为 YAML 字符串"""
# TODO: Phase 4 按实际 IR schema 调整输出结构
return yaml.dump(cases, allow_unicode=True, sort_keys=False)
def to_csv(cases: list[dict]) -> str:
"""将用例集序列化为 CSV 字符串"""
if not cases:
return ""
output = io.StringIO()
writer = csv.DictWriter(output, fieldnames=cases[0].keys())
writer.writeheader()
writer.writerows(cases)
return output.getvalue()
def to_xmind(cases: list[dict]) -> bytes:
"""将用例集导出为 XMind 文件(占位,Phase 4 实现)"""
# TODO: Phase 4 使用 xmind 库生成 .xmind 文件
return b""
+28
View File
@@ -0,0 +1,28 @@
# 结构化日志工具
import logging
import sys
from server.config import settings
def setup_logger(name: str = "testflow") -> logging.Logger:
logger = logging.getLogger(name)
if logger.handlers:
return logger
logger.setLevel(logging.DEBUG if settings.DEBUG else logging.INFO)
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(
logging.Formatter(
"[%(asctime)s] %(levelname)s [%(name)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
)
logger.addHandler(handler)
return logger
logger = setup_logger()
+33
View File
@@ -0,0 +1,33 @@
# ZeekerWatchman - FastAPI Application Entry Point
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from server.config import settings
from server.api.routes import prd, ir, testcase, skills, chat
app = FastAPI(
title="ZeekerWatchman",
description="测试用例智能管理平台 - 将 PRD 转化为可执行测试用例",
version="0.1.0",
redirect_slashes=False,
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(prd.router, prefix="/api/prd", tags=["PRD"])
app.include_router(ir.router, prefix="/api/ir", tags=["IR"])
app.include_router(testcase.router, prefix="/api/testcase", tags=["Testcase"])
app.include_router(skills.router, prefix="/api/skills", tags=["Skills"])
app.include_router(chat.router, prefix="/api/chat", tags=["Chat"])
@app.get("/api/health")
async def health_check():
return {"status": "ok", "version": "0.1.0"}
+27
View File
@@ -0,0 +1,27 @@
# ZeekerWatchman - Python Backend Dependencies
# Web Framework
fastapi>=0.115.0
uvicorn[standard]>=0.30.0
python-multipart>=0.0.9
# Config & Settings
pydantic>=2.0.0
pydantic-settings>=2.0.0
python-dotenv>=1.0.0
# LLM Client (OpenAI-compatible)
openai>=1.0.0
# YAML & Data
pyyaml>=6.0
jsonschema>=4.0.0
# Document Parsing
python-docx>=1.0.0
PyPDF2>=3.0.0
# Dev
pytest>=8.0.0
pytest-asyncio>=0.24.0
httpx>=0.27.0
View File
+46
View File
@@ -0,0 +1,46 @@
# IR 版本差异对比服务
import logging
import yaml
from server.core.utils.diff import compute_diff
from server.services.ir_engine.generator import ir_generator
logger = logging.getLogger(__name__)
class IRDiffService:
"""Compare two IR versions and return a structured diff."""
async def diff(self, ir_id_a: str, ir_id_b: str) -> dict:
"""Compute diff between two IR versions."""
ir_a = await ir_generator.get_ir(ir_id_a)
ir_b = await ir_generator.get_ir(ir_id_b)
if not ir_a or not ir_b:
return {"error": "One or both IR versions not found", "diff": {}}
yaml_a = ir_a.get("yaml_content", "")
yaml_b = ir_b.get("yaml_content", "")
try:
data_a = yaml.safe_load(yaml_a)
data_b = yaml.safe_load(yaml_b)
except yaml.YAMLError as e:
return {"error": f"YAML parsing error: {e}", "diff": {}}
diff_result = compute_diff(data_a or {}, data_b or {})
logger.info("IR diff: %d added, %d removed, %d modified",
len(diff_result.get("added", [])),
len(diff_result.get("removed", [])),
len(diff_result.get("modified", [])))
return {
"ir_id_a": ir_id_a,
"ir_id_b": ir_id_b,
"diff": diff_result,
}
ir_diff = IRDiffService()
+76
View File
@@ -0,0 +1,76 @@
# IR Generator — delegates to the 3-stage pipeline
import logging
from server.services.ir_engine.pipeline import ir_pipeline
from server.services.prd_manager.service import prd_service
logger = logging.getLogger(__name__)
class IRGenerator:
"""IR generator that runs the full 3-stage pipeline on parsed PRD documents."""
async def generate(self, prd_id: str, prd_text: str = "", skill_name: str = "default") -> dict:
"""Run the pipeline on a parsed PRD document."""
# Get the parsed document from the PRD service
prd = await prd_service.get_prd(prd_id)
if not prd:
return {"error": f"PRD not found: {prd_id}", "ir_id": ""}
# Fetch full parsed data from disk if available
parsed_doc = self._load_parsed(prd)
if not parsed_doc:
# Fallback: build minimal doc from PRD text
parsed_doc = {
"source": "",
"sections": [{"source": "正文", "blocks": [{"type": "para", "index": 1, "text": prd.get("full_text", "")}], "images": []}],
"image_sources": {},
"image_analysis": [],
"resolved_conflicts": [],
}
result = await ir_pipeline.run(prd_id, parsed_doc)
return {
"ir_id": result["ir_id"],
"prd_id": result["prd_id"],
"yaml_content": result["yaml_content"],
"ir_json": result["ir_json"],
"audit": result["audit"],
"audit_report": result["audit_report"],
"skill_used": skill_name,
"created_at": result["created_at"],
"pipeline_stats": result["pipeline_stats"],
}
async def get_ir(self, ir_id: str) -> dict | None:
return await ir_pipeline.get_ir(ir_id)
def _load_parsed(self, prd: dict) -> dict | None:
"""Load the full parsed JSON from disk if available."""
import json
parsed_path = prd.get("parsed_path", "")
if parsed_path:
try:
with open(parsed_path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
pass
# Build from in-memory data
if prd.get("sections") or prd.get("full_text"):
sections = prd.get("sections", [])
if not sections and prd.get("full_text"):
sections = [{"source": "正文", "blocks": [{"type": "para", "index": 1, "text": prd["full_text"]}], "images": []}]
return {
"source": "",
"sections": sections,
"image_sources": {},
"image_analysis": prd.get("images", []),
"resolved_conflicts": [],
}
return None
ir_generator = IRGenerator()
+753
View File
@@ -0,0 +1,753 @@
# 3-Stage IR Generation Pipeline
#
# Stage 1: Semantic Index — full document → function_units + concepts
# Stage 2: Per-Unit IR Extraction — precision context → detailed IR rules
# Stage 3: Deterministic Merge & Audit — dedup, assign IDs, coverage report
import concurrent.futures
import hashlib
import json
import logging
import re
import time
import uuid
from collections import defaultdict
from datetime import datetime, timezone
from pathlib import Path
from server.config import settings
from server.core.llm_provider.router import router
logger = logging.getLogger(__name__)
PROMPTS_DIR = Path(__file__).parent.parent / "prompts" # services/prompts/
MAX_CONCURRENCY = 5
PASS, WARN, FAIL = "[PASS]", "[WARN]", "[FAIL]"
# =============================================================================
# Stage 1: Semantic Index Generation
# =============================================================================
def format_document_for_prompt(doc: dict) -> str:
"""Render the full parsed document as a readable string for the LLM prompt."""
lines = ["=== SECTIONS ==="]
for i, section in enumerate(doc.get("sections", [])):
source = section.get("source", f"(无标题-章节{i})")
lines.append(f"\n--- Section: {source} ---")
for block in section.get("blocks", []):
if block["type"] == "para":
lines.append(f"[段落 {block['index']}] {block['text']}")
elif block["type"] == "table":
lines.append(f"[表格 {block.get('table', '?')}]")
headers = block.get("headers", [])
lines.append(f" 表头: {' | '.join(headers)}")
for row in block.get("rows", []):
cols = row.get("columns", [])
cell_texts = []
for c in cols:
cell_texts.append(f"[行{c.get('row','?')}]{c.get('name','')}: {c.get('text','')}")
lines.append(f" {'; '.join(cell_texts)}")
images = section.get("images", [])
if images:
lines.append(f" 图片引用: {', '.join(images)}")
# Image Analysis
lines.append("\n\n=== IMAGE_ANALYSIS ===")
for img in doc.get("image_analysis", []):
rid = img.get("rid", "?")
img_type = img.get("type", "?")
lines.append(f"\n--- Image: {rid} (type={img_type}) ---")
lines.append(f" 描述: {img.get('description', '')[:500]}")
# Resolved Conflicts
conflicts = doc.get("resolved_conflicts", [])
if conflicts:
lines.append("\n\n=== RESOLVED_CONFLICTS ===")
for c in conflicts:
lines.append(
f" [{c.get('conflict_type','?')}] {c.get('section','?')}: "
f"{c.get('source','?')}为准 — {c.get('correction','')}"
)
return "\n".join(lines)
def extract_json_from_response(text: str, expect_array: bool = False) -> str:
"""Robustly extract JSON object or array from LLM response."""
open_char = "[" if expect_array else "{"
close_char = "]" if expect_array else "}"
# Try code fence first
pattern = rf"```(?:json)?\s*(\[{open_char}[\s\S]*?\{close_char}\])\s*```" if expect_array else rf"```(?:json)?\s*(\{{[\s\S]*?\}})\s*```"
m = re.search(pattern, text)
if m:
return m.group(1).strip()
# Find outermost bracket
start = text.find(open_char)
if start == -1:
raise ValueError(f"No JSON {'array' if expect_array else 'object'} found in LLM response")
depth = 0
for i in range(start, len(text)):
if text[i] == open_char:
depth += 1
elif text[i] == close_char:
depth -= 1
if depth == 0:
return text[start: i + 1]
raise ValueError(f"Unclosed JSON {'array' if expect_array else 'object'} in LLM response")
async def stage1_semantic_index(doc: dict) -> dict:
"""Stage 1: Generate semantic index from full document."""
logger.info("[PIPELINE] ═══ Stage 1/3: 语义索引 ═══")
template_path = PROMPTS_DIR / "ir_step1_semantic_index.txt"
if not template_path.exists():
logger.warning("[PIPELINE] Stage 1 prompt template not found, using fallback")
return _fallback_semantic_index(doc)
template = template_path.read_text(encoding="utf-8")
formatted_doc = format_document_for_prompt(doc)
prompt = template.replace("{document_json}", formatted_doc)
logger.info("[PIPELINE] Prompt: %d chars (~%d tokens)", len(prompt), len(prompt) // 3)
client = router.get_text_client()
for attempt in range(3):
try:
raw = client.chat(
model=router.text_model,
messages=[
{"role": "system", "content": "你是一个精确的 JSON 输出引擎。只输出合法的 JSON,不输出任何其他文字。"},
{"role": "user", "content": prompt},
],
temperature=0.1,
)
json_str = extract_json_from_response(raw)
result = json.loads(json_str)
# Validate
if "function_units" not in result or not result["function_units"]:
raise ValueError("Missing function_units")
logger.info("[PIPELINE] ✅ Stage 1 完成: %d function_units, %d concepts",
len(result["function_units"]), len(result.get("concepts", [])))
return result
except Exception as e:
logger.warning("[PIPELINE] Stage 1 attempt %d failed: %s", attempt + 1, e)
if attempt < 2:
time.sleep(2)
# Fallback
logger.warning("[PIPELINE] Stage 1 全部重试失败 exhausted, using fallback")
return _fallback_semantic_index(doc)
def _fallback_semantic_index(doc: dict) -> dict:
"""Generate a basic semantic index without LLM (for mock/demo)."""
units = []
unit_idx = 1
for section in doc.get("sections", []):
source = section.get("source", "")
for block in section.get("blocks", []):
if block["type"] == "para":
# Create a function unit from each substantial paragraph
text = block.get("text", "")
if len(text) > 30:
units.append({
"unit_id": f"FU-{unit_idx:03d}",
"name": text[:60],
"description": text[:200],
"sources": [{"section": source, "type": "para", "text_snippet": text[:200]}],
})
unit_idx += 1
elif block["type"] == "table":
for row in block.get("rows", []):
cols = row.get("columns", [])
texts = [f"{c.get('name','')}: {c.get('text','')}" for c in cols if c.get('text')]
if texts:
combined = "; ".join(texts)
units.append({
"unit_id": f"FU-{unit_idx:03d}",
"name": combined[:60],
"description": combined[:200],
"sources": [{"section": source, "type": "table", "row": row.get("columns", [{}])[0].get("row", 1) if cols else 1, "text_snippet": combined[:200]}],
})
unit_idx += 1
return {
"feature_name": doc.get("sections", [{}])[0].get("source", "未命名功能"),
"concepts": [],
"function_units": units,
}
# =============================================================================
# Stage 2: Per-Unit IR Extraction
# =============================================================================
def build_context_package(fu: dict, doc: dict, image_sources: dict) -> dict:
"""Build a precision context package for one function unit."""
texts = []
tables = []
images = []
seen_sections = set()
img_by_rid = {img.get("rid", ""): img for img in doc.get("image_analysis", [])}
for src in fu.get("sources", []):
section_key = src.get("section", "")
src_type = src.get("type", "")
if src_type in ("para", "table") and section_key:
if section_key in seen_sections:
continue
seen_sections.add(section_key)
# Find matching section
for section in doc.get("sections", []):
if section.get("source", "") == section_key:
for block in section.get("blocks", []):
if block["type"] == "para":
texts.append({
"section": section_key,
"text": block["text"],
})
elif block["type"] == "table":
all_rows = []
for row in block.get("rows", []):
for col in row.get("columns", []):
all_rows.append({
"row": col.get("row"),
"name": col.get("name"),
"text": col.get("text"),
})
# Trim: max 20 cells per table to reduce prompt size
tables.append({
"section": section_key,
"headers": block.get("headers", []),
"all_rows": all_rows[:20],
"_truncated": len(all_rows) > 20,
})
break
if src_type in ("image", "logic_tree"):
img_id = src.get("image_id", "")
if img_id and img_id in img_by_rid:
img = img_by_rid[img_id]
images.append({
"image_id": img_id,
"type": img.get("type", "other"),
"description": img.get("description", ""),
})
return {
"unit_id": fu["unit_id"],
"unit_name": fu.get("name", ""),
"unit_description": fu.get("description", ""),
"texts": texts,
"tables": tables,
"images": images,
"resolved_conflicts": doc.get("resolved_conflicts", []),
}
async def stage2_extract_rules(semantic_index: dict, doc: dict) -> list[dict]:
"""Stage 2: Extract detailed IR rules for each function unit."""
logger.info("=" * 50)
logger.info("[PIPELINE] ═══ Stage 2/3: Per-Unit IR Extraction")
template_path = PROMPTS_DIR / "ir_step2_extraction.txt"
if not template_path.exists():
return _fallback_rules(semantic_index)
template = template_path.read_text(encoding="utf-8")
function_units = semantic_index.get("function_units", [])
image_sources = doc.get("image_sources", {})
logger.info("Processing %d function units (max concurrency: %d)", len(function_units), MAX_CONCURRENCY)
# Build context packages
packages = []
for fu in function_units:
pkg = build_context_package(fu, doc, image_sources)
packages.append(pkg)
# Parallel LLM calls
fragments = []
client = router.get_text_client()
def extract_one(pkg: dict) -> dict:
"""Extract rules for one function unit."""
prompt = _build_stage2_prompt(template, pkg)
unit_id = pkg["unit_id"]
for attempt in range(3):
try:
raw = client.chat(
model=router.text_model,
messages=[
{"role": "system", "content": "你是一个精确的 JSON 输出引擎。只输出合法的 JSON 数组,不输出任何其他文字。"},
{"role": "user", "content": prompt},
],
temperature=0.1,
)
json_str = extract_json_from_response(raw, expect_array=True)
rules = json.loads(json_str)
if not isinstance(rules, list):
raise ValueError(f"Expected array, got {type(rules).__name__}")
return {"unit_id": unit_id, "unit_name": pkg["unit_name"], "rules": rules}
except Exception as e:
if attempt < 2:
time.sleep(2)
else:
logger.warning("Unit %s failed after 3 attempts: %s", unit_id, e)
return {"unit_id": unit_id, "unit_name": pkg["unit_name"], "rules": [], "error": str(e)}
return {"unit_id": unit_id, "unit_name": pkg["unit_name"], "rules": [], "error": "unknown"}
with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_CONCURRENCY) as executor:
futures = {executor.submit(extract_one, pkg): pkg for pkg in packages}
for future in concurrent.futures.as_completed(futures):
result = future.result()
fragments.append(result)
n_rules = len(result.get("rules", []))
logger.info("[PIPELINE] %s: %d rules %s", result["unit_id"], n_rules,
"" if result.get("error") else "")
fragments.sort(key=lambda f: f["unit_id"])
total = sum(len(f.get("rules", [])) for f in fragments)
logger.info("[PIPELINE] ✅ Stage 2 完成: %d fragments, %d total rules", len(fragments), total)
return fragments
def _build_stage2_prompt(template: str, pkg: dict) -> str:
"""Render the stage 2 prompt template with context package data."""
# Simple str.replace (avoids format issues with curly braces in JSON)
prompt = template
prompt = prompt.replace("{unit_id}", pkg["unit_id"])
prompt = prompt.replace("{unit_name}", pkg["unit_name"])
prompt = prompt.replace("{unit_description}", pkg["unit_description"])
prompt = prompt.replace("{texts}", json.dumps(pkg.get("texts", []), ensure_ascii=False, indent=2))
prompt = prompt.replace("{tables}", json.dumps(pkg.get("tables", []), ensure_ascii=False, indent=2))
prompt = prompt.replace("{images}", json.dumps(pkg.get("images", []), ensure_ascii=False, indent=2))
prompt = prompt.replace("{resolved_conflicts}", json.dumps(pkg.get("resolved_conflicts", []), ensure_ascii=False, indent=2))
return prompt
def _fallback_rules(semantic_index: dict) -> list[dict]:
"""Generate basic rules without LLM."""
fragments = []
for fu in semantic_index.get("function_units", []):
fragments.append({
"unit_id": fu["unit_id"],
"unit_name": fu.get("name", ""),
"rules": [{
"description": fu.get("description", ""),
"priority": "P2",
"sources": fu.get("sources", []),
"precondition": {},
"trigger": {"operator": "AND", "conditions": []},
"actions": [],
}],
})
return fragments
# =============================================================================
# Stage 3: Merge & Audit
# =============================================================================
def rule_signature(rule: dict) -> str:
"""SHA256 signature from trigger + actions for dedup."""
trigger = rule.get("trigger", {})
actions = rule.get("actions", [])
conditions = sorted(trigger.get("conditions", []), key=lambda c: json.dumps(c, sort_keys=True))
sorted_actions = sorted(actions, key=lambda a: json.dumps(a, sort_keys=True))
sig = json.dumps({"conditions": conditions, "actions": sorted_actions}, ensure_ascii=False, sort_keys=True)
return hashlib.sha256(sig.encode()).hexdigest()[:16]
def stage3_merge_and_audit(fragments: list[dict], semantic_index: dict, doc: dict) -> dict:
"""Stage 3: Merge rules, assign IDs, and generate audit report."""
logger.info("=" * 50)
logger.info("[PIPELINE] ═══ Stage 3/3: Merge & Audit")
# Merge and dedup
sig_map: dict[str, dict] = {}
order = []
for f in fragments:
for rule in f.get("rules", []):
sig = rule_signature(rule)
if sig in sig_map:
existing = sig_map[sig]
existing_sources = existing.setdefault("sources", [])
for src in rule.get("sources", []):
if src not in existing_sources:
existing_sources.append(src)
if len(rule.get("description", "")) > len(existing.get("description", "")):
existing["description"] = rule["description"]
else:
sig_map[sig] = dict(rule)
order.append(sig)
merged = [sig_map[sig] for sig in order]
logger.info("[PIPELINE] 合并: %d%d rules (dedup)", sum(len(f.get("rules", [])) for f in fragments), len(merged))
# Assign rule IDs: IR-{feature_id}-{section}-{seq}
section_counters = defaultdict(int)
feature_name = semantic_index.get("feature_name", "FEATURE")
feature_id = re.sub(r'[^A-Z]', '', feature_name.upper())[:6] or "FEAT"
if len(feature_id) < 2:
feature_id = (feature_id + "FEATURE")[:4]
for rule in merged:
# Extract section from first para/table source, fallback to "0"
section = "0"
for src in rule.get("sources", []):
s = src.get("section", "")
if s:
# Extract first number pattern like "4.2.1" or "3.1"
m = re.search(r'[\d]+(?:\.[\d]+)*', s)
if m:
section = m.group()
break
section_counters[section] += 1
rule["rule_id"] = f"IR-{feature_id}-{section}-{section_counters[section]:03d}"
# Build final IR
ir_final = {
"feature": feature_name,
"feature_id": feature_id,
"rules": merged,
"rule_count": len(merged),
"generated_at": datetime.now(timezone.utc).isoformat(),
}
# Generate audit report
audit_results = _generate_audit(merged, doc)
report = _format_audit_report(feature_name, len(merged), audit_results)
logger.info("[PIPELINE] ✅ Stage 3 完成: %d rules, %d audit issues", len(merged), audit_results.get("issue_count", 0))
return {"ir": ir_final, "audit": audit_results, "audit_report": report}
def _generate_audit(rules: list[dict], doc: dict) -> dict:
"""Run coverage audits and return structured results."""
results = []
# 1. Source coverage: check that all sections with content are referenced
all_sources = set()
for rule in rules:
for src in rule.get("sources", []):
section = src.get("section", "")
if section:
all_sources.add(section)
doc_sections = set()
for section in doc.get("sections", []):
source = section.get("source", "")
if source and section.get("blocks"):
doc_sections.add(source)
uncovered = doc_sections - all_sources
results.append({
"check": "文档章节覆盖",
"status": PASS if not uncovered else WARN,
"detail": f"已覆盖 {len(all_sources)}/{len(doc_sections)} 章节"
+ (f"; 未覆盖: {list(uncovered)[:5]}" if uncovered else ""),
})
# 2. Image coverage
img_count = len(doc.get("image_analysis", []))
img_referenced = set()
for rule in rules:
for src in rule.get("sources", []):
if src.get("type") in ("image", "logic_tree"):
img_referenced.add(src.get("image_id", ""))
results.append({
"check": "图片覆盖率",
"status": PASS if len(img_referenced) >= img_count else (WARN if img_referenced else FAIL),
"detail": f"已引用 {len(img_referenced)}/{img_count} 张图片",
})
# 3. Priority distribution
p0 = sum(1 for r in rules if r.get("priority") == "P0")
p1 = sum(1 for r in rules if r.get("priority") == "P1")
p2 = sum(1 for r in rules if r.get("priority") == "P2")
results.append({
"check": "优先级分布",
"status": PASS if p0 > 0 else WARN,
"detail": f"P0={p0}, P1={p1}, P2={p2}",
})
issue_count = sum(1 for r in results if r["status"] in (WARN, FAIL))
return {"checks": results, "issue_count": issue_count}
def _sync_call(async_fn, *args, **kwargs):
"""Run an async function synchronously using the existing event loop if available."""
import asyncio
try:
loop = asyncio.get_running_loop()
except RuntimeError:
# No event loop running — just use asyncio.run
return asyncio.run(async_fn(*args, **kwargs))
else:
# Event loop is running (e.g., inside FastAPI) — must use run_until_complete
# Create a new loop in a thread to avoid conflicts
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex:
future = ex.submit(asyncio.run, async_fn(*args, **kwargs))
return future.result()
def _format_audit_report(feature_name: str, rule_count: int, audit: dict) -> str:
"""Generate Markdown audit report."""
lines = [
f"# IR 完整性审计报告",
f"",
f"**功能**: {feature_name}",
f"**规则总数**: {rule_count}",
f"**生成时间**: {datetime.now(timezone.utc).isoformat()}",
f"",
f"> ⚠️ 请人工审查以下 ⚠️ 和 ❌ 项。",
f"",
f"## 审计结果",
f"",
f"| 检查项 | 状态 | 详情 |",
f"|--------|------|------|",
]
for r in audit.get("checks", []):
lines.append(f"| {r['check']} | {r['status']} | {r['detail']} |")
lines.append("")
lines.append(f"**审计问题总计**: {audit.get('issue_count', 0)} 个需要人工审查")
return "\n".join(lines)
# =============================================================================
# Pipeline Orchestrator
# =============================================================================
class IRPipeline:
"""Orchestrates the 3-stage IR generation pipeline."""
def __init__(self):
self._store: dict[str, dict] = {}
def run_streaming(self, prd_id: str, parsed_doc: dict):
"""Run pipeline as a sync generator yielding progress events.
This is a plain generator (NOT async) so it never blocks the event loop
between yields. The SSE endpoint wraps it in a thread + Queue bridge.
"""
t_total_start = time.time()
n_sections = len(parsed_doc.get("sections", []))
est_units = max(n_sections * 2, 3)
est_total = 20 + (est_units * 20 / 3) + 1
# --- Stage 1 ---
yield {"stage": 1, "status": "running", "message": "Stage 1: 分析文档结构,识别功能单元...",
"elapsed": 0, "stage_total": 3, "detail": f"文档含 {n_sections} 个章节"}
t0 = time.time()
try:
semantic_index = _sync_call(stage1_semantic_index, parsed_doc)
except Exception as e:
yield {"stage": 1, "status": "error", "message": f"Stage 1 失败: {e}", "stage_total": 3}
return
t1 = time.time()
n_units = len(semantic_index.get("function_units", []))
est_total = 20 + (max(n_units, 1) * 25 / 3) + 1
yield {"stage": 1, "status": "done", "message": f"Stage 1 完成: 识别 {n_units} 个功能单元",
"elapsed": round(t1 - t0, 1), "stage_total": 3, "detail": f"识别 {n_units} 个功能单元",
"n_units": n_units, "estimated_total": round(est_total)}
# --- Stage 2 ---
est_stage2 = max(n_units, 1) * 25 / min(MAX_CONCURRENCY, max(n_units, 1))
yield {"stage": 2, "status": "running", "message": f"Stage 2: 逐单元提取 IR 规则 ({n_units} 个单元)...",
"elapsed": 0, "stage_total": 3, "detail": f"预计 {est_stage2:.0f}s{n_units} 个单元 × {min(MAX_CONCURRENCY, n_units)} 并发",
"n_units": n_units, "estimated_total": round(est_total)}
t1b = time.time()
completed = 0
total_rules = 0
fragments = []
function_units = semantic_index.get("function_units", [])
# Optimization: filter out trivial units (very short desc, no text sources)
filtered_units = []
for fu in function_units:
sources = fu.get("sources", [])
desc = fu.get("description", "")
has_text = any(s.get("type") in ("para", "table") for s in sources)
if len(desc) < 15 and not has_text:
logger.info("[PIPELINE] 跳过空单元: %s", fu.get("unit_id", "?"))
continue
filtered_units.append(fu)
skipped = len(function_units) - len(filtered_units)
if skipped:
logger.info("[PIPELINE] Stage 2 过滤掉 %d 个空单元,剩余 %d", skipped, len(filtered_units))
function_units = filtered_units
image_sources = parsed_doc.get("image_sources", {})
template_path = PROMPTS_DIR / "ir_step2_extraction.txt"
template = template_path.read_text(encoding="utf-8") if template_path.exists() else ""
client = router.get_text_client()
packages = [build_context_package(fu, parsed_doc, image_sources) for fu in function_units]
with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_CONCURRENCY) as executor:
futures = {}
for i, pkg in enumerate(packages):
future = executor.submit(self._extract_one_sync, template, pkg, client)
futures[future] = (i, pkg["unit_id"], pkg["unit_name"])
for future in concurrent.futures.as_completed(futures):
i, uid, uname = futures[future]
try:
rules = future.result()
fragments.append({"unit_id": uid, "unit_name": uname, "rules": rules})
total_rules += len(rules)
except Exception as e:
logger.warning("Unit %s failed: %s", uid, e)
fragments.append({"unit_id": uid, "unit_name": uname, "rules": [], "error": str(e)})
completed += 1
elapsed = round(time.time() - t1b, 1)
yield {"stage": 2, "status": "running",
"message": f"Stage 2: {completed}/{n_units} 单元完成 ({total_rules} 条规则)",
"elapsed": elapsed, "stage_total": 3,
"detail": f"已完成 {completed}/{n_units},累计 {total_rules} 条规则",
"completed": completed, "total": n_units, "rules_so_far": total_rules,
"estimated_total": round(est_total)}
fragments.sort(key=lambda f: f["unit_id"])
t2 = time.time()
logger.info("[PIPELINE] ✅ Stage 2 完成: %d fragments, %d total rules", len(fragments), total_rules)
yield {"stage": 2, "status": "done",
"message": f"Stage 2 完成: {len(fragments)} 个单元 → {total_rules} 条规则",
"elapsed": round(t2 - t1b, 1), "stage_total": 3,
"detail": f"{len(fragments)} 个单元 → {total_rules} 条规则",
"total_rules": total_rules}
# --- Stage 3 ---
yield {"stage": 3, "status": "running", "message": "Stage 3: 合并去重 + 审计报告...",
"elapsed": 0, "stage_total": 3, "detail": "去重 + 分配 ID + 生成审计报告"}
result = stage3_merge_and_audit(fragments, semantic_index, parsed_doc)
t3 = time.time()
n_rules = len(result["ir"]["rules"])
ir_id = str(uuid.uuid4())[:8]
output = {
"ir_id": ir_id, "prd_id": prd_id,
"yaml_content": json.dumps(result["ir"], ensure_ascii=False, indent=2),
"ir_json": result["ir"],
"audit": result["audit"],
"audit_report": result["audit_report"],
"skill_used": "default",
"created_at": datetime.now(timezone.utc).isoformat(),
"pipeline_stats": {
"stage1_seconds": round(t1 - t0, 1),
"stage2_seconds": round(t2 - t1b, 1),
"stage3_seconds": round(t3 - t2, 1),
"total_seconds": round(t3 - t_total_start, 1),
},
}
self._store[ir_id] = output
# Save to disk
output_dir = settings.OUTPUT_DIR / prd_id
output_dir.mkdir(parents=True, exist_ok=True)
with open(output_dir / f"ir_{ir_id}.json", "w", encoding="utf-8") as f:
json.dump(output, f, ensure_ascii=False, indent=2, default=str)
yield {"stage": 3, "status": "done", "message": f"流水线完成!共 {n_rules} 条规则",
"elapsed": round(t3 - t2, 1), "stage_total": 3,
"detail": f"合并后 {n_rules} 条规则,审计问题 {result['audit'].get('issue_count', 0)}",
"done": True, "result": output}
def _extract_one_sync(self, template: str, pkg: dict, client) -> list[dict]:
"""Synchronous rule extraction for one unit (runs in thread pool)."""
prompt = _build_stage2_prompt(template, pkg)
for attempt in range(3):
try:
raw = client.chat(
model=router.text_model,
messages=[
{"role": "system", "content": "你是一个精确的 JSON 输出引擎。只输出合法的 JSON 数组,不输出任何其他文字。"},
{"role": "user", "content": prompt},
],
temperature=0.1,
)
json_str = extract_json_from_response(raw, expect_array=True)
rules = json.loads(json_str)
if isinstance(rules, list):
return rules
except Exception as e:
if attempt == 2:
raise
time.sleep(2)
return []
async def run(self, prd_id: str, parsed_doc: dict) -> dict:
"""Run the full 3-stage pipeline and return the final IR + audit."""
logger.info("[PIPELINE] ═══ 流水线启动 for PRD %s", prd_id)
# Stage 1
t0 = time.time()
semantic_index = await stage1_semantic_index(parsed_doc)
t1 = time.time()
logger.info("[PIPELINE] Stage 1 耗时: %.1fs", t1 - t0)
# Stage 2
fragments = await stage2_extract_rules(semantic_index, parsed_doc)
t2 = time.time()
logger.info("[PIPELINE] Stage 2 耗时 %.1fs", t2 - t1)
# Stage 3
result = stage3_merge_and_audit(fragments, semantic_index, parsed_doc)
t3 = time.time()
logger.info("[PIPELINE] Stage 3 耗时 %.1fs", t3 - t2)
ir_id = str(uuid.uuid4())[:8]
output = {
"ir_id": ir_id,
"prd_id": prd_id,
"yaml_content": json.dumps(result["ir"], ensure_ascii=False, indent=2),
"ir_json": result["ir"],
"audit": result["audit"],
"audit_report": result["audit_report"],
"skill_used": "default",
"created_at": datetime.now(timezone.utc).isoformat(),
"pipeline_stats": {
"stage1_seconds": round(t1 - t0, 1),
"stage2_seconds": round(t2 - t1, 1),
"stage3_seconds": round(t3 - t2, 1),
"total_seconds": round(t3 - t0, 1),
},
}
self._store[ir_id] = output
# Save output
output_dir = settings.OUTPUT_DIR / prd_id
output_dir.mkdir(parents=True, exist_ok=True)
with open(output_dir / f"ir_{ir_id}.json", "w", encoding="utf-8") as f:
json.dump(output, f, ensure_ascii=False, indent=2, default=str)
logger.info("[PIPELINE] ✅ 流水线完成: ir_id=%s, rules=%d, total=%.1fs",
ir_id, len(result["ir"]["rules"]), t3 - t0)
return output
async def get_ir(self, ir_id: str) -> dict | None:
return self._store.get(ir_id)
ir_pipeline = IRPipeline()
+100
View File
@@ -0,0 +1,100 @@
# IR 验证器:Schema 校验 + Principles 规则检查
import json
import logging
import jsonschema
import yaml
from server.config import settings
from server.services.ir_engine.generator import ir_generator
logger = logging.getLogger(__name__)
class IRValidator:
"""Validate IR against skill schema and soul principles."""
async def validate(self, ir_yaml: str, skill_name: str = "default") -> dict:
"""Validate IR YAML content and return {valid, issues}."""
issues = []
# 1. Parse YAML
try:
ir_data = yaml.safe_load(ir_yaml)
except yaml.YAMLError as e:
return {"valid": False, "issues": [{
"severity": "error",
"message": f"YAML 解析失败: {e}",
}]}
if not isinstance(ir_data, dict):
return {"valid": False, "issues": [{
"severity": "error",
"message": "IR 内容必须是 YAML 字典/对象",
}]}
# 2. Schema validation
skill = ir_generator.load_skill(skill_name)
schema = skill.get("schema", {})
if schema:
try:
jsonschema.validate(instance=ir_data, schema=schema)
except jsonschema.ValidationError as e:
issues.append({
"severity": "error",
"message": f"Schema 校验失败: {e.message}",
"location": ".".join(str(p) for p in e.absolute_path),
})
# 3. Principles validation
principles = self._load_principles()
for principle in principles:
rule = principle.get("rule", "")
pid = principle.get("id", "")
# Check completeness: each feature must have both positive and negative paths
if pid == "completeness":
features = ir_data.get("features", [])
if not features:
issues.append({
"severity": "warning",
"message": f"原则 '{pid}': 未检测到功能点,无法验证完整性",
})
# Check traceability: features should have descriptions
if pid == "traceability":
features = ir_data.get("features", [])
for i, feat in enumerate(features):
if not feat.get("description"):
issues.append({
"severity": "warning",
"message": f"原则 '{pid}': 功能点 {feat.get('feature_name', f'#{i}')} 缺少描述",
})
# Check no-hallucination: warn about potentially vague features
if pid == "no-hallucination":
features = ir_data.get("features", [])
for feat in features:
desc = feat.get("description", "")
if "应该" in desc or "可能" in desc or "猜测" in desc:
issues.append({
"severity": "warning",
"message": f"原则 '{pid}': 功能点 '{feat.get('feature_name', '')}' 描述包含不确定措辞",
})
valid = len([i for i in issues if i["severity"] == "error"]) == 0
logger.info("IR validation: valid=%s, issues=%d", valid, len(issues))
return {"valid": valid, "issues": issues}
def _load_principles(self) -> list[dict]:
"""Load principles from .zeekerwatchmen/soul/principles.yaml."""
principles_path = settings.SOUL_DIR / "principles.yaml"
if not principles_path.exists():
return []
with open(principles_path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f)
return data.get("principles", []) if data else []
ir_validator = IRValidator()
@@ -0,0 +1,85 @@
# Vision LLM wrapper for analyzing document images (type + description).
import logging
from typing import Optional
from server.core.llm_provider.router import router
logger = logging.getLogger(__name__)
PROMPT_IMAGE = """请分析这张图片,判断类型并输出文字描述。
## 判断图片类型
如果是 **流程图 / 架构图 / 状态图 / 时序图 / 活动图**,详细描述:
- 图中所有节点/步骤/状态/组件的名称
- 所有连线/箭头/转换关系及其方向
- 所有分支条件、判断逻辑和判断结果
- 所有文字标注、注释、标签
- 图的整体结构和逻辑流程
- 如果图片包含多个子图,拆解描述
如果是 **其他类型**(UI原型图 / 界面截图 / 设计稿 / 手机屏幕截图 / 网页截图等),简要描述图片内容。
## 输出格式
**1. 类型标签(单独一行):**
type: <flowchart|architecture|state|sequence|activity|other>
**2. 文字描述:**
该图片的详细文字描述。
不要输出 ---YAML--- 分隔符或 YAML 内容,不要添加任何额外的解释或问候语。"""
class ImageParser:
"""Analyze document images using Qwen VL model.
Usage::
parser = ImageParser()
result = parser.parse_image("images/flow.png")
# {"type": "flowchart", "description": "..."}
"""
_VALID_TYPES = {"flowchart", "architecture", "state", "sequence", "activity", "other"}
def __init__(self):
self._llm = router.get_image_client()
@property
def usage(self) -> dict:
return self._llm.usage
def parse_image(self, image_path: str) -> Optional[dict]:
"""Parse an image and return {type, description}."""
logger.info("Parsing image: %s", image_path)
try:
content = self._llm.chat_with_image(
model=router.image_model,
image_path=image_path,
prompt=PROMPT_IMAGE,
)
except Exception as e:
logger.error("Image parsing failed: %s", e)
return {"type": "other", "description": "", "error": str(e)}
return self._parse_response(content)
def _parse_response(self, content: str) -> dict:
"""Extract (type, description) from vision model response."""
content = content.strip()
parsed_type = "other"
desc_lines: list[str] = []
for line in content.splitlines():
stripped = line.strip()
if (stripped.startswith("type:") or stripped.startswith("类型:")):
type_val = stripped.split(":", 1)[1].strip().lower()
if type_val in self._VALID_TYPES:
parsed_type = type_val
else:
desc_lines.append(line)
return {"type": parsed_type, "description": "\n".join(desc_lines).strip()}
+260
View File
@@ -0,0 +1,260 @@
# PRD 管理服务:上传、解析、版本快照
import json
import logging
import os
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
from server.config import settings
from server.services.prd_manager.word_parser import WordParser
from server.services.prd_manager.image_parser import ImageParser
logger = logging.getLogger("testflow")
RATE_LIMIT_DELAY = 0.5
class PRDService:
"""PRD management: handle file upload, text extraction, and version snapshots."""
def __init__(self):
self.output_dir = settings.OUTPUT_DIR
os.makedirs(self.output_dir, exist_ok=True)
self._prds: dict[str, dict] = {}
async def upload_and_parse(self, filename: str, content: bytes) -> dict:
prd_id = str(uuid.uuid4())[:8]
basename = Path(filename).stem
ext = Path(filename).suffix.lower()
upload_dir = self.output_dir / prd_id
os.makedirs(upload_dir, exist_ok=True)
filepath = upload_dir / filename
t0 = time.time()
with open(filepath, "wb") as f:
f.write(content)
logger.info("[UPLOAD] 收到文件: %s (%.1f KB, %s)", filename, len(content) / 1024, ext)
if ext in (".txt", ".md"):
text = content.decode("utf-8", errors="replace")
sections = self._parse_markdown_sections(text)
parsed = {
"source": str(filepath), "sections": sections,
"image_sources": {}, "image_analysis": [], "full_text": text,
}
logger.info("[PARSE] %.4s %s%d 章节, %d 字符, %.1fs",
prd_id, ext, len(sections), len(text), time.time() - t0)
elif ext == ".docx":
logger.info("[PARSE] %.4s → 开始解析 .docx...", prd_id)
parsed = await self._parse_docx(str(filepath), upload_dir)
logger.info("[PARSE] %.4s .docx 完成: %d 章节, %.1fs",
prd_id, len(parsed.get("sections", [])), time.time() - t0)
elif ext == ".pdf":
logger.info("[PARSE] %.4s → 开始解析 .pdf...", prd_id)
parsed = await self._parse_pdf(str(filepath), upload_dir)
logger.info("[PARSE] %.4s .pdf 完成: %d 页, %.1fs",
prd_id, len(parsed.get("sections", [])), time.time() - t0)
else:
raise ValueError(f"Unsupported file format: {ext}")
parsed_path = upload_dir / f"{basename}_parsed.json"
with open(parsed_path, "w", encoding="utf-8") as f:
json.dump(parsed, f, ensure_ascii=False, indent=2)
prd_version = {
"prd_id": prd_id, "filename": filename,
"uploaded_at": datetime.now(timezone.utc).isoformat(), "status": "ready",
"parsed_path": str(parsed_path), "full_text": parsed.get("full_text", ""),
"sections": parsed.get("sections", []), "images": parsed.get("image_analysis", []),
}
self._prds[prd_id] = prd_version
n_paras = sum(1 for s in parsed.get("sections", []) for b in s.get("blocks", []) if b["type"] == "para")
n_tables = sum(1 for s in parsed.get("sections", []) for b in s.get("blocks", []) if b["type"] == "table")
logger.info("[UPLOAD] %.4s 解析完成: %d章节 %d段落 %d表格 %d图片 → %s",
prd_id, len(parsed.get("sections", [])), n_paras, n_tables,
len(parsed.get("image_analysis", [])), parsed_path)
return prd_version
async def upload_quick(self, filename: str, content: bytes) -> dict:
"""Quick save — returns immediately, parsing happens in background."""
prd_id = str(uuid.uuid4())[:8]
upload_dir = self.output_dir / prd_id
os.makedirs(upload_dir, exist_ok=True)
filepath = upload_dir / filename
with open(filepath, "wb") as f:
f.write(content)
logger.info("[UPLOAD] %.4s 快速保存: %s (%.1f KB)", prd_id, filename, len(content) / 1024)
prd_version = {
"prd_id": prd_id, "filename": filename,
"uploaded_at": datetime.now(timezone.utc).isoformat(),
"status": "parsing", "full_text": "", "sections": [], "images": [],
"parsed_path": "", "filepath": str(filepath),
}
self._prds[prd_id] = prd_version
return prd_version
async def parse_async(self, prd_id: str, filename: str, content: bytes):
"""Full parse (text + images + vision) — called in background thread."""
basename = Path(filename).stem
ext = Path(filename).suffix.lower()
upload_dir = self.output_dir / prd_id
filepath = upload_dir / filename
t0 = time.time()
# Parse
if ext in (".txt", ".md"):
text = content.decode("utf-8", errors="replace")
sections = self._parse_markdown_sections(text)
parsed = {"source": str(filepath), "sections": sections,
"image_sources": {}, "image_analysis": [], "full_text": text}
elif ext == ".docx":
parsed = await self._parse_docx(str(filepath), upload_dir)
elif ext == ".pdf":
parsed = await self._parse_pdf(str(filepath), upload_dir)
else:
self.set_status(prd_id, "error", f"Unsupported format: {ext}")
return
# Save parsed
parsed_path = upload_dir / f"{basename}_parsed.json"
with open(parsed_path, "w", encoding="utf-8") as f:
json.dump(parsed, f, ensure_ascii=False, indent=2)
# Update in-memory
prd = self._prds.get(prd_id, {})
prd.update({
"status": "ready", "parsed_path": str(parsed_path),
"full_text": parsed.get("full_text", ""),
"sections": parsed.get("sections", []),
"images": parsed.get("image_analysis", []),
})
n_paras = sum(1 for s in parsed.get("sections", []) for b in s.get("blocks", []) if b["type"] == "para")
n_tables = sum(1 for s in parsed.get("sections", []) for b in s.get("blocks", []) if b["type"] == "table")
logger.info("[UPLOAD] %.4s 后台解析完成: %d章节 %d段落 %d表格 %d图片, %.1fs",
prd_id, len(parsed.get("sections", [])), n_paras, n_tables,
len(parsed.get("image_analysis", [])), time.time() - t0)
def set_status(self, prd_id: str, status: str, error: str = ""):
if prd_id in self._prds:
self._prds[prd_id]["status"] = status
if error:
self._prds[prd_id]["error"] = error
async def get_status(self, prd_id: str) -> dict | None:
prd = self._prds.get(prd_id)
if not prd:
return None
return {"prd_id": prd_id, "status": prd.get("status", "ready"),
"error": prd.get("error", ""),
"sections_count": len(prd.get("sections", [])),
"images_count": len(prd.get("images", []))}
async def get_prd(self, prd_id: str) -> dict | None:
return self._prds.get(prd_id)
# ---- internal parsers ----
async def _parse_docx(self, filepath: str, output_dir: Path) -> dict:
word = WordParser(filepath)
sections, image_sources = word.extract_sections()
full_text = word.extract_full_text()
images_dir = output_dir / "images"
images = word.extract_images(str(images_dir))
image_analysis = []
n_chart_images = 0
if images:
parser = ImageParser()
logger.info("[IMAGE] %.4s 发现 %d 张图片,开始视觉分析...", output_dir.parent.name, len(images))
for i, img in enumerate(images):
t_img = time.time()
logger.info("[IMAGE] [%d/%d] rid=%s, 调用 Qwen VL...", i + 1, len(images), img["rid"])
result = parser.parse_image(img["path"])
if result is None:
result = {"type": "other", "description": ""}
result["rid"] = img["rid"]
result["path"] = img["path"]
if img["rid"] in image_sources:
result["context"] = image_sources[img["rid"]]
image_analysis.append(result)
if result.get("type") in ("flowchart", "architecture", "state", "sequence", "activity"):
n_chart_images += 1
logger.info("[IMAGE] [%d/%d] rid=%s 完成,类型=%s (%.1fs)",
i + 1, len(images), img["rid"], result.get("type", "?"), time.time() - t_img)
if i < len(images) - 1:
time.sleep(RATE_LIMIT_DELAY)
usg = parser.usage
logger.info("[IMAGE] %.4s 图片分析完成: %d张, 图表类%d张, tokens(p=%d c=%d t=%d)",
output_dir.parent.name, len(images), n_chart_images,
usg["prompt_tokens"], usg["completion_tokens"], usg["total_tokens"])
else:
logger.info("[IMAGE] %.4s 文档无图片", output_dir.parent.name)
return {
"source": filepath, "sections": sections,
"image_sources": image_sources, "image_analysis": image_analysis,
"full_text": full_text,
}
def _parse_markdown_sections(self, text: str) -> list[dict]:
import re
sections: list[dict] = []
current_source = "正文"
current_blocks: list[dict] = []
para_idx = 0
for line in text.split("\n"):
stripped = line.strip()
if not stripped:
continue
heading_match = re.match(r"^(#{1,6})\s+(.+)", stripped)
if heading_match:
if current_blocks:
sections.append({"source": current_source, "blocks": current_blocks, "images": []})
current_blocks = []
level = len(heading_match.group(1))
current_source = f"{'#' * level} {heading_match.group(2)}"
continue
if "|" in stripped and stripped.count("|") >= 2:
cells = [c.strip() for c in stripped.split("|") if c.strip()]
if cells:
current_blocks.append({
"type": "table",
"table": len([b for b in current_blocks if b["type"] == "table"]) + 1,
"headers": cells,
"rows": [{"columns": [{"name": c, "row": 1, "col": i + 1, "text": c} for i, c in enumerate(cells)]}],
})
continue
para_idx += 1
current_blocks.append({"type": "para", "index": para_idx, "text": stripped})
if current_blocks:
sections.append({"source": current_source, "blocks": current_blocks, "images": []})
return sections
async def _parse_pdf(self, filepath: str, output_dir: Path) -> dict:
from PyPDF2 import PdfReader
reader = PdfReader(filepath)
full_text_parts = []
for i, page in enumerate(reader.pages):
text = page.extract_text()
if text:
full_text_parts.append(text)
full_text = "\n".join(full_text_parts)
sections = [{"source": "PDF 内容", "blocks": [{"type": "para", "index": i + 1, "text": t} for i, t in enumerate(full_text_parts)], "images": []}]
return {"source": filepath, "sections": sections, "image_sources": {}, "image_analysis": [], "full_text": full_text}
prd_service = PRDService()
+255
View File
@@ -0,0 +1,255 @@
# Microsoft Word (.docx) parser — extracts structured sections with typed blocks,
# image markers, and detailed location metadata for downstream IR generation.
import logging
import os
from docx import Document
from docx.table import Table
from docx.text.paragraph import Paragraph
logger = logging.getLogger(__name__)
IMAGE_EXT = {
"image/png": ".png", "image/jpeg": ".jpg", "image/gif": ".gif",
"image/bmp": ".bmp", "image/tiff": ".tiff", "image/webp": ".webp",
"image/x-emf": ".emf", "image/x-wmf": ".wmf", "image/svg+xml": ".svg",
}
HEADER_CELL_MAX_LEN = 20
WML_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
DRAW_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
class WordParser:
"""Parse a .docx file — extract images, split body into sections.
Sections contain typed blocks (para/table) with structured table columns
and IMAGE:rid markers, matching the reference document analyzer format.
Usage::
parser = WordParser("doc.docx")
parser.extract_images("images/")
sections, image_sources = parser.extract_sections()
"""
def __init__(self, docx_path: str):
if not os.path.isfile(docx_path):
raise FileNotFoundError(f"Document not found: {docx_path}")
self._doc = Document(docx_path)
# ---- public API ---------------------------------------------------------
def extract_images(self, images_dir: str) -> list[dict]:
"""Save all images to *images_dir*. Returns [{rid, path}, ...]."""
os.makedirs(images_dir, exist_ok=True)
images: list[dict] = []
for rel in self._doc.part.rels.values():
if "image" not in rel.reltype:
continue
ext = IMAGE_EXT.get(rel.target_part.content_type, ".png")
name = f"image_{rel.rId}{ext}"
path = os.path.join(images_dir, name)
with open(path, "wb") as f:
f.write(rel.target_part.blob)
images.append({"rid": rel.rId, "path": path})
return images
def extract_sections(self) -> tuple[list[dict], dict[str, dict]]:
"""Walk document body and split into sections by heading.
Returns:
*sections* — [{source, blocks, images}, ...]
Each block is {type, index, text} (paragraph) or
{type, table, headers, rows} (table with structured columns).
*image_sources* — rid → {section, table?, row?, column?, name?}
"""
sections: list[dict] = []
current_source = ""
blocks: list[dict] = []
section_images: list[str] = []
image_sources: dict[str, dict] = {}
para_idx = 0
tbl_idx = 0
for child in self._doc.element.body:
tag = child.tag.split("}")[-1] if "}" in child.tag else child.tag
if tag == "p":
para = Paragraph(child, self._doc)
# Heading detection
if self._heading_level(para) is not None:
heading_text = para.text.strip()
if heading_text:
# Flush previous section
if blocks or section_images:
sections.append({
"source": current_source,
"blocks": blocks,
"images": list(section_images),
})
blocks = []
section_images = []
para_idx = 0
tbl_idx = 0
current_source = heading_text
continue
text = para.text.strip()
# Scan for inline images — append [[IMAGE:rid]] markers
for run in para.runs:
for rid in self._images_in(run._element):
text += f" [[IMAGE:{rid}]]"
section_images.append(rid)
image_sources[rid] = {"section": current_source}
if text.strip():
blocks.append({"type": "para", "index": para_idx + 1, "text": text.strip()})
para_idx += 1
elif tag == "tbl":
tbl_idx += 1
table = Table(child, self._doc)
# Collect all rows with cell text and embedded images
all_rows: list[list[str]] = []
all_images: list[list[list[str]]] = [] # row → col → [rids]
for row in table.rows:
row_texts: list[str] = []
row_cell_images: list[list[str]] = []
for cell in row.cells:
cell_text = cell.text.strip()
cell_imgs: list[str] = []
for cp in cell.paragraphs:
for run in cp.runs:
for rid in self._images_in(run._element):
cell_imgs.append(rid)
for rid in cell_imgs:
cell_text += f" [[IMAGE:{rid}]]"
section_images.append(rid)
row_texts.append(cell_text.strip())
row_cell_images.append(cell_imgs)
if any(row_texts) or any(row_cell_images):
all_rows.append(row_texts)
all_images.append(row_cell_images)
if len(all_rows) >= 2:
# Header heuristic: first row is header if all cells are short
first_row = all_rows[0]
has_header = all(len(c) < HEADER_CELL_MAX_LEN for c in first_row)
if has_header:
headers = first_row
data_rows_slice = zip(all_rows[1:], all_images[1:])
else:
headers = [f"{ci + 1}" for ci in range(len(first_row))]
data_rows_slice = zip(all_rows, all_images)
data_rows: list[dict] = []
for ri, (row_data, row_imgs) in enumerate(data_rows_slice):
columns: list[dict] = []
max_cols = max(len(headers), len(row_data))
for ci in range(max_cols):
hdr = headers[ci] if ci < len(headers) else ""
txt = row_data[ci] if ci < len(row_data) else ""
columns.append({
"name": hdr,
"row": ri + 1,
"col": ci + 1,
"text": txt,
})
# Register image sources with structured location
imgs = row_imgs[ci] if ci < len(row_imgs) else []
for rid in imgs:
image_sources[rid] = {
"section": current_source,
"table": tbl_idx,
"row": ri + 1,
"column": ci + 1,
"name": hdr,
}
data_rows.append({"columns": columns})
blocks.append({
"type": "table",
"table": tbl_idx,
"headers": headers,
"rows": data_rows,
})
elif all_rows:
# Degenerate table — treat as plain rows
for ri, row_data in enumerate(all_rows):
row_text = " | ".join(row_data)
if row_text.strip():
blocks.append({
"type": "para",
"index": para_idx + 1,
"text": row_text,
})
para_idx += 1
# Flush final section
if blocks or section_images:
sections.append({
"source": current_source,
"blocks": blocks,
"images": list(section_images),
})
return sections, image_sources
def extract_full_text(self) -> str:
"""Return all text content as a single string."""
lines = []
for para in self._doc.paragraphs:
text = para.text.strip()
if text:
if self._heading_level(para) is not None:
lines.append(f"\n## {text}\n")
else:
lines.append(text)
for table in self._doc.tables:
for row in table.rows:
lines.append(" | ".join(cell.text.strip() for cell in row.cells))
return "\n".join(lines)
# ---- internals ----------------------------------------------------------
def _heading_level(self, para: Paragraph) -> int | None:
"""Heading level 1-9, or None if not a heading."""
if para.style and para.style.name:
name = para.style.name
for prefix in ("Heading", "标题"):
if name.startswith(prefix):
try:
return int(name.split()[-1])
except (ValueError, IndexError):
pass
pPr = para._element.find(f"{{{WML_NS}}}pPr")
if pPr is not None:
ol = pPr.find(f"{{{WML_NS}}}outlineLvl")
if ol is not None:
val = ol.get(f"{{{WML_NS}}}val")
if val is not None:
try:
return int(val) + 1
except ValueError:
pass
return None
def _images_in(self, element) -> list[str]:
"""Return rId values for drawings embedded in *element*."""
rids: list[str] = []
for drawing in element.findall(f".//{{{WML_NS}}}drawing"):
blip = drawing.find(f".//{{{DRAW_NS}}}blip")
if blip is not None:
rid = blip.get(f"{{{REL_NS}}}embed")
if rid:
rids.append(rid)
return rids
+30
View File
@@ -0,0 +1,30 @@
{persona}
{principles}
## 你的能力
你运行在 ZeekerWatchman 平台中,是一个测试用例管理助手。你可以帮助用户:
1. 分析 PRD 文档,提取功能点
2. 审查和修改 IR(中间表示)
3. 生成和优化测试用例
4. 回答关于测试设计的问题
## 当前左侧面板状态
{page_context}
## 左侧数据
{data_context}
## 操作指令
{action_format}
### IR 规则 ID 格式
IR 规则 ID 格式为 `IR-{FEATURE}-{section}-{NNN}`,如 `IR-ZWM-4.2.1-001`
测试用例 ID 格式为 `TC-{FEATURE}-{NNN}`,如 `TC-ZWM-001`
### 操作格式
当需要让系统执行操作时,在回复末尾附加 action JSON 块:
```action
{{"action": "<action_name>", ...}}
```
每次最多 3 个操作。无操作时不输出。
@@ -0,0 +1,56 @@
你是一个产品需求分析师。你的任务是从 PRD 文档中提取"语义索引"——一份结构化的功能清单,而不是逐字翻译。
## 文档结构说明
下面是一份文档的解析结果,包含:
1. **sections**:按章节组织的混合内容(段落 + 表格),每个 section 有 `source`(章节标题)、`blocks``para` 文本段落和 `table` 结构表格)、`images`(引用的图片 ID 列表)
2. **image_analysis**:文档中图片的程序化分析结果,包含图片类型(flowchart/architecture/state/sequence/activity/other)和文字描述
3. **resolved_conflicts**:文档中图文冲突的仲裁结果(如有)
## 文档全文
{document_json}
## 你的任务
阅读整份文档后,输出一份 **语义索引 JSON**,包含:
### 1. feature_name
从文档中识别的主要功能名称
### 2. concepts
文档中定义或使用的关键概念列表。每个概念包含:
- `name`:概念的标准名称
- `aliases`:同义词/别名列表
- `defined_in`:定义该概念的章节号列表
### 3. function_units
文档中描述的所有主要功能行为的列表。**每个 function_unit 对应一条完整的"如果...则..."规则**。每个 function unit 包含:
- `unit_id`:唯一标识,格式 "FU-001", "FU-002"...
- `name`:简短名称
- `description`1-3 句描述该规则的行为
- `sources`:该规则在文档中的来源锚点列表,每项包含:
- `section`:章节号
- `type`:来源类型,"table"/"para"/"logic_tree"/"image"
- `row`:如果是表格行(从 1 开始)
- `text_snippet`:前 200 字的关键文字
- `image_id`:如果是图片来源,填写图片 rId
## 关键要求
1. **必须覆盖所有图片分析中的逻辑**:图片描述中的每个行为都要出现在某个 function_unit 中
2. **必须覆盖表格中的所有规则**:表格中列出的每条规则都要有对应的 function_unit
3. **区分不同的应用场景和状态**:不同前置条件/状态下的行为分别建模
4. **包含开关/状态切换**:功能开关"开启"和"关闭"两种状态下的行为都要覆盖
## 输出格式
**只输出 JSON,不要有 markdown 代码块标记或其他文字**:
{
"feature_name": "...",
"concepts": [ ... ],
"function_units": [ ... ]
}
@@ -0,0 +1,81 @@
你是一个需求分析专家。你的任务是基于给定的精准上下文包,为单个功能单元(Function Unit)提取详细的 **IR 规则(Intermediate Representation Rule**。
## 上下文
下面是一个功能单元的精准上下文包,包含了从原始需求文档中提取的相关文字、表格和逻辑:
### 功能单元概要
- **unit_id**: {unit_id}
- **unit_name**: {unit_name}
- **unit_description**: {unit_description}
### 相关文字段落
{texts}
### 相关表格
{tables}
### 相关图片分析
{images}
### 冲突仲裁(如有)
{resolved_conflicts}
## IR Schema
你需要为这个功能单元输出一个 **规则数组(rules)**。每条规则遵循以下 schema:
```json
{{
"description": "用完整的中文自然语言描述该规则的触发条件和行为,一句话概括",
"priority": "P0",
"sources": [
{{"type": "table", "section": "...", "row": 2, "text_snippet": "..."}},
{{"type": "image", "image_id": "rId16", "description": "..."}}
],
"precondition": {{
"app_type": "...",
"app_state": "..."
}},
"trigger": {{
"operator": "AND",
"conditions": [
{{"signal": "车速", "operator": ">=", "value": 15, "unit": "km/h"}},
{{"signal": "持续时间", "operator": ">", "value": 5, "unit": "秒"}}
]
}},
"actions": [
{{"type": "system", "description": "系统执行的自动动作"}},
{{"type": "user_interaction", "description": "对用户的提示", "content": "提示文案内容"}}
]
}}
```
### 字段说明
1. **description**: 完整但简洁地描述整个规则,包含前置条件、触发条件和所有动作
2. **priority**: P0(核心规则)、P1(重要规则)、P2(边界情况)
3. **sources**: 每条规则必须列出所有数据来源,包括引用的表格行和图片
4. **precondition**: 规则生效的前置状态条件。可以是空对象 `{{}}`
5. **trigger**: 触发条件对象:
- `operator`: "AND" 或 "OR"
- `conditions`: 条件数组,每个条件有 `signal`、`operator`、`value`,可选 `unit`
6. **actions**: 每个动作有 `type`"system"/"user_interaction")和 `description`。用户可见交互包含 `content` 字段
## 关键要求
1. **信号和数值必须精确**:不写"车速超过阈值",写 `"车速 >= 15 km/h"`
2. **条件必须完整**:文档中的所有触发条件都要出现在 trigger.conditions 中
3. **动作类型区分**:系统行为用 "system",用户可见交互用 "user_interaction"
4. **多条规则**:如果一个功能单元包含多个独立的行为分支,输出多条规则分别描述
## 输出格式
**只输出 JSON 数组,不要有任何其他文字或 markdown 标记**
[
{{ ... }},
{{ ... }}
]
即使只有一个规则,也必须用数组格式 `[...]`。
+108
View File
@@ -0,0 +1,108 @@
# Skill 管理服务:扫描、验证、列出所有可用 Skill
import json
import logging
from pathlib import Path
import yaml
from server.config import settings
logger = logging.getLogger("testflow")
REQUIRED_SKILL_FILES = ["skill.yaml", "ir_schema.json", "extract_ir_prompt.j2", "gen_cases_prompt.j2"]
class SkillManager:
"""Discover and validate skill packages from .zeekerwatchmen/skills/."""
def __init__(self):
self._skills_dir = settings.SKILLS_DIR
self._cache: dict[str, dict] = {}
self._scan()
def _scan(self):
"""Scan the skills directory and cache valid skills."""
self._cache = {}
if not self._skills_dir.exists():
logger.warning("[SKILL] s directory not found: %s", self._skills_dir)
return
for skill_dir in sorted(self._skills_dir.iterdir()):
if not skill_dir.is_dir():
continue
skill_name = skill_dir.name
skill = self._load_skill(skill_name)
if skill:
self._cache[skill_name] = skill
logger.info("[SKILL] 加载: %s (v%s)", skill_name, skill.get("version", "?"))
logger.info("[SKILL] 发现 %d skills: %s", len(self._cache), list(self._cache.keys()))
def _load_skill(self, skill_name: str) -> dict | None:
"""Load and validate a single skill package."""
skill_dir = self._skills_dir / skill_name
# Check required files
missing = []
for filename in REQUIRED_SKILL_FILES:
if not (skill_dir / filename).exists():
missing.append(filename)
if missing:
logger.warning("[SKILL] '%s' missing files: %s", skill_name, missing)
return None
# Load metadata
try:
with open(skill_dir / "skill.yaml", "r", encoding="utf-8") as f:
meta = yaml.safe_load(f) or {}
except yaml.YAMLError as e:
logger.error("[SKILL] '%s': invalid skill.yaml: %s", skill_name, e)
return None
# Load schema
try:
with open(skill_dir / "ir_schema.json", "r", encoding="utf-8") as f:
schema = json.load(f)
except (json.JSONDecodeError, IOError) as e:
logger.error("[SKILL] '%s': invalid ir_schema.json: %s", skill_name, e)
return None
return {
"name": skill_name,
"display_name": meta.get("display_name", skill_name),
"description": meta.get("description", ""),
"version": meta.get("version", "0.0.0"),
"domain": meta.get("domain", "general"),
"author": meta.get("author", ""),
"auto_match": meta.get("auto_match", {}),
"schema_fields": list(schema.get("properties", {}).get("features", {}).get("items", {}).get("properties", {}).keys()),
}
def list_skills(self) -> list[dict]:
"""Return metadata for all discovered skills."""
return [
{
"name": s["name"],
"display_name": s["display_name"],
"description": s["description"],
"version": s["version"],
"domain": s["domain"],
}
for s in self._cache.values()
]
def get_skill(self, name: str) -> dict | None:
"""Get a specific skill's full metadata."""
return self._cache.get(name)
def reload(self):
"""Re-scan the skills directory (for hot-reload)."""
logger.info("[SKILL] 热重载 skills...")
self._cache = {}
self._scan()
skill_manager = SkillManager()
@@ -0,0 +1,66 @@
# 用例导出服务:将用例集转换为 YAML / CSV 格式
import csv
import io
import logging
import yaml
from server.services.testcase_engine.generator import tc_generator
logger = logging.getLogger(__name__)
class TestCaseExporter:
"""Export test case sets to YAML, CSV, or structured data."""
async def export(self, tc_set_id: str, fmt: str) -> tuple[bytes | str, str]:
"""Export testcases in specified format. Returns (content, mime_type, filename)."""
tc_set = await tc_generator.get_tc_set(tc_set_id)
if not tc_set:
raise ValueError(f"Test case set not found: {tc_set_id}")
cases = tc_set.get("cases", [])
if fmt == "yaml":
return self._to_yaml(cases), "application/x-yaml", f"testcases_{tc_set_id}.yaml"
elif fmt == "csv":
return self._to_csv(cases), "text/csv", f"testcases_{tc_set_id}.csv"
elif fmt == "json":
import json
return json.dumps(tc_set, ensure_ascii=False, indent=2), "application/json", f"testcases_{tc_set_id}.json"
else:
raise ValueError(f"Unsupported export format: {fmt}")
def _to_yaml(self, cases: list[dict]) -> str:
"""Convert cases to YAML string."""
output = {
"testcases": cases,
"metadata": {
"total": len(cases),
"exported_format": "yaml",
},
}
return yaml.dump(output, allow_unicode=True, sort_keys=False, default_flow_style=False)
def _to_csv(self, cases: list[dict]) -> str:
"""Convert cases to CSV string."""
if not cases:
return ""
# Flatten tags for CSV
flattened = []
for c in cases:
row = {k: v for k, v in c.items() if k != "tags"}
row["tags"] = ";".join(c.get("tags", []))
flattened.append(row)
output = io.StringIO()
fieldnames = ["id", "module", "feature", "case_title", "preconditions", "steps", "expected_result", "priority", "tags"]
writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction="ignore")
writer.writeheader()
writer.writerows(flattened)
return output.getvalue()
tc_exporter = TestCaseExporter()
@@ -0,0 +1,212 @@
# 测试用例生成器:基于 IR 生成测试用例集
import json
import logging
import uuid
from datetime import datetime, timezone
from server.config import settings
from server.core.llm_provider.router import router
from server.services.ir_engine.generator import ir_generator
logger = logging.getLogger("testflow")
DEFAULT_GEN_PROMPT = """你是一位资深的软件测试工程师。基于以下中间表示(IR),请生成完整的测试用例集。
测试设计原则:
1. 每个功能点至少覆盖一条正向用例和一条异常用例
2. 对于 P0 功能点,额外覆盖边界值测试
3. 用例步骤使用 Given-When-Then 结构
4. 预期结果必须明确可验证
输出格式:纯 JSON 数组,每个测试用例包含以下字段:
- id: 唯一标识符(字符串)
- module: 模块名
- feature: 功能点名称
- case_title: 用例标题
- preconditions: 前置条件
- steps: 测试步骤(Given-When-Then 格式)
- expected_result: 预期结果
- priority: 用例优先级 (P0/P1/P2)
- tags: 标签数组(如:["正向", "异常", "边界"]
## IR 内容:
{{ ir_content }}
请只输出 JSON 数组,不要用 ```json 代码块包裹。"""
class TestCaseGenerator:
"""Generate test case sets from IR YAML using LLM."""
def __init__(self):
self._tc_store: dict[str, dict] = {}
async def generate(self, ir_id: str, skill_name: str = "default") -> dict:
"""Generate test cases from an IR version."""
# Get IR content
ir_version = await ir_generator.get_ir(ir_id)
if not ir_version:
return {"error": f"IR version not found: {ir_id}", "cases": []}
# Handle both old YAML and new pipeline JSON formats
ir_content = ir_version.get("yaml_content", "")
ir_json = ir_version.get("ir_json")
# If we have the new pipeline format (rules array), convert to testcases directly
if ir_json and ir_json.get("rules"):
logger.info("[TC] 从 %d 条 IR 规则转换测试用例...", len(ir_json["rules"]))
cases = self._rules_to_cases(ir_json["rules"], ir_json.get("feature", ""))
tc_set_id = str(uuid.uuid4())[:8]
tc_set = self._store_tc(tc_set_id, ir_id, cases)
# Log distribution
p0 = sum(1 for c in cases if c.get("priority") == "P0")
p1 = sum(1 for c in cases if c.get("priority") == "P1")
logger.info("[TC] ✅ 生成 %d 条用例 (P0:%d P1:%d P2:%d)",
len(cases), p0, p1, len(cases) - p0 - p1)
return tc_set
# Fallback: use LLM with skill prompt
if not ir_content:
return {"error": "IR has no content", "cases": [], "tc_set_id": ""}
# Load skill prompt
skill = ir_generator.load_skill(skill_name)
prompt_template = skill.get("gen_cases_prompt", DEFAULT_GEN_PROMPT)
prompt = prompt_template.replace("{{ ir_content }}", ir_content)
logger.info("[TC] 调用 LLM 生成测试用例 for IR %s with skill %s", ir_id, skill_name)
client = router.get_text_client()
try:
raw = client.chat(
model=router.text_model,
messages=[{"role": "user", "content": prompt}],
temperature=0.0,
)
except Exception as e:
logger.error("Test case generation failed: %s", e)
return {"error": str(e), "cases": [], "tc_set_id": ""}
# Parse JSON response
cases = self._parse_json(raw)
# Create test case set
tc_set_id = str(uuid.uuid4())[:8]
tc_set = {
"tc_set_id": tc_set_id,
"ir_id": ir_id,
"cases": cases,
"case_count": len(cases),
"created_at": datetime.now(timezone.utc).isoformat(),
}
self._tc_store[tc_set_id] = tc_set
# Save to output
output_dir = settings.OUTPUT_DIR / ir_version.get("prd_id", "unknown") / "tc_sets"
output_dir.mkdir(parents=True, exist_ok=True)
tc_path = output_dir / f"tc_{tc_set_id}.json"
with open(tc_path, "w", encoding="utf-8") as f:
json.dump(tc_set, f, ensure_ascii=False, indent=2)
usg = client.usage
logger.info("Test cases generated: id=%s, count=%d, tokens: prompt=%d completion=%d total=%d",
tc_set_id, len(cases), usg["prompt_tokens"], usg["completion_tokens"], usg["total_tokens"])
return tc_set
async def get_tc_set(self, tc_set_id: str) -> dict | None:
"""Get a test case set by ID."""
return self._tc_store.get(tc_set_id)
@staticmethod
def _parse_json(raw: str) -> list[dict]:
"""Parse JSON from LLM response, handling code fences."""
text = raw.strip()
if text.startswith("```json") or text.startswith("```"):
first_nl = text.find("\n")
if first_nl != -1:
text = text[first_nl + 1:]
if text.endswith("```"):
text = text[:-3]
text = text.strip()
try:
parsed = json.loads(text)
if isinstance(parsed, list):
return parsed
if isinstance(parsed, dict) and "cases" in parsed:
return parsed["cases"]
return [parsed] if isinstance(parsed, dict) else []
except json.JSONDecodeError:
logger.warning("Failed to parse test case JSON, returning empty")
return []
def _rules_to_cases(self, rules: list[dict], feature: str) -> list[dict]:
"""Convert pipeline IR rules to test case format. TC IDs: TC-{feature_id}-{seq}"""
import re
feature_id = re.sub(r'[^A-Z]', '', feature.upper())[:6] or "FEAT"
if len(feature_id) < 2:
feature_id = (feature_id + "FEAT")[:4]
cases = []
for i, rule in enumerate(rules):
tc_id = f"TC-{feature_id}-{i+1:03d}"
desc = rule.get("description", "")
# Build steps string from trigger + actions
trigger = rule.get("trigger", {})
conditions = trigger.get("conditions", [])
cond_texts = []
for c in conditions:
unit = f" {c.get('unit', '')}" if c.get('unit') else ""
cond_texts.append(f"{c.get('signal','?')} {c.get('operator','?')} {c.get('value','?')}{unit}")
given = "Given " + (", ".join(cond_texts) if cond_texts else "触发条件满足")
actions = rule.get("actions", [])
action_texts = []
for a in actions:
if a.get("type") == "user_interaction":
action_texts.append(f"{a.get('description','')}('{a.get('content','')}')")
else:
action_texts.append(a.get("description", ""))
then = "Then " + ("; ".join(action_texts) if action_texts else "执行动作")
steps = f"{given}\nWhen 条件触发\n{then}"
# Determine tags
tags = ["正向"]
priority = rule.get("priority", "P2")
if priority == "P0":
tags.append("冒烟")
precond = rule.get("precondition", {})
if precond:
tags.append(f"{precond.get('app_type', '')} {precond.get('app_state', '')}".strip())
cases.append({
"id": tc_id,
"ir_rule_id": rule.get("rule_id", ""),
"module": feature,
"feature": rule.get("description", desc)[:40],
"case_title": desc[:60],
"preconditions": json.dumps(precond, ensure_ascii=False) if precond else "",
"steps": steps,
"expected_result": action_texts[-1] if action_texts else desc,
"priority": priority,
"tags": [t for t in tags if t],
})
return cases
def _store_tc(self, tc_set_id: str, ir_id: str, cases: list[dict]) -> dict:
"""Store a test case set and return its metadata."""
tc_set = {
"tc_set_id": tc_set_id,
"ir_id": ir_id,
"cases": cases,
"case_count": len(cases),
"created_at": datetime.now(timezone.utc).isoformat(),
}
self._tc_store[tc_set_id] = tc_set
return tc_set
tc_generator = TestCaseGenerator()