diff --git a/README.md b/README.md index db2be90..d617076 100644 --- a/README.md +++ b/README.md @@ -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/网络/随机),确定性可复现。 diff --git a/pbl_validation/__init__.py b/pbl_validation/__init__.py index 8a71f38..e892b65 100644 --- a/pbl_validation/__init__.py +++ b/pbl_validation/__init__.py @@ -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.7,Q-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__", ] diff --git a/pbl_validation/api.py b/pbl_validation/api.py index 5612ce8..d3dcea9 100644 --- a/pbl_validation/api.py +++ b/pbl_validation/api.py @@ -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/.dspy)。 -规则形态:规则体存 `pbl_validation_rule.rule_json`(与 script_engine script_type=1 的 -event→condition→response 同构,**不新建规则引擎**),阈值可配(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/.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", +] diff --git a/pbl_validation/constants.py b/pbl_validation/constants.py new file mode 100644 index 0000000..b77fae6 --- /dev/null +++ b/pbl_validation/constants.py @@ -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" diff --git a/pbl_validation/dimensions.py b/pbl_validation/dimensions.py new file mode 100644 index 0000000..6ec3b59 --- /dev/null +++ b/pbl_validation/dimensions.py @@ -0,0 +1,1084 @@ +# -*- coding: utf-8 -*- +"""14 维校验维度定义与检查器实现。 + +每个维度是一个纯函数 ``check_(ctx) -> DimResult``: +- 只读 ``ctx.data``(归一化后的蓝图载荷)与 ``ctx.thresholds``(配置化阈值); +- 不访问数据库、不抛异常(异常由引擎统一兜底为 fail); +- 返回维度状态(pass/warn/fail/skip)+ 得分 + 问题清单。 + +维度清单(14 维) +----------------- +D01 structure_completeness 结构完整性(7 类子对象齐备) +D02 naming_quality 命名规范 +D03 objective_alignment 学习目标对齐 +D04 driving_question 驱动问题质量 +D05 role_design 角色设计 +D06 scene_design 场景设计 +D07 entity_model 实体模型 +D08 task_chain 任务链完整性 + 多路径覆盖率(mcRatio,Q-OPEN-8) +D09 rule_integrity 规则引用完整性 +D10 assessment_rubric 评估量规(权重和=1) +D11 evidence_binding 产出物/证据绑定 +D12 difficulty_balance 难度平衡 +D13 duration_balance 时长平衡 +D14 safety_compliance 安全合规 +""" + +from __future__ import annotations + +import re +from typing import Any, Callable, Dict, List, Optional, Tuple + +from .constants import ( + DIM_FAIL, + DIM_PASS, + DIM_SKIP, + DIM_STATE_SCORE, + DIM_WARN, + SEV_BLOCKER, + SEV_INFO, + SEV_MAJOR, + SEV_MINOR, +) + +# --------------------------------------------------------------------------- +# 7 类子对象(蓝图泛化契约) +# --------------------------------------------------------------------------- +SUB_OBJECT_KINDS = ( + "objective", + "role", + "scene", + "entity", + "task", + "rule", + "assessment", +) + + +class Issue(object): + """单条校验问题。""" + + __slots__ = ("dim", "code", "severity", "message", "path", "expected", "actual") + + def __init__(self, dim, code, severity, message, path="", expected=None, actual=None): + self.dim = dim + self.code = code + self.severity = severity + self.message = message + self.path = path + self.expected = expected + self.actual = actual + + def to_dict(self): + return { + "dim": self.dim, + "code": self.code, + "severity": self.severity, + "message": self.message, + "path": self.path, + "expected": self.expected, + "actual": self.actual, + } + + +class DimResult(object): + """单维度校验结果。""" + + __slots__ = ("dim", "name", "state", "score", "weight", "issues", "metrics") + + def __init__(self, dim, name, state, score=None, weight=1.0, issues=None, metrics=None): + self.dim = dim + self.name = name + self.state = state if state in DIM_STATE_SCORE else DIM_FAIL + self.score = float(score) if score is not None else DIM_STATE_SCORE[self.state] + self.weight = float(weight) + self.issues = list(issues or []) + self.metrics = dict(metrics or {}) + + @property + def weighted_score(self): + return self.score * self.weight + + def to_dict(self): + return { + "dim": self.dim, + "name": self.name, + "state": self.state, + "score": round(self.score, 4), + "weight": round(self.weight, 4), + "weighted_score": round(self.weighted_score, 4), + "metrics": self.metrics, + "issues": [i.to_dict() if isinstance(i, Issue) else i for i in self.issues], + } + + +class CheckContext(object): + """校验上下文:归一化蓝图数据 + 阈值 + 辅助取值。""" + + def __init__(self, data: Dict[str, Any], thresholds: Dict[str, Any], + tenant_id: str = "", blueprint_id: str = "", version: str = ""): + self.data = data if isinstance(data, dict) else {} + self.thresholds = thresholds or {} + self.tenant_id = tenant_id or "" + self.blueprint_id = blueprint_id or "" + self.version = version or "" + self._kinds = self._normalize_kinds() + + # -- 数据访问 ----------------------------------------------------------- + def th(self, key: str, default: Any = None) -> Any: + """读阈值(缺失回落默认常量表)。""" + if key in self.thresholds: + return self.thresholds[key] + from .constants import DEFAULT_THRESHOLDS + return DEFAULT_THRESHOLDS.get(key, default) + + def kind_items(self, kind: str) -> List[Dict[str, Any]]: + """取某类子对象列表(兼容 objects/kinds 两种载荷形态)。""" + return list(self._kinds.get(kind, [])) + + def _normalize_kinds(self) -> Dict[str, List[Dict[str, Any]]]: + out: Dict[str, List[Dict[str, Any]]] = {k: [] for k in SUB_OBJECT_KINDS} + data = self.data + + # 形态 A:{"objects": [{"kind": "task", ...}, ...]} + objs = data.get("objects") + if isinstance(objs, list): + for o in objs: + if not isinstance(o, dict): + continue + k = str(o.get("kind") or o.get("object_kind") or "").strip().lower() + if k in out: + out[k].append(o) + + # 形态 B:{"kinds": {"task": [...], ...}} + kinds = data.get("kinds") + if isinstance(kinds, dict): + for k, v in kinds.items(): + kk = str(k).strip().lower() + if kk in out and isinstance(v, list): + for o in v: + if isinstance(o, dict) and o not in out[kk]: + out[kk].append(o) + + # 形态 C:顶层复数键 tasks/roles/scenes/... + plural_map = { + "objective": ("objectives", "learning_objectives"), + "role": ("roles",), + "scene": ("scenes",), + "entity": ("entities",), + "task": ("tasks",), + "rule": ("rules",), + "assessment": ("assessments", "rubrics"), + } + for kind, keys in plural_map.items(): + for key in keys: + v = data.get(key) + if isinstance(v, list): + for o in v: + if isinstance(o, dict) and o not in out[kind]: + out[kind].append(o) + return out + + def root(self) -> Dict[str, Any]: + """蓝图根对象(兼容 root/blueprint/meta 三种键)。""" + for key in ("root", "blueprint", "meta"): + v = self.data.get(key) + if isinstance(v, dict): + return v + return self.data + + +# --------------------------------------------------------------------------- +# 工具函数 +# --------------------------------------------------------------------------- +_NAME_RE = re.compile(r"^[\w\u4e00-\u9fa5][\w\u4e00-\u9fa5\-\. /]{0,63}$") + + +def _text(v: Any) -> str: + if v is None: + return "" + if isinstance(v, str): + return v.strip() + return str(v).strip() + + +def _ident(v: Any) -> str: + return _text(v) + + +def _ratio(numer: int, denom: int) -> float: + if denom <= 0: + return 0.0 + return float(numer) / float(denom) + + +def _num(v: Any, default: float = 0.0) -> float: + try: + if v is None or v == "": + return float(default) + return float(v) + except (TypeError, ValueError): + return float(default) + + +def _ids(items: List[Dict[str, Any]]) -> List[str]: + out = [] + for it in items: + i = _ident(it.get("id") or it.get("code") or it.get("key") or it.get("name")) + if i: + out.append(i) + return out + + +def _state_from_ratio(ratio: float, warn_line: float) -> str: + if ratio >= 1.0: + return DIM_PASS + if ratio >= warn_line: + return DIM_WARN + return DIM_FAIL + + +# --------------------------------------------------------------------------- +# D01 结构完整性 +# --------------------------------------------------------------------------- +def check_structure_completeness(ctx: CheckContext) -> DimResult: + dim, name = "D01", "结构完整性" + issues: List[Issue] = [] + need = int(ctx.th("minSubObjectKinds", 7)) + present = [k for k in SUB_OBJECT_KINDS if ctx.kind_items(k)] + missing = [k for k in SUB_OBJECT_KINDS if not ctx.kind_items(k)] + + root = ctx.root() + if not _text(root.get("name") or root.get("title")): + issues.append(Issue(dim, "V-STRUCT-001", SEV_BLOCKER, + "蓝图根对象缺少名称(name)", "root.name")) + + for k in missing: + sev = SEV_BLOCKER if k in ("objective", "task", "assessment") else SEV_MAJOR + issues.append(Issue(dim, "V-STRUCT-002", sev, + "缺少子对象类别:%s" % k, "kinds.%s" % k)) + + total = len(present) + score = _ratio(total, max(need, len(SUB_OBJECT_KINDS))) + if any(i.severity == SEV_BLOCKER for i in issues): + state = DIM_FAIL + score = min(score, 0.3) + elif issues: + state = DIM_WARN if total >= need else DIM_FAIL + else: + state = DIM_PASS + + return DimResult(dim, name, state, score, 1.2, issues, + {"present_kinds": present, "missing_kinds": missing, + "present_count": total, "required_count": need}) + + +# --------------------------------------------------------------------------- +# D02 命名规范 +# --------------------------------------------------------------------------- +def check_naming_quality(ctx: CheckContext) -> DimResult: + dim, name = "D02", "命名规范" + issues: List[Issue] = [] + min_len = int(ctx.th("minNameLen", 2)) + checked = 0 + bad = 0 + + root = ctx.root() + root_name = _text(root.get("name") or root.get("title")) + checked += 1 + if len(root_name) < min_len: + bad += 1 + issues.append(Issue(dim, "V-NAME-001", SEV_MAJOR, + "蓝图名称过短(<%d 字符)" % min_len, "root.name", + min_len, root_name)) + if root_name and not _NAME_RE.match(root_name): + bad += 1 + issues.append(Issue(dim, "V-NAME-002", SEV_MINOR, + "蓝图名称含非法字符(允许中英文/数字/_-.空格)", "root.name")) + + for kind in SUB_OBJECT_KINDS: + for idx, it in enumerate(ctx.kind_items(kind)): + nm = _text(it.get("name") or it.get("title") or it.get("label")) + checked += 1 + path = "%s[%d].name" % (kind, idx) + if len(nm) < min_len: + bad += 1 + issues.append(Issue(dim, "V-NAME-003", SEV_MINOR, + "%s 名称缺失或过短" % kind, path, min_len, nm)) + elif not _NAME_RE.match(nm): + bad += 1 + issues.append(Issue(dim, "V-NAME-004", SEV_INFO, + "%s 名称含非法字符" % kind, path)) + + ok_ratio = _ratio(checked - bad, checked) + state = DIM_PASS if ok_ratio >= 1.0 else (DIM_WARN if ok_ratio >= 0.9 else DIM_FAIL) + return DimResult(dim, name, state, ok_ratio, 0.8, issues, + {"checked": checked, "bad": bad, "ok_ratio": round(ok_ratio, 4)}) + + +# --------------------------------------------------------------------------- +# D03 学习目标对齐 +# --------------------------------------------------------------------------- +def check_objective_alignment(ctx: CheckContext) -> DimResult: + dim, name = "D03", "学习目标对齐" + issues: List[Issue] = [] + objs = ctx.kind_items("objective") + min_objs = int(ctx.th("minObjectives", 1)) + cov_line = float(ctx.th("objectiveCoverageRatio", 0.8)) + + if len(objs) < min_objs: + issues.append(Issue(dim, "V-OBJ-001", SEV_BLOCKER, + "学习目标数量不足(需 >= %d)" % min_objs, + "kinds.objective", min_objs, len(objs))) + + obj_ids = set(_ids(objs)) + referenced = set() + for kind in ("task", "assessment", "rule"): + for it in ctx.kind_items(kind): + for key in ("objective_id", "objective", "objective_code", "objectiveId"): + v = it.get(key) + if isinstance(v, list): + referenced.update(_text(x) for x in v if _text(x)) + elif _text(v): + referenced.add(_text(v)) + + covered = obj_ids & referenced + cov = _ratio(len(covered), len(obj_ids)) + if obj_ids and cov < cov_line: + issues.append(Issue(dim, "V-OBJ-002", SEV_MAJOR, + "目标被任务/评估覆盖率 %.2f 低于阈值 %.2f" % (cov, cov_line), + "objectives.coverage", cov_line, round(cov, 4))) + + for idx, o in enumerate(objs): + txt = _text(o.get("description") or o.get("text") or o.get("content")) + if len(txt) < int(ctx.th("minTextLen", 8)): + issues.append(Issue(dim, "V-OBJ-003", SEV_MINOR, + "目标描述过短,无法评估", "objective[%d].description" % idx)) + + if not obj_ids: + state, score = DIM_FAIL, 0.0 + else: + score = cov + state = DIM_FAIL if any(i.severity == SEV_BLOCKER for i in issues) else \ + (DIM_PASS if cov >= 1.0 else (DIM_WARN if cov >= cov_line else DIM_FAIL)) + return DimResult(dim, name, state, score, 1.2, issues, + {"objective_count": len(objs), "covered": len(covered), + "coverage": round(cov, 4), "threshold": cov_line}) + + +# --------------------------------------------------------------------------- +# D04 驱动问题质量 +# --------------------------------------------------------------------------- +def check_driving_question(ctx: CheckContext) -> DimResult: + dim, name = "D04", "驱动问题质量" + issues: List[Issue] = [] + root = ctx.root() + min_len = int(ctx.th("drivingQuestionMinLen", 8)) + + dq = _text(root.get("driving_question") or root.get("drivingQuestion") + or root.get("core_question")) + if not dq: + for o in ctx.kind_items("objective"): + dq = dq or _text(o.get("driving_question")) + if dq: + break + + if not dq: + issues.append(Issue(dim, "V-DQ-001", SEV_BLOCKER, + "缺少驱动问题(driving_question)", "root.driving_question")) + return DimResult(dim, name, DIM_FAIL, 0.0, 1.0, issues, {"length": 0}) + + score = 1.0 + if len(dq) < min_len: + score = 0.4 + issues.append(Issue(dim, "V-DQ-002", SEV_MAJOR, + "驱动问题过短(<%d 字符),不具开放性" % min_len, + "root.driving_question", min_len, len(dq))) + if not re.search(r"[??]", dq): + score = min(score, 0.7) + issues.append(Issue(dim, "V-DQ-003", SEV_MINOR, + "驱动问题未以疑问句形式表达(缺少 ? / ?)", + "root.driving_question")) + if re.search(r"^(是否|能不能|对不对)", dq): + score = min(score, 0.7) + issues.append(Issue(dim, "V-DQ-004", SEV_MINOR, + "驱动问题为封闭式是非问句,建议改为开放性问题", + "root.driving_question")) + + state = DIM_PASS if score >= 1.0 else (DIM_WARN if score >= 0.6 else DIM_FAIL) + return DimResult(dim, name, state, score, 1.0, issues, + {"length": len(dq), "min_len": min_len}) + + +# --------------------------------------------------------------------------- +# D05 角色设计 +# --------------------------------------------------------------------------- +def check_role_design(ctx: CheckContext) -> DimResult: + dim, name = "D05", "角色设计" + issues: List[Issue] = [] + roles = ctx.kind_items("role") + min_roles = int(ctx.th("minRoles", 1)) + cov_line = float(ctx.th("roleTaskCoverageRatio", 0.8)) + + if len(roles) < min_roles: + issues.append(Issue(dim, "V-ROLE-001", SEV_BLOCKER, + "角色数量不足(需 >= %d)" % min_roles, + "kinds.role", min_roles, len(roles))) + + role_ids = set(_ids(roles)) + assigned = set() + for t in ctx.kind_items("task"): + for key in ("role_id", "role", "owner_role", "assignee_role"): + v = t.get(key) + if isinstance(v, list): + assigned.update(_text(x) for x in v if _text(x)) + elif _text(v): + assigned.add(_text(v)) + + covered = role_ids & assigned + cov = _ratio(len(covered), len(role_ids)) + if role_ids and cov < cov_line: + issues.append(Issue(dim, "V-ROLE-002", SEV_MAJOR, + "角色被任务分配覆盖率 %.2f 低于阈值 %.2f" % (cov, cov_line), + "roles.coverage", cov_line, round(cov, 4))) + + for idx, r in enumerate(roles): + if len(_text(r.get("description") or r.get("duty") or r.get("text"))) < int(ctx.th("minTextLen", 8)): + issues.append(Issue(dim, "V-ROLE-003", SEV_MINOR, + "角色职责描述过短", "role[%d].description" % idx)) + + if not role_ids: + state, score = DIM_FAIL, 0.0 + else: + score = cov + state = DIM_FAIL if any(i.severity == SEV_BLOCKER for i in issues) else \ + (DIM_PASS if cov >= 1.0 else (DIM_WARN if cov >= cov_line else DIM_FAIL)) + return DimResult(dim, name, state, score, 1.0, issues, + {"role_count": len(roles), "covered": len(covered), + "coverage": round(cov, 4), "threshold": cov_line}) + + +# --------------------------------------------------------------------------- +# D06 场景设计 +# --------------------------------------------------------------------------- +def check_scene_design(ctx: CheckContext) -> DimResult: + dim, name = "D06", "场景设计" + issues: List[Issue] = [] + scenes = ctx.kind_items("scene") + min_scenes = int(ctx.th("minScenes", 1)) + + if len(scenes) < min_scenes: + issues.append(Issue(dim, "V-SCENE-001", SEV_BLOCKER, + "场景数量不足(需 >= %d)" % min_scenes, + "kinds.scene", min_scenes, len(scenes))) + + scene_ids = set(_ids(scenes)) + used = set() + for t in ctx.kind_items("task"): + for key in ("scene_id", "scene", "scene_code"): + if _text(t.get(key)): + used.add(_text(t.get(key))) + + orphan = scene_ids - used + for s in sorted(orphan): + issues.append(Issue(dim, "V-SCENE-002", SEV_MINOR, + "场景未被任何任务引用:%s" % s, "scene:%s" % s)) + + dangling = used - scene_ids + for d in sorted(dangling): + issues.append(Issue(dim, "V-SCENE-003", SEV_MAJOR, + "任务引用了不存在的场景:%s" % d, "task.scene_id=%s" % d)) + + if not scene_ids: + state, score = DIM_FAIL, 0.0 + elif dangling: + state, score = DIM_FAIL, 0.3 + elif orphan: + state, score = DIM_WARN, _ratio(len(used & scene_ids), len(scene_ids)) + else: + state, score = DIM_PASS, 1.0 + return DimResult(dim, name, state, score, 0.9, issues, + {"scene_count": len(scenes), "used": len(used & scene_ids), + "orphan": sorted(orphan), "dangling": sorted(dangling)}) + + +# --------------------------------------------------------------------------- +# D07 实体模型 +# --------------------------------------------------------------------------- +def check_entity_model(ctx: CheckContext) -> DimResult: + dim, name = "D07", "实体模型" + issues: List[Issue] = [] + ents = ctx.kind_items("entity") + min_ents = int(ctx.th("minEntities", 1)) + + if len(ents) < min_ents: + issues.append(Issue(dim, "V-ENT-001", SEV_BLOCKER, + "实体数量不足(需 >= %d)" % min_ents, + "kinds.entity", min_ents, len(ents))) + + ent_ids = set(_ids(ents)) + dup = len(_ids(ents)) - len(ent_ids) + if dup > 0: + issues.append(Issue(dim, "V-ENT-002", SEV_MAJOR, + "存在 %d 个重复实体标识" % dup, "kinds.entity")) + + no_attr = 0 + for idx, e in enumerate(ents): + attrs = e.get("attributes") or e.get("fields") or e.get("props") + if not (isinstance(attrs, (list, dict)) and len(attrs) > 0): + no_attr += 1 + issues.append(Issue(dim, "V-ENT-003", SEV_MINOR, + "实体缺少属性定义", "entity[%d].attributes" % idx)) + + if not ent_ids: + state, score = DIM_FAIL, 0.0 + else: + score = _ratio(len(ent_ids) - no_attr, len(ent_ids)) + state = DIM_FAIL if (dup > 0 or any(i.severity == SEV_BLOCKER for i in issues)) \ + else (DIM_PASS if score >= 1.0 else (DIM_WARN if score >= 0.6 else DIM_FAIL)) + return DimResult(dim, name, state, score, 0.9, issues, + {"entity_count": len(ents), "unique": len(ent_ids), + "duplicated": dup, "without_attributes": no_attr}) + + +# --------------------------------------------------------------------------- +# D08 任务链完整性 + 多路径覆盖率(mcRatio,Q-OPEN-8) +# --------------------------------------------------------------------------- +def _collect_paths(tasks: List[Dict[str, Any]]) -> Tuple[List[List[str]], int]: + """从任务的 next/branches 关系推导路径,返回 (路径列表, 分支节点数)。""" + by_id: Dict[str, Dict[str, Any]] = {} + for t in tasks: + tid = _ident(t.get("id") or t.get("code") or t.get("key") or t.get("name")) + if tid: + by_id[tid] = t + + def nexts(t: Dict[str, Any]) -> List[str]: + out: List[str] = [] + for key in ("next", "next_id", "next_task", "next_task_id"): + v = t.get(key) + if _text(v): + out.append(_text(v)) + elif isinstance(v, list): + out.extend(_text(x) for x in v if _text(x)) + for key in ("branches", "options", "choices", "paths"): + v = t.get(key) + if isinstance(v, list): + for b in v: + if isinstance(b, dict): + nv = _text(b.get("next") or b.get("next_id") or b.get("target") + or b.get("to")) + if nv: + out.append(nv) + elif _text(b): + out.append(_text(b)) + seen, uniq = set(), [] + for x in out: + if x not in seen: + seen.add(x) + uniq.append(x) + return uniq + + referenced = set() + for t in by_id.values(): + referenced.update(nexts(t)) + roots = [tid for tid in by_id if tid not in referenced] or list(by_id.keys())[:1] + + branch_nodes = sum(1 for t in by_id.values() if len(nexts(t)) > 1) + paths: List[List[str]] = [] + MAX_PATHS = 500 + + def walk(node: str, acc: List[str], depth: int): + if len(paths) >= MAX_PATHS or depth > 64: + return + if node in acc: + paths.append(acc + [node]) # 环,记录后终止 + return + acc2 = acc + [node] + t = by_id.get(node) + if t is None: + paths.append(acc2) + return + nxt = nexts(t) + if not nxt: + paths.append(acc2) + return + for n in nxt: + walk(n, acc2, depth + 1) + + for r in roots: + walk(r, [], 0) + return paths, branch_nodes + + +def check_task_chain(ctx: CheckContext) -> DimResult: + dim, name = "D08", "任务链完整性" + issues: List[Issue] = [] + tasks = ctx.kind_items("task") + min_tasks = int(ctx.th("minTasks", 1)) + max_tasks = int(ctx.th("maxTasks", 200)) + mc_ratio_th = float(ctx.th("mcRatio", 0.7)) # Q-OPEN-8 + + if len(tasks) < min_tasks: + issues.append(Issue(dim, "V-TASK-001", SEV_BLOCKER, + "任务数量不足(需 >= %d)" % min_tasks, + "kinds.task", min_tasks, len(tasks))) + if len(tasks) > max_tasks: + issues.append(Issue(dim, "V-TASK-002", SEV_MAJOR, + "任务数量超过上限 %d,建议拆分蓝图" % max_tasks, + "kinds.task", max_tasks, len(tasks))) + + task_ids = set(_ids(tasks)) + paths, branch_nodes = _collect_paths(tasks) + + # 悬空引用 + dangling = set() + for p in paths: + for node in p: + if node not in task_ids: + dangling.add(node) + for d in sorted(dangling): + issues.append(Issue(dim, "V-TASK-003", SEV_MAJOR, + "任务链引用了不存在的任务:%s" % d, "task.next=%s" % d)) + + # 多路径覆盖率 mcRatio(Q-OPEN-8):有分支节点时, + # 覆盖率 = 被至少一条完整路径覆盖到的分支出口数 / 分支出口总数 + branch_exits_total = 0 + branch_exits_covered = 0 + for t in tasks: + tid = _ident(t.get("id") or t.get("code") or t.get("key") or t.get("name")) + exits = [] + for key in ("branches", "options", "choices", "paths"): + v = t.get(key) + if isinstance(v, list): + for b in v: + if isinstance(b, dict): + nv = _text(b.get("next") or b.get("next_id") or b.get("target") + or b.get("to")) + if nv: + exits.append(nv) + if len(exits) > 1: + branch_exits_total += len(exits) + path_nodes = set() + for p in paths: + if tid in p: + path_nodes.update(p) + for e in exits: + # 只有「指向真实存在任务」且「被至少一条完整路径走到」的出口才算覆盖; + # 悬空出口(指向不存在任务)不计入覆盖,直接拉低 mcRatio。 + if e in path_nodes and e in task_ids: + branch_exits_covered += 1 + + mc_ratio = _ratio(branch_exits_covered, branch_exits_total) if branch_exits_total else 1.0 + # 无分支节点的线性任务链:mcRatio 视为 1.0(不适用多路径门禁) + if branch_exits_total and mc_ratio < mc_ratio_th: + issues.append(Issue(dim, "V-TASK-004", SEV_MAJOR, + "多路径覆盖率 mcRatio=%.2f 低于阈值 %.2f(Q-OPEN-8)" + % (mc_ratio, mc_ratio_th), + "tasks.mcRatio", mc_ratio_th, round(mc_ratio, 4))) + + # 环检测 + cyclic = [p for p in paths if len(set(p)) != len(p)] + if cyclic: + issues.append(Issue(dim, "V-TASK-005", SEV_MAJOR, + "任务链存在 %d 条环形路径" % len(cyclic), "tasks.cycle")) + + if not task_ids: + state, score = DIM_FAIL, 0.0 + else: + base = 1.0 + if dangling: + base -= 0.3 + if cyclic: + base -= 0.2 + base = base * (0.5 + 0.5 * mc_ratio) + score = max(0.0, min(1.0, base)) + if any(i.severity == SEV_BLOCKER for i in issues) or dangling or cyclic \ + or (branch_exits_total and mc_ratio < mc_ratio_th): + state = DIM_FAIL + elif score >= 0.999: + state = DIM_PASS + else: + state = DIM_WARN + return DimResult(dim, name, state, score, 1.3, issues, + {"task_count": len(tasks), "path_count": len(paths), + "branch_nodes": branch_nodes, + "branch_exits_total": branch_exits_total, + "branch_exits_covered": branch_exits_covered, + "mcRatio": round(mc_ratio, 4), + "mcRatio_threshold": mc_ratio_th, + "dangling": sorted(dangling), "cyclic_paths": len(cyclic)}) + + +# --------------------------------------------------------------------------- +# D09 规则引用完整性 +# --------------------------------------------------------------------------- +def check_rule_integrity(ctx: CheckContext) -> DimResult: + dim, name = "D09", "规则引用完整性" + issues: List[Issue] = [] + rules = ctx.kind_items("rule") + need = float(ctx.th("ruleRefIntegrityRatio", 1.0)) + + known = set() + for kind in SUB_OBJECT_KINDS: + known.update(_ids(ctx.kind_items(kind))) + + total_refs = 0 + bad_refs = 0 + for idx, r in enumerate(rules): + refs = [] + for key in ("refs", "references", "targets", "scope", "when", "trigger"): + v = r.get(key) + if isinstance(v, list): + refs.extend(_text(x) for x in v if _text(x)) + elif isinstance(v, dict): + refs.extend(_text(x) for x in v.values() if _text(x)) + elif _text(v): + refs.append(_text(v)) + for ref in refs: + total_refs += 1 + if ref not in known: + bad_refs += 1 + issues.append(Issue(dim, "V-RULE-001", SEV_MAJOR, + "规则引用了不存在的对象:%s" % ref, + "rule[%d].refs" % idx)) + if not (r.get("expression") or r.get("script") or r.get("condition") or refs): + issues.append(Issue(dim, "V-RULE-002", SEV_MINOR, + "规则缺少表达式/条件定义", "rule[%d]" % idx)) + + integrity = _ratio(total_refs - bad_refs, total_refs) if total_refs else 1.0 + if integrity < need: + state = DIM_FAIL + elif any(i.severity == SEV_MINOR for i in issues): + state = DIM_WARN + else: + state = DIM_PASS + return DimResult(dim, name, state, integrity, 1.1, issues, + {"rule_count": len(rules), "total_refs": total_refs, + "bad_refs": bad_refs, "integrity": round(integrity, 4), + "threshold": need}) + + +# --------------------------------------------------------------------------- +# D10 评估量规 +# --------------------------------------------------------------------------- +def check_assessment_rubric(ctx: CheckContext) -> DimResult: + dim, name = "D10", "评估量规" + issues: List[Issue] = [] + rubrics = ctx.kind_items("assessment") + min_crit = int(ctx.th("minRubricCriteria", 1)) + want_sum = float(ctx.th("rubricWeightSum", 1.0)) + tol = float(ctx.th("rubricWeightTolerance", 0.01)) + + if not rubrics: + issues.append(Issue(dim, "V-RUB-001", SEV_BLOCKER, + "缺少评估量规(assessment/rubric)", "kinds.assessment")) + return DimResult(dim, name, DIM_FAIL, 0.0, 1.2, issues, {"rubric_count": 0}) + + score = 1.0 + for idx, rb in enumerate(rubrics): + crits = rb.get("criteria") or rb.get("items") or rb.get("dimensions") or [] + if not isinstance(crits, list) or len(crits) < min_crit: + issues.append(Issue(dim, "V-RUB-002", SEV_MAJOR, + "量规评分项不足(需 >= %d)" % min_crit, + "assessment[%d].criteria" % idx, min_crit, + len(crits) if isinstance(crits, list) else 0)) + score -= 0.4 + continue + wsum = 0.0 + for c in crits: + if isinstance(c, dict): + wsum += _num(c.get("weight") or c.get("score") or c.get("points"), 0.0) + if not _text(c.get("name") or c.get("title") or c.get("criterion")): + issues.append(Issue(dim, "V-RUB-003", SEV_MINOR, + "评分项缺少名称", "assessment[%d].criteria" % idx)) + score -= 0.05 + if abs(wsum - want_sum) > tol: + issues.append(Issue(dim, "V-RUB-004", SEV_MAJOR, + "量规权重和 %.4f != %.2f(容差 %.2f)" + % (wsum, want_sum, tol), + "assessment[%d].weight_sum" % idx, want_sum, round(wsum, 4))) + score -= 0.35 + + score = max(0.0, min(1.0, score)) + if any(i.severity == SEV_BLOCKER for i in issues): + state = DIM_FAIL + elif any(i.severity == SEV_MAJOR for i in issues): + state = DIM_FAIL + elif issues: + state = DIM_WARN + else: + state = DIM_PASS + return DimResult(dim, name, state, score, 1.2, issues, + {"rubric_count": len(rubrics), "score": round(score, 4)}) + + +# --------------------------------------------------------------------------- +# D11 产出物 / 证据绑定 +# --------------------------------------------------------------------------- +def check_evidence_binding(ctx: CheckContext) -> DimResult: + dim, name = "D11", "产出物绑定" + issues: List[Issue] = [] + tasks = ctx.kind_items("task") + cov_line = float(ctx.th("taskEvidenceCoverageRatio", 0.8)) + + if not tasks: + issues.append(Issue(dim, "V-EV-001", SEV_BLOCKER, + "无任务,无法校验产出物绑定", "kinds.task")) + return DimResult(dim, name, DIM_FAIL, 0.0, 1.0, issues, {"task_count": 0}) + + bound = 0 + unbound = [] + for idx, t in enumerate(tasks): + ev = None + for key in ("evidence", "evidences", "artifacts", "deliverables", "outputs", + "evidence_type", "artifact_type"): + if t.get(key): + ev = t.get(key) + break + if ev: + bound += 1 + else: + unbound.append(_ident(t.get("id") or t.get("name") or idx)) + + cov = _ratio(bound, len(tasks)) + for u in unbound[:20]: + issues.append(Issue(dim, "V-EV-002", SEV_MINOR, + "任务未绑定产出物/证据:%s" % u, "task:%s.evidence" % u)) + if cov < cov_line: + issues.append(Issue(dim, "V-EV-003", SEV_MAJOR, + "任务产出物绑定覆盖率 %.2f 低于阈值 %.2f" + % (cov, cov_line), "tasks.evidence_coverage", + cov_line, round(cov, 4))) + + state = DIM_PASS if cov >= 1.0 else (DIM_WARN if cov >= cov_line else DIM_FAIL) + return DimResult(dim, name, state, cov, 1.0, issues, + {"task_count": len(tasks), "bound": bound, + "coverage": round(cov, 4), "threshold": cov_line}) + + +# --------------------------------------------------------------------------- +# D12 难度平衡 +# --------------------------------------------------------------------------- +def check_difficulty_balance(ctx: CheckContext) -> DimResult: + dim, name = "D12", "难度平衡" + issues: List[Issue] = [] + tasks = ctx.kind_items("task") + dmin = int(ctx.th("difficultyMin", 1)) + dmax = int(ctx.th("difficultyMax", 5)) + + vals = [] + for idx, t in enumerate(tasks): + d = t.get("difficulty") + if d is None or d == "": + continue + dv = _num(d, -1) + if dv < 0: + issues.append(Issue(dim, "V-DIFF-001", SEV_MINOR, + "难度值非数值:%r" % (d,), "task[%d].difficulty" % idx)) + continue + if dv < dmin or dv > dmax: + issues.append(Issue(dim, "V-DIFF-002", SEV_MAJOR, + "难度值 %s 超出范围 [%d,%d]" % (dv, dmin, dmax), + "task[%d].difficulty" % idx, [dmin, dmax], dv)) + vals.append(dv) + + if not tasks: + return DimResult(dim, name, DIM_FAIL, 0.0, 0.7, issues, {"task_count": 0}) + + declared = _ratio(len(vals), len(tasks)) + if declared < 0.5: + issues.append(Issue(dim, "V-DIFF-003", SEV_MINOR, + "仅 %.0f%% 任务声明了难度值" % (declared * 100), + "tasks.difficulty")) + + spread = 0.0 + if len(vals) >= 2: + avg = sum(vals) / len(vals) + var = sum((v - avg) ** 2 for v in vals) / len(vals) + spread = var ** 0.5 + if spread < 0.3: + issues.append(Issue(dim, "V-DIFF-004", SEV_INFO, + "难度分布过于平坦(标准差 %.2f),缺少梯度" % spread, + "tasks.difficulty")) + + score = declared + if any(i.severity == SEV_MAJOR for i in issues): + state = DIM_FAIL + score = min(score, 0.4) + elif any(i.severity == SEV_MINOR for i in issues): + state = DIM_WARN + else: + state = DIM_PASS + return DimResult(dim, name, state, score, 0.7, issues, + {"task_count": len(tasks), "declared": len(vals), + "declared_ratio": round(declared, 4), + "stddev": round(spread, 4)}) + + +# --------------------------------------------------------------------------- +# D13 时长平衡 +# --------------------------------------------------------------------------- +def check_duration_balance(ctx: CheckContext) -> DimResult: + dim, name = "D13", "时长平衡" + issues: List[Issue] = [] + tasks = ctx.kind_items("task") + root = ctx.root() + + total_min = _num(root.get("duration_minutes") or root.get("total_minutes") + or root.get("duration"), 0.0) + per_min = float(ctx.th("minutesPerTaskMin", 3)) + per_max = float(ctx.th("minutesPerTaskMax", 60)) + dmin = float(ctx.th("minDurationMinutes", 10)) + dmax = float(ctx.th("maxDurationMinutes", 600)) + + if not tasks: + return DimResult(dim, name, DIM_FAIL, 0.0, 0.7, issues, {"task_count": 0}) + + declared = 0 + over = 0 + for idx, t in enumerate(tasks): + m = _num(t.get("duration_minutes") or t.get("minutes") or t.get("duration"), 0.0) + if m <= 0: + continue + declared += 1 + if m < per_min or m > per_max: + over += 1 + issues.append(Issue(dim, "V-DUR-001", SEV_MINOR, + "任务时长 %s 分钟超出单任务合理区间 [%s,%s]" + % (m, per_min, per_max), + "task[%d].duration_minutes" % idx)) + + if total_min <= 0: + issues.append(Issue(dim, "V-DUR-002", SEV_MINOR, + "蓝图未声明总时长(duration_minutes)", + "root.duration_minutes")) + elif total_min < dmin or total_min > dmax: + issues.append(Issue(dim, "V-DUR-003", SEV_MAJOR, + "总时长 %s 分钟超出区间 [%s,%s]" % (total_min, dmin, dmax), + "root.duration_minutes", [dmin, dmax], total_min)) + + decl_ratio = _ratio(declared, len(tasks)) + score = decl_ratio if total_min > 0 else decl_ratio * 0.7 + if any(i.severity == SEV_MAJOR for i in issues): + state = DIM_FAIL + elif issues: + state = DIM_WARN + else: + state = DIM_PASS + return DimResult(dim, name, state, max(0.0, min(1.0, score)), 0.7, issues, + {"task_count": len(tasks), "declared": declared, + "out_of_range": over, "total_minutes": total_min}) + + +# --------------------------------------------------------------------------- +# D14 安全合规 +# --------------------------------------------------------------------------- +_FORBIDDEN_PATTERNS = ( + (r"(?i)\b(drop\s+table|truncate\s+table|delete\s+from\s+\w+\s*;)", "V-SEC-001", + "包含破坏性 SQL 语句"), + (r"(?i)\b(rm\s+-rf|shutdown|reboot|mkfs)\b", "V-SEC-002", "包含危险系统命令"), + (r"(?i)(password|passwd|secret|api[_-]?key|token)\s*[:=]\s*['\"][^'\"]{6,}", + "V-SEC-003", "疑似硬编码凭据"), + (r"(?i)\b(eval|exec)\s*\(\s*['\"]", "V-SEC-004", "包含动态代码执行"), + (r"(暴力|血腥|自残|自杀|毒品|赌博|色情)", "V-SEC-005", "包含不适宜教学内容"), +) + + +def _walk_strings(obj: Any, path: str = ""): + if isinstance(obj, dict): + for k, v in obj.items(): + for item in _walk_strings(v, "%s.%s" % (path, k)): + yield item + elif isinstance(obj, list): + for i, v in enumerate(obj): + for item in _walk_strings(v, "%s[%d]" % (path, i)): + yield item + elif isinstance(obj, str): + yield path, obj + + +def check_safety_compliance(ctx: CheckContext) -> DimResult: + dim, name = "D14", "安全合规" + issues: List[Issue] = [] + max_hits = int(ctx.th("maxSafetyHits", 0)) + hits = 0 + + for path, text in _walk_strings(ctx.data): + for pattern, code, msg in _FORBIDDEN_PATTERNS: + if re.search(pattern, text): + hits += 1 + issues.append(Issue(dim, code, SEV_BLOCKER, + "%s(%s)" % (msg, path[:120]), path[:200])) + break + + if hits > max_hits: + state, score = DIM_FAIL, 0.0 + else: + state, score = DIM_PASS, 1.0 + return DimResult(dim, name, state, score, 1.0, issues[:50], + {"hits": hits, "max_hits": max_hits, "scanned": True}) + + +# --------------------------------------------------------------------------- +# 维度注册表(顺序即报告顺序) +# --------------------------------------------------------------------------- +DIMENSION_REGISTRY: List[Dict[str, Any]] = [ + {"dim": "D01", "code": "structure_completeness", "name": "结构完整性", + "weight": 1.2, "checker": check_structure_completeness, + "desc": "7 类子对象齐备性、根对象必填项"}, + {"dim": "D02", "code": "naming_quality", "name": "命名规范", + "weight": 0.8, "checker": check_naming_quality, + "desc": "名称长度与合法字符集"}, + {"dim": "D03", "code": "objective_alignment", "name": "学习目标对齐", + "weight": 1.2, "checker": check_objective_alignment, + "desc": "目标数量、被任务/评估覆盖率"}, + {"dim": "D04", "code": "driving_question", "name": "驱动问题质量", + "weight": 1.0, "checker": check_driving_question, + "desc": "驱动问题存在性、开放性、长度"}, + {"dim": "D05", "code": "role_design", "name": "角色设计", + "weight": 1.0, "checker": check_role_design, + "desc": "角色数量、任务分配覆盖率、职责描述"}, + {"dim": "D06", "code": "scene_design", "name": "场景设计", + "weight": 0.9, "checker": check_scene_design, + "desc": "场景数量、孤儿场景、悬空引用"}, + {"dim": "D07", "code": "entity_model", "name": "实体模型", + "weight": 0.9, "checker": check_entity_model, + "desc": "实体数量、标识唯一性、属性定义"}, + {"dim": "D08", "code": "task_chain", "name": "任务链完整性", + "weight": 1.3, "checker": check_task_chain, + "desc": "任务数量、链路连通、环检测、多路径覆盖率 mcRatio(Q-OPEN-8,默认 0.7)"}, + {"dim": "D09", "code": "rule_integrity", "name": "规则引用完整性", + "weight": 1.1, "checker": check_rule_integrity, + "desc": "规则引用对象存在性、表达式定义"}, + {"dim": "D10", "code": "assessment_rubric", "name": "评估量规", + "weight": 1.2, "checker": check_assessment_rubric, + "desc": "量规存在性、评分项、权重和=1"}, + {"dim": "D11", "code": "evidence_binding", "name": "产出物绑定", + "weight": 1.0, "checker": check_evidence_binding, + "desc": "任务与产出物/证据绑定覆盖率"}, + {"dim": "D12", "code": "difficulty_balance", "name": "难度平衡", + "weight": 0.7, "checker": check_difficulty_balance, + "desc": "难度值范围、声明率、梯度分布"}, + {"dim": "D13", "code": "duration_balance", "name": "时长平衡", + "weight": 0.7, "checker": check_duration_balance, + "desc": "总时长与单任务时长区间"}, + {"dim": "D14", "code": "safety_compliance", "name": "安全合规", + "weight": 1.0, "checker": check_safety_compliance, + "desc": "危险语句/凭据泄露/不适宜内容扫描"}, +] + +DIM_COUNT = len(DIMENSION_REGISTRY) + + +def dimension_meta() -> List[Dict[str, Any]]: + """返回 14 维元信息(不含函数对象),供规则列表接口输出。""" + return [{"dim": d["dim"], "code": d["code"], "name": d["name"], + "weight": d["weight"], "desc": d["desc"]} for d in DIMENSION_REGISTRY] + + +def get_checker(dim_or_code: str) -> Optional[Callable[[CheckContext], DimResult]]: + key = _text(dim_or_code).lower() + for d in DIMENSION_REGISTRY: + if d["dim"].lower() == key or d["code"].lower() == key: + return d["checker"] + return None diff --git a/pbl_validation/engine.py b/pbl_validation/engine.py new file mode 100644 index 0000000..8d7673c --- /dev/null +++ b/pbl_validation/engine.py @@ -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 线 %.2f(warn=%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": {...}}。 + 非法输入抛 ValidationError(fail-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 diff --git a/pbl_validation/init.py b/pbl_validation/init.py index 6818586..b3f6acf 100644 --- a/pbl_validation/init.py +++ b/pbl_validation/init.py @@ -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/.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/.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: 挂载信息 dict(dbname / 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 "")) + 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"] diff --git a/scripts/load_path.py b/scripts/load_path.py index c33936d..d77642e 100644 --- a/scripts/load_path.py +++ b/scripts/load_path.py @@ -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()) diff --git a/skill/SKILL.md b/skill/SKILL.md index dcda9eb..eb7a4d9 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -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/.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/.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=0,D08/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.70(Q-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=mcRatio,005=环) · +`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 # 打印路径与契约清单 +``` diff --git a/sql/pbl_validation.sql b/sql/pbl_validation.sql index ba649e6..4654c9b 100644 --- a/sql/pbl_validation.sql +++ b/sql/pbl_validation.sql @@ -1,37 +1,92 @@ --- pbl_validation 表 DDL(自动生成,与 apps/pbls/scripts/ddl/pbls_tables.sql 同源) +-- =========================================================================== +-- pbl_validation 校验引擎(M2)建表 DDL +-- 2 张表:pbl_validation_rule(14 维规则,阈值配置化) +-- pbl_validation_result(14 维得分 + 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','任务链完整性','任务数量、链路连通、环检测、多路径覆盖率 mcRatio(Q-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`); diff --git a/tests/test_validation_engine.py b/tests/test_validation_engine.py new file mode 100644 index 0000000..9d8de18 --- /dev/null +++ b/tests/test_validation_engine.py @@ -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.7(Q-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())