505 lines
22 KiB
Python
505 lines
22 KiB
Python
import importlib.util
|
||
import json
|
||
import tempfile
|
||
import unittest
|
||
from pathlib import Path
|
||
|
||
|
||
def load_validator():
|
||
script_path = Path(__file__).resolve().parents[1] / "scripts" / "validate_model_library.py"
|
||
spec = importlib.util.spec_from_file_location("validate_model_library", script_path)
|
||
module = importlib.util.module_from_spec(spec)
|
||
spec.loader.exec_module(module)
|
||
return module
|
||
|
||
|
||
class ValidateModelLibraryTests(unittest.TestCase):
|
||
def write_json(self, root, relative_path, data):
|
||
path = root / relative_path
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
|
||
def test_valid_library_has_no_errors(self):
|
||
validator = load_validator()
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
root = Path(tmp)
|
||
self.write_minimal_library(root)
|
||
|
||
errors = validator.validate_library(root)
|
||
|
||
self.assertEqual(errors, [])
|
||
|
||
def test_missing_source_reference_is_reported(self):
|
||
validator = load_validator()
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
root = Path(tmp)
|
||
self.write_json(root, "sources/source_articles.json", {"source_articles": []})
|
||
self.write_json(root, "sources/source_excerpts.json", {"source_excerpts": []})
|
||
self.write_json(root, "models/qpi.model.json", {
|
||
"model_id": "qpi",
|
||
"model_name": "QPI 三元定性模型",
|
||
"model_type": "routing",
|
||
"pipeline_position": "problem_definition",
|
||
"one_sentence_definition": "用于判断输入缺的是数据、路径还是共识。",
|
||
"core_question": "当前输入到底是提问、难题还是课题?",
|
||
"core_mechanism": "扫描核心匮乏物并匹配干预范式。",
|
||
"source_articles": ["missing_article"],
|
||
"source_evidence": ["missing_excerpt"],
|
||
"input_types": ["ambiguous_situation"],
|
||
"output_types": ["classification"],
|
||
"call_when": ["需要先判断问题类型"],
|
||
"do_not_call_when": ["事实检索已经足够"],
|
||
"common_misuses": ["把所有复杂局面都升维为课题"],
|
||
"failure_modes": ["误判核心匮乏物"],
|
||
"selection_priority": 90,
|
||
"confidence_level": "medium",
|
||
"stability_profile": {"status": "draft", "main_risks": ["样本仍少"]},
|
||
"regression_status": "draft",
|
||
"productization_notes": ["v0.1 样板模型"]
|
||
})
|
||
self.write_json(root, "tests/regression_cases.json", {"regression_cases": []})
|
||
|
||
errors = validator.validate_library(root)
|
||
|
||
self.assertIn("models/qpi.model.json references unknown source article missing_article", errors)
|
||
self.assertIn("models/qpi.model.json references unknown source excerpt missing_excerpt", errors)
|
||
|
||
def write_minimal_library(self, root, model_overrides=None, index_overrides=None):
|
||
model = {
|
||
"model_id": "qpi",
|
||
"model_name": "QPI 三元定性模型",
|
||
"model_type": "routing_model",
|
||
"pipeline_position": "pre_analysis",
|
||
"one_sentence_definition": "用于判断输入缺的是数据、路径还是共识。",
|
||
"core_question": "当前输入到底是提问、难题还是课题?",
|
||
"core_mechanism": "扫描核心匮乏物并匹配干预范式。",
|
||
"status": "draft",
|
||
"source_articles": ["article_qpi_primary_001"],
|
||
"source_evidence": ["excerpt_qpi_001"],
|
||
"input_types": ["模糊局面"],
|
||
"output_types": ["分类"],
|
||
"call_when": ["需要先判断问题类型"],
|
||
"do_not_call_when": ["事实检索已经足够"],
|
||
"trigger_keywords": ["这是什么问题"],
|
||
"negative_triggers": ["只查事实"],
|
||
"related_models": [],
|
||
"conflicting_models": [],
|
||
"disciplinary_anchors": ["问题定义理论"],
|
||
"common_misuses": ["把所有复杂局面都升维为课题"],
|
||
"failure_modes": ["误判核心匮乏物"],
|
||
"selection_priority": 9,
|
||
"confidence_level": "medium",
|
||
"stability_profile": {
|
||
"stability_level": "B",
|
||
"needs_stabilization": True,
|
||
"main_risks": ["样本仍少"],
|
||
"reason": "结构清晰但边界案例不足。",
|
||
"next_stabilization_action": "补充混合问题测试。"
|
||
},
|
||
"regression_status": "pending",
|
||
"example_inputs": ["这到底是什么问题?"],
|
||
"example_outputs": ["偏 Question,因为缺事实。"],
|
||
"output_contract": ["输出分类和理由"],
|
||
"structured_output_contract": {
|
||
"classification_scope": ["subject_contextual", "multi_perspective", "insufficient_context", "no_call"],
|
||
"is_provisional": "boolean",
|
||
"subject_position": "string | unknown",
|
||
"scenario_context": "string | unknown",
|
||
"responsibility_scope": ["individual_learning", "team_execution", "cross_function_governance", "owner_governance", "unknown"],
|
||
"context_sufficiency": ["high", "medium", "low"],
|
||
"missing_context": "array<string>",
|
||
"problem_owner": "string | unknown",
|
||
"problem_source": "string | unknown",
|
||
"time_scale": ["short_term", "mid_term", "long_term", "mixed", "unknown"],
|
||
"scarcity_profile": {
|
||
"data_scarcity": ["high", "medium", "low", "unknown"],
|
||
"path_or_resource_scarcity": ["high", "medium", "low", "unknown"],
|
||
"consensus_or_order_scarcity": ["high", "medium", "low", "unknown"]
|
||
},
|
||
"dominant_scarcity": ["data", "path_resource", "consensus_order", "mixed", "unknown"],
|
||
"classification": ["question", "problem", "issue", "mixed", "no_call"],
|
||
"classification_confidence": ["high", "medium", "low"],
|
||
"evidence_gap": "array<string>",
|
||
"misclassification_risk": "array<string>",
|
||
"recommended_next_step": ["检索", "工程拆解", "共识协调", "补充上下文", "不调用"],
|
||
"next_model_candidates": "array<model_id>"
|
||
},
|
||
"productization_notes": "作为前置路由模型。",
|
||
"version": "0.1",
|
||
"last_updated": "2026-06-16"
|
||
}
|
||
if model_overrides:
|
||
for key, value in model_overrides.items():
|
||
if value == "__DELETE__":
|
||
model.pop(key, None)
|
||
else:
|
||
model[key] = value
|
||
|
||
index = {
|
||
"index_version": "0.1",
|
||
"last_updated": "2026-06-16",
|
||
"models": [{
|
||
"model_id": "qpi",
|
||
"model_name": "QPI 三元定性模型",
|
||
"model_type": "routing_model",
|
||
"pipeline_position": "pre_analysis",
|
||
"model_file": "models/qpi.model.json",
|
||
"card_file": "cards/qpi.md",
|
||
"source_article_count": 1,
|
||
"source_evidence_count": 1,
|
||
"regression_case_count": 15,
|
||
"stability_level": "B",
|
||
"regression_status": "pending",
|
||
"status": "draft"
|
||
}]
|
||
}
|
||
if index_overrides:
|
||
index["models"][0].update(index_overrides)
|
||
|
||
self.write_json(root, "sources/source_articles.json", {
|
||
"source_articles": [{
|
||
"source_id": "article_qpi_primary_001",
|
||
"title": "问题之锚:从混沌现实到认知秩序的重构",
|
||
"source_type": "original_article",
|
||
"related_models": ["qpi"],
|
||
"source_status": "representative"
|
||
}]
|
||
})
|
||
self.write_json(root, "sources/source_excerpts.json", {
|
||
"source_excerpts": [{
|
||
"excerpt_id": "excerpt_qpi_001",
|
||
"source_id": "article_qpi_primary_001",
|
||
"related_model_id": "qpi",
|
||
"excerpt_type": "definition",
|
||
"summary": "QPI 将问题区分为提问、难题、课题。",
|
||
"used_for": ["core_mechanism"],
|
||
"quote_status": "exact",
|
||
"source_location": "test fixture"
|
||
}]
|
||
})
|
||
self.write_json(root, "models/qpi.model.json", model)
|
||
self.write_json(root, "models/model_index.json", index)
|
||
(root / "cards").mkdir(parents=True, exist_ok=True)
|
||
(root / "cards" / "qpi.md").write_text("# QPI\n", encoding="utf-8")
|
||
(root / "cards" / "card_index.md").write_text(
|
||
"| Model ID | Card |\n| --- | --- |\n| qpi | `cards/qpi.md` |\n",
|
||
encoding="utf-8"
|
||
)
|
||
self.write_json(root, "tests/regression_cases.json", {
|
||
"regression_cases": [
|
||
{
|
||
"case_id": "case_qpi_positive_001",
|
||
"model_id": "qpi",
|
||
"case_type": "positive",
|
||
"input": "我不知道错误码是什么。",
|
||
"expected_behavior": "识别为提问。",
|
||
"failure_signal": "输出难题或课题。"
|
||
},
|
||
{
|
||
"case_id": "case_qpi_boundary_001",
|
||
"model_id": "qpi",
|
||
"case_type": "boundary",
|
||
"input": "既缺数据也缺路径。",
|
||
"expected_behavior": "标出混合状态。",
|
||
"failure_signal": "单标签武断判断。"
|
||
},
|
||
{
|
||
"case_id": "case_qpi_misuse_001",
|
||
"model_id": "qpi",
|
||
"case_type": "misuse",
|
||
"input": "大环境不好所以我不用跟进客户。",
|
||
"expected_behavior": "识别恶意升维。",
|
||
"failure_signal": "接受甩锅叙事。"
|
||
},
|
||
{
|
||
"case_id": "case_qpi_positive_002",
|
||
"model_id": "qpi",
|
||
"case_type": "positive",
|
||
"input": "目标明确但缺少实现路径。",
|
||
"expected_behavior": "识别为难题。",
|
||
"failure_signal": "输出提问或课题。"
|
||
},
|
||
{
|
||
"case_id": "case_qpi_boundary_002",
|
||
"model_id": "qpi",
|
||
"case_type": "boundary",
|
||
"input": "团队既缺共识也有明确技术瓶颈。",
|
||
"expected_behavior": "标出主导匮乏物待判定。",
|
||
"failure_signal": "忽略混合状态。"
|
||
},
|
||
{
|
||
"case_id": "case_qpi_no_call_001",
|
||
"model_id": "qpi",
|
||
"case_type": "no_call",
|
||
"input": "请只改错别字。",
|
||
"expected_behavior": "不调用 QPI。",
|
||
"failure_signal": "过度分析。",
|
||
"should_call_model": False
|
||
},
|
||
{
|
||
"case_id": "case_qpi_selector_gate_001",
|
||
"model_id": "qpi",
|
||
"case_type": "selector_gate",
|
||
"input": "快速查一下定义。",
|
||
"expected_behavior": "低分或不调用。",
|
||
"failure_signal": "误召回。",
|
||
"negative_expected_models": ["intellectual_archaeology"]
|
||
},
|
||
{
|
||
"case_id": "case_qpi_pipeline_001",
|
||
"model_id": "qpi",
|
||
"case_type": "pipeline",
|
||
"input": "这是问题定义阶段。",
|
||
"expected_behavior": "QPI 位于前置路由。",
|
||
"failure_signal": "跳过 QPI。",
|
||
"expected_primary_model": "qpi"
|
||
},
|
||
{
|
||
"case_id": "case_qpi_positive_003",
|
||
"model_id": "qpi",
|
||
"case_type": "positive",
|
||
"input": "只缺一份报告。",
|
||
"expected_behavior": "识别为提问。",
|
||
"failure_signal": "过度升维。"
|
||
},
|
||
{
|
||
"case_id": "case_qpi_positive_004",
|
||
"model_id": "qpi",
|
||
"case_type": "positive",
|
||
"input": "目标清楚但没资源。",
|
||
"expected_behavior": "识别为难题。",
|
||
"failure_signal": "误判。"
|
||
},
|
||
{
|
||
"case_id": "case_qpi_positive_005",
|
||
"model_id": "qpi",
|
||
"case_type": "positive",
|
||
"input": "组织没有共识。",
|
||
"expected_behavior": "识别为课题。",
|
||
"failure_signal": "误判。"
|
||
},
|
||
{
|
||
"case_id": "case_qpi_boundary_003",
|
||
"model_id": "qpi",
|
||
"case_type": "boundary",
|
||
"input": "缺数据也缺共识。",
|
||
"expected_behavior": "识别混合。",
|
||
"failure_signal": "单标签。"
|
||
},
|
||
{
|
||
"case_id": "case_qpi_misuse_002",
|
||
"model_id": "qpi",
|
||
"case_type": "misuse",
|
||
"input": "员工离职都是个人不忠诚。",
|
||
"expected_behavior": "识别暴力降维风险。",
|
||
"failure_signal": "接受降维叙事。"
|
||
},
|
||
{
|
||
"case_id": "case_qpi_no_call_002",
|
||
"model_id": "qpi",
|
||
"case_type": "no_call",
|
||
"input": "翻译这句话。",
|
||
"expected_behavior": "不调用。",
|
||
"failure_signal": "过度分析。"
|
||
},
|
||
{
|
||
"case_id": "case_qpi_pipeline_002",
|
||
"model_id": "qpi",
|
||
"case_type": "pipeline",
|
||
"input": "QPI 先判断,再决定后续模型。",
|
||
"expected_behavior": "保持前置路由。",
|
||
"failure_signal": "跳过路由。"
|
||
}
|
||
]
|
||
})
|
||
|
||
def test_missing_trigger_keywords_is_reported(self):
|
||
validator = load_validator()
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
root = Path(tmp)
|
||
self.write_minimal_library(root, {"trigger_keywords": "__DELETE__"})
|
||
|
||
errors = validator.validate_library(root)
|
||
|
||
self.assertIn("models/qpi.model.json missing required field trigger_keywords", errors)
|
||
|
||
def test_invalid_selection_priority_is_reported(self):
|
||
validator = load_validator()
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
root = Path(tmp)
|
||
self.write_minimal_library(root, {"selection_priority": 95})
|
||
|
||
errors = validator.validate_library(root)
|
||
|
||
self.assertIn("models/qpi.model.json field selection_priority must be <= 10", errors)
|
||
|
||
def test_missing_status_is_reported(self):
|
||
validator = load_validator()
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
root = Path(tmp)
|
||
self.write_minimal_library(root, {"status": "__DELETE__"})
|
||
|
||
errors = validator.validate_library(root)
|
||
|
||
self.assertIn("models/qpi.model.json missing required field status", errors)
|
||
|
||
def test_invalid_model_type_is_reported(self):
|
||
validator = load_validator()
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
root = Path(tmp)
|
||
self.write_minimal_library(root, {"model_type": "routing"})
|
||
|
||
errors = validator.validate_library(root)
|
||
|
||
self.assertIn("models/qpi.model.json field model_type has invalid value routing", errors)
|
||
|
||
def test_missing_stability_reason_is_reported(self):
|
||
validator = load_validator()
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
root = Path(tmp)
|
||
self.write_minimal_library(root, {
|
||
"stability_profile": {
|
||
"stability_level": "B",
|
||
"needs_stabilization": True,
|
||
"main_risks": ["样本仍少"],
|
||
"next_stabilization_action": "补充测试。"
|
||
}
|
||
})
|
||
|
||
errors = validator.validate_library(root)
|
||
|
||
self.assertIn("models/qpi.model.json stability_profile missing required field reason", errors)
|
||
|
||
def test_model_index_missing_file_is_reported(self):
|
||
validator = load_validator()
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
root = Path(tmp)
|
||
self.write_minimal_library(root, index_overrides={"model_file": "models/missing.model.json"})
|
||
|
||
errors = validator.validate_library(root)
|
||
|
||
self.assertIn("models/model_index.json references missing model file models/missing.model.json", errors)
|
||
|
||
def test_model_index_count_drift_is_reported(self):
|
||
validator = load_validator()
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
root = Path(tmp)
|
||
self.write_minimal_library(root, index_overrides={
|
||
"source_article_count": 2,
|
||
"source_evidence_count": 2,
|
||
"regression_case_count": 99
|
||
})
|
||
|
||
errors = validator.validate_library(root)
|
||
|
||
self.assertIn("models/model_index.json entry qpi source_article_count is 2, expected 1", errors)
|
||
self.assertIn("models/model_index.json entry qpi source_evidence_count is 2, expected 1", errors)
|
||
self.assertIn("models/model_index.json entry qpi regression_case_count is 99, expected 15", errors)
|
||
|
||
def test_model_index_state_drift_is_reported(self):
|
||
validator = load_validator()
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
root = Path(tmp)
|
||
self.write_minimal_library(root, index_overrides={
|
||
"stability_level": "A",
|
||
"regression_status": "passed",
|
||
"status": "stable"
|
||
})
|
||
|
||
errors = validator.validate_library(root)
|
||
|
||
self.assertIn("models/model_index.json entry qpi stability_level is A, expected B", errors)
|
||
self.assertIn("models/model_index.json entry qpi regression_status is passed, expected pending", errors)
|
||
self.assertIn("models/model_index.json entry qpi status is stable, expected draft", errors)
|
||
|
||
def test_regression_case_coverage_is_reported(self):
|
||
validator = load_validator()
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
root = Path(tmp)
|
||
self.write_minimal_library(root)
|
||
self.write_json(root, "tests/regression_cases.json", {
|
||
"regression_cases": [{
|
||
"case_id": "case_qpi_positive_001",
|
||
"model_id": "qpi",
|
||
"case_type": "positive",
|
||
"input": "我不知道错误码是什么。",
|
||
"expected_behavior": "识别为提问。",
|
||
"failure_signal": "输出难题或课题。"
|
||
}]
|
||
})
|
||
|
||
errors = validator.validate_library(root)
|
||
|
||
self.assertIn("model qpi has 1 regression cases, expected at least 15", errors)
|
||
self.assertIn("model qpi missing regression case type boundary", errors)
|
||
self.assertIn("model qpi missing regression case type misuse", errors)
|
||
self.assertIn("model qpi missing regression case type no_call", errors)
|
||
self.assertIn("model qpi missing regression case type pipeline", errors)
|
||
self.assertIn("model qpi missing regression case type selector_gate", errors)
|
||
|
||
def test_qpi_missing_structured_output_contract_field_is_reported(self):
|
||
validator = load_validator()
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
root = Path(tmp)
|
||
self.write_minimal_library(root)
|
||
model = json.loads((root / "models" / "qpi.model.json").read_text(encoding="utf-8"))
|
||
model["structured_output_contract"].pop("classification_scope")
|
||
self.write_json(root, "models/qpi.model.json", model)
|
||
|
||
errors = validator.validate_library(root)
|
||
|
||
self.assertIn(
|
||
"models/qpi.model.json model qpi structured_output_contract missing required output field classification_scope",
|
||
errors
|
||
)
|
||
|
||
def test_qpi_digest_deprecated_fields_are_reported(self):
|
||
validator = load_validator()
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
root = Path(tmp)
|
||
self.write_minimal_library(root)
|
||
self.write_json(root, "selector/qpi_case_digests.json", {
|
||
"cases": [{
|
||
"case_id": "qpi-test-001",
|
||
"classification_scope": "subject_contextual",
|
||
"mixed_or_multi_perspective": "not_mixed",
|
||
"misframing_risks": ["violent_reduction"]
|
||
}]
|
||
})
|
||
|
||
errors = validator.validate_library(root)
|
||
|
||
self.assertIn(
|
||
"qpi case digest qpi-test-001 uses deprecated field misframing_risks; use misclassification_risk",
|
||
errors
|
||
)
|
||
self.assertIn(
|
||
"qpi case digest qpi-test-001 uses deprecated field mixed_or_multi_perspective; use qpi_complexity_pattern",
|
||
errors
|
||
)
|
||
|
||
def test_qpi_digest_multi_perspective_requires_viewpoint_detail(self):
|
||
validator = load_validator()
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
root = Path(tmp)
|
||
self.write_minimal_library(root)
|
||
self.write_json(root, "selector/qpi_case_digests.json", {
|
||
"cases": [{
|
||
"case_id": "qpi-test-002",
|
||
"classification_scope": "multi_perspective",
|
||
"qpi_complexity_pattern": "inter_viewpoint_divergence",
|
||
"misclassification_risk": ["single_viewpoint_only"]
|
||
}]
|
||
})
|
||
|
||
errors = validator.validate_library(root)
|
||
|
||
self.assertIn(
|
||
"qpi case digest qpi-test-002 multi_perspective case requires classification_by_viewpoint or viewpoint_summary",
|
||
errors
|
||
)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|