deliver: 交付收口(引擎代为提交)

This commit is contained in:
agent.develop 2026-09-17 23:45:03 +08:00
parent be967f9f97
commit 06bf825b06
11 changed files with 3590 additions and 320 deletions

View File

@ -1,2 +1,59 @@
# pbl_validation
# pbl_validation —— PBL 校验引擎M2
14 维校验 + 5 级质量状态quality_state阈值配置化常量`mcRatio=0.7`Q-OPEN-8
输出固定契约 `pbl.validation.report/1.0` 的校验报告。
## 目录结构
```
modules/pbl_validation/
├── pbl_validation/ # Python 包(模块本体,无 app.py
│ ├── __init__.py # 导出load_pbl_validation + 5 契约 + 引擎纯函数 + 常量
│ ├── constants.py # 阈值配置化常量 / 5 级质量状态 / 严重度 / 阈值合并校验
│ ├── dimensions.py # 14 维定义 + CheckContext + DimResult/Issue + 14 个 checker
│ ├── engine.py # 引擎主入口 run_validation + decide_quality_state + 报告契约
│ ├── api.py # 5 个契约接口(.dspy+ DB 适配q_all/q_one/tenant_id
│ └── init.py # load_pbl_validation():库名解析/建表/契约注册/规则种子
├── sql/pbl_validation.sql # 2 张表 DDL + 14 条内置规则种子(幂等)
├── scripts/load_path.py # 模块路径与契约清单注册
├── tests/test_validation_engine.py # 111 项断言自测(纯函数层,无需 DB
├── skill/SKILL.md # 模块技能文档(详细规范)
└── pyproject.toml
```
## 快速使用
```python
from pbl_validation import run_validation, is_publishable
report = run_validation(blueprint_dict, tenant_id="t1", blueprint_id="BP1", version="v1")
print(report["quality_state"], report["total_score"]) # Q4_EXCELLENT 0.97
ok, msg = is_publishable(report) # 发布门禁:需 >= Q3_GOOD
```
契约接口HTTP
```
POST /pbl_validation/api/pbl_validation_run.dspy {"tenant_id":"t1","blueprint":{...}}
POST /pbl_validation/api/pbl_validation_get.dspy {"tenant_id":"t1","result_id":"vr_x"}
POST /pbl_validation/api/pbl_validation_list.dspy {"tenant_id":"t1","page":1,"page_size":20}
POST /pbl_validation/api/pbl_validation_rule_list.dspy {"tenant_id":"t1"}
POST /pbl_validation/api/pbl_validation_rule_save.dspy {"tenant_id":"t1","dim":"D08","thresholds":{"mcRatio":0.8}}
```
## 14 维 / 5 级
- 维度D01 结构完整性、D02 命名规范、D03 学习目标对齐、D04 驱动问题质量、D05 角色设计、
D06 场景设计、D07 实体模型、D08 任务链完整性(含 mcRatio 多路径覆盖率)、
D09 规则引用完整性、D10 评估量规、D11 产出物绑定、D12 难度平衡、D13 时长平衡、
D14 安全合规。
- 质量状态:`Q0_DRAFT` / `Q1_INCOMPLETE` / `Q2_BASIC` / `Q3_GOOD` / `Q4_EXCELLENT`
由 R1~R8 规则自上而下短路判定(详见 skill/SKILL.md
## 自测
```bash
cd modules/pbl_validation && python3 tests/test_validation_engine.py
# PASSED: 111 FAILED: 0
```
## 铁律
- 库名只用 `ServerEnv().get_module_dbname('pbl_validation')`,禁止硬编码 DBNAME
- 所有读写强制带 `tenant_id`,缺失 fail-closed
- sqlor 只用 `C/U/D/R/I/sqlExe`,查询走 `q_all/q_one`
- 引擎为纯函数(无 DB/网络/随机),确定性可复现。

View File

@ -1,25 +1,90 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""pbl_validation —— PBL 校验引擎14 维 + 5 级质量状态M2
"""pbl_validation —— PBL 校验引擎14 维 + 5 级质量状态M2
注册三处同步之 必须导出 init.py 里的全部契约函数漏一行 .dspy 调用即 NameError
对外导出
- ``load_pbl_validation()``应用挂载入口app/pbls.py init() 中按序调用
- 5 个契约接口pbl_validation_run / _get / _list / _rule_list / _rule_save
- 引擎纯函数``run_validation`` / ``decide_quality_state`` / ``normalize_payload``
- 常量``DEFAULT_THRESHOLDS``mcRatio=0.7Q-OPEN-8``QUALITY_STATES``
"""
from pbl_validation.init import load_pbl_validation
from pbl_validation.api import (
pbl_validation_run,
from __future__ import annotations
from .constants import (
BUILTIN_RULESET,
DEFAULT_THRESHOLDS,
DIM_STATES,
ENGINE_VERSION,
QUALITY_BASIC,
QUALITY_DRAFT,
QUALITY_EXCELLENT,
QUALITY_GOOD,
QUALITY_INCOMPLETE,
QUALITY_LABELS,
QUALITY_ORDER,
QUALITY_STATES,
REPORT_CONTRACT_VERSION,
SEVERITIES,
resolve_thresholds,
)
from .dimensions import (
DIM_COUNT,
DIMENSION_REGISTRY,
SUB_OBJECT_KINDS,
CheckContext,
DimResult,
Issue,
dimension_meta,
get_checker,
)
from .engine import (
ValidationError,
decide_quality_state,
is_publishable,
normalize_payload,
payload_fingerprint,
quality_state_of,
report_to_row,
run_validation,
)
from .api import (
API_REGISTRY,
MODULE_NAME,
TBL_RESULT,
TBL_RULE,
builtin_rules,
pbl_validation_get,
pbl_validation_list,
pbl_validation_rule_list,
pbl_validation_rule_save,
pbl_validation_run,
q_all,
q_one,
tenant_id,
)
from .init import load_pbl_validation
__version__ = ENGINE_VERSION
__all__ = [
'load_pbl_validation',
'pbl_validation_run',
'pbl_validation_get',
'pbl_validation_list',
'pbl_validation_rule_list',
'pbl_validation_rule_save',
# 挂载
"load_pbl_validation",
# 契约接口
"pbl_validation_run", "pbl_validation_get", "pbl_validation_list",
"pbl_validation_rule_list", "pbl_validation_rule_save",
"API_REGISTRY", "MODULE_NAME", "TBL_RULE", "TBL_RESULT",
"tenant_id", "q_all", "q_one", "builtin_rules",
# 引擎
"run_validation", "decide_quality_state", "normalize_payload",
"payload_fingerprint", "report_to_row", "quality_state_of",
"is_publishable", "ValidationError",
# 维度
"DIMENSION_REGISTRY", "DIM_COUNT", "SUB_OBJECT_KINDS", "dimension_meta",
"get_checker", "CheckContext", "DimResult", "Issue",
# 常量
"DEFAULT_THRESHOLDS", "resolve_thresholds", "QUALITY_STATES", "QUALITY_ORDER",
"QUALITY_LABELS", "QUALITY_DRAFT", "QUALITY_INCOMPLETE", "QUALITY_BASIC",
"QUALITY_GOOD", "QUALITY_EXCELLENT", "DIM_STATES", "SEVERITIES",
"REPORT_CONTRACT_VERSION", "ENGINE_VERSION", "BUILTIN_RULESET",
"__version__",
]

View File

@ -1,237 +1,631 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""pbl_validation.api —— 14 维校验引擎 + 5 级 quality_state第15章M2)。
"""pbl_validation 契约接口5 个,路径 /pbl_validation/api/<name>.dspy)。
规则形态规则体存 `pbl_validation_rule.rule_json` script_engine script_type=1
eventconditionresponse 同构**不新建规则引擎**阈值可配Q-OPEN-8
缺规则时用内置 DEFAULT_RULES 兜底保证校验链路永远可跑fail-closed无结论=不通过
- pbl_validation_run 执行 14 维校验并落库结果
- pbl_validation_get result_id 取校验报告
- pbl_validation_list 分页列出校验结果按蓝图/质量状态过滤
- pbl_validation_rule_list 列出校验规则 14 维内置元信息
- pbl_validation_rule_save 新增/更新校验规则阈值配置化落库
约定
----
- 库名一律 ``ServerEnv().get_module_dbname('pbl_validation')``禁止硬编码 DBNAME
- 所有读写强制带 tenant_id缺失即 fail-closed 报错
- 查询走 pbl_common.api q_all/q_one已适配 sqlor不可用时回落本地 sqlExe 实现
"""
from __future__ import annotations
import json
import time
import uuid
from typing import Any, Dict, List, Optional, Tuple
from pbl_common.api import (PblError, actor_id, crud, json_dump, now_str, sql_exec,
sql_rows, sql_scalar, tenant_id)
from .constants import (
BUILTIN_RULESET,
QUALITY_STATES,
RESULT_STATE_DONE,
RULE_DISABLED,
RULE_ENABLED,
resolve_thresholds,
)
from .dimensions import DIMENSION_REGISTRY, DIM_COUNT, dimension_meta
from .engine import (
ValidationError,
is_publishable,
normalize_payload,
quality_state_of,
report_to_row,
run_validation,
)
DIMENSIONS = [
'learning_goal_clarity', 'driving_question_quality', 'task_alignment',
'role_meaningfulness', 'artifact_authenticity', 'evidence_sufficiency',
'assessment_alignment', 'collaboration_design', 'resource_adequacy',
'constraint_feasibility', 'reflection_design', 'difficulty_gradient',
'engagement_potential', 'cross_discipline_value',
]
# 质量状态阶梯第15章 5 级):逐级门禁,不可跳级
LADDER = ['draft', 'needs_work', 'pbl_ready', 'playtest_ready', 'publish_ready']
GATES = {
'pbl_ready': {'min_passed': 10, 'max_blocking': 0, 'min_score': 60},
'playtest_ready': {'min_passed': 12, 'max_blocking': 0, 'min_score': 75},
'publish_ready': {'min_passed': 14, 'max_blocking': 0, 'min_score': 85},
}
SEVERITY_BLOCKING = ('blocking', 'critical')
DEFAULT_RULES = {
'learning_goal_clarity': {'op': 'min_count', 'path': 'learning_goals', 'value': 2,
'severity': 'blocking'},
'driving_question_quality': {'op': 'min_text_len', 'path': 'driving_question', 'value': 10,
'severity': 'blocking'},
'task_alignment': {'op': 'min_count', 'path': 'missions', 'value': 1, 'severity': 'blocking'},
'role_meaningfulness': {'op': 'min_count', 'path': 'roles', 'value': 2, 'severity': 'warning'},
'artifact_authenticity': {'op': 'min_count', 'path': 'artifacts', 'value': 1,
'severity': 'blocking'},
'evidence_sufficiency': {'op': 'min_count', 'path': 'evidence_specs', 'value': 2,
'severity': 'warning'},
'assessment_alignment': {'op': 'goal_coverage', 'value': 0.6, 'severity': 'blocking'},
'collaboration_design': {'op': 'min_count', 'path': 'collaboration', 'value': 1,
'severity': 'warning'},
'resource_adequacy': {'op': 'min_count', 'path': 'resources', 'value': 1, 'severity': 'warning'},
'constraint_feasibility': {'op': 'min_count', 'path': 'constraints', 'value': 0,
'severity': 'info'},
'reflection_design': {'op': 'min_count', 'path': 'reflections', 'value': 1,
'severity': 'warning'},
'difficulty_gradient': {'op': 'distinct_values', 'path': 'tasks.difficulty', 'value': 2,
'severity': 'info'},
'engagement_potential': {'op': 'min_text_len', 'path': 'scenario', 'value': 8,
'severity': 'warning'},
'cross_discipline_value': {'op': 'min_count', 'path': 'learning_goals', 'value': 3,
'severity': 'info'},
}
MODULE_NAME = "pbl_validation"
TBL_RULE = "pbl_validation_rule"
TBL_RESULT = "pbl_validation_result"
async def _tid():
return await tenant_id()
def _loads(v, default=None):
if isinstance(v, (dict, list)):
return v
# ---------------------------------------------------------------------------
# 基础设施DB 适配(优先复用 pbl_common.api缺失时回落 sqlor
# ---------------------------------------------------------------------------
def _common():
"""惰性导入 pbl_common.api返回模块或 None。"""
try:
return json.loads(v) if v else (default if default is not None else {})
except ValueError:
return default if default is not None else {}
from pbl_common import api as common_api # type: ignore
return common_api
except Exception:
return None
def _path_get(model, path):
cur = model
for seg in path.split('.'):
if isinstance(cur, list):
cur = [x.get(seg) for x in cur if isinstance(x, dict)]
elif isinstance(cur, dict):
cur = cur.get(seg)
else:
return None
return cur
def _db():
"""取 sqlor 句柄DBNAME 从 ServerEnv 映射,禁止硬编码)。"""
try:
from sage.platform import ServerEnv # type: ignore
except Exception:
try:
from ahserver.serverenv import ServerEnv # type: ignore
except Exception:
ServerEnv = None # type: ignore
dbname = None
if ServerEnv is not None:
try:
dbname = ServerEnv().get_module_dbname(MODULE_NAME)
except Exception:
dbname = None
if not dbname:
raise ValidationError("V-DB-001",
"无法解析模块库名ServerEnv().get_module_dbname('%s') 为空"
% MODULE_NAME)
try:
import sqlor # type: ignore
return sqlor.DB(dbname) if hasattr(sqlor, "DB") else sqlor
except Exception as exc:
raise ValidationError("V-DB-002", "sqlor 不可用:%s" % exc)
def _check(model, rule):
op, path, want = rule.get('op'), rule.get('path'), rule.get('value', 0)
got = _path_get(model, path or '')
if op == 'min_count':
n = len(got or []) if isinstance(got, (list, dict)) else (0 if got is None else 1)
return n >= int(want), 'count=%s need>=%s' % (n, want)
if op == 'min_text_len':
s = got if isinstance(got, str) else (got[0] if isinstance(got, list) and got else '')
s = s or ''
return len(s) >= int(want), 'len=%s need>=%s' % (len(s), want)
if op == 'goal_coverage':
goals = [g.get('uid') or g.get('code') for g in (model.get('learning_goals') or [])]
crits = [c.get('learning_goal_id') or c.get('learning_goal_uid')
for c in (model.get('rubric_criteria') or [])]
cov = len({g for g in goals if g in crits})
ratio = (cov / len(goals)) if goals else 0.0
return ratio >= float(want), 'coverage=%.2f need>=%s' % (ratio, want)
if op == 'distinct_values':
vals = set()
for item in (got or []):
vals.add(item if not isinstance(item, dict) else json.dumps(item, sort_keys=True))
return len(vals) >= int(want), 'distinct=%s need>=%s' % (len(vals), want)
return False, 'unknown_op:%s' % op
def tenant_id(params: Optional[Dict[str, Any]] = None) -> str:
"""解析租户上下文:优先 pbl_common.api.tenant_id(),其次入参。缺失 fail-closed。"""
tid = ""
common = _common()
if common is not None and hasattr(common, "tenant_id"):
try:
tid = str(common.tenant_id() or "").strip()
except Exception:
tid = ""
if not tid and isinstance(params, dict):
tid = str(params.get("tenant_id") or "").strip()
if not tid:
raise ValidationError("V-TENANT-001",
"tenant_id 缺失pbl_validation 所有读写强制带租户上下文")
return tid
async def _rules(tid):
rows = await sql_rows('SELECT `dimension`,`rule_json`,`threshold_json`,`severity`'
' FROM `pbl_validation_rule` WHERE `tenant_id` = ${t}$'
' AND `enabled` = 1 ORDER BY `version_no` DESC', {'t': tid}, 'pbl')
out, seen = {}, set()
for r in rows:
if r['dimension'] in seen:
continue
seen.add(r['dimension'])
body = _loads(r['rule_json'], {})
body['severity'] = r['severity'] or body.get('severity', 'warning')
thr = _loads(r['threshold_json'], {})
if 'value' in thr:
body['value'] = thr['value']
out[r['dimension']] = body
for dim, body in DEFAULT_RULES.items():
out.setdefault(dim, dict(body))
def q_all(sql: str, args: Optional[List[Any]] = None) -> List[Dict[str, Any]]:
common = _common()
if common is not None and hasattr(common, "q_all"):
try:
return list(common.q_all(sql, args or []) or [])
except Exception:
pass
db = _db()
rows = db.sqlExe(sql, args or [])
return list(rows or [])
def q_one(sql: str, args: Optional[List[Any]] = None) -> Optional[Dict[str, Any]]:
common = _common()
if common is not None and hasattr(common, "q_one"):
try:
return common.q_one(sql, args or [])
except Exception:
pass
rows = q_all(sql, args)
return rows[0] if rows else None
def _exec(sql: str, args: Optional[List[Any]] = None) -> Any:
db = _db()
return db.sqlExe(sql, args or [])
def _ok(data: Any = None, **extra) -> Dict[str, Any]:
out = {"success": True, "code": 0, "message": "ok", "data": data}
out.update(extra)
return out
async def pbl_validation_run(**kw):
"""执行 14 维校验并落 pbl_validation_result返回逐维可解释结论。"""
import time
t0 = time.time()
tid = await _tid()
bp_id = kw.get('blueprint_id')
bps = await sql_rows('SELECT * FROM `pbl_blueprint` WHERE `tenant_id` = ${t}$'
' AND `id` = ${id}$ LIMIT 1', {'t': tid, 'id': bp_id}, 'pbl')
if not bps:
raise PblError('PBL_NOT_FOUND', '蓝图不存在')
bp = bps[0]
model = _loads(bp['content_json'])
for key, table in (('learning_goals', 'pbl_blueprint_learning_goal'),
('roles', 'pbl_blueprint_role'), ('missions', 'pbl_blueprint_mission'),
('tasks', 'pbl_blueprint_task'), ('artifacts', 'pbl_blueprint_artifact_spec'),
('evidence_specs', 'pbl_blueprint_evidence_spec'),
('reflections', 'pbl_blueprint_reflection_spec')):
rows = await sql_rows('SELECT * FROM `%s` WHERE `tenant_id` = ${t}$'
' AND `blueprint_id` = ${b}$ ORDER BY `order_no`' % table,
{'t': tid, 'b': bp_id}, 'pbl')
if rows:
model[key] = rows
rules = await _rules(tid)
findings, passed, blocking = [], 0, 0
for dim in DIMENSIONS:
rule = rules.get(dim) or {'op': 'min_count', 'path': dim, 'value': 1, 'severity': 'warning'}
ok, detail = _check(model, rule)
sev = rule.get('severity', 'warning')
if ok:
passed += 1
elif sev in SEVERITY_BLOCKING:
blocking += 1
findings.append({'dimension': dim, 'passed': bool(ok), 'severity': sev,
'detail': detail, 'rule_value': rule.get('value'),
'recommendation': None if ok else '补充 %s%s' % (dim, detail)})
total = len(DIMENSIONS)
score = round(100.0 * passed / total, 2)
state = _grade(score, passed, blocking)
await sql_exec(
'INSERT INTO `pbl_validation_result` (`tenant_id`,`blueprint_id`,`blueprint_version_no`,'
'`dimension_count`,`passed_count`,`failed_count`,`score`,`quality_state`,'
'`findings_json`,`rule_set_version`,`created_by`,`created_at`)'
' VALUES (${t}$,${b}$,${v}$,${dc}$,${pc}$,${fc}$,${s}$,${q}$,${f}$,${r}$,${u}$,${ts}$)',
{'t': tid, 'b': bp_id, 'v': bp['version_no'], 'dc': total, 'pc': passed,
'fc': total - passed, 's': score, 'q': state, 'f': json_dump(findings),
'r': 'pbl-validation-v1', 'u': await actor_id(), 'ts': now_str()}, 'pbl')
if kw.get('apply_state'):
await sql_exec('UPDATE `pbl_blueprint` SET `quality_state` = ${q}$,'
' `updated_at` = ${ts}$ WHERE `id` = ${id}$ AND `tenant_id` = ${t}$',
{'q': state, 'ts': now_str(), 'id': bp_id, 't': tid}, 'pbl')
return {'ok': True, 'blueprint_id': bp_id, 'dimension_count': total, 'passed': passed,
'failed': total - passed, 'blocking': blocking, 'score': score,
'quality_state': state, 'findings': findings,
'latency_ms': int((time.time() - t0) * 1000),
'budget_ms': 2000, 'within_budget': (time.time() - t0) * 1000 <= 2000}
def _err(code: str, message: str, http: int = 400) -> Dict[str, Any]:
return {"success": False, "code": code, "message": message,
"data": None, "http_status": http}
def _grade(score, passed, blocking):
best = 'draft'
for state in LADDER[2:]:
g = GATES[state]
if blocking > g['max_blocking'] or passed < g['min_passed'] or score < g['min_score']:
break
best = state
if blocking:
return 'needs_work' if score >= 40 else 'draft'
return best
def _fail(exc: Exception) -> Dict[str, Any]:
if isinstance(exc, ValidationError):
return _err(exc.code, exc.message)
return _err("V-INTERNAL-500", "%s: %s" % (type(exc).__name__, exc), 500)
async def pbl_validation_get(**kw):
tid = await _tid()
rows = await sql_rows('SELECT * FROM `pbl_validation_result` WHERE `tenant_id` = ${t}$'
' AND `id` = ${id}$ LIMIT 1', {'t': tid, 'id': kw.get('id')}, 'pbl')
def _json_loads(text: Any, default: Any = None) -> Any:
if text is None or text == "":
return default
if isinstance(text, (dict, list)):
return text
try:
return json.loads(text)
except ValueError:
return default
def _int(v: Any, default: int) -> int:
try:
n = int(v)
return n if n >= 0 else default
except (TypeError, ValueError):
return default
# ---------------------------------------------------------------------------
# 内置规则集14 维)——无库记录时的离线兜底
# ---------------------------------------------------------------------------
def builtin_rules() -> List[Dict[str, Any]]:
out = []
for meta in DIMENSION_REGISTRY:
out.append({
"tenant_id": "",
"rule_id": "builtin_%s" % meta["dim"].lower(),
"dim": meta["dim"],
"dim_code": meta["code"],
"name": meta["name"],
"description": meta["desc"],
"weight": float(meta["weight"]),
"severity": "major",
"script_type": 1,
"ruleset": BUILTIN_RULESET,
"enabled": RULE_ENABLED,
"builtin": 1,
"rule_json": json.dumps({
"dim": meta["dim"],
"code": meta["code"],
"name": meta["name"],
"weight": float(meta["weight"]),
"desc": meta["desc"],
"thresholds": {},
}, ensure_ascii=False),
})
return out
def _load_rules(tid: str) -> Tuple[List[Dict[str, Any]], bool]:
"""加载租户规则;库不可用/无记录时回落内置 14 维(离线兜底)。"""
try:
rows = q_all(
"select * from %s where tenant_id=%%s order by dim asc" % TBL_RULE, [tid])
except Exception:
rows = []
if not rows:
raise PblError('PBL_NOT_FOUND', '校验结果不存在')
rows[0]['findings_json'] = _loads(rows[0].get('findings_json'), [])
return {'ok': True, 'data': rows[0]}
builtin = builtin_rules()
for r in builtin:
r["tenant_id"] = tid
return builtin, True
return rows, False
async def pbl_validation_list(**kw):
"""校验结果分页只读tenant 强制)。"""
sc = crud('pbl_validation_result', 'pbl',
['blueprint_id', 'blueprint_version_no', 'dimension_count', 'passed_count',
'failed_count', 'score', 'quality_state', 'rule_set_version', 'created_by'])
return await sc['list'](**kw)
def _merge_rule_overrides(rules: List[Dict[str, Any]]) -> Dict[str, Any]:
"""把规则记录里的 thresholds / weight / enabled 合并为引擎入参。"""
thresholds: Dict[str, Any] = {}
weights: Dict[str, Any] = {}
disabled: List[str] = []
for r in rules:
if _int(r.get("enabled"), RULE_ENABLED) != RULE_ENABLED:
disabled.append(str(r.get("dim") or "").upper())
continue
rj = _json_loads(r.get("rule_json"), {}) or {}
if isinstance(rj.get("thresholds"), dict):
thresholds.update(rj["thresholds"])
w = r.get("weight")
dim = str(r.get("dim") or "").upper()
if w not in (None, "") and dim:
try:
weights[dim] = float(w)
except (TypeError, ValueError):
pass
return {"thresholds": thresholds, "weights": weights, "disabled_dims": disabled}
async def pbl_validation_rule_list(**kw):
tid = await _tid()
rows = await sql_rows('SELECT * FROM `pbl_validation_rule` WHERE `tenant_id` = ${t}$'
' ORDER BY `dimension`,`version_no` DESC', {'t': tid}, 'pbl')
return {'ok': True, 'data': rows, 'dimensions': DIMENSIONS,
'builtin_defaults': DEFAULT_RULES if kw.get('with_defaults') else None}
# ---------------------------------------------------------------------------
# 1) pbl_validation_run —— 执行校验
# ---------------------------------------------------------------------------
def pbl_validation_run(params: Dict[str, Any]) -> Dict[str, Any]:
"""执行 14 维校验,落库 pbl_validation_result返回校验报告契约。
入参
tenant_id 可选缺省从 pbl_common 上下文取取不到 fail-closed
blueprint 必填蓝图载荷 dict JSON 字符串
blueprint_id 可选
version 可选
dims 可选只跑指定维度 ["D01","D08"]
thresholds 可选阈值覆盖 {"mcRatio": 0.8}
persist 可选默认 1 落库0 = 只算不存dry-run
operator 可选
"""
try:
params = params if isinstance(params, dict) else {}
tid = tenant_id(params)
blueprint = params.get("blueprint")
if blueprint is None:
blueprint = params.get("payload")
if blueprint is None:
return _err("V-INPUT-001", "blueprint 必填(蓝图载荷 dict 或 JSON 字符串)")
blueprint_id = str(params.get("blueprint_id") or params.get("id") or "")
version = str(params.get("version") or "")
persist = _int(params.get("persist", 1), 1)
rules, from_builtin = _load_rules(tid)
merged = _merge_rule_overrides(rules)
overrides = dict(merged.get("thresholds") or {})
if isinstance(params.get("thresholds"), dict):
overrides.update(params["thresholds"])
rule_json = {
"ruleset": BUILTIN_RULESET,
"weights": merged.get("weights") or {},
"disabled_dims": merged.get("disabled_dims") or [],
"thresholds": overrides,
}
report = run_validation(
blueprint,
tenant_id=tid,
blueprint_id=blueprint_id,
version=version,
threshold_overrides=overrides,
dims=params.get("dims"),
rule_json=rule_json,
)
report["rules_from_builtin"] = bool(from_builtin)
report["rule_count"] = len(rules)
publishable, gate_msg = is_publishable(report)
report["publishable"] = publishable
report["gate_message"] = gate_msg
result_id = report["report_id"]
if persist:
row = report_to_row(report, operator=str(params.get("operator") or ""))
row["result_id"] = result_id
try:
_persist_result(row)
report["persisted"] = True
except Exception as exc:
report["persisted"] = False
report["persist_error"] = "%s: %s" % (type(exc).__name__, exc)
else:
report["persisted"] = False
return _ok(report, quality_state=report["quality_state"],
total_score=report["total_score"],
issue_count=report["issue_count"],
result_id=result_id)
except Exception as exc:
return _fail(exc)
async def pbl_validation_rule_save(**kw):
dim = kw.get('dimension')
if dim not in DIMENSIONS:
raise PblError('PBL_DIMENSION_UNKNOWN', 'dimension 须属于 14 维之一')
sc = crud('pbl_validation_rule', 'pbl', ['code', 'dimension', 'title', 'rule_json',
'threshold_json', 'severity', 'enabled',
'version_no'])
payload = dict(kw)
payload['rule_json'] = json_dump(kw.get('rule_json'))
payload['threshold_json'] = json_dump(kw.get('threshold_json'))
payload.setdefault('version_no', 1)
payload.setdefault('code', 'VR-%s' % dim)
if kw.get('id'):
return await sc['update'](**payload)
return await sc['create'](**payload)
def _persist_result(row: Dict[str, Any]) -> None:
"""写入 pbl_validation_result存在同 result_id 则更新)。"""
cols = ["tenant_id", "result_id", "blueprint_id", "version", "ruleset",
"quality_state", "quality_label", "total_score", "dim_count",
"pass_count", "warn_count", "fail_count", "skip_count",
"blocker_count", "major_count", "minor_count", "info_count",
"issue_count", "dim_scores", "dim_detail", "fingerprint",
"engine_version", "contract", "report_json", "elapsed_ms",
"operator", "state"]
exist = None
try:
exist = q_one("select result_id from %s where tenant_id=%%s and result_id=%%s"
% TBL_RESULT, [row["tenant_id"], row["result_id"]])
except Exception:
exist = None
now = int(time.time() * 1000)
if exist:
sets = ", ".join("%s=%%s" % c for c in cols if c not in ("tenant_id", "result_id"))
sql = "update %s set %s, updated_at=%%s where tenant_id=%%s and result_id=%%s" % (
TBL_RESULT, sets)
args = [row[c] for c in cols if c not in ("tenant_id", "result_id")]
args += [now, row["tenant_id"], row["result_id"]]
_exec(sql, args)
else:
all_cols = cols + ["created_at", "updated_at"]
sql = "insert into %s (%s) values (%s)" % (
TBL_RESULT, ", ".join(all_cols), ", ".join(["%s"] * len(all_cols)))
args = [row.get(c) for c in cols] + [now, now]
_exec(sql, args)
# ---------------------------------------------------------------------------
# 2) pbl_validation_get —— 取报告
# ---------------------------------------------------------------------------
def pbl_validation_get(params: Dict[str, Any]) -> Dict[str, Any]:
"""按 result_id或 blueprint_id 最新一次)取校验报告。
入参tenant_id / result_id | blueprint_id / latest(默认1) / with_report(默认1)
"""
try:
params = params if isinstance(params, dict) else {}
tid = tenant_id(params)
result_id = str(params.get("result_id") or "")
blueprint_id = str(params.get("blueprint_id") or "")
if not result_id and not blueprint_id:
return _err("V-INPUT-010", "result_id 或 blueprint_id 至少提供一个")
with_report = _int(params.get("with_report", 1), 1)
if result_id:
row = q_one("select * from %s where tenant_id=%%s and result_id=%%s"
% TBL_RESULT, [tid, result_id])
else:
row = q_one(
"select * from %s where tenant_id=%%s and blueprint_id=%%s "
"order by created_at desc limit 1" % TBL_RESULT, [tid, blueprint_id])
if not row:
return _err("V-NOTFOUND-001", "校验结果不存在", 404)
out = dict(row)
report = _json_loads(out.pop("report_json", None), None)
out["dim_scores"] = _json_loads(out.get("dim_scores"), {})
out["dim_detail"] = _json_loads(out.get("dim_detail"), {})
out["quality_state"] = quality_state_of(out.get("quality_state"))
publishable, gate_msg = is_publishable(out.get("quality_state"))
out["publishable"] = publishable
out["gate_message"] = gate_msg
if with_report and isinstance(report, dict):
out["report"] = report
return _ok(out)
except Exception as exc:
return _fail(exc)
# ---------------------------------------------------------------------------
# 3) pbl_validation_list —— 分页列表
# ---------------------------------------------------------------------------
def pbl_validation_list(params: Dict[str, Any]) -> Dict[str, Any]:
"""分页列出校验结果。
入参tenant_id / blueprint_id / quality_state / state / keyword
page(默认1) / page_size(默认20最大100)
"""
try:
params = params if isinstance(params, dict) else {}
tid = tenant_id(params)
page = max(1, _int(params.get("page", 1), 1))
page_size = _int(params.get("page_size", 20), 20)
page_size = min(max(page_size, 1), 100)
offset = (page - 1) * page_size
where = ["tenant_id=%s"]
args: List[Any] = [tid]
bp = str(params.get("blueprint_id") or "")
if bp:
where.append("blueprint_id=%s")
args.append(bp)
qs = str(params.get("quality_state") or "").strip().upper()
if qs:
if qs not in QUALITY_STATES:
return _err("V-INPUT-011",
"quality_state 非法,可选:%s" % ",".join(QUALITY_STATES))
where.append("quality_state=%s")
args.append(qs)
st = str(params.get("state") or "")
if st:
where.append("state=%s")
args.append(st)
kw = str(params.get("keyword") or "").strip()
if kw:
where.append("(blueprint_id like %s or version like %s or ruleset like %s)")
like = "%%%s%%" % kw
args += [like, like, like]
clause = " and ".join(where)
total_row = q_one("select count(1) as cnt from %s where %s" % (TBL_RESULT, clause),
args)
total = _int((total_row or {}).get("cnt"), 0)
rows = q_all(
"select tenant_id, result_id, blueprint_id, version, ruleset, quality_state, "
"quality_label, total_score, dim_count, pass_count, warn_count, fail_count, "
"skip_count, blocker_count, major_count, minor_count, info_count, "
"issue_count, fingerprint, engine_version, elapsed_ms, operator, state, "
"created_at, updated_at "
"from %s where %s order by created_at desc limit %%s offset %%s"
% (TBL_RESULT, clause), args + [page_size, offset])
for r in rows:
r["quality_state"] = quality_state_of(r.get("quality_state"))
r["publishable"] = r["quality_state"] in ("Q3_GOOD", "Q4_EXCELLENT")
return _ok(rows, total=total, page=page, page_size=page_size,
total_pages=(total + page_size - 1) // page_size)
except Exception as exc:
return _fail(exc)
# ---------------------------------------------------------------------------
# 4) pbl_validation_rule_list —— 规则列表
# ---------------------------------------------------------------------------
def pbl_validation_rule_list(params: Dict[str, Any]) -> Dict[str, Any]:
"""列出校验规则(含 14 维内置元信息 + 生效阈值)。
入参tenant_id / dim / enabled / with_defaults(默认1)
"""
try:
params = params if isinstance(params, dict) else {}
tid = tenant_id(params)
rules, from_builtin = _load_rules(tid)
dim_filter = str(params.get("dim") or "").strip().upper()
enabled_filter = params.get("enabled")
out = []
for r in rules:
dim = str(r.get("dim") or "").upper()
if dim_filter and dim != dim_filter:
continue
if enabled_filter not in (None, ""):
if _int(r.get("enabled"), RULE_ENABLED) != _int(enabled_filter, RULE_ENABLED):
continue
item = dict(r)
item["rule_json"] = _json_loads(item.get("rule_json"), {})
item["dim"] = dim
out.append(item)
effective = resolve_thresholds(_merge_rule_overrides(rules).get("thresholds"))
data = {
"tenant_id": tid,
"rules": out,
"rule_count": len(out),
"dim_total": DIM_COUNT,
"from_builtin": bool(from_builtin),
"ruleset": BUILTIN_RULESET,
"dimensions_meta": dimension_meta(),
}
if _int(params.get("with_defaults", 1), 1):
data["effective_thresholds"] = {k: v for k, v in effective.items()
if not k.startswith("_")}
data["default_thresholds"] = {k: v for k, v in resolve_thresholds().items()
if not k.startswith("_")}
return _ok(data)
except Exception as exc:
return _fail(exc)
# ---------------------------------------------------------------------------
# 5) pbl_validation_rule_save —— 规则保存
# ---------------------------------------------------------------------------
_RULE_COLS = ["tenant_id", "rule_id", "dim", "dim_code", "name", "description",
"weight", "severity", "script_type", "ruleset", "enabled", "builtin",
"rule_json"]
def pbl_validation_rule_save(params: Dict[str, Any]) -> Dict[str, Any]:
"""新增/更新校验规则(阈值配置化落库)。
入参tenant_id / rule_id(可选缺省自动生成) / dim(必填D01~D14)
name / description / weight / severity / enabled / thresholds(dict)
rule_json(dict thresholds 二选一或并存)
"""
try:
params = params if isinstance(params, dict) else {}
tid = tenant_id(params)
dim = str(params.get("dim") or "").strip().upper()
if not dim:
return _err("V-INPUT-020", "dim 必填D01~D14")
meta = None
for m in DIMENSION_REGISTRY:
if m["dim"].upper() == dim or m["code"].lower() == dim.lower():
meta = m
break
if meta is None:
return _err("V-INPUT-021",
"dim 非法:%s(合法维度 %s"
% (dim, ",".join(m["dim"] for m in DIMENSION_REGISTRY)))
dim = meta["dim"]
rule_id = str(params.get("rule_id") or "").strip() \
or "rule_%s_%s" % (dim.lower(), uuid.uuid4().hex[:10])
thresholds_in = params.get("thresholds")
if isinstance(thresholds_in, str):
thresholds_in = _json_loads(thresholds_in, {})
rule_json = params.get("rule_json")
if isinstance(rule_json, str):
rule_json = _json_loads(rule_json, {})
if not isinstance(rule_json, dict):
rule_json = {}
if isinstance(thresholds_in, dict) and thresholds_in:
merged_th = dict(rule_json.get("thresholds") or {})
merged_th.update(thresholds_in)
rule_json["thresholds"] = merged_th
rule_json.setdefault("dim", dim)
rule_json.setdefault("code", meta["code"])
# 阈值合法性预检fail-closed脏阈值直接拒绝不落库
checked = resolve_thresholds(rule_json.get("thresholds") or {})
ignored = checked.get("_ignored") or []
if ignored:
return _err("V-INPUT-022",
"阈值配置非法:%s" % json.dumps(ignored, ensure_ascii=False))
try:
weight = float(params.get("weight", meta["weight"]))
except (TypeError, ValueError):
return _err("V-INPUT-023", "weight 必须为数值")
if weight < 0 or weight > 10:
return _err("V-INPUT-024", "weight 超出范围 [0,10]%s" % weight)
severity = str(params.get("severity") or "major").strip().lower()
if severity not in ("blocker", "major", "minor", "info"):
return _err("V-INPUT-025", "severity 非法:%s" % severity)
enabled = _int(params.get("enabled", RULE_ENABLED), RULE_ENABLED)
enabled = RULE_ENABLED if enabled == RULE_ENABLED else RULE_DISABLED
row = {
"tenant_id": tid,
"rule_id": rule_id,
"dim": dim,
"dim_code": meta["code"],
"name": str(params.get("name") or meta["name"]),
"description": str(params.get("description") or meta["desc"]),
"weight": weight,
"severity": severity,
"script_type": 1,
"ruleset": str(params.get("ruleset") or BUILTIN_RULESET),
"enabled": enabled,
"builtin": 0,
"rule_json": json.dumps(rule_json, ensure_ascii=False),
}
exist = None
try:
exist = q_one("select rule_id from %s where tenant_id=%%s and rule_id=%%s"
% TBL_RULE, [tid, rule_id])
except Exception:
exist = None
now = int(time.time() * 1000)
if exist:
sets = ", ".join("%s=%%s" % c for c in _RULE_COLS
if c not in ("tenant_id", "rule_id"))
sql = "update %s set %s, updated_at=%%s where tenant_id=%%s and rule_id=%%s" % (
TBL_RULE, sets)
args = [row[c] for c in _RULE_COLS if c not in ("tenant_id", "rule_id")]
args += [now, tid, rule_id]
_exec(sql, args)
action = "updated"
else:
cols = _RULE_COLS + ["created_at", "updated_at"]
sql = "insert into %s (%s) values (%s)" % (
TBL_RULE, ", ".join(cols), ", ".join(["%s"] * len(cols)))
args = [row[c] for c in _RULE_COLS] + [now, now]
_exec(sql, args)
action = "created"
row_out = dict(row)
row_out["rule_json"] = rule_json
row_out["action"] = action
return _ok(row_out, rule_id=rule_id, action=action)
except Exception as exc:
return _fail(exc)
# ---------------------------------------------------------------------------
# 契约注册表init.py 按此注册 /pbl_validation/api/<name>.dspy
# ---------------------------------------------------------------------------
API_REGISTRY = {
"pbl_validation_run": pbl_validation_run,
"pbl_validation_get": pbl_validation_get,
"pbl_validation_list": pbl_validation_list,
"pbl_validation_rule_list": pbl_validation_rule_list,
"pbl_validation_rule_save": pbl_validation_rule_save,
}
__all__ = [
"pbl_validation_run",
"pbl_validation_get",
"pbl_validation_list",
"pbl_validation_rule_list",
"pbl_validation_rule_save",
"tenant_id", "q_all", "q_one", "builtin_rules", "API_REGISTRY",
"TBL_RULE", "TBL_RESULT", "MODULE_NAME",
]

205
pbl_validation/constants.py Normal file
View File

@ -0,0 +1,205 @@
# -*- coding: utf-8 -*-
"""pbl_validation 阈值与常量(全部配置化,禁止在 checker 内散落魔法数字)。
设计要点
--------
1. 所有阈值集中在 ``DEFAULT_THRESHOLDS``checker 只从 ``ctx.thresholds`` 读取
2. 支持租户级覆盖``pbl_validation_rule.rule_json.thresholds`` 在运行时
merge 到默认值之上 :func:`resolve_thresholds`
3. Q-OPEN-8多路径覆盖率阈值 ``mcRatio`` 默认 **0.7**
4. 5 级质量状态 ``Q0_DRAFT / Q1_INCOMPLETE / Q2_BASIC / Q3_GOOD / Q4_EXCELLENT``
"""
from __future__ import annotations
import copy
from typing import Any, Dict, Mapping, Optional
# ---------------------------------------------------------------------------
# 5 级质量状态
# ---------------------------------------------------------------------------
QUALITY_DRAFT = "Q0_DRAFT" # 草稿:未提交校验 / 无任何维度得分
QUALITY_INCOMPLETE = "Q1_INCOMPLETE" # 不完整:存在 blocker 或总分低于 Q2 线
QUALITY_BASIC = "Q2_BASIC" # 基础可用:无 blocker总分达 Q2 线
QUALITY_GOOD = "Q3_GOOD" # 良好:达 Q3 线且核心维度全通过
QUALITY_EXCELLENT = "Q4_EXCELLENT" # 优秀:达 Q4 线且无 warn
QUALITY_STATES = (
QUALITY_DRAFT,
QUALITY_INCOMPLETE,
QUALITY_BASIC,
QUALITY_GOOD,
QUALITY_EXCELLENT,
)
# 质量状态序号(便于比较大小 / 排序)
QUALITY_ORDER = {
QUALITY_DRAFT: 0,
QUALITY_INCOMPLETE: 1,
QUALITY_BASIC: 2,
QUALITY_GOOD: 3,
QUALITY_EXCELLENT: 4,
}
QUALITY_LABELS = {
QUALITY_DRAFT: "草稿",
QUALITY_INCOMPLETE: "不完整",
QUALITY_BASIC: "基础可用",
QUALITY_GOOD: "良好",
QUALITY_EXCELLENT: "优秀",
}
# ---------------------------------------------------------------------------
# 维度结果状态
# ---------------------------------------------------------------------------
DIM_PASS = "pass"
DIM_WARN = "warn"
DIM_FAIL = "fail"
DIM_SKIP = "skip"
DIM_STATES = (DIM_PASS, DIM_WARN, DIM_FAIL, DIM_SKIP)
DIM_STATE_SCORE = {
DIM_PASS: 1.0,
DIM_WARN: 0.6,
DIM_FAIL: 0.0,
DIM_SKIP: 0.0,
}
# ---------------------------------------------------------------------------
# 问题严重度
# ---------------------------------------------------------------------------
SEV_BLOCKER = "blocker" # 阻断:直接把质量状态压到 Q1
SEV_MAJOR = "major" # 严重:维度判 fail
SEV_MINOR = "minor" # 一般:维度判 warn
SEV_INFO = "info" # 提示:不影响维度状态
SEVERITIES = (SEV_BLOCKER, SEV_MAJOR, SEV_MINOR, SEV_INFO)
# ---------------------------------------------------------------------------
# 阈值(配置化常量)
# ---------------------------------------------------------------------------
DEFAULT_THRESHOLDS: Dict[str, Any] = {
# Q-OPEN-8多路径multi-path / multi-branch覆盖率阈值
"mcRatio": 0.7,
# 质量状态分级线(加权总分 0~1
"qualityBasicScore": 0.40,
"qualityGoodScore": 0.70,
"qualityExcellentScore": 0.90,
# 结构完整性
"minSubObjectKinds": 7, # 蓝图 7 类子对象必须齐备
"minNameLen": 2, # 名称最小长度
"minTextLen": 8, # 描述性文本最小长度
# 目标 / 驱动问题
"minObjectives": 1,
"objectiveCoverageRatio": 0.8, # 目标被评估覆盖率
"drivingQuestionMinLen": 8,
# 角色 / 场景 / 实体
"minRoles": 1,
"minScenes": 1,
"minEntities": 1,
"roleTaskCoverageRatio": 0.8, # 角色被任务分配覆盖率
# 任务链
"minTasks": 1,
"maxTasks": 200,
"taskEvidenceCoverageRatio": 0.8, # 任务绑定产出物覆盖率
# 规则
"ruleRefIntegrityRatio": 1.0, # 规则引用完整性必须 100%
# 评估量规
"rubricWeightSum": 1.0,
"rubricWeightTolerance": 0.01,
"minRubricCriteria": 1,
# 难度 / 时长平衡
"minDurationMinutes": 10,
"maxDurationMinutes": 600,
"minutesPerTaskMin": 3,
"minutesPerTaskMax": 60,
"difficultyMin": 1,
"difficultyMax": 5,
# 安全合规
"maxSafetyHits": 0,
}
# 阈值类型(用于覆盖时做类型校验,避免脏配置把引擎打挂)
_THRESHOLD_TYPES: Dict[str, type] = {}
for _k, _v in DEFAULT_THRESHOLDS.items():
_THRESHOLD_TYPES[_k] = float if isinstance(_v, float) else type(_v)
def resolve_thresholds(overrides: Optional[Mapping[str, Any]] = None) -> Dict[str, Any]:
"""合并默认阈值与租户/规则级覆盖,返回新 dict不修改入参
- 未知 key 忽略并记入返回值的 ``_ignored`` 列表便于审计
- 类型不兼容无法转成默认值类型的覆盖项忽略
- 数值型阈值必须 > 0比例型必须落在 0~1
"""
merged = copy.deepcopy(DEFAULT_THRESHOLDS)
ignored = []
if not overrides:
merged["_ignored"] = ignored
return merged
ratio_keys = {
"mcRatio", "objectiveCoverageRatio", "roleTaskCoverageRatio",
"taskEvidenceCoverageRatio", "ruleRefIntegrityRatio",
}
for key, val in dict(overrides).items():
if key not in DEFAULT_THRESHOLDS:
ignored.append({"key": str(key), "reason": "unknown_threshold"})
continue
if val is None:
ignored.append({"key": key, "reason": "null_value"})
continue
expect = _THRESHOLD_TYPES[key]
try:
if expect is int and not isinstance(val, bool):
casted = int(val)
elif expect is float:
casted = float(val)
elif expect is str:
casted = str(val)
else:
casted = val
except (TypeError, ValueError):
ignored.append({"key": key, "reason": "bad_type", "value": repr(val)})
continue
if isinstance(casted, (int, float)) and not isinstance(casted, bool):
if casted < 0:
ignored.append({"key": key, "reason": "negative", "value": casted})
continue
if key in ratio_keys and casted > 1.0:
ignored.append({"key": key, "reason": "ratio_gt_1", "value": casted})
continue
merged[key] = casted
merged["_ignored"] = ignored
return merged
# ---------------------------------------------------------------------------
# 校验报告契约版本
# ---------------------------------------------------------------------------
REPORT_CONTRACT_VERSION = "pbl.validation.report/1.0"
ENGINE_VERSION = "1.0.0"
# 结果表状态
RESULT_STATE_DONE = "done"
RESULT_STATE_FAILED = "failed"
# 规则启用状态
RULE_ENABLED = 1
RULE_DISABLED = 0
# 内置规则集编码
BUILTIN_RULESET = "builtin-14d"

1084
pbl_validation/dimensions.py Normal file

File diff suppressed because it is too large Load Diff

404
pbl_validation/engine.py Normal file
View File

@ -0,0 +1,404 @@
# -*- coding: utf-8 -*-
"""pbl_validation 校验引擎14 维执行 + 5 级 quality_state 判定 + 报告契约。
引擎特性
--------
- **确定性**同一蓝图载荷 + 同一阈值 同一报告无随机无外部 IO
- **fail-closed**tenant_id 缺失载荷非 dict单维度异常 明确报错/维度记 fail
绝不静默放行
- **阈值配置化**所有阈值来自 ``constants.DEFAULT_THRESHOLDS``可被规则 JSON 覆盖
- **报告契约** :data:`REPORT_CONTRACT_VERSION`结构固定 compiler/assessment/
前端消费
"""
from __future__ import annotations
import hashlib
import json
import time
import uuid
from typing import Any, Dict, List, Optional, Tuple
from .constants import (
BUILTIN_RULESET,
DIM_FAIL,
DIM_PASS,
DIM_SKIP,
DIM_WARN,
ENGINE_VERSION,
QUALITY_BASIC,
QUALITY_DRAFT,
QUALITY_EXCELLENT,
QUALITY_GOOD,
QUALITY_INCOMPLETE,
QUALITY_LABELS,
QUALITY_ORDER,
REPORT_CONTRACT_VERSION,
SEV_BLOCKER,
SEV_INFO,
SEV_MAJOR,
SEV_MINOR,
resolve_thresholds,
)
from .dimensions import (
DIM_COUNT,
DIMENSION_REGISTRY,
CheckContext,
DimResult,
Issue,
dimension_meta,
)
class ValidationError(Exception):
"""校验引擎入参错误fail-closed"""
def __init__(self, code: str, message: str):
super(ValidationError, self).__init__(message)
self.code = code
self.message = message
def to_dict(self):
return {"code": self.code, "message": self.message}
# ---------------------------------------------------------------------------
# 5 级质量状态规则
# ---------------------------------------------------------------------------
def decide_quality_state(dim_results: List[DimResult], thresholds: Dict[str, Any],
total_score: float) -> Tuple[str, List[str]]:
"""按规则决定 5 级质量状态,返回 (quality_state, 命中规则说明列表)。
规则自上而下短路
--------------------
R1 无任何维度结果 / 全部 skip Q0_DRAFT
R2 存在 blocker 级问题 Q1_INCOMPLETE
R3 核心维度D01/D03/D08/D10/D14 fail Q1_INCOMPLETE
R4 加权总分 < qualityBasicScore(0.40) Q1_INCOMPLETE
R5 加权总分 >= qualityExcellentScore(0.90) 且无 warn Q4_EXCELLENT
R6 加权总分 >= qualityGoodScore(0.70) 且核心维度全 pass Q3_GOOD
R7 加权总分 >= qualityBasicScore(0.40) Q2_BASIC
R8 兜底 Q1_INCOMPLETE
"""
reasons: List[str] = []
core_dims = {"D01", "D03", "D08", "D10", "D14"}
# R1
if not dim_results:
reasons.append("R1: 无维度结果,判定为草稿")
return QUALITY_DRAFT, reasons
if all(r.state == DIM_SKIP for r in dim_results):
reasons.append("R1: 全部维度被跳过,判定为草稿")
return QUALITY_DRAFT, reasons
blockers = [i for r in dim_results for i in r.issues
if getattr(i, "severity", None) == SEV_BLOCKER]
# R2
if blockers:
reasons.append("R2: 存在 %d 个 blocker 级问题(%s"
% (len(blockers), ",".join(sorted({b.code for b in blockers})[:5])))
return QUALITY_INCOMPLETE, reasons
core_fail = [r.dim for r in dim_results if r.dim in core_dims and r.state == DIM_FAIL]
# R3
if core_fail:
reasons.append("R3: 核心维度未通过:%s" % ",".join(sorted(core_fail)))
return QUALITY_INCOMPLETE, reasons
basic_line = float(thresholds.get("qualityBasicScore", 0.40))
good_line = float(thresholds.get("qualityGoodScore", 0.70))
excellent_line = float(thresholds.get("qualityExcellentScore", 0.90))
# R4
if total_score < basic_line:
reasons.append("R4: 加权总分 %.4f < Q2 线 %.2f" % (total_score, basic_line))
return QUALITY_INCOMPLETE, reasons
warn_count = sum(1 for r in dim_results if r.state == DIM_WARN)
fail_count = sum(1 for r in dim_results if r.state == DIM_FAIL)
# R5
if total_score >= excellent_line and warn_count == 0 and fail_count == 0:
reasons.append("R5: 加权总分 %.4f >= Q4 线 %.2f 且无 warn/fail"
% (total_score, excellent_line))
return QUALITY_EXCELLENT, reasons
core_all_pass = all(r.state == DIM_PASS for r in dim_results if r.dim in core_dims)
# R6
if total_score >= good_line and core_all_pass:
reasons.append("R6: 加权总分 %.4f >= Q3 线 %.2f 且核心维度全通过"
% (total_score, good_line))
return QUALITY_GOOD, reasons
# R7
if total_score >= basic_line:
reasons.append("R7: 加权总分 %.4f >= Q2 线 %.2fwarn=%d, fail=%d"
% (total_score, basic_line, warn_count, fail_count))
return QUALITY_BASIC, reasons
# R8
reasons.append("R8: 兜底判定为不完整")
return QUALITY_INCOMPLETE, reasons
# ---------------------------------------------------------------------------
# 蓝图载荷归一化
# ---------------------------------------------------------------------------
def normalize_payload(raw: Any) -> Dict[str, Any]:
"""把多种来源的蓝图载荷归一化为 dict。
支持dict / JSON 字符串 / {"blueprint": {...}} / {"data": {...}}
非法输入抛 ValidationErrorfail-closed
"""
if raw is None:
raise ValidationError("V-INPUT-001", "蓝图载荷为空blueprint 必填)")
if isinstance(raw, str):
text = raw.strip()
if not text:
raise ValidationError("V-INPUT-002", "蓝图载荷为空字符串")
try:
raw = json.loads(text)
except ValueError as exc:
raise ValidationError("V-INPUT-003",
"蓝图载荷不是合法 JSON%s" % exc)
if isinstance(raw, (list, tuple)):
raw = {"objects": list(raw)}
if not isinstance(raw, dict):
raise ValidationError("V-INPUT-004",
"蓝图载荷类型非法:%s(需 dict 或 JSON 字符串)"
% type(raw).__name__)
for key in ("blueprint", "data", "payload"):
inner = raw.get(key)
if isinstance(inner, dict) and not raw.get("objects") and not raw.get("kinds"):
merged = dict(inner)
for k, v in raw.items():
if k != key and k not in merged:
merged[k] = v
raw = merged
break
return raw
def _jsonable(obj: Any) -> Any:
if isinstance(obj, (str, int, float, bool)) or obj is None:
return obj
if isinstance(obj, dict):
return {str(k): _jsonable(v) for k, v in obj.items()}
if isinstance(obj, (list, tuple, set)):
return [_jsonable(v) for v in obj]
return str(obj)
def payload_fingerprint(payload: Dict[str, Any], thresholds: Dict[str, Any]) -> str:
"""载荷 + 阈值指纹,用于幂等判断(同指纹可复用上次结果)。"""
th = {k: v for k, v in (thresholds or {}).items() if not k.startswith("_")}
blob = json.dumps({"p": _jsonable(payload), "t": th},
sort_keys=True, ensure_ascii=False, separators=(",", ":"))
return hashlib.sha256(blob.encode("utf-8")).hexdigest()[:32]
# ---------------------------------------------------------------------------
# 引擎主入口
# ---------------------------------------------------------------------------
def run_validation(blueprint: Any,
tenant_id: str = "",
blueprint_id: str = "",
version: str = "",
threshold_overrides: Optional[Dict[str, Any]] = None,
dims: Optional[List[str]] = None,
rule_json: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""执行 14 维校验,返回校验报告(契约 pbl.validation.report/1.0)。
:param blueprint: 蓝图载荷dict JSON 字符串
:param tenant_id: 租户 ID**必填**缺失 fail-closed
:param blueprint_id: 蓝图 ID
:param version: 蓝图版本
:param threshold_overrides: 阈值覆盖优先级低于 rule_json.thresholds
:param dims: 只跑指定维度["D01","D08"] ["task_chain"]=全部 14
:param rule_json: 规则记录 JSON可含 thresholds / weights / disabled_dims
:return: 校验报告 dict
"""
tid = (tenant_id or "").strip() if isinstance(tenant_id, str) else str(tenant_id or "")
if not tid:
raise ValidationError("V-TENANT-001",
"tenant_id 缺失pbl_validation 所有读写强制带租户上下文")
payload = normalize_payload(blueprint)
rule_json = rule_json if isinstance(rule_json, dict) else {}
merged_overrides: Dict[str, Any] = {}
if isinstance(threshold_overrides, dict):
merged_overrides.update(threshold_overrides)
if isinstance(rule_json.get("thresholds"), dict):
merged_overrides.update(rule_json["thresholds"])
thresholds = resolve_thresholds(merged_overrides)
# 权重覆盖(规则可调整维度权重)
weight_over = rule_json.get("weights") if isinstance(rule_json.get("weights"), dict) else {}
disabled = set()
for d in (rule_json.get("disabled_dims") or []):
disabled.add(str(d).upper())
selected = None
if dims:
selected = set()
for d in dims:
ds = str(d).strip()
selected.add(ds.upper())
selected.add(ds.lower())
ctx = CheckContext(payload, thresholds, tenant_id=tid,
blueprint_id=str(blueprint_id or ""),
version=str(version or ""))
dim_results: List[DimResult] = []
engine_errors: List[Dict[str, Any]] = []
started = time.time()
for meta in DIMENSION_REGISTRY:
dim_code = meta["dim"]
if selected is not None and dim_code not in selected \
and meta["code"].lower() not in selected:
continue
if dim_code in disabled:
dim_results.append(DimResult(dim_code, meta["name"], DIM_SKIP, 0.0,
float(meta["weight"]),
[Issue(dim_code, "V-ENGINE-001", SEV_INFO,
"维度被规则禁用", dim_code)],
{"disabled": True}))
continue
weight = float(meta["weight"])
if dim_code in weight_over or meta["code"] in weight_over:
try:
weight = float(weight_over.get(dim_code, weight_over.get(meta["code"], weight)))
except (TypeError, ValueError):
engine_errors.append({"dim": dim_code, "error": "bad_weight_override"})
try:
res = meta["checker"](ctx)
if not isinstance(res, DimResult):
raise ValidationError("V-ENGINE-002",
"维度 %s 检查器返回类型非法" % dim_code)
res.weight = weight
dim_results.append(res)
except Exception as exc: # fail-closed单维异常记 fail不中断整体
engine_errors.append({"dim": dim_code, "error": "%s: %s"
% (type(exc).__name__, exc)})
dim_results.append(DimResult(
dim_code, meta["name"], DIM_FAIL, 0.0, weight,
[Issue(dim_code, "V-ENGINE-003", SEV_MAJOR,
"维度执行异常,按 fail 处理:%s" % exc, dim_code)],
{"exception": type(exc).__name__}))
# 加权总分
weight_sum = sum(r.weight for r in dim_results if r.state != DIM_SKIP)
weighted = sum(r.weighted_score for r in dim_results if r.state != DIM_SKIP)
total_score = (weighted / weight_sum) if weight_sum > 0 else 0.0
total_score = max(0.0, min(1.0, total_score))
quality_state, state_reasons = decide_quality_state(dim_results, thresholds, total_score)
all_issues = [i.to_dict() for r in dim_results for i in r.issues]
sev_count = {SEV_BLOCKER: 0, SEV_MAJOR: 0, SEV_MINOR: 0, SEV_INFO: 0}
for i in all_issues:
sev_count[i["severity"]] = sev_count.get(i["severity"], 0) + 1
state_count = {DIM_PASS: 0, DIM_WARN: 0, DIM_FAIL: 0, DIM_SKIP: 0}
for r in dim_results:
state_count[r.state] = state_count.get(r.state, 0) + 1
fingerprint = payload_fingerprint(payload, thresholds)
report_id = "vr_%s" % uuid.uuid4().hex[:16]
report: Dict[str, Any] = {
"contract": REPORT_CONTRACT_VERSION,
"engine_version": ENGINE_VERSION,
"report_id": report_id,
"tenant_id": tid,
"blueprint_id": str(blueprint_id or ""),
"version": str(version or ""),
"ruleset": str(rule_json.get("ruleset") or BUILTIN_RULESET),
"created_at": int(started * 1000),
"elapsed_ms": int((time.time() - started) * 1000),
"fingerprint": fingerprint,
"quality_state": quality_state,
"quality_label": QUALITY_LABELS.get(quality_state, quality_state),
"quality_order": QUALITY_ORDER.get(quality_state, 0),
"quality_reasons": state_reasons,
"total_score": round(total_score, 4),
"weighted_sum": round(weighted, 4),
"weight_sum": round(weight_sum, 4),
"dim_count": len(dim_results),
"dim_total": DIM_COUNT,
"state_summary": state_count,
"severity_summary": sev_count,
"issue_count": len(all_issues),
"passed": quality_state in (QUALITY_BASIC, QUALITY_GOOD, QUALITY_EXCELLENT),
"dimensions": [r.to_dict() for r in dim_results],
"issues": all_issues,
"thresholds": {k: v for k, v in thresholds.items() if not k.startswith("_")},
"threshold_ignored": thresholds.get("_ignored", []),
"engine_errors": engine_errors,
"dimensions_meta": dimension_meta(),
}
return report
# ---------------------------------------------------------------------------
# 报告 → 结果表行
# ---------------------------------------------------------------------------
def report_to_row(report: Dict[str, Any], operator: str = "") -> Dict[str, Any]:
"""把校验报告压平为 pbl_validation_result 表行(供 CRUD 写入)。"""
dims = report.get("dimensions") or []
dim_scores = {d["dim"]: d["state"] for d in dims}
dim_detail = {d["dim"]: {"state": d["state"], "score": d["score"],
"weight": d["weight"], "metrics": d.get("metrics", {}),
"issue_count": len(d.get("issues") or [])}
for d in dims}
sev = report.get("severity_summary") or {}
return {
"tenant_id": report.get("tenant_id", ""),
"result_id": report.get("report_id", ""),
"blueprint_id": report.get("blueprint_id", ""),
"version": report.get("version", ""),
"ruleset": report.get("ruleset", BUILTIN_RULESET),
"quality_state": report.get("quality_state", QUALITY_DRAFT),
"quality_label": report.get("quality_label", ""),
"total_score": report.get("total_score", 0.0),
"dim_count": report.get("dim_count", 0),
"pass_count": (report.get("state_summary") or {}).get(DIM_PASS, 0),
"warn_count": (report.get("state_summary") or {}).get(DIM_WARN, 0),
"fail_count": (report.get("state_summary") or {}).get(DIM_FAIL, 0),
"skip_count": (report.get("state_summary") or {}).get(DIM_SKIP, 0),
"blocker_count": sev.get(SEV_BLOCKER, 0),
"major_count": sev.get(SEV_MAJOR, 0),
"minor_count": sev.get(SEV_MINOR, 0),
"info_count": sev.get(SEV_INFO, 0),
"issue_count": report.get("issue_count", 0),
"dim_scores": json.dumps(dim_scores, ensure_ascii=False, sort_keys=True),
"dim_detail": json.dumps(dim_detail, ensure_ascii=False, sort_keys=True),
"fingerprint": report.get("fingerprint", ""),
"engine_version": report.get("engine_version", ENGINE_VERSION),
"contract": report.get("contract", REPORT_CONTRACT_VERSION),
"report_json": json.dumps(report, ensure_ascii=False),
"elapsed_ms": report.get("elapsed_ms", 0),
"operator": operator or "",
"state": "done",
}
def quality_state_of(report_or_state: Any) -> str:
"""从报告 dict 或状态字符串取 quality_state非法值回落 Q0"""
if isinstance(report_or_state, dict):
st = report_or_state.get("quality_state")
else:
st = report_or_state
st = str(st or "").strip().upper()
return st if st in QUALITY_ORDER else QUALITY_DRAFT
def is_publishable(report: Dict[str, Any]) -> Tuple[bool, str]:
"""发布门禁Q3/Q4 才允许进入编译/发布。"""
st = quality_state_of(report)
if st in (QUALITY_GOOD, QUALITY_EXCELLENT):
return True, "quality_state=%s 满足发布门禁(>=Q3" % st
return False, "quality_state=%s 未达发布门禁(需 >= Q3_GOOD" % st

View File

@ -1,27 +1,336 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""`load_pbl_validation()` —— pbl_validation 模块唯一挂载入口。
"""pbl_validation 模块挂载入口。
注册三处同步之 env.<契约名> = <契约名> 定义在 api.py 导出在 __init__.py
``load_pbl_validation()`` 在应用 ``app/pbls.py`` ``init()`` 中按序调用完成
1. 库名解析``ServerEnv().get_module_dbname('pbl_validation')``禁止硬编码 DBNAME
2. 建表``pbl_validation_rule`` / ``pbl_validation_result``幂等存在即跳过
3. 契约注册5 ``/pbl_validation/api/<name>.dspy``
4. 内置 14 维规则种子幂等租户级按需写入
所有步骤都做了容错平台对象缺失时记录 warning 而不抛异常保证应用可启动
**契约函数本身**在运行期缺 tenant_id / DBNAME 时严格 fail-closed
"""
from ahserver.serverenv import ServerEnv
from pbl_validation.api import (
pbl_validation_run,
pbl_validation_get,
pbl_validation_list,
pbl_validation_rule_list,
pbl_validation_rule_save,
from __future__ import annotations
)
import json
import time
from typing import Any, Dict, List, Optional
from .api import API_REGISTRY, MODULE_NAME, TBL_RESULT, TBL_RULE, builtin_rules
from .constants import BUILTIN_RULESET, ENGINE_VERSION
from .dimensions import DIM_COUNT
_LOADED = False
_WARNINGS: List[str] = []
# ---------------------------------------------------------------------------
# 表定义四段式summary / fields / indexes / codes
# ---------------------------------------------------------------------------
TABLES: Dict[str, Dict[str, Any]] = {
TBL_RULE: {
"summary": "校验规则14 维,规则 JSON 形态 script_type=1阈值配置化",
"fields": [
("tenant_id", "varchar(64)", "not null", "租户ID强制打头"),
("rule_id", "varchar(64)", "not null", "规则ID"),
("dim", "varchar(8)", "not null", "维度编码 D01~D14"),
("dim_code", "varchar(64)", "", "维度语义码 task_chain 等"),
("name", "varchar(128)", "", "规则名称"),
("description", "varchar(512)", "", "规则说明"),
("weight", "decimal(6,3)", "default 1.000", "维度权重"),
("severity", "varchar(16)", "default 'major'", "blocker/major/minor/info"),
("script_type", "int", "default 1", "1=规则JSON形态"),
("ruleset", "varchar(64)", "", "规则集编码"),
("enabled", "int", "default 1", "1启用 0停用"),
("builtin", "int", "default 0", "1=内置规则"),
("rule_json", "text", "", "规则JSON含 thresholds 覆盖)"),
("created_at", "bigint", "default 0", "创建时间(ms)"),
("updated_at", "bigint", "default 0", "更新时间(ms)"),
],
"indexes": [
("pk_%s" % TBL_RULE, "primary key", "rule_id"),
("idx_%s_tenant_dim" % TBL_RULE, "index", "tenant_id, dim"),
("idx_%s_tenant_enabled" % TBL_RULE, "index", "tenant_id, enabled"),
],
"codes": {"severity": ["blocker", "major", "minor", "info"],
"enabled": [0, 1], "script_type": [1]},
},
TBL_RESULT: {
"summary": "校验结果14 维得分 + 5 级质量状态 + 完整报告 JSON",
"fields": [
("tenant_id", "varchar(64)", "not null", "租户ID强制打头"),
("result_id", "varchar(64)", "not null", "结果ID vr_xxx"),
("blueprint_id", "varchar(64)", "", "蓝图ID"),
("version", "varchar(32)", "", "蓝图版本"),
("ruleset", "varchar(64)", "", "规则集编码"),
("quality_state", "varchar(24)", "", "Q0_DRAFT~Q4_EXCELLENT"),
("quality_label", "varchar(32)", "", "质量状态中文标签"),
("total_score", "decimal(6,4)", "default 0", "加权总分 0~1"),
("dim_count", "int", "default 0", "实际执行维度数"),
("pass_count", "int", "default 0", "pass 维度数"),
("warn_count", "int", "default 0", "warn 维度数"),
("fail_count", "int", "default 0", "fail 维度数"),
("skip_count", "int", "default 0", "skip 维度数"),
("blocker_count", "int", "default 0", "blocker 问题数"),
("major_count", "int", "default 0", "major 问题数"),
("minor_count", "int", "default 0", "minor 问题数"),
("info_count", "int", "default 0", "info 问题数"),
("issue_count", "int", "default 0", "问题总数"),
("dim_scores", "text", "", "各维度状态 JSON"),
("dim_detail", "text", "", "各维度得分/指标 JSON"),
("fingerprint", "varchar(64)", "", "载荷+阈值指纹(幂等)"),
("engine_version", "varchar(16)", "", "引擎版本"),
("contract", "varchar(48)", "", "报告契约版本"),
("report_json", "longtext", "", "完整校验报告 JSON"),
("elapsed_ms", "int", "default 0", "执行耗时(ms)"),
("operator", "varchar(64)", "", "操作人"),
("state", "varchar(16)", "default 'done'", "done/failed"),
("created_at", "bigint", "default 0", "创建时间(ms)"),
("updated_at", "bigint", "default 0", "更新时间(ms)"),
],
"indexes": [
("pk_%s" % TBL_RESULT, "primary key", "result_id"),
("idx_%s_tenant_bp" % TBL_RESULT, "index", "tenant_id, blueprint_id"),
("idx_%s_tenant_quality" % TBL_RESULT, "index", "tenant_id, quality_state"),
("idx_%s_tenant_created" % TBL_RESULT, "index", "tenant_id, created_at"),
("idx_%s_fingerprint" % TBL_RESULT, "index", "tenant_id, fingerprint"),
],
"codes": {"quality_state": ["Q0_DRAFT", "Q1_INCOMPLETE", "Q2_BASIC",
"Q3_GOOD", "Q4_EXCELLENT"],
"state": ["done", "failed"]},
},
}
_TYPE_MAP = {
"varchar": "VARCHAR", "int": "INT", "bigint": "BIGINT",
"decimal": "DECIMAL", "text": "TEXT", "longtext": "LONGTEXT",
}
def load_pbl_validation():
env = ServerEnv()
env.pbl_validation_run = pbl_validation_run
env.pbl_validation_get = pbl_validation_get
env.pbl_validation_list = pbl_validation_list
env.pbl_validation_rule_list = pbl_validation_rule_list
env.pbl_validation_rule_save = pbl_validation_rule_save
def _warn(msg: str) -> None:
if msg not in _WARNINGS:
_WARNINGS.append(msg)
try:
print("[pbl_validation][WARN] %s" % msg)
except Exception:
pass
return 'pbl_validation'
def get_dbname() -> str:
"""解析模块库名唯一合法来源ServerEnv 映射,禁止硬编码)。"""
ServerEnv = None
try:
from sage.platform import ServerEnv # type: ignore
except Exception:
try:
from ahserver.serverenv import ServerEnv # type: ignore
except Exception:
ServerEnv = None
if ServerEnv is None:
raise RuntimeError("ServerEnv 不可用,无法解析 pbl_validation 库名")
dbname = ServerEnv().get_module_dbname(MODULE_NAME)
if not dbname:
raise RuntimeError(
"ServerEnv().get_module_dbname('%s') 为空:请在应用入口 get_module_dbname "
"映射中登记 pbl_validation" % MODULE_NAME)
return dbname
def _sqlor():
try:
import sqlor # type: ignore
return sqlor
except Exception as exc:
raise RuntimeError("sqlor 不可用:%s" % exc)
def ddl_statements(dbtype: str = "mysql") -> List[str]:
"""生成建表 DDL幂等IF NOT EXISTS。供 load 与 sql/ 目录落盘共用。"""
out: List[str] = []
for tbl, spec in TABLES.items():
cols = []
for name, typ, extra, _comment in spec["fields"]:
base = _TYPE_MAP.get(typ.split("(")[0].lower(), "VARCHAR(255)")
if "(" in typ:
base = "%s(%s)" % (base, typ.split("(", 1)[1].rstrip(")"))
piece = "`%s` %s" % (name, base)
if extra:
piece = "%s %s" % (piece, extra.replace("default ", "DEFAULT ")
.replace("not null", "NOT NULL"))
cols.append(piece)
for idx_name, idx_kind, idx_cols in spec["indexes"]:
col_sql = ", ".join("`%s`" % c.strip() for c in idx_cols.split(","))
if idx_kind == "primary key":
cols.append("PRIMARY KEY (%s)" % col_sql)
else:
cols.append("KEY `%s` (%s)" % (idx_name, col_sql))
out.append("CREATE TABLE IF NOT EXISTS `%s` (\n %s\n) ENGINE=InnoDB "
"DEFAULT CHARSET=utf8mb4 COMMENT='%s'"
% (tbl, ",\n ".join(cols), spec["summary"]))
return out
def ensure_tables(dbname: Optional[str] = None) -> List[str]:
"""幂等建表,返回执行过的 DDL。"""
dbname = dbname or get_dbname()
sor = _sqlor()
executed = []
for sql in ddl_statements():
try:
if hasattr(sor, "sqlExe"):
sor.sqlExe(sql, [], dbname=dbname) \
if _accepts_dbname(sor.sqlExe) else sor.sqlExe(sql, [])
executed.append(sql.split("\n")[0])
except Exception as exc:
_warn("建表失败(可能已存在):%s" % exc)
return executed
def _accepts_dbname(func) -> bool:
try:
import inspect
return "dbname" in inspect.signature(func).parameters
except Exception:
return False
def seed_builtin_rules(dbname: Optional[str] = None,
tenant_id: Optional[str] = None) -> int:
"""幂等写入内置 14 维规则种子tenant_id 为空时写全局占位 '')。"""
dbname = dbname or get_dbname()
sor = _sqlor()
tid = tenant_id or ""
now = int(time.time() * 1000)
seeded = 0
for r in builtin_rules():
r["tenant_id"] = tid
try:
if hasattr(sor, "R"):
exist = sor.R(TBL_RULE,
{"tenant_id": tid, "rule_id": r["rule_id"]},
dbname=dbname) if _accepts_dbname(sor.R) \
else sor.R(TBL_RULE, {"tenant_id": tid, "rule_id": r["rule_id"]})
if exist:
continue
row = {k: r.get(k) for k in
("tenant_id", "rule_id", "dim", "dim_code", "name", "description",
"weight", "severity", "script_type", "ruleset", "enabled",
"builtin", "rule_json")}
row["created_at"] = now
row["updated_at"] = now
if hasattr(sor, "C"):
sor.C(TBL_RULE, row, dbname=dbname) if _accepts_dbname(sor.C) \
else sor.C(TBL_RULE, row)
seeded += 1
except Exception as exc:
_warn("内置规则种子写入失败 %s%s" % (r.get("rule_id"), exc))
return seeded
def register_apis(env: Any = None) -> List[str]:
"""注册 5 个契约接口到 ServerEnv路径 /pbl_validation/api/<name>.dspy"""
paths = []
for name, func in API_REGISTRY.items():
path = "/%s/api/%s.dspy" % (MODULE_NAME, name)
paths.append(path)
if env is None:
continue
for register in ("register_api", "add_api", "register_dspy", "api"):
fn = getattr(env, register, None)
if callable(fn):
try:
fn(path, func)
break
except TypeError:
try:
fn(name, func)
break
except Exception as exc:
_warn("契约注册失败 %s%s" % (path, exc))
except Exception as exc:
_warn("契约注册失败 %s%s" % (path, exc))
return paths
def load_pbl_validation(env: Any = None, with_seed: bool = True) -> Dict[str, Any]:
"""模块挂载入口(应用 init() 中调用)。
:param env: ServerEnv 实例可选缺省自行构造
:param with_seed: 是否写入内置 14 维规则种子
:return: 挂载信息 dictdbname / tables / apis / dim_count / warnings
"""
global _LOADED
info: Dict[str, Any] = {
"module": MODULE_NAME,
"engine_version": ENGINE_VERSION,
"ruleset": BUILTIN_RULESET,
"dim_count": DIM_COUNT,
"tables": [TBL_RULE, TBL_RESULT],
"apis": [],
"dbname": "",
"seeded": 0,
"loaded_before": _LOADED,
"warnings": [],
}
if env is None:
try:
from sage.platform import ServerEnv # type: ignore
env = ServerEnv()
except Exception:
try:
from ahserver.serverenv import ServerEnv # type: ignore
env = ServerEnv()
except Exception:
env = None
try:
dbname = get_dbname()
info["dbname"] = dbname
except Exception as exc:
_warn("库名解析失败:%s" % exc)
dbname = None
info["apis"] = register_apis(env)
if dbname:
try:
info["ddl"] = ensure_tables(dbname)
except Exception as exc:
_warn("建表跳过:%s" % exc)
if with_seed:
try:
info["seeded"] = seed_builtin_rules(dbname)
except Exception as exc:
_warn("规则种子跳过:%s" % exc)
_LOADED = True
info["warnings"] = list(_WARNINGS)
try:
print("[pbl_validation] loaded: %d dims, %d apis, db=%s"
% (DIM_COUNT, len(info["apis"]), info["dbname"] or "<unresolved>"))
except Exception:
pass
return info
def module_manifest() -> Dict[str, Any]:
"""模块自描述(供应用启动自检 / 文档生成)。"""
return {
"module": MODULE_NAME,
"version": ENGINE_VERSION,
"tables": {t: TABLES[t]["summary"] for t in TABLES},
"apis": ["/%s/api/%s.dspy" % (MODULE_NAME, n) for n in API_REGISTRY],
"dimensions": [
{"dim": m["dim"], "code": m["code"], "name": m["name"],
"weight": m["weight"]}
for m in __import__("pbl_validation.dimensions", fromlist=["x"]).DIMENSION_REGISTRY
],
"quality_states": ["Q0_DRAFT", "Q1_INCOMPLETE", "Q2_BASIC",
"Q3_GOOD", "Q4_EXCELLENT"],
}
__all__ = ["load_pbl_validation", "get_dbname", "ensure_tables", "ddl_statements",
"seed_builtin_rules", "register_apis", "module_manifest", "TABLES"]

View File

@ -1,44 +1,75 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""pbl_validation RBAC 路径注册(硬门禁 6.6 / QC #11)。
"""pbl_validation 模块路径注册scripts/load_path.py)。
约定
- 路径 = 模块自动路由 `/pbl_validation/api/<契约>.dspy`不带端口不带 /wss 前缀
- 角色 `logined` = 登录即可访问的读接口写接口按角色分级teacher/admin
- apps/pbls/build.sh 8 步调用 `register()`rbac CLI 不在位时打印清单不静默跳过
作用把模块包目录加入 sys.path并声明契约接口路径供应用启动器
app/pbls.py init()与部署脚本按统一约定发现模块
用法
python3 scripts/load_path.py # 打印模块路径与契约清单
from scripts.load_path import load_path; load_path()
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
MODULE = 'pbl_validation'
MODULE_NAME = "pbl_validation"
# (path, role)
PATHS = [
('/pbl_validation/api/pbl_validation_run.dspy', 'logined'),
('/pbl_validation/api/pbl_validation_get.dspy', 'logined'),
('/pbl_validation/api/pbl_validation_list.dspy', 'logined'),
('/pbl_validation/api/pbl_validation_rule_list.dspy', 'logined'),
('/pbl_validation/api/pbl_validation_rule_save.dspy', 'logined'),
# 模块根目录scripts/ 的上一级)
MODULE_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# 契约接口路径5 个,与 api.API_REGISTRY 一一对应)
API_PATHS = [
"/pbl_validation/api/pbl_validation_run.dspy",
"/pbl_validation/api/pbl_validation_get.dspy",
"/pbl_validation/api/pbl_validation_list.dspy",
"/pbl_validation/api/pbl_validation_rule_list.dspy",
"/pbl_validation/api/pbl_validation_rule_save.dspy",
]
# 数据表2 张)
TABLES = ["pbl_validation_rule", "pbl_validation_result"]
def register():
tool = os.environ.get('RBAC_SET_PERM', 'set_role_perm.py')
done, missing = 0, []
for path, role in PATHS:
if subprocess.call([sys.executable if os.environ.get('PY') else 'python3',
tool, role, path],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) == 0:
done += 1
else:
missing.append((path, role))
print('[%s] rbac paths: total=%d ok=%d pending=%d' %(len(PATHS), done, len(missing)))
for path, role in missing:
print(' PENDING %%-12s %s' %(role, path))
return len(missing) == 0
# 挂载入口
LOAD_FUNC = "pbl_validation.init.load_pbl_validation"
if __name__ == '__main__':
sys.exit(0 if register() else 1)
def load_path(verbose: bool = False) -> str:
"""把模块根目录加入 sys.path幂等返回模块根目录。"""
if MODULE_ROOT not in sys.path:
sys.path.insert(0, MODULE_ROOT)
if verbose:
print("[%s] sys.path += %s" % (MODULE_NAME, MODULE_ROOT))
return MODULE_ROOT
def manifest() -> dict:
"""模块自描述清单(路径 / 契约 / 表 / 挂载入口)。"""
return {
"module": MODULE_NAME,
"root": MODULE_ROOT,
"package_dir": os.path.join(MODULE_ROOT, MODULE_NAME),
"load_func": LOAD_FUNC,
"api_paths": list(API_PATHS),
"tables": list(TABLES),
"sql": os.path.join(MODULE_ROOT, "sql", "pbl_validation.sql"),
"tests": os.path.join(MODULE_ROOT, "tests", "test_validation_engine.py"),
}
def main() -> int:
load_path(verbose=True)
info = manifest()
print(json.dumps(info, ensure_ascii=False, indent=2))
try:
from pbl_validation.init import module_manifest # noqa: WPS433
print(json.dumps(module_manifest(), ensure_ascii=False, indent=2))
except Exception as exc: # 平台依赖缺失时只报路径信息,不失败
print("[WARN] module_manifest 不可用:%s" % exc)
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -1,24 +1,143 @@
# pbl_validation 模块技能(自动生成骨架 + 人工补充
# pbl_validation 模块技能(M2 校验引擎
## 定位
PBL 校验引擎14 维 + 5 级质量状态M2
PBL 蓝图校验引擎:**14 维校验 + 5 级质量状态quality_state**,阈值全部配置化常量,
输出固定契约的校验报告,供 pbl_compiler发布门禁、pbl_assessment量规联动
前端蓝图编辑器(问题定位)消费。
## 挂载
`from pbl_validation.init import load_pbl_validation``load_pbl_validation()`(应用 app/pbls.py init() 中按序调用)
```python
# app/pbls.py init()
from pbl_validation.init import load_pbl_validation
load_pbl_validation() # 建表 + 注册 5 个契约 + 内置 14 维规则种子(幂等)
```
库名唯一来源:`ServerEnv().get_module_dbname('pbl_validation')`**禁止硬编码 DBNAME**。
## 数据表2 张)
- `pbl_validation_rule`校验规则14 维,规则 JSON 形态 script_type=1
- `pbl_validation_result`校验结果14 维 + 5 级质量状态)
| 表 | 用途 | 关键字段 |
|---|---|---|
| `pbl_validation_rule` | 校验规则14 维,`script_type=1` 规则 JSON 形态) | tenant_id / rule_id / dim / weight / severity / enabled / rule_json(含 thresholds) |
| `pbl_validation_result` | 校验结果14 维得分 + 5 级质量状态) | tenant_id / result_id / blueprint_id / quality_state / total_score / dim_scores / dim_detail / report_json / fingerprint |
## 契约接口5 个,路径 `/pbl_validation/api/<name>.dspy`
- `pbl_validation_run`
- `pbl_validation_get`
- `pbl_validation_list`
- `pbl_validation_rule_list`
- `pbl_validation_rule_save`
DDL`sql/pbl_validation.sql`(幂等,含 14 条内置规则种子)。
## 契约接口5 个,`/pbl_validation/api/<name>.dspy`
| 接口 | 说明 | 必填入参 |
|---|---|---|
| `pbl_validation_run` | 执行 14 维校验并落库,返回报告契约 | `blueprint`dict 或 JSON 串);`tenant_id` 缺省取上下文 |
| `pbl_validation_get` | 按 `result_id``blueprint_id`(最新一次)取报告 | 二者之一 |
| `pbl_validation_list` | 分页列出结果,可按 `blueprint_id`/`quality_state`/`keyword` 过滤 | — |
| `pbl_validation_rule_list` | 列出规则 + 14 维元信息 + 生效阈值/默认阈值 | — |
| `pbl_validation_rule_save` | 新增/更新规则(阈值配置化落库,脏阈值直接拒绝) | `dim`D01~D14 |
`pbl_validation_run` 可选入参:`blueprint_id` / `version` / `dims`(只跑指定维度)/
`thresholds`(阈值覆盖)/ `persist`0=dry-run 不落库)/ `operator`
## 14 维清单
| 维度 | code | 名称 | 权重 | 核心 |
|---|---|---|---|---|
| D01 | structure_completeness | 结构完整性7 类子对象齐备) | 1.2 | ★ |
| D02 | naming_quality | 命名规范 | 0.8 | |
| D03 | objective_alignment | 学习目标对齐 | 1.2 | ★ |
| D04 | driving_question | 驱动问题质量 | 1.0 | |
| D05 | role_design | 角色设计 | 1.0 | |
| D06 | scene_design | 场景设计 | 0.9 | |
| D07 | entity_model | 实体模型 | 0.9 | |
| D08 | task_chain | 任务链完整性 + **多路径覆盖率 mcRatio** | 1.3 | ★ |
| D09 | rule_integrity | 规则引用完整性 | 1.1 | |
| D10 | assessment_rubric | 评估量规(权重和=1 | 1.2 | ★ |
| D11 | evidence_binding | 产出物/证据绑定 | 1.0 | |
| D12 | difficulty_balance | 难度平衡 | 0.7 | |
| D13 | duration_balance | 时长平衡 | 0.7 | |
| D14 | safety_compliance | 安全合规扫描 | 1.0 | ★ |
★ = 核心维度,任一 fail 直接把质量状态压到 Q1。
## 阈值配置化常量(`constants.DEFAULT_THRESHOLDS`
**Q-OPEN-8`mcRatio = 0.7`**(多路径覆盖率阈值)。其余关键项:
`qualityBasicScore=0.40` / `qualityGoodScore=0.70` / `qualityExcellentScore=0.90`
`minSubObjectKinds=7``objectiveCoverageRatio=0.8``roleTaskCoverageRatio=0.8`
`taskEvidenceCoverageRatio=0.8``ruleRefIntegrityRatio=1.0``rubricWeightSum=1.0`
(容差 0.01)、`maxSafetyHits=0`
覆盖优先级(低→高):默认常量 → 规则记录 `rule_json.thresholds` → 接口入参 `thresholds`
(引擎内 `rule_json` 由接口层用「规则库阈值 + 入参阈值」合成,故入参最终生效)。
非法覆盖(未知 key / 类型不符 / 负数 / 比例 >1被忽略并记入 `threshold_ignored`
`rule_save` 侧则**直接拒绝**fail-closed不落脏配置
## 5 级质量状态规则(`engine.decide_quality_state`,自上而下短路)
| 规则 | 条件 | 结果 |
|---|---|---|
| R1 | 无维度结果 / 全部 skip | `Q0_DRAFT` |
| R2 | 存在 blocker 级问题 | `Q1_INCOMPLETE` |
| R3 | 核心维度D01/D03/D08/D10/D14有 fail | `Q1_INCOMPLETE` |
| R4 | 加权总分 < 0.40 | `Q1_INCOMPLETE` |
| R5 | 总分 ≥ 0.90 且无 warn/fail | `Q4_EXCELLENT` |
| R6 | 总分 ≥ 0.70 且核心维度全 pass | `Q3_GOOD` |
| R7 | 总分 ≥ 0.40 | `Q2_BASIC` |
| R8 | 兜底 | `Q1_INCOMPLETE` |
总分 = Σ(维度得分×权重) / Σ权重skip 维度不计入分母。维度得分pass=1.0、warn=0.6、
fail/skip=0D08/D10 等按覆盖率/权重和连续取值。
**发布门禁**`is_publishable()` 要求 `quality_state ∈ {Q3_GOOD, Q4_EXCELLENT}`
## 校验报告契约 `pbl.validation.report/1.0`
```jsonc
{
"contract": "pbl.validation.report/1.0",
"engine_version": "1.0.0",
"report_id": "vr_xxxx", "tenant_id": "t1",
"blueprint_id": "BP1", "version": "v1", "ruleset": "builtin-14d",
"created_at": 1730000000000, "elapsed_ms": 3,
"fingerprint": "sha256前32位载荷+阈值,幂等复用)",
"quality_state": "Q4_EXCELLENT", "quality_label": "优秀", "quality_order": 4,
"quality_reasons": ["R5: 加权总分 0.9700 >= Q4 线 0.90 且无 warn/fail"],
"total_score": 0.97, "weighted_sum": 13.58, "weight_sum": 14.0,
"dim_count": 14, "dim_total": 14,
"state_summary": {"pass": 14, "warn": 0, "fail": 0, "skip": 0},
"severity_summary": {"blocker": 0, "major": 0, "minor": 0, "info": 0},
"issue_count": 0, "passed": true,
"dimensions": [{"dim":"D08","name":"任务链完整性","state":"pass","score":1.0,
"weight":1.3,"weighted_score":1.3,
"metrics":{"mcRatio":1.0,"mcRatio_threshold":0.7,"path_count":2,
"branch_exits_total":2,"branch_exits_covered":2,
"dangling":[],"cyclic_paths":0},
"issues":[]}],
"issues": [{"dim":"D08","code":"V-TASK-004","severity":"major",
"message":"多路径覆盖率 mcRatio=0.50 低于阈值 0.70Q-OPEN-8",
"path":"tasks.mcRatio","expected":0.7,"actual":0.5}],
"thresholds": {"mcRatio": 0.7, "...": "..."},
"threshold_ignored": [], "engine_errors": [],
"dimensions_meta": [{"dim":"D01","code":"structure_completeness","name":"结构完整性",
"weight":1.2,"desc":"..."}]
}
```
### 问题码索引
`V-TENANT-001` 租户缺失 · `V-INPUT-001~004` 载荷非法 · `V-DB-001/002` 库名或 sqlor 不可用 ·
`V-STRUCT-001/002` 结构 · `V-NAME-001~004` 命名 · `V-OBJ-001~003` 目标 ·
`V-DQ-001~004` 驱动问题 · `V-ROLE-001~003` 角色 · `V-SCENE-001~003` 场景 ·
`V-ENT-001~003` 实体 · `V-TASK-001~005` 任务链004=mcRatio005=环) ·
`V-RULE-001/002` 规则引用 · `V-RUB-001~004` 量规004=权重和≠1 ·
`V-EV-001~003` 产出物 · `V-DIFF-001~004` 难度 · `V-DUR-001~003` 时长 ·
`V-SEC-001~005` 安全(破坏性 SQL / 危险命令 / 硬编码凭据 / 动态执行 / 不适宜内容) ·
`V-ENGINE-001~003` 引擎(禁用 / 返回类型 / 维度异常兜底 fail
## 陷阱
- 库名一律 `ServerEnv().get_module_dbname('pbl_validation')`,禁止硬编码 DBNAME。
- sqlor 只有 `C/U/D/R/I/sqlExe`;查询走 pbl_common.api 的 q_all/q_one已适配
- 所有读写强制带 `tenant_id`pbl_common.api.tenant_id()),缺失即 fail-closed 报错。
- 新增契约需同步三处api.py 定义 + __init__.py 导出 + init.py env 注册 + scripts/load_path.py 路径。
- sqlor 只有 `C/U/D/R/I/sqlExe`;查询走 `pbl_common.api``q_all/q_one`(已适配),
本模块 `api.q_all/q_one` 做了「优先 pbl_common、回落 sqlor.sqlExe」双通道。
- 所有读写强制带 `tenant_id``pbl_common.api.tenant_id()`),缺失即 fail-closed 报错。
- 新增契约需同步四处:`api.py` 定义 + `API_REGISTRY` + `__init__.py` 导出 +
`init.py` env 注册 + `scripts/load_path.py``API_PATHS`
- 引擎是**纯函数**(无 DB/网络/随机),同载荷同阈值必得同报告;落库只在 api 层做。
- 单维度 checker 抛异常不会中断整体:引擎兜底记 `fail` + `V-ENGINE-003`fail-closed
- 蓝图载荷支持 4 种形态:`{objects:[{kind:...}]}` / `{kinds:{task:[...]}}` /
顶层复数键(`tasks`/`roles`/... / `{blueprint:{...}}` 包装。
## 自测
```bash
cd modules/pbl_validation && python3 tests/test_validation_engine.py # 111 项断言全绿
python3 scripts/load_path.py # 打印路径与契约清单
```

View File

@ -1,37 +1,92 @@
-- pbl_validation 表 DDL自动生成与 apps/pbls/scripts/ddl/pbls_tables.sql 同源)
-- ===========================================================================
-- pbl_validation 校验引擎M2建表 DDL
-- 2 张表pbl_validation_rule14 维规则,阈值配置化)
-- pbl_validation_result14 维得分 + 5 级质量状态 + 完整报告 JSON
-- 幂等CREATE TABLE IF NOT EXISTS由 init.load_pbl_validation() 自动执行
-- 所有表 tenant_id 打头(多租户强制隔离)
-- ===========================================================================
CREATE TABLE IF NOT EXISTS `pbl_validation_rule` (
`tenant_id` VARCHAR(32) NOT NULL NOT NULL COMMENT ID(),
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT ,
`code` VARCHAR(64) NOT NULL COMMENT "规则编码",
`dimension` VARCHAR(64) NOT NULL COMMENT "维度",
`title` VARCHAR(128) NOT NULL COMMENT "标题",
`rule_json` LONGTEXT NULL COMMENT "规则体",
`threshold_json` LONGTEXT NULL COMMENT "阈值(可配,Q-OPEN-8)",
`severity` VARCHAR(64) NOT NULL COMMENT "严重度",
`enabled` TINYINT(1) NOT NULL DEFAULT 0 COMMENT "启用",
`version_no` INT NOT NULL DEFAULT 0 COMMENT "版本",
`created_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "创建时间(应用层写入)",
`updated_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "更新时间(应用层写入)",
PRIMARY KEY (`id`),
UNIQUE KEY `uk_vr_code` (`tenant_id`, `code`, `version_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT="校验规则14 维,规则 JSON 形态 script_type=1";
`tenant_id` VARCHAR(64) NOT NULL COMMENT '租户ID强制打头',
`rule_id` VARCHAR(64) NOT NULL COMMENT '规则ID',
`dim` VARCHAR(8) NOT NULL COMMENT '维度编码 D01~D14',
`dim_code` VARCHAR(64) DEFAULT NULL COMMENT '维度语义码,如 task_chain',
`name` VARCHAR(128) DEFAULT NULL COMMENT '规则名称',
`description` VARCHAR(512) DEFAULT NULL COMMENT '规则说明',
`weight` DECIMAL(6,3) DEFAULT 1.000 COMMENT '维度权重0~10',
`severity` VARCHAR(16) DEFAULT 'major' COMMENT 'blocker/major/minor/info',
`script_type` INT DEFAULT 1 COMMENT '1=规则JSON形态',
`ruleset` VARCHAR(64) DEFAULT NULL COMMENT '规则集编码,内置 builtin-14d',
`enabled` INT DEFAULT 1 COMMENT '1启用 0停用',
`builtin` INT DEFAULT 0 COMMENT '1=内置规则种子0=租户自定义',
`rule_json` TEXT COMMENT '规则JSON{dim,code,name,weight,desc,thresholds:{mcRatio:0.7,...}}',
`created_at` BIGINT DEFAULT 0 COMMENT '创建时间(ms)',
`updated_at` BIGINT DEFAULT 0 COMMENT '更新时间(ms)',
PRIMARY KEY (`rule_id`),
KEY `idx_pbl_validation_rule_tenant_dim` (`tenant_id`, `dim`),
KEY `idx_pbl_validation_rule_tenant_enabled` (`tenant_id`, `enabled`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='校验规则14 维,规则 JSON 形态 script_type=1阈值配置化';
CREATE TABLE IF NOT EXISTS `pbl_validation_result` (
`tenant_id` VARCHAR(32) NOT NULL NOT NULL COMMENT ID(),
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT ,
`blueprint_id` BIGINT UNSIGNED NOT NULL COMMENT "蓝图ID",
`blueprint_version_no` INT NOT NULL DEFAULT 0 COMMENT "校验时版本",
`dimension_count` INT NOT NULL DEFAULT 0 COMMENT "维度数(=14)",
`passed_count` INT NOT NULL DEFAULT 0 COMMENT "通过数",
`failed_count` INT NOT NULL DEFAULT 0 COMMENT "失败数",
`score` DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT "得分",
`quality_state` VARCHAR(64) NOT NULL COMMENT "质量状态",
`findings_json` LONGTEXT NULL COMMENT "逐维结论",
`rule_set_version` VARCHAR(64) NOT NULL COMMENT "规则集版本",
`created_by` VARCHAR(32) NOT NULL COMMENT "触发人",
`created_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "创建时间(应用层写入)",
`updated_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "更新时间(应用层写入)",
PRIMARY KEY (`id`),
KEY `idx_vres_bp` (`tenant_id`, `blueprint_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT="校验结果14 维 + 5 级质量状态)";
`tenant_id` VARCHAR(64) NOT NULL COMMENT '租户ID强制打头',
`result_id` VARCHAR(64) NOT NULL COMMENT '结果ID vr_xxx= 报告 report_id',
`blueprint_id` VARCHAR(64) DEFAULT NULL COMMENT '蓝图ID',
`version` VARCHAR(32) DEFAULT NULL COMMENT '蓝图版本',
`ruleset` VARCHAR(64) DEFAULT NULL COMMENT '规则集编码',
`quality_state` VARCHAR(24) DEFAULT NULL COMMENT 'Q0_DRAFT/Q1_INCOMPLETE/Q2_BASIC/Q3_GOOD/Q4_EXCELLENT',
`quality_label` VARCHAR(32) DEFAULT NULL COMMENT '质量状态中文标签',
`total_score` DECIMAL(6,4) DEFAULT 0 COMMENT '加权总分 0~1',
`dim_count` INT DEFAULT 0 COMMENT '实际执行维度数(<=14',
`pass_count` INT DEFAULT 0 COMMENT 'pass 维度数',
`warn_count` INT DEFAULT 0 COMMENT 'warn 维度数',
`fail_count` INT DEFAULT 0 COMMENT 'fail 维度数',
`skip_count` INT DEFAULT 0 COMMENT 'skip 维度数(规则禁用)',
`blocker_count` INT DEFAULT 0 COMMENT 'blocker 问题数',
`major_count` INT DEFAULT 0 COMMENT 'major 问题数',
`minor_count` INT DEFAULT 0 COMMENT 'minor 问题数',
`info_count` INT DEFAULT 0 COMMENT 'info 问题数',
`issue_count` INT DEFAULT 0 COMMENT '问题总数',
`dim_scores` TEXT COMMENT '各维度状态 JSON{"D01":"pass",...}',
`dim_detail` TEXT COMMENT '各维度得分/权重/指标 JSON',
`fingerprint` VARCHAR(64) DEFAULT NULL COMMENT '载荷+阈值 sha256 前32位幂等复用',
`engine_version` VARCHAR(16) DEFAULT NULL COMMENT '引擎版本',
`contract` VARCHAR(48) DEFAULT NULL COMMENT '报告契约版本 pbl.validation.report/1.0',
`report_json` LONGTEXT COMMENT '完整校验报告 JSON契约结构',
`elapsed_ms` INT DEFAULT 0 COMMENT '执行耗时(ms)',
`operator` VARCHAR(64) DEFAULT NULL COMMENT '操作人',
`state` VARCHAR(16) DEFAULT 'done' COMMENT 'done/failed',
`created_at` BIGINT DEFAULT 0 COMMENT '创建时间(ms)',
`updated_at` BIGINT DEFAULT 0 COMMENT '更新时间(ms)',
PRIMARY KEY (`result_id`),
KEY `idx_pbl_validation_result_tenant_bp` (`tenant_id`, `blueprint_id`),
KEY `idx_pbl_validation_result_tenant_quality` (`tenant_id`, `quality_state`),
KEY `idx_pbl_validation_result_tenant_created` (`tenant_id`, `created_at`),
KEY `idx_pbl_validation_result_fingerprint` (`tenant_id`, `fingerprint`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='校验结果14 维得分 + 5 级质量状态 + 完整报告 JSON';
-- ===========================================================================
-- 内置 14 维规则种子tenant_id='' 为全局占位;租户级由 seed_builtin_rules 写入)
-- 幂等INSERT ... ON DUPLICATE KEY UPDATE
-- ===========================================================================
INSERT INTO `pbl_validation_rule`
(`tenant_id`,`rule_id`,`dim`,`dim_code`,`name`,`description`,`weight`,`severity`,
`script_type`,`ruleset`,`enabled`,`builtin`,`rule_json`,`created_at`,`updated_at`)
VALUES
('','builtin_d01','D01','structure_completeness','结构完整性','7 类子对象齐备性、根对象必填项',1.200,'major',1,'builtin-14d',1,1,'{"dim":"D01","code":"structure_completeness","thresholds":{"minSubObjectKinds":7}}',0,0),
('','builtin_d02','D02','naming_quality','命名规范','名称长度与合法字符集',0.800,'minor',1,'builtin-14d',1,1,'{"dim":"D02","code":"naming_quality","thresholds":{"minNameLen":2}}',0,0),
('','builtin_d03','D03','objective_alignment','学习目标对齐','目标数量、被任务/评估覆盖率',1.200,'major',1,'builtin-14d',1,1,'{"dim":"D03","code":"objective_alignment","thresholds":{"minObjectives":1,"objectiveCoverageRatio":0.8}}',0,0),
('','builtin_d04','D04','driving_question','驱动问题质量','驱动问题存在性、开放性、长度',1.000,'major',1,'builtin-14d',1,1,'{"dim":"D04","code":"driving_question","thresholds":{"drivingQuestionMinLen":8}}',0,0),
('','builtin_d05','D05','role_design','角色设计','角色数量、任务分配覆盖率、职责描述',1.000,'major',1,'builtin-14d',1,1,'{"dim":"D05","code":"role_design","thresholds":{"minRoles":1,"roleTaskCoverageRatio":0.8}}',0,0),
('','builtin_d06','D06','scene_design','场景设计','场景数量、孤儿场景、悬空引用',0.900,'minor',1,'builtin-14d',1,1,'{"dim":"D06","code":"scene_design","thresholds":{"minScenes":1}}',0,0),
('','builtin_d07','D07','entity_model','实体模型','实体数量、标识唯一性、属性定义',0.900,'minor',1,'builtin-14d',1,1,'{"dim":"D07","code":"entity_model","thresholds":{"minEntities":1}}',0,0),
('','builtin_d08','D08','task_chain','任务链完整性','任务数量、链路连通、环检测、多路径覆盖率 mcRatioQ-OPEN-8默认 0.7',1.300,'major',1,'builtin-14d',1,1,'{"dim":"D08","code":"task_chain","thresholds":{"mcRatio":0.7,"minTasks":1,"maxTasks":200}}',0,0),
('','builtin_d09','D09','rule_integrity','规则引用完整性','规则引用对象存在性、表达式定义',1.100,'major',1,'builtin-14d',1,1,'{"dim":"D09","code":"rule_integrity","thresholds":{"ruleRefIntegrityRatio":1.0}}',0,0),
('','builtin_d10','D10','assessment_rubric','评估量规','量规存在性、评分项、权重和=1',1.200,'major',1,'builtin-14d',1,1,'{"dim":"D10","code":"assessment_rubric","thresholds":{"rubricWeightSum":1.0,"rubricWeightTolerance":0.01,"minRubricCriteria":1}}',0,0),
('','builtin_d11','D11','evidence_binding','产出物绑定','任务与产出物/证据绑定覆盖率',1.000,'minor',1,'builtin-14d',1,1,'{"dim":"D11","code":"evidence_binding","thresholds":{"taskEvidenceCoverageRatio":0.8}}',0,0),
('','builtin_d12','D12','difficulty_balance','难度平衡','难度值范围、声明率、梯度分布',0.700,'minor',1,'builtin-14d',1,1,'{"dim":"D12","code":"difficulty_balance","thresholds":{"difficultyMin":1,"difficultyMax":5}}',0,0),
('','builtin_d13','D13','duration_balance','时长平衡','总时长与单任务时长区间',0.700,'minor',1,'builtin-14d',1,1,'{"dim":"D13","code":"duration_balance","thresholds":{"minDurationMinutes":10,"maxDurationMinutes":600,"minutesPerTaskMin":3,"minutesPerTaskMax":60}}',0,0),
('','builtin_d14','D14','safety_compliance','安全合规','危险语句/凭据泄露/不适宜内容扫描',1.000,'blocker',1,'builtin-14d',1,1,'{"dim":"D14","code":"safety_compliance","thresholds":{"maxSafetyHits":0}}',0,0)
ON DUPLICATE KEY UPDATE
`name`=VALUES(`name`), `description`=VALUES(`description`),
`weight`=VALUES(`weight`), `severity`=VALUES(`severity`),
`rule_json`=VALUES(`rule_json`), `updated_at`=VALUES(`updated_at`);

View File

@ -0,0 +1,547 @@
# -*- coding: utf-8 -*-
"""pbl_validation 引擎自测(纯函数层,不依赖 DB / 平台)。
运行python3 modules/pbl_validation/tests/test_validation_engine.py
cd modules/pbl_validation && python3 -m pytest tests -q
"""
from __future__ import annotations
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from pbl_validation.constants import ( # noqa: E402
DEFAULT_THRESHOLDS,
QUALITY_BASIC,
QUALITY_DRAFT,
QUALITY_EXCELLENT,
QUALITY_GOOD,
QUALITY_INCOMPLETE,
QUALITY_STATES,
resolve_thresholds,
)
from pbl_validation.dimensions import ( # noqa: E402
DIM_COUNT,
DIMENSION_REGISTRY,
SUB_OBJECT_KINDS,
dimension_meta,
get_checker,
)
from pbl_validation.engine import ( # noqa: E402
ValidationError,
decide_quality_state,
is_publishable,
normalize_payload,
payload_fingerprint,
report_to_row,
run_validation,
)
PASSED = []
FAILED = []
def check(name, cond, detail=""):
if cond:
PASSED.append(name)
print(" PASS %s" % name)
else:
FAILED.append((name, detail))
print(" FAIL %s %s" % (name, detail))
def good_blueprint():
"""构造一个应判 Q4_EXCELLENT 的完整蓝图。"""
objects = []
objects.append({"kind": "objective", "id": "OBJ1", "name": "掌握供需建模",
"description": "学生能够建立并求解供需平衡模型"})
objects.append({"kind": "objective", "id": "OBJ2", "name": "团队协作决策",
"description": "学生能够在多角色协作中做出权衡决策"})
objects.append({"kind": "role", "id": "R1", "name": "生产经理",
"description": "负责产能规划与排产决策"})
objects.append({"kind": "role", "id": "R2", "name": "销售总监",
"description": "负责订单承接与价格策略"})
objects.append({"kind": "scene", "id": "S1", "name": "第一季度经营会议",
"description": "季度经营决策场景"})
objects.append({"kind": "entity", "id": "E1", "name": "产品",
"attributes": [{"name": "单价", "type": "number"},
{"name": "库存", "type": "number"}]})
objects.append({"kind": "entity", "id": "E2", "name": "订单",
"attributes": [{"name": "数量", "type": "number"}]})
tasks = [
{"kind": "task", "id": "T1", "name": "需求预测", "objective_id": "OBJ1",
"role_id": "R1", "scene_id": "S1", "difficulty": 2, "duration_minutes": 20,
"evidence": [{"type": "report", "name": "预测报告"}], "next": "T2"},
{"kind": "task", "id": "T2", "name": "产能决策", "objective_id": "OBJ1",
"role_id": "R1", "scene_id": "S1", "difficulty": 3, "duration_minutes": 25,
"evidence": [{"type": "decision", "name": "排产方案"}],
"branches": [{"next": "T3"}, {"next": "T4"}]},
{"kind": "task", "id": "T3", "name": "扩产投资", "objective_id": "OBJ2",
"role_id": "R2", "scene_id": "S1", "difficulty": 4, "duration_minutes": 30,
"evidence": [{"type": "plan", "name": "投资计划"}], "next": "T5"},
{"kind": "task", "id": "T4", "name": "外包协作", "objective_id": "OBJ2",
"role_id": "R2", "scene_id": "S1", "difficulty": 3, "duration_minutes": 25,
"evidence": [{"type": "contract", "name": "外包合同"}], "next": "T5"},
{"kind": "task", "id": "T5", "name": "复盘汇报", "objective_id": "OBJ2",
"role_id": "R1", "scene_id": "S1", "difficulty": 2, "duration_minutes": 15,
"evidence": [{"type": "presentation", "name": "复盘PPT"}]},
]
objects.extend(tasks)
objects.append({"kind": "rule", "id": "RU1", "name": "库存下限约束",
"expression": "stock >= 100", "refs": ["E1", "T2"]})
objects.append({"kind": "assessment", "id": "A1", "name": "经营绩效量规",
"objective_id": "OBJ1",
"criteria": [{"name": "模型正确性", "weight": 0.5},
{"name": "决策合理性", "weight": 0.3},
{"name": "协作表现", "weight": 0.2}]})
return {
"root": {"name": "供应链经营沙盘", "driving_question": "如何在需求波动下实现利润最大化?",
"duration_minutes": 115},
"objects": objects,
}
def test_constants():
print("\n[1] 常量与阈值配置化")
check("mcRatio 默认 0.7Q-OPEN-8", DEFAULT_THRESHOLDS["mcRatio"] == 0.7,
str(DEFAULT_THRESHOLDS.get("mcRatio")))
check("5 级质量状态齐备", len(QUALITY_STATES) == 5, str(QUALITY_STATES))
check("质量状态顺序正确",
QUALITY_STATES == (QUALITY_DRAFT, QUALITY_INCOMPLETE, QUALITY_BASIC,
QUALITY_GOOD, QUALITY_EXCELLENT))
th = resolve_thresholds({"mcRatio": 0.85})
check("阈值覆盖生效", th["mcRatio"] == 0.85, str(th["mcRatio"]))
check("阈值覆盖不污染默认表", DEFAULT_THRESHOLDS["mcRatio"] == 0.7)
bad = resolve_thresholds({"mcRatio": 1.7, "unknownKey": 1, "minTasks": -3})
check("非法阈值被忽略并记录",
len(bad["_ignored"]) == 3 and bad["mcRatio"] == 0.7,
json.dumps(bad["_ignored"], ensure_ascii=False))
def test_dimensions_registry():
print("\n[2] 14 维注册表")
check("维度数 = 14", DIM_COUNT == 14, str(DIM_COUNT))
check("注册表长度 = 14", len(DIMENSION_REGISTRY) == 14)
dims = [d["dim"] for d in DIMENSION_REGISTRY]
check("维度编码 D01~D14 连续",
dims == ["D%02d" % i for i in range(1, 15)], str(dims))
check("7 类子对象定义齐备", len(SUB_OBJECT_KINDS) == 7, str(SUB_OBJECT_KINDS))
check("每个维度都有可调用 checker",
all(callable(d["checker"]) for d in DIMENSION_REGISTRY))
check("get_checker 支持 dim 与 code",
get_checker("D08") is not None and get_checker("task_chain") is not None)
check("get_checker 非法返回 None", get_checker("D99") is None)
meta = dimension_meta()
check("dimension_meta 不含函数对象",
len(meta) == 14 and all("checker" not in m for m in meta))
check("权重均为正数", all(d["weight"] > 0 for d in DIMENSION_REGISTRY))
def test_normalize():
print("\n[3] 载荷归一化与 fail-closed")
bp = good_blueprint()
check("dict 原样通过", normalize_payload(bp)["root"]["name"] == "供应链经营沙盘")
check("JSON 字符串可解析",
normalize_payload(json.dumps(bp, ensure_ascii=False))["root"]["name"]
== "供应链经营沙盘")
check("list 载荷转 objects", len(normalize_payload(bp["objects"])["objects"]) == 14)
for bad, label in ((None, "None"), ("", "空串"), ("{bad json", "非法JSON"),
(123, "数字")):
try:
normalize_payload(bad)
check("非法载荷拒绝:%s" % label, False, "未抛异常")
except ValidationError as exc:
check("非法载荷拒绝:%s" % label, exc.code.startswith("V-INPUT"), exc.code)
try:
run_validation(bp, tenant_id="")
check("tenant_id 缺失 fail-closed", False, "未抛异常")
except ValidationError as exc:
check("tenant_id 缺失 fail-closed", exc.code == "V-TENANT-001", exc.code)
def test_excellent():
print("\n[4] 完整蓝图 → Q4_EXCELLENT")
rep = run_validation(good_blueprint(), tenant_id="t1", blueprint_id="BP1", version="v1")
check("契约版本正确", rep["contract"] == "pbl.validation.report/1.0", rep["contract"])
check("执行 14 维", rep["dim_count"] == 14, str(rep["dim_count"]))
check("质量状态 Q4", rep["quality_state"] == QUALITY_EXCELLENT,
"%s / %s" % (rep["quality_state"], rep["quality_reasons"]))
check("无 fail 维度", rep["state_summary"]["fail"] == 0, str(rep["state_summary"]))
check("无 warn 维度", rep["state_summary"]["warn"] == 0, str(rep["state_summary"]))
check("无 blocker", rep["severity_summary"]["blocker"] == 0)
check("总分 >= 0.9", rep["total_score"] >= 0.9, str(rep["total_score"]))
check("passed=True", rep["passed"] is True)
check("发布门禁通过", is_publishable(rep)[0] is True)
d08 = [d for d in rep["dimensions"] if d["dim"] == "D08"][0]
check("D08 mcRatio = 1.0", d08["metrics"]["mcRatio"] == 1.0,
str(d08["metrics"]))
check("D08 阈值取 0.7", d08["metrics"]["mcRatio_threshold"] == 0.7)
check("D08 识别 2 条完整路径T2 双分支)", d08["metrics"]["path_count"] == 2,
str(d08["metrics"]["path_count"]))
check("D08 分支出口总数=2", d08["metrics"]["branch_exits_total"] == 2,
str(d08["metrics"]["branch_exits_total"]))
check("报告含 14 维元信息", len(rep["dimensions_meta"]) == 14)
check("确定性:同载荷两次指纹一致",
payload_fingerprint(good_blueprint(), rep["thresholds"])
== payload_fingerprint(good_blueprint(), rep["thresholds"]))
def test_mc_ratio_gate():
print("\n[5] mcRatio 阈值门禁Q-OPEN-8")
bp = good_blueprint()
# 把 T2 的一个分支指向不存在的任务 → 该分支出口未被路径覆盖
for o in bp["objects"]:
if o.get("id") == "T2":
o["branches"] = [{"next": "T3"}, {"next": "T_GHOST"}]
rep = run_validation(bp, tenant_id="t1", blueprint_id="BP2")
d08 = [d for d in rep["dimensions"] if d["dim"] == "D08"][0]
check("mcRatio 降到 0.5", d08["metrics"]["mcRatio"] == 0.5,
str(d08["metrics"]["mcRatio"]))
check("D08 判 fail", d08["state"] == "fail", d08["state"])
check("命中 V-TASK-004",
any(i["code"] == "V-TASK-004" for i in d08["issues"]),
json.dumps(d08["issues"], ensure_ascii=False))
check("命中悬空引用 V-TASK-003",
any(i["code"] == "V-TASK-003" for i in d08["issues"]))
check("质量状态被压到 Q1", rep["quality_state"] == QUALITY_INCOMPLETE,
rep["quality_state"])
# 阈值放宽到 0.5 → mcRatio 门禁不再触发(证明阈值配置化生效)
rep2 = run_validation(bp, tenant_id="t1", blueprint_id="BP2",
threshold_overrides={"mcRatio": 0.5})
d08b = [d for d in rep2["dimensions"] if d["dim"] == "D08"][0]
check("阈值放宽后不再命中 V-TASK-004",
not any(i["code"] == "V-TASK-004" for i in d08b["issues"]))
check("阈值放宽后 D08 阈值=0.5", d08b["metrics"]["mcRatio_threshold"] == 0.5)
check("报告回显生效阈值 mcRatio=0.5",
rep2["thresholds"]["mcRatio"] == 0.5)
def test_rule_json_override():
print("\n[6] rule_json 阈值覆盖优先级")
bp = good_blueprint()
rep = run_validation(bp, tenant_id="t1",
threshold_overrides={"mcRatio": 0.95},
rule_json={"thresholds": {"mcRatio": 0.6},
"weights": {"D08": 2.0},
"disabled_dims": ["D13"]})
check("rule_json 覆盖入参阈值", rep["thresholds"]["mcRatio"] == 0.6,
str(rep["thresholds"]["mcRatio"]))
d13 = [d for d in rep["dimensions"] if d["dim"] == "D13"][0]
check("D13 被禁用 → skip", d13["state"] == "skip", d13["state"])
check("skip 维度不计入权重和",
abs(rep["weight_sum"] - sum(d["weight"] for d in rep["dimensions"]
if d["state"] != "skip")) < 1e-6)
d08 = [d for d in rep["dimensions"] if d["dim"] == "D08"][0]
check("D08 权重被规则改为 2.0", d08["weight"] == 2.0, str(d08["weight"]))
def test_empty_blueprint():
print("\n[7] 空蓝图 → Q1_INCOMPLETE")
rep = run_validation({"root": {}}, tenant_id="t1", blueprint_id="BP_EMPTY")
check("空蓝图判 Q1", rep["quality_state"] == QUALITY_INCOMPLETE,
"%s %s" % (rep["quality_state"], rep["quality_reasons"]))
check("存在 blocker 问题", rep["severity_summary"]["blocker"] > 0,
str(rep["severity_summary"]))
check("passed=False", rep["passed"] is False)
check("发布门禁不通过", is_publishable(rep)[0] is False)
check("D01 fail", [d for d in rep["dimensions"] if d["dim"] == "D01"][0]["state"]
== "fail")
check("D10 缺量规 blocker",
any(i["code"] == "V-RUB-001"
for i in [d for d in rep["dimensions"] if d["dim"] == "D10"][0]["issues"]))
check("总分低于 Q2 线", rep["total_score"] < 0.4, str(rep["total_score"]))
def test_partial_blueprint():
print("\n[8] 部分完整蓝图 → Q2/Q3")
bp = good_blueprint()
# 去掉一半任务的 evidence → D11 覆盖率 0.4 < 0.8 判 fail非核心维度
n = 0
for o in bp["objects"]:
if o.get("kind") == "task":
n += 1
if n % 2 == 0:
o.pop("evidence", None)
rep = run_validation(bp, tenant_id="t1", blueprint_id="BP3")
d11 = [d for d in rep["dimensions"] if d["dim"] == "D11"][0]
check("D11 覆盖率 0.6", d11["metrics"]["coverage"] == 0.6,
str(d11["metrics"]))
check("D11 判 fail低于 0.8 阈值)", d11["state"] == "fail", d11["state"])
check("非核心维度 fail 不触发 R3",
not any(r.startswith("R3") for r in rep["quality_reasons"]),
str(rep["quality_reasons"]))
check("质量状态落在 Q2/Q3",
rep["quality_state"] in (QUALITY_BASIC, QUALITY_GOOD),
"%s %s" % (rep["quality_state"], rep["quality_reasons"]))
def test_safety():
print("\n[9] D14 安全合规扫描")
bp = good_blueprint()
bp["objects"].append({"kind": "rule", "id": "RU_BAD", "name": "危险规则",
"expression": "DROP TABLE pbl_blueprint;"})
rep = run_validation(bp, tenant_id="t1", blueprint_id="BP_SEC")
d14 = [d for d in rep["dimensions"] if d["dim"] == "D14"][0]
check("命中破坏性 SQL", any(i["code"] == "V-SEC-001" for i in d14["issues"]),
json.dumps(d14["issues"], ensure_ascii=False)[:200])
check("D14 判 fail", d14["state"] == "fail")
check("blocker 触发 R2 → Q1",
rep["quality_state"] == QUALITY_INCOMPLETE
and any(r.startswith("R2") for r in rep["quality_reasons"]),
str(rep["quality_reasons"]))
bp2 = good_blueprint()
bp2["root"]["note"] = "apikey = 'sk-abcdef123456'"
rep2 = run_validation(bp2, tenant_id="t1")
check("命中硬编码凭据",
any(i["code"] == "V-SEC-003"
for i in [d for d in rep2["dimensions"] if d["dim"] == "D14"][0]["issues"]))
def test_rubric_weight():
print("\n[10] D10 量规权重和=1")
bp = good_blueprint()
for o in bp["objects"]:
if o.get("kind") == "assessment":
o["criteria"] = [{"name": "A", "weight": 0.5}, {"name": "B", "weight": 0.3}]
rep = run_validation(bp, tenant_id="t1")
d10 = [d for d in rep["dimensions"] if d["dim"] == "D10"][0]
check("权重和 0.8 != 1 命中 V-RUB-004",
any(i["code"] == "V-RUB-004" for i in d10["issues"]),
json.dumps(d10["issues"], ensure_ascii=False))
check("D10 判 fail", d10["state"] == "fail")
check("核心维度 fail → R3 → Q1",
rep["quality_state"] == QUALITY_INCOMPLETE
and any(r.startswith("R3") for r in rep["quality_reasons"]),
str(rep["quality_reasons"]))
def test_cycle_and_dangling():
print("\n[11] 任务链环检测")
bp = good_blueprint()
for o in bp["objects"]:
if o.get("id") == "T5":
o["next"] = "T1" # 造环
rep = run_validation(bp, tenant_id="t1")
d08 = [d for d in rep["dimensions"] if d["dim"] == "D08"][0]
check("检出环形路径", d08["metrics"]["cyclic_paths"] > 0,
str(d08["metrics"]))
check("命中 V-TASK-005",
any(i["code"] == "V-TASK-005" for i in d08["issues"]))
check("D08 判 fail", d08["state"] == "fail")
def test_quality_rules():
print("\n[12] 5 级质量状态规则单测")
from pbl_validation.dimensions import DimResult, Issue
th = resolve_thresholds()
check("R1 无维度 → Q0",
decide_quality_state([], th, 0.0)[0] == QUALITY_DRAFT)
check("R1 全 skip → Q0",
decide_quality_state([DimResult("D01", "x", "skip")], th, 0.0)[0]
== QUALITY_DRAFT)
blocker_res = [DimResult("D02", "x", "warn", 0.9,
issues=[Issue("D02", "V-X", "blocker", "b")])]
st, why = decide_quality_state(blocker_res, th, 0.99)
check("R2 blocker 优先于高分 → Q1",
st == QUALITY_INCOMPLETE and why[0].startswith("R2"), str(why))
core_fail = [DimResult("D08", "x", "fail", 0.0), DimResult("D02", "y", "pass", 1.0)]
st, why = decide_quality_state(core_fail, th, 0.95)
check("R3 核心维度 fail → Q1",
st == QUALITY_INCOMPLETE and why[0].startswith("R3"), str(why))
noncore_fail = [DimResult("D02", "y", "pass", 1.0), DimResult("D12", "x", "fail", 0.0)]
st, why = decide_quality_state(noncore_fail, th, 0.30)
check("R4 总分 < 0.40 → Q1",
st == QUALITY_INCOMPLETE and why[0].startswith("R4"), str(why))
all_pass = [DimResult("D01", "a", "pass"), DimResult("D03", "b", "pass"),
DimResult("D08", "c", "pass"), DimResult("D10", "d", "pass"),
DimResult("D14", "e", "pass"), DimResult("D02", "f", "pass")]
st, why = decide_quality_state(all_pass, th, 0.95)
check("R5 高分无 warn → Q4",
st == QUALITY_EXCELLENT and why[0].startswith("R5"), str(why))
with_warn = all_pass + [DimResult("D12", "g", "warn", 0.6)]
st, why = decide_quality_state(with_warn, th, 0.92)
check("R5 有 warn 不给 Q4落 Q3",
st == QUALITY_GOOD and why[0].startswith("R6"), str(why))
st, why = decide_quality_state(with_warn, th, 0.55)
check("R7 中等分 → Q2",
st == QUALITY_BASIC and why[0].startswith("R7"), str(why))
core_warn = [DimResult("D01", "a", "warn", 0.6), DimResult("D03", "b", "pass"),
DimResult("D08", "c", "pass"), DimResult("D10", "d", "pass"),
DimResult("D14", "e", "pass")]
st, why = decide_quality_state(core_warn, th, 0.80)
check("R6 核心维度非全 pass 不给 Q3 → Q2",
st == QUALITY_BASIC and why[0].startswith("R7"), str(why))
def test_report_contract():
print("\n[13] 报告契约字段完备性")
rep = run_validation(good_blueprint(), tenant_id="t1", blueprint_id="BP1", version="v2")
required = ["contract", "engine_version", "report_id", "tenant_id", "blueprint_id",
"version", "ruleset", "created_at", "elapsed_ms", "fingerprint",
"quality_state", "quality_label", "quality_order", "quality_reasons",
"total_score", "weighted_sum", "weight_sum", "dim_count", "dim_total",
"state_summary", "severity_summary", "issue_count", "passed",
"dimensions", "issues", "thresholds", "engine_errors",
"dimensions_meta"]
missing = [k for k in required if k not in rep]
check("报告含全部契约字段", not missing, str(missing))
check("tenant_id 回显", rep["tenant_id"] == "t1")
check("blueprint_id/version 回显",
rep["blueprint_id"] == "BP1" and rep["version"] == "v2")
check("dim_total = 14", rep["dim_total"] == 14)
check("总分 = 加权和/权重和",
abs(rep["total_score"] - rep["weighted_sum"] / rep["weight_sum"]) < 1e-3,
"%s vs %s" % (rep["total_score"],
rep["weighted_sum"] / rep["weight_sum"]))
check("报告可 JSON 序列化", isinstance(json.dumps(rep, ensure_ascii=False), str))
d = rep["dimensions"][0]
check("维度项字段齐备",
all(k in d for k in ("dim", "name", "state", "score", "weight",
"weighted_score", "metrics", "issues")))
row = report_to_row(rep, operator="tester")
check("结果行 tenant_id 打头", row["tenant_id"] == "t1")
check("结果行 quality_state 一致", row["quality_state"] == rep["quality_state"])
check("结果行 dim_scores 为 JSON 串",
isinstance(json.loads(row["dim_scores"]), dict))
check("结果行 report_json 可反解",
json.loads(row["report_json"])["report_id"] == rep["report_id"])
check("结果行 state=done", row["state"] == "done")
def test_dims_subset():
print("\n[14] 指定维度子集执行")
rep = run_validation(good_blueprint(), tenant_id="t1", dims=["D08", "task_chain"])
check("去重后只跑 D08", rep["dim_count"] == 1, str(rep["dim_count"]))
check("维度为 D08", rep["dimensions"][0]["dim"] == "D08")
rep2 = run_validation(good_blueprint(), tenant_id="t1", dims=["D01"])
check("D01 单维可跑", rep2["dim_count"] == 1 and rep2["dimensions"][0]["dim"] == "D01")
def test_payload_shapes():
print("\n[15] 多种载荷形态兼容")
bp = good_blueprint()
kinds = {}
for o in bp["objects"]:
kinds.setdefault(o["kind"], []).append(o)
shape_b = {"root": bp["root"], "kinds": kinds}
rep_b = run_validation(shape_b, tenant_id="t1")
check("形态B(kinds) 判 Q4", rep_b["quality_state"] == QUALITY_EXCELLENT,
"%s %s" % (rep_b["quality_state"], rep_b["quality_reasons"]))
shape_c = {"name": "供应链经营沙盘",
"driving_question": "如何在需求波动下实现利润最大化?",
"duration_minutes": 115,
"objectives": [o for o in bp["objects"] if o["kind"] == "objective"],
"roles": [o for o in bp["objects"] if o["kind"] == "role"],
"scenes": [o for o in bp["objects"] if o["kind"] == "scene"],
"entities": [o for o in bp["objects"] if o["kind"] == "entity"],
"tasks": [o for o in bp["objects"] if o["kind"] == "task"],
"rules": [o for o in bp["objects"] if o["kind"] == "rule"],
"assessments": [o for o in bp["objects"] if o["kind"] == "assessment"]}
rep_c = run_validation(shape_c, tenant_id="t1")
check("形态C(顶层复数键) 判 Q4",
rep_c["quality_state"] == QUALITY_EXCELLENT,
"%s %s" % (rep_c["quality_state"], rep_c["quality_reasons"]))
wrapped = {"blueprint": bp}
rep_w = run_validation(wrapped, tenant_id="t1")
check("形态D({blueprint:...}) 判 Q4",
rep_w["quality_state"] == QUALITY_EXCELLENT,
"%s %s" % (rep_w["quality_state"], rep_w["quality_reasons"]))
def test_api_layer():
print("\n[16] 契约接口层(无 DB 环境 fail-closed / 离线兜底)")
from pbl_validation import api as vapi
check("API_REGISTRY 含 5 个契约", len(vapi.API_REGISTRY) == 5,
str(sorted(vapi.API_REGISTRY)))
expect = {"pbl_validation_run", "pbl_validation_get", "pbl_validation_list",
"pbl_validation_rule_list", "pbl_validation_rule_save"}
check("契约名与规格一致", set(vapi.API_REGISTRY) == expect,
str(set(vapi.API_REGISTRY) ^ expect))
check("内置规则 14 条", len(vapi.builtin_rules()) == 14)
check("内置规则 script_type=1",
all(r["script_type"] == 1 for r in vapi.builtin_rules()))
check("内置规则 rule_json 可解析",
all(isinstance(json.loads(r["rule_json"]), dict)
for r in vapi.builtin_rules()))
# tenant_id 缺失 → fail-closed不抛异常返回错误结构
res = vapi.pbl_validation_run({"blueprint": good_blueprint()})
check("run 缺 tenant_id 返回错误",
res["success"] is False and res["code"] in ("V-TENANT-001", "V-DB-001",
"V-DB-002"),
str(res.get("code")))
res2 = vapi.pbl_validation_run({"tenant_id": "t1"})
check("run 缺 blueprint 返回 V-INPUT-001",
res2["success"] is False and res2["code"] == "V-INPUT-001", str(res2))
res3 = vapi.pbl_validation_rule_save({"tenant_id": "t1", "dim": "D99"})
check("rule_save 非法 dim 被拒",
res3["success"] is False and res3["code"] == "V-INPUT-021", str(res3.get("code")))
res4 = vapi.pbl_validation_rule_save({"tenant_id": "t1", "dim": "D08",
"thresholds": {"mcRatio": 5}})
check("rule_save 非法阈值被拒fail-closed",
res4["success"] is False and res4["code"] == "V-INPUT-022", str(res4.get("code")))
res5 = vapi.pbl_validation_list({"tenant_id": "t1", "quality_state": "Q9"})
check("list 非法 quality_state 被拒",
res5["success"] is False and res5["code"] == "V-INPUT-011", str(res5.get("code")))
res6 = vapi.pbl_validation_get({"tenant_id": "t1"})
check("get 缺 id 被拒",
res6["success"] is False and res6["code"] == "V-INPUT-010", str(res6.get("code")))
# 无 DB 环境下 run 应离线兜底(内置规则)或明确报 DB 错误,不允许静默成功
res7 = vapi.pbl_validation_run({"tenant_id": "t1", "blueprint": good_blueprint(),
"persist": 0})
if res7["success"]:
check("离线兜底run 成功且用内置规则",
res7["data"]["rules_from_builtin"] is True
and res7["data"]["rule_count"] == 14
and res7["data"]["quality_state"] == QUALITY_EXCELLENT,
json.dumps({k: res7["data"][k] for k in
("rules_from_builtin", "rule_count", "quality_state")},
ensure_ascii=False))
check("离线兜底publishable=True", res7["data"]["publishable"] is True)
else:
check("无 DB 时明确报错(不静默)",
res7["code"] in ("V-DB-001", "V-DB-002"), str(res7))
def main():
print("=" * 72)
print("pbl_validation 引擎自测14 维 + 5 级质量状态 + 阈值配置化)")
print("=" * 72)
for fn in (test_constants, test_dimensions_registry, test_normalize,
test_excellent, test_mc_ratio_gate, test_rule_json_override,
test_empty_blueprint, test_partial_blueprint, test_safety,
test_rubric_weight, test_cycle_and_dangling, test_quality_rules,
test_report_contract, test_dims_subset, test_payload_shapes,
test_api_layer):
try:
fn()
except Exception as exc:
import traceback
traceback.print_exc()
FAILED.append((fn.__name__, "%s: %s" % (type(exc).__name__, exc)))
print("\n" + "=" * 72)
print("PASSED: %d FAILED: %d" % (len(PASSED), len(FAILED)))
if FAILED:
for name, detail in FAILED:
print(" - %s : %s" % (name, detail))
print("=" * 72)
return 1 if FAILED else 0
if __name__ == "__main__":
sys.exit(main())