diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d3b5c0..8bbbec6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,10 +21,10 @@ jobs: python-version: ["3.10", "3.11", "3.12", "3.13"] steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} cache: "pip" diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 53c7fa8..e4eff07 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -16,7 +16,7 @@ permissions: concurrency: group: pages - cancel-in-progress: false + cancel-in-progress: true jobs: deploy: @@ -26,13 +26,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Configure Pages - uses: actions/configure-pages@v5 + uses: actions/configure-pages@v6 - name: Upload static site - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@v5 with: path: site - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3bf2b0f..fe72576 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,10 +15,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.11" cache: "pip" @@ -30,14 +30,14 @@ jobs: run: bash scripts/certify_release.sh "$RUNNER_TEMP/redline-certify" - name: Upload release distributions - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: redline-release-distributions path: ${{ runner.temp }}/redline-certify/dist/* if-no-files-found: error - name: Upload certification summary - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: redline-release-certification path: ${{ runner.temp }}/redline-certify/certification.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index e47bec0..76c3724 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,75 @@ # Changelog +## 0.3.0 + +redline 0.3.0 turns the alpha from a CLI-first regression checker into a +guided local product loop: import logs, generate a suite, run a quick check, +review cases in the app, and carry the result into CI or an MCP client. + +Highlights: + +- Add a guided local app workflow for first-run demos, report review, suite + health, benchmark evidence, history, and next-step commands. +- Add `redline quick-check` and `redline status` so new users can get from two + logs to a reviewed result without knowing the full command graph. +- Expand log import with field detection, auto-mapping, previews, presets, and + redaction-first docs for real team exports. +- Add suite readiness, methodology, coverage, calibration, and per-case impact + signals so users can understand what redline selected and why it matters. +- Expand MCP coverage so AI coding assistants can run quick checks, imports, + status review, and dashboard rendering with the same trust boundary as the + CLI. +- Refresh the README, GitHub Pages site, screenshots, and case studies around + reproducible product proof, including a 100-row Databricks Dolly dogfood run. + +### Added + +- Add `redline app --demo` for a one-command local product demo with generated + reports and review workflow. +- Add `redline quick-check` for temporary suite generation, diffing, report + output, and optional app opening from two local logs. +- Add `redline status` to summarize config, suites, reports, history, audit + evidence, and the next command a user should run. +- Add import field detection, `--auto-map`, preview output, richer diagnostics, + and source guides for public datasets and team log exports. +- Add suite readiness scoring and explicit messaging that readiness measures + suite health, not model quality or candidate safety. +- Add report methodology and suite coverage metadata to JSON, Markdown, HTML, + dashboard, and summary surfaces. +- Add calibration examples and profile documentation for strict versus review + behavior. +- Add per-case impact guidance in reports and dashboard review queues. +- Add MCP tools and prompts for quick-check, status, auto import, and safer + review workflows inside AI coding assistants. +- Add a public internet dogfood case study using a local 100-row Databricks + Dolly sample, with raw third-party rows kept out of git. + +### Changed + +- Revamp the local dashboard into an app-style review surface with report + cards, ship readiness, case review, benchmark evidence, and history context. +- Refresh GitHub Pages and README positioning around product proof instead of + only command reference. +- Update proof screenshots from real local app and HTML report artifacts. +- Position deterministic structural checks as a merge gate while keeping the + trust boundary visible in CLI, docs, and reports. +- Improve CLI next-step output by quoting paths and commands that users can + copy safely. +- Surface HTML reports and app-opening commands after first-run and eval flows. +- Improve summary and dashboard wording for stochastic suites, English-centric + heuristics, and semantic review gaps. + +### Fixed + +- Ignore dashboard sidecar JSON artifacts such as Slack payloads and schemas so + dashboard/app report counts stay clean. +- Validate suite schema compatibility more explicitly before users rely on a + generated suite. +- Fix README and PyPI product-surface rendering by removing the fragile top SVG + README hero and using current raw asset URLs for proof images. +- Quote printed app, demo, status, and CLI next-step commands to avoid broken + copy/paste paths. + ## 0.2.1 redline 0.2.1 is a packaging polish patch for PyPI rendering. diff --git a/README.md b/README.md index 220f766..dae77d6 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,6 @@ -

- - - - redline - -

+

redline

Catch prompt regressions before they ship.

@@ -44,26 +38,50 @@ Install from PyPI: python -m pip install redline-ai ``` -Run the public proof: +Run the guided local app with the public proof loaded: + +```bash +redline app --demo +``` + +This generates the public demo reports, opens the local product app, and shows +the full import -> suite -> eval -> review workflow. The demo catches ten +synthetic regressions without API keys, private logs, a cloud account, or an LLM +judge. + +Prefer terminal output first: ```bash redline demo --public --compact ``` -The demo catches ten synthetic regressions without API keys, private logs, a -cloud account, or an LLM judge. It writes JSON, Markdown, and self-contained -HTML reports under `.redline/demo`. +The demo writes JSON, Markdown, and self-contained HTML reports under +`.redline/demo`. + +Ask redline what to do next: + +```bash +redline status --reports-dir .redline/demo/reports +``` + +`status` reads local config, suites, reports, history, and audit evidence, then +prints the next command instead of leaving you to infer the workflow. -Open the local demo report index: +Open the guided local product app on existing reports: ```bash -redline dashboard --reports-dir .redline/demo/reports --open +redline app --reports-dir .redline/demo/reports ``` -On headless CI or remote shells, skip `--open` and use the printed HTML path: +The app is a local, copy-command workflow: import logs, generate suites, run +evals, review regressions, record history, and export CI/MCP setup without the +browser executing shell commands. + +On headless CI or remote shells, skip browser opening and use the printed HTML +path: ```bash -redline dashboard --reports-dir .redline/demo/reports --out .redline/dashboard.html +redline app --reports-dir .redline/demo/reports --no-open --out .redline/app.html ```
@@ -71,7 +89,7 @@ redline dashboard --reports-dir .redline/demo/reports --out .redline/dashboard.h - `redline: command not found`: run `python -m pip install redline-ai`, then confirm `python -m pip show redline-ai`. -- Dashboard did not open: use `--out .redline/dashboard.html` and open or +- App did not open: use `--no-open --out .redline/app.html` and open or upload that file from your environment. - Suite not found: run `redline suite logs/baseline.jsonl --out redline-suite.json`. - Validation failed: run `redline validate redline-suite.json --strict` and fix @@ -82,15 +100,24 @@ redline dashboard --reports-dir .redline/demo/reports --out .redline/dashboard.h Full guide: [docs/troubleshooting.md](docs/troubleshooting.md).
-![redline product demo](https://gowtham0992.github.io/redline/assets/redline-product-demo.gif) +![redline product demo](https://raw.githubusercontent.com/gowtham0992/redline/main/site/assets/redline-product-demo.gif) ## Product Proof -These are real local artifacts generated by `redline demo --public --compact`. +redline has two proof paths: a fast first-run demo and a larger public-data +dogfood run. + +| Proof | Command or data | Result | +| --- | --- | --- | +| First-run demo | `redline demo --public --compact` | 10 synthetic regressions caught locally with no API keys. | +| Internet dogfood | 100 prompt-response rows sampled from [Databricks Dolly 15k](https://huggingface.co/datasets/databricks/databricks-dolly-15k) | 51 regressions, 27 changed cases, 22 neutral controls, and 0 dashboard warnings. | +| Release gate | tests, lint, type check, action smoke, and release build | Package, CI, report, dashboard, and MCP paths are validated before publish. | + +These screenshots are local artifacts from the 100-row internet dogfood run. | Dashboard | HTML report | | --- | --- | -| ![redline dashboard showing reports, benchmark evidence, history, and ship readiness](https://gowtham0992.github.io/redline/assets/redline-dashboard-proof.png) | ![redline HTML report showing concrete regression reasons and side-by-side baseline and candidate outputs](https://gowtham0992.github.io/redline/assets/redline-report-proof.png) | +| ![redline dashboard showing reports, benchmark evidence, history, and ship readiness](https://raw.githubusercontent.com/gowtham0992/redline/main/site/assets/redline-dashboard-proof.png) | ![redline HTML report showing concrete regression reasons and side-by-side baseline and candidate outputs](https://raw.githubusercontent.com/gowtham0992/redline/main/site/assets/redline-report-proof.png) | ## What Is redline? @@ -111,6 +138,17 @@ small. redline gives you three primitives that cover the prompt-regression loop: +For a first pass on two local logs, use one command: + +```bash +redline quick-check logs/baseline.jsonl logs/candidate.jsonl --open +``` + +It generates a temporary suite, writes JSON/Markdown/HTML reports plus a guided +local app under `.redline/quick-check`, opens the focused HTML report, and +prints the concrete behavioral diff. Use `--open-app` when you want the guided +review workflow to open instead of only the focused report. + ### 1. Logs Start with prompt-response data you already have. Import JSONL, convert exports @@ -118,11 +156,23 @@ from tools like Langfuse or Helicone, capture OpenAI/Anthropic SDK calls, or add bounded FastAPI/ASGI middleware. ```bash +redline import downloaded.jsonl --detect +redline import downloaded.jsonl --auto-map --preview 3 +redline import downloaded.jsonl --auto-map --out logs/baseline.jsonl +redline import downloaded.jsonl --input-field instruction --output-field response --preview 3 redline import downloaded.jsonl --input-field instruction --output-field response --out logs/baseline.jsonl +redline import langfuse-export.jsonl --preset langfuse --out logs/baseline.jsonl redline suite logs/baseline.jsonl --out redline-suite.json redline cases redline-suite.json ``` +Use `--detect` when you do not know the field names. Use `--preview` when the +export is new to you; it shows mapped, redacted sample rows without writing a +baseline file. + +Suite generation prints a readiness score and improvement suggestions. That +score measures suite health, not model quality or candidate safety. + ### 2. Suite redline groups behavior into deterministic signatures and selects @@ -200,10 +250,21 @@ redline is deterministic and local-first by default. Optional judge commands are available for ambiguous `changed` cases, but redline does not call a cloud model unless you explicitly configure that command. -Suite generation groups logs by deterministic behavior signatures, not opaque -embedding clusters. It picks one representative per group first, then adds -high-variance edges and evenly spread prompt-diverse samples from large clusters -when the case budget allows. +That is the point. redline is designed to be the fast merge-blocking gate for +regressions that break production systems: invalid JSON, missing required +fields, lost tables, empty answers, dropped URLs, changed refusal behavior, and +explicit requirement failures. LLM judges are useful for semantic review, but +they are slower, cost money, and can be flaky in CI. redline keeps the default +gate deterministic, reproducible, and cheap, then lets you add judges only where +the structural signal is not enough. + +Methodology details live in [docs/methodology.md](docs/methodology.md). + +Suite generation does not run statistical or embedding clustering by default. +It groups logs by deterministic behavior signatures, such as prompt intent, +response shape, length bucket, and JSON schema. It picks one representative per +group first, then adds high-variance edges and evenly spread prompt-diverse +samples from large groups when the case budget allows. ## Trust Boundary @@ -224,10 +285,13 @@ redline is built around the full prompt-regression loop: - `redline watch`: collect prompt-response observations from logs, Python functions, OpenAI/Anthropic-compatible SDK calls, or ASGI apps, with best-effort common secrets and PII redacted before write by default. +- `redline import`: normalize exported team logs into redline JSONL, with the + same best-effort redaction enabled by default. Use `--no-redact` only for + reviewed local-only logs. - `RedlineMiddleware`: capture bounded JSON FastAPI or ASGI request/response pairs locally, with optional skip diagnostics. - `redline redact --check`: scan logs for common secrets and PII, then write a scrubbed copy when needed. Redaction is best-effort pattern matching, not a privacy boundary; review sensitive logs before sharing. -- `redline cluster`: inspect behavior groups before suite generation. +- `redline cluster`: inspect deterministic behavior-signature groups before suite generation. - `redline suite`: generate a representative eval suite from baseline logs. - `redline prompts`: scan many prompt files and write or check a versionable prompt-to-suite manifest. Add `--check-suites` in CI when every prompt should already have a built and valid suite. @@ -247,11 +311,16 @@ redline is built around the full prompt-regression loop: `--out-checkpoint .redline/audit-checkpoint.json` to persist that evidence, then `--checkpoint .redline/audit-checkpoint.json` to verify against it later. - `redline sbom`: write CycloneDX SBOM release evidence for security review. +- `redline app`: open the guided local product surface for importing logs, + generating suites, reviewing regressions, recording history, and wiring CI/MCP. +- `redline status`: show project readiness and the next command from local + evidence, including the guided app command, first review case, its reason, and + why it matters. - `redline history`, `redline compare`, and `redline dashboard`: track quality - over time and inspect reports locally. The dashboard surfaces feature-level - rollups, prompt-level eval rows, benchmark evidence, and a latest-report - review queue when reports come from a prompt manifest. It also warns when - reports exist without benchmark evidence from the same project. + over time and inspect report artifacts locally. The dashboard surfaces + feature-level rollups, prompt-level eval rows, benchmark evidence, and a + latest-report review queue when reports come from a prompt manifest. It also + warns when reports exist without benchmark evidence from the same project. - `redline summary`: inspect suite readiness, or pass `redline-prompts.json` to roll up multi-prompt suite coverage, owners, requirements, and missing suites. - `redline-mcp`: let AI coding assistants run checks inside Claude, Codex, @@ -311,7 +380,7 @@ redline-mcp Use [docs/mcp.md](docs/mcp.md) to wire redline into an MCP client. The MCP surface exposes safe capture-readiness, privacy, audit, scale, read, -case-inspection, eval, and report tools plus workflow prompts like +quick-check, case-inspection, eval, and report tools plus workflow prompts like `setup_redline_project`, `check_prompt_change`, `build_suite_from_logs`, and `review_candidate_outputs`. It can also list or copy runner adapters and optional judge templates during setup. @@ -330,7 +399,7 @@ redline init --runner stdio --copy-runner --github-action Use redline as a composite GitHub Action from another repo: ```yaml -- uses: gowtham0992/redline@v0.2.1 +- uses: gowtham0992/redline@v0.3.0 with: prompt-path: prompts/v2.txt benchmark-max-seconds: "300" @@ -424,9 +493,10 @@ redline summary redline-suite.json `doctor` shows whether the suite has explicit requirements or recorded judgments before you rely on structural checks in CI. -`summary` reports cluster/case coverage, owner coverage, accepted baseline -history, approver coverage, and explicit guard coverage for cases with -requirements or recorded judgments so teams can review suite readiness before CI. +`summary` reports a suite readiness score, behavior-group/case coverage, owner +coverage, accepted baseline history, approver coverage, and explicit guard +coverage for cases with requirements or recorded judgments so teams can review +suite readiness before CI. `dashboard` also shows audit checkpoint evidence when `.redline/audit-checkpoint.json` is present. @@ -476,7 +546,11 @@ bash scripts/release_check.sh - [docs/release.md](docs/release.md): package, tag, PyPI, and MCP Registry release flow - [docs/launch.md](docs/launch.md): public alpha launch plan - [docs/troubleshooting.md](docs/troubleshooting.md): first-run and CI failure recovery +- [docs/import-guides.md](docs/import-guides.md): Langfuse, Helicone, OpenAI chat, Datadog, and custom log import recipes +- [docs/methodology.md](docs/methodology.md): behavior grouping, case selection, scoring, and trust boundaries +- [docs/calibration.md](docs/calibration.md): tiny fixture showing regressions, changed cases, and neutral cases - [docs/commands.md](docs/commands.md): compact CLI command reference +- [docs/real-log-dogfood.md](docs/real-log-dogfood.md): redaction-first real-log test protocol - [docs/dogfood.md](docs/dogfood.md): first-user dogfood protocol - [docs/case-studies.md](docs/case-studies.md): reproducible dogfood case studies - [docs/internet-dogfood-sources.md](docs/internet-dogfood-sources.md): public prompt-response datasets for dogfood sourcing diff --git a/docs/ai-session-dogfood-prompts.jsonl b/docs/ai-session-dogfood-prompts.jsonl index e75ab61..ad0534b 100644 --- a/docs/ai-session-dogfood-prompts.jsonl +++ b/docs/ai-session-dogfood-prompts.jsonl @@ -2,7 +2,7 @@ {"case_id":"task_02","prompt":"Design a first-run experience for redline. A new user cloned the repo and has five minutes. What exact commands should they run, what should they see, and where are they most likely to get confused?"} {"case_id":"task_03","prompt":"Write a concise config reference for redline.json. Include fields for suite path, input/output fields, max cases, replay command, judge command, reports, logs, and fail-on behavior."} {"case_id":"task_04","prompt":"Create a runner adapter guide for a team using an HTTP API. Their endpoint accepts {\"prompt\":\"...\"} and returns {\"response\":\"...\"}. Show the script, environment variables, and redline command."} -{"case_id":"task_05","prompt":"Review this CLI output and tell me whether it is clear enough for a first-time user. Suggest exact wording improvements:\nGenerated 5 cases from 5 records.\nDetected 5 behavioral clusters.\nWrote redline-suite.json."} +{"case_id":"task_05","prompt":"Review this CLI output and tell me whether it is clear enough for a first-time user. Suggest exact wording improvements:\nGenerated 5 cases from 5 records.\nDetected 5 behavior-signature groups.\nWrote redline-suite.json."} {"case_id":"task_06","prompt":"I changed an LLM system prompt and now responses are shorter. Give me a realistic before/after prompt-output log with 5 JSONL rows where the shorter candidate silently loses important behavior."} {"case_id":"task_07","prompt":"Act as a code reviewer for a Python CLI eval tool. What are the top 10 bugs or product risks you would look for before calling it public alpha?"} {"case_id":"task_08","prompt":"Write a GitHub Actions workflow for redline. It should install the package, run doctor, validate the suite, run eval, upload reports, and fail on regressions."} diff --git a/docs/calibration.md b/docs/calibration.md new file mode 100644 index 0000000..6b91980 --- /dev/null +++ b/docs/calibration.md @@ -0,0 +1,48 @@ +# Calibration Examples + +Use this fixture when you want to see redline's trust boundary in a tiny, +inspectable dataset. It is not a benchmark. It is a calibration exercise that +shows what deterministic checks catch, what they mark for review, and what a +green case means. + +```bash +redline suite examples/calibration_baseline.jsonl \ + --out /tmp/redline-calibration-suite.json \ + --all-cases + +redline diff /tmp/redline-calibration-suite.json \ + examples/calibration_candidate.jsonl \ + --fail-on none +``` + +Expected result: + +- Two regressions: lost JSON structure, and dropped refund details. +- One changed case: the reply added apologetic tone. +- One neutral case: the response is unchanged. + +The neutral case is deliberately boring. It means redline found no configured +behavioral change in that case. It does not prove factual correctness, +hallucination safety, policy compliance, or subtle reasoning quality. + +## Why This Matters + +The fixture demonstrates the intended operating model: + +- structural losses should block a prompt release; +- tone or wording shifts should be reviewed before acceptance; +- unchanged or neutral cases still inherit the global trust boundary; +- must-cover product requirements should be pinned with `redline suite add` or + `redline require`. + +For semantic risks, add requirements or configure an optional judge. Keep the +default deterministic checks as the fast local gate, and use judges where the +product risk is not visible from structure or concrete detail loss. + +For exploratory data with noisy entity extraction, compare strict and review +profiles: + +```bash +redline diff /tmp/redline-calibration-suite.json examples/calibration_candidate.jsonl --profile strict --fail-on none +redline diff /tmp/redline-calibration-suite.json examples/calibration_candidate.jsonl --profile review --fail-on none +``` diff --git a/docs/case-studies.md b/docs/case-studies.md index 7d8c678..d042412 100644 --- a/docs/case-studies.md +++ b/docs/case-studies.md @@ -1,8 +1,9 @@ # Case Studies -These are repo-local dogfood runs that anyone can reproduce from a fresh -checkout. They are not customer case studies yet. They are the proof fixtures we -use until external users share anonymized logs. +These are dogfood runs we use until external users share anonymized logs. The +first two are repo-local fixtures that anyone can reproduce from a fresh +checkout. The public internet dogfood run uses third-party rows that stay under +`.redline/private/`, so only the protocol and aggregate result are committed. The important property is that the data is checked in, deterministic, and launch-safe. If a future redline change stops catching these regressions, the @@ -131,6 +132,68 @@ Expected result: long-form assistant differences appear as `changed` review items unless a candidate loses stronger deterministic signals such as required structure, URLs, refusals, or empty output. +## Case Study 4: Public Internet Dogfood + +Scenario: download a public instruction-response dataset, import a 100-row +sample, create a deterministic local "candidate got shorter" variant, then +verify that redline diagnoses the regression pattern across reports and +dashboard surfaces. + +Source: + +- [Databricks Dolly 15k](https://huggingface.co/datasets/databricks/databricks-dolly-15k) +- Local raw and normalized rows stay under `.redline/private/`. + +Protocol used: + +```bash +curl -L \ + https://huggingface.co/datasets/databricks/databricks-dolly-15k/resolve/main/databricks-dolly-15k.jsonl \ + -o .redline/private/internet-dogfood-100/dolly-raw.jsonl + +redline import .redline/private/internet-dogfood-100/dolly-raw.jsonl \ + --preset dolly \ + --limit 100 \ + --out .redline/private/internet-dogfood-100/dolly-baseline-100.jsonl + +redline suite .redline/private/internet-dogfood-100/dolly-baseline-100.jsonl \ + --out .redline/private/internet-dogfood-100/dolly-suite-100.json \ + --all-cases +``` + +The candidate file for this run was generated locally by applying deterministic +shortening, refusal, empty-output, and generic-answer mutations to the imported +baseline. It stays under `.redline/private/` with the downloaded source rows. +For real dogfood, replace that mutation step with your actual candidate model or +prompt runner. + +```bash +redline diff .redline/private/internet-dogfood-100/dolly-suite-100.json \ + .redline/private/internet-dogfood-100/dolly-candidate-100.jsonl \ + --compact \ + --fail-on none +``` + +Current result: + +```text +redline diff: cases=100 regression=51 changed=27 improved=0 accepted=0 ignored=0 missing=0 neutral=22 +Diagnosis: Candidate got shorter, lost required structure, dropped concrete details, returned empty outputs, and changed content substantially; fix blocking cases before shipping. +``` + +What redline catches: + +- Shorter candidate outputs that drop concrete details. +- Empty outputs and newly refused safe requests. +- Structure loss in code, lists, and other format-sensitive answers. +- Suite coverage across 100/100 cases and 20/20 behavior groups. +- Dashboard sidecar filtering: 1 report, 1 benchmark, 1 history entry, and 0 + warnings in the local product app. + +Why this matters: the demo proves the first-run promise; this run proves the +same local loop can process a larger public prompt-response sample and produce +dashboard/report evidence without cloud services or private data. + ## External Case Studies Still Needed The repo fixtures prove the loop and protect regressions in redline itself. The diff --git a/docs/commands.md b/docs/commands.md index 58a23ce..1308788 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -7,20 +7,23 @@ output and examples after installing. | --- | --- | --- | | `redline` | Show first-run help. | `--version` | | `redline demo` | Generate and diff the bundled demo logs. | `--public`, `--compact` | +| `redline status` | Show project readiness, latest local evidence, app command, first review case, and the next command to run. | `--reports-dir`, `--history`, `--checkpoint`, `--limit`, `--json` | +| `redline app` | Open the guided local product app for import, suite, eval, review, history, and integration commands. | `--demo`, `--reports-dir`, `--history`, `--checkpoint`, `--out`, `--no-open`, `--json` | | `redline init` | Write `redline.json`, runner files, and optional CI workflow. | `--runner`, `--copy-runner`, `--github-action`, `--force` | | `redline doctor` | Check config, suite, replay, reports, audit, and team workflow. | `--strict`, `--json` | | `redline watch` | Collect prompt-response observations or print snippets. | `--log`, `--stats`, `--snippet`, `--follow` | -| `redline import` | Normalize exported JSONL fields into redline `prompt`/`response` logs. | `--input-field`, `--output-field`, `--context-field`, `--metadata-field`, `--limit`, `--out` | +| `redline import` | Normalize exported JSONL fields into redline `prompt`/`response` logs, with best-effort redaction on by default. | `--list-presets`, `--detect`, `--auto-map`, `--preview`, `--preset`, `--input-field`, `--output-field`, `--context-field`, `--metadata-field`, `--limit`, `--out`, `--no-redact` | | `redline redact` | Check or write best-effort redacted logs. | `--check`, `--out`, `--json` | -| `redline cluster` | Inspect behavior groups before suite generation. | `--max-cases`, `--json` | +| `redline cluster` | Inspect deterministic behavior-signature groups before suite generation. | `--max-cases`, `--json` | | `redline suite` | Generate a suite from baseline JSONL logs. | `--out`, `--max-cases`, `--all-cases`, `--owner` | | `redline suite add` | Pin a hand-picked edge case. | `--prompt`, `--response`, `--include`, `--exclude`, `--owner` | | `redline cases` | List generated suite case IDs and coverage. | `--json` | | `redline case` | Show one full suite case. | `--json` | | `redline require` | Add deterministic include/exclude requirements. | `--include`, `--exclude`, `--owner` | | `redline prompts` | Build or check a prompt-to-suite manifest. | `--suite-dir`, `--out`, `--check`, `--check-suites` | -| `redline summary` | Summarize suite or manifest readiness. | `--json` | +| `redline summary` | Summarize suite or manifest readiness, including suite score and coverage gaps. | `--json` | | `redline validate` | Validate suite or manifest structure and freshness. | `--strict`, `--json` | +| `redline quick-check` | Generate a temporary suite from baseline JSONL, diff candidate JSONL, and write reports plus a guided local app in one first-run command. | `--input-field`, `--output-field`, `--out-dir`, `--max-cases`, `--all-cases`, `--profile`, `--fail-on`, `--open`, `--open-app` | | `redline budget` | Estimate CI runtime without replaying prompts. | `--workers`, `--timeout`, `--max-seconds`, `--measure-local` | | `redline benchmark` | Compatibility alias for `redline budget`. | Same as `budget` | | `redline eval` | Replay suite cases through a configured runner. | `--prompt`, `--replay`, `--workers`, `--judge`, `--fail-on`, `--compact` | @@ -29,7 +32,7 @@ output and examples after installing. | `redline accept` | Promote reviewed candidate outputs into the suite baseline. | `--candidate`, `--all-expected`, `--approver`, `--note` | | `redline history` | Append and summarize trend history. | `--label`, `--out`, `--out-md`, `--fail-on` | | `redline compare` | Compare two redline reports. | `--fail-on`, `--out-json`, `--out-html` | -| `redline dashboard` | Render a self-contained local HTML dashboard. | `--reports-dir`, `--history`, `--checkpoint`, `--out`, `--open` | +| `redline dashboard` | Render a self-contained local HTML dashboard artifact. | `--reports-dir`, `--history`, `--checkpoint`, `--out`, `--style classic\|app`, `--open` | | `redline runners` | List or copy runner and log adapter templates. | `--copy`, `--out`, `--force` | | `redline judges` | List or copy judge templates. | `--copy`, `--out`, `--force` | | `redline audit` | Show and verify local audit events. | `--verify`, `--checkpoint`, `--out-checkpoint`, `--expect-last-hash` | @@ -51,3 +54,19 @@ output and examples after installing. Structural checks are deterministic and local. Green or neutral does not prove semantic equivalence. Use `redline require`, `redline mark`, and optional judge templates for factual, tone, hallucination, policy, or reasoning risks. + +## Import Help + +Use `redline import --detect` when you do not know an export's field names. +Use `--auto-map --preview 3` when you want redline to try the best detected +mapping before writing normalized logs. Source-specific recipes live in +[docs/import-guides.md](import-guides.md). + +## Diff profiles + +- `--profile strict` is the default CI-oriented mode. Missing concrete details + such as numbers and likely entities are blocking regressions. +- `--profile review` is a softer calibration mode for noisy exploratory logs. + Missing numbers and likely entities become reviewable `changed` cases while + hard structural losses, such as invalid JSON, missing JSON keys, empty + outputs, refusals, tables, lists, code blocks, and URLs, stay blocking. diff --git a/docs/dogfood.md b/docs/dogfood.md index 143dfb2..a1eade3 100644 --- a/docs/dogfood.md +++ b/docs/dogfood.md @@ -23,13 +23,13 @@ Estimated time: 10 minutes. python -m pip install redline-ai redline demo redline cases .redline/demo/suite.json -redline dashboard --reports-dir .redline/demo/reports --out .redline/dashboard.html +redline app --reports-dir .redline/demo/reports --no-open --out .redline/app.html redline runners redline doctor ``` Expected result: the demo catches realistic support-agent regressions, the next -steps are obvious, the dashboard links reports cleanly, and the first warning +steps are obvious, the app links reports cleanly, and the first warning explains exactly what to run next. ## Pass 2: Real Replay Setup diff --git a/docs/import-guides.md b/docs/import-guides.md new file mode 100644 index 0000000..616ffc5 --- /dev/null +++ b/docs/import-guides.md @@ -0,0 +1,120 @@ +# Import Guides + +redline only needs prompt-response JSONL. Your production system probably calls +those fields something else. Start with detection, preview the mapping, then +write the normalized baseline. + +```bash +redline import raw-export.jsonl --detect +redline import raw-export.jsonl --auto-map --preview 3 +redline import raw-export.jsonl --auto-map --out logs/baseline.jsonl +redline import raw-export.jsonl --input-field request.prompt --output-field response.text --preview 3 +redline import raw-export.jsonl --input-field request.prompt --output-field response.text --out logs/baseline.jsonl +``` + +Redaction is enabled by default during import. It is best-effort pattern +matching, not a privacy boundary; review normalized files before sharing or +committing them. + +## Langfuse-Style Exports + +Use the built-in preset when your export has `input` and `output` fields. + +```bash +redline import langfuse-export.jsonl --preset langfuse --preview 3 +redline import langfuse-export.jsonl --preset langfuse --out logs/baseline.jsonl +``` + +If your export nests generations under a trace object, detect first: + +```bash +redline import langfuse-export.jsonl --detect +``` + +Then rerun import with the suggested field paths. + +## Helicone-Style Exports + +The preset expects `request.prompt` and `response.text`. + +```bash +redline import helicone-export.jsonl --preset helicone --preview 3 +redline import helicone-export.jsonl --preset helicone --out logs/baseline.jsonl +``` + +For chat payloads, the prompt may be an array of messages. That is acceptable; +redline stringifies structured prompts deterministically. + +## OpenAI Chat Traces + +The `openai-chat` preset maps chat messages to the prompt and the first choice +message to the response. + +```bash +redline import openai-chat.jsonl --preset openai-chat --preview 3 +redline import openai-chat.jsonl --preset openai-chat --out logs/baseline.jsonl +``` + +If your app stores `response.output_text`, override the response path: + +```bash +redline import openai-chat.jsonl \ + --input-field request.messages \ + --output-field response.output_text \ + --preview 3 +``` + +## Datadog Or App Logs + +The `datadog` preset expects `attributes.input` and `attributes.output`. + +```bash +redline import datadog.jsonl --preset datadog --preview 3 +redline import datadog.jsonl --preset datadog --out logs/baseline.jsonl +``` + +For custom app logs, common mappings look like: + +```bash +redline import app.jsonl --input-field ticket.text --output-field assistant.reply --preview 3 +redline import app.jsonl --input-field prompt --output-field completion --preview 3 +redline import app.jsonl --input-field payload.user_question --output-field result.assistant_answer --preview 3 +``` + +## Database Exports + +Export the rows you want as JSONL, one object per line: + +```json +{"prompt":"Classify ticket INV-1042","response":"{\"owner\":\"billing\"}","created_at":"2026-06-14T10:00:00Z"} +``` + +Then import: + +```bash +redline import db-export.jsonl \ + --input-field prompt \ + --output-field response \ + --metadata-field created_at \ + --preview 3 + +redline import db-export.jsonl \ + --input-field prompt \ + --output-field response \ + --metadata-field created_at \ + --out logs/baseline.jsonl +``` + +## After Import + +```bash +redline suite logs/baseline.jsonl --out redline-suite.json +redline summary redline-suite.json +redline cases redline-suite.json +``` + +If the suite looks thin, add pinned cases: + +```bash +redline suite add redline-suite.json --prompt "..." --response "..." +``` diff --git a/docs/launch.md b/docs/launch.md index b8da4dc..33e082e 100644 --- a/docs/launch.md +++ b/docs/launch.md @@ -34,15 +34,15 @@ Generate launch artifacts from a clean release checkout: ```bash bash scripts/release_check.sh bash scripts/demo_gif.sh .redline/launch .redline/launch/redline-demo.gif -bash scripts/build_release.sh /tmp/redline-dist-v0.2.1 +bash scripts/build_release.sh /tmp/redline-dist-v0.3.0 ``` Required artifacts: - `.redline/launch/redline-demo.gif` or `.redline/launch/redline-demo-transcript.txt` - `.redline/dashboard.html` from the demo path -- `/tmp/redline-dist-v0.2.1/redline_ai-0.2.1-py3-none-any.whl` -- `/tmp/redline-dist-v0.2.1/redline_ai-0.2.1.tar.gz` +- `/tmp/redline-dist-v0.3.0/redline_ai-0.3.0-py3-none-any.whl` +- `/tmp/redline-dist-v0.3.0/redline_ai-0.3.0.tar.gz` ## Website Checklist @@ -79,19 +79,21 @@ should sell the loop, not the package manager. 2. Tag the exact commit: ```bash - git tag v0.2.1 - git push origin v0.2.1 + git tag v0.3.0 + git push origin v0.3.0 ``` 3. Upload the release distribution: ```bash - python -m twine upload /tmp/redline-dist-v0.2.1/* + python -m twine upload \ + /tmp/redline-dist-v0.3.0/redline_ai-*.whl \ + /tmp/redline-dist-v0.3.0/redline_ai-*.tar.gz ``` -4. Create the GitHub release from `v0.2.1`. +4. Create the GitHub release from `v0.3.0`. 5. Attach the demo GIF or link the public README asset: - `https://gowtham0992.github.io/redline/assets/redline-product-demo.gif`. + `https://raw.githubusercontent.com/gowtham0992/redline/main/site/assets/redline-product-demo.gif`. ## Launch Post @@ -101,7 +103,7 @@ Short post: I built redline, a local-first tool that turns prompt-response logs into eval suites automatically. -You point it at existing JSONL logs, it clusters behavior, picks representative +You point it at existing JSONL logs, groups behavior by deterministic signatures, picks representative cases, and catches regressions when a prompt change drops structure, numbers, URLs, entities, refusals, tables, code blocks, or required fields. @@ -123,7 +125,7 @@ Longer post: Most eval tools start with "write test cases." redline starts from the logs you already have. -It watches or imports prompt-response JSONL, clusters observed behavior, builds +It watches or imports prompt-response JSONL, groups observed behavior by deterministic signatures, builds a representative suite, and compares new prompt runs against the accepted baseline. The default checks are deterministic and local: JSON validity, missing keys, empty outputs, refusals, URLs, numbers, entities, code blocks, tables, diff --git a/docs/mcp.md b/docs/mcp.md index 1350bea..cf61b83 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -63,10 +63,13 @@ reports. Available tools: +- `redline_status` - `redline_doctor` - `redline_suite` +- `redline_quick_check` - `redline_redact` -- `redline_import` +- `redline_import` (supports `detect`, `auto_map`, and `preview` before writing) +- `redline_import_presets` - `redline_watch_stats` - `redline_watch_snippets` - `redline_prompts` @@ -86,9 +89,9 @@ Available tools: - `redline_audit` (including `verify` and checkpoint output for the local audit hash chain) - `redline_sbom` -`redline_diff` and `redline_eval` return the underlying redline exit code as -structured data. Exit code `1` means redline found blocking regressions or -missing outputs; the MCP tool still returns successfully because that is a +`redline_quick_check`, `redline_diff`, and `redline_eval` return the underlying +redline exit code as structured data. Exit code `1` means redline found blocking regressions +or missing outputs; the MCP tool still returns successfully because that is a product finding, not a protocol failure. Exit code `2` and above indicates a setup or command error. @@ -101,9 +104,9 @@ assistants can reason over reports without scraping terminal text. The server also exposes MCP prompt templates for the workflows agents should reach for most often: -- `check_prompt_change`: run doctor, then eval a changed prompt file. +- `check_prompt_change`: run status/doctor, then eval a changed prompt file. - `build_suite_from_logs`: generate a suite, validate it, and summarize coverage. -- `review_candidate_outputs`: diff candidate JSONL outputs and lead with blocking findings. +- `review_candidate_outputs`: quick-check two JSONL logs or diff candidate outputs against a suite, then lead with blocking findings. - `setup_redline_project`: guide first-time setup through runner selection, prompt/log discovery, suite validation, CI scale checks, and optional judge setup. @@ -120,6 +123,11 @@ Ask your assistant: Run redline doctor in this repo and tell me the next setup step. ``` +```text +Run redline status in this repo and tell me the exact next command, app command, +first review case, and why that case matters. +``` + ```text Set up redline for this project. Use my existing logs if available, choose the right runner adapter, and do not mutate the baseline. @@ -130,6 +138,11 @@ Generate a redline suite from logs/baseline.jsonl, then diff it against logs/candidate.jsonl. Summarize only regressions I should review. ``` +```text +Quick-check logs/baseline.jsonl against logs/candidate.jsonl and tell me the +blocking regressions before I wire a permanent suite. +``` + ```text Run redline eval with prompts/v2.txt and tell me whether this prompt change is safe to ship. Do not accept or modify the baseline. diff --git a/docs/methodology.md b/docs/methodology.md new file mode 100644 index 0000000..d02a7c3 --- /dev/null +++ b/docs/methodology.md @@ -0,0 +1,129 @@ +# Methodology + +redline is a deterministic regression detector for prompt-response systems. It +does not claim that a green run proves semantic safety. It turns logs into a +reviewable suite, checks high-signal behavioral changes, and leaves factual, +tone, policy, hallucination, and subtle reasoning judgment to explicit +requirements, optional judges, or humans. + +This is a deliberate product boundary. A merge blocker should be fast, +repeatable, local, and cheap. Deterministic checks catch the production failures +that break parsers and workflows without asking a model to grade every run. Use +judges as review evidence for ambiguous or domain-specific behavior, not as the +only line of defense. + +## Input Model + +redline expects prompt-response observations as JSONL. The canonical fields are +`prompt` and `response`, but `redline import` can normalize nested exports from +application logs, observability tools, or model gateways. + +Import is redaction-first by default. The importer redacts common secrets and +PII before extracting fields so copied metadata is treated with the same local +privacy boundary as prompt and response text. Use `--no-redact` only for local +fixtures you have already inspected. + +## Behavior Grouping + +redline does not run statistical, embedding, or semantic clustering by default. +It groups observations by deterministic behavior signatures: + +- prompt intent, such as classify, summarize, transform, generate, or answer +- response shape, such as JSON, table, code block, bullet list, numbered list, + refusal, empty output, or prose +- response length bucket +- JSON schema shape when the response is valid JSON + +The CLI command is named `redline cluster` for compatibility, but the public +model is behavior-signature grouping. These groups are explainable and stable; +they are not a claim that two prompts are semantically equivalent. + +## Case Selection + +Suite generation chooses cases in this order: + +1. One representative from each behavior group. +2. High-risk groups with obvious failure patterns, such as invalid JSON, + malformed tables, empty outputs, or refusals. +3. High-variance edges where same-shape outputs vary substantially in length. +4. Prompt-diverse samples from large groups when the case budget allows. +5. User-pinned cases from `redline suite add`. + +This strategy optimizes for useful regression coverage from existing logs, not +perfect coverage of every product risk. If a scenario matters, pin it. + +Generated suites include a `methodology` block with the deterministic +case-selection method and version, currently `behavior-signature-v1`, so +reports can be audited against the algorithm that selected the suite. Diff +and eval reports copy that block into JSON, Markdown, HTML, and dashboard +surfaces so reviewers can see the trust method beside the result. +The generated suite summary also carries `case_coverage` and +`cluster_coverage` so downstream tools can display basic suite-health context +without recomputing it. + +Suite summaries also count `stochastic_prompt_groups`: prompts that appear with +more than one distinct baseline response. Those prompts may be genuinely +multi-answer, or they may come from high-temperature sampling, model drift, or +mixed production contexts. redline warns on them because natural baseline +variance can look like a regression unless the suite is stabilized, switched to +review mode, or guarded with explicit requirements. + +## Regression Signals + +The deterministic diff engine checks for high-signal changes: + +- lost JSON validity or missing JSON keys +- lost table, list, or code-block structure +- missing numbers, URLs, ticket IDs, and likely entities +- empty outputs +- newly refused safe-looking tasks +- allow/deny polarity flips +- large same-shape content drift +- explicit include/exclude requirements + +These checks are intentionally conservative. They catch common prompt +regressions quickly in CI, but they do not replace domain review. + +## Suite Readiness Score + +`redline summary` reports a suite readiness score from 0 to 100. The score is a +suite-health signal, not a model-quality score. It combines: + +- behavior-group coverage +- unique prompt-response pair coverage +- explicit requirements or recorded judgments +- owner coverage +- high-risk group calibration +- non-English calibration warnings +- stochastic baseline warnings + +Use the score to decide what to improve before CI gating. A high score means the +suite is better prepared for review and automation. It does not mean the +candidate prompt is safe to ship. + +## Judges + +Optional judges are for ambiguous `changed` cases and semantic risks that +deterministic checks cannot prove. A judge can be a local script, a local model, +or a cloud LLM command you control. redline routes only the configured review +surface and records judge output as evidence. + +Default redline behavior is local and deterministic. No cloud judge is called +unless you configure one. + +## Calibration + +Use redline as a safety loop: + +1. Import or watch real logs. +2. Generate a suite. +3. Inspect `redline summary` and `redline cases`. +4. Pin must-cover edge cases. +5. Add requirements for details that must not disappear. +6. Run `redline eval` or `redline diff` before prompt changes ship. +7. Mark expected changes and accept new baselines after review. + +The strongest evidence for redline is not a score. It is a concrete report that +says which production behavior changed and why it matters. + +For a tiny runnable boundary check, see [docs/calibration.md](calibration.md). diff --git a/docs/real-log-dogfood.md b/docs/real-log-dogfood.md new file mode 100644 index 0000000..68a19f4 --- /dev/null +++ b/docs/real-log-dogfood.md @@ -0,0 +1,133 @@ +# Real Log Dogfood + +Use this protocol when testing redline on real prompt/output logs from a team, +agent, support workflow, extraction job, or public dataset. The goal is to learn +whether redline catches useful regressions without leaking private data. + +## Rule Zero + +Do not send raw customer logs, secrets, API keys, or private prompts to another +person. Keep the raw export local. Share only sanitized reports or short +hand-redacted examples. + +## 1. Inspect the Export + +Open a few rows before importing: + +```bash +python -m json.tool < first-row.json +``` + +Find the field that contains the user prompt or instruction, and the field that +contains the model or agent response. Common examples: + +| Source shape | Input field | Output field | +| --- | --- | --- | +| redline JSONL | `prompt` | `response` | +| Dolly-style public data | `instruction` | `response` | +| OpenAI wrapper log | `request.messages` | `response.output_text` | +| Langfuse/Helicone-style export | `input` | `output` | +| Support traces | `ticket.text` | `assistant.reply` | + +For common exports, start with a preset and override fields only when your file +differs: + +```bash +redline import --list-presets +redline import raw-export.jsonl --detect +redline import raw-export.jsonl --preset langfuse --preview 3 +redline import raw-export.jsonl --preset langfuse --out .redline/dogfood/baseline.jsonl +redline import raw-export.jsonl --preset helicone --out .redline/dogfood/baseline.jsonl +redline import raw-export.jsonl --preset openai-chat --out .redline/dogfood/baseline.jsonl +``` + +## 2. Import With Redaction On + +`redline import` redacts common secrets and PII by default before writing the +normalized baseline file. + +```bash +redline import raw-export.jsonl \ + --input-field instruction \ + --output-field response \ + --metadata-field category \ + --preview 3 + +redline import raw-export.jsonl \ + --input-field instruction \ + --output-field response \ + --metadata-field category \ + --out .redline/dogfood/baseline.jsonl +``` + +Use `--detect` when you do not know the export field names. Use `--preview` +before writing when the export shape is unfamiliar. It prints mapped, redacted +sample prompt-response rows without writing a file. The import command prints +how many values were redacted. If the count is non-zero, inspect the normalized +output before generating a suite. + +Use `--no-redact` only for local-only test fixtures that have already been +reviewed. + +## 3. Generate a Suite + +```bash +redline suite .redline/dogfood/baseline.jsonl \ + --out .redline/dogfood/suite.json +``` + +Then inspect the selected cases: + +```bash +redline cases .redline/dogfood/suite.json +redline summary .redline/dogfood/suite.json +``` + +## 4. Compare a Candidate + +If you already have candidate outputs: + +```bash +redline diff .redline/dogfood/suite.json .redline/dogfood/candidate.jsonl \ + --compact \ + --out-json .redline/dogfood/diff.json \ + --out-html .redline/dogfood/diff.html \ + --fail-on none +``` + +If you have a runner configured: + +```bash +redline eval .redline/dogfood/suite.json \ + --prompt prompts/v2.txt \ + --compact \ + --fail-on none +``` + +## 5. Report Back + +For early dogfood, collect this information: + +- Source type: public dataset, app logs, trace export, SDK capture, or manual JSONL. +- Rows imported and redaction count. +- Suite cases generated and behavior groups covered. +- Blocking regressions found, if any. +- False positives that felt noisy. +- First command or error message that caused confusion. +- Whether the result would have changed a shipping decision. + +Do not share raw logs. Share the redline report only after reviewing it for +private content. + +## What Good Looks Like + +A useful dogfood run does not need to prove semantic correctness. It should show +whether redline caught concrete structural drift: + +- JSON keys disappeared. +- A table became prose. +- A safe task became a refusal. +- Required numbers, URLs, owners, or action items were dropped. +- A pinned requirement failed. + +Those are the regressions redline is designed to catch quickly and locally. diff --git a/docs/release.md b/docs/release.md index 87b0edd..96808c9 100644 --- a/docs/release.md +++ b/docs/release.md @@ -14,7 +14,8 @@ changes; patch versions are reserved for compatible fixes. - Work from a clean release branch that is pushed to origin. - Before tagging, merge to `main` and rerun `bash scripts/certify_release.sh`. -- Update the version in `pyproject.toml` and `redline/__init__.py`. +- Update the version in `pyproject.toml`, `redline/__init__.py`, and + `server.json`. - Update `CHANGELOG.md` with the user-visible changes. - Run the packaged release gate: @@ -51,7 +52,7 @@ new user: python -m pip install redline-ai redline demo redline demo --public --compact -redline dashboard --reports-dir .redline/demo/reports --out .redline/dashboard.html +redline app --reports-dir .redline/demo/reports --no-open --out .redline/app.html redline runners redline init --runner stdio --copy-runner redline doctor @@ -59,7 +60,7 @@ redline doctor Confirm the demo ends with actionable next steps, shows the mark/accept review loop, and catches the intentional support-agent regressions. Confirm the -dashboard opens as a self-contained local report index. Confirm the +guided app opens as a self-contained local workflow. Confirm the public-pattern proof works from the installed package without relying on repo-local `examples/` paths. @@ -78,7 +79,7 @@ To run the package gate, external-project Action smoke, release build, and `twine check` as one certification pass: ```bash -bash scripts/certify_release.sh /tmp/redline-certify-v0.2.1 +bash scripts/certify_release.sh /tmp/redline-certify-v0.3.0 ``` The certification summary records the git commit, branch, and clean/dirty worktree state. @@ -89,8 +90,8 @@ Release evidence can be traced back to the exact code that was tested. After the release gate and public-alpha smoke both pass: ```bash -git tag v0.2.1 -git push origin v0.2.1 +git tag v0.3.0 +git push origin v0.3.0 ``` Use a new tag for every public release. Do not move an existing release tag @@ -106,11 +107,11 @@ that was tagged. Do not upload an ignored local `dist/*`; it can contain stale dogfood artifacts from earlier builds. ```bash -bash scripts/build_release.sh /tmp/redline-dist-v0.2.1 -python -m twine upload /tmp/redline-dist-v0.2.1/redline_ai-*.whl /tmp/redline-dist-v0.2.1/redline_ai-*.tar.gz +bash scripts/build_release.sh /tmp/redline-dist-v0.3.0 +python -m twine upload /tmp/redline-dist-v0.3.0/redline_ai-*.whl /tmp/redline-dist-v0.3.0/redline_ai-*.tar.gz ``` -`build_release.sh` also writes `/tmp/redline-dist-v0.2.1/redline-sbom.json` as +`build_release.sh` also writes `/tmp/redline-dist-v0.3.0/redline-sbom.json` as CycloneDX release evidence. Keep it with internal release records or attach it to GitHub release artifacts when needed. diff --git a/docs/repository.md b/docs/repository.md index 6421d2a..0572efb 100644 --- a/docs/repository.md +++ b/docs/repository.md @@ -33,7 +33,7 @@ release needs a fix, cut a new patch tag. Before tagging, run: ```bash -bash scripts/certify_release.sh /tmp/redline-certify-v0.2.1 +bash scripts/certify_release.sh /tmp/redline-certify-v0.3.0 ``` Build and upload PyPI artifacts from the exact tagged commit, using a fresh @@ -65,7 +65,7 @@ Before posting publicly: ```bash git status --short --branch -bash scripts/certify_release.sh /tmp/redline-certify-v0.2.1 +bash scripts/certify_release.sh /tmp/redline-certify-v0.3.0 bash scripts/demo_gif.sh .redline/launch .redline/launch/redline-demo.gif ``` diff --git a/docs/runners.md b/docs/runners.md index cb30b24..a6187a2 100644 --- a/docs/runners.md +++ b/docs/runners.md @@ -247,7 +247,7 @@ python runners/braintrust_suite_export.py redline-suite.json \ ``` What it does: writes one JSONL row per redline case with `input`, `expected`, -and redline metadata such as case ID, cluster, owner, and source line. Import +and redline metadata such as case ID, behavior signature, owner, and source line. Import that JSONL into Braintrust when you want Braintrust to mirror the same dataset redline gates locally. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 2298d3d..9e99a95 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -32,14 +32,15 @@ The demo writes only local files under `.redline/demo`. ## Dashboard does not open -`--open` uses Python's local browser integration. It can be a no-op on headless -CI, SSH sessions, containers, and remote agents. Write the file explicitly: +`redline app` uses Python's local browser integration by default. It can be a +no-op on headless CI, SSH sessions, containers, and remote agents. Write the +file explicitly: ```bash -redline dashboard --reports-dir .redline/demo/reports --out .redline/dashboard.html +redline app --reports-dir .redline/demo/reports --no-open --out .redline/app.html ``` -Then open `.redline/dashboard.html` from your local machine or upload it as a CI +Then open `.redline/app.html` from your local machine or upload it as a CI artifact. ## Suite file not found @@ -75,7 +76,7 @@ The Action runs in CI against committed files. Make sure the suite or prompt manifest exists in the repository and the action input points to it: ```yaml -uses: gowtham0992/redline@v0.2.1 +uses: gowtham0992/redline@v0.3.0 with: suite: redline-suite.json ``` diff --git a/examples/calibration_baseline.jsonl b/examples/calibration_baseline.jsonl new file mode 100644 index 0000000..4a2c9f4 --- /dev/null +++ b/examples/calibration_baseline.jsonl @@ -0,0 +1,4 @@ +{"prompt":"Return JSON with keys owner and priority for ticket INV-1042.","response":"{\"owner\":\"billing\",\"priority\":\"high\"}"} +{"prompt":"Answer a customer asking about refunds. Include the 30 day window and policy URL https://docs.example.test/refunds.","response":"Refunds are available within 30 days. See https://docs.example.test/refunds for the full policy."} +{"prompt":"Tell Sam that the export is ready.","response":"Sam, the export is ready in the dashboard."} +{"prompt":"Confirm the workspace status in one sentence.","response":"The workspace remains available."} diff --git a/examples/calibration_candidate.jsonl b/examples/calibration_candidate.jsonl new file mode 100644 index 0000000..7461a9b --- /dev/null +++ b/examples/calibration_candidate.jsonl @@ -0,0 +1,4 @@ +{"prompt":"Return JSON with keys owner and priority for ticket INV-1042.","response":"owner: billing, priority: high"} +{"prompt":"Answer a customer asking about refunds. Include the 30 day window and policy URL https://docs.example.test/refunds.","response":"Refunds are available. See the policy page."} +{"prompt":"Tell Sam that the export is ready.","response":"Sorry Sam, unfortunately the export is ready in the dashboard."} +{"prompt":"Confirm the workspace status in one sentence.","response":"The workspace remains available."} diff --git a/pyproject.toml b/pyproject.toml index 8109fbf..8a8e2e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "redline-ai" -version = "0.2.1" +version = "0.3.0" description = "Generate eval suites from prompt logs and catch LLM regressions locally." readme = "README.md" requires-python = ">=3.10" diff --git a/redline-report.schema.json b/redline-report.schema.json index 6c70357..04214a0 100644 --- a/redline-report.schema.json +++ b/redline-report.schema.json @@ -19,6 +19,31 @@ "enum": ["strict", "review"], "description": "Diff profile used to classify missing numbers and entities." }, + "methodology": { + "type": "object", + "description": "Suite selection methodology copied from the suite, when available.", + "properties": { + "name": {"type": "string"}, + "version": {"type": "string"}, + "trust_scope": {"type": "string"} + }, + "additionalProperties": true + }, + "suite_summary": { + "type": "object", + "description": "Suite-health metadata copied from the suite used to generate this report.", + "properties": { + "records_seen": {"type": "integer", "minimum": 0}, + "unique_prompt_response_pairs": {"type": "integer", "minimum": 0}, + "clusters": {"type": "integer", "minimum": 0}, + "cases": {"type": "integer", "minimum": 0}, + "case_coverage": {"type": ["number", "null"], "minimum": 0, "maximum": 1}, + "cluster_coverage": {"type": ["number", "null"], "minimum": 0, "maximum": 1}, + "stochastic_prompt_groups": {"type": "integer", "minimum": 0}, + "selection": {"type": "string"} + }, + "additionalProperties": true + }, "summary": { "type": "object", "required": ["cases", "regression", "changed", "improved", "accepted", "ignored", "neutral", "missing"], @@ -93,6 +118,10 @@ "baseline_response": {"type": "string"}, "candidate_response": {"type": ["string", "null"]}, "reasons": {"type": "array", "items": {"type": "string"}}, + "impact": { + "type": "string", + "description": "Plain-English explanation of why this case matters to reviewers." + }, "confidence": {"type": "string", "enum": ["low", "medium", "high"]}, "signal": { "type": "string", diff --git a/redline-suite.schema.json b/redline-suite.schema.json index e786323..c815c79 100644 --- a/redline-suite.schema.json +++ b/redline-suite.schema.json @@ -31,6 +31,17 @@ "type": "string", "description": "JSONL field path used as the baseline response/output." }, + "methodology": { + "type": "object", + "description": "Deterministic grouping and case-selection method used to generate this suite.", + "properties": { + "name": {"type": "string"}, + "version": {"type": "string"}, + "trust_scope": {"type": "string"}, + "case_selection": {"type": "array", "items": {"type": "string"}} + }, + "additionalProperties": true + }, "summary": { "type": "object", "required": ["records_seen", "unique_prompt_response_pairs", "clusters", "cases", "selection"], @@ -40,6 +51,8 @@ "duplicate_prompt_response_pairs": {"type": "integer", "minimum": 0}, "clusters": {"type": "integer", "minimum": 0}, "cases": {"type": "integer", "minimum": 0}, + "case_coverage": {"type": ["number", "null"], "minimum": 0, "maximum": 1}, + "cluster_coverage": {"type": ["number", "null"], "minimum": 0, "maximum": 1}, "max_cases": {"type": "integer", "minimum": 0}, "selection": {"type": "string", "enum": ["representative", "all"]}, "pinned_cases": {"type": "integer", "minimum": 0}, @@ -49,7 +62,8 @@ "high_variance_clusters": {"type": "integer", "minimum": 0}, "failure_pattern_clusters": {"type": "integer", "minimum": 0}, "prompt_diversity_cases": {"type": "integer", "minimum": 0}, - "non_ascii_records": {"type": "integer", "minimum": 0} + "non_ascii_records": {"type": "integer", "minimum": 0}, + "stochastic_prompt_groups": {"type": "integer", "minimum": 0} }, "additionalProperties": true }, diff --git a/redline/__init__.py b/redline/__init__.py index 5db1b9b..ea71969 100644 --- a/redline/__init__.py +++ b/redline/__init__.py @@ -3,4 +3,4 @@ __all__ = ["RedlineMiddleware", "__version__", "patch_anthropic", "patch_openai", "record", "watch"] -__version__ = "0.2.1" +__version__ = "0.3.0" diff --git a/redline/benchmark.py b/redline/benchmark.py index e479a93..1d7b9bd 100644 --- a/redline/benchmark.py +++ b/redline/benchmark.py @@ -186,7 +186,7 @@ def format_benchmark_report(report: dict[str, Any], *, command_name: str = "benc "Mode: static estimate; no replay commands are executed", f"{suite_label + ':':<22}{report['suite']}", f"Cases: {report['cases']}", - f"Behavioral clusters: {report['clusters']}", + f"Behavior groups: {report['clusters']}", f"Records seen: {report['records_seen']}", f"Workers: {report['workers']}", f"Timeout per case: {_seconds(report['timeout_seconds'])}", @@ -250,7 +250,7 @@ def format_benchmark_markdown(report: dict[str, Any], *, command_name: str = "be "| --- | --- |", f"| {'Prompt manifest' if report.get('is_prompt_manifest') else 'Suite'} | `{report['suite']}` |", f"| Cases | {report['cases']} |", - f"| Behavioral clusters | {report['clusters']} |", + f"| Behavior groups | {report['clusters']} |", f"| Workers | {report['workers']} |", f"| Timeout per case | {_seconds(report['timeout_seconds'])} |", f"| Worst-case eval budget | {_duration(report['worst_case_seconds'])} |", diff --git a/redline/cli.py b/redline/cli.py index ce803f5..67f26f2 100644 --- a/redline/cli.py +++ b/redline/cli.py @@ -63,7 +63,16 @@ read_history, should_fail_history, ) -from .import_logs import import_jsonl_log +from .import_logs import ( + IMPORT_PRESETS, + detect_import_fields, + format_import_detection, + format_import_presets, + import_jsonl_log, + import_preset, + import_preset_rows, + preview_jsonl_import, +) from .io import append_jsonl, append_text, read_json, read_jsonl_records, write_json, write_jsonl, write_text from .judge import apply_judge from .judge_templates import ( @@ -81,7 +90,7 @@ format_prompt_manifest, format_prompt_manifest_check, ) -from .redact import DEFAULT_PLACEHOLDER, format_redaction_report, redact_jsonl, scan_jsonl_redactions +from .redact import DEFAULT_PLACEHOLDER, REDACTION_BOUNDARY, format_redaction_report, redact_jsonl, scan_jsonl_redactions from .reports import ( format_github_annotations, format_html_report, @@ -100,6 +109,7 @@ runner_adapters, ) from .sbom import build_sbom, format_sbom_report +from .status import build_project_status, format_project_status from .summary import ( format_prompt_manifest_summary, format_suite_summary, @@ -140,6 +150,9 @@ def _root_help() -> str: Start here: redline demo + redline status + redline app --demo + redline quick-check path/to/baseline.jsonl path/to/candidate.jsonl --open redline dashboard redline init --runner stdio --copy-runner redline runners @@ -210,6 +223,15 @@ def build_parser() -> argparse.ArgumentParser: doctor_parser.add_argument("--strict", action="store_true", help="exit non-zero when warnings are present") doctor_parser.set_defaults(func=cmd_doctor) + status_parser = subparsers.add_parser("status", help="show project readiness and the next command to run") + status_parser.add_argument("--config", default=DEFAULT_CONFIG_PATH, help="config path to read") + status_parser.add_argument("--reports-dir", default=".redline/reports", help="directory containing redline JSON reports") + status_parser.add_argument("--history", default=".redline/history.jsonl", help="history JSONL path") + status_parser.add_argument("--checkpoint", default=".redline/audit-checkpoint.json", help="audit checkpoint JSON path") + status_parser.add_argument("--limit", type=int, default=20, help="recent reports/history entries to inspect; use 0 for all") + status_parser.add_argument("--json", action="store_true", help="print machine-readable status metadata") + status_parser.set_defaults(func=cmd_status) + sbom_parser = subparsers.add_parser("sbom", help="write CycloneDX SBOM release evidence") sbom_parser.add_argument("--out", help="write SBOM JSON to this path") sbom_parser.add_argument("--json", action="store_true", help="print machine-readable JSON") @@ -238,9 +260,13 @@ def build_parser() -> argparse.ArgumentParser: runners_parser.set_defaults(func=cmd_runners) import_parser = subparsers.add_parser("import", help="normalize exported JSONL logs into redline format") - import_parser.add_argument("path", help="source JSONL file to normalize") - import_parser.add_argument("--input-field", default="prompt", help="source field path containing prompt text") - import_parser.add_argument("--output-field", default="response", help="source field path containing response text") + import_parser.add_argument("path", nargs="?", help="source JSONL file to normalize") + import_parser.add_argument("--list-presets", action="store_true", help="list built-in import presets") + import_parser.add_argument("--detect", action="store_true", help="suggest prompt and response field mappings") + import_parser.add_argument("--auto-map", action="store_true", help="use the best detected prompt/response mapping") + import_parser.add_argument("--preset", choices=sorted(IMPORT_PRESETS), help="field mapping preset for common log exports") + import_parser.add_argument("--input-field", help="source field path containing prompt text") + import_parser.add_argument("--output-field", help="source field path containing response text") import_parser.add_argument("--context-field", help="optional source field path appended to the prompt as Context") import_parser.add_argument("--id-field", help="optional source field path copied to the redline id field") import_parser.add_argument( @@ -250,7 +276,10 @@ def build_parser() -> argparse.ArgumentParser: help="source field path copied into metadata; repeat for multiple fields", ) import_parser.add_argument("--limit", type=int, help="maximum records to import") - import_parser.add_argument("--out", required=True, help="redline JSONL output path") + import_parser.add_argument("--preview", type=int, metavar="N", help="preview N mapped rows without writing output") + import_parser.add_argument("--out", help="redline JSONL output path") + import_parser.add_argument("--no-redact", action="store_true", help="write raw values without import redaction") + import_parser.add_argument("--redaction-placeholder", default=DEFAULT_PLACEHOLDER, help="replacement text for import redaction") import_parser.add_argument("--json", action="store_true", help="print machine-readable JSON") import_parser.set_defaults(func=cmd_import) @@ -320,7 +349,7 @@ def build_parser() -> argparse.ArgumentParser: audit_parser.add_argument("--json", action="store_true", help="print machine-readable JSON") audit_parser.set_defaults(func=cmd_audit) - cluster_parser = subparsers.add_parser("cluster", help="analyze behavioral clusters in a log") + cluster_parser = subparsers.add_parser("cluster", help="inspect deterministic behavior-signature groups in a log") cluster_parser.add_argument("log", nargs="?", help="JSONL prompt-response log; defaults to watched log") cluster_parser.add_argument("--config", default=DEFAULT_CONFIG_PATH, help="config path to read") cluster_parser.add_argument("--input-field", help="JSONL input field") @@ -391,6 +420,26 @@ def build_parser() -> argparse.ArgumentParser: validate_parser.add_argument("--strict", action="store_true", help="exit non-zero when warnings are present") validate_parser.set_defaults(func=cmd_validate) + quick_check_parser = subparsers.add_parser("quick-check", help="generate a temporary suite and diff candidate outputs") + quick_check_parser.add_argument("baseline", help="baseline prompt-response JSONL") + quick_check_parser.add_argument("candidate", help="candidate prompt-response JSONL") + quick_check_parser.add_argument("--input-field", default="prompt", help="JSONL prompt field path") + quick_check_parser.add_argument("--output-field", default="response", help="JSONL response field path") + quick_check_parser.add_argument("--out-dir", default=".redline/quick-check", help="directory for suite and reports") + quick_check_parser.add_argument("--max-cases", type=int, default=42, help="maximum representative suite cases") + quick_check_parser.add_argument("--all-cases", action="store_true", help="include every unique baseline record") + quick_check_parser.add_argument("--profile", choices=DIFF_PROFILES, default="strict", help="diff signal profile") + quick_check_parser.add_argument("--compact", action="store_true", help="print compact one-line-per-case output") + quick_check_parser.add_argument("--json", action="store_true", help="print machine-readable JSON") + quick_check_parser.add_argument("--open", action="store_true", help="open the HTML report in the default browser") + quick_check_parser.add_argument("--open-app", action="store_true", help="open the guided local app in the default browser") + quick_check_parser.add_argument( + "--fail-on", + default="regression,missing", + help="comma-separated statuses that produce exit code 1; use 'none' for report-only", + ) + quick_check_parser.set_defaults(func=cmd_quick_check) + diff_parser = subparsers.add_parser("diff", help="compare candidate JSONL to a suite") diff_parser.add_argument( "paths", @@ -456,10 +505,24 @@ def build_parser() -> argparse.ArgumentParser: dashboard_parser.add_argument("--checkpoint", default=".redline/audit-checkpoint.json", help="audit checkpoint JSON path") dashboard_parser.add_argument("--out", default=".redline/dashboard.html", help="dashboard HTML output path") dashboard_parser.add_argument("--limit", type=int, default=20, help="recent reports/history entries to include; use 0 for all") + dashboard_parser.add_argument("--style", choices=("classic", "app"), default="classic", help="dashboard visual style") dashboard_parser.add_argument("--open", action="store_true", help="open the dashboard in the default browser") dashboard_parser.add_argument("--json", action="store_true", help="print machine-readable dashboard metadata") dashboard_parser.set_defaults(func=cmd_dashboard) + app_parser = subparsers.add_parser("app", help="open the guided local redline app") + app_parser.add_argument("--reports-dir", default=".redline/reports", help="directory containing redline JSON reports") + app_parser.add_argument("--history", default=".redline/history.jsonl", help="history JSONL path") + app_parser.add_argument("--checkpoint", default=".redline/audit-checkpoint.json", help="audit checkpoint JSON path") + app_parser.add_argument("--out", default=".redline/app.html", help="app HTML output path") + app_parser.add_argument("--limit", type=int, default=20, help="recent reports/history entries to include; use 0 for all") + app_parser.add_argument("--demo", action="store_true", help="generate the public demo reports before opening the app") + app_parser.add_argument("--demo-out", default=".redline/demo", help="demo output directory for --demo") + app_parser.add_argument("--open", dest="open", action="store_true", default=True, help="open the local app in the default browser") + app_parser.add_argument("--no-open", dest="open", action="store_false", help="write the app HTML without opening a browser") + app_parser.add_argument("--json", action="store_true", help="print machine-readable app metadata") + app_parser.set_defaults(func=cmd_app) + eval_parser = subparsers.add_parser("eval", help="replay a suite with a local command") eval_parser.add_argument("suite", nargs="?", help="suite JSON, or prompt manifest JSON from redline prompts") eval_parser.add_argument("--config", default=DEFAULT_CONFIG_PATH, help="config path to read") @@ -595,7 +658,7 @@ def cmd_init(args: argparse.Namespace) -> int: print("Next:") if not replay: print("- Connect a runner: redline init --runner stdio --copy-runner --force") - print(f"- Generate suite: redline suite path/to/log.jsonl --out {config['suite']}") + print(f"- Generate suite: {_shell_command('redline', 'suite', 'path/to/log.jsonl', '--out', config['suite'])}") if replay: print("- Run eval: redline eval") print("- Check setup: redline doctor") @@ -630,6 +693,36 @@ def cmd_doctor(args: argparse.Namespace) -> int: return 0 +def cmd_status(args: argparse.Namespace) -> int: + if args.limit < 0: + raise ValueError("status --limit must be 0 or greater") + config = load_config(args.config) + suite_path = str(config.get("suite") or "redline-suite.json") + suite = None + suite_error = None + if Path(suite_path).exists(): + try: + suite = _read_suite_or_manifest(suite_path) + except ValueError as exc: + suite_error = str(exc) + status = build_project_status( + config_path=args.config, + config=config, + suite=suite, + suite_error=suite_error, + suite_git_ignored=_is_git_ignored(suite_path), + reports_dir=args.reports_dir, + history_path=args.history, + checkpoint_path=args.checkpoint, + limit=args.limit, + ) + if args.json: + print(json.dumps(status, indent=2, sort_keys=True)) + else: + print(format_project_status(status), end="") + return 0 + + def cmd_sbom(args: argparse.Namespace) -> int: sbom = build_sbom() if args.out: @@ -729,35 +822,202 @@ def cmd_judges(args: argparse.Namespace) -> int: def cmd_import(args: argparse.Namespace) -> int: + if args.list_presets: + if args.json: + print(json.dumps({"presets": import_preset_rows()}, indent=2, sort_keys=True)) + else: + print(format_import_presets(), end="") + return 0 + if not args.path: + raise ValueError("import path is required unless --list-presets is used") + if args.detect: + result = detect_import_fields(args.path, limit=args.limit or 20) + if args.json: + print(json.dumps(result, indent=2, sort_keys=True)) + else: + print(format_import_detection(result), end="") + return 0 + auto_mapping = _auto_import_mapping(args) if args.auto_map else {} + preset_name = args.preset or str(auto_mapping.get("preset") or "") + preset = import_preset(preset_name) if preset_name else {} + input_field = args.input_field or str(preset.get("input_field") or "prompt") + output_field = args.output_field or str(preset.get("output_field") or "response") + if args.auto_map: + input_field = args.input_field or str(auto_mapping.get("input_field") or input_field) + output_field = args.output_field or str(auto_mapping.get("output_field") or output_field) + context_field = args.context_field if args.context_field is not None else _optional_preset_string(preset, "context_field") + id_field = args.id_field if args.id_field is not None else _optional_preset_string(preset, "id_field") + metadata_fields = _preset_string_list(preset, "metadata_fields") + metadata_fields.extend(args.metadata_field) + if args.preview is not None: + report = preview_jsonl_import( + args.path, + input_field=input_field, + output_field=output_field, + context_field=context_field, + id_field=id_field, + metadata_fields=metadata_fields, + limit=args.preview, + redact=not args.no_redact, + placeholder=args.redaction_placeholder, + ) + report["preset"] = preset_name + report["auto_mapped"] = bool(args.auto_map) + if auto_mapping: + report["auto_mapping"] = auto_mapping + if args.json: + print(json.dumps(report, indent=2, sort_keys=True)) + else: + _print_import_preview(report) + return 0 + if not args.out: + raise ValueError("--out is required unless --list-presets or --preview is used") report = import_jsonl_log( args.path, output=args.out, - input_field=args.input_field, - output_field=args.output_field, - context_field=args.context_field, - id_field=args.id_field, - metadata_fields=args.metadata_field, + input_field=input_field, + output_field=output_field, + context_field=context_field, + id_field=id_field, + metadata_fields=metadata_fields, limit=args.limit, + redact=not args.no_redact, + placeholder=args.redaction_placeholder, ) + report["preset"] = preset_name + report["auto_mapped"] = bool(args.auto_map) + if auto_mapping: + report["auto_mapping"] = auto_mapping if args.json: print(json.dumps(report, indent=2, sort_keys=True)) else: print(f"Imported {report['records']} prompt-response pairs from {Path(str(report['source']))}.") + if report["auto_mapped"]: + _print_auto_mapping(report) + if report["preset"]: + print(f"Preset: {report['preset']}") print(f"Mapped prompt: {report['input_field']}") print(f"Mapped response: {report['output_field']}") if report["context_field"]: print(f"Appended context: {report['context_field']}") if report["metadata_fields"]: print(f"Copied metadata: {', '.join(report['metadata_fields'])}") + if report["redacted"]: + print( + "Redaction: " + f"best-effort common secrets/PII scanned; {report['redactions']} value(s) redacted" + ) + print(f"Boundary: {REDACTION_BOUNDARY}") + patterns = report.get("redaction_patterns") + if isinstance(patterns, dict) and patterns: + print(f"Redaction hits: {', '.join(f'{key}={value}' for key, value in patterns.items())}") + else: + print("Redaction: disabled; review sensitive logs before sharing or committing") print(f"Wrote {Path(str(report['output']))}.") print() print("Next:") - print(f"- Generate suite: redline suite {Path(str(report['output']))} --out redline-suite.json") + print( + f"- Generate suite: " + f"{_shell_command('redline', 'suite', report['output'], '--out', 'redline-suite.json')}" + ) print("- Inspect cases: redline cases redline-suite.json") print("- Compare candidate outputs: redline diff redline-suite.json path/to/candidate.jsonl") return 0 +def _auto_import_mapping(args: argparse.Namespace) -> dict[str, object]: + detection = detect_import_fields(args.path, limit=args.limit or 20) + suggestions = detection.get("suggestions") + if not isinstance(suggestions, list) or not suggestions: + raise ValueError( + "auto-map could not find a prompt/response mapping; run " + "`redline import path/to/export.jsonl --detect` and pass fields manually" + ) + best = suggestions[0] + if not isinstance(best, dict): + raise ValueError("auto-map produced an invalid mapping; pass --input-field and --output-field manually") + return { + "input_field": str(best.get("input_field") or ""), + "output_field": str(best.get("output_field") or ""), + "preset": str(best.get("preset") or ""), + "score": int(best.get("score") or 0), + "matches": int(best.get("matches") or 0), + "records_scanned": int(best.get("records_scanned") or 0), + } + + +def _print_auto_mapping(report: Mapping[str, object]) -> None: + mapping = report.get("auto_mapping") + mapping = mapping if isinstance(mapping, Mapping) else {} + details = f"score {mapping.get('score', 0)}" + if mapping.get("preset"): + details += f"; preset {mapping['preset']}" + print(f"Auto-mapped: yes ({details})") + + +def _print_import_preview(report: Mapping[str, object]) -> None: + print(f"Previewed {report['previewed']} prompt-response pairs from {Path(str(report['source']))}.") + if report.get("auto_mapped"): + _print_auto_mapping(report) + if report.get("preset"): + print(f"Preset: {report['preset']}") + print(f"Mapped prompt: {report['input_field']}") + print(f"Mapped response: {report['output_field']}") + if report["context_field"]: + print(f"Appended context: {report['context_field']}") + metadata_fields = report.get("metadata_fields") + if isinstance(metadata_fields, list) and metadata_fields: + print(f"Copied metadata: {', '.join(str(value) for value in metadata_fields)}") + if report["redacted"]: + print( + "Redaction: " + f"best-effort common secrets/PII scanned; {report['redactions']} value(s) redacted" + ) + print(f"Boundary: {REDACTION_BOUNDARY}") + else: + print("Redaction: disabled; review sensitive logs before sharing or committing") + print() + print("Preview:") + rows = report.get("rows") + if isinstance(rows, list): + for index, row in enumerate(rows, start=1): + if not isinstance(row, Mapping): + continue + prompt = _truncate_preview(str(row.get("prompt") or "")) + response = _truncate_preview(str(row.get("response") or "")) + print(f"{index}. prompt: {prompt}") + print(f" response: {response}") + metadata = row.get("metadata") + if isinstance(metadata, Mapping) and metadata: + print(f" metadata: {json.dumps(metadata, sort_keys=True)}") + print() + print("No file written.") + print("Next:") + print(f"- Import when ready: {_shell_command('redline', 'import', report['source'], '--out', 'baseline.jsonl')}") + + +def _truncate_preview(value: str, *, limit: int = 120) -> str: + compact = " ".join(value.split()) + if len(compact) <= limit: + return compact + return compact[: limit - 3].rstrip() + "..." + + +def _optional_preset_string(preset: Mapping[str, object], key: str) -> str | None: + value = preset.get(key) + if value is None: + return None + text = str(value).strip() + return text or None + + +def _preset_string_list(preset: Mapping[str, object], key: str) -> list[str]: + value = preset.get(key) + if not isinstance(value, list): + return [] + return [str(item) for item in value if str(item).strip()] + + def cmd_watch(args: argparse.Namespace) -> int: config = load_config(args.config) input_field = str(_config_value(args.input_field, config, "input_field", "prompt")) @@ -816,6 +1076,7 @@ def on_records(rows) -> None: print(f"Skipped {result['skipped_duplicates']} duplicate records.") if result.get("redactions"): print(f"Redacted {result['redactions']} sensitive value(s).") + print(f"Boundary: {REDACTION_BOUNDARY}.") print(f"{str(result['mode']).title()} {Path(str(result['output']))}.") return 0 @@ -1008,11 +1269,16 @@ def cmd_suite(args: argparse.Namespace) -> int: }, }, ) + suite_health = suite_summary(suite) + readiness = suite_health.get("suite_readiness") + readiness = readiness if isinstance(readiness, dict) else {} summary = suite["summary"] print(f"Generated {summary['cases']} cases from {summary['records_seen']} records.") if summary.get("duplicate_prompt_response_pairs"): print(f"Skipped {summary['duplicate_prompt_response_pairs']} duplicate prompt-response pairs.") - print(f"Detected {summary['clusters']} behavioral clusters.") + print(f"Detected {summary['clusters']} behavior-signature groups.") + print(f"Suite readiness: {readiness.get('label', 'unknown')} ({readiness.get('score', 0)}/100)") + print("Readiness scope: suite health, not model quality or candidate safety") if summary.get("selection") != "all": unique_pairs = int(summary.get("unique_prompt_response_pairs", summary["records_seen"])) cases = int(summary["cases"]) @@ -1021,11 +1287,16 @@ def cmd_suite(args: argparse.Namespace) -> int: f"Selected {cases} representative cases from {unique_pairs} unique prompt-response pairs. " "Use --all-cases for exhaustive coverage or redline suite add for must-cover edge cases." ) + improvement_steps = suite_health.get("next_steps") + if isinstance(improvement_steps, list) and improvement_steps: + print("Improve suite:") + for step in improvement_steps[:2]: + print(f"- {step}") print(f"Wrote {Path(output)}.") print() print("Next:") - print(f"- Inspect cases: redline cases {Path(output)}") - print(f"- Compare a candidate log: redline diff {Path(output)} path/to/candidate.jsonl") + print(f"- Inspect cases: {_shell_command('redline', 'cases', output)}") + print(f"- Compare a candidate log: {_shell_command('redline', 'diff', output, 'path/to/candidate.jsonl')}") print("- Configure replay when ready: redline init --runner stdio --copy-runner") return 0 @@ -1089,9 +1360,9 @@ def cmd_suite_add(args: argparse.Namespace) -> int: print(f"Added {rules} requirement rule(s).") print() print("Next:") - print(f"- Inspect case: redline case {Path(output)} {case['id']}") - print(f"- Validate suite: redline validate {Path(output)}") - print(f"- Compare candidate: redline diff {Path(output)} path/to/candidate.jsonl") + print(f"- Inspect case: {_shell_command('redline', 'case', output, case['id'])}") + print(f"- Validate suite: {_shell_command('redline', 'validate', output)}") + print(f"- Compare candidate: {_shell_command('redline', 'diff', output, 'path/to/candidate.jsonl')}") return 0 @@ -1199,6 +1470,86 @@ def cmd_validate(args: argparse.Namespace) -> int: return 0 +def cmd_quick_check(args: argparse.Namespace) -> int: + if args.all_cases and args.max_cases != 42: + raise ValueError("--all-cases cannot be combined with --max-cases") + baseline_records = read_jsonl_records(args.baseline, args.input_field, args.output_field) + candidate_records = read_jsonl_records(args.candidate, args.input_field, args.output_field) + suite = build_suite( + baseline_records, + source=args.baseline, + input_field=args.input_field, + output_field=args.output_field, + max_cases=args.max_cases, + all_cases=args.all_cases, + ) + out_dir = Path(args.out_dir) + suite_path = out_dir / "suite.json" + report_json = out_dir / "diff.json" + report_md = out_dir / "diff.md" + report_html = out_dir / "diff.html" + app_html = out_dir / "app.html" + write_json(suite_path, suite) + + result = compare_suite_to_candidate(suite, candidate_records, profile=args.profile) + result["suite"] = str(suite_path) + result["candidate"] = str(args.candidate) + result["artifacts"] = _artifact_paths( + { + "json": str(report_json), + "markdown": str(report_md), + "html": str(report_html), + "app": str(app_html), + } + ) + write_json(report_json, result) + write_text(report_md, format_markdown_report(result, title="redline quick-check")) + write_text(report_html, format_html_report(result, title="redline quick-check")) + dashboard = build_dashboard( + reports_dir=out_dir, + history_path=out_dir / "history.jsonl", + checkpoint_path=out_dir / "audit-checkpoint.json", + ) + write_text(app_html, format_dashboard_html(dashboard, title="redline quick-check app", output_path=app_html, style="app")) + if args.open: + webbrowser.open(report_html.resolve().as_uri()) + if args.open_app: + webbrowser.open(app_html.resolve().as_uri()) + + fail_on = parse_fail_on(args.fail_on) + if args.json: + print(json.dumps(result, indent=2, sort_keys=True)) + elif args.compact: + print(format_compact_report(result, title="redline quick-check"), end="") + else: + print(format_report(result, title="redline quick-check"), end="") + if not args.json: + summary = suite["summary"] + print() + print("Artifacts") + print(f"- Suite: {suite_path}") + print(f"- JSON report: {report_json}") + print(f"- Markdown: {report_md}") + print(f"- HTML report: {report_html}") + print(f"- App: {app_html}") + print() + print("Suite coverage") + print(f"- Cases: {summary['cases']}/{summary['unique_prompt_response_pairs']} unique prompt-response pairs") + covered_clusters = round(int(summary["clusters"]) * float(summary.get("cluster_coverage") or 0.0)) + print(f"- Behavior groups: {covered_clusters}/{summary['clusters']} represented") + print() + print("Next:") + print(f"- Open HTML report: {report_html}") + print(f"- Open guided app: {app_html}") + print(f"- Inspect cases: {_shell_command('redline', 'cases', suite_path)}") + print(f"- Make this persistent: {_shell_command('redline', 'suite', args.baseline, '--out', 'redline-suite.json')}") + if args.open: + print("- Opened HTML report in the default browser.") + if args.open_app: + print("- Opened guided app in the default browser.") + return 1 if should_fail(result, fail_on) else 0 + + def cmd_diff(args: argparse.Namespace) -> int: config = load_config(args.config) suite_path, candidate_path = _suite_candidate_args(args.paths, config) @@ -1287,15 +1638,50 @@ def cmd_history(args: argparse.Namespace) -> int: def cmd_dashboard(args: argparse.Namespace) -> int: + return _write_dashboard_artifact( + args, + style=args.style, + title="redline dashboard", + noun="dashboard", + open_message="Opened dashboard in the default browser.", + ) + + +def cmd_app(args: argparse.Namespace) -> int: + if args.demo: + run_demo(args.demo_out, public=True) + if args.reports_dir == ".redline/reports": + args.reports_dir = str(Path(args.demo_out) / "reports") + if not args.json: + print(f"Generated public demo evidence in {Path(args.demo_out)}.") + return _write_dashboard_artifact( + args, + style="app", + title="redline app", + noun="app", + open_message="Opened redline app in the default browser.", + ) + + +def _write_dashboard_artifact( + args: argparse.Namespace, + *, + style: str, + title: str, + noun: str, + open_message: str, +) -> int: dashboard = build_dashboard( reports_dir=args.reports_dir, history_path=args.history, checkpoint_path=args.checkpoint, limit=args.limit, ) - write_text(args.out, format_dashboard_html(dashboard, output_path=args.out)) - if args.open: + write_text(args.out, format_dashboard_html(dashboard, title=title, output_path=args.out, style=style)) + opened = False + if args.open and not args.json: webbrowser.open(Path(args.out).resolve().as_uri()) + opened = True if args.json: print(json.dumps({**dashboard, "output": args.out}, indent=2, sort_keys=True)) else: @@ -1306,10 +1692,16 @@ def cmd_dashboard(args: argparse.Namespace) -> int: print(f"Checkpoint: {'yes' if dashboard.get('checkpoint') else 'no'}") print(f"Notices: {len(dashboard.get('notices', []))}") print(f"Warnings: {len(dashboard.get('errors', []))}") - if args.open: - print("Opened dashboard in the default browser.") + if opened: + print(open_message) else: print(f"Open: {Path(args.out)}") + if noun == "app": + print() + print("Next:") + print("- Import logs: redline import path/to/export.jsonl --detect") + print("- Generate suite: redline suite .redline/logs/baseline.jsonl --out redline-suite.json") + print("- Run eval: redline eval --compact") return 0 @@ -1525,14 +1917,14 @@ def cmd_mark(args: argparse.Namespace) -> int: print(f"Marked {case_id} as {args.status} in {Path(output)}.") print() print("Next:") - print(f"- Validate suite: redline validate {Path(output)}") + print(f"- Validate suite: {_shell_command('redline', 'validate', output)}") if args.status == "expected": print( - f"- Promote reviewed output: redline accept {Path(output)} {case_id} " - '--candidate path/to/candidate.jsonl --note "accepted prompt change"' + f"- Promote reviewed output: " + f"{_shell_command('redline', 'accept', output, case_id, '--candidate', 'path/to/candidate.jsonl', '--note', 'accepted prompt change')}" ) else: - print(f"- Re-run diff: redline diff {Path(output)} path/to/candidate.jsonl") + print(f"- Re-run diff: {_shell_command('redline', 'diff', output, 'path/to/candidate.jsonl')}") return 0 @@ -1692,6 +2084,10 @@ def _emit_result( print(format_compact_report(result, title=title), end="") else: print(format_report(result, title=title), end="") + if not args.json and out_html: + print() + print(f"Open HTML report: {Path(out_html)}") + print(f"Open app: redline app --reports-dir {shlex.quote(str(Path(out_html).parent))}") exit_code = 1 if should_fail(result, fail_on) else 0 if audit_event is not None: @@ -1886,7 +2282,7 @@ def _read_suite_or_manifest(path: str) -> dict[str, object]: raise ValueError( f"{path} looks like raw JSONL logs, but this command expects a redline suite JSON " "or prompt manifest. Build a suite first: " - f"redline suite {path} --out redline-suite.json" + f"{_shell_command('redline', 'suite', path, '--out', 'redline-suite.json')}" ) from exc raise @@ -2235,4 +2631,8 @@ def _prompts_regenerate_command(args: argparse.Namespace) -> str: parts = ["redline", "prompts", str(args.path), "--suite-dir", str(args.suite_dir), "--out", str(args.out)] for extension in args.ext: parts.extend(["--ext", str(extension)]) - return " ".join(shlex.quote(part) for part in parts) + return _shell_command(*parts) + + +def _shell_command(*parts: object) -> str: + return " ".join(shlex.quote(str(part)) for part in parts) diff --git a/redline/clusters.py b/redline/clusters.py index e3caa31..8335481 100644 --- a/redline/clusters.py +++ b/redline/clusters.py @@ -46,11 +46,11 @@ def format_cluster_report(suite: dict[str, Any]) -> str: lines = [ "redline cluster", "", - f"Identified {report['clusters']} behavioral clusters from {report['unique_prompt_response_pairs']} unique pairs.", + f"Identified {report['clusters']} behavior-signature groups from {report['unique_prompt_response_pairs']} unique pairs.", f"Records seen: {report['records_seen']} Duplicate pairs: {report['duplicate_prompt_response_pairs']}", - f"High-risk clusters: {report['high_risk_clusters']}", - f"High-variance clusters: {report['high_variance_clusters']}", - f"Failure-pattern clusters: {report['failure_pattern_clusters']}", + f"High-risk groups: {report['high_risk_clusters']}", + f"High-variance groups: {report['high_variance_clusters']}", + f"Failure-pattern groups: {report['failure_pattern_clusters']}", f"Suggested eval suite: {report['suggested_cases']} representative cases.", f"Case coverage: {report['suggested_cases']}/{report['unique_prompt_response_pairs']} ({_percent(report['case_coverage'])})", "", diff --git a/redline/dashboard.py b/redline/dashboard.py index d9e673f..1392e06 100644 --- a/redline/dashboard.py +++ b/redline/dashboard.py @@ -7,7 +7,7 @@ from typing import Any from urllib.parse import quote -from .diff import TRUST_SCOPE +from .diff import TRUST_SCOPE, case_impact from .history import history_trend, read_history from .io import read_json @@ -50,6 +50,7 @@ def format_dashboard_html( *, title: str = "redline dashboard", output_path: str | Path | None = None, + style: str = "classic", ) -> str: reports = dashboard.get("reports") if not isinstance(reports, list): @@ -78,6 +79,24 @@ def format_dashboard_html( raw_trend = dashboard.get("trend") trend = raw_trend if isinstance(raw_trend, dict) else history_trend(list(reversed(history))) latest = reports[0] if reports and isinstance(reports[0], dict) else {} + if style == "app": + return _format_dashboard_app_html( + dashboard, + title=title, + output_path=output_path, + reports=reports, + history=history, + benchmarks=benchmarks, + errors=errors, + notices=notices, + owners=owners, + trust=trust, + checkpoint=checkpoint, + trend=trend, + latest=latest, + ) + if style != "classic": + raise ValueError(f"unknown dashboard style: {style}") return "\n".join( [ "", @@ -120,6 +139,786 @@ def format_dashboard_html( ) +def _format_dashboard_app_html( + dashboard: dict[str, Any], + *, + title: str, + output_path: str | Path | None, + reports: list[Any], + history: list[Any], + benchmarks: list[Any], + errors: list[Any], + notices: list[Any], + owners: list[Any], + trust: dict[str, Any], + checkpoint: dict[str, Any], + trend: dict[str, Any], + latest: dict[str, Any], +) -> str: + summary = _app_dict(latest.get("summary")) + suite_summary = _app_dict(latest.get("suite_summary")) + decision = _app_dict(latest.get("decision")) + active = _blocking_count(summary) + changed = _changed_count(summary) + status_class = "red" if active else "amber" if changed else "green" + status_text = "Regressions" if active else "Review" if changed else "Clear" + review_cases = _app_list(latest.get("review_cases")) + warnings = _app_list(latest.get("warnings")) + if notices: + warnings = [*(str(item.get("message") or "") for item in notices if isinstance(item, dict)), *warnings] + alert_count = active + len([warning for warning in warnings if str(warning).strip()]) + return "\n".join( + [ + "", + '', + "", + '', + '', + f"{_h(title)}", + "", + "", + "", + '
', + _app_sidebar(active=active, changed=changed, alerts=alert_count), + '
', + _app_topbar(title=title, status_class=status_class, status_text=status_text, reports=len(reports)), + '
', + _app_workflow_screen( + dashboard=dashboard, + latest=latest, + reports=reports, + benchmarks=benchmarks, + history=history, + checkpoint=checkpoint, + review_cases=review_cases, + ), + _app_dashboard_screen( + summary=summary, + suite_summary=suite_summary, + decision=decision, + reports=reports, + benchmarks=benchmarks, + history=history, + review_cases=review_cases, + warnings=[warning for warning in warnings if str(warning).strip()], + status_class=status_class, + output_path=output_path, + ), + _app_regressions_screen(review_cases=review_cases, summary=summary, decision=decision), + _app_suites_screen(suite_summary=suite_summary, owners=owners, trust=trust, checkpoint=checkpoint, reports=reports), + _app_logs_screen(reports=reports, suite_summary=suite_summary), + _app_compare_screen(latest=latest, output_path=output_path), + _app_history_screen(history=history, trend=trend, reports=reports), + _app_alerts_screen(review_cases=review_cases, warnings=warnings, summary=summary), + _app_integrations_screen(benchmarks=benchmarks), + _app_settings_screen(errors=errors, dashboard=dashboard), + "
", + "
", + "
", + _APP_DASHBOARD_SCRIPT, + "", + "", + "", + ] + ) + + +def _app_sidebar(*, active: int, changed: int, alerts: int) -> str: + alert_badge = f'{active}' if active else "" + changed_badge = f'{changed}' if changed else "" + alerts_badge = f'{alerts}' if alerts else "" + return ( + '" + ) + + +def _app_dict(value: Any) -> dict[str, Any]: + if not isinstance(value, dict): + return {} + return {str(key): item for key, item in value.items()} + + +def _app_list(value: Any) -> list[Any]: + return value if isinstance(value, list) else [] + + +def _app_topbar(*, title: str, status_class: str, status_text: str, reports: int) -> str: + badge_class = {"red": "badge-crit", "amber": "badge-warn", "green": "badge-pass"}.get(status_class, "badge-blue") + return ( + '
' + '
' + f'
{_h(title)}
' + f'{_h(status_text)}' + "
" + '
' + f'{reports} report(s)' + 'Open reports' + 'Review' + "
" + "
" + ) + + +def _app_workflow_screen( + *, + dashboard: dict[str, Any], + latest: dict[str, Any], + reports: list[Any], + benchmarks: list[Any], + history: list[Any], + checkpoint: dict[str, Any], + review_cases: list[Any], +) -> str: + items = _app_workflow_items( + dashboard=dashboard, + latest=latest, + reports=reports, + benchmarks=benchmarks, + history=history, + checkpoint=checkpoint, + review_cases=review_cases, + ) + blocking = _blocking_count(_app_dict(latest.get("summary"))) + next_item = next((item for item in items if item["tone"] != "green"), items[-1]) + cards = "".join(_app_workflow_card(item) for item in items) + return ( + '
' + '
' + f'{_app_metric("Next command", str(next_item["stage"]), next_item["title"], "amber" if next_item["tone"] == "amber" else "red" if next_item["tone"] == "red" else "green")}' + f'{_app_metric("Reports", str(len(reports)), "local evidence files", "green" if reports else "amber")}' + f'{_app_metric("Blocking", str(blocking), "cases to inspect", "red" if blocking else "green")}' + f'{_app_metric("Automation", "copy-only", "dashboard never runs shell commands", "blue")}' + "
" + f'{_app_alert("blue", "Command center.", "Copy a command, run it in your terminal, then refresh this dashboard. redline stays local-first and does not execute shell commands from the browser.")}' + f'
{_app_icon("workflow")} Next best actionstate-aware
{_app_workflow_card(next_item, featured=True)}
' + f'
{cards}
' + "
" + ) + + +def _app_workflow_items( + *, + dashboard: dict[str, Any], + latest: dict[str, Any], + reports: list[Any], + benchmarks: list[Any], + history: list[Any], + checkpoint: dict[str, Any], + review_cases: list[Any], +) -> list[dict[str, str]]: + reports_dir = str(dashboard.get("reports_dir") or ".redline/reports") + history_path = str(dashboard.get("history_path") or ".redline/history.jsonl") + checkpoint_path = str(dashboard.get("checkpoint_path") or ".redline/audit-checkpoint.json") + latest_report = str(latest.get("path") or f"{reports_dir}/eval.json") + suite = _app_latest_suite(latest=latest, benchmarks=benchmarks) + first_case = next((item for item in review_cases if isinstance(item, dict)), {}) + case_id = str(first_case.get("case_id") or first_case.get("suite_case_id") or "") + case_suite = str(first_case.get("suite") or suite) + blocking = _blocking_count(_app_dict(latest.get("summary"))) + changed = _changed_count(_app_dict(latest.get("summary"))) + has_reports = bool(reports) + has_history = bool(history) + has_benchmarks = bool(benchmarks) + has_checkpoint = bool(checkpoint) + return [ + { + "stage": "0", + "title": "Prove the loop", + "body": "Run the bundled regression proof before connecting private logs.", + "command": "redline demo --public --compact", + "tone": "green" if has_reports else "amber", + }, + { + "stage": "1", + "title": "Get logs in", + "body": "Detect fields first. Redaction is on by default when writing normalized JSONL.", + "command": "redline import path/to/export.jsonl --detect", + "tone": "green" if has_reports else "amber", + }, + { + "stage": "2", + "title": "Preview import", + "body": "Check a few mapped rows before creating a baseline log.", + "command": "redline import path/to/export.jsonl --auto-map --preview 3", + "tone": "green" if has_reports else "amber", + }, + { + "stage": "3", + "title": "Generate suite", + "body": "Turn normalized baseline logs into representative regression cases.", + "command": f"redline suite .redline/logs/baseline.jsonl --out {shell_quote(suite)}", + "tone": "green" if has_reports else "amber", + }, + { + "stage": "4", + "title": "Run eval", + "body": "Replay your changed prompt or runner and write reports for the dashboard.", + "command": "redline eval --compact", + "tone": "green" if has_reports else "amber", + }, + { + "stage": "5", + "title": "Review first case", + "body": "Inspect the exact baseline, candidate, and concrete regression reason.", + "command": f"redline case {shell_quote(case_suite)} {shell_quote(case_id)}", + "tone": "red" if blocking else "amber" if changed else "green", + }, + { + "stage": "6", + "title": "Record trend", + "body": "Add this run to history so prompt quality is visible over time.", + "command": f"redline history {shell_quote(latest_report)} --label prompt-v2 --out {shell_quote(history_path)} --out-md .redline/history.md", + "tone": "green" if has_history else "amber", + }, + { + "stage": "7", + "title": "Add runtime evidence", + "body": "Estimate worst-case eval time and attach local benchmark evidence.", + "command": f"redline budget {shell_quote(suite)} --measure-local --out-json {shell_quote(reports_dir + '/benchmark.json')} --out-md {shell_quote(reports_dir + '/benchmark.md')}", + "tone": "green" if has_benchmarks else "amber", + }, + { + "stage": "8", + "title": "Verify audit trail", + "body": "Write a checkpoint for stronger evidence that local run history was not silently changed.", + "command": f"redline audit --verify --out-checkpoint {shell_quote(checkpoint_path)}", + "tone": "green" if has_checkpoint else "amber", + }, + { + "stage": "9", + "title": "Refresh dashboard", + "body": "Regenerate the app dashboard after each run.", + "command": f"redline app --reports-dir {shell_quote(reports_dir)} --history {shell_quote(history_path)}", + "tone": "green", + }, + ] + + +def _app_latest_suite(*, latest: dict[str, Any], benchmarks: list[Any]) -> str: + suite = str(latest.get("suite") or "").strip() + if suite: + return suite + prompt_evals = latest.get("prompt_evals") + if isinstance(prompt_evals, list): + for item in prompt_evals: + if isinstance(item, dict) and str(item.get("suite") or "").strip(): + return str(item.get("suite")) + for item in benchmarks: + if isinstance(item, dict) and str(item.get("suite") or "").strip() and str(item.get("suite")) != "-": + return str(item.get("suite")) + return "redline-suite.json" + + +def _app_workflow_card(item: dict[str, str], *, featured: bool = False) -> str: + tone = item.get("tone") or "blue" + command = item.get("command") or "" + featured_class = " featured" if featured else "" + return ( + f'
' + '
' + f'{_h(item.get("stage") or "")}' + f'
{_h(item.get("title") or "")}
{_h(item.get("body") or "")}
' + f'{_h(_workflow_status(tone))}' + "
" + '
' + f'{_h(command)}' + f'' + "
" + "
" + ) + + +def _workflow_status(tone: str) -> str: + if tone == "green": + return "ready" + if tone == "red": + return "blocking" + return "next" + + +def _app_dashboard_screen( + *, + summary: dict[str, Any], + suite_summary: dict[str, Any], + decision: dict[str, Any], + reports: list[Any], + benchmarks: list[Any], + history: list[Any], + review_cases: list[Any], + warnings: list[Any], + status_class: str, + output_path: str | Path | None, +) -> str: + blocking = _blocking_count(summary) + changed = _changed_count(summary) + neutral = _safe_int(summary.get("neutral")) + cases = _safe_int(summary.get("cases")) + action = str(decision.get("recommended_action") or "Run a diff or eval to populate ship guidance.") + warning_html = _app_warning_banner(warnings, status_class=status_class) + pass_text = _pass_rate_label(summary) + return ( + '
' + '
' + f'{_app_metric("Active regressions", str(blocking), "regression + missing", "red" if blocking else "green")}' + f'{_app_metric("Review changes", str(changed), "changed cases", "amber" if changed else "green")}' + f'{_app_metric("Eval cases", str(cases), f"{neutral} neutral", "")}' + f'{_app_metric("Pass rate", pass_text, f"{len(reports)} reports, {len(history)} history rows", "green" if not blocking else "red")}' + "
" + f"{warning_html}" + '
' + f'
{_app_icon("alert")} Recent regressionslatest report
' + f'
{_app_review_rows(review_cases)}
' + f'
{_app_icon("health")} Suite healthlocal evidence
' + f'
{_h(action)}
{_app_suite_health(suite_summary, benchmarks)}
' + "
" + '
' + f'
{_app_icon("trend")} Regression trendlatest local reports
' + f'
{_app_report_chart(reports)}{_app_trust_strip(summary, suite_summary)}
' + "
" + f'{_app_reports_table(reports, output_path=output_path)}' + "
" + ) + + +def _app_regressions_screen(*, review_cases: list[Any], summary: dict[str, Any], decision: dict[str, Any]) -> str: + blocking = _blocking_count(summary) + action = str(decision.get("recommended_action") or "Review blocking cases before shipping.") + return ( + '
' + f'{_app_alert("red" if blocking else "green", f"{blocking} blocking case(s).", action)}' + f'
{_app_icon("alert")} Active regressionsblocking and changed
' + f'
{_app_review_rows(review_cases, empty="No review cases in the latest report.")}
' + '
' + f'{_app_breakdown_card("Blocking status", summary)}' + f'{_app_fix_card(review_cases)}' + "
" + "
" + ) + + +def _app_suites_screen( + *, + suite_summary: dict[str, Any], + owners: list[Any], + trust: dict[str, Any], + checkpoint: dict[str, Any], + reports: list[Any], +) -> str: + rows = [ + ("Cases", _safe_int(suite_summary.get("cases"))), + ("Unique pairs", _safe_int(suite_summary.get("unique_prompt_response_pairs"))), + ("Behavior groups", _safe_int(suite_summary.get("clusters"))), + ("Stochastic prompts", _safe_int(suite_summary.get("stochastic_prompt_groups"))), + ("Non-ASCII records", _safe_int(suite_summary.get("non_ascii_records"))), + ] + health_rows = "".join( + f'
{_h(label)}{value}
' + for label, value in rows + ) + owner_rows = _app_owner_rows(owners) + trust_text = str(trust.get("scope") or TRUST_SCOPE) + checkpoint_text = "verified" if checkpoint else "not loaded" + return ( + '
' + '
' + f'{_app_metric("Reports", str(len(reports)), "loaded locally", "")}' + f'{_app_metric("Suite coverage", _coverage_label(suite_summary), "behavior groups", "")}' + f'{_app_metric("Owners", str(len(owners)), "review routing rows", "")}' + f'{_app_metric("Audit", checkpoint_text, "checkpoint status", "green" if checkpoint else "amber")}' + "
" + '
' + f'
{_app_icon("suite")} Suite health
{health_rows}
' + f'
{_app_icon("owner")} Owners
{owner_rows}
' + "
" + f'
{_app_icon("shield")} Trust boundary

{_h(trust_text)}

Audit checkpoint: {_h(checkpoint_text)}

' + "
" + ) + + +def _app_logs_screen(*, reports: list[Any], suite_summary: dict[str, Any]) -> str: + return ( + '
' + '
' + f'{_app_metric("Reports loaded", str(len(reports)), "JSON report files", "")}' + f'{_app_metric("Cases generated", str(_safe_int(suite_summary.get("cases"))), "from baseline logs", "")}' + f'{_app_metric("Import presets", "5", "Langfuse, Helicone, Datadog, OpenAI, custom", "")}' + f'{_app_metric("Redaction", "on", "default import posture", "green")}' + "
" + f'
{_app_icon("upload")} Import log file
' + '
' + '
Drop exported JSONL into the project, then detect fields locally.

Use redline import --detect, then --auto-map --preview 3 before writing normalized logs.

' + '
1

Detect fields from Langfuse, Helicone, Datadog, OpenAI chat, or custom JSONL exports.

' + '
2

Redaction is best-effort pattern matching, not a privacy boundary. Inspect private logs locally.

' + '
3

Run redline quick-check baseline.jsonl candidate.jsonl --open for the fastest first pass.

' + "
" + f'
{_app_icon("preset")} Import presets
' + f'{_app_integration_row("Langfuse export", "redline import langfuse.jsonl --preset langfuse --out logs/baseline.jsonl", "Available")}' + f'{_app_integration_row("Helicone export", "redline import helicone.jsonl --preset helicone --out logs/baseline.jsonl", "Available")}' + f'{_app_integration_row("Datadog logs", "redline import datadog.jsonl --preset datadog --out logs/baseline.jsonl", "Available")}' + "
" + "
" + ) + + +def _app_compare_screen(*, latest: dict[str, Any], output_path: str | Path | None) -> str: + diffs = _app_list(latest.get("review_cases")) + links = _links( + [ + ("HTML", str(latest.get("html_path") or "")), + ("Markdown", str(latest.get("markdown_path") or "")), + ("JSON", str(latest.get("path") or "")), + ], + output_path=output_path, + ) + return ( + '
' + f'
{_app_icon("report")} Latest report links
{links}
' + f'
{_app_icon("diff")} Concrete reasons
' + f'
{_app_review_rows(diffs, empty="No changed or blocking cases in the latest report.")}
' + "
" + ) + + +def _app_history_screen(*, history: list[Any], trend: dict[str, Any], reports: list[Any]) -> str: + direction = str(trend.get("direction") or "unknown").replace("_", " ").title() + rows = _app_history_rows(history) + return ( + '
' + '
' + f'{_app_metric("Total reports", str(len(reports)), "loaded from reports dir", "")}' + f'{_app_metric("History rows", str(len(history)), "recorded runs", "")}' + f'{_app_metric("Trend", direction, "latest local direction", "red" if direction.lower() == "worse" else "")}' + f'{_app_metric("Latest blocking", str(_latest_blocking(reports)), "regression + missing", "red" if _latest_blocking(reports) else "green")}' + "
" + f'{_app_alert("blue", f"Trend: {direction}", str(trend.get("summary") or "Record history entries to see whether prompt quality is improving or regressing."))}' + f'
{_app_icon("history")} Run historylatest 20 runs
{rows}
' + "
" + ) + + +def _app_alerts_screen(*, review_cases: list[Any], warnings: list[Any], summary: dict[str, Any]) -> str: + blocking = _blocking_count(summary) + warning_count = len([warning for warning in warnings if str(warning).strip()]) + alert_rows = _app_review_rows(review_cases, empty="No blocking report alerts.") + warning_rows = "".join( + f'
W
Calibration warning
{_h(_preview(str(warning), 160))}
review
' + for warning in warnings[:6] + if str(warning).strip() + ) + warnings_body = warning_rows or '

No dashboard warnings.

' + return ( + '
' + '
' + f'{_app_metric("Open alerts", str(blocking + warning_count), "derived from local reports", "red" if blocking else "amber" if warning_count else "green")}' + f'{_app_metric("Blocking", str(blocking), "regression + missing", "red" if blocking else "green")}' + f'{_app_metric("Warnings", str(warning_count), "notices and calibration", "amber" if warning_count else "green")}' + f'{_app_metric("Channels", "local", "CLI, HTML, MCP, CI artifacts", "")}' + "
" + '
' + f'
{_app_icon("bell")} Report alerts
{alert_rows}
' + f'
{_app_icon("warning")} Warnings
{warnings_body}
' + "
" + "
" + ) + + +def _app_integrations_screen(*, benchmarks: list[Any]) -> str: + content = ( + _app_integration_row("MCP", "redline-mcp", "Ready") + + _app_integration_row("GitHub Action", "uses: gowtham0992/redline@v0", "Ready") + + _app_integration_row("Runner adapters", "redline runners --copy all", "Ready") + + _app_integration_row("Judge templates", "redline judges --copy support-rubric", "Optional") + + _app_integration_row("Benchmark evidence", f"{len(benchmarks)} local benchmark artifact(s)", "Local") + ) + return ( + '
' + '
' + f'{_app_metric("Connected surfaces", "4", "CLI, MCP, Action, local HTML", "")}' + f'{_app_metric("Benchmarks", str(len(benchmarks)), "runtime evidence artifacts", "green" if benchmarks else "amber")}' + f'{_app_metric("Runner adapters", "7", "stdio, HTTP, SDK, chains, logs", "")}' + f'{_app_metric("Judge templates", "4", "optional semantic review", "")}' + "
" + f'
{_app_icon("plug")} Developer workflow integrations
{content}
' + "
" + ) + + +def _app_settings_screen(*, errors: list[Any], dashboard: dict[str, Any]) -> str: + error_html = _errors(errors) if errors else '

No skipped local report files.

' + reports_dir = str(dashboard.get("reports_dir") or ".redline/reports") + history_path = str(dashboard.get("history_path") or ".redline/history.jsonl") + checkpoint_path = str(dashboard.get("checkpoint_path") or ".redline/audit-checkpoint.json") + return ( + '
' + '
' + f'
{_app_icon("settings")} Local settings
' + f'
Runtime
All data is read from local files
local
Telemetry
Dashboard does not transmit report data
off
Reports dir
{_h(reports_dir)}
History path
{_h(history_path)}
Checkpoint path
{_h(checkpoint_path)}
' + f'
{_app_icon("warning")} Skipped files
{error_html}
' + "
" + "
" + ) + + +def _app_metric(label: str, value: str, sub: str, tone: str) -> str: + tone_class = f" {tone}" if tone else "" + return ( + '
' + f'
{_h(label)}
' + f'
{_h(value)}
' + f'
{_h(sub)}
' + "
" + ) + + +def _app_warning_banner(warnings: list[Any], *, status_class: str) -> str: + if warnings: + text = " ".join(_preview(str(warning), 160) for warning in warnings[:2]) + return _app_alert("amber", "Calibration warning.", text) + if status_class == "red": + return _app_alert("red", "Blocking regressions detected.", "Fix or mark expected changes before shipping.") + return _app_alert("green", "No blocking structural regressions in latest report.", "Review semantic risks separately.") + + +def _app_review_rows(items: list[Any], *, empty: str = "No review cases found.") -> str: + rows = [] + for item in items[:8]: + if not isinstance(item, dict): + continue + status = str(item.get("status") or item.get("kind") or "review") + case_id = str(item.get("case_id") or item.get("suite_case_id") or "-") + prompt = _preview(str(item.get("prompt") or item.get("reason") or "Review case"), 96) + reason = _preview(str(item.get("reason") or "; ".join(str(value) for value in item.get("reasons", []) if value) or ""), 120) + impact = _preview(str(item.get("impact") or ""), 140) + impact_html = f'
Why this matters: {_h(impact)}
' if impact else "" + tone = "red" if status in {"regression", "missing"} else "amber" if status == "changed" else "green" + rows.append( + '
' + f'
{_h(status[:1].upper())}
' + f'
{_h(case_id)} - {_h(prompt)}
{_h(reason or status)}
{impact_html}
' + f'{_h(status)}' + "
" + ) + if not rows: + return f'

{_h(empty)}

' + overflow = len(items) - 8 + if overflow > 0: + rows.append(f'

Showing the first 8 cases. Open the HTML report for {overflow} more.

') + return "".join(rows) + + +def _app_suite_health(suite_summary: dict[str, Any], benchmarks: list[Any]) -> str: + rows = [ + ("Cases", _safe_int(suite_summary.get("cases"))), + ("Groups", _safe_int(suite_summary.get("clusters"))), + ("Benchmarks", len(benchmarks)), + ] + return "".join(f'
{_h(label)}{value}
' for label, value in rows) + + +def _app_owner_rows(owners: list[Any]) -> str: + rows = [] + for owner in owners[:8]: + if not isinstance(owner, dict): + continue + rows.append( + '
' + f'{_h(str(owner.get("owner") or "unowned"))}' + f'{_safe_int(owner.get("reviewable"))}' + "
" + ) + return "".join(rows) if rows else '

No owner review rows yet.

' + + +def _app_reports_table(reports: list[Any], *, output_path: str | Path | None) -> str: + if not reports: + return '
Reports

No report JSON files found yet.

' + rows = [] + for report in reports[:8]: + if not isinstance(report, dict): + continue + summary = _app_dict(report.get("summary")) + rows.append( + '
' + f'
{_h(str(report.get("name") or "-"))}
{_summary_pills(summary)}
' + f'
{_links([("HTML", str(report.get("html_path") or "")), ("JSON", str(report.get("path") or ""))], output_path=output_path)}
' + "
" + ) + return '
Reports
' + "".join(rows) + "
" + + +def _app_history_rows(history: list[Any]) -> str: + rows = [] + for item in history[:10]: + if not isinstance(item, dict): + continue + summary = _app_dict(item.get("summary")) + rows.append( + '
' + f'
{_h(str(item.get("label") or item.get("timestamp") or "-"))}
{_summary_pills(_summary_counts(summary))}
' + f'
{_h(str(item.get("timestamp") or ""))}
' + "
" + ) + return "".join(rows) if rows else '

No history recorded yet.

' + + +def _app_alert(tone: str, title: str, message: str) -> str: + return f'
{_app_icon("alert")}
{_h(title)} {_h(message)}
' + + +def _app_breakdown_card(title: str, summary: dict[str, Any]) -> str: + rows = [ + ("Regressions", _safe_int(summary.get("regression")), "red"), + ("Missing", _safe_int(summary.get("missing")), "red"), + ("Changed", _safe_int(summary.get("changed")), "amber"), + ("Neutral", _safe_int(summary.get("neutral")), "green"), + ] + body = "".join( + f'
{_h(label)}
{value}
' + for label, value, tone in rows + ) + return f'
{_app_icon("breakdown")} {_h(title)}
{body}
' + + +def _app_fix_card(review_cases: list[Any]) -> str: + first = next((item for item in review_cases if isinstance(item, dict)), {}) + case_id = str(first.get("case_id") or first.get("suite_case_id") or "case id") + suite = str(first.get("suite") or "redline-suite.json") + command = f"redline case {shell_quote(suite)} {shell_quote(case_id)}" if case_id != "case id" else "redline cases redline-suite.json" + body = ( + '
' + 'Recommended local loop' + f'1. Inspect: {_h(command)}' + '2. Fix prompt or runner output' + '3. Mark expected only when the change is intentional' + '
' + ) + return f'
{_app_icon("fix")} Suggested fix
{body}
' + + +def _app_report_chart(reports: list[Any]) -> str: + points = [] + for index, report in enumerate(reversed([item for item in reports[:12] if isinstance(item, dict)])): + summary = _app_dict(report.get("summary")) + cases = max(_safe_int(summary.get("cases")), 1) + blocking = _blocking_count(summary) + pass_rate = max(0.0, min(1.0, (cases - blocking) / cases)) + x = 24 + index * 74 + y = 132 - int(pass_rate * 96) + points.append((x, y)) + if not points: + return '

No report chart yet. Run redline diff or eval to populate trend evidence.

' + path = " ".join(f"{x},{y}" for x, y in points) + dots = "".join(f'' for x, y in points) + return ( + '' + '' + '' + f'{dots}' + "" + '
pass rate blocking cases lower is better
' + ) + + +def _app_trust_strip(summary: dict[str, Any], suite_summary: dict[str, Any]) -> str: + return ( + '
' + f'
Scopestructural checks
' + f'
Cases{_safe_int(summary.get("cases"))}
' + f'
Coverage{_coverage_label(suite_summary)}
' + f'
Boundaryreview semantics separately
' + "
" + ) + + +def _app_integration_row(name: str, command: str, status: str) -> str: + tone = "chip-pass" if status.lower() in {"ready", "available", "local"} else "chip-blue" + return ( + '
' + f'' + f'
{_h(name)}
{_h(command)}
' + f'{_h(status)}' + "
" + ) + + +def _app_icon(name: str) -> str: + paths = { + "dashboard": '', + "workflow": '', + "alert": '', + "suite": '', + "logs": '', + "diff": '', + "history": '', + "bell": '', + "plug": '', + "settings": '', + "compare": '', + "health": '', + "trend": '', + "owner": '', + "shield": '', + "upload": '', + "preset": '', + "report": '', + "warning": '', + "breakdown": '', + "fix": '', + } + path = paths.get(name, paths["dashboard"]) + return f'' + + +def _status_width(status: str) -> int: + if status in {"regression", "missing"}: + return 92 + if status == "changed": + return 62 + return 35 + + +def _chip_tone(tone: str) -> str: + return {"red": "fail", "amber": "warn", "green": "pass", "blue": "blue"}.get(tone, "idle") + + +def _pass_rate_label(summary: dict[str, Any]) -> str: + cases = _safe_int(summary.get("cases")) + if not cases: + return "-" + blocking = _blocking_count(summary) + return f"{max(0, int(round(((cases - blocking) / cases) * 100)))}%" + + +def _latest_blocking(reports: list[Any]) -> int: + latest = next((report for report in reports if isinstance(report, dict)), {}) + return _blocking_count(_app_dict(latest.get("summary"))) + + +def _coverage_label(summary: dict[str, Any]) -> str: + coverage = summary.get("cluster_coverage") + if isinstance(coverage, (int, float)): + return f"{coverage * 100:.0f}%" + return "-" + + def _collect_reports(reports_dir: Path, *, limit: int) -> tuple[list[dict[str, Any]], list[dict[str, str]]]: if not reports_dir.exists(): return [], [] @@ -140,6 +939,8 @@ def _collect_reports(reports_dir: Path, *, limit: int) -> tuple[list[dict[str, A continue if _is_benchmark_report(report): continue + if _is_dashboard_sidecar_json(path, report): + continue summary = report.get("summary") if not isinstance(summary, dict): errors.append({"path": str(path), "message": "missing summary object"}) @@ -150,12 +951,15 @@ def _collect_reports(reports_dir: Path, *, limit: int) -> tuple[list[dict[str, A "path": str(path), "name": path.name, "kind": _report_kind(report, path), + "suite": str(report.get("suite") or ""), "summary": _summary_counts(summary), "decision": report.get("decision") if isinstance(report.get("decision"), dict) else {}, + "methodology": report.get("methodology") if isinstance(report.get("methodology"), dict) else {}, + "suite_summary": report.get("suite_summary") if isinstance(report.get("suite_summary"), dict) else {}, "owners": _report_owner_review(report.get("diffs"), suite_path=str(report.get("suite") or "")), "trust": _report_trust_summary(report.get("diffs")), "review": _report_review_summary(report.get("diffs")), - "review_cases": _report_review_cases(report.get("diffs")), + "review_cases": _report_review_cases(report.get("diffs"), suite_path=str(report.get("suite") or "")), "prompt_evals": prompt_evals, "prompt_groups": _report_prompt_groups(prompt_evals), "html_path": _existing_sibling(path, ".html"), @@ -165,6 +969,15 @@ def _collect_reports(reports_dir: Path, *, limit: int) -> tuple[list[dict[str, A return reports, errors +def _is_dashboard_sidecar_json(path: Path, payload: dict[str, Any]) -> bool: + schema = str(payload.get("$schema") or "") + if schema.endswith("/redline-suite.schema.json") or schema.endswith("redline-suite.schema.json"): + return True + if path.name.endswith(".slack.json") and isinstance(payload.get("blocks"), list): + return True + return False + + def _collect_benchmarks(reports_dir: Path, *, limit: int) -> list[dict[str, Any]]: if not reports_dir.exists(): return [] @@ -489,7 +1302,7 @@ def _changed_count(summary: Any) -> int: return int(summary.get("changed") or 0) -def _report_review_cases(diffs: Any, *, limit: int = 12) -> list[dict[str, Any]]: +def _report_review_cases(diffs: Any, *, limit: int = 12, suite_path: str = "") -> list[dict[str, Any]]: if not isinstance(diffs, list): return [] rows = [] @@ -503,6 +1316,9 @@ def _report_review_cases(diffs: Any, *, limit: int = 12) -> list[dict[str, Any]] first_reason = "" if isinstance(reasons, list) and reasons: first_reason = str(reasons[0]) + impact = str(item.get("impact") or "").strip() + if not impact: + impact = case_impact(status, reasons if isinstance(reasons, list) else []) rows.append( { "case_id": str(item.get("case_id") or ""), @@ -510,10 +1326,11 @@ def _report_review_cases(diffs: Any, *, limit: int = 12) -> list[dict[str, Any]] "owner": str(item.get("owner") or ""), "prompt": str(item.get("prompt") or ""), "reason": first_reason, + "impact": impact, "confidence": str(item.get("confidence") or ""), "signal": str(item.get("signal") or ""), "prompt_path": str(item.get("prompt_path") or ""), - "suite": str(item.get("suite") or ""), + "suite": str(item.get("suite") or suite_path), } ) return rows[:limit] @@ -523,6 +1340,8 @@ def _dashboard_trust_summary(reports: list[dict[str, Any]]) -> dict[str, Any]: cases = 0 confidence: dict[str, int] = {} signal: dict[str, int] = {} + methodology: dict[str, int] = {} + suite_coverage: dict[str, int] = {} for report in reports: trust = report.get("trust") if not isinstance(trust, dict): @@ -530,10 +1349,22 @@ def _dashboard_trust_summary(reports: list[dict[str, Any]]) -> dict[str, Any]: cases += int(trust.get("cases") or 0) _merge_counts(confidence, trust.get("confidence")) _merge_counts(signal, trust.get("signal")) + method = report.get("methodology") + if isinstance(method, dict): + label = _methodology_label(method) + if label: + methodology[label] = methodology.get(label, 0) + 1 + suite_summary = report.get("suite_summary") + if isinstance(suite_summary, dict): + label = _suite_coverage_label(suite_summary) + if label: + suite_coverage[label] = suite_coverage.get(label, 0) + 1 return { "cases": cases, "confidence": dict(sorted(confidence.items())), "signal": dict(sorted(signal.items())), + "methodology": dict(sorted(methodology.items())), + "suite_coverage": dict(sorted(suite_coverage.items())), } @@ -747,10 +1578,14 @@ def _trust_panel(trust: dict[str, Any]) -> str: cases = int(trust.get("cases") or 0) confidence = trust.get("confidence") signal = trust.get("signal") + methodology = trust.get("methodology") + suite_coverage = trust.get("suite_coverage") if cases <= 0 or not isinstance(confidence, dict) or not isinstance(signal, dict): return "" confidence_pills = _count_pills(confidence) signal_pills = _count_pills(signal) + methodology_pills = _count_pills(methodology) if isinstance(methodology, dict) else "" + coverage_pills = _count_pills(suite_coverage) if isinstance(suite_coverage, dict) else "" if not confidence_pills and not signal_pills: return "" return ( @@ -759,6 +1594,8 @@ def _trust_panel(trust: dict[str, Any]) -> str: '
' f'
Confidence

{confidence_pills or "-"}

' f'
Signal

{signal_pills or "-"}

' + f'
Methodology

{methodology_pills or "-"}

' + f'
Suite coverage

{coverage_pills or "-"}

' "
" "" ) @@ -798,6 +1635,35 @@ def _count_pills(counts: dict[Any, Any]) -> str: return "".join(rows) +def _methodology_label(value: dict[str, Any]) -> str: + name = str(value.get("name") or "").strip() + version = str(value.get("version") or "").strip() + if name and version: + return f"{name} ({version})" + return version or name + + +def _suite_coverage_label(value: dict[str, Any]) -> str: + case_coverage = _percent_value(value.get("case_coverage")) + cluster_coverage = _percent_value(value.get("cluster_coverage")) + cases = value.get("cases") + pairs = value.get("unique_prompt_response_pairs") + clusters = value.get("clusters") + parts = [] + if cases is not None and pairs is not None and case_coverage: + parts.append(f"cases {cases}/{pairs} ({case_coverage})") + if isinstance(clusters, int) and isinstance(value.get("cluster_coverage"), int | float) and cluster_coverage: + covered_clusters = round(clusters * float(value["cluster_coverage"])) + parts.append(f"groups {covered_clusters}/{clusters} ({cluster_coverage})") + return "; ".join(parts) + + +def _percent_value(value: object) -> str: + if not isinstance(value, int | float): + return "" + return f"{value * 100:.1f}%" + + def _owners_panel(owners: list[Any]) -> str: if not owners: return "" @@ -882,7 +1748,7 @@ def _trend_cluster_diagnosis(value: Any) -> str: ) if not rows: return "" - return "

Cluster diagnosis

    " + "".join(rows) + "
" + return "

Behavior group diagnosis

    " + "".join(rows) + "
" def _trend_class(direction: str) -> str: @@ -1121,7 +1987,7 @@ def _review_queue_panel(review_cases: Any) -> str: "" f"{_h(str(item.get('status') or '-'))}" f"{_h(str(item.get('case_id') or '-'))}{_h(_preview(str(item.get('prompt') or '')))}" - f"{_h(str(item.get('reason') or '-'))}" + f"{_h(str(item.get('reason') or '-'))}{_h(str(item.get('impact') or ''))}" f"{_h(str(item.get('owner') or '-'))}" f"{_h(trust or '-')}{_h(context)}" "" @@ -1284,6 +2150,761 @@ def _rate(value: Any) -> str: return f"{number:.2f}" +_APP_DASHBOARD_SCRIPT = """ + +""" + + +_APP_DASHBOARD_CSS = """ +:root { + color-scheme: dark; + --bg0: #0e0f11; + --bg1: #141519; + --bg2: #1c1d22; + --bg3: #23242b; + --border: #2a2b33; + --border2: #32333d; + --text0: #f0f0f2; + --text1: #b8b9c6; + --text2: #6e6f82; + --text3: #44455a; + --green: #3ecf8e; + --green-bg: #0d2a1f; + --green-border: #1a4a32; + --amber: #f5a623; + --amber-bg: #2a1f08; + --amber-border: #4a3510; + --red: #f06060; + --red-bg: #2a0e0e; + --red-border: #4a1a1a; + --blue: #60a5fa; + --blue-bg: #0e1a2a; + --blue-border: #1a3050; + --accent: #e24b4a; + --accent-bg: #2a0e0e; + --accent-border: #5a1a1a; + --radius: 6px; + --radius-lg: 10px; +} +* { box-sizing: border-box; } +html, body { min-height: 100%; } +body { + margin: 0; + background: var(--bg0); + color: var(--text0); + font: 13px/1.5 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} +body::-webkit-scrollbar, .pane::-webkit-scrollbar, .sidebar::-webkit-scrollbar { width: 5px; height: 5px; } +body::-webkit-scrollbar-track, .pane::-webkit-scrollbar-track, .sidebar::-webkit-scrollbar-track { background: var(--bg1); } +body::-webkit-scrollbar-thumb, .pane::-webkit-scrollbar-thumb, .sidebar::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 10px; } +a { color: var(--blue); text-decoration: none; font-weight: 600; margin-right: 10px; } +code { + border: 1px solid var(--border2); + border-radius: var(--radius); + padding: 2px 5px; + background: var(--bg3); + color: var(--text0); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; +} +.app { + height: 100vh; + overflow: hidden; + display: grid; + grid-template-columns: 176px minmax(0, 1fr); +} +.sidebar { + background: var(--bg1); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + overflow-y: auto; +} +.sb-logo { + padding: 16px 16px 14px; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + gap: 9px; +} +.sb-logo-icon { + width: 28px; + height: 28px; + background: var(--accent); + border-radius: var(--radius); + display: flex; + align-items: center; + justify-content: center; + color: #fff; + flex-shrink: 0; + font-size: 15px; + font-weight: 700; +} +.sb-logo-name { font-size: 14px; font-weight: 650; color: var(--text0); letter-spacing: 0; } +.sb-logo-version { color: var(--text2); font-size: 10px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } +.sb-section { + padding: 14px 16px 4px; + color: var(--text3); + font-size: 10px; + font-weight: 700; + letter-spacing: .07em; + text-transform: uppercase; +} +.sb-item { + width: 100%; + border: 0; + border-left: 2px solid transparent; + padding: 7px 14px 7px 12px; + color: var(--text2); + background: transparent; + text-align: left; + cursor: pointer; + font: inherit; + font-size: 12.5px; + display: flex; + align-items: center; + gap: 9px; + transition: all .12s; +} +.sb-item:hover, .sb-item.active { + color: var(--text0); + background: var(--bg2); +} +.sb-item.active { + border-left-color: var(--accent); + font-weight: 600; +} +.sb-spacer { flex: 1; } +.sb-bottom { + padding: 10px 14px 14px; + border-top: 1px solid var(--border); + display: flex; + align-items: center; + gap: 8px; +} +.sb-avatar { + width: 26px; + height: 26px; + border-radius: 50%; + background: var(--accent); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 700; + flex-shrink: 0; +} +.sb-user-name { font-size: 12px; font-weight: 600; color: var(--text1); } +.sb-user-role { font-size: 10px; color: var(--text2); } +.svg-ico { + width: 15px; + height: 15px; + flex-shrink: 0; + display: inline-block; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; + vertical-align: -2px; +} +.dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--green); +} +.dot.red { background: var(--red); } +.dot.amber { background: var(--amber); } +.dot.green { background: var(--green); } +.main { + min-width: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} +.topbar { + background: var(--bg1); + border-bottom: 1px solid var(--border); + padding: 10px 20px; + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; +} +.topbar-left { + display: flex; + align-items: center; + gap: 10px; +} +.topbar-right { + display: flex; + align-items: center; + gap: 8px; +} +.crumb { font-size: 12px; color: var(--text2); } +.crumb b { color: var(--text0); font-weight: 600; } +.top-badge { + font-size: 10px; + font-weight: 600; + padding: 2px 8px; + border-radius: 20px; +} +.badge-crit { background: var(--red-bg); color: var(--red); border: 1px solid var(--red-border); } +.badge-warn { background: var(--amber-bg); color: var(--amber); border: 1px solid var(--amber-border); } +.badge-pass { background: var(--green-bg); color: var(--green); border: 1px solid var(--green-border); } +.badge-blue { background: var(--blue-bg); color: var(--blue); border: 1px solid var(--blue-border); } +.btn { + font-size: 12px; + padding: 5px 13px; + border-radius: var(--radius); + border: 1px solid var(--border2); + background: var(--bg2); + color: var(--text1); + cursor: pointer; + transition: all .12s; + display: flex; + align-items: center; + gap: 5px; +} +.btn:hover { color: var(--text0); background: var(--bg3); } +.btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); font-weight: 600; } +.btn-primary:hover { opacity: .9; } +.pane { + flex: 1; + overflow-y: auto; + padding: 20px; + display: flex; + flex-direction: column; + gap: 14px; + background: var(--bg0); +} +.screen { display: none; flex-direction: column; gap: 14px; } +.screen.active { display: flex; } +.metric-row { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 10px; +} +.metric-card, .card, .alert { + background: var(--bg2); + border: 1px solid var(--border); + border-radius: var(--radius-lg); +} +.metric-card { padding: 12px 14px; } +.metric-label, .metric-sub, .muted, .t-sub, .kv-key { color: var(--text2); } +.metric-label { + font-size: 10px; + color: var(--text2); + font-weight: 600; + text-transform: uppercase; + letter-spacing: .04em; + margin-bottom: 5px; +} +.metric-val { + font-size: 22px; + font-weight: 650; + color: var(--text0); + line-height: 1; +} +.metric-val.green { color: var(--green); } +.metric-val.amber { color: var(--amber); } +.metric-val.red { color: var(--red); } +.metric-sub { font-size: 11px; margin-top: 4px; } +.two-col { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} +.hero-grid { grid-template-columns: 1.2fr .8fr; } +.card { margin-bottom: 16px; overflow: hidden; } +.card-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 14px 10px; + border-bottom: 1px solid var(--border); +} +.card-title { + font-size: 12px; + font-weight: 650; + color: var(--text0); + display: flex; + align-items: center; + gap: 7px; +} +.card-meta { font-size: 11px; color: var(--text2); display: flex; align-items: center; gap: 6px; } +.card-body { padding: 12px 14px; } +.decision { font-size: 16px; font-weight: 650; margin-bottom: 12px; } +.alert { + padding: 13px 16px; + margin: 16px 0; +} +.alert-bar { + border-radius: var(--radius); + padding: 10px 14px; + display: flex; + align-items: flex-start; + gap: 10px; + font-size: 12px; + line-height: 1.5; +} +.alert-red { background: var(--red-bg); border: 1px solid var(--red-border); color: var(--red); } +.alert-amber { background: var(--amber-bg); border: 1px solid var(--amber-border); color: var(--amber); } +.alert-green { background: var(--green-bg); border: 1px solid var(--green-border); color: var(--green); } +.alert-blue { background: var(--blue-bg); border: 1px solid var(--blue-border); color: var(--blue); } +.alert-bar b { font-weight: 650; } +.t-row, .kv-row, .log-row, .check-row, .setting-row, .int-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 8px 0; + border-bottom: 1px solid var(--border); +} +.t-row:last-child, .kv-row:last-child, .log-row:last-child, .check-row:last-child, .setting-row:last-child, .int-row:last-child { border-bottom: 0; } +.t-info { flex: 1; min-width: 0; } +.t-name { font-size: 12px; font-weight: 600; color: var(--text0); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.t-sub { font-size: 11px; color: var(--text2); overflow-wrap: anywhere; } +.t-right { margin-left: auto; display: flex; align-items: center; gap: 8px; flex-shrink: 0; } +.t-icon { + width: 28px; + height: 28px; + border-radius: var(--radius); + background: var(--bg3); + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; + color: var(--text2); + flex-shrink: 0; +} +.t-icon.red, .reg-icon.red { background: var(--red-bg); color: var(--red); } +.t-icon.amber, .reg-icon.amber { background: var(--amber-bg); color: var(--amber); } +.t-icon.green, .reg-icon.green { background: var(--green-bg); color: var(--green); } +.t-icon.blue, .reg-icon.blue { background: var(--blue-bg); color: var(--blue); } +.reg-row { + display: flex; + align-items: flex-start; + padding: 10px 0; + border-bottom: 1px solid var(--border); + gap: 10px; +} +.reg-row:last-child { border-bottom: none; } +.reg-icon { + width: 30px; + height: 30px; + border-radius: var(--radius); + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + flex-shrink: 0; + margin-top: 1px; + font-weight: 700; +} +.reg-info { flex: 1; min-width: 0; } +.reg-name { font-size: 12px; font-weight: 600; color: var(--text0); overflow-wrap: anywhere; } +.reg-sub { font-size: 11px; color: var(--text2); margin-top: 1px; overflow-wrap: anywhere; } +.reg-impact { font-size: 11px; color: var(--text1); margin-top: 4px; overflow-wrap: anywhere; } +.prog-bar { + height: 3px; + background: var(--border); + border-radius: 2px; + margin-top: 6px; + overflow: hidden; +} +.prog-fill { height: 100%; border-radius: 2px; background: var(--text3); } +.prog-fill.red { background: var(--red); } +.prog-fill.amber { background: var(--amber); } +.prog-fill.green { background: var(--green); } +.check-left { + display: flex; + align-items: center; + gap: 7px; + font-size: 12px; + color: var(--text0); +} +.chip, .badge { + display: inline-flex; + align-items: center; + border-radius: 999px; + padding: 2px 8px; + font-size: 10px; + font-weight: 600; + white-space: nowrap; +} +.badge { + margin-left: auto; + padding: 1px 6px; + font-size: 10px; +} +.red { color: var(--red); } +.amber { color: var(--amber); } +.green { color: var(--green); } +.blue { color: var(--blue); } +.chip-pass, .badge.green { background: var(--green-bg); color: var(--green); border: 1px solid var(--green-border); } +.chip-warn, .badge.amber { background: var(--amber-bg); color: var(--amber); border: 1px solid var(--amber-border); } +.chip-fail, .badge.red { background: var(--red-bg); color: var(--red); border: 1px solid var(--red-border); } +.chip-blue { background: var(--blue-bg); color: var(--blue); border: 1px solid var(--blue-border); } +.chip-idle { background: var(--bg3); color: var(--text2); border: 1px solid var(--border); } +.kv-key { font-size: 12px; } +.kv-val { font-size: 12px; color: var(--text0); font-weight: 600; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } +.kv-val.red { color: var(--red); } +.kv-val.green { color: var(--green); } +.kv-val.amber { color: var(--amber); } +.upload-zone { + border: 1.5px dashed var(--border2); + border-radius: var(--radius-lg); + padding: 28px 20px; + margin-bottom: 12px; + text-align: center; +} +.upload-zone p { color: var(--text2); margin-top: 8px; font-size: 12px; } +.log-row { + align-items: flex-start; + justify-content: flex-start; +} +.log-row span:first-child { + width: 24px; + height: 24px; + border-radius: var(--radius); + background: var(--bg3); + display: flex; + align-items: center; + justify-content: center; + color: var(--text2); + flex-shrink: 0; + font-weight: 700; +} +.log-row p { margin: 0; color: var(--text1); } +.diff-block { + background: var(--bg1); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 12px 14px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; + line-height: 1.8; +} +.diff-del, .diff-add, .diff-ctx { + display: block; + padding: 1px 6px; + border-radius: 3px; + margin: 1px 0; +} +.diff-del { background: #2a0d0d; color: var(--red); } +.diff-add { background: #0d2a1a; color: var(--green); } +.diff-ctx { color: var(--text1); } +.command-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 12px; +} +.command-card { + background: var(--bg2); + border: 1px solid var(--border); + border-left: 3px solid var(--blue); + border-radius: var(--radius-lg); + padding: 12px; +} +.command-card.featured { + background: linear-gradient(180deg, rgba(96, 165, 250, .08), rgba(96, 165, 250, .02)), var(--bg2); +} +.command-green { border-left-color: var(--green); } +.command-amber { border-left-color: var(--amber); } +.command-red { border-left-color: var(--red); } +.command-head { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 10px; + align-items: start; +} +.command-stage { + width: 24px; + height: 24px; + border-radius: var(--radius); + background: var(--bg3); + color: var(--text1); + display: flex; + align-items: center; + justify-content: center; + font-weight: 800; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} +.command-title { + color: var(--text0); + font-size: 12px; + font-weight: 650; +} +.command-body { + color: var(--text2); + font-size: 11px; + margin-top: 2px; +} +.command-row { + margin-top: 10px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: center; +} +.command-row code { + display: block; + overflow-x: auto; + white-space: nowrap; +} +.copy-btn { + border: 1px solid var(--border2); + border-radius: var(--radius); + background: var(--bg3); + color: var(--text1); + cursor: pointer; + font: inherit; + font-size: 11px; + font-weight: 650; + padding: 5px 9px; +} +.copy-btn:hover, .copy-btn.copied { + border-color: var(--green-border); + color: var(--green); + background: var(--green-bg); +} +.chart-svg { + width: 100%; + height: 150px; + display: block; +} +.chart-svg line { + stroke: var(--border); + stroke-width: 1; +} +.chart-svg polyline { + fill: none; + stroke: var(--green); + stroke-width: 3; + stroke-linecap: round; + stroke-linejoin: round; +} +.chart-svg circle { + fill: var(--green); + stroke: var(--bg2); + stroke-width: 2; +} +.chart-legend { + display: flex; + gap: 14px; + padding-top: 8px; + color: var(--text2); + font-size: 10px; +} +.legend { + width: 12px; + height: 2px; + display: inline-block; + border-radius: 1px; + margin-right: 5px; + vertical-align: middle; +} +.legend.pass { background: var(--green); } +.legend.block { background: var(--red); } +.trust-strip { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 10px; + border-top: 1px solid var(--border); + margin-top: 12px; + padding-top: 12px; +} +.trust-strip span { + display: block; + color: var(--text2); + font-size: 10px; + text-transform: uppercase; + letter-spacing: .04em; +} +.trust-strip strong { + display: block; + margin-top: 3px; + color: var(--text0); + font-size: 12px; +} +.pill { + display: inline-block; + border: 1px solid var(--border); + border-radius: 999px; + padding: 2px 8px; + margin: 0 6px 6px 0; + background: var(--bg3); + font-size: 12px; +} +.regression, .missing, .worse { color: var(--red); border-color: var(--red-border); background: var(--red-bg); } +.changed { color: var(--amber); border-color: var(--amber-border); background: var(--amber-bg); } +.better, .resolved { color: var(--green); border-color: var(--green-border); background: var(--green-bg); } +.empty p { color: var(--text2); } +.row-note { + margin: 10px 0 0; + padding-top: 10px; + border-top: 1px solid var(--border); + font-size: 11px; +} +.int-logo { + width: 32px; + height: 32px; + border-radius: var(--radius); + background: var(--bg3); + border: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + color: var(--text2); + flex-shrink: 0; +} +.setting-row { padding: 9px 0; } +.setting-label { font-size: 12px; color: var(--text0); font-weight: 600; } +.setting-sub { font-size: 11px; color: var(--text2); overflow-wrap: anywhere; } +@media (max-width: 900px) { + .metric-row { grid-template-columns: repeat(2, 1fr); } + .two-col, .hero-grid, .trust-strip { grid-template-columns: 1fr; } +} +@media (max-width: 700px) { + .app { + display: block; + height: auto; + min-height: 100vh; + overflow: visible; + } + .sidebar { + position: sticky; + top: 0; + z-index: 20; + display: flex; + flex-direction: row; + align-items: center; + gap: 4px; + overflow-x: auto; + overflow-y: hidden; + border-right: 0; + border-bottom: 1px solid var(--border); + padding: 6px 8px; + } + .sb-logo { + padding: 0 8px 0 0; + border-bottom: 0; + flex-shrink: 0; + } + .sb-logo-icon { width: 26px; height: 26px; } + .sb-logo-version, .sb-section, .sb-bottom { display: none; } + .sb-item { + width: auto; + flex: 0 0 auto; + border-left: 0; + border-bottom: 2px solid transparent; + border-radius: var(--radius); + padding: 7px 10px; + white-space: nowrap; + } + .sb-item.active { + border-left-color: transparent; + border-bottom-color: var(--accent); + } + .main { + display: block; + overflow: visible; + } + .topbar { padding: 10px 12px; } + .topbar-right .chip { display: none; } + .pane { + overflow: visible; + padding: 12px; + } + .metric-row { grid-template-columns: 1fr; } +} +""" + + _DASHBOARD_CSS = """ :root { color-scheme: light; diff --git a/redline/demo.py b/redline/demo.py index 5cc5f3b..b3e5ec0 100644 --- a/redline/demo.py +++ b/redline/demo.py @@ -1,6 +1,7 @@ from __future__ import annotations from pathlib import Path +from shlex import quote from typing import Any from .diff import compare_suite_to_candidate, format_compact_report, format_report @@ -179,6 +180,7 @@ def run_demo(output_dir: str | Path = ".redline/demo", *, public: bool = False) candidate = read_jsonl_records(candidate_path, "prompt", "response") result = compare_suite_to_candidate(suite, candidate) + result["suite"] = str(suite_path) write_json(report_json_path, result) title = "redline public dogfood" if public else "redline demo" write_text(report_md_path, format_markdown_report(result, title=title)) @@ -219,10 +221,13 @@ def format_demo(result: dict[str, Any], *, compact: bool = False) -> str: ) history_label = "public-dogfood" if result.get("public") else "demo" history_command = ( - f"redline history {result['report_json']} --label {history_label} " + f"redline history {quote(str(result['report_json']))} --label {history_label} " "--out .redline/history.jsonl --out-md .redline/history.md" ) review_case_id = _first_reviewable_case_id(result["diff"]) + suite_arg = quote(str(result["suite"])) + candidate_arg = quote(str(result["candidate"])) + review_case_arg = quote(review_case_id) lines = [ result["title"], "", @@ -239,11 +244,11 @@ def format_demo(result: dict[str, Any], *, compact: bool = False) -> str: "Next steps", f"- Inspect the HTML report: {result['report_html']}", f"- Inspect the Markdown report: {result['report_markdown']}", - f"- List demo cases: redline cases {result['suite']}", - f"- Mark an intentional change: redline mark {result['suite']} {review_case_id} --status expected --note \"intentional prompt change\"", - f"- Promote reviewed changes: redline accept {result['suite']} --all-expected --candidate {result['candidate']} --note \"accepted prompt v2\"", + f"- List demo cases: redline cases {suite_arg}", + f"- Mark an intentional change: redline mark {suite_arg} {review_case_arg} --status expected --note \"intentional prompt change\"", + f"- Promote reviewed changes: redline accept {suite_arg} --all-expected --candidate {candidate_arg} --note \"accepted prompt v2\"", f"- Record a trend entry: {history_command}", - f"- Open a local dashboard: redline dashboard --reports-dir {Path(result['report_json']).parent} --history .redline/history.jsonl", + f"- Open the guided local app: redline app --reports-dir {quote(str(Path(result['report_json']).parent))} --history .redline/history.jsonl", "- Connect a runner: redline init --runner stdio --copy-runner --github-action", "- Explore adapters: redline runners --copy all", "- Build a real suite: redline suite path/to/baseline.jsonl --out redline-suite.json", diff --git a/redline/diff.py b/redline/diff.py index bf19d1d..8778a93 100644 --- a/redline/diff.py +++ b/redline/diff.py @@ -149,6 +149,7 @@ def to_dict(self) -> dict[str, Any]: "baseline_response": self.baseline_response, "candidate_response": self.candidate_response, "reasons": list(self.reasons), + "impact": case_impact(self.status, self.reasons), "confidence": self.confidence, "signal": self.signal, "baseline_features": self.baseline_features, @@ -265,6 +266,9 @@ def compare_suite_to_candidate( "$schema": REPORT_SCHEMA_URL, "version": "0.1", "profile": profile, + "methodology": _report_methodology(suite), + "suite_summary": _report_suite_summary(suite), + "warnings": _report_warnings(suite), "summary": summary, "decision": decision, "diffs": [diff.to_dict() for diff in diffs], @@ -409,6 +413,58 @@ def _diff_profile(value: str) -> str: return value +def _report_methodology(suite: dict[str, Any]) -> dict[str, Any]: + methodology = suite.get("methodology") + if not isinstance(methodology, dict): + return {} + return { + "name": str(methodology.get("name") or ""), + "version": str(methodology.get("version") or ""), + "trust_scope": str(methodology.get("trust_scope") or ""), + } + + +def _report_suite_summary(suite: dict[str, Any]) -> dict[str, Any]: + summary = suite.get("summary") + if not isinstance(summary, dict): + return {} + keys = ( + "records_seen", + "unique_prompt_response_pairs", + "clusters", + "cases", + "max_cases", + "case_coverage", + "cluster_coverage", + "selection", + "high_risk_clusters", + "medium_risk_clusters", + "non_ascii_records", + "stochastic_prompt_groups", + ) + return {key: summary[key] for key in keys if key in summary} + + +def _report_warnings(suite: dict[str, Any]) -> list[str]: + summary = suite.get("summary") + if not isinstance(summary, dict): + return [] + warnings = [] + non_ascii_records = int(summary.get("non_ascii_records") or 0) + if non_ascii_records: + warnings.append( + f"suite contains {non_ascii_records} non-ASCII record(s); " + "English-centric entity, tone, refusal, and policy-polarity heuristics need manual or judge review" + ) + stochastic_prompt_groups = int(summary.get("stochastic_prompt_groups") or 0) + if stochastic_prompt_groups: + warnings.append( + f"suite contains {stochastic_prompt_groups} prompt(s) with multiple distinct baseline responses; " + "separate natural sampling variance from prompt regressions before CI gating" + ) + return warnings + + def _confidence_drift_reason( baseline_text: str | None, candidate_text: str | None, @@ -497,6 +553,34 @@ def _case_confidence_signal(status: str, reasons: list[str]) -> tuple[str, str]: return "medium", "shallow_semantic" +def case_impact(status: str, reasons: tuple[str, ...] | list[str]) -> str: + text = " ".join(str(reason).lower() for reason in reasons) + normalized_status = status.lower() + if normalized_status == "missing": + return "Replay did not produce a candidate output, so this baseline behavior is untested." + if "valid json" in text or "json keys" in text: + return "Downstream code may fail if consumers expect parseable JSON or required fields." + if "markdown table" in text or "table structure" in text: + return "A consumer expecting rows and columns may receive unstructured prose instead." + if "code block" in text: + return "A developer or tool expecting copyable code may lose the executable structure." + if "bullet list" in text or "numbered list" in text: + return "A workflow expecting ordered or scannable steps may become harder to review." + if "missing numbers" in text or "missing urls" in text or "missing entities" in text: + return "Concrete details used for decisions, routing, or compliance may have been dropped." + if "newly refuses" in text or "refusal" in text: + return "A previously supported safe workflow may now be blocked." + if "became empty" in text: + return "The user may receive no usable answer." + if "content changed substantially" in text or "much shorter" in text: + return "Meaning may have changed enough to require human review before acceptance." + if normalized_status == "regression": + return "This case changed in a way redline treats as blocking before shipping." + if normalized_status == "changed": + return "Review whether this behavioral change is intentional before accepting it." + return "" + + def _is_requirement_reason(reason: str) -> bool: return reason.startswith("candidate missing required text:") or reason.startswith( "candidate includes forbidden text:" @@ -544,6 +628,10 @@ def format_report(result: dict[str, Any], *, title: str = "redline diff") -> str f" MISSING {summary['missing']:>3}", "", ] + profile = str(result.get("profile") or "") + if profile: + lines.append(f"Profile: {profile} ({_profile_description(profile)})") + lines.append("") decision = result.get("decision") if isinstance(decision, dict): confidence = str(decision.get("confidence") or "").upper() @@ -578,6 +666,9 @@ def format_report(result: dict[str, Any], *, title: str = "redline diff") -> str lines.append(status.upper()) for item in matching: lines.append(f"- {_case_label(item)}: {_preview(item['prompt'])}") + impact = str(item.get("impact") or "") + if impact: + lines.append(f" - why this matters: {impact}") for reason in item["reasons"]: lines.append(f" - {reason}") lines.append("") @@ -596,6 +687,9 @@ def format_compact_report(result: dict[str, Any], *, title: str = "redline diff" f"neutral={summary['neutral']}" ) ] + profile = str(result.get("profile") or "") + if profile: + lines.append(f"Profile: {profile} ({_profile_description(profile)})") decision = result.get("decision") if isinstance(decision, dict): confidence = str(decision.get("confidence") or "").upper() @@ -671,6 +765,14 @@ def _prompt_eval_lines(value: Any) -> list[str]: return rows +def _profile_description(profile: str) -> str: + if profile == "review": + return "detail/entity loss becomes reviewable changed signal" + if profile == "strict": + return "detail/entity loss is blocking" + return "custom" + + def _prompt_eval_status(summary: dict[str, Any]) -> str: if _summary_count(summary, "regression") or _summary_count(summary, "missing"): return "REGRESSION" diff --git a/redline/doctor.py b/redline/doctor.py index e9004a5..71fce81 100644 --- a/redline/doctor.py +++ b/redline/doctor.py @@ -235,7 +235,7 @@ def _coverage_check( non_ascii_records = _manifest_int(manifest_summary, "non_ascii_records") judge_configured = "judge" in config message += ( - f"; high-risk clusters={high_risk_clusters}; " + f"; high-risk groups={high_risk_clusters}; " f"requirements={requirements_count}; " f"judge={'yes' if judge_configured else 'no'}; " f"explicit guards={explicit_guard_cases}/{cases_count}" @@ -261,7 +261,7 @@ def _coverage_check( non_ascii_records = int(summary_report.get("non_ascii_records") or 0) judge_configured = "judge" in config message += ( - f"; high-risk clusters={high_risk_clusters}; " + f"; high-risk groups={high_risk_clusters}; " f"requirements={requirements_count}; " f"judge={'yes' if judge_configured else 'no'}; " f"explicit guards={explicit_guard_cases}/{cases_count}" diff --git a/redline/history.py b/redline/history.py index c3ff184..b2032dd 100644 --- a/redline/history.py +++ b/redline/history.py @@ -156,9 +156,9 @@ def format_markdown_history(entries: list[dict[str, Any]], *, limit: int | None if cluster_rows: lines.extend( [ - "## Cluster Diagnosis", + "## Behavior Group Diagnosis", "", - "| Cluster | Blocking Delta | Latest Blocking | Changed Delta |", + "| Behavior Group | Blocking Delta | Latest Blocking | Changed Delta |", "| --- | ---: | ---: | ---: |", ] ) diff --git a/redline/import_logs.py b/redline/import_logs.py index 2baa500..94d92aa 100644 --- a/redline/import_logs.py +++ b/redline/import_logs.py @@ -5,10 +5,168 @@ from typing import Any from .io import iter_jsonl, write_jsonl +from .redact import DEFAULT_PLACEHOLDER, redact_object _MISSING = object() +IMPORT_PRESETS: dict[str, dict[str, object]] = { + "datadog": { + "input_field": "attributes.input", + "output_field": "attributes.output", + "metadata_fields": ["service", "trace_id", "attributes.model"], + }, + "dolly": { + "input_field": "instruction", + "output_field": "response", + "context_field": "context", + "metadata_fields": ["category"], + }, + "helicone": { + "input_field": "request.prompt", + "output_field": "response.text", + "metadata_fields": ["request.model", "response.model", "user_id"], + }, + "langfuse": { + "input_field": "input", + "output_field": "output", + "metadata_fields": ["name", "traceId", "userId"], + }, + "openai-chat": { + "input_field": "request.messages", + "output_field": "response.choices.0.message.content", + "metadata_fields": ["request.model", "response.model"], + }, +} + +_PROMPT_FIELD_CANDIDATES = ( + "prompt", + "input", + "instruction", + "request.prompt", + "request.input", + "request.messages", + "messages", + "ticket.text", + "attributes.input", +) + +_RESPONSE_FIELD_CANDIDATES = ( + "response", + "output", + "completion", + "response.text", + "response.output_text", + "response.choices.0.message.content", + "assistant.reply", + "attributes.output", +) + + +def import_preset(name: str) -> dict[str, object]: + try: + return dict(IMPORT_PRESETS[name]) + except KeyError as exc: + choices = ", ".join(sorted(IMPORT_PRESETS)) + raise ValueError(f"unknown import preset: {name}; choose one of: {choices}") from exc + + +def import_preset_rows() -> list[dict[str, Any]]: + rows = [] + for name, preset in sorted(IMPORT_PRESETS.items()): + rows.append( + { + "id": name, + "input_field": str(preset.get("input_field") or ""), + "output_field": str(preset.get("output_field") or ""), + "context_field": str(preset.get("context_field") or ""), + "metadata_fields": [str(value) for value in _preset_list(preset.get("metadata_fields"))], + } + ) + return rows + + +def format_import_presets() -> str: + lines = [ + "redline import presets", + "", + f"{'PRESET':<12} {'PROMPT FIELD':<28} {'RESPONSE FIELD':<38} METADATA", + f"{'-' * 12} {'-' * 28} {'-' * 38} {'-' * 24}", + ] + for row in import_preset_rows(): + metadata = ", ".join(row["metadata_fields"]) + lines.append( + f"{row['id']:<12} {row['input_field']:<28} {row['output_field']:<38} {metadata}" + ) + lines.extend( + [ + "", + "Use: redline import raw.jsonl --preset langfuse --out .redline/logs/prompts.jsonl", + "Override any preset field with --input-field, --output-field, --context-field, or --metadata-field.", + ] + ) + return "\n".join(lines).rstrip() + "\n" + + +def detect_import_fields(path: str | Path, *, limit: int = 20) -> dict[str, Any]: + if limit < 1: + raise ValueError("--limit must be 1 or greater") + source = Path(path) + rows = [row for _, row in iter_jsonl(source)][:limit] + if not rows: + raise ValueError(f"{path} contains no JSONL records") + suggestions = _detect_suggestions(rows) + return { + "source": str(path), + "records_scanned": len(rows), + "suggestions": suggestions, + "top_level_fields": sorted({str(key) for row in rows for key in row})[:20], + } + + +def format_import_detection(result: dict[str, Any]) -> str: + suggestions = result.get("suggestions") + rows = suggestions if isinstance(suggestions, list) else [] + lines = [ + "redline import detection", + "", + f"Source: {result.get('source')}", + f"Records scanned: {result.get('records_scanned')}", + f"Top fields: {', '.join(str(value) for value in result.get('top_level_fields', []))}", + "", + ] + if not rows: + lines.extend( + [ + "No confident prompt/response mapping found.", + "Next: run `redline import --list-presets` or pass --input-field and --output-field manually.", + ] + ) + return "\n".join(lines).rstrip() + "\n" + lines.append(f"{'SCORE':<7} {'PRESET':<12} {'PROMPT FIELD':<28} RESPONSE FIELD") + lines.append(f"{'-' * 7} {'-' * 12} {'-' * 28} {'-' * 28}") + for row in rows[:8]: + lines.append( + f"{str(row.get('score', '')):<7} " + f"{str(row.get('preset', '')):<12} " + f"{str(row.get('input_field', '')):<28} " + f"{str(row.get('output_field', ''))}" + ) + best = rows[0] + lines.extend( + [ + "", + "Preview the best mapping:", + ( + f"redline import {result.get('source')} " + f"--input-field {best.get('input_field')} " + f"--output-field {best.get('output_field')} " + "--preview 3" + ), + ] + ) + return "\n".join(lines).rstrip() + "\n" + def import_jsonl_log( path: str | Path, @@ -20,15 +178,193 @@ def import_jsonl_log( id_field: str | None = None, metadata_fields: list[str] | None = None, limit: int | None = None, + redact: bool = True, + placeholder: str = DEFAULT_PLACEHOLDER, +) -> dict[str, Any]: + rows, redaction_counts = _collect_import_rows( + path, + input_field=input_field, + output_field=output_field, + context_field=context_field, + id_field=id_field, + metadata_fields=metadata_fields, + limit=limit, + redact=redact, + placeholder=placeholder, + ) + if not rows: + raise ValueError(f"{path} contains no JSONL records") + write_jsonl(output, rows) + redactions = sum(redaction_counts.values()) + return { + "source": str(path), + "output": str(output), + "records": len(rows), + "input_field": input_field, + "output_field": output_field, + "context_field": context_field or "", + "id_field": id_field or "", + "metadata_fields": metadata_fields or [], + "redacted": redact, + "redactions": redactions, + "redaction_patterns": dict(sorted(redaction_counts.items())), + } + + +def preview_jsonl_import( + path: str | Path, + *, + input_field: str = "prompt", + output_field: str = "response", + context_field: str | None = None, + id_field: str | None = None, + metadata_fields: list[str] | None = None, + limit: int = 3, + redact: bool = True, + placeholder: str = DEFAULT_PLACEHOLDER, ) -> dict[str, Any]: + rows, redaction_counts = _collect_import_rows( + path, + input_field=input_field, + output_field=output_field, + context_field=context_field, + id_field=id_field, + metadata_fields=metadata_fields, + limit=limit, + redact=redact, + placeholder=placeholder, + ) + if not rows: + raise ValueError(f"{path} contains no JSONL records") + redactions = sum(redaction_counts.values()) + return { + "source": str(path), + "previewed": len(rows), + "input_field": input_field, + "output_field": output_field, + "context_field": context_field or "", + "id_field": id_field or "", + "metadata_fields": metadata_fields or [], + "redacted": redact, + "redactions": redactions, + "redaction_patterns": dict(sorted(redaction_counts.items())), + "rows": rows, + } + + +def _detect_suggestions(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + candidates: dict[tuple[str, str, str], dict[str, Any]] = {} + for preset_name, preset in IMPORT_PRESETS.items(): + input_field = str(preset.get("input_field") or "") + output_field = str(preset.get("output_field") or "") + if input_field and output_field: + _score_mapping(rows, candidates, input_field, output_field, preset_name) + discovered_paths = _discovered_field_paths(rows) + input_candidates = sorted(set(_PROMPT_FIELD_CANDIDATES) | {path for path in discovered_paths if _looks_like_prompt_path(path)}) + output_candidates = sorted( + set(_RESPONSE_FIELD_CANDIDATES) | {path for path in discovered_paths if _looks_like_response_path(path)} + ) + for input_field in input_candidates[:80]: + for output_field in output_candidates[:80]: + _score_mapping(rows, candidates, input_field, output_field, "") + return sorted( + candidates.values(), + key=lambda row: (-int(row["score"]), str(row["preset"] or "zzzz"), str(row["input_field"])), + ) + + +def _score_mapping( + rows: list[dict[str, Any]], + candidates: dict[tuple[str, str, str], dict[str, Any]], + input_field: str, + output_field: str, + preset: str, +) -> None: + matches = 0 + for row in rows: + prompt = _get_field(row, input_field) + response = _get_field(row, output_field) + if prompt is _MISSING or response is _MISSING: + continue + if not _stringify(prompt).strip() or not _stringify_response(response).strip(): + continue + matches += 1 + if not matches: + return + score = round(100 * matches / len(rows)) + key = (input_field, output_field, preset) + candidates[key] = { + "input_field": input_field, + "output_field": output_field, + "preset": preset, + "matches": matches, + "records_scanned": len(rows), + "score": score, + } + + +def _discovered_field_paths(rows: list[dict[str, Any]]) -> set[str]: + paths: set[str] = set() + for row in rows[:5]: + _collect_field_paths(row, "", paths, depth=0) + return paths + + +def _collect_field_paths(value: Any, prefix: str, paths: set[str], *, depth: int) -> None: + if depth >= 4: + return + if isinstance(value, dict): + for key, child in value.items(): + field = str(key) + path = f"{prefix}.{field}" if prefix else field + paths.add(path) + _collect_field_paths(child, path, paths, depth=depth + 1) + return + if isinstance(value, list) and value: + path = f"{prefix}.0" if prefix else "0" + paths.add(path) + _collect_field_paths(value[0], path, paths, depth=depth + 1) + + +def _looks_like_prompt_path(path: str) -> bool: + normalized = path.lower().replace("_", ".").replace("-", ".") + return any( + token in normalized.split(".") or token in normalized + for token in ("prompt", "input", "instruction", "question", "query", "messages", "user") + ) + + +def _looks_like_response_path(path: str) -> bool: + normalized = path.lower().replace("_", ".").replace("-", ".") + return any( + token in normalized.split(".") or token in normalized + for token in ("response", "output", "completion", "answer", "reply", "assistant", "content") + ) + + +def _collect_import_rows( + path: str | Path, + *, + input_field: str, + output_field: str, + context_field: str | None, + id_field: str | None, + metadata_fields: list[str] | None, + limit: int | None, + redact: bool, + placeholder: str, +) -> tuple[list[dict[str, Any]], dict[str, int]]: if limit is not None and limit < 1: raise ValueError("--limit must be 1 or greater") metadata_paths = metadata_fields or [] rows: list[dict[str, Any]] = [] + redaction_counts: dict[str, int] = {} source = Path(path) for line_number, row in iter_jsonl(source): if limit is not None and len(rows) >= limit: break + if redact: + row = redact_object(row, placeholder=placeholder, counts=redaction_counts) prompt = _required_field(source, line_number, row, input_field, "input") response = _required_field(source, line_number, row, output_field, "output") imported: dict[str, Any] = { @@ -47,19 +383,7 @@ def import_jsonl_log( if metadata: imported["metadata"] = metadata rows.append(imported) - if not rows: - raise ValueError(f"{path} contains no JSONL records") - write_jsonl(output, rows) - return { - "source": str(path), - "output": str(output), - "records": len(rows), - "input_field": input_field, - "output_field": output_field, - "context_field": context_field or "", - "id_field": id_field or "", - "metadata_fields": metadata_paths, - } + return rows, redaction_counts def _required_field( @@ -71,7 +395,12 @@ def _required_field( ) -> Any: value = _get_field(row, path) if value is _MISSING: - raise ValueError(f"{source}:{line_number} missing {label} field: {path}") + fields = ", ".join(sorted(str(key) for key in row)[:8]) or "" + raise ValueError( + f"{source}:{line_number} missing {label} field: {path}. " + f"Available top-level fields: {fields}. " + "Run `redline import --list-presets` or override the field path." + ) return value @@ -105,6 +434,12 @@ def _get_field(row: dict[str, Any], path: str) -> Any: return row[path] current: Any = row for part in path.split("."): + if isinstance(current, list) and part.isdigit(): + index = int(part) + if index >= len(current): + return _MISSING + current = current[index] + continue if not isinstance(current, dict) or part not in current: return _MISSING current = current[part] @@ -121,3 +456,9 @@ def _stringify_response(value: Any) -> str: if isinstance(value, str): return value.strip() return _stringify(value) + + +def _preset_list(value: object) -> list[object]: + if not isinstance(value, list): + return [] + return value diff --git a/redline/io.py b/redline/io.py index 6a0ba9a..c45a9d1 100644 --- a/redline/io.py +++ b/redline/io.py @@ -19,8 +19,7 @@ def read_jsonl_records(path: str | Path, input_field: str, output_field: str) -> for line_number, obj in iter_jsonl(path): missing = [field for field in (input_field, output_field) if _get_field(obj, field) is _MISSING] if missing: - joined = ", ".join(missing) - raise ValueError(f"{path}:{line_number} missing required field(s): {joined}") + raise ValueError(_missing_fields_message(path, line_number, obj, missing)) prompt = _get_field(obj, input_field) response = _get_field(obj, output_field) records.append( @@ -63,8 +62,7 @@ def read_jsonl_records_from_offset( obj = _parse_jsonl_object(path, line_number, stripped) missing = [field for field in (input_field, output_field) if _get_field(obj, field) is _MISSING] if missing: - joined = ", ".join(missing) - raise ValueError(f"{path}:{line_number} missing required field(s): {joined}") + raise ValueError(_missing_fields_message(path, line_number, obj, missing)) prompt = _get_field(obj, input_field) response = _get_field(obj, output_field) records.append( @@ -183,3 +181,14 @@ def _get_field(obj: dict[str, Any], field: str) -> Any: return _MISSING current = current[part] return current + + +def _missing_fields_message(path: str | Path, line_number: int, obj: dict[str, Any], missing: list[str]) -> str: + joined = ", ".join(missing) + fields = ", ".join(sorted(str(key) for key in obj)[:8]) or "" + return ( + f"{path}:{line_number} missing required field(s): {joined}. " + f"Available top-level fields: {fields}. " + "If this is an exported provider log, run " + "`redline import --list-presets` or pass --input-field/--output-field." + ) diff --git a/redline/mcp.py b/redline/mcp.py index 93e89fb..b92ba67 100644 --- a/redline/mcp.py +++ b/redline/mcp.py @@ -107,8 +107,8 @@ def main(argv: Sequence[str] | None = None) -> int: print( "redline-mcp\n\n" "Local MCP stdio server for redline.\n\n" - "Run this command from an MCP client. It exposes redline doctor, suite,\n" - "watch stats, prompts, runners, judges, redact, audit, SBOM, benchmark, validate, summary, diff, eval, history, dashboard, cases, case, and guarded mark tools.\n", + "Run this command from an MCP client. It exposes redline status, doctor, suite,\n" + "quick-check, import, watch stats, prompts, runners, judges, redact, audit, SBOM, benchmark, validate, summary, diff, eval, history, dashboard, cases, case, and guarded mark tools.\n", end="", ) return 0 @@ -361,9 +361,10 @@ def _prompts() -> list[PromptSpec]: ), PromptSpec( "review_candidate_outputs", - "Compare candidate JSONL outputs against a suite and summarize risky behavior changes.", + "Quick-check or diff candidate JSONL outputs and summarize risky behavior changes.", ( PromptArgument("cwd", "Project directory where redline should run."), + PromptArgument("baseline_path", "Baseline JSONL outputs to compare when no suite exists."), PromptArgument("candidate_path", "Candidate JSONL outputs to compare.", required=True), PromptArgument("suite_path", "Optional suite JSON path."), ), @@ -398,11 +399,12 @@ def _build_check_prompt_change_prompt(arguments: dict[str, Any]) -> str: f"- Prompt file: {prompt_path}\n" f"- Suite: {suite_path}\n\n" "Use this workflow:\n" - "1. Call `redline_doctor` first. If setup has errors, stop and tell me the next fix.\n" - "2. Call `redline_eval` with the prompt file when provided and the suite when provided.\n" - "3. Treat exit code 1 as a redline finding, not a tool failure.\n" - "4. Summarize regressions, missing outputs, changed cases, and the recommended action.\n" - "5. Do not call baseline mutation commands or tell me the change is safe when redline only found neutral output.\n" + "1. Call `redline_status` first so I can see current readiness and the exact next command.\n" + "2. Call `redline_doctor` if status reports setup gaps. If setup has errors, stop and tell me the next fix.\n" + "3. Call `redline_eval` with the prompt file when provided and the suite when provided.\n" + "4. Treat exit code 1 as a redline finding, not a tool failure.\n" + "5. Summarize regressions, missing outputs, changed cases, and the recommended action.\n" + "6. Do not call baseline mutation commands or tell me the change is safe when redline only found neutral output.\n" ) @@ -418,7 +420,7 @@ def _build_suite_from_logs_prompt(arguments: dict[str, Any]) -> str: "Use this workflow:\n" "1. Call `redline_suite` for the baseline log and write the suite output.\n" "2. Call `redline_validate` on the generated suite.\n" - "3. Call `redline_summary` so I can see coverage, clusters, pinned cases, and next steps.\n" + "3. Call `redline_summary` so I can see coverage, behavior groups, pinned cases, and next steps.\n" "4. If coverage is low or cases look unclear, call `redline_cases` and `redline_case` to inspect representative cases before recommending pins.\n" "5. Call `redline_budget` so I can see expected CI runtime before enabling a gate.\n" "6. Explain what behavior redline can catch, what still needs human review, and the exact `redline suite add` command I can run for must-cover edge cases.\n" @@ -428,18 +430,21 @@ def _build_suite_from_logs_prompt(arguments: dict[str, Any]) -> str: def _build_review_candidate_outputs_prompt(arguments: dict[str, Any]) -> str: candidate_path = _required_string(arguments, "candidate_path") cwd = _optional_prompt_argument(arguments, "cwd", "the current project") + baseline_path = _optional_prompt_argument(arguments, "baseline_path", "not provided") suite_path = _optional_prompt_argument(arguments, "suite_path", "the configured suite") return ( "Review candidate prompt outputs with redline and tell me what changed.\n\n" f"- Run in: {cwd}\n" + f"- Baseline outputs: {baseline_path}\n" f"- Candidate outputs: {candidate_path}\n" f"- Suite: {suite_path}\n\n" "Use this workflow:\n" - "1. Call `redline_diff` against the candidate outputs and suite when provided.\n" - "2. Treat exit code 1 as a redline finding, not a tool failure.\n" - "3. Lead with blocking regressions and missing outputs.\n" - "4. Then summarize changed cases that need human review.\n" - "5. Do not accept or modify the baseline.\n" + "1. If baseline outputs are provided and no suite exists yet, call `redline_quick_check` with the baseline and candidate logs.\n" + "2. Otherwise call `redline_diff` against the candidate outputs and suite when provided.\n" + "3. Treat exit code 1 as a redline finding, not a tool failure.\n" + "4. Lead with blocking regressions and missing outputs.\n" + "5. Then summarize changed cases that need human review.\n" + "6. Do not accept or modify the baseline.\n" ) @@ -456,16 +461,18 @@ def _build_setup_redline_project_prompt(arguments: dict[str, Any]) -> str: f"- Runner preference: {runner}\n" f"- Judge preference: {judge}\n\n" "Use this workflow:\n" - "1. Call `redline_doctor` first and explain the next setup gap in plain language.\n" - "2. Call `redline_runners` to show adapter choices. If I named a runner, copy that runner; otherwise recommend the safest adapter for my app shape before copying anything.\n" - "3. If I do not already have logs, call `redline_watch_snippets` for the app shape so I can add local capture first.\n" - "4. If prompt files are available, call `redline_prompts` to create or check a prompt-to-suite manifest.\n" - "5. If logs are available, call `redline_suite`, then `redline_validate` and `redline_summary` so I can inspect coverage before trusting the suite.\n" - "6. If summary reports coverage gaps, call `redline_cases` or `redline_case` and recommend a `redline suite add` command I can run; do not mutate the suite yourself.\n" - "7. Call `redline_budget` before recommending CI gating.\n" - "8. Call `redline_judges` only when structural checks cannot cover factual, tone, hallucination, or reasoning risk; copy a judge template only after naming why it is needed.\n" - "9. Finish by re-running `redline_doctor` with strict setup when possible and list the exact next commands I should run.\n" - "10. Do not call baseline mutation commands, do not upload private logs, and do not say green or neutral means semantically safe.\n" + "1. Call `redline_status` first and explain the next setup gap in plain language.\n" + "2. Call `redline_doctor` when status reports setup gaps or when I ask for strict setup validation.\n" + "3. Call `redline_runners` to show adapter choices. If I named a runner, copy that runner; otherwise recommend the safest adapter for my app shape before copying anything.\n" + "4. If I do not already have logs, call `redline_watch_snippets` for the app shape so I can add local capture first.\n" + "5. If prompt files are available, call `redline_prompts` to create or check a prompt-to-suite manifest.\n" + "6. If logs are available but field names are unclear, call `redline_import` with `detect=true`, then use `auto_map=true` with preview before writing normalized logs.\n" + "7. If logs are available, call `redline_suite`, then `redline_validate` and `redline_summary` so I can inspect coverage before trusting the suite.\n" + "8. If summary reports coverage gaps, call `redline_cases` or `redline_case` and recommend a `redline suite add` command I can run; do not mutate the suite yourself.\n" + "9. Call `redline_budget` before recommending CI gating.\n" + "10. Call `redline_judges` only when structural checks cannot cover factual, tone, hallucination, or reasoning risk; copy a judge template only after naming why it is needed.\n" + "11. Finish by re-running `redline_status` and list the exact next commands I should run.\n" + "12. Do not call baseline mutation commands, do not upload private logs, and do not say green or neutral means semantically safe.\n" ) @@ -480,6 +487,21 @@ def _optional_prompt_argument(arguments: dict[str, Any], key: str, fallback: str def _tools() -> list[ToolSpec]: return [ + ToolSpec( + "redline_status", + "Show project readiness, latest local evidence, and the next command to run.", + _schema( + { + "config": _string("Config path to read."), + "reports_dir": _string("Directory containing redline JSON reports."), + "history": _string("History JSONL path."), + "checkpoint": _string("Audit checkpoint JSON path."), + "limit": _integer("Recent reports/history entries to inspect; use 0 for all."), + "json": _boolean("Print machine-readable JSON."), + } + ), + _build_status, + ), ToolSpec( "redline_doctor", "Check local redline setup health and print next steps.", @@ -508,6 +530,28 @@ def _tools() -> list[ToolSpec]: ), _build_suite, ), + ToolSpec( + "redline_quick_check", + "Generate a temporary suite from baseline JSONL, diff candidate JSONL, and write reports plus a guided local app.", + _schema( + { + "baseline_path": _string("Baseline prompt-response JSONL path."), + "candidate_path": _string("Candidate prompt-response JSONL path."), + "input_field": _string("JSONL prompt field path."), + "output_field": _string("JSONL response field path."), + "out_dir": _string("Directory for quick-check suite and reports. Defaults to .redline/quick-check."), + "max_cases": _integer("Maximum representative cases."), + "all_cases": _boolean("Include every unique record instead of sampling representative cases."), + "profile": _string("Diff profile: strict or review."), + "compact": _boolean("Print compact one-line-per-case output."), + "json": _boolean("Print machine-readable JSON."), + "open_app": _boolean("Open the guided local app in the default browser."), + "fail_on": _string("Comma-separated statuses that produce exit code 1; use none for report-only."), + }, + required=("baseline_path", "candidate_path"), + ), + _build_quick_check, + ), ToolSpec( "redline_redact", "Scan or redact common secrets and PII from JSONL prompt-response logs.", @@ -531,6 +575,9 @@ def _tools() -> list[ToolSpec]: { "path": _string("Source JSONL file to normalize."), "out": _string("Redline JSONL output path."), + "detect": _boolean("Suggest prompt and response field mappings without writing output."), + "auto_map": _boolean("Use the best detected prompt/response mapping."), + "preset": _string("Optional import preset such as langfuse, helicone, datadog, dolly, or openai-chat."), "input_field": _string("Source field path containing prompt text."), "output_field": _string("Source field path containing response text."), "context_field": _string("Optional source field path appended to the prompt as Context."), @@ -541,9 +588,12 @@ def _tools() -> list[ToolSpec]: "description": "Source field paths copied into metadata.", }, "limit": _integer("Maximum records to import."), + "preview": _integer("Preview this many mapped rows without writing output."), + "no_redact": _boolean("Write raw values without import redaction."), + "redaction_placeholder": _string("Replacement text for import redaction."), "json": _boolean("Print machine-readable JSON."), }, - required=("path", "out"), + required=("path",), ), _build_import, ), @@ -610,6 +660,12 @@ def _tools() -> list[ToolSpec]: ), _build_judges, ), + ToolSpec( + "redline_import_presets", + "List built-in log import presets and their field mappings.", + _schema({"json": _boolean("Print machine-readable JSON.")}), + _build_import_presets, + ), ToolSpec( "redline_runners", "List or copy runner adapters for replay commands, log import, and SDK capture.", @@ -638,7 +694,7 @@ def _tools() -> list[ToolSpec]: ), ToolSpec( "redline_summary", - "Summarize suite or prompt-manifest provenance, coverage, clusters, owners, requirements, and next steps.", + "Summarize suite or prompt-manifest provenance, coverage, behavior groups, owners, requirements, and next steps.", _schema( { "suite_path": _string("Suite or prompt manifest JSON path. Defaults to config."), @@ -849,6 +905,17 @@ def _tool_by_name(name: str) -> ToolSpec | None: return next((tool for tool in _tools() if tool.name == name), None) +def _build_status(arguments: dict[str, Any]) -> list[str]: + args = ["status"] + _add_option(args, "--config", arguments.get("config")) + _add_option(args, "--reports-dir", arguments.get("reports_dir")) + _add_option(args, "--history", arguments.get("history")) + _add_option(args, "--checkpoint", arguments.get("checkpoint")) + _add_option(args, "--limit", arguments.get("limit")) + _add_flag(args, "--json", arguments.get("json")) + return args + + def _build_doctor(arguments: dict[str, Any]) -> list[str]: args = ["doctor"] _add_option(args, "--config", arguments.get("config")) @@ -869,6 +936,23 @@ def _build_suite(arguments: dict[str, Any]) -> list[str]: return args +def _build_quick_check(arguments: dict[str, Any]) -> list[str]: + args = ["quick-check"] + _add_positional(args, _required_string(arguments, "baseline_path")) + _add_positional(args, _required_string(arguments, "candidate_path")) + _add_option(args, "--input-field", arguments.get("input_field")) + _add_option(args, "--output-field", arguments.get("output_field")) + _add_option(args, "--out-dir", arguments.get("out_dir")) + _add_option(args, "--max-cases", arguments.get("max_cases")) + _add_flag(args, "--all-cases", arguments.get("all_cases")) + _add_option(args, "--profile", arguments.get("profile")) + _add_flag(args, "--compact", arguments.get("compact")) + _add_flag(args, "--json", arguments.get("json")) + _add_flag(args, "--open-app", arguments.get("open_app")) + _add_option(args, "--fail-on", arguments.get("fail_on")) + return args + + def _build_redact(arguments: dict[str, Any]) -> list[str]: args = ["redact"] _add_positional(args, _required_string(arguments, "log_path")) @@ -883,13 +967,22 @@ def _build_redact(arguments: dict[str, Any]) -> list[str]: def _build_import(arguments: dict[str, Any]) -> list[str]: args = ["import"] _add_positional(args, _required_string(arguments, "path")) - _add_option(args, "--out", _required_string(arguments, "out")) + if arguments.get("preview") is None and not arguments.get("detect"): + _add_option(args, "--out", _required_string(arguments, "out")) + else: + _add_option(args, "--out", arguments.get("out")) + _add_flag(args, "--detect", arguments.get("detect")) + _add_flag(args, "--auto-map", arguments.get("auto_map")) + _add_option(args, "--preset", arguments.get("preset")) _add_option(args, "--input-field", arguments.get("input_field")) _add_option(args, "--output-field", arguments.get("output_field")) _add_option(args, "--context-field", arguments.get("context_field")) _add_option(args, "--id-field", arguments.get("id_field")) _add_repeated_options(args, "--metadata-field", arguments.get("metadata_fields")) _add_option(args, "--limit", arguments.get("limit")) + _add_option(args, "--preview", arguments.get("preview")) + _add_flag(args, "--no-redact", arguments.get("no_redact")) + _add_option(args, "--redaction-placeholder", arguments.get("redaction_placeholder")) _add_flag(args, "--json", arguments.get("json")) return args @@ -930,6 +1023,12 @@ def _build_judges(arguments: dict[str, Any]) -> list[str]: return args +def _build_import_presets(arguments: dict[str, Any]) -> list[str]: + args = ["import", "--list-presets"] + _add_flag(args, "--json", arguments.get("json")) + return args + + def _build_runners(arguments: dict[str, Any]) -> list[str]: args = ["runners"] _add_option(args, "--copy", arguments.get("copy")) diff --git a/redline/redact.py b/redline/redact.py index 318706f..d401aad 100644 --- a/redline/redact.py +++ b/redline/redact.py @@ -8,6 +8,7 @@ DEFAULT_PLACEHOLDER = "[REDACTED]" +REDACTION_BOUNDARY = "best-effort pattern matching, not a privacy boundary; review before sharing or committing" SENSITIVE_FIELD_FRAGMENTS = ( "api_key", @@ -135,7 +136,7 @@ def format_redaction_report(report: dict[str, Any]) -> str: [ f"Records: {report['records']}", f"Redactions: {report['redactions']}", - "Boundary: best-effort common secret/PII patterns; review sensitive logs before sharing", + f"Boundary: {REDACTION_BOUNDARY}", ] ) patterns = report.get("patterns") diff --git a/redline/reports.py b/redline/reports.py index 6cb1b48..e32684e 100644 --- a/redline/reports.py +++ b/redline/reports.py @@ -5,6 +5,7 @@ from typing import Any from xml.etree import ElementTree +from .diff import case_impact from .labels import behavior_label @@ -41,6 +42,14 @@ def format_markdown_report(result: dict[str, Any], *, title: str = "redline diff if diagnosis: lines.append(f"**Diagnosis:** {diagnosis}") lines.append("") + methodology = _methodology_label(result.get("methodology")) + if methodology: + lines.append(f"**Methodology:** {methodology}") + lines.append("") + suite_coverage = _suite_coverage_label(result.get("suite_summary")) + if suite_coverage: + lines.append(f"**Suite coverage:** {suite_coverage}") + lines.append("") warnings = _result_warnings(result) if warnings: @@ -132,6 +141,10 @@ def format_markdown_report(result: dict[str, Any], *, title: str = "redline diff if metadata: lines.extend(metadata) lines.append("") + impact = _why_this_matters(item) + if impact: + lines.append(f"Why this matters: {impact}") + lines.append("") for reason in item["reasons"]: lines.append(f"- {reason}") lines.append("") @@ -352,6 +365,8 @@ def format_html_report(result: dict[str, Any], *, title: str = "redline diff") - "", _html_summary(summary), _html_decision(decision), + _html_methodology(result.get("methodology")), + _html_suite_coverage(result.get("suite_summary")), _html_warnings(result), _html_artifacts(result), _html_owner_review(diffs), @@ -482,6 +497,48 @@ def _metadata_lines(item: dict[str, Any]) -> list[str]: return lines +def _methodology_label(value: object) -> str: + if not isinstance(value, dict): + return "" + name = str(value.get("name") or "").strip() + version = str(value.get("version") or "").strip() + if name and version: + return f"{name} ({version})" + return version or name + + +def _suite_coverage_label(value: object) -> str: + if not isinstance(value, dict): + return "" + case_coverage = _percent_value(value.get("case_coverage")) + cluster_coverage = _percent_value(value.get("cluster_coverage")) + cases = value.get("cases") + pairs = value.get("unique_prompt_response_pairs") + clusters = value.get("clusters") + parts = [] + if cases is not None and pairs is not None and case_coverage: + parts.append(f"cases {cases}/{pairs} ({case_coverage})") + if isinstance(clusters, int) and isinstance(value.get("cluster_coverage"), int | float) and cluster_coverage: + covered_clusters = round(clusters * float(value["cluster_coverage"])) + parts.append(f"behavior groups {covered_clusters}/{clusters} ({cluster_coverage})") + return "; ".join(parts) + + +def _percent_value(value: object) -> str: + if not isinstance(value, int | float): + return "" + return f"{value * 100:.1f}%" + + +def _why_this_matters(item: dict[str, Any]) -> str: + impact = str(item.get("impact") or "").strip() + if impact: + return impact + status = str(item.get("status") or "").lower() + reasons = item.get("reasons") + return case_impact(status, reasons if isinstance(reasons, list) else []) + + def _result_warnings(result: dict[str, Any]) -> list[str]: warnings = result.get("warnings") if not isinstance(warnings, list): @@ -518,6 +575,7 @@ def _artifact_label(label: str) -> str: "slack": "Slack", "markdown": "Markdown", "comment": "PR comment", + "app": "App", "dashboard": "Dashboard", "audit_checkpoint": "Audit checkpoint", } @@ -1054,7 +1112,12 @@ def _annotation_message(item: dict[str, Any]) -> str: .changed { background: var(--changed); } .improved { background: var(--improved); } .accepted, .ignored, .neutral { background: var(--neutral); } -.meta, .prompt { color: var(--muted); font-size: 13px; } +.meta, .prompt, .impact { color: var(--muted); font-size: 13px; } +.impact { + border-left: 3px solid var(--changed); + margin: 10px 0; + padding-left: 10px; +} .reasons { margin: 10px 0 14px 20px; padding: 0; } .responses { display: grid; @@ -1136,6 +1199,30 @@ def _html_decision(decision: dict[str, Any]) -> str: return "".join(lines) +def _html_methodology(value: object) -> str: + label = _methodology_label(value) + if not label: + return "" + return ( + '
' + "

Methodology

" + f"

{_h(label)}

" + "
" + ) + + +def _html_suite_coverage(value: object) -> str: + label = _suite_coverage_label(value) + if not label: + return "" + return ( + '
' + "

Suite coverage

" + f"

{_h(label)}

" + "
" + ) + + def _html_warnings(result: dict[str, Any]) -> str: warnings = _result_warnings(result) if not warnings: @@ -1344,6 +1431,9 @@ def _html_case(item: dict[str, Any]) -> str: lines.append(f'
{_h(metadata)}
') if prompt: lines.append(f'

Prompt: {_h(prompt)}

') + impact = _why_this_matters(item) + if impact: + lines.append(f'

Why this matters: {_h(impact)}

') if isinstance(reasons, list) and reasons: lines.append('
    ') for reason in reasons: diff --git a/redline/status.py b/redline/status.py new file mode 100644 index 0000000..da6e260 --- /dev/null +++ b/redline/status.py @@ -0,0 +1,258 @@ +from __future__ import annotations + +from pathlib import Path +from shlex import quote +from typing import Any + +from .dashboard import build_dashboard +from .diff import TRUST_SCOPE +from .doctor import doctor_report + + +def build_project_status( + *, + config_path: str, + config: dict[str, Any], + suite: dict[str, Any] | None, + suite_error: str | None = None, + suite_git_ignored: bool = False, + reports_dir: str | Path = ".redline/reports", + history_path: str | Path = ".redline/history.jsonl", + checkpoint_path: str | Path = ".redline/audit-checkpoint.json", + limit: int = 20, +) -> dict[str, Any]: + doctor = doctor_report( + config_path=config_path, + config=config, + suite=suite, + suite_error=suite_error, + suite_git_ignored=suite_git_ignored, + ) + dashboard = build_dashboard( + reports_dir=reports_dir, + history_path=history_path, + checkpoint_path=checkpoint_path, + limit=limit, + ) + latest = _first_dict(dashboard.get("reports")) + summary = _dict(latest.get("summary")) + review_cases = _list(latest.get("review_cases")) + first_review_case = _first_dict(review_cases) + blocking = _count(summary, "regression") + _count(summary, "missing") + changed = _count(summary, "changed") + suite_path = str(config.get("suite") or "redline-suite.json") + state, message, next_command = _state_and_next( + doctor=doctor, + latest=latest, + summary=summary, + review_cases=review_cases, + blocking=blocking, + changed=changed, + suite_path=suite_path, + reports_dir=str(reports_dir), + history_path=str(history_path), + ) + return { + "version": "0.1", + "state": state, + "message": message, + "next_command": next_command, + "app_command": _app_command( + reports_dir=str(reports_dir), + history_path=str(history_path), + checkpoint_path=str(checkpoint_path), + ), + "config_path": config_path, + "suite_path": suite_path, + "reports_dir": str(reports_dir), + "history_path": str(history_path), + "checkpoint_path": str(checkpoint_path), + "doctor": { + "errors": int(doctor.get("errors") or 0), + "warnings": int(doctor.get("warnings") or 0), + "checks": doctor.get("checks") if isinstance(doctor.get("checks"), list) else [], + "next_steps": doctor.get("next_steps") if isinstance(doctor.get("next_steps"), list) else [], + }, + "reports": len(_list(dashboard.get("reports"))), + "history": len(_list(dashboard.get("history"))), + "benchmarks": len(_list(dashboard.get("benchmarks"))), + "checkpoint": bool(dashboard.get("checkpoint")), + "latest_report": latest, + "first_review_case": _status_review_case(first_review_case, fallback_suite=suite_path), + "summary": summary, + "blocking": blocking, + "changed": changed, + "scope": TRUST_SCOPE, + } + + +def format_project_status(status: dict[str, Any]) -> str: + latest = _dict(status.get("latest_report")) + summary = _dict(status.get("summary")) + doctor = _dict(status.get("doctor")) + checks = _list(doctor.get("checks")) + first_review_case = _dict(status.get("first_review_case")) + lines = [ + "redline status", + "", + f"State: {str(status.get('state') or '').upper()} - {status.get('message')}", + f"Next: {status.get('next_command')}", + f"App: {status.get('app_command')}", + "", + "Evidence", + f"- Config: {_check_line(checks, 'config')}", + f"- Suite: {_check_line(checks, 'suite')}", + f"- Replay: {_check_line(checks, 'replay')}", + f"- Reports: {status.get('reports')} in {status.get('reports_dir')}", + f"- History: {status.get('history')} in {status.get('history_path')}", + f"- Benchmarks: {status.get('benchmarks')}", + f"- Audit checkpoint: {'yes' if status.get('checkpoint') else 'no'}", + f"- Doctor: errors={doctor.get('errors', 0)} warnings={doctor.get('warnings', 0)}", + ] + if latest: + lines.extend( + [ + "", + "Latest report", + f"- File: {latest.get('path')}", + f"- Summary: {_summary_line(summary)}", + f"- Decision: {_decision_line(latest)}", + ] + ) + if first_review_case: + lines.extend( + [ + "", + "First review case", + f"- Case: {first_review_case.get('case_id')} ({first_review_case.get('status')})", + f"- Reason: {first_review_case.get('reason') or 'review report details'}", + f"- Impact: {first_review_case.get('impact') or 'review the case before shipping'}", + f"- Command: {first_review_case.get('command')}", + ] + ) + else: + lines.extend(["", "Latest report", "- No redline report found yet."]) + lines.extend(["", "Trust boundary", f"- {status.get('scope') or TRUST_SCOPE}"]) + next_steps = _list(doctor.get("next_steps")) + if next_steps: + lines.extend(["", "Doctor next steps"]) + lines.extend(f"- {step}" for step in next_steps) + return "\n".join(str(line) for line in lines) + "\n" + + +def _state_and_next( + *, + doctor: dict[str, Any], + latest: dict[str, Any], + summary: dict[str, Any], + review_cases: list[Any], + blocking: int, + changed: int, + suite_path: str, + reports_dir: str, + history_path: str, +) -> tuple[str, str, str]: + checks = {str(check.get("name") or ""): check for check in _list(doctor.get("checks")) if isinstance(check, dict)} + doctor_steps = [str(step) for step in _list(doctor.get("next_steps")) if str(step).strip()] + config = _dict(checks.get("config")) + suite = _dict(checks.get("suite")) + if config.get("status") == "warn": + return "setup", "project is not initialized", _first_step(doctor_steps, "redline init --runner stdio --copy-runner") + if suite.get("status") in {"warn", "error"}: + return "setup", "no usable suite baseline yet", _first_step( + doctor_steps, + f"redline suite path/to/log.jsonl --out {quote(suite_path)}", + ) + if not latest: + return "ready", "suite exists; run the first eval or diff", "redline eval --compact" + first_case = _first_dict(review_cases) + case_command = _case_command(first_case, fallback_suite=suite_path) + if blocking: + return "blocked", f"latest report has {blocking} blocking case(s)", case_command + if changed: + return "review", f"latest report has {changed} changed case(s)", case_command + if int(doctor.get("errors") or 0): + return "setup", "doctor found setup errors", _first_step(doctor_steps, "redline doctor") + if not Path(history_path).exists(): + report_path = str(latest.get("path") or f"{reports_dir}/eval.json") + return "record", "latest report is clear; record it in history", ( + f"redline history {quote(report_path)} --label prompt-v2 --out {quote(history_path)}" + ) + return "ready", "latest report has no blocking structural regressions", ( + f"redline app --reports-dir {quote(reports_dir)} --history {quote(history_path)}" + ) + + +def _case_command(case: dict[str, Any], *, fallback_suite: str) -> str: + case_id = str(case.get("suite_case_id") or case.get("case_id") or "") + suite = str(case.get("suite") or fallback_suite) + return f"redline case {quote(suite)} {quote(case_id)}" + + +def _app_command(*, reports_dir: str, history_path: str, checkpoint_path: str) -> str: + return ( + f"redline app --reports-dir {quote(reports_dir)} " + f"--history {quote(history_path)} --checkpoint {quote(checkpoint_path)}" + ) + + +def _status_review_case(case: dict[str, Any], *, fallback_suite: str) -> dict[str, str]: + if not case: + return {} + command = _case_command(case, fallback_suite=fallback_suite) + return { + "case_id": str(case.get("case_id") or case.get("suite_case_id") or ""), + "status": str(case.get("status") or ""), + "reason": str(case.get("reason") or ""), + "impact": str(case.get("impact") or ""), + "command": command, + } + + +def _summary_line(summary: dict[str, Any]) -> str: + if not summary: + return "none" + keys = ("cases", "regression", "missing", "changed", "neutral", "improved", "accepted", "ignored") + return " ".join(f"{key}={_count(summary, key)}" for key in keys if key in summary) + + +def _decision_line(report: dict[str, Any]) -> str: + decision = _dict(report.get("decision")) + return str(decision.get("recommended_action") or "review report details") + + +def _check_line(checks: list[Any], name: str) -> str: + for item in checks: + if isinstance(item, dict) and item.get("name") == name: + return f"{item.get('status')} - {item.get('message')}" + return "not checked" + + +def _first_step(steps: list[str], fallback: str) -> str: + if not steps: + return fallback + step = steps[0] + if ": redline " in step: + return "redline " + step.split(": redline ", 1)[1] + return step + + +def _first_dict(value: Any) -> dict[str, Any]: + items = _list(value) + first = items[0] if items else {} + return first if isinstance(first, dict) else {} + + +def _dict(value: Any) -> dict[str, Any]: + return value if isinstance(value, dict) else {} + + +def _list(value: Any) -> list[Any]: + return value if isinstance(value, list) else [] + + +def _count(summary: dict[str, Any], key: str) -> int: + try: + return int(summary.get(key) or 0) + except (TypeError, ValueError): + return 0 diff --git a/redline/suite.py b/redline/suite.py index f27851e..c5ec2fa 100644 --- a/redline/suite.py +++ b/redline/suite.py @@ -16,6 +16,24 @@ ClusterInfo = dict[str, Any] SUITE_SCHEMA_URL = "https://raw.githubusercontent.com/gowtham0992/redline/main/redline-suite.schema.json" PROMPT_DIVERSITY_EDGE_TARGET = 8 +SELECTION_METHODOLOGY_VERSION = "behavior-signature-v1" +SELECTION_METHODOLOGY = { + "name": "deterministic behavior-signature grouping", + "version": SELECTION_METHODOLOGY_VERSION, + "trust_scope": "structural checks only; review factual, tone, hallucination, policy, and reasoning risks separately", + "case_selection": [ + "one representative per behavior-signature group", + "high-risk groups first when case budget is tight", + "high-variance edge cases when budget remains", + "prompt-diverse samples from large groups when budget remains", + ], +} + + +def _ratio(part: int, total: int) -> float | None: + if total <= 0: + return None + return part / total def build_suite( @@ -48,7 +66,9 @@ def build_suite( if all_cases else _select_representatives(grouped, max_cases, feature_cache, cluster_infos) ) + selected_clusters = {signatures[id(record)] for record, _ in selected} non_ascii_records = sum(1 for record in unique_records if _has_non_ascii(record.prompt) or _has_non_ascii(record.response)) + stochastic_prompt_groups = _stochastic_prompt_groups(unique_records) cases = [] for index, (record, selection_reason) in enumerate(selected, 1): signature = signatures[id(record)] @@ -99,12 +119,15 @@ def build_suite( "source": str(source), "input_field": input_field, "output_field": output_field, + "methodology": dict(SELECTION_METHODOLOGY), "summary": { "records_seen": len(records), "unique_prompt_response_pairs": len(unique_records), "duplicate_prompt_response_pairs": len(records) - len(unique_records), "clusters": len(grouped), "cases": len(cases), + "case_coverage": _ratio(len(cases), len(unique_records)), + "cluster_coverage": _ratio(len(selected_clusters), len(grouped)), "max_cases": len(unique_records) if all_cases else max_cases, "selection": "all" if all_cases else "representative", "high_risk_clusters": _risk_count(cluster_infos, "high"), @@ -113,6 +136,7 @@ def build_suite( "failure_pattern_clusters": sum(1 for info in cluster_infos.values() if info["failure_patterns"]), "prompt_diversity_cases": sum(1 for _, reason in selected if reason == "prompt_diversity_edge"), "non_ascii_records": non_ascii_records, + "stochastic_prompt_groups": stochastic_prompt_groups, "owned_cases": _owned_case_count(cases), }, "clusters": clusters, @@ -251,6 +275,13 @@ def _unique_prompt_response_records(records: list[LogRecord]) -> list[LogRecord] return unique +def _stochastic_prompt_groups(records: list[LogRecord]) -> int: + responses_by_prompt: dict[str, set[str]] = defaultdict(set) + for record in records: + responses_by_prompt[record.prompt].add(record.response) + return sum(1 for responses in responses_by_prompt.values() if len(responses) > 1) + + def _has_non_ascii(value: str) -> bool: return any(ord(char) > 127 for char in value) diff --git a/redline/summary.py b/redline/summary.py index e20774f..3f0e99e 100644 --- a/redline/summary.py +++ b/redline/summary.py @@ -59,6 +59,8 @@ def suite_summary(suite: dict[str, Any]) -> dict[str, Any]: summary = suite.get("summary", {}) if not isinstance(summary, dict): summary = {} + methodology = suite.get("methodology") + methodology = methodology if isinstance(methodology, dict) else {} cases = suite.get("cases", []) if not isinstance(cases, list): cases = [] @@ -121,6 +123,8 @@ def suite_summary(suite: dict[str, Any]) -> dict[str, Any]: "source": str(suite.get("source") or ""), "created_at": str(suite.get("created_at") or ""), "selection": str(summary.get("selection") or ""), + "methodology_version": str(methodology.get("version") or ""), + "methodology_name": str(methodology.get("name") or ""), "records_seen": records_seen, "unique_prompt_response_pairs": unique_pairs, "duplicate_prompt_response_pairs": int(summary.get("duplicate_prompt_response_pairs", 0)), @@ -150,10 +154,12 @@ def suite_summary(suite: dict[str, Any]) -> dict[str, Any]: "high_variance_clusters": len(high_variance), "failure_pattern_clusters": len(failure_patterns), "non_ascii_records": int(summary.get("non_ascii_records", 0)), + "stochastic_prompt_groups": int(summary.get("stochastic_prompt_groups", 0)), "judgments": dict(sorted(judgment_counts.items())), "requirements": requirements_count, "top_clusters": top_clusters, } + result["suite_readiness"] = _suite_readiness(result) result["next_steps"] = _summary_next_steps(result) return result @@ -240,6 +246,7 @@ def prompt_manifest_summary( "high_variance_clusters", "failure_pattern_clusters", "non_ascii_records", + "stochastic_prompt_groups", "requirements", ): totals[key] += int(child_summary.get(key) or 0) @@ -302,6 +309,7 @@ def prompt_manifest_summary( "high_variance_clusters": totals["high_variance_clusters"], "failure_pattern_clusters": totals["failure_pattern_clusters"], "non_ascii_records": totals["non_ascii_records"], + "stochastic_prompt_groups": totals["stochastic_prompt_groups"], "requirements": totals["requirements"], "prompts": prompt_rows, "missing_suites": missing_suites, @@ -326,13 +334,16 @@ def format_suite_summary(suite: dict[str, Any], *, suite_path: str | None = None f"Source: {summary['source'] or ''}", f"Created: {summary['created_at'] or ''}", f"Selection: {summary['selection'] or ''}", + f"Methodology: {_methodology_label(summary)}", f"Records seen: {summary['records_seen']}", f"Unique pairs: {summary['unique_prompt_response_pairs']}", f"Duplicate pairs: {summary['duplicate_prompt_response_pairs']}", - f"Behavioral clusters: {summary['clusters']}", - f"Cluster coverage: {summary['covered_clusters']}/{summary['clusters']} ({_percent(summary['cluster_coverage'])})", + f"Behavior groups: {summary['clusters']}", + f"Group coverage: {summary['covered_clusters']}/{summary['clusters']} ({_percent(summary['cluster_coverage'])})", f"Representative cases: {summary['cases']}", f"Case coverage: {summary['cases']}/{summary['unique_prompt_response_pairs']} ({_percent(summary['case_coverage'])})", + f"Suite readiness: {_format_readiness(summary['suite_readiness'])}", + "Readiness scope: suite health, not model quality or candidate safety", f"Pinned cases: {summary['pinned_cases']}", f"Owned cases: {summary['owned_cases']}/{summary['cases']}", f"Owner rule coverage: {summary['owner_rule_cases']}/{summary['owned_cases']} ({_percent(summary['owner_rule_coverage'])})", @@ -340,11 +351,12 @@ def format_suite_summary(suite: dict[str, Any], *, suite_path: str | None = None f"Approved baselines: {summary['approved_baselines']}/{summary['accepted_baselines']}", f"Explicit guard coverage: {summary['explicit_guard_cases']}/{summary['cases']} ({_percent(summary['explicit_guard_coverage'])})", f"Max cases: {summary['max_cases']}", - f"High-risk clusters: {summary['high_risk_clusters']}", - f"Medium-risk clusters: {summary['medium_risk_clusters']}", - f"High-variance clusters: {summary['high_variance_clusters']}", - f"Failure-pattern clusters: {summary['failure_pattern_clusters']:>2}", + f"High-risk groups: {summary['high_risk_clusters']}", + f"Medium-risk groups: {summary['medium_risk_clusters']}", + f"High-variance groups: {summary['high_variance_clusters']}", + f"Failure-pattern groups: {summary['failure_pattern_clusters']:>2}", f"Non-ASCII records: {summary['non_ascii_records']}", + f"Stochastic prompts: {summary['stochastic_prompt_groups']}", f"Cases with requirements: {summary['requirements']:>2}", "", ] @@ -365,7 +377,7 @@ def format_suite_summary(suite: dict[str, Any], *, suite_path: str | None = None top_clusters = summary["top_clusters"] if top_clusters: - lines.append("Top clusters:") + lines.append("Top groups:") for cluster in top_clusters: marker = " high-variance" if cluster["high_variance"] else "" flags = cluster["failure_patterns"] @@ -374,6 +386,15 @@ def format_suite_summary(suite: dict[str, Any], *, suite_path: str | None = None lines.append(f" {cluster['size']:>4} {cluster['behavior']}{marker}") lines.append("") + readiness = summary.get("suite_readiness") + if isinstance(readiness, dict): + reasons = readiness.get("reasons") + if isinstance(reasons, list) and reasons: + lines.append("Readiness signals:") + for reason in reasons: + lines.append(f"- {reason}") + lines.append("") + next_steps = summary["next_steps"] if next_steps: lines.append("Next:") @@ -398,8 +419,8 @@ def format_prompt_manifest_summary(report: dict[str, Any]) -> str: f"Invalid suites: {report['invalid_suite_count']}", f"Records seen: {report['records_seen']}", f"Unique pairs: {report['unique_prompt_response_pairs']}", - f"Behavioral clusters: {report['clusters']}", - f"Cluster coverage: {report['covered_clusters']}/{report['clusters']} ({_percent(report['cluster_coverage'])})", + f"Behavior groups: {report['clusters']}", + f"Group coverage: {report['covered_clusters']}/{report['clusters']} ({_percent(report['cluster_coverage'])})", f"Representative cases: {report['cases']}", f"Case coverage: {report['cases']}/{report['unique_prompt_response_pairs']} ({_percent(report['case_coverage'])})", f"Pinned cases: {report['pinned_cases']}", @@ -408,9 +429,10 @@ def format_prompt_manifest_summary(report: dict[str, Any]) -> str: f"Accepted baselines: {report['accepted_baselines']}", f"Approved baselines: {report['approved_baselines']}/{report['accepted_baselines']}", f"Explicit guard coverage: {report['explicit_guard_cases']}/{report['cases']} ({_percent(report['explicit_guard_coverage'])})", - f"High-risk clusters: {report['high_risk_clusters']}", - f"Medium-risk clusters: {report['medium_risk_clusters']}", + f"High-risk groups: {report['high_risk_clusters']}", + f"Medium-risk groups: {report['medium_risk_clusters']}", f"Non-ASCII records: {report['non_ascii_records']}", + f"Stochastic prompts: {report['stochastic_prompt_groups']}", f"Cases with requirements: {report['requirements']:>2}", "", ] @@ -477,6 +499,10 @@ def _summary_next_steps(summary: dict[str, Any], *, suite_path: str | None = Non "Review non-English cases manually or with a domain judge; structural checks still work, " "but entity/refusal heuristics are English-oriented." ) + if int(summary.get("stochastic_prompt_groups") or 0): + steps.append( + "Stabilize repeated-prompt baselines or use review mode; otherwise natural sampling variance can look like regressions." + ) if int(summary["cases"]) and not summary["judgments"]: steps.append("After the first eval, mark expected or ignored changes to train the suite.") if int(summary["cases"]) == 0: @@ -484,6 +510,99 @@ def _summary_next_steps(summary: dict[str, Any], *, suite_path: str | None = Non return steps +def _suite_readiness(summary: dict[str, Any]) -> dict[str, Any]: + cases = int(summary.get("cases") or 0) + if cases == 0: + return { + "score": 0, + "label": "empty", + "reasons": ["no suite cases are available yet"], + } + + score = 0.0 + score += 35.0 * float(summary.get("cluster_coverage") or 0.0) + score += 20.0 * float(summary.get("case_coverage") or 0.0) + score += 20.0 * float(summary.get("explicit_guard_coverage") or 0.0) + owner_coverage = _ratio(int(summary.get("owned_cases") or 0), cases) or 0.0 + score += 10.0 * owner_coverage + if int(summary.get("high_risk_clusters") or 0) == 0 or int(summary.get("requirements") or 0): + score += 10.0 + if int(summary.get("non_ascii_records") or 0) == 0: + score += 5.0 + + rounded = int(round(min(100.0, max(0.0, score)))) + if rounded >= 80: + label = "strong" + elif rounded >= 55: + label = "usable" + else: + label = "needs_work" + + return { + "score": rounded, + "label": label, + "reasons": _readiness_reasons(summary, owner_coverage=owner_coverage), + } + + +def _readiness_reasons(summary: dict[str, Any], *, owner_coverage: float) -> list[str]: + reasons = [] + cluster_coverage = float(summary.get("cluster_coverage") or 0.0) + case_coverage = float(summary.get("case_coverage") or 0.0) + explicit_guard_coverage = float(summary.get("explicit_guard_coverage") or 0.0) + + if cluster_coverage >= 1.0: + reasons.append("all detected behavior-signature groups have at least one selected case") + else: + reasons.append("some behavior-signature groups are not represented in the suite") + + if case_coverage >= 0.8: + reasons.append("case budget covers most unique prompt-response pairs") + else: + reasons.append("case budget is sampling a small share of unique prompt-response pairs") + + if explicit_guard_coverage >= 0.5: + reasons.append("many cases have requirements or recorded judgments") + elif explicit_guard_coverage > 0: + reasons.append("some cases have requirements or recorded judgments") + else: + reasons.append("no cases have explicit requirements or recorded judgments yet") + + if owner_coverage >= 1.0: + reasons.append("all cases have owners") + elif owner_coverage > 0: + reasons.append("some cases still need owners") + else: + reasons.append("no cases have owners yet") + + if int(summary.get("high_risk_clusters") or 0) and int(summary.get("requirements") or 0) == 0: + reasons.append("high-risk groups need explicit requirements or a judge before CI gating") + if int(summary.get("non_ascii_records") or 0): + reasons.append("non-ASCII records need extra review because entity/refusal heuristics are English-oriented") + if int(summary.get("stochastic_prompt_groups") or 0): + reasons.append("repeated prompts with multiple baseline responses need stochastic baseline review") + + return reasons + + +def _format_readiness(value: object) -> str: + if not isinstance(value, dict): + return "" + score = int(value.get("score") or 0) + label = str(value.get("label") or "unknown").replace("_", " ") + return f"{score}/100 ({label})" + + +def _methodology_label(summary: dict[str, Any]) -> str: + name = str(summary.get("methodology_name") or "").strip() + version = str(summary.get("methodology_version") or "").strip() + if name and version: + return f"{name} ({version})" + if version: + return version + return "" + + def _manifest_summary_status(summary: dict[str, Any]) -> str: if int(summary["invalid_suite_count"]): return "invalid" diff --git a/redline/validate.py b/redline/validate.py index 92afea5..582f261 100644 --- a/redline/validate.py +++ b/redline/validate.py @@ -26,10 +26,13 @@ "shape", "length_bucket", ) +_CURRENT_SUITE_VERSION = "0.1" +_SUPPORTED_SUITE_VERSIONS = {_CURRENT_SUITE_VERSION} def validate_suite(suite: dict[str, Any], *, suite_path: str = "") -> dict[str, Any]: items: list[dict[str, str]] = [] + _validate_suite_metadata(items, suite) cases = suite.get("cases") if not isinstance(cases, list): _add(items, "error", "cases", "expected a list of suite cases") @@ -93,7 +96,7 @@ def validate_suite(suite: dict[str, Any], *, suite_path: str = "") -> dict[str, error_count = _count(items, "error") warning_count = _count(items, "warning") return { - "version": "0.1", + "version": _CURRENT_SUITE_VERSION, "suite": suite_path, "valid": error_count == 0, "errors": error_count, @@ -107,6 +110,33 @@ def validate_suite(suite: dict[str, Any], *, suite_path: str = "") -> dict[str, } +def _validate_suite_metadata(items: list[dict[str, str]], suite: dict[str, Any]) -> None: + version = suite.get("version") + if not isinstance(version, str) or not version.strip(): + _add( + items, + "warning", + "version", + "missing suite version; treating as legacy and recommending regeneration with current redline", + ) + elif version.strip() not in _SUPPORTED_SUITE_VERSIONS: + supported = ", ".join(sorted(_SUPPORTED_SUITE_VERSIONS)) + _add( + items, + "error", + "version", + f"unsupported suite version {version.strip()}; supported versions: {supported}", + ) + + schema = suite.get("$schema") + if not isinstance(schema, str) or not schema.strip(): + _add(items, "warning", "$schema", "missing suite JSON schema reference") + + methodology = suite.get("methodology") + if not isinstance(methodology, dict): + _add(items, "warning", "methodology", "missing suite selection methodology metadata") + + def validate_prompt_manifest( manifest: dict[str, Any], *, @@ -281,6 +311,8 @@ def _next_steps(items: list[dict[str, str]], *, suite_path: str, source: object) steps.append(f"Use a supported judgment status, then rerun: redline validate {suite_arg}") if any(item["level"] == "error" and item["path"] == "cases" for item in items): steps.append(f"Fix suite JSON shape, then rerun: redline validate {suite_arg}") + if any(item["level"] == "error" and item["path"] == "version" for item in items): + steps.append(f"Regenerate or migrate unsupported suite schema: redline suite {source_arg} --out {suite_arg}") if any( item["level"] == "warning" and "duplicate prompt-response pair" in item["message"] @@ -295,6 +327,7 @@ def _next_steps(items: list[dict[str, str]], *, suite_path: str, source: object) item["path"].endswith(".content_hash") or ".features." in item["path"] or item["path"] == "summary" + or item["path"] in {"version", "$schema", "methodology"} ) for item in items ): diff --git a/scripts/demo_gif.sh b/scripts/demo_gif.sh index 6a55643..cabd778 100755 --- a/scripts/demo_gif.sh +++ b/scripts/demo_gif.sh @@ -27,7 +27,7 @@ Sleep 5s Type "python -m redline history ${run_dir}/demo/reports/public_diff.json --label public-demo --out ${run_dir}/history.jsonl --out-md ${run_dir}/history.md" Enter Sleep 2s -Type "python -m redline dashboard --reports-dir ${run_dir}/demo/reports --history ${run_dir}/history.jsonl --out ${run_dir}/dashboard.html" +Type "python -m redline app --reports-dir ${run_dir}/demo/reports --history ${run_dir}/history.jsonl --no-open --out ${run_dir}/app.html" Enter Sleep 2s TAPE diff --git a/scripts/demo_terminal.sh b/scripts/demo_terminal.sh index 23a566b..40b90d6 100755 --- a/scripts/demo_terminal.sh +++ b/scripts/demo_terminal.sh @@ -23,10 +23,11 @@ python -m redline history "$demo_dir/reports/public_diff.json" \ --out "$history_path" \ --out-md "$history_markdown_path" -printf '\n$ redline dashboard --reports-dir %s --history %s --out %s\n' "$demo_dir/reports" "$history_path" "$dashboard_path" -python -m redline dashboard \ +printf '\n$ redline app --reports-dir %s --history %s --no-open --out %s\n' "$demo_dir/reports" "$history_path" "$dashboard_path" +python -m redline app \ --reports-dir "$demo_dir/reports" \ --history "$history_path" \ + --no-open \ --out "$dashboard_path" printf '\nArtifacts:\n' diff --git a/scripts/release_check.sh b/scripts/release_check.sh index dad0da9..92ac13a 100755 --- a/scripts/release_check.sh +++ b/scripts/release_check.sh @@ -121,12 +121,13 @@ printf '\n$ %s -m pip install --no-deps --force-reinstall %s\n' "$venv_dir/bin/p --fail-on none test -s compare.html - printf '\n$ redline dashboard --reports-dir .redline/demo/reports --history history.jsonl --out dashboard.html\n' - "$venv_dir/bin/redline" dashboard \ + printf '\n$ redline app --reports-dir .redline/demo/reports --history history.jsonl --no-open --out app.html\n' + "$venv_dir/bin/redline" app \ --reports-dir .redline/demo/reports \ --history history.jsonl \ - --out dashboard.html - test -s dashboard.html + --no-open \ + --out app.html + test -s app.html printf '\n$ redline suite .redline/demo/baseline.jsonl --out all-suite.json --all-cases\n' "$venv_dir/bin/redline" suite .redline/demo/baseline.jsonl --out all-suite.json --all-cases diff --git a/server.json b/server.json index 711721e..042d862 100644 --- a/server.json +++ b/server.json @@ -8,19 +8,19 @@ "url": "https://github.com/gowtham0992/redline", "source": "github" }, - "version": "0.2.1", + "version": "0.3.0", "packages": [ { "registryType": "pypi", "registryBaseUrl": "https://pypi.org", "identifier": "redline-ai", - "version": "0.2.1", + "version": "0.3.0", "runtimeHint": "uvx", "runtimeArguments": [ { "type": "named", "name": "--from", - "value": "redline-ai==0.2.1", + "value": "redline-ai==0.3.0", "description": "Install the redline-ai package version that matches this registry entry." }, { diff --git a/site/assets/redline-dashboard-proof.png b/site/assets/redline-dashboard-proof.png index 0a1b48b..0f8e509 100644 Binary files a/site/assets/redline-dashboard-proof.png and b/site/assets/redline-dashboard-proof.png differ diff --git a/site/assets/redline-preview.png b/site/assets/redline-preview.png index cac96bd..0f8e509 100644 Binary files a/site/assets/redline-preview.png and b/site/assets/redline-preview.png differ diff --git a/site/assets/redline-report-proof.png b/site/assets/redline-report-proof.png index e687dc2..3e6a8ca 100644 Binary files a/site/assets/redline-report-proof.png and b/site/assets/redline-report-proof.png differ diff --git a/site/index.html b/site/index.html index fb917d9..11ed861 100644 --- a/site/index.html +++ b/site/index.html @@ -3,13 +3,13 @@ - redline - Local-first prompt regression diffs + redline - Catch prompt regressions before they ship - + @@ -29,68 +29,51 @@ MCP Certify Docs - GitHub + Star on GitHub
    -
    +
    -

    Automatic eval suites from logs you already have

    -

    redline

    +

    Local-first prompt regression diffs

    +

    Catch prompt regressions before they ship.

    - Local-first prompt regression diffs for AI engineers. Point redline - at real prompt-response logs, change your prompt, then catch broken - JSON, missing URLs, lost tables, refusals, and other structural - regressions before they ship. + Point redline at the prompt-response logs you already have, change + your prompt, then catch broken JSON, missing URLs, lost tables, and + silent refusals before your users do.

    -
    - 0manual test cases to start - 0cloud calls by default - 1command to see the proof -
    -
    - $ - python -m pip install redline-ai
    redline demo --public --compact
    -
    -
    - CI status - PyPI version - GitHub stars +
    +
    + $ python -m pip install redline-ai +
    +
    + $ redline demo --public --compact +
    +
    + 100public rows dogfooded + 0runtime dependencies + 1local app workflow +
    +
    -
    - redline terminal and dashboard preview showing four prompt regressions caught -
    -
    -
    -
    -

    First-run proof

    -

    One command, ten regressions, no account setup.

    -

    - The public demo is synthetic and safe to share, but it behaves like - a real prompt change: the candidate gets shorter and silently drops - required structure, dates, owners, URLs, and refusal behavior. -

    -
    -
    +
    - redline demo + redline - demo
    $ redline demo --public --compact
     redline public dogfood: cases=10 regression=10 changed=0 missing=0 neutral=0
    @@ -102,53 +85,143 @@ 

    One command, ten regressions, no account setup.

    REGRESSION case_006: candidate newly refuses REGRESSION case_010: candidate lost bullet list structure -Next: inspect the HTML report, mark intentional changes, accept reviewed outputs.
    +10 blocking regressions - fix before you ship. + +$ redline diff dolly-suite-100.json dolly-candidate-100.jsonl --compact +redline diff: cases=100 regression=51 changed=27 missing=0 neutral=22 +Diagnosis: candidate got shorter, lost required structure, dropped concrete details, returned empty outputs, and changed content substantially.
    +
    + +
    +
    +

    First-run proof

    +

    One command for the demo. One public dataset for proof.

    +

    + The public demo is synthetic and safe to share, but it behaves like + a real prompt change: the candidate gets shorter and silently drops + required structure, dates, owners, URLs, and refusal behavior. The + larger internet dogfood run imports 100 rows from Databricks Dolly + 15k and pushes them through the same suite, diff, history, + benchmark, app, and dashboard surfaces. +

    +
    +
    +
    +
    + case_001 + JSON validity + REGRESSION +
    +
    +
    +

    baseline

    +
    {
    +  "owner": "ana@acme.dev",
    +  "due": "2025-07-01",
    +  "links": ["acme.dev/x"],
    +  "status": "open"
    +}
    +
    +
    +

    candidate

    +
    owner: ana, due: soon,
    +links: omitted,
    +status: open
    +
    +not valid JSON
    +
    +
    +

    reason: candidate lost valid JSON format; required keys are no longer parseable.

    +
    + +
    +
    +
    + 100 public prompt-response rows +

    Imported from Databricks Dolly 15k with redline's JSONL importer and kept as local-only dogfood evidence.

    +
    +
    + 20 behavior groups covered +

    The generated suite covered 100/100 cases and 20/20 deterministic behavior groups.

    +
    +
    + 51 blocking regressions found +

    The report diagnosed shorter answers, lost structure, dropped concrete details, empty outputs, and substantial content drift.

    +
    +
    + 0 dashboard warnings +

    The local app loaded 1 report, 1 benchmark, and 1 history entry with no sidecar-artifact noise.

    +
    +
    +
    + Animated redline product demo showing a prompt regression report +
    redline dashboard showing reports, benchmark evidence, history, and ship readiness -
    Local dashboard with ship readiness, history, benchmark evidence, and report links.
    +
    Local app from the 100-row Dolly dogfood run: reports, benchmark evidence, history, and ship readiness.
    redline HTML report showing concrete regression reasons and side-by-side baseline and candidate outputs -
    HTML report with concrete reasons and side-by-side baseline versus candidate output.
    +
    HTML report from the same run with the diagnosis, coverage, methodology, and concrete reasons.
    -

    Why it exists

    +

    Product promise

    You already have the eval data. redline turns it into a gate.

    - Zero manual test writing -

    Build suites from JSONL prompt-response logs instead of inventing cases by hand.

    + Zero manual cases to start +

    Build suites from JSONL prompt-response logs instead of inventing eval cases by hand.

    - Deterministic by default -

    No cloud account is required for the core loop; CI-friendly checks run locally, cost nothing, and do not depend on hosted judges.

    + No cloud calls by default +

    The core checks run locally with zero runtime dependencies, no API keys, and reproducible CI output.

    - Reports people can review -

    Write JSON, Markdown, JUnit, and self-contained HTML reports for local review, CI artifacts, and GitHub summaries.

    + Clear failure reasons +

    Reports name the broken behavior: lost JSON, missing keys, dropped URLs, new refusals, empty output, or structure drift.

    Review loop included -

    Mark intentional changes, accept new baselines, and keep the suite moving with your prompt.

    +

    Mark intentional changes, accept reviewed baselines, and keep the suite evolving with your prompt.

    @@ -163,6 +236,7 @@

    From logs to a shipping gate in four commands.

    01

    Collect behavior

    +

    Watch an app, import JSONL, or adapt exports from your logging stack.

    redline watch --log logs/prompts.jsonl
    @@ -170,13 +244,15 @@

    Collect behavior

    02

    Generate the suite

    - redline suite .redline/logs/prompts.jsonl --out redline-suite.json +

    Select representative behavior from real prompt-output data.

    + redline suite logs/prompts.jsonl --out redline-suite.json
  • 03
    -

    Evaluate a prompt change

    +

    Evaluate a change

    +

    Replay the suite against a new prompt file or candidate log.

    redline eval --prompt prompts/v2.txt
  • @@ -184,6 +260,7 @@

    Evaluate a prompt change

    04

    Review and accept

    +

    Pin requirements, mark expected changes, and promote reviewed outputs.

    redline mark ... redline accept ...
    @@ -250,8 +327,8 @@

    Replay any app

    Local dashboard

    -

    Publish self-contained HTML reports as CI artifacts or browse them locally.

    - redline dashboard --reports-dir .redline/demo/reports --open +

    Publish self-contained HTML reports as CI artifacts or browse the ship-readiness dashboard locally.

    + redline app --reports-dir .redline/demo/reports
    @@ -314,7 +391,7 @@

    Not just a demo. A certified local product loop.

    03 - Trend and coverage pressure + Trend pressure

    History shows whether blocking regressions are getting better or worse; summary and doctor surface thin coverage and semantic gaps.

    redline history --fail-on worse
    @@ -329,12 +406,9 @@

    Not just a demo. A certified local product loop.

    -

    Public alpha ready path

    +

    Public alpha path

    Clone it, run the proof, then point it at one real prompt log.

    -

    - The fastest way to understand redline is to make it catch one - regression in your own AI workflow. -

    +

    The fastest way to understand redline is to make it catch one regression in your own AI workflow.

    Read the quickstart
    @@ -346,8 +420,8 @@

    Review the checks before you trust the gate.

    diff --git a/site/styles.css b/site/styles.css index 46a9f21..2bb528b 100644 --- a/site/styles.css +++ b/site/styles.css @@ -1,19 +1,20 @@ :root { - --ink: #111827; - --muted: #5f6c72; - --paper: #f8fafc; - --surface: #ffffff; - --line: #d9e0e8; - --teal: #0f766e; - --teal-dark: #115e59; - --red: #dc2626; - --amber: #a16207; - --blue: #2563eb; - --soft-teal: #e6f4f1; - --soft-red: #fef2f2; - --soft-amber: #fff7ed; - --soft-blue: #eff6ff; - --shadow: 0 18px 50px rgb(16 24 32 / 12%); + --bg: #09090b; + --bg2: #111116; + --bg3: #171820; + --border: rgb(255 255 255 / 11%); + --border-strong: rgb(255 255 255 / 20%); + --fg: #f5f5f7; + --muted: #a1a1aa; + --faint: #71717a; + --red: #ff4d52; + --red-soft: rgb(255 77 82 / 11%); + --green: #22c55e; + --green-soft: rgb(34 197 94 / 10%); + --amber: #f59e0b; + --amber-soft: rgb(245 158 11 / 11%); + --panel-shadow: 0 30px 80px -42px rgb(0 0 0 / 80%); + --mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; } * { @@ -26,8 +27,11 @@ html { body { margin: 0; - background: var(--paper); - color: var(--ink); + background: + radial-gradient(circle at 18% 2%, rgb(255 77 82 / 12%), transparent 28rem), + radial-gradient(circle at 82% 0%, rgb(34 197 94 / 8%), transparent 22rem), + var(--bg); + color: var(--fg); font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; @@ -39,26 +43,31 @@ a { text-decoration: none; } +img { + max-width: 100%; +} + .site-header { position: sticky; top: 0; - z-index: 10; + z-index: 20; display: flex; align-items: center; justify-content: space-between; gap: 24px; - min-height: 72px; - padding: 0 48px; - border-bottom: 1px solid rgb(16 24 32 / 10%); - background: rgb(248 250 252 / 92%); - backdrop-filter: blur(16px); + min-height: 68px; + padding: 0 max(28px, calc((100vw - 1180px) / 2 + 28px)); + border-bottom: 1px solid var(--border); + background: rgb(9 9 11 / 88%); + backdrop-filter: blur(18px); } .brand { display: inline-flex; align-items: center; gap: 10px; - font-size: 22px; + color: var(--fg); + font-size: 18px; font-weight: 760; } @@ -71,29 +80,43 @@ a { nav { display: flex; align-items: center; - gap: 22px; + gap: 20px; color: var(--muted); - font-size: 15px; - font-weight: 620; + font-size: 14px; + font-weight: 600; } nav a:hover, nav a:focus-visible { - color: var(--ink); + color: var(--fg); +} + +.nav-cta { + padding: 8px 14px; + border-radius: 8px; + background: var(--red); + color: #ffffff; +} + +.nav-cta:hover, +.nav-cta:focus-visible { + color: #ffffff; + filter: brightness(1.06); } .band { - padding: 84px 48px; + max-width: 1180px; + margin: 0 auto; + padding: 58px 28px; } .hero { display: grid; - grid-template-columns: minmax(320px, 0.9fr) minmax(460px, 1.1fr); - gap: 54px; + grid-template-columns: minmax(0, 1.05fr) minmax(420px, 1fr); + gap: 56px; align-items: center; - min-height: calc(100vh - 72px); - padding-top: 64px; - padding-bottom: 64px; + padding-top: 84px; + padding-bottom: 72px; } .hero-copy, @@ -104,12 +127,31 @@ nav a:focus-visible { .eyebrow { margin: 0 0 14px; - color: var(--teal-dark); - font-size: 13px; - font-weight: 800; + color: var(--red); + font-family: var(--mono); + font-size: 12px; + font-weight: 760; + letter-spacing: 0; text-transform: uppercase; } +.eyebrow.pill { + display: inline-flex; + align-items: center; + gap: 9px; + padding: 6px 13px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--bg2); +} + +.eyebrow.pill span { + width: 6px; + height: 6px; + border-radius: 999px; + background: var(--red); +} + h1, h2, h3, @@ -118,103 +160,83 @@ p { } h1 { - margin-bottom: 18px; - font-size: 76px; - line-height: 0.96; + margin: 24px 0 0; + max-width: 620px; + color: var(--fg); + font-size: 58px; + line-height: 1.02; + font-weight: 780; + letter-spacing: 0; +} + +h1 span { + color: var(--red); } h2 { - margin-bottom: 20px; - font-size: 44px; - line-height: 1.05; + margin: 14px 0 0; + max-width: 760px; + color: var(--fg); + font-size: 38px; + line-height: 1.08; + font-weight: 740; + letter-spacing: 0; } h3 { margin-bottom: 10px; - font-size: 21px; + color: var(--fg); + font-size: 17px; line-height: 1.2; } .lede { - max-width: 680px; - color: #324047; - font-size: 22px; + max-width: 530px; + margin: 22px 0 0; + color: var(--muted); + font-size: 18px; } -.hero-metrics { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 12px; - max-width: 680px; +.install-card { + max-width: 480px; margin-top: 28px; -} - -.hero-metrics span { - padding: 14px; - border: 1px solid var(--line); + overflow: hidden; + border: 1px solid var(--border); border-radius: 8px; - background: var(--surface); + background: var(--bg2); } -.hero-metrics strong, -.hero-metrics small { - display: block; +.install-card div { + padding: 11px 14px; } -.hero-metrics strong { - color: var(--red); - font-size: 30px; - line-height: 1; +.install-card div:first-child { + border-bottom: 1px solid var(--border); } -.hero-metrics small { - margin-top: 8px; - color: var(--muted); +code { + font-family: var(--mono); font-size: 13px; - font-weight: 700; -} - -.command { - display: flex; - align-items: flex-start; - gap: 12px; - max-width: 760px; - margin: 32px 0 24px; - padding: 18px 20px; - overflow-wrap: anywhere; - border: 1px solid #263241; - border-radius: 8px; - background: var(--ink); - color: #f8fafc; - box-shadow: var(--shadow); } -code { - font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; - font-size: 15px; +.install-card code, +.steps code, +.split code, +.assistant-grid code, +.certification-grid code { + color: var(--muted); } -.command span { - color: #a7f3d0; - font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; +code span, +.terminal-proof .prompt { + color: var(--red); } .actions { display: flex; flex-wrap: wrap; gap: 12px; -} - -.badges { - display: flex; - flex-wrap: wrap; - gap: 8px; - margin: -8px 0 24px; -} - -.badges img { - display: block; - height: 20px; + margin-top: 26px; } .button { @@ -222,75 +244,112 @@ code { align-items: center; justify-content: center; min-height: 46px; - padding: 0 18px; - border: 1px solid var(--ink); + padding: 0 20px; + border: 1px solid var(--border); border-radius: 8px; - font-weight: 760; + font-size: 15px; + font-weight: 700; } .button.primary { - background: var(--ink); + border-color: var(--red); + background: var(--red); color: #ffffff; } .button.secondary { background: transparent; - color: var(--ink); + color: var(--fg); } .button:hover, .button:focus-visible { - transform: translateY(-1px); + border-color: var(--red); } -.hero-visual { - margin: 0; +.hero-metrics { + display: flex; + flex-wrap: wrap; + gap: 26px; + margin-top: 34px; } -.hero-visual img { +.hero-metrics strong, +.hero-metrics small { display: block; - width: 100%; - height: auto; - border: 1px solid var(--line); - border-radius: 8px; - box-shadow: var(--shadow); } -.proof, -.quickstart, -.assistant, -.trust, -.certification, -.launch { - background: var(--surface); - border-top: 1px solid var(--line); - border-bottom: 1px solid var(--line); +.hero-metrics strong { + color: var(--fg); + font-size: 26px; + line-height: 1; } -.section-heading p { - max-width: 760px; +.hero-metrics span:last-child strong { + color: var(--red); +} + +.hero-metrics small { + margin-top: 4px; + color: var(--faint); + font-family: var(--mono); + font-size: 12px; +} + +.badges { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 28px; +} + +.badges a { + display: inline-flex; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 999px; color: var(--muted); - font-size: 18px; + font-family: var(--mono); + font-size: 11px; +} + +.badges strong, +.badges span { + padding: 4px 8px; +} + +.badges strong { + background: var(--bg3); + color: var(--fg); + font-weight: 700; +} + +.badges span { + background: var(--green-soft); + color: var(--green); } .terminal-proof { - max-width: 1080px; overflow: hidden; - border: 1px solid #1f2937; + border: 1px solid var(--border); border-radius: 8px; - background: #0b1220; - box-shadow: var(--shadow); + background: var(--bg2); + box-shadow: var(--panel-shadow); +} + +.hero-terminal { + min-height: 440px; } .terminal-top { display: flex; align-items: center; gap: 8px; - height: 48px; - padding: 0 18px; - border-bottom: 1px solid rgb(255 255 255 / 8%); - background: #111827; - color: #cbd5e1; + height: 46px; + padding: 0 15px; + border-bottom: 1px solid var(--border); + background: var(--bg3); + color: var(--faint); } .terminal-top span { @@ -300,58 +359,268 @@ code { } .terminal-top span:nth-child(1) { - background: #ef4444; + background: #ff5f57; } .terminal-top span:nth-child(2) { - background: #f59e0b; + background: #febc2e; } .terminal-top span:nth-child(3) { - background: #22c55e; + background: #28c840; } .terminal-top strong { - margin-left: auto; - font-size: 14px; - font-weight: 700; + margin-left: 8px; + color: var(--faint); + font-family: var(--mono); + font-size: 12px; + font-weight: 500; } .terminal-proof pre { + min-height: 368px; margin: 0; - padding: 22px; + padding: 18px; overflow-x: auto; - color: #e5e7eb; + color: #e8edf7; + font-family: var(--mono); + font-size: 13px; + line-height: 1.7; white-space: pre-wrap; } -.artifact-proof { +.section-heading p { + max-width: 660px; + margin-top: 16px; + color: var(--muted); + font-size: 17px; +} + +.proof-showcase { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 18px; - max-width: 1180px; - margin-top: 28px; + grid-template-columns: minmax(0, 1.55fr) minmax(300px, 1fr); + gap: 22px; + margin-top: 36px; } -.artifact-proof figure { +.diff-card, +.readiness-card, +.dogfood-proof article, +.proof-grid article, +.trust-layout > div, +.certification-grid article, +.assistant-grid article, +.split > div, +.steps li { + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg2); +} + +.diff-card { + overflow: hidden; +} + +.diff-card-header { + display: flex; + align-items: center; + gap: 10px; + padding: 13px 16px; + border-bottom: 1px solid var(--border); +} + +.diff-card-header span, +.diff-card-header small, +.diff-card-header strong { + font-family: var(--mono); +} + +.diff-card-header span { + font-size: 13px; +} + +.diff-card-header small { + color: var(--faint); + font-size: 12px; +} + +.diff-card-header strong { + margin-left: auto; + padding: 4px 10px; + border-radius: 999px; + background: var(--red-soft); + color: var(--red); + font-size: 11px; +} + +.diff-columns { + display: grid; + grid-template-columns: 1fr 1fr; +} + +.diff-columns > div { + padding: 14px 16px; +} + +.diff-columns > div:first-child { + border-right: 1px solid var(--border); +} + +.diff-columns > div:last-child { + background: var(--red-soft); +} + +.diff-columns p { + margin: 0 0 10px; + color: var(--green); + font-family: var(--mono); + font-size: 11px; +} + +.diff-columns > div:last-child p { + color: var(--red); +} + +.diff-columns pre { + margin: 0; + color: var(--muted); + font-family: var(--mono); + font-size: 12px; + line-height: 1.65; + white-space: pre-wrap; +} + +.reason { margin: 0; + padding: 12px 16px; + border-top: 1px solid var(--border); + color: var(--muted); + font-family: var(--mono); + font-size: 12px; +} + +.reason strong { + color: var(--red); +} + +.readiness-card { + display: flex; + flex-direction: column; + padding: 20px; +} + +.readiness-card h3 { + margin: 2px 0 4px; + color: var(--red); + font-size: 30px; +} + +.readiness-card p, +.readiness-card small { + color: var(--muted); +} + +.readiness-card p strong { + color: var(--fg); +} + +.dogfood-proof { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 14px; + margin-top: 22px; +} + +.dogfood-proof article { + padding: 16px; +} + +.dogfood-proof strong { + display: block; + color: var(--fg); + font-size: 15px; +} + +.dogfood-proof p { + margin: 8px 0 0; + color: var(--muted); + font-size: 14px; +} + +.readiness-stats { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + margin: 18px 0; +} + +.readiness-stats span { + padding: 11px; + border: 1px solid var(--border); + border-radius: 8px; +} + +.readiness-stats strong, +.readiness-stats small { + display: block; +} + +.readiness-stats strong { + color: var(--fg); + font-size: 22px; +} + +.readiness-stats span:first-child strong { + color: var(--red); +} + +.readiness-stats small { + color: var(--faint); + font-family: var(--mono); + font-size: 11px; +} + +.demo-film, +.artifact-proof figure { overflow: hidden; - border: 1px solid var(--line); + border: 1px solid var(--border); border-radius: 8px; background: #ffffff; - box-shadow: 0 12px 28px rgb(16 24 32 / 10%); + box-shadow: var(--panel-shadow); +} + +.demo-film { + max-width: 1120px; + margin: 22px 0 0; } +.demo-film img, .artifact-proof img { display: block; width: 100%; height: auto; - border-bottom: 1px solid var(--line); +} + +.artifact-proof { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 18px; + max-width: 1120px; + margin-top: 22px; +} + +.artifact-proof figure { + margin: 0; +} + +.artifact-proof img { + border-bottom: 1px solid #d8dee8; } .artifact-proof figcaption { padding: 14px 16px 16px; - color: var(--muted); + color: #4c5563; font-size: 14px; } @@ -365,12 +634,19 @@ code { max-width: 1180px; } -.proof-grid { +.proof-grid, +.steps { grid-template-columns: repeat(4, 1fr); } +.proof-grid { + display: grid; + margin-top: 36px; +} + .trust-layout { grid-template-columns: repeat(3, minmax(0, 1fr)); + margin-top: 36px; } .certification-grid { @@ -385,170 +661,152 @@ code { .split { grid-template-columns: repeat(2, minmax(0, 1fr)); + margin-top: 24px; } .proof-grid article, .trust-layout > div, .certification-grid article, .assistant-grid article, -.split > div { - min-height: 180px; - padding: 24px; - border: 1px solid var(--line); - border-radius: 8px; - background: #fbfcfd; -} - -.proof-grid strong { - display: block; - margin-bottom: 12px; - font-size: 20px; -} - -.proof-grid p, -.trust-layout p, -.certification-grid p, -.split p, -.launch p { - color: var(--muted); -} - -.certification-grid article { - display: flex; - flex-direction: column; - min-height: 300px; +.split > div, +.steps li { + min-height: 190px; + padding: 22px; } -.certification-grid span { +.proof-grid article::before { display: inline-flex; align-items: center; justify-content: center; - width: 46px; + width: 34px; height: 34px; - margin-bottom: 22px; + margin-bottom: 16px; border-radius: 8px; - background: var(--soft-red); + background: var(--red-soft); color: var(--red); - font-weight: 800; + content: "+"; + font-family: var(--mono); } -.certification-grid strong { - display: block; - margin-bottom: 12px; - font-size: 20px; +.proof-grid article:nth-child(2)::before { + content: "="; } -.assistant-grid strong { - display: block; - margin-bottom: 12px; - font-size: 20px; +.proof-grid article:nth-child(3)::before { + content: "#"; } -.certification-grid code { +.proof-grid article:nth-child(4)::before { + content: "~"; +} + +.proof-grid strong, +.certification-grid strong, +.assistant-grid strong { display: block; - margin-top: auto; - padding: 12px; - overflow-wrap: anywhere; - border-radius: 8px; - background: var(--ink); - color: #f8fafc; + margin-bottom: 12px; + color: var(--fg); + font-size: 17px; } -.workflow { - background: var(--paper); +.proof-grid p, +.trust-layout p, +.certification-grid p, +.split p, +.assistant-grid p, +.steps p, +.launch p { + color: var(--muted); + font-size: 14px; } .steps { display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 16px; - max-width: 1280px; + gap: 18px; + max-width: 1180px; margin: 36px 0 0; padding: 0; list-style: none; } -.steps li { +.steps li, +.certification-grid article, +.assistant-grid article { display: flex; flex-direction: column; - min-height: 240px; - padding: 24px; - border: 1px solid rgb(16 24 32 / 12%); - border-radius: 8px; - background: #ffffff; } -.steps span { - display: inline-flex; - align-items: center; - justify-content: center; - width: 46px; - height: 34px; - margin-bottom: 36px; - border-radius: 8px; - background: var(--soft-teal); - color: var(--teal-dark); - font-weight: 800; +.steps span, +.certification-grid span { + color: var(--red); + font-family: var(--mono); + font-size: 13px; + font-weight: 700; } .steps code, .split code, -.assistant-grid code { +.assistant-grid code, +.certification-grid code { display: block; - margin-top: 18px; - padding: 12px; + margin-top: auto; + padding: 10px 11px; overflow-wrap: anywhere; + border: 1px solid var(--border); border-radius: 8px; - background: var(--ink); - color: #f8fafc; + background: var(--bg); + line-height: 1.5; +} + +.steps code + code { + margin-top: 8px; +} + +.trust-layout > div { + border-left-width: 2px; } .trust-layout > div:first-child { - border-color: #b7d9d3; - background: var(--soft-teal); + border-left-color: var(--green); +} + +.trust-layout > div:nth-child(2) { + border-left-color: var(--red); } .trust-layout > div:last-child { - border-color: #f0d4b2; - background: var(--soft-amber); + border-left-color: var(--faint); } -.trust-layout > div:nth-child(3) { - border-color: #bfdbfe; - background: var(--soft-blue); +.trust-layout > div:first-child h3 { + color: var(--green); +} + +.trust-layout > div:nth-child(2) h3 { + color: var(--red); } .adapter-list { display: flex; flex-wrap: wrap; - gap: 10px; + gap: 9px; max-width: 1080px; - margin: 28px 0 36px; + margin: 26px 0 24px; } .adapter-list span { - padding: 8px 12px; - border: 1px solid var(--line); + padding: 7px 12px; + border: 1px solid var(--border); border-radius: 8px; - background: #ffffff; - color: #324047; - font-size: 14px; - font-weight: 720; -} - -.assistant-grid article { - display: flex; - flex-direction: column; - min-height: 240px; - background: #fbfcfd; -} - -.assistant-grid p { + background: var(--bg2); color: var(--muted); + font-family: var(--mono); + font-size: 12px; } -.assistant-grid code { - margin-top: auto; +.assistant-grid article:nth-child(2) { + border-color: var(--red); + background: var(--red-soft); } .assistant-links { @@ -558,50 +816,58 @@ code { margin-top: 24px; } -.assistant-links a { +.assistant-links a, +.resource-links a { display: inline-flex; align-items: center; min-height: 44px; padding: 0 16px; - border: 1px solid var(--ink); + border: 1px solid var(--border); border-radius: 8px; - color: var(--ink); - font-weight: 760; + color: var(--fg); + font-weight: 700; } .assistant-links a:hover, -.assistant-links a:focus-visible { - border-color: var(--teal); - color: var(--teal-dark); +.assistant-links a:focus-visible, +.resource-links a:hover, +.resource-links a:focus-visible { + border-color: var(--red); } .launch { - display: flex; - align-items: center; - justify-content: space-between; - gap: 32px; + max-width: 1124px; + margin-top: 40px; + border: 1px solid var(--border); + border-radius: 8px; + background: linear-gradient(180deg, var(--red-soft), transparent); + text-align: center; } -.launch h2 { - max-width: 820px; +.launch .launch-copy { + margin: 0 auto; +} + +.launch h2, +.launch p { + margin-right: auto; + margin-left: auto; +} + +.launch .button { + margin-top: 18px; } .resources { display: flex; - align-items: center; + align-items: flex-start; justify-content: space-between; gap: 32px; - background: #111827; - color: #ffffff; + border-top: 1px solid var(--border); } .resources h2 { max-width: 700px; - margin-bottom: 0; -} - -.resources .eyebrow { - color: #99f6e4; } .resource-links { @@ -611,27 +877,12 @@ code { justify-content: flex-end; } -.resource-links a { - display: inline-flex; - align-items: center; - min-height: 44px; - padding: 0 16px; - border: 1px solid rgb(255 255 255 / 28%); - border-radius: 8px; - color: #f8fafc; - font-weight: 760; -} - -.resource-links a:hover, -.resource-links a:focus-visible { - border-color: #99f6e4; - color: #99f6e4; -} - -@media (max-width: 1050px) { +@media (max-width: 1100px) { .hero, + .proof-showcase, .steps, .proof-grid, + .dogfood-proof, .artifact-proof, .trust-layout, .assistant-grid, @@ -639,8 +890,9 @@ code { grid-template-columns: 1fr; } - .hero { - min-height: auto; + .hero-terminal, + .terminal-proof pre { + min-height: 0; } .steps li { @@ -658,37 +910,41 @@ code { nav { flex-wrap: wrap; justify-content: flex-end; - gap: 10px 16px; + gap: 10px 14px; } .band { - padding: 56px 22px; + padding: 50px 22px; + } + + .hero { + padding-top: 58px; } h1 { - font-size: 56px; + font-size: 44px; } h2 { - font-size: 34px; + font-size: 31px; } .lede { - font-size: 19px; + font-size: 17px; } - .hero-metrics { - grid-template-columns: 1fr; - } - - .trust-layout, + .diff-columns, .split, - .launch, .resources { display: grid; grid-template-columns: 1fr; } + .diff-columns > div:first-child { + border-right: 0; + border-bottom: 1px solid var(--border); + } + .resource-links { justify-content: flex-start; } diff --git a/tests/test_calibration_examples.py b/tests/test_calibration_examples.py new file mode 100644 index 0000000..16fdc19 --- /dev/null +++ b/tests/test_calibration_examples.py @@ -0,0 +1,31 @@ +import unittest + +from redline.diff import compare_suite_to_candidate +from redline.io import read_jsonl_records +from redline.suite import build_suite + + +class CalibrationExampleTests(unittest.TestCase): + def test_calibration_fixture_shows_regression_changed_and_neutral(self) -> None: + baseline = read_jsonl_records("examples/calibration_baseline.jsonl", "prompt", "response") + candidate = read_jsonl_records("examples/calibration_candidate.jsonl", "prompt", "response") + suite = build_suite( + baseline, + source="examples/calibration_baseline.jsonl", + input_field="prompt", + output_field="response", + max_cases=10, + all_cases=True, + ) + + result = compare_suite_to_candidate(suite, candidate) + statuses = [item["status"] for item in result["diffs"]] + reasons = "\n".join(reason for item in result["diffs"] for reason in item["reasons"]) + + self.assertEqual(result["summary"]["regression"], 2) + self.assertEqual(result["summary"]["changed"], 1) + self.assertEqual(result["summary"]["neutral"], 1) + self.assertEqual(statuses, ["regression", "regression", "changed", "neutral"]) + self.assertIn("candidate lost valid JSON format", reasons) + self.assertIn("candidate missing URLs", reasons) + self.assertIn("tone changed", reasons) diff --git a/tests/test_cli_config.py b/tests/test_cli_config.py index 3d2097b..c4b17e7 100644 --- a/tests/test_cli_config.py +++ b/tests/test_cli_config.py @@ -6,6 +6,7 @@ import tempfile import unittest from pathlib import Path +from unittest.mock import patch from redline.cli import main @@ -20,6 +21,8 @@ def test_bare_cli_prints_first_run_help(self) -> None: text = output.getvalue() self.assertIn("Start here:", text) self.assertIn("redline demo", text) + self.assertIn("redline status", text) + self.assertIn("redline quick-check path/to/baseline.jsonl path/to/candidate.jsonl --open", text) self.assertIn("redline init --runner stdio --copy-runner", text) self.assertIn("Review loop:", text) self.assertIn("redline suite add redline-suite.json", text) @@ -35,6 +38,8 @@ def test_root_help_prints_first_run_help(self) -> None: text = output.getvalue() self.assertIn("Start here:", text) self.assertIn("redline demo", text) + self.assertIn("redline status", text) + self.assertIn("redline quick-check path/to/baseline.jsonl path/to/candidate.jsonl --open", text) self.assertNotIn("suite-add", text) self.assertNotIn("==SUPPRESS==", text) @@ -46,7 +51,7 @@ def test_cli_version_flag_prints_version(self) -> None: main(["--version"]) self.assertEqual(raised.exception.code, 0) - self.assertIn("redline 0.2.1", output.getvalue()) + self.assertIn("redline 0.3.0", output.getvalue()) def test_init_help_lists_only_replay_runners(self) -> None: output = io.StringIO() @@ -148,6 +153,223 @@ def test_import_command_normalizes_external_jsonl(self) -> None: self.assertEqual(row["response"], "Summary") self.assertEqual(row["metadata"], {"category": "summarization"}) + def test_import_command_previews_external_jsonl_without_output(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / "downloaded.jsonl" + imported = root / "baseline.jsonl" + source.write_text( + '{"instruction": "Summarize", "response": "Summary", "category": "summarization"}\n', + encoding="utf-8", + ) + output = io.StringIO() + + with contextlib.redirect_stdout(output): + self.assertEqual( + main( + [ + "import", + str(source), + "--input-field", + "instruction", + "--output-field", + "response", + "--metadata-field", + "category", + "--preview", + "1", + ] + ), + 0, + ) + + text = output.getvalue() + self.assertIn("Previewed 1 prompt-response pairs", text) + self.assertIn("No file written.", text) + self.assertIn("prompt: Summarize", text) + self.assertIn("response: Summary", text) + self.assertIn('"category": "summarization"', text) + self.assertFalse(imported.exists()) + + def test_next_step_commands_quote_user_paths_with_spaces(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + previous = Path.cwd() + os.chdir(root) + try: + raw_source = Path("raw exports/downloaded log.jsonl") + raw_source.parent.mkdir() + raw_source.write_text( + '{"instruction": "Return JSON", "response": "{\\"owner\\": \\"billing\\"}"}\n', + encoding="utf-8", + ) + imported = Path("normalized logs/baseline log.jsonl") + imported.parent.mkdir() + + preview_output = io.StringIO() + with contextlib.redirect_stdout(preview_output): + self.assertEqual( + main( + [ + "import", + str(raw_source), + "--input-field", + "instruction", + "--output-field", + "response", + "--preview", + "1", + ] + ), + 0, + ) + self.assertIn( + "redline import 'raw exports/downloaded log.jsonl' --out baseline.jsonl", + preview_output.getvalue(), + ) + + import_output = io.StringIO() + with contextlib.redirect_stdout(import_output): + self.assertEqual( + main( + [ + "import", + str(raw_source), + "--input-field", + "instruction", + "--output-field", + "response", + "--out", + str(imported), + ] + ), + 0, + ) + self.assertIn( + "redline suite 'normalized logs/baseline log.jsonl' --out redline-suite.json", + import_output.getvalue(), + ) + + suite_path = Path("suite files/redline suite.json") + suite_path.parent.mkdir() + suite_output = io.StringIO() + with contextlib.redirect_stdout(suite_output): + self.assertEqual(main(["suite", str(imported), "--out", str(suite_path), "--all-cases"]), 0) + suite_text = suite_output.getvalue() + self.assertIn("redline cases 'suite files/redline suite.json'", suite_text) + self.assertIn("redline diff 'suite files/redline suite.json' path/to/candidate.jsonl", suite_text) + + add_output = io.StringIO() + with contextlib.redirect_stdout(add_output): + self.assertEqual( + main( + [ + "suite", + "add", + str(suite_path), + "--prompt", + "Pinned path prompt", + "--response", + "Pinned path response", + "--case-id", + "case with spaces", + ] + ), + 0, + ) + add_text = add_output.getvalue() + self.assertIn("redline case 'suite files/redline suite.json' 'case with spaces'", add_text) + self.assertIn("redline validate 'suite files/redline suite.json'", add_text) + + mark_output = io.StringIO() + with contextlib.redirect_stdout(mark_output): + self.assertEqual( + main( + [ + "mark", + str(suite_path), + "case with spaces", + "--status", + "expected", + "--note", + "intentional", + ] + ), + 0, + ) + mark_text = mark_output.getvalue() + self.assertIn("redline validate 'suite files/redline suite.json'", mark_text) + self.assertIn( + "redline accept 'suite files/redline suite.json' 'case with spaces'", + mark_text, + ) + finally: + os.chdir(previous) + + def test_import_command_auto_maps_external_jsonl(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / "downloaded.jsonl" + imported = root / "baseline.jsonl" + source.write_text( + ( + '{"instruction": "Summarize", "context": "Policy text", ' + '"response": "Summary", "category": "summarization"}\n' + ), + encoding="utf-8", + ) + output = io.StringIO() + + with contextlib.redirect_stdout(output): + self.assertEqual(main(["import", str(source), "--auto-map", "--out", str(imported)]), 0) + + text = output.getvalue() + self.assertIn("Auto-mapped: yes (score 100; preset dolly)", text) + self.assertIn("Preset: dolly", text) + self.assertIn("Mapped prompt: instruction", text) + self.assertIn("Mapped response: response", text) + row = json.loads(imported.read_text(encoding="utf-8")) + self.assertEqual(row["prompt"], "Summarize\n\nContext:\nPolicy text") + self.assertEqual(row["response"], "Summary") + self.assertEqual(row["metadata"], {"category": "summarization"}) + + def test_import_command_auto_maps_preview_without_output(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / "downloaded.jsonl" + imported = root / "baseline.jsonl" + source.write_text('{"input": "Classify", "output": "billing"}\n', encoding="utf-8") + output = io.StringIO() + + with contextlib.redirect_stdout(output): + self.assertEqual(main(["import", str(source), "--auto-map", "--preview", "1"]), 0) + + text = output.getvalue() + self.assertIn("Previewed 1 prompt-response pairs", text) + self.assertIn("Auto-mapped: yes (score 100; preset langfuse)", text) + self.assertIn("Mapped prompt: input", text) + self.assertIn("Mapped response: output", text) + self.assertFalse(imported.exists()) + + def test_import_command_detects_external_jsonl_fields(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / "export.jsonl" + source.write_text( + '{"input": "Classify", "output": "billing"}\n', + encoding="utf-8", + ) + output = io.StringIO() + + with contextlib.redirect_stdout(output): + self.assertEqual(main(["import", str(source), "--detect"]), 0) + + text = output.getvalue() + self.assertIn("redline import detection", text) + self.assertIn("input", text) + self.assertIn("output", text) + self.assertIn("--preview 3", text) + def test_runners_command_can_copy_adapter(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) @@ -220,6 +442,23 @@ def test_runners_command_refuses_out_with_copy_all(self) -> None: finally: os.chdir(previous) + def test_import_command_lists_presets(self) -> None: + output = io.StringIO() + + with contextlib.redirect_stdout(output): + self.assertEqual(main(["import", "--list-presets"]), 0) + + text = output.getvalue() + self.assertIn("redline import presets", text) + self.assertIn("langfuse", text) + self.assertIn("openai-chat", text) + + json_output = io.StringIO() + with contextlib.redirect_stdout(json_output): + self.assertEqual(main(["import", "--list-presets", "--json"]), 0) + payload = json.loads(json_output.getvalue()) + self.assertTrue(any(row["id"] == "helicone" for row in payload["presets"])) + def test_judges_command_lists_templates(self) -> None: output = io.StringIO() @@ -558,10 +797,14 @@ def test_suite_and_diff_use_config_defaults(self) -> None: encoding="utf-8", ) - with contextlib.redirect_stdout(io.StringIO()): + output = io.StringIO() + with contextlib.redirect_stdout(output): self.assertEqual(main(["suite", "baseline.jsonl"]), 0) self.assertEqual(main(["diff", "candidate.jsonl"]), 0) + text = output.getvalue() + self.assertIn("Open HTML report: .redline/reports/diff.html", text) + self.assertIn("Open app: redline app --reports-dir .redline/reports", text) self.assertTrue((root / ".redline" / "suite.json").exists()) self.assertTrue((root / ".redline" / "reports" / "diff.json").exists()) self.assertTrue((root / ".redline" / "reports" / "diff.md").exists()) @@ -581,6 +824,137 @@ def test_suite_and_diff_use_config_defaults(self) -> None: finally: os.chdir(previous) + def test_quick_check_generates_suite_and_reports(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + previous = Path.cwd() + os.chdir(root) + try: + Path("baseline.jsonl").write_text( + '{"prompt": "Return JSON", "response": "{\\"ok\\": true}"}\n', + encoding="utf-8", + ) + Path("candidate.jsonl").write_text( + '{"prompt": "Return JSON", "response": "ok"}\n', + encoding="utf-8", + ) + output = io.StringIO() + + with patch("redline.cli.webbrowser.open") as open_browser: + with contextlib.redirect_stdout(output): + self.assertEqual( + main( + [ + "quick-check", + "baseline.jsonl", + "candidate.jsonl", + "--fail-on", + "none", + "--open", + ] + ), + 0, + ) + + text = output.getvalue() + self.assertIn("redline quick-check", text) + self.assertIn("candidate lost valid JSON format", text) + self.assertIn("Open HTML report", text) + self.assertIn("Open guided app", text) + self.assertIn("Opened HTML report in the default browser.", text) + self.assertTrue((root / ".redline" / "quick-check" / "suite.json").exists()) + self.assertTrue((root / ".redline" / "quick-check" / "diff.json").exists()) + self.assertTrue((root / ".redline" / "quick-check" / "diff.md").exists()) + self.assertTrue((root / ".redline" / "quick-check" / "diff.html").exists()) + self.assertTrue((root / ".redline" / "quick-check" / "app.html").exists()) + report = json.loads((root / ".redline" / "quick-check" / "diff.json").read_text(encoding="utf-8")) + self.assertEqual(report["summary"]["regression"], 1) + self.assertEqual(report["artifacts"]["html"], ".redline/quick-check/diff.html") + self.assertEqual(report["artifacts"]["app"], ".redline/quick-check/app.html") + self.assertEqual(report["suite"], ".redline/quick-check/suite.json") + app = (root / ".redline" / "quick-check" / "app.html").read_text(encoding="utf-8") + self.assertIn('data-redline-dashboard="app"', app) + self.assertIn("candidate lost valid JSON format", app) + open_browser.assert_called_once() + finally: + os.chdir(previous) + + def test_quick_check_can_open_guided_app(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + previous = Path.cwd() + os.chdir(root) + try: + Path("baseline.jsonl").write_text( + '{"prompt": "Return JSON", "response": "{\\"ok\\": true}"}\n', + encoding="utf-8", + ) + Path("candidate.jsonl").write_text( + '{"prompt": "Return JSON", "response": "ok"}\n', + encoding="utf-8", + ) + output = io.StringIO() + + with patch("redline.cli.webbrowser.open") as open_browser: + with contextlib.redirect_stdout(output): + self.assertEqual( + main( + [ + "quick-check", + "baseline.jsonl", + "candidate.jsonl", + "--fail-on", + "none", + "--open-app", + ] + ), + 0, + ) + + text = output.getvalue() + app_path = root / ".redline" / "quick-check" / "app.html" + self.assertTrue(app_path.exists()) + self.assertIn("Opened guided app in the default browser.", text) + open_browser.assert_called_once_with(app_path.resolve().as_uri()) + finally: + os.chdir(previous) + + def test_diff_output_quotes_app_command_report_dir_with_spaces(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + previous = Path.cwd() + os.chdir(root) + try: + Path("baseline.jsonl").write_text( + '{"prompt": "Return JSON", "response": "{\\"ok\\": true}"}\n', + encoding="utf-8", + ) + Path("candidate.jsonl").write_text( + '{"prompt": "Return JSON", "response": "ok"}\n', + encoding="utf-8", + ) + output = io.StringIO() + with contextlib.redirect_stdout(output): + self.assertEqual(main(["suite", "baseline.jsonl", "--out", "suite.json"]), 0) + self.assertEqual( + main( + [ + "diff", + "suite.json", + "candidate.jsonl", + "--fail-on", + "none", + "--out-html", + "report dir/diff.html", + ] + ), + 0, + ) + finally: + os.chdir(previous) + + self.assertIn("Open app: redline app --reports-dir 'report dir'", output.getvalue()) + def test_diff_profile_review_downgrades_number_and_entity_loss(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) @@ -672,6 +1046,9 @@ def test_suite_output_explains_representative_selection(self) -> None: ) text = output.getvalue() + self.assertIn("Suite readiness:", text) + self.assertIn("Readiness scope: suite health, not model quality or candidate safety", text) + self.assertIn("Improve suite:", text) self.assertIn("Selected 1 representative cases from 3 unique prompt-response pairs.", text) self.assertIn("Use --all-cases for exhaustive coverage", text) self.assertIn("redline suite add for must-cover edge cases", text) @@ -797,7 +1174,7 @@ def test_redact_command_sanitizes_jsonl_and_appends_audit_event(self) -> None: self.assertNotIn("ada@example.com", row["prompt"]) self.assertEqual(row["api_key"], "[REDACTED]") self.assertIn("Redactions: 2", output.getvalue()) - self.assertIn("best-effort common secret/PII patterns", output.getvalue()) + self.assertIn("not a privacy boundary", output.getvalue()) audit = [ json.loads(line) for line in (root / ".redline" / "audit.jsonl").read_text(encoding="utf-8").splitlines() @@ -2070,6 +2447,7 @@ def test_watch_reports_default_redaction_and_supports_raw_opt_out(self) -> None: observed = root / ".redline" / "logs" / "prompts.jsonl" self.assertIn("Redacted 1 sensitive value", output.getvalue()) + self.assertIn("not a privacy boundary", output.getvalue()) self.assertNotIn("ada@example.com", observed.read_text(encoding="utf-8")) with contextlib.redirect_stdout(io.StringIO()): @@ -2081,6 +2459,93 @@ def test_watch_reports_default_redaction_and_supports_raw_opt_out(self) -> None: finally: os.chdir(previous) + def test_import_reports_default_redaction_and_supports_raw_opt_out(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + previous = Path.cwd() + os.chdir(root) + try: + Path("downloaded.jsonl").write_text( + '{"instruction": "Email ada@example.com", "response": "ok"}\n', + encoding="utf-8", + ) + + output = io.StringIO() + with contextlib.redirect_stdout(output): + self.assertEqual( + main( + [ + "import", + "downloaded.jsonl", + "--input-field", + "instruction", + "--output-field", + "response", + "--out", + "baseline.jsonl", + ] + ), + 0, + ) + + self.assertIn("1 value(s) redacted", output.getvalue()) + self.assertIn("not a privacy boundary", output.getvalue()) + self.assertNotIn("ada@example.com", Path("baseline.jsonl").read_text(encoding="utf-8")) + + with contextlib.redirect_stdout(io.StringIO()): + self.assertEqual( + main( + [ + "import", + "downloaded.jsonl", + "--input-field", + "instruction", + "--output-field", + "response", + "--out", + "raw.jsonl", + "--no-redact", + ] + ), + 0, + ) + self.assertIn("ada@example.com", Path("raw.jsonl").read_text(encoding="utf-8")) + + Path("langfuse.jsonl").write_text( + json.dumps( + { + "input": "Summarize refund policy", + "output": "Refunds are available for 30 days.", + "traceId": "trace-123", + } + ) + + "\n", + encoding="utf-8", + ) + output = io.StringIO() + with contextlib.redirect_stdout(output): + self.assertEqual( + main( + [ + "import", + "langfuse.jsonl", + "--preset", + "langfuse", + "--out", + "langfuse-baseline.jsonl", + ] + ), + 0, + ) + text = output.getvalue() + self.assertIn("Preset: langfuse", text) + self.assertIn("Mapped prompt: input", text) + imported_row = json.loads(Path("langfuse-baseline.jsonl").read_text(encoding="utf-8")) + self.assertEqual(imported_row["prompt"], "Summarize refund policy") + self.assertEqual(imported_row["metadata"], {"traceId": "trace-123"}) + finally: + os.chdir(previous) + def test_eval_uses_configured_replay_command(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) diff --git a/tests/test_clusters.py b/tests/test_clusters.py index b0e8ef7..5ae4f50 100644 --- a/tests/test_clusters.py +++ b/tests/test_clusters.py @@ -69,10 +69,10 @@ def test_format_cluster_report_is_readable(self) -> None: output = format_cluster_report(suite) self.assertIn("redline cluster", output) - self.assertIn("Identified 1 behavioral clusters from 1 unique pairs", output) + self.assertIn("Identified 1 behavior-signature groups from 1 unique pairs", output) self.assertIn("Records seen: 1 Duplicate pairs: 0", output) - self.assertIn("High-risk clusters: 0", output) - self.assertIn("Failure-pattern clusters: 0", output) + self.assertIn("High-risk groups: 0", output) + self.assertIn("Failure-pattern groups: 0", output) self.assertIn("Suggested eval suite: 1 representative cases.", output) self.assertIn("Case coverage: 1/1 (100.0%)", output) self.assertIn("RISK", output) diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index ad90aeb..8e8796a 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -4,6 +4,7 @@ import tempfile import unittest from pathlib import Path +from unittest.mock import patch from redline.cli import main from redline.dashboard import build_dashboard, format_dashboard_html @@ -24,6 +25,17 @@ def test_dashboard_collects_reports_history_and_links(self) -> None: "neutral": 3, }, "decision": {"recommended_action": "review changed cases before shipping"}, + "methodology": { + "name": "deterministic behavior-signature grouping", + "version": "behavior-signature-v1", + }, + "suite_summary": { + "cases": 5, + "unique_prompt_response_pairs": 10, + "clusters": 4, + "case_coverage": 0.5, + "cluster_coverage": 0.75, + }, "prompt_evals": [ { "id": "support/triage", @@ -156,7 +168,11 @@ def test_dashboard_collects_reports_history_and_links(self) -> None: { "cases": 2, "confidence": {"high": 1, "medium": 1}, + "methodology": { + "deterministic behavior-signature grouping (behavior-signature-v1)": 1, + }, "signal": {"judge": 1, "structural": 1}, + "suite_coverage": {"cases 5/10 (50.0%); groups 3/4 (75.0%)": 1}, }, ) self.assertEqual( @@ -168,6 +184,10 @@ def test_dashboard_collects_reports_history_and_links(self) -> None: dashboard["reports"][0]["review_cases"][0]["reason"], "candidate lost valid JSON format", ) + self.assertEqual( + dashboard["reports"][0]["review_cases"][0]["impact"], + "Downstream code may fail if consumers expect parseable JSON or required fields.", + ) self.assertEqual(dashboard["checkpoint"]["entries"], 3) self.assertEqual( dashboard["reports"][0]["prompt_evals"], @@ -228,6 +248,7 @@ def test_dashboard_collects_reports_history_and_links(self) -> None: self.assertIn("

    Review Queue

    ", html) self.assertIn("case_001", html) self.assertIn("candidate lost valid JSON format", html) + self.assertIn("Downstream code may fail if consumers expect parseable JSON or required fields.", html) self.assertIn("Return JSON with owner and priority.", html) self.assertIn("Review", html) self.assertIn("blocking 1", html) @@ -238,6 +259,11 @@ def test_dashboard_collects_reports_history_and_links(self) -> None: self.assertIn("redline-suite.json", html) self.assertIn("1m 30s", html) self.assertIn("5ms for 5 cases", html) + self.assertIn("

    Trust Signals

    ", html) + self.assertIn("Methodology", html) + self.assertIn("deterministic behavior-signature grouping (behavior-signature-v1)", html) + self.assertIn("Suite coverage", html) + self.assertIn("cases 5/10 (50.0%); groups 3/4 (75.0%)", html) self.assertIn("1000 cases/sec", html) self.assertIn("

    Trust Signals

    ", html) self.assertIn("

    Audit Checkpoint

    ", html) @@ -257,6 +283,83 @@ def test_dashboard_collects_reports_history_and_links(self) -> None: self.assertIn("structural checks only", html) self.assertNotIn("Missing benchmark evidence", html) + def test_app_dashboard_renders_real_report_data(self) -> None: + dashboard = { + "reports": [ + { + "name": "eval.json", + "path": ".redline/reports/eval.json", + "html_path": ".redline/reports/eval.html", + "summary": {"cases": 3, "regression": 1, "changed": 1, "neutral": 1}, + "decision": {"recommended_action": "fix blocking cases before shipping"}, + "suite_summary": {"cases": 3, "clusters": 2, "cluster_coverage": 0.67}, + "review_cases": [ + { + "case_id": "case_001", + "status": "regression", + "suite": "suites/support/triage.redline-suite.json", + "prompt": "Return JSON with owner and priority.", + "reason": "candidate lost valid JSON format", + "impact": "Downstream code may fail if consumers expect parseable JSON or required fields.", + } + ], + } + ], + "history": [ + { + "timestamp": "2026-05-24T00:00:00Z", + "label": "prompt-v2", + "summary": {"cases": 3, "regression": 1}, + } + ], + "benchmarks": [], + "errors": [], + "notices": [{"message": "Run a benchmark before relying on runtime readiness."}], + "scope": "structural checks only", + } + + html = format_dashboard_html( + dashboard, + style="app", + output_path=Path(".redline") / "dashboard.html", + ) + + self.assertIn('data-redline-dashboard="app"', html) + self.assertIn("local dashboard", html) + self.assertIn('class="svg-ico"', html) + self.assertIn('button type="button" class="sb-item', html) + self.assertIn("Workflow", html) + self.assertIn('id="s-workflow"', html) + self.assertIn("Command center.", html) + self.assertIn("dashboard never runs shell commands", html) + self.assertIn('data-copy="redline demo --public --compact"', html) + self.assertIn("redline import path/to/export.jsonl --detect", html) + self.assertIn("redline import path/to/export.jsonl --auto-map --preview 3", html) + self.assertIn("redline suite .redline/logs/baseline.jsonl", html) + self.assertIn("redline eval --compact", html) + self.assertIn("redline history .redline/reports/eval.json", html) + self.assertIn("redline app --reports-dir .redline/reports", html) + self.assertIn("Active regressions", html) + self.assertIn("Regression trend", html) + self.assertIn("Alerts", html) + self.assertIn("Log import", html) + self.assertIn("Developer workflow integrations", html) + self.assertIn("Settings", html) + self.assertIn("Suite coverage", html) + self.assertIn("67%", html) + self.assertIn("case_001", html) + self.assertIn("candidate lost valid JSON format", html) + self.assertIn("Why this matters: Downstream code may fail", html) + self.assertIn("redline case suites/support/triage.redline-suite.json case_001", html) + self.assertIn("fix blocking cases before shipping", html) + self.assertIn("eval.json", html) + self.assertIn("reports/eval.html", html) + self.assertIn("prompt-v2", html) + self.assertIn("Local-first, no telemetry", html) + self.assertIn("@media (max-width: 700px)", html) + self.assertIn("position: sticky;", html) + self.assertNotIn(".sidebar { display: none; }", html) + def test_dashboard_warns_when_reports_have_no_benchmark_evidence(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) @@ -326,7 +429,7 @@ def test_dashboard_trend_shows_cluster_diagnosis(self) -> None: html = format_dashboard_html(dashboard) - self.assertIn("

    Cluster diagnosis

    ", html) + self.assertIn("

    Behavior group diagnosis

    ", html) self.assertIn("structured JSON prompt -> JSON response (short)", html) self.assertIn("blocking +1", html) self.assertIn("changed -1", html) @@ -348,6 +451,41 @@ def test_dashboard_skips_invalid_local_files_with_warning(self) -> None: self.assertIn("bad.json", html) self.assertIn("missing summary object", html) + def test_dashboard_ignores_redline_sidecar_json_files(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + reports = root / ".redline" / "reports" + reports.mkdir(parents=True) + write_json( + reports / "eval.json", + { + "$schema": "https://raw.githubusercontent.com/gowtham0992/redline/main/redline-report.schema.json", + "summary": {"cases": 1, "regression": 1, "changed": 0, "missing": 0, "neutral": 0}, + "decision": {"recommended_action": "fix blocking cases before shipping"}, + "diffs": [], + }, + ) + write_json( + reports / "redline-suite.json", + { + "$schema": "https://raw.githubusercontent.com/gowtham0992/redline/main/redline-suite.schema.json", + "summary": {"cases": 1, "clusters": 1}, + "cases": [], + }, + ) + write_json( + reports / "eval.slack.json", + { + "text": "redline summary", + "blocks": [{"type": "section", "text": {"type": "mrkdwn", "text": "regression"}}], + }, + ) + + dashboard = build_dashboard(reports_dir=reports, history_path=root / "missing.jsonl") + + self.assertEqual([report["name"] for report in dashboard["reports"]], ["eval.json"]) + self.assertEqual(dashboard["errors"], []) + def test_dashboard_reports_invalid_checkpoint_file(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) @@ -411,6 +549,139 @@ def test_cli_writes_dashboard_html(self) -> None: self.assertIn("Benchmarks: 1", stdout.getvalue()) self.assertIn("Notices: 0", stdout.getvalue()) + def test_cli_writes_app_dashboard_html(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + reports = root / ".redline" / "reports" + write_json( + reports / "diff.json", + { + "summary": {"cases": 1, "regression": 1}, + "decision": {"recommended_action": "fix blocking cases before shipping"}, + "diffs": [ + { + "case_id": "case_001", + "status": "regression", + "prompt": "Return JSON.", + "reasons": ["candidate lost valid JSON format"], + } + ], + }, + ) + output = root / ".redline" / "dashboard.html" + + current = Path.cwd() + stdout = io.StringIO() + try: + import os + + os.chdir(root) + with contextlib.redirect_stdout(stdout): + code = main(["dashboard", "--style", "app", "--out", str(output)]) + finally: + os.chdir(current) + + self.assertEqual(code, 0) + text = output.read_text(encoding="utf-8") + self.assertIn('data-redline-dashboard="app"', text) + self.assertIn("candidate lost valid JSON format", text) + self.assertIn("Why this matters: Downstream code may fail", text) + self.assertIn("Reports: 1", stdout.getvalue()) + + def test_cli_app_opens_guided_local_app_by_default(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + reports = root / ".redline" / "reports" + write_json( + reports / "eval.json", + { + "summary": {"cases": 1, "regression": 1}, + "decision": {"recommended_action": "fix blocking cases before shipping"}, + "suite": "redline-suite.json", + "diffs": [ + { + "case_id": "case_001", + "status": "regression", + "prompt": "Return JSON.", + "reasons": ["candidate lost valid JSON format"], + } + ], + }, + ) + output = root / ".redline" / "app.html" + + current = Path.cwd() + stdout = io.StringIO() + try: + import os + + os.chdir(root) + with patch("redline.cli.webbrowser.open") as open_browser: + with contextlib.redirect_stdout(stdout): + code = main(["app", "--out", str(output)]) + finally: + os.chdir(current) + + self.assertEqual(code, 0) + text = output.read_text(encoding="utf-8") + self.assertIn("redline app", text) + self.assertIn('data-redline-dashboard="app"', text) + self.assertIn('id="s-workflow"', text) + self.assertIn("redline import path/to/export.jsonl --detect", text) + self.assertIn("candidate lost valid JSON format", text) + self.assertIn("Why this matters: Downstream code may fail", text) + self.assertIn("Opened redline app in the default browser.", stdout.getvalue()) + self.assertIn("Next:", stdout.getvalue()) + open_browser.assert_called_once() + + def test_cli_app_can_write_without_opening_browser(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + output = root / ".redline" / "app.html" + + current = Path.cwd() + stdout = io.StringIO() + try: + import os + + os.chdir(root) + with patch("redline.cli.webbrowser.open") as open_browser: + with contextlib.redirect_stdout(stdout): + code = main(["app", "--no-open", "--out", str(output)]) + finally: + os.chdir(current) + + self.assertEqual(code, 0) + self.assertTrue(output.exists()) + self.assertIn("Open:", stdout.getvalue()) + open_browser.assert_not_called() + + def test_cli_app_demo_generates_public_reports(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + output = root / ".redline" / "app.html" + + current = Path.cwd() + stdout = io.StringIO() + try: + import os + + os.chdir(root) + with patch("redline.cli.webbrowser.open") as open_browser: + with contextlib.redirect_stdout(stdout): + code = main(["app", "--demo", "--no-open", "--out", str(output)]) + finally: + os.chdir(current) + + self.assertEqual(code, 0) + text = output.read_text(encoding="utf-8") + self.assertIn("Generated public demo evidence in .redline/demo.", stdout.getvalue()) + self.assertIn("Reports: 1", stdout.getvalue()) + self.assertTrue((root / ".redline" / "demo" / "reports" / "public_diff.json").exists()) + self.assertIn("public_diff.json", text) + self.assertIn("Workflow", text) + open_browser.assert_not_called() + if __name__ == "__main__": unittest.main() diff --git a/tests/test_demo.py b/tests/test_demo.py index 75b07bb..fadc7e5 100644 --- a/tests/test_demo.py +++ b/tests/test_demo.py @@ -23,6 +23,7 @@ def test_run_demo_writes_artifacts_and_finds_regressions(self) -> None: report = read_json(result["report_json"]) self.assertEqual(report["summary"]["regression"], result["summary"]["regression"]) + self.assertEqual(report["suite"], result["suite"]) def test_format_demo_includes_regression_report(self) -> None: with tempfile.TemporaryDirectory() as directory: @@ -45,12 +46,29 @@ def test_format_demo_includes_regression_report(self) -> None: self.assertIn("--status expected", output) self.assertIn("redline accept", output) self.assertIn("--all-expected", output) - self.assertIn("redline dashboard --reports-dir", output) + self.assertIn("redline app --reports-dir", output) self.assertIn("redline init --runner stdio --copy-runner --github-action", output) self.assertIn("redline runners --copy all", output) self.assertIn("redline suite path/to/baseline.jsonl --out redline-suite.json", output) self.assertIn("redline doctor --strict", output) + def test_format_demo_quotes_app_command_with_spaced_output_path(self) -> None: + with tempfile.TemporaryDirectory() as directory: + result = run_demo(Path(directory) / "demo output") + + output = format_demo(result) + + self.assertIn("redline app --reports-dir", output) + self.assertIn("--reports-dir '", output) + self.assertIn("demo output/reports", output) + self.assertIn("redline history '", output) + self.assertIn("demo output/reports/diff.json", output) + self.assertIn("redline cases '", output) + self.assertIn("demo output/suite.json", output) + self.assertIn("redline accept '", output) + self.assertIn("--candidate '", output) + self.assertIn("demo output/candidate.jsonl", output) + def test_format_demo_can_use_compact_report(self) -> None: with tempfile.TemporaryDirectory() as directory: result = run_demo(Path(directory) / "demo") @@ -74,6 +92,7 @@ def test_run_public_demo_writes_self_contained_fixture(self) -> None: self.assertEqual(result["summary"]["cases"], 10) self.assertEqual(result["summary"]["regression"], 10) self.assertTrue(result["public"]) + self.assertEqual(read_json(result["report_json"])["suite"], result["suite"]) output = format_demo(result, compact=True) diff --git a/tests/test_diff.py b/tests/test_diff.py index 5cdc3ee..335de37 100644 --- a/tests/test_diff.py +++ b/tests/test_diff.py @@ -4,6 +4,7 @@ from redline.diff import ( REPORT_SCHEMA_URL, + case_impact, classify_change, compare_suite_to_candidate, format_compact_report, @@ -24,11 +25,65 @@ def test_report_schema_documents_generated_report_fields(self) -> None: self.assertIn("summary", schema["properties"]) self.assertIn("decision", schema["properties"]) self.assertIn("diagnosis", schema["properties"]["decision"]["properties"]) + self.assertIn("methodology", schema["properties"]) + self.assertIn("suite_summary", schema["properties"]) + self.assertIn("stochastic_prompt_groups", schema["properties"]["suite_summary"]["properties"]) self.assertIn("suite", schema["properties"]) self.assertIn("candidate", schema["properties"]) self.assertIn("diffs", schema["properties"]) diff_properties = schema["properties"]["diffs"]["items"]["properties"] self.assertIn("owner_rule", diff_properties) + self.assertIn("impact", diff_properties) + + def test_report_carries_suite_methodology(self) -> None: + suite = build_suite( + [LogRecord(1, "Return JSON", '{"ok": true}', {})], + source="memory", + input_field="prompt", + output_field="response", + max_cases=10, + ) + candidate = [LogRecord(1, "Return JSON", '{"ok": true}', {})] + + result = compare_suite_to_candidate(suite, candidate) + + self.assertEqual(result["methodology"]["version"], "behavior-signature-v1") + self.assertIn("behavior-signature", result["methodology"]["name"]) + self.assertEqual(result["suite_summary"]["cases"], 1) + self.assertEqual(result["suite_summary"]["case_coverage"], 1.0) + self.assertEqual(result["suite_summary"]["cluster_coverage"], 1.0) + + def test_report_warns_when_suite_contains_non_ascii_records(self) -> None: + suite = build_suite( + [LogRecord(1, "Résumé refund policy", "Répondre avec la politique.", {})], + source="memory", + input_field="prompt", + output_field="response", + max_cases=10, + ) + candidate = [LogRecord(1, "Résumé refund policy", "Répondre avec la politique.", {})] + + result = compare_suite_to_candidate(suite, candidate) + + self.assertTrue(any("English-centric" in warning for warning in result["warnings"])) + + def test_report_warns_when_suite_contains_stochastic_baselines(self) -> None: + suite = build_suite( + [ + LogRecord(1, "Classify ticket", "billing", {}), + LogRecord(2, "Classify ticket", "support", {}), + ], + source="memory", + input_field="prompt", + output_field="response", + max_cases=10, + ) + candidate = [LogRecord(1, "Classify ticket", "billing", {})] + + result = compare_suite_to_candidate(suite, candidate) + + self.assertEqual(result["suite_summary"]["stochastic_prompt_groups"], 1) + self.assertTrue(any("multiple distinct baseline responses" in warning for warning in result["warnings"])) def test_classify_json_regression(self) -> None: baseline = extract_features('{"name":"Ada","status":"active"}').to_dict() @@ -304,6 +359,20 @@ def test_compare_detects_missing_candidate(self) -> None: self.assertIn("cluster", result["diffs"][0]) self.assertEqual(result["diffs"][0]["confidence"], "high") self.assertEqual(result["diffs"][0]["signal"], "structural") + self.assertEqual( + result["diffs"][0]["impact"], + "Replay did not produce a candidate output, so this baseline behavior is untested.", + ) + + def test_case_impact_explains_common_regression_shapes(self) -> None: + self.assertEqual( + case_impact("regression", ["candidate missing numbers: 30"]), + "Concrete details used for decisions, routing, or compliance may have been dropped.", + ) + self.assertEqual( + case_impact("changed", ["short answer changed"]), + "Review whether this behavioral change is intentional before accepting it.", + ) def test_compare_summarizes_plain_english_diagnosis(self) -> None: prompt = "Return a numbered rollout checklist with owner, URL, and 30 day deadline." @@ -446,11 +515,14 @@ def test_format_report_includes_decision(self) -> None: "diagnosis": "No structural blockers were detected; still review semantic risks.", }, "warnings": ["prompt file prompts/v2.txt is newer than suite"], + "profile": "review", "diffs": [], } report = format_report(result) + self.assertIn("Profile: review", report) + self.assertIn("detail/entity loss becomes reviewable changed signal", report) self.assertIn("Confidence: MEDIUM", report) self.assertIn("Recommended action: no structural blockers detected; review semantic risks before shipping", report) self.assertIn("Scope: structural checks only", report) @@ -458,6 +530,41 @@ def test_format_report_includes_decision(self) -> None: self.assertIn("Warnings:", report) self.assertIn("prompt file prompts/v2.txt is newer than suite", report) + def test_format_report_includes_case_impact(self) -> None: + result = { + "summary": { + "cases": 1, + "regression": 1, + "changed": 0, + "improved": 0, + "accepted": 0, + "ignored": 0, + "neutral": 0, + "missing": 0, + }, + "decision": { + "confidence": "high", + "recommended_action": "fix blocking cases before shipping", + "scope": "structural checks only", + }, + "diffs": [ + { + "case_id": "case_001", + "status": "regression", + "prompt": "Return JSON", + "reasons": ["candidate lost valid JSON format"], + "impact": "Downstream code may fail if consumers expect parseable JSON or required fields.", + } + ], + } + + report = format_report(result) + + self.assertIn( + "why this matters: Downstream code may fail if consumers expect parseable JSON or required fields.", + report, + ) + def test_format_compact_report_outputs_one_line_per_actionable_case(self) -> None: result = { "summary": { @@ -476,6 +583,7 @@ def test_format_compact_report_outputs_one_line_per_actionable_case(self) -> Non "scope": "structural checks only; review semantic risks separately", "diagnosis": "Candidate lost required structure; fix blocking cases before shipping.", }, + "profile": "strict", "prompt_evals": [ { "id": "support/triage", @@ -517,6 +625,8 @@ def test_format_compact_report_outputs_one_line_per_actionable_case(self) -> Non report = format_compact_report(result, title="redline eval") self.assertIn("redline eval: cases=2 regression=1 changed=1", report) + self.assertIn("Profile: strict", report) + self.assertIn("detail/entity loss is blocking", report) self.assertIn("Confidence: HIGH | fix blocking cases before shipping", report) self.assertIn("Scope: structural checks only", report) self.assertIn("Diagnosis: Candidate lost required structure; fix blocking cases before shipping.", report) diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 8c2de55..237dac6 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -352,7 +352,7 @@ def test_doctor_calibrates_coverage_for_high_risk_suite_without_semantic_checks( coverage = next(check for check in report["checks"] if check["name"] == "coverage") self.assertEqual(coverage["status"], "ok") - self.assertIn("high-risk clusters=1", coverage["message"]) + self.assertIn("high-risk groups=1", coverage["message"]) self.assertIn("requirements=0; judge=no", coverage["message"]) self.assertIn("explicit guards=0/1", coverage["message"]) self.assertIn("add requirements or a judge", coverage["message"]) diff --git a/tests/test_history.py b/tests/test_history.py index fc364f4..7bc1755 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -211,7 +211,7 @@ def test_format_markdown_history_includes_cluster_diagnosis(self) -> None: ] ) - self.assertIn("## Cluster Diagnosis", text) + self.assertIn("## Behavior Group Diagnosis", text) self.assertIn("structured JSON prompt -> JSON response (short)", text) self.assertIn("| structured JSON prompt -> JSON response (short) | +1 | 1 | 0 |", text) diff --git a/tests/test_import_logs.py b/tests/test_import_logs.py index b9e08d2..ac3c7c9 100644 --- a/tests/test_import_logs.py +++ b/tests/test_import_logs.py @@ -5,11 +5,69 @@ import unittest from pathlib import Path -from redline.import_logs import import_jsonl_log +from redline.import_logs import ( + detect_import_fields, + format_import_detection, + format_import_presets, + import_jsonl_log, + import_preset_rows, + preview_jsonl_import, +) from redline.io import read_jsonl_records class ImportLogTests(unittest.TestCase): + def test_import_preset_rows_are_human_discoverable(self) -> None: + rows = import_preset_rows() + output = format_import_presets() + + self.assertTrue(any(row["id"] == "langfuse" for row in rows)) + self.assertTrue(any(row["id"] == "openai-chat" for row in rows)) + self.assertIn("redline import presets", output) + self.assertIn("langfuse", output) + self.assertIn("openai-chat", output) + self.assertIn("redline import raw.jsonl --preset langfuse", output) + + def test_detect_import_fields_suggests_known_mapping(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / "langfuse.jsonl" + source.write_text( + '{"input": "Classify ticket", "output": "billing", "traceId": "trace-1"}\n' + '{"input": "Summarize ticket", "output": "short summary", "traceId": "trace-2"}\n', + encoding="utf-8", + ) + + result = detect_import_fields(source) + text = format_import_detection(result) + + self.assertEqual(result["records_scanned"], 2) + self.assertEqual(result["suggestions"][0]["input_field"], "input") + self.assertEqual(result["suggestions"][0]["output_field"], "output") + self.assertEqual(result["suggestions"][0]["score"], 100) + self.assertIn("redline import detection", text) + self.assertIn("--input-field input --output-field output --preview 3", text) + + def test_detect_import_fields_discovers_custom_nested_mapping(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / "custom.jsonl" + source.write_text( + json.dumps( + { + "payload": {"user_question": "What is refund policy?"}, + "result": {"assistant_answer": "Refunds are available within 30 days."}, + } + ) + + "\n", + encoding="utf-8", + ) + + result = detect_import_fields(source) + fields = {(row["input_field"], row["output_field"]) for row in result["suggestions"]} + + self.assertIn(("payload.user_question", "result.assistant_answer"), fields) + def test_import_jsonl_log_maps_external_fields_and_context(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) @@ -68,11 +126,130 @@ def test_import_jsonl_log_limits_records(self) -> None: self.assertEqual(report["records"], 1) self.assertEqual(len(read_jsonl_records(output, "prompt", "response")), 1) + def test_preview_jsonl_import_maps_rows_without_writing(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / "downloaded.jsonl" + output = root / "baseline.jsonl" + source.write_text( + '{"instruction": "Email ada@example.com", "response": "ok", "category": "support"}\n', + encoding="utf-8", + ) + + report = preview_jsonl_import( + source, + input_field="instruction", + output_field="response", + metadata_fields=["category"], + limit=1, + ) + + self.assertEqual(report["previewed"], 1) + self.assertFalse(output.exists()) + self.assertNotIn("ada@example.com", report["rows"][0]["prompt"]) + self.assertEqual(report["rows"][0]["response"], "ok") + self.assertEqual(report["rows"][0]["metadata"], {"category": "support"}) + + def test_import_jsonl_log_reads_nested_list_paths_for_chat_exports(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / "openai.jsonl" + output = root / "baseline.jsonl" + source.write_text( + json.dumps( + { + "request": { + "messages": [ + {"role": "system", "content": "Use JSON."}, + {"role": "user", "content": "Classify ticket INV-1042."}, + ], + "model": "gpt-4o-mini", + }, + "response": { + "choices": [ + {"message": {"content": '{"owner": "billing"}'}}, + ] + }, + } + ) + + "\n", + encoding="utf-8", + ) + + report = import_jsonl_log( + source, + output=output, + input_field="request.messages", + output_field="response.choices.0.message.content", + metadata_fields=["request.model"], + ) + + self.assertEqual(report["records"], 1) + records = read_jsonl_records(output, "prompt", "response") + self.assertIn("Classify ticket INV-1042", records[0].prompt) + self.assertEqual(records[0].response, '{"owner": "billing"}') + self.assertEqual(records[0].raw["metadata"], {"request.model": "gpt-4o-mini"}) + + def test_import_jsonl_log_redacts_common_secrets_by_default(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / "downloaded.jsonl" + output = root / "baseline.jsonl" + source.write_text( + json.dumps( + { + "instruction": "Email ada@example.com with the result.", + "response": "Used key sk-ant-" + ("a" * 24), + "metadata": {"api_key": "secret-value"}, + } + ) + + "\n", + encoding="utf-8", + ) + + report = import_jsonl_log( + source, + output=output, + input_field="instruction", + output_field="response", + metadata_fields=["metadata"], + ) + + self.assertTrue(report["redacted"]) + self.assertEqual(report["redactions"], 3) + records = read_jsonl_records(output, "prompt", "response") + self.assertNotIn("ada@example.com", records[0].prompt) + self.assertNotIn("sk-ant-", records[0].response) + self.assertEqual(records[0].raw["metadata"]["metadata"]["api_key"], "[REDACTED]") + + def test_import_jsonl_log_can_disable_redaction_for_local_only_logs(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / "downloaded.jsonl" + output = root / "baseline.jsonl" + source.write_text( + '{"instruction": "Email ada@example.com", "response": "ok"}\n', + encoding="utf-8", + ) + + report = import_jsonl_log( + source, + output=output, + input_field="instruction", + output_field="response", + redact=False, + ) + + self.assertFalse(report["redacted"]) + self.assertEqual(report["redactions"], 0) + records = read_jsonl_records(output, "prompt", "response") + self.assertIn("ada@example.com", records[0].prompt) + def test_import_jsonl_log_reports_missing_fields(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) source = root / "downloaded.jsonl" source.write_text('{"instruction": "missing response"}\n', encoding="utf-8") - with self.assertRaisesRegex(ValueError, "missing output field: response"): + with self.assertRaisesRegex(ValueError, "redline import --list-presets"): import_jsonl_log(source, output=root / "baseline.jsonl", input_field="instruction") diff --git a/tests/test_io.py b/tests/test_io.py index 3a763b0..4bddd58 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -22,6 +22,14 @@ def test_missing_jsonl_file_raises_value_error(self) -> None: with self.assertRaisesRegex(ValueError, "not found"): read_jsonl_records("missing.jsonl", "prompt", "response") + def test_missing_jsonl_fields_suggest_import_presets(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "provider.jsonl" + path.write_text('{"instruction": "Summarize", "completion": "Done"}\n', encoding="utf-8") + + with self.assertRaisesRegex(ValueError, "redline import --list-presets"): + read_jsonl_records(path, "prompt", "response") + def test_read_json_gives_jsonl_next_step_for_extra_data(self) -> None: with tempfile.TemporaryDirectory() as directory: path = Path(directory) / "baseline.jsonl" diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 5e2c8af..420d725 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -28,10 +28,13 @@ def test_tools_list_exposes_redline_tools_with_guarded_writes(self) -> None: assert response is not None names = {tool["name"] for tool in response["result"]["tools"]} + self.assertIn("redline_status", names) self.assertIn("redline_doctor", names) self.assertIn("redline_suite", names) + self.assertIn("redline_quick_check", names) self.assertIn("redline_redact", names) self.assertIn("redline_import", names) + self.assertIn("redline_import_presets", names) self.assertIn("redline_watch_stats", names) self.assertIn("redline_watch_snippets", names) self.assertIn("redline_prompts", names) @@ -48,6 +51,49 @@ def test_tools_list_exposes_redline_tools_with_guarded_writes(self) -> None: self.assertIn("redline_mark", names) self.assertNotIn("redline_accept", names) self.assertNotIn("redline_require", names) + quick_check = next(tool for tool in response["result"]["tools"] if tool["name"] == "redline_quick_check") + self.assertIn("open_app", quick_check["inputSchema"]["properties"]) + + def test_quick_check_tool_generates_suite_and_reports(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + baseline = root / "baseline.jsonl" + candidate = root / "candidate.jsonl" + baseline.write_text( + '{"prompt": "Return JSON with owner and priority.", "response": "{\\"owner\\": \\"Support\\", \\"priority\\": \\"high\\"}"}\n', + encoding="utf-8", + ) + candidate.write_text( + '{"prompt": "Return JSON with owner and priority.", "response": "Support should handle this."}\n', + encoding="utf-8", + ) + + result = call_tool( + "redline_quick_check", + { + "cwd": directory, + "baseline_path": "baseline.jsonl", + "candidate_path": "candidate.jsonl", + "fail_on": "none", + "json": True, + }, + ) + report_dir = root / ".redline" / "quick-check" + suite_exists = (report_dir / "suite.json").exists() + json_exists = (report_dir / "diff.json").exists() + html_exists = (report_dir / "diff.html").exists() + app_exists = (report_dir / "app.html").exists() + + self.assertFalse(result["isError"]) + self.assertEqual(result["structuredContent"]["exit_code"], 0) + payload = result["structuredContent"]["json"] + self.assertEqual(payload["summary"]["regression"], 1) + self.assertEqual(payload["artifacts"]["html"], ".redline/quick-check/diff.html") + self.assertEqual(payload["artifacts"]["app"], ".redline/quick-check/app.html") + self.assertTrue(suite_exists) + self.assertTrue(json_exists) + self.assertTrue(html_exists) + self.assertTrue(app_exists) def test_import_tool_normalizes_external_jsonl(self) -> None: with tempfile.TemporaryDirectory() as directory: @@ -65,6 +111,7 @@ def test_import_tool_normalizes_external_jsonl(self) -> None: "cwd": directory, "path": str(source), "out": str(output), + "preset": "dolly", "input_field": "instruction", "output_field": "response", "context_field": "context", @@ -77,8 +124,97 @@ def test_import_tool_normalizes_external_jsonl(self) -> None: self.assertFalse(result["isError"]) self.assertEqual(result["structuredContent"]["exit_code"], 0) self.assertEqual(result["structuredContent"]["json"]["records"], 1) + self.assertEqual(result["structuredContent"]["json"]["preset"], "dolly") + self.assertTrue(result["structuredContent"]["json"]["redacted"]) self.assertTrue(wrote_output) + def test_import_tool_previews_external_jsonl_without_output(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / "downloaded.jsonl" + output = root / "baseline.jsonl" + source.write_text( + '{"instruction": "Classify", "response": "billing"}\n', + encoding="utf-8", + ) + + result = call_tool( + "redline_import", + { + "cwd": directory, + "path": str(source), + "input_field": "instruction", + "output_field": "response", + "preview": 1, + "json": True, + }, + ) + wrote_output = output.exists() + + self.assertFalse(result["isError"]) + self.assertEqual(result["structuredContent"]["exit_code"], 0) + self.assertEqual(result["structuredContent"]["json"]["previewed"], 1) + self.assertEqual(result["structuredContent"]["json"]["rows"][0]["prompt"], "Classify") + self.assertFalse(wrote_output) + + def test_import_tool_detects_external_jsonl_fields(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / "export.jsonl" + source.write_text('{"input": "Classify", "output": "billing"}\n', encoding="utf-8") + + result = call_tool( + "redline_import", + { + "cwd": directory, + "path": str(source), + "detect": True, + "json": True, + }, + ) + + self.assertFalse(result["isError"]) + self.assertEqual(result["structuredContent"]["exit_code"], 0) + self.assertEqual(result["structuredContent"]["json"]["suggestions"][0]["input_field"], "input") + self.assertEqual(result["structuredContent"]["json"]["suggestions"][0]["output_field"], "output") + + def test_import_tool_auto_maps_external_jsonl(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / "downloaded.jsonl" + output = root / "baseline.jsonl" + source.write_text('{"instruction": "Classify", "response": "billing"}\n', encoding="utf-8") + + result = call_tool( + "redline_import", + { + "cwd": directory, + "path": str(source), + "out": str(output), + "auto_map": True, + "json": True, + }, + ) + wrote_output = output.exists() + + self.assertFalse(result["isError"]) + self.assertEqual(result["structuredContent"]["exit_code"], 0) + payload = result["structuredContent"]["json"] + self.assertEqual(payload["records"], 1) + self.assertTrue(payload["auto_mapped"]) + self.assertEqual(payload["input_field"], "instruction") + self.assertEqual(payload["output_field"], "response") + self.assertTrue(wrote_output) + + def test_import_presets_tool_lists_mappings(self) -> None: + result = call_tool("redline_import_presets", {"json": True}) + + self.assertFalse(result["isError"]) + self.assertEqual(result["structuredContent"]["exit_code"], 0) + presets = result["structuredContent"]["json"]["presets"] + self.assertTrue(any(row["id"] == "langfuse" for row in presets)) + self.assertTrue(any(row["id"] == "openai-chat" for row in presets)) + def test_eval_and_diff_tools_do_not_accept_dynamic_commands(self) -> None: response = handle_jsonrpc_line( json.dumps({"jsonrpc": "2.0", "id": 20, "method": "tools/list"}) @@ -124,6 +260,7 @@ def test_prompt_get_builds_check_prompt_change_workflow(self) -> None: assert response is not None text = response["result"]["messages"][0]["content"]["text"] + self.assertIn("redline_status", text) self.assertIn("redline_doctor", text) self.assertIn("redline_eval", text) self.assertIn("prompts/v2.txt", text) @@ -154,6 +291,32 @@ def test_prompt_get_build_suite_workflow_includes_benchmark(self) -> None: self.assertIn("redline suite add", text) self.assertIn("redline_budget", text) + def test_prompt_get_review_candidate_outputs_uses_quick_check_when_baseline_is_available(self) -> None: + response = handle_jsonrpc_line( + json.dumps( + { + "jsonrpc": "2.0", + "id": 36, + "method": "prompts/get", + "params": { + "name": "review_candidate_outputs", + "arguments": { + "baseline_path": "logs/baseline.jsonl", + "candidate_path": "logs/candidate.jsonl", + }, + }, + } + ) + ) + + assert response is not None + text = response["result"]["messages"][0]["content"]["text"] + self.assertIn("redline_quick_check", text) + self.assertIn("redline_diff", text) + self.assertIn("logs/baseline.jsonl", text) + self.assertIn("logs/candidate.jsonl", text) + self.assertIn("Do not accept or modify the baseline", text) + def test_prompt_get_builds_first_time_setup_workflow(self) -> None: response = handle_jsonrpc_line( json.dumps( @@ -176,9 +339,11 @@ def test_prompt_get_builds_first_time_setup_workflow(self) -> None: assert response is not None text = response["result"]["messages"][0]["content"]["text"] + self.assertIn("redline_status", text) self.assertIn("redline_doctor", text) self.assertIn("redline_runners", text) self.assertIn("redline_watch_snippets", text) + self.assertIn("auto_map=true", text) self.assertIn("redline_prompts", text) self.assertIn("redline_suite", text) self.assertIn("redline_validate", text) @@ -193,6 +358,22 @@ def test_prompt_get_builds_first_time_setup_workflow(self) -> None: self.assertIn("support-rubric", text) self.assertIn("do not say green or neutral means semantically safe", text) + def test_status_tool_reports_next_command(self) -> None: + with tempfile.TemporaryDirectory() as directory: + result = call_tool( + "redline_status", + { + "cwd": directory, + "json": True, + }, + ) + + self.assertFalse(result["isError"]) + self.assertEqual(result["structuredContent"]["exit_code"], 0) + payload = result["structuredContent"]["json"] + self.assertEqual(payload["state"], "setup") + self.assertEqual(payload["next_command"], "redline init --runner stdio --copy-runner") + def test_unknown_prompt_returns_jsonrpc_error(self) -> None: response = handle_jsonrpc_line( json.dumps( diff --git a/tests/test_packaging.py b/tests/test_packaging.py index 72c270c..1b4c302 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -91,7 +91,7 @@ def test_demo_recording_script_runs_compact_demo(self) -> None: self.assertIn("redline demo --public --compact", text) self.assertIn("redline history", text) self.assertIn("--out-md", text) - self.assertIn("redline dashboard", text) + self.assertIn("redline app", text) def test_demo_gif_script_records_or_writes_transcript(self) -> None: script = Path("scripts/demo_gif.sh") @@ -103,6 +103,28 @@ def test_demo_gif_script_records_or_writes_transcript(self) -> None: self.assertIn("redline-demo-transcript.txt", text) self.assertIn("redline demo --public --compact", text) + def test_methodology_doc_calibrates_behavior_grouping_and_score(self) -> None: + text = Path("docs/methodology.md").read_text(encoding="utf-8") + readme = Path("README.md").read_text(encoding="utf-8") + + self.assertIn("does not run statistical, embedding, or semantic clustering", text) + self.assertIn("behavior-signature grouping", text) + self.assertIn("suite readiness score", text) + self.assertIn("not a model-quality score", text) + self.assertIn("stochastic_prompt_groups", text) + self.assertIn("docs/methodology.md", readme) + + def test_calibration_doc_links_runnable_fixture(self) -> None: + text = Path("docs/calibration.md").read_text(encoding="utf-8") + readme = Path("README.md").read_text(encoding="utf-8") + + self.assertIn("examples/calibration_baseline.jsonl", text) + self.assertIn("examples/calibration_candidate.jsonl", text) + self.assertIn("Two regressions", text) + self.assertIn("One changed case", text) + self.assertIn("One neutral case", text) + self.assertIn("docs/calibration.md", readme) + def test_release_check_builds_and_smokes_installed_wheel(self) -> None: script = Path("scripts/release_check.sh").read_text(encoding="utf-8") @@ -138,7 +160,7 @@ def test_release_check_builds_and_smokes_installed_wheel(self) -> None: self.assertIn("--out-md history.md", script) self.assertIn("redline compare .redline/demo/reports/diff.json", script) self.assertIn("--out-html compare.html", script) - self.assertIn("redline dashboard --reports-dir .redline/demo/reports", script) + self.assertIn("redline app --reports-dir .redline/demo/reports", script) self.assertIn("--github-summary", script) self.assertIn("--all-cases", script) self.assertIn("redline suite add all-suite.json", script) @@ -219,13 +241,25 @@ def test_release_guide_documents_package_gate(self) -> None: def test_readme_marks_repo_only_script_commands(self) -> None: readme = Path("README.md").read_text(encoding="utf-8") - self.assertIn("https://gowtham0992.github.io/redline/assets/redline-logo.svg", readme) - self.assertIn("https://gowtham0992.github.io/redline/assets/redline-logo-dark.svg", readme) - self.assertIn("https://gowtham0992.github.io/redline/assets/redline-product-demo.gif", readme) - self.assertIn("https://gowtham0992.github.io/redline/assets/redline-dashboard-proof.png", readme) - self.assertIn("https://gowtham0992.github.io/redline/assets/redline-report-proof.png", readme) + self.assertIn( + "https://raw.githubusercontent.com/gowtham0992/redline/main/site/assets/redline-product-demo.gif", + readme, + ) + self.assertIn( + "https://raw.githubusercontent.com/gowtham0992/redline/main/site/assets/redline-dashboard-proof.png", + readme, + ) + self.assertIn( + "https://raw.githubusercontent.com/gowtham0992/redline/main/site/assets/redline-report-proof.png", + readme, + ) + self.assertIn('

    redline

    ', readme) self.assertIn("Automatic eval suites from the prompt logs you already have", readme) self.assertIn("Product Proof", readme) + self.assertIn("100 prompt-response rows sampled from [Databricks Dolly 15k]", readme) + self.assertIn("51 regressions, 27 changed cases, 22 neutral controls", readme) + self.assertIn("0 dashboard warnings", readme) + self.assertIn("These screenshots are local artifacts from the 100-row internet dogfood run", readme) self.assertIn("Product Promise", readme) self.assertIn("[![PyPI](https://img.shields.io/pypi/v/redline-ai.svg)](https://pypi.org/project/redline-ai/)", readme) self.assertIn("[![MCP Registry]", readme) @@ -238,6 +272,7 @@ def test_readme_marks_repo_only_script_commands(self) -> None: self.assertIn("zero runtime", readme) self.assertIn("docs/troubleshooting.md", readme) self.assertIn("docs/commands.md", readme) + self.assertIn("docs/real-log-dogfood.md", readme) self.assertIn("redline judges", readme) self.assertIn("feature-level", readme) self.assertIn("From a repo checkout, record the public demo", readme) @@ -284,6 +319,7 @@ def test_troubleshooting_and_command_reference_are_public_docs(self) -> None: self.assertIn("Neutral does not mean safe", troubleshooting) self.assertIn("| `redline eval` |", commands) self.assertIn("| `redline import` |", commands) + self.assertIn("--no-redact", commands) self.assertIn("| `redline dashboard` |", commands) self.assertIn("| `redline-mcp` |", commands) self.assertIn("Default fail-on statuses", commands) @@ -331,7 +367,10 @@ def test_launch_playbook_covers_assets_and_feedback(self) -> None: readme = Path("README.md").read_text(encoding="utf-8") self.assertIn("redline-demo.gif", guide) - self.assertIn("https://gowtham0992.github.io/redline/assets/redline-product-demo.gif", guide) + self.assertIn( + "https://raw.githubusercontent.com/gowtham0992/redline/main/site/assets/redline-product-demo.gif", + guide, + ) self.assertIn("python -m pip install redline-ai", guide) self.assertIn("Website Checklist", guide) self.assertIn("Demo GIF Storyboard", guide) @@ -419,8 +458,8 @@ def test_repository_ci_runs_full_release_gate(self) -> None: self.assertIn("pull_request:", workflow) self.assertIn("- develop", workflow) self.assertIn("- main", workflow) - self.assertRegex(workflow, r"actions/checkout@v\d+") - self.assertRegex(workflow, r"actions/setup-python@v\d+") + self.assertIn("actions/checkout@v7", workflow) + self.assertIn("actions/setup-python@v6", workflow) self.assertIn('python-version: ["3.10", "3.11", "3.12", "3.13"]', workflow) self.assertIn("matrix.python-version", workflow) self.assertIn('cache: "pip"', workflow) @@ -438,6 +477,11 @@ def test_pages_workflow_deploys_public_site_from_main_only(self) -> None: self.assertIn("- main", workflow) self.assertNotIn("- develop", workflow) + self.assertIn("cancel-in-progress: true", workflow) + self.assertIn("actions/checkout@v7", workflow) + self.assertIn("actions/configure-pages@v6", workflow) + self.assertIn("actions/upload-pages-artifact@v5", workflow) + self.assertIn("actions/deploy-pages@v5", workflow) self.assertIn("path: site", workflow) def test_release_workflow_builds_artifacts_without_publishing(self) -> None: @@ -445,11 +489,12 @@ def test_release_workflow_builds_artifacts_without_publishing(self) -> None: guide = Path("docs/release.md").read_text(encoding="utf-8") self.assertIn("- \"v*\"", workflow) - self.assertRegex(workflow, r"actions/setup-python@v\d+") + self.assertIn("actions/checkout@v7", workflow) + self.assertIn("actions/setup-python@v6", workflow) self.assertIn('cache: "pip"', workflow) self.assertIn('python -m pip install -e ".[dev]"', workflow) self.assertIn('bash scripts/certify_release.sh "$RUNNER_TEMP/redline-certify"', workflow) - self.assertRegex(workflow, r"actions/upload-artifact@v\d+") + self.assertIn("actions/upload-artifact@v7", workflow) self.assertIn("redline-release-distributions", workflow) self.assertIn("redline-release-certification", workflow) self.assertNotIn("pypa/gh-action-pypi-publish", workflow) @@ -500,7 +545,7 @@ def test_repository_settings_document_launch_controls(self) -> None: self.assertIn("Dependabot alerts", guide) self.assertIn(".github/workflows/pages.yml", guide) self.assertIn("on `main`", guide) - self.assertIn("bash scripts/certify_release.sh /tmp/redline-certify-v0.2.1", guide) + self.assertIn("bash scripts/certify_release.sh /tmp/redline-certify-v0.3.0", guide) self.assertIn("docs/repository.md", readme) self.assertIn("scripts/README.md", readme) self.assertIn("redline import downloaded.jsonl", readme) @@ -550,6 +595,10 @@ def test_case_studies_use_checked_in_dogfood_data(self) -> None: self.assertIn("redline diff: cases=10 regression=9", guide) self.assertIn("docs/ai-session-dogfood-prompts.jsonl", guide) self.assertIn(".redline/private/", guide) + self.assertIn("Public Internet Dogfood", guide) + self.assertIn("Databricks Dolly 15k", guide) + self.assertIn("redline diff: cases=100 regression=51 changed=27", guide) + self.assertIn("1 report, 1 benchmark, 1 history entry, and 0", guide) self.assertIn("External Case Studies Still Needed", guide) def test_public_dogfood_fixture_documents_source_inspiration(self) -> None: diff --git a/tests/test_quickstart.py b/tests/test_quickstart.py index baf51e7..e167ef9 100644 --- a/tests/test_quickstart.py +++ b/tests/test_quickstart.py @@ -14,7 +14,7 @@ def test_readme_demo_dashboard_command_points_at_demo_reports(self) -> None: readme = Path("README.md").read_text(encoding="utf-8") self.assertIn("redline demo --public --compact", readme) - self.assertIn("redline dashboard --reports-dir .redline/demo/reports --open", readme) + self.assertIn("redline app --reports-dir .redline/demo/reports", readme) def test_readme_quickstart_path_catches_realistic_regressions(self) -> None: repo = Path(__file__).resolve().parents[1] diff --git a/tests/test_redact.py b/tests/test_redact.py index 7b54f81..d9af2bd 100644 --- a/tests/test_redact.py +++ b/tests/test_redact.py @@ -95,7 +95,7 @@ def test_format_redaction_report_points_to_next_suite_step(self) -> None: ) self.assertIn("redline redact", output) - self.assertIn("best-effort common secret/PII patterns", output) + self.assertIn("not a privacy boundary", output) self.assertIn("redline suite clean.jsonl --out redline-suite.json", output) def test_format_redaction_check_report_points_to_write_step(self) -> None: diff --git a/tests/test_reports.py b/tests/test_reports.py index 8fa1f7b..ce442fc 100644 --- a/tests/test_reports.py +++ b/tests/test_reports.py @@ -33,11 +33,23 @@ def test_markdown_report_includes_summary_and_reasons(self) -> None: }, "warnings": ["prompt file prompts/v2.txt is newer than suite"], "suite": "redline-suite.json", + "methodology": { + "name": "deterministic behavior-signature grouping", + "version": "behavior-signature-v1", + }, + "suite_summary": { + "cases": 1, + "unique_prompt_response_pairs": 2, + "clusters": 1, + "case_coverage": 0.5, + "cluster_coverage": 1.0, + }, "candidate": ".redline/runs/candidate.jsonl", "artifacts": { "json": ".redline/reports/eval.json", "markdown": ".redline/reports/eval.md", "html": ".redline/reports/eval.html", + "app": ".redline/reports/app.html", "junit": ".redline/reports/eval.xml", }, "prompt_evals": [ @@ -83,9 +95,12 @@ def test_markdown_report_includes_summary_and_reasons(self) -> None: self.assertIn("**Recommended action:** fix blocking cases before shipping", report) self.assertIn("**Scope:** structural checks only", report) self.assertIn("**Diagnosis:** Candidate lost required structure; fix blocking cases before shipping.", report) + self.assertIn("**Methodology:** deterministic behavior-signature grouping (behavior-signature-v1)", report) + self.assertIn("**Suite coverage:** cases 1/2 (50.0%); behavior groups 1/1 (100.0%)", report) self.assertIn("## Warnings", report) self.assertIn("prompt file prompts/v2.txt is newer than suite", report) self.assertIn("## Artifacts", report) + self.assertIn("| App | `.redline/reports/app.html` |", report) self.assertIn("| HTML | `.redline/reports/eval.html` |", report) self.assertIn("| JUnit | `.redline/reports/eval.xml` |", report) self.assertIn("## Owner Review", report) @@ -112,6 +127,10 @@ def test_markdown_report_includes_summary_and_reasons(self) -> None: self.assertIn("Owner: `@platform-team`", report) self.assertIn("Confidence: `high`", report) self.assertIn("Signal: `structural`", report) + self.assertIn( + "Why this matters: Downstream code may fail if consumers expect parseable JSON or required fields.", + report, + ) self.assertIn("Baseline:", report) self.assertIn('{"ok": true}', report) self.assertIn("Candidate:", report) @@ -135,6 +154,33 @@ def test_markdown_inline_code_preserves_backticks(self) -> None: self.assertIn("Prompt: ``Use `json` output``", report) + def test_markdown_report_prefers_explicit_case_impact(self) -> None: + result = { + "summary": {"regression": 1, "changed": 0, "improved": 0, "neutral": 0, "missing": 0}, + "diffs": [ + { + "case_id": "case_001", + "status": "regression", + "prompt": "Return JSON", + "baseline_response": '{"owner":"billing"}', + "candidate_response": "billing", + "reasons": ["candidate lost valid JSON format"], + "impact": "Billing routing automation may fail because the owner field disappeared.", + } + ], + } + + report = format_markdown_report(result) + + self.assertIn( + "Why this matters: Billing routing automation may fail because the owner field disappeared.", + report, + ) + self.assertNotIn( + "Why this matters: Downstream code may fail if consumers expect parseable JSON or required fields.", + report, + ) + def test_markdown_code_blocks_use_fence_longer_than_output_backticks(self) -> None: result = { "summary": {"regression": 0, "changed": 1, "improved": 0, "neutral": 0, "missing": 0}, @@ -176,6 +222,17 @@ def test_html_report_includes_side_by_side_case_detail(self) -> None: }, "warnings": ["prompt file prompts/v2.txt is newer than suite"], "suite": "redline-suite.json", + "methodology": { + "name": "deterministic behavior-signature grouping", + "version": "behavior-signature-v1", + }, + "suite_summary": { + "cases": 1, + "unique_prompt_response_pairs": 2, + "clusters": 1, + "case_coverage": 0.5, + "cluster_coverage": 1.0, + }, "candidate": ".redline/runs/candidate.jsonl", "artifacts": { "json": ".redline/reports/eval.json", @@ -226,9 +283,15 @@ def test_html_report_includes_side_by_side_case_detail(self) -> None: self.assertIn("Owner: @platform-team", report) self.assertIn("Behavior: structured JSON prompt -> JSON response (short)", report) self.assertIn("Confidence: high | Signal: structural", report) + self.assertIn("Why this matters:", report) + self.assertIn("Downstream code may fail if consumers expect parseable JSON or required fields.", report) self.assertIn("fix blocking cases before shipping", report) self.assertIn("structural checks only", report) self.assertIn("Candidate lost required structure; fix blocking cases before shipping.", report) + self.assertIn("

    Methodology

    ", report) + self.assertIn("deterministic behavior-signature grouping (behavior-signature-v1)", report) + self.assertIn("

    Suite coverage

    ", report) + self.assertIn("cases 1/2 (50.0%); behavior groups 1/1 (100.0%)", report) self.assertIn("

    Warnings

    ", report) self.assertIn("

    Artifacts

    ", report) self.assertIn("HTML.redline/reports/eval.html", report) diff --git a/tests/test_site.py b/tests/test_site.py index 5609982..712d492 100644 --- a/tests/test_site.py +++ b/tests/test_site.py @@ -27,16 +27,32 @@ class GitHubPagesSiteTests(unittest.TestCase): def test_site_homepage_has_launch_copy_and_product_paths(self) -> None: html = Path("site/index.html").read_text(encoding="utf-8") - self.assertIn("

    redline

    ", html) + self.assertIn("Catch prompt regressions", html) + self.assertIn("Local-first prompt regression diffs", html) + self.assertIn("Star on GitHub", html) self.assertIn("redline demo --public --compact", html) - self.assertIn("redline dashboard --reports-dir .redline/demo/reports --open", html) + self.assertIn("redline diff dolly-suite-100.json dolly-candidate-100.jsonl", html) + self.assertIn("redline app --reports-dir .redline/demo/reports", html) self.assertIn("redline eval --prompt prompts/v2.txt", html) + self.assertIn("candidate lost valid JSON format", html) + self.assertIn("cases=100 regression=51 changed=27", html) + self.assertIn("Databricks Dolly 15k", html) + self.assertIn("public rows dogfooded", html) + self.assertIn("runtime dependencies", html) + self.assertIn("100-row Dolly dogfood", html) + self.assertIn("0 dashboard warnings", html) + self.assertIn("Ship readiness", html) + self.assertIn("BLOCKED", html) self.assertIn("No cloud", html) self.assertIn("Optional judges", html) - self.assertIn("One command, ten regressions", html) + self.assertIn("One command for the demo. One public dataset for proof.", html) self.assertIn("AI-agnostic first", html) self.assertIn("AI assistant loop", html) self.assertIn("MCP Registry", html) + self.assertIn("CIpassing", html) + self.assertIn("PyPIredline-ai", html) + self.assertIn("MCPregistered", html) + self.assertIn("GitHubstar", html) self.assertIn("uvx --from redline-ai redline-mcp", html) self.assertIn("redline_mark requires allow_write", html) self.assertIn("What redline does not pretend", html) @@ -46,8 +62,8 @@ def test_site_homepage_has_launch_copy_and_product_paths(self) -> None: self.assertIn("Review the checks before you trust the gate", html) self.assertIn('loading="lazy"', html) self.assertIn("Generated redline product artifacts", html) - self.assertIn("Local dashboard with ship readiness", html) - self.assertIn("HTML report with concrete reasons", html) + self.assertIn("Local app from the 100-row Dolly dogfood run", html) + self.assertIn("HTML report from the same run", html) self.assertIn("bash scripts/release_check.sh", html) self.assertIn("bash scripts/action_smoke.sh", html) self.assertIn("redline history --fail-on worse", html) @@ -96,7 +112,7 @@ def test_site_links_stylesheet_and_preview_image(self) -> None: parser.links, ) self.assertIn( - ("assets/redline-preview.png", "redline terminal and dashboard preview showing four prompt regressions caught"), + ("assets/redline-product-demo.gif", "Animated redline product demo showing a prompt regression report"), parser.images, ) self.assertIn( @@ -107,10 +123,6 @@ def test_site_links_stylesheet_and_preview_image(self) -> None: ("assets/redline-report-proof.png", "redline HTML report showing concrete regression reasons and side-by-side baseline and candidate outputs"), parser.images, ) - self.assertIn( - ("https://img.shields.io/github/stars/gowtham0992/redline?style=social", "GitHub stars"), - parser.images, - ) self.assertIn(("assets/redline-mark.svg", ""), parser.images) def test_logo_assets_are_committed_svg_files(self) -> None: @@ -140,6 +152,7 @@ def test_site_css_uses_responsive_static_layout(self) -> None: self.assertIn(".certification-grid", css) self.assertIn(".assistant-grid", css) self.assertIn(".artifact-proof", css) + self.assertIn(".dogfood-proof", css) self.assertIn("border-radius: 8px", css) self.assertNotIn("letter-spacing: -", css) self.assertNotIn("font-size: clamp(", css) @@ -149,10 +162,12 @@ def test_pages_workflow_deploys_site_directory_from_main(self) -> None: self.assertIn("- main", workflow) self.assertNotIn("- develop", workflow) - self.assertRegex(workflow, r"actions/configure-pages@v\d+") - self.assertRegex(workflow, r"actions/upload-pages-artifact@v\d+") + self.assertIn("cancel-in-progress: true", workflow) + self.assertIn("actions/checkout@v7", workflow) + self.assertIn("actions/configure-pages@v6", workflow) + self.assertIn("actions/upload-pages-artifact@v5", workflow) self.assertIn("path: site", workflow) - self.assertRegex(workflow, r"actions/deploy-pages@v\d+") + self.assertIn("actions/deploy-pages@v5", workflow) if __name__ == "__main__": diff --git a/tests/test_status.py b/tests/test_status.py new file mode 100644 index 0000000..6c099ff --- /dev/null +++ b/tests/test_status.py @@ -0,0 +1,201 @@ +import contextlib +import io +import json +import os +import sys +import tempfile +import unittest +from pathlib import Path + +from redline.cli import main +from redline.io import LogRecord, write_json +from redline.suite import build_suite + + +class StatusTests(unittest.TestCase): + def test_status_guides_uninitialized_project(self) -> None: + with tempfile.TemporaryDirectory() as directory: + previous = Path.cwd() + os.chdir(directory) + try: + output = io.StringIO() + with contextlib.redirect_stdout(output): + self.assertEqual(main(["status"]), 0) + finally: + os.chdir(previous) + + text = output.getvalue() + self.assertIn("redline status", text) + self.assertIn("State: SETUP - project is not initialized", text) + self.assertIn("Next: redline init --runner stdio --copy-runner", text) + self.assertIn( + "App: redline app --reports-dir .redline/reports --history .redline/history.jsonl --checkpoint .redline/audit-checkpoint.json", + text, + ) + self.assertIn("- Config: warn - redline.json not found", text) + self.assertIn("- No redline report found yet.", text) + + def test_status_surfaces_blocking_latest_report(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + write_json( + root / "redline.json", + { + "suite": "redline-suite.json", + "replay": f"{sys.executable} -c 'import sys; print(sys.stdin.read())'", + "reports": {"json": ".redline/reports/eval.json"}, + }, + ) + suite = build_suite( + [LogRecord(1, "Return JSON with owner.", '{"owner":"support"}', {})], + source="baseline.jsonl", + input_field="prompt", + output_field="response", + all_cases=True, + ) + write_json(root / "redline-suite.json", suite) + write_json( + root / ".redline" / "reports" / "eval.json", + { + "suite": "redline-suite.json", + "summary": {"cases": 1, "regression": 1, "missing": 0, "changed": 0, "neutral": 0}, + "decision": {"recommended_action": "fix blocking cases before shipping"}, + "diffs": [ + { + "case_id": "case_001", + "suite_case_id": "case_001", + "status": "regression", + "prompt": "Return JSON with owner.", + "reasons": ["candidate lost valid JSON format"], + } + ], + }, + ) + previous = Path.cwd() + os.chdir(root) + try: + output = io.StringIO() + with contextlib.redirect_stdout(output): + self.assertEqual(main(["status"]), 0) + json_output = io.StringIO() + with contextlib.redirect_stdout(json_output): + self.assertEqual(main(["status", "--json"]), 0) + finally: + os.chdir(previous) + + text = output.getvalue() + self.assertIn("State: BLOCKED - latest report has 1 blocking case(s)", text) + self.assertIn("Next: redline case redline-suite.json case_001", text) + self.assertIn( + "App: redline app --reports-dir .redline/reports --history .redline/history.jsonl --checkpoint .redline/audit-checkpoint.json", + text, + ) + self.assertIn("- Reports: 1 in .redline/reports", text) + self.assertIn("- Summary: cases=1 regression=1 missing=0 changed=0 neutral=0", text) + self.assertIn("- Decision: fix blocking cases before shipping", text) + self.assertIn("First review case", text) + self.assertIn("- Case: case_001 (regression)", text) + self.assertIn("- Reason: candidate lost valid JSON format", text) + self.assertIn( + "- Impact: Downstream code may fail if consumers expect parseable JSON or required fields.", + text, + ) + self.assertIn("- Command: redline case redline-suite.json case_001", text) + + payload = json.loads(json_output.getvalue()) + self.assertEqual(payload["state"], "blocked") + self.assertEqual(payload["blocking"], 1) + self.assertEqual(payload["next_command"], "redline case redline-suite.json case_001") + self.assertEqual( + payload["app_command"], + "redline app --reports-dir .redline/reports --history .redline/history.jsonl --checkpoint .redline/audit-checkpoint.json", + ) + self.assertEqual( + payload["first_review_case"], + { + "case_id": "case_001", + "status": "regression", + "reason": "candidate lost valid JSON format", + "impact": "Downstream code may fail if consumers expect parseable JSON or required fields.", + "command": "redline case redline-suite.json case_001", + }, + ) + + def test_status_quotes_app_command_paths_with_spaces(self) -> None: + with tempfile.TemporaryDirectory() as directory: + previous = Path.cwd() + os.chdir(directory) + try: + output = io.StringIO() + with contextlib.redirect_stdout(output): + self.assertEqual( + main( + [ + "status", + "--reports-dir", + "redline reports", + "--history", + "history file.jsonl", + "--checkpoint", + "audit checkpoint.json", + ] + ), + 0, + ) + finally: + os.chdir(previous) + + self.assertIn( + "App: redline app --reports-dir 'redline reports' --history 'history file.jsonl' --checkpoint 'audit checkpoint.json'", + output.getvalue(), + ) + + def test_status_quotes_review_case_command_paths_with_spaces(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + suite_path = "suite files/redline suite.json" + write_json( + root / "redline.json", + { + "suite": suite_path, + "replay": f"{sys.executable} -c 'import sys; print(sys.stdin.read())'", + "reports": {"json": ".redline/reports/eval.json"}, + }, + ) + suite = build_suite( + [LogRecord(1, "Return JSON with owner.", '{"owner":"support"}', {})], + source="baseline.jsonl", + input_field="prompt", + output_field="response", + all_cases=True, + ) + write_json(root / suite_path, suite) + write_json( + root / ".redline" / "reports" / "eval.json", + { + "suite": suite_path, + "summary": {"cases": 1, "regression": 1}, + "decision": {"recommended_action": "fix blocking cases before shipping"}, + "diffs": [ + { + "case_id": "case needs review", + "status": "regression", + "prompt": "Return JSON with owner.", + "reasons": ["candidate lost valid JSON format"], + "suite": suite_path, + } + ], + }, + ) + previous = Path.cwd() + os.chdir(root) + try: + output = io.StringIO() + with contextlib.redirect_stdout(output): + self.assertEqual(main(["status"]), 0) + finally: + os.chdir(previous) + + text = output.getvalue() + self.assertIn("Next: redline case 'suite files/redline suite.json' 'case needs review'", text) + self.assertIn("- Command: redline case 'suite files/redline suite.json' 'case needs review'", text) diff --git a/tests/test_suite.py b/tests/test_suite.py index ee5d9d7..bc5827a 100644 --- a/tests/test_suite.py +++ b/tests/test_suite.py @@ -5,7 +5,7 @@ from redline.features import extract_features from redline.io import LogRecord -from redline.suite import SUITE_SCHEMA_URL, add_suite_case, build_suite +from redline.suite import SELECTION_METHODOLOGY_VERSION, SUITE_SCHEMA_URL, add_suite_case, build_suite class SuiteTests(unittest.TestCase): @@ -26,10 +26,15 @@ def test_build_suite_groups_behavioral_clusters(self) -> None: self.assertEqual(suite["summary"]["records_seen"], 3) self.assertEqual(suite["$schema"], SUITE_SCHEMA_URL) + self.assertEqual(suite["methodology"]["version"], SELECTION_METHODOLOGY_VERSION) + self.assertIn("behavior-signature", suite["methodology"]["name"]) + self.assertIn("case_selection", suite["methodology"]) self.assertEqual(suite["summary"]["unique_prompt_response_pairs"], 3) self.assertEqual(suite["summary"]["duplicate_prompt_response_pairs"], 0) self.assertEqual(suite["summary"]["cases"], 2) self.assertEqual(suite["summary"]["clusters"], 2) + self.assertEqual(suite["summary"]["case_coverage"], 2 / 3) + self.assertEqual(suite["summary"]["cluster_coverage"], 1.0) self.assertEqual(suite["summary"]["high_risk_clusters"], 0) self.assertEqual(suite["summary"]["medium_risk_clusters"], 0) self.assertEqual(suite["summary"]["prompt_diversity_cases"], 0) @@ -57,14 +62,18 @@ def test_suite_schema_documents_generated_suite_fields(self) -> None: self.assertIn("Portable prompt-response regression suite", schema["description"]) for key in ("summary", "clusters", "cases"): self.assertIn(key, schema["properties"]) + self.assertIn("methodology", schema["properties"]) case_properties = schema["properties"]["cases"]["items"]["properties"] self.assertIn("selection_reason", case_properties) self.assertIn("cluster_risk", case_properties) self.assertIn("owner", case_properties) self.assertIn("owner_rule", case_properties) self.assertIn("owned_cases", schema["properties"]["summary"]["properties"]) + self.assertIn("case_coverage", schema["properties"]["summary"]["properties"]) + self.assertIn("cluster_coverage", schema["properties"]["summary"]["properties"]) self.assertIn("prompt_diversity_cases", schema["properties"]["summary"]["properties"]) self.assertIn("non_ascii_records", schema["properties"]["summary"]["properties"]) + self.assertIn("stochastic_prompt_groups", schema["properties"]["summary"]["properties"]) def test_build_suite_assigns_case_owners_from_rules(self) -> None: suite = build_suite( @@ -295,6 +304,25 @@ def test_build_suite_counts_non_ascii_records(self) -> None: self.assertEqual(suite["summary"]["non_ascii_records"], 1) + def test_build_suite_counts_repeated_prompts_with_distinct_responses(self) -> None: + suite = build_suite( + [ + LogRecord(1, "Classify ticket", "billing", {}), + LogRecord(2, "Classify ticket", "support", {}), + LogRecord(3, "Classify ticket", "billing", {}), + LogRecord(4, "Summarize ticket", "Customer needs a refund.", {}), + ], + source="logs/baseline.jsonl", + input_field="prompt", + output_field="response", + max_cases=10, + ) + + self.assertEqual(suite["summary"]["records_seen"], 4) + self.assertEqual(suite["summary"]["unique_prompt_response_pairs"], 3) + self.assertEqual(suite["summary"]["duplicate_prompt_response_pairs"], 1) + self.assertEqual(suite["summary"]["stochastic_prompt_groups"], 1) + def test_add_suite_case_pins_manual_case(self) -> None: suite = build_suite( [LogRecord(1, "Return JSON", '{"ok": true}', {})], diff --git a/tests/test_summary.py b/tests/test_summary.py index 530ea02..f1d3b19 100644 --- a/tests/test_summary.py +++ b/tests/test_summary.py @@ -33,6 +33,8 @@ def test_suite_summary_counts_cases_clusters_and_judgments(self) -> None: self.assertEqual(summary["source"], "logs/baseline.jsonl") self.assertEqual(summary["selection"], "representative") + self.assertEqual(summary["methodology_version"], "behavior-signature-v1") + self.assertEqual(summary["methodology_name"], "deterministic behavior-signature grouping") self.assertEqual(summary["records_seen"], 2) self.assertEqual(summary["unique_prompt_response_pairs"], 2) self.assertEqual(summary["duplicate_prompt_response_pairs"], 0) @@ -54,6 +56,12 @@ def test_suite_summary_counts_cases_clusters_and_judgments(self) -> None: self.assertEqual(summary["judgment_cases"], 1) self.assertEqual(summary["explicit_guard_cases"], 1) self.assertEqual(summary["explicit_guard_coverage"], 0.5) + self.assertEqual(summary["suite_readiness"]["score"], 80) + self.assertEqual(summary["suite_readiness"]["label"], "strong") + self.assertIn( + "many cases have requirements or recorded judgments", + summary["suite_readiness"]["reasons"], + ) self.assertEqual(summary["judgments"], {"expected": 1}) self.assertEqual(summary["requirements"], 1) self.assertEqual(summary["failure_pattern_clusters"], 0) @@ -89,20 +97,25 @@ def test_format_suite_summary_is_readable(self) -> None: self.assertIn("redline summary", output) self.assertIn("Source:", output) self.assertIn("Selection:", output) + self.assertIn("Methodology:", output) + self.assertIn("behavior-signature-v1", output) self.assertIn("Records seen:", output) self.assertIn("Unique pairs:", output) self.assertIn("Duplicate pairs:", output) - self.assertIn("Cluster coverage:", output) + self.assertIn("Group coverage:", output) self.assertIn("Case coverage:", output) + self.assertIn("Suite readiness:", output) + self.assertIn("Readiness scope: suite health, not model quality or candidate safety", output) self.assertIn("Pinned cases:", output) self.assertIn("Owned cases:", output) self.assertIn("Owner rule coverage:", output) self.assertIn("Accepted baselines:", output) self.assertIn("Approved baselines:", output) self.assertIn("Explicit guard coverage:", output) - self.assertIn("High-risk clusters:", output) - self.assertIn("Failure-pattern clusters:", output) - self.assertIn("Top clusters:", output) + self.assertIn("High-risk groups:", output) + self.assertIn("Failure-pattern groups:", output) + self.assertIn("Top groups:", output) + self.assertIn("Readiness signals:", output) self.assertIn("structured JSON prompt -> JSON response", output) def test_suite_summary_surfaces_non_ascii_calibration(self) -> None: @@ -121,6 +134,26 @@ def test_suite_summary_surfaces_non_ascii_calibration(self) -> None: self.assertIn("Non-ASCII records: 1", output) self.assertIn("entity/refusal heuristics are English-oriented", "\n".join(summary["next_steps"])) + def test_suite_summary_surfaces_stochastic_baseline_calibration(self) -> None: + suite = build_suite( + [ + LogRecord(1, "Classify ticket", "billing", {}), + LogRecord(2, "Classify ticket", "support", {}), + ], + source="memory", + input_field="prompt", + output_field="response", + max_cases=10, + ) + + summary = suite_summary(suite) + output = format_suite_summary(suite) + + self.assertEqual(summary["stochastic_prompt_groups"], 1) + self.assertIn("Stochastic prompts: 1", output) + self.assertIn("natural sampling variance can look like regressions", "\n".join(summary["next_steps"])) + self.assertTrue(any("stochastic baseline review" in reason for reason in summary["suite_readiness"]["reasons"])) + def test_suite_summary_counts_owners(self) -> None: suite = build_suite( [ @@ -271,9 +304,10 @@ def test_suite_summary_recommends_more_coverage_when_budget_is_tight(self) -> No self.assertEqual(summary["clusters"], 2) self.assertEqual(summary["case_coverage"], 0.5) self.assertEqual(summary["cluster_coverage"], 0.5) + self.assertEqual(summary["suite_readiness"]["label"], "needs_work") self.assertIn("Increase --max-cases", summary["next_steps"][0]) self.assertIn("redline suite add redline-suite.json --prompt-file", output) - self.assertIn("Cluster coverage: 1/2 (50.0%)", output) + self.assertIn("Group coverage: 1/2 (50.0%)", output) self.assertIn("Next:", output) diff --git a/tests/test_validate.py b/tests/test_validate.py index 67a1ac2..2fbb318 100644 --- a/tests/test_validate.py +++ b/tests/test_validate.py @@ -24,6 +24,50 @@ def test_validate_suite_accepts_generated_suite(self) -> None: self.assertEqual(report["warnings"], 0) self.assertEqual(report["next_steps"], []) + def test_validate_suite_warns_for_legacy_missing_version_metadata(self) -> None: + suite = build_suite( + [LogRecord(1, "Return JSON", '{"ok": true}', {})], + source="logs/baseline.jsonl", + input_field="prompt", + output_field="response", + max_cases=10, + ) + del suite["version"] + + report = validate_suite(suite, suite_path="redline-suite.json") + + self.assertTrue(report["valid"]) + self.assertEqual(report["warnings"], 1) + self.assertTrue( + any(item["path"] == "version" and "missing suite version" in item["message"] for item in report["items"]) + ) + self.assertIn( + "Regenerate suite metadata from trusted logs: redline suite logs/baseline.jsonl --out redline-suite.json", + report["next_steps"], + ) + + def test_validate_suite_rejects_unsupported_future_version(self) -> None: + suite = build_suite( + [LogRecord(1, "Return JSON", '{"ok": true}', {})], + source="logs/baseline.jsonl", + input_field="prompt", + output_field="response", + max_cases=10, + ) + suite["version"] = "99.0" + + report = validate_suite(suite, suite_path="redline-suite.json") + + self.assertFalse(report["valid"]) + self.assertEqual(report["errors"], 1) + self.assertTrue( + any(item["path"] == "version" and "unsupported suite version 99.0" in item["message"] for item in report["items"]) + ) + self.assertIn( + "Regenerate or migrate unsupported suite schema: redline suite logs/baseline.jsonl --out redline-suite.json", + report["next_steps"], + ) + def test_validate_suite_rejects_duplicate_case_ids(self) -> None: suite = build_suite( [