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
+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}"},
)