Flamehaven Initiative commited on
Commit
e9858b7
·
1 Parent(s): e3afeec

docs: sync hf packet surfaces to 5p and 7p

Browse files
CHANGELOG.md CHANGED
@@ -17,6 +17,9 @@ Rubric refinements and additions use patch versions (1.0.x).
17
  - Tightened bio-detector scan boundaries by excluding `.manual_verify` from generated/non-runtime paths and replacing the broad trace-manifest `rglob("*")` walk with a pruned filesystem traversal that preserves existing detector semantics while lowering trace-scan overhead.
18
  - Deferred the RDKit optional lane until at least one SMILES-like candidate is present, and cached the optional `rdkit.Chem` import so availability/import work is not repeated unnecessarily across candidate checks.
19
  - Added a file-signature AST context cache for `detector_bio.py` so repeated scans of unchanged Python files in the same process avoid redundant `ast.parse/compile` work while keeping detector semantics unchanged.
 
 
 
20
 
21
  ---
22
 
 
17
  - Tightened bio-detector scan boundaries by excluding `.manual_verify` from generated/non-runtime paths and replacing the broad trace-manifest `rglob("*")` walk with a pruned filesystem traversal that preserves existing detector semantics while lowering trace-scan overhead.
18
  - Deferred the RDKit optional lane until at least one SMILES-like candidate is present, and cached the optional `rdkit.Chem` import so availability/import work is not repeated unnecessarily across candidate checks.
19
  - Added a file-signature AST context cache for `detector_bio.py` so repeated scans of unchanged Python files in the same process avoid redundant `ast.parse/compile` work while keeping detector semantics unchanged.
20
+ - Changed the CLI default report depth from `--level 1` to `--level 3`, so the plain `stem <repo>` path now emits the full packet by default unless the user selects a shallower level.
21
+ - Reworked ReportLab PDF pagination so the brief, standard, and full packet tiers now honor their intended page counts, keeping the executive/regulatory summary on page 1 instead of allowing silent overflow into extra pages.
22
+ - Redefined detailed packet tiers so `--level 2` now emits a standard 5-page review packet and `--level 3` emits the full 7-page packet, restoring Stage 4 as a dedicated PDF page and preventing end-of-report overcrowding in the former 5-page Level 3 path.
23
 
24
  ---
25
 
README.md CHANGED
@@ -57,7 +57,7 @@ pip install -e .[pdf]
57
  # fastest path: scan a local repository
58
  stem /path/to/bio-ai-repo
59
 
60
- # 5-page full evidence packet with proof trace
61
  stem scan /path/to/bio-ai-repo --level 3 --format all --explain
62
  ```
63
 
@@ -175,20 +175,21 @@ Core modules: `stem_ai/scanner.py`, `stem_ai/render.py`, `stem_ai/cli.py`, `stem
175
  ## Output Artifacts
176
 
177
  Each run writes to `--out DIR` (default: `stem_output/`).
 
178
 
179
  | Level | Pages | Audience | Artifacts |
180
  |-------|------:|---------|-----------|
181
- | `--level 1` | 1 | Executive / triage | Score, tier, stage cards, code integrity summary |
182
- | `--level 2` | 3 | Standard audit review | Level 1 + Stage 1/2R/3 breakdown, gap analysis |
183
- | `--level 3` | 5 | Full evidence packet | Level 2 + code integrity deep dive, classification analysis, remediation roadmap |
184
 
185
  ```
186
  <repo>_experiment_results.json # machine-readable score + full evidence object
187
  <repo>_report.html # interactive 5-section HTML dashboard (v1.7.0+)
188
  <repo>_report.md # human-readable audit report
189
  <repo>_brief_1p.pdf # Level 1 executive dashboard
190
- <repo>_detailed_3p.pdf # Level 2 stage analysis
191
- <repo>_detailed_5p.pdf # Level 3 deep review packet
192
  <repo>_explain.txt # --explain: file/line/snippet proof trace
193
  ```
194
 
@@ -199,12 +200,12 @@ Each run writes to `--out DIR` (default: `stem_output/`).
199
  `--format html` generates a self-contained interactive dashboard (v1.7.0+). Single `.html` file — no network, no external dependencies.
200
 
201
  <p align="center">
202
- <img src="docs/assets/html_report_preview.png" alt="STEM BIO-AI interactive HTML dashboard" width="760">
203
  </p>
204
 
205
  **Example interactive HTML audit**
206
  - Open in browser: <https://htmlpreview.github.io/?https://raw.githubusercontent.com/flamehaven01/STEM-BIO-AI/main/docs/examples/maziyarpanahi_openmed_report.html>
207
- - Raw HTML artifact: [`docs/examples/maziyarpanahi_openmed_report.html`](docs/examples/maziyarpanahi_openmed_report.html)
208
 
209
  **5 sections:** Executive Summary · Score Matrix · Code Integrity (expandable cards) · AIRI Risk Coverage (toggle) · Evidence Detail (filter chips)
210
 
@@ -219,25 +220,25 @@ Covered AIRI entries now also carry bounded `why mapped` reasoning derived from
219
  ## Report Preview
220
 
221
  <p align="center">
222
- <img src="docs/assets/report-preview/fieldbioinformatics/page-1.png" alt="STEM BIO-AI Level 3 report — page 1" width="760">
223
  </p>
224
 
225
- **Sample PDF:** [Download the 5-page Level 3 report](docs/assets/report-preview/fieldbioinformatics/artic-network_fieldbioinformatics_detailed_5p.pdf)
226
 
227
  <details>
228
- <summary>View all 5 preview pages</summary>
229
 
230
  | Page 1 | Page 2 |
231
  |--------|--------|
232
- | <img src="docs/assets/report-preview/fieldbioinformatics/page-1.png" alt="Page 1"> | <img src="docs/assets/report-preview/fieldbioinformatics/page-2.png" alt="Page 2"> |
233
 
234
  | Page 3 | Page 4 |
235
  |--------|--------|
236
- | <img src="docs/assets/report-preview/fieldbioinformatics/page-3.png" alt="Page 3"> | <img src="docs/assets/report-preview/fieldbioinformatics/page-4.png" alt="Page 4"> |
237
 
238
  | Page 5 |
239
  |--------|
240
- | <img src="docs/assets/report-preview/fieldbioinformatics/page-5.png" alt="Page 5"> |
241
 
242
  </details>
243
 
