import { RagService } from '#services/rag_service' import { inject } from '@adonisjs/core' import logger from '@adonisjs/core/services/logger' import { createHash } from 'node:crypto' import { readdir, readFile } from 'node:fs/promises' import { basename, join, resolve } from 'node:path' import { KB_EVAL_COLLECTION } from '../../constants/kb_collections.js' import { EMBEDDING_MODEL_NAME } from '../../constants/ollama.js' import { assertGoldensMatchCorpus, computeCorpusFingerprint, parseGoldens, type Golden, } from '../utils/eval/golden_set.js' import { EVAL_CORPUS_DIR } from '../utils/eval/corpus_source.js' /** Where the golden question set lives, relative to the app root. */ export const EVAL_GOLDENS_DIR = 'tests/eval/goldens' // EVAL_CORPUS_DIR and docIdFromSource live in app/utils/eval/corpus_source.ts: // they are pure path logic, and keeping them out of this service is what lets // the leak guard be unit-tested without booting AdonisJS. export { docIdFromSource, EVAL_CORPUS_DIR } from '../utils/eval/corpus_source.js' export type CorpusDocument = { /** Filename without extension. This is the id goldens refer to. */ docId: string /** Absolute path on disk; becomes the Qdrant `source` payload. */ path: string text: string } export type IngestSummary = { fingerprint: string documents: number chunks: number removedBeforeIngest: number failures: Array<{ docId: string; reason: string }> } /** * Owns the frozen evaluation corpus: reading it off disk, fingerprinting it, * and pushing it through NOMAD's real ingest path into the reserved * `__nomad_eval__` collection tag. * * Ingesting through `RagService.embedAndStoreText` rather than writing vectors * directly is the whole point — chunk size, the token-estimate ratio, the * search_document prefix, and the embedding model are all in scope of the * measurement, so a change to any of them shows up as a retrieval score * movement instead of hiding. */ @inject() export class EvalCorpusService { constructor(private ragService: RagService) {} private corpusPath(): string { return resolve(join(process.cwd(), EVAL_CORPUS_DIR)) } /** Read every markdown document in the corpus, sorted for determinism. */ async loadCorpus(): Promise { const dir = this.corpusPath() const entries = (await readdir(dir)).filter((f) => f.endsWith('.md')).sort() if (entries.length === 0) { throw new Error(`No corpus documents found in ${dir}`) } return Promise.all( entries.map(async (file) => { const path = join(dir, file) return { docId: basename(file, '.md'), path, text: await readFile(path, 'utf8') } }) ) } /** Load and validate every golden file, cross-checked against the corpus. */ async loadGoldens(): Promise { const dir = resolve(join(process.cwd(), EVAL_GOLDENS_DIR)) const files = (await readdir(dir)).filter((f) => f.endsWith('.jsonl')).sort() if (files.length === 0) throw new Error(`No golden files found in ${dir}`) const goldens: Golden[] = [] const seen = new Set() for (const file of files) { const parsed = parseGoldens(await readFile(join(dir, file), 'utf8'), file) for (const g of parsed) { // parseGoldens dedupes within a file; this catches collisions across files. if (seen.has(g.id)) throw new Error(`Duplicate golden id "${g.id}" in ${file}`) seen.add(g.id) goldens.push(g) } } const corpus = await this.loadCorpus() assertGoldensMatchCorpus( goldens, corpus.map((d) => d.docId) ) return goldens } /** * Hash the corpus together with the ingest parameters that shaped it. * Reports carry this; two reports with different fingerprints are not * comparable and `eval:compare` refuses to pretend otherwise. */ async fingerprint(): Promise { const corpus = await this.loadCorpus() return computeCorpusFingerprint( { documents: new Map(corpus.map((d) => [d.docId, d.text])), chunkTokens: RagService.TARGET_TOKENS_PER_CHUNK, chunkOverlapTokens: RagService.CHUNK_OVERLAP_TOKENS, charToTokenRatio: RagService.CHAR_TO_TOKEN_RATIO, embeddingModel: EMBEDDING_MODEL_NAME, embeddingDimension: RagService.EMBEDDING_DIMENSION, }, (input) => createHash('sha256').update(input).digest('hex') ) } /** Remove every eval point. Never touches user content. */ async reset(): Promise { const removed = await this.ragService.deleteCollectionPoints(KB_EVAL_COLLECTION) logger.info(`[Eval] Removed ${removed} eval corpus chunks`) return removed } /** How many eval chunks are currently in the vector store. */ async count(): Promise { return this.ragService.countChunksInCollection(KB_EVAL_COLLECTION) } /** * Wipe and rebuild the eval corpus. * * Always a full rebuild: a partial re-ingest would leave the vector store in * a state no fingerprint describes, and a fingerprint that does not describe * the store is worse than no fingerprint at all. */ async ingest(onProgress?: (docId: string, index: number, total: number) => void): Promise { const corpus = await this.loadCorpus() const removedBeforeIngest = await this.reset() let chunks = 0 const failures: IngestSummary['failures'] = [] for (const [index, doc] of corpus.entries()) { onProgress?.(doc.docId, index + 1, corpus.length) try { const result = await this.ragService.embedAndStoreText(doc.text, { source: doc.path, collection: KB_EVAL_COLLECTION, }) if (!result) { failures.push({ docId: doc.docId, reason: 'embedAndStoreText returned null' }) continue } chunks += result.chunks } catch (error) { failures.push({ docId: doc.docId, reason: error instanceof Error ? error.message : String(error) }) } } return { fingerprint: await this.fingerprint(), documents: corpus.length, chunks, removedBeforeIngest, failures, } } }