Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions research-disclosure-assistant/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Research Disclosure Assistant

Self-contained SCIBASE AI-Assisted Research Tools slice for issue #13. The assistant checks funding statements, sponsor role clarity, competing-interest disclosures, author contribution roles, and corresponding-author accountability before AI peer-review aid output is trusted.

## Why this slice is distinct

Existing #13 submissions cover broad AI tool suites, evidence-grounded summaries, citation metadata/context/style/diversity/retraction/venue checks, methods reproducibility, protocol deviation, statistical power, multiple-comparison control, terminology/unit consistency, contradictory evidence, novelty overlap, and reporting-guideline checklists. This module focuses only on disclosure completeness and accountability.

## Run

```bash
npm test
npm run demo
npm run demo:video
```

Demo artifacts are written to `reports/`, including JSON, Markdown, SVG, GIF, and MP4 files.
59 changes: 59 additions & 0 deletions research-disclosure-assistant/demo.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
const fs = require("fs");
const path = require("path");

const { assessResearchDisclosures } = require("./index");
const { cleanPacket, riskyPacket } = require("./sample-data");

const reportsDir = path.join(__dirname, "reports");
fs.mkdirSync(reportsDir, { recursive: true });

function markdownReport(name, report) {
const findings = report.findings.length
? report.findings
.map((item) => `- ${item.severity.toUpperCase()} ${item.code}: ${item.message}`)
.join("\n")
: "- No disclosure findings.";
return `# ${report.title}

Scenario: ${name}

Decision: ${report.decision.toUpperCase()}

Reviewed ${report.summary.authorsReviewed} authors, ${report.summary.fundersReviewed} funders, and ${report.summary.disclosuresReviewed} disclosure records.

## Findings

${findings}

## Release Criteria

${report.releaseCriteria.map((item) => `- ${item}`).join("\n")}
`;
}

function svgReport(report) {
const color = report.decision === "hold" ? "#dc2626" : report.decision === "revise" ? "#d97706" : "#16a34a";
return `<svg xmlns="http://www.w3.org/2000/svg" width="900" height="420" viewBox="0 0 900 420">
<rect width="900" height="420" fill="#101820"/>
<text x="42" y="70" fill="#f8fafc" font-family="Arial" font-size="34">Research Disclosure Assistant</text>
<text x="42" y="118" fill="#cbd5e1" font-family="Arial" font-size="20">${report.manuscriptId}</text>
<rect x="42" y="156" width="220" height="82" rx="8" fill="${color}"/>
<text x="68" y="207" fill="#fff" font-family="Arial" font-size="30">${report.decision.toUpperCase()}</text>
<text x="42" y="286" fill="#e5e7eb" font-family="Arial" font-size="22">Findings: ${report.summary.findings}</text>
<text x="42" y="326" fill="#fecaca" font-family="Arial" font-size="20">High: ${report.summary.high}</text>
<text x="172" y="326" fill="#fed7aa" font-family="Arial" font-size="20">Medium: ${report.summary.medium}</text>
<text x="342" y="326" fill="#bfdbfe" font-family="Arial" font-size="20">Low: ${report.summary.low}</text>
<text x="42" y="372" fill="#94a3b8" font-family="Arial" font-size="18">Synthetic peer-review packet only. No private manuscripts or external APIs.</text>
</svg>`;
}

for (const [name, packet] of [
["clean-packet", cleanPacket],
["risky-packet", riskyPacket],
]) {
const report = assessResearchDisclosures(packet);
fs.writeFileSync(path.join(reportsDir, `${name}.json`), JSON.stringify(report, null, 2));
fs.writeFileSync(path.join(reportsDir, `${name}.md`), markdownReport(name, report));
fs.writeFileSync(path.join(reportsDir, `${name}.svg`), svgReport(report));
console.log(`${name}: ${report.decision} (${report.summary.findings} findings)`);
}
46 changes: 46 additions & 0 deletions research-disclosure-assistant/demo_video.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
from pathlib import Path

import imageio.v3 as iio
import numpy as np
from PIL import Image, ImageDraw, ImageFont


ROOT = Path(__file__).resolve().parent
REPORTS = ROOT / "reports"
REPORTS.mkdir(exist_ok=True)


def font(size):
for name in ("arial.ttf", "segoeui.ttf"):
try:
return ImageFont.truetype(name, size)
except OSError:
pass
return ImageFont.load_default()


slides = [
("Research Disclosure Assistant", "AI-Assisted Research Tools #13"),
("Checks", "funding statements + sponsor role clarity"),
("Checks", "COI declarations + industry relationships"),
("Decision", "hold AI review output until disclosures are complete"),
]

