Add shared library for APIs/chunking

This commit is contained in:
KS Jannette
2026-08-12 19:41:32 -04:00
parent d9da15bab4
commit 7e9baeed74
12 changed files with 1011 additions and 52 deletions

154
lib/anthropic.js Normal file
View File

@@ -0,0 +1,154 @@
import Anthropic from "@anthropic-ai/sdk";
const GENERATION_MODEL = "claude-opus-4-6";
const NLI_MODEL = "claude-haiku-4-5";
const RESPONSE_SCHEMA = {
type: "object",
properties: {
answer: {
type: "string",
description:
"The answer with inline numeric citations like [1], [2] matching the source document numbers provided",
},
citedSourceIndices: {
type: "array",
items: { type: "integer" },
description: "The document numbers cited in the answer",
},
followUpQuestions: {
type: "array",
items: { type: "string" },
description: "Exactly 3 follow-up questions the user might ask",
},
},
required: ["answer", "citedSourceIndices", "followUpQuestions"],
additionalProperties: false,
};
const NLI_SCHEMA = {
type: "object",
properties: {
label: {
type: "string",
enum: ["supported", "contradicted", "unsupported"],
description: "Whether the claim is entailed, contradicted, or neither by the passages",
},
confidence: {
type: "number",
description: "Confidence in the label, from 0 to 1",
},
},
required: ["label", "confidence"],
additionalProperties: false,
};
function getClient() {
const key = process.env.ANTHROPIC_API_KEY;
if (!key) throw new Error("ANTHROPIC_API_KEY is not set");
return new Anthropic({ apiKey: key });
}
function textFromMessage(message) {
const block = message.content[0];
return block && "text" in block ? block.text : "";
}
function buildPrompt(query, sources) {
const today = new Date().toLocaleDateString("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
const sourceBlock = sources
.map((s) => `[Source ${s.docIndex}] (${s.name})\n${s.text}`)
.join("\n\n---\n\n");
return `You are a research assistant. Today's date is ${today}. Answer the user's question using ONLY the source documents provided below. Follow these rules strictly:
1. Ground every claim in a specific source document. Cite sources inline using numeric notation like [1], [2], etc., matching the source document numbers below.
2. If the sources do not contain enough information to answer, say so honestly.
3. After your answer, suggest exactly 3 follow-up questions the user might ask based on the sources.
--- SOURCE DOCUMENTS ---
${sourceBlock}
--- USER QUESTION ---
${query}`;
}
function stripInvalidCitations(answer, validIndices) {
return answer
.replace(/\[(\d+)\]/g, (match, n) => (validIndices.has(Number(n)) ? match : ""))
.replace(/[ \t]{2,}/g, " ")
.trim();
}
export async function generate(query, sources) {
const client = getClient();
const message = await client.messages.create({
model: GENERATION_MODEL,
max_tokens: 2048,
messages: [{ role: "user", content: buildPrompt(query, sources) }],
output_config: {
format: {
type: "json_schema",
schema: RESPONSE_SCHEMA,
},
},
});
const parsed = JSON.parse(textFromMessage(message) || "{}");
const validIndices = new Set(sources.map((s) => s.docIndex));
const citedSourceIndices = [
...new Set((parsed.citedSourceIndices || []).filter((idx) => validIndices.has(idx))),
];
return {
answer: stripInvalidCitations(parsed.answer || "", validIndices),
citedSourceIndices,
followUpQuestions: parsed.followUpQuestions || [],
};
}
export async function judgeEntailment(sentence, chunkTexts) {
const client = getClient();
const passages = chunkTexts.map((t, i) => `[Passage ${i + 1}]\n${t}`).join("\n\n");
const prompt = `You are an NLI judge. Decide whether the CLAIM is supported, contradicted, or unsupported by the SOURCE PASSAGES.
- supported: the passages entail the claim
- contradicted: the passages contradict the claim
- unsupported: the passages neither support nor contradict the claim
--- CLAIM ---
${sentence}
--- SOURCE PASSAGES ---
${passages}`;
const message = await client.messages.create({
model: NLI_MODEL,
max_tokens: 256,
messages: [{ role: "user", content: prompt }],
output_config: {
format: {
type: "json_schema",
schema: NLI_SCHEMA,
},
},
});
const parsed = JSON.parse(textFromMessage(message) || "{}");
const label = ["supported", "contradicted", "unsupported"].includes(parsed.label)
? parsed.label
: "unsupported";
const confidence = typeof parsed.confidence === "number" ? parsed.confidence : 0;
return { label, confidence };
}

32
lib/chunk.js Normal file
View File

@@ -0,0 +1,32 @@
const CHUNK_SIZE = 2000;
const CHUNK_OVERLAP = 200;
export function chunkText(text) {
const cleaned = String(text || "").replace(/\r\n/g, "\n").trim();
if (!cleaned) return [];
if (cleaned.length <= CHUNK_SIZE) return [cleaned];
const chunks = [];
let start = 0;
while (start < cleaned.length) {
let end = Math.min(start + CHUNK_SIZE, cleaned.length);
if (end < cleaned.length) {
const slice = cleaned.slice(start, end);
const lastBreak = Math.max(slice.lastIndexOf("\n"), slice.lastIndexOf(" "));
if (lastBreak > CHUNK_SIZE * 0.5) {
end = start + lastBreak;
}
}
const chunk = cleaned.slice(start, end).trim();
if (chunk) chunks.push(chunk);
if (end >= cleaned.length) break;
const nextStart = end - CHUNK_OVERLAP;
start = nextStart <= start ? end : nextStart;
}
return chunks;
}

27
lib/chunk.test.js Normal file
View File

@@ -0,0 +1,27 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { chunkText } from "./chunk.js";
describe("chunkText", () => {
it("returns empty array for blank input", () => {
assert.deepEqual(chunkText(""), []);
assert.deepEqual(chunkText(" "), []);
});
it("keeps short text as a single chunk", () => {
const text = "A short paragraph.";
assert.deepEqual(chunkText(text), [text]);
});
it("splits long text into overlapping chunks under 2000 chars", () => {
const word = "word ";
const text = word.repeat(900);
const chunks = chunkText(text);
assert.ok(chunks.length > 1);
for (const chunk of chunks) {
assert.ok(chunk.length <= 2000);
}
const overlap = chunks[0].slice(-50);
assert.ok(chunks[1].includes(overlap.trim().slice(0, 20)));
});
});

77
lib/scoring.js Normal file
View File

@@ -0,0 +1,77 @@
import { rerank } from "./voyage.js";
import { judgeEntailment } from "./anthropic.js";
// These bounds are a starting ramp for rerank-2; log raw scores and adjust.
const RERANK_FLOOR = 0.2;
const RERANK_CEILING = 0.8;
const MIN_SENTENCE_LENGTH = 20;
const MAX_SENTENCES = 12;
const MAX_CITED_CHUNKS = 5;
function splitIntoSentences(text) {
const cleaned = text.replace(/\[\d+\]/g, "").trim();
const raw = cleaned.split(/(?<=[.!?])\s+/);
return raw
.map((s) => s.trim())
.filter((s) => s.length >= MIN_SENTENCE_LENGTH)
.slice(0, MAX_SENTENCES);
}
function calibrate(rawScore) {
const scaled = (rawScore - RERANK_FLOOR) / (RERANK_CEILING - RERANK_FLOOR);
return Math.max(0, Math.min(1, scaled));
}
function groundednessMode() {
const mode = (process.env.GROUNDEDNESS_MODE || "rerank_nli").toLowerCase();
return mode === "rerank" ? "rerank" : "rerank_nli";
}
export async function computeGroundedness(answerText, citedChunkTexts) {
if (!citedChunkTexts || citedChunkTexts.length === 0) return 0;
const sentences = splitIntoSentences(answerText);
if (sentences.length === 0) return 0;
const chunks = citedChunkTexts.filter(Boolean).slice(0, MAX_CITED_CHUNKS);
if (chunks.length === 0) return 0;
const mode = groundednessMode();
const perSentence = await Promise.all(
sentences.map(async (sentence) => {
const ranked = await rerank(sentence, chunks);
const raw =
ranked.length === 0
? 0
: Math.max(...ranked.map((r) => r.relevanceScore));
const calibrated = calibrate(raw);
let nli = null;
if (mode === "rerank_nli") {
nli = await judgeEntailment(sentence, chunks);
}
console.log(
JSON.stringify({
event: "sentence_groundedness",
sentence: sentence.slice(0, 80),
rawRerank: Number(raw.toFixed(4)),
calibrated: Number(calibrated.toFixed(4)),
nli,
mode,
})
);
return { raw, calibrated, nli };
})
);
if (perSentence.some((s) => s.nli && s.nli.label === "contradicted")) {
console.log(JSON.stringify({ event: "groundedness_floored", reason: "contradicted" }));
return 0;
}
const total = perSentence.reduce((sum, s) => sum + s.calibrated, 0);
return total / perSentence.length;
}

66
lib/voyage.js Normal file
View File

@@ -0,0 +1,66 @@
const VOYAGE_API_URL = "https://api.voyageai.com/v1";
const EMBED_MODEL = "voyage-3";
const RERANK_MODEL = "rerank-2";
const EMBED_BATCH_SIZE = 32;
function getApiKey() {
const key = process.env.VOYAGE_API_KEY;
if (!key) throw new Error("VOYAGE_API_KEY is not set");
return key;
}
async function voyagePost(path, body) {
const res = await fetch(`${VOYAGE_API_URL}${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${getApiKey()}`,
},
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Voyage ${path} failed (${res.status}): ${text}`);
}
return res.json();
}
// Embed text - voyage-3. inputType must be "document" (ingest) or "query" (search).
export async function embedTexts(texts, inputType = "document") {
if (texts.length === 0) return [];
const embeddings = [];
for (let i = 0; i < texts.length; i += EMBED_BATCH_SIZE) {
const batch = texts.slice(i, i + EMBED_BATCH_SIZE);
const json = await voyagePost("/embeddings", {
model: EMBED_MODEL,
input: batch,
input_type: inputType,
});
const ordered = (json.data || [])
.slice()
.sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
embeddings.push(...ordered.map((d) => d.embedding));
}
return embeddings;
}
export async function rerank(query, documents) {
if (!documents || documents.length === 0) return [];
const texts = documents.map((d) => (typeof d === "string" ? d : d.text));
const json = await voyagePost("/rerank", {
model: RERANK_MODEL,
query,
documents: texts,
});
return (json.data || []).map((item) => ({
index: item.index,
document: documents[item.index],
relevanceScore: item.relevance_score,
}));
}