diff --git a/citation-context-fit-assistant/README.md b/citation-context-fit-assistant/README.md new file mode 100644 index 00000000..39505686 --- /dev/null +++ b/citation-context-fit-assistant/README.md @@ -0,0 +1,38 @@ +# Citation Context-Fit Assistant + +Self-contained AI-Assisted Research Tools slice for +`SCIBASE-AI/SCIBASE.AI#13`. + +The assistant validates citation recommendations before one-click insertion. It +checks whether each candidate supports, contradicts, only contextualizes, or is +irrelevant to the highlighted manuscript claim. It also checks citation intent +labels, evidence strength, field fit, stale evidence, and whether contradictory +citations include an explicit contrast note. + +This is intentionally separate from broad AI tool suites, evidence-grounded +summarizers, citation provenance, citation metadata integrity, citation style +normalization, citation diversity, citation retraction watch, methods +reproducibility, figure/table evidence, protocol deviation, novelty overlap, +manuscript similarity, ethics/data availability, statistical consistency, study +power, unit consistency, supplementary readiness, and biomethods provenance +slices. + +## Run + +```bash +npm run check +npm test +npm run demo +npm run demo:video +``` + +## Outputs + +- `reports/summary.json` +- `reports/reviewer-packet.md` +- `reports/summary.svg` +- `reports/demo.webm` + +All data is synthetic. The module does not call DOI registries, Crossref, +PubMed, arXiv, Semantic Scholar, publishers, external corpora, or live citation +insertion systems. diff --git a/citation-context-fit-assistant/demo-video.js b/citation-context-fit-assistant/demo-video.js new file mode 100644 index 00000000..c1234d7e --- /dev/null +++ b/citation-context-fit-assistant/demo-video.js @@ -0,0 +1,173 @@ +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const { execFileSync } = require("child_process"); + +const reportDir = path.join(__dirname, "reports"); +const outputPath = path.join(reportDir, "demo.webm"); + +const chromeCandidates = [ + process.env.CHROME_PATH, + "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe", + "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", + "C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe", + "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe" +].filter(Boolean); + +function findBrowser() { + const found = chromeCandidates.find((candidate) => fs.existsSync(candidate)); + if (!found) { + throw new Error("Chrome or Edge was not found. Set CHROME_PATH to generate reports/demo.webm."); + } + return found; +} + +function fileUrl(filePath) { + return `file:///${filePath.replace(/\\/g, "/")}`; +} + +const html = String.raw` + + + + Citation context fit assistant demo + + + + +
recording
+ + +`; + +fs.mkdirSync(reportDir, { recursive: true }); + +const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "citation-context-fit-demo-")); +const htmlPath = path.join(tempDir, "demo.html"); +const profileDir = path.join(tempDir, "profile"); +fs.writeFileSync(htmlPath, html, "utf8"); + +const stdout = execFileSync( + findBrowser(), + [ + "--headless=new", + "--disable-gpu", + "--disable-dev-shm-usage", + "--autoplay-policy=no-user-gesture-required", + "--run-all-compositor-stages-before-draw", + "--virtual-time-budget=7500", + `--user-data-dir=${profileDir}`, + "--dump-dom", + fileUrl(htmlPath) + ], + { encoding: "utf8", maxBuffer: 30 * 1024 * 1024 } +); + +const match = stdout.match(/data:video\/webm;base64,([A-Za-z0-9+/=]+)/); +if (!match) { + throw new Error(`Demo video generation failed. Browser output ended with: ${stdout.slice(-600)}`); +} + +fs.writeFileSync(outputPath, Buffer.from(match[1], "base64")); +console.log(`Generated ${path.relative(process.cwd(), outputPath)}`); diff --git a/citation-context-fit-assistant/demo.js b/citation-context-fit-assistant/demo.js new file mode 100644 index 00000000..1d152ecd --- /dev/null +++ b/citation-context-fit-assistant/demo.js @@ -0,0 +1,18 @@ +const fs = require("fs"); +const path = require("path"); +const { project } = require("./sample-data"); +const { buildReviewPacket, renderMarkdownReport, renderSvgSummary } = require("./index"); + +const reportDir = path.join(__dirname, "reports"); +fs.mkdirSync(reportDir, { recursive: true }); + +const packet = buildReviewPacket(project); + +fs.writeFileSync(path.join(reportDir, "summary.json"), `${JSON.stringify(packet, null, 2)}\n`, "utf8"); +fs.writeFileSync(path.join(reportDir, "reviewer-packet.md"), renderMarkdownReport(packet), "utf8"); +fs.writeFileSync(path.join(reportDir, "summary.svg"), renderSvgSummary(packet), "utf8"); + +console.log(`Generated reports for ${packet.assistant}`); +console.log(`Decision: ${packet.decision}`); +console.log(`Score: ${packet.score}`); +console.log(`Findings: ${packet.findings.length}`); diff --git a/citation-context-fit-assistant/index.js b/citation-context-fit-assistant/index.js new file mode 100644 index 00000000..39a0c964 --- /dev/null +++ b/citation-context-fit-assistant/index.js @@ -0,0 +1,262 @@ +const SEVERITY_WEIGHTS = { + critical: 34, + high: 20, + medium: 10, + low: 4 +}; + +function addFinding(findings, severity, rule, message, action, refs = []) { + findings.push({ severity, rule, message, action, refs }); +} + +function yearsOld(asOfDate, year) { + return new Date(asOfDate).getUTCFullYear() - year; +} + +function claimById(project) { + return new Map(project.manuscript.highlightedClaims.map((claim) => [claim.id, claim])); +} + +function evaluateCandidate(project, claim, candidate, findings) { + if (!claim) { + addFinding( + findings, + "critical", + "citation-claim-anchor-missing", + `${candidate.id} references missing highlighted claim ${candidate.claimId}.`, + "Block insertion until the citation is attached to a valid manuscript claim.", + [candidate.id, candidate.claimId] + ); + return; + } + + if (project.policy.insertionRequiresIntent && !candidate.citationIntent) { + addFinding( + findings, + "high", + "citation-intent-missing", + `${candidate.id} has no citation intent label.`, + "Require the assistant to label the citation as direct support, background, method, or contrast.", + [candidate.id, claim.id] + ); + } + + if (candidate.citationIntent !== claim.intendedCitationRole) { + addFinding( + findings, + "medium", + "citation-intent-claim-role-mismatch", + `${candidate.id} intent ${candidate.citationIntent} does not match claim role ${claim.intendedCitationRole}.`, + "Ask the citation tool to relabel or move the candidate before one-click insertion.", + [candidate.id, claim.id] + ); + } + + if (candidate.relation === "irrelevant") { + addFinding( + findings, + "critical", + "citation-context-irrelevant", + `${candidate.id} is irrelevant to ${claim.id}.`, + "Suppress the recommendation from the citation tool.", + [candidate.id, claim.id] + ); + } + + if (candidate.relation === "contradicts" && claim.intendedCitationRole === "direct-support") { + addFinding( + findings, + "critical", + "contradictory-citation-for-supporting-claim", + `${candidate.id} contradicts a claim that requested direct support.`, + "Block one-click insertion unless the manuscript text is rewritten as a contrast or limitation.", + [candidate.id, claim.id] + ); + } + + if (candidate.relation === "contradicts" && project.policy.allowContradictoryOnlyWithNote && !candidate.insertionNote) { + addFinding( + findings, + "high", + "contradictory-citation-note-missing", + `${candidate.id} is contradictory but has no insertion note.`, + "Require an explicit contrast note before insertion.", + [candidate.id, claim.id] + ); + } + + if (candidate.citationIntent === "direct-support" && candidate.evidenceStrength < project.policy.minimumSupportStrength) { + addFinding( + findings, + "high", + "direct-support-evidence-too-weak", + `${candidate.id} evidence strength ${candidate.evidenceStrength} is below support threshold.`, + "Downgrade the candidate to background/context or require a stronger source.", + [candidate.id, claim.id] + ); + } + + if (candidate.fieldOverlap < project.policy.minimumFieldOverlap) { + addFinding( + findings, + "high", + "citation-field-fit-too-low", + `${candidate.id} field overlap ${candidate.fieldOverlap} is below the accepted threshold.`, + "Hold the candidate for manual review or retrieve a field-matched citation.", + [candidate.id, claim.id, candidate.field] + ); + } + + if (yearsOld(project.asOfDate, candidate.year) > project.policy.staleEvidenceYears && candidate.citationIntent !== "background") { + addFinding( + findings, + "medium", + "citation-evidence-stale-for-claim", + `${candidate.id} is ${yearsOld(project.asOfDate, candidate.year)} years old for a non-background claim.`, + "Ask the citation tool to retrieve fresher evidence or mark this as historical context.", + [candidate.id, String(candidate.year)] + ); + } + + if (candidate.relation === "contextualizes" && candidate.citationIntent === "direct-support") { + addFinding( + findings, + "high", + "context-only-citation-used-as-support", + `${candidate.id} only contextualizes ${claim.id} but is labeled direct support.`, + "Change the insertion label to background or select a direct evidence source.", + [candidate.id, claim.id] + ); + } +} + +function evaluateCitationFit(project) { + const findings = []; + const claims = claimById(project); + + for (const candidate of project.candidates) { + evaluateCandidate(project, claims.get(candidate.claimId), candidate, findings); + } + + const decisions = project.candidates.map((candidate) => { + const candidateFindings = findings.filter((finding) => finding.refs.includes(candidate.id)); + const hasCritical = candidateFindings.some((finding) => finding.severity === "critical"); + const hasHigh = candidateFindings.some((finding) => finding.severity === "high"); + return { + candidateId: candidate.id, + claimId: candidate.claimId, + decision: hasCritical ? "suppress" : hasHigh ? "manual-review" : "allow-insertion", + rules: candidateFindings.map((finding) => finding.rule) + }; + }); + + const severitySummary = findings.reduce( + (summary, finding) => { + summary[finding.severity] += 1; + return summary; + }, + { critical: 0, high: 0, medium: 0, low: 0 } + ); + const score = Math.max(0, 100 - findings.reduce((sum, finding) => sum + SEVERITY_WEIGHTS[finding.severity], 0)); + + return { findings, decisions, severitySummary, score }; +} + +function decisionFromEvaluation(evaluation) { + if (evaluation.severitySummary.critical > 0) { + return "block-unsafe-citation-insertions"; + } + if (evaluation.severitySummary.high > 0 || evaluation.score < 75) { + return "hold-citations-for-manual-review"; + } + if (evaluation.score < 90) { + return "review-citation-context-before-insertion"; + } + return "citation-context-fit-ready"; +} + +function buildReviewPacket(project) { + const evaluation = evaluateCitationFit(project); + return { + assistant: "citation-context-fit-assistant", + issue: "SCIBASE-AI/SCIBASE.AI#13", + manuscriptId: project.manuscript.id, + title: project.manuscript.title, + asOfDate: project.asOfDate, + decision: decisionFromEvaluation(evaluation), + score: evaluation.score, + severitySummary: evaluation.severitySummary, + findings: evaluation.findings, + insertionDecisions: evaluation.decisions, + safety: [ + "Synthetic manuscript claims and citation candidates only", + "No DOI, Crossref, PubMed, arXiv, Semantic Scholar, publisher, or external corpus calls", + "No private manuscripts, credentials, real literature metadata, or live citation insertions" + ] + }; +} + +function renderMarkdownReport(packet) { + const lines = [ + "# Citation Context-Fit Assistant", + "", + `Manuscript: ${packet.title}`, + `Issue: ${packet.issue}`, + `Decision: ${packet.decision}`, + `Score: ${packet.score}`, + "", + "## Insertion Decisions", + "" + ]; + + for (const decision of packet.insertionDecisions) { + lines.push(`- ${decision.candidateId} for ${decision.claimId}: ${decision.decision}`); + if (decision.rules.length > 0) { + lines.push(` - Rules: ${decision.rules.join(", ")}`); + } + } + + lines.push("", "## Findings", ""); + for (const finding of packet.findings) { + lines.push(`- **${finding.severity} / ${finding.rule}**: ${finding.message}`); + lines.push(` - Action: ${finding.action}`); + lines.push(` - Refs: ${finding.refs.join(", ") || "none"}`); + } + + lines.push("", "## Safety", ""); + for (const item of packet.safety) { + lines.push(`- ${item}`); + } + + return `${lines.join("\n")}\n`; +} + +function renderSvgSummary(packet) { + const scoreWidth = Math.max(44, Math.min(760, packet.score * 7.6)); + return ` + + Citation Context-Fit Assistant + ${packet.title} + + ${packet.decision} + Critical ${packet.severitySummary.critical} | High ${packet.severitySummary.high} | Findings ${packet.findings.length} + + Context-fit score + + + ${packet.score}/100 + + Block misleading one-click citations + Checks support, contradiction, context-only use, field fit, evidence strength, recency, and insertion notes. + +`; +} + +module.exports = { + buildReviewPacket, + decisionFromEvaluation, + evaluateCitationFit, + renderMarkdownReport, + renderSvgSummary, + yearsOld +}; diff --git a/citation-context-fit-assistant/package.json b/citation-context-fit-assistant/package.json new file mode 100644 index 00000000..00c19c69 --- /dev/null +++ b/citation-context-fit-assistant/package.json @@ -0,0 +1,14 @@ +{ + "name": "citation-context-fit-assistant", + "version": "1.0.0", + "description": "Deterministic assistant for citation recommendation context-fit checks.", + "main": "index.js", + "private": true, + "type": "commonjs", + "scripts": { + "check": "node --check index.js && node --check sample-data.js && node --check demo.js && node --check demo-video.js && node --check test.js", + "test": "node test.js", + "demo": "node demo.js", + "demo:video": "node demo-video.js" + } +} diff --git a/citation-context-fit-assistant/reports/demo.webm b/citation-context-fit-assistant/reports/demo.webm new file mode 100644 index 00000000..31b1ae1d Binary files /dev/null and b/citation-context-fit-assistant/reports/demo.webm differ diff --git a/citation-context-fit-assistant/reports/reviewer-packet.md b/citation-context-fit-assistant/reports/reviewer-packet.md new file mode 100644 index 00000000..f434a458 --- /dev/null +++ b/citation-context-fit-assistant/reports/reviewer-packet.md @@ -0,0 +1,39 @@ +# Citation Context-Fit Assistant + +Manuscript: Synthetic Microbiome Intervention Review +Issue: SCIBASE-AI/SCIBASE.AI#13 +Decision: block-unsafe-citation-insertions +Score: 0 + +## Insertion Decisions + +- cand-001 for claim-001: allow-insertion +- cand-002 for claim-002: suppress + - Rules: contradictory-citation-for-supporting-claim, contradictory-citation-note-missing +- cand-003 for claim-003: allow-insertion +- cand-004 for claim-001: suppress + - Rules: citation-context-irrelevant, direct-support-evidence-too-weak, citation-field-fit-too-low + +## Findings + +- **critical / contradictory-citation-for-supporting-claim**: cand-002 contradicts a claim that requested direct support. + - Action: Block one-click insertion unless the manuscript text is rewritten as a contrast or limitation. + - Refs: cand-002, claim-002 +- **high / contradictory-citation-note-missing**: cand-002 is contradictory but has no insertion note. + - Action: Require an explicit contrast note before insertion. + - Refs: cand-002, claim-002 +- **critical / citation-context-irrelevant**: cand-004 is irrelevant to claim-001. + - Action: Suppress the recommendation from the citation tool. + - Refs: cand-004, claim-001 +- **high / direct-support-evidence-too-weak**: cand-004 evidence strength 0.3 is below support threshold. + - Action: Downgrade the candidate to background/context or require a stronger source. + - Refs: cand-004, claim-001 +- **high / citation-field-fit-too-low**: cand-004 field overlap 0.18 is below the accepted threshold. + - Action: Hold the candidate for manual review or retrieve a field-matched citation. + - Refs: cand-004, claim-001, environmental-microbiology + +## Safety + +- Synthetic manuscript claims and citation candidates only +- No DOI, Crossref, PubMed, arXiv, Semantic Scholar, publisher, or external corpus calls +- No private manuscripts, credentials, real literature metadata, or live citation insertions diff --git a/citation-context-fit-assistant/reports/summary.json b/citation-context-fit-assistant/reports/summary.json new file mode 100644 index 00000000..3b347125 --- /dev/null +++ b/citation-context-fit-assistant/reports/summary.json @@ -0,0 +1,106 @@ +{ + "assistant": "citation-context-fit-assistant", + "issue": "SCIBASE-AI/SCIBASE.AI#13", + "manuscriptId": "ms-ai-citation-013", + "title": "Synthetic Microbiome Intervention Review", + "asOfDate": "2026-05-22", + "decision": "block-unsafe-citation-insertions", + "score": 0, + "severitySummary": { + "critical": 2, + "high": 3, + "medium": 0, + "low": 0 + }, + "findings": [ + { + "severity": "critical", + "rule": "contradictory-citation-for-supporting-claim", + "message": "cand-002 contradicts a claim that requested direct support.", + "action": "Block one-click insertion unless the manuscript text is rewritten as a contrast or limitation.", + "refs": [ + "cand-002", + "claim-002" + ] + }, + { + "severity": "high", + "rule": "contradictory-citation-note-missing", + "message": "cand-002 is contradictory but has no insertion note.", + "action": "Require an explicit contrast note before insertion.", + "refs": [ + "cand-002", + "claim-002" + ] + }, + { + "severity": "critical", + "rule": "citation-context-irrelevant", + "message": "cand-004 is irrelevant to claim-001.", + "action": "Suppress the recommendation from the citation tool.", + "refs": [ + "cand-004", + "claim-001" + ] + }, + { + "severity": "high", + "rule": "direct-support-evidence-too-weak", + "message": "cand-004 evidence strength 0.3 is below support threshold.", + "action": "Downgrade the candidate to background/context or require a stronger source.", + "refs": [ + "cand-004", + "claim-001" + ] + }, + { + "severity": "high", + "rule": "citation-field-fit-too-low", + "message": "cand-004 field overlap 0.18 is below the accepted threshold.", + "action": "Hold the candidate for manual review or retrieve a field-matched citation.", + "refs": [ + "cand-004", + "claim-001", + "environmental-microbiology" + ] + } + ], + "insertionDecisions": [ + { + "candidateId": "cand-001", + "claimId": "claim-001", + "decision": "allow-insertion", + "rules": [] + }, + { + "candidateId": "cand-002", + "claimId": "claim-002", + "decision": "suppress", + "rules": [ + "contradictory-citation-for-supporting-claim", + "contradictory-citation-note-missing" + ] + }, + { + "candidateId": "cand-003", + "claimId": "claim-003", + "decision": "allow-insertion", + "rules": [] + }, + { + "candidateId": "cand-004", + "claimId": "claim-001", + "decision": "suppress", + "rules": [ + "citation-context-irrelevant", + "direct-support-evidence-too-weak", + "citation-field-fit-too-low" + ] + } + ], + "safety": [ + "Synthetic manuscript claims and citation candidates only", + "No DOI, Crossref, PubMed, arXiv, Semantic Scholar, publisher, or external corpus calls", + "No private manuscripts, credentials, real literature metadata, or live citation insertions" + ] +} diff --git a/citation-context-fit-assistant/reports/summary.svg b/citation-context-fit-assistant/reports/summary.svg new file mode 100644 index 00000000..fba237da --- /dev/null +++ b/citation-context-fit-assistant/reports/summary.svg @@ -0,0 +1,16 @@ + + + Citation Context-Fit Assistant + Synthetic Microbiome Intervention Review + + block-unsafe-citation-insertions + Critical 2 | High 3 | Findings 5 + + Context-fit score + + + 0/100 + + Block misleading one-click citations + Checks support, contradiction, context-only use, field fit, evidence strength, recency, and insertion notes. + diff --git a/citation-context-fit-assistant/requirements-map.md b/citation-context-fit-assistant/requirements-map.md new file mode 100644 index 00000000..1722b698 --- /dev/null +++ b/citation-context-fit-assistant/requirements-map.md @@ -0,0 +1,17 @@ +# Requirements Map + +Issue: `SCIBASE-AI/SCIBASE.AI#13` + +| Issue requirement | Implementation | +| --- | --- | +| AI citation tool | Validates recommended citations before one-click insertion into a manuscript. | +| Context-aware retrieval | Checks candidate relation to highlighted claims: supports, contradicts, contextualizes, or irrelevant. | +| Completeness and relevance | Scores evidence strength, field fit, stale evidence, and citation intent alignment. | +| Peer-review quality | Blocks misleading direct-support citations and requires notes for contradictory evidence. | +| Safe local validation | Includes dependency-free tests and demo generation from synthetic manuscript/citation metadata only. | + +## Non-goals + +- No live DOI, Crossref, PubMed, arXiv, Semantic Scholar, publisher, corpus, or citation database calls. +- No private manuscripts, credentials, real literature metadata, or live citation insertions. +- No replacement for citation metadata, style, diversity, retraction, provenance, similarity, ethics, unit, or biomethods workflows. diff --git a/citation-context-fit-assistant/sample-data.js b/citation-context-fit-assistant/sample-data.js new file mode 100644 index 00000000..dcc1eedc --- /dev/null +++ b/citation-context-fit-assistant/sample-data.js @@ -0,0 +1,96 @@ +const project = { + asOfDate: "2026-05-22", + policy: { + minimumSupportStrength: 0.72, + staleEvidenceYears: 8, + minimumFieldOverlap: 0.45, + insertionRequiresIntent: true, + allowContradictoryOnlyWithNote: true + }, + manuscript: { + id: "ms-ai-citation-013", + title: "Synthetic Microbiome Intervention Review", + highlightedClaims: [ + { + id: "claim-001", + text: "Short-course prebiotic treatment improves alpha diversity within two weeks.", + field: "microbiome", + claimType: "causal", + polarity: "positive", + intendedCitationRole: "direct-support" + }, + { + id: "claim-002", + text: "The workflow generalizes across pediatric and geriatric cohorts.", + field: "clinical-microbiome", + claimType: "generalization", + polarity: "positive", + intendedCitationRole: "direct-support" + }, + { + id: "claim-003", + text: "Older metagenomic pipelines remain useful as historical baselines.", + field: "metagenomics", + claimType: "background", + polarity: "neutral", + intendedCitationRole: "background" + } + ] + }, + candidates: [ + { + id: "cand-001", + claimId: "claim-001", + title: "Prebiotic Intervention Effects in Synthetic Adult Cohorts", + year: 2024, + field: "microbiome", + citationIntent: "direct-support", + relation: "supports", + evidenceStrength: 0.86, + fieldOverlap: 0.91, + polarity: "positive", + insertionNote: "Supports two-week alpha-diversity improvement in adult cohorts." + }, + { + id: "cand-002", + claimId: "claim-002", + title: "Pediatric Microbiome Response Limits", + year: 2021, + field: "clinical-microbiome", + citationIntent: "direct-support", + relation: "contradicts", + evidenceStrength: 0.81, + fieldOverlap: 0.74, + polarity: "negative", + insertionNote: "" + }, + { + id: "cand-003", + claimId: "claim-003", + title: "Early Metagenomic Pipeline Benchmarks", + year: 2012, + field: "metagenomics", + citationIntent: "background", + relation: "contextualizes", + evidenceStrength: 0.64, + fieldOverlap: 0.67, + polarity: "neutral", + insertionNote: "Historical baseline only." + }, + { + id: "cand-004", + claimId: "claim-001", + title: "Soil Microbial Bioreactor Survey", + year: 2025, + field: "environmental-microbiology", + citationIntent: "direct-support", + relation: "irrelevant", + evidenceStrength: 0.3, + fieldOverlap: 0.18, + polarity: "neutral", + insertionNote: "Related organism methods." + } + ] +}; + +module.exports = { project }; diff --git a/citation-context-fit-assistant/test.js b/citation-context-fit-assistant/test.js new file mode 100644 index 00000000..930606d9 --- /dev/null +++ b/citation-context-fit-assistant/test.js @@ -0,0 +1,77 @@ +const assert = require("assert"); +const { project } = require("./sample-data"); +const { buildReviewPacket, evaluateCitationFit, renderMarkdownReport, renderSvgSummary, yearsOld } = require("./index"); + +const evaluation = evaluateCitationFit(project); +const packet = buildReviewPacket(project); + +assert.strictEqual(packet.assistant, "citation-context-fit-assistant"); +assert.strictEqual(packet.issue, "SCIBASE-AI/SCIBASE.AI#13"); +assert.strictEqual(packet.decision, "block-unsafe-citation-insertions"); + +assert.ok( + evaluation.findings.some((finding) => finding.rule === "contradictory-citation-for-supporting-claim"), + "expected contradictory citation finding" +); +assert.ok( + evaluation.findings.some((finding) => finding.rule === "contradictory-citation-note-missing"), + "expected missing contrast note finding" +); +assert.ok( + evaluation.findings.some((finding) => finding.rule === "citation-context-irrelevant"), + "expected irrelevant citation finding" +); +assert.ok( + evaluation.findings.some((finding) => finding.rule === "citation-field-fit-too-low"), + "expected field fit finding" +); +assert.strictEqual(yearsOld("2026-05-22", 2012), 14); + +const decisionByCandidate = new Map(evaluation.decisions.map((decision) => [decision.candidateId, decision.decision])); +assert.strictEqual(decisionByCandidate.get("cand-001"), "allow-insertion"); +assert.strictEqual(decisionByCandidate.get("cand-002"), "suppress"); +assert.strictEqual(decisionByCandidate.get("cand-004"), "suppress"); + +const cleanProject = JSON.parse(JSON.stringify(project)); +cleanProject.candidates = [ + { + id: "cand-clean-1", + claimId: "claim-001", + title: "Prebiotic Intervention Effects in Synthetic Adult Cohorts", + year: 2024, + field: "microbiome", + citationIntent: "direct-support", + relation: "supports", + evidenceStrength: 0.9, + fieldOverlap: 0.95, + polarity: "positive", + insertionNote: "Directly supports two-week diversity improvement." + }, + { + id: "cand-clean-2", + claimId: "claim-003", + title: "Early Metagenomic Pipeline Benchmarks", + year: 2012, + field: "metagenomics", + citationIntent: "background", + relation: "contextualizes", + evidenceStrength: 0.64, + fieldOverlap: 0.67, + polarity: "neutral", + insertionNote: "Historical baseline only." + } +]; + +const cleanPacket = buildReviewPacket(cleanProject); +assert.strictEqual(cleanPacket.decision, "citation-context-fit-ready"); +assert.strictEqual(cleanPacket.findings.length, 0); + +const markdown = renderMarkdownReport(packet); +assert.ok(markdown.includes("## Insertion Decisions")); +assert.ok(markdown.includes("contradictory-citation-for-supporting-claim")); + +const svg = renderSvgSummary(packet); +assert.ok(svg.includes("