diff --git a/README.md b/README.md index 1f63441..fb97765 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,9 @@ A privacy-first, open-source document reader whose goal is simple: The project started from a medical-education use case but is intentionally generic. Medical science will become an optional domain pack rather than the core application. -## Current milestone: v0.0.2 +## Current milestone: v0.1a -v0.0.1 proved that the application can be built, installed and used on a clean Windows machine. v0.0.2 hardens the provenance layer before any local AI is introduced. +v0.0.1 proved clean-machine Windows installation. v0.0.2 added durable source provenance and sidecars. v0.1a adds the first end-to-end **local Hindi Translate/Explain path** through that provenance architecture. Current capabilities include: @@ -20,12 +20,27 @@ Current capabilities include: - SHA-256 identity for source documents and normalized source quotes; - durable per-document JSON sidecars stored in application data; - fallback source restoration when PDF text-item boundaries shift; -- visible **Translate / Explain / Ask** modes, still deliberately non-generative; +- separate **Translate / Explain / Ask** modes; +- a tiny built-in **Hindi reference engine** for deterministic Translate/Explain regression passages; +- generated-output provenance including language, level, engine id/version/local flag, verification state and review state; +- restoration of saved Translate/Explain output when the same source is reopened; - persistent Light/Dark mode; - visible **LOCAL ONLY** state; - Windows NSIS packaging with CI-generated SHA-256 installer manifest. -There is still **no AI model, OCR or runtime internet verification path** in v0.0.2. +### Important v0.1a limitation + +The built-in Hindi engine is **not a general translator**. It contains a very small, explicit regression catalog spanning medical, administrative and safety examples. Unknown text is rejected as unsupported rather than fabricated. + +This milestone exists to prove: + +`source → local engine → generated record → sidecar → close/reopen → provenance restore` + +before introducing a heavyweight local model runtime and model-pack distribution system. + +See `docs/LOCAL_HINDI_REFERENCE_SLICE.md` and ADR 0002 for the exact scope and rationale. + +There is still **no OCR, runtime internet verification path, or general local AI model** in v0.1a. ## Product principles @@ -36,6 +51,7 @@ There is still **no AI model, OCR or runtime internet verification path** in v0. 5. **Domain knowledge is modular.** Medical, administrative, technical, safety and education packs extend the generic reader. 6. **Every explanation remains anchored to its source.** The user can always see the exact page and selected text. 7. **Installable from the beginning.** Releases should be usable on another computer without Python, Git, Conda or a terminal. +8. **Unsupported beats fabricated.** A local engine that cannot responsibly handle a passage must say so. ## Development policy @@ -43,7 +59,7 @@ There is still **no AI model, OCR or runtime internet verification path** in v0. ## Status -This repository is in the provenance-hardening stage. Do not use it for clinical decisions, safety-critical instructions or authoritative translation yet. +This repository is in the local-intelligence architecture stage. Do not use it for clinical decisions, safety-critical instructions or authoritative translation yet. ## License diff --git a/ROADMAP.md b/ROADMAP.md index 3ee7af6..846e5e7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -20,7 +20,7 @@ Completed and clean-machine validated on Windows: - Windows NSIS packaging; - synthetic smoke-test document. -## v0.0.2 — Robust source anchoring and release hardening (current) +## v0.0.2 — Robust source anchoring and release hardening ✅ - SHA-256 document identity; - SHA-256 normalized quote identity; @@ -33,19 +33,35 @@ Completed and clean-machine validated on Windows: - persistent Light/Dark mode; - cross-file version consistency checks; - validated Windows installer checksum manifest; -- release-validation checklist. +- clean-machine persistence validation. -Exit criterion: a stored source/provenance record survives application restart, reopens against the same SHA-256-identified PDF, reliably restores/highlights its source context, and the v0.0.2 Windows installer passes the documented clean-machine validation including persistent dark mode. +## v0.1a — Local Hindi reference intelligence (current) -## v0.1 — First local understanding loop +Goal: prove the complete local-generation lifecycle without coupling it to a heavyweight model runtime. -- local translation engine; -- English -> Hindi vertical slice; -- local explanation engine; +- deterministic built-in Hindi reference engine; +- Translate and Simple Explain operations; +- medical, administrative and safety regression passages; +- explicit `unsupported` behavior for unknown text; +- generated-output sidecar records with language, level and engine provenance; +- externally-verified and human-reviewed states remain explicit and false by default; +- saved generated outputs restore when the same source is reopened; +- no runtime network path added; +- Windows installer and CI validation retained. + +Exit criterion: a supported English passage can be translated and explained in Hindi locally, each output is separately persisted with its exact source anchor and engine provenance, and the output survives restart/reopen. Unknown passages must be rejected rather than fabricated. + +## v0.1 — First production local understanding loop + +- replace the reference engine behind the same contract with a distributable local model/runtime; +- English -> Hindi general translation vertical slice; +- local explanation engine beyond the fixed regression catalog; - document-grounded Ask mode; - clear provenance labels for Translate / Explain / Ask; - no document upload required; -- model pack manifest and installation path. +- model pack manifest and installation path; +- CPU-only Windows benchmark and memory/latency targets; +- model/runtime license documentation. Exit criterion: a normal Windows user can install the app + required model pack and use Translate, Explain and Ask on a local English PDF without internet access. diff --git a/docs/LOCAL_HINDI_REFERENCE_SLICE.md b/docs/LOCAL_HINDI_REFERENCE_SLICE.md new file mode 100644 index 0000000..c0441f3 --- /dev/null +++ b/docs/LOCAL_HINDI_REFERENCE_SLICE.md @@ -0,0 +1,92 @@ +# Local Hindi Translate/Explain reference slice + +## Purpose + +v0.1a is the smallest end-to-end proof that a source-anchored passage can be sent to a **local understanding engine**, produce a Hindi output, persist that output in the document sidecar, close/reopen the app, and restore the generated result with provenance intact. + +It is deliberately **not** a general-purpose translator yet. + +## Why a reference engine first + +A production Indic model introduces several independent engineering problems at the same time: + +- model-pack download and offline installation; +- inference runtime and CPU/GPU fallback; +- RAM/disk requirements; +- tokenizer/model compatibility; +- model and dataset licensing; +- model versioning and upgrades; +- packaging size and release distribution; +- quality evaluation for translation and explanation. + +The reference engine lets the application prove the architectural path before those concerns are added. + +## Supported behavior + +The built-in engine is `odu.hindi-reference` version `0.1a.1`. + +It supports: + +- target language: **Hindi**; +- operations: **Translate** and **Explain**; +- explanation level: **Simple**; +- a tiny deterministic regression catalog containing medical, administrative and safety passages. + +Unknown passages are returned as `unsupported`. The engine must never improvise an answer outside the catalog. + +## Provenance path + +```text +PDF source selection + ↓ +SourceAnchor v2 +(document SHA-256 + quote SHA-256 + page/range) + ↓ +local Hindi reference engine + ↓ +translate OR explain output + ↓ +ProvenanceRecord +(operation + output + language + level + engine metadata) + ↓ +per-document local JSON sidecar + ↓ +close/reopen document + ↓ +output restored from the same sidecar +``` + +Every generated record explicitly stores: + +- operation (`translate` or `explain`); +- exact source anchor; +- generated output; +- target language; +- explanation level where applicable; +- engine id/version/local flag; +- `externallyVerified: false`; +- `humanReviewed: false`. + +## Privacy + +The reference engine is ordinary TypeScript bundled inside the desktop application. It performs no HTTP requests and adds no network permission or runtime endpoint. The current CSP remains local-only. + +## Quality limitation + +A successful output means only that the architecture worked for a supported regression passage. It does **not** mean the application can accurately translate arbitrary English text or safely explain arbitrary medical/safety material. + +The UI therefore labels generated output as coming from a **LOCAL REFERENCE ENGINE** and displays that it has not been externally verified or human reviewed. + +## Next step + +Replace the deterministic reference implementation behind the same engine contract with an installable local model pack. The first production candidate should be evaluated separately for: + +1. English → Hindi translation quality; +2. simple-English/Hindi explanation quality; +3. CPU-only latency and RAM on ordinary Windows laptops; +4. distributable model license; +5. fully offline installation; +6. medical terminology preservation; +7. deterministic provenance/version identification. + +The model-pack runtime must not require changes to the sidecar provenance contract introduced here. diff --git a/docs/adr/0002-reference-engine-before-model-runtime.md b/docs/adr/0002-reference-engine-before-model-runtime.md new file mode 100644 index 0000000..97babc6 --- /dev/null +++ b/docs/adr/0002-reference-engine-before-model-runtime.md @@ -0,0 +1,44 @@ +# ADR 0002: Prove local intelligence with a deterministic reference engine before adding a model runtime + +- Status: Accepted +- Date: 2026-09-15 + +## Context + +The product needs real local Hindi translation/explanation, but introducing a production model immediately would couple provenance work to model packaging, runtime selection, hardware fallback, licensing, memory/performance and distribution concerns. + +The provenance layer is already stable enough to store generated outputs. We need to prove the entire generated-output lifecycle independently from model quality. + +## Decision + +Introduce a tiny deterministic in-process Hindi reference engine with an explicit regression catalog. It supports only known passages and returns `unsupported` for everything else. + +The engine uses the same interface and provenance metadata expected from future model-backed engines: + +- operation; +- output; +- target language; +- explanation level; +- engine id/version/local flag; +- exact source anchor; +- external-verification and human-review flags. + +No network capability is added. + +## Consequences + +Positive: + +- UI → local engine → provenance record → durable sidecar → reopen/restore can be tested end-to-end now. +- The application never pretends that a fixture-backed engine is a general translator. +- Future model runtimes can replace the implementation without changing the sidecar contract. +- CI remains fast and deterministic. + +Negative: + +- v0.1a is not useful for arbitrary documents yet. +- The reference outputs must be clearly labelled to avoid giving users a false impression of coverage. + +## Follow-up + +A separate model-pack milestone will select and benchmark a distributable local English↔Indic translation/explanation runtime and preserve this provenance contract. diff --git a/package.json b/package.json index 403bd8d..29e0d08 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "open-document-understanding", "private": true, - "version": "0.0.2", + "version": "0.1.0-alpha.1", "type": "module", "scripts": { "dev": "vite --port 1420", diff --git a/schemas/document-sidecar.schema.json b/schemas/document-sidecar.schema.json index 69b8ab4..300a532 100644 --- a/schemas/document-sidecar.schema.json +++ b/schemas/document-sidecar.schema.json @@ -27,6 +27,8 @@ "createdAt": { "type": "string" }, "output": { "type": "string" }, "question": { "type": "string" }, + "targetLanguage": { "type": "string" }, + "explanationLevel": { "type": "string" }, "externallyVerified": { "type": "boolean" }, "humanReviewed": { "type": "boolean" }, "engine": { diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ede76be..2988ad6 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "open-document-understanding" -version = "0.0.2" +version = "0.1.0-alpha.1" description = "Privacy-first local document understanding reader" authors = ["Open Document Understanding contributors"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 715c813..6afacf6 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Open Document Understanding", - "version": "0.0.2", + "version": "0.1.0-alpha.1", "identifier": "org.opendocumentunderstanding.desktop", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.tsx b/src/App.tsx index b1846a2..40a548a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,10 +3,13 @@ import { getDocument, type PDFDocumentProxy, type PDFPageProxy } from "pdfjs-dis import { invoke, isTauri } from "@tauri-apps/api/core"; import { open } from "@tauri-apps/plugin-dialog"; import { PdfPage } from "./PdfPage"; +import { runLocalHindi } from "./localHindiEngine"; import { anchorMatchesDocument, sha256Bytes, type SourceAnchor } from "./sourceAnchor"; import { + addGeneratedRecord, addSourceRecord, createDocumentSidecar, + latestGeneratedRecord, latestSourceAnchor, loadDocumentSidecar, saveDocumentSidecar, @@ -50,6 +53,7 @@ export default function App() { const [sidecar, setSidecar] = useState(null); const [mode, setMode] = useState("explain"); const [question, setQuestion] = useState(""); + const [runtimeMessage, setRuntimeMessage] = useState(""); const [settings, setSettings] = useState(() => loadSettings()); const [status, setStatus] = useState("Ready. No document is open."); const fileInputRef = useRef(null); @@ -70,6 +74,10 @@ export default function App() { }; }, [pdf, pageNumber]); + useEffect(() => { + setRuntimeMessage(""); + }, [mode, anchor?.quoteSha256, settings.targetLanguage, settings.explanationLevel]); + async function loadPdfBytes(bytes: Uint8Array, name: string) { setStatus("Hashing and opening PDF locally…"); const sha256 = await sha256Bytes(bytes); @@ -96,7 +104,7 @@ export default function App() { if (previous && anchorMatchesDocument(previous, sha256)) { setAnchor(previous); setPageNumber(Math.min(Math.max(1, previous.page), loaded.numPages)); - setStatus("PDF opened locally. Source anchor restored from durable local sidecar."); + setStatus("PDF opened locally. Source anchor and saved outputs restored from the local sidecar."); } else { setAnchor(null); setStatus("PDF opened locally. Select text to begin."); @@ -161,29 +169,87 @@ export default function App() { setSettings((current) => ({ ...current, theme: current.theme === "dark" ? "light" : "dark" })); } - const placeholder = useMemo(() => { - if (!anchor) return "Select text in the PDF. Your source passage will appear here before any AI is allowed to act on it."; - if (mode === "translate") { - return `Translation engine not installed yet. Planned target: ${settings.targetLanguage}. The source remains unchanged.`; + async function runLocalAction(operation: "translate" | "explain") { + if (!anchor) { + setRuntimeMessage("Select source text before running the local engine."); + return; + } + + const result = runLocalHindi({ + sourceText: anchor.quote, + operation, + targetLanguage: settings.targetLanguage, + explanationLevel: settings.explanationLevel + }); + + if (result.status === "unsupported") { + setRuntimeMessage(result.reason); + setStatus("Reference engine declined unsupported text instead of inventing output."); + return; + } + + const base = + sidecar ?? + createDocumentSidecar({ + sha256: anchor.documentSha256, + fileName: fileName || "document.pdf", + byteLength: documentByteLength + }); + const updated = addGeneratedRecord(base, { + operation, + anchor, + output: result.output, + targetLanguage: settings.targetLanguage, + explanationLevel: operation === "explain" ? settings.explanationLevel : undefined, + engine: result.engine + }); + + setSidecar(updated); + setRuntimeMessage(""); + try { + await saveDocumentSidecar(updated); + setStatus(`${operation === "translate" ? "Translation" : "Explanation"} generated locally and saved with provenance.`); + } catch (error) { + setStatus(`Output was generated locally, but its sidecar could not be saved: ${String(error)}`); } + } + + const activeGeneratedRecord = useMemo(() => { + if (!sidecar || !anchor || (mode !== "translate" && mode !== "explain")) return null; + return latestGeneratedRecord(sidecar, { + operation: mode, + anchor, + targetLanguage: settings.targetLanguage, + explanationLevel: mode === "explain" ? settings.explanationLevel : undefined + }); + }, [anchor, mode, settings.explanationLevel, settings.targetLanguage, sidecar]); + + const placeholder = useMemo(() => { + if (runtimeMessage) return runtimeMessage; + if (!anchor) return "Select text in the PDF. Your source passage will appear here before any engine is allowed to act on it."; if (mode === "ask") { return question.trim() - ? "Document-grounded question engine not installed yet. The future answer will cite this exact source anchor." - : "Type a question about the selected passage. v0.0.2 deliberately does not invent an answer."; + ? "Document-grounded question engine is not installed yet. No answer has been invented." + : "Type a question about the selected passage. Ask remains intentionally disabled in this slice."; } - return `${settings.explanationLevel} explanation engine not installed yet. v0.0.2 hardens durable source provenance before local AI is added.`; - }, [anchor, mode, question, settings]); + if (activeGeneratedRecord?.output) return activeGeneratedRecord.output; + return mode === "translate" + ? "No saved Hindi translation for this source yet. Run the local reference engine below." + : "No saved simple Hindi explanation for this source yet. Run the local reference engine below."; + }, [activeGeneratedRecord?.output, anchor, mode, question, runtimeMessage]); const savedSourceCount = sidecar?.records.filter((record) => record.operation === "source").length ?? 0; + const savedGeneratedCount = + sidecar?.records.filter((record) => record.operation === "translate" || record.operation === "explain").length ?? 0; return (
Open Document Understanding - v0.0.2 + v0.1a
-
+
LOCAL ONLY
)} @@ -252,6 +318,7 @@ export default function App() { Document SHA-256 {anchor.documentSha256.slice(0, 12)}… Quote SHA-256 {anchor.quoteSha256.slice(0, 12)}… {savedSourceCount} saved source {savedSourceCount === 1 ? "record" : "records"} + {savedGeneratedCount} saved generated {savedGeneratedCount === 1 ? "record" : "records"}
@@ -286,8 +353,23 @@ export default function App() {
{mode.toUpperCase()} OUTPUT

{placeholder}

+ {(mode === "translate" || mode === "explain") && anchor && ( + + )} + {activeGeneratedRecord && ( +
+ LOCAL REFERENCE ENGINE + {activeGeneratedRecord.engine?.id} · {activeGeneratedRecord.engine?.version} + Externally verified: no · Human reviewed: no +
+ )}
+
+ v0.1a uses a tiny deterministic regression catalog to prove the local engine → provenance → restore path. It is not yet a general translator. +
{status}
diff --git a/src/localHindiEngine.test.ts b/src/localHindiEngine.test.ts new file mode 100644 index 0000000..bc7db65 --- /dev/null +++ b/src/localHindiEngine.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { referenceCatalogSize, runLocalHindi } from "./localHindiEngine"; + +const MEDICAL = + "Myocardial contractility describes the intrinsic ability of cardiac muscle to generate force at a given preload and afterload."; + +describe("local Hindi reference engine", () => { + it("translates the synthetic medical passage entirely locally", () => { + const result = runLocalHindi({ + sourceText: MEDICAL, + operation: "translate", + targetLanguage: "Hindi", + explanationLevel: "Simple" + }); + + expect(result.status).toBe("ok"); + if (result.status === "ok") { + expect(result.output).toContain("मायोकार्डियल"); + expect(result.engine.local).toBe(true); + expect(result.engine.id).toBe("odu.hindi-reference"); + } + }); + + it("explains the same passage in simple Hindi", () => { + const result = runLocalHindi({ + sourceText: MEDICAL, + operation: "explain", + targetLanguage: "Hindi", + explanationLevel: "Simple" + }); + + expect(result.status).toBe("ok"); + if (result.status === "ok") expect(result.output).toContain("सरल शब्दों में"); + }); + + it("supports at least one non-medical passage", () => { + const result = runLocalHindi({ + sourceText: "Submit Form B to Administration before 4 PM Friday.", + operation: "explain", + targetLanguage: "Hindi", + explanationLevel: "Simple" + }); + + expect(result.status).toBe("ok"); + expect(referenceCatalogSize()).toBeGreaterThanOrEqual(3); + }); + + it("rejects unknown passages rather than fabricating an answer", () => { + const result = runLocalHindi({ + sourceText: "An unseen passage that is not part of the regression catalog.", + operation: "translate", + targetLanguage: "Hindi", + explanationLevel: "Simple" + }); + + expect(result.status).toBe("unsupported"); + if (result.status === "unsupported") expect(result.reason).toMatch(/outside the small built-in reference catalog/i); + }); + + it("rejects unsupported language and explanation settings explicitly", () => { + expect( + runLocalHindi({ + sourceText: MEDICAL, + operation: "translate", + targetLanguage: "English", + explanationLevel: "Simple" + }).status + ).toBe("unsupported"); + + expect( + runLocalHindi({ + sourceText: MEDICAL, + operation: "explain", + targetLanguage: "Hindi", + explanationLevel: "Technical" + }).status + ).toBe("unsupported"); + }); +}); diff --git a/src/localHindiEngine.ts b/src/localHindiEngine.ts new file mode 100644 index 0000000..e24a21d --- /dev/null +++ b/src/localHindiEngine.ts @@ -0,0 +1,114 @@ +import { normalizeQuote } from "./sourceAnchor"; + +export type LocalHindiOperation = "translate" | "explain"; + +export type LocalHindiRequest = { + sourceText: string; + operation: LocalHindiOperation; + targetLanguage: string; + explanationLevel: string; +}; + +export type LocalHindiEngineDescriptor = { + id: "odu.hindi-reference"; + version: "0.1a.1"; + local: true; +}; + +export type LocalHindiResult = + | { + status: "ok"; + output: string; + engine: LocalHindiEngineDescriptor; + } + | { + status: "unsupported"; + reason: string; + engine: LocalHindiEngineDescriptor; + }; + +export const LOCAL_HINDI_ENGINE: LocalHindiEngineDescriptor = { + id: "odu.hindi-reference", + version: "0.1a.1", + local: true +}; + +type CatalogEntry = { + source: string; + translate: string; + explainSimple: string; +}; + +const CATALOG: CatalogEntry[] = [ + { + source: + "Myocardial contractility describes the intrinsic ability of cardiac muscle to generate force at a given preload and afterload.", + translate: + "मायोकार्डियल कॉन्ट्रैक्टिलिटी हृदय की मांसपेशी की वह अंतर्निहित क्षमता है जिसके द्वारा वह दिए गए प्रीलोड और आफ्टरलोड पर बल उत्पन्न करती है।", + explainSimple: + "सरल शब्दों में, contractility बताती है कि हृदय की मांसपेशी अपनी स्वयं की संकुचन-शक्ति से कितना बल बना सकती है, जब preload और afterload को अलग-अलग रखा जाए।" + }, + { + source: "Increasing afterload can reduce stroke volume when other conditions are unchanged.", + translate: + "यदि अन्य परिस्थितियाँ समान रहें, तो आफ्टरलोड बढ़ने से स्ट्रोक वॉल्यूम कम हो सकता है।", + explainSimple: + "Afterload बढ़ने का अर्थ है कि निलय को रक्त बाहर निकालने के लिए अधिक प्रतिरोध के विरुद्ध काम करना पड़ता है। बाकी स्थितियाँ समान हों तो हर धड़कन में बाहर निकला रक्त कम हो सकता है।" + }, + { + source: "Submit Form B to Administration before 4 PM Friday.", + translate: "शुक्रवार शाम 4 बजे से पहले Form B प्रशासन विभाग में जमा करें।", + explainSimple: + "आपको शुक्रवार शाम 4 बजे की समय-सीमा से पहले Form B प्रशासन विभाग में देना है। मुख्य काम: Form B जमा करना; अंतिम समय: शुक्रवार 4 PM।" + }, + { + source: "Ensure the device is electrically isolated before servicing.", + translate: "मरम्मत या सर्विसिंग शुरू करने से पहले उपकरण को बिजली की आपूर्ति से पूरी तरह अलग करें।", + explainSimple: + "सर्विसिंग से पहले मशीन की बिजली बंद करें और उसे विद्युत स्रोत से अलग करें, ताकि काम करते समय बिजली का झटका या अनचाहा चालू होना रोका जा सके।" + } +]; + +function lookupKey(value: string): string { + return normalizeQuote(value).toLocaleLowerCase("en-US"); +} + +const CATALOG_BY_SOURCE = new Map(CATALOG.map((entry) => [lookupKey(entry.source), entry])); + +export function runLocalHindi(request: LocalHindiRequest): LocalHindiResult { + if (request.targetLanguage !== "Hindi") { + return { + status: "unsupported", + reason: "The built-in reference engine currently supports Hindi only.", + engine: LOCAL_HINDI_ENGINE + }; + } + + if (request.operation === "explain" && request.explanationLevel !== "Simple") { + return { + status: "unsupported", + reason: "The built-in reference engine currently supports Simple Hindi explanations only.", + engine: LOCAL_HINDI_ENGINE + }; + } + + const entry = CATALOG_BY_SOURCE.get(lookupKey(request.sourceText)); + if (!entry) { + return { + status: "unsupported", + reason: + "This passage is outside the small built-in reference catalog. No translation or explanation was invented.", + engine: LOCAL_HINDI_ENGINE + }; + } + + return { + status: "ok", + output: request.operation === "translate" ? entry.translate : entry.explainSimple, + engine: LOCAL_HINDI_ENGINE + }; +} + +export function referenceCatalogSize(): number { + return CATALOG.length; +} diff --git a/src/sidecar.test.ts b/src/sidecar.test.ts index 2cb614f..9e50011 100644 --- a/src/sidecar.test.ts +++ b/src/sidecar.test.ts @@ -1,14 +1,28 @@ import { describe, expect, it } from "vitest"; import { createSourceAnchor } from "./sourceAnchor"; import { + addGeneratedRecord, addSourceRecord, createDocumentSidecar, + latestGeneratedRecord, latestSourceAnchor, parseDocumentSidecar } from "./sidecar"; const DOC_SHA = "c".repeat(64); +async function makeAnchor(quote = "source passage") { + return createSourceAnchor({ + documentSha256: DOC_SHA, + page: 2, + quote, + startItem: 3, + endItem: 4, + startOffset: 0, + endOffset: 7 + }); +} + describe("document sidecar", () => { it("persists source provenance without modifying the document", async () => { const sidecar = createDocumentSidecar({ @@ -16,15 +30,7 @@ describe("document sidecar", () => { fileName: "sample.pdf", byteLength: 1234 }); - const anchor = await createSourceAnchor({ - documentSha256: DOC_SHA, - page: 2, - quote: "source passage", - startItem: 3, - endItem: 4, - startOffset: 0, - endOffset: 7 - }); + const anchor = await makeAnchor(); const updated = addSourceRecord(sidecar, anchor); expect(updated.records).toHaveLength(1); @@ -35,20 +41,75 @@ describe("document sidecar", () => { it("deduplicates the same source selection", async () => { let sidecar = createDocumentSidecar({ sha256: DOC_SHA, fileName: "sample.pdf", byteLength: 1234 }); - const anchor = await createSourceAnchor({ - documentSha256: DOC_SHA, - page: 1, - quote: "same selection", - startItem: 0, - endItem: 1, - startOffset: 0, - endOffset: 4 - }); + const anchor = await makeAnchor("same selection"); sidecar = addSourceRecord(sidecar, anchor); sidecar = addSourceRecord(sidecar, anchor); expect(sidecar.records).toHaveLength(1); }); + it("persists and restores a local translation independently from the source record", async () => { + let sidecar = createDocumentSidecar({ sha256: DOC_SHA, fileName: "sample.pdf", byteLength: 1234 }); + const anchor = await makeAnchor("medical source"); + sidecar = addSourceRecord(sidecar, anchor); + sidecar = addGeneratedRecord(sidecar, { + operation: "translate", + anchor, + output: "स्थानीय हिन्दी अनुवाद", + targetLanguage: "Hindi", + engine: { id: "odu.hindi-reference", version: "0.1a.1", local: true } + }); + + const restored = latestGeneratedRecord(sidecar, { + operation: "translate", + anchor, + targetLanguage: "Hindi" + }); + + expect(sidecar.records).toHaveLength(2); + expect(restored?.output).toBe("स्थानीय हिन्दी अनुवाद"); + expect(restored?.engine?.local).toBe(true); + expect(restored?.externallyVerified).toBe(false); + expect(restored?.humanReviewed).toBe(false); + }); + + it("keeps Translate and Explain provenance records separate", async () => { + let sidecar = createDocumentSidecar({ sha256: DOC_SHA, fileName: "sample.pdf", byteLength: 1234 }); + const anchor = await makeAnchor("same source"); + const engine = { id: "odu.hindi-reference", version: "0.1a.1", local: true }; + + sidecar = addGeneratedRecord(sidecar, { + operation: "translate", + anchor, + output: "अनुवाद", + targetLanguage: "Hindi", + engine + }); + sidecar = addGeneratedRecord(sidecar, { + operation: "explain", + anchor, + output: "सरल व्याख्या", + targetLanguage: "Hindi", + explanationLevel: "Simple", + engine + }); + + expect( + latestGeneratedRecord(sidecar, { + operation: "translate", + anchor, + targetLanguage: "Hindi" + })?.output + ).toBe("अनुवाद"); + expect( + latestGeneratedRecord(sidecar, { + operation: "explain", + anchor, + targetLanguage: "Hindi", + explanationLevel: "Simple" + })?.output + ).toBe("सरल व्याख्या"); + }); + it("rejects malformed serialized sidecars", () => { expect(parseDocumentSidecar("not json")).toBeNull(); expect(parseDocumentSidecar(JSON.stringify({ schemaVersion: 2 }))).toBeNull(); diff --git a/src/sidecar.ts b/src/sidecar.ts index 70bf51a..b8f6e12 100644 --- a/src/sidecar.ts +++ b/src/sidecar.ts @@ -12,6 +12,8 @@ export type ProvenanceRecord = { createdAt: string; output?: string; question?: string; + targetLanguage?: string; + explanationLevel?: string; engine?: { id: string; version: string; @@ -81,12 +83,80 @@ export function addSourceRecord(sidecar: DocumentSidecar, anchor: SourceAnchor): }; } +export function addGeneratedRecord( + sidecar: DocumentSidecar, + input: { + operation: "translate" | "explain"; + anchor: SourceAnchor; + output: string; + targetLanguage: string; + explanationLevel?: string; + engine: { id: string; version: string; local: boolean }; + } +): DocumentSidecar { + if (input.anchor.documentSha256 !== sidecar.document.sha256) { + throw new Error("Cannot attach generated output to a different document sidecar."); + } + if (!input.output.trim()) throw new Error("Generated output cannot be empty."); + + const createdAt = new Date().toISOString(); + const record: ProvenanceRecord = { + id: `${input.operation}-${input.anchor.quoteSha256.slice(0, 12)}-${Date.now()}`, + operation: input.operation, + anchor: input.anchor, + output: input.output.trim(), + targetLanguage: input.targetLanguage, + explanationLevel: input.explanationLevel, + engine: input.engine, + createdAt, + externallyVerified: false, + humanReviewed: false + }; + + const records = [ + ...sidecar.records.filter( + (existing) => + !( + existing.operation === input.operation && + existing.anchor.quoteSha256 === input.anchor.quoteSha256 && + existing.targetLanguage === input.targetLanguage && + existing.explanationLevel === input.explanationLevel && + existing.engine?.id === input.engine.id && + existing.engine?.version === input.engine.version + ) + ), + record + ]; + + return { ...sidecar, records, updatedAt: createdAt }; +} + export function latestSourceAnchor(sidecar: DocumentSidecar): SourceAnchor | null { const sources = sidecar.records.filter((record) => record.operation === "source"); if (sources.length === 0) return null; return sources[sources.length - 1].anchor; } +export function latestGeneratedRecord( + sidecar: DocumentSidecar, + input: { + operation: "translate" | "explain"; + anchor: SourceAnchor; + targetLanguage: string; + explanationLevel?: string; + } +): ProvenanceRecord | null { + const matching = sidecar.records.filter( + (record) => + record.operation === input.operation && + record.anchor.quoteSha256 === input.anchor.quoteSha256 && + record.targetLanguage === input.targetLanguage && + record.explanationLevel === input.explanationLevel && + typeof record.output === "string" + ); + return matching.length ? matching[matching.length - 1] : null; +} + export function parseDocumentSidecar(value: string): DocumentSidecar | null { try { const parsed = JSON.parse(value) as DocumentSidecar; diff --git a/src/styles.css b/src/styles.css index 66df626..a4b2ad9 100644 --- a/src/styles.css +++ b/src/styles.css @@ -20,6 +20,8 @@ --privacy-fg: #176b45; --privacy-bg: #eaf8f1; --privacy-dot: #19a463; + --warning-fg: #8a4b08; + --warning-bg: #fff7e6; --shadow: rgb(16 24 40 / 18%); --highlight: rgb(255 199 61 / 38%); } @@ -44,6 +46,8 @@ html[data-theme="dark"] { --privacy-fg: #79d8aa; --privacy-bg: #173126; --privacy-dot: #56c98e; + --warning-fg: #f3c47a; + --warning-bg: #332814; --shadow: rgb(0 0 0 / 42%); --highlight: rgb(255 199 61 / 45%); } @@ -101,7 +105,11 @@ blockquote { margin: 10px 0; padding-left: 12px; border-left: 3px solid #98a2b3; select, textarea { width: 100%; border: 1px solid var(--control-border); border-radius: 8px; background: var(--control-bg); color: var(--text); padding: 9px; } textarea { min-height: 86px; resize: vertical; } .output-card { background: var(--surface-output); line-height: 1.55; } -.output-card p { margin-bottom: 0; } +.output-card p { margin-bottom: 0; white-space: pre-wrap; } +.local-action { width: 100%; margin-top: 14px; } +.generation-meta { display: grid; gap: 3px; margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--border-soft); color: var(--muted); font-size: 10px; } +.generation-meta span:first-child { color: var(--privacy-fg); font-weight: 800; letter-spacing: .05em; } +.reference-warning { margin-top: 14px; border-radius: 10px; padding: 10px 12px; background: var(--warning-bg); color: var(--warning-fg); font-size: 11px; line-height: 1.45; } .status-line { margin-top: 14px; font-size: 12px; color: var(--muted); line-height: 1.4; } @media (max-width: 1120px) {