@@ -390,7 +391,7 @@ Operational reference: [`docs/MICA_MEMORY.md`](docs/MICA_MEMORY.md)
390
  Live demo: [huggingface.co/spaces/Flamehaven/stem-bio-ai](https://huggingface.co/spaces/Flamehaven/stem-bio-ai)
391
 
392
  <p align="center">
393
- <img src="docs/assets/HF-STEM-BIO_AI.png" alt="STEM BIO-AI Hugging Face Space" width="760">
394
  </p>
395
 
396
  The Space runs the same deterministic local scanner on public GitHub repositories. No provider API call is made.
 
57
  # fastest path: scan a local repository
58
  stem /path/to/bio-ai-repo
59
 
60
+ # 7-page full evidence packet with proof trace
61
  stem scan /path/to/bio-ai-repo --level 3 --format all --explain
62
  ```
63
 
 
175
  ## Output Artifacts
176
 
177
  Each run writes to `--out DIR` (default: `stem_output/`).
178
+ The plain `stem <repo>` and `stem scan <repo>` path now defaults to `--level 3`, which emits the full 7-page evidence packet unless you select a lower level explicitly.
179
 
180
  | Level | Pages | Audience | Artifacts |
181
  |-------|------:|---------|-----------|
182
+ | `--level 1` | 1 | Executive / triage (legacy) | Score, tier, stage cards, code integrity summary |
183
+ | `--level 2` | 5 | Standard audit review | Level 1 + Stage 1/2R/3/4 breakdown, AIRI summary, closeout page |
184
+ | `--level 3` | 7 | Full evidence packet | Level 2 + Stage 4 replication page, code integrity deep dive, remediation roadmap, metadata page |
185
 
186
  ```
187
  <repo>_experiment_results.json # machine-readable score + full evidence object
188
  <repo>_report.html # interactive 5-section HTML dashboard (v1.7.0+)
189
  <repo>_report.md # human-readable audit report
190
  <repo>_brief_1p.pdf # Level 1 executive dashboard
191
+ <repo>_detailed_5p.pdf # Level 2 standard review packet
192
+ <repo>_detailed_7p.pdf # Level 3 full review packet
193
  <repo>_explain.txt # --explain: file/line/snippet proof trace
194
  ```
195
 
 
200
  `--format html` generates a self-contained interactive dashboard (v1.7.0+). Single `.html` file — no network, no external dependencies.
201
 
202
  <p align="center">
203
+ <img src="https://raw.githubusercontent.com/flamehaven01/STEM-BIO-AI/main/docs/assets/html_report_preview.png" alt="STEM BIO-AI interactive HTML dashboard" width="760">
204
  </p>
205
 
206
  **Example interactive HTML audit**
207
  - Open in browser: <https://htmlpreview.github.io/?https://raw.githubusercontent.com/flamehaven01/STEM-BIO-AI/main/docs/examples/maziyarpanahi_openmed_report.html>
208
+ - Raw HTML artifact: [`docs/examples/maziyarpanahi_openmed_report.html`](https://raw.githubusercontent.com/flamehaven01/STEM-BIO-AI/main/docs/examples/maziyarpanahi_openmed_report.html)
209
 
210
  **5 sections:** Executive Summary · Score Matrix · Code Integrity (expandable cards) · AIRI Risk Coverage (toggle) · Evidence Detail (filter chips)
211
 
 
220
  ## Report Preview
221
 
222
  <p align="center">
223
+ <img src="https://raw.githubusercontent.com/flamehaven01/STEM-BIO-AI/main/docs/assets/report-preview/fieldbioinformatics/page-1.png" alt="STEM BIO-AI standard 5-page packet — page 1" width="760">
224
  </p>
225
 
226
+ **Sample PDF:** [Download the 5-page standard packet preview](https://raw.githubusercontent.com/flamehaven01/STEM-BIO-AI/main/docs/assets/report-preview/fieldbioinformatics/artic-network_fieldbioinformatics_detailed_5p.pdf)
227
 
228
  <details>
229
+ <summary>View all 5 standard-packet preview pages</summary>
230
 
231
  | Page 1 | Page 2 |
232
  |--------|--------|
233
+ | <img src="https://raw.githubusercontent.com/flamehaven01/STEM-BIO-AI/main/docs/assets/report-preview/fieldbioinformatics/page-1.png" alt="Page 1"> | <img src="https://raw.githubusercontent.com/flamehaven01/STEM-BIO-AI/main/docs/assets/report-preview/fieldbioinformatics/page-2.png" alt="Page 2"> |
234
 
235
  | Page 3 | Page 4 |
236
  |--------|--------|
237
+ | <img src="https://raw.githubusercontent.com/flamehaven01/STEM-BIO-AI/main/docs/assets/report-preview/fieldbioinformatics/page-3.png" alt="Page 3"> | <img src="https://raw.githubusercontent.com/flamehaven01/STEM-BIO-AI/main/docs/assets/report-preview/fieldbioinformatics/page-4.png" alt="Page 4"> |
238
 
239
  | Page 5 |
240
  |--------|
241
+ | <img src="https://raw.githubusercontent.com/flamehaven01/STEM-BIO-AI/main/docs/assets/report-preview/fieldbioinformatics/page-5.png" alt="Page 5"> |
242
 
243
  </details>
244
 
 
391
  Live demo: [huggingface.co/spaces/Flamehaven/stem-bio-ai](https://huggingface.co/spaces/Flamehaven/stem-bio-ai)
392
 
393
  <p align="center">
394
+ <img src="https://raw.githubusercontent.com/flamehaven01/STEM-BIO-AI/main/docs/assets/HF-STEM-BIO_AI.png" alt="STEM BIO-AI Hugging Face Space" width="760">
395
  </p>
396
 
397
  The Space runs the same deterministic local scanner on public GitHub repositories. No provider API call is made.
docs/CLI_REFERENCE.md CHANGED
@@ -53,7 +53,7 @@ These options work across `scan`, `gate`, and `advisory` workflows unless otherw
53
 
54
  | Flag | Values | Default | Description |
55
  |------|--------|---------|-------------|
56
- | `--level` | `1`, `2`, `3` | `1` | Report depth: 1=brief 1p, 2=stage review 3p, 3=full packet 5p |
57
  | `--format` | `json`, `md`, `pdf`, `all` | command-specific | Artifact format to write |
58
  | `--out`, `--output` | `DIR` | `stem_output` | Output directory |
59
  | `--policy` | `NAME` | `default` | Named calibration profile to surface in the scan result |
@@ -243,9 +243,9 @@ Writes artifacts only. Equivalent to `--quiet`.
243
  |---------|---------|
244
  | `<repo>_experiment_results.json` | Machine-readable result object |
245
  | `<repo>_report.md` | Human-readable Markdown report |
246
- | `<repo>_brief_1p.pdf` | Level 1 brief report |
247
- | `<repo>_detailed_3p.pdf` | Level 2 stage review |
248
- | `<repo>_detailed_5p.pdf` | Level 3 full packet |
249
  | `<repo>_explain.txt` | Proof trace from `--explain` |
250
 
251
  ---
 
53
 
54
  | Flag | Values | Default | Description |
55
  |------|--------|---------|-------------|
56
+ | `--level` | `1`, `2`, `3` | `3` | Report depth: 1=brief 1p (legacy), 2=standard review 5p, 3=full packet 7p |
57
  | `--format` | `json`, `md`, `pdf`, `all` | command-specific | Artifact format to write |
58
  | `--out`, `--output` | `DIR` | `stem_output` | Output directory |
59
  | `--policy` | `NAME` | `default` | Named calibration profile to surface in the scan result |
 
243
  |---------|---------|
244
  | `<repo>_experiment_results.json` | Machine-readable result object |
245
  | `<repo>_report.md` | Human-readable Markdown report |
246
+ | `<repo>_brief_1p.pdf` | Level 1 brief report (legacy quick brief) |
247
+ | `<repo>_detailed_5p.pdf` | Level 2 standard review packet |
248
+ | `<repo>_detailed_7p.pdf` | Level 3 full packet |
249
  | `<repo>_explain.txt` | Proof trace from `--explain` |
250
 
251
  ---
docs/EXAMPLE_AUDITS.md CHANGED
@@ -16,7 +16,7 @@ This project is easier to evaluate through outputs than through claims. This pag
16
  - Report preview pages: [`docs/assets/report-preview/fieldbioinformatics/`](assets/report-preview/fieldbioinformatics)
17
  - Sample PDF: [`artic-network_fieldbioinformatics_detailed_5p.pdf`](assets/report-preview/fieldbioinformatics/artic-network_fieldbioinformatics_detailed_5p.pdf)
18
 
19
- Use this when you want to inspect the 5-page report shape, stage cards, rubric detail, and disclaimer boundary.
20
 
21
  ---
22
 
 
16
  - Report preview pages: [`docs/assets/report-preview/fieldbioinformatics/`](assets/report-preview/fieldbioinformatics)
17
  - Sample PDF: [`artic-network_fieldbioinformatics_detailed_5p.pdf`](assets/report-preview/fieldbioinformatics/artic-network_fieldbioinformatics_detailed_5p.pdf)
18
 
19
+ Use this when you want to inspect the standard 5-page report shape, stage cards, rubric detail, and disclaimer boundary.
20
 
21
  ---
22
 
stem_ai/app.py CHANGED
@@ -335,7 +335,7 @@ with gr.Blocks(title=f"STEM BIO-AI — Evidence Scanner v{__version__}", **_bloc
335
  <div style="{_CARD_STYLE}">
336
  <p style="{_CARD_TITLE}">Local CLI</p>
337
  <p style="{_CARD_BODY}">Run locally for downloadable artifacts:</p>
338
- <code style="{_CODE_PILL}">stem &lt;folder&gt; --level 3 --format all --explain</code>
339
  </div>
340
  </div>
341
  """
@@ -361,13 +361,13 @@ with gr.Blocks(title=f"STEM BIO-AI — Evidence Scanner v{__version__}", **_bloc
361
  with gr.Column(scale=1):
362
  level_input = gr.Radio(
363
  choices=[
364
- ("Brief - 1 page", 1),
365
- ("Standard - 3 pages", 2),
366
- ("Full - 5 pages", 3),
367
  ],
368
- value=1,
369
  label="Report Level",
370
- info="Brief, Standard, or Full report depth.",
371
  )
372
  with gr.Row():
373
  run_button = gr.Button("Run live GitHub audit", variant="primary", scale=2)
 
335
  <div style="{_CARD_STYLE}">
336
  <p style="{_CARD_TITLE}">Local CLI</p>
337
  <p style="{_CARD_BODY}">Run locally for downloadable artifacts:</p>
338
+ <code style="{_CODE_PILL}">stem &lt;folder&gt; --format all --explain</code>
339
  </div>
340
  </div>
341
  """
 
361
  with gr.Column(scale=1):
362
  level_input = gr.Radio(
363
  choices=[
364
+ ("Brief - 1 page (legacy)", 1),
365
+ ("Standard - 5 pages", 2),
366
+ ("Full - 7 pages", 3),
367
  ],
368
+ value=3,
369
  label="Report Level",
370
+ info="Legacy brief, standard review packet, or full evidence packet.",
371
  )
372
  with gr.Row():
373
  run_button = gr.Button("Run live GitHub audit", variant="primary", scale=2)
stem_ai/cli.py CHANGED
@@ -14,8 +14,8 @@ from .scanner import audit_repository
14
 
15
  _LEVEL_MAP = {
16
  1: ("brief", 1),
17
- 2: ("detailed", 3),
18
- 3: ("detailed", 5),
19
  }
20
  _FORMAT_CHOICES = ["json", "md", "html", "pdf", "all"]
21
  _SUMMARY_CHOICES = ["full", "compact", "off"]
@@ -203,12 +203,12 @@ def _add_shared_arguments(parser: argparse.ArgumentParser, *, default_format: st
203
  "--level",
204
  type=int,
205
  choices=[1, 2, 3],
206
- default=1,
207
  help=(
208
  "Report depth: "
209
- "1=brief 1-page executive summary (default), "
210
- "2=detailed 3-page stage review, "
211
- "3=full 5-page evidence packet"
212
  ),
213
  )
214
  parser.add_argument(
 
14
 
15
  _LEVEL_MAP = {
16
  1: ("brief", 1),
17
+ 2: ("detailed", 5),
18
+ 3: ("detailed", 7),
19
  }
20
  _FORMAT_CHOICES = ["json", "md", "html", "pdf", "all"]
21
  _SUMMARY_CHOICES = ["full", "compact", "off"]
 
203
  "--level",
204
  type=int,
205
  choices=[1, 2, 3],
206
+ default=3,
207
  help=(
208
  "Report depth: "
209
+ "1=brief 1-page executive summary, "
210
+ "2=standard 5-page review packet, "
211
+ "3=full 7-page evidence packet (default)"
212
  ),
213
  )
214
  parser.add_argument(
stem_ai/render.py CHANGED
@@ -17,6 +17,7 @@ try:
17
  from reportlab.lib.units import mm
18
  from reportlab.platypus import (
19
  HRFlowable,
 
20
  PageBreak,
21
  Paragraph,
22
  SimpleDocTemplate,
@@ -43,6 +44,8 @@ _DGRAY = "#4A5568"
43
  _WHITE = "#FFFFFF"
44
 
45
  _TIER_COLOR = {"T0": _RED, "T1": _RED, "T2": _ORANGE, "T3": _TEAL, "T4": _GREEN}
 
 
46
  _BIO_DETECTOR_LABELS = {
47
  "BIO_smiles_surface_integrity": "SMILES Surface Integrity",
48
  "BIO_smiles_rdkit_validation": "SMILES RDKit Validation",
@@ -732,7 +735,7 @@ def _page1_executive(result: dict[str, Any], mode: str, pages: int) -> list[Any]
732
  story.append(Spacer(1, 2 * mm))
733
  story += _regulatory_basis_box(result)
734
  story += _footer_block()
735
- return story
736
 
737
 
738
  def _header_block(result: dict[str, Any]) -> list[Any]:
@@ -1064,14 +1067,29 @@ def _footer_block() -> list[Any]:
1064
  ]
1065
 
1066
 
 
 
 
 
 
 
 
 
 
 
1067
  # ── Detail page dispatcher ────────────────────────────────────────────────────
1068
  def _detail_pages(result: dict[str, Any], pages: int) -> list[Any]:
1069
  story: list[Any] = []
1070
  story += _page2_stage_analysis(result)
1071
  story += _page3_stage3_analysis(result)
1072
  if pages >= 5:
 
 
 
 
1073
  story += _page4_integrity_deep(result)
1074
- story += _page5_method_remediation(result)
 
1075
  return story
1076
 
1077
 
@@ -1146,7 +1164,7 @@ def _rubric_rows(items: list[tuple[str, str, str, str]], id_prefix: str = "R") -
1146
 
1147
  # ── Page 2: Stage 1 + Stage 2R Analysis ──────────────────────────────────────
1148
  def _page2_stage_analysis(result: dict[str, Any]) -> list[Any]:
1149
- story: list[Any] = [PageBreak()]
1150
  score = result["score"]
1151
  cls = result["classification"]
1152
  s1 = score["stage_1_readme_intent"]
@@ -1311,12 +1329,12 @@ def _page2_stage_analysis(result: dict[str, Any]) -> list[Any]:
1311
  ))
1312
 
1313
  story += _footer_block()
1314
- return story
1315
 
1316
 
1317
  # ── Page 3: Stage 3 Full Breakdown ───────────────────────────────────────────
1318
  def _page3_stage3_analysis(result: dict[str, Any]) -> list[Any]:
1319
- story: list[Any] = [PageBreak()]
1320
  score = result["score"]
1321
  s3 = score["stage_3_code_bio"]
1322
  rubric = result.get("stage_3_rubric", {})
@@ -1437,12 +1455,75 @@ def _page3_stage3_analysis(result: dict[str, Any]) -> list[Any]:
1437
  ))
1438
 
1439
  story += _footer_block()
1440
- return story
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1441
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1442
 
1443
- # ── Page 4: Code Integrity Deep Dive + Classification (5p only) ───────────────
 
1444
  def _page4_integrity_deep(result: dict[str, Any]) -> list[Any]:
1445
- story: list[Any] = [PageBreak()]
1446
  ci = result["code_integrity"]
1447
  cls = result["classification"]
1448
  hashes = result.get("file_hashes_sha256", {})
@@ -1608,15 +1689,87 @@ def _page4_integrity_deep(result: dict[str, Any]) -> list[Any]:
1608
  story.append(hash_tbl)
1609
 
1610
  story += _footer_block()
1611
- return story
1612
 
1613
 
1614
- # ── Page 5: Priority Improvements + Method + Metadata (5p only) ───────────────
1615
- def _page5_method_remediation(result: dict[str, Any]) -> list[Any]:
1616
- story: list[Any] = [PageBreak()]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1617
  risks = result.get("notable_risks", [])
1618
  positive = result.get("notable_positive_evidence", [])
1619
- score = result["score"]
1620
  airi = result.get("airi_risk_coverage", {})
1621
 
1622
  # Priority Improvements
@@ -1733,8 +1886,14 @@ def _page5_method_remediation(result: dict[str, Any]) -> list[Any]:
1733
  _style("MBSCOPE", 8, 11, _DGRAY),
1734
  ))
1735
 
1736
- # Report Metadata
1737
- story.append(Spacer(1, 4 * mm))
 
 
 
 
 
 
1738
  story += _sec_hdr("Report Metadata", _NAVY)
1739
  tgt = result["target"]
1740
  meta_items = [
@@ -1772,7 +1931,7 @@ def _page5_method_remediation(result: dict[str, Any]) -> list[Any]:
1772
  ]))
1773
  story.append(meta_tbl)
1774
  story += _footer_block()
1775
- return story
1776
 
1777
 
1778
  # ── plain-text PDF fallback (no reportlab) ───────────────────────────────────
@@ -1830,31 +1989,73 @@ def render_pdf_pages(result: dict[str, Any], mode: str, pages: int) -> list[list
1830
  f"- {k}: {v['score']} / {v['max']} {v['evidence']}"
1831
  for k, v in result.get("stage_4_rubric", {}).items()
1832
  ]])
1833
- sets = [_fit_page(brief), p2, p3]
 
 
 
 
 
 
 
 
 
 
 
1834
  if pages == 5:
1835
- sets.append(_fit_page(["Code Integrity",
 
 
 
1836
  *[f"- {k}: {v['status']} {v['evidence'][0]}" for k, v in result["code_integrity"].items()],
1837
- *([f"- AST analysis scope: {ast_note}"] if ast_note else [])]))
1838
- airi_lines = [
1839
  "AIRI Coverage Summary",
1840
  f"- Covered Risks: {airi.get('covered_count', 0)} / {airi.get('total_risks_in_detector_scope', 0)}",
1841
  f"- Coverage Rate: {airi.get('coverage_rate', 0):.3f}",
1842
- f"- Bundle Scope: {airi.get('airi_bundle_scope', 'unknown')}",
1843
- ]
1844
- for risk in airi.get("covered_risks", [])[:3]:
1845
- reason = _airi_reason_summary(risk)
1846
- line = f"- {risk.get('id', '—')}: {risk.get('title', '')}"
1847
- if reason:
1848
- line += f" | why: {reason}"
1849
- airi_lines.append(line)
1850
- for gap in airi.get("known_gaps_in_bundle", [])[:2]:
1851
- airi_lines.append(f"- gap: {gap.get('id', '—')} {gap.get('title', '')}")
 
1852
  sets.append(_fit_page([
1853
- *airi_lines,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1854
  "",
1855
  "Method Boundary",
1856
  result["method"],
1857
  ]))
 
 
 
 
 
 
 
 
 
 
 
1858
  return sets[:pages]
1859
 
1860
 
 
17
  from reportlab.lib.units import mm
18
  from reportlab.platypus import (
19
  HRFlowable,
20
+ KeepInFrame,
21
  PageBreak,
22
  Paragraph,
23
  SimpleDocTemplate,
 
44
  _WHITE = "#FFFFFF"
45
 
46
  _TIER_COLOR = {"T0": _RED, "T1": _RED, "T2": _ORANGE, "T3": _TEAL, "T4": _GREEN}
47
+ _PDF_CONTENT_WIDTH = A4[0] - (28 * mm)
48
+ _PDF_CONTENT_HEIGHT = A4[1] - (24 * mm)
49
  _BIO_DETECTOR_LABELS = {
50
  "BIO_smiles_surface_integrity": "SMILES Surface Integrity",
51
  "BIO_smiles_rdkit_validation": "SMILES RDKit Validation",
 
735
  story.append(Spacer(1, 2 * mm))
736
  story += _regulatory_basis_box(result)
737
  story += _footer_block()
738
+ return _single_page_story(story)
739
 
740
 
741
  def _header_block(result: dict[str, Any]) -> list[Any]:
 
1067
  ]
1068
 
1069
 
1070
+ def _single_page_story(flowables: list[Any], *, break_before: bool = False) -> list[Any]:
1071
+ wrapped = KeepInFrame(
1072
+ _PDF_CONTENT_WIDTH,
1073
+ _PDF_CONTENT_HEIGHT,
1074
+ flowables,
1075
+ mode="shrink",
1076
+ )
1077
+ return ([PageBreak()] if break_before else []) + [wrapped]
1078
+
1079
+
1080
  # ── Detail page dispatcher ────────────────────────────────────────────────────
1081
  def _detail_pages(result: dict[str, Any], pages: int) -> list[Any]:
1082
  story: list[Any] = []
1083
  story += _page2_stage_analysis(result)
1084
  story += _page3_stage3_analysis(result)
1085
  if pages >= 5:
1086
+ story += _page4_stage4_replication(result)
1087
+ if pages == 5:
1088
+ story += _page5_compact_closure(result)
1089
+ elif pages >= 7:
1090
  story += _page4_integrity_deep(result)
1091
+ story += _page6_method_airi(result)
1092
+ story += _page7_report_metadata(result)
1093
  return story
1094
 
1095
 
 
1164
 
1165
  # ── Page 2: Stage 1 + Stage 2R Analysis ──────────────────────────────────────
1166
  def _page2_stage_analysis(result: dict[str, Any]) -> list[Any]:
1167
+ story: list[Any] = []
1168
  score = result["score"]
1169
  cls = result["classification"]
1170
  s1 = score["stage_1_readme_intent"]
 
1329
  ))
1330
 
1331
  story += _footer_block()
1332
+ return _single_page_story(story, break_before=True)
1333
 
1334
 
1335
  # ── Page 3: Stage 3 Full Breakdown ───────────────────────────────────────────
1336
  def _page3_stage3_analysis(result: dict[str, Any]) -> list[Any]:
1337
+ story: list[Any] = []
1338
  score = result["score"]
1339
  s3 = score["stage_3_code_bio"]
1340
  rubric = result.get("stage_3_rubric", {})
 
1455
  ))
1456
 
1457
  story += _footer_block()
1458
+ return _single_page_story(story, break_before=True)
1459
+
1460
+
1461
+ # ── Page 4: Stage 4 Replication Deep Dive (5p/7p) ────────────────────────────
1462
+ def _page4_stage4_replication(result: dict[str, Any]) -> list[Any]:
1463
+ story: list[Any] = []
1464
+ score = result["score"]
1465
+ stage4_score = result.get("replication_score", 0)
1466
+ stage4_tier = result.get("replication_tier", "R0")
1467
+ rubric = result.get("stage_4_rubric", {})
1468
+
1469
+ story += _sec_hdr("Stage 4 — Replication Evidence Lane | Separate lane", _GREEN)
1470
+
1471
+ label_map = {
1472
+ "S4_container_environment": "S4: Container / Runtime Environment",
1473
+ "S4_make_reproduce_target": "S4: Reproduce Target",
1474
+ "S4_environment_lock_evidence": "S4: Environment Lock Evidence",
1475
+ "S4_exact_dependency_pins_or_hashes": "S4: Exact Dependency Pins / Hashes",
1476
+ "S4_readme_reproducibility_section": "S4: Reproducibility Section",
1477
+ "S4_checksum_files": "S4: Checksums / Integrity Files",
1478
+ "S4_dataset_url": "S4: Dataset / Data Source URL",
1479
+ "S4_model_weight_url_or_checksum": "S4: Model Artifact URL / Checksum",
1480
+ "S4_citation_cff": "S4: CITATION.cff",
1481
+ "S4_license_restriction": "S4: License / Use Restriction",
1482
+ "S4_cli_entrypoint": "S4: CLI Entrypoint",
1483
+ "S4_seed_setting": "S4: Deterministic Seed Setting",
1484
+ "S4_runnable_examples": "S4: Runnable Examples",
1485
+ }
1486
 
1487
+ items: list[tuple[str, str, str, str]] = []
1488
+ for key, item in rubric.items():
1489
+ if not isinstance(item, dict) or "score" not in item or "max" not in item:
1490
+ continue
1491
+ sc = item.get("score", 0)
1492
+ mx = item.get("max", 0)
1493
+ color = _GREEN if sc == mx and mx else (_AMBER if sc > 0 else _RED)
1494
+ evidence = item.get("evidence", "")
1495
+ items.append((label_map.get(key, key), f"{sc} / {mx}", color, evidence))
1496
+
1497
+ chip = _mini_score("S4 Score", stage4_score, 100, _GREEN)
1498
+ tbl = _rubric_rows(items, "S4")
1499
+ combined = Table([[chip, tbl]], colWidths=[38 * mm, None])
1500
+ combined.setStyle(TableStyle([
1501
+ ("VALIGN", (0, 0), (-1, -1), "TOP"),
1502
+ ("LEFTPADDING", (0, 0), (-1, -1), 2),
1503
+ ("RIGHTPADDING", (0, 0), (-1, -1), 2),
1504
+ ]))
1505
+ story.append(combined)
1506
+ story.append(Spacer(1, 2 * mm))
1507
+ story.append(Paragraph(
1508
+ f'<font color="{_DGRAY}" size="7.5"><i>Replication tier: {stage4_tier}. '
1509
+ 'Stage 4 is reported separately and does not alter the formal score.</i></font>',
1510
+ _style("S4_NOTE", 7.5, 10, _DGRAY),
1511
+ ))
1512
+ story.append(Spacer(1, 3 * mm))
1513
+ story.append(Paragraph(
1514
+ f'<font color="{_DGRAY}" size="8">'
1515
+ f'Final score remains <b>{score["final_score"]} / 100</b> ({_xt(score["formal_tier"])}) even when Stage 4 moves. '
1516
+ 'This lane exists to show reproducibility and operational evidence posture separately from the formal repository score.'
1517
+ f'</font>',
1518
+ _style("S4_SCOPE", 8, 11, _DGRAY),
1519
+ ))
1520
+ story += _footer_block()
1521
+ return _single_page_story(story, break_before=True)
1522
 
1523
+
1524
+ # ── Page 5: Code Integrity Deep Dive + Classification (7p only) ──────────────
1525
  def _page4_integrity_deep(result: dict[str, Any]) -> list[Any]:
1526
+ story: list[Any] = []
1527
  ci = result["code_integrity"]
1528
  cls = result["classification"]
1529
  hashes = result.get("file_hashes_sha256", {})
 
1689
  story.append(hash_tbl)
1690
 
1691
  story += _footer_block()
1692
+ return _single_page_story(story, break_before=True)
1693
 
1694
 
1695
+ # ── Page 5: Compact Closeout (5p standard packet only) ───────────────────────
1696
+ def _page5_compact_closure(result: dict[str, Any]) -> list[Any]:
1697
+ story: list[Any] = []
1698
+ score = result["score"]
1699
+ risks = result.get("notable_risks", [])
1700
+ airi = result.get("airi_risk_coverage", {})
1701
+ ci = result["code_integrity"]
1702
+
1703
+ story += _sec_hdr("Closeout Summary", _NAVY)
1704
+
1705
+ ci_rows: list[tuple[str, str, str, str]] = []
1706
+ short = {
1707
+ "C1_hardcoded_credentials": "C1: Hardcoded Credentials",
1708
+ "C2_dependency_pinning": "C2: Dependency Pinning",
1709
+ "C3_dead_or_deprecated_patient_adjacent_paths": "C3: Deprecated Patient Paths",
1710
+ "C4_exception_handling_clinical_adjacent_paths": "C4: Fail-Open Exceptions",
1711
+ "C5_compliance_boundary_integrity": "C5: Compliance Boundary Integrity",
1712
+ "C6_mock_auth_or_fail_open_boundary": "C6: Mock Auth / Fail-Open Boundary",
1713
+ }
1714
+ for key, cfg in ci.items():
1715
+ status = cfg["status"]
1716
+ color = _status_hex(status)
1717
+ evidence = cfg["evidence"][0] if cfg.get("evidence") else ""
1718
+ ci_rows.append((short.get(key, key), status, color, evidence))
1719
+
1720
+ story.append(_rubric_rows(ci_rows, "CC"))
1721
+ story.append(Spacer(1, 3 * mm))
1722
+
1723
+ if risks:
1724
+ story += _sec_hdr("Top Risks", _RED)
1725
+ for risk in risks[:4]:
1726
+ story.append(Paragraph(
1727
+ f'&#8226; <font color="{_DGRAY}" size="8">{_xt(risk)}</font>',
1728
+ _style(f"TR_{risk[:6]}", 8, 11, _DGRAY),
1729
+ ))
1730
+ if airi:
1731
+ story.append(Spacer(1, 3 * mm))
1732
+ story += _sec_hdr("AIRI Coverage Summary", _TEAL)
1733
+ story.append(Paragraph(
1734
+ f'<font color="{_DGRAY}" size="8">'
1735
+ f'Covered Risks: <b>{airi.get("covered_count", 0)} / {airi.get("total_risks_in_detector_scope", 0)}</b> '
1736
+ f'| Coverage Rate: <b>{airi.get("coverage_rate", 0):.3f}</b></font>',
1737
+ _style("AIRI_COMPACT", 8, 11, _DGRAY),
1738
+ ))
1739
+ for risk in airi.get("covered_risks", [])[:3]:
1740
+ reason = _airi_reason_summary(risk)
1741
+ story.append(Paragraph(
1742
+ f'<font color="{_DGRAY}" size="8">&#8226; <b>{_xt(str(risk.get("id", "—")))}</b> '
1743
+ f'{_xt(str(risk.get("title", "")))}'
1744
+ f'{f" — why: {_xt(reason)}" if reason else ""}</font>',
1745
+ _style(f"AIRC_{str(risk.get('id', 'risk'))[:8]}", 8, 10, _DGRAY),
1746
+ ))
1747
+ if airi.get("known_gaps_in_bundle"):
1748
+ gap_preview = ", ".join(
1749
+ f"{g.get('id', '—')} {_xt(str(g.get('title', '')))}"
1750
+ for g in airi.get("known_gaps_in_bundle", [])[:2]
1751
+ )
1752
+ story.append(Paragraph(
1753
+ f'<font color="{_DGRAY}" size="8">Known gaps preview: {gap_preview}</font>',
1754
+ _style("AIRI_GAPC", 8, 10, _DGRAY),
1755
+ ))
1756
+
1757
+ story.append(Spacer(1, 3 * mm))
1758
+ story += _sec_hdr("Method Boundary", _DGRAY)
1759
+ story.append(Paragraph(
1760
+ f'<font color="{_DGRAY}" size="8"><b>Final score:</b> {score["final_score"]} / 100 ({_xt(score["formal_tier"])})'
1761
+ f'<br/><b>Method:</b> {_xt(result.get("method", ""))}</font>',
1762
+ _style("MBCOMPACT", 8, 11, _DGRAY),
1763
+ ))
1764
+ story += _footer_block()
1765
+ return _single_page_story(story, break_before=True)
1766
+
1767
+
1768
+ # ── Page 6: Priority Improvements + AIRI + Method (7p only) ─────────────────
1769
+ def _page6_method_airi(result: dict[str, Any]) -> list[Any]:
1770
+ story: list[Any] = []
1771
  risks = result.get("notable_risks", [])
1772
  positive = result.get("notable_positive_evidence", [])
 
1773
  airi = result.get("airi_risk_coverage", {})
1774
 
1775
  # Priority Improvements
 
1886
  _style("MBSCOPE", 8, 11, _DGRAY),
1887
  ))
1888
 
1889
+ story += _footer_block()
1890
+ return _single_page_story(story, break_before=True)
1891
+
1892
+
1893
+ # ── Page 7: Report Metadata (7p only) ────────────────────────────────────────
1894
+ def _page7_report_metadata(result: dict[str, Any]) -> list[Any]:
1895
+ story: list[Any] = []
1896
+ score = result["score"]
1897
  story += _sec_hdr("Report Metadata", _NAVY)
1898
  tgt = result["target"]
1899
  meta_items = [
 
1931
  ]))
1932
  story.append(meta_tbl)
1933
  story += _footer_block()
1934
+ return _single_page_story(story, break_before=True)
1935
 
1936
 
1937
  # ── plain-text PDF fallback (no reportlab) ───────────────────────────────────
 
1989
  f"- {k}: {v['score']} / {v['max']} {v['evidence']}"
1990
  for k, v in result.get("stage_4_rubric", {}).items()
1991
  ]])
1992
+ p4 = _fit_page([
1993
+ "Stage 4 Replication Evidence",
1994
+ f"- Stage 4 Replication Score: {result.get('replication_score', 0)} / 100 ({result.get('replication_tier', 'R0')})",
1995
+ *[
1996
+ f"- {k}: {v['score']} / {v['max']} {v['evidence']}"
1997
+ for k, v in result.get("stage_4_rubric", {}).items()
1998
+ ],
1999
+ ])
2000
+ sets = [_fit_page(brief), p2, _fit_page([
2001
+ "Stage 3 Evidence",
2002
+ *[f"- {k}: {v['score']} / {v['max']} {v['evidence']}" for k, v in result["stage_3_rubric"].items()]
2003
+ ])]
2004
  if pages == 5:
2005
+ sets.append(p4)
2006
+ sets.append(_fit_page([
2007
+ "Closeout Summary",
2008
+ "Code Integrity",
2009
  *[f"- {k}: {v['status']} {v['evidence'][0]}" for k, v in result["code_integrity"].items()],
2010
+ *([f"- AST analysis scope: {ast_note}"] if ast_note else []),
2011
+ "",
2012
  "AIRI Coverage Summary",
2013
  f"- Covered Risks: {airi.get('covered_count', 0)} / {airi.get('total_risks_in_detector_scope', 0)}",
2014
  f"- Coverage Rate: {airi.get('coverage_rate', 0):.3f}",
2015
+ *[
2016
+ f"- {risk.get('id', '—')}: {risk.get('title', '')}"
2017
+ + (f" | why: {_airi_reason_summary(risk)}" if _airi_reason_summary(risk) else "")
2018
+ for risk in airi.get("covered_risks", [])[:2]
2019
+ ],
2020
+ "",
2021
+ "Method Boundary",
2022
+ result["method"],
2023
+ ]))
2024
+ elif pages >= 7:
2025
+ sets.append(p4)
2026
  sets.append(_fit_page([
2027
+ "Code Integrity",
2028
+ *[f"- {k}: {v['status']} {v['evidence'][0]}" for k, v in result["code_integrity"].items()],
2029
+ *([f"- AST analysis scope: {ast_note}"] if ast_note else []),
2030
+ ]))
2031
+ sets.append(_fit_page([
2032
+ "Priority Improvement Roadmap",
2033
+ *[f"- {r}" for r in result.get("notable_risks", [])[:4]],
2034
+ "",
2035
+ "AIRI Coverage Summary",
2036
+ f"- Covered Risks: {airi.get('covered_count', 0)} / {airi.get('total_risks_in_detector_scope', 0)}",
2037
+ f"- Coverage Rate: {airi.get('coverage_rate', 0):.3f}",
2038
+ f"- Bundle Scope: {airi.get('airi_bundle_scope', 'unknown')}",
2039
+ *[
2040
+ f"- {risk.get('id', '—')}: {risk.get('title', '')}"
2041
+ + (f" | why: {_airi_reason_summary(risk)}" if _airi_reason_summary(risk) else "")
2042
+ for risk in airi.get("covered_risks", [])[:3]
2043
+ ],
2044
  "",
2045
  "Method Boundary",
2046
  result["method"],
2047
  ]))
2048
+ sets.append(_fit_page([
2049
+ "Report Metadata",
2050
+ f"- Schema Version: {result.get('schema_version', '—')}",
2051
+ f"- STEM BIO-AI Version: {result.get('stem_ai_version', '—')}",
2052
+ f"- Generated (local date): {result.get('generated_at_local', '—')}",
2053
+ f"- Repository: {result['target']['name']}",
2054
+ f"- Branch: {result['target'].get('branch') or '—'}",
2055
+ f"- Commit (HEAD): {(result['target'].get('commit') or '—')[:40]}",
2056
+ f"- Files Scanned: {result['target'].get('file_count', '—')}",
2057
+ f"- Final Score / Tier: {score['final_score']} / 100 — {score['formal_tier']}",
2058
+ ]))
2059
  return sets[:pages]
2060
 
2061
 
stem_ai/render_html.py CHANGED
@@ -35,7 +35,7 @@ def _calibration_effect_note(calibration: dict[str, Any]) -> str | None:
35
  )
36
 
37
 
38
- def _nav() -> str:
39
  links = [
40
  ("#s1", "1. Summary"),
41
  ("#s2", "2. Decision Path"),
@@ -44,18 +44,39 @@ def _nav() -> str:
44
  ("#s5", "5. Evidence"),
45
  ]
46
  items = "".join(f'<a href="{h}" class="nav-link">{l}</a>' for h, l in links)
47
- return f'<nav class="nav" aria-label="Report sections">{items}</nav>'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
 
49
 
50
  def _hero(score: dict, final: int, tier: str, tc: str, target: str, date: str) -> str:
51
- gauge = svg_gauge(final, tc)
52
  use_scope = xt(str(score.get("use_scope", "")))
 
 
 
 
 
 
53
  return (
54
  f'<header class="hero">'
55
- f'<div class="hero-left">{gauge}</div>'
56
  f'<div class="hero-right">'
57
  f'<div class="eyebrow">STEM BIO-AI Local Audit &nbsp;|&nbsp; {date}</div>'
58
- f'<h1>{target}</h1>'
59
  f'<div class="hero-meta">'
60
  f'<span class="tier">{xt(tier)}</span>'
61
  f'<span class="hero-chip">Deterministic local scan</span>'
@@ -350,7 +371,7 @@ def _section3(integrity: dict, cc: dict) -> str:
350
  return (
351
  f'<section id="s3">'
352
  f'<h2 class="s-title">Code Integrity &amp; Contract {hint}</h2>'
353
- f'<div class="integrity-layout">'
354
  f'<div class="panel">'
355
  f'<div class="eyebrow">Warnings First</div>'
356
  f'<h3 class="subhead">Mapped risk lanes that fired</h3>'
@@ -376,15 +397,24 @@ def _section4(airi: dict) -> str:
376
  all_risks = airi.get("covered_risks", [])
377
  donut = svg_donut(pct, _C["green"], 98)
378
 
379
- domain_counts: dict[int, int] = {d: 0 for d in range(1, 8)}
 
380
  for r in all_risks:
381
  try:
382
  d = int(str(r.get("subdomain_id", "0")).split(".")[0])
383
- if d in domain_counts:
384
- domain_counts[d] += 1
 
 
 
 
 
 
 
385
  except (ValueError, IndexError):
386
  continue
387
- domain_boxes = "".join(domain_card(d, domain_counts[d]) for d in range(1, 8))
 
388
 
389
  c_rows = "".join(airi_row(r, "covered") for r in all_risks[:24])
390
  g_rows = "".join(airi_row(g, "gap") for g in gaps)
@@ -394,7 +424,7 @@ def _section4(airi: dict) -> str:
394
  f'<button class="toggle-btn" data-view="covered" onclick="airiToggle(\'covered\')">Covered ({covered_n})</button>'
395
  f'<button class="toggle-btn" data-view="gaps" onclick="airiToggle(\'gaps\')">Gaps ({len(gaps)})</button>'
396
  f'</div>'
397
- f'<span class="muted-note">Click a domain to filter.</span>'
398
  f'</div>'
399
  )
400
  table = (
@@ -448,7 +478,7 @@ def _section4(airi: dict) -> str:
448
  return (
449
  f'<section id="s4">'
450
  f'<h2 class="s-title">MIT AI Risk Repository Coverage {hint}<span class="airi-tag">{src} | airisk.mit.edu</span></h2>'
451
- f'<div class="airi-layout">'
452
  f'<div class="panel">'
453
  f'<div class="eyebrow">Feature Explainer</div>'
454
  f'<h3 class="subhead">What this section is doing</h3>'
@@ -521,6 +551,7 @@ def render_html(result: dict[str, Any]) -> str:
521
  tier = str(score["formal_tier"])
522
  tc = tier_color(tier)
523
  target = xt(str(result["target"]["name"]))
 
524
  date = xt(str(result.get("generated_at_local", "")))
525
  s1 = int(score.get("stage_1_readme_intent", 0))
526
  s2 = int(score.get("stage_2_repo_local_consistency", 0))
@@ -529,7 +560,6 @@ def render_html(result: dict[str, Any]) -> str:
529
  t0 = result.get("classification", {}).get("t0_hard_floor", False)
530
 
531
  css = build_css(tc)
532
- nav = _nav()
533
  hero = _hero(score, final, tier, tc, target, date)
534
  sec1 = _section1(result, final, tc, s1, s2, s3, s4, t0)
535
  sec2 = _section2(result, s1, s2, s3, s4)
@@ -537,6 +567,7 @@ def render_html(result: dict[str, Any]) -> str:
537
  sec4 = _section4(result.get("airi_risk_coverage", {}))
538
  sec5 = _section5(result.get("evidence_ledger", []))
539
  ver = xt(result.get("stem_ai_version", ""))
 
540
 
541
  return f"""<!DOCTYPE html>
542
  <html lang="en">
 
35
  )
36
 
37
 
38
+ def _nav(ver: str) -> str:
39
  links = [
40
  ("#s1", "1. Summary"),
41
  ("#s2", "2. Decision Path"),
 
44
  ("#s5", "5. Evidence"),
45
  ]
46
  items = "".join(f'<a href="{h}" class="nav-link">{l}</a>' for h, l in links)
47
+ return (
48
+ f'<nav class="nav" aria-label="Report sections">'
49
+ f'<div class="nav-links">{items}</div>'
50
+ f'<div class="nav-brand"><strong>STEM-BIO-AI</strong><span>STEM BIO-AI Local CLI Scan &nbsp;|&nbsp; {ver}</span></div>'
51
+ f'</nav>'
52
+ )
53
+
54
+
55
+ def _hero_accent(tc: str) -> str:
56
+ if tc == _C["red"]:
57
+ return "#D65D6D"
58
+ if tc == _C["amber"]:
59
+ return "#D28F22"
60
+ if tc == _C["green"]:
61
+ return "#2A8A56"
62
+ return "#3B86BE"
63
 
64
 
65
  def _hero(score: dict, final: int, tier: str, tc: str, target: str, date: str) -> str:
66
+ gauge = svg_gauge(final, _hero_accent(tc))
67
  use_scope = xt(str(score.get("use_scope", "")))
68
+ remote = xt(str(score.get("_target_remote", "")))
69
+ title = (
70
+ f'<a href="{remote}" class="hero-link" target="_blank" rel="noopener noreferrer">{target}</a>'
71
+ if remote
72
+ else target
73
+ )
74
  return (
75
  f'<header class="hero">'
76
+ f'<div class="hero-left"><div class="hero-gauge-card">{gauge}</div></div>'
77
  f'<div class="hero-right">'
78
  f'<div class="eyebrow">STEM BIO-AI Local Audit &nbsp;|&nbsp; {date}</div>'
79
+ f'<h1>{title}</h1>'
80
  f'<div class="hero-meta">'
81
  f'<span class="tier">{xt(tier)}</span>'
82
  f'<span class="hero-chip">Deterministic local scan</span>'
 
371
  return (
372
  f'<section id="s3">'
373
  f'<h2 class="s-title">Code Integrity &amp; Contract {hint}</h2>'
374
+ f'<div class="integrity-stack">'
375
  f'<div class="panel">'
376
  f'<div class="eyebrow">Warnings First</div>'
377
  f'<h3 class="subhead">Mapped risk lanes that fired</h3>'
 
397
  all_risks = airi.get("covered_risks", [])
398
  donut = svg_donut(pct, _C["green"], 98)
399
 
400
+ covered_counts: dict[int, int] = {d: 0 for d in range(1, 8)}
401
+ gap_counts: dict[int, int] = {d: 0 for d in range(1, 8)}
402
  for r in all_risks:
403
  try:
404
  d = int(str(r.get("subdomain_id", "0")).split(".")[0])
405
+ if d in covered_counts:
406
+ covered_counts[d] += 1
407
+ except (ValueError, IndexError):
408
+ continue
409
+ for g in gaps:
410
+ try:
411
+ d = int(str(g.get("subdomain_id", "0")).split(".")[0])
412
+ if d in gap_counts:
413
+ gap_counts[d] += 1
414
  except (ValueError, IndexError):
415
  continue
416
+ domain_boxes = domain_card(0, covered_n, len(gaps))
417
+ domain_boxes += "".join(domain_card(d, covered_counts[d], gap_counts[d]) for d in range(1, 8))
418
 
419
  c_rows = "".join(airi_row(r, "covered") for r in all_risks[:24])
420
  g_rows = "".join(airi_row(g, "gap") for g in gaps)
 
424
  f'<button class="toggle-btn" data-view="covered" onclick="airiToggle(\'covered\')">Covered ({covered_n})</button>'
425
  f'<button class="toggle-btn" data-view="gaps" onclick="airiToggle(\'gaps\')">Gaps ({len(gaps)})</button>'
426
  f'</div>'
427
+ f'<span class="muted-note">Click a domain card to filter. Counts are shown as covered / gaps.</span>'
428
  f'</div>'
429
  )
430
  table = (
 
478
  return (
479
  f'<section id="s4">'
480
  f'<h2 class="s-title">MIT AI Risk Repository Coverage {hint}<span class="airi-tag">{src} | airisk.mit.edu</span></h2>'
481
+ f'<div class="airi-stack">'
482
  f'<div class="panel">'
483
  f'<div class="eyebrow">Feature Explainer</div>'
484
  f'<h3 class="subhead">What this section is doing</h3>'
 
551
  tier = str(score["formal_tier"])
552
  tc = tier_color(tier)
553
  target = xt(str(result["target"]["name"]))
554
+ score["_target_remote"] = str(result.get("target", {}).get("remote", "")).removesuffix(".git")
555
  date = xt(str(result.get("generated_at_local", "")))
556
  s1 = int(score.get("stage_1_readme_intent", 0))
557
  s2 = int(score.get("stage_2_repo_local_consistency", 0))
 
560
  t0 = result.get("classification", {}).get("t0_hard_floor", False)
561
 
562
  css = build_css(tc)
 
563
  hero = _hero(score, final, tier, tc, target, date)
564
  sec1 = _section1(result, final, tc, s1, s2, s3, s4, t0)
565
  sec2 = _section2(result, s1, s2, s3, s4)
 
567
  sec4 = _section4(result.get("airi_risk_coverage", {}))
568
  sec5 = _section5(result.get("evidence_ledger", []))
569
  ver = xt(result.get("stem_ai_version", ""))
570
+ nav = _nav(ver)
571
 
572
  return f"""<!DOCTYPE html>
573
  <html lang="en">
stem_ai/render_html_components.py CHANGED
@@ -158,16 +158,23 @@ def integrity_card(key: str, info: dict[str, Any]) -> str:
158
  )
159
 
160
 
161
- def domain_card(d_num: int, count: int) -> str:
162
- color = _AIRI_DOMAIN_COLORS.get(d_num, _C["dgray"])
163
- name = _AIRI_DOMAIN_NAMES.get(d_num, f"Domain {d_num}")
164
- dim = "opacity:.3;pointer-events:none" if count == 0 else "cursor:pointer"
 
 
 
 
 
 
165
  return (
166
  f'<div class="domain-card" data-domain="{d_num}"'
 
167
  f' onclick="filterDomain({d_num})" style="{dim}">'
168
- f'<span class="domain-num" style="background:{color}">{d_num}</span>'
169
  f'<span class="domain-label">{name}</span>'
170
- f'<span class="domain-cnt" style="color:{color}">{count}</span>'
171
  f'</div>'
172
  )
173
 
 
158
  )
159
 
160
 
161
+ def domain_card(d_num: int, covered_count: int, gap_count: int = 0) -> str:
162
+ if d_num == 0:
163
+ color = _C["navy"]
164
+ name = "All Domains"
165
+ else:
166
+ color = _AIRI_DOMAIN_COLORS.get(d_num, _C["dgray"])
167
+ name = _AIRI_DOMAIN_NAMES.get(d_num, f"Domain {d_num}")
168
+ total = covered_count + gap_count
169
+ dim = "opacity:.55;cursor:pointer" if total == 0 else "cursor:pointer"
170
+ badge = f"{covered_count}/{gap_count}"
171
  return (
172
  f'<div class="domain-card" data-domain="{d_num}"'
173
+ f' data-covered="{covered_count}" data-gaps="{gap_count}"'
174
  f' onclick="filterDomain({d_num})" style="{dim}">'
175
+ f'<span class="domain-num" style="background:{color}">{"All" if d_num == 0 else d_num}</span>'
176
  f'<span class="domain-label">{name}</span>'
177
+ f'<span class="domain-cnt" style="color:{color}">{badge}</span>'
178
  f'</div>'
179
  )
180
 
stem_ai/render_html_styles.py CHANGED
@@ -44,21 +44,27 @@ h1,h2,h3,.stage-name,.metric-value,.tier{{
44
  position:sticky;top:0;z-index:60;
45
  background:rgba(255,255,255,.86);
46
  backdrop-filter:blur(14px);
47
- display:flex;gap:4px;overflow-x:auto;
48
  border-bottom:1px solid rgba(13,31,60,.08);
49
  box-shadow:0 8px 24px rgba(13,31,60,.06);
 
50
  }}
 
 
 
 
51
  .nav-link{{
52
  padding:14px 18px;color:{dg};font-size:12px;font-weight:700;
53
  text-decoration:none;transition:color .2s,background .2s,border-color .2s;
54
  border-bottom:3px solid transparent;white-space:nowrap;
55
  }}
56
- .nav-link:hover{{color:{n};background:rgba(13,31,60,.04)}}
57
  .nav-link.active{{color:{n};border-bottom-color:{t}}}
58
  .hero{{
59
  background:
60
- radial-gradient(circle at top right, rgba(255,255,255,.12), transparent 26%),
61
- linear-gradient(135deg, {n} 0%, #163457 48%, #24537B 100%);
 
62
  color:#fff;
63
  padding:42px 48px 46px;
64
  display:grid;
@@ -69,8 +75,17 @@ h1,h2,h3,.stage-name,.metric-value,.tier{{
69
  .hero-left{{display:flex;justify-content:center}}
70
  .hero-right{{display:flex;flex-direction:column;gap:10px}}
71
  .hero h1{{font-size:34px;font-weight:700;line-height:1.1;letter-spacing:-.02em}}
 
 
72
  .eyebrow{{font-size:11px;font-weight:800;letter-spacing:.12em;text-transform:uppercase;color:rgba(13,31,60,.62)}}
73
- .hero .eyebrow{{color:rgba(255,255,255,.62)}}
 
 
 
 
 
 
 
74
  .hero-meta{{display:flex;gap:10px;flex-wrap:wrap;align-items:center}}
75
  .tier{{
76
  display:inline-block;background:{tc};color:#fff;font-size:13px;font-weight:700;
@@ -93,7 +108,15 @@ section{{margin-bottom:34px;scroll-margin-top:72px}}
93
  padding:24px;
94
  box-shadow:var(--shadow-soft);
95
  }}
96
- .memo-grid,.integrity-layout,.airi-layout{{display:grid;grid-template-columns:repeat(12,1fr);gap:18px}}
 
 
 
 
 
 
 
 
97
  .memo-card,.stage-card{{
98
  background:{w};
99
  border:1px solid rgba(13,31,60,.08);
@@ -203,7 +226,7 @@ section{{margin-bottom:34px;scroll-margin-top:72px}}
203
  .risk-item:hover{{border-left-color:{r};box-shadow:0 2px 8px rgba(0,0,0,.08)}}
204
  .domain-grid{{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:10px;margin-bottom:18px}}
205
  .domain-card{{display:flex;align-items:center;gap:10px;padding:11px 14px;background:{w};border:1.5px solid {mg};border-radius:12px;transition:border-color .15s,box-shadow .15s;user-select:none}}
206
- .domain-card:hover{{border-color:{t};box-shadow:0 2px 8px rgba(0,0,0,.1)}}
207
  .domain-card.domain-active{{border-color:var(--d-color,{t});box-shadow:0 0 0 3px color-mix(in srgb,var(--d-color,{t}) 18%,transparent)}}
208
  .domain-num{{display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;border-radius:6px;color:#fff;font-size:11px;font-weight:800;flex-shrink:0}}
209
  .domain-label{{font-size:11px;color:{n};font-weight:700;flex:1;line-height:1.3}}
@@ -225,13 +248,14 @@ section{{margin-bottom:34px;scroll-margin-top:72px}}
225
  .metric-grid{{grid-template-columns:repeat(3,minmax(0,1fr))}}
226
  .stage-deck{{grid-template-columns:1fr}}
227
  .memo-card,.memo-card:nth-child(1),.memo-card:nth-child(2),.memo-card:nth-child(3),.memo-card:nth-child(4){{grid-column:span 12}}
228
- .integrity-layout,.airi-layout{{grid-template-columns:1fr}}
229
  .config-pattern{{grid-template-columns:1fr}}
230
  .config-grid{{grid-template-columns:1fr}}
231
  }}
232
  @media(max-width:760px){{
233
  .hero{{padding:28px 22px;grid-template-columns:1fr}}
234
  .content{{padding:24px 14px 42px}}
 
 
235
  .nav-link{{padding:12px 12px;font-size:11px}}
236
  .metric-grid{{grid-template-columns:repeat(2,minmax(0,1fr))}}
237
  }}
@@ -254,12 +278,31 @@ function toggleCard(card) {
254
  var _airiView = 'covered';
255
  var _airiDomain = 0;
256
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
257
  function _applyAiriFilter() {
258
  document.querySelectorAll('.airi-covered,.airi-gaps').forEach(function(r) {
259
  var viewOk = r.classList.contains('airi-' + _airiView);
260
  var domainOk = (_airiDomain === 0 || r.dataset.domain == _airiDomain);
261
  r.style.display = (viewOk && domainOk) ? '' : 'none';
262
  });
 
263
  }
264
 
265
  function airiToggle(view) {
 
44
  position:sticky;top:0;z-index:60;
45
  background:rgba(255,255,255,.86);
46
  backdrop-filter:blur(14px);
47
+ display:flex;justify-content:space-between;align-items:center;gap:16px;
48
  border-bottom:1px solid rgba(13,31,60,.08);
49
  box-shadow:0 8px 24px rgba(13,31,60,.06);
50
+ padding-right:16px;
51
  }}
52
+ .nav-links{{display:flex;gap:4px;overflow-x:auto;flex:1;min-width:0}}
53
+ .nav-brand{{display:grid;justify-items:end;flex-shrink:0;white-space:nowrap;padding-left:12px;border-left:1px solid rgba(13,31,60,.08)}}
54
+ .nav-brand strong{{font-size:11px;letter-spacing:.08em;color:{n}}}
55
+ .nav-brand span{{font-size:10px;color:{dg};font-weight:700}}
56
  .nav-link{{
57
  padding:14px 18px;color:{dg};font-size:12px;font-weight:700;
58
  text-decoration:none;transition:color .2s,background .2s,border-color .2s;
59
  border-bottom:3px solid transparent;white-space:nowrap;
60
  }}
61
+ .nav-link:hover{{color:{n};background:rgba(13,31,60,.04);transform:translateY(-1px)}}
62
  .nav-link.active{{color:{n};border-bottom-color:{t}}}
63
  .hero{{
64
  background:
65
+ radial-gradient(circle at top right, rgba(255,255,255,.35), transparent 28%),
66
+ radial-gradient(circle at top left, rgba(255,255,255,.26), transparent 26%),
67
+ linear-gradient(135deg, #2B547B 0%, #3C6C96 46%, #6E9FBE 100%);
68
  color:#fff;
69
  padding:42px 48px 46px;
70
  display:grid;
 
75
  .hero-left{{display:flex;justify-content:center}}
76
  .hero-right{{display:flex;flex-direction:column;gap:10px}}
77
  .hero h1{{font-size:34px;font-weight:700;line-height:1.1;letter-spacing:-.02em}}
78
+ .hero-link{{color:#fff;text-decoration:none;border-bottom:1px solid transparent}}
79
+ .hero-link:hover{{border-bottom-color:rgba(255,255,255,.72)}}
80
  .eyebrow{{font-size:11px;font-weight:800;letter-spacing:.12em;text-transform:uppercase;color:rgba(13,31,60,.62)}}
81
+ .hero .eyebrow{{color:rgba(255,255,255,.82)}}
82
+ .hero-gauge-card{{
83
+ background:rgba(255,255,255,.96);
84
+ border:1px solid rgba(255,255,255,.45);
85
+ border-radius:22px;
86
+ padding:18px 20px 14px;
87
+ box-shadow:0 18px 42px rgba(8,22,38,.18);
88
+ }}
89
  .hero-meta{{display:flex;gap:10px;flex-wrap:wrap;align-items:center}}
90
  .tier{{
91
  display:inline-block;background:{tc};color:#fff;font-size:13px;font-weight:700;
 
108
  padding:24px;
109
  box-shadow:var(--shadow-soft);
110
  }}
111
+ .panel:hover,
112
+ .metric-card:hover,
113
+ .memo-card:hover,
114
+ .stage-card:hover{{
115
+ box-shadow:var(--shadow-strong);
116
+ transform:translateY(-2px);
117
+ }}
118
+ .memo-grid{{display:grid;grid-template-columns:repeat(12,1fr);gap:18px}}
119
+ .integrity-stack,.airi-stack{{display:grid;grid-template-columns:1fr;gap:18px}}
120
  .memo-card,.stage-card{{
121
  background:{w};
122
  border:1px solid rgba(13,31,60,.08);
 
226
  .risk-item:hover{{border-left-color:{r};box-shadow:0 2px 8px rgba(0,0,0,.08)}}
227
  .domain-grid{{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:10px;margin-bottom:18px}}
228
  .domain-card{{display:flex;align-items:center;gap:10px;padding:11px 14px;background:{w};border:1.5px solid {mg};border-radius:12px;transition:border-color .15s,box-shadow .15s;user-select:none}}
229
+ .domain-card:hover{{border-color:{t};box-shadow:var(--shadow-strong);transform:translateY(-2px)}}
230
  .domain-card.domain-active{{border-color:var(--d-color,{t});box-shadow:0 0 0 3px color-mix(in srgb,var(--d-color,{t}) 18%,transparent)}}
231
  .domain-num{{display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;border-radius:6px;color:#fff;font-size:11px;font-weight:800;flex-shrink:0}}
232
  .domain-label{{font-size:11px;color:{n};font-weight:700;flex:1;line-height:1.3}}
 
248
  .metric-grid{{grid-template-columns:repeat(3,minmax(0,1fr))}}
249
  .stage-deck{{grid-template-columns:1fr}}
250
  .memo-card,.memo-card:nth-child(1),.memo-card:nth-child(2),.memo-card:nth-child(3),.memo-card:nth-child(4){{grid-column:span 12}}
 
251
  .config-pattern{{grid-template-columns:1fr}}
252
  .config-grid{{grid-template-columns:1fr}}
253
  }}
254
  @media(max-width:760px){{
255
  .hero{{padding:28px 22px;grid-template-columns:1fr}}
256
  .content{{padding:24px 14px 42px}}
257
+ .nav{{padding-right:10px}}
258
+ .nav-brand{{display:none}}
259
  .nav-link{{padding:12px 12px;font-size:11px}}
260
  .metric-grid{{grid-template-columns:repeat(2,minmax(0,1fr))}}
261
  }}
 
278
  var _airiView = 'covered';
279
  var _airiDomain = 0;
280
 
281
+ function _updateAiriToggleLabels() {
282
+ var covered = 0;
283
+ var gaps = 0;
284
+ var card = document.querySelector('.domain-card[data-domain="' + _airiDomain + '"]');
285
+ if (card) {
286
+ covered = parseInt(card.dataset.covered || '0', 10);
287
+ gaps = parseInt(card.dataset.gaps || '0', 10);
288
+ } else {
289
+ var allCard = document.querySelector('.domain-card[data-domain="0"]');
290
+ covered = parseInt(allCard?.dataset.covered || '0', 10);
291
+ gaps = parseInt(allCard?.dataset.gaps || '0', 10);
292
+ }
293
+ document.querySelectorAll('.toggle-btn[data-view]').forEach(function(b) {
294
+ if (b.dataset.view === 'covered') b.textContent = 'Covered (' + covered + ')';
295
+ if (b.dataset.view === 'gaps') b.textContent = 'Gaps (' + gaps + ')';
296
+ });
297
+ }
298
+
299
  function _applyAiriFilter() {
300
  document.querySelectorAll('.airi-covered,.airi-gaps').forEach(function(r) {
301
  var viewOk = r.classList.contains('airi-' + _airiView);
302
  var domainOk = (_airiDomain === 0 || r.dataset.domain == _airiDomain);
303
  r.style.display = (viewOk && domainOk) ? '' : 'none';
304
  });
305
+ _updateAiriToggleLabels();
306
  }
307
 
308
  function airiToggle(view) {
tests/test_scanner_regressions.py CHANGED
@@ -4,6 +4,8 @@ from pathlib import Path
4
  import json
5
  from datetime import date
6
 
 
 
7
  import stem_ai.detectors as detectors
8
  from stem_ai.advisory_contract import (
9
  _fallback_citations,
@@ -53,7 +55,7 @@ from stem_ai.reasoning_model import (
53
  )
54
  from stem_ai.regulatory_traceability import build_regulatory_basis
55
  from stem_ai.redaction import redact_object, redaction_policy, sanitize_artifact_text, secret_scan
56
- from stem_ai.render import _explain_status_label, render_explain, render_markdown, render_pdf_pages
57
  from stem_ai.render_html import render_html
58
  from stem_ai.scanner import _score_bias, _score_changelog, _score_provenance, _score_stage_2r, audit_repository
59
 
@@ -2458,6 +2460,50 @@ def test_cli_scan_accepts_named_policy_and_surfaces_it_in_output(tmp_path: Path,
2458
  assert result["calibration_profile"]["profile_name"] == "strict_clinical_adjacency"
2459
 
2460
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2461
  def test_cli_rejects_invalid_policy_name(tmp_path: Path) -> None:
2462
  repo = tmp_path / "repo"
2463
  _write(repo / "README.md", "Bio repository for molecular analysis.\n")
 
4
  import json
5
  from datetime import date
6
 
7
+ import pytest
8
+
9
  import stem_ai.detectors as detectors
10
  from stem_ai.advisory_contract import (
11
  _fallback_citations,
 
55
  )
56
  from stem_ai.regulatory_traceability import build_regulatory_basis
57
  from stem_ai.redaction import redact_object, redaction_policy, sanitize_artifact_text, secret_scan
58
+ from stem_ai.render import _explain_status_label, render_explain, render_markdown, render_pdf_pages, write_outputs
59
  from stem_ai.render_html import render_html
60
  from stem_ai.scanner import _score_bias, _score_changelog, _score_provenance, _score_stage_2r, audit_repository
61
 
 
2460
  assert result["calibration_profile"]["profile_name"] == "strict_clinical_adjacency"
2461
 
2462
 
2463
+ def test_cli_scan_defaults_to_level_3() -> None:
2464
+ from stem_ai.cli import _build_parser
2465
+
2466
+ parser = _build_parser()
2467
+
2468
+ args = parser.parse_args(["scan", "D:/tmp/example"])
2469
+
2470
+ assert args.level == 3
2471
+
2472
+
2473
+ def test_detailed_pdf_packets_render_expected_page_counts(tmp_path: Path) -> None:
2474
+ pypdf = pytest.importorskip("pypdf")
2475
+
2476
+ _write(
2477
+ tmp_path / "README.md",
2478
+ "Bioinformatics repository for clinical trial biomarker analysis and medical imaging.\n"
2479
+ "HIPAA-compliant architecture when self-hosted.\n"
2480
+ "World's most powerful open-source bio AI.\n"
2481
+ "PubMed, ClinicalTrials.gov, and DailyMed workflow support.\n",
2482
+ )
2483
+ _write(tmp_path / "package.json", '{\"name\":\"demo-bio\"}\n')
2484
+ _write(tmp_path / "pnpm-lock.yaml", "lockfileVersion: '9.0'\n")
2485
+ _write(tmp_path / "Dockerfile", "FROM python:3.11-slim\n")
2486
+ _write(
2487
+ tmp_path / "src" / "pipeline.py",
2488
+ "def self_host_mode():\n"
2489
+ " if True:\n"
2490
+ " return {'user': 'dev@localhost'}\n",
2491
+ )
2492
+
2493
+ result = audit_repository(tmp_path)
2494
+ out_dir = tmp_path / "out"
2495
+ created_5 = write_outputs(result, out_dir / "l2", mode="detailed", pages=5, fmt="pdf", explain=False)
2496
+ pdf_5 = next(path for path in created_5 if path.suffix == ".pdf")
2497
+ reader_5 = pypdf.PdfReader(str(pdf_5))
2498
+
2499
+ created_7 = write_outputs(result, out_dir / "l3", mode="detailed", pages=7, fmt="pdf", explain=False)
2500
+ pdf_7 = next(path for path in created_7 if path.suffix == ".pdf")
2501
+ reader_7 = pypdf.PdfReader(str(pdf_7))
2502
+
2503
+ assert len(reader_5.pages) == 5
2504
+ assert len(reader_7.pages) == 7
2505
+
2506
+
2507
  def test_cli_rejects_invalid_policy_name(tmp_path: Path) -> None:
2508
  repo = tmp_path / "repo"
2509
  _write(repo / "README.md", "Bio repository for molecular analysis.\n")