Add shared library for APIs/chunking
This commit is contained in:
154
lib/anthropic.js
Normal file
154
lib/anthropic.js
Normal 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
32
lib/chunk.js
Normal 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
27
lib/chunk.test.js
Normal 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
77
lib/scoring.js
Normal 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
66
lib/voyage.js
Normal 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,
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user