From 3ed859739205a2edeac522cfde37cc91afcdd04b Mon Sep 17 00:00:00 2001 From: baran Date: Thu, 2 Jul 2026 21:23:20 +0200 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20phase=205=20=E2=80=94=20determinism?= =?UTF-8?q?=20classification,=20per-check=20config,=20activation=20authori?= =?UTF-8?q?ty?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Derive deterministic/stochastic badge from check type (no DB column): template/generated → deterministic, judge → stochastic - CheckResult gains `type` field, stored in CaseResult.checks JSON for display - registry.py stamps type on every result including error fallbacks - New update_check_params() in service.py; deep-copies config to ensure SQLAlchemy JSON dirty detection; draft-only, recomputes content_hash - _build_check_list adds determinism + check_index fields - New GET /pipelines/versions/{vid}/checks and PATCH /pipelines/versions/{vid}/check-params endpoints - POST /pipelines/generate surfaces PermissionError (activated:false + permission_error) instead of silently swallowing it in enforced mode - determinism_badge macro added to _macros.html - pipeline_review.html: badge per check row + All/Deterministic/Stochastic filter pills - pipeline_new.html Step 3: fetches persisted checks, shows badges, inline param editing, draft warning banner for enforced-mode non-reviewer flows - _transcript.html: badge + score/threshold display per check (graceful for old records) - 10 new tests in test_phase5.py; all 143 tests passing Co-Authored-By: Claude Sonnet 4.6 --- assay/checks/base.py | 4 +- assay/checks/registry.py | 15 +- assay/pipeline/service.py | 46 ++++ assay/server/app.py | 64 ++++- assay/server/templates/_macros.html | 15 ++ assay/server/templates/_transcript.html | 10 + assay/server/templates/pipeline_new.html | 113 ++++++++- assay/server/templates/pipeline_review.html | 20 +- tests/test_phase5.py | 264 ++++++++++++++++++++ 9 files changed, 524 insertions(+), 27 deletions(-) create mode 100644 tests/test_phase5.py diff --git a/assay/checks/base.py b/assay/checks/base.py index dcba02e..1233d6a 100644 --- a/assay/checks/base.py +++ b/assay/checks/base.py @@ -13,12 +13,13 @@ class CheckResult: message: str = "" evidence: dict[str, Any] = field(default_factory=dict) required: bool = True + type: str = "" # template | generated | judge (for display) def to_dict(self) -> dict: return asdict(self) -def from_raw(raw: dict, check_id: str, required: bool) -> CheckResult: +def from_raw(raw: dict, check_id: str, required: bool, type: str = "") -> CheckResult: """Build a CheckResult from the plain dict a generated function returns.""" return CheckResult( check_id=check_id, @@ -28,4 +29,5 @@ def from_raw(raw: dict, check_id: str, required: bool) -> CheckResult: message=raw.get("message", ""), evidence=raw.get("evidence", {}) or {}, required=required, + type=type, ) diff --git a/assay/checks/registry.py b/assay/checks/registry.py index 5d4df39..4382b77 100644 --- a/assay/checks/registry.py +++ b/assay/checks/registry.py @@ -15,18 +15,21 @@ def run_check(spec: CheckSpec, response: dict, context: dict, fn = TEMPLATES.get(spec.uses) if fn is None: return CheckResult(cid, False, severity="fail", - message=f"unknown template: {spec.uses}", required=spec.required) + message=f"unknown template: {spec.uses}", required=spec.required, + type="template") raw = fn(response, spec.with_) - return from_raw(raw, f"template:{spec.uses}", spec.required) + return from_raw(raw, f"template:{spec.uses}", spec.required, type="template") if spec.type == "generated": raw = run_generated_check(spec.uses, response, context) - return from_raw(raw, f"generated:{spec.uses}", spec.required) + return from_raw(raw, f"generated:{spec.uses}", spec.required, type="generated") if spec.type == "judge": provider = judges.get(spec.judge) if provider is None: return CheckResult(cid, False, severity="fail", - message=f"unknown judge: {spec.judge}", required=spec.required) + message=f"unknown judge: {spec.judge}", required=spec.required, + type="judge") raw = run_judge_check(provider, spec.rubric, response, context) - return from_raw(raw, f"judge:{spec.judge}", spec.required) + return from_raw(raw, f"judge:{spec.judge}", spec.required, type="judge") return CheckResult(cid, False, severity="fail", - message=f"unknown check type: {spec.type}", required=spec.required) + message=f"unknown check type: {spec.type}", required=spec.required, + type=spec.type) diff --git a/assay/pipeline/service.py b/assay/pipeline/service.py index 9acb318..7032849 100644 --- a/assay/pipeline/service.py +++ b/assay/pipeline/service.py @@ -78,6 +78,52 @@ def update_version_config( pv.content_hash = ch +def update_check_params( + version_id: int, + suite_id: str, + case_id: str, + check_index: int, + params: dict, +) -> None: + """Replace the `with` params of a single check in a draft PipelineVersion. + + Locates config.suites[suite_id].cases[case_id].checks[check_index] and swaps + its `with` dict, then recomputes the content hash. Draft only. + """ + import copy + with session_scope() as s: + pv = s.get(PipelineVersion, version_id) + if pv is None: + raise ValueError(f"PipelineVersion {version_id} not found") + if pv.status != "draft": + raise ValueError(f"Can only update draft versions (status: {pv.status})") + # Deep-copy so the reassignment is a genuinely distinct object from the + # loaded value — otherwise SQLAlchemy's JSON column won't flag the change. + config = copy.deepcopy(dict(pv.config or {})) + suites = config.get("suites", []) + target = None + for suite in suites: + if suite.get("id") != suite_id: + continue + for case in suite.get("cases", []): + if case.get("id") != case_id: + continue + checks = case.get("checks", []) + if check_index < 0 or check_index >= len(checks): + raise ValueError( + f"check_index {check_index} out of range for {suite_id}/{case_id}" + ) + target = checks[check_index] + break + if target is not None: + break + if target is None: + raise ValueError(f"check not found: {suite_id}/{case_id}[{check_index}]") + target["with"] = params + pv.config = config + pv.content_hash = content_hash(config, pv.generated_sources or {}, pv.rubrics or {}) + + def import_from_yaml( spec_path: str, project: str, diff --git a/assay/server/app.py b/assay/server/app.py index 42a9a62..7bfc29b 100644 --- a/assay/server/app.py +++ b/assay/server/app.py @@ -384,13 +384,27 @@ def pipeline_generate( pv = create_version(pid, spec_dict, {}, {}, actor) version_id = pv.id update_step_reached(version_id, "review") + activated = True + permission_error = None try: activate_version(version_id, actor) - except PermissionError: - pass # stays draft if actor lacks reviewer role + except PermissionError as e: + # In enforced mode a non-reviewer cannot save-and-activate. Surface it + # instead of silently swallowing — the version stays a draft awaiting + # reviewer activation. + activated = False + permission_error = str(e) if _is_htmx(request): return Response(headers={"HX-Redirect": f"/projects/{_urlquote(body.project, safe='')}"}) - return {"pipeline_version_id": version_id} + with session_scope() as s: + pv = s.get(PipelineVersion, version_id) + resolved_pipeline_id = pv.pipeline_id if pv else None + return { + "pipeline_version_id": version_id, + "pipeline_id": resolved_pipeline_id, + "activated": activated, + "permission_error": permission_error, + } class SaveDraftBody(BaseModel): @@ -756,7 +770,7 @@ def _build_check_list(pv) -> list[dict]: checks = [] for suite in (pv.config or {}).get("suites", []): for case in suite.get("cases", []): - for chk in case.get("checks", []): + for idx, chk in enumerate(case.get("checks", [])): ctype = chk.get("type", "template") key = None source = None @@ -770,7 +784,9 @@ def _build_check_list(pv) -> list[dict]: checks.append({ "suite_id": suite.get("id", ""), "case_id": case.get("id", ""), + "check_index": idx, "type": ctype, + "determinism": "stochastic" if ctype == "judge" else "deterministic", "key": key, "uses": chk.get("uses"), "rubric": chk.get("rubric"), @@ -816,6 +832,46 @@ def pipeline_review_page(request: Request, pipeline_id: int, version_id: int): return templates.TemplateResponse(request, "pipeline_review.html", ctx) +@app.get("/pipelines/versions/{version_id}/checks") +def get_version_checks(version_id: int): + """Return the flattened check list (with determinism) for a version as JSON.""" + with session_scope() as s: + pv = s.get(PipelineVersion, version_id) + if not pv: + raise HTTPException(404, "version not found") + return _build_check_list(pv) + + +class CheckParamsBody(BaseModel): + suite_id: str + case_id: str + check_index: int + params: dict + + +@app.patch("/pipelines/versions/{version_id}/check-params") +def patch_check_params( + version_id: int, + body: CheckParamsBody, + request: Request, + x_assay_user: str | None = Header(default=None), +): + from ..pipeline.service import update_check_params + actor = _require_identity(request, x_assay_user) + try: + update_check_params(version_id, body.suite_id, body.case_id, + body.check_index, body.params) + except ValueError as e: + status = 409 if "draft" in str(e) else 404 + raise HTTPException(status, str(e)) + if _is_htmx(request): + return HTMLResponse( + '' + ' Saved', + ) + return {"ok": True, "version_id": version_id} + + class CheckEditBody(BaseModel): source: str diff --git a/assay/server/templates/_macros.html b/assay/server/templates/_macros.html index 180c128..dded58d 100644 --- a/assay/server/templates/_macros.html +++ b/assay/server/templates/_macros.html @@ -44,6 +44,21 @@
{{ caller() }}
{% endmacro %} +{# + determinism_badge(type) + ─────────────────────── + Derived classification (no stored column): + judge → stochastic (model-graded, scored) + template | generated → deterministic (coded, reproducible, binary) +#} +{% macro determinism_badge(type) %} +{% if type == 'judge' %} + Stochastic +{% else %} + Deterministic +{% endif %} +{% endmacro %} + {# model_selector(model_var, provider_var) ──────────────────────────────────────── diff --git a/assay/server/templates/_transcript.html b/assay/server/templates/_transcript.html index 4670611..4bf4da2 100644 --- a/assay/server/templates/_transcript.html +++ b/assay/server/templates/_transcript.html @@ -1,3 +1,4 @@ +{% from "_macros.html" import determinism_badge %}

Input

@@ -18,6 +19,15 @@

{{ chk.get('check_id', chk.get('uses', '?')) }} + {% if chk.get('type') %}{{ determinism_badge(chk['type']) }}{% endif %} + {% if chk.get('score') is not none %} + + {{ '%.2f' | format(chk['score']) }} + {%- if chk.get('threshold') is not none %} + {{ '≥' if chk.get('passed') else '<' }} {{ '%.2f' | format(chk['threshold']) }} + {%- endif %} + + {% endif %} {% if chk.get('message') %} — {{ chk['message'] }}{% endif %} {% if chk.get('evidence') %}
diff --git a/assay/server/templates/pipeline_new.html b/assay/server/templates/pipeline_new.html index 7b5bb17..927529d 100644 --- a/assay/server/templates/pipeline_new.html +++ b/assay/server/templates/pipeline_new.html @@ -26,6 +26,8 @@

{% if resume_data %}Edit pipeline{% else %}New pipeline{% e judgeAdapter: {{ judge_adapter | tojson }}, judgeModel: {{ judge_model | tojson }}, preview: null, + generatedChecks: [], + permissionError: null, hasJudge() { return this.preview ? this.preview.checks.some(c => c.type === 'judge') : false; }, @@ -62,9 +64,26 @@

{% if resume_data %}Edit pipeline{% else %}New pipeline{% e this.generating = false; if (d.pipeline_version_id) { this.generatedVersionId = d.pipeline_version_id; + if (d.pipeline_id) this.resumePipelineId = d.pipeline_id; + this.permissionError = d.permission_error || null; + try { + const cr = await fetch('/pipelines/versions/' + d.pipeline_version_id + '/checks'); + this.generatedChecks = await cr.json(); + } catch (e) { + this.generatedChecks = []; + } this.step = 3; } }, + async saveCheckParams(chk, params) { + await fetch('/pipelines/versions/' + this.generatedVersionId + '/check-params', { + method: 'PATCH', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({suite_id: chk.suite_id, case_id: chk.case_id, + check_index: chk.check_index, params}) + }); + chk.params = params; + }, async saveDraft(s) { if (!this.requirements || !this.project || !this.name) return; await fetch('/pipelines/save-draft', { @@ -243,8 +262,9 @@

- -
+ +
@@ -256,6 +276,26 @@

+ +
+
+ +
+

Saved as draft — reviewer activation required

+

+ You don't have reviewer authority to activate. A reviewer must open the + version and activate it before it can run. +

+ + Open version for review + +
+
+
+

Configuration

@@ -274,23 +314,70 @@

Configuration

- -
+ +

Checks + x-text="'(' + (generatedChecks.length || (preview ? preview.checks.length : 0)) + ' total)'">

-