From 812501d831c2f6a6cbf875b6a6a413ecf8a94f8b Mon Sep 17 00:00:00 2001 From: Telemark Codex Agent Date: Sun, 16 Aug 2026 00:06:32 +0200 Subject: [PATCH] Add paper ingestion quality gate --- paper-ingestion-quality-gate/README.md | 48 ++++ .../data/sample_ingestion_packets.json | 150 ++++++++++ paper-ingestion-quality-gate/package.json | 13 + paper-ingestion-quality-gate/reports/demo.mp4 | Bin 0 -> 54851 bytes .../reports/reviewer-packet.md | 69 +++++ .../reports/summary.json | 241 ++++++++++++++++ .../reports/summary.svg | 17 ++ paper-ingestion-quality-gate/scripts/demo.js | 77 ++++++ .../scripts/render-demo-video.js | 60 ++++ paper-ingestion-quality-gate/src/index.js | 259 ++++++++++++++++++ .../test/paper-ingestion-quality-gate.test.js | 57 ++++ 11 files changed, 991 insertions(+) create mode 100644 paper-ingestion-quality-gate/README.md create mode 100644 paper-ingestion-quality-gate/data/sample_ingestion_packets.json create mode 100644 paper-ingestion-quality-gate/package.json create mode 100644 paper-ingestion-quality-gate/reports/demo.mp4 create mode 100644 paper-ingestion-quality-gate/reports/reviewer-packet.md create mode 100644 paper-ingestion-quality-gate/reports/summary.json create mode 100644 paper-ingestion-quality-gate/reports/summary.svg create mode 100644 paper-ingestion-quality-gate/scripts/demo.js create mode 100644 paper-ingestion-quality-gate/scripts/render-demo-video.js create mode 100644 paper-ingestion-quality-gate/src/index.js create mode 100644 paper-ingestion-quality-gate/test/paper-ingestion-quality-gate.test.js diff --git a/paper-ingestion-quality-gate/README.md b/paper-ingestion-quality-gate/README.md new file mode 100644 index 00000000..2c2f8082 --- /dev/null +++ b/paper-ingestion-quality-gate/README.md @@ -0,0 +1,48 @@ +# Paper Ingestion Quality Gate + +This contribution adds a focused intake guard for the AI Paper Summarizer in SCIBASE's AI-Assisted Research Tools MVP. It checks whether an uploaded or repository-linked paper has been extracted cleanly enough before an AI summarizer generates key findings, implications, or next steps. + +The slice is intentionally narrow. It does not score the generated summary, validate citation context, check journal venue quality, assess statistics, run reporting guidelines, detect plagiarism, or review disclosure statements. It sits earlier in the workflow: before summarization, when broken PDF/OCR/table/reference extraction can quietly poison every downstream AI output. + +## What It Checks + +- Dropped pages between the source PDF and extracted text. +- Required scientific sections: abstract, methods, results, and discussion. +- OCR confidence and unrecognized glyph noise. +- Table extraction completeness before numeric key findings are generated. +- Figure caption extraction before figure-derived summary bullets are allowed. +- Reference parsing coverage before citation-based implications are generated. +- Equation extraction coverage for mathematical or methods summaries. +- Required extraction provenance for text, sections, tables, figures, references, and digests. +- Whether key-finding generation is incorrectly enabled while intake findings are unresolved. + +## Running Locally + +```bash +npm test +npm run demo +npm run check +npm run video +``` + +The demo uses only synthetic intake packets in `data/sample_ingestion_packets.json`. + +Generated files: + +- `reports/summary.json` +- `reports/reviewer-packet.md` +- `reports/summary.svg` +- `reports/demo.mp4` + +The MP4 is generated from an FFmpeg color source and drawtext slate. It is not a screen recording and contains no desktop capture or private information. + +## Why This Matters + +AI summarizers can sound confident even when source extraction is broken. A missing methods section, low-quality OCR, dropped pages, unparsed tables, or failed references can lead to unsupported key findings and misleading implications. + +This guard creates a deterministic intake review packet so SCIBASE can: + +1. Block summarization when the source is incomplete. +2. Route questionable intake packets for manual review. +3. Suppress table, figure, reference, or equation-derived summary bullets when extraction is incomplete. +4. Preserve extraction provenance for reviewers and collaborators. diff --git a/paper-ingestion-quality-gate/data/sample_ingestion_packets.json b/paper-ingestion-quality-gate/data/sample_ingestion_packets.json new file mode 100644 index 00000000..593b83ab --- /dev/null +++ b/paper-ingestion-quality-gate/data/sample_ingestion_packets.json @@ -0,0 +1,150 @@ +[ + { + "id": "ingest-summarize-001", + "title": "Clean paper intake for abstract and executive summary generation", + "source": { + "fileType": "pdf", + "pageCount": 12 + }, + "expected": { + "tables": 2, + "figures": 3, + "references": 42, + "minEquationCoverage": 0.9 + }, + "extraction": { + "pagesExtracted": 12, + "sectionsPresent": ["abstract", "introduction", "methods", "results", "discussion", "references"], + "ocrConfidence": 0.98, + "unrecognizedGlyphRate": 0.002, + "textCoverage": 0.96, + "tablesExtracted": 2, + "figureCaptionsExtracted": 3, + "referencesParsed": 41, + "equationCoverage": 0.93 + }, + "summarizer": { + "generateKeyFindings": true, + "requireCleanIntake": true + }, + "provenance": { + "textLayer": true, + "sectionMap": true, + "tableMap": true, + "figureCaptionMap": true, + "referenceMap": true, + "extractionDigest": true + } + }, + { + "id": "ingest-hold-002", + "title": "OCR-only scan with missing methods and dropped pages", + "source": { + "fileType": "pdf_scan", + "pageCount": 18 + }, + "expected": { + "tables": 4, + "figures": 6, + "references": 61, + "minEquationCoverage": 0.9 + }, + "extraction": { + "pagesExtracted": 15, + "sectionsPresent": ["abstract", "introduction", "discussion", "references"], + "ocrConfidence": 0.61, + "unrecognizedGlyphRate": 0.092, + "textCoverage": 0.62, + "tablesExtracted": 1, + "figureCaptionsExtracted": 3, + "referencesParsed": 25, + "equationCoverage": 0.41 + }, + "summarizer": { + "generateKeyFindings": true, + "requireCleanIntake": false + }, + "provenance": { + "textLayer": true, + "sectionMap": false, + "tableMap": false, + "figureCaptionMap": false, + "referenceMap": false, + "extractionDigest": false + } + }, + { + "id": "ingest-review-003", + "title": "Good text extraction with incomplete tables and captions", + "source": { + "fileType": "pdf", + "pageCount": 9 + }, + "expected": { + "tables": 3, + "figures": 4, + "references": 30, + "minEquationCoverage": 0.85 + }, + "extraction": { + "pagesExtracted": 9, + "sectionsPresent": ["abstract", "introduction", "methods", "results", "discussion", "references"], + "ocrConfidence": 0.94, + "unrecognizedGlyphRate": 0.006, + "textCoverage": 0.91, + "tablesExtracted": 2, + "figureCaptionsExtracted": 2, + "referencesParsed": 28, + "equationCoverage": 0.87 + }, + "summarizer": { + "generateKeyFindings": false, + "requireCleanIntake": true + }, + "provenance": { + "textLayer": true, + "sectionMap": true, + "tableMap": true, + "figureCaptionMap": false, + "referenceMap": true, + "extractionDigest": true + } + }, + { + "id": "ingest-hold-004", + "title": "Reference and equation extraction failure before implications summary", + "source": { + "fileType": "pdf", + "pageCount": 14 + }, + "expected": { + "tables": 1, + "figures": 2, + "references": 48, + "minEquationCoverage": 0.9 + }, + "extraction": { + "pagesExtracted": 14, + "sectionsPresent": ["abstract", "introduction", "methods", "results", "discussion", "references"], + "ocrConfidence": 0.9, + "unrecognizedGlyphRate": 0.01, + "textCoverage": 0.88, + "tablesExtracted": 1, + "figureCaptionsExtracted": 2, + "referencesParsed": 20, + "equationCoverage": 0.45 + }, + "summarizer": { + "generateKeyFindings": true, + "requireCleanIntake": false + }, + "provenance": { + "textLayer": true, + "sectionMap": true, + "tableMap": true, + "figureCaptionMap": true, + "referenceMap": false, + "extractionDigest": true + } + } +] diff --git a/paper-ingestion-quality-gate/package.json b/paper-ingestion-quality-gate/package.json new file mode 100644 index 00000000..a31e60e7 --- /dev/null +++ b/paper-ingestion-quality-gate/package.json @@ -0,0 +1,13 @@ +{ + "name": "paper-ingestion-quality-gate", + "version": "1.0.0", + "description": "Dependency-free AI summarizer intake guard for broken paper/OCR/table/reference extraction.", + "main": "src/index.js", + "scripts": { + "test": "node --test", + "demo": "node scripts/demo.js", + "video": "node scripts/render-demo-video.js", + "check": "node --check src/index.js && node --check scripts/demo.js && node --check scripts/render-demo-video.js" + }, + "license": "MIT" +} diff --git a/paper-ingestion-quality-gate/reports/demo.mp4 b/paper-ingestion-quality-gate/reports/demo.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..e10ca8ac3814c96d9176248504e7c5ee78310b21 GIT binary patch literal 54851 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 literal 0 HcmV?d00001 diff --git a/paper-ingestion-quality-gate/reports/reviewer-packet.md b/paper-ingestion-quality-gate/reports/reviewer-packet.md new file mode 100644 index 00000000..8fc7cc75 --- /dev/null +++ b/paper-ingestion-quality-gate/reports/reviewer-packet.md @@ -0,0 +1,69 @@ +# Paper Ingestion Quality Gate Report + +Generated: 2026-08-15T22:05:11.925Z +Packets analyzed: 4 +Decision counts: SUMMARIZE 1, REVIEW 1, HOLD 2 + +## Findings + +### ingest-summarize-001: Clean paper intake for abstract and executive summary generation + +Decision: SUMMARIZE +Completeness: 0.984 + +- No intake quality risk detected. + +Recommended actions: +- Allow AI summarization with normal extraction provenance attached. + +### ingest-hold-002: OCR-only scan with missing methods and dropped pages + +Decision: HOLD +Completeness: 0.528 + +- CRITICAL PDF_PAGE_DROP_DETECTED: The source paper has pages that were not extracted. +- CRITICAL REQUIRED_SECTION_MISSING: Required scientific sections are missing from the extracted text map. +- CRITICAL OCR_OR_ENCODING_QUALITY_LOW: OCR confidence or glyph quality is too poor for a reliable AI-generated summary. +- CRITICAL TABLE_EXTRACTION_INCOMPLETE: One or more expected tables were not extracted for summarizer grounding. +- MAJOR FIGURE_CAPTION_MAP_INCOMPLETE: Figure captions are missing from the summarizer intake packet. +- MAJOR REFERENCE_PARSE_COVERAGE_LOW: Reference parsing coverage is too low for reliable related-work or implication summaries. +- MAJOR EQUATION_EXTRACTION_COVERAGE_LOW: Equation extraction coverage is below the configured summarizer threshold. +- MAJOR EXTRACTION_PROVENANCE_INCOMPLETE: The intake packet lacks required extraction provenance flags. +- CRITICAL INTAKE_COMPLETENESS_BELOW_THRESHOLD: Overall source extraction completeness is below the AI summarizer safety threshold. +- CRITICAL SUMMARIZER_NOT_GATED_ON_CLEAN_INTAKE: AI key-finding generation is enabled while source-ingestion quality findings are unresolved. + +Recommended actions: +- Disable key-finding generation until intake quality is clean. +- Attach the intake findings to the AI summarizer queue. +- Regenerate extraction artifacts before key findings or implications are generated. +- Show reviewers which source sections, tables, figures, or references are unsafe. + +### ingest-review-003: Good text extraction with incomplete tables and captions + +Decision: REVIEW +Completeness: 0.877 + +- MAJOR TABLE_EXTRACTION_INCOMPLETE: One or more expected tables were not extracted for summarizer grounding. +- MAJOR FIGURE_CAPTION_MAP_INCOMPLETE: Figure captions are missing from the summarizer intake packet. +- MAJOR EXTRACTION_PROVENANCE_INCOMPLETE: The intake packet lacks required extraction provenance flags. + +Recommended actions: +- Attach the intake findings to the AI summarizer queue. +- Regenerate extraction artifacts before key findings or implications are generated. +- Show reviewers which source sections, tables, figures, or references are unsafe. + +### ingest-hold-004: Reference and equation extraction failure before implications summary + +Decision: HOLD +Completeness: 0.824 + +- MAJOR REFERENCE_PARSE_COVERAGE_LOW: Reference parsing coverage is too low for reliable related-work or implication summaries. +- MAJOR EQUATION_EXTRACTION_COVERAGE_LOW: Equation extraction coverage is below the configured summarizer threshold. +- MAJOR EXTRACTION_PROVENANCE_INCOMPLETE: The intake packet lacks required extraction provenance flags. +- CRITICAL SUMMARIZER_NOT_GATED_ON_CLEAN_INTAKE: AI key-finding generation is enabled while source-ingestion quality findings are unresolved. + +Recommended actions: +- Disable key-finding generation until intake quality is clean. +- Attach the intake findings to the AI summarizer queue. +- Regenerate extraction artifacts before key findings or implications are generated. +- Show reviewers which source sections, tables, figures, or references are unsafe. diff --git a/paper-ingestion-quality-gate/reports/summary.json b/paper-ingestion-quality-gate/reports/summary.json new file mode 100644 index 00000000..dd0eba72 --- /dev/null +++ b/paper-ingestion-quality-gate/reports/summary.json @@ -0,0 +1,241 @@ +{ + "generatedAt": "2026-08-15T22:05:11.925Z", + "totalPackets": 4, + "counts": { + "SUMMARIZE": 1, + "REVIEW": 1, + "HOLD": 2 + }, + "results": [ + { + "id": "ingest-summarize-001", + "title": "Clean paper intake for abstract and executive summary generation", + "decision": "SUMMARIZE", + "completeness": 0.984, + "findings": [], + "recommendedActions": [ + "Allow AI summarization with normal extraction provenance attached." + ] + }, + { + "id": "ingest-hold-002", + "title": "OCR-only scan with missing methods and dropped pages", + "decision": "HOLD", + "completeness": 0.528, + "findings": [ + { + "code": "PDF_PAGE_DROP_DETECTED", + "severity": "critical", + "message": "The source paper has pages that were not extracted.", + "evidence": { + "pageCount": 18, + "pagesExtracted": 15, + "droppedPages": 3 + }, + "remediation": "Block summarization until the paper is reprocessed and every page has extraction evidence." + }, + { + "code": "REQUIRED_SECTION_MISSING", + "severity": "critical", + "message": "Required scientific sections are missing from the extracted text map.", + "evidence": { + "missingSections": [ + "methods", + "results" + ] + }, + "remediation": "Re-run section segmentation or require manual section confirmation before summary generation." + }, + { + "code": "OCR_OR_ENCODING_QUALITY_LOW", + "severity": "critical", + "message": "OCR confidence or glyph quality is too poor for a reliable AI-generated summary.", + "evidence": { + "ocrConfidence": 0.61, + "unrecognizedGlyphRate": 0.092 + }, + "remediation": "Use a higher-quality text layer or route to manual intake review before summarization." + }, + { + "code": "TABLE_EXTRACTION_INCOMPLETE", + "severity": "critical", + "message": "One or more expected tables were not extracted for summarizer grounding.", + "evidence": { + "expectedTables": 4, + "extractedTables": 1 + }, + "remediation": "Hold key-finding and numeric-summary generation until table extraction is complete." + }, + { + "code": "FIGURE_CAPTION_MAP_INCOMPLETE", + "severity": "major", + "message": "Figure captions are missing from the summarizer intake packet.", + "evidence": { + "expectedFigures": 6, + "extractedCaptions": 3 + }, + "remediation": "Regenerate figure-caption anchors or suppress figure-derived claims from the summary." + }, + { + "code": "REFERENCE_PARSE_COVERAGE_LOW", + "severity": "major", + "message": "Reference parsing coverage is too low for reliable related-work or implication summaries.", + "evidence": { + "expectedReferences": 61, + "parsedReferences": 25 + }, + "remediation": "Suppress citation-based implication bullets until reference parsing is repaired." + }, + { + "code": "EQUATION_EXTRACTION_COVERAGE_LOW", + "severity": "major", + "message": "Equation extraction coverage is below the configured summarizer threshold.", + "evidence": { + "equationCoverage": 0.41, + "minEquationCoverage": 0.9 + }, + "remediation": "Warn reviewers and avoid mathematical-method summaries until equation extraction is confirmed." + }, + { + "code": "EXTRACTION_PROVENANCE_INCOMPLETE", + "severity": "major", + "message": "The intake packet lacks required extraction provenance flags.", + "evidence": { + "missingProvenance": [ + "sectionMap", + "tableMap", + "figureCaptionMap", + "referenceMap", + "extractionDigest" + ] + }, + "remediation": "Attach text, section, table, figure, reference, and digest evidence before releasing AI summaries." + }, + { + "code": "INTAKE_COMPLETENESS_BELOW_THRESHOLD", + "severity": "critical", + "message": "Overall source extraction completeness is below the AI summarizer safety threshold.", + "evidence": { + "completeness": 0.528 + }, + "remediation": "Hold summarization and regenerate the intake packet from the source document." + }, + { + "code": "SUMMARIZER_NOT_GATED_ON_CLEAN_INTAKE", + "severity": "critical", + "message": "AI key-finding generation is enabled while source-ingestion quality findings are unresolved.", + "evidence": { + "generateKeyFindings": true, + "requireCleanIntake": false + }, + "remediation": "Require clean intake status before generating key findings, implications, or next steps." + } + ], + "recommendedActions": [ + "Disable key-finding generation until intake quality is clean.", + "Attach the intake findings to the AI summarizer queue.", + "Regenerate extraction artifacts before key findings or implications are generated.", + "Show reviewers which source sections, tables, figures, or references are unsafe." + ] + }, + { + "id": "ingest-review-003", + "title": "Good text extraction with incomplete tables and captions", + "decision": "REVIEW", + "completeness": 0.877, + "findings": [ + { + "code": "TABLE_EXTRACTION_INCOMPLETE", + "severity": "major", + "message": "One or more expected tables were not extracted for summarizer grounding.", + "evidence": { + "expectedTables": 3, + "extractedTables": 2 + }, + "remediation": "Hold key-finding and numeric-summary generation until table extraction is complete." + }, + { + "code": "FIGURE_CAPTION_MAP_INCOMPLETE", + "severity": "major", + "message": "Figure captions are missing from the summarizer intake packet.", + "evidence": { + "expectedFigures": 4, + "extractedCaptions": 2 + }, + "remediation": "Regenerate figure-caption anchors or suppress figure-derived claims from the summary." + }, + { + "code": "EXTRACTION_PROVENANCE_INCOMPLETE", + "severity": "major", + "message": "The intake packet lacks required extraction provenance flags.", + "evidence": { + "missingProvenance": [ + "figureCaptionMap" + ] + }, + "remediation": "Attach text, section, table, figure, reference, and digest evidence before releasing AI summaries." + } + ], + "recommendedActions": [ + "Attach the intake findings to the AI summarizer queue.", + "Regenerate extraction artifacts before key findings or implications are generated.", + "Show reviewers which source sections, tables, figures, or references are unsafe." + ] + }, + { + "id": "ingest-hold-004", + "title": "Reference and equation extraction failure before implications summary", + "decision": "HOLD", + "completeness": 0.824, + "findings": [ + { + "code": "REFERENCE_PARSE_COVERAGE_LOW", + "severity": "major", + "message": "Reference parsing coverage is too low for reliable related-work or implication summaries.", + "evidence": { + "expectedReferences": 48, + "parsedReferences": 20 + }, + "remediation": "Suppress citation-based implication bullets until reference parsing is repaired." + }, + { + "code": "EQUATION_EXTRACTION_COVERAGE_LOW", + "severity": "major", + "message": "Equation extraction coverage is below the configured summarizer threshold.", + "evidence": { + "equationCoverage": 0.45, + "minEquationCoverage": 0.9 + }, + "remediation": "Warn reviewers and avoid mathematical-method summaries until equation extraction is confirmed." + }, + { + "code": "EXTRACTION_PROVENANCE_INCOMPLETE", + "severity": "major", + "message": "The intake packet lacks required extraction provenance flags.", + "evidence": { + "missingProvenance": [ + "referenceMap" + ] + }, + "remediation": "Attach text, section, table, figure, reference, and digest evidence before releasing AI summaries." + }, + { + "code": "SUMMARIZER_NOT_GATED_ON_CLEAN_INTAKE", + "severity": "critical", + "message": "AI key-finding generation is enabled while source-ingestion quality findings are unresolved.", + "evidence": { + "generateKeyFindings": true, + "requireCleanIntake": false + }, + "remediation": "Require clean intake status before generating key findings, implications, or next steps." + } + ], + "recommendedActions": [ + "Disable key-finding generation until intake quality is clean.", + "Attach the intake findings to the AI summarizer queue.", + "Regenerate extraction artifacts before key findings or implications are generated.", + "Show reviewers which source sections, tables, figures, or references are unsafe." + ] + } + ] +} diff --git a/paper-ingestion-quality-gate/reports/summary.svg b/paper-ingestion-quality-gate/reports/summary.svg new file mode 100644 index 00000000..1a076eab --- /dev/null +++ b/paper-ingestion-quality-gate/reports/summary.svg @@ -0,0 +1,17 @@ + + + Paper Ingestion Quality Gate + Synthetic AI summarizer intake audit before key findings are generated. + Decision distribution + HOLD + + 2 + REVIEW + + 1 + SUMMARIZE + + 1 + Flags page drops, missing sections, OCR noise, table/caption gaps, reference failures, and unsafe summarizer release. + Synthetic generated artifact only. No desktop capture or private data. + diff --git a/paper-ingestion-quality-gate/scripts/demo.js b/paper-ingestion-quality-gate/scripts/demo.js new file mode 100644 index 00000000..192ef09c --- /dev/null +++ b/paper-ingestion-quality-gate/scripts/demo.js @@ -0,0 +1,77 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const { analyzeIngestionPackets } = require("../src"); + +const root = path.resolve(__dirname, ".."); +const dataPath = path.join(root, "data", "sample_ingestion_packets.json"); +const outDir = path.join(root, "reports"); +const packets = JSON.parse(fs.readFileSync(dataPath, "utf8")); +const report = analyzeIngestionPackets(packets); + +fs.mkdirSync(outDir, { recursive: true }); +fs.writeFileSync(path.join(outDir, "summary.json"), `${JSON.stringify(report, null, 2)}\n`); + +const markdown = [ + "# Paper Ingestion Quality Gate Report", + "", + `Generated: ${report.generatedAt}`, + `Packets analyzed: ${report.totalPackets}`, + `Decision counts: SUMMARIZE ${report.counts.SUMMARIZE}, REVIEW ${report.counts.REVIEW}, HOLD ${report.counts.HOLD}`, + "", + "## Findings", + "", + ...report.results.flatMap((item) => [ + `### ${item.id}: ${item.title}`, + "", + `Decision: ${item.decision}`, + `Completeness: ${item.completeness}`, + "", + item.findings.length === 0 + ? "- No intake quality risk detected." + : item.findings.map((finding) => `- ${finding.severity.toUpperCase()} ${finding.code}: ${finding.message}`).join("\n"), + "", + "Recommended actions:", + ...item.recommendedActions.map((action) => `- ${action}`), + "" + ]) +].join("\n"); + +fs.writeFileSync(path.join(outDir, "reviewer-packet.md"), markdown); + +const total = Math.max(1, report.totalPackets); +const maxBar = 620; +const holdWidth = Math.round((report.counts.HOLD / total) * maxBar); +const reviewWidth = Math.round((report.counts.REVIEW / total) * maxBar); +const summarizeWidth = Math.round((report.counts.SUMMARIZE / total) * maxBar); +const svg = ` + + Paper Ingestion Quality Gate + Synthetic AI summarizer intake audit before key findings are generated. + Decision distribution + HOLD + + ${report.counts.HOLD} + REVIEW + + ${report.counts.REVIEW} + SUMMARIZE + + ${report.counts.SUMMARIZE} + Flags page drops, missing sections, OCR noise, table/caption gaps, reference failures, and unsafe summarizer release. + Synthetic generated artifact only. No desktop capture or private data. + +`; + +fs.writeFileSync(path.join(outDir, "summary.svg"), svg); + +console.log(JSON.stringify({ + packets: report.totalPackets, + counts: report.counts, + outputs: [ + path.join(outDir, "summary.json"), + path.join(outDir, "reviewer-packet.md"), + path.join(outDir, "summary.svg") + ] +}, null, 2)); diff --git a/paper-ingestion-quality-gate/scripts/render-demo-video.js b/paper-ingestion-quality-gate/scripts/render-demo-video.js new file mode 100644 index 00000000..804fca35 --- /dev/null +++ b/paper-ingestion-quality-gate/scripts/render-demo-video.js @@ -0,0 +1,60 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); + +const root = path.resolve(__dirname, ".."); +const ffmpeg = path.resolve( + root, + "..", + "..", + "tool_downloads", + "video_tools", + "node_modules", + "ffmpeg-static", + "ffmpeg.exe" +); +const outDir = path.join(root, "reports"); +const out = path.join(outDir, "demo.mp4"); +const font = "C\\:/Windows/Fonts/arial.ttf"; + +fs.mkdirSync(outDir, { recursive: true }); + +const draw = [ + `drawtext=fontfile=${font}:text='Paper Ingestion Quality Gate':x=64:y=56:fontsize=42:fontcolor=black`, + `drawtext=fontfile=${font}:text='AI summarizer intake audit before key findings are generated':x=64:y=122:fontsize=25:fontcolor=0x34413b`, + `drawtext=fontfile=${font}:text='Synthetic intake packets analyzed 4':x=64:y=214:fontsize=34:fontcolor=black`, + `drawtext=fontfile=${font}:text='HOLD 2 REVIEW 1 SUMMARIZE 1':x=64:y=274:fontsize=40:fontcolor=0x245fd6`, + `drawtext=fontfile=${font}:text='Detects page drops OCR noise missing sections and extraction gaps':x=64:y=366:fontsize=26:fontcolor=0x34413b`, + `drawtext=fontfile=${font}:text='Blocks AI key findings until source intake quality is clean':x=64:y=428:fontsize=26:fontcolor=0x34413b`, + `drawtext=fontfile=${font}:text='Synthetic generated slate only no desktop capture or private data':x=64:y=492:fontsize=26:fontcolor=0x34413b` +].join(","); + +if (!fs.existsSync(ffmpeg)) { + throw new Error(`ffmpeg binary not found at ${ffmpeg}`); +} + +const result = spawnSync(ffmpeg, [ + "-y", + "-f", + "lavfi", + "-i", + "color=c=0xf9faf8:s=1280x720:d=8:r=30", + "-vf", + draw, + "-c:v", + "libx264", + "-pix_fmt", + "yuv420p", + "-movflags", + "+faststart", + out +], { encoding: "utf8" }); + +if (result.status !== 0) { + process.stderr.write(result.stderr || result.stdout); + process.exit(result.status || 1); +} + +console.log(JSON.stringify({ out, bytes: fs.statSync(out).size }, null, 2)); diff --git a/paper-ingestion-quality-gate/src/index.js b/paper-ingestion-quality-gate/src/index.js new file mode 100644 index 00000000..33d900ad --- /dev/null +++ b/paper-ingestion-quality-gate/src/index.js @@ -0,0 +1,259 @@ +"use strict"; + +const REQUIRED_SECTIONS = ["abstract", "methods", "results", "discussion"]; +const REQUIRED_EXTRACTION_KEYS = [ + "textLayer", + "sectionMap", + "tableMap", + "figureCaptionMap", + "referenceMap", + "extractionDigest" +]; + +function list(value) { + return Array.isArray(value) ? value : []; +} + +function normalizeText(value) { + return String(value || "").trim().toLowerCase(); +} + +function finding(code, severity, message, evidence, remediation) { + return { code, severity, message, evidence, remediation }; +} + +function normalizePacket(packet) { + return { + id: packet.id, + title: packet.title || "Untitled AI summarizer intake packet", + source: packet.source || {}, + extraction: packet.extraction || {}, + expected: packet.expected || {}, + summarizer: packet.summarizer || {}, + provenance: packet.provenance || {} + }; +} + +function percent(value) { + const number = Number(value); + return Number.isFinite(number) ? number : 0; +} + +function missingRequiredSections(packet) { + const present = new Set(list(packet.extraction.sectionsPresent).map(normalizeText)); + return REQUIRED_SECTIONS.filter((section) => !present.has(section)); +} + +function missingProvenance(packet) { + return REQUIRED_EXTRACTION_KEYS.filter((key) => packet.provenance[key] !== true); +} + +function pageDropCount(packet) { + return Math.max(0, Number(packet.source.pageCount || 0) - Number(packet.extraction.pagesExtracted || 0)); +} + +function extractionCompleteness(packet) { + const sourcePages = Math.max(1, Number(packet.source.pageCount || 0)); + const pages = Number(packet.extraction.pagesExtracted || 0) / sourcePages; + const text = percent(packet.extraction.textCoverage); + const tablesExpected = Math.max(0, Number(packet.expected.tables || 0)); + const tablesExtracted = Number(packet.extraction.tablesExtracted || 0); + const tables = tablesExpected === 0 ? 1 : tablesExtracted / tablesExpected; + const referencesExpected = Math.max(0, Number(packet.expected.references || 0)); + const referencesParsed = Number(packet.extraction.referencesParsed || 0); + const references = referencesExpected === 0 ? 1 : referencesParsed / referencesExpected; + return Math.max(0, Math.min(1, (pages + text + Math.min(1, tables) + Math.min(1, references)) / 4)); +} + +function hasSevereEncodingNoise(packet) { + return percent(packet.extraction.unrecognizedGlyphRate) > 0.04 || percent(packet.extraction.ocrConfidence) < 0.72; +} + +function analyzePacket(input) { + const packet = normalizePacket(input); + const findings = []; + const missingSections = missingRequiredSections(packet); + const droppedPages = pageDropCount(packet); + const completeness = extractionCompleteness(packet); + const missingProv = missingProvenance(packet); + + if (droppedPages > 0) { + findings.push(finding( + "PDF_PAGE_DROP_DETECTED", + droppedPages >= 2 ? "critical" : "major", + "The source paper has pages that were not extracted.", + { + pageCount: Number(packet.source.pageCount || 0), + pagesExtracted: Number(packet.extraction.pagesExtracted || 0), + droppedPages + }, + "Block summarization until the paper is reprocessed and every page has extraction evidence." + )); + } + + if (missingSections.length > 0) { + findings.push(finding( + "REQUIRED_SECTION_MISSING", + missingSections.includes("methods") || missingSections.includes("results") ? "critical" : "major", + "Required scientific sections are missing from the extracted text map.", + { missingSections }, + "Re-run section segmentation or require manual section confirmation before summary generation." + )); + } + + if (hasSevereEncodingNoise(packet)) { + findings.push(finding( + "OCR_OR_ENCODING_QUALITY_LOW", + "critical", + "OCR confidence or glyph quality is too poor for a reliable AI-generated summary.", + { + ocrConfidence: percent(packet.extraction.ocrConfidence), + unrecognizedGlyphRate: percent(packet.extraction.unrecognizedGlyphRate) + }, + "Use a higher-quality text layer or route to manual intake review before summarization." + )); + } + + const expectedTables = Number(packet.expected.tables || 0); + const extractedTables = Number(packet.extraction.tablesExtracted || 0); + if (expectedTables > extractedTables) { + findings.push(finding( + "TABLE_EXTRACTION_INCOMPLETE", + expectedTables - extractedTables >= 2 ? "critical" : "major", + "One or more expected tables were not extracted for summarizer grounding.", + { expectedTables, extractedTables }, + "Hold key-finding and numeric-summary generation until table extraction is complete." + )); + } + + const expectedFigures = Number(packet.expected.figures || 0); + const extractedCaptions = Number(packet.extraction.figureCaptionsExtracted || 0); + if (expectedFigures > extractedCaptions) { + findings.push(finding( + "FIGURE_CAPTION_MAP_INCOMPLETE", + "major", + "Figure captions are missing from the summarizer intake packet.", + { expectedFigures, extractedCaptions }, + "Regenerate figure-caption anchors or suppress figure-derived claims from the summary." + )); + } + + const expectedReferences = Number(packet.expected.references || 0); + const parsedReferences = Number(packet.extraction.referencesParsed || 0); + if (expectedReferences > 0 && parsedReferences / expectedReferences < 0.8) { + findings.push(finding( + "REFERENCE_PARSE_COVERAGE_LOW", + "major", + "Reference parsing coverage is too low for reliable related-work or implication summaries.", + { expectedReferences, parsedReferences }, + "Suppress citation-based implication bullets until reference parsing is repaired." + )); + } + + if (percent(packet.extraction.equationCoverage) < percent(packet.expected.minEquationCoverage || 0.9)) { + findings.push(finding( + "EQUATION_EXTRACTION_COVERAGE_LOW", + "major", + "Equation extraction coverage is below the configured summarizer threshold.", + { + equationCoverage: percent(packet.extraction.equationCoverage), + minEquationCoverage: percent(packet.expected.minEquationCoverage || 0.9) + }, + "Warn reviewers and avoid mathematical-method summaries until equation extraction is confirmed." + )); + } + + if (missingProv.length > 0) { + findings.push(finding( + "EXTRACTION_PROVENANCE_INCOMPLETE", + "major", + "The intake packet lacks required extraction provenance flags.", + { missingProvenance: missingProv }, + "Attach text, section, table, figure, reference, and digest evidence before releasing AI summaries." + )); + } + + if (completeness < 0.78) { + findings.push(finding( + "INTAKE_COMPLETENESS_BELOW_THRESHOLD", + "critical", + "Overall source extraction completeness is below the AI summarizer safety threshold.", + { completeness: Number(completeness.toFixed(3)) }, + "Hold summarization and regenerate the intake packet from the source document." + )); + } + + if ( + findings.length > 0 && + packet.summarizer.generateKeyFindings === true && + packet.summarizer.requireCleanIntake !== true + ) { + findings.push(finding( + "SUMMARIZER_NOT_GATED_ON_CLEAN_INTAKE", + "critical", + "AI key-finding generation is enabled while source-ingestion quality findings are unresolved.", + { + generateKeyFindings: packet.summarizer.generateKeyFindings, + requireCleanIntake: packet.summarizer.requireCleanIntake || false + }, + "Require clean intake status before generating key findings, implications, or next steps." + )); + } + + const decision = decisionFor(findings); + return { + id: packet.id, + title: packet.title, + decision, + completeness: Number(completeness.toFixed(3)), + findings, + recommendedActions: buildRecommendedActions(decision, findings) + }; +} + +function decisionFor(findings) { + if (findings.some((item) => item.severity === "critical")) return "HOLD"; + if (findings.some((item) => item.severity === "major")) return "REVIEW"; + return "SUMMARIZE"; +} + +function buildRecommendedActions(decision, findings) { + if (decision === "SUMMARIZE") { + return ["Allow AI summarization with normal extraction provenance attached."]; + } + + const actions = [ + "Attach the intake findings to the AI summarizer queue.", + "Regenerate extraction artifacts before key findings or implications are generated.", + "Show reviewers which source sections, tables, figures, or references are unsafe." + ]; + + if (findings.some((item) => item.code === "SUMMARIZER_NOT_GATED_ON_CLEAN_INTAKE")) { + actions.unshift("Disable key-finding generation until intake quality is clean."); + } + + return actions; +} + +function analyzeIngestionPackets(packets) { + const results = list(packets).map(analyzePacket); + const counts = results.reduce((acc, item) => { + acc[item.decision] = (acc[item.decision] || 0) + 1; + return acc; + }, { SUMMARIZE: 0, REVIEW: 0, HOLD: 0 }); + + return { + generatedAt: new Date().toISOString(), + totalPackets: results.length, + counts, + results + }; +} + +module.exports = { + analyzePacket, + analyzeIngestionPackets, + extractionCompleteness, + missingRequiredSections, + pageDropCount +}; diff --git a/paper-ingestion-quality-gate/test/paper-ingestion-quality-gate.test.js b/paper-ingestion-quality-gate/test/paper-ingestion-quality-gate.test.js new file mode 100644 index 00000000..45f05db2 --- /dev/null +++ b/paper-ingestion-quality-gate/test/paper-ingestion-quality-gate.test.js @@ -0,0 +1,57 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const test = require("node:test"); +const { analyzePacket, analyzeIngestionPackets, extractionCompleteness, missingRequiredSections, pageDropCount } = require("../src"); +const samples = require("../data/sample_ingestion_packets.json"); + +test("detects dropped pages", () => { + assert.equal(pageDropCount({ source: { pageCount: 10 }, extraction: { pagesExtracted: 9 } }), 1); + assert.equal(pageDropCount({ source: { pageCount: 10 }, extraction: { pagesExtracted: 11 } }), 0); +}); + +test("detects missing required scientific sections", () => { + const missing = missingRequiredSections({ extraction: { sectionsPresent: ["abstract", "methods"] } }); + assert.deepEqual(missing, ["results", "discussion"]); +}); + +test("computes extraction completeness", () => { + const score = extractionCompleteness(samples.find((item) => item.id === "ingest-summarize-001")); + assert.ok(score > 0.95); +}); + +test("allows clean paper intake to summarize", () => { + const result = analyzePacket(samples.find((item) => item.id === "ingest-summarize-001")); + assert.equal(result.decision, "SUMMARIZE"); + assert.equal(result.findings.length, 0); +}); + +test("holds low-quality OCR with dropped pages and missing methods", () => { + const result = analyzePacket(samples.find((item) => item.id === "ingest-hold-002")); + assert.equal(result.decision, "HOLD"); + assert.ok(result.findings.some((item) => item.code === "PDF_PAGE_DROP_DETECTED")); + assert.ok(result.findings.some((item) => item.code === "REQUIRED_SECTION_MISSING")); + assert.ok(result.findings.some((item) => item.code === "OCR_OR_ENCODING_QUALITY_LOW")); + assert.ok(result.findings.some((item) => item.code === "SUMMARIZER_NOT_GATED_ON_CLEAN_INTAKE")); +}); + +test("routes incomplete tables and captions for review", () => { + const result = analyzePacket(samples.find((item) => item.id === "ingest-review-003")); + assert.equal(result.decision, "REVIEW"); + assert.ok(result.findings.some((item) => item.code === "TABLE_EXTRACTION_INCOMPLETE")); + assert.ok(result.findings.some((item) => item.code === "FIGURE_CAPTION_MAP_INCOMPLETE")); +}); + +test("holds unsafe key-finding generation when reference and equation extraction fail", () => { + const result = analyzePacket(samples.find((item) => item.id === "ingest-hold-004")); + assert.equal(result.decision, "HOLD"); + assert.ok(result.findings.some((item) => item.code === "REFERENCE_PARSE_COVERAGE_LOW")); + assert.ok(result.findings.some((item) => item.code === "EQUATION_EXTRACTION_COVERAGE_LOW")); + assert.ok(result.findings.some((item) => item.code === "SUMMARIZER_NOT_GATED_ON_CLEAN_INTAKE")); +}); + +test("aggregates paper ingestion decisions", () => { + const report = analyzeIngestionPackets(samples); + assert.equal(report.totalPackets, 4); + assert.deepEqual(report.counts, { SUMMARIZE: 1, REVIEW: 1, HOLD: 2 }); +});