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 @@
{% 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 %}Pipeline ready
+Draft saved
++ Generated and activated — taking you to Pipelines… +
+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…
+ + Redirecting… or go now +