Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@ htmlcov/
tests/benchmarks/*.npz
app_runtime/python/
bundled_models/**/*.gguf
app_runtime/models/
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"test:coverage:ts": "c8 node --test tests/unit/typescript/*.test.mjs",
"test:coverage:python": "node scripts/python-dev.mjs coverage",
"test:benchmark": "node scripts/python-dev.mjs benchmark",
"test:nli-benchmark": "node scripts/python-dev.mjs nli-benchmark",
"test:embedding-benchmark": "node scripts/python-dev.mjs embedding-benchmark",
"build:embedding-benchmark-cache": "node scripts/python-dev.mjs build-embedding-benchmark-cache",
"test:integration": "node scripts/python-dev.mjs integration",
Expand Down
6 changes: 6 additions & 0 deletions python_engine/tokensmith_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,11 @@
section_header_from_line,
)

try:
from tokensmith_nli import verify_answer
except ImportError: # pragma: no cover - allows direct package imports in tests
from python_engine.tokensmith_nli import verify_answer

try:
import pypdfium2 as pdfium # type: ignore
except Exception: # pragma: no cover - optional runtime dependency
Expand Down Expand Up @@ -2052,6 +2057,7 @@ def resolve_source_document(payload: Dict[str, Any]) -> Dict[str, Any]:
"set_material_enabled": set_material_enabled,
"remove_material": remove_material,
"resolve_source_document": resolve_source_document,
"verify_answer": verify_answer,
}


Expand Down
397 changes: 397 additions & 0 deletions python_engine/tokensmith_nli.py

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions requirements-runtime.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
faiss-cpu==1.15.0
numpy==2.5.2
onnxruntime==1.30.0
packaging==26.3
Pillow==12.3.0
pypdfium2==5.13.0
tokenizers==0.23.2
37 changes: 34 additions & 3 deletions scripts/python-dev.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@ import { fileURLToPath } from 'node:url'
import { spawnSync } from 'node:child_process'
import { Readable } from 'node:stream'
import { pipeline } from 'node:stream/promises'
import { standaloneRuntime } from './python-runtime-manifest.mjs'
import { nliModel, standaloneRuntime } from './python-runtime-manifest.mjs'

