Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
8831720
init
larsPlessing Jul 30, 2026
a5e0a5a
Add deterministic retrieval eval suite for the CAP MCP server
larsPlessing Jul 30, 2026
a12cc34
Use oldest run as baseline; drop baseline.json
larsPlessing Jul 30, 2026
36c1e86
remove offline
larsPlessing Jul 30, 2026
5360890
.
larsPlessing Jul 30, 2026
7ba5c98
Score search_docs as a black box via URL#breadcrumb doc ids
larsPlessing Jul 31, 2026
6ef3ff2
improvments
larsPlessing Jul 31, 2026
d149a97
comments
larsPlessing Jul 31, 2026
3165171
evals: replace inline node -e scripts with bin/ entry files; drop eva…
larsPlessing Jul 31, 2026
c130f2f
evals: fix review findings
larsPlessing Jul 31, 2026
339af42
evals: address review findings
larsPlessing Aug 3, 2026
00557b7
evals: revert golden set to the original 10 questions
larsPlessing Aug 3, 2026
fbf047f
evals: satisfy no-console lint like the rest of the server
larsPlessing Aug 3, 2026
f937925
evals: set CDS_MCP_OFFLINE at the entry point, not per-run
larsPlessing Aug 3, 2026
611e44a
evals: trim comments to essentials on recently-changed code
larsPlessing Aug 3, 2026
bc56abe
evals: id = Source URL, throw on missing URL; trim comments
larsPlessing Aug 4, 2026
f6ddcaa
evals: add optional run label (EVAL_LABEL) shown in console + compare…
larsPlessing Aug 5, 2026
8ae4c39
evals: run label, warn-based checks, generated-anker ids, expandable …
larsPlessing Aug 5, 2026
b3f520f
evals: drop post-run console block, dedupe compare helpers
larsPlessing Aug 5, 2026
848d7ba
evals: snapshot retrieved chunk text per slot into the report
larsPlessing Aug 5, 2026
a01fd28
make embedding model configurable via CDS_MCP_MODEL / EVAL_MODEL
larsPlessing Aug 6, 2026
ec2d2cb
evals: add tests for model config (CDS_MCP_MODEL propagation + proven…
larsPlessing Aug 6, 2026
93092bd
evals: always include model name in the run label
larsPlessing Aug 6, 2026
7364e5a
evals: test that model switch uses the correct corpus for id resolution
larsPlessing Aug 6, 2026
760e1bb
support ONNX external-data files for models like pplx-embed
larsPlessing Aug 6, 2026
79321b0
use onnxruntime-node for models with external ONNX data files (pplx-e…
larsPlessing Aug 6, 2026
138f549
compare.html: leaderboard, short x-axis labels, rank replaces delta/b…
larsPlessing Aug 6, 2026
94bb3e3
compare.html: rotate x-axis labels -45° to prevent overlap
larsPlessing Aug 6, 2026
f1d622e
compare.html: one label per dot (no skipping), full label on hover
larsPlessing Aug 6, 2026
e3d642c
compare.html: 3-column grid (max 2 metrics per column)
larsPlessing Aug 6, 2026
1ba386e
compare.html: 2-column grid (max 2 charts per row)
larsPlessing Aug 6, 2026
f8f51b4
leaderboard: show all 5 metrics, not just gated ones
larsPlessing Aug 6, 2026
c4b055b
tests: cover leaderboard (all 5 metrics), ranking, rank column, x-axi…
larsPlessing Aug 6, 2026
c1d8baa
ids: fallback synthetic capire:// URL for breadcrumb-only chunks (no …
larsPlessing Aug 6, 2026
fbb92c7
evals: add embed-history.js to re-embed a corpus history with any model
larsPlessing Aug 6, 2026
9819eb5
config
larsPlessing Aug 6, 2026
460ab30
evals:compare: accept --runs <path> and --out <path> CLI args
larsPlessing Aug 7, 2026
3ef7322
docs: expand Configuration with a useful commands subsection
larsPlessing Aug 7, 2026
16b1579
remove EVAL_MODEL and EVAL_RUNS; use CDS_MCP_MODEL directly, run once…
larsPlessing Aug 7, 2026
c54e968
Add progress logging to createEmbeddings (every 50 chunks by default)
larsPlessing Aug 8, 2026
620dbcd
parseId: search full chunk body for Source: URL, not just first line
larsPlessing Aug 8, 2026
d664610
evals: credit retrieved chunk as hit when golden URL appears in its b…
larsPlessing Aug 10, 2026
08c08b9
golden set: add capire-11 — destinations in multitenant deployment (b…
larsPlessing Aug 10, 2026
978f443
golden set: capire-11 — cds watch + inner loop (tests multi-Source ch…
larsPlessing Aug 10, 2026
eaf5fd0
better golden set
larsPlessing Aug 11, 2026
926c6ee
Address PR #129 review
larsPlessing Aug 11, 2026
9409496
evals: resolve doc ids via a Source tree from llms-full.txt
larsPlessing Aug 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,6 @@ models/
embeddings/
node_modules/
.claude

# Transient eval output — result.jsonl and compare.html/.md (recreated on demand)
evals/runs/
102 changes: 102 additions & 0 deletions evals/bin/build-source-tree.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/* eslint-disable no-console */
// Build a Source tree from docs-resources/llms-full.txt so the eval can resolve
// EVERY doc section a corpus chunk covers — not just the one on its first
// `> Source:` line. A chunk is a slice of llms-full.txt spanning several
// headings; deeper headings carry their own `> Source:` line, but a chunk
// boundary can split a heading from its Source line. The tree recovers those.
//
// Output (evals/data/source-tree.json):
// {
// source: "<abs path to llms-full.txt>",
// tree: { "<page-path>": ["<anchor-url>", ...] }, // page → its anchors
// byHeadingInPage: { "<page-path>": { "<heading-lc>": "<source-url>" } },
// byBreadcrumb: { "<a > b > c lc>": "<source-url>" }, // full heading path → url
// byLeaf: { "<heading-lc>": "<source-url>" } // leaf heading → url
// }
//
// byBreadcrumb / byLeaf let the eval score the live LLM-summary corpus, whose
// chunks are keyed by a breadcrumb ("The Bookshop Sample > Databases") instead of
// a `> Source:` URL. Regenerate with `npm run evals:build-source-tree`. Do not hand-edit.
import fs from 'fs/promises'
import path from 'path'
import { fileURLToPath } from 'url'

const HERE = path.dirname(fileURLToPath(import.meta.url))
const OUT = path.join(HERE, '..', 'data', 'source-tree.json')

// Default location of the docs export; override with argv[2].
const DEFAULT_SRC = '/Users/i543501/SAPDevelop/docs-resources/llms-full.txt'

const HEADING = /^(#{1,6})\s+(.*\S)\s*$/
const SOURCE = /^>\s*Source:\s*(\S+)/

// Strip a trailing VitePress heading attribute like `{.subtitle}`.
function cleanHeading(t) {
return t.replace(/\s*\{[^}]*\}\s*$/, '').trim()
}

export function buildSourceTree(text) {
const tree = {}
const byHeadingInPage = {}
const byBreadcrumb = {} // full heading path ("A > B > C") → url
const byLeaf = {} // leaf heading ("C") → url (first occurrence wins)
const stack = [] // [{ level, text }] heading ancestry
let lastHeading = null // leaf text of the most recent heading
let lastCrumb = null // full path of the most recent heading
for (const line of text.split('\n')) {
const h = HEADING.exec(line)
if (h) {
const level = h[1].length
const txt = cleanHeading(h[2])
while (stack.length && stack[stack.length - 1].level >= level) stack.pop()
stack.push({ level, text: txt })
lastHeading = txt
lastCrumb = stack.map(s => s.text).join(' > ')
continue
}
const s = SOURCE.exec(line)
if (s) {
const url = s[1]
const page = url.split('#')[0]
const anchors = (tree[page] ||= [])
if (url.includes('#') && !anchors.includes(url)) anchors.push(url)
if (lastHeading !== null) {
const map = (byHeadingInPage[page] ||= {})
// Page-scoped, so cross-page heading collisions can't occur; a heading
// repeated on ONE page keeps the first occurrence (the un-suffixed anchor).
const key = lastHeading.toLowerCase()
if (!(key in map)) map[key] = url
const crumbKey = lastCrumb.toLowerCase()
if (!(crumbKey in byBreadcrumb)) byBreadcrumb[crumbKey] = url
if (!(key in byLeaf)) byLeaf[key] = url
}
lastHeading = null
lastCrumb = null
}
}
return { tree, byHeadingInPage, byBreadcrumb, byLeaf }
}

async function main() {
const srcPath = process.argv[2] ? path.resolve(process.argv[2]) : DEFAULT_SRC
const text = await fs.readFile(srcPath, 'utf8')
const { tree, byHeadingInPage, byBreadcrumb, byLeaf } = buildSourceTree(text)
const pages = Object.keys(tree).length
const anchors = Object.values(tree).reduce((n, a) => n + a.length, 0)
await fs.writeFile(
OUT,
JSON.stringify({ source: srcPath, tree, byHeadingInPage, byBreadcrumb, byLeaf }, null, 2)
)
console.log(
`Wrote ${path.relative(process.cwd(), OUT)} — ${pages} pages, ${anchors} anchors, ` +
`${Object.keys(byBreadcrumb).length} breadcrumbs`
)
}

// Run as a script; importable for tests.
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch(e => {
console.error(e)
process.exit(1)
})
}
40 changes: 40 additions & 0 deletions evals/bin/compare.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/* eslint-disable no-console */
// Entry point for `npm run evals:compare`: (re)build the comparison report.
//
// Optional CLI args:
// --runs <path> path to a result.jsonl file OR a runs dir
// --out <path> output path for compare.html / compare.md
//
// Examples:
// npm run evals:compare
// node evals/bin/compare.js --runs runs-xenova/result.jsonl
// node evals/bin/compare.js --runs runs-pplx/ --out runs-pplx/compare.html
Comment on lines +8 to +11

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really need the binary?

import path from 'path'
import { compare } from '../lib/compare.js'

const args = process.argv.slice(2)
const get = flag => { const i = args.indexOf(flag); return i !== -1 ? args[i + 1] : null }
const runsArg = get('--runs')
const outArg = get('--out')

// runsArg may be a result.jsonl file or a directory — normalise to a dir.
let overrides = {}
let outPath = outArg || undefined

if (runsArg) {
const abs = path.resolve(runsArg)
const isJsonl = abs.endsWith('.jsonl')
const runsDir = isJsonl ? path.dirname(abs) : abs
const resultsName = isJsonl ? path.basename(abs) : undefined
overrides.paths = { runsDir }
if (resultsName) overrides.output = { resultsName }
// default output alongside the jsonl when --out not given
if (!outPath && isJsonl) outPath = path.join(runsDir, 'compare.html')
}

compare({ overrides, outPath })
.then(r => process.exit(r.code))
.catch(e => {
console.error(e)
process.exit(3)
})
19 changes: 19 additions & 0 deletions evals/bin/eval.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/* eslint-disable no-console */
// Entry point for `npm run evals`: evaluate once, then build the comparison report.
//
// This thin wrapper exists for one reason: search_docs reads CDS_MCP_OFFLINE at
// MODULE LOAD. It must be set before ./evaluate.js (which transitively imports
// the search tool) is loaded — hence the env assignment followed by a dynamic
// import. A static import here, or setting the env inside evaluate.js, would run
// too late. Offline scoring against the already-downloaded corpus keeps runs
// deterministic (no mid-run re-download).
process.env.CDS_MCP_OFFLINE = 'true'

const { evaluateAndCompare } = await import('../lib/evaluate.js')

evaluateAndCompare()
.then(r => process.exit(r.code))
.catch(e => {
console.error(e)
process.exit(3)
})
20 changes: 20 additions & 0 deletions evals/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"k": 5,
"capire_version": "2026.5.0",
"paths": {
"goldenSet": "data/golden-set.json",
"runsDir": "runs"
},
"gates": {
"recall_at_k": 0.8,
"mrr": 0.5,
"hit_rate_at_k": 0.8,
"precision_at_k": null,
"ndcg_at_k": null
},
"output": {
"keepRuns": 100,
"resultsName": "result.jsonl",
"compareFormat": "html"
}
}
Loading