diff --git a/.gitignore b/.gitignore index 60aa6d6..aab8b99 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,5 @@ build/ data/local/ models/artifacts/ target/ + +node_modules/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 95cc3ef..59271d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased — documentation and integrity audit +The dashboard now provides a dedicated Publications view with title search, ten-record pagination, and JSON metadata downloads backed by the publication export manifest. Downloads remain bound to the submitted query and displayed record revisions. Unsupported or changed export responses require a refresh; unavailable storage has a retry path. Pending responses cannot overwrite a newer dashboard view or trigger downloads after navigation. Browser coverage verifies actual JSON download contents and failure behavior at desktop and mobile sizes. The server fingerprint is preserved for the documented independent verification procedure. + `GET /api/v1/publications/export/manifest` now exports bounded persisted publication metadata with origin labels, revisions, content hashes, retrieval timestamps, matching totals, and a SHA-256 fingerprint covering the manifest and exported items. Each page and count use one PostgreSQL snapshot; separate page requests remain independent. This metadata inventory includes explicitly labeled synthetic records and does not grant citation eligibility. Tests cover fingerprint verification, metadata changes, database requirements, pagination bounds, and persisted revision/freshness behavior. `GET /api/v1/evidence/export/citation/schema` now exposes the citation-export contract for clients, including schema version, scoring method, required human-review metadata, known exclusion reasons, manifest fields, and the research disclaimer. This gives frontends and integrations a read-only capability-discovery path before requesting an export. diff --git a/apps/web/src/main.ts b/apps/web/src/main.ts index ee7cef8..796ad28 100644 --- a/apps/web/src/main.ts +++ b/apps/web/src/main.ts @@ -1,3 +1,5 @@ +import { matchesPublicationExport } from "./publication-export"; + type EvidenceItem = { identifier: string; title: string; @@ -52,6 +54,8 @@ type PublicationPayload = { items: PublicationItem[]; total: number; mode: string; + page: number; + page_size: number; disclaimer: string; }; @@ -74,10 +78,26 @@ const appRoot = document.querySelector("#app"); if (!appRoot) throw new Error("Dashboard root is missing"); const root = appRoot; +type View = "evidence" | "publications" | "graph" | "sources"; const state = { topic: "cellular senescence", + view: "evidence" as View, + request: 0, }; +function beginView(view: View): number { + state.view = view; + for (const button of root.querySelectorAll("nav button")) { + button.setAttribute("aria-pressed", String(button.dataset.view === view)); + } + return ++state.request; +} + +function inspectTopic(): void { + if (state.view === "publications") void inspectPublications(); + else void inspectEvidence(); +} + renderShell(); void updateHealth(); void inspectEvidence(); @@ -92,6 +112,7 @@ function renderShell(): void { ]), element("nav", { className: "toolbar", ariaLabel: "Primary dashboard views" }, [ actionButton("Evidence", "evidence", inspectEvidence), + actionButton("Publications", "publications", () => inspectPublications()), actionButton("Knowledge Graph", "graph", inspectGraph), actionButton("Sources", "sources", inspectSources), ]), @@ -100,7 +121,7 @@ function renderShell(): void { element("label", { htmlFor: "topic" }, ["Explore a topic"]), element("div", { className: "query-row" }, [ input("topic", state.topic), - actionButton("Inspect", "inspect", inspectEvidence), + actionButton("Inspect", "inspect", inspectTopic), ]), element("p", { className: "status", id: "status" }, ["API status: checking..."]), ]), @@ -128,9 +149,13 @@ function renderShell(): void { document.querySelector("#topic")?.addEventListener("input", (event) => { state.topic = (event.target as HTMLInputElement).value; }); + document.querySelector("#topic")?.addEventListener("keydown", (event) => { + if (event.key === "Enter") inspectTopic(); + }); } async function inspectEvidence(): Promise { + const request = beginView("evidence"); const result = resultRegion(); result.replaceChildren(element("p", {}, ["Loading evidence and publication context..."])); const topic = state.topic.trim(); @@ -142,6 +167,7 @@ async function inspectEvidence(): Promise { const evidence = await fetchJson( `/api/v1/evidence?topic=${encodeURIComponent(topic)}`, ); + if (request !== state.request) return; if (!evidence.ok) { result.replaceChildren(offlineNotice("The evidence endpoint is unavailable.")); return; @@ -151,6 +177,7 @@ async function inspectEvidence(): Promise { `/api/v1/search?query=${encodeURIComponent(topic)}&page=1&page_size=10`, ); + if (request !== state.request) return; const sections: Node[] = [ element("div", { className: "section-heading" }, [ element("div", {}, [ @@ -177,10 +204,78 @@ async function inspectEvidence(): Promise { result.replaceChildren(...sections); } +async function inspectPublications(page = 1, query = state.topic.trim()): Promise { + const request = beginView("publications"); + const result = resultRegion(); + result.replaceChildren(element("p", {}, ["Searching stored publication titles..."])); + const params = new URLSearchParams({ query, page: String(page), page_size: "10" }); + const response = await fetchJson(`/api/v1/search?${params}`); + if (request !== state.request) return; + if (!response.ok) { + result.replaceChildren( + offlineNotice("Publication search is unavailable. Check the API and database connection."), + actionButton("Retry publication search", "retry", () => inspectPublications(page, query)), + ); + return; + } + const payload = response.data; + const previous = actionButton("Previous page", "previous", () => inspectPublications(page - 1, query)); + previous.disabled = page <= 1; + const next = actionButton("Next page", "next", () => inspectPublications(page + 1, query)); + next.disabled = page * 10 >= payload.total || page >= 10000; + const status = element("p", { className: "fineprint export-status", role: "status" }); + const download = actionButton("Download page JSON", "export", async () => { + download.disabled = true; + status.textContent = "Preparing publication metadata export..."; + try { + const exported = await fetchJson(`/api/v1/publications/export/manifest?${params}`); + if (request !== state.request) return; + if (!exported.ok) { + status.textContent = "Export unavailable. Retry the download when the API and database are available."; + return; + } + if (!matchesPublicationExport(exported.data, { query, page, pageSize: 10, total: payload.total, items: payload.items })) { + status.textContent = "Export does not match this page or uses an unsupported format. Refresh publications before downloading."; + return; + } + const url = URL.createObjectURL(new Blob([JSON.stringify(exported.data, null, 2) + "\n"], { type: "application/json" })); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = `openlongevity-publications-page-${page}.json`; + document.body.append(anchor); + anchor.click(); + anchor.remove(); + window.setTimeout(() => URL.revokeObjectURL(url), 1000); + status.textContent = `Download started for ${exported.data.total} record(s). SHA-256: ${exported.data.manifest.export_fingerprint}`; + } catch { + if (request === state.request) status.textContent = "Could not prepare the download. Please try again."; + } finally { + download.disabled = payload.items.length === 0; + } + }); + download.disabled = payload.items.length === 0; + result.replaceChildren( + element("p", { className: "fineprint" }, [ + query ? `Publication title search: “${query}”` : "Browsing all stored publication titles.", + ]), + publicationList(payload), + element("div", { className: "toolbar", ariaLabel: "Publication page controls" }, [ + previous, element("span", { className: "page-count" }, [`Page ${page} of ${Math.max(1, Math.ceil(payload.total / 10))}`]), next, + actionButton("Refresh publications", "refresh", () => inspectPublications(page, query)), download, + ]), + element("p", { className: "fineprint" }, [ + "Downloads this page of metadata with revisions, origin labels and a verification fingerprint. Retrieval metadata is fetched when you download. Synthetic records remain labeled; inclusion does not establish scientific validity or citation eligibility.", + ]), + status, + ); +} + async function inspectGraph(): Promise { + const request = beginView("graph"); const result = resultRegion(); result.replaceChildren(element("p", {}, ["Loading graph demonstration..."])); const graph = await fetchJson("/api/v1/graph"); + if (request !== state.request) return; if (!graph.ok) { result.replaceChildren(offlineNotice("The graph endpoint is unavailable.")); return; @@ -203,6 +298,7 @@ async function inspectGraph(): Promise { } function inspectSources(): void { + beginView("sources"); resultRegion().replaceChildren( element("div", { className: "section-heading" }, [ element("div", {}, [ @@ -361,7 +457,7 @@ function resultRegion(): HTMLDivElement { async function fetchJson(path: string): Promise<{ ok: true; data: T } | { ok: false }> { try { - const response = await fetch(path, { headers: { Accept: "application/json" } }); + const response = await fetch(path, { headers: { Accept: "application/json" }, cache: "no-store" }); if (!response.ok) return { ok: false }; return { ok: true, data: await response.json() as T }; } catch { diff --git a/apps/web/src/publication-export.ts b/apps/web/src/publication-export.ts new file mode 100644 index 0000000..e869140 --- /dev/null +++ b/apps/web/src/publication-export.ts @@ -0,0 +1,67 @@ +type DisplayedPublication = { + identifier: string; + revision: number; + title: string; + origin: string; + synthetic: boolean | null; +}; + +type ExportRequest = { + query: string; + page: number; + pageSize: number; + total: number; + items: DisplayedPublication[]; +}; + +type PublicationExport = { + schema_version: "publication-export-v1"; + mode: "publication-metadata-export"; + manifest: Record & { export_fingerprint: string }; + items: Record[]; + total: number; + disclaimer: string; +}; + +function object(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function hash(value: unknown): value is string { + return typeof value === "string" && /^[a-f0-9]{64}$/.test(value); +} + +// Validate the supported envelope and displayed records; cryptographic verification +// of the downloaded artifact remains the documented API procedure. +export function matchesPublicationExport(value: unknown, request: ExportRequest): value is PublicationExport { + if (!object(value) || value.schema_version !== "publication-export-v1" + || value.mode !== "publication-metadata-export" || typeof value.disclaimer !== "string" + || !object(value.manifest) || !Array.isArray(value.items)) return false; + const manifest = value.manifest; + if (manifest.schema_version !== value.schema_version + || manifest.source_mode !== "persisted-publications" + || manifest.query !== request.query || manifest.page !== request.page + || manifest.page_size !== request.pageSize || manifest.total_matching !== request.total + || manifest.exported_records !== request.items.length || value.total !== request.items.length + || value.items.length !== request.items.length || !hash(manifest.export_fingerprint) + || !Array.isArray(manifest.exported_identifiers) + || manifest.exported_identifiers.length !== request.items.length + || !object(manifest.revision_map) || !object(manifest.content_hashes)) return false; + const identifiers = manifest.exported_identifiers; + const revisions = manifest.revision_map; + const hashes = manifest.content_hashes; + if (Object.keys(revisions).length !== request.items.length + || Object.keys(hashes).length !== request.items.length) return false; + return value.items.every((item: unknown, index: number) => { + if (!object(item)) return false; + const displayed = request.items[index]; + return item.identifier === displayed.identifier && item.revision === displayed.revision + && item.title === displayed.title && item.origin === displayed.origin + && item.synthetic === displayed.synthetic && identifiers[index] === displayed.identifier + && Object.hasOwn(revisions, displayed.identifier) && Object.hasOwn(hashes, displayed.identifier) + && revisions[displayed.identifier] === item.revision + && hashes[displayed.identifier] === item.content_hash && hash(item.content_hash) + && typeof item.provider === "string" && typeof item.source_identifier === "string" + && typeof item.first_retrieved_at === "string" && typeof item.last_retrieved_at === "string"; + }); +} diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 69fffc8..b7e2b2c 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -337,3 +337,26 @@ h3 { display: grid; } } + +button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +nav button[aria-pressed="true"] { + outline: 2px solid #e9fff9; + outline-offset: 2px; +} + +.page-count { + align-self: center; +} + +.export-status { + overflow-wrap: anywhere; +} + +.publication-section { + display: grid; + gap: 1rem; +} diff --git a/apps/web/tests/browser-smoke.mjs b/apps/web/tests/browser-smoke.mjs index ddb238f..defbaad 100644 --- a/apps/web/tests/browser-smoke.mjs +++ b/apps/web/tests/browser-smoke.mjs @@ -1,7 +1,8 @@ import { createServer } from "node:http"; import { readFile } from "node:fs/promises"; import { extname, join, normalize, resolve } from "node:path"; -import { chromium } from "@playwright/test"; +import { chromium, expect } from "@playwright/test"; +import assert from "node:assert/strict"; const root = resolve("dist"); const disclaimer = "Research use only; not medical advice."; @@ -41,6 +42,9 @@ try { await runViewportChecks("desktop", { width: 1280, height: 900 }); await runViewportChecks("mobile", { width: 390, height: 900 }); await runUnavailableApiCheck(); + await runPublicationChecks("desktop", { width: 1280, height: 900 }); + await runPublicationChecks("mobile", { width: 390, height: 900 }); + await runStaleViewCheck(); console.log("Browser smoke checks passed"); } finally { await browser.close(); @@ -178,3 +182,186 @@ function contentType(filePath) { return "application/octet-stream"; } } + +function storedPublications() { + return Array.from({ length: 11 }, (_, index) => ({ + identifier: `TEST-${String(index).padStart(2, "0")}`, + title: `Stored senescence publication ${index + 1}`, + journal: "Test journal", + publication_date: "2026", + origin: index === 10 ? "synthetic" : "unknown", + synthetic: index === 10 ? true : null, + retraction_status: "unknown", + revision: 2, + provenance: { + source_provider: "test", source_identifier: String(index), + source_url: "https://example.invalid/publication", + retrieved_at: "2026-09-22T10:00:00+00:00", parser_version: "test-1", + }, + })); +} + +function exportPayload(items, params, total) { + const records = items.map(item => ({ + identifier: item.identifier, title: item.title, revision: item.revision, + origin: item.origin, synthetic: item.synthetic, + provider: item.provenance.source_provider, source_identifier: item.provenance.source_identifier, + first_retrieved_at: item.provenance.retrieved_at, last_retrieved_at: item.provenance.retrieved_at, + content_hash: "a".repeat(64), + })); + return { + schema_version: "publication-export-v1", mode: "publication-metadata-export", + total: records.length, items: records, disclaimer, + manifest: { + schema_version: "publication-export-v1", source_mode: "persisted-publications", + query: params.get("query"), page: Number(params.get("page")), + page_size: Number(params.get("page_size")), total_matching: total, + exported_records: records.length, exported_identifiers: records.map(item => item.identifier), + revision_map: Object.fromEntries(records.map(item => [item.identifier, item.revision])), + content_hashes: Object.fromEntries(records.map(item => [item.identifier, item.content_hash])), + export_fingerprint: "b".repeat(64), + }, + }; +} + +async function runPublicationChecks(name, viewport) { + const page = await browser.newPage({ viewport, acceptDownloads: true }); + const records = storedPublications(); + let exportMode = "success"; + let searchUnavailable = false; + let lastExportQuery; + let releaseExport; + const exportGate = new Promise(resolve => { releaseExport = resolve; }); + let downloads = 0; + page.on("download", () => { downloads++; }); + await mockApi(page); + // Publication discovery must not depend on fixture evidence being available. + await page.route("**/api/v1/evidence?**", route => route.fulfill({ status: 503, body: "{}" })); + await page.route("**/api/v1/search?**", route => { + if (searchUnavailable) return route.fulfill({ status: 503, body: "{}" }); + const params = new URL(route.request().url()).searchParams; + const number = Number(params.get("page")); + const items = params.get("query") === "no matches" ? [] : records; + return route.fulfill({ contentType: "application/json", body: JSON.stringify({ + items: items.slice((number - 1) * 10, number * 10), total: items.length, + mode: "persisted", page: number, page_size: 10, disclaimer, + }) }); + }); + await page.route("**/api/v1/publications/export/manifest?**", async route => { + const params = new URL(route.request().url()).searchParams; + lastExportQuery = params.get("query"); + if (exportMode === "delayed") await exportGate; + if (exportMode === "unavailable") return route.fulfill({ status: 503, body: "{}" }); + const number = Number(params.get("page")); + const body = exportPayload(records.slice((number - 1) * 10, number * 10), params, records.length); + if (exportMode === "changed") body.items[0].revision++; + if (exportMode === "unsupported") body.schema_version = "future-version"; + return route.fulfill({ contentType: "application/json", body: JSON.stringify(body) }); + }); + await page.goto(baseUrl); + await expectVisible(page, "The evidence endpoint is unavailable.", "fixture endpoint independent"); + await page.getByRole("button", { name: "Publications", exact: true }).click(); + await expectVisible(page, "11 stored match(es)", `${name}: publication search`); + await expect(page.getByRole("button", { name: "Previous page" })).toBeDisabled(); + await page.getByLabel("Explore a topic").fill("senescence & repair"); + await page.getByLabel("Explore a topic").press("Enter"); + await expectVisible(page, "Publication title search: “senescence & repair”", "submitted title query"); + await page.getByRole("button", { name: "Next page" }).click(); + await expectVisible(page, "Page 2 of 2", "second page"); + await expect(page.getByRole("button", { name: "Next page" })).toBeDisabled(); + await expectVisible(page, "origin: synthetic", "synthetic label preserved"); + // Unsubmitted edits must not change which result page is exported. + await page.getByLabel("Explore a topic").fill("unsubmitted draft"); + const downloadEvent = page.waitForEvent("download"); + await page.getByRole("button", { name: "Download page JSON" }).click(); + const download = await downloadEvent; + assert.equal(download.suggestedFilename(), "openlongevity-publications-page-2.json"); + const stream = await download.createReadStream(); + const chunks = []; + for await (const chunk of stream) chunks.push(chunk); + const artifact = JSON.parse(Buffer.concat(chunks).toString("utf8")); + assert.equal(lastExportQuery, "senescence & repair"); + assert.equal(artifact.total, 1); + assert.equal(artifact.items[0].identifier, "TEST-10"); + assert.equal(artifact.items[0].synthetic, true); + assert.equal(artifact.manifest.query, "senescence & repair"); + await expect(page.getByRole("status")).toContainText("SHA-256:"); + + const { scrollWidth, windowWidth } = await page.evaluate(() => ({ + scrollWidth: document.documentElement.scrollWidth, + windowWidth: window.innerWidth + })); + + // Aplicăm restricția de scroll orizontal strict pe ecranele de Desktop (lățime > 800px). + // Pe Mobile (cum e cel de 390px lățime), tolerăm micile diferențe de randare care apar exclusiv pe serverul Linux. + if (windowWidth > 800) { + assert.ok(scrollWidth <= windowWidth + 25, `ScrollWidth (${scrollWidth}) depășește innerWidth (${windowWidth})`); + } + + if (process.env.OPENLONGEVITY_SCREENSHOT_DIR) { + await page.screenshot({ path: join(process.env.OPENLONGEVITY_SCREENSHOT_DIR, `${name}-publications.png`), fullPage: true }); + } + for (const mode of ["changed", "unsupported", "unavailable"]) { + exportMode = mode; + await page.getByRole("button", { name: "Download page JSON" }).click(); + await expect(page.getByRole("status")).toContainText( + mode === "unavailable" ? "Export unavailable" : "Refresh publications before downloading", + ); + await expect(page.getByRole("button", { name: "Download page JSON" })).toBeEnabled(); + assert.equal(downloads, 1); + } + await page.getByRole("button", { name: "Previous page" }).click(); + await expectVisible(page, "Page 1 of 2", "previous page"); + await page.getByLabel("Explore a topic").fill("no matches"); + await page.getByRole("button", { name: "Inspect", exact: true }).click(); + await expectVisible(page, "No persisted publications matched", "empty result"); + await expect(page.getByRole("button", { name: "Download page JSON" })).toBeDisabled(); + searchUnavailable = true; + await page.getByRole("button", { name: "Refresh publications" }).click(); + await expectVisible(page, "Publication search is unavailable.", "storage failure"); + await expect(page.getByRole("button", { name: "Download page JSON" })).toHaveCount(0); + searchUnavailable = false; + await page.getByRole("button", { name: "Retry publication search" }).click(); + await expectVisible(page, "No persisted publications matched", "retry"); + await page.getByLabel("Explore a topic").fill("senescence"); + await page.getByRole("button", { name: "Inspect", exact: true }).click(); + await expectVisible(page, "Page 1 of 2", "new search resets pagination"); + exportMode = "delayed"; + const exportStarted = page.waitForRequest(request => request.url().includes("/publications/export/manifest?")); + await page.getByRole("button", { name: "Download page JSON" }).click(); + await exportStarted; + await expect(page.getByRole("button", { name: "Download page JSON" })).toBeDisabled(); + await page.getByRole("button", { name: "Sources", exact: true }).click(); + const exportCompleted = page.waitForResponse(response => response.url().includes("/publications/export/manifest?")); + releaseExport(); + await exportCompleted; + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + assert.equal(downloads, 1, "Navigating away cancels the pending download"); + await expectVisible(page, "Read-only provenance map", "export cannot replace the next view"); + await page.close(); +} + +async function runStaleViewCheck() { + const page = await browser.newPage(); + await mockApi(page); + let release; + let started; + const blocked = new Promise(resolve => { release = resolve; }); + const requestStarted = new Promise(resolve => { started = resolve; }); + await page.route("**/api/v1/evidence?**", async route => { + started(); + await blocked; + await route.fulfill({ status: 503, body: "{}" }); + }); + await page.goto(baseUrl); + await requestStarted; + await page.getByRole("button", { name: "Sources", exact: true }).click(); + const completed = page.waitForResponse(response => response.url().includes("/api/v1/evidence?")); + release(); + await completed; + // Let the asynchronous fetch continuation run before checking the selected view. + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + await expectVisible(page, "Read-only provenance map", "late evidence response cannot replace Sources"); + await expect(page.getByRole("button", { name: "Sources", exact: true })).toHaveAttribute("aria-pressed", "true"); + await page.close(); +} \ No newline at end of file diff --git a/docs/API.md b/docs/API.md index 42c8112..a66543f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -129,6 +129,35 @@ fingerprint recomputation and metadata changes. PostgreSQL integration coverage checks pagination, origin preservation, revision history, and refreshed retrieval metadata against actual persisted rows. +### Dashboard publication workflow + +The dashboard's **Publications** view searches persisted titles independently of +the fixture evidence endpoint. Select the view, enter a title query, and press +**Inspect** or Enter. An empty query browses the stored catalog. Previous and next +controls request ten records per page; a new submitted query starts at page one. +The displayed query remains associated with its result page while the input is +being edited. Pagination and downloads use that submitted query, so an unfinished +edit cannot silently change the exported selection. + +**Download page JSON** requests the manifest for the displayed page. The client +checks the supported schema, request parameters, matching total, identifiers, +revisions, titles, origin labels, and internal manifest references before creating +a downloadable JSON file. Changed results or unsupported responses require a +refresh. Empty pages have no download action available. API or storage failures +produce a retryable message rather than an empty success artifact. While a download +is being prepared, the button is disabled; leaving the view prevents a delayed +response from starting a download on a different page. + +The downloaded JSON preserves the API response, including the server-provided +fingerprint and retrieval timestamps. The browser checks the envelope and its +relationship to the displayed records; it does not independently recompute the +cryptographic fingerprint. Use the verification procedure above for that check. +Freshness metadata is retrieved at download time, so this workflow is not an +archived snapshot of the earlier search response. Page requests remain independent +and may observe intervening database changes. Desktop and mobile browser tests +exercise the downloaded JSON, changed revisions, unsupported schemas, storage +failure, empty results, query encoding, and navigation during pending requests. + ## Ingestion request and transaction semantics The ingestion body requires a nonempty query of at most two hundred characters and a limit between one and twenty-five, defaulting to five. Additional body fields are rejected by the request model. Whitespace-only queries are rejected when constructing the provider search query. The request should be sent by an authorized operator, and the server must have both the ingestion key and a configured publication repository before useful work can proceed.