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: 21 additions & 1 deletion tests/test_consolidated_report.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
from __future__ import annotations

from zwill.consolidated_report import downloads_section_html, render_consolidated_report
from zwill.consolidated_report import (
downloads_section_html,
mark_intermediate_page_html,
render_consolidated_report,
)
from zwill.reporting import EP_REPORT_CSS


Expand Down Expand Up @@ -40,6 +44,22 @@ def test_composes_pages_into_one_document_with_toc() -> None:
assert 'href="audit/run.html"' in html and "Twin run audit" in html


def test_section_banner_is_added_outside_main_and_excluded_from_report() -> None:
page = _page("", "<section class='panel'>BODY-CONTENT</section>")
marked = mark_intermediate_page_html(page)
# Banner is inserted (after <body>, before <main>).
assert "This is one section of a larger report" in marked
assert marked.index("This is one section") < marked.index("<main")
# Idempotent.
assert mark_intermediate_page_html(marked) == marked
# When composed into the report, the banner is not pulled in (only <main> is).
html = render_consolidated_report(
survey="demo", sections=[("s", "Section", marked)], downloads_section=""
)
assert "BODY-CONTENT" in html
assert "This is one section of a larger report" not in html


def test_empty_sections_and_downloads_are_skipped() -> None:
html = render_consolidated_report(
survey="demo",
Expand Down
32 changes: 32 additions & 0 deletions tests/test_report_conditional_baseline.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,38 @@ def test_executive_summary_features_conditional_baseline(tmp_path) -> None:
assert comp["share_twin_better"] == 1.0


def test_stale_narrative_banner_when_model_set_changed(tmp_path) -> None:
twin = [_row("openai:gpt-5.5", "r1", "A", 0.20, 1), _row("openai:gpt-5.5", "r2", "B", 0.25, 1)]
build_executive_summary(
twin,
survey="demo",
path=tmp_path / "exec.html",
markdown_path=None,
simulations=25,
seed=1,
generated_markdown="The GPT-5.5 twin captures individual signal.",
generation={"report_id": "r", "stale": True, "stale_reason": "written for openai:gpt-5.5; current: anthropic:claude-opus-4-8, openai:gpt-5.5"},
)
html = (tmp_path / "exec.html").read_text()
assert "This written summary is out of date" in html
assert "anthropic:claude-opus-4-8" in html


def test_no_stale_banner_for_fresh_narrative(tmp_path) -> None:
twin = [_row("openai:gpt-5.5", "r1", "A", 0.20, 1), _row("openai:gpt-5.5", "r2", "B", 0.25, 1)]
build_executive_summary(
twin,
survey="demo",
path=tmp_path / "exec.html",
markdown_path=None,
simulations=25,
seed=1,
generated_markdown="Fresh narrative.",
generation={"report_id": "r"},
)
assert "This written summary is out of date" not in (tmp_path / "exec.html").read_text()


def test_executive_summary_unchanged_without_baseline(tmp_path) -> None:
twin = [_row("openai:gpt-5.5", "r1", "A", 0.20, 1), _row("openai:gpt-5.5", "r2", "B", 0.25, 1)]
result = build_executive_summary(
Comment on lines 94 to 131

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Detection logic is not unit-tested, only the rendering side is

Both new tests call build_executive_summary with a hand-crafted generation={"stale": True, …} dict. They confirm the HTML banner appears when told to, but the code in build_report_bundle that computes whether staleness should be set (comparing narrative_model_labels vs the current model set) has no direct test. If, for example, the key name mismatched — narrative_model_labels vs model_labels — or the None-guard accidentally fired on an empty list, the detection would silently skip marking and neither test would catch it.

Expand Down
25 changes: 25 additions & 0 deletions zwill/consolidated_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,31 @@

_STYLE_RE = re.compile(r"<style>(.*?)</style>", re.S)
_MAIN_RE = re.compile(r"<main[^>]*>(.*)</main>", re.S)
_BODY_RE = re.compile(r"(<body[^>]*>)", re.I)

_SECTION_BANNER_MARK = "This is one section of a larger report"
_SECTION_BANNER = (
"<div style=\"max-width:1040px;margin:12px auto;padding:10px 14px;border:1px solid #f0c36d;"
"background:#fef7e6;border-radius:8px;font:14px/1.45 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;"
"color:#664d03\"><strong>" + _SECTION_BANNER_MARK + ".</strong> "
"Open the <a href=\"{index_href}\">full report</a> for all sections, the table of contents, "
"and the executive summary.</div>"
)


def mark_intermediate_page_html(html: str, index_href: str = "index.html") -> str:
"""Insert a 'this is a section' banner after ``<body>``.

Placed outside ``<main>`` so :func:`render_consolidated_report` (which lifts
only the ``<main>`` body) never pulls it into the combined report. Idempotent.
"""
if _SECTION_BANNER_MARK in html:
return html
match = _BODY_RE.search(html)
if not match:
return html
banner = _SECTION_BANNER.format(index_href=escape(index_href))
return html[: match.end()] + "\n" + banner + html[match.end() :]

_CONSOLIDATED_CSS = """
Comment on lines +44 to 46

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Missing blank line between mark_intermediate_page_html and the module-level _CONSOLIDATED_CSS string — PEP 8 recommends two blank lines surrounding top-level definitions.

Suggested change
return html[: match.end()] + "\n" + banner + html[match.end() :]
_CONSOLIDATED_CSS = """
return html[: match.end()] + "\n" + banner + html[match.end() :]
_CONSOLIDATED_CSS = """

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

.report-shell { max-width: 1180px; margin: 0 auto; padding: 0 16px 4rem; }
Expand Down
10 changes: 10 additions & 0 deletions zwill/executive_summary.py
Original file line number Diff line number Diff line change
Expand Up @@ -435,14 +435,24 @@ def render_html(
for row in individual.get("per_question", [])
)
generation_note = ""
stale_banner = ""
if generation:
generation_note = (
f"<p class=\"subtle\">Generated analysis: <code>{escape(str(generation.get('report_id') or ''))}</code>"
f"{' · model: <code>' + escape(str(generation.get('model'))) + '</code>' if generation.get('model') else ''}</p>"
)
if generation.get("stale"):
reason = escape(str(generation.get("stale_reason") or ""))
stale_banner = (
"<div class=\"panel span-12\" style=\"border-left:4px solid #b42318;background:#fef3f2\">"
"<strong>⚠ This written summary is out of date.</strong> It was generated for a different set of "
f"models than the tables below now show{f' ({reason})' if reason else ''}. Regenerate it with "
"<code>zwill twin-results executive-summary-export</code> → run → import, then rebuild the report.</div>"
)
if generated_markdown:
generated_body = markdown_to_html(remove_leading_executive_summary_heading(generated_markdown))
executive_body = (
f"{stale_banner}"
"<div class=\"panel span-12 callout\">"
"<h2>Executive Summary</h2>"
f"{generated_body}"
Expand Down
10 changes: 10 additions & 0 deletions zwill/generated_reports.py
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,16 @@ def build_executive_summary_report_context(
"job_ids": job_ids,
"model": getattr(args, "prediction_model", None),
"questions": heldout_names,
# Fingerprint of the twin model set this narrative describes, so a
# rebuild can detect when the prose predates an added/removed model.
# Baseline rows are excluded so the fingerprint compares twin-to-twin.
"model_labels": sorted(
{
str(row.get("model_label"))
for row in rows
if row.get("model_label") and not str(row.get("model_label")).startswith("baseline:")
}
),
},
"heldout_questions": [
{
Expand Down
45 changes: 41 additions & 4 deletions zwill/report_bundle.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
from __future__ import annotations

from .cli import * # noqa: F403
from .consolidated_report import downloads_section_html, render_consolidated_report
from .consolidated_report import (
downloads_section_html,
mark_intermediate_page_html,
render_consolidated_report,
)
from .twin_baseline import MODEL_LABEL as BASELINE_MODEL_LABEL

# Bundle pages that fold into the single scrollable report as sections, in order.
Expand Down Expand Up @@ -1046,6 +1050,24 @@ def add_page(page: dict[str, Any]) -> None:
model=getattr(args, "model", None),
questions=heldout_questions_for_report,
)
# Flag a cached narrative that predates the current model set: the prose
# is matched on jobs+questions but not on the models inside them, so an
# added/removed model can leave stale prose over fresh tables.
if generated_executive:
current_models = sorted(
{
str(row.get("model_label"))
for row in twin_rows
if row.get("model_label") and not str(row.get("model_label")).startswith("baseline:")
}
)
narrative_models = generated_executive["generation"].get("narrative_model_labels")
if narrative_models is not None and sorted(narrative_models) != current_models:
generated_executive["generation"]["stale"] = True
generated_executive["generation"]["stale_reason"] = (
f"written for {', '.join(narrative_models) or 'no models'}; "
f"current: {', '.join(current_models) or 'no models'}"
)
executive_result = build_executive_summary(
twin_rows,
survey=survey,
Expand Down Expand Up @@ -1255,12 +1277,24 @@ def add_page(page: dict[str, Any]) -> None:
# it replaces the old digest index. When there is no ready twin validation
# yet, fall back to the readiness/next-steps index so the bundle is still
# navigable.
consolidated_report = build_consolidated_report_html(
output_dir, pages, survey, (manifest.get("executive_summary") or {}).get("path")
)
executive_html_path = (manifest.get("executive_summary") or {}).get("path")
consolidated_report = build_consolidated_report_html(output_dir, pages, survey, executive_html_path)
if consolidated_report:
(output_dir / "report.html").write_text(consolidated_report)
(output_dir / "index.html").write_text(consolidated_report)
# Mark the folded-in pages as sections so they are not mistaken for the
# report when opened on their own (banner sits outside <main>, so it is
# never pulled into the consolidated report above).
mark_paths = [executive_html_path] if executive_html_path else []
by_id = {page.get("page_id"): page for page in pages}
for page_id in ("twin-validation", "survey-profile", "one-shot-marginals"):
page = by_id.get(page_id)
if page and page.get("path"):
mark_paths.append(page["path"])
for mark_path in mark_paths:
page_file = Path(mark_path)
if page_file.exists():
page_file.write_text(mark_intermediate_page_html(page_file.read_text()))
else:
(output_dir / "index.html").write_text(render_report_bundle_index(manifest))
write_bundle_json(manifest_path, manifest)
Expand Down Expand Up @@ -1358,6 +1392,9 @@ def find_imported_executive_summary_report(
"markdown_path": str(markdown_path),
"import_path": str(import_path),
"imported_at": imported.get("imported_at"),
# The model set this narrative was written against (None for narratives
# generated before fingerprinting existed).
"narrative_model_labels": filters.get("model_labels"),
}
candidates.append(
{
Expand Down
Loading