Repository navigation
test(notes): add LLM-notes eval harness and prompt snapshot tests #609
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
f228589
test(notes): add LLM-notes eval harness and prompt snapshot tests
github-actions[bot] 44dba53
test(notes): address eval-harness review — refresh fixtures, tense-ch…
github-actions[bot] 2d8c802
test(notes): pin eval capabilities and write through the cache instea…
github-actions[bot] File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| /** | ||
| * Deterministic quality checks for generated release notes. Each returns the list of violations it | ||
| * found (empty = clean), so the spec can assert `toEqual([])` and surface the offending lines on | ||
| * failure. Kept pure and content-based so they hold for both replayed fixtures and live-model output. | ||
| */ | ||
|
|
||
| /** Internal HTML markers (`<!-- releasekit-… -->`) must never leak into user-facing notes. */ | ||
| export function findMarkerLeaks(text: string): string[] { | ||
| return text.match(/<!--\s*releasekit-[^>]*-->/g) ?? []; | ||
| } | ||
|
|
||
| /** A model that restates "Updated dependencies" per bump produces noisy, duplicated churn lines. */ | ||
| export function findDuplicateDependencyChurn(text: string): string[] { | ||
| const lines = text.split('\n').filter((line) => /updated dependencies/i.test(line)); | ||
| return lines.length > 1 ? lines : []; | ||
| } | ||
|
|
||
| /** Guard against a truncated (empty/near-empty) or runaway generation. */ | ||
| export function checkLengthBounds(text: string, min: number, max: number): string[] { | ||
| const n = text.trim().length; | ||
| if (n < min) return [`output too short: ${n} < ${min} chars`]; | ||
| if (n > max) return [`output too long: ${n} > ${max} chars`]; | ||
| return []; | ||
| } | ||
|
|
||
| const IRREGULAR_PAST = new Set(['made', 'built', 'brought', 'rewrote', 'drove', 'took', 'gave', 'kept', 'shipped']); | ||
|
|
||
| function looksPastTense(word: string): boolean { | ||
| const w = word.toLowerCase().replace(/[^a-z]/g, ''); | ||
| return w.endsWith('ed') || IRREGULAR_PAST.has(w); | ||
| } | ||
|
|
||
| /** Markdown list-item lines — bullets (`- …`, `* …`) and ordered items (`1. …`). */ | ||
| export function listItemLines(text: string): string[] { | ||
| return text.split('\n').filter((line) => /^\s*(?:[-*]|\d+\.)\s+\S/.test(line)); | ||
| } | ||
|
|
||
| /** | ||
| * The style guides all mandate past tense ("Added feature", not "Add feature"). Check that a majority | ||
| * of list items lead with a past-tense verb — a ratio, not all-or-nothing, since an item may | ||
| * legitimately open with a proper noun or an API name. Both bullet and numbered lists are inspected, | ||
| * so a model that switches to a numbered list can't bypass the check. Pure prose (no list items) has | ||
| * no reliable per-change verb to inspect, so it is not tense-checked here. | ||
| */ | ||
| export function checkPastTenseLeaning(text: string, minRatio = 0.6): string[] { | ||
| const items = listItemLines(text); | ||
| if (items.length === 0) return []; | ||
| const firstWords = items.map((line) => line.replace(/^\s*(?:[-*]|\d+\.)\s+/, '').split(/\s+/)[0] ?? ''); | ||
| const pastCount = firstWords.filter(looksPastTense).length; | ||
| const ratio = pastCount / items.length; | ||
| return ratio >= minRatio ? [] : [`only ${pastCount}/${items.length} items lead past-tense (< ${minRatio})`]; | ||
| } |
1 change: 1 addition & 0 deletions
1
...eval/fixtures/cache/203073b4cd17409091bd9e90fdfdb46b681cac7015dfa98ae70ea09cc081c3fc.json
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| {"content":"## What's new in 2.0.0\n\nThis release brought real-time capabilities to the widget and smoothed over a few rough edges since 1.4.2.\n\n### Added\n- Added a streaming API for real-time updates.\n- Added deeplink support for the mobile client.\n\n### Fixed\n- Fixed a crash that occurred when the config file was missing.\n\n### Changed\n- Improved error messages for invalid configuration.\n- Updated dependencies.\n\nThanks to everyone who contributed to this release!"} |
30 changes: 30 additions & 0 deletions
30
packages/notes/test/eval/fixtures/release-notes-basic.json
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| { | ||
| "context": { | ||
| "packageName": "@acme/widget", | ||
| "version": "2.0.0", | ||
| "previousVersion": "1.4.2", | ||
| "date": "2026-01-01" | ||
| }, | ||
| "entries": [ | ||
| { | ||
| "type": "added", | ||
| "description": "Add a streaming API for real-time updates" | ||
| }, | ||
| { | ||
| "type": "added", | ||
| "description": "Add deeplink support for the mobile client" | ||
| }, | ||
| { | ||
| "type": "fixed", | ||
| "description": "Fix a crash when the config file is missing" | ||
| }, | ||
| { | ||
| "type": "changed", | ||
| "description": "Improve error messages for invalid configuration" | ||
| }, | ||
| { | ||
| "type": "changed", | ||
| "description": "Update dependencies" | ||
| } | ||
| ] | ||
| } |
18 changes: 18 additions & 0 deletions
18
packages/notes/test/eval/fixtures/release-notes-basic.recorded.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| <!-- recorded: hand-authored --> | ||
|
|
||
| ## What's new in 2.0.0 | ||
|
|
||
| This release brought real-time capabilities to the widget and smoothed over a few rough edges since 1.4.2. | ||
|
|
||
| ### Added | ||
| - Added a streaming API for real-time updates. | ||
| - Added deeplink support for the mobile client. | ||
|
|
||
| ### Fixed | ||
| - Fixed a crash that occurred when the config file was missing. | ||
|
|
||
| ### Changed | ||
| - Improved error messages for invalid configuration. | ||
| - Updated dependencies. | ||
|
|
||
| Thanks to everyone who contributed to this release! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,134 @@ | ||
| import { readFileSync, writeFileSync } from 'node:fs'; | ||
| import { fileURLToPath } from 'node:url'; | ||
| import type { ChangelogEntry } from '../../src/core/types.js'; | ||
| import { type CacheIdentity, withContentHashCache } from '../../src/llm/cache.js'; | ||
| import type { ReleaseNotesContext } from '../../src/llm/index.js'; | ||
| import type { CompleteResult, LLMMessage, LLMProvider, ProviderCapabilities } from '../../src/llm/provider.js'; | ||
|
|
||
| /** | ||
| * Eval harness for the LLM-notes pipeline. Golden commit sets run through the real pipeline against | ||
| * recorded provider responses, replayed deterministically in CI via the existing on-disk cache format | ||
| * ({@link withContentHashCache}) — no API keys needed. The value is in the deterministic assertions: | ||
| * a prompt or post-processing regression changes the output, and a changed prompt busts the cache key, | ||
| * so a missing fixture is itself the signal to re-record. | ||
| * | ||
| * Modes (env): | ||
| * default strict replay from the committed cache; a cache miss fails loudly. | ||
| * RELEASEKIT_EVAL_RECORD=1 seed the cache from the human-readable `*.recorded.md` fixtures (no model). | ||
| * RELEASEKIT_EVAL=1 run a real provider (Ollama), recording fresh responses into the cache | ||
| * and back into `*.recorded.md`. | ||
| * | ||
| * The recording modes pass `refresh` so the cache writes through instead of serving the entry it is | ||
| * meant to replace. They rewrite the entries the cases they run produce, and nothing else — changing | ||
| * a prompt therefore leaves the old entry behind under its now-unreachable key. To re-record from a | ||
| * clean slate, delete the fixture cache first: | ||
| * | ||
| * rm -rf packages/notes/test/eval/fixtures/cache | ||
| * RELEASEKIT_EVAL_RECORD=1 pnpm --filter @releasekit/notes test | ||
| */ | ||
|
|
||
| export const EVAL_DIR = fileURLToPath(new URL('.', import.meta.url)); | ||
| export const CACHE_DIR = fileURLToPath(new URL('./fixtures/cache', import.meta.url)); | ||
|
|
||
| // Fixed across record and replay: the cache key folds in provider name + identity, so both must be | ||
| // stable regardless of which underlying model (canned, offline, or a real Ollama) produced a response. | ||
| const EVAL_PROVIDER_NAME = 'eval'; | ||
| export const EVAL_IDENTITY: CacheIdentity = { model: 'eval-fixture' }; | ||
|
|
||
| /** | ||
| * Every mode reports these, the real provider included — capabilities are not free-floating metadata. | ||
| * A task consults them to decide whether to send a structured-output `schema`/`toolName`, and both are | ||
| * part of the cache key, so a provider that advertises different capabilities keys the same golden | ||
| * input differently. Ollama advertises `structuredOutputs: true`; letting that through would mean a | ||
| * live recording of a structured case could never be found by the replay that has to read it back. | ||
| */ | ||
| export const CAPABILITIES: ProviderCapabilities = { systemRole: true, structuredOutputs: false, toolUse: false }; | ||
|
|
||
| export const isLiveMode = process.env.RELEASEKIT_EVAL === '1' || process.env.RELEASEKIT_EVAL === 'true'; | ||
| export const isRecordMode = process.env.RELEASEKIT_EVAL_RECORD === '1' || process.env.RELEASEKIT_EVAL_RECORD === 'true'; | ||
|
|
||
| const strictOfflineProvider: LLMProvider = { | ||
| name: EVAL_PROVIDER_NAME, | ||
| capabilities: CAPABILITIES, | ||
| async complete(): Promise<CompleteResult> { | ||
| throw new Error( | ||
| 'eval replay: no recorded fixture for this request. The prompt or golden input changed — ' + | ||
| 're-record with RELEASEKIT_EVAL_RECORD=1 (from *.recorded.md) or RELEASEKIT_EVAL=1 (live provider).', | ||
| ); | ||
| }, | ||
| }; | ||
|
|
||
| /** Base provider that returns a fixed canned response, used to seed fixtures from `*.recorded.md`. */ | ||
| function cannedProvider(content: string): LLMProvider { | ||
| return { | ||
| name: EVAL_PROVIDER_NAME, | ||
| capabilities: CAPABILITIES, | ||
| async complete(): Promise<CompleteResult> { | ||
| return { content }; | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Re-brand a real provider under the fixed eval name and capabilities so its recorded responses key | ||
| * like the rest, and tee each response back into `*.recorded.md` so the human-readable fixture and | ||
| * the cache entry stay the same generation — with the model that produced it recorded alongside. | ||
| */ | ||
| export function asEvalProvider(base: LLMProvider, caseName: string, provenance: string): LLMProvider { | ||
| return { | ||
| name: EVAL_PROVIDER_NAME, | ||
| capabilities: CAPABILITIES, | ||
| async complete(messages: LLMMessage[], options): Promise<CompleteResult> { | ||
| const result = await base.complete(messages, options); | ||
| writeRecorded(caseName, result.content, provenance); | ||
| return result; | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| export interface GoldenCase { | ||
| entries: ChangelogEntry[]; | ||
| context: ReleaseNotesContext; | ||
| } | ||
|
|
||
| /** Load a golden input fixture (a real-shaped commit set with a fixed date, so cache keys are stable). */ | ||
| export function loadGoldenCase(name: string): GoldenCase { | ||
| const path = fileURLToPath(new URL(`./fixtures/${name}.json`, import.meta.url)); | ||
| return JSON.parse(readFileSync(path, 'utf-8')) as GoldenCase; | ||
| } | ||
|
|
||
| function recordedPath(name: string): string { | ||
| return fileURLToPath(new URL(`./fixtures/${name}.recorded.md`, import.meta.url)); | ||
| } | ||
|
|
||
| // Provenance header stamped on a recording. The assertions' calibration (length bounds, tense ratio) | ||
| // is implicitly tuned to whatever produced the fixture, and the cache key deliberately pins the model | ||
| // to EVAL_IDENTITY so replay works — which leaves nothing else recording what actually generated it. | ||
| const PROVENANCE_HEADER = /^<!--\s*recorded:.*?-->\n+/; | ||
|
|
||
| function loadRecorded(name: string): string { | ||
| return readFileSync(recordedPath(name), 'utf-8').replace(PROVENANCE_HEADER, '').trimEnd(); | ||
| } | ||
|
|
||
| function writeRecorded(name: string, content: string, provenance: string): void { | ||
| writeFileSync(recordedPath(name), `<!-- recorded: ${provenance} -->\n\n${content.trimEnd()}\n`); | ||
| } | ||
|
|
||
| /** | ||
| * The provider each eval case runs through. Live mode wraps a real Ollama provider (recording as it | ||
| * goes); record mode seeds from the committed markdown; default mode replays strictly from the cache. | ||
| * Every mode wraps {@link withContentHashCache} so the on-disk format is identical across them. | ||
| */ | ||
| export async function evalProvider(caseName: string): Promise<LLMProvider> { | ||
| if (isLiveMode) { | ||
| const { OllamaProvider } = await import('../../src/llm/ollama.js'); | ||
| const model = process.env.RELEASEKIT_EVAL_MODEL ?? 'llama3.2'; | ||
| const baseURL = process.env.OLLAMA_BASE_URL; | ||
| const live = asEvalProvider(new OllamaProvider({ model, baseURL }), caseName, `ollama/${model}`); | ||
| return withContentHashCache(live, EVAL_IDENTITY, CACHE_DIR, { refresh: true }); | ||
| } | ||
| if (isRecordMode) { | ||
| return withContentHashCache(cannedProvider(loadRecorded(caseName)), EVAL_IDENTITY, CACHE_DIR, { refresh: true }); | ||
| } | ||
| return withContentHashCache(strictOfflineProvider, EVAL_IDENTITY, CACHE_DIR); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| import { describe, expect, it } from 'vitest'; | ||
| import type { CompleteResult, LLMProvider } from '../../src/llm/provider.js'; | ||
| import { generateReleaseNotes } from '../../src/llm/tasks/release-notes.js'; | ||
| import { | ||
| checkLengthBounds, | ||
| checkPastTenseLeaning, | ||
| findDuplicateDependencyChurn, | ||
| findMarkerLeaks, | ||
| } from './assertions.js'; | ||
| import { asEvalProvider, CAPABILITIES, evalProvider, isLiveMode, loadGoldenCase } from './harness.js'; | ||
|
|
||
| /** | ||
| * Golden-fixture eval for the LLM-notes pipeline. Runs a real commit set through the real pipeline and | ||
| * checks the output against deterministic quality assertions. By default the provider response is | ||
| * replayed from a committed cache fixture (no keys, deterministic); with RELEASEKIT_EVAL=1 it runs a | ||
| * live model, and with RELEASEKIT_EVAL_RECORD=1 it re-seeds the fixture from the recorded markdown. | ||
| * The assertions are the regression net — they hold whether the response is replayed or freshly | ||
| * generated, so a prompt or post-processing change that degrades output is caught. | ||
| */ | ||
| describe('notes eval: release notes', () => { | ||
| it( | ||
| 'should generate clean release notes for the basic golden case', | ||
| async () => { | ||
| const golden = loadGoldenCase('release-notes-basic'); | ||
| const provider = await evalProvider('release-notes-basic'); | ||
|
|
||
| const notes = await generateReleaseNotes(provider, golden.entries, golden.context); | ||
|
|
||
| expect(findMarkerLeaks(notes)).toEqual([]); | ||
| expect(findDuplicateDependencyChurn(notes)).toEqual([]); | ||
| expect(checkLengthBounds(notes, 80, 4000)).toEqual([]); | ||
| expect(checkPastTenseLeaning(notes)).toEqual([]); | ||
| }, | ||
| isLiveMode ? 180_000 : 10_000, | ||
| ); | ||
|
|
||
| // Guards the one thing that makes record→replay work at all. Capabilities decide whether a task | ||
| // sends a structured-output schema/toolName, and those are part of the cache key — so a live | ||
| // recording made under the real provider's capabilities would key differently from the replay that | ||
| // has to read it back. Ollama advertises structuredOutputs: true, so this fails the moment the | ||
| // wrapper passes the base provider's capabilities through instead of the harness's fixed set. | ||
| it('should report the fixed eval capabilities regardless of what the live provider advertises', () => { | ||
| const ollamaLike: LLMProvider = { | ||
| name: 'ollama', | ||
| capabilities: { systemRole: true, structuredOutputs: true, toolUse: false, honorsTemperature: true }, | ||
| complete: async (): Promise<CompleteResult> => ({ content: '' }), | ||
| }; | ||
|
|
||
| expect(asEvalProvider(ollamaLike, 'unused', 'test').capabilities).toEqual(CAPABILITIES); | ||
| }); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.