/** Browser adaptation: original preserved in the public source bundle; see REVIEW_NOTES.md. */ /** * PUBLIC SHOWCASE — an independently written, transparent heuristic example. * It is not a copy of proprietary Odyssey/Troy ingestion or prompt logic. * * Linguistic design influences: * - lexical cohesion: repeated content words imply topical continuity; * - discourse structure: temporal/spatial transitions suggest scene boundaries; * - rhetorical cohesion: contrast/causal connectives usually link adjacent units; * - event segmentation: explicit scene breaks override soft thresholds. * * This is NOT a trained NLP model or an RST discourse parser. */ export interface OcrPage { documentId: string; book: number; scanPage: number; printedPage?: string; text: string; } export interface ChunkingOptions { minWords?: number; maxWords?: number; lexicalShiftThreshold?: number; } export type BoundaryReason = | "book-start" | "scene-divider" | "temporal-shift" | "spatial-shift" | "dialogue-transition" | "lexical-shift" | "max-words"; export interface PageReference { scanPage: number; printedPage?: string; } export interface NarrativeChunk { id: string; documentId: string; book: number; scene: number; wordCount: number; pageReferences: PageReference[]; boundaryReasons: BoundaryReason[]; /** Text overlap with the paragraph immediately before this chunk, if available. */ cohesionWithPrevious: number | null; text: string; } interface Paragraph { text: string; wordCount: number; page: PageReference; forcedBoundary: boolean; } interface DraftChunk { paragraphs: Paragraph[]; wordCount: number; boundaryReasons: BoundaryReason[]; cohesionWithPrevious: number | null; } const DEFAULT_OPTIONS = { minWords: 300, maxWords: 900, lexicalShiftThreshold: 0.12, } as const; const STOP_WORDS = new Set([ "a", "about", "after", "again", "all", "also", "an", "and", "are", "as", "at", "be", "been", "before", "but", "by", "can", "could", "did", "do", "does", "each", "for", "from", "had", "has", "have", "he", "her", "his", "how", "i", "if", "in", "into", "is", "it", "its", "just", "may", "more", "my", "no", "not", "of", "on", "or", "our", "out", "over", "she", "so", "some", "such", "than", "that", "the", "their", "them", "then", "there", "these", "they", "this", "those", "through", "to", "too", "under", "up", "was", "we", "were", "what", "when", "where", "which", "while", "who", "will", "with", "would", "you", "your" ]); const TEMPORAL_TRANSITION = /^(?:at dawn|at dusk|at first light|the next morning|the following day|hours later|days later|by nightfall|after sunset|before sunrise|meanwhile|the next evening)\b/i; const SPATIAL_TRANSITION = /^(?:elsewhere|inside the|across the|beyond the|on the far shore|upon reaching|at the harbor|at the gate|in the courtyard|back aboard)\b/i; const RHETORICAL_LINK = /^(?:however|nevertheless|therefore|because|although|consequently|as a result|for that reason|yet\b)/i; const SCENE_DIVIDER = /^(?:\*\s*\*\s*\*|---+|scene\s+break)$/i; const BOOK_HEADING = /^book\s+[ivxlcdm\d]+(?:\s*[:—-].*)?$/i; const WORD_PATTERN = /[\p{L}\p{N}]+(?:['’][\p{L}\p{N}]+)*/gu; function words(text: string): string[] { return text.match(WORD_PATTERN) ?? []; } function contentWords(text: string): Set { return new Set(words(text.toLowerCase()) .filter((word) => word.length > 2 && !STOP_WORDS.has(word))); } /** Adjacent paragraph lexical-cohesion proxy, NOT semantic embeddings. */ export function lexicalCohesion(left: string, right: string): number { const a = contentWords(left); const b = contentWords(right); if (a.size === 0 || b.size === 0) return 1; let intersection = 0; for (const word of a) if (b.has(word)) intersection += 1; const union = a.size + b.size - intersection; return union === 0 ? 1 : intersection / union; } /** Clean common scan/PDF extraction artifacts but preserve paragraph boundaries. */ export function normalizeOcrText(raw: string): string { return raw .replace(/\r\n?/g, "\n") .replace(/[\t ]+\n/g, "\n") // Rejoin hyphenated lowercase words broken solely by line wrapping. .replace(/(\p{Ll})-\n(?=\p{Ll})/gu, "$1") // Single physical line breaks are not necessarily paragraph boundaries. .replace(/([^\n])\n(?!\n)/g, "$1 ") .replace(/[ \t]+/g, " ") .trim(); } /** Long OCR paragraphs split by sentence before fallback to word windows. */ function splitOversizedParagraph(paragraph: Paragraph, maxWords: number): Paragraph[] { if (paragraph.wordCount <= maxWords) return [paragraph]; // Slice at sentence endings without discarding punctuation-only prefixes. const sentences: string[] = []; const endings = /[.!?]+[”"'’]*(?=\s|$)/gu; let sentenceStart = 0; for (const match of paragraph.text.matchAll(endings)) { const end = match.index + match[0].length; sentences.push(paragraph.text.slice(sentenceStart,end).trim()); sentenceStart = end; } const tail = paragraph.text.slice(sentenceStart).trim(); if (tail) sentences.push(tail); const pieces: string[] = []; let segment = ""; const pushSegment = (): void => { if (segment) pieces.push(segment); segment = ""; }; for (const sentence of sentences) { if (words(sentence).length > maxWords) { pushSegment(); // Count with the same Unicode word definition used by wordCount. // Whitespace tokens can contain several words (for example one-two-three). const matches = [...sentence.matchAll(WORD_PATTERN)]; let start = 0; for (let i = 0; i < matches.length; i += maxWords) { const end = matches[i + maxWords]?.index ?? sentence.length; pieces.push(sentence.slice(start,end).trim()); start = end; } continue; } const candidate = segment ? `${segment} ${sentence}` : sentence; if (words(candidate).length > maxWords) pushSegment(); segment = segment ? `${segment} ${sentence}` : sentence; } pushSegment(); return pieces.map((text, i) => ({ text, wordCount: words(text).length, page: paragraph.page, forcedBoundary: i === 0 && paragraph.forcedBoundary, })); } /** Page boundaries never automatically create scene boundaries. */ function extractParagraphs(pages: readonly OcrPage[], maxWords: number): Paragraph[] { const paragraphs: Paragraph[] = []; let pendingDivider = false; for (const page of pages) { const rawBlocks = page.text.replace(/\r\n?/g, "\n").split(/\n\s*\n/); for (const block of rawBlocks) { const cleaned = normalizeOcrText(block); if (!cleaned) continue; if (SCENE_DIVIDER.test(cleaned)) { pendingDivider = true; continue; } if (BOOK_HEADING.test(cleaned)) continue; const base: Paragraph = { text: cleaned, wordCount: words(cleaned).length, page: { scanPage: page.scanPage, ...(page.printedPage !== undefined ? { printedPage: page.printedPage } : {}), }, forcedBoundary: pendingDivider, }; paragraphs.push(...splitOversizedParagraph(base, maxWords)); pendingDivider = false; } } return paragraphs; } interface BreakDecision { reasons: BoundaryReason[]; cohesion: number; } function breakBefore( draft: DraftChunk, paragraph: Paragraph, previous: Paragraph, options: Required, ): BreakDecision | null { const cohesion = lexicalCohesion(previous.text, paragraph.text); if (paragraph.forcedBoundary) return { reasons: ["scene-divider"], cohesion }; if (draft.wordCount + paragraph.wordCount > options.maxWords) { return { reasons: ["max-words"], cohesion }; } if (draft.wordCount < options.minWords) return null; // Rhetorical connectors are inter-paragraph links, not scene boundaries. // For cue detection, temporarily look past the connector (e.g. "However, at dawn"). // A linked paragraph still stays attached unless the hard word limit applies. const rhetoricallyLinked = RHETORICAL_LINK.test(paragraph.text); const cueText = rhetoricallyLinked ? paragraph.text.replace(RHETORICAL_LINK, "").replace(/^[,;:\s—-]+/, "") : paragraph.text; const reasons: BoundaryReason[] = []; let boundaryStrength = 0; if (TEMPORAL_TRANSITION.test(cueText)) { reasons.push("temporal-shift"); boundaryStrength += 2; } if (SPATIAL_TRANSITION.test(cueText)) { reasons.push("spatial-shift"); boundaryStrength += 2; } if (/^[“"—]/u.test(paragraph.text) && !/^[“"—]/u.test(previous.text)) { reasons.push("dialogue-transition"); boundaryStrength += 1; } if (cohesion < options.lexicalShiftThreshold) { reasons.push("lexical-shift"); boundaryStrength += 1; } // Low lexical overlap alone is not enough: it is not a scene classifier. return !rhetoricallyLinked && boundaryStrength >= 3 ? { reasons, cohesion } : null; } function makeChunk( draft: DraftChunk, documentId: string, book: number, scene: number, ): NarrativeChunk { const seen = new Set(); const pageReferences: PageReference[] = []; for (const paragraph of draft.paragraphs) { const key = `${paragraph.page.scanPage}|${paragraph.page.printedPage ?? ""}`; if (!seen.has(key)) { seen.add(key); pageReferences.push(paragraph.page); } } return { id: `${documentId}-Bk${String(book).padStart(2, "0")}-Sc${String(scene).padStart(2, "0")}`, documentId, book, scene, wordCount: draft.wordCount, pageReferences, boundaryReasons: draft.boundaryReasons, cohesionWithPrevious: draft.cohesionWithPrevious, text: draft.paragraphs.map((paragraph) => paragraph.text).join("\n\n"), }; } export function chunkNarrative( pages: readonly OcrPage[], options: ChunkingOptions = {}, ): NarrativeChunk[] { const config: Required = { minWords: options.minWords ?? DEFAULT_OPTIONS.minWords, maxWords: options.maxWords ?? DEFAULT_OPTIONS.maxWords, lexicalShiftThreshold: options.lexicalShiftThreshold ?? DEFAULT_OPTIONS.lexicalShiftThreshold, }; if ( !Number.isInteger(config.minWords) || !Number.isInteger(config.maxWords) || config.minWords < 1 || config.maxWords < config.minWords || !Number.isFinite(config.lexicalShiftThreshold) || config.lexicalShiftThreshold < 0 || config.lexicalShiftThreshold > 1 ) throw new RangeError("Invalid word limits or lexical-cohesion threshold."); const documentIds = new Set(pages.map((page) => page.documentId)); if (documentIds.size > 1) throw new Error("Use one document per chunking call."); const pageKeys = new Set(); for (const page of pages) { const pageKey = `${page.book}:${page.scanPage}`; if (pageKeys.has(pageKey)) throw new Error("Duplicate book/scan-page reference."); pageKeys.add(pageKey); if (!Number.isInteger(page.book) || page.book < 1 || !Number.isInteger(page.scanPage) || page.scanPage < 1) { throw new RangeError("Book and scan-page numbers must be positive integers."); } if (!/^[A-Za-z0-9_-]+$/.test(page.documentId)) { throw new Error("documentId must be URL-safe alphanumeric text with - or _."); } } const books = new Map(); for (const page of pages) { const collection = books.get(page.book) ?? []; collection.push(page); books.set(page.book, collection); } const chunks: NarrativeChunk[] = []; for (const [book, bookPages] of [...books.entries()].sort((a, b) => a[0] - b[0])) { bookPages.sort((a, b) => a.scanPage - b.scanPage); const paragraphs = extractParagraphs(bookPages, config.maxWords); const documentId = bookPages[0]?.documentId; if (!documentId || paragraphs.length === 0) continue; let draft: DraftChunk | null = null; let previous: Paragraph | null = null; let scene = 1; for (const paragraph of paragraphs) { if (!draft) { draft = { paragraphs: [paragraph], wordCount: paragraph.wordCount, boundaryReasons: ["book-start"], cohesionWithPrevious: null, }; previous = paragraph; continue; } const decision: BreakDecision | null = previous ? breakBefore(draft, paragraph, previous, config) : null; if (decision) { chunks.push(makeChunk(draft, documentId, book, scene)); scene += 1; draft = { paragraphs: [], wordCount: 0, boundaryReasons: decision.reasons, cohesionWithPrevious: Number(decision.cohesion.toFixed(3)), }; } draft.paragraphs.push(paragraph); draft.wordCount += paragraph.wordCount; previous = paragraph; } if (draft) chunks.push(makeChunk(draft, documentId, book, scene)); } return chunks; }