init the project
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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"],
|
||||
}
|
||||
@@ -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"}
|
||||
@@ -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}"},
|
||||
)
|
||||
Reference in New Issue
Block a user