Skip to content

Real LLMs on every build path, honest docs, and three bugs behind shipped UI - #9

Merged
barancan merged 11 commits into
mainfrom
claude/real-llm-foundation
Aug 4, 2026
Merged

Real LLMs on every build path, honest docs, and three bugs behind shipped UI#9
barancan merged 11 commits into
mainfrom
claude/real-llm-foundation

Conversation

@barancan

@barancan barancan commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Moves Assay off mocks and heuristics on the paths that matter, makes the docs match the code, and fixes three bugs that were reachable from the shipped UI.

273 tests passing with zero API keys set. Baseline was 149.

Why

A status audit found the runner half production-shaped and the builder half a v0 heuristic: the web UI never called an LLM, codegen did not exist, rubrics were templated, and mock adapters were the silent default. Docs claimed capabilities that were not there. This PR is the first three phases of closing that.

Bugs fixed (all were live in the UI)

  • Judge rubrics were never materialised at run time. _resolve_spec returned only generated sources, so judges/judge.py hit FileNotFoundError and aborted the entire run. Any DB pipeline with a judge check was dead. Reproduced first, then fixed.
  • The sandbox was not a sandbox at module level. The import allowlist and the removal of open/exec/eval/compile were installed after exec_module(). Verified as a real escape, not a theoretical one: against the old code a generated check's top-level open(path,'w').write(...) succeeded and the file existed. The worker now compiles the module while those builtins are available, then locks down before running any user code. A second bug found in passing: the socket patch ran after the import guard, so import socket was blocked and the patch was silently skipped.
  • "Regenerate check" emitted code the sandbox could never load — wrong signature, return True instead of a dict. Now emits a contract-correct scaffold that fails with an explicit message until codegen lands.

Real LLMs (P0 + P1)

  • New assay/llm/provider.py: one entry point for which model, which key, is it configured. Keys stay in the environment, referenced by name, never persisted and never rendered. An unconfigured provider raises LLMConfigError carrying the adapter and the variable to set.
  • key_env now reaches the adapters. It was collected by the wizard and silently discarded, because the spec models did not declare it and pydantic ignored extras. openai_compat no longer hardcodes OPENAI_API_KEY, and judges get endpoint/key_env/params instead of only model — so a judge can point at a local vLLM.
  • Both build routes call a real model. /pipelines/preview and /pipelines/generate resolve the configured builder model. No key produces a 422 naming the exact variable, never a silent heuristic fallback. assay generate --offline is the explicit opt-in to the old keyword path.
  • Real requirement traceability. generator/ingest.py splits requirements into R1…Rn; the prompt carries those ids, every returned ref is resolved against them, and each requirement becomes its own suite. requirement_ref: "auto" is gone.
  • The connection badge stopped lying. It reported a green "Connected" when there was no API key at all, because a missing-credential exception matched a "reachable" heuristic. Ping now distinguishes unreachable from unauthenticated and names the missing variable.
  • The judge system prompt now actually reaches the model. It was passed as params["system"] and read by no adapter, so judge verdicts were being requested without their instructions.
  • Settings gains a Providers card: per-adapter env var name and a configured badge. Names only, never values.

Run progress

A run blocked the HTTP request until every case finished — invisible against mocks, minutes of a dead button against real models. execute_run is split into setup and execution; setup stays on the calling thread so an unreachable target still raises synchronously, and only the case loop is deferred. Browsers get a polling progress view; programmatic callers keep synchronous semantics because CI depends on the response carrying report_id. Case results now commit per case, which is what makes progress observable and means a run that dies half way keeps what it produced.

Docs

docs/STATUS.md is now the source of truth: a built/partial/planned matrix over every subsystem. README's false claims are corrected rather than softened — no MCP/SDK targets, no custom adapter, no OpenAPI import, and the sandbox section says plainly there is no filesystem jail and no egress block. docs/user-journeys.md maps twelve builder journeys to UI touchpoint, route, business logic and state effect. The stale UI sprint plan is archived with a note on what actually shipped.

tests/test_docs_truth.py enforces the mechanically checkable claims: README adapter tables must match the registry, documented CLI verbs must exist or be declared planned, and a verb listed as planned must not already be implemented.

Behaviour changes worth review

  • TargetSpec/JudgeSpec are now extra="forbid". Silently dropping an undeclared field is exactly what caused the key_env bug. A hand-written assay.yaml with an unknown key under target: now fails loudly at load. Spec itself is unchanged, so spec_dict["requirements"] still round-trips.
  • /pipelines/preview now requires identity. It calls a real model on every request, so unauthenticated it was an open spend vector. No change in open mode; 401 in enforced mode.
  • key_env: "" means "this target takes no credential" — the escape hatch for a keyless local vLLM or LM Studio, which would otherwise have been forced to invent an API key.
  • SQLite now opens with check_same_thread=False so background runs can write, and the hand-rolled migrations are dialect-aware (they emitted SQLite-only DDL while the README documents a Postgres switch).

