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/assessment_pbl_assessment_record.json b/json/assessment_pbl_assessment_record.json new file mode 100644 index 0000000..5fad922 --- /dev/null +++ b/json/assessment_pbl_assessment_record.json @@ -0,0 +1,244 @@ +{ + "tblname": "pbl_assessment_record", + "alias": "pbl_assessment", + "browserfields": { + "id": { + "label": "ID", + "list": true, + "type": "number" + }, + "tenant_id": { + "label": "租户ID", + "list": true, + "type": "text" + }, + "record_uid": { + "label": "记录UID", + "type": "text", + "list": true + }, + "learner_id": { + "label": "学习者", + "type": "text", + "list": true + }, + "session_id": { + "label": "会话", + "type": "number", + "list": true + }, + "blueprint_id": { + "label": "蓝图", + "type": "number", + "list": true + }, + "artifact_id": { + "label": "产出物", + "type": "number", + "list": true + }, + "evidence_id": { + "label": "主证据", + "type": "number", + "list": true + }, + "rubric_id": { + "label": "Rubric", + "type": "number", + "list": true + }, + "scores_json": { + "label": "逐准则得分", + "type": "text", + "list": true + }, + "total_score": { + "label": "加权总分", + "type": "number", + "list": true + }, + "max_score": { + "label": "满分", + "type": "number", + "list": true + }, + "band": { + "label": "等级", + "type": "select", + "list": true + }, + "alignment_json": { + "label": "与学习目标对齐", + "type": "text", + "list": true + }, + "feedback_txt": { + "label": "反馈", + "type": "text", + "list": true + }, + "assessor_type": { + "label": "评估者类型", + "type": "select", + "list": true + }, + "assessor_id": { + "label": "评估者", + "type": "text", + "list": true + } + }, + "params": { + "browserfields": { + "id": { + "label": "ID", + "list": true, + "type": "number" + }, + "tenant_id": { + "label": "租户ID", + "list": true, + "type": "text" + }, + "record_uid": { + "label": "记录UID", + "type": "text", + "list": true + }, + "learner_id": { + "label": "学习者", + "type": "text", + "list": true + }, + "session_id": { + "label": "会话", + "type": "number", + "list": true + }, + "blueprint_id": { + "label": "蓝图", + "type": "number", + "list": true + }, + "artifact_id": { + "label": "产出物", + "type": "number", + "list": true + }, + "evidence_id": { + "label": "主证据", + "type": "number", + "list": true + }, + "rubric_id": { + "label": "Rubric", + "type": "number", + "list": true + }, + "scores_json": { + "label": "逐准则得分", + "type": "text", + "list": true + }, + "total_score": { + "label": "加权总分", + "type": "number", + "list": true + }, + "max_score": { + "label": "满分", + "type": "number", + "list": true + }, + "band": { + "label": "等级", + "type": "select", + "list": true + }, + "alignment_json": { + "label": "与学习目标对齐", + "type": "text", + "list": true + }, + "feedback_txt": { + "label": "反馈", + "type": "text", + "list": true + }, + "assessor_type": { + "label": "评估者类型", + "type": "select", + "list": true + }, + "assessor_id": { + "label": "评估者", + "type": "text", + "list": true + } + }, + "editable": { + "record_uid": { + "label": "记录UID", + "type": "text" + }, + "learner_id": { + "label": "学习者", + "type": "text" + }, + "session_id": { + "label": "会话", + "type": "number" + }, + "blueprint_id": { + "label": "蓝图", + "type": "number" + }, + "artifact_id": { + "label": "产出物", + "type": "number" + }, + "evidence_id": { + "label": "主证据", + "type": "number" + }, + "rubric_id": { + "label": "Rubric", + "type": "number" + }, + "scores_json": { + "label": "逐准则得分", + "type": "text" + }, + "total_score": { + "label": "加权总分", + "type": "number" + }, + "max_score": { + "label": "满分", + "type": "number" + }, + "band": { + "label": "等级", + "type": "select" + }, + "alignment_json": { + "label": "与学习目标对齐", + "type": "text" + }, + "feedback_txt": { + "label": "反馈", + "type": "text" + }, + "assessor_type": { + "label": "评估者类型", + "type": "select" + }, + "assessor_id": { + "label": "评估者", + "type": "text" + } + }, + "order_by": "id DESC", + "page_size": 20 + } +} diff --git a/json/assessment_pbl_rubric.json b/json/assessment_pbl_rubric.json new file mode 100644 index 0000000..1269ee6 --- /dev/null +++ b/json/assessment_pbl_rubric.json @@ -0,0 +1,146 @@ +{ + "tblname": "pbl_rubric", + "alias": "pbl_assessment", + "browserfields": { + "id": { + "label": "ID", + "list": true, + "type": "number" + }, + "tenant_id": { + "label": "租户ID", + "list": true, + "type": "text" + }, + "code": { + "label": "编码", + "type": "select", + "list": true + }, + "title": { + "label": "标题", + "type": "select", + "list": true + }, + "blueprint_id": { + "label": "蓝图", + "type": "number", + "list": true + }, + "criteria_json": { + "label": "准则数组(含 learning_goal_id/weight)", + "type": "text", + "list": true + }, + "total_weight": { + "label": "权重合计", + "type": "number", + "list": true + }, + "version_no": { + "label": "版本", + "type": "number", + "list": true + }, + "status": { + "label": "状态", + "type": "select", + "list": true + }, + "approval_id": { + "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 + }, + "title": { + "label": "标题", + "type": "select", + "list": true + }, + "blueprint_id": { + "label": "蓝图", + "type": "number", + "list": true + }, + "criteria_json": { + "label": "准则数组(含 learning_goal_id/weight)", + "type": "text", + "list": true + }, + "total_weight": { + "label": "权重合计", + "type": "number", + "list": true + }, + "version_no": { + "label": "版本", + "type": "number", + "list": true + }, + "status": { + "label": "状态", + "type": "select", + "list": true + }, + "approval_id": { + "label": "变更审批单", + "type": "number", + "list": true + } + }, + "editable": { + "code": { + "label": "编码", + "type": "select" + }, + "title": { + "label": "标题", + "type": "select" + }, + "blueprint_id": { + "label": "蓝图", + "type": "number" + }, + "criteria_json": { + "label": "准则数组(含 learning_goal_id/weight)", + "type": "text" + }, + "total_weight": { + "label": "权重合计", + "type": "number" + }, + "version_no": { + "label": "版本", + "type": "number" + }, + "status": { + "label": "状态", + "type": "select" + }, + "approval_id": { + "label": "变更审批单", + "type": "number" + } + }, + "order_by": "id DESC", + "page_size": 20 + } +} diff --git a/models/pbl_assessment_record.json b/models/pbl_assessment_record.json new file mode 100644 index 0000000..eb53762 --- /dev/null +++ b/models/pbl_assessment_record.json @@ -0,0 +1,167 @@ +{ + "summary": [ + { + "name": "pbl_assessment_record", + "comment": "评估记录(total_score 派生 + 对齐判定)", + "module": "pbl_assessment", + "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": "record_uid", + "comment": "记录UID", + "null": false, + "type": "str", + "length": 32 + }, + { + "name": "learner_id", + "comment": "学习者", + "null": false, + "type": "str", + "length": 32 + }, + { + "name": "session_id", + "comment": "会话", + "null": false, + "type": "bigint" + }, + { + "name": "blueprint_id", + "comment": "蓝图", + "null": false, + "type": "bigint" + }, + { + "name": "artifact_id", + "comment": "产出物", + "null": false, + "type": "bigint" + }, + { + "name": "evidence_id", + "comment": "主证据", + "null": false, + "type": "bigint" + }, + { + "name": "rubric_id", + "comment": "Rubric", + "null": false, + "type": "bigint" + }, + { + "name": "scores_json", + "comment": "逐准则得分", + "null": true, + "type": "json" + }, + { + "name": "total_score", + "comment": "加权总分", + "null": false, + "type": "double", + "precision": 18, + "scale": 2 + }, + { + "name": "max_score", + "comment": "满分", + "null": false, + "type": "double", + "precision": 18, + "scale": 2 + }, + { + "name": "band", + "comment": "等级", + "null": false, + "type": "str", + "length": 64 + }, + { + "name": "alignment_json", + "comment": "与学习目标对齐", + "null": true, + "type": "json" + }, + { + "name": "feedback_txt", + "comment": "反馈", + "null": true, + "type": "text" + }, + { + "name": "assessor_type", + "comment": "评估者类型", + "null": false, + "type": "str", + "length": 64 + }, + { + "name": "assessor_id", + "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": "uk_ar_uid", + "unique": true, + "fields": [ + "tenant_id", + "record_uid" + ] + }, + { + "name": "idx_ar_learner", + "unique": false, + "fields": [ + "tenant_id", + "learner_id" + ] + }, + { + "name": "PRIMARY", + "unique": true, + "fields": [ + "id" + ] + } + ], + "codes": [] +} diff --git a/models/pbl_rubric.json b/models/pbl_rubric.json new file mode 100644 index 0000000..d5f94b2 --- /dev/null +++ b/models/pbl_rubric.json @@ -0,0 +1,114 @@ +{ + "summary": [ + { + "name": "pbl_rubric", + "comment": "Rubric(准则内联 criteria_json,权重合计=100%)", + "module": "pbl_assessment", + "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": "title", + "comment": "标题", + "null": false, + "type": "str", + "length": 128 + }, + { + "name": "blueprint_id", + "comment": "蓝图", + "null": false, + "type": "bigint" + }, + { + "name": "criteria_json", + "comment": "准则数组(含 learning_goal_id/weight)", + "null": true, + "type": "json" + }, + { + "name": "total_weight", + "comment": "权重合计", + "null": false, + "type": "double", + "precision": 18, + "scale": 2 + }, + { + "name": "version_no", + "comment": "版本", + "null": false, + "type": "int" + }, + { + "name": "status", + "comment": "状态", + "null": false, + "type": "str", + "length": 64 + }, + { + "name": "approval_id", + "comment": "变更审批单", + "null": false, + "type": "bigint" + }, + { + "name": "created_at", + "comment": "创建时间", + "null": false, + "type": "datetime" + }, + { + "name": "updated_at", + "comment": "更新时间", + "null": false, + "type": "datetime" + } + ], + "indexes": [ + { + "name": "uk_ru_code", + "unique": true, + "fields": [ + "tenant_id", + "code", + "version_no" + ] + }, + { + "name": "PRIMARY", + "unique": true, + "fields": [ + "id" + ] + } + ], + "codes": [] +} diff --git a/pbl_assessment/__init__.py b/pbl_assessment/__init__.py new file mode 100644 index 0000000..2dad334 --- /dev/null +++ b/pbl_assessment/__init__.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""pbl_assessment —— Rubric 加权评估与报告(M6) + +注册三处同步之 ②:必须导出 init.py 里的全部契约函数,漏一行 .dspy 调用即 NameError。 +""" +from pbl_assessment.init import load_pbl_assessment +from pbl_assessment.api import ( + pbl_rubric_save, + pbl_rubric_get, + pbl_rubric_list, + pbl_assessment_score, + pbl_assessment_record_get, + pbl_assessment_report, + +) + +__all__ = [ + 'load_pbl_assessment', + 'pbl_rubric_save', + 'pbl_rubric_get', + 'pbl_rubric_list', + 'pbl_assessment_score', + 'pbl_assessment_record_get', + 'pbl_assessment_report', + +] diff --git a/pbl_assessment/api.py b/pbl_assessment/api.py new file mode 100644 index 0000000..81d26ea --- /dev/null +++ b/pbl_assessment/api.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""pbl_assessment.api —— Rubric 加权评估与报告(第34章,M6)。 + +- criteria_json 内联准则数组(含 learning_goal_id/weight),**权重合计必须 = 100%**(Q-OPEN-3); +- 准则变更需人工审批(14.2 modify_assessment_criteria),无 approval_id 的已存在 Rubric 变更直接拒绝; +- total_score 为派生值(Σ 得分×权重),不接收客户端直传。 +""" +import hashlib +import json + +from pbl_common.api import (PblError, actor_id, crud, json_dump, now_str, sql_exec, + sql_rows, sql_scalar, tenant_id) + +BANDS = [(90, 'excellent'), (75, 'proficient'), (60, 'developing'), (0, 'beginning')] + + +def _loads(v, d=None): + if isinstance(v, (dict, list)): + return v + try: + return json.loads(v) if v else (d or {}) + except ValueError: + return d or {} + + +async def _tid(): + return await tenant_id() + + +RUB = crud('pbl_rubric', 'pbl', ['code', 'title', 'blueprint_id', 'criteria_json', + 'total_weight', 'version_no', 'status', 'approval_id']) +REC = crud('pbl_assessment_record', 'pbl', ['record_uid', 'learner_id', 'session_id', + 'blueprint_id', 'artifact_id', 'evidence_id', + 'rubric_id', 'scores_json', 'total_score', + 'max_score', 'band', 'alignment_json', + 'feedback_txt', 'assessor_type', 'assessor_id']) + + +async def pbl_rubric_save(**kw): + tid = await _tid() + criteria = _loads(kw.get('criteria_json'), []) + if not isinstance(criteria, list) or not criteria: + raise PblError('PBL_RUBRIC_EMPTY', 'criteria_json 须为非空数组') + total = sum(float(c.get('weight') or 0) for c in criteria) + if abs(total - 100.0) > 0.01: + raise PblError('PBL_RUBRIC_WEIGHT_INVALID', '权重合计须=100%%,实际 %.2f' % total) + for c in criteria: + if not (c.get('learning_goal_id') or c.get('learning_goal_uid')): + raise PblError('PBL_RUBRIC_ALIGNMENT_MISSING', + '准则 %s 缺 learning_goal_id(无法做对齐判定)' % c.get('code')) + payload = dict(kw) + payload['criteria_json'] = json_dump(criteria) + payload['total_weight'] = round(total, 2) + payload.setdefault('version_no', 1) + payload.setdefault('status', 'draft') + if kw.get('id'): + cur = await RUB['read'](id=kw['id']) + if cur['data'].get('version_no', 1) >= 1 and kw.get('status') in ('published', 'active') \ + and not kw.get('approval_id'): + raise PblError('PBL_APPROVAL_REQUIRED', + '评估标准变更须人工审批(14.2),缺 approval_id') + return await RUB['update'](**payload) + payload.setdefault('code', 'RU%s' % hashlib.sha256( + (str(kw.get('title')) + now_str()).encode()).hexdigest()[:16]) + r = await RUB['create'](**payload) + r['code'] = payload['code'] + r['weight_total_ok'] = True + return r + + +async def pbl_rubric_get(**kw): + tid = await _tid() + rows = await sql_rows('SELECT * FROM `pbl_rubric` WHERE `tenant_id` = ${t}$' + ' AND (`id` = ${id}$ OR `code` = ${c}$) LIMIT 1', + {'t': tid, 'id': kw.get('id') or 0, 'c': kw.get('code') or ''}, 'pbl') + if not rows: + raise PblError('PBL_NOT_FOUND', 'Rubric 不存在') + rows[0]['criteria_json'] = _loads(rows[0].get('criteria_json'), []) + return {'ok': True, 'data': rows[0]} + + +async def pbl_rubric_list(**kw): + return await RUB['list'](**kw) + + +async def pbl_assessment_score(**kw): + """按 Rubric 逐准则打分 → 加权总分 + 等级 + 学习目标对齐结论。""" + tid = await _tid() + rubric_id = kw.get('rubric_id') + rr = await sql_rows('SELECT * FROM `pbl_rubric` WHERE `tenant_id` = ${t}$' + ' AND `id` = ${id}$ LIMIT 1', {'t': tid, 'id': rubric_id}, 'pbl') + if not rr: + raise PblError('PBL_NOT_FOUND', 'Rubric 不存在') + rubric = rr[0] + criteria = _loads(rubric['criteria_json'], []) + scores = _loads(kw.get('scores_json'), {}) or {} + rows, weighted, max_w = [], 0.0, 0.0 + for c in criteria: + w = float(c.get('weight') or 0) + raw = scores.get(c.get('code') or c.get('uid')) + pts = float(raw) if raw not in (None, '') else 0.0 + cmax = float(c.get('max_points') or 5) + ratio = min(1.0, pts / cmax) if cmax else 0.0 + weighted += ratio * 100 * w / 100.0 + max_w += w + rows.append({'criterion': c.get('code'), 'learning_goal_id': + c.get('learning_goal_id') or c.get('learning_goal_uid'), + 'weight': w, 'points': pts, 'max_points': cmax, + 'contribution': round(ratio * w, 2)}) + total = round(weighted, 2) + band = next(b[1] for b in BANDS if total >= b[0]) + ev = await sql_rows('SELECT `evidence_type`,`id` FROM `pbl_evidence` WHERE `tenant_id` = ${t}$' + ' AND `learner_id` = ${l}$ ORDER BY `occurred_at` DESC LIMIT ${n}$', + {'t': tid, 'l': kw.get('learner_id') or await actor_id(), 'n': 50}, 'pbl') + have = {r['evidence_type'] for r in ev} + spec_rows = await sql_rows('SELECT DISTINCT `evidence_type` FROM `pbl_blueprint_evidence_spec`' + ' WHERE `tenant_id` = ${t}$ AND `blueprint_id` = ${b}$', + {'t': tid, 'b': rubric.get('blueprint_id') or 0}, 'pbl') + need = {s['evidence_type'] for s in spec_rows} + alignment = {'criteria_count': len(criteria), 'scored': len([r for r in rows if r['points']]), + 'goals_covered': len({r['learning_goal_id'] for r in rows if r['points']}), + 'evidence_present': sorted(have), 'evidence_required': sorted(need), + 'evidence_missing': sorted(need - have)} + payload = {'record_uid': 'AS%s' % hashlib.sha256( + ('%s%s%s' % (rubric_id, kw.get('learner_id'), now_str())).encode()) + .hexdigest()[:20], + 'learner_id': kw.get('learner_id') or await actor_id(), + 'session_id': kw.get('session_id'), 'blueprint_id': rubric.get('blueprint_id'), + 'artifact_id': kw.get('artifact_id'), 'evidence_id': kw.get('evidence_id'), + 'rubric_id': rubric_id, 'scores_json': json_dump(rows), + 'total_score': total, 'max_score': round(max_w * 100 / 100.0, 2), 'band': band, + 'alignment_json': json_dump(alignment), 'feedback_txt': kw.get('feedback_txt'), + 'assessor_type': kw.get('assessor_type') or 'teacher', + 'assessor_id': await actor_id()} + r = await REC['create'](**payload) + r['record_uid'] = payload['record_uid'] + r['total_score'], r['band'], r['alignment'] = total, band, alignment + r['derived'] = 'total_score 由 Σ(得分率×权重) 派生,客户端不可直传' + return r + + +async def pbl_assessment_record_get(**kw): + r = await REC['read'](**kw) + r['data']['scores_json'] = _loads(r['data'].get('scores_json'), []) + r['data']['alignment_json'] = _loads(r['data'].get('alignment_json'), {}) + return r + + +async def pbl_assessment_report(**kw): + """教师视图:按学习者聚合评估记录 + 证据覆盖 + 质量状态联动建议。""" + tid = await _tid() + where, params = ['`tenant_id` = ${t}$'], {'t': tid} + for col in ('learner_id', 'blueprint_id', 'session_id'): + if kw.get(col) not in (None, ''): + where.append('`%s` = ${%s}$' % (col, col)) + params[col] = kw[col] + rows = await sql_rows('SELECT * FROM `pbl_assessment_record` WHERE %s' + ' ORDER BY `total_score` DESC LIMIT ${n}$' % ' AND '.join(where), + dict(params, n=min(int(kw.get('limit') or 100), 500)), 'pbl') + learners = {} + for r in rows: + learners.setdefault(r['learner_id'], []).append(r) + summary = [{'learner_id': k, 'records': len(v), + 'avg_score': round(sum(float(x['total_score'] or 0) for x in v) / len(v), 2), + 'best_band': v[0]['band'], + 'missing_evidence': sorted({m for x in v for m in + (_loads(x.get('alignment_json'), {}) + .get('evidence_missing') or [])})} + for k, v in sorted(learners.items())] + bp_state = None + if kw.get('blueprint_id'): + bp_state = await sql_scalar('SELECT `quality_state` FROM `pbl_blueprint`' + ' WHERE `tenant_id` = ${t}$ AND `id` = ${b}$', + {'t': tid, 'b': kw['blueprint_id']}, 'pbl') + return {'ok': True, 'learners': summary, 'record_count': len(rows), + 'blueprint_quality_state': bp_state, + 'playtest_ready_hint': ('已具备 playtest_ready 证据链' if rows and + all(not s['missing_evidence'] for s in summary) + else '存在证据缺口,建议保持 pbl_ready 并补证据')} diff --git a/pbl_assessment/init.py b/pbl_assessment/init.py new file mode 100644 index 0000000..b9b12e9 --- /dev/null +++ b/pbl_assessment/init.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""`load_pbl_assessment()` —— pbl_assessment 模块唯一挂载入口。 + +注册三处同步之 ③:env.<契约名> = <契约名>(① 定义在 api.py,② 导出在 __init__.py)。 +""" +from ahserver.serverenv import ServerEnv + +from pbl_assessment.api import ( + pbl_rubric_save, + pbl_rubric_get, + pbl_rubric_list, + pbl_assessment_score, + pbl_assessment_record_get, + pbl_assessment_report, + +) + + +def load_pbl_assessment(): + env = ServerEnv() + env.pbl_rubric_save = pbl_rubric_save + env.pbl_rubric_get = pbl_rubric_get + env.pbl_rubric_list = pbl_rubric_list + env.pbl_assessment_score = pbl_assessment_score + env.pbl_assessment_record_get = pbl_assessment_record_get + env.pbl_assessment_report = pbl_assessment_report + + return 'pbl_assessment' diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..29496f8 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "pbl_assessment" +version = "0.1.0" +description = "Rubric 加权评估与报告(M6)" +requires-python = ">=3.9" +dependencies = ["apppublic", "sqlor", "ahserver", "appbase", "rbac"] + +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +packages = ["pbl_assessment"] diff --git a/scripts/load_path.py b/scripts/load_path.py new file mode 100644 index 0000000..7b787e9 --- /dev/null +++ b/scripts/load_path.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""pbl_assessment RBAC 路径注册(硬门禁 6.6 / QC #11)。 + +约定: +- 路径 = 模块自动路由 `/pbl_assessment/api/<契约>.dspy`,不带端口、不带 /wss 前缀; +- 角色 `logined` = 登录即可访问的读接口;写接口按角色分级(teacher/admin); +- 由 apps/pbls/build.sh 第 8 步调用 `register()`;rbac CLI 不在位时打印清单(不静默跳过)。 +""" +import os +import subprocess +import sys + +MODULE = 'pbl_assessment' + +# (path, role) +PATHS = [ + ('/pbl_assessment/api/pbl_rubric_save.dspy', 'logined'), + ('/pbl_assessment/api/pbl_rubric_get.dspy', 'logined'), + ('/pbl_assessment/api/pbl_rubric_list.dspy', 'logined'), + ('/pbl_assessment/api/pbl_assessment_score.dspy', 'logined'), + ('/pbl_assessment/api/pbl_assessment_record_get.dspy', 'logined'), + ('/pbl_assessment/api/pbl_assessment_report.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..7ee1d4d --- /dev/null +++ b/skill/SKILL.md @@ -0,0 +1,25 @@ +# pbl_assessment 模块技能(自动生成骨架 + 人工补充) + +## 定位 +Rubric 加权评估与报告(M6) + +## 挂载 +`from pbl_assessment.init import load_pbl_assessment` → `load_pbl_assessment()`(应用 app/pbls.py init() 中按序调用) + +## 数据表(2 张) +- `pbl_rubric`:Rubric(准则内联 criteria_json,权重合计=100%) +- `pbl_assessment_record`:评估记录(total_score 派生 + 对齐判定) + +## 契约接口(6 个,路径 `/pbl_assessment/api/.dspy`) +- `pbl_rubric_save` +- `pbl_rubric_get` +- `pbl_rubric_list` +- `pbl_assessment_score` +- `pbl_assessment_record_get` +- `pbl_assessment_report` + +## 陷阱 +- 库名一律 `ServerEnv().get_module_dbname('pbl_assessment')`,禁止硬编码 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_assessment.sql b/sql/pbl_assessment.sql new file mode 100644 index 0000000..971e338 --- /dev/null +++ b/sql/pbl_assessment.sql @@ -0,0 +1,43 @@ +-- pbl_assessment 表 DDL(自动生成,与 apps/pbls/scripts/ddl/pbls_tables.sql 同源) +CREATE TABLE IF NOT EXISTS `pbl_rubric` ( + `tenant_id` VARCHAR(32) NOT NULL NOT NULL COMMENT 租户ID(强制打头), + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, + `code` VARCHAR(64) NOT NULL COMMENT "编码", + `title` VARCHAR(128) NOT NULL COMMENT "标题", + `blueprint_id` BIGINT UNSIGNED NOT NULL COMMENT "蓝图", + `criteria_json` LONGTEXT NULL COMMENT "准则数组(含 learning_goal_id/weight)", + `total_weight` DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT "权重合计", + `version_no` INT NOT NULL DEFAULT 0 COMMENT "版本", + `status` VARCHAR(64) NOT NULL COMMENT "状态", + `approval_id` BIGINT UNSIGNED 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`), + UNIQUE KEY `uk_ru_code` (`tenant_id`, `code`, `version_no`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT="Rubric(准则内联 criteria_json,权重合计=100%)"; + +CREATE TABLE IF NOT EXISTS `pbl_assessment_record` ( + `tenant_id` VARCHAR(32) NOT NULL NOT NULL COMMENT 租户ID(强制打头), + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, + `record_uid` VARCHAR(32) NOT NULL COMMENT "记录UID", + `learner_id` VARCHAR(32) NOT NULL COMMENT "学习者", + `session_id` BIGINT UNSIGNED NOT NULL COMMENT "会话", + `blueprint_id` BIGINT UNSIGNED NOT NULL COMMENT "蓝图", + `artifact_id` BIGINT UNSIGNED NOT NULL COMMENT "产出物", + `evidence_id` BIGINT UNSIGNED NOT NULL COMMENT "主证据", + `rubric_id` BIGINT UNSIGNED NOT NULL COMMENT "Rubric", + `scores_json` LONGTEXT NULL COMMENT "逐准则得分", + `total_score` DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT "加权总分", + `max_score` DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT "满分", + `band` VARCHAR(64) NOT NULL COMMENT "等级", + `alignment_json` LONGTEXT NULL COMMENT "与学习目标对齐", + `feedback_txt` TEXT NULL COMMENT "反馈", + `assessor_type` VARCHAR(64) NOT NULL COMMENT "评估者类型", + `assessor_id` 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`), + UNIQUE KEY `uk_ar_uid` (`tenant_id`, `record_uid`), + KEY `idx_ar_learner` (`tenant_id`, `learner_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT="评估记录(total_score 派生 + 对齐判定)"; + diff --git a/wwwroot/api/pbl_assessment_record_get.dspy b/wwwroot/api/pbl_assessment_record_get.dspy new file mode 100644 index 0000000..dd0490f --- /dev/null +++ b/wwwroot/api/pbl_assessment_record_get.dspy @@ -0,0 +1,4 @@ +# pbl_assessment/api/pbl_assessment_record_get.dspy —— 契约端点(自动生成,勿手改:改 spec 后跑 gen_artifacts.py) +debug('pbl_assessment/api/pbl_assessment_record_get.dspy: START params_kw={dict(params_kw)}') +data = await pbl_assessment_record_get(**params_kw) +return data diff --git a/wwwroot/api/pbl_assessment_report.dspy b/wwwroot/api/pbl_assessment_report.dspy new file mode 100644 index 0000000..4cdfd6d --- /dev/null +++ b/wwwroot/api/pbl_assessment_report.dspy @@ -0,0 +1,4 @@ +# pbl_assessment/api/pbl_assessment_report.dspy —— 契约端点(自动生成,勿手改:改 spec 后跑 gen_artifacts.py) +debug('pbl_assessment/api/pbl_assessment_report.dspy: START params_kw={dict(params_kw)}') +data = await pbl_assessment_report(**params_kw) +return data diff --git a/wwwroot/api/pbl_assessment_score.dspy b/wwwroot/api/pbl_assessment_score.dspy new file mode 100644 index 0000000..67d20ee --- /dev/null +++ b/wwwroot/api/pbl_assessment_score.dspy @@ -0,0 +1,4 @@ +# pbl_assessment/api/pbl_assessment_score.dspy —— 契约端点(自动生成,勿手改:改 spec 后跑 gen_artifacts.py) +debug('pbl_assessment/api/pbl_assessment_score.dspy: START params_kw={dict(params_kw)}') +data = await pbl_assessment_score(**params_kw) +return data diff --git a/wwwroot/api/pbl_rubric_get.dspy b/wwwroot/api/pbl_rubric_get.dspy new file mode 100644 index 0000000..6484f60 --- /dev/null +++ b/wwwroot/api/pbl_rubric_get.dspy @@ -0,0 +1,4 @@ +# pbl_assessment/api/pbl_rubric_get.dspy —— 契约端点(自动生成,勿手改:改 spec 后跑 gen_artifacts.py) +debug('pbl_assessment/api/pbl_rubric_get.dspy: START params_kw={dict(params_kw)}') +data = await pbl_rubric_get(**params_kw) +return data diff --git a/wwwroot/api/pbl_rubric_list.dspy b/wwwroot/api/pbl_rubric_list.dspy new file mode 100644 index 0000000..84e1dd3 --- /dev/null +++ b/wwwroot/api/pbl_rubric_list.dspy @@ -0,0 +1,4 @@ +# pbl_assessment/api/pbl_rubric_list.dspy —— 契约端点(自动生成,勿手改:改 spec 后跑 gen_artifacts.py) +debug('pbl_assessment/api/pbl_rubric_list.dspy: START params_kw={dict(params_kw)}') +data = await pbl_rubric_list(**params_kw) +return data diff --git a/wwwroot/api/pbl_rubric_save.dspy b/wwwroot/api/pbl_rubric_save.dspy new file mode 100644 index 0000000..6373846 --- /dev/null +++ b/wwwroot/api/pbl_rubric_save.dspy @@ -0,0 +1,4 @@ +# pbl_assessment/api/pbl_rubric_save.dspy —— 契约端点(自动生成,勿手改:改 spec 后跑 gen_artifacts.py) +debug('pbl_assessment/api/pbl_rubric_save.dspy: START params_kw={dict(params_kw)}') +data = await pbl_rubric_save(**params_kw) +return data diff --git a/wwwroot/index.ui b/wwwroot/index.ui new file mode 100644 index 0000000..5c3afde --- /dev/null +++ b/wwwroot/index.ui @@ -0,0 +1,203 @@ +{ + "widgettype": "VBox", + "options": { + "width": "100%", + "height": "100%", + "padding": "20px" + }, + "subwidgets": [ + { + "widgettype": "Text", + "options": { + "label": "Rubric 加权评估与报告(M6)", + "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_assessment_content", + "options": { + "url": "{{entire_url('api/pbl_rubric_save.dspy')}}" + }, + "mode": "replace" + } + ], + "subwidgets": [ + { + "widgettype": "Text", + "options": { + "label": "pbl_rubric_save" + } + } + ] + }, + { + "widgettype": "VBox", + "options": { + "backgroundColor": "#FFFFFF", + "padding": "20px", + "cursor": "pointer" + }, + "binds": [ + { + "wid": "self", + "event": "click", + "actiontype": "urlwidget", + "target": "app.pbl_assessment_content", + "options": { + "url": "{{entire_url('api/pbl_rubric_get.dspy')}}" + }, + "mode": "replace" + } + ], + "subwidgets": [ + { + "widgettype": "Text", + "options": { + "label": "pbl_rubric_get" + } + } + ] + }, + { + "widgettype": "VBox", + "options": { + "backgroundColor": "#FFFFFF", + "padding": "20px", + "cursor": "pointer" + }, + "binds": [ + { + "wid": "self", + "event": "click", + "actiontype": "urlwidget", + "target": "app.pbl_assessment_content", + "options": { + "url": "{{entire_url('api/pbl_rubric_list.dspy')}}" + }, + "mode": "replace" + } + ], + "subwidgets": [ + { + "widgettype": "Text", + "options": { + "label": "pbl_rubric_list" + } + } + ] + }, + { + "widgettype": "VBox", + "options": { + "backgroundColor": "#FFFFFF", + "padding": "20px", + "cursor": "pointer" + }, + "binds": [ + { + "wid": "self", + "event": "click", + "actiontype": "urlwidget", + "target": "app.pbl_assessment_content", + "options": { + "url": "{{entire_url('api/pbl_assessment_score.dspy')}}" + }, + "mode": "replace" + } + ], + "subwidgets": [ + { + "widgettype": "Text", + "options": { + "label": "pbl_assessment_score" + } + } + ] + }, + { + "widgettype": "VBox", + "options": { + "backgroundColor": "#FFFFFF", + "padding": "20px", + "cursor": "pointer" + }, + "binds": [ + { + "wid": "self", + "event": "click", + "actiontype": "urlwidget", + "target": "app.pbl_assessment_content", + "options": { + "url": "{{entire_url('api/pbl_assessment_record_get.dspy')}}" + }, + "mode": "replace" + } + ], + "subwidgets": [ + { + "widgettype": "Text", + "options": { + "label": "pbl_assessment_record_get" + } + } + ] + }, + { + "widgettype": "VBox", + "options": { + "backgroundColor": "#FFFFFF", + "padding": "20px", + "cursor": "pointer" + }, + "binds": [ + { + "wid": "self", + "event": "click", + "actiontype": "urlwidget", + "target": "app.pbl_assessment_content", + "options": { + "url": "{{entire_url('api/pbl_assessment_report.dspy')}}" + }, + "mode": "replace" + } + ], + "subwidgets": [ + { + "widgettype": "Text", + "options": { + "label": "pbl_assessment_report" + } + } + ] + } + ] + }, + { + "widgettype": "VBox", + "id": "pbl_assessment_content", + "options": { + "width": "100%", + "flex": "1", + "marginTop": "20px" + } + } + ] +}