Skip to content
4 changes: 3 additions & 1 deletion assay/checks/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
)
15 changes: 9 additions & 6 deletions assay/checks/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
46 changes: 46 additions & 0 deletions assay/pipeline/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
67 changes: 60 additions & 7 deletions assay/server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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}


Expand Down Expand Up @@ -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
Expand All @@ -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"),
Expand Down Expand Up @@ -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(
'<span class="badge badge-pass" style="font-size:12px">'
'<i class="ti ti-check" aria-hidden="true"></i> Saved</span>',
)
return {"ok": True, "version_id": version_id}


class CheckEditBody(BaseModel):
source: str

Expand Down
15 changes: 15 additions & 0 deletions assay/server/templates/_macros.html
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,21 @@
<div class="filter-bar">{{ caller() }}</div>
{% 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' %}
<span class="badge badge-pro"><i class="ti ti-dice-3" aria-hidden="true"></i> Stochastic</span>
{% else %}
<span class="badge badge-accent"><i class="ti ti-binary" aria-hidden="true"></i> Deterministic</span>
{% endif %}
{% endmacro %}

{#
model_selector(model_var, provider_var)
────────────────────────────────────────
Expand Down
10 changes: 10 additions & 0 deletions assay/server/templates/_transcript.html
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{% from "_macros.html" import determinism_badge %}
<div style="padding:.75rem;font-size:13px">
<div style="margin-bottom:.75rem">
<h4 style="font-size:12px;font-weight:600;letter-spacing:.04em;color:var(--text-muted);text-transform:uppercase;margin:0 0 .35rem">Input</h4>
Expand All @@ -18,6 +19,15 @@ <h4 style="font-size:12px;font-weight:600;letter-spacing:.04em;color:var(--text-
{% endif %}
<div style="flex:1">
<strong>{{ chk.get('check_id', chk.get('uses', '?')) }}</strong>
{% if chk.get('type') %}{{ determinism_badge(chk['type']) }}{% endif %}
{% if chk.get('score') is not none %}
<span style="font-size:12px;color:var(--text-muted)">
{{ '%.2f' | format(chk['score']) }}
{%- if chk.get('threshold') is not none %}
{{ '≥' if chk.get('passed') else '<' }} {{ '%.2f' | format(chk['threshold']) }}
{%- endif %}
</span>
{% endif %}
{% if chk.get('message') %} — {{ chk['message'] }}{% endif %}
{% if chk.get('evidence') %}
<details style="margin-top:.25rem">
Expand Down
Loading
Loading