Verification

  • pytest -q — 273 passing, verified with ANTHROPIC_API_KEY and OPENAI_API_KEY explicitly unset. No test makes a network call.
  • Run progress verified against a real uvicorn server, not just TestClient, since threading plus SQLite is where TestClient can mislead: run → progress page → poll redirects to the report → ready_for_review, zero server errors.
  • No-key behaviour verified against a real server: both build routes return an actionable 422, Settings renders the Providers card with no value leakage, the wizard carries the error banner.
  • CLI verified: bare generate without a key explains the three ways forward, a malformed --judge gives an example, --offline works and emits real R1/R2 refs.

Not in this PR

Codegen (P4) still does not exist, case inputs are still empty, the target interface is still not parsed at build time, and mock adapters are still selectable as ordinary targets (P6). docs/STATUS.md marks all of these planned, and the ranked-gap table in docs/user-journeys.md tracks them with the phase that closes each.


Generated by Claude Code

claude added 11 commits August 4, 2026 18:20
Judge rubrics stored on a PipelineVersion were never materialised at run time,
so any DB pipeline containing a judge check aborted the whole run with
FileNotFoundError. _resolve_spec now returns rubrics alongside generated
sources, _materialise_sources writes both into separate subdirectories, and
_patch_spec_paths rewrites CheckSpec.rubric as well as CheckSpec.uses.

The sandbox installed its import allowlist and removed open/exec/eval/compile
only after exec_module(), leaving a generated check's module-level code
completely uncontained -- it could import os and write files. The worker now
reads and compiles the module while those builtins are still available, then
locks down before executing any user code. The socket patch also moved ahead of
the guard: socket is not allowlisted, so importing it afterwards raised and the
patch was silently skipped.

regenerate_check emitted `def <stem>(response, **kwargs): return True`, but the
sandbox requires a module-level `check(response, context) -> dict`. Every
regenerated check failed at run time with "module defines no check". The
scaffold now matches the contract and fails with an explicit message until real
codegen lands.

Adds docs/user-journeys.md: twelve builder/integrator journeys mapping each step
to its UI touchpoint, route, business logic and state effect, marked
BUILT/PARTIAL/MISSING/BROKEN against the current code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UJsfnFtK7P69zJvxwY98TE
The README claimed MCP and SDK targets, a `custom` adapter, and OpenAPI import,
none of which exist; it also described sandbox containment that was never
implemented (filesystem isolation, deny-all egress). Those claims are corrected
rather than softened -- each now says what is actually there.

Adds docs/STATUS.md as the single source of truth for capability claims: a
built/partial/planned matrix over builder, adapters, judges, sandbox, engine,
review, server, exporters, CLI and storage, plus the roadmap phases.

Adds tests/test_docs_truth.py so the mechanically checkable claims cannot drift
again: the README adapter tables must match the adapter registry, every CLI verb
named in the docs must exist or be declared Planned, the template-primitive count
must match the registry, and a command listed as Planned must not already be
implemented.

Archives assay-ui-sprint-plan-prompt.md, whose locked decisions contradicted
shipped behaviour and whose Phases 5 and 6 were never built, with a header
explaining what actually happened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UJsfnFtK7P69zJvxwY98TE
Adds assay/llm/provider.py: one entry point for "which model, which key, and is
it configured". Keys stay in the environment, referenced by variable name, and an
unconfigured provider raises LLMConfigError carrying the adapter and the env var
to set -- so callers can tell the user exactly what is missing instead of falling
back to an offline heuristic.

Builder and judge are separate roles: builder_choice() prefers explicit builder
settings, falls back to the judge settings, then to a built-in default. The
fallback is evaluated at read time rather than seeded, because _seed_settings()
guards on a whole-table count and would never backfill an existing workspace.

This is the frozen interface that the credential-plumbing and build-path
workstreams are both developed against.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UJsfnFtK7P69zJvxwY98TE
A run blocked the HTTP request until every case finished. Against mock adapters
that is instant; against real models it is minutes of a dead, disabled button
with no indication anything is happening. This is journey J10.7, which the
journey mapping surfaced as a real-LLM requirement that had never been written
down.

execute_run is split into _setup_run (resolve the spec, materialise artifacts,
reach the target, create the Run row) and _execute_cases (the loop). Setup stays
on the calling thread so an unreachable target or an inactive version still
raises synchronously rather than disappearing into a background thread; only the
case loop is deferred. start_run returns the run id immediately, and the browser
is redirected to a progress view that polls done/total and forwards to the report
when the run lands.

Case results now commit per case rather than once at the end, which is what makes
progress observable at all. A run that dies half way therefore keeps the results
it produced, and failures are recorded on the run instead of being lost.

Programmatic callers keep synchronous semantics -- CI and the webhook depend on
the response carrying report_id.

Also makes the hand-rolled migrations dialect-aware via _add_columns, since they
emitted SQLite-only DDL while the README documents a Postgres switch, and opens
SQLite with check_same_thread=False so background runs can write.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UJsfnFtK7P69zJvxwY98TE
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UJsfnFtK7P69zJvxwY98TE
…t sent

