verify / booking-verify (push) Has been cancelled
补齐C1正文资料交付和C2配额分类规则,完善Proposal人工任务、固定图片过滤及邮件附件展示。 修正失败终态与恢复上限,增加关闭状态的通知组件,永久保留处理历史并完善筛选分页。 同步相关页面修复、迁移、测试和项目记录。 验证:后台复用同源码clean verify结果1155通过/10条件跳过;前端262项及生产构建通过;敏感资料和提交路径检查通过。
206 lines
11 KiB
Python
206 lines
11 KiB
Python
"""Determinism and Main-Prompt separation tests for the Booking Skill packager."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import json
|
|
import tempfile
|
|
import unittest
|
|
import zipfile
|
|
from pathlib import Path
|
|
|
|
|
|
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
|
|
PACKAGER_PATH = REPOSITORY_ROOT / "scripts" / "package_booking_desk_event_skill.py"
|
|
|
|
|
|
def load_packager():
|
|
spec = importlib.util.spec_from_file_location("booking_skill_packager", PACKAGER_PATH)
|
|
if spec is None or spec.loader is None:
|
|
raise RuntimeError("Booking Skill packager module cannot be loaded")
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
class BookingDeskEventSkillPackageTest(unittest.TestCase):
|
|
|
|
@classmethod
|
|
def setUpClass(cls) -> None:
|
|
cls.packager = load_packager()
|
|
|
|
def test_archive_manifest_are_deterministic_hold_and_prompt_free(self) -> None:
|
|
with tempfile.TemporaryDirectory(prefix="booking-skill-package-test-") as temporary_directory:
|
|
root = Path(temporary_directory)
|
|
source = self.create_source(root / "source")
|
|
first = root / "first" / "booking-desk-event.skill"
|
|
second = root / "second" / "booking-desk-event.skill"
|
|
first_members = self.packager.write_archive(source, first)
|
|
second_members = self.packager.write_archive(source, second)
|
|
|
|
self.assertEqual(first.read_bytes(), second.read_bytes())
|
|
self.assertEqual(first_members, second_members)
|
|
manifest = self.packager.build_manifest(source, first, first_members)
|
|
self.assertEqual("booking-agent-skill-v3-rc4-object-action", manifest["release"])
|
|
self.assertEqual("HOLD", manifest["publication_status"])
|
|
self.assertEqual("INTERNAL_ONLY", manifest["distribution"])
|
|
self.assertEqual("zh-CN", manifest["instruction_language"])
|
|
self.assertEqual("Booking Agent Skill V3", manifest["skill_release_version"])
|
|
self.assertEqual("booking-business-agent-compact-input-v2", manifest["input_contract"])
|
|
self.assertEqual("booking-business-agent-compact-decision-v2", manifest["output_contract"])
|
|
self.assertFalse(manifest["main_prompt_packaged"])
|
|
self.assertEqual(
|
|
self.packager.sha256(source / "prompts/main-prompt.md"),
|
|
manifest["main_prompt_sha256"],
|
|
)
|
|
self.assertEqual(49, manifest["scenario_count"])
|
|
self.assertFalse(any("prompts" in member for member in first_members))
|
|
with zipfile.ZipFile(first) as archive:
|
|
self.assertNotIn("booking-desk-event/prompts/main-prompt.md", archive.namelist())
|
|
|
|
def test_manifest_check_is_read_only_and_rejects_stale_bytes(self) -> None:
|
|
with tempfile.TemporaryDirectory(prefix="booking-skill-manifest-test-") as temporary_directory:
|
|
root = Path(temporary_directory)
|
|
source = self.create_source(root / "source")
|
|
archive = root / "booking-desk-event.skill"
|
|
members = self.packager.write_archive(source, archive)
|
|
manifest = self.packager.build_manifest(source, archive, members)
|
|
path = root / "manifest.json"
|
|
self.packager.write_or_check_manifest(path, manifest, False)
|
|
before = path.read_bytes()
|
|
before_mtime = path.stat().st_mtime_ns
|
|
|
|
self.packager.write_or_check_manifest(path, manifest, True)
|
|
self.assertEqual(before, path.read_bytes())
|
|
self.assertEqual(before_mtime, path.stat().st_mtime_ns)
|
|
stale = dict(manifest)
|
|
stale["publication_status"] = "RELEASED"
|
|
with self.assertRaisesRegex(ValueError, "stale or missing"):
|
|
self.packager.write_or_check_manifest(path, stale, True)
|
|
|
|
def test_rejects_any_source_member_outside_the_archive_allowlist(self) -> None:
|
|
with tempfile.TemporaryDirectory(prefix="booking-skill-forbidden-test-") as temporary_directory:
|
|
source = self.create_source(Path(temporary_directory) / "source")
|
|
(source / "scripts").mkdir()
|
|
(source / "scripts" / "unexpected.py").write_text("pass\n", encoding="utf-8")
|
|
with self.assertRaisesRegex(ValueError, "forbidden top-level"):
|
|
self.packager.source_files(source)
|
|
|
|
def test_rejects_retired_schema_mixed_into_current_package(self) -> None:
|
|
with tempfile.TemporaryDirectory(prefix="booking-skill-mixed-schema-test-") as temporary_directory:
|
|
source = self.create_source(Path(temporary_directory) / "source")
|
|
(source / "schemas/booking-business-agent-compact-decision-v1.schema.json").write_text(
|
|
"{}\n", encoding="utf-8")
|
|
with self.assertRaisesRegex(ValueError, "only the current compact-v2"):
|
|
self.packager.source_files(source)
|
|
|
|
def test_main_prompt_is_concise_and_keeps_contract_defenses_out(self) -> None:
|
|
prompt = (REPOSITORY_ROOT / "agent-skills/booking-desk-event/prompts/main-prompt.md").read_text(encoding="utf-8")
|
|
self.assertLessEqual(len(prompt.splitlines()), 5)
|
|
self.assertIn("$booking-desk-event", prompt)
|
|
self.assertIn("booking-business-agent-compact-decision-v2", prompt)
|
|
self.assertIn("必要规则", prompt)
|
|
self.assertIn("不调用其他工具", prompt)
|
|
self.assertIn("每项只判断一次", prompt)
|
|
self.assertIn("立即返回", prompt)
|
|
self.assertNotIn("你是 Wyndham-RSVN", prompt)
|
|
self.assertNotIn("source_input_hash", prompt)
|
|
self.assertNotIn("decision_origin", prompt)
|
|
|
|
def test_skill_requires_compact_decision_schema_before_output(self) -> None:
|
|
skill = (REPOSITORY_ROOT / "agent-skills/booking-desk-event/SKILL.md").read_text(encoding="utf-8")
|
|
schema_path = "schemas/booking-business-agent-compact-decision-v2.schema.json"
|
|
self.assertIn(f"`{schema_path}`。输出 Schema", skill)
|
|
self.assertIn("完整读取", skill)
|
|
self.assertIn("输出 Schema 是字段和允许值的唯一权威", skill)
|
|
self.assertIn("信息系统在 Layer 5B", skill)
|
|
|
|
schema = json.loads((REPOSITORY_ROOT / "agent-skills/booking-desk-event" / schema_path)
|
|
.read_text(encoding="utf-8"))
|
|
self.assertNotIn("source_input_hash", schema["required"])
|
|
self.assertNotIn("source_input_hash", schema["properties"])
|
|
|
|
def test_narrow_qbd_booking_precedence_rules_are_explicit(self) -> None:
|
|
root = REPOSITORY_ROOT / "agent-skills/booking-desk-event"
|
|
skill = (root / "SKILL.md").read_text(encoding="utf-8")
|
|
evidence = (root / "references/01-input-output-evidence.md").read_text(encoding="utf-8")
|
|
splitting = (root / "references/02-target-and-source-splitting.md").read_text(encoding="utf-8")
|
|
update = (root / "references/11-update-booking.md").read_text(encoding="utf-8")
|
|
allotment = (root / "references/15-allotment.md").read_text(encoding="utf-8")
|
|
general = (root / "references/90-general-risk-ignored.md").read_text(encoding="utf-8")
|
|
|
|
self.assertIn("每项只判断一次", skill)
|
|
self.assertIn("只执行一次 Schema 字段检查", skill)
|
|
self.assertIn("不要创建待办", skill)
|
|
self.assertIn("`D1`、`D2`", evidence)
|
|
self.assertIn("只使用自己的 `target_ref`", evidence)
|
|
self.assertIn("`linked_relationship_refs` 只允许引用 `GROUP_CODE_REPLACEMENT`", evidence)
|
|
self.assertIn("`ALLOTMENT_SOURCE_TO_ACTUAL`", evidence)
|
|
self.assertIn("只使用该正文的 `body_ref`", evidence)
|
|
self.assertIn("只汇总一次", splitting)
|
|
self.assertIn("立即冻结", splitting)
|
|
self.assertIn("第一道门槛成立后", update)
|
|
self.assertIn("重新讨论行类型", update)
|
|
self.assertIn("ตัด Allotment <code>", allotment)
|
|
self.assertIn("固定视为明确的整块取消", allotment)
|
|
self.assertIn("不搜索 actual 决定", allotment)
|
|
self.assertIn("不要求与 actual 订单同码", allotment)
|
|
self.assertIn("`source_target_ref` 必须填写材料中的 source Tour Code 原值", allotment)
|
|
self.assertIn("不得填写 `T1`、`T10`", allotment)
|
|
self.assertIn("整块取消与同封物理单元决定可以同时存在", allotment)
|
|
self.assertIn("QBD 固定 OP 提醒", general)
|
|
self.assertIn("未归类原文", general)
|
|
|
|
def test_trace_schema_keeps_only_semantic_decision_fields(self) -> None:
|
|
schema = json.loads((
|
|
REPOSITORY_ROOT
|
|
/ "agent-skills/booking-desk-event/schemas/booking-business-agent-compact-decision-v2.schema.json"
|
|
).read_text(encoding="utf-8"))
|
|
trace_item = schema["$defs"]["traceItem"]
|
|
expected = {
|
|
"service_type", "service_text", "room_type", "quantity", "source_refs",
|
|
}
|
|
self.assertEqual(expected, set(trace_item["required"]))
|
|
self.assertEqual(expected, set(trace_item["properties"]))
|
|
self.assertNotIn("parameters", trace_item["properties"])
|
|
self.assertEqual(["GENERAL_SERVICE", "EXTRA_BED"], trace_item["properties"]["service_type"]["enum"])
|
|
|
|
def test_agent_readable_skill_materials_are_chinese(self) -> None:
|
|
skill_root = REPOSITORY_ROOT / "agent-skills/booking-desk-event"
|
|
paths = [skill_root / "SKILL.md", skill_root / "agents/openai.yaml"]
|
|
paths.extend(sorted((skill_root / "references").glob("*.md")))
|
|
self.assertEqual(13, len(paths))
|
|
for path in paths:
|
|
with self.subTest(path=path.name):
|
|
self.assertRegex(path.read_text(encoding="utf-8"), r"[\u4e00-\u9fff]")
|
|
|
|
def create_source(self, root: Path) -> Path:
|
|
for directory in ("agents", "schemas", "references", "fixtures", "prompts"):
|
|
(root / directory).mkdir(parents=True, exist_ok=True)
|
|
(root / "SKILL.md").write_text(
|
|
"---\nname: booking-desk-event\ndescription: A sufficiently long synthetic skill description for tests.\n---\n",
|
|
encoding="utf-8",
|
|
)
|
|
(root / "agents/openai.yaml").write_text("interface:\n display_name: test\n", encoding="utf-8")
|
|
(root / "prompts/main-prompt.md").write_text("independent prompt\n", encoding="utf-8")
|
|
(root / "manifest.json").write_text(json.dumps({
|
|
"publication_status": "HOLD",
|
|
"distribution": "INTERNAL_ONLY",
|
|
"main_prompt_packaged": False,
|
|
}) + "\n", encoding="utf-8")
|
|
for name in (
|
|
"booking-business-agent-compact-input-v2.schema.json",
|
|
"booking-business-agent-compact-decision-v2.schema.json"):
|
|
(root / "schemas" / name).write_text("{}\n", encoding="utf-8")
|
|
(root / "references/rule.md").write_text("synthetic rule\n", encoding="utf-8")
|
|
scenarios = [{"scenario_id": f"scenario-{index}"} for index in range(49)]
|
|
(root / "fixtures/scenario-matrix.json").write_text(json.dumps({
|
|
"scenario_count": len(scenarios),
|
|
"scenarios": scenarios,
|
|
}) + "\n", encoding="utf-8")
|
|
return root
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|