frames = []
for index, (title, subtitle) in enumerate(slides, start=1):
image = Image.new("RGB", (960, 544), "#102033")
draw = ImageDraw.Draw(image)
draw.rectangle((46, 54, 914, 490), outline="#facc15", width=3)
draw.text((82, 124), title, fill="#f8fafc", font=font(40))
draw.text((82, 206), subtitle, fill="#fef9c3", font=font(26))
draw.rectangle((82, 326, 770, 382), fill="#854d0e")
draw.text((104, 342), "disclosure gaps block trusted peer-review output", fill="#fffbeb", font=font(22))
draw.text((82, 438), f"Slide {index}/4 - synthetic reviewer artifact", fill="#cbd5e1", font=font(20))
frames.extend([image] * 14)

gif_path = REPORTS / "demo.gif"
mp4_path = REPORTS / "demo.mp4"
frames[0].save(gif_path, save_all=True, append_images=frames[1:], duration=120, loop=0)
iio.imwrite(mp4_path, [np.asarray(frame) for frame in frames], fps=8, codec="libx264")
print(f"wrote {gif_path}")
print(f"wrote {mp4_path}")
231 changes: 231 additions & 0 deletions research-disclosure-assistant/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
const HIGH = "high";
const MEDIUM = "medium";
const LOW = "low";

const REQUIRED_CONTRIBUTIONS = new Set([
"Conceptualization",
"Methodology",
"Software",
"Validation",
"Formal analysis",
"Data curation",
"Writing - original draft",
"Writing - review and editing",
]);

function requireString(value, field) {
if (typeof value !== "string" || value.trim() === "") {
throw new TypeError(`${field} must be a non-empty string`);
}
return value.trim();
}

function asArray(value, field) {
if (!Array.isArray(value)) {
throw new TypeError(`${field} must be an array`);
}
return value;
}

function normalizePacket(raw) {
return {
manuscriptId: requireString(raw.manuscriptId, "manuscriptId"),
title: requireString(raw.title, "title"),
fundingStatement: {
funders: asArray(raw.fundingStatement?.funders || [], "fundingStatement.funders").map(String),
grantIds: asArray(raw.fundingStatement?.grantIds || [], "fundingStatement.grantIds").map(String),
sponsorRole: String(raw.fundingStatement?.sponsorRole || "").trim(),
},
competingInterests: asArray(raw.competingInterests || [], "competingInterests").map((item) => ({
authorId: requireString(item.authorId, "competingInterests.authorId"),
statement: String(item.statement || "").trim(),
})),
acknowledgements: asArray(raw.acknowledgements || [], "acknowledgements").map(String),
authors: asArray(raw.authors || [], "authors").map((author) => ({
id: requireString(author.id, "author.id"),
name: requireString(author.name, "author.name"),
corresponding: Boolean(author.corresponding),
contributions: asArray(author.contributions || [], "author.contributions").map(String),
affiliations: asArray(author.affiliations || [], "author.affiliations").map(String),
industryRelationships: asArray(author.industryRelationships || [], "author.industryRelationships").map(String),
})),
};
}

function finding(code, severity, message, remediation, subject = "manuscript") {
return { code, severity, subject, message, remediation };
}

function hasNoConflictStatement(statement) {
return /no competing|none declared|no conflict/i.test(statement);
}

function relationshipDisclosed(relationship, statement) {
return statement.toLowerCase().includes(relationship.toLowerCase());
}