const root = dirname(dirname(fileURLToPath(import.meta.url)))
const appRuntimeRoot = join(root, 'app_runtime', 'python')
const nliModelRoot = join(root, 'app_runtime', 'models', nliModel.name)
const appRuntimePython = process.platform === 'win32'
? join(appRuntimeRoot, 'python.exe')
: join(appRuntimeRoot, 'bin', 'python')
Expand Down Expand Up @@ -200,7 +201,7 @@ async function sha256(filePath) {
async function downloadFile(url, destination) {
const response = await fetch(url, { redirect: 'follow' })
if (!response.ok || !response.body) {
throw new Error(`Could not download the Python runtime: HTTP ${response.status} ${response.statusText}.`)
throw new Error(`Could not download ${url}: HTTP ${response.status} ${response.statusText}.`)
}

await pipeline(Readable.fromWeb(response.body), createWriteStream(destination))
Expand Down Expand Up @@ -234,6 +235,28 @@ async function downloadStandaloneRuntime() {
}
}

async function downloadNliModel() {
for (const file of nliModel.files) {
const destination = join(nliModelRoot, file.path)
if (existsSync(destination) && (await sha256(destination)) === file.sha256) {
continue
}

console.log(`[python-runtime] Downloading ${nliModel.name}/${file.path}.`)
mkdirSync(dirname(destination), { recursive: true })
const partial = `${destination}.partial`
await downloadFile(file.url, partial)
const actualSha256 = await sha256(partial)
if (actualSha256 !== file.sha256) {
rmSync(partial, { force: true })
throw new Error(`${nliModel.name}/${file.path} checksum mismatch: expected ${file.sha256}, received ${actualSha256}.`)
}
renameSync(partial, destination)
}

console.log(`[python-runtime] Verified NLI model ${nliModel.name}.`)
}

function installRuntimeDependencies(python) {
const sitePackages = join(runtimePythonLib(), 'site-packages')
const temporarySitePackages = join(appRuntimeRoot, '.tokensmith-site-packages')
Expand Down Expand Up @@ -399,9 +422,11 @@ async function setup() {
process.exit(1)
}

await downloadNliModel()

status = runPython(appRuntimePython, [
'-c',
'import faiss, importlib.util, numpy, pypdfium2, PIL, sys; assert importlib.util.find_spec("llama_cpp") is None; print("TokenSmith app_runtime ready:", sys.executable)'
'import faiss, importlib.util, numpy, onnxruntime, pypdfium2, PIL, sys, tokenizers; assert importlib.util.find_spec("llama_cpp") is None; print("TokenSmith app_runtime ready:", sys.executable)'
])
if (status !== 0) {
process.exit(status)
Expand Down Expand Up @@ -445,6 +470,12 @@ if (task === 'setup' || task === 'setup-runtime') {
process.exit(runPython(python.executable, ['-m', 'unittest', 'discover', '-s', 'tests/python', '-p', 'test_*.py']))
} else if (task === 'benchmark') {
runBenchmark()
} else if (task === 'nli-benchmark') {
const python = requireRuntimePython()
process.exit(runNode('tests/benchmarks/test_buzzdb_nli.mjs', [], {
TOKENSMITH_BENCHMARK_PYTHON: python.executable,
...pythonEnv(python.executable)
}))
} else if (task === 'embedding-benchmark') {
const python = requireRuntimePython()
process.exit(runPython(
Expand Down
18 changes: 18 additions & 0 deletions scripts/python-runtime-manifest.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,21 @@ export function standaloneRuntime(platform = process.platform, arch = process.ar
url: `${releaseBaseUrl}/${runtime.filename.replace('+', '%2B')}`
}
}

// The NLI cross-encoder that checks whether retrieved passages entail an
// answer's claims. Pinned to a Hugging Face revision; update the revision and
// every SHA-256 together.
const nliModelRepo = 'Xenova/deberta-v3-base-tasksource-nli'
const nliModelRevision = 'b2f7405d6a3d25f3fcce84c5cdbd54eac6587578'

export const nliModel = {
name: 'deberta-v3-base-tasksource-nli',
files: [
{ path: 'config.json', sha256: 'a3b1f85299f8c6b1ecf57e73a58bd3d136af6e3b40a9f95917a0d7b9dfdf61dd' },
{ path: 'tokenizer.json', sha256: 'fa89bd3f10b58dfd80d4d673771fb24a40386d53053e9dbb4a08976c069a00f1' },
{ path: 'onnx/model_quantized.onnx', sha256: '442bf6ec019b56558029cf301fce36e50bda4cb3ce332a98dfe2748d6a4ad3e0' }
].map((file) => ({
...file,
url: `https://huggingface.co/${nliModelRepo}/resolve/${nliModelRevision}/${file.path}`
}))
}
122 changes: 122 additions & 0 deletions src/main/engine/answer-confidence.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import type { ChatSource } from '../../shared/app-state'
import type { AnswerConfidence, AnswerVerification } from '../../shared/confidence'
import { abstentionAnswer, scoreAnswerConfidence } from '../../shared/confidence'
import type { EngineChatRequest, EngineChatResponse } from '../../shared/engine'

function normalizeForAbstentionMatch(text: string): string {
return text.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').replace(/\s+/g, ' ').trim()
}

const abstentionMarker = normalizeForAbstentionMatch(abstentionAnswer)

/**
* True when the model took the abstention instruction itself, rather than the
* scorer having to force it.
*
* Only an answer that is essentially just that sentence counts. Models often
* write a real answer and then tack the sentence on as a hedge; treating that
* as an abstention would label a substantive answer "abstained" while still
* showing it. Those trailing hedges go through `stripAbstentionHedge` instead.
*/
export function isAbstention(text: string): boolean {
const normalized = normalizeForAbstentionMatch(text)

return normalized.includes(abstentionMarker) && normalized.length <= abstentionMarker.length + 24
}

/**
* Drops a trailing abstention sentence from an otherwise substantive answer, so
* the answer is graded on what it actually claims.
*/
export function stripAbstentionHedge(text: string): string {
const kept = text
.split(/(?<=[.!?])\s+/)
.filter((sentence) => !normalizeForAbstentionMatch(sentence).includes(abstentionMarker))
.join(' ')
.trim()

return kept || text
}

export function scoredSources(request: EngineChatRequest, response: EngineChatResponse): ChatSource[] {
return response.sources.length > 0 ? response.sources : request.retrievedSources ?? []
}

/** Runs the NLI check of `answer`'s claims against the passages. */
export type AnswerVerifier = (question: string, answer: string, passages: string[]) => Promise<AnswerVerification>

/** The question the answer responds to, as the answer prompt posed it. */
function answeredQuestion(request: EngineChatRequest): string {
return (request.referenceExchange ? request.prompt : request.answerPrompt ?? request.prompt).trim()
}

/** The passage text the answer was written from, as the prompt gave it. */
function passageText(source: ChatSource): string {
return (source.context || source.excerpt || '').trim()
}

async function verifySafely(
verify: AnswerVerifier,
question: string,
answer: string,
sources: ChatSource[]
): Promise<AnswerVerification> {
if (sources.length === 0) {
return { available: false, claims: [], error: 'No passages to check the answer against.' }
}

try {
return await verify(question, answer, sources.map(passageText))
} catch (error) {
return { available: false, claims: [], error: error instanceof Error ? error.message : String(error) }
}
}

/**
* Settles what the answer is before it is shown: the model's own abstention
* becomes the standard sentence, and a trailing hedge is dropped. The answer
* is not checked here; `scoreAnswer` does that after it is on screen.
*/
export function prepareAnswer(request: EngineChatRequest, response: EngineChatResponse): EngineChatResponse {
if (isAbstention(response.text)) {
const scored = scoreAnswerConfidence(answeredQuestion(request), scoredSources(request, response), {
available: false,
claims: []
})
const confidence: AnswerConfidence = { ...scored, status: 'unsupported', abstained: true }

return { ...response, text: abstentionAnswer, confidence, followUpSuggestions: [] }
}

return { ...response, text: stripAbstentionHedge(response.text) }
}

/**
* Checks a prepared answer against the passages it was written from. If the
* check cannot run, the answer is marked unverified.
*/
export async function scoreAnswer(
request: EngineChatRequest,
response: EngineChatResponse,
verify: AnswerVerifier
): Promise<AnswerConfidence> {
const sources = scoredSources(request, response)
const question = answeredQuestion(request)
const verification = await verifySafely(verify, question, response.text, sources)

return scoreAnswerConfidence(question, sources, verification)
}

/** Prepares and scores an answer in one step. */
export async function withAnswerConfidence(
request: EngineChatRequest,
response: EngineChatResponse,
verify: AnswerVerifier
): Promise<EngineChatResponse> {
const prepared = prepareAnswer(request, response)
if (prepared.confidence) {
return prepared
}

return { ...prepared, confidence: await scoreAnswer(request, prepared, verify) }
}
34 changes: 32 additions & 2 deletions src/main/engine/engine-service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
getPythonEngineHealth,
starterSourcesWithPython,
verifyAnswerWithPython,
writeTokenSmithLog
} from '../python/python-engine-service'
import type {
Expand All @@ -15,7 +16,9 @@ import type { EngineQuestionRewriteRequest, QuestionRewrite } from '../../shared
import { modelWithRememberedRemoteApiKey } from './remote-model-secrets'
import { resolveRemoteChatQuestion } from './remote-chat-service'
import type { ChatSource } from '../../shared/app-state'
import type { AnswerConfidence } from '../../shared/confidence'
import { generateStudyQuestionSuggestions, listStudyEngines, sendStudyChatMessage } from './study-engine-core'
import { prepareAnswer, scoreAnswer } from './answer-confidence'
import { generateOllamaStudyQuestionSuggestions, resolveOllamaChatQuestion, runOllamaStudyEngine } from './ollama-service'
import { questionSuggestionMessages, sourceContextBudgetForRequest, studyChatMessages } from './study-chat-format'

Expand Down Expand Up @@ -51,17 +54,26 @@ export async function sendChatMessage(request: EngineChatRequest, options: Engin
...chatRequestLogDetails(request)
})

const response = await sendStudyChatMessage(request, {
const rawResponse = await sendStudyChatMessage(request, {
getPythonEngineHealth,
generateOllamaStudyQuestionSuggestions,
runOllamaStudyEngine
}, options)
}, {
...options,
// The early answer goes through the same abstention handling as the final one.
onAnswer: options.onAnswer && ((answer, hasFollowUps) => {
const prepared = prepareAnswer(request, answer)
options.onAnswer?.(prepared, hasFollowUps && !prepared.confidence?.abstained)
})
})
const response = prepareAnswer(request, rawResponse)

writeTokenSmithLog('chat_response_context', {
requestId: request.requestId,
modelName: response.modelName,
text: response.text,
sourceCount: response.sources.length,
confidence: response.confidence,
sources: response.sources.map(logSource)
})
if (response.latencyTrace) {
Expand All @@ -74,6 +86,24 @@ export async function sendChatMessage(request: EngineChatRequest, options: Engin
return response
}

/**
* Checks an answer that is already on screen. Kept separate from
* `sendChatMessage` so the student reads the answer while this runs.
*/
export async function scoreChatAnswer(request: EngineChatRequest, response: EngineChatResponse): Promise<AnswerConfidence> {
const started = Date.now()
const confidence = await scoreAnswer(request, response, verifyAnswerWithPython)

writeTokenSmithLog('chat_answer_verification', {
durationMs: Date.now() - started,
score: confidence.score,
status: confidence.status,
claimCount: confidence.claims?.length ?? 0
})

return confidence
}

export async function resolveChatQuestion(request: EngineQuestionRewriteRequest, signal?: AbortSignal): Promise<QuestionRewrite> {
if (request.model.engine === 'ollama') return resolveOllamaChatQuestion(request, signal)
if (request.model.engine === 'remote') {
Expand Down
27 changes: 13 additions & 14 deletions src/main/engine/ollama-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { effectiveContextLength, localContextForTokens, usesAutomaticContext } f
import { withModelContextMetadata } from './model-context-metadata'
import { practiceResponseSchema } from '../../shared/quiz'
import { createLatencyTrace, startLatencySpan, type LatencySpan } from '../../shared/latency-trace'
import { parseQuestionRewrite, questionRewriteMessages, questionRewriteSchema } from './question-rewrite'
import { questionRewriteMessages, questionRewriteSchema, resolveQuestionRewriteText } from './question-rewrite'
import {
defaultOllamaBaseUrl,
recommendedOllamaChatModel,
Expand Down Expand Up @@ -1021,22 +1021,21 @@ export async function resolveOllamaChatQuestion(request: EngineQuestionRewriteRe
const text = await runOllamaChatCompletion(baseUrl, request.model.ollamaModelName, messages, runtime.modelSettings, {
maxTokens: 512, temperature: 0, format: questionRewriteSchema, signal
})
try {
const parsed = parseQuestionRewrite(text, request.prompt, Boolean(request.selectedPassage))
const resolution: QuestionRewrite = automatic && !canReason ? { ...parsed, reasoning: false } : parsed
writeTokenSmithLog('chat_question_rewrite', {
modelName: request.model.ollamaModelName, prompt: request.prompt, modelMessages: messages,
rawResponse: text, resolution, query: resolution.query, conversationContextMode: resolution.mode,
durationMs: performance.now() - started
})
return resolution
} catch (error) {
const { rewrite: parsed, fallbackReason, reasoningMissing } = resolveQuestionRewriteText(text, request.prompt, Boolean(request.selectedPassage))
if (fallbackReason && reasoningMissing && canReason) {
// Auto must decide reasoning; a response without that decision is never read as "off".
writeTokenSmithLog('chat_question_rewrite_error', {
modelName: request.model.ollamaModelName, prompt: request.prompt, rawResponse: text,
error: errorMessage(error, 'Question rewrite failed.')
modelName: request.model.ollamaModelName, prompt: request.prompt, rawResponse: text, error: fallbackReason
})
throw error
throw new Error(fallbackReason)
}
const resolution: QuestionRewrite = automatic && !canReason ? { ...parsed, reasoning: false } : parsed
writeTokenSmithLog(fallbackReason ? 'chat_question_rewrite_fallback' : 'chat_question_rewrite', {
modelName: request.model.ollamaModelName, prompt: request.prompt, modelMessages: messages,
rawResponse: text, resolution, query: resolution.query, conversationContextMode: resolution.mode,
durationMs: performance.now() - started, ...(fallbackReason ? { error: fallbackReason } : {})
})
return resolution
}

async function generateOllamaFollowUpSuggestions(
Expand Down
Loading
Loading