jeuko commited on
Commit
3fc6f6d
·
verified ·
1 Parent(s): f6b7a59

Sync from GitHub (main)

Browse files
AGENTS.md CHANGED
@@ -14,6 +14,42 @@ This repository contains the LLM-based Cancer Risk Assessment Assistant.
14
  - Keep comments short and only where the code isn't self-explanatory.
15
  - Avoid verbose docstrings for simple functions.
16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  ## Testing
18
  - Write meaningful tests that verify core functionality and prevent regressions.
19
  - Run tests with `uv run pytest`.
 
14
  - Keep comments short and only where the code isn't self-explanatory.
15
  - Avoid verbose docstrings for simple functions.
16
 
17
+ ### Variable Naming
18
+ - **Avoid single-letter variable names** (x, y, i, j, e, t, f, m, c, ct) in favor of descriptive names.
19
+ - **Avoid abbreviations** (fh, ct, w, h) in favor of full descriptive names.
20
+ - Use context-specific names for loop indices based on what you're iterating over:
21
+ - `item_index` for general enumeration
22
+ - `line_index` for text line iteration
23
+ - `column_index` for table/array column iteration
24
+ - `row_index` for table/array row iteration
25
+ - Use descriptive names for comprehensions and iterations:
26
+ - `item` instead of `i` for general items
27
+ - `element` instead of `e` for list elements
28
+ - `key` instead of `k` for dictionary keys
29
+ - `value` instead of `v` for dictionary values
30
+ - Use descriptive names for coordinates and positions:
31
+ - `x_position`, `y_position` instead of `x`, `y`
32
+ - `width`, `height` instead of `w`, `h`
33
+ - Use descriptive names for data structures:
34
+ - `file_path` instead of `f` for file paths
35
+ - `model` instead of `m` for model instances
36
+ - `user` instead of `u` for user objects
37
+
38
+ **Examples from recent refactoring:**
39
+ - `for i, ref in enumerate(references)` → `for ref_index, ref in enumerate(references)`
40
+ - `for e in examples` → `for example in examples`
41
+ - `for m in models` → `for model in models`
42
+ - `x = pdf.get_x()` → `x_position = pdf.get_x()`
43
+ - `fh = family_history` → `family_history = family_history` (avoid abbreviations)
44
+ - `ct for ct in cancer_types` → `cancer_type for cancer_type in cancer_types`
45
+ - `f in MODELS_DIR.glob` → `file_path in MODELS_DIR.glob`
46
+ - `t in field_type.__args__` → `type_arg in field_type.__args__`
47
+
48
+ ### Import Management
49
+ - **Place all imports at the top of the file**, not inside functions.
50
+ - This improves performance (imports loaded once) and code readability.
51
+ - Group imports logically: standard library, third-party, local modules.
52
+
53
  ## Testing
54
  - Write meaningful tests that verify core functionality and prevent regressions.
55
  - Run tests with `uv run pytest`.
scripts/generate_documentation.py CHANGED
@@ -7,6 +7,7 @@ import re
7
  from collections import defaultdict
8
  from collections.abc import Iterable, Iterator
9
  from dataclasses import dataclass
 
10
  from pathlib import Path
11
  from typing import Any, Union, get_args, get_origin
12
 
@@ -15,6 +16,7 @@ from annotated_types import Ge, Gt, Le, Lt
15
  from fpdf import FPDF
16
  from pydantic import BaseModel
17
 
 
18
  from sentinel.risk_models.base import RiskModel