function assessResearchDisclosures(rawPacket) {
const packet = normalizePacket(rawPacket);
const findings = [];
const disclosures = new Map(packet.competingInterests.map((item) => [item.authorId, item.statement]));
const authorIds = new Set(packet.authors.map((author) => author.id));
const acknowledgementText = packet.acknowledgements.join(" ").toLowerCase();

if (packet.fundingStatement.funders.length === 0) {
findings.push(
finding(
"MISSING_FUNDER_STATEMENT",
HIGH,
"No funder is listed for the manuscript.",
"Add an explicit funding or no-funding statement before AI peer-review output is trusted."
)
);
}

if (packet.fundingStatement.funders.length > 0 && packet.fundingStatement.grantIds.length === 0) {
findings.push(
finding(
"MISSING_GRANT_IDENTIFIERS",
MEDIUM,
"Funding is declared without grant, award, or contract identifiers.",
"Add grant identifiers or document why the funder did not issue one."
)
);
}

if (packet.fundingStatement.funders.length > 0 && !packet.fundingStatement.sponsorRole) {
findings.push(
finding(
"SPONSOR_ROLE_UNCLEAR",
HIGH,
"Sponsor role in study design, analysis, writing, or publication decision is not stated.",
"State the sponsor role explicitly, including whether the funder reviewed or influenced the manuscript."
)
);
}

for (const author of packet.authors) {
const statement = disclosures.get(author.id) || "";
if (!statement) {
findings.push(
finding(
"MISSING_COMPETING_INTEREST",
HIGH,
`${author.name} has no competing-interest statement.`,
"Add a competing-interest declaration for every listed author.",
author.id
)
);
}

for (const relationship of author.industryRelationships) {
if (hasNoConflictStatement(statement) || !relationshipDisclosed(relationship, statement)) {
findings.push(
finding(
"UNDISCLOSED_INDUSTRY_RELATIONSHIP",
HIGH,
`${author.name} lists an industry relationship with ${relationship}, but the conflict statement does not disclose it.`,
"Revise the competing-interest declaration or remove/justify the relationship record.",
author.id
)
);
}
}

if (author.contributions.length === 0) {
findings.push(
finding(
"MISSING_AUTHOR_CONTRIBUTIONS",
MEDIUM,
`${author.name} has no CRediT-style contributions listed.`,
"Add at least one contributor role for every author before reviewer packets are released.",
author.id
)
);
}

const unrecognized = author.contributions.filter((role) => !REQUIRED_CONTRIBUTIONS.has(role));
if (unrecognized.length > 0) {
findings.push(
finding(
"UNRECOGNIZED_CONTRIBUTION_ROLE",
LOW,
`${author.name} has non-standard contribution roles: ${unrecognized.join(", ")}.`,
"Map non-standard roles to the configured contribution taxonomy or document the extension.",
author.id
)
);
}
}

for (const disclosure of packet.competingInterests) {
if (!authorIds.has(disclosure.authorId)) {
findings.push(
finding(
"ORPHAN_COMPETING_INTEREST",
MEDIUM,
`Competing-interest statement references unknown author ${disclosure.authorId}.`,
"Retarget or remove orphan disclosure records before export.",
disclosure.authorId
)
);
}
}

for (const funder of packet.fundingStatement.funders) {
const namedInAcknowledgements = acknowledgementText.includes(funder.toLowerCase());
if (namedInAcknowledgements && !packet.fundingStatement.sponsorRole) {
findings.push(
finding(
"ACKNOWLEDGED_SPONSOR_ROLE_MISMATCH",
MEDIUM,
`${funder} is acknowledged in the manuscript but sponsor role is absent from the funding statement.`,
"Reconcile acknowledgements with funding and sponsor-role disclosures."
)
);
}
}

const hasCorrespondingAuthor = packet.authors.some((author) => author.corresponding);
if (!hasCorrespondingAuthor) {
findings.push(
finding(
"MISSING_CORRESPONDING_AUTHOR",
HIGH,
"No corresponding author is marked for accountability and disclosure follow-up.",
"Mark one or more corresponding authors before submission or AI-generated review packets proceed."
)
);
}

const high = findings.filter((item) => item.severity === HIGH).length;
const medium = findings.filter((item) => item.severity === MEDIUM).length;
const decision = high > 0 ? "hold" : medium > 0 ? "revise" : "release";

return {
manuscriptId: packet.manuscriptId,
title: packet.title,
decision,
summary: {
authorsReviewed: packet.authors.length,
fundersReviewed: packet.fundingStatement.funders.length,
disclosuresReviewed: packet.competingInterests.length,
findings: findings.length,
high,
medium,
low: findings.filter((item) => item.severity === LOW).length,
},
findings,
releaseCriteria: [
"Funder and grant statements are explicit before AI peer-review aid output is trusted.",
"Sponsor role in design, analysis, writing, review, and publication decision is disclosed.",
"Every author has a competing-interest statement matching known relationships.",
"CRediT-style author contributions and corresponding-author accountability are present.",
],
};
}

module.exports = {
assessResearchDisclosures,
normalizePacket,
};
13 changes: 13 additions & 0 deletions research-disclosure-assistant/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "research-disclosure-assistant",
"version": "1.0.0",
"description": "Funding, conflict-of-interest, and author contribution assistant for SCIBASE issue #13",
"main": "index.js",
"type": "commonjs",
"scripts": {
"test": "node test.js",
"demo": "node demo.js",
"demo:video": "python demo_video.py"
},
"license": "MIT"
}
21 changes: 21 additions & 0 deletions research-disclosure-assistant/reports/clean-packet.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"manuscriptId": "peer-review-disclosure-001",
"title": "AI-assisted polymer screening",
"decision": "release",
"summary": {
"authorsReviewed": 3,
"fundersReviewed": 2,
"disclosuresReviewed": 3,
"findings": 0,
"high": 0,
"medium": 0,
"low": 0
},
"findings": [],
"releaseCriteria": [
"Funder and grant statements are explicit before AI peer-review aid output is trusted.",
"Sponsor role in design, analysis, writing, review, and publication decision is disclosed.",
"Every author has a competing-interest statement matching known relationships.",
"CRediT-style author contributions and corresponding-author accountability are present."
]
}
Loading