覆盖 AC-50/51/52(BR-48/49、E-28)。审计确认的 P1-3 缺口"历史提炼与卡片 不在实时 API 调用图上"由此关闭:提炼、候选治理、卡片进方案全部有真实调用方。 后端: - contracts:新增 E-28 EXTRACTION_UNAVAILABLE(运行级,不进 HTTP 信封)与 KnowledgeExtraction 记录类型。契约层强制"失败不得带候选", AC-50「模型不可用⇒不产生候选」是类型约束而不是文档约定。 - services/experience_extraction.py(新):真实提炼入口。模型不可用或调用失败 → 记 E-28 且 candidate_id 为空;不产半成品候选。传输纪律同 assistant_model (禁进程内代理、零重试),且 tools=[] 使"越权读材料"在结构上不可能。 自然语言修订同样必须真实调用模型,没有"把中文猜成条件"的回退路径。 - services/knowledge_cards.py:新增 current_draft/revise/list_cards 与 registry_conditions。修订只产草稿、保留 origin 与来源、升版本记 parent_version; test_cases 纳入可改字段——它是 component_evaluation 对自定义 Skill 的发布门禁, 不开放则提炼出的候选永远卡在评测门外。 - services/project_assistant.py:enqueue 改用 review_plan.assemble(), 让 T06-b 的依赖预检(E-19 可定位文案)与维度解析不再被绕过。 - api/prescreen.py:case-timeline、knowledge-extractions、knowledge-candidates (创建/修订/状态机)、review-plans(装配/状态机)。角色门禁与既有 /components 入口一致;id 与数据域由服务端生成;按 id 的路由一律在 run() 内 查归属,否则 KeyError 会变成 500 而不是 404。 - component_registry/review_plan:下沉 current_draft/version_key 与 list_plans。 前端: - 新增 /experience-center 独立入口(BR-50)与 ExperienceCenter 组件; AgentNav 增加「历史经验加工」。 - originBadge 此前零调用方、.origin-* 无任何 CSS——守卫只 grep 库文件字符串, 于是"测试通过但功能不存在"。本批补齐消费方与样式,并用左边框样式区分四种来源, 保证去色仍可区分(AC-42/BR-48)。 - 守卫语义按 AC-55 调整:由"禁止新建路由"改为"禁止重复页面" (面板不得搬到独立路由、已合并页必须保持重定向),并新增 experience-center-ui 守卫(已登记 verify.py,共 12 项检查)。 测试:新增 36 项(提炼 15 / 候选治理 15 / 卡片到运行 6),全量 unittest 无回归; 两次同项目运行对照证明卡片确实改变了核查结果,且 basis_ids 指向 卡片id@版本。 未闭合(记录在 17 的剩余缺口):真实模型提炼尚未实测(需端点); 界面未经浏览器点击验证;例外边界未进入可求值语言;规则类候选不在本批。 Co-Authored-By: Claude Code <noreply@anthropic.com>
241 lines
10 KiB
Python
241 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""Run project verification and retain machine-readable test evidence."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import urllib.request
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
BACKEND = ROOT / "backend"
|
|
|
|
|
|
def executable(name: str) -> str:
|
|
found = shutil.which(name)
|
|
if not found:
|
|
raise RuntimeError(f"Required executable is unavailable: {name}")
|
|
return found
|
|
|
|
|
|
def project_python() -> str:
|
|
candidates = [
|
|
BACKEND / "venv" / "Scripts" / "python.exe",
|
|
BACKEND / "venv" / "bin" / "python",
|
|
]
|
|
return str(next((p for p in candidates if p.exists()), Path(sys.executable)))
|
|
|
|
|
|
def git_output(*args: str) -> str:
|
|
result = subprocess.run(
|
|
[executable("git"), *args], cwd=ROOT, text=True,
|
|
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, check=False,
|
|
)
|
|
return result.stdout.strip()
|
|
|
|
|
|
def changed_files(base: str | None) -> list[str]:
|
|
command = ["diff", "--name-only"]
|
|
if base:
|
|
command.append(f"{base}...HEAD")
|
|
files = set(filter(None, git_output(*command).splitlines()))
|
|
files.update(filter(None, git_output("ls-files", "--others", "--exclude-standard").splitlines()))
|
|
return sorted(files)
|
|
|
|
|
|
def make_plan(files: list[str], docker_runtime: bool) -> list[dict[str, str]]:
|
|
plan = [
|
|
{"case": "repository-diff", "reason": "Reject whitespace and patch formatting errors"},
|
|
{"case": "backend-unit", "reason": "Run all deterministic API and agent unit tests"},
|
|
{"case": "frontend-build", "reason": "Type-check and create the production Next.js bundle"},
|
|
{"case": "compose-config", "reason": "Validate local and Alibaba Cloud Compose manifests"},
|
|
]
|
|
if any(path.startswith("backend/") for path in files):
|
|
plan.append({"case": "backend-change-coverage", "reason": "Backend files changed; full backend suite is mandatory"})
|
|
if any(path.startswith("src/") for path in files):
|
|
plan.append({"case": "frontend-change-coverage", "reason": "Frontend files changed; production build is mandatory"})
|
|
if any("docker" in path.lower() or path.startswith("deploy/") for path in files):
|
|
plan.append({"case": "deployment-change-coverage", "reason": "Deployment files changed; both manifests are validated"})
|
|
if docker_runtime:
|
|
plan.append({"case": "docker-runtime-smoke", "reason": "Build, start and probe the real container"})
|
|
return plan
|
|
|
|
|
|
def run_case(name: str, command: list[str], cwd: Path, evidence_dir: Path) -> dict:
|
|
started = time.monotonic()
|
|
result = subprocess.run(
|
|
command, cwd=cwd, text=True, encoding="utf-8", errors="replace",
|
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False,
|
|
)
|
|
duration = round(time.monotonic() - started, 3)
|
|
log_path = evidence_dir / f"{name}.log"
|
|
log_path.write_text(result.stdout, encoding="utf-8")
|
|
print(f"[{name}] {'PASS' if result.returncode == 0 else 'FAIL'} ({duration}s)")
|
|
return {
|
|
"name": name,
|
|
"status": "passed" if result.returncode == 0 else "failed",
|
|
"exit_code": result.returncode,
|
|
"duration_seconds": duration,
|
|
"command": command,
|
|
"working_directory": str(cwd),
|
|
"log": log_path.name,
|
|
}
|
|
|
|
|
|
def wait_for(url: str, timeout: int = 150) -> None:
|
|
deadline = time.monotonic() + timeout
|
|
last_error = "not attempted"
|
|
while time.monotonic() < deadline:
|
|
try:
|
|
with urllib.request.urlopen(url, timeout=5) as response:
|
|
if 200 <= response.status < 400:
|
|
return
|
|
except Exception as exc: # evidence captures the eventual timeout
|
|
last_error = str(exc)
|
|
time.sleep(2)
|
|
raise RuntimeError(f"Timed out waiting for {url}: {last_error}")
|
|
|
|
|
|
def docker_smoke(docker: str, evidence_dir: Path, keep: bool) -> dict:
|
|
started = time.monotonic()
|
|
output: list[str] = []
|
|
status = "passed"
|
|
exit_code = 0
|
|
compose = [docker, "compose", "-f", str(ROOT / "docker-compose.yml")]
|
|
try:
|
|
for args in (["build"], ["up", "-d", "--wait"]):
|
|
result = subprocess.run(compose + args, cwd=ROOT, text=True, encoding="utf-8",
|
|
errors="replace", stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT, check=False)
|
|
output.append(f"$ {' '.join(compose + args)}\n{result.stdout}")
|
|
if result.returncode:
|
|
raise RuntimeError(f"Docker Compose {' '.join(args)} failed with {result.returncode}")
|
|
wait_for("http://127.0.0.1:3212/")
|
|
wait_for("http://127.0.0.1:8012/api/xingbang/health")
|
|
output.append("HTTP smoke checks passed for frontend and backend")
|
|
except Exception as exc:
|
|
status, exit_code = "failed", 1
|
|
output.append(f"ERROR: {exc}")
|
|
finally:
|
|
ps = subprocess.run(compose + ["ps"], cwd=ROOT, text=True, encoding="utf-8",
|
|
errors="replace", stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT, check=False)
|
|
output.append(f"$ {' '.join(compose + ['ps'])}\n{ps.stdout}")
|
|
if not keep:
|
|
down = subprocess.run(compose + ["down"], cwd=ROOT, text=True, encoding="utf-8",
|
|
errors="replace", stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT, check=False)
|
|
output.append(f"$ {' '.join(compose + ['down'])}\n{down.stdout}")
|
|
log_path = evidence_dir / "docker-runtime-smoke.log"
|
|
log_path.write_text("\n\n".join(output), encoding="utf-8")
|
|
duration = round(time.monotonic() - started, 3)
|
|
print(f"[docker-runtime-smoke] {status.upper()} ({duration}s)")
|
|
return {"name": "docker-runtime-smoke", "status": status, "exit_code": exit_code,
|
|
"duration_seconds": duration, "command": compose, "working_directory": str(ROOT),
|
|
"log": log_path.name}
|
|
|
|
|
|
def write_report(evidence_dir: Path, summary: dict) -> None:
|
|
(evidence_dir / "summary.json").write_text(
|
|
json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8"
|
|
)
|
|
rows = "\n".join(
|
|
f"| {item['name']} | {item['status']} | {item['duration_seconds']}s | [{item['log']}]({item['log']}) |"
|
|
for item in summary["results"]
|
|
)
|
|
report = f"""# Verification Evidence
|
|
|
|
- Run ID: `{summary['run_id']}`
|
|
- Commit: `{summary['commit']}`
|
|
- Branch: `{summary['branch']}`
|
|
- Started: `{summary['started_at']}`
|
|
- Result: **{summary['status'].upper()}**
|
|
|
|
## Generated test plan
|
|
|
|
```json
|
|
{json.dumps(summary['test_plan'], ensure_ascii=False, indent=2)}
|
|
```
|
|
|
|
## Results
|
|
|
|
| Check | Result | Duration | Evidence |
|
|
| --- | --- | ---: | --- |
|
|
{rows}
|
|
|
|
## Changed files used to generate the plan
|
|
|
|
```text
|
|
{os.linesep.join(summary['changed_files']) or '(clean working tree)'}
|
|
```
|
|
"""
|
|
(evidence_dir / "REPORT.md").write_text(report, encoding="utf-8")
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--base", help="Git base revision used to generate the change-aware test plan")
|
|
parser.add_argument("--docker", action="store_true", help="Build and smoke-test the local Docker deployment")
|
|
parser.add_argument("--keep-docker", action="store_true", help="Keep containers running after Docker smoke tests")
|
|
args = parser.parse_args()
|
|
|
|
run_id = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
evidence_dir = ROOT / "test-evidence" / f"{run_id}-{git_output('rev-parse', '--short', 'HEAD') or 'uncommitted'}"
|
|
evidence_dir.mkdir(parents=True, exist_ok=False)
|
|
files = changed_files(args.base)
|
|
summary = {
|
|
"run_id": run_id,
|
|
"started_at": datetime.now(timezone.utc).isoformat(),
|
|
"commit": git_output("rev-parse", "HEAD"),
|
|
"branch": git_output("branch", "--show-current"),
|
|
"changed_files": files,
|
|
"test_plan": make_plan(files, args.docker),
|
|
"results": [],
|
|
}
|
|
|
|
try:
|
|
git = executable("git")
|
|
npm = executable("npm")
|
|
docker = executable("docker")
|
|
checks = [
|
|
("repository-diff", [git, "diff", "--check"], ROOT),
|
|
("backend-unit", [project_python(), "-m", "unittest", "discover", "-s", ".", "-p", "test_*.py", "-v"], BACKEND),
|
|
("reviewer-workspace-ui", [executable("node"), "scripts/test_reviewer_workspace.cjs"], ROOT),
|
|
("identity-recovery-ui", [executable("node"), "scripts/test_identity_recovery.cjs"], ROOT),
|
|
("cloud-assistant-access-ui", [executable("node"), "scripts/test_cloud_assistant_access.cjs"], ROOT),
|
|
("ui-readability", [executable("node"), "scripts/test_ui_readability.cjs"], ROOT),
|
|
("prescreen-panel-ui", [executable("node"), "scripts/test_prescreen_panel.cjs"], ROOT),
|
|
("prescreen-ui", [executable("node"), "scripts/test_prescreen_ui.cjs"], ROOT),
|
|
("experience-center-ui", [executable("node"), "scripts/test_experience_center_ui.cjs"], ROOT),
|
|
("frontend-build", [npm, "run", "build"], ROOT),
|
|
("compose-local-config", [docker, "compose", "-f", "docker-compose.yml", "config", "--quiet"], ROOT),
|
|
("compose-aliyun-config", [docker, "compose", "--env-file", "deploy/aliyun.env.example", "-f", "deploy/docker-compose.aliyun.yml", "config", "--quiet"], ROOT),
|
|
]
|
|
for name, command, cwd in checks:
|
|
summary["results"].append(run_case(name, command, cwd, evidence_dir))
|
|
if args.docker:
|
|
summary["results"].append(docker_smoke(docker, evidence_dir, args.keep_docker))
|
|
except Exception as exc:
|
|
error_log = evidence_dir / "runner-error.log"
|
|
error_log.write_text(str(exc), encoding="utf-8")
|
|
summary["results"].append({"name": "runner", "status": "failed", "exit_code": 1,
|
|
"duration_seconds": 0, "command": [], "working_directory": str(ROOT),
|
|
"log": error_log.name})
|
|
|
|
summary["status"] = "passed" if all(x["status"] == "passed" for x in summary["results"]) else "failed"
|
|
write_report(evidence_dir, summary)
|
|
print(f"Evidence: {evidence_dir / 'REPORT.md'}")
|
|
return 0 if summary["status"] == "passed" else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|