19
  from sentinel.risk_models.qcancer import (
20
  FEMALE_CANCER_TYPES as QC_FEMALE_CANCERS,
@@ -22,7 +24,7 @@ from sentinel.risk_models.qcancer import (
22
  from sentinel.risk_models.qcancer import (
23
  MALE_CANCER_TYPES as QC_MALE_CANCERS,
24
  )
25
- from sentinel.user_input import UserInput
26
 
27
  # Constants
28
  HERE = Path(__file__).resolve().parent
@@ -50,6 +52,7 @@ class LinkManager:
50
  def __init__(self):
51
  self.model_path_to_link_id: dict[str, int] = {}
52
  self.next_link_id = 1
 
53
 
54
  def get_or_create_link_id(self, model_path: str) -> int:
55
  """Get or create a link ID for a model path.
@@ -75,6 +78,35 @@ class LinkManager:
75
  link_id = self.get_or_create_link_id(model_path)
76
  pdf.set_link(link_id, y=pdf.get_y())
77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
 
79
  # ---------------------------------------------------------------------------
80
  # Metadata extraction helpers
@@ -97,6 +129,688 @@ def _get_enum_choices(annotation: Any) -> list[str] | None:
97
  return None
98
 
99
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  def _format_type(annotation: Any) -> str:
101
  origin = get_origin(annotation)
102
  args = get_args(annotation)
@@ -158,8 +872,6 @@ def parse_annotated_type(field_type: type) -> tuple[type, dict[str, Any]]:
158
  Returns:
159
  (base_type, constraints_dict)
160
  """
161
- from typing import get_args, get_origin
162
-
163
  origin = get_origin(field_type)
164
  args = get_args(field_type)
165
 
@@ -352,7 +1064,9 @@ def build_field_usage_map(models: list[RiskModel]) -> dict[str, list[tuple[str,
352
  return field_usage
353
 
354
 
355
- def extract_field_attributes(field_info, field_type) -> tuple[str, str, str, str]:
 
 
356
  """Extract field attributes directly from Field metadata.
357
 
358
  Args:
@@ -360,12 +1074,13 @@ def extract_field_attributes(field_info, field_type) -> tuple[str, str, str, str
360
  field_type: The field's type annotation
361
 
362
  Returns:
363
- tuple of (description, examples, constraints, used_by) strings
364
  """
365
  description = "-"
366
  examples = "-"
367
  constraints = "-"
368
  used_by = "-"
 
369
 
370
  # Extract description from Field
371
  if hasattr(field_info, "description") and field_info.description:
@@ -402,6 +1117,7 @@ def extract_field_attributes(field_info, field_type) -> tuple[str, str, str, str
402
  # Add enum count information if the field is an enum
403
  if hasattr(field_type, "__members__"):
404
  enum_count = len(field_type.__members__)
 
405
  if constraints == "-":
406
  constraints = f"{enum_count} choices"
407
  else:
@@ -411,6 +1127,7 @@ def extract_field_attributes(field_info, field_type) -> tuple[str, str, str, str
411
  for arg in field_type.__args__:
412
  if hasattr(arg, "__members__"):
413
  enum_count = len(arg.__members__)
 
414
  if constraints == "-":
415
  constraints = f"{enum_count} choices"
416
  else:
@@ -427,7 +1144,7 @@ def extract_field_attributes(field_info, field_type) -> tuple[str, str, str, str
427
  # Check if it's a numeric type (int, float) without constraints
428
  elif field_type in (int, float) or (
429
  hasattr(field_type, "__args__")
430
- and any(_type in field_type.__args__ for _type in (int, float))
431
  ):
432
  constraints = "any number"
433
  # Check if it's a Date type
@@ -440,7 +1157,7 @@ def extract_field_attributes(field_info, field_type) -> tuple[str, str, str, str
440
  ):
441
  constraints = "date"
442
 
443
- return description, examples, constraints, used_by
444
 
445
 
446
  def format_used_by(usage_list: list[tuple[str, bool]]) -> str:
@@ -471,11 +1188,10 @@ def render_user_input_hierarchy(
471
  Args:
472
  pdf: Active PDF instance
473
  models: List of risk model instances
474
- link_manager: Link manager for creating hyperlinks between sections
475
  """
476
- from collections import defaultdict
477
-
478
- from sentinel.user_input import UserInput
479
 
480
  # Build field usage mapping
481
  field_usage = build_field_usage_map(models)
@@ -497,6 +1213,9 @@ def render_user_input_hierarchy(
497
  # Render sections in order, ensuring each parent gets its leaf fields rendered
498
  section_counter = 1
499
 
 
 
 
500
  # Process items in the order they appear in the original structure
501
  processed_parents = set()
502
 
@@ -590,6 +1309,7 @@ def render_user_input_hierarchy(
590
  link_manager,
591
  nested_models_for_parent,
592
  parent_info_for_current,
 
593
  )
594
  section_counter += 1
595
 
@@ -603,6 +1323,7 @@ def render_model_fields_table(
603
  link_manager: LinkManager,
604
  nested_models_info: list[tuple[str, str, type[BaseModel]]] | None = None,
605
  parent_info: tuple[str, str, type[BaseModel]] | None = None,
 
606
  ) -> None:
607
  """Render a table for a specific model's fields.
608
 
@@ -615,60 +1336,47 @@ def render_model_fields_table(
615
  link_manager: Link manager for hyperlinks
616
  nested_models_info: List of nested model info tuples
617
  parent_info: Parent model info tuple
 
618
  """
619
  if not model_info or not fields:
620
  return
621
 
622
  _model_path, model_name, model_class = model_info
623
 
624
- # Add parent reference if this is a child model
625
- if parent_info:
626
- parent_path, parent_name, _ = parent_info
627
- parent_link_id = link_manager.get_or_create_link_id(parent_path)
628
 
629
- # Add parent reference with hyperlink
630
- pdf.set_font("Helvetica", "I", 9)
631
- pdf.set_text_color(*THEME_MUTED)
632
-
633
- # Get position for hyperlink
634
- link_x = pdf.get_x()
635
- link_y = pdf.get_y()
636
-
637
- pdf.cell(0, 6, f"Parent: {parent_name} ^", 0, 1, "L")
638
-
639
- # Add hyperlink to the parent reference
640
- pdf.link(
641
- link_x,
642
- link_y,
643
- pdf.get_string_width(f"Parent: {parent_name} ^"),
644
- 6,
645
- parent_link_id,
646
- )
647
-
648
- pdf.ln(2)
649
 
650
- # Add section heading with parent reference if applicable
651
  if parent_info:
652
  parent_path, parent_name, _ = parent_info
653
  parent_link_id = link_manager.get_or_create_link_id(parent_path)
654
 
655
- # Create section heading with parent reference
656
- section_title = f"{section_number}. {model_name} (from {parent_name})"
657
- add_subheading(pdf, section_title)
658
 
659
- # Add hyperlink to the parent reference part
660
- # Get the position after the section number and model name
661
- text_before_parent = f"{section_number}. {model_name} (from "
662
- text_width_before_parent = pdf.get_string_width(text_before_parent)
663
- parent_text_width = pdf.get_string_width(parent_name)
664
 
665
- # Set link position (approximate, since we can't get exact position after add_subheading)
666
- link_x = pdf.l_margin + text_width_before_parent
667
- link_y = pdf.get_y() - 8 # Approximate position of the text line
 
668
 
669
- pdf.link(link_x, link_y, parent_text_width, 8, parent_link_id)
 
 
 
670
  else:
671
- add_subheading(pdf, f"{section_number}. {model_name}")
 
 
 
 
 
672
 
673
  # Create table for this model's fields (removed "Used By" column)
674
  table_width = pdf.w - 2 * pdf.l_margin
@@ -693,6 +1401,8 @@ def render_model_fields_table(
693
  if pdf.get_y() + 15 > pdf.h - pdf.b_margin:
694
  pdf.add_page()
695
 
 
 
696
  render_table_header()
697
 
698
  # Table rows
@@ -700,10 +1410,12 @@ def render_model_fields_table(
700
  pdf.set_text_color(*TEXT_DARK)
701
  line_height = 5.5
702
 
703
- for idx, (field_path, field_name) in enumerate(fields):
704
  # Check if we need a page break before this row
705
  if pdf.get_y() + 15 > pdf.h - pdf.b_margin:
706
  pdf.add_page()
 
 
707
  render_table_header()
708
  # Reset text color and font after page break to ensure readability
709
  pdf.set_text_color(*TEXT_DARK)
@@ -715,21 +1427,43 @@ def render_model_fields_table(
715
  continue
716
 
717
  # Extract field attributes
718
- description, examples, constraints, _ = extract_field_attributes(
719
  field_info, field_info.annotation
720
  )
721
 
722
- # Alternate row colors
723
- fill_color = ROW_BACKGROUND_LIGHT if idx % 2 == 0 else ROW_BACKGROUND_ALT
 
 
 
 
 
 
724
 
725
- render_table_row(
726
- pdf,
727
- [field_name, description, examples, constraints],
728
- col_widths,
729
- line_height,
730
- fill_color,
731
  )
732
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
733
  # Add nested model rows if any exist
734
  if nested_models_info:
735
  for nested_path, nested_name, _nested_model_class in nested_models_info:
@@ -740,7 +1474,7 @@ def render_model_fields_table(
740
  continue
741
 
742
  # Extract field attributes
743
- description, _, _, _ = extract_field_attributes(
744
  field_info, field_info.annotation
745
  )
746
 
@@ -754,17 +1488,29 @@ def render_model_fields_table(
754
  # Check if we need a page break before this row
755
  if pdf.get_y() + 15 > pdf.h - pdf.b_margin:
756
  pdf.add_page()
 
 
757
  render_table_header()
758
  # Reset text color and font after page break
759
  pdf.set_text_color(*TEXT_DARK)
760
  pdf.set_font("Helvetica", "", 9)
761
 
762
- # Use a lighter background for nested model rows
763
- fill_color = (250, 250, 255) # Very light blue
 
 
 
 
 
 
 
 
 
 
764
 
765
  # Create hyperlink for the nested model
766
- row_x = pdf.get_x()
767
- row_y = pdf.get_y()
768
 
769
  render_table_row(
770
  pdf,
@@ -775,8 +1521,10 @@ def render_model_fields_table(
775
  )
776
 
777
  # Add the hyperlink to the examples cell
778
- examples_x = row_x + field_width + desc_width
779
- pdf.link(examples_x, row_y, examples_width, line_height, nested_link_id)
 
 
780
 
781
  pdf.ln(6)
782
 
@@ -924,7 +1672,7 @@ def add_subheading(pdf: FPDF, title: str) -> None:
924
  pdf.set_text_color(*THEME_PRIMARY) # Changed to primary color
925
  pdf.set_font("Helvetica", "B", 12) # Reduced from 13 to 12
926
  pdf.cell(0, 7, title, 0, 1) # Removed .upper(), reduced height from 8 to 7
927
- pdf.ln(2)
928
 
929
 
930
  def draw_stat_card(pdf: FPDF, title: str, value: str, note: str, width: float) -> None:
@@ -937,17 +1685,17 @@ def draw_stat_card(pdf: FPDF, title: str, value: str, note: str, width: float) -
937
  note (str): Supporting text beneath the value.
938
  width (float): Width to allocate for the card (points).
939
  """
940
- card_x = pdf.get_x()
941
- card_y = pdf.get_y()
942
  height = 32
943
 
944
  pdf.set_fill_color(*CARD_BACKGROUND)
945
  pdf.set_draw_color(255, 255, 255)
946
- pdf.rect(card_x, card_y, width, height, "F")
947
 
948
  pdf.set_text_color(*THEME_MUTED)
949
  pdf.set_font("Helvetica", "B", 9)
950
- pdf.set_xy(card_x + 6, card_y + 5)
951
  pdf.cell(width - 12, 4, title.upper(), 0, 2, "L")
952
 
953
  pdf.set_text_color(*THEME_PRIMARY)
@@ -958,7 +1706,7 @@ def draw_stat_card(pdf: FPDF, title: str, value: str, note: str, width: float) -
958
  pdf.set_font("Helvetica", "", 10)
959
  pdf.multi_cell(width - 12, 5, note, 0, "L")
960
 
961
- pdf.set_xy(card_x + width + 8, card_y)
962
 
963
 
964
  def render_summary_cards(pdf: FPDF, models: list[RiskModel]) -> None:
@@ -970,17 +1718,62 @@ def render_summary_cards(pdf: FPDF, models: list[RiskModel]) -> None:
970
  """
971
  stats: list[tuple[str, str, str]] = []
972
 
973
- total_models = len(models)
974
- total_cancers = len({ct for m in models for ct in cancer_types_for_model(m)})
 
 
 
 
 
 
 
 
 
975
  stats.append(
976
- ("Risk Models", str(total_models), "Distinct risk calculators available")
 
 
 
 
977
  )
978
  stats.append(("Cancer Types", str(total_cancers), "Unique cancer sites covered"))
979
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
980
  available_width = pdf.w - 2 * pdf.l_margin
981
- columns = 2
982
  gutter = 8
983
- card_width = (available_width - gutter) / columns
 
 
984
  start_x = pdf.l_margin
985
  start_y = pdf.get_y()
986
  max_height_in_row = 0
@@ -988,9 +1781,9 @@ def render_summary_cards(pdf: FPDF, models: list[RiskModel]) -> None:
988
  for idx, (title, value, note) in enumerate(stats):
989
  col = idx % columns
990
  row = idx // columns
991
- x = start_x + col * (card_width + gutter)
992
- y = start_y + row * 38 # fixed row height
993
- pdf.set_xy(x, y)
994
  draw_stat_card(pdf, title, value, note, card_width)
995
  max_height_in_row = max(max_height_in_row, 36)
996
 
@@ -1056,8 +1849,8 @@ def render_model_summary(pdf: FPDF, model: RiskModel, cancer_types: list[str]) -
1056
  pdf.set_font("Helvetica", "", 8)
1057
  pdf.set_text_color(*TEXT_DARK)
1058
  references = model.references()
1059
- for i, ref in enumerate(references, 1):
1060
- pdf.multi_cell(0, 4, f"{i}. {ref}", 0, "L")
1061
  pdf.ln(2)
1062
 
1063
 
@@ -1223,8 +2016,6 @@ def gather_spec_details(
1223
 
1224
  # Special handling for specific fields
1225
  if spec and spec.path == "demographics.sex":
1226
- from sentinel.models import Sex
1227
-
1228
  sex_choices = [choice.value for choice in Sex]
1229
  ranges.append(", ".join(sex_choices))
1230
  elif spec and spec.path == "demographics.ethnicity":
@@ -1491,30 +2282,118 @@ def render_table_row(
1491
  # Reset text color after page break to ensure readability
1492
  pdf.set_text_color(*TEXT_DARK)
1493
 
1494
- row_x = pdf.get_x()
1495
- row_y = pdf.get_y()
1496
 
1497
  # First, draw the background rectangle for the entire row
1498
  pdf.set_fill_color(*fill_color)
1499
- pdf.rect(row_x, row_y, sum(widths), row_height, "F")
1500
 
1501
  # Then draw the text in each cell with consistent height
1502
- current_x = row_x
1503
  for width, wrapped_lines in zip(widths, wrapped_texts, strict=False):
1504
- pdf.set_xy(current_x, row_y)
1505
  pdf.set_fill_color(*fill_color)
1506
 
1507
  # Draw each line of wrapped text
1508
- for i, line in enumerate(wrapped_lines):
1509
- pdf.set_xy(current_x, row_y + i * line_height)
1510
  pdf.cell(width, line_height, line, border=0, align="L", fill=False)
1511
 
1512
  current_x += width
1513
 
1514
  # Draw the border around the entire row
1515
  pdf.set_draw_color(*TABLE_BORDER)
1516
- pdf.rect(row_x, row_y, sum(widths), row_height)
1517
- pdf.set_xy(row_x, row_y + row_height)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1518
 
1519
 
1520
  def render_field_table(pdf: FPDF, model: RiskModel) -> None:
@@ -1552,6 +2431,8 @@ def render_field_table(pdf: FPDF, model: RiskModel) -> None:
1552
  pdf.cell(unit_width, 8, "Unit", 0, 0, "L", True)
1553
  pdf.cell(range_width, 8, "Choices / Range", 0, 1, "L", True)
1554
 
 
 
1555
  render_table_header()
1556
 
1557
  pdf.set_font("Helvetica", "", 9)
@@ -1564,10 +2445,15 @@ def render_field_table(pdf: FPDF, model: RiskModel) -> None:
1564
  # Define a fixed color for parent field rows (slightly darker than regular rows)
1565
  PARENT_ROW_COLOR = (245, 245, 245) # Light gray for parent rows
1566
 
 
 
 
1567
  for parent_name, sub_fields in grouped_fields:
1568
  # Check if we need a page break before the parent field
1569
  if pdf.get_y() + 20 > pdf.h - pdf.b_margin:
1570
  pdf.add_page()
 
 
1571
  render_table_header()
1572
  # Reset text color after page break to ensure readability
1573
  pdf.set_text_color(*TEXT_DARK)
@@ -1585,7 +2471,7 @@ def render_field_table(pdf: FPDF, model: RiskModel) -> None:
1585
 
1586
  # Render sub-fields (normal weight, indented) with alternating colors
1587
  pdf.set_font("Helvetica", "", 9)
1588
- for sub_idx, (field_path, field_type, is_required) in enumerate(sub_fields):
1589
  # Get the spec from UserInput introspection
1590
  spec = USER_INPUT_FIELD_SPECS.get(field_path)
1591
 
@@ -1600,9 +2486,9 @@ def render_field_table(pdf: FPDF, model: RiskModel) -> None:
1600
  # Indent the sub-field name
1601
  sub_field_name = prettify_field_name(field_path.split(".")[-1])
1602
  indented_name = f" {sub_field_name}"
1603
- # Alternate colors for sub-fields only
1604
  fill_color = (
1605
- ROW_BACKGROUND_LIGHT if sub_idx % 2 == 0 else ROW_BACKGROUND_ALT
1606
  )
1607
  render_table_row(
1608
  pdf,
@@ -1611,6 +2497,8 @@ def render_field_table(pdf: FPDF, model: RiskModel) -> None:
1611
  line_height,
1612
  fill_color,
1613
  )
 
 
1614
 
1615
  pdf.ln(4)
1616
 
@@ -1644,6 +2532,9 @@ class PDF(FPDF):
1644
  self.set_draw_color(*THEME_MUTED)
1645
  self.line(self.l_margin, 16, self.w - self.r_margin, 16)
1646
 
 
 
 
1647
  def footer(self):
1648
  """Render the footer with page numbering."""
1649
  self.set_y(-15)
@@ -1659,10 +2550,10 @@ def discover_risk_models() -> list[RiskModel]:
1659
  list[RiskModel]: Instantiated models ordered by name.
1660
  """
1661
  models = []
1662
- for f in MODELS_DIR.glob("*.py"):
1663
- if f.stem.startswith("_"):
1664
  continue
1665
- module_name = f"sentinel.risk_models.{f.stem}"
1666
  module = importlib.import_module(module_name)
1667
  for _, obj in inspect.getmembers(module, inspect.isclass):
1668
  if issubclass(obj, RiskModel) and obj is not RiskModel:
@@ -1687,44 +2578,46 @@ def generate_coverage_chart(models: list[RiskModel]) -> None:
1687
  cancer_types = list(cancer_types)
1688
  counts = list(counts)
1689
 
1690
- plt.figure(figsize=(10, 8))
1691
- plt.barh(cancer_types, counts, color=[c / 255 for c in THEME_PRIMARY])
1692
- plt.xlabel("Number of Models")
1693
- plt.ylabel("Cancer Type")
1694
- plt.title("Cancer Type Coverage by Risk Models")
1695
- plt.gca().invert_yaxis()
 
 
 
 
 
1696
  plt.tight_layout()
1697
  plt.savefig(CHART_FILE)
1698
  plt.close()
1699
 
1700
 
1701
- def render_summary_page(
1702
- pdf: FPDF, _models: list[RiskModel], link_manager: "LinkManager"
1703
- ) -> None:
1704
  """Render a summary page with hyperlinks to all sections.
1705
 
1706
  Args:
1707
  pdf: Active PDF document.
1708
- _models: List of risk models to document (unused but kept for API consistency).
1709
  link_manager: Link manager for creating hyperlinks.
1710
  """
1711
  # Title
1712
  pdf.set_text_color(*TEXT_DARK)
1713
  pdf.set_font("Helvetica", "B", 24)
1714
  pdf.cell(0, 15, "Sentinel Risk Model Documentation", 0, 1, "C")
1715
- pdf.ln(10)
1716
 
1717
  # Subtitle
1718
  pdf.set_text_color(*THEME_MUTED)
1719
  pdf.set_font("Helvetica", "", 12)
1720
  pdf.cell(0, 8, "Comprehensive Guide to Cancer Risk Assessment Models", 0, 1, "C")
1721
- pdf.ln(20)
1722
 
1723
  # Table of Contents
1724
  pdf.set_text_color(*TEXT_DARK)
1725
  pdf.set_font("Helvetica", "B", 16)
1726
- pdf.cell(0, 10, "Table of Contents", 0, 1)
1727
- pdf.ln(5)
1728
 
1729
  # Section 1: Overview
1730
  pdf.set_font("Helvetica", "B", 12)
@@ -1732,15 +2625,17 @@ def render_summary_page(
1732
 
1733
  # Create hyperlink for Overview section
1734
  overview_link_id = link_manager.get_or_create_link_id("overview")
1735
- link_x = pdf.get_x()
1736
- link_y = pdf.get_y()
1737
- pdf.cell(0, 8, "1. Overview", 0, 1)
1738
- pdf.link(link_x, link_y, pdf.get_string_width("1. Overview"), 8, overview_link_id)
 
 
1739
 
1740
  pdf.set_font("Helvetica", "", 10)
1741
  pdf.set_text_color(*TEXT_DARK)
1742
- pdf.cell(0, 6, " Key metrics, cancer coverage, and model statistics", 0, 1)
1743
- pdf.ln(3)
1744
 
1745
  # Section 2: User Input Structure
1746
  pdf.set_font("Helvetica", "B", 12)
@@ -1748,29 +2643,27 @@ def render_summary_page(
1748
 
1749
  # Create hyperlink for User Input Structure section
1750
  user_input_link_id = link_manager.get_or_create_link_id("user_input_structure")
1751
- link_x = pdf.get_x()
1752
- link_y = pdf.get_y()
1753
- pdf.cell(0, 8, "2. User Input Structure & Requirements", 0, 1)
1754
  pdf.link(
1755
- link_x,
1756
- link_y,
1757
  pdf.get_string_width("2. User Input Structure & Requirements"),
1758
- 8,
1759
  user_input_link_id,
1760
  )
1761
 
1762
  pdf.set_font("Helvetica", "", 10)
1763
  pdf.set_text_color(*TEXT_DARK)
1764
- pdf.cell(0, 6, " Complete field definitions, examples, and constraints", 0, 1)
1765
- pdf.ln(3)
1766
 
1767
  # Sub-sections for User Input (simplified - no hyperlinks for now)
1768
  pdf.set_font("Helvetica", "", 10)
1769
  pdf.set_text_color(*THEME_MUTED)
1770
 
1771
  # Get all sections that will be rendered
1772
- from sentinel.user_input import UserInput
1773
-
1774
  structure = traverse_user_input_structure(UserInput)
1775
  parent_to_items = defaultdict(list)
1776
 
@@ -1781,7 +2674,7 @@ def render_summary_page(
1781
  parent_path = ""
1782
  parent_to_items[parent_path].append((field_path, field_name, model_class))
1783
 
1784
- section_counter = 3
1785
  processed_parents = set()
1786
 
1787
  for field_path, _field_name, _model_class in structure:
@@ -1807,11 +2700,84 @@ def render_summary_page(
1807
  for field_path_check, field_name_check, model_class_check in structure:
1808
  if field_path_check == parent_path and model_class_check is not None:
1809
  model_name = field_name_check.replace("_", " ").title()
1810
- pdf.cell(0, 5, f" {section_counter}. {model_name}", 0, 1)
1811
  section_counter += 1
1812
  break
1813
 
1814
- pdf.ln(10)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1815
 
1816
  # Footer note
1817
  pdf.set_font("Helvetica", "I", 9)
@@ -1850,12 +2816,14 @@ def create_pdf(models: list[RiskModel], output_path: Path) -> None:
1850
  link_manager = LinkManager()
1851
 
1852
  # --- Summary Section ---
1853
- render_summary_page(pdf, models, link_manager)
1854
 
1855
  # --- Overview Section ---
1856
  pdf.add_page()
1857
  # Create link destination for Overview section
1858
  link_manager.create_link_destination(pdf, "overview")
 
 
1859
  add_section_heading(pdf, "1", "Overview")
1860
  add_subheading(pdf, "Key Metrics")
1861
  render_summary_cards(pdf, models)
@@ -1869,9 +2837,35 @@ def create_pdf(models: list[RiskModel], output_path: Path) -> None:
1869
  pdf.add_page()
1870
  # Create link destination for User Input Structure section
1871
  link_manager.create_link_destination(pdf, "user_input_structure")
 
 
1872
  add_section_heading(pdf, "2", "User Input Structure & Requirements")
1873
  render_user_input_hierarchy(pdf, models, link_manager)
1874
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1875
  pdf.output(output_path)
1876
  print(f"Documentation successfully generated at: {output_path}")
1877
 
 
7
  from collections import defaultdict
8
  from collections.abc import Iterable, Iterator
9
  from dataclasses import dataclass
10
+ from enum import Enum
11
  from pathlib import Path
12
  from typing import Any, Union, get_args, get_origin
13
 
 
16
  from fpdf import FPDF
17
  from pydantic import BaseModel
18
 
19
+ from sentinel.models import Sex
20
  from sentinel.risk_models.base import RiskModel
21
  from sentinel.risk_models.qcancer import (
22
  FEMALE_CANCER_TYPES as QC_FEMALE_CANCERS,
 
24
  from sentinel.risk_models.qcancer import (
25
  MALE_CANCER_TYPES as QC_MALE_CANCERS,
26
  )
27
+ from sentinel.user_input import GeneticMutation, Lifestyle, UserInput
28
 
29
  # Constants
30
  HERE = Path(__file__).resolve().parent
 
52
  def __init__(self):
53
  self.model_path_to_link_id: dict[str, int] = {}
54
  self.next_link_id = 1
55
+ self.pending_links: list[tuple[str, float, float, float, float]] = []
56
 
57
  def get_or_create_link_id(self, model_path: str) -> int:
58
  """Get or create a link ID for a model path.
 
78
  link_id = self.get_or_create_link_id(model_path)
79
  pdf.set_link(link_id, y=pdf.get_y())
80
 
81
+ def store_link_info(
82
+ self,
83
+ model_path: str,
84
+ x_position: float,
85
+ y_position: float,
86
+ width: float,
87
+ height: float,
88
+ ) -> None:
89
+ """Store link information for later creation.
90
+
91
+ Args:
92
+ model_path: The path to the model
93
+ x_position: X position
94
+ y_position: Y position
95
+ width: Link width
96
+ height: Link height
97
+ """
98
+ self.pending_links.append((model_path, x_position, y_position, width, height))
99
+
100
+ def create_pending_links(self, pdf: FPDF) -> None:
101
+ """Create all pending links after destinations are created.
102
+
103
+ Args:
104
+ pdf: The PDF instance
105
+ """
106
+ for model_path, x_position, y_position, width, height in self.pending_links:
107
+ link_id = self.get_or_create_link_id(model_path)
108
+ pdf.link(x_position, y_position, width, height, link_id)
109
+
110
 
111
  # ---------------------------------------------------------------------------
112
  # Metadata extraction helpers
 
129
  return None
130
 
131
 
132
+ def extract_enum_info(enum_class: type[Enum]) -> dict:
133
+ """Extract enum values and descriptions from docstring.
134
+
135
+ Args:
136
+ enum_class: The enum class to extract information from
137
+
138
+ Returns:
139
+ dict with:
140
+ - name: Enum class name
141
+ - description: Class-level description
142
+ - values: List of (value_name, value, description) tuples
143
+ """
144
+ name = enum_class.__name__
145
+ docstring = enum_class.__doc__ or ""
146
+
147
+ # Extract class description (first paragraph before Attributes)
148
+ description = ""
149
+ if docstring:
150
+ # Split by "Attributes:" to get the main description
151
+ parts = docstring.split("Attributes:")
152
+ if parts:
153
+ description = parts[0].strip()
154
+ # Clean up the description
155
+ description = re.sub(r"\s+", " ", description)
156
+ description = description.replace("\n", " ").strip()
157
+
158
+ # Extract individual value descriptions from Attributes section
159
+ value_descriptions = {}
160
+ if "Attributes:" in docstring:
161
+ attributes_section = docstring.split("Attributes:")[1]
162
+ # Parse lines like "VALUE_NAME: Description text"
163
+ for line in attributes_section.split("\n"):
164
+ line = line.strip()
165
+ if ":" in line and not line.startswith(" "):
166
+ parts = line.split(":", 1)
167
+ if len(parts) == 2:
168
+ value_name = parts[0].strip()
169
+ value_desc = parts[1].strip()
170
+ value_descriptions[value_name] = value_desc
171
+
172
+ # Build values list
173
+ values = []
174
+ for member_name, member_value in enum_class.__members__.items():
175
+ description_text = value_descriptions.get(member_name, "")
176
+ values.append((member_name, member_value.value, description_text))
177
+
178
+ return {"name": name, "description": description, "values": values}
179
+
180
+
181
+ def count_genomic_mutations() -> int:
182
+ """Count the number of genomic mutation values available.
183
+
184
+ Returns:
185
+ int: Number of genetic mutation enum values available
186
+ """
187
+ return len(GeneticMutation.__members__)
188
+
189
+
190
+ def count_lifestyle_factors() -> int:
191
+ """Count the number of fields in the Lifestyle model.
192
+
193
+ Returns:
194
+ int: Number of fields in the Lifestyle model
195
+ """
196
+ return len(Lifestyle.model_fields)
197
+
198
+
199
+ def count_total_input_categories() -> int:
200
+ """Count total number of leaf field categories across UserInput.
201
+
202
+ Returns:
203
+ int: Total number of leaf field categories across all models
204
+ """
205
+ structure = traverse_user_input_structure(UserInput)
206
+ # Count only leaf fields (where model_class is None)
207
+ return sum(1 for _, _, model_class in structure if model_class is None)
208
+
209
+
210
+ def count_total_discrete_choices() -> int:
211
+ """Count total number of enum member values across all enums.
212
+
213
+ Returns:
214
+ int: Total number of enum member values across all enums
215
+ """
216
+ enums_by_parent = collect_all_enums()
217
+ total = 0
218
+ for parent_enums in enums_by_parent.values():
219
+ for enum_info in parent_enums.values():
220
+ total += len(enum_info["values"])
221
+ return total
222
+
223
+
224
+ def collect_all_enums() -> dict[str, dict]:
225
+ """Collect all enum types used in UserInput, grouped by parent model.
226
+
227
+ Returns:
228
+ dict: {parent_model_name: {enum_name: enum_info}}
229
+ """
230
+ enums_by_parent = defaultdict(dict)
231
+ seen_enums = set()
232
+
233
+ def extract_enums_from_type(field_type: Any, parent_path: str = "") -> None:
234
+ """Recursively extract enum types from field annotations.
235
+
236
+ Args:
237
+ field_type: The field type annotation to check
238
+ parent_path: The path to the parent model
239
+ """
240
+ # Check if it's a direct enum
241
+ if hasattr(field_type, "__members__"):
242
+ if field_type not in seen_enums:
243
+ seen_enums.add(field_type)
244
+ enum_info = extract_enum_info(field_type)
245
+ parent_name = parent_path.split(".")[0] if parent_path else "Root"
246
+ enums_by_parent[parent_name][enum_info["name"]] = enum_info
247
+ return
248
+
249
+ # Check Union types
250
+ if hasattr(field_type, "__args__"):
251
+ for arg in field_type.__args__:
252
+ extract_enums_from_type(arg, parent_path)
253
+
254
+ # Check if it's a list or other container
255
+ origin = get_origin(field_type)
256
+ if origin is list and hasattr(field_type, "__args__"):
257
+ args = get_args(field_type)
258
+ if args:
259
+ extract_enums_from_type(args[0], parent_path)
260
+
261
+ # Traverse UserInput structure to find all enum fields
262
+ structure = traverse_user_input_structure(UserInput)
263
+
264
+ for field_path, _field_name, model_class in structure:
265
+ if model_class is not None:
266
+ # This is a nested model, check its fields
267
+ for _field_name_inner, field_info in model_class.model_fields.items():
268
+ field_type = field_info.annotation
269
+ extract_enums_from_type(field_type, field_path)
270
+ else:
271
+ # This is a leaf field, check its type
272
+ if "." in field_path:
273
+ parent_path = ".".join(field_path.split(".")[:-1])
274
+ else:
275
+ parent_path = ""
276
+
277
+ # Get the field from UserInput
278
+ current_model = UserInput
279
+ for part in field_path.split("."):
280
+ if hasattr(current_model, part):
281
+ field_info = current_model.model_fields.get(part)
282
+ if field_info:
283
+ extract_enums_from_type(field_info.annotation, parent_path)
284
+ break
285
+
286
+ return dict(enums_by_parent)
287
+
288
+
289
+ def render_enum_choices_section(
290
+ pdf: FPDF, enums_by_parent: dict, link_manager: LinkManager
291
+ ) -> None:
292
+ """Render Section 3 with enum tables grouped by parent model.
293
+
294
+ Args:
295
+ pdf: Active PDF instance
296
+ enums_by_parent: Dict of enums grouped by parent model
297
+ link_manager: Link manager for creating hyperlinks
298
+ """
299
+ section_counter = 1
300
+
301
+ for parent_name, enums in enums_by_parent.items():
302
+ if not enums:
303
+ continue
304
+
305
+ # Add subsection heading
306
+ pdf.set_font("Helvetica", "B", 12)
307
+ pdf.set_text_color(*THEME_PRIMARY)
308
+ # Capitalize first letter and remove "Enums" suffix
309
+ formatted_parent_name = parent_name.replace("_", " ").title()
310
+
311
+ # Create link destination for this subsection
312
+ subsection_link_id = f"enum_{parent_name}"
313
+ link_manager.create_link_destination(pdf, subsection_link_id)
314
+
315
+ pdf.write(7, f"3.{section_counter} {formatted_parent_name}")
316
+ pdf.ln(8)
317
+
318
+ for enum_name, enum_info in enums.items():
319
+ # Create link destination for the enum
320
+ enum_link_id = f"enum_{enum_name}"
321
+ link_manager.create_link_destination(pdf, enum_link_id)
322
+
323
+ # Add enum heading with name and description
324
+ pdf.set_font("Helvetica", "B", 11)
325
+ pdf.set_text_color(*THEME_PRIMARY)
326
+ # Format enum name: convert CamelCase to "Camel Case" and capitalize
327
+ formatted_enum_name = " ".join(
328
+ word.capitalize()
329
+ for word in re.findall(r"[A-Z][a-z]*|[a-z]+", enum_name)
330
+ )
331
+ pdf.write(7, f"{formatted_enum_name}")
332
+ pdf.ln(6)
333
+
334
+ # Add enum description if available
335
+ if enum_info["description"]:
336
+ pdf.set_font("Helvetica", "", 9)
337
+ pdf.set_text_color(*TEXT_DARK)
338
+ pdf.multi_cell(0, 4, enum_info["description"], 0, "L")
339
+ pdf.ln(3)
340
+
341
+ # Create table for enum values
342
+ table_width = pdf.w - 2 * pdf.l_margin
343
+ value_width = table_width * 0.25
344
+ description_width = table_width * 0.75
345
+
346
+ # Table header
347
+ pdf.set_font("Helvetica", "B", 10)
348
+ pdf.set_fill_color(*TABLE_HEADER_BACKGROUND)
349
+ pdf.set_text_color(*TEXT_LIGHT)
350
+ pdf.cell(value_width, 8, "Value", 0, 0, "L", True)
351
+ pdf.cell(description_width, 8, "Description", 0, 1, "L", True)
352
+
353
+ # Table rows with alternating colors
354
+ pdf.set_font("Helvetica", "", 9)
355
+ pdf.set_text_color(*TEXT_DARK)
356
+ line_height = 5.5
357
+
358
+ for idx, (_value_name, value, description) in enumerate(
359
+ enum_info["values"]
360
+ ):
361
+ # Check if we need a page break
362
+ if pdf.get_y() + 15 > pdf.h - pdf.b_margin:
363
+ pdf.add_page()
364
+ # Re-render table header on new page
365
+ pdf.set_font("Helvetica", "B", 10)
366
+ pdf.set_fill_color(*TABLE_HEADER_BACKGROUND)
367
+ pdf.set_text_color(*TEXT_LIGHT)
368
+ pdf.cell(value_width, 8, "Value", 0, 0, "L", True)
369
+ pdf.cell(description_width, 8, "Description", 0, 1, "L", True)
370
+ pdf.set_font("Helvetica", "", 9)
371
+ pdf.set_text_color(*TEXT_DARK)
372
+
373
+ # Alternate row colors
374
+ fill_color = (
375
+ ROW_BACKGROUND_LIGHT if idx % 2 == 0 else ROW_BACKGROUND_ALT
376
+ )
377
+
378
+ # Use the same table row rendering as Section 2
379
+ render_table_row(
380
+ pdf,
381
+ [str(value), description or "-"],
382
+ [value_width, description_width],
383
+ line_height,
384
+ fill_color,
385
+ )
386
+
387
+ pdf.ln(4)
388
+
389
+ section_counter += 1
390
+
391
+
392
+ def render_risk_models_section(
393
+ pdf: FPDF, models: list[RiskModel], link_manager: LinkManager
394
+ ) -> None:
395
+ """Render Section 4 with detailed risk model documentation.
396
+
397
+ For each model, display:
398
+ - Model name (as section heading)
399
+ - Description
400
+ - Reference/citation
401
+ - Cancer types covered
402
+ - Table of admissible input fields
403
+
404
+ Args:
405
+ pdf: Active PDF instance
406
+ models: List of risk model instances
407
+ link_manager: Link manager for creating hyperlinks
408
+ """
409
+ section_counter = 1
410
+
411
+ for model in models:
412
+ # Add subsection heading for each model
413
+ pdf.set_font("Helvetica", "B", 12)
414
+ pdf.set_text_color(*THEME_PRIMARY)
415
+
416
+ # Create link destination for this model
417
+ model_link_id = f"model_{model.__class__.__name__}"
418
+ link_manager.create_link_destination(pdf, model_link_id)
419
+
420
+ model_name = getattr(model, "name", model.__class__.__name__)
421
+ # Convert underscores to spaces and capitalize each word
422
+ formatted_name = model_name.replace("_", " ").title()
423
+ pdf.write(7, f"4.{section_counter} {formatted_name}")
424
+ pdf.ln(8)
425
+
426
+ # Extract model information
427
+ model_info = extract_model_metadata(model)
428
+
429
+ # Render model description
430
+ if model_info["description"]:
431
+ pdf.set_font("Helvetica", "", 10)
432
+ pdf.set_text_color(*TEXT_DARK)
433
+ pdf.multi_cell(0, 5, model_info["description"], 0, "L")
434
+ pdf.ln(3)
435
+
436
+ # Render interpretation if available
437
+ if model_info["interpretation"]:
438
+ pdf.set_font("Helvetica", "B", 10)
439
+ pdf.set_text_color(*THEME_PRIMARY)
440
+ pdf.cell(0, 5, "Interpretation:", 0, 1)
441
+ pdf.set_font("Helvetica", "", 9)
442
+ pdf.set_text_color(*TEXT_DARK)
443
+ pdf.multi_cell(0, 4, model_info["interpretation"], 0, "L")
444
+ pdf.ln(3)
445
+
446
+ # Render reference if available
447
+ if model_info["reference"]:
448
+ pdf.set_font("Helvetica", "B", 10)
449
+ pdf.set_text_color(*THEME_PRIMARY)
450
+ pdf.cell(0, 5, "Reference:", 0, 1)
451
+ pdf.set_font("Helvetica", "I", 9)
452
+ pdf.set_text_color(*THEME_MUTED)
453
+ pdf.multi_cell(0, 4, model_info["reference"], 0, "L")
454
+ pdf.ln(3)
455
+
456
+ # Render cancer types covered
457
+ cancer_types = cancer_types_for_model(model)
458
+ if cancer_types:
459
+ pdf.set_font("Helvetica", "B", 10)
460
+ pdf.set_text_color(*THEME_PRIMARY)
461
+ pdf.cell(0, 5, "Cancer Types Covered:", 0, 1)
462
+ pdf.set_font("Helvetica", "", 9)
463
+ pdf.set_text_color(*THEME_MUTED)
464
+ cancer_types_str = ", ".join(cancer_types)
465
+ pdf.multi_cell(0, 4, cancer_types_str, 0, "L")
466
+ pdf.ln(3)
467
+
468
+ # Add subheading for input requirements
469
+ pdf.set_font("Helvetica", "B", 11)
470
+ pdf.set_text_color(*THEME_PRIMARY)
471
+ pdf.cell(0, 6, "Input Requirements", 0, 1)
472
+ pdf.ln(2)
473
+
474
+ # Render input requirements table
475
+ render_model_input_table(pdf, model, link_manager)
476
+
477
+ # Add light horizontal separator between models (except for the last one)
478
+ if section_counter < len(models):
479
+ pdf.ln(6)
480
+ pdf.set_draw_color(200, 200, 200) # Light gray color
481
+ # Shorter separator - about 60% of page width, centered
482
+ separator_width = (pdf.w - pdf.l_margin - pdf.r_margin) * 0.6
483
+ separator_start = (
484
+ pdf.l_margin
485
+ + (pdf.w - pdf.l_margin - pdf.r_margin - separator_width) / 2
486
+ )
487
+ pdf.line(
488
+ separator_start,
489
+ pdf.get_y(),
490
+ separator_start + separator_width,
491
+ pdf.get_y(),
492
+ )
493
+ pdf.ln(6)
494
+ else:
495
+ pdf.ln(8)
496
+
497
+ section_counter += 1
498
+
499
+
500
+ def extract_model_metadata(model: RiskModel) -> dict:
501
+ """Extract metadata from a risk model.
502
+
503
+ Args:
504
+ model: Risk model instance
505
+
506
+ Returns:
507
+ Dictionary with description, reference, interpretation, and other metadata
508
+ """
509
+ # Get description from method
510
+ description = ""
511
+ if hasattr(model, "description") and callable(model.description):
512
+ description = model.description()
513
+
514
+ # Get interpretation from method
515
+ interpretation = ""
516
+ if hasattr(model, "interpretation") and callable(model.interpretation):
517
+ interpretation = model.interpretation()
518
+
519
+ # Get references from method
520
+ references = []
521
+ if hasattr(model, "references") and callable(model.references):
522
+ references = model.references()
523
+
524
+ # Format references as a single string
525
+ reference = ""
526
+ if references:
527
+ reference = "; ".join(references)
528
+
529
+ return {
530
+ "description": description,
531
+ "interpretation": interpretation,
532
+ "reference": reference,
533
+ }
534
+
535
+
536
+ def get_field_info_from_user_input(field_path: str) -> dict:
537
+ """Extract description and examples from UserInput for a field path.
538
+
539
+ Args:
540
+ field_path: Full path like "demographics.age_years"
541
+
542
+ Returns:
543
+ Dict with 'description' and 'examples'
544
+ """
545
+ # Navigate the field path
546
+ parts = field_path.split(".")
547
+ current_model = UserInput
548
+
549
+ for part in parts:
550
+ if hasattr(current_model, "model_fields"):
551
+ field_info = current_model.model_fields.get(part)
552
+ if field_info:
553
+ # Get description from field metadata
554
+ description = field_info.description or ""
555
+ # Get examples from field metadata
556
+ examples = (
557
+ field_info.json_schema_extra.get("examples", [])
558
+ if field_info.json_schema_extra
559
+ else []
560
+ )
561
+ examples_str = (
562
+ ", ".join(str(example) for example in examples[:3])
563
+ if examples
564
+ else ""
565
+ )
566
+
567
+ # If this is the last part, return the info
568
+ if part == parts[-1]:
569
+ return {"description": description, "examples": examples_str}
570
+
571
+ # Otherwise, navigate deeper
572
+ current_model = field_info.annotation
573
+
574
+ return {"description": "", "examples": ""}
575
+
576
+
577
+ def has_different_constraints(model_field_type, _user_input_field_type) -> bool:
578
+ """Check if model field type has different constraints than UserInput.
579
+
580
+ Args:
581
+ model_field_type: Field type from model's REQUIRED_INPUTS
582
+ _user_input_field_type: Field type from UserInput (unused)
583
+
584
+ Returns:
585
+ True if constraints are different, False otherwise
586
+ """
587
+ # Check if model field type is Annotated[..., Field(...)]
588
+ # This indicates the model is redefining the format
589
+ if get_origin(model_field_type) is not None:
590
+ # It's an Annotated type, check if it contains Field metadata
591
+ args = get_args(model_field_type)
592
+ if args:
593
+ # Check if any of the metadata contains Field instances
594
+ for arg in args[1:]: # Skip the first arg (the actual type)
595
+ if hasattr(arg, "__class__") and "Field" in str(arg.__class__):
596
+ return True
597
+
598
+ return False
599
+
600
+
601
+ def extract_constraint_text(field_type) -> str:
602
+ """Extract just the constraint text from a field type.
603
+
604
+ Args:
605
+ field_type: Field type from model's REQUIRED_INPUTS
606
+
607
+ Returns:
608
+ Constraint text like ">=0 to <=120" or "2 choices"
609
+ """
610
+ # Check if it's an Annotated type
611
+ if get_origin(field_type) is not None:
612
+ args = get_args(field_type)
613
+ if args:
614
+ # Look for FieldInfo metadata in the arguments
615
+ for arg in args[1:]: # Skip the first arg (the actual type)
616
+ if hasattr(arg, "__class__") and "FieldInfo" in str(arg.__class__):
617
+ # Extract constraints from FieldInfo metadata
618
+ constraints = []
619
+ if hasattr(arg, "metadata") and arg.metadata:
620
+ for metadata_item in arg.metadata:
621
+ if (
622
+ hasattr(metadata_item, "ge")
623
+ and metadata_item.ge is not None
624
+ ):
625
+ constraints.append(f">={metadata_item.ge}")
626
+ if (
627
+ hasattr(metadata_item, "le")
628
+ and metadata_item.le is not None
629
+ ):
630
+ constraints.append(f"<={metadata_item.le}")
631
+ if (
632
+ hasattr(metadata_item, "gt")
633
+ and metadata_item.gt is not None
634
+ ):
635
+ constraints.append(f">{metadata_item.gt}")
636
+ if (
637
+ hasattr(metadata_item, "lt")
638
+ and metadata_item.lt is not None
639
+ ):
640
+ constraints.append(f"<{metadata_item.lt}")
641
+ if (
642
+ hasattr(metadata_item, "min_length")
643
+ and metadata_item.min_length is not None
644
+ ):
645
+ constraints.append(
646
+ f"min_length={metadata_item.min_length}"
647
+ )
648
+ if (
649
+ hasattr(metadata_item, "max_length")
650
+ and metadata_item.max_length is not None
651
+ ):
652
+ constraints.append(
653
+ f"max_length={metadata_item.max_length}"
654
+ )
655
+
656
+ if constraints:
657
+ return " to ".join(constraints)
658
+
659
+ # Check if it's an enum type
660
+ if hasattr(field_type, "__members__"):
661
+ return f"{len(field_type.__members__)} choices"
662
+
663
+ # Check if it's a Union type with enums
664
+ if get_origin(field_type) is Union:
665
+ args = get_args(field_type)
666
+ for arg in args:
667
+ if hasattr(arg, "__members__"):
668
+ return f"{len(arg.__members__)} choices"
669
+
670
+ return "any"
671
+
672
+
673
+ def get_user_input_field_type(field_path: str):
674
+ """Get the field type from UserInput for a given field path.
675
+
676
+ Args:
677
+ field_path: Full path like "demographics.age_years"
678
+
679
+ Returns:
680
+ Field type from UserInput or None if not found
681
+ """
682
+ # Navigate the field path
683
+ parts = field_path.split(".")
684
+ current_model = UserInput
685
+
686
+ for part in parts:
687
+ if hasattr(current_model, "model_fields"):
688
+ field_info = current_model.model_fields.get(part)
689
+ if field_info:
690
+ # If this is the last part, return the field type
691
+ if part == parts[-1]:
692
+ return field_info.annotation
693
+
694
+ # Otherwise, navigate deeper
695
+ current_model = field_info.annotation
696
+
697
+ return None
698
+
699
+
700
+ def render_model_input_table(
701
+ pdf: FPDF, model: RiskModel, _link_manager: LinkManager
702
+ ) -> None:
703
+ """Render input requirements table with 2 columns and hyperlinks.
704
+
705
+ Columns:
706
+ - Field Name (with hyperlinks to Section 2)
707
+ - Format (model-specific or "same as user input")
708
+
709
+ Args:
710
+ pdf: Active PDF instance
711
+ model: Risk model instance
712
+ _link_manager: Link manager for creating hyperlinks (unused)
713
+ """
714
+ requirements = extract_model_requirements(model)
715
+
716
+ if not requirements:
717
+ pdf.set_font("Helvetica", "", 10)
718
+ pdf.cell(0, 6, "No input requirements defined for this model.", 0, 1)
719
+ pdf.ln(4)
720
+ return
721
+
722
+ # Table dimensions - only 2 columns now
723
+ table_width = pdf.w - 2 * pdf.l_margin
724
+ field_width = table_width * 0.60 # Increased since we only have 2 columns
725
+ format_width = table_width * 0.40
726
+ col_widths = [field_width, format_width]
727
+
728
+ # Table header function for reuse on page breaks
729
+ def render_table_header():
730
+ pdf.set_font("Helvetica", "B", 10)
731
+ pdf.set_fill_color(*TABLE_HEADER_BACKGROUND)
732
+ pdf.set_text_color(*TEXT_LIGHT)
733
+ pdf.cell(field_width, 8, "Field Name", 0, 0, "L", True)
734
+ pdf.cell(format_width, 8, "Format (when override)", 0, 1, "L", True)
735
+
736
+ # Add spacing before table header
737
+ pdf.ln(6)
738
+ render_table_header()
739
+
740
+ pdf.set_font("Helvetica", "", 9)
741
+ pdf.set_text_color(*TEXT_DARK)
742
+ line_height = 5.5
743
+
744
+ # Group fields by parent
745
+ grouped_fields = group_fields_by_requirements(requirements)
746
+
747
+ # Global index for alternating colors across all sub-fields
748
+ global_sub_idx = 0
749
+
750
+ for parent_name, sub_fields in grouped_fields:
751
+ # Check if we need a page break before the parent field
752
+ if pdf.get_y() + 20 > pdf.h - pdf.b_margin:
753
+ pdf.add_page()
754
+ # Add spacing after page header and before table header
755
+ pdf.ln(3)
756
+ render_table_header()
757
+ # Reset text color after page break to ensure readability
758
+ pdf.set_text_color(*TEXT_DARK)
759
+
760
+ # Render parent field name (bold) with fixed color
761
+ pdf.set_font("Helvetica", "B", 9)
762
+ pdf.set_text_color(*TEXT_DARK)
763
+ render_table_row(
764
+ pdf,
765
+ [parent_name, ""],
766
+ col_widths,
767
+ line_height,
768
+ (245, 245, 245), # Light gray for parent rows
769
+ )
770
+
771
+ # Render sub-fields (normal weight, indented) with alternating colors
772
+ pdf.set_font("Helvetica", "", 9)
773
+ for field_path, field_type, _is_required in sub_fields:
774
+ # Format field name
775
+ sub_field_name = prettify_field_name(field_path.split(".")[-1])
776
+ indented_name = f" {sub_field_name}"
777
+
778
+ # Determine format text based on whether model overrides UserInput
779
+ user_input_field_type = get_user_input_field_type(field_path)
780
+ if user_input_field_type and has_different_constraints(
781
+ field_type, user_input_field_type
782
+ ):
783
+ # Extract just the constraint part from the field type
784
+ format_text = extract_constraint_text(field_type)
785
+ else:
786
+ # Show "See User Input Doc" instead of "same as user input"
787
+ format_text = "See User Input Doc"
788
+
789
+ # Get position before rendering for hyperlink
790
+ x_position = pdf.get_x()
791
+ y_position = pdf.get_y()
792
+
793
+ # Alternate colors for sub-fields using global index
794
+ fill_color = (
795
+ ROW_BACKGROUND_LIGHT if global_sub_idx % 2 == 0 else ROW_BACKGROUND_ALT
796
+ )
797
+ render_table_row(
798
+ pdf,
799
+ [indented_name, format_text],
800
+ col_widths,
801
+ line_height,
802
+ fill_color,
803
+ )
804
+
805
+ # TODO: Add hyperlinks to field names linking to Section 2
806
+ # Temporarily disabled to focus on format column functionality
807
+
808
+ # Increment global index for next sub-field
809
+ global_sub_idx += 1
810
+
811
+ pdf.ln(4)
812
+
813
+
814
  def _format_type(annotation: Any) -> str:
815
  origin = get_origin(annotation)
816
  args = get_args(annotation)
 
872
  Returns:
873
  (base_type, constraints_dict)
874
  """
 
 
875
  origin = get_origin(field_type)
876
  args = get_args(field_type)
877
 
 
1064
  return field_usage
1065
 
1066
 
1067
+ def extract_field_attributes(
1068
+ field_info, field_type
1069
+ ) -> tuple[str, str, str, str, type | None]:
1070
  """Extract field attributes directly from Field metadata.
1071
 
1072
  Args:
 
1074
  field_type: The field's type annotation
1075
 
1076
  Returns:
1077
+ tuple of (description, examples, constraints, used_by, enum_class) strings and enum class
1078
  """
1079
  description = "-"
1080
  examples = "-"
1081
  constraints = "-"
1082
  used_by = "-"
1083
+ enum_class = None
1084
 
1085
  # Extract description from Field
1086
  if hasattr(field_info, "description") and field_info.description:
 
1117
  # Add enum count information if the field is an enum
1118
  if hasattr(field_type, "__members__"):
1119
  enum_count = len(field_type.__members__)
1120
+ enum_class = field_type
1121
  if constraints == "-":
1122
  constraints = f"{enum_count} choices"
1123
  else:
 
1127
  for arg in field_type.__args__:
1128
  if hasattr(arg, "__members__"):
1129
  enum_count = len(arg.__members__)
1130
+ enum_class = arg
1131
  if constraints == "-":
1132
  constraints = f"{enum_count} choices"
1133
  else:
 
1144
  # Check if it's a numeric type (int, float) without constraints
1145
  elif field_type in (int, float) or (
1146
  hasattr(field_type, "__args__")
1147
+ and any(type_arg in field_type.__args__ for type_arg in (int, float))
1148
  ):
1149
  constraints = "any number"
1150
  # Check if it's a Date type
 
1157
  ):
1158
  constraints = "date"
1159
 
1160
+ return description, examples, constraints, used_by, enum_class
1161
 
1162
 
1163
  def format_used_by(usage_list: list[tuple[str, bool]]) -> str:
 
1188
  Args:
1189
  pdf: Active PDF instance
1190
  models: List of risk model instances
1191
+ link_manager: Link manager for creating hyperlinks
1192
  """
1193
+ # Initialize link manager for hyperlinks
1194
+ link_manager = LinkManager()
 
1195
 
1196
  # Build field usage mapping
1197
  field_usage = build_field_usage_map(models)
 
1213
  # Render sections in order, ensuring each parent gets its leaf fields rendered
1214
  section_counter = 1
1215
 
1216
+ # Global row counter for alternating colors across all tables
1217
+ global_row_counter = [0]
1218
+
1219
  # Process items in the order they appear in the original structure
1220
  processed_parents = set()
1221
 
 
1309
  link_manager,
1310
  nested_models_for_parent,
1311
  parent_info_for_current,
1312
+ global_row_counter,
1313
  )
1314
  section_counter += 1
1315
 
 
1323
  link_manager: LinkManager,
1324
  nested_models_info: list[tuple[str, str, type[BaseModel]]] | None = None,
1325
  parent_info: tuple[str, str, type[BaseModel]] | None = None,
1326
+ global_row_counter: list[int] | None = None,
1327
  ) -> None:
1328
  """Render a table for a specific model's fields.
1329
 
 
1336
  link_manager: Link manager for hyperlinks
1337
  nested_models_info: List of nested model info tuples
1338
  parent_info: Parent model info tuple
1339
+ global_row_counter: Mutable list containing the global row counter for alternating colors
1340
  """
1341
  if not model_info or not fields:
1342
  return
1343
 
1344
  _model_path, model_name, model_class = model_info
1345
 
1346
+ # Parent reference is now handled in the section title
 
 
 
1347
 
1348
+ # Add section heading with uniform rendering technique
1349
+ pdf.set_font("Helvetica", "B", 12)
1350
+ pdf.set_text_color(*THEME_PRIMARY)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1351
 
 
1352
  if parent_info:
1353
  parent_path, parent_name, _ = parent_info
1354
  parent_link_id = link_manager.get_or_create_link_id(parent_path)
1355
 
1356
+ # Render main title in primary color
1357
+ main_title = f"{section_number}. {model_name} "
1358
+ pdf.write(7, main_title)
1359
 
1360
+ # Get current position for hyperlink
1361
+ x_position = pdf.get_x()
1362
+ y_position = pdf.get_y()
 
 
1363
 
1364
+ # Render parenthetical part in dark gray
1365
+ parenthetical = f"(from {parent_name})"
1366
+ pdf.set_text_color(64, 64, 64) # Dark gray color
1367
+ pdf.write(7, parenthetical)
1368
 
1369
+ # Add hyperlink to the parent name within the parenthetical
1370
+ text_width_before_parent = pdf.get_string_width(f"{main_title}(from ")
1371
+ parent_text_width = pdf.get_string_width(parent_name)
1372
+ pdf.link(x_position, y_position, parent_text_width, 7, parent_link_id)
1373
  else:
1374
+ # Render single-color title
1375
+ title = f"{section_number}. {model_name}"
1376
+ pdf.write(7, title)
1377
+
1378
+ # Add proper line spacing after the title (uniform for all)
1379
+ pdf.ln(8)
1380
 
1381
  # Create table for this model's fields (removed "Used By" column)
1382
  table_width = pdf.w - 2 * pdf.l_margin
 
1401
  if pdf.get_y() + 15 > pdf.h - pdf.b_margin:
1402
  pdf.add_page()
1403
 
1404
+ # Add minimal spacing before table header to avoid conflicts with headings
1405
+ pdf.ln(1)
1406
  render_table_header()
1407
 
1408
  # Table rows
 
1410
  pdf.set_text_color(*TEXT_DARK)
1411
  line_height = 5.5
1412
 
1413
+ for field_path, field_name in fields:
1414
  # Check if we need a page break before this row
1415
  if pdf.get_y() + 15 > pdf.h - pdf.b_margin:
1416
  pdf.add_page()
1417
+ # Add spacing after page header and before table header
1418
+ pdf.ln(3)
1419
  render_table_header()
1420
  # Reset text color and font after page break to ensure readability
1421
  pdf.set_text_color(*TEXT_DARK)
 
1427
  continue
1428
 
1429
  # Extract field attributes
1430
+ description, examples, constraints, _, enum_class = extract_field_attributes(
1431
  field_info, field_info.annotation
1432
  )
1433
 
1434
+ # Alternate row colors using global counter
1435
+ if global_row_counter is None:
1436
+ # This should never happen if called correctly
1437
+ current_row_index = 0
1438
+ else:
1439
+ current_row_index = global_row_counter[0]
1440
+ # Increment global counter for next row
1441
+ global_row_counter[0] += 1
1442
 
1443
+ fill_color = (
1444
+ ROW_BACKGROUND_LIGHT if current_row_index % 2 == 0 else ROW_BACKGROUND_ALT
 
 
 
 
1445
  )
1446
 
1447
+ # Check if constraints contain enum choices and make them clickable
1448
+ if enum_class and "choices" in constraints:
1449
+ render_table_row_with_enum_link(
1450
+ pdf,
1451
+ [field_name, description, examples, constraints],
1452
+ col_widths,
1453
+ line_height,
1454
+ fill_color,
1455
+ enum_class,
1456
+ link_manager,
1457
+ )
1458
+ else:
1459
+ render_table_row(
1460
+ pdf,
1461
+ [field_name, description, examples, constraints],
1462
+ col_widths,
1463
+ line_height,
1464
+ fill_color,
1465
+ )
1466
+
1467
  # Add nested model rows if any exist
1468
  if nested_models_info:
1469
  for nested_path, nested_name, _nested_model_class in nested_models_info:
 
1474
  continue
1475
 
1476
  # Extract field attributes
1477
+ description, _, _, _, _ = extract_field_attributes(
1478
  field_info, field_info.annotation
1479
  )
1480
 
 
1488
  # Check if we need a page break before this row
1489
  if pdf.get_y() + 15 > pdf.h - pdf.b_margin:
1490
  pdf.add_page()
1491
+ # Add spacing after page header and before table header
1492
+ pdf.ln(10)
1493
  render_table_header()
1494
  # Reset text color and font after page break
1495
  pdf.set_text_color(*TEXT_DARK)
1496
  pdf.set_font("Helvetica", "", 9)
1497
 
1498
+ # Use a lighter background for nested model rows, but still alternate
1499
+ if global_row_counter is None:
1500
+ current_row_index = 0
1501
+ else:
1502
+ current_row_index = global_row_counter[0]
1503
+ global_row_counter[0] += 1
1504
+
1505
+ # Use light blue for nested models, but alternate the shade
1506
+ if current_row_index % 2 == 0:
1507
+ fill_color = (250, 250, 255) # Very light blue
1508
+ else:
1509
+ fill_color = (245, 245, 250) # Slightly darker light blue
1510
 
1511
  # Create hyperlink for the nested model
1512
+ x_position = pdf.get_x()
1513
+ y_position = pdf.get_y()
1514
 
1515
  render_table_row(
1516
  pdf,
 
1521
  )
1522
 
1523
  # Add the hyperlink to the examples cell
1524
+ examples_x = x_position + field_width + desc_width
1525
+ pdf.link(
1526
+ examples_x, y_position, examples_width, line_height, nested_link_id
1527
+ )
1528
 
1529
  pdf.ln(6)
1530
 
 
1672
  pdf.set_text_color(*THEME_PRIMARY) # Changed to primary color
1673
  pdf.set_font("Helvetica", "B", 12) # Reduced from 13 to 12
1674
  pdf.cell(0, 7, title, 0, 1) # Removed .upper(), reduced height from 8 to 7
1675
+ pdf.ln(8) # Increased spacing significantly to prevent conflicts with table headers
1676
 
1677
 
1678
  def draw_stat_card(pdf: FPDF, title: str, value: str, note: str, width: float) -> None:
 
1685
  note (str): Supporting text beneath the value.
1686
  width (float): Width to allocate for the card (points).
1687
  """
1688
+ x_position = pdf.get_x()
1689
+ y_position = pdf.get_y()
1690
  height = 32
1691
 
1692
  pdf.set_fill_color(*CARD_BACKGROUND)
1693
  pdf.set_draw_color(255, 255, 255)
1694
+ pdf.rect(x_position, y_position, width, height, "F")
1695
 
1696
  pdf.set_text_color(*THEME_MUTED)
1697
  pdf.set_font("Helvetica", "B", 9)
1698
+ pdf.set_xy(x_position + 6, y_position + 5)
1699
  pdf.cell(width - 12, 4, title.upper(), 0, 2, "L")
1700
 
1701
  pdf.set_text_color(*THEME_PRIMARY)
 
1706
  pdf.set_font("Helvetica", "", 10)
1707
  pdf.multi_cell(width - 12, 5, note, 0, "L")
1708
 
1709
+ pdf.set_xy(x_position + width + 8, y_position)
1710
 
1711
 
1712
  def render_summary_cards(pdf: FPDF, models: list[RiskModel]) -> None:
 
1718
  """
1719
  stats: list[tuple[str, str, str]] = []
1720
 
1721
+ # Calculate total cancer type coverage instances (sum of all bars in chart)
1722
+ total_coverage_instances = sum(
1723
+ len(cancer_types_for_model(model)) for model in models
1724
+ )
1725
+ total_cancers = len(
1726
+ {
1727
+ cancer_type
1728
+ for model in models
1729
+ for cancer_type in cancer_types_for_model(model)
1730
+ }
1731
+ )
1732
  stats.append(
1733
+ (
1734
+ "Risk Models",
1735
+ str(total_coverage_instances),
1736
+ "Total cancer type coverage instances",
1737
+ )
1738
  )
1739
  stats.append(("Cancer Types", str(total_cancers), "Unique cancer sites covered"))
1740
 
1741
+ # New metrics
1742
+ stats.append(
1743
+ (
1744
+ "Genes",
1745
+ str(count_genomic_mutations()),
1746
+ "Genetic mutations tracked",
1747
+ )
1748
+ )
1749
+ stats.append(
1750
+ (
1751
+ "Lifestyle Factors",
1752
+ str(count_lifestyle_factors()),
1753
+ "Lifestyle fields available",
1754
+ )
1755
+ )
1756
+ stats.append(
1757
+ (
1758
+ "Input Categories",
1759
+ str(count_total_input_categories()),
1760
+ "Total input fields across all categories",
1761
+ )
1762
+ )
1763
+ stats.append(
1764
+ (
1765
+ "Discrete Choices",
1766
+ str(count_total_discrete_choices()),
1767
+ "Total number of categorical values available",
1768
+ )
1769
+ )
1770
+
1771
  available_width = pdf.w - 2 * pdf.l_margin
1772
+ columns = 3 # Changed from 2 to accommodate 6 cards
1773
  gutter = 8
1774
+ card_width = (
1775
+ available_width - 2 * gutter
1776
+ ) / columns # Account for 2 gutters between 3 cards
1777
  start_x = pdf.l_margin
1778
  start_y = pdf.get_y()
1779
  max_height_in_row = 0
 
1781
  for idx, (title, value, note) in enumerate(stats):
1782
  col = idx % columns
1783
  row = idx // columns
1784
+ x_position = start_x + col * (card_width + gutter)
1785
+ y_position = start_y + row * 38 # fixed row height
1786
+ pdf.set_xy(x_position, y_position)
1787
  draw_stat_card(pdf, title, value, note, card_width)
1788
  max_height_in_row = max(max_height_in_row, 36)
1789
 
 
1849
  pdf.set_font("Helvetica", "", 8)
1850
  pdf.set_text_color(*TEXT_DARK)
1851
  references = model.references()
1852
+ for ref_index, ref in enumerate(references, 1):
1853
+ pdf.multi_cell(0, 4, f"{ref_index}. {ref}", 0, "L")
1854
  pdf.ln(2)
1855
 
1856
 
 
2016
 
2017
  # Special handling for specific fields
2018
  if spec and spec.path == "demographics.sex":
 
 
2019
  sex_choices = [choice.value for choice in Sex]
2020
  ranges.append(", ".join(sex_choices))
2021
  elif spec and spec.path == "demographics.ethnicity":
 
2282
  # Reset text color after page break to ensure readability
2283
  pdf.set_text_color(*TEXT_DARK)
2284
 
2285
+ x_position = pdf.get_x()
2286
+ y_position = pdf.get_y()
2287
 
2288
  # First, draw the background rectangle for the entire row
2289
  pdf.set_fill_color(*fill_color)
2290
+ pdf.rect(x_position, y_position, sum(widths), row_height, "F")
2291
 
2292
  # Then draw the text in each cell with consistent height
2293
+ current_x = x_position
2294
  for width, wrapped_lines in zip(widths, wrapped_texts, strict=False):
2295
+ pdf.set_xy(current_x, y_position)
2296
  pdf.set_fill_color(*fill_color)
2297
 
2298
  # Draw each line of wrapped text
2299
+ for line_index, line in enumerate(wrapped_lines):
2300
+ pdf.set_xy(current_x, y_position + line_index * line_height)
2301
  pdf.cell(width, line_height, line, border=0, align="L", fill=False)
2302
 
2303
  current_x += width
2304
 
2305
  # Draw the border around the entire row
2306
  pdf.set_draw_color(*TABLE_BORDER)
2307
+ pdf.rect(x_position, y_position, sum(widths), row_height)
2308
+ pdf.set_xy(x_position, y_position + row_height)
2309
+
2310
+
2311
+ def render_table_row_with_enum_link(
2312
+ pdf: FPDF,
2313
+ texts: list[str],
2314
+ widths: list[float],
2315
+ line_height: float,
2316
+ fill_color: tuple[int, int, int],
2317
+ enum_class: type,
2318
+ link_manager: LinkManager,
2319
+ ) -> None:
2320
+ """Render a table row with clickable enum choices in the constraints column.
2321
+
2322
+ Args:
2323
+ pdf: Active PDF instance
2324
+ texts: Column texts (constraints should be in the last column)
2325
+ widths: Column widths
2326
+ line_height: Line height for text
2327
+ fill_color: Background color for the row
2328
+ enum_class: The enum class to link to
2329
+ link_manager: Link manager for creating hyperlinks
2330
+ """
2331
+ # Calculate the maximum height needed for this row by wrapping text
2332
+ max_lines = 0
2333
+ wrapped_texts = []
2334
+
2335
+ for text, width in zip(texts, widths, strict=False):
2336
+ if not text:
2337
+ wrapped_lines = [""]
2338
+ else:
2339
+ # Wrap text to fit within the column width
2340
+ wrapped_lines = pdf.multi_cell(
2341
+ width, line_height, text, border=0, align="L", split_only=True
2342
+ )
2343
+ wrapped_texts.append(wrapped_lines)
2344
+ max_lines = max(max_lines, len(wrapped_lines))
2345
+
2346
+ # Calculate the total row height
2347
+ row_height = max_lines * line_height
2348
+
2349
+ # Check if we need a page break before rendering the row
2350
+ if pdf.get_y() + row_height > pdf.h - pdf.b_margin:
2351
+ pdf.add_page()
2352
+ # Reset text color after page break to ensure readability
2353
+ pdf.set_text_color(*TEXT_DARK)
2354
+
2355
+ x_position = pdf.get_x()
2356
+ y_position = pdf.get_y()
2357
+
2358
+ # First, draw the background rectangle for the entire row
2359
+ pdf.set_fill_color(*fill_color)
2360
+ pdf.rect(x_position, y_position, sum(widths), row_height, "F")
2361
+
2362
+ # Then draw the text in each cell with consistent height
2363
+ current_x = x_position
2364
+ for column_index, (width, wrapped_lines) in enumerate(
2365
+ zip(widths, wrapped_texts, strict=False)
2366
+ ):
2367
+ pdf.set_xy(current_x, y_position)
2368
+ pdf.set_fill_color(*fill_color)
2369
+
2370
+ # Draw each line of wrapped text
2371
+ for line_index, line in enumerate(wrapped_lines):
2372
+ pdf.set_xy(current_x, y_position + line_index * line_height)
2373
+
2374
+ # If this is the constraints column (last column) and contains choices, make it clickable
2375
+ if column_index == len(texts) - 1 and "choices" in line and enum_class:
2376
+ # Store link information for later creation (after destinations are created)
2377
+ enum_link_id = f"enum_{enum_class.__name__}"
2378
+ link_manager.store_link_info(
2379
+ enum_link_id,
2380
+ pdf.get_x(),
2381
+ pdf.get_y(),
2382
+ pdf.get_string_width(line),
2383
+ line_height,
2384
+ )
2385
+
2386
+ # Render the text
2387
+ pdf.cell(width, line_height, line, border=0, align="L", fill=False)
2388
+ else:
2389
+ pdf.cell(width, line_height, line, border=0, align="L", fill=False)
2390
+
2391
+ current_x += width
2392
+
2393
+ # Draw the border around the entire row
2394
+ pdf.set_draw_color(*TABLE_BORDER)
2395
+ pdf.rect(x_position, y_position, sum(widths), row_height)
2396
+ pdf.set_xy(x_position, y_position + row_height)
2397
 
2398
 
2399
  def render_field_table(pdf: FPDF, model: RiskModel) -> None:
 
2431
  pdf.cell(unit_width, 8, "Unit", 0, 0, "L", True)
2432
  pdf.cell(range_width, 8, "Choices / Range", 0, 1, "L", True)
2433
 
2434
+ # Add minimal spacing before table header
2435
+ pdf.ln(1)
2436
  render_table_header()
2437
 
2438
  pdf.set_font("Helvetica", "", 9)
 
2445
  # Define a fixed color for parent field rows (slightly darker than regular rows)
2446
  PARENT_ROW_COLOR = (245, 245, 245) # Light gray for parent rows
2447
 
2448
+ # Global index for alternating colors across all sub-fields
2449
+ global_sub_idx = 0
2450
+
2451
  for parent_name, sub_fields in grouped_fields:
2452
  # Check if we need a page break before the parent field
2453
  if pdf.get_y() + 20 > pdf.h - pdf.b_margin:
2454
  pdf.add_page()
2455
+ # Add spacing after page header and before table header
2456
+ pdf.ln(3)
2457
  render_table_header()
2458
  # Reset text color after page break to ensure readability
2459
  pdf.set_text_color(*TEXT_DARK)
 
2471
 
2472
  # Render sub-fields (normal weight, indented) with alternating colors
2473
  pdf.set_font("Helvetica", "", 9)
2474
+ for field_path, field_type, is_required in sub_fields:
2475
  # Get the spec from UserInput introspection
2476
  spec = USER_INPUT_FIELD_SPECS.get(field_path)
2477
 
 
2486
  # Indent the sub-field name
2487
  sub_field_name = prettify_field_name(field_path.split(".")[-1])
2488
  indented_name = f" {sub_field_name}"
2489
+ # Alternate colors for sub-fields using global index
2490
  fill_color = (
2491
+ ROW_BACKGROUND_LIGHT if global_sub_idx % 2 == 0 else ROW_BACKGROUND_ALT
2492
  )
2493
  render_table_row(
2494
  pdf,
 
2497
  line_height,
2498
  fill_color,
2499
  )
2500
+ # Increment global index for next sub-field
2501
+ global_sub_idx += 1
2502
 
2503
  pdf.ln(4)
2504
 
 
2532
  self.set_draw_color(*THEME_MUTED)
2533
  self.line(self.l_margin, 16, self.w - self.r_margin, 16)
2534
 
2535
+ # Add spacing after header to prevent overlap with content
2536
+ self.set_y(25)
2537
+
2538
  def footer(self):
2539
  """Render the footer with page numbering."""
2540
  self.set_y(-15)
 
2550
  list[RiskModel]: Instantiated models ordered by name.
2551
  """
2552
  models = []
2553
+ for file_path in MODELS_DIR.glob("*.py"):
2554
+ if file_path.stem.startswith("_"):
2555
  continue
2556
+ module_name = f"sentinel.risk_models.{file_path.stem}"
2557
  module = importlib.import_module(module_name)
2558
  for _, obj in inspect.getmembers(module, inspect.isclass):
2559
  if issubclass(obj, RiskModel) and obj is not RiskModel:
 
2578
  cancer_types = list(cancer_types)
2579
  counts = list(counts)
2580
 
2581
+ plt.figure(figsize=(15, 6)) # Keep width, double height (15:6 ratio)
2582
+ plt.bar(
2583
+ cancer_types, counts, color=[color_value / 255 for color_value in THEME_PRIMARY]
2584
+ )
2585
+ plt.xlabel("Cancer Type", fontsize=16, fontweight="bold")
2586
+ plt.ylabel("Number of Models", fontsize=16, fontweight="bold")
2587
+ plt.title("Cancer Type Coverage by Risk Models", fontsize=18, fontweight="bold")
2588
+ plt.xticks(
2589
+ rotation=45, ha="right", fontsize=14
2590
+ ) # Rotate x-axis labels for better readability
2591
+ plt.yticks(fontsize=14) # Set y-axis tick font size
2592
  plt.tight_layout()
2593
  plt.savefig(CHART_FILE)
2594
  plt.close()
2595
 
2596
 
2597
+ def render_summary_page(pdf: FPDF, link_manager: "LinkManager") -> None:
 
 
2598
  """Render a summary page with hyperlinks to all sections.
2599
 
2600
  Args:
2601
  pdf: Active PDF document.
 
2602
  link_manager: Link manager for creating hyperlinks.
2603
  """
2604
  # Title
2605
  pdf.set_text_color(*TEXT_DARK)
2606
  pdf.set_font("Helvetica", "B", 24)
2607
  pdf.cell(0, 15, "Sentinel Risk Model Documentation", 0, 1, "C")
2608
+ pdf.ln(6)
2609
 
2610
  # Subtitle
2611
  pdf.set_text_color(*THEME_MUTED)
2612
  pdf.set_font("Helvetica", "", 12)
2613
  pdf.cell(0, 8, "Comprehensive Guide to Cancer Risk Assessment Models", 0, 1, "C")
2614
+ pdf.ln(8)
2615
 
2616
  # Table of Contents
2617
  pdf.set_text_color(*TEXT_DARK)
2618
  pdf.set_font("Helvetica", "B", 16)
2619
+ pdf.cell(0, 8, "Table of Contents", 0, 1)
2620
+ pdf.ln(4)
2621
 
2622
  # Section 1: Overview
2623
  pdf.set_font("Helvetica", "B", 12)
 
2625
 
2626
  # Create hyperlink for Overview section
2627
  overview_link_id = link_manager.get_or_create_link_id("overview")
2628
+ x_position = pdf.get_x()
2629
+ y_position = pdf.get_y()
2630
+ pdf.cell(0, 7, "1. Overview", 0, 1)
2631
+ pdf.link(
2632
+ x_position, y_position, pdf.get_string_width("1. Overview"), 7, overview_link_id
2633
+ )
2634
 
2635
  pdf.set_font("Helvetica", "", 10)
2636
  pdf.set_text_color(*TEXT_DARK)
2637
+ pdf.cell(0, 5, " Key metrics, cancer coverage, and model statistics", 0, 1)
2638
+ pdf.ln(2)
2639
 
2640
  # Section 2: User Input Structure
2641
  pdf.set_font("Helvetica", "B", 12)
 
2643
 
2644
  # Create hyperlink for User Input Structure section
2645
  user_input_link_id = link_manager.get_or_create_link_id("user_input_structure")
2646
+ x_position = pdf.get_x()
2647
+ y_position = pdf.get_y()
2648
+ pdf.cell(0, 7, "2. User Input Structure & Requirements", 0, 1)
2649
  pdf.link(
2650
+ x_position,
2651
+ y_position,
2652
  pdf.get_string_width("2. User Input Structure & Requirements"),
2653
+ 7,
2654
  user_input_link_id,
2655
  )
2656
 
2657
  pdf.set_font("Helvetica", "", 10)
2658
  pdf.set_text_color(*TEXT_DARK)
2659
+ pdf.cell(0, 5, " Complete field definitions, examples, and constraints", 0, 1)
2660
+ pdf.ln(2)
2661
 
2662
  # Sub-sections for User Input (simplified - no hyperlinks for now)
2663
  pdf.set_font("Helvetica", "", 10)
2664
  pdf.set_text_color(*THEME_MUTED)
2665
 
2666
  # Get all sections that will be rendered
 
 
2667
  structure = traverse_user_input_structure(UserInput)
2668
  parent_to_items = defaultdict(list)
2669
 
 
2674
  parent_path = ""
2675
  parent_to_items[parent_path].append((field_path, field_name, model_class))
2676
 
2677
+ section_counter = 1
2678
  processed_parents = set()
2679
 
2680
  for field_path, _field_name, _model_class in structure:
 
2700
  for field_path_check, field_name_check, model_class_check in structure:
2701
  if field_path_check == parent_path and model_class_check is not None:
2702
  model_name = field_name_check.replace("_", " ").title()
2703
+ pdf.cell(0, 4, f" {section_counter}. {model_name}", 0, 1)
2704
  section_counter += 1
2705
  break
2706
 
2707
+ # Section 3: User Input Tabular Choices
2708
+ pdf.set_font("Helvetica", "B", 12)
2709
+ pdf.set_text_color(*THEME_PRIMARY)
2710
+
2711
+ # Create hyperlink for User Input Tabular Choices section
2712
+ enum_choices_link_id = link_manager.get_or_create_link_id("user_input_enums")
2713
+ x_position = pdf.get_x()
2714
+ y_position = pdf.get_y()
2715
+ pdf.cell(0, 7, "3. User Input Tabular Choices", 0, 1)
2716
+ pdf.link(
2717
+ x_position,
2718
+ y_position,
2719
+ pdf.get_string_width("3. User Input Tabular Choices"),
2720
+ 7,
2721
+ enum_choices_link_id,
2722
+ )
2723
+
2724
+ pdf.set_font("Helvetica", "", 10)
2725
+ pdf.set_text_color(*TEXT_DARK)
2726
+ pdf.cell(0, 5, " Enum values and descriptions for all choice fields", 0, 1)
2727
+ pdf.ln(2)
2728
+
2729
+ # Add subsections for Section 3
2730
+ pdf.set_font("Helvetica", "", 10)
2731
+ pdf.set_text_color(*THEME_MUTED)
2732
+
2733
+ # Get enum information to create subsections
2734
+ enums_by_parent = collect_all_enums()
2735
+ section_counter = 1
2736
+
2737
+ for parent_name, enums in enums_by_parent.items():
2738
+ if not enums:
2739
+ continue
2740
+
2741
+ # Format parent name
2742
+ formatted_parent_name = parent_name.replace("_", " ").title()
2743
+
2744
+ # Create hyperlink for this subsection
2745
+ subsection_link_id = f"enum_{parent_name}"
2746
+ x_position = pdf.get_x()
2747
+ y_position = pdf.get_y()
2748
+ pdf.cell(0, 4, f" {section_counter}. {formatted_parent_name}", 0, 1)
2749
+ pdf.link(
2750
+ x_position,
2751
+ y_position,
2752
+ pdf.get_string_width(f" {section_counter}. {formatted_parent_name}"),
2753
+ 5,
2754
+ subsection_link_id,
2755
+ )
2756
+ section_counter += 1
2757
+
2758
+ # Section 4: Risk Score Models
2759
+ pdf.set_font("Helvetica", "B", 12)
2760
+ pdf.set_text_color(*THEME_PRIMARY)
2761
+
2762
+ # Create hyperlink for Risk Score Models section
2763
+ risk_models_link_id = link_manager.get_or_create_link_id("risk_models")
2764
+ x_position = pdf.get_x()
2765
+ y_position = pdf.get_y()
2766
+ pdf.cell(0, 7, "4. Risk Score Models", 0, 1)
2767
+ pdf.link(
2768
+ x_position,
2769
+ y_position,
2770
+ pdf.get_string_width("4. Risk Score Models"),
2771
+ 7,
2772
+ risk_models_link_id,
2773
+ )
2774
+
2775
+ pdf.set_font("Helvetica", "", 10)
2776
+ pdf.set_text_color(*TEXT_DARK)
2777
+ pdf.cell(0, 5, " Detailed documentation for each risk assessment model", 0, 1)
2778
+ pdf.ln(2)
2779
+
2780
+ pdf.ln(5)
2781
 
2782
  # Footer note
2783
  pdf.set_font("Helvetica", "I", 9)
 
2816
  link_manager = LinkManager()
2817
 
2818
  # --- Summary Section ---
2819
+ render_summary_page(pdf, link_manager)
2820
 
2821
  # --- Overview Section ---
2822
  pdf.add_page()
2823
  # Create link destination for Overview section
2824
  link_manager.create_link_destination(pdf, "overview")
2825
+ # Add spacing after page header
2826
+ pdf.ln(3)
2827
  add_section_heading(pdf, "1", "Overview")
2828
  add_subheading(pdf, "Key Metrics")
2829
  render_summary_cards(pdf, models)
 
2837
  pdf.add_page()
2838
  # Create link destination for User Input Structure section
2839
  link_manager.create_link_destination(pdf, "user_input_structure")
2840
+ # Add spacing after page header
2841
+ pdf.ln(3)
2842
  add_section_heading(pdf, "2", "User Input Structure & Requirements")
2843
  render_user_input_hierarchy(pdf, models, link_manager)
2844
 
2845
+ # --- User Input Tabular Choices Section ---
2846
+ pdf.add_page()
2847
+ # Create link destination for User Input Tabular Choices section
2848
+ link_manager.create_link_destination(pdf, "user_input_enums")
2849
+ # Add spacing after page header
2850
+ pdf.ln(3)
2851
+ add_section_heading(pdf, "3", "User Input Tabular Choices")
2852
+
2853
+ # Collect all enums and render the section
2854
+ enums_by_parent = collect_all_enums()
2855
+ render_enum_choices_section(pdf, enums_by_parent, link_manager)
2856
+
2857
+ # --- Risk Models Section ---
2858
+ pdf.add_page()
2859
+ # Create link destination for Risk Models section
2860
+ link_manager.create_link_destination(pdf, "risk_models")
2861
+ # Add spacing after page header
2862
+ pdf.ln(3)
2863
+ add_section_heading(pdf, "4", "Risk Score Models")
2864
+ render_risk_models_section(pdf, models, link_manager)
2865
+
2866
+ # Create all pending links after destinations are created
2867
+ link_manager.create_pending_links(pdf)
2868
+
2869
  pdf.output(output_path)
2870
  print(f"Documentation successfully generated at: {output_path}")
2871
 
tests/test_generate_documentation.py CHANGED
@@ -287,11 +287,12 @@ class TestUserInputStructureExtraction:
287
  field_info = UserInput.model_fields["demographics"]
288
  field_type = field_info.annotation
289
 
290
- description, examples, constraints, used_by = extract_field_attributes(
291
- field_info, field_type
292
  )
293
 
294
  assert isinstance(description, str)
295
  assert isinstance(examples, str)
296
  assert isinstance(constraints, str)
297
  assert isinstance(used_by, str)
 
 
287
  field_info = UserInput.model_fields["demographics"]
288
  field_type = field_info.annotation
289
 
290
+ description, examples, constraints, used_by, enum_class = (
291
+ extract_field_attributes(field_info, field_type)
292
  )
293
 
294
  assert isinstance(description, str)
295
  assert isinstance(examples, str)
296
  assert isinstance(constraints, str)
297
  assert isinstance(used_by, str)
298
+ assert enum_class is None or isinstance(enum_class, type)