diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6579d96 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +__pycache__/ +*.pyc +*.pyo +.pytest_cache/ +.coverage +.DS_Store +*.log +node_modules/ +dist/ +build/ +.venv/ +venv/ diff --git a/json/validation_pbl_validation_result.json b/json/validation_pbl_validation_result.json new file mode 100644 index 0000000..ab8fe63 --- /dev/null +++ b/json/validation_pbl_validation_result.json @@ -0,0 +1,174 @@ +{ + "tblname": "pbl_validation_result", + "alias": "pbl_validation", + "browserfields": { + "id": { + "label": "ID", + "list": true, + "type": "number" + }, + "tenant_id": { + "label": "租户ID", + "list": true, + "type": "text" + }, + "blueprint_id": { + "label": "蓝图ID", + "type": "number", + "list": true + }, + "blueprint_version_no": { + "label": "校验时版本", + "type": "number", + "list": true + }, + "dimension_count": { + "label": "维度数(=14)", + "type": "number", + "list": true + }, + "passed_count": { + "label": "通过数", + "type": "number", + "list": true + }, + "failed_count": { + "label": "失败数", + "type": "number", + "list": true + }, + "score": { + "label": "得分", + "type": "number", + "list": true + }, + "quality_state": { + "label": "质量状态", + "type": "select", + "list": true + }, + "findings_json": { + "label": "逐维结论", + "type": "text", + "list": true + }, + "rule_set_version": { + "label": "规则集版本", + "type": "select", + "list": true + }, + "created_by": { + "label": "触发人", + "type": "text", + "list": true + } + }, + "params": { + "browserfields": { + "id": { + "label": "ID", + "list": true, + "type": "number" + }, + "tenant_id": { + "label": "租户ID", + "list": true, + "type": "text" + }, + "blueprint_id": { + "label": "蓝图ID", + "type": "number", + "list": true + }, + "blueprint_version_no": { + "label": "校验时版本", + "type": "number", + "list": true + }, + "dimension_count": { + "label": "维度数(=14)", + "type": "number", + "list": true + }, + "passed_count": { + "label": "通过数", + "type": "number", + "list": true + }, + "failed_count": { + "label": "失败数", + "type": "number", + "list": true + }, + "score": { + "label": "得分", + "type": "number", + "list": true + }, + "quality_state": { + "label": "质量状态", + "type": "select", + "list": true + }, + "findings_json": { + "label": "逐维结论", + "type": "text", + "list": true + }, + "rule_set_version": { + "label": "规则集版本", + "type": "select", + "list": true + }, + "created_by": { + "label": "触发人", + "type": "text", + "list": true + } + }, + "editable": { + "blueprint_id": { + "label": "蓝图ID", + "type": "number" + }, + "blueprint_version_no": { + "label": "校验时版本", + "type": "number" + }, + "dimension_count": { + "label": "维度数(=14)", + "type": "number" + }, + "passed_count": { + "label": "通过数", + "type": "number" + }, + "failed_count": { + "label": "失败数", + "type": "number" + }, + "score": { + "label": "得分", + "type": "number" + }, + "quality_state": { + "label": "质量状态", + "type": "select" + }, + "findings_json": { + "label": "逐维结论", + "type": "text" + }, + "rule_set_version": { + "label": "规则集版本", + "type": "select" + }, + "created_by": { + "label": "触发人", + "type": "text" + } + }, + "order_by": "id DESC", + "page_size": 20 + } +} diff --git a/json/validation_pbl_validation_rule.json b/json/validation_pbl_validation_rule.json new file mode 100644 index 0000000..f276cdf --- /dev/null +++ b/json/validation_pbl_validation_rule.json @@ -0,0 +1,146 @@ +{ + "tblname": "pbl_validation_rule", + "alias": "pbl_validation", + "browserfields": { + "id": { + "label": "ID", + "list": true, + "type": "number" + }, + "tenant_id": { + "label": "租户ID", + "list": true, + "type": "text" + }, + "code": { + "label": "规则编码", + "type": "select", + "list": true + }, + "dimension": { + "label": "维度", + "type": "select", + "list": true + }, + "title": { + "label": "标题", + "type": "select", + "list": true + }, + "rule_json": { + "label": "规则体", + "type": "text", + "list": true + }, + "threshold_json": { + "label": "阈值(可配,Q-OPEN-8)", + "type": "text", + "list": true + }, + "severity": { + "label": "严重度", + "type": "select", + "list": true + }, + "enabled": { + "label": "启用", + "type": "checkbox", + "list": true + }, + "version_no": { + "label": "版本", + "type": "number", + "list": true + } + }, + "params": { + "browserfields": { + "id": { + "label": "ID", + "list": true, + "type": "number" + }, + "tenant_id": { + "label": "租户ID", + "list": true, + "type": "text" + }, + "code": { + "label": "规则编码", + "type": "select", + "list": true + }, + "dimension": { + "label": "维度", + "type": "select", + "list": true + }, + "title": { + "label": "标题", + "type": "select", + "list": true + }, + "rule_json": { + "label": "规则体", + "type": "text", + "list": true + }, + "threshold_json": { + "label": "阈值(可配,Q-OPEN-8)", + "type": "text", + "list": true + }, + "severity": { + "label": "严重度", + "type": "select", + "list": true + }, + "enabled": { + "label": "启用", + "type": "checkbox", + "list": true + }, + "version_no": { + "label": "版本", + "type": "number", + "list": true + } + }, + "editable": { + "code": { + "label": "规则编码", + "type": "select" + }, + "dimension": { + "label": "维度", + "type": "select" + }, + "title": { + "label": "标题", + "type": "select" + }, + "rule_json": { + "label": "规则体", + "type": "text" + }, + "threshold_json": { + "label": "阈值(可配,Q-OPEN-8)", + "type": "text" + }, + "severity": { + "label": "严重度", + "type": "select" + }, + "enabled": { + "label": "启用", + "type": "checkbox" + }, + "version_no": { + "label": "版本", + "type": "number" + } + }, + "order_by": "id DESC", + "page_size": 20 + } +} diff --git a/models/pbl_validation_result.json b/models/pbl_validation_result.json new file mode 100644 index 0000000..724f8c4 --- /dev/null +++ b/models/pbl_validation_result.json @@ -0,0 +1,125 @@ +{ + "summary": [ + { + "name": "pbl_validation_result", + "comment": "校验结果(14 维 + 5 级质量状态)", + "module": "pbl_validation", + "engine": "mariadb", + "charset": "utf8mb4", + "tenant_scoped": true + } + ], + "fields": [ + { + "name": "tenant_id", + "comment": "租户ID(强制打头)", + "null": false, + "type": "str", + "length": 32 + }, + { + "name": "id", + "comment": "主键", + "null": false, + "type": "int", + "length": 20, + "unsigned": true, + "auto_increment": true + }, + { + "name": "blueprint_id", + "comment": "蓝图ID", + "null": false, + "type": "bigint" + }, + { + "name": "blueprint_version_no", + "comment": "校验时版本", + "null": false, + "type": "int" + }, + { + "name": "dimension_count", + "comment": "维度数(=14)", + "null": false, + "type": "int" + }, + { + "name": "passed_count", + "comment": "通过数", + "null": false, + "type": "int" + }, + { + "name": "failed_count", + "comment": "失败数", + "null": false, + "type": "int" + }, + { + "name": "score", + "comment": "得分", + "null": false, + "type": "double", + "precision": 18, + "scale": 2 + }, + { + "name": "quality_state", + "comment": "质量状态", + "null": false, + "type": "str", + "length": 64 + }, + { + "name": "findings_json", + "comment": "逐维结论", + "null": true, + "type": "json" + }, + { + "name": "rule_set_version", + "comment": "规则集版本", + "null": false, + "type": "str", + "length": 64 + }, + { + "name": "created_by", + "comment": "触发人", + "null": false, + "type": "str", + "length": 32 + }, + { + "name": "created_at", + "comment": "创建时间", + "null": false, + "type": "datetime" + }, + { + "name": "updated_at", + "comment": "更新时间", + "null": false, + "type": "datetime" + } + ], + "indexes": [ + { + "name": "idx_vres_bp", + "unique": false, + "fields": [ + "tenant_id", + "blueprint_id" + ] + }, + { + "name": "PRIMARY", + "unique": true, + "fields": [ + "id" + ] + } + ], + "codes": [] +} diff --git a/models/pbl_validation_rule.json b/models/pbl_validation_rule.json new file mode 100644 index 0000000..86a58b5 --- /dev/null +++ b/models/pbl_validation_rule.json @@ -0,0 +1,113 @@ +{ + "summary": [ + { + "name": "pbl_validation_rule", + "comment": "校验规则(14 维,规则 JSON 形态 script_type=1)", + "module": "pbl_validation", + "engine": "mariadb", + "charset": "utf8mb4", + "tenant_scoped": true + } + ], + "fields": [ + { + "name": "tenant_id", + "comment": "租户ID(强制打头)", + "null": false, + "type": "str", + "length": 32 + }, + { + "name": "id", + "comment": "主键", + "null": false, + "type": "int", + "length": 20, + "unsigned": true, + "auto_increment": true + }, + { + "name": "code", + "comment": "规则编码", + "null": false, + "type": "str", + "length": 64 + }, + { + "name": "dimension", + "comment": "维度", + "null": false, + "type": "str", + "length": 64 + }, + { + "name": "title", + "comment": "标题", + "null": false, + "type": "str", + "length": 128 + }, + { + "name": "rule_json", + "comment": "规则体", + "null": true, + "type": "json" + }, + { + "name": "threshold_json", + "comment": "阈值(可配,Q-OPEN-8)", + "null": true, + "type": "json" + }, + { + "name": "severity", + "comment": "严重度", + "null": false, + "type": "str", + "length": 64 + }, + { + "name": "enabled", + "comment": "启用", + "null": false, + "type": "bool" + }, + { + "name": "version_no", + "comment": "版本", + "null": false, + "type": "int" + }, + { + "name": "created_at", + "comment": "创建时间", + "null": false, + "type": "datetime" + }, + { + "name": "updated_at", + "comment": "更新时间", + "null": false, + "type": "datetime" + } + ], + "indexes": [ + { + "name": "uk_vr_code", + "unique": true, + "fields": [ + "tenant_id", + "code", + "version_no" + ] + }, + { + "name": "PRIMARY", + "unique": true, + "fields": [ + "id" + ] + } + ], + "codes": [] +} diff --git a/pbl_validation/__init__.py b/pbl_validation/__init__.py new file mode 100644 index 0000000..8a71f38 --- /dev/null +++ b/pbl_validation/__init__.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""pbl_validation —— PBL 校验引擎(14 维 + 5 级质量状态,M2) + +注册三处同步之 ②:必须导出 init.py 里的全部契约函数,漏一行 .dspy 调用即 NameError。 +""" +from pbl_validation.init import load_pbl_validation +from pbl_validation.api import ( + pbl_validation_run, + pbl_validation_get, + pbl_validation_list, + pbl_validation_rule_list, + pbl_validation_rule_save, + +) + +__all__ = [ + 'load_pbl_validation', + 'pbl_validation_run', + 'pbl_validation_get', + 'pbl_validation_list', + 'pbl_validation_rule_list', + 'pbl_validation_rule_save', + +] diff --git a/pbl_validation/api.py b/pbl_validation/api.py new file mode 100644 index 0000000..5612ce8 --- /dev/null +++ b/pbl_validation/api.py @@ -0,0 +1,237 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""pbl_validation.api —— 14 维校验引擎 + 5 级 quality_state(第15章,M2)。 + +规则形态:规则体存 `pbl_validation_rule.rule_json`(与 script_engine script_type=1 的 +event→condition→response 同构,**不新建规则引擎**),阈值可配(Q-OPEN-8)。 +缺规则时用内置 DEFAULT_RULES 兜底,保证校验链路永远可跑(fail-closed:无结论=不通过)。 +""" +import json + +from pbl_common.api import (PblError, actor_id, crud, json_dump, now_str, sql_exec, + sql_rows, sql_scalar, tenant_id) + +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'}, +} + + +async def _tid(): + return await tenant_id() + + +def _loads(v, default=None): + if isinstance(v, (dict, list)): + return v + 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 {} + + +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 _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 + + +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)) + 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 _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 + + +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') + if not rows: + raise PblError('PBL_NOT_FOUND', '校验结果不存在') + rows[0]['findings_json'] = _loads(rows[0].get('findings_json'), []) + return {'ok': True, 'data': rows[0]} + + +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) + + +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} + + +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) diff --git a/pbl_validation/init.py b/pbl_validation/init.py new file mode 100644 index 0000000..6818586 --- /dev/null +++ b/pbl_validation/init.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""`load_pbl_validation()` —— pbl_validation 模块唯一挂载入口。 + +注册三处同步之 ③:env.<契约名> = <契约名>(① 定义在 api.py,② 导出在 __init__.py)。 +""" +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, + +) + + +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 + + return 'pbl_validation' diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f89c294 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "pbl_validation" +version = "0.1.0" +description = "PBL 校验引擎(14 维 + 5 级质量状态,M2)" +requires-python = ">=3.9" +dependencies = ["apppublic", "sqlor", "ahserver", "appbase", "rbac"] + +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +packages = ["pbl_validation"] diff --git a/scripts/load_path.py b/scripts/load_path.py new file mode 100644 index 0000000..5553572 --- /dev/null +++ b/scripts/load_path.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""pbl_validation RBAC 路径注册(硬门禁 6.6 / QC #11)。 + +约定: +- 路径 = 模块自动路由 `/pbl_validation/api/<契约>.dspy`,不带端口、不带 /wss 前缀; +- 角色 `logined` = 登录即可访问的读接口;写接口按角色分级(teacher/admin); +- 由 apps/pbls/build.sh 第 8 步调用 `register()`;rbac CLI 不在位时打印清单(不静默跳过)。 +""" +import os +import subprocess +import sys + +MODULE = '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'), + +] + + +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 + + +if __name__ == '__main__': + sys.exit(0 if register() else 1) diff --git a/skill/SKILL.md b/skill/SKILL.md new file mode 100644 index 0000000..dcda9eb --- /dev/null +++ b/skill/SKILL.md @@ -0,0 +1,24 @@ +# pbl_validation 模块技能(自动生成骨架 + 人工补充) + +## 定位 +PBL 校验引擎(14 维 + 5 级质量状态,M2) + +## 挂载 +`from pbl_validation.init import load_pbl_validation` → `load_pbl_validation()`(应用 app/pbls.py init() 中按序调用) + +## 数据表(2 张) +- `pbl_validation_rule`:校验规则(14 维,规则 JSON 形态 script_type=1) +- `pbl_validation_result`:校验结果(14 维 + 5 级质量状态) + +## 契约接口(5 个,路径 `/pbl_validation/api/.dspy`) +- `pbl_validation_run` +- `pbl_validation_get` +- `pbl_validation_list` +- `pbl_validation_rule_list` +- `pbl_validation_rule_save` + +## 陷阱 +- 库名一律 `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 路径。 diff --git a/sql/pbl_validation.sql b/sql/pbl_validation.sql new file mode 100644 index 0000000..ba649e6 --- /dev/null +++ b/sql/pbl_validation.sql @@ -0,0 +1,37 @@ +-- pbl_validation 表 DDL(自动生成,与 apps/pbls/scripts/ddl/pbls_tables.sql 同源) +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)"; + +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 级质量状态)"; + diff --git a/wwwroot/api/pbl_validation_get.dspy b/wwwroot/api/pbl_validation_get.dspy new file mode 100644 index 0000000..8caeb50 --- /dev/null +++ b/wwwroot/api/pbl_validation_get.dspy @@ -0,0 +1,4 @@ +# pbl_validation/api/pbl_validation_get.dspy —— 契约端点(自动生成,勿手改:改 spec 后跑 gen_artifacts.py) +debug('pbl_validation/api/pbl_validation_get.dspy: START params_kw={dict(params_kw)}') +data = await pbl_validation_get(**params_kw) +return data diff --git a/wwwroot/api/pbl_validation_list.dspy b/wwwroot/api/pbl_validation_list.dspy new file mode 100644 index 0000000..dc596bb --- /dev/null +++ b/wwwroot/api/pbl_validation_list.dspy @@ -0,0 +1,4 @@ +# pbl_validation/api/pbl_validation_list.dspy —— 契约端点(自动生成,勿手改:改 spec 后跑 gen_artifacts.py) +debug('pbl_validation/api/pbl_validation_list.dspy: START params_kw={dict(params_kw)}') +data = await pbl_validation_list(**params_kw) +return data diff --git a/wwwroot/api/pbl_validation_rule_list.dspy b/wwwroot/api/pbl_validation_rule_list.dspy new file mode 100644 index 0000000..55d04fb --- /dev/null +++ b/wwwroot/api/pbl_validation_rule_list.dspy @@ -0,0 +1,4 @@ +# pbl_validation/api/pbl_validation_rule_list.dspy —— 契约端点(自动生成,勿手改:改 spec 后跑 gen_artifacts.py) +debug('pbl_validation/api/pbl_validation_rule_list.dspy: START params_kw={dict(params_kw)}') +data = await pbl_validation_rule_list(**params_kw) +return data diff --git a/wwwroot/api/pbl_validation_rule_save.dspy b/wwwroot/api/pbl_validation_rule_save.dspy new file mode 100644 index 0000000..912da03 --- /dev/null +++ b/wwwroot/api/pbl_validation_rule_save.dspy @@ -0,0 +1,4 @@ +# pbl_validation/api/pbl_validation_rule_save.dspy —— 契约端点(自动生成,勿手改:改 spec 后跑 gen_artifacts.py) +debug('pbl_validation/api/pbl_validation_rule_save.dspy: START params_kw={dict(params_kw)}') +data = await pbl_validation_rule_save(**params_kw) +return data diff --git a/wwwroot/api/pbl_validation_run.dspy b/wwwroot/api/pbl_validation_run.dspy new file mode 100644 index 0000000..519559e --- /dev/null +++ b/wwwroot/api/pbl_validation_run.dspy @@ -0,0 +1,4 @@ +# pbl_validation/api/pbl_validation_run.dspy —— 契约端点(自动生成,勿手改:改 spec 后跑 gen_artifacts.py) +debug('pbl_validation/api/pbl_validation_run.dspy: START params_kw={dict(params_kw)}') +data = await pbl_validation_run(**params_kw) +return data diff --git a/wwwroot/index.ui b/wwwroot/index.ui new file mode 100644 index 0000000..1419292 --- /dev/null +++ b/wwwroot/index.ui @@ -0,0 +1,175 @@ +{ + "widgettype": "VBox", + "options": { + "width": "100%", + "height": "100%", + "padding": "20px" + }, + "subwidgets": [ + { + "widgettype": "Text", + "options": { + "label": "PBL 校验引擎(14 维 + 5 级质量状态,M2)", + "fontSize": "24px" + } + }, + { + "widgettype": "ResponsableBox", + "options": { + "gap": "16px", + "minWidth": "250px" + }, + "subwidgets": [ + { + "widgettype": "VBox", + "options": { + "backgroundColor": "#FFFFFF", + "padding": "20px", + "cursor": "pointer" + }, + "binds": [ + { + "wid": "self", + "event": "click", + "actiontype": "urlwidget", + "target": "app.pbl_validation_content", + "options": { + "url": "{{entire_url('api/pbl_validation_run.dspy')}}" + }, + "mode": "replace" + } + ], + "subwidgets": [ + { + "widgettype": "Text", + "options": { + "label": "pbl_validation_run" + } + } + ] + }, + { + "widgettype": "VBox", + "options": { + "backgroundColor": "#FFFFFF", + "padding": "20px", + "cursor": "pointer" + }, + "binds": [ + { + "wid": "self", + "event": "click", + "actiontype": "urlwidget", + "target": "app.pbl_validation_content", + "options": { + "url": "{{entire_url('api/pbl_validation_get.dspy')}}" + }, + "mode": "replace" + } + ], + "subwidgets": [ + { + "widgettype": "Text", + "options": { + "label": "pbl_validation_get" + } + } + ] + }, + { + "widgettype": "VBox", + "options": { + "backgroundColor": "#FFFFFF", + "padding": "20px", + "cursor": "pointer" + }, + "binds": [ + { + "wid": "self", + "event": "click", + "actiontype": "urlwidget", + "target": "app.pbl_validation_content", + "options": { + "url": "{{entire_url('api/pbl_validation_list.dspy')}}" + }, + "mode": "replace" + } + ], + "subwidgets": [ + { + "widgettype": "Text", + "options": { + "label": "pbl_validation_list" + } + } + ] + }, + { + "widgettype": "VBox", + "options": { + "backgroundColor": "#FFFFFF", + "padding": "20px", + "cursor": "pointer" + }, + "binds": [ + { + "wid": "self", + "event": "click", + "actiontype": "urlwidget", + "target": "app.pbl_validation_content", + "options": { + "url": "{{entire_url('api/pbl_validation_rule_list.dspy')}}" + }, + "mode": "replace" + } + ], + "subwidgets": [ + { + "widgettype": "Text", + "options": { + "label": "pbl_validation_rule_list" + } + } + ] + }, + { + "widgettype": "VBox", + "options": { + "backgroundColor": "#FFFFFF", + "padding": "20px", + "cursor": "pointer" + }, + "binds": [ + { + "wid": "self", + "event": "click", + "actiontype": "urlwidget", + "target": "app.pbl_validation_content", + "options": { + "url": "{{entire_url('api/pbl_validation_rule_save.dspy')}}" + }, + "mode": "replace" + } + ], + "subwidgets": [ + { + "widgettype": "Text", + "options": { + "label": "pbl_validation_rule_save" + } + } + ] + } + ] + }, + { + "widgettype": "VBox", + "id": "pbl_validation_content", + "options": { + "width": "100%", + "flex": "1", + "marginTop": "20px" + } + } + ] +}