-
Notifications
You must be signed in to change notification settings - Fork 61
Add literature freshness review assistant #394
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,2 +1,6 @@ | ||
| # deepevents.ai | ||
| deepevents.ai main codebase | ||
|
|
||
| ## Research Assistant Bounty Slices | ||
|
|
||
| - [Literature freshness review assistant](literature-freshness-review-assistant/README.md) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| # Literature Freshness Review Assistant | ||
|
|
||
| This is a focused AI-Powered Research Assistant Suite slice for SCIBASE issue #16. It checks whether reviewer-facing manuscript claims rely on current evidence before the assistant marks a packet ready. | ||
|
|
||
| ## Scope | ||
|
|
||
| - Flags stale support citations outside a configurable freshness window. | ||
| - Detects newer systematic reviews, reporting guidelines, dataset releases, and benchmark updates that are missing from a claim. | ||
| - Escalates newer contradictory evidence as a review hold. | ||
| - Detects dataset and benchmark version drift for claims using "current" or "state-of-the-art" wording. | ||
| - Emits reviewer actions, research-gap prompts, stable audit digests, and deterministic JSON/Markdown/SVG/MP4 artifacts. | ||
|
|
||
| This is intentionally separate from broad assistant suites, citation-context reconciliation, retraction notices, statistics review, benchmark-leakage checks, reporting-guideline compliance, figure/table checks, prompt-safety guards, and assay control/calibration completeness. | ||
|
|
||
| ## Files | ||
|
|
||
| - `index.js` - freshness evaluator and report renderers | ||
| - `sample-data.js` - synthetic evidence ledger and manuscript packets | ||
| - `test.js` - dependency-free assertion tests | ||
| - `demo.js` - deterministic JSON/Markdown/SVG report generator | ||
| - `scripts/render-demo-video.js` - optional ffmpeg MP4 renderer | ||
| - `reports/` - generated reviewer artifacts | ||
|
|
||
| ## Validate | ||
|
|
||
| ```bash | ||
| npm run check | ||
| npm test | ||
| npm run demo | ||
| npm run demo:video | ||
| ``` | ||
|
|
||
| Synthetic data only. The module does not call external APIs, scan private manuscripts, use credentials, or contact payment services. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| const fs = require("fs"); | ||
| const path = require("path"); | ||
|
|
||
| const { | ||
| evaluateFreshnessReview, | ||
| renderMarkdown, | ||
| renderSvg | ||
| } = require("./index"); | ||
| const { | ||
| freshnessPolicy, | ||
| evidenceLedger, | ||
| manuscriptPackets | ||
| } = require("./sample-data"); | ||
|
|
||
| const reportsDir = path.join(__dirname, "reports"); | ||
| fs.mkdirSync(reportsDir, { recursive: true }); | ||
|
|
||
| const report = evaluateFreshnessReview(manuscriptPackets, evidenceLedger, freshnessPolicy); | ||
| fs.writeFileSync(path.join(reportsDir, "demo.json"), `${JSON.stringify(report, null, 2)}\n`); | ||
| fs.writeFileSync(path.join(reportsDir, "demo.md"), renderMarkdown(report)); | ||
| fs.writeFileSync(path.join(reportsDir, "demo.svg"), renderSvg(report)); | ||
|
|
||
| console.log(`status=${report.manuscripts[0].summary.status}`); | ||
| console.log(`reviewed=${report.aggregate.reviewedManuscripts}`); | ||
| console.log(`held=${report.aggregate.heldManuscripts}`); | ||
| console.log(`critical=${report.aggregate.criticalFindings}`); | ||
| console.log(`major=${report.aggregate.majorFindings}`); | ||
| console.log(`digest=${report.auditDigest}`); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,322 @@ | ||
| const crypto = require("crypto"); | ||
|
|
||
| const SEVERITY_RANK = { | ||
| info: 0, | ||
| warning: 1, | ||
| major: 2, | ||
| critical: 3 | ||
| }; | ||
|
|
||
| function yearsBetween(olderDate, newerDate) { | ||
| const older = new Date(`${olderDate}T00:00:00Z`); | ||
| const newer = new Date(`${newerDate}T00:00:00Z`); | ||
| return (newer.getTime() - older.getTime()) / (365.25 * 24 * 60 * 60 * 1000); | ||
| } | ||
|
|
||
| function latestDate(dates) { | ||
| const validDates = dates.filter(Boolean).sort(); | ||
| return validDates[validDates.length - 1] || null; | ||
| } | ||
|
|
||
| function evidenceForTopic(ledger, topic) { | ||
| return ledger.signals.filter((signal) => signal.topic === topic); | ||
| } | ||
|
|
||
| function addFinding(findings, finding) { | ||
| findings.push({ | ||
| severity: finding.severity, | ||
| code: finding.code, | ||
| claimId: finding.claimId, | ||
| topic: finding.topic, | ||
| detail: finding.detail, | ||
| requiredAction: finding.requiredAction, | ||
| evidenceSignalId: finding.evidenceSignalId || null | ||
| }); | ||
| } | ||
|
|
||
| function evaluateClaimFreshness(claim, ledger, policy) { | ||
| const findings = []; | ||
| const signals = evidenceForTopic(ledger, claim.topic); | ||
| const latestCitation = latestDate(claim.citationDates || []); | ||
| const reviewDate = policy.reviewDate; | ||
|
|
||
|
Comment on lines
+77
to
+83
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in 1cddc7d. The evaluator now filters topic signals through |
||
| if (!latestCitation) { | ||
| addFinding(findings, { | ||
| severity: "critical", | ||
| code: "NO_SUPPORT_DATES", | ||
| claimId: claim.id, | ||
| topic: claim.topic, | ||
| detail: "Claim has no dated supporting citations for freshness review.", | ||
| requiredAction: "Attach dated evidence before the assistant marks this claim reviewer-ready." | ||
| }); | ||
| } else if (yearsBetween(latestCitation, reviewDate) > policy.staleAfterYears) { | ||
| addFinding(findings, { | ||
| severity: "major", | ||
| code: "STALE_SUPPORT_WINDOW", | ||
| claimId: claim.id, | ||
| topic: claim.topic, | ||
| detail: `Latest support is ${latestCitation}, older than the ${policy.staleAfterYears}-year freshness window.`, | ||
| requiredAction: "Add recent evidence or downgrade the claim wording." | ||
| }); | ||
| } | ||
|
|
||
| for (const signal of signals) { | ||
| const cited = (claim.citedCurrentSignalIds || []).includes(signal.id); | ||
| const signalIsNewer = !latestCitation || signal.published > latestCitation; | ||
|
|
||
| if (!cited && signalIsNewer) { | ||
| const severity = signal.relation === "contradicts" ? policy.contradictionSeverity : "major"; | ||
| addFinding(findings, { | ||
| severity, | ||
| code: signal.relation === "contradicts" ? "NEWER_CONTRADICTORY_EVIDENCE" : "MISSING_NEWER_EVIDENCE", | ||
| claimId: claim.id, | ||
| topic: claim.topic, | ||
| detail: `${signal.title} (${signal.published}) ${signal.relation} older support but is not cited.`, | ||
| requiredAction: signal.requirement, | ||
| evidenceSignalId: signal.id | ||
| }); | ||
| } | ||
|
|
||
| if (signal.currentVersion && claim.datasetVersion && claim.datasetVersion !== signal.currentVersion) { | ||
| addFinding(findings, { | ||
| severity: "major", | ||
| code: "DATASET_VERSION_DRIFT", | ||
| claimId: claim.id, | ||
| topic: claim.topic, | ||
| detail: `Claim uses dataset ${claim.datasetVersion}; current ledger version is ${signal.currentVersion}.`, | ||
| requiredAction: signal.requirement, | ||
| evidenceSignalId: signal.id | ||
| }); | ||
| } | ||
|
|
||
| if (signal.currentVersion && claim.benchmarkVersion && claim.benchmarkVersion !== signal.currentVersion) { | ||
| addFinding(findings, { | ||
| severity: "major", | ||
| code: "BENCHMARK_VERSION_DRIFT", | ||
| claimId: claim.id, | ||
| topic: claim.topic, | ||
| detail: `Claim uses benchmark ${claim.benchmarkVersion}; current ledger version is ${signal.currentVersion}.`, | ||
| requiredAction: signal.requirement, | ||
| evidenceSignalId: signal.id | ||
| }); | ||
| } | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in 1cddc7d. Dataset drift now only fires for |
||
| } | ||
|
|
||
| if (findings.length === 0) { | ||
| addFinding(findings, { | ||
| severity: "info", | ||
| code: "FRESHNESS_READY", | ||
| claimId: claim.id, | ||
| topic: claim.topic, | ||
| detail: "Claim cites the current evidence signal for its topic.", | ||
| requiredAction: "No freshness hold required." | ||
| }); | ||
| } | ||
|
|
||
| return findings; | ||
| } | ||
|
|
||
| function summarizeFindings(findings, policy) { | ||
| const actionable = findings.filter((finding) => finding.severity !== "info"); | ||
| const critical = findings.filter((finding) => finding.severity === "critical"); | ||
| const major = findings.filter((finding) => finding.severity === "major"); | ||
| const warnings = findings.filter((finding) => finding.severity === "warning"); | ||
| const maxSeverity = findings.reduce((max, finding) => { | ||
| return SEVERITY_RANK[finding.severity] > SEVERITY_RANK[max] ? finding.severity : max; | ||
| }, "info"); | ||
| const status = SEVERITY_RANK[maxSeverity] >= SEVERITY_RANK[policy.holdSeverityCutoff] | ||
| ? "hold_freshness_review" | ||
| : actionable.length > 0 | ||
| ? "revise_freshness_context" | ||
| : "ready_for_review"; | ||
|
|
||
| return { | ||
| status, | ||
| totalFindings: findings.length, | ||
| actionableFindings: actionable.length, | ||
| criticalFindings: critical.length, | ||
| majorFindings: major.length, | ||
| warningFindings: warnings.length, | ||
| readyFindings: findings.length - actionable.length | ||
| }; | ||
| } | ||
|
|
||
| function buildReviewerActions(findings) { | ||
| return findings | ||
| .filter((finding) => finding.severity !== "info") | ||
| .map((finding) => ({ | ||
| claimId: finding.claimId, | ||
| severity: finding.severity, | ||
| action: finding.requiredAction, | ||
| reason: finding.detail | ||
| })); | ||
| } | ||
|
|
||
| function buildResearchGapPrompts(findings) { | ||
| const topics = new Map(); | ||
| for (const finding of findings) { | ||
| if (finding.severity === "info") { | ||
| continue; | ||
| } | ||
| if (!topics.has(finding.topic)) { | ||
| topics.set( | ||
| finding.topic, | ||
| `Freshness gap: update the evidence base for ${finding.topic} before treating the claim as current.` | ||
| ); | ||
| } | ||
| } | ||
| return [...topics.values()]; | ||
| } | ||
|
|
||
| function stableDigest(value) { | ||
| return crypto.createHash("sha256").update(JSON.stringify(value)).digest("hex"); | ||
| } | ||
|
|
||
| function evaluateManuscript(packet, ledger, policy) { | ||
| const findings = packet.claims.flatMap((claim) => evaluateClaimFreshness(claim, ledger, policy)); | ||
| const summary = summarizeFindings(findings, policy); | ||
| const reviewerActions = buildReviewerActions(findings); | ||
| const researchGapPrompts = buildResearchGapPrompts(findings); | ||
| const result = { | ||
| packetId: packet.id, | ||
| title: packet.title, | ||
| domain: packet.domain, | ||
| reviewDate: policy.reviewDate, | ||
| summary, | ||
| findings, | ||
| reviewerActions, | ||
| researchGapPrompts | ||
| }; | ||
| return { | ||
| ...result, | ||
| auditDigest: stableDigest(result) | ||
| }; | ||
| } | ||
|
|
||
| function evaluateFreshnessReview(packets, ledger, policy) { | ||
| const manuscripts = packets.map((packet) => evaluateManuscript(packet, ledger, policy)); | ||
| const aggregate = { | ||
| reviewedManuscripts: manuscripts.length, | ||
| heldManuscripts: manuscripts.filter((entry) => entry.summary.status === "hold_freshness_review").length, | ||
| revisionManuscripts: manuscripts.filter((entry) => entry.summary.status === "revise_freshness_context").length, | ||
| readyManuscripts: manuscripts.filter((entry) => entry.summary.status === "ready_for_review").length, | ||
| criticalFindings: manuscripts.reduce((sum, entry) => sum + entry.summary.criticalFindings, 0), | ||
| majorFindings: manuscripts.reduce((sum, entry) => sum + entry.summary.majorFindings, 0), | ||
| actionableFindings: manuscripts.reduce((sum, entry) => sum + entry.summary.actionableFindings, 0) | ||
| }; | ||
| const result = { | ||
| assistant: "literature-freshness-review-assistant", | ||
| policy, | ||
| aggregate, | ||
| manuscripts | ||
| }; | ||
| return { | ||
| ...result, | ||
| auditDigest: stableDigest(result) | ||
| }; | ||
| } | ||
|
|
||
| function renderMarkdown(report) { | ||
| const lines = [ | ||
| "# Literature Freshness Review Assistant", | ||
| "", | ||
| `Review date: ${report.policy.reviewDate}`, | ||
| `Overall digest: ${report.auditDigest}`, | ||
| "", | ||
| "## Aggregate", | ||
| "", | ||
| `- Reviewed manuscripts: ${report.aggregate.reviewedManuscripts}`, | ||
| `- Held manuscripts: ${report.aggregate.heldManuscripts}`, | ||
| `- Revision manuscripts: ${report.aggregate.revisionManuscripts}`, | ||
| `- Ready manuscripts: ${report.aggregate.readyManuscripts}`, | ||
| `- Critical findings: ${report.aggregate.criticalFindings}`, | ||
| `- Major findings: ${report.aggregate.majorFindings}`, | ||
| `- Actionable findings: ${report.aggregate.actionableFindings}`, | ||
| "" | ||
| ]; | ||
|
|
||
| for (const manuscript of report.manuscripts) { | ||
| lines.push(`## ${manuscript.title}`); | ||
| lines.push(""); | ||
| lines.push(`Status: ${manuscript.summary.status}`); | ||
| lines.push(`Digest: ${manuscript.auditDigest}`); | ||
| lines.push(""); | ||
| lines.push("| Severity | Code | Claim | Detail | Required action |"); | ||
| lines.push("| --- | --- | --- | --- | --- |"); | ||
| for (const finding of manuscript.findings) { | ||
| lines.push( | ||
| `| ${finding.severity} | ${finding.code} | ${finding.claimId} | ${finding.detail} | ${finding.requiredAction} |` | ||
| ); | ||
|
Comment on lines
+308
to
+313
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in 1cddc7d. Markdown table cells now escape pipes and normalize newlines to |
||
| } | ||
| lines.push(""); | ||
| if (manuscript.researchGapPrompts.length > 0) { | ||
| lines.push("Research gap prompts:"); | ||
| for (const prompt of manuscript.researchGapPrompts) { | ||
| lines.push(`- ${prompt}`); | ||
| } | ||
| lines.push(""); | ||
| } | ||
| } | ||
|
|
||
| return `${lines.join("\n").trimEnd()}\n`; | ||
| } | ||
|
|
||
| function escapeXml(value) { | ||
| return String(value) | ||
| .replaceAll("&", "&") | ||
| .replaceAll("<", "<") | ||
| .replaceAll(">", ">") | ||
| .replaceAll('"', """); | ||
| } | ||
|
|
||
| function renderSvg(report) { | ||
| const held = report.aggregate.heldManuscripts; | ||
| const ready = report.aggregate.readyManuscripts; | ||
| const critical = report.aggregate.criticalFindings; | ||
| const major = report.aggregate.majorFindings; | ||
| const bars = [ | ||
| { label: "Held", value: held, color: "#b91c1c" }, | ||
| { label: "Ready", value: ready, color: "#047857" }, | ||
| { label: "Critical", value: critical, color: "#dc2626" }, | ||
| { label: "Major", value: major, color: "#d97706" } | ||
| ]; | ||
| const maxValue = Math.max(1, ...bars.map((bar) => bar.value)); | ||
| const rows = bars.map((bar, index) => { | ||
| const y = 170 + index * 70; | ||
| const width = Math.round((bar.value / maxValue) * 560); | ||
| return ` <text x="72" y="${y + 24}" class="label">${escapeXml(bar.label)}</text> | ||
| <rect x="190" y="${y}" width="560" height="34" rx="6" fill="#e5e7eb"/> | ||
| <rect x="190" y="${y}" width="${width}" height="34" rx="6" fill="${bar.color}"/> | ||
| <text x="770" y="${y + 24}" class="value">${bar.value}</text>`; | ||
| }).join("\n"); | ||
|
|
||
| return `<svg xmlns="http://www.w3.org/2000/svg" width="960" height="560" viewBox="0 0 960 560" role="img" aria-labelledby="title desc"> | ||
| <title id="title">Literature freshness review assistant summary</title> | ||
| <desc id="desc">Summary of held manuscripts, ready manuscripts, and freshness findings.</desc> | ||
| <style> | ||
| .bg { fill: #f8fafc; } | ||
| .panel { fill: #ffffff; stroke: #cbd5e1; stroke-width: 1.5; } | ||
| .title { font: 700 34px Arial, sans-serif; fill: #0f172a; } | ||
| .subtitle { font: 400 18px Arial, sans-serif; fill: #475569; } | ||
| .label { font: 700 20px Arial, sans-serif; fill: #0f172a; } | ||
| .value { font: 700 22px Arial, sans-serif; fill: #0f172a; } | ||
| .digest { font: 400 14px Arial, sans-serif; fill: #475569; } | ||
| </style> | ||
| <rect class="bg" width="960" height="560"/> | ||
| <rect class="panel" x="40" y="40" width="880" height="480" rx="8"/> | ||
| <text x="72" y="98" class="title">Literature freshness review assistant</text> | ||
| <text x="72" y="132" class="subtitle">Stale citations, missing newer evidence, and temporal drift before AI review release</text> | ||
| ${rows} | ||
| <text x="72" y="500" class="digest">Audit digest: ${escapeXml(report.auditDigest.slice(0, 24))}</text> | ||
| </svg> | ||
| `; | ||
| } | ||
|
|
||
| module.exports = { | ||
| evaluateClaimFreshness, | ||
| evaluateManuscript, | ||
| evaluateFreshnessReview, | ||
| renderMarkdown, | ||
| renderSvg, | ||
| stableDigest | ||
| }; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Addressed in 1cddc7d. Date parsing now validates ISO
YYYY-MM-DDstrings and finite timestamps. Invalid support dates produce anINVALID_SUPPORT_DATEfinding and, if no valid support date remains, the existing missing-date hold still fires. Added regression coverage.