mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-10-17 05:14:19 +02:00
Closes #1179. Surfaces the documents an answer was drawn from as a "Sources" list under the assistant message, with the archive date where one is known, and persists it so reopening a past conversation still shows what backed each answer. Offline there is no second opinion to check an answer against, so provenance is the only signal the user has that a claim came from a real document rather than from the model. This is the other half of retrieval being allowed to decline: one says when nothing relevant was found, this says what was found. Citations are built from the chunks actually injected into the prompt, not from everything retrieval returned. A chunk dropped by the budget planner never reached the model, so citing it would credit the answer to a document it was not based on -- the exact failure the list exists to prevent. The archive title and date have been written into the vector payload by the ZIM ingestion path all along and were simply never read back, so this works on already-embedded content with no re-embed. Rebased onto the RagPipelineService refactor, which landed after this was written: the citation list now comes off the pipeline trace rather than the inline retrieval block that used to live in the controller. Entries with neither a path nor a title are skipped rather than shown as "Unknown source" -- a row the user cannot act on makes an answer look sourced when it is not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV
112 lines
4.5 KiB
TypeScript
112 lines
4.5 KiB
TypeScript
/**
|
|
* Pure helpers for shaping the retrieved-context block.
|
|
*
|
|
* These live here rather than in RagPipelineService so they can be exercised
|
|
* under bare `node --experimental-strip-types` with no MySQL, Redis, Qdrant, or
|
|
* Ollama — the same reason `kb_ratio_lookup.ts` is shaped this way. The service
|
|
* supplies the config; everything below is a function of its arguments.
|
|
*
|
|
* Token budgeting used to live here too, as a chars-per-token estimate feeding a
|
|
* `num_ctx` ladder. Both are gone: the estimate now comes from
|
|
* `token_estimate.ts` (calibrated against real token counts) and the allocation
|
|
* from `context_budget.ts`. The ladder in particular was dead code — it fed a
|
|
* `num_ctx` that the OpenAI-compatible endpoint silently discarded, so no chat
|
|
* ever ran at the window it computed.
|
|
*/
|
|
|
|
import { parseParameterBillions } from './context_window.js'
|
|
import type { ChatSource } from '../../types/chat.js'
|
|
|
|
export type ContextLimits = { maxResults: number; maxTokens: number }
|
|
export type ContextLimitTier = { maxParams: number; maxResults: number; maxTokens: number }
|
|
|
|
/** The minimum shape these helpers need; the real chunks carry more. */
|
|
export type BudgetableChunk = { text: string; metadata?: Record<string, any> }
|
|
export type BudgetableMessage = { role: 'system' | 'user' | 'assistant'; content: string }
|
|
|
|
/**
|
|
* Determine RAG context limits from the model's parameter count.
|
|
*
|
|
* `parameterSize` is Ollama's own `details.parameter_size` ("8.0B") and is the
|
|
* honest source. The model name is only a fallback: it used to be the *only*
|
|
* source, which meant a tag carrying no size — "phi3", "mistral-nemo", anything
|
|
* custom — was silently assumed to be 8B and handed far more context than a
|
|
* small model can actually use. When neither source knows, 8B remains the
|
|
* assumption, but now it is one made explicitly rather than by a failed regex.
|
|
*/
|
|
export function getContextLimitsForModel(
|
|
modelName: string,
|
|
tiers: readonly ContextLimitTier[],
|
|
parameterSize?: string
|
|
): ContextLimits {
|
|
const paramBillions = parseParameterBillions(parameterSize, modelName) ?? 8
|
|
|
|
for (const tier of tiers) {
|
|
if (paramBillions <= tier.maxParams) {
|
|
return { maxResults: tier.maxResults, maxTokens: tier.maxTokens }
|
|
}
|
|
}
|
|
|
|
return { maxResults: 5, maxTokens: 0 }
|
|
}
|
|
|
|
/**
|
|
* Render retrieved chunks into the block the rag_context prompt wraps.
|
|
*
|
|
* Each block is labelled with its source title when one is available — a
|
|
* neutral, honest provenance signal — but never with the raw relevance score.
|
|
* nomic cosine scores for genuinely relevant passages sit around 0.4-0.6, and
|
|
* surfacing e.g. "42%" primes the model to distrust correct context. Scores
|
|
* stay in the logs and in the eval report.
|
|
*/
|
|
export function buildContextBlock(docs: BudgetableChunk[]): string {
|
|
return docs
|
|
.map((doc, idx) => {
|
|
const title =
|
|
doc.metadata?.archive_title || doc.metadata?.full_title || doc.metadata?.article_title
|
|
const date = doc.metadata?.archive_date
|
|
const label = title
|
|
? `[Context ${idx + 1} — ${title}${date ? ` (${date})` : ''}]`
|
|
: `[Context ${idx + 1}]`
|
|
return `${label}\n${doc.text}`
|
|
})
|
|
.join('\n\n')
|
|
}
|
|
|
|
/**
|
|
* Build the deduplicated "Sources" list shown under an assistant answer.
|
|
*
|
|
* Deliberately fed from what was *injected* into the prompt rather than from
|
|
* everything retrieval returned: a chunk dropped by the budget planner never
|
|
* reached the model, so citing it would credit the answer to a document it was
|
|
* not based on -- the exact failure the citation list exists to prevent.
|
|
*
|
|
* Dedupes on the originating path so a dozen chunks out of one archive collapse
|
|
* to one entry, falling back to the title when a point carries no path. Points
|
|
* with neither are skipped rather than shown as "Unknown source": an entry the
|
|
* user cannot act on is worse than one fewer entry.
|
|
*/
|
|
export function buildCitations(docs: BudgetableChunk[]): ChatSource[] {
|
|
const seen = new Set<string>()
|
|
const sources: ChatSource[] = []
|
|
|
|
for (const doc of docs) {
|
|
const title =
|
|
doc.metadata?.archive_title || doc.metadata?.full_title || doc.metadata?.article_title
|
|
const path = doc.metadata?.source as string | undefined
|
|
const key = path || title
|
|
if (!key || seen.has(key)) continue
|
|
seen.add(key)
|
|
|
|
sources.push({
|
|
// A user-uploaded PDF carries no embedded title, so fall back to its
|
|
// filename -- which is what the user named it and will recognise.
|
|
title: title || path!.split('/').pop() || path!,
|
|
date: doc.metadata?.archive_date,
|
|
source: path,
|
|
})
|
|
}
|
|
|
|
return sources
|
|
}
|