import html import inspect import json import os import re import tempfile from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path from typing import Any from urllib.parse import quote import gradio as gr import matplotlib import requests from bs4 import BeautifulSoup from docx import Document from huggingface_hub import InferenceClient from pypdf import PdfReader matplotlib.use("Agg") import matplotlib.pyplot as plt # noqa: E402 APP_NAME = "OpenStudy" QWEN_MODEL_ID = "Qwen/Qwen3.6-27B" MAX_ANALYSIS_CHARS = 180_000 MAX_QWEN_CONTEXT_CHARS = 18_000 REQUEST_TIMEOUT = 12 MIN_TOPIC_PAPERS = 15 DEFAULT_TOPIC_PAPERS = 15 MAX_TOPIC_PAPERS = 100 HEADERS = { "User-Agent": "OpenStudy/0.1 (open-source research-bias-screening; https://huggingface.co/spaces)" } # --- OpenStudy design tokens --------------------------------------------- BRAND = "#0e6b5f" BRAND_DEEP = "#0a554b" BRAND_BRIGHT = "#2fa08f" PAPER = "#eef4f2" CARD = "#ffffff" INK = "#172522" INK_SOFT = "#4f6660" BORDER = "#d9e4e0" WARN = "#b45309" RISK = "#b91c1c" THEME = gr.themes.Soft( primary_hue="teal", secondary_hue="emerald", neutral_hue="stone", font=[gr.themes.GoogleFont("Plus Jakarta Sans"), "ui-sans-serif", "system-ui", "sans-serif"], font_mono=[gr.themes.GoogleFont("IBM Plex Mono"), "ui-monospace", "SFMono-Regular", "monospace"], ).set( body_background_fill=PAPER, body_background_fill_dark="#101614", body_text_color=INK, body_text_color_dark="#e9e7df", body_text_color_subdued=INK_SOFT, body_text_color_subdued_dark="#9fafa9", background_fill_primary=CARD, background_fill_primary_dark="#19221f", background_fill_secondary=PAPER, background_fill_secondary_dark="#141c19", border_color_primary=BORDER, border_color_primary_dark="#2b3733", block_background_fill=CARD, block_background_fill_dark="#19221f", block_border_color=BORDER, block_border_color_dark="#2b3733", block_shadow="0 1px 2px rgba(32, 49, 45, 0.05)", block_title_text_color=INK, block_title_text_color_dark="#e9e7df", block_label_text_color=INK_SOFT, block_label_text_color_dark="#9fafa9", block_label_background_fill=PAPER, block_label_background_fill_dark="#141c19", input_background_fill="#ffffff", input_background_fill_dark="#101614", input_border_color="#c4d5cf", input_border_color_dark="#33403b", button_primary_background_fill=BRAND, button_primary_background_fill_hover=BRAND_DEEP, button_primary_background_fill_dark=BRAND, button_primary_background_fill_hover_dark="#128172", button_primary_text_color="#ffffff", button_primary_text_color_dark="#ffffff", button_secondary_background_fill="#e2ece9", button_secondary_background_fill_hover="#d6e4e0", button_secondary_background_fill_dark="#243029", button_secondary_background_fill_hover_dark="#2c3a32", button_secondary_text_color=INK, button_secondary_text_color_dark="#e9e7df", table_even_background_fill="#f5f9f8", table_even_background_fill_dark="#1b2421", table_odd_background_fill=CARD, table_odd_background_fill_dark="#19221f", link_text_color=BRAND, link_text_color_dark="#3db2a0", color_accent_soft="#e3efec", color_accent_soft_dark="#16302b", slider_color=BRAND, slider_color_dark=BRAND_BRIGHT, ) STUDY_TYPE_CHOICES = [ "Auto-detect", "Clinical trial", "Observational study", "Systematic review or meta-analysis", "Qualitative study", "Survey study", "Animal or preclinical study", "Model or algorithm study", ] @dataclass(frozen=True) class Criterion: category: str label: str positive: tuple[str, ...] recommendation: str concern: tuple[str, ...] = () applies_to: tuple[str, ...] = () excludes: tuple[str, ...] = () @dataclass(frozen=True) class Benchmark: name: str description: str criteria: tuple[str, ...] applies_to: tuple[str, ...] = () excludes: tuple[str, ...] = () CRITERIA: tuple[Criterion, ...] = ( Criterion( "Design and protocol", "Study design is named", ( r"\brandomi[sz]ed\b", r"\bclinical trial\b", r"\bcohort\b", r"\bcase[- ]control\b", r"\bcross[- ]sectional\b", r"\bsurvey\b", r"\bqualitative\b", r"\bsystematic review\b", r"\bmeta[- ]analysis\b", r"\bprospective\b", r"\bretrospective\b", ), "State the study design clearly enough for readers to judge what claims are supported.", ), Criterion( "Design and protocol", "Protocol or preregistration is reported", ( r"\bpre[- ]registered\b", r"\bpreregistration\b", r"\bregistered\b", r"\bclinicaltrials\.gov\b", r"\bprotocol\b", r"\bosf\.io\b", r"\bprospero\b", ), "Report a protocol, registry entry, or explain why preregistration was not possible.", ), Criterion( "Design and protocol", "Primary outcomes or endpoints are specified", ( r"\bprimary outcome\b", r"\bprimary endpoint\b", r"\bsecondary outcome\b", r"\bend ?point\b", r"\boutcome measures?\b", ), "Identify primary and secondary outcomes before presenting results.", ), Criterion( "Sampling and participants", "Eligibility criteria are described", ( r"\binclusion criteria\b", r"\bexclusion criteria\b", r"\beligib(le|ility)\b", r"\bwere included\b", r"\bwere excluded\b", ), "Describe who could enter the study and who was excluded.", ), Criterion( "Sampling and participants", "Recruitment source or study period is described", ( r"\brecruited\b", r"\benrolled\b", r"\bconsecutive\b", r"\bstudy period\b", r"\bbetween [a-z]+ \d{4}\b", r"\bfrom \d{4} to \d{4}\b", r"\bdata were collected\b", ), "Report where, when, and how participants or records were selected.", ), Criterion( "Sampling and participants", "Sample size or power rationale is reported", ( r"\bsample size\b", r"\bpower (analysis|calculation)\b", r"\bstatistical power\b", r"\bminimum detectable\b", ), "Provide a sample-size rationale, power analysis, or precision target.", ), Criterion( "Sampling and participants", "Participant characteristics are reported", ( r"\bbaseline characteristics\b", r"\bdemographic", r"\bage\b", r"\bsex\b", r"\bgender\b", r"\brace\b", r"\bethnicity\b", ), "Report participant characteristics relevant to fairness and generalizability.", ), Criterion( "Bias controls", "Random allocation is reported", ( r"\brandomi[sz]ed\b", r"\brandom allocation\b", r"\ballocation sequence\b", r"\bblock random", r"\bstratified random", ), "Describe the allocation method and who generated the sequence.", applies_to=("clinical_trial", "animal"), ), Criterion( "Bias controls", "Blinding or masking is reported", ( r"\bdouble[- ]blind\b", r"\bsingle[- ]blind\b", r"\btriple[- ]blind\b", r"\bblind(ed|ing)\b", r"\bmask(ed|ing)\b", ), "State whether participants, investigators, outcome assessors, or analysts were blinded.", concern=(r"\bopen[- ]label\b", r"\bunblinded\b", r"\bnot blinded\b"), applies_to=("clinical_trial", "animal"), ), Criterion( "Bias controls", "Comparator or control condition is described", ( r"\bcontrol group\b", r"\bcontrol condition\b", r"\bcomparator\b", r"\bplacebo\b", r"\busual care\b", r"\bmatched controls?\b", ), "Describe the comparator, control, or counterfactual condition.", excludes=("review", "qualitative"), ), Criterion( "Bias controls", "Confounding is addressed", ( r"\bconfound", r"\badjusted for\b", r"\bmultivariable\b", r"\bmultivariate\b", r"\bpropensity score\b", r"\binverse probability\b", r"\bstratified analysis\b", ), "Explain likely confounders and how the analysis reduces confounding.", applies_to=("observational", "survey", "unknown"), ), Criterion( "Bias controls", "Missing data, attrition, or follow-up is addressed", ( r"\bmissing data\b", r"\blost to follow[- ]up\b", r"\battrition\b", r"\bwithdrawn\b", r"\bimputation\b", r"\bcomplete case\b", ), "Report missing data, exclusions after enrollment, attrition, and handling methods.", ), Criterion( "Bias controls", "Measurement validity or reliability is addressed", ( r"\bvalidated\b", r"\bvalidation\b", r"\breliability\b", r"\binter[- ]rater\b", r"\bintra[- ]class\b", r"\bcalibrat", r"\bstandardi[sz]ed\b", ), "Describe validated instruments, calibration, or reliability checks for key measures.", ), Criterion( "Analysis", "Statistical methods are named", ( r"\bregression\b", r"\banova\b", r"\bt[- ]test\b", r"\bchi[- ]square\b", r"\bhazard ratio\b", r"\bodds ratio\b", r"\brisk ratio\b", r"\bbayesian\b", r"\bmodel\b", ), "Name the statistical methods and connect them to the hypotheses or outcomes.", ), Criterion( "Analysis", "Uncertainty is reported", ( r"\bconfidence interval\b", r"\bcredible interval\b", r"\bstandard error\b", r"\bstandard deviation\b", r"\beffect size\b", r"\bp ?[<=>]\s?0\.", r"\bp-value\b", ), "Report effect sizes and uncertainty, not only direction or significance.", ), Criterion( "Analysis", "Sensitivity, subgroup, or robustness checks are reported", ( r"\bsensitivity analysis\b", r"\brobustness\b", r"\bsubgroup analysis\b", r"\bmultiple comparisons\b", r"\bfalse discovery\b", r"\bbonferroni\b", ), "Show whether conclusions hold under reasonable alternative analyses.", ), Criterion( "Transparency", "Data or code availability is reported", ( r"\bdata availability\b", r"\bavailable upon request\b", r"\bopen data\b", r"\bcode (is )?available\b", r"\bgithub\.com\b", r"\brepository\b", r"\bsupplementary data\b", ), "Provide data, code, materials, or a justified access limitation.", ), Criterion( "Ethics", "Ethics approval or oversight is reported", ( r"\binstitutional review board\b", r"\birb\b", r"\bethics committee\b", r"\bethical approval\b", r"\bethics approval\b", r"\bapproved by\b", r"\bdeclaration of helsinki\b", ), "Report the oversight body, approval number when available, or exemption rationale.", excludes=("review",), ), Criterion( "Ethics", "Consent process is reported", ( r"\binformed consent\b", r"\bconsent (was )?obtained\b", r"\bwritten consent\b", r"\bwaiver of consent\b", r"\bassent\b", ), "State how consent was obtained or why consent was waived.", excludes=("review", "animal"), ), Criterion( "Ethics", "Privacy, confidentiality, or safety safeguards are reported", ( r"\bconfidential", r"\banonymi[sz]ed\b", r"\bde[- ]identified\b", r"\bprivacy\b", r"\badverse events?\b", r"\bsafety monitoring\b", r"\bdata protection\b", ), "Describe participant privacy protections and safety monitoring where relevant.", excludes=("review", "animal"), ), Criterion( "Ethics", "Animal welfare oversight is reported", ( r"\biacuc\b", r"\banimal care\b", r"\banimal ethics\b", r"\banimal welfare\b", r"\barrive\b", ), "Report animal-care approval and welfare safeguards.", applies_to=("animal",), ), Criterion( "Funding and conflicts", "Funding source is reported", ( r"\bfunding\b", r"\bfunded by\b", r"\bgrant\b", r"\bsponsor(ed|ship)?\b", r"\bfinancial support\b", ), "Report funding sources or state that no external funding was received.", ), Criterion( "Funding and conflicts", "Conflicts or competing interests are reported", ( r"\bconflicts? of interest\b", r"\bcompeting interests?\b", r"\bfinancial interests?\b", r"\bno competing interests\b", r"\bno conflicts?\b", ), "Disclose conflicts of interest or explicitly state there were none.", ), Criterion( "Funding and conflicts", "Funder or sponsor role is clarified", ( r"\bfunder(s)? had no role\b", r"\bsponsor(s)? had no role\b", r"\bindependent of the funder\b", r"\brole of the funding source\b", ), "Clarify whether funders influenced design, analysis, interpretation, or publication.", ), Criterion( "Interpretation", "Limitations are discussed", ( r"\blimitations?\b", r"\bstrengths and limitations\b", r"\bexternal validity\b", r"\bgeneralizability\b", r"\bmay not generalize\b", ), "Discuss limitations, generalizability, and remaining uncertainty.", ), Criterion( "Interpretation", "Causal claims are appropriately constrained", ( r"\bassociated with\b", r"\bassociation between\b", r"\bcorrelat", r"\bcannot infer caus", r"\bnot establish caus", r"\bobservational\b", ), "Avoid causal language unless the design supports causal inference.", concern=(r"\bcauses?\b", r"\bcaused\b", r"\bleads to\b", r"\bprevents?\b"), applies_to=("observational", "survey", "unknown"), ), Criterion( "Review methods", "Search strategy and databases are reported", ( r"\bsearch strategy\b", r"\bpubmed\b", r"\bmedline\b", r"\bembase\b", r"\bweb of science\b", r"\bscopus\b", r"\bcochrane\b", ), "Report databases, search dates, and enough query detail to reproduce the review.", applies_to=("review",), ), Criterion( "Review methods", "Risk-of-bias or quality appraisal is reported", ( r"\brisk of bias\b", r"\bquality assessment\b", r"\bcochrane risk\b", r"\brobins\b", r"\bnewcastle[- ]ottawa\b", r"\bgrade\b", ), "Assess included-study quality or risk of bias with an appropriate tool.", applies_to=("review",), ), Criterion( "Review methods", "Publication bias is considered", ( r"\bpublication bias\b", r"\bfunnel plot\b", r"\begger", r"\btrim and fill\b", ), "Assess publication bias or explain why it could not be assessed.", applies_to=("review",), ), Criterion( "Qualitative methods", "Coding, reflexivity, or triangulation is reported", ( r"\bthematic analysis\b", r"\bcoding\b", r"\bcodebook\b", r"\breflexivity\b", r"\btriangulation\b", r"\bmember checking\b", ), "Report coding procedures and researcher reflexivity checks.", applies_to=("qualitative",), ), Criterion( "Model or algorithm studies", "Validation split or external validation is reported", ( r"\btraining set\b", r"\btest set\b", r"\bvalidation set\b", r"\bcross[- ]validation\b", r"\bexternal validation\b", r"\bholdout\b", ), "Describe training, validation, testing, and external validation if available.", applies_to=("ml",), ), Criterion( "Model or algorithm studies", "Fairness or subgroup performance is reported", ( r"\bfairness\b", r"\bbias audit\b", r"\bsubgroup performance\b", r"\bdemographic parity\b", r"\bequalized odds\b", ), "Report subgroup performance and fairness checks for affected populations.", applies_to=("ml",), ), ) BENCHMARKS: tuple[Benchmark, ...] = ( Benchmark( "Protocol readiness", "Design, protocol, outcomes, and sample-size planning are clear before strong claims are made.", ( "Study design is named", "Protocol or preregistration is reported", "Primary outcomes or endpoints are specified", "Sample size or power rationale is reported", ), ), Benchmark( "Participant selection fairness", "Recruitment, eligibility, and participant characteristics are transparent enough to judge selection bias and generalizability.", ( "Eligibility criteria are described", "Recruitment source or study period is described", "Participant characteristics are reported", ), ), Benchmark( "Bias control readiness", "The project includes design or analysis safeguards that reduce expected sources of bias.", ( "Random allocation is reported", "Blinding or masking is reported", "Comparator or control condition is described", "Confounding is addressed", "Measurement validity or reliability is addressed", ), ), Benchmark( "Ethical conduct readiness", "Oversight, consent, privacy, safety, or welfare safeguards are described before data collection or publication.", ( "Ethics approval or oversight is reported", "Consent process is reported", "Privacy, confidentiality, or safety safeguards are reported", "Animal welfare oversight is reported", ), ), Benchmark( "Analysis integrity", "The analysis plan reports methods, uncertainty, robustness checks, and missing-data handling.", ( "Statistical methods are named", "Uncertainty is reported", "Sensitivity, subgroup, or robustness checks are reported", "Missing data, attrition, or follow-up is addressed", ), ), Benchmark( "Transparency and conflicts", "Data, code, funding, sponsor role, competing interests, and limitations are visible to reviewers.", ( "Data or code availability is reported", "Funding source is reported", "Conflicts or competing interests are reported", "Funder or sponsor role is clarified", "Limitations are discussed", ), ), Benchmark( "Review-method completeness", "Search strategy, included-study appraisal, and publication-bias assessment are planned or reported.", ( "Search strategy and databases are reported", "Risk-of-bias or quality appraisal is reported", "Publication bias is considered", ), applies_to=("review",), ), Benchmark( "Qualitative rigor", "Qualitative coding, reflexivity, triangulation, or member-checking safeguards are planned or reported.", ("Coding, reflexivity, or triangulation is reported",), applies_to=("qualitative",), ), Benchmark( "Algorithmic fairness", "Model validation and subgroup or fairness checks are planned or reported for algorithmic studies.", ( "Validation split or external validation is reported", "Fairness or subgroup performance is reported", ), applies_to=("ml",), ), ) STANDARD_CATEGORIES = list(dict.fromkeys(criterion.category for criterion in CRITERIA)) STANDARD_CRITERIA_LABELS = [criterion.label for criterion in CRITERIA] DEFAULT_RISK_THRESHOLDS = (50.0, 75.0) CATEGORY_EXPLANATIONS = { "Design and protocol": "Is the study design named, was it planned ahead (protocol or preregistration), and were primary outcomes specified before results?", "Sampling and participants": "Who was studied, how they were recruited, eligibility rules, sample size justification, and participant characteristics.", "Bias controls": "Protections against skewed results: randomization, blinding, comparator groups, validated measures, and confounder control.", "Analysis": "Whether the statistical or qualitative analysis approach is described, including how missing data and attrition were handled.", "Transparency": "Whether data, code, and materials are shared or their availability is explained.", "Ethics": "Ethics approval or oversight, informed consent, privacy and safety safeguards, and animal welfare where relevant.", "Funding and conflicts": "Who paid for the study, what role the funder played, and whether competing interests are disclosed.", "Interpretation": "Whether limitations are discussed and conclusions stay within what the evidence supports.", "Review methods": "For reviews: search strategy, quality appraisal of included studies, and publication-bias assessment.", "Qualitative methods": "For qualitative work: coding approach, reflexivity, triangulation, or member checking.", "Model or algorithm studies": "For models: validation on held-out or external data and fairness or subgroup performance checks.", } STATUS_ICONS = { "Reported": "✅ Reported", "Potential concern": "⚠️ Potential concern", "Missing or unclear": "❌ Missing or unclear", "Meets benchmark": "✅ Meets benchmark", "Partially meets benchmark": "🟡 Partially meets benchmark", "Needs work": "❌ Needs work", "Lower signal": "✅ Lower signal", "Needs review": "🟡 Needs review", } def iconize(status: str) -> str: return STATUS_ICONS.get(status, status) APP_HEAD = """ """ APP_CSS = """ /* ===================== OpenStudy design system ===================== */ :root { --os-paper: #eef4f2; --os-card: #ffffff; --os-ink: #172522; --os-ink-soft: #4f6660; --os-brand: #0e6b5f; --os-brand-deep: #0a554b; --os-brand-tint: #e0efeb; --os-border: #d9e4e0; --os-serif: "Space Grotesk", "Plus Jakarta Sans", ui-sans-serif, sans-serif; --os-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, monospace; } .dark { --os-paper: #101614; --os-card: #19221f; --os-ink: #e9e7df; --os-ink-soft: #9fafa9; --os-brand: #2fa08f; --os-brand-deep: #3db2a0; --os-brand-tint: #16302b; --os-border: #2b3733; } body { background: linear-gradient(180deg, #e0eeea 0%, var(--os-paper) 340px) fixed !important; } .dark body { background: var(--os-paper) !important; } .gradio-container { max-width: 1020px !important; margin: 0 auto !important; background: transparent !important; } /* Readability: roomier body text */ .gradio-container .prose p, .gradio-container .prose li { font-size: 0.98rem; line-height: 1.62; } /* Hero CTA buttons: identical fixed size, side by side */ button.os-cta { width: 280px !important; min-width: 280px !important; flex: 0 0 280px !important; height: 48px; white-space: nowrap; font-weight: 600; } /* Action buttons keep their natural width instead of stretching */ button.os-action { width: fit-content !important; flex-grow: 0 !important; align-self: flex-start; min-width: 230px; padding-left: 1.5rem !important; padding-right: 1.5rem !important; } /* ---------- Scorecard ---------- */ .os-scorecard { display: flex; align-items: center; gap: 0.65rem; flex-wrap: wrap; margin: 0.35rem 0 0.6rem; } .os-score-chip { font-family: var(--os-mono); font-weight: 700; font-size: 1.18rem; padding: 0.32rem 0.8rem; border-radius: 10px; color: #ffffff; letter-spacing: -0.01em; } .os-chip-good { background: #0e6b5f; } .os-chip-warn { background: #b45309; } .os-chip-risk { background: #b91c1c; } .os-score-level { font-weight: 650; font-size: 1.02rem; } /* ---------- Standards verdict ---------- */ .os-verdict { border: 1px solid var(--os-border); border-radius: 10px; padding: 0.85rem 1.05rem; margin: 0.5rem 0 0.75rem; font-size: 0.92rem; line-height: 1.55; } .os-verdict ul { margin: 0.4rem 0 0 1.15rem; padding: 0; } .os-verdict li { margin: 0.15rem 0; } .os-verdict-pass { background: #ecf6f1; border-color: #bfe0d4; color: #0a554b; } .dark .os-verdict-pass { background: #14302a; border-color: #265e51; color: #9fd9cb; } .os-verdict-fail { background: #fdf3ee; border-color: #f0d4c4; color: #9a3412; } .dark .os-verdict-fail { background: #321d14; border-color: #6b3a22; color: #f0b893; } /* ---------- Hero header ---------- */ .os-hero { padding: 2.1rem 0.2rem 1.5rem; border-bottom: 1px solid var(--os-border); margin-bottom: 0.35rem; } .os-wordmark { font-family: var(--os-serif); font-weight: 700; font-size: 1.15rem; letter-spacing: -0.01em; color: var(--os-brand-deep); } .os-hero h1 { font-family: var(--os-serif); font-weight: 700; font-size: clamp(1.6rem, 3.1vw, 2.25rem); line-height: 1.18; letter-spacing: -0.022em; color: var(--os-ink); max-width: 32ch; margin: 0.9rem 0 0.6rem; } .os-lede { color: var(--os-ink-soft); font-size: 1.02rem; line-height: 1.55; max-width: 72ch; margin: 0 0 1.05rem; } .os-badges { display: flex; flex-wrap: wrap; gap: 0.45rem; margin-bottom: 1.1rem; } .os-badge { display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.28rem 0.72rem; border: 1px solid var(--os-border); border-radius: 999px; background: var(--os-card); color: var(--os-ink-soft); font-size: 0.78rem; font-weight: 600; letter-spacing: 0.01em; white-space: nowrap; } .os-badge.os-badge-brand { border-color: transparent; background: var(--os-brand-tint); color: var(--os-brand-deep); } /* ---------- Callout note ---------- */ .os-note, .openstudy-note { border: 1px solid var(--os-border); border-left: 4px solid var(--os-brand); padding: 0.85rem 1.05rem; background: var(--os-card); color: var(--os-ink); border-radius: 10px; font-size: 0.92rem; line-height: 1.55; max-width: 86ch; } .os-note strong { color: var(--os-brand-deep); } /* ---------- Tabs as underlined nav ---------- */ .gradio-container button[role="tab"] { font-weight: 600; font-size: 0.95rem; color: var(--os-ink-soft); background: transparent; border-radius: 6px 6px 0 0; padding: 0.6rem 1rem; border-bottom: 2px solid transparent; } .gradio-container button[role="tab"][aria-selected="true"] { color: var(--os-brand-deep); border-bottom: 2px solid var(--os-brand); background: transparent; } .gradio-container .tabitem { padding-top: 0.85rem; border: none; background: transparent; } /* ---------- Typography in markdown blocks ---------- */ .gradio-container .prose h1, .gradio-container .prose h2, .gradio-container .prose h3 { font-family: var(--os-serif); font-weight: 600; letter-spacing: -0.01em; color: var(--os-ink); } .gradio-container .prose code { font-family: var(--os-mono); font-size: 0.85em; background: var(--os-brand-tint); color: var(--os-brand-deep); border-radius: 4px; padding: 0.08em 0.35em; } /* ---------- Buttons ---------- */ .gradio-container button.primary, .gradio-container button.secondary { font-weight: 600; letter-spacing: 0.01em; transition: transform 0.08s ease, box-shadow 0.12s ease; } .gradio-container button.primary:hover { box-shadow: 0 3px 10px rgba(14, 107, 95, 0.28); } .gradio-container button.primary:active, .gradio-container button.secondary:active { transform: translateY(1px); } /* ---------- Tables ---------- */ .gradio-container .table-wrap { border: 1px solid var(--os-border); border-radius: 10px; } .gradio-container thead th, .gradio-container thead th span { font-size: 0.72rem !important; text-transform: uppercase; letter-spacing: 0.07em; font-weight: 700; color: var(--os-ink-soft); } .gradio-container tbody td, .gradio-container tbody td span { font-size: 0.86rem; line-height: 1.45; } .compact-table textarea { font-size: 0.88rem !important; } /* ---------- Inputs ---------- */ .gradio-container input:focus, .gradio-container textarea:focus { border-color: var(--os-brand) !important; box-shadow: 0 0 0 3px rgba(14, 107, 95, 0.14) !important; } /* ---------- Footer ---------- */ .os-footer { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 0.5rem 1.5rem; border-top: 1px solid var(--os-border); margin-top: 2.2rem; padding: 1.1rem 0.2rem 0.4rem; color: var(--os-ink-soft); font-size: 0.82rem; } .os-footer a { color: var(--os-brand); text-decoration: none; } @media (max-width: 720px) { .os-hero { padding-top: 1.4rem; } .os-badge { font-size: 0.72rem; } } """ def strip_markup(value: Any) -> str: if not value: return "" text = str(value) text = re.sub(r"<[^>]+>", " ", text) text = html.unescape(text) return normalize_space(text) def normalize_space(text: str) -> str: return re.sub(r"\s+", " ", text or "").strip() def normalize_doi(value: str) -> str: match = re.search(r"\b10\.\d{4,9}/[-._;()/:A-Z0-9]+\b", value or "", flags=re.I) if not match: return "" return match.group(0).rstrip(".,;)]}").lower() def is_url(value: str) -> bool: return bool(re.match(r"^https?://", (value or "").strip(), flags=re.I)) def safe_get_json(url: str, params: dict[str, Any] | None = None) -> dict[str, Any]: try: response = requests.get(url, params=params, headers=HEADERS, timeout=REQUEST_TIMEOUT) response.raise_for_status() return response.json() except Exception: return {} def get_first(value: Any) -> str: if isinstance(value, list) and value: return strip_markup(value[0]) return strip_markup(value) def year_from_crossref(item: dict[str, Any]) -> str: for key in ("published-print", "published-online", "published", "issued", "created"): parts = item.get(key, {}).get("date-parts", []) if parts and parts[0]: return str(parts[0][0]) return "" def record_from_crossref(item: dict[str, Any]) -> dict[str, Any]: title = get_first(item.get("title")) container = get_first(item.get("container-title")) abstract = strip_markup(item.get("abstract")) doi = strip_markup(item.get("DOI")).lower() authors = [] for author in item.get("author", [])[:8]: name = normalize_space(" ".join(part for part in (author.get("given"), author.get("family")) if part)) if name: authors.append(name) return { "title": title or "Untitled study", "year": year_from_crossref(item), "venue": container, "doi": doi, "url": item.get("URL", ""), "abstract": abstract, "authors": ", ".join(authors), "source": "Crossref", } def crossref_by_doi(doi: str) -> dict[str, Any] | None: url = f"https://api.crossref.org/works/{quote(doi, safe='')}" data = safe_get_json(url) item = data.get("message") if isinstance(item, dict): return record_from_crossref(item) return None def crossref_search(query: str, rows: int) -> list[dict[str, Any]]: data = safe_get_json( "https://api.crossref.org/works", params={"query.bibliographic": query, "rows": rows, "select": "title,DOI,URL,abstract,author,container-title,published,published-print,published-online,issued,created"}, ) items = data.get("message", {}).get("items", []) return [record_from_crossref(item) for item in items if isinstance(item, dict)] def abstract_from_openalex(index: dict[str, list[int]] | None) -> str: if not index: return "" positions: dict[int, str] = {} for word, offsets in index.items(): for offset in offsets: positions[int(offset)] = word return normalize_space(" ".join(positions[i] for i in sorted(positions))) def record_from_openalex(item: dict[str, Any]) -> dict[str, Any]: primary = item.get("primary_location") or {} source = primary.get("source") or {} doi = (item.get("doi") or "").replace("https://doi.org/", "").lower() authors = [] for authorship in item.get("authorships", [])[:8]: author = authorship.get("author", {}) name = strip_markup(author.get("display_name")) if name: authors.append(name) return { "title": strip_markup(item.get("display_name")) or "Untitled study", "year": str(item.get("publication_year") or ""), "venue": strip_markup(source.get("display_name")), "doi": doi, "url": item.get("doi") or item.get("id") or "", "abstract": abstract_from_openalex(item.get("abstract_inverted_index")), "authors": ", ".join(authors), "source": "OpenAlex", } def openalex_by_doi(doi: str) -> dict[str, Any] | None: url = f"https://api.openalex.org/works/https://doi.org/{doi}" data = safe_get_json(url) if data.get("id"): return record_from_openalex(data) return None def openalex_search(query: str, rows: int) -> list[dict[str, Any]]: data = safe_get_json("https://api.openalex.org/works", params={"search": query, "per-page": rows}) items = data.get("results", []) return [record_from_openalex(item) for item in items if isinstance(item, dict)] def extract_page_record(url: str) -> dict[str, Any] | None: try: response = requests.get(url, headers=HEADERS, timeout=REQUEST_TIMEOUT) response.raise_for_status() except Exception: return None content_type = response.headers.get("content-type", "").lower() if "pdf" in content_type or url.lower().endswith(".pdf"): with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmp: tmp.write(response.content) tmp_path = tmp.name text = extract_text_from_file(tmp_path) return { "title": Path(url).name or "Uploaded PDF from URL", "year": "", "venue": "", "doi": normalize_doi(text), "url": url, "abstract": text[:6000], "authors": "", "source": "PDF URL", } soup = BeautifulSoup(response.text, "html.parser") def meta_content(*names: str) -> str: for name in names: tag = soup.find("meta", attrs={"name": name}) or soup.find("meta", attrs={"property": name}) if tag and tag.get("content"): return strip_markup(tag.get("content")) return "" title = meta_content("citation_title", "dc.title", "og:title") or strip_markup(soup.title.string if soup.title else "") abstract = meta_content("citation_abstract", "dc.description", "description", "og:description") doi = meta_content("citation_doi", "dc.identifier") or normalize_doi(response.text) year = meta_content("citation_publication_date", "citation_online_date")[:4] venue = meta_content("citation_journal_title", "citation_conference_title") if doi: richer = crossref_by_doi(doi) or openalex_by_doi(doi) if richer: if not richer.get("abstract") and abstract: richer["abstract"] = abstract richer["source"] = f"{richer['source']} + page" return richer return { "title": title or url, "year": year, "venue": venue, "doi": doi.lower(), "url": url, "abstract": abstract, "authors": "", "source": "Web page", } def merge_records(records: list[dict[str, Any]], limit: int) -> list[dict[str, Any]]: merged: dict[str, dict[str, Any]] = {} for record in records: if not record: continue doi = normalize_doi(record.get("doi", "")) title_key = re.sub(r"[^a-z0-9]+", "", (record.get("title") or "").lower())[:90] key = doi or title_key if not key: continue if key not in merged: merged[key] = record continue existing = merged[key] for field in ("title", "year", "venue", "doi", "url", "authors"): if not existing.get(field) and record.get(field): existing[field] = record[field] if len(record.get("abstract", "")) > len(existing.get("abstract", "")): existing["abstract"] = record["abstract"] sources = {part.strip() for part in existing.get("source", "").split("+") if part.strip()} sources.update(part.strip() for part in record.get("source", "").split("+") if part.strip()) existing["source"] = " + ".join(sorted(sources)) return list(merged.values())[:limit] def search_studies(query: str, rows: int) -> list[dict[str, Any]]: query = normalize_space(query) if not query: return [] requested_rows = max(1, min(int(rows), MAX_TOPIC_PAPERS)) records: list[dict[str, Any]] = [] doi = normalize_doi(query) if doi: records.extend(record for record in (crossref_by_doi(doi), openalex_by_doi(doi)) if record) if records: return merge_records(records, requested_rows) if is_url(query) and not records: page_record = extract_page_record(query) if page_record: records.append(page_record) return merge_records(records, requested_rows) target_rows = max(requested_rows, MIN_TOPIC_PAPERS) api_rows = min(max(target_rows * 2, MIN_TOPIC_PAPERS), MAX_TOPIC_PAPERS) if not records or len(records) < target_rows: records.extend(crossref_search(query, api_rows)) records.extend(openalex_search(query, api_rows)) return merge_records(records, target_rows) def detect_study_type(text: str) -> tuple[str, str]: lowered = text.lower() scores = { "review": count_patterns(lowered, (r"\bsystematic review\b", r"\bmeta[- ]analysis\b", r"\bprisma\b")), "clinical_trial": count_patterns(lowered, (r"\brandomi[sz]ed controlled trial\b", r"\bclinical trial\b", r"\btrial registration\b", r"\bplacebo\b")), "observational": count_patterns(lowered, (r"\bcohort\b", r"\bcase[- ]control\b", r"\bcross[- ]sectional\b", r"\bretrospective\b", r"\bprospective observational\b")), "qualitative": count_patterns(lowered, (r"\bqualitative\b", r"\binterviews?\b", r"\bfocus groups?\b", r"\bthematic analysis\b")), "animal": count_patterns(lowered, (r"\bmice\b", r"\brats?\b", r"\banimal model\b", r"\biacuc\b", r"\banimal care\b")), "ml": count_patterns(lowered, (r"\bmachine learning\b", r"\bdeep learning\b", r"\bprediction model\b", r"\balgorithm\b", r"\bclassifier\b")), "survey": count_patterns(lowered, (r"\bsurvey\b", r"\bquestionnaire\b", r"\brespondents\b")), } best_key, best_score = max(scores.items(), key=lambda item: item[1]) labels = { "review": "Systematic review or meta-analysis", "clinical_trial": "Clinical trial", "observational": "Observational study", "qualitative": "Qualitative study", "animal": "Animal or preclinical study", "ml": "Model or algorithm study", "survey": "Survey study", "unknown": "Not enough text to classify", } if best_score <= 0: return "unknown", labels["unknown"] return best_key, labels[best_key] def count_patterns(text: str, patterns: tuple[str, ...]) -> int: return sum(1 for pattern in patterns if re.search(pattern, text, flags=re.I)) def criterion_applies(criterion: Criterion, study_key: str) -> bool: if study_key in criterion.excludes: return False if criterion.applies_to: return study_key in criterion.applies_to return True def benchmark_applies(benchmark: Benchmark, study_key: str) -> bool: if study_key in benchmark.excludes: return False if benchmark.applies_to: return study_key in benchmark.applies_to return True def split_sentences(text: str) -> list[str]: normalized = normalize_space(text) pieces = re.split(r"(?<=[.!?])\s+(?=[A-Z0-9])", normalized) if len(pieces) <= 1: pieces = re.split(r"\s{2,}", normalized) return [piece.strip() for piece in pieces if piece.strip()] def find_snippet(text: str, patterns: tuple[str, ...], max_chars: int = 260) -> str: sentences = split_sentences(text[:MAX_ANALYSIS_CHARS]) for sentence in sentences: for pattern in patterns: if re.search(pattern, sentence, flags=re.I): return truncate(sentence, max_chars) for pattern in patterns: match = re.search(pattern, text, flags=re.I) if match: start = max(0, match.start() - max_chars // 2) end = min(len(text), match.end() + max_chars // 2) return truncate(normalize_space(text[start:end]), max_chars) return "" def truncate(text: str, max_chars: int) -> str: text = normalize_space(text) if len(text) <= max_chars: return text return text[: max_chars - 3].rstrip() + "..." def evaluate_criterion(text: str, criterion: Criterion) -> dict[str, Any]: positive_hit = any(re.search(pattern, text, flags=re.I) for pattern in criterion.positive) concern_hit = any(re.search(pattern, text, flags=re.I) for pattern in criterion.concern) if positive_hit: status = "Reported" score = 1.0 evidence = find_snippet(text, criterion.positive) elif concern_hit: status = "Potential concern" score = 0.25 evidence = find_snippet(text, criterion.concern) else: status = "Missing or unclear" score = 0.0 evidence = "" return { "category": criterion.category, "criterion": criterion.label, "status": status, "score": score, "evidence": evidence, "recommendation": criterion.recommendation, } def category_scores(results: list[dict[str, Any]]) -> dict[str, float]: grouped: dict[str, list[float]] = {} for result in results: grouped.setdefault(result["category"], []).append(float(result["score"])) return {category: round(sum(values) / len(values) * 100, 1) for category, values in grouped.items()} def risk_level(score: float, thresholds: tuple[float, float] = DEFAULT_RISK_THRESHOLDS) -> tuple[str, str]: moderate_at, lower_at = thresholds if score >= lower_at: return "Lower reporting risk", "The available text reports many safeguards readers expect." if score >= moderate_at: return "Moderate reporting risk", "Several safeguards are reported, but important items need review." return "Higher reporting risk", "Many expected safeguards are missing or unclear in the available text." def evaluate_study_text( text: str, metadata: dict[str, Any] | None = None, scope: str = "available text", forced_study_key: str | None = None, risk_thresholds: tuple[float, float] = DEFAULT_RISK_THRESHOLDS, ) -> dict[str, Any]: metadata = metadata or {} text = normalize_space(text) truncated = len(text) > MAX_ANALYSIS_CHARS analysis_text = text[:MAX_ANALYSIS_CHARS] detected_key, detected_label = detect_study_type(analysis_text) study_key = forced_study_key or detected_key study_label = label_for_study_key(study_key) if forced_study_key else detected_label criteria = [criterion for criterion in CRITERIA if criterion_applies(criterion, study_key)] checklist = [evaluate_criterion(analysis_text, criterion) for criterion in criteria] scores = category_scores(checklist) overall = round(sum(item["score"] for item in checklist) / max(len(checklist), 1) * 100, 1) level, level_detail = risk_level(overall, risk_thresholds) conducted = extract_conduct_summary(analysis_text, metadata, study_label) benchmarks = build_benchmark_rows(checklist, study_key) biases = build_bias_rows(checklist, study_key) return { "metadata": metadata, "scope": scope, "study_key": study_key, "study_label": study_label, "checklist": checklist, "scores": scores, "overall": overall, "level": level, "level_detail": level_detail, "conducted": conducted, "benchmarks": benchmarks, "biases": biases, "truncated": truncated, "text_chars": len(text), "analysis_text": analysis_text, "risk_thresholds": risk_thresholds, } def label_for_study_key(study_key: str) -> str: labels = { "review": "Systematic review or meta-analysis", "clinical_trial": "Clinical trial", "observational": "Observational study", "qualitative": "Qualitative study", "animal": "Animal or preclinical study", "ml": "Model or algorithm study", "survey": "Survey study", "unknown": "Not enough text to classify", } return labels.get(study_key, labels["unknown"]) def key_for_type_hint(type_hint: str) -> str | None: mapping = { "Clinical trial": "clinical_trial", "Observational study": "observational", "Systematic review or meta-analysis": "review", "Qualitative study": "qualitative", "Survey study": "survey", "Animal or preclinical study": "animal", "Model or algorithm study": "ml", } return mapping.get(type_hint or "") def extract_conduct_summary(text: str, metadata: dict[str, Any], study_label: str) -> list[tuple[str, str]]: fields = [ ("Title", metadata.get("title") or find_snippet(text, (r"^.{20,180}",), 180)), ("Detected study type", study_label), ] if metadata.get("project_status"): fields.append(("Project status", metadata["project_status"])) if metadata.get("team"): fields.append(("Team or owner", metadata["team"])) fields.extend( [ ("Authors", metadata.get("authors", "")), ("Publication", " ".join(part for part in (metadata.get("venue"), metadata.get("year")) if part)), ("DOI or URL", metadata.get("doi") or metadata.get("url", "")), ("Population or sample", find_snippet(text, (r"\bparticipants?\b", r"\bpatients?\b", r"\bsample\b", r"\bcohort\b", r"\brecruited\b", r"\benrolled\b"))), ("Intervention, exposure, or focus", find_snippet(text, (r"\bintervention\b", r"\btreatment\b", r"\bexposure\b", r"\bprogram\b", r"\bassigned\b", r"\bassociation between\b"))), ("Comparator or control", find_snippet(text, (r"\bcontrol group\b", r"\bcomparator\b", r"\bplacebo\b", r"\busual care\b", r"\bmatched controls?\b"))), ("Outcomes or endpoints", find_snippet(text, (r"\bprimary outcome\b", r"\bendpoint\b", r"\boutcome measures?\b", r"\bmeasured\b"))), ("Analysis approach", find_snippet(text, (r"\bregression\b", r"\banova\b", r"\bodds ratio\b", r"\bhazard ratio\b", r"\bconfidence interval\b", r"\bmodel\b"))), ("Ethics or consent", find_snippet(text, (r"\bethics\b", r"\birb\b", r"\binformed consent\b", r"\bconsent\b", r"\bdeclaration of helsinki\b"))), ("Funding or conflicts", find_snippet(text, (r"\bfunding\b", r"\bgrant\b", r"\bsponsor\b", r"\bconflict", r"\bcompeting interests?\b"))), ] ) return [(label, value or "Not found in available text") for label, value in fields] def checklist_lookup(checklist: list[dict[str, Any]]) -> dict[str, dict[str, Any]]: return {item["criterion"]: item for item in checklist} def combine_evidence(items: list[dict[str, Any]]) -> str: snippets = [item["evidence"] for item in items if item.get("evidence")] if snippets: return " | ".join(snippets[:2]) missing = [item["criterion"] for item in items if item.get("status") == "Missing or unclear"] if missing: return "Missing or unclear: " + "; ".join(missing[:3]) return "No strong concern found in available text" def bias_status(items: list[dict[str, Any]]) -> str: statuses = [item.get("status") for item in items] if any(status == "Potential concern" for status in statuses): return "Potential concern" if all(status == "Reported" for status in statuses if status): return "Lower signal" return "Needs review" def benchmark_status(score: float) -> str: if score >= 0.85: return "Meets benchmark" if score >= 0.55: return "Partially meets benchmark" return "Needs work" def build_benchmark_rows(checklist: list[dict[str, Any]], study_key: str) -> list[list[str]]: lookup = checklist_lookup(checklist) rows = [] for benchmark in BENCHMARKS: if not benchmark_applies(benchmark, study_key): continue items = [lookup[label] for label in benchmark.criteria if label in lookup] if not items: continue score = sum(float(item["score"]) for item in items) / len(items) missing = [item["criterion"] for item in items if item["status"] != "Reported"] action = "Maintain this benchmark and keep evidence in the protocol or report." if missing: action = "Address: " + "; ".join(missing[:4]) rows.append( [ benchmark.name, iconize(benchmark_status(score)), f"{score * 100:.0f}%", benchmark.description, combine_evidence(items), action, ] ) return rows def build_bias_rows(checklist: list[dict[str, Any]], study_key: str) -> list[list[str]]: lookup = checklist_lookup(checklist) def pick(*labels: str) -> list[dict[str, Any]]: return [lookup[label] for label in labels if label in lookup] rows = [ [ "Selection bias", bias_status(pick("Eligibility criteria are described", "Recruitment source or study period is described", "Participant characteristics are reported")), combine_evidence(pick("Eligibility criteria are described", "Recruitment source or study period is described", "Participant characteristics are reported")), "Review whether the sample represents the target population and whether exclusions could skew results.", ], [ "Reporting bias", bias_status(pick("Protocol or preregistration is reported", "Primary outcomes or endpoints are specified", "Limitations are discussed")), combine_evidence(pick("Protocol or preregistration is reported", "Primary outcomes or endpoints are specified", "Limitations are discussed")), "Check for preregistration, outcome switching, selective reporting, and absent limitations.", ], [ "Funding or conflict bias", bias_status(pick("Funding source is reported", "Conflicts or competing interests are reported", "Funder or sponsor role is clarified")), combine_evidence(pick("Funding source is reported", "Conflicts or competing interests are reported", "Funder or sponsor role is clarified")), "Check whether funders or sponsors influenced design, analysis, interpretation, or publication.", ], [ "Ethics safeguards", bias_status(pick("Ethics approval or oversight is reported", "Consent process is reported", "Privacy, confidentiality, or safety safeguards are reported", "Animal welfare oversight is reported")), combine_evidence(pick("Ethics approval or oversight is reported", "Consent process is reported", "Privacy, confidentiality, or safety safeguards are reported", "Animal welfare oversight is reported")), "Confirm oversight, consent or waiver, privacy protections, and participant or animal welfare safeguards.", ], [ "Measurement bias", bias_status(pick("Measurement validity or reliability is addressed", "Blinding or masking is reported")), combine_evidence(pick("Measurement validity or reliability is addressed", "Blinding or masking is reported")), "Check whether outcomes were measured with valid instruments and protected from assessor expectations.", ], [ "Attrition or missing-data bias", bias_status(pick("Missing data, attrition, or follow-up is addressed")), combine_evidence(pick("Missing data, attrition, or follow-up is addressed")), "Look for lost-to-follow-up counts, exclusions after enrollment, imputation, and sensitivity checks.", ], ] if study_key in {"observational", "survey", "unknown"}: rows.append( [ "Confounding", bias_status(pick("Confounding is addressed", "Causal claims are appropriately constrained")), combine_evidence(pick("Confounding is addressed", "Causal claims are appropriately constrained")), "For non-randomized designs, check whether major confounders were identified and adjusted.", ] ) if study_key == "clinical_trial": rows.append( [ "Performance and detection bias", bias_status(pick("Random allocation is reported", "Blinding or masking is reported", "Comparator or control condition is described")), combine_evidence(pick("Random allocation is reported", "Blinding or masking is reported", "Comparator or control condition is described")), "Confirm allocation concealment, blinding, and comparator details.", ] ) if study_key == "review": rows.append( [ "Review publication bias", bias_status(pick("Search strategy and databases are reported", "Risk-of-bias or quality appraisal is reported", "Publication bias is considered")), combine_evidence(pick("Search strategy and databases are reported", "Risk-of-bias or quality appraisal is reported", "Publication bias is considered")), "Review search completeness, included-study appraisal, and publication-bias assessment.", ] ) if study_key == "ml": rows.append( [ "Algorithmic bias", bias_status(pick("Validation split or external validation is reported", "Fairness or subgroup performance is reported", "Participant characteristics are reported")), combine_evidence(pick("Validation split or external validation is reported", "Fairness or subgroup performance is reported", "Participant characteristics are reported")), "Check subgroup performance, external validation, leakage, and whether affected groups are represented.", ] ) return [[row[0], iconize(row[1]), *row[2:]] for row in rows] def style_plot(fig, ax) -> None: fig.patch.set_facecolor(CARD) ax.set_facecolor(CARD) for spine in ax.spines.values(): spine.set_visible(False) ax.tick_params(colors=INK, length=0, labelsize=9.5) def score_plot(scores: dict[str, float], thresholds: tuple[float, float] = DEFAULT_RISK_THRESHOLDS): fig, ax = plt.subplots(figsize=(8.5, max(3.2, 0.42 * len(scores)))) style_plot(fig, ax) if not scores: ax.text(0.5, 0.5, "No scores available", ha="center", va="center", color=INK_SOFT) ax.axis("off") return fig moderate_at, lower_at = thresholds ordered = sorted(scores.items(), key=lambda item: item[1]) labels = [item[0] for item in ordered] values = [item[1] for item in ordered] colors = [RISK if value < moderate_at else WARN if value < lower_at else BRAND for value in values] ax.barh(labels, values, color=colors, height=0.62, zorder=3) ax.barh(labels, [100] * len(values), color=BORDER, height=0.62, zorder=1, alpha=0.45) ax.set_xlim(0, 100) ax.set_xlabel("Reported safeguard score", color=INK_SOFT, fontsize=9.5) ax.grid(axis="x", color=BORDER, linewidth=0.8, zorder=0) for index, value in enumerate(values): inside = value > 88 ax.text( value - 2 if inside else value + 1.5, index, f"{value:.0f}%", va="center", ha="right" if inside else "left", fontsize=9, fontweight="bold", color="#ffffff" if inside else INK, zorder=4, ) ax.set_title("Reporting safeguards by category", loc="left", color=INK, fontsize=11, fontweight="bold", pad=12) fig.tight_layout() return fig def user_standards_markdown( report: dict[str, Any], categories: list[str] | None, required: list[str] | None, min_score: float, terms: str, ) -> str: categories = [cat for cat in (categories or []) if cat in STANDARD_CATEGORIES] required = list(required or []) term_list = [term.strip() for term in (terms or "").split(",") if term.strip()] scores = report.get("scores", {}) chosen = {cat: scores[cat] for cat in categories if cat in scores} if chosen: user_score = round(sum(chosen.values()) / len(chosen), 1) score_scope = ", ".join(chosen) else: user_score = report.get("overall", 0.0) score_scope = "all categories" status_by_label = {item.get("criterion"): item.get("status") for item in report.get("checklist", [])} failures: list[str] = [] notes: list[str] = [] if user_score < float(min_score): failures.append( f"Safeguard score is {user_score:.1f}, below your minimum of {float(min_score):.0f} (scored on: {score_scope})." ) for label in required: if label not in status_by_label: notes.append(f"“{html.escape(label)}” was not checked for this study type.") elif status_by_label[label] != "Reported": failures.append(f"Required safeguard not reported: {html.escape(label)}.") haystack = (report.get("analysis_text") or "").lower() for term in term_list: if term.lower() not in haystack: failures.append(f"Required term not found in the text: “{html.escape(term)}”.") if failures: items = "".join(f"
{APP_NAME} scores how completely a study reports the safeguards that make research trustworthy: randomization and blinding, ethics approval and consent, funding and conflict disclosures, data sharing, and limitations. It then judges the study against standards you set. Look up any published paper by topic, title, or DOI, or add your own study to see exactly which safeguards are missing before reviewers find them.