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..4babe02 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): @@ -667,10 +681,7 @@ def delete_pipeline(pipeline_id: int, request: Request): ) for pv in list(pipe.versions): s.delete(pv) - project = pipe.project s.delete(pipe) - if _is_htmx(request): - return Response(headers={"HX-Redirect": f"/projects/{_urlquote(project, safe='')}"}) return {"ok": True} @@ -756,7 +767,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 +781,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 +829,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..beaa664 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..121d1de 100644 --- a/assay/server/templates/pipeline_new.html +++ b/assay/server/templates/pipeline_new.html @@ -26,6 +26,9 @@

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

{% 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; + localStorage.removeItem('assay_wizard_new'); + setTimeout(() => { window.location.href = '/pipelines'; }, 2500); } }, + 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', { @@ -73,9 +95,51 @@

{% if resume_data %}Edit pipeline{% else %}New pipeline{% e body: JSON.stringify({project: this.project, name: this.name, requirements: this.requirements, step: s}) }); + localStorage.removeItem('assay_wizard_new'); + this.draftSaved = true; + setTimeout(() => { window.location.href = '/pipelines'; }, 1500); + }, + _persist() { + try { + localStorage.setItem('assay_wizard_new', JSON.stringify({ + requirements: this.requirements, project: this.project, name: this.name, + adapter: this.adapter, model: this.model, + endpoint: this.endpoint, key_env: this.key_env, + })); + } catch(e) {} + }, + init() { + if (this.resumeVersionId) return; + try { + const d = JSON.parse(localStorage.getItem('assay_wizard_new') || 'null'); + if (d) { + if (d.requirements !== undefined) this.requirements = d.requirements; + if (d.project !== undefined) this.project = d.project; + if (d.name !== undefined) this.name = d.name; + if (d.adapter !== undefined) this.adapter = d.adapter; + if (d.model !== undefined) this.model = d.model; + if (d.endpoint !== undefined) this.endpoint = d.endpoint; + if (d.key_env !== undefined) this.key_env = d.key_env; + } + } catch(e) {} + ['requirements','project','name','adapter','model','endpoint','key_env'].forEach(k => { + this.$watch(k, () => this._persist()); + }); } }"> + +
+ + Draft saved — taking you to Pipelines… +
+
{% for label in ['Define', 'Connect', 'Review'] %} @@ -126,7 +190,7 @@

What should your model do?

style="color:var(--text-danger)" hx-delete="/pipelines/{{ resume_data.pipeline_id }}" hx-confirm="Delete pipeline '{{ resume_data.name }}'? This removes all versions and cannot be undone." - hx-on:htmx:after-request="if(event.detail.successful) window.location='/projects/{{ resume_data.project | urlencode }}'"> + hx-on:htmx:after-request="if(event.detail.successful) window.location='/pipelines'"> Delete {% endif %} @@ -220,7 +284,7 @@

style="color:var(--text-danger)" hx-delete="/pipelines/{{ resume_data.pipeline_id }}" hx-confirm="Delete pipeline '{{ resume_data.name }}'? This removes all versions and cannot be undone." - hx-on:htmx:after-request="if(event.detail.successful) window.location='/projects/{{ resume_data.project | urlencode }}'"> + hx-on:htmx:after-request="if(event.detail.successful) window.location='/pipelines'"> Delete {% endif %} @@ -243,16 +307,37 @@

- -
+ +
-
-

Pipeline ready

+
+

Draft saved

+

+ Generated and activated — taking you to Pipelines… +

+
+ + Go now + +
+
+ + +
+
+ +
+

Draft saved — reviewer activation required

- Generated and activated — you can run it now. + You don't have reviewer authority to activate. Taking you to Pipelines…

+ + Go now +
@@ -274,35 +359,56 @@

Configuration

- -
+ +

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

-