test/report-prose.test.js
Rama main · réplica de lectura del repositorio original.
import { test } from "node:test";
import assert from "node:assert/strict";
import { salvageParagraph, assembleReport, cleanReference } from "../src/ai/report.js";
/**
* Paragraphs the writer emitted as data rather than as prose.
*
* The section schema asks for an array of strings, and constrained decoding
* enforces exactly that: the type and the length, never whether the characters
* read as English. So a model can satisfy the grammar completely and still hand
* back JSON. One generated report's Theoretical Background reached the .docx as
* a serialised `{"text": …, "source": …}` object, the bare schema key
* `paragraph_number_indexed_text`, and `]}` — three valid strings of legal
* length, typeset into a document meant for submission.
*/
test("a paragraph that is really a JSON object gives up its prose", () => {
const raw = JSON.stringify({
text: "Perovskite solar cells suffer from inherent instability.",
source: "Attributed to weak van der Waals interactions.",
});
assert.equal(salvageParagraph(raw), "Perovskite solar cells suffer from inherent instability.");
});
test("bare syntax and leaked schema keys are dropped", () => {
assert.equal(salvageParagraph("]}"), null);
assert.equal(salvageParagraph("}"), null);
assert.equal(salvageParagraph("],"), null);
assert.equal(salvageParagraph("paragraph_number_indexed_text"), null);
assert.equal(salvageParagraph(" "), null);
assert.equal(salvageParagraph(null), null);
});
test("truncated JSON is not prose either", () => {
// The grammar cut the object mid-string; half an object cannot be salvaged.
assert.equal(salvageParagraph('{"text": "a sentence that never clo'), null);
});
test("ordinary prose passes through untouched", () => {
const p = "Perovskite efficiency rose from 3.8% to 25.8% within a decade.";
assert.equal(salvageParagraph(p), p);
// Prose that merely mentions a brace is still prose.
const braces = "The set {A, B} denotes the two architectures compared here.";
assert.equal(salvageParagraph(braces), braces);
// A single word with no underscore is a legitimate (if terse) paragraph.
assert.equal(salvageParagraph("Indeed."), "Indeed.");
});
test("assembleReport drops a section whose paragraphs were all junk", () => {
const plan = { title: "T", sections: [{ name: "Good" }, { name: "Junk" }] };
const report = assembleReport(plan, {
Good: { paragraphs: ["A real sentence about the topic."] },
Junk: { paragraphs: ["]}", "paragraph_number_indexed_text"] },
});
assert.deepEqual(Object.keys(report.content), ["Good"]);
// A bare heading with nothing under it is worse than an absent section —
// the renderer already contracts to skip what is not there.
assert.equal(report.content.Junk, undefined);
});
test("assembleReport keeps a section's other fields while cleaning its prose", () => {
const plan = { title: "T", sections: [{ name: "Mixed" }] };
const table = { headers: ["a"], rows: [["1"]] };
const report = assembleReport(plan, {
Mixed: { paragraphs: ["Real prose here.", "]}"], table },
});
assert.deepEqual(report.content.Mixed.paragraphs, ["Real prose here."]);
assert.deepEqual(report.content.Mixed.table, table);
});
/**
* Reference entries scraped from PubMed/PMC.
*
* Those pages render "[DOI] [PubMed] [Google Scholar]" beside every citation.
* The text lands in the research notes as part of the reference and the writer
* copies it through — and a run of identical bracketed tokens is exactly what
* constrained decoding loops on. One generated reference ran 600 characters:
* the real citation, then "[Google Scholar] [PubMed]" twenty times, then a
* dangling bracket where the grammar cut the next one.
*/
test("scraped database link labels are stripped from a citation", () => {
const raw =
"Bass K. K. Influence of moisture on organohalide perovskites. " +
"Chem. Commun. 2014;50:15819-15822. doi: 10.1039/c4cc05231e. [DOI] [PubMed] [Google Scholar]";
const out = cleanReference(raw);
assert.ok(out.endsWith("doi: 10.1039/c4cc05231e."), out);
for (const label of ["[DOI]", "[PubMed]", "[Google Scholar]"]) {
assert.ok(!out.includes(label), `${label} must not survive`);
}
});
test("a repetition loop collapses to the citation it started from", () => {
const real = "He S. How far are we from a 10-year lifetime? Mater. Sci. Eng., R. 2020;140:100545.";
const looped = real + " [Google Scholar] [PubMed]".repeat(20) + " [";
const out = cleanReference(looped);
assert.equal(out, real);
assert.ok(out.length < 200, `600 characters of loop must not survive: ${out.length}`);
});
test("a clean citation is returned unchanged", () => {
const clean = "Bryant D. Light and oxygen induced degradation. Energy Environ. Sci. 2016;9:1655-1660.";
assert.equal(cleanReference(clean), clean);
});
test("assembleReport cleans a References section's entries", () => {
const plan = { title: "T", sections: [{ name: "References" }] };
const report = assembleReport(plan, {
References: { entries: ["Smith J. A paper. Journal 2020;1:1. [DOI] [PubMed]", " ", "[Google Scholar]"] },
});
assert.deepEqual(report.content.References.entries, ["Smith J. A paper. Journal 2020;1:1."]);
});
/**
* A field the schema offers is a field the model will fill.
*
* The table was offered on every section at full depth, so the writer put a
* five-row data table in the Abstract of a real generated report. An abstract
* is a prose summary of the whole document and a conclusion is its closing
* argument; neither carries a table in any academic format.
*/
test("prose-only sections are never offered a table", async () => {
const { sectionSchema } = await import("../src/ai/report.js");
for (const name of ["Abstract", "Conclusion", "Conclusions", "Summary", "Acknowledgement"]) {
const schema = sectionSchema(name, "full");
assert.ok(!schema.properties?.table, `${name} must not be offered a table`);
}
});
test("body sections still get a table at full depth, and never at brief", async () => {
const { sectionSchema } = await import("../src/ai/report.js");
for (const name of ["Introduction", "Theoretical Background", "Methodology"]) {
assert.ok(sectionSchema(name, "full").properties.table, `${name} should offer a table at full depth`);
assert.ok(!sectionSchema(name, "brief").properties.table, `${name} must not at brief depth`);
}
});
test("the prose-only check ignores case and surrounding space", async () => {
const { sectionSchema } = await import("../src/ai/report.js");
assert.ok(!sectionSchema(" ABSTRACT ", "full").properties?.table);
});
test("a table on a prose-only section is stripped, and the section survives", async () => {
const { assembleReport } = await import("../src/ai/report.js");
const table = { header: ["a", "b"], rows: [["1", "2"]] };
const report = assembleReport(
{ title: "T", sections: [{ name: "Abstract" }, { name: "Introduction" }] },
{
Abstract: { paragraphs: ["The abstract's prose."], table },
Introduction: { paragraphs: ["The introduction's prose."], table },
},
);
// Losing the Abstract would be far worse than losing its table, so the
// unwanted field goes and the section stays.
assert.ok(report.content.Abstract, "the section must survive");
assert.equal(report.content.Abstract.table, undefined);
assert.deepEqual(report.content.Abstract.paragraphs, ["The abstract's prose."]);
assert.deepEqual(report.content.Introduction.table, table, "a body section keeps its table");
});