Three credential bugs, one plumbing gap:

* TargetSpec/JudgeSpec now declare key_env (and JudgeSpec an endpoint), so the
  variable name the wizard collects survives validation instead of being dropped
  by pydantic's extra="ignore". Both models are extra="forbid" now, so the next
  dropped field is a validation error rather than silence.
* get_judge_provider passes model/endpoint/key_env/params through; every adapter
  reads them. Anthropic constructs its client with an explicit api_key so a
  per-target key is possible at all; openai_compat and rest raise LLMConfigError
  naming the variable instead of sending an empty bearer token.
* ping() gained reachable/authenticated/env_var, so "reachable but has no
  credential" is no longer reported as a green Connected badge.
* Adapters honour params["system"] -- judge verdicts were being requested
  without the judge's instructions on every real provider.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UJsfnFtK7P69zJvxwY98TE
Adds tests/test_p0_credentials.py (33 tests, no network -- requests is
monkeypatched and the Anthropic SDK is a fake module): key_env reaches every
adapter, a missing key raises LLMConfigError naming the variable instead of
sending an empty bearer, ping tells "unauthenticated" apart from "unreachable",
and params["system"] is in the outbound payload for all three real providers.

Settings gains a read-only Providers card (variable names and a configured
badge, never a value) and a builder model selector backed by GET/POST
/settings/builder. Anthropic's ping now checks the credential before importing
the SDK, so a missing key is reported as a missing key. The seeded default
claude-haiku-4-5-20251001 is now in the model_selector option list, so it no
longer renders as "Custom...".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UJsfnFtK7P69zJvxwY98TE
The web UI never called a model. Both /pipelines/preview and /pipelines/generate
hardcoded judge=None, so every pipeline built through the product's primary surface
came from an offline keyword heuristic — and once persisted, it looked identical to
one built from comprehension.

Both routes now resolve the workspace build model through resolve_builder_llm and
call it. Nothing falls back silently: an unconfigured provider, a provider that
throws, or a model that replies with something other than usable intents all become
a 422 whose detail names the environment variable to set or what went wrong. The
heuristic survives as an explicit opt-in (derive_intents(..., allow_heuristic=True),
assay generate --offline).

Requirement traceability is real. generator/ingest.py splits requirements into
R1..Rn across markdown headings, bullets, numbered lists and the wizard's
one-sentence-per-line textarea; the prompt carries those ids; every returned
requirement_ref is resolved back to one of them (repaired where plausible, rejected
where not) instead of the "auto" stamp that collapsed the coverage matrix into a
single bucket. intents_to_spec now emits one suite per requirement.

Model output is validated before it becomes a persisted pipeline: `how` must be a
known route, template names must exist in the registry, thresholds are coerced and
clamped, and ids are sanitised because they land in generated/rubrics/<id>.yaml.
Judge rubrics are now stored on the version by the generate route, which previously
wrote a rubric path with nothing behind it.

The wizard had no error handling at all — no r.ok check, no else branch — so a 4xx
stopped the spinner and showed the user nothing. generate() and loadPreview() now
check r.ok, tolerate a non-JSON body, and render the detail in a .banner-danger
sitting directly under the adapter fields, with the wizard staying on step 2.

CLI: --offline for the heuristic, bare `assay generate` resolves the configured
build model, and --judge without a colon gets a real message instead of a traceback.

Tests: new tests/conftest.py provides fake builder models, so the whole suite still
passes with zero API keys and makes no network calls.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UJsfnFtK7P69zJvxwY98TE
P0 made openai_compat raise rather than send an empty bearer token, which is
right for api.openai.com but breaks a local vLLM, LM Studio or llama.cpp server
that wants no auth at all -- those users would have had to invent an API key.

An explicit key_env="" now means "this target takes no credential": key_env_for
distinguishes it from None (use the adapter default), and openai_compat omits the
Authorization header entirely rather than sending one with an empty value.

get_judge_provider filtered its kwargs on truthiness, so an empty key_env was
dropped and the adapter silently fell back to demanding OPENAI_API_KEY. It now
filters on `is not None`, matching get_target_adapter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UJsfnFtK7P69zJvxwY98TE
… workstreams

POST /pipelines/preview took no identity and now calls a real model on every
request, which made it an unauthenticated spend vector for anyone able to reach
the port. It resolves identity like the other privileged routes: no change in
open mode, 401 in enforced mode, which is the deployed posture.

The credential and build-path workstreams each updated docs/STATUS.md and
docs/user-journeys.md without seeing the other, so several rows still described
gaps that are now closed -- provider credential status, per-target key_env,
openai_compat's hardcoded variable, and the unauthenticated-vs-unreachable
distinction on the connection badge. Reconciled against the merged tree.

The ranked-gap table now carries closed items with the phase that closed them
rather than dropping them, so the list stays auditable rather than just getting
shorter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UJsfnFtK7P69zJvxwY98TE
@barancan
barancan merged commit 4783def into main Aug 4, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants