Skip to content
Merged
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
22 changes: 16 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,23 @@ There are two ways to run it:

## Run with Nextflow

Text, markdown, and JSON reports are all supported. By default, the workflow will split
input into sections, interpret each separately, and then combine the results. Json, will
be split by keys, and markdown by markdown headers. MultiQC report (llms-full.txt) will
be split by MultiQC sections. Avoid chunking with the `--whole-report` flag.

```bash
nextflow run main.nf --input data/multiqc_data.json
nextflow run main.nf --input data[.txt|.md|.json] --whole-report
Comment thread
dimalvovs marked this conversation as resolved.
Resolved
```

To steer the model with your own instruction, add `--prompt`:
A system prompt is included by default. To steer the model with your own additional
instruction, add a `--user`, and to always use your own system prompt (from a file),
add `--system`:
Comment thread
dimalvovs marked this conversation as resolved.
Resolved

```bash
nextflow run main.nf --input data/multiqc_data.json \
--prompt "Summarize immune infiltration and flag any tumor-immune interactions."
nextflow run main.nf --input multiqc_data.json \
--user "Summarize immune infiltration and flag any tumor-immune interactions."
--system "path/to/your/system/prompt.txt"
```

By default this uses the `docker` profile (see `nextflow.config`), which pulls
Expand Down Expand Up @@ -108,7 +116,7 @@ Common flags (see `python3 llmize.py --help` for the full list):

```bash
python3 llmize.py --input data.json \
--prompt "Summarize immune infiltration and flag any tumor-immune interactions." \
--user "Summarize immune infiltration and flag any tumor-immune interactions." \
--review --output my_interpretation.md
```

Expand Down Expand Up @@ -176,7 +184,9 @@ are set explicitly, e.g. `--think false` or `--review true`.
| `--descriptor` | bundled schema | Descriptor schema JSON (JSON mode only), keyed by top-level section name; overlaid onto matching sections to tell the model what each field means. Copy `ingest/descriptor_schema.json` and edit it to describe your own report's sections, then pass the copy here. Sections with no matching entry pass through unannotated. |
| `--model` | `gemma4` | Ollama model name. |
| `--output` | (none) | Path to the output file; if not specified, defaults to `<input_stem>_interpretation_<timestamp>.md` in the current directory. |
| `--prompt` | (none) | Extra instruction appended to the model prompt. |
| `--user` | (none) | Extra instruction appended to the system prompt for every section. |
| `--system` | bundled default | Path to a file that fully replaces the default system prompt for the analysis stage. |
Comment thread
dimalvovs marked this conversation as resolved.
Resolved
| `--gpus` | `1` | Number of GPUs to allocate per job. |
| `--num_ctx` | `32768` | Context window size. |
| `--temperature` | model default | Sampling temperature (`0` = deterministic). |
| `--top_p` | model default | Nucleus-sampling threshold. |
Expand Down
156 changes: 36 additions & 120 deletions interpret.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@

from verify import deterministic_findings, extract_entities
from ingest import (
read_text,
resolve_path,
split_text_sections,
looks_like_multiqc_llms_full,
split_multiqc_llms_full,
Expand All @@ -20,65 +22,20 @@

SAMPLESHEET_KEY = "multiqc_samplesheet"

STYLE_GUIDE = """
Style and register (apply throughout):
- Write in the neutral, descriptive register of a peer-reviewed research paper.
Report what the data shows; do not editorialize, rate, or pass judgement on it.
- Remove judgemental and evaluative language. Do NOT use words such as: striking,
remarkable, overwhelming, dominant(ly), extreme, dramatic, impressive, crucial,
notable(ly), interesting, surprising, concerning, alarming, poor, excellent, good,
bad, or superlatives; and do not use exclamations.
- Lead with the data and tie every statement to specific values. Use hedged, precise
verbs for any inference: "suggests", "is consistent with", "indicates", "may",
"potentially". Prefer "associated with" over causal claims ("causes", "drives").
- Report negative or null results plainly (e.g. "no differential signal was detected").
- Present per-sample numbers in compact markdown tables rather than long inline lists.
- When presenting summaries, check that all relevant samples are included.
- Use plain formatting only: markdown headers, bold, and tables. Do NOT use LaTeX math
($...$, \\text{}, \\mathbf{}) or emoji.
- Be concise: do not restate the section name or descriptor, and do not repeat the same
adjective across sentences.
- If a label or abbreviation's meaning is not provided, and it is not a commonly known
acronym, use it verbatim; do not infer or expand what it stands for.
"""

EVIDENCE_RULES = """
Evidence and interpretation rules:
- Tie every claim to numeric evidence. Do not infer causality; describe associations only.
- State a between-group difference (for any sample-metadata grouping — e.g. response,
timepoint, region) ONLY if it is consistent across at least 2 samples per group, OR both
the mean and the median support the same direction. Otherwise state plainly:
"No consistent group-level difference detected."
- When you say a group is higher or lower, check that the direction matches the numbers
(e.g. do not call the smaller mean "higher").
- Do not generalize a pattern driven by a single sample. If one sample drives a group's
mean (an outlier), say so explicitly and treat that group-level claim as weak.
- Assess within-group variability as low / moderate / high. If variability is high or the
trend is inconsistent, prefer a conservative interpretation or report no clear difference.
- Briefly note relevant limitations where they affect a conclusion (e.g. ~3 samples per
group, high within-group variability, outlier influence) instead of implying certainty.
"""

SYSTEM_PROMPT = """You are an expert bioinformatician. You are given a report generated
by a bioinformatics workflow. The report includes outputs from a number of bioinformatics
tools and include quality control metrics, as well as actual aggregated analysis results.
The report includes the samplesheet table that may contain important clinical metadata.
Different report sections may have different structures and different biological meaning,
and contains a short descriptor of the section's structure and meaning.Your task is to
analyse the data and give a concise summary.
""" + STYLE_GUIDE + EVIDENCE_RULES

TEXT_SYSTEM_PROMPT = """You are an expert bioinformatician. You are given a plain-text
report produced by a bioinformatics tool or workflow (for example, a MultiQC
"llms-full.txt" export). It has no fixed schema, so infer the meaning of each
section from its heading and content. Your task is to analyse it and give a
concise summary.
""" + STYLE_GUIDE + EVIDENCE_RULES

TEXT_AS_IS_SYSTEM_PROMPT = """You are an expert bioinformatician. You are given a
plain-text report produced by a bioinformatics tool or workflow. The text already
contains its own analysis instructions and formatting guidance — follow those
instructions exactly as written instead of any separate style guide."""
_PROMPTS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "prompts")


def _load_prompt(filename: str) -> str:
"""Load a default system prompt from prompts/, so it can be read/edited outside Python."""
return read_text(os.path.join(_PROMPTS_DIR, filename)).strip()


def load_system_prompt_override(path: str) -> str:
"""Load a user-supplied file that fully replaces the default analysis system prompt."""
return read_text(resolve_path(path)).strip()


SYSTEM_PROMPT = _load_prompt("system_prompt.md")


def build_prompt(report: dict) -> str:
Expand Down Expand Up @@ -318,13 +275,12 @@ def _round_floats(obj, sig: int = 4):


def build_section_prompt(section_name: str, section_obj: dict, groups: dict = None) -> str:
"""Build the prompt analysing one MultiQC section."""
"""Build the prompt analysing one report section."""
descriptor, data = _split_descriptor(section_obj)
analysis_instructions = descriptor.pop("analysis_instructions", None)

prompt = (
f"Analyze the MultiQC section `{section_name}` in isolation.\n\n"
"Section descriptor (defines what each field/key/value means):\n"
f"Analyze the report section `{section_name}` in isolation.\n\n"
f"```json\n{json.dumps(descriptor, indent=2)}\n```\n\n"
"Section data:\n"
f"```json\n{json.dumps(_round_floats(data), indent=2)}\n```\n\n"
Expand Down Expand Up @@ -370,54 +326,7 @@ def iter_analysis_sections(report: dict):
yield name, section_obj


SYNTHESIS_SYSTEM_PROMPT = """You are an expert bioinformatician. You are given
the per-section analyses of a bioinformatics report.

Synthesize them into one concise executive summary of a few short paragraphs.
Cover:
- the overall picture and whether the pipeline appears to have run successfully,
- any notable patterns or outliers in the data and other data quality issues,
- the most important quantitative findings, citing key numbers and percentages,
- findings across the report sections that are consistent with each other, or
contradict each other,
- in case contrast analysis is present, provide a clear verdict on whether the
data supports difference across metadata groups,
- in case no metadata present, summary should focus on overall similarity patterns

Only surface findings that are well supported: consistent across at least 2
samples per group, or supported by both the mean and the median. Exclude weak,
single-sample-driven, or high-variability findings, or mention them only as not
robust. Do not combine several weak signals into a strong conclusion. Where the
data does not support a group difference, say so plainly.

Be decisive and readable. Do not repeat each section verbatim or list sections
one by one. Output only the summary prose — do NOT add your own title or markdown
heading.
""" + STYLE_GUIDE + EVIDENCE_RULES

TEXT_SYNTHESIS_SYSTEM_PROMPT = """You are an expert bioinformatician. You are given
the per-section analyses of a plain-text report produced by a bioinformatics
tool or workflow.

Synthesize them into one concise executive summary of a few short paragraphs.
Cover:
- the overall picture and whether the report appears internally consistent,
- any notable patterns, outliers, or data-quality issues surfaced across sections,
- the most important quantitative findings, citing key numbers and percentages,
- findings across sections that are consistent with each other, or contradict each other,
- any well-supported group differences only when the report itself provides the needed
grouping context; otherwise focus on overall patterns and limitations.

Do not repeat each section verbatim or list sections one by one. Output only the
summary prose — do NOT add your own title or markdown heading.
""" + STYLE_GUIDE + EVIDENCE_RULES

TEXT_AS_IS_SYNTHESIS_SYSTEM_PROMPT = """You are an expert bioinformatician. You are
given the per-section analyses of a plain-text report produced by a bioinformatics
tool or workflow. The report's text already carries its own analysis instructions
and formatting guidance, so preserve that guidance while writing one concise
executive summary across the sections. Output only the summary prose — do NOT add
your own title or markdown heading."""
SYNTHESIS_SYSTEM_PROMPT = _load_prompt("synthesis_system_prompt.md")


def synthesize_sections(
Expand Down Expand Up @@ -466,7 +375,7 @@ def combine_responses(responses: list, summary: str = None, title: str = None) -
return "\n".join(parts)


REVIEW_SYSTEM_PROMPT = """You are an expert bioinformatician reviewing a bioinformatics report interpretation for factual grounding and internal consistency. Correct statements that are not supported by the underlying report data, remove claims about genes, proteins, or cell types that do not appear in the data, and resolve contradictions between sections. Preserve the document's headings, tables, and structure. Do not add new findings and do not soften the removal of unsupported claims. Output only the corrected document, with no preamble.""" + STYLE_GUIDE + EVIDENCE_RULES
REVIEW_SYSTEM_PROMPT = _load_prompt("review_system_prompt.md")


def build_review_prompt(text: str, findings: list) -> str:
Expand Down Expand Up @@ -595,10 +504,12 @@ def interpret_report(
think: bool = True,
gen_options: dict = None,
user_instruction: str = "",
system_prompt_file: str = None,
) -> str:
"""Interpret the report as one or more chunks; a single chunk is the whole report."""
samplesheet_context = build_samplesheet_context(report)
system = (SYSTEM_PROMPT + samplesheet_context
base_prompt = load_system_prompt_override(system_prompt_file) if system_prompt_file else SYSTEM_PROMPT
system = (base_prompt + samplesheet_context
+ build_user_instruction(user_instruction))
groups = sample_groups(report)
chunks = build_chunks(report, whole_report, groups)
Expand All @@ -618,16 +529,20 @@ def interpret_text_report(
gen_options: dict = None,
user_instruction: str = "",
as_is: bool = False,
system_prompt_file: str = None,
) -> str:
"""Interpret a plain-text report (e.g. MultiQC llms-full.txt), bypassing the JSON pipeline.

`as_is` drops the style guide/evidence rules, assuming the text already carries its
own analysis instructions; it does not affect whole-report vs. heading-split chunking.
`as_is` assumes the text already carries its own analysis instructions, so no default
system prompt is used for the analysis stage (MultiQC's own leading instructions are
used instead when present); it does not affect whole-report vs. heading-split chunking.
`system_prompt_file`, when set, takes precedence over both the default and `as_is`.
The synthesis stage always uses SYNTHESIS_SYSTEM_PROMPT, `as_is` or not.
"""
base_prompt = TEXT_AS_IS_SYSTEM_PROMPT if as_is else TEXT_SYSTEM_PROMPT
synthesis_prompt = (
TEXT_AS_IS_SYNTHESIS_SYSTEM_PROMPT if as_is else TEXT_SYNTHESIS_SYSTEM_PROMPT
)
if system_prompt_file:
base_prompt = load_system_prompt_override(system_prompt_file)
else:
base_prompt = "" if as_is else SYSTEM_PROMPT
Comment thread
dimalvovs marked this conversation as resolved.
Resolved
samplesheet_context = ""
if whole_report:
chunks = [("Whole report", build_prompt_text(text))]
Expand All @@ -636,7 +551,7 @@ def interpret_text_report(
# `Tool:`/`Section:`/`Title:` block layout instead of falling back to one blob.
if looks_like_multiqc_llms_full(text):
sections, leading_instructions = split_multiqc_llms_full(text)
if as_is and leading_instructions:
if as_is and leading_instructions and not system_prompt_file:
base_prompt = leading_instructions
else:
sections = split_text_sections(text)
Expand All @@ -659,7 +574,6 @@ def interpret_text_report(
return _run_chunks(
chunks, model, system, num_ctx, synthesize_final, think, gen_options,
title="Report Interpretation",
synthesis_system_prompt=synthesis_prompt,
samplesheet_context=samplesheet_context,
)

Expand Down Expand Up @@ -876,6 +790,7 @@ def build_run_footer(
mode: str = "section-by-section",
user_instruction: str = "",
input_path: str = None,
system_prompt_file: str = None,
) -> str:
"""Markdown footer recording run parameters so each output is reproducible."""
gen = gen_options or {}
Expand All @@ -899,6 +814,7 @@ def g(key):
f"top_p={g('top_p')}, top_k={g('top_k')}, num_predict={g('num_predict')}"
)
lines.append(f"- num_ctx: {num_ctx}")
lines.append(f"- system prompt: {'custom (' + system_prompt_file + ')' if system_prompt_file else 'default'}")
if user_instruction:
lines.append(f"- custom prompt: {user_instruction!r}")
sha = _git_short_sha()
Expand Down
22 changes: 19 additions & 3 deletions llmize.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,10 +116,18 @@ def parse_args() -> argparse.Namespace:
"(not saved) and kept out of the interpretation. On by default; --no-think disables it (faster).",
)
parser.add_argument(
"--prompt",
"--user",
dest="user_instruction",
default=None,
help="Extra instruction appended to the system prompt for every section.",
)
parser.add_argument(
"--system",
dest="system_prompt_file",
default=None,
help="Path to a file that fully replaces the default system prompt for the "
"analysis stage (style guide and evidence rules included).",
)
parser.add_argument("--temperature", type=float, default=None,
help="Sampling temperature (model default if unset; gemma4 defaults to 1).")
parser.add_argument("--top_p", type=float, default=None, help="Nucleus sampling top_p.")
Expand Down Expand Up @@ -175,6 +183,7 @@ def run_pipeline(
review: bool = False,
review_passes: int = 2,
save_intermediates: bool = False,
system_prompt_file: str | None = None,
) -> str:
input_path = resolve_input_path(input_json)
print(f"[pipeline] Loading raw JSON: {input_path}")
Expand Down Expand Up @@ -204,6 +213,7 @@ def run_pipeline(
think=think,
gen_options=gen_options,
user_instruction=user_instruction,
system_prompt_file=system_prompt_file,
)

if review:
Expand All @@ -222,6 +232,7 @@ def run_pipeline(
model=model, num_ctx=num_ctx, think=think, gen_options=gen_options,
mode="whole-report" if whole_report else "section-by-section",
user_instruction=user_instruction, input_path=input_path,
system_prompt_file=system_prompt_file,
)
save_text(response + footer, output_path)
return output_path
Expand All @@ -238,6 +249,7 @@ def run_text_pipeline(
gen_options: dict = None,
user_instruction: str = "",
as_is: bool = False,
system_prompt_file: str | None = None,
) -> str:
input_path = resolve_input_path(input_txt)
print(f"[pipeline] Loading raw text: {input_path}")
Expand All @@ -256,6 +268,7 @@ def run_text_pipeline(
gen_options=gen_options,
user_instruction=user_instruction,
as_is=as_is,
system_prompt_file=system_prompt_file,
)

if output_path is None:
Expand All @@ -269,6 +282,7 @@ def run_text_pipeline(
footer = build_run_footer(
model=model, num_ctx=num_ctx, think=think, gen_options=gen_options,
mode=footer_mode, user_instruction=user_instruction, input_path=input_path,
system_prompt_file=system_prompt_file,
)
save_text(response + footer, output_path)
return output_path
Expand Down Expand Up @@ -299,10 +313,11 @@ def main() -> None:
synthesize_final=not args.no_synthesis,
think=args.think,
gen_options=gen_options,
user_instruction=args.prompt,
user_instruction=args.user_instruction,
review=args.review,
review_passes=args.review_passes,
save_intermediates=args.save_intermediates,
system_prompt_file=args.system_prompt_file,
)
else:
if args.review:
Expand All @@ -319,8 +334,9 @@ def main() -> None:
synthesize_final=not args.no_synthesis,
think=args.think,
gen_options=gen_options,
user_instruction=args.prompt,
user_instruction=args.user_instruction,
as_is=args.as_is,
system_prompt_file=args.system_prompt_file,
)
print(f"[pipeline] Completed. Final report: {final_path}")

Expand Down
Loading
Loading