Spaces:
Runtime error
Runtime error
| """Generate PDF documentation for Sentinel risk models.""" | |
| import argparse | |
| import importlib | |
| import inspect | |
| import re | |
| from collections import defaultdict | |
| from collections.abc import Iterable, Iterator | |
| from dataclasses import dataclass | |
| from enum import Enum | |
| from pathlib import Path | |
| from typing import Any, Union, get_args, get_origin | |
| import matplotlib.pyplot as plt | |
| from annotated_types import Ge, Gt, Le, Lt | |
| from fpdf import FPDF | |
| from pydantic import BaseModel | |
| from sentinel.models import Sex | |
| from sentinel.risk_models.base import RiskModel | |
| from sentinel.risk_models.qcancer import ( | |
| FEMALE_CANCER_TYPES as QC_FEMALE_CANCERS, | |
| ) | |
| from sentinel.risk_models.qcancer import ( | |
| MALE_CANCER_TYPES as QC_MALE_CANCERS, | |
| ) | |
| from sentinel.user_input import GeneticMutation, Lifestyle, UserInput | |
| # Constants | |
| HERE = Path(__file__).resolve().parent | |
| PROJECT_ROOT = HERE.parent | |
| MODELS_DIR = PROJECT_ROOT / "src" / "sentinel" / "risk_models" | |
| OUTPUT_DIR = PROJECT_ROOT / "docs" | |
| CHART_FILE = OUTPUT_DIR / "cancer_coverage.png" | |
| # Palette & typography | |
| THEME_PRIMARY = (59, 130, 246) # Modern blue | |
| THEME_ACCENT = (16, 185, 129) # Modern green | |
| THEME_MUTED = (107, 114, 128) # Modern gray | |
| TEXT_DARK = (31, 41, 55) # Darker text | |
| TEXT_LIGHT = (255, 255, 255) | |
| CARD_BACKGROUND = (249, 250, 251) # Very light gray | |
| TABLE_HEADER_BACKGROUND = (75, 85, 99) # Modern dark gray | |
| ROW_BACKGROUND_LIGHT = (255, 255, 255) | |
| ROW_BACKGROUND_ALT = (246, 248, 255) | |
| TABLE_BORDER = (216, 222, 233) | |
| class LinkManager: | |
| """Manages PDF hyperlinks for navigation between sections.""" | |
| def __init__(self): | |
| self.model_path_to_link_id: dict[str, int] = {} | |
| self.next_link_id = 1 | |
| self.pending_links: list[tuple[str, float, float, float, float]] = [] | |
| def get_or_create_link_id(self, model_path: str) -> int: | |
| """Get or create a link ID for a model path. | |
| Args: | |
| model_path: The path to the model | |
| Returns: | |
| The link ID for the model path | |
| """ | |
| if model_path not in self.model_path_to_link_id: | |
| self.model_path_to_link_id[model_path] = self.next_link_id | |
| self.next_link_id += 1 | |
| return self.model_path_to_link_id[model_path] | |
| def create_link_destination(self, pdf: FPDF, model_path: str) -> None: | |
| """Create a link destination in the PDF for a model path. | |
| Args: | |
| pdf: The PDF instance | |
| model_path: The path to the model | |
| """ | |
| link_id = self.get_or_create_link_id(model_path) | |
| pdf.set_link(link_id, y=pdf.get_y()) | |
| def store_link_info( | |
| self, | |
| model_path: str, | |
| x_position: float, | |
| y_position: float, | |
| width: float, | |
| height: float, | |
| ) -> None: | |
| """Store link information for later creation. | |
| Args: | |
| model_path: The path to the model | |
| x_position: X position | |
| y_position: Y position | |
| width: Link width | |
| height: Link height | |
| """ | |
| self.pending_links.append((model_path, x_position, y_position, width, height)) | |
| def create_pending_links(self, pdf: FPDF) -> None: | |
| """Create all pending links after destinations are created. | |
| Args: | |
| pdf: The PDF instance | |
| """ | |
| for model_path, x_position, y_position, width, height in self.pending_links: | |
| link_id = self.get_or_create_link_id(model_path) | |
| pdf.link(x_position, y_position, width, height, link_id) | |
| # --------------------------------------------------------------------------- | |
| # Metadata extraction helpers | |
| # --------------------------------------------------------------------------- | |
| NUMERIC_CONSTRAINT_TYPES = (Ge, Gt, Le, Lt) | |
| def _get_enum_choices(annotation: Any) -> list[str] | None: | |
| try: | |
| members = getattr(annotation, "__members__", None) | |
| if members: | |
| return [ | |
| member.value if hasattr(member, "value") else member | |
| for member in members.values() | |
| ] | |
| except Exception: # pragma: no cover - defensive | |
| return None | |
| return None | |
| def extract_enum_info(enum_class: type[Enum]) -> dict: | |
| """Extract enum values and descriptions from docstring. | |
| Args: | |
| enum_class: The enum class to extract information from | |
| Returns: | |
| dict with: | |
| - name: Enum class name | |
| - description: Class-level description | |
| - values: List of (value_name, value, description) tuples | |
| """ | |
| name = enum_class.__name__ | |
| docstring = enum_class.__doc__ or "" | |
| # Extract class description (first paragraph before Attributes) | |
| description = "" | |
| if docstring: | |
| # Split by "Attributes:" to get the main description | |
| parts = docstring.split("Attributes:") | |
| if parts: | |
| description = parts[0].strip() | |
| # Clean up the description | |
| description = re.sub(r"\s+", " ", description) | |
| description = description.replace("\n", " ").strip() | |
| # Extract individual value descriptions from Attributes section | |
| value_descriptions = {} | |
| if "Attributes:" in docstring: | |
| attributes_section = docstring.split("Attributes:")[1] | |
| # Parse lines like "VALUE_NAME: Description text" | |
| for line in attributes_section.split("\n"): | |
| line = line.strip() | |
| if ":" in line and not line.startswith(" "): | |
| parts = line.split(":", 1) | |
| if len(parts) == 2: | |
| value_name = parts[0].strip() | |
| value_desc = parts[1].strip() | |
| value_descriptions[value_name] = value_desc | |
| # Build values list | |
| values = [] | |
| for member_name, member_value in enum_class.__members__.items(): | |
| description_text = value_descriptions.get(member_name, "") | |
| values.append((member_name, member_value.value, description_text)) | |
| return {"name": name, "description": description, "values": values} | |
| def count_genomic_mutations() -> int: | |
| """Count the number of genomic mutation values available. | |
| Returns: | |
| int: Number of genetic mutation enum values available | |
| """ | |
| return len(GeneticMutation.__members__) | |
| def count_lifestyle_factors() -> int: | |
| """Count the number of fields in the Lifestyle model. | |
| Returns: | |
| int: Number of fields in the Lifestyle model | |
| """ | |
| return len(Lifestyle.model_fields) | |
| def count_total_input_categories() -> int: | |
| """Count total number of leaf field categories across UserInput. | |
| Returns: | |
| int: Total number of leaf field categories across all models | |
| """ | |
| structure = traverse_user_input_structure(UserInput) | |
| # Count only leaf fields (where model_class is None) | |
| return sum(1 for _, _, model_class in structure if model_class is None) | |
| def count_total_discrete_choices() -> int: | |
| """Count total number of enum member values across all enums. | |
| Returns: | |
| int: Total number of enum member values across all enums | |
| """ | |
| enums_by_parent = collect_all_enums() | |
| total = 0 | |
| for parent_enums in enums_by_parent.values(): | |
| for enum_info in parent_enums.values(): | |
| total += len(enum_info["values"]) | |
| return total | |
| def collect_all_enums() -> dict[str, dict]: | |
| """Collect all enum types used in UserInput, grouped by parent model. | |
| Returns: | |
| dict: {parent_model_name: {enum_name: enum_info}} | |
| """ | |
| enums_by_parent = defaultdict(dict) | |
| seen_enums = set() | |
| def extract_enums_from_type(field_type: Any, parent_path: str = "") -> None: | |
| """Recursively extract enum types from field annotations. | |
| Args: | |
| field_type: The field type annotation to check | |
| parent_path: The path to the parent model | |
| """ | |
| # Check if it's a direct enum | |
| if hasattr(field_type, "__members__"): | |
| if field_type not in seen_enums: | |
| seen_enums.add(field_type) | |
| enum_info = extract_enum_info(field_type) | |
| parent_name = parent_path.split(".")[0] if parent_path else "Root" | |
| enums_by_parent[parent_name][enum_info["name"]] = enum_info | |
| return | |
| # Check Union types | |
| if hasattr(field_type, "__args__"): | |
| for arg in field_type.__args__: | |
| extract_enums_from_type(arg, parent_path) | |
| # Check if it's a list or other container | |
| origin = get_origin(field_type) | |
| if origin is list and hasattr(field_type, "__args__"): | |
| args = get_args(field_type) | |
| if args: | |
| extract_enums_from_type(args[0], parent_path) | |
| # Traverse UserInput structure to find all enum fields | |
| structure = traverse_user_input_structure(UserInput) | |
| for field_path, _field_name, model_class in structure: | |
| if model_class is not None: | |
| # This is a nested model, check its fields | |
| for _field_name_inner, field_info in model_class.model_fields.items(): | |
| field_type = field_info.annotation | |
| extract_enums_from_type(field_type, field_path) | |
| else: | |
| # This is a leaf field, check its type | |
| if "." in field_path: | |
| parent_path = ".".join(field_path.split(".")[:-1]) | |
| else: | |
| parent_path = "" | |
| # Get the field from UserInput | |
| current_model = UserInput | |
| for part in field_path.split("."): | |
| if hasattr(current_model, part): | |
| field_info = current_model.model_fields.get(part) | |
| if field_info: | |
| extract_enums_from_type(field_info.annotation, parent_path) | |
| break | |
| return dict(enums_by_parent) | |
| def render_enum_choices_section( | |
| pdf: FPDF, enums_by_parent: dict, link_manager: LinkManager | |
| ) -> None: | |
| """Render Section 3 with enum tables grouped by parent model. | |
| Args: | |
| pdf: Active PDF instance | |
| enums_by_parent: Dict of enums grouped by parent model | |
| link_manager: Link manager for creating hyperlinks | |
| """ | |
| section_counter = 1 | |
| for parent_name, enums in enums_by_parent.items(): | |
| if not enums: | |
| continue | |
| # Add subsection heading | |
| pdf.set_font("Helvetica", "B", 12) | |
| pdf.set_text_color(*THEME_PRIMARY) | |
| # Capitalize first letter and remove "Enums" suffix | |
| formatted_parent_name = parent_name.replace("_", " ").title() | |
| # Create link destination for this subsection | |
| subsection_link_id = f"enum_{parent_name}" | |
| link_manager.create_link_destination(pdf, subsection_link_id) | |
| pdf.write(7, f"3.{section_counter} {formatted_parent_name}") | |
| pdf.ln(8) | |
| for enum_name, enum_info in enums.items(): | |
| # Create link destination for the enum | |
| enum_link_id = f"enum_{enum_name}" | |
| link_manager.create_link_destination(pdf, enum_link_id) | |
| # Add enum heading with name and description | |
| pdf.set_font("Helvetica", "B", 11) | |
| pdf.set_text_color(*THEME_PRIMARY) | |
| # Format enum name: convert CamelCase to "Camel Case" and capitalize | |
| formatted_enum_name = " ".join( | |
| word.capitalize() | |
| for word in re.findall(r"[A-Z][a-z]*|[a-z]+", enum_name) | |
| ) | |
| pdf.write(7, f"{formatted_enum_name}") | |
| pdf.ln(6) | |
| # Add enum description if available | |
| if enum_info["description"]: | |
| pdf.set_font("Helvetica", "", 9) | |
| pdf.set_text_color(*TEXT_DARK) | |
| pdf.multi_cell(0, 4, enum_info["description"], 0, "L") | |
| pdf.ln(3) | |
| # Create table for enum values | |
| table_width = pdf.w - 2 * pdf.l_margin | |
| value_width = table_width * 0.25 | |
| description_width = table_width * 0.75 | |
| # Table header | |
| pdf.set_font("Helvetica", "B", 10) | |
| pdf.set_fill_color(*TABLE_HEADER_BACKGROUND) | |
| pdf.set_text_color(*TEXT_LIGHT) | |
| pdf.cell(value_width, 8, "Value", 0, 0, "L", True) | |
| pdf.cell(description_width, 8, "Description", 0, 1, "L", True) | |
| # Table rows with alternating colors | |
| pdf.set_font("Helvetica", "", 9) | |
| pdf.set_text_color(*TEXT_DARK) | |
| line_height = 5.5 | |
| for idx, (_value_name, value, description) in enumerate( | |
| enum_info["values"] | |
| ): | |
| # Check if we need a page break | |
| if pdf.get_y() + 15 > pdf.h - pdf.b_margin: | |
| pdf.add_page() | |
| # Re-render table header on new page | |
| pdf.set_font("Helvetica", "B", 10) | |
| pdf.set_fill_color(*TABLE_HEADER_BACKGROUND) | |
| pdf.set_text_color(*TEXT_LIGHT) | |
| pdf.cell(value_width, 8, "Value", 0, 0, "L", True) | |
| pdf.cell(description_width, 8, "Description", 0, 1, "L", True) | |
| pdf.set_font("Helvetica", "", 9) | |
| pdf.set_text_color(*TEXT_DARK) | |
| # Alternate row colors | |
| fill_color = ( | |
| ROW_BACKGROUND_LIGHT if idx % 2 == 0 else ROW_BACKGROUND_ALT | |
| ) | |
| # Use the same table row rendering as Section 2 | |
| render_table_row( | |
| pdf, | |
| [str(value), description or "-"], | |
| [value_width, description_width], | |
| line_height, | |
| fill_color, | |
| ) | |
| pdf.ln(4) | |
| section_counter += 1 | |
| def render_risk_models_section( | |
| pdf: FPDF, models: list[RiskModel], link_manager: LinkManager | |
| ) -> None: | |
| """Render Section 4 with detailed risk model documentation. | |
| For each model, display: | |
| - Model name (as section heading) | |
| - Description | |
| - Reference/citation | |
| - Cancer types covered | |
| - Table of admissible input fields | |
| Args: | |
| pdf: Active PDF instance | |
| models: List of risk model instances | |
| link_manager: Link manager for creating hyperlinks | |
| """ | |
| section_counter = 1 | |
| for model in models: | |
| # Add subsection heading for each model | |
| pdf.set_font("Helvetica", "B", 12) | |
| pdf.set_text_color(*THEME_PRIMARY) | |
| # Create link destination for this model | |
| model_link_id = f"model_{model.__class__.__name__}" | |
| link_manager.create_link_destination(pdf, model_link_id) | |
| model_name = getattr(model, "name", model.__class__.__name__) | |
| # Convert underscores to spaces and capitalize each word | |
| formatted_name = model_name.replace("_", " ").title() | |
| pdf.write(7, f"4.{section_counter} {formatted_name}") | |
| pdf.ln(8) | |
| # Extract model information | |
| model_info = extract_model_metadata(model) | |
| # Render model description | |
| if model_info["description"]: | |
| pdf.set_font("Helvetica", "", 10) | |
| pdf.set_text_color(*TEXT_DARK) | |
| pdf.multi_cell(0, 5, model_info["description"], 0, "L") | |
| pdf.ln(3) | |
| # Render interpretation if available | |
| if model_info["interpretation"]: | |
| pdf.set_font("Helvetica", "B", 10) | |
| pdf.set_text_color(*THEME_PRIMARY) | |
| pdf.cell(0, 5, "Interpretation:", 0, 1) | |
| pdf.set_font("Helvetica", "", 9) | |
| pdf.set_text_color(*TEXT_DARK) | |
| pdf.multi_cell(0, 4, model_info["interpretation"], 0, "L") | |
| pdf.ln(3) | |
| # Render reference if available | |
| if model_info["reference"]: | |
| pdf.set_font("Helvetica", "B", 10) | |
| pdf.set_text_color(*THEME_PRIMARY) | |
| pdf.cell(0, 5, "Reference:", 0, 1) | |
| pdf.set_font("Helvetica", "I", 9) | |
| pdf.set_text_color(*THEME_MUTED) | |
| pdf.multi_cell(0, 4, model_info["reference"], 0, "L") | |
| pdf.ln(3) | |
| # Render cancer types covered | |
| cancer_types = cancer_types_for_model(model) | |
| if cancer_types: | |
| pdf.set_font("Helvetica", "B", 10) | |
| pdf.set_text_color(*THEME_PRIMARY) | |
| pdf.cell(0, 5, "Cancer Types Covered:", 0, 1) | |
| pdf.set_font("Helvetica", "", 9) | |
| pdf.set_text_color(*THEME_MUTED) | |
| cancer_types_str = ", ".join(cancer_types) | |
| pdf.multi_cell(0, 4, cancer_types_str, 0, "L") | |
| pdf.ln(3) | |
| # Add subheading for input requirements | |
| pdf.set_font("Helvetica", "B", 11) | |
| pdf.set_text_color(*THEME_PRIMARY) | |
| pdf.cell(0, 6, "Input Requirements", 0, 1) | |
| pdf.ln(2) | |
| # Render input requirements table | |
| render_model_input_table(pdf, model, link_manager) | |
| # Add light horizontal separator between models (except for the last one) | |
| if section_counter < len(models): | |
| pdf.ln(6) | |
| pdf.set_draw_color(200, 200, 200) # Light gray color | |
| # Shorter separator - about 60% of page width, centered | |
| separator_width = (pdf.w - pdf.l_margin - pdf.r_margin) * 0.6 | |
| separator_start = ( | |
| pdf.l_margin | |
| + (pdf.w - pdf.l_margin - pdf.r_margin - separator_width) / 2 | |
| ) | |
| pdf.line( | |
| separator_start, | |
| pdf.get_y(), | |
| separator_start + separator_width, | |
| pdf.get_y(), | |
| ) | |
| pdf.ln(6) | |
| else: | |
| pdf.ln(8) | |
| section_counter += 1 | |
| def extract_model_metadata(model: RiskModel) -> dict: | |
| """Extract metadata from a risk model. | |
| Args: | |
| model: Risk model instance | |
| Returns: | |
| Dictionary with description, reference, interpretation, and other metadata | |
| """ | |
| # Get description from method | |
| description = "" | |
| if hasattr(model, "description") and callable(model.description): | |
| description = model.description() | |
| # Get interpretation from method | |
| interpretation = "" | |
| if hasattr(model, "interpretation") and callable(model.interpretation): | |
| interpretation = model.interpretation() | |
| # Get references from method | |
| references = [] | |
| if hasattr(model, "references") and callable(model.references): | |
| references = model.references() | |
| # Format references as a single string | |
| reference = "" | |
| if references: | |
| reference = "; ".join(references) | |
| return { | |
| "description": description, | |
| "interpretation": interpretation, | |
| "reference": reference, | |
| } | |
| def get_field_info_from_user_input(field_path: str) -> dict: | |
| """Extract description and examples from UserInput for a field path. | |
| Args: | |
| field_path: Full path like "demographics.age_years" | |
| Returns: | |
| Dict with 'description' and 'examples' | |
| """ | |
| # Navigate the field path | |
| parts = field_path.split(".") | |
| current_model = UserInput | |
| for part in parts: | |
| if hasattr(current_model, "model_fields"): | |
| field_info = current_model.model_fields.get(part) | |
| if field_info: | |
| # Get description from field metadata | |
| description = field_info.description or "" | |
| # Get examples from field metadata | |
| examples = ( | |
| field_info.json_schema_extra.get("examples", []) | |
| if field_info.json_schema_extra | |
| else [] | |
| ) | |
| examples_str = ( | |
| ", ".join(str(example) for example in examples[:3]) | |
| if examples | |
| else "" | |
| ) | |
| # If this is the last part, return the info | |
| if part == parts[-1]: | |
| return {"description": description, "examples": examples_str} | |
| # Otherwise, navigate deeper | |
| current_model = field_info.annotation | |
| return {"description": "", "examples": ""} | |
| def has_different_constraints(model_field_type, _user_input_field_type) -> bool: | |
| """Check if model field type has different constraints than UserInput. | |
| Args: | |
| model_field_type: Field type from model's REQUIRED_INPUTS | |
| _user_input_field_type: Field type from UserInput (unused) | |
| Returns: | |
| True if constraints are different, False otherwise | |
| """ | |
| # Check if model field type is Annotated[..., Field(...)] | |
| # This indicates the model is redefining the format | |
| if get_origin(model_field_type) is not None: | |
| # It's an Annotated type, check if it contains Field metadata | |
| args = get_args(model_field_type) | |
| if args: | |
| # Check if any of the metadata contains Field instances | |
| for arg in args[1:]: # Skip the first arg (the actual type) | |
| if hasattr(arg, "__class__") and "Field" in str(arg.__class__): | |
| return True | |
| return False | |
| def extract_constraint_text(field_type) -> str: | |
| """Extract just the constraint text from a field type. | |
| Args: | |
| field_type: Field type from model's REQUIRED_INPUTS | |
| Returns: | |
| Constraint text like ">=0 to <=120" or "2 choices" | |
| """ | |
| # Check if it's an Annotated type | |
| if get_origin(field_type) is not None: | |
| args = get_args(field_type) | |
| if args: | |
| # Look for FieldInfo metadata in the arguments | |
| for arg in args[1:]: # Skip the first arg (the actual type) | |
| if hasattr(arg, "__class__") and "FieldInfo" in str(arg.__class__): | |
| # Extract constraints from FieldInfo metadata | |
| constraints = [] | |
| if hasattr(arg, "metadata") and arg.metadata: | |
| for metadata_item in arg.metadata: | |
| if ( | |
| hasattr(metadata_item, "ge") | |
| and metadata_item.ge is not None | |
| ): | |
| constraints.append(f">={metadata_item.ge}") | |
| if ( | |
| hasattr(metadata_item, "le") | |
| and metadata_item.le is not None | |
| ): | |
| constraints.append(f"<={metadata_item.le}") | |
| if ( | |
| hasattr(metadata_item, "gt") | |
| and metadata_item.gt is not None | |
| ): | |
| constraints.append(f">{metadata_item.gt}") | |
| if ( | |
| hasattr(metadata_item, "lt") | |
| and metadata_item.lt is not None | |
| ): | |
| constraints.append(f"<{metadata_item.lt}") | |
| if ( | |
| hasattr(metadata_item, "min_length") | |
| and metadata_item.min_length is not None | |
| ): | |
| constraints.append( | |
| f"min_length={metadata_item.min_length}" | |
| ) | |
| if ( | |
| hasattr(metadata_item, "max_length") | |
| and metadata_item.max_length is not None | |
| ): | |
| constraints.append( | |
| f"max_length={metadata_item.max_length}" | |
| ) | |
| if constraints: | |
| return " to ".join(constraints) | |
| # Check if it's an enum type | |
| if hasattr(field_type, "__members__"): | |
| return f"{len(field_type.__members__)} choices" | |
| # Check if it's a Union type with enums | |
| if get_origin(field_type) is Union: | |
| args = get_args(field_type) | |
| for arg in args: | |
| if hasattr(arg, "__members__"): | |
| return f"{len(arg.__members__)} choices" | |
| return "any" | |
| def get_user_input_field_type(field_path: str): | |
| """Get the field type from UserInput for a given field path. | |
| Args: | |
| field_path: Full path like "demographics.age_years" | |
| Returns: | |
| Field type from UserInput or None if not found | |
| """ | |
| # Navigate the field path | |
| parts = field_path.split(".") | |
| current_model = UserInput | |
| for part in parts: | |
| if hasattr(current_model, "model_fields"): | |
| field_info = current_model.model_fields.get(part) | |
| if field_info: | |
| # If this is the last part, return the field type | |
| if part == parts[-1]: | |
| return field_info.annotation | |
| # Otherwise, navigate deeper | |
| current_model = field_info.annotation | |
| return None | |
| def render_model_input_table( | |
| pdf: FPDF, model: RiskModel, _link_manager: LinkManager | |
| ) -> None: | |
| """Render input requirements table with 2 columns and hyperlinks. | |
| Columns: | |
| - Field Name (with hyperlinks to Section 2) | |
| - Format (model-specific or "same as user input") | |
| Args: | |
| pdf: Active PDF instance | |
| model: Risk model instance | |
| _link_manager: Link manager for creating hyperlinks (unused) | |
| """ | |
| requirements = extract_model_requirements(model) | |
| if not requirements: | |
| pdf.set_font("Helvetica", "", 10) | |
| pdf.cell(0, 6, "No input requirements defined for this model.", 0, 1) | |
| pdf.ln(4) | |
| return | |
| # Table dimensions - only 2 columns now | |
| table_width = pdf.w - 2 * pdf.l_margin | |
| field_width = table_width * 0.60 # Increased since we only have 2 columns | |
| format_width = table_width * 0.40 | |
| col_widths = [field_width, format_width] | |
| # Table header function for reuse on page breaks | |
| def render_table_header(): | |
| pdf.set_font("Helvetica", "B", 10) | |
| pdf.set_fill_color(*TABLE_HEADER_BACKGROUND) | |
| pdf.set_text_color(*TEXT_LIGHT) | |
| pdf.cell(field_width, 8, "Field Name", 0, 0, "L", True) | |
| pdf.cell(format_width, 8, "Format (when override)", 0, 1, "L", True) | |
| # Add spacing before table header | |
| pdf.ln(6) | |
| render_table_header() | |
| pdf.set_font("Helvetica", "", 9) | |
| pdf.set_text_color(*TEXT_DARK) | |
| line_height = 5.5 | |
| # Group fields by parent | |
| grouped_fields = group_fields_by_requirements(requirements) | |
| # Global index for alternating colors across all sub-fields | |
| global_sub_idx = 0 | |
| for parent_name, sub_fields in grouped_fields: | |
| # Check if we need a page break before the parent field | |
| if pdf.get_y() + 20 > pdf.h - pdf.b_margin: | |
| pdf.add_page() | |
| # Add spacing after page header and before table header | |
| pdf.ln(3) | |
| render_table_header() | |
| # Reset text color after page break to ensure readability | |
| pdf.set_text_color(*TEXT_DARK) | |
| # Render parent field name (bold) with fixed color | |
| pdf.set_font("Helvetica", "B", 9) | |
| pdf.set_text_color(*TEXT_DARK) | |
| render_table_row( | |
| pdf, | |
| [parent_name, ""], | |
| col_widths, | |
| line_height, | |
| (245, 245, 245), # Light gray for parent rows | |
| ) | |
| # Render sub-fields (normal weight, indented) with alternating colors | |
| pdf.set_font("Helvetica", "", 9) | |
| for field_path, field_type, _is_required in sub_fields: | |
| # Format field name | |
| sub_field_name = prettify_field_name(field_path.split(".")[-1]) | |
| indented_name = f" {sub_field_name}" | |
| # Determine format text based on whether model overrides UserInput | |
| user_input_field_type = get_user_input_field_type(field_path) | |
| if user_input_field_type and has_different_constraints( | |
| field_type, user_input_field_type | |
| ): | |
| # Extract just the constraint part from the field type | |
| format_text = extract_constraint_text(field_type) | |
| else: | |
| # Show "See User Input Doc" instead of "same as user input" | |
| format_text = "See User Input Doc" | |
| # Get position before rendering for hyperlink | |
| x_position = pdf.get_x() | |
| y_position = pdf.get_y() | |
| # Alternate colors for sub-fields using global index | |
| fill_color = ( | |
| ROW_BACKGROUND_LIGHT if global_sub_idx % 2 == 0 else ROW_BACKGROUND_ALT | |
| ) | |
| render_table_row( | |
| pdf, | |
| [indented_name, format_text], | |
| col_widths, | |
| line_height, | |
| fill_color, | |
| ) | |
| # TODO: Add hyperlinks to field names linking to Section 2 | |
| # Temporarily disabled to focus on format column functionality | |
| # Increment global index for next sub-field | |
| global_sub_idx += 1 | |
| pdf.ln(4) | |
| def _format_type(annotation: Any) -> str: | |
| origin = get_origin(annotation) | |
| args = get_args(annotation) | |
| if origin is list: | |
| inner = args[0] if args else Any | |
| return f"List[{_format_type(inner)}]" | |
| if origin is tuple: | |
| inner = ", ".join(_format_type(arg) for arg in args) | |
| return f"Tuple[{inner}]" | |
| if origin is dict: | |
| key_type = _format_type(args[0]) if args else "Any" | |
| value_type = _format_type(args[1]) if len(args) > 1 else "Any" | |
| return f"Dict[{key_type}, {value_type}]" | |
| if origin is Union and args: | |
| # PEP604 union | |
| return " | ".join(sorted({_format_type(arg) for arg in args})) | |
| if isinstance(annotation, type): | |
| try: | |
| if issubclass(annotation, BaseModel): | |
| return annotation.__name__ | |
| except TypeError: | |
| pass | |
| return annotation.__name__ | |
| if hasattr(annotation, "__name__"): | |
| return annotation.__name__ | |
| return str(annotation) | |
| def _collect_constraints(metadata: Iterable[Any]) -> dict[str, Any]: | |
| constraints: dict[str, Any] = {} | |
| for item in metadata: | |
| if isinstance(item, NUMERIC_CONSTRAINT_TYPES): | |
| attr = item.__class__.__name__.lower() | |
| for key, attr_name in { | |
| "min": "ge", | |
| "min_exclusive": "gt", | |
| "max": "le", | |
| "max_exclusive": "lt", | |
| }.items(): | |
| if hasattr(item, attr_name): | |
| constraints[key] = getattr(item, attr_name) | |
| elif hasattr(item, "min_length") or hasattr(item, "max_length"): | |
| for key, attr_name in { | |
| "min_length": "min_length", | |
| "max_length": "max_length", | |
| }.items(): | |
| value = getattr(item, attr_name, None) | |
| if value is not None: | |
| constraints[key] = value | |
| return constraints | |
| def parse_annotated_type(field_type: type) -> tuple[type, dict[str, Any]]: | |
| """Parse Annotated[Type, Field(...)] to extract base type and constraints. | |
| Args: | |
| field_type: The type annotation to parse | |
| Returns: | |
| (base_type, constraints_dict) | |
| """ | |
| origin = get_origin(field_type) | |
| args = get_args(field_type) | |
| if ( | |
| origin is not None | |
| and hasattr(origin, "__name__") | |
| and origin.__name__ == "Annotated" | |
| ): | |
| # Extract base type and metadata | |
| base_type = args[0] if args else field_type | |
| metadata = args[1:] if len(args) > 1 else [] | |
| # Extract constraints from Field(...) metadata | |
| constraints = {} | |
| for item in metadata: | |
| if hasattr(item, "__class__") and hasattr(item.__class__, "__name__"): | |
| if item.__class__.__name__ == "FieldInfo": | |
| # Extract Field constraints | |
| for attr_name in [ | |
| "ge", | |
| "gt", | |
| "le", | |
| "lt", | |
| "min_length", | |
| "max_length", | |
| ]: | |
| if hasattr(item, attr_name): | |
| value = getattr(item, attr_name) | |
| if value is not None: | |
| constraints[attr_name] = value | |
| elif item.__class__.__name__ in ["Ge", "Gt", "Le", "Lt"]: | |
| # Handle annotated_types constraints | |
| for key, attr_name in { | |
| "ge": "ge", | |
| "gt": "gt", | |
| "le": "le", | |
| "lt": "lt", | |
| }.items(): | |
| if hasattr(item, attr_name): | |
| constraints[key] = getattr(item, attr_name) | |
| return base_type, constraints | |
| else: | |
| # Handle Union types like Ethnicity | None | |
| if ( | |
| origin is not None | |
| and hasattr(origin, "__name__") | |
| and origin.__name__ == "Union" | |
| ): | |
| # Find the non-None type | |
| non_none_types = [arg for arg in args if arg is not type(None)] | |
| if non_none_types: | |
| return non_none_types[0], {} | |
| # Plain type, no constraints | |
| return field_type, {} | |
| def extract_model_requirements(model: RiskModel) -> list[tuple[str, type, bool]]: | |
| """Extract field requirements from a model's REQUIRED_INPUTS. | |
| Args: | |
| model: The risk model to extract requirements from | |
| Returns: | |
| list of (field_path, original_field_type, is_required) tuples | |
| """ | |
| requirements = [] | |
| for field_path, (field_type, is_required) in model.REQUIRED_INPUTS.items(): | |
| # Keep the original type annotation with constraints | |
| requirements.append((field_path, field_type, is_required)) | |
| return requirements | |
| def traverse_user_input_structure( | |
| model: type[BaseModel], prefix: str = "" | |
| ) -> list[tuple[str, str, type[BaseModel] | None]]: | |
| """Recursively traverse UserInput to find all nested models and leaf fields. | |
| Args: | |
| model: The BaseModel class to traverse | |
| prefix: Current path prefix for nested models | |
| Returns: | |
| list of (path, name, model_class) tuples where: | |
| - path: Full dotted path to the field/model | |
| - name: Display name for the field/model | |
| - model_class: The model class (None for leaf fields) | |
| """ | |
| structure = [] | |
| for field_name, field_info in model.model_fields.items(): | |
| field_path = f"{prefix}.{field_name}" if prefix else field_name | |
| field_type = field_info.annotation | |
| # First check: Direct BaseModel type | |
| if isinstance(field_type, type) and issubclass(field_type, BaseModel): | |
| structure.append((field_path, prettify_field_name(field_name), field_type)) | |
| structure.extend(traverse_user_input_structure(field_type, field_path)) | |
| # Second check: Generic Union/Optional handling (works for both Union[T, None] and T | None) | |
| elif hasattr(field_type, "__args__"): | |
| args = field_type.__args__ | |
| non_none_types = [arg for arg in args if arg is not type(None)] | |
| if non_none_types: | |
| try: | |
| first_type = non_none_types[0] | |
| # Check if it's a Union with BaseModel | |
| if issubclass(first_type, BaseModel): | |
| nested_model = first_type | |
| structure.append( | |
| (field_path, prettify_field_name(field_name), nested_model) | |
| ) | |
| structure.extend( | |
| traverse_user_input_structure(nested_model, field_path) | |
| ) | |
| # Check if it's a list | |
| elif ( | |
| hasattr(field_type, "__origin__") | |
| and hasattr(field_type.__origin__, "__name__") | |
| and field_type.__origin__.__name__ == "list" | |
| ): | |
| # Handle list types - check if it's a list of BaseModel | |
| if issubclass(first_type, BaseModel): | |
| list_item_model = first_type | |
| structure.append( | |
| ( | |
| field_path, | |
| prettify_field_name(field_name), | |
| list_item_model, | |
| ) | |
| ) | |
| # Recursively traverse the list item model | |
| structure.extend( | |
| traverse_user_input_structure( | |
| list_item_model, f"{field_path}[]" | |
| ) | |
| ) | |
| else: | |
| # List of primitive types (enum, str, int, etc.) - treat as leaf field | |
| structure.append( | |
| (field_path, prettify_field_name(field_name), None) | |
| ) | |
| else: | |
| # Leaf field (list of primitives, plain type, etc.) | |
| structure.append( | |
| (field_path, prettify_field_name(field_name), None) | |
| ) | |
| except TypeError: | |
| # Not a class, treat as leaf | |
| structure.append( | |
| (field_path, prettify_field_name(field_name), None) | |
| ) | |
| else: | |
| # No non-None types, treat as leaf | |
| structure.append((field_path, prettify_field_name(field_name), None)) | |
| # Third check: Everything else is a leaf field | |
| else: | |
| structure.append((field_path, prettify_field_name(field_name), None)) | |
| return structure | |
| def build_field_usage_map(models: list[RiskModel]) -> dict[str, list[tuple[str, bool]]]: | |
| """Build mapping of field_path -> [(model_name, is_required), ...] | |
| Args: | |
| models: List of risk model instances | |
| Returns: | |
| dict mapping each field path to list of (model_name, required_flag) tuples | |
| """ | |
| field_usage: dict[str, list[tuple[str, bool]]] = {} | |
| for model in models: | |
| for field_path, (_, is_required) in model.REQUIRED_INPUTS.items(): | |
| if field_path not in field_usage: | |
| field_usage[field_path] = [] | |
| field_usage[field_path].append((model.name, is_required)) | |
| # Sort each field's usage by model name | |
| for field_path in field_usage: | |
| field_usage[field_path].sort(key=lambda x: x[0]) | |
| return field_usage | |
| def extract_field_attributes( | |
| field_info, field_type | |
| ) -> tuple[str, str, str, str, type | None]: | |
| """Extract field attributes directly from Field metadata. | |
| Args: | |
| field_info: Pydantic field info object | |
| field_type: The field's type annotation | |
| Returns: | |
| tuple of (description, examples, constraints, used_by, enum_class) strings and enum class | |
| """ | |
| description = "-" | |
| examples = "-" | |
| constraints = "-" | |
| used_by = "-" | |
| enum_class = None | |
| # Extract description from Field | |
| if hasattr(field_info, "description") and field_info.description: | |
| description = field_info.description | |
| # Extract examples from Field - they are directly on the field_info object | |
| if hasattr(field_info, "examples") and field_info.examples: | |
| examples_list = field_info.examples | |
| if isinstance(examples_list, list): | |
| examples = ", ".join(str(ex) for ex in examples_list) | |
| # Extract constraints from Field metadata | |
| constraints_list = [] | |
| if hasattr(field_info, "metadata") and field_info.metadata: | |
| for item in field_info.metadata: | |
| if hasattr(item, "__class__") and hasattr(item.__class__, "__name__"): | |
| class_name = item.__class__.__name__ | |
| if class_name == "Ge" and hasattr(item, "ge"): | |
| constraints_list.append(f">={item.ge}") | |
| elif class_name == "Gt" and hasattr(item, "gt"): | |
| constraints_list.append(f">{item.gt}") | |
| elif class_name == "Le" and hasattr(item, "le"): | |
| constraints_list.append(f"<={item.le}") | |
| elif class_name == "Lt" and hasattr(item, "lt"): | |
| constraints_list.append(f"<{item.lt}") | |
| elif class_name == "MinLen" and hasattr(item, "min_length"): | |
| constraints_list.append(f"min_length={item.min_length}") | |
| elif class_name == "MaxLen" and hasattr(item, "max_length"): | |
| constraints_list.append(f"max_length={item.max_length}") | |
| if constraints_list: | |
| constraints = ", ".join(constraints_list) | |
| # Add enum count information if the field is an enum | |
| if hasattr(field_type, "__members__"): | |
| enum_count = len(field_type.__members__) | |
| enum_class = field_type | |
| if constraints == "-": | |
| constraints = f"{enum_count} choices" | |
| else: | |
| constraints = f"{constraints}, {enum_count} choices" | |
| elif hasattr(field_type, "__args__"): | |
| # Handle Union types - find enum in union | |
| for arg in field_type.__args__: | |
| if hasattr(arg, "__members__"): | |
| enum_count = len(arg.__members__) | |
| enum_class = arg | |
| if constraints == "-": | |
| constraints = f"{enum_count} choices" | |
| else: | |
| constraints = f"{constraints}, {enum_count} choices" | |
| break | |
| # Add format information for basic types if no constraints | |
| if constraints == "-": | |
| # Check if it's a boolean type | |
| if field_type is bool or ( | |
| hasattr(field_type, "__args__") and bool in field_type.__args__ | |
| ): | |
| constraints = "binary" | |
| # Check if it's a numeric type (int, float) without constraints | |
| elif field_type in (int, float) or ( | |
| hasattr(field_type, "__args__") | |
| and any(type_arg in field_type.__args__ for type_arg in (int, float)) | |
| ): | |
| constraints = "any number" | |
| # Check if it's a Date type | |
| elif (hasattr(field_type, "__name__") and field_type.__name__ == "date") or ( | |
| hasattr(field_type, "__args__") | |
| and any( | |
| hasattr(arg, "__name__") and arg.__name__ == "date" | |
| for arg in field_type.__args__ | |
| ) | |
| ): | |
| constraints = "date" | |
| return description, examples, constraints, used_by, enum_class | |
| def format_used_by(usage_list: list[tuple[str, bool]]) -> str: | |
| """Format the 'Used By' column with model names and req/opt indicators. | |
| Args: | |
| usage_list: List of (model_name, is_required) tuples | |
| Returns: | |
| Formatted string like "gail (req), boadicea (opt), claus (req)" | |
| """ | |
| if not usage_list: | |
| return "-" | |
| formatted_items = [] | |
| for model_name, is_required in usage_list: | |
| indicator = "req" if is_required else "opt" | |
| formatted_items.append(f"{model_name} ({indicator})") | |
| return ", ".join(formatted_items) | |
| def render_user_input_hierarchy( | |
| pdf: FPDF, models: list[RiskModel], link_manager: LinkManager | |
| ) -> None: | |
| """Render complete UserInput structure hierarchically. | |
| Args: | |
| pdf: Active PDF instance | |
| models: List of risk model instances | |
| link_manager: Link manager for creating hyperlinks | |
| """ | |
| # Initialize link manager for hyperlinks | |
| link_manager = LinkManager() | |
| # Build field usage mapping | |
| field_usage = build_field_usage_map(models) | |
| # Traverse the UserInput structure | |
| structure = traverse_user_input_structure(UserInput) | |
| # Group structure by parent path to handle mixed parent models properly | |
| parent_to_items = defaultdict(list) | |
| for field_path, field_name, model_class in structure: | |
| # Get parent path (everything before last dot, or empty for top-level) | |
| if "." in field_path: | |
| parent_path = ".".join(field_path.split(".")[:-1]) | |
| else: | |
| parent_path = "" | |
| parent_to_items[parent_path].append((field_path, field_name, model_class)) | |
| # Render sections in order, ensuring each parent gets its leaf fields rendered | |
| section_counter = 1 | |
| # Global row counter for alternating colors across all tables | |
| global_row_counter = [0] | |
| # Process items in the order they appear in the original structure | |
| processed_parents = set() | |
| for field_path, _field_name, _model_class in structure: | |
| # Get parent path for this item | |
| if "." in field_path: | |
| parent_path = ".".join(field_path.split(".")[:-1]) | |
| else: | |
| parent_path = "" | |
| # Skip if we've already processed this parent | |
| if parent_path in processed_parents: | |
| continue | |
| # Mark this parent as processed | |
| processed_parents.add(parent_path) | |
| # Get all items for this parent | |
| items = parent_to_items[parent_path] | |
| # Separate leaf fields from nested models | |
| leaf_fields = [] | |
| nested_models = [] | |
| for item_path, item_name, item_model_class in items: | |
| if item_model_class is not None: | |
| # This is a nested model | |
| nested_models.append((item_path, item_name, item_model_class)) | |
| else: | |
| # This is a leaf field | |
| leaf_fields.append((item_path, item_name)) | |
| # Render leaf fields for this parent if any exist | |
| if leaf_fields and parent_path: | |
| # Find the parent model info - look for the parent path in the structure | |
| parent_model_info = None | |
| for field_path, field_name, model_class in structure: | |
| if field_path == parent_path and model_class is not None: | |
| parent_model_info = (field_path, field_name, model_class) | |
| break | |
| if parent_model_info: | |
| # Get nested models for this parent | |
| nested_models_for_parent = [] | |
| for item_path, item_name, item_model_class in items: | |
| if item_model_class is not None and item_path != parent_path: | |
| nested_models_for_parent.append( | |
| (item_path, item_name, item_model_class) | |
| ) | |
| # Create link destination for this section | |
| link_manager.create_link_destination(pdf, parent_path) | |
| # Find parent info for this model (if it's not a root-level model) | |
| parent_info_for_current = None | |
| if parent_path: # Not root level | |
| parent_of_current = ( | |
| ".".join(parent_path.split(".")[:-1]) | |
| if "." in parent_path | |
| else "" | |
| ) | |
| if parent_of_current: # Has a parent | |
| # Only create parent info if the parent actually gets rendered (has leaf fields) | |
| parent_items = parent_to_items[parent_of_current] | |
| parent_has_leaf_fields = any( | |
| item_model_class is None | |
| for _, _, item_model_class in parent_items | |
| ) | |
| if ( | |
| parent_has_leaf_fields | |
| ): # Parent gets rendered, so we can reference it | |
| for field_path, field_name, model_class in structure: | |
| if ( | |
| field_path == parent_of_current | |
| and model_class is not None | |
| ): | |
| parent_info_for_current = ( | |
| field_path, | |
| field_name, | |
| model_class, | |
| ) | |
| break | |
| render_model_fields_table( | |
| pdf, | |
| parent_model_info, | |
| leaf_fields, | |
| field_usage, | |
| section_counter, | |
| link_manager, | |
| nested_models_for_parent, | |
| parent_info_for_current, | |
| global_row_counter, | |
| ) | |
| section_counter += 1 | |
| def render_model_fields_table( | |
| pdf: FPDF, | |
| model_info: tuple[str, str, type[BaseModel]], | |
| fields: list[tuple[str, str]], | |
| _field_usage: dict[str, list[tuple[str, bool]]], | |
| section_number: int, | |
| link_manager: LinkManager, | |
| nested_models_info: list[tuple[str, str, type[BaseModel]]] | None = None, | |
| parent_info: tuple[str, str, type[BaseModel]] | None = None, | |
| global_row_counter: list[int] | None = None, | |
| ) -> None: | |
| """Render a table for a specific model's fields. | |
| Args: | |
| pdf: Active PDF instance | |
| model_info: (path, name, model_class) tuple | |
| fields: List of (field_path, field_name) tuples for leaf fields | |
| _field_usage: Mapping of field paths to usage information (unused) | |
| section_number: Section number for heading | |
| link_manager: Link manager for hyperlinks | |
| nested_models_info: List of nested model info tuples | |
| parent_info: Parent model info tuple | |
| global_row_counter: Mutable list containing the global row counter for alternating colors | |
| """ | |
| if not model_info or not fields: | |
| return | |
| _model_path, model_name, model_class = model_info | |
| # Parent reference is now handled in the section title | |
| # Add section heading with uniform rendering technique | |
| pdf.set_font("Helvetica", "B", 12) | |
| pdf.set_text_color(*THEME_PRIMARY) | |
| if parent_info: | |
| parent_path, parent_name, _ = parent_info | |
| parent_link_id = link_manager.get_or_create_link_id(parent_path) | |
| # Render main title in primary color | |
| main_title = f"{section_number}. {model_name} " | |
| pdf.write(7, main_title) | |
| # Get current position for hyperlink | |
| x_position = pdf.get_x() | |
| y_position = pdf.get_y() | |
| # Render parenthetical part in dark gray | |
| parenthetical = f"(from {parent_name})" | |
| pdf.set_text_color(64, 64, 64) # Dark gray color | |
| pdf.write(7, parenthetical) | |
| # Add hyperlink to the parent name within the parenthetical | |
| text_width_before_parent = pdf.get_string_width(f"{main_title}(from ") | |
| parent_text_width = pdf.get_string_width(parent_name) | |
| pdf.link(x_position, y_position, parent_text_width, 7, parent_link_id) | |
| else: | |
| # Render single-color title | |
| title = f"{section_number}. {model_name}" | |
| pdf.write(7, title) | |
| # Add proper line spacing after the title (uniform for all) | |
| pdf.ln(8) | |
| # Create table for this model's fields (removed "Used By" column) | |
| table_width = pdf.w - 2 * pdf.l_margin | |
| field_width = table_width * 0.20 | |
| desc_width = table_width * 0.45 # Reduced from 0.50 to make room for examples | |
| examples_width = table_width * 0.20 # Increased from 0.15 to 0.20 | |
| constraints_width = table_width * 0.15 # Kept at 0.15 | |
| col_widths = [field_width, desc_width, examples_width, constraints_width] | |
| # Table header function for reuse on page breaks | |
| def render_table_header(): | |
| pdf.set_font("Helvetica", "B", 10) | |
| pdf.set_fill_color(*TABLE_HEADER_BACKGROUND) | |
| pdf.set_text_color(*TEXT_LIGHT) | |
| pdf.cell(field_width, 8, "Field Name", 0, 0, "L", True) | |
| pdf.cell(desc_width, 8, "Description", 0, 0, "L", True) | |
| pdf.cell(examples_width, 8, "Examples", 0, 0, "L", True) | |
| pdf.cell(constraints_width, 8, "Format", 0, 1, "L", True) | |
| # Check if we need a page break before rendering the table header | |
| # Account for table header height (8) plus some margin | |
| if pdf.get_y() + 15 > pdf.h - pdf.b_margin: | |
| pdf.add_page() | |
| # Add minimal spacing before table header to avoid conflicts with headings | |
| pdf.ln(1) | |
| render_table_header() | |
| # Table rows | |
| pdf.set_font("Helvetica", "", 9) | |
| pdf.set_text_color(*TEXT_DARK) | |
| line_height = 5.5 | |
| for field_path, field_name in fields: | |
| # Check if we need a page break before this row | |
| if pdf.get_y() + 15 > pdf.h - pdf.b_margin: | |
| pdf.add_page() | |
| # Add spacing after page header and before table header | |
| pdf.ln(3) | |
| render_table_header() | |
| # Reset text color and font after page break to ensure readability | |
| pdf.set_text_color(*TEXT_DARK) | |
| pdf.set_font("Helvetica", "", 9) # Reset to normal weight for table rows | |
| # Get field info from the model | |
| field_info = model_class.model_fields.get(field_path.split(".")[-1]) | |
| if not field_info: | |
| continue | |
| # Extract field attributes | |
| description, examples, constraints, _, enum_class = extract_field_attributes( | |
| field_info, field_info.annotation | |
| ) | |
| # Alternate row colors using global counter | |
| if global_row_counter is None: | |
| # This should never happen if called correctly | |
| current_row_index = 0 | |
| else: | |
| current_row_index = global_row_counter[0] | |
| # Increment global counter for next row | |
| global_row_counter[0] += 1 | |
| fill_color = ( | |
| ROW_BACKGROUND_LIGHT if current_row_index % 2 == 0 else ROW_BACKGROUND_ALT | |
| ) | |
| # Check if constraints contain enum choices and make them clickable | |
| if enum_class and "choices" in constraints: | |
| render_table_row_with_enum_link( | |
| pdf, | |
| [field_name, description, examples, constraints], | |
| col_widths, | |
| line_height, | |
| fill_color, | |
| enum_class, | |
| link_manager, | |
| ) | |
| else: | |
| render_table_row( | |
| pdf, | |
| [field_name, description, examples, constraints], | |
| col_widths, | |
| line_height, | |
| fill_color, | |
| ) | |
| # Add nested model rows if any exist | |
| if nested_models_info: | |
| for nested_path, nested_name, _nested_model_class in nested_models_info: | |
| # Get field info for the nested model field | |
| field_name = nested_path.split(".")[-1] | |
| field_info = model_class.model_fields.get(field_name) | |
| if not field_info: | |
| continue | |
| # Extract field attributes | |
| description, _, _, _, _ = extract_field_attributes( | |
| field_info, field_info.annotation | |
| ) | |
| # Get the section number for this nested model | |
| nested_link_id = link_manager.get_or_create_link_id(nested_path) | |
| # Create hyperlink text | |
| examples = f"See Section {nested_link_id}" | |
| constraints = "nested object" | |
| # Check if we need a page break before this row | |
| if pdf.get_y() + 15 > pdf.h - pdf.b_margin: | |
| pdf.add_page() | |
| # Add spacing after page header and before table header | |
| pdf.ln(10) | |
| render_table_header() | |
| # Reset text color and font after page break | |
| pdf.set_text_color(*TEXT_DARK) | |
| pdf.set_font("Helvetica", "", 9) | |
| # Use a lighter background for nested model rows, but still alternate | |
| if global_row_counter is None: | |
| current_row_index = 0 | |
| else: | |
| current_row_index = global_row_counter[0] | |
| global_row_counter[0] += 1 | |
| # Use light blue for nested models, but alternate the shade | |
| if current_row_index % 2 == 0: | |
| fill_color = (250, 250, 255) # Very light blue | |
| else: | |
| fill_color = (245, 245, 250) # Slightly darker light blue | |
| # Create hyperlink for the nested model | |
| x_position = pdf.get_x() | |
| y_position = pdf.get_y() | |
| render_table_row( | |
| pdf, | |
| [nested_name, description, examples, constraints], | |
| col_widths, | |
| line_height, | |
| fill_color, | |
| ) | |
| # Add the hyperlink to the examples cell | |
| examples_x = x_position + field_width + desc_width | |
| pdf.link( | |
| examples_x, y_position, examples_width, line_height, nested_link_id | |
| ) | |
| pdf.ln(6) | |
| class FieldSpec: | |
| """Descriptor capturing metadata for a UserInput field.""" | |
| path: str | |
| type_label: str | |
| required: bool | |
| default: Any | |
| choices: list[str] | None | |
| constraints: dict[str, Any] | |
| description: str | None | |
| def _iter_model_fields(model: type[BaseModel], prefix: str = "") -> Iterator[FieldSpec]: | |
| """Yield FieldSpec instances for every nested field in a model. | |
| Args: | |
| model (type[BaseModel]): Model class to introspect. | |
| prefix (str): Path prefix accumulated for nested fields. | |
| Yields: | |
| FieldSpec: Metadata describing each discovered field. | |
| """ | |
| for name, field in model.model_fields.items(): | |
| path = f"{prefix}{name}" if not prefix else f"{prefix}.{name}" | |
| annotation = field.annotation | |
| required = field.is_required() | |
| default = None if field.is_required() else field.default | |
| choices = _get_enum_choices(annotation) | |
| constraints = _collect_constraints(field.metadata) | |
| description = getattr(field, "description", None) or None | |
| type_label = _format_type(annotation) | |
| yield FieldSpec( | |
| path=path, | |
| type_label=type_label, | |
| required=required, | |
| default=default, | |
| choices=choices, | |
| constraints=constraints, | |
| description=description, | |
| ) | |
| try: | |
| if isinstance(annotation, type) and issubclass(annotation, BaseModel): | |
| yield from _iter_model_fields(annotation, path) | |
| continue | |
| except TypeError: | |
| pass | |
| origin = get_origin(annotation) | |
| args = get_args(annotation) | |
| if origin in {list, Iterable, list[Any]} and args: | |
| item = args[0] | |
| try: | |
| if isinstance(item, type) and issubclass(item, BaseModel): | |
| yield from _iter_model_fields(item, f"{path}[]") | |
| except TypeError: | |
| pass | |
| elif args: | |
| for arg in args: | |
| try: | |
| if isinstance(arg, type) and issubclass(arg, BaseModel): | |
| yield from _iter_model_fields(arg, path) | |
| except TypeError: | |
| continue | |
| USER_INPUT_FIELD_SPECS: dict[str, FieldSpec] = { | |
| field.path: field for field in _iter_model_fields(UserInput) | |
| } | |
| # --------------------------------------------------------------------------- | |
| # Presentation helpers | |
| # --------------------------------------------------------------------------- | |
| def _normalise_cancer_label(label: str) -> str: | |
| text = label.replace("_", " ").replace("-", " ") | |
| text = text.strip().lower() | |
| suffixes = [" cancer", " cancers"] | |
| for suffix in suffixes: | |
| if text.endswith(suffix): | |
| text = text[: -len(suffix)] | |
| break | |
| return text.strip().title() | |
| def _unique_qcancer_sites() -> list[str]: | |
| """Return the union of QCancer cancer sites across sexes. | |
| Returns: | |
| list[str]: Alphabetically ordered, normalised cancer site names. | |
| """ | |
| sites = set() | |
| for name in (*QC_FEMALE_CANCERS, *QC_MALE_CANCERS): | |
| sites.add(_normalise_cancer_label(name)) | |
| return sorted(sites) | |
| def cancer_types_for_model(model: RiskModel) -> list[str]: | |
| """Return the normalised cancer types handled by a model. | |
| Args: | |
| model: Risk model instance to inspect. | |
| Returns: | |
| list[str]: Collection of title-cased cancer type labels. | |
| """ | |
| if model.name == "qcancer": | |
| return _unique_qcancer_sites() | |
| raw = model.cancer_type() | |
| return [ | |
| _normalise_cancer_label(part.strip()) for part in raw.split(",") if part.strip() | |
| ] | |
| def add_section_heading(pdf: FPDF, index: str, title: str) -> None: | |
| """Render a primary section heading for the PDF. | |
| Args: | |
| pdf: Active PDF document. | |
| index: Section identifier prefix (e.g., "1"). | |
| title: Display title for the section. | |
| """ | |
| pdf.set_text_color(*TEXT_DARK) | |
| pdf.set_font("Helvetica", "B", 16) # Reduced from 20 to 16 | |
| pdf.cell(0, 10, f"{index}. {title}", 0, 1) # Reduced height from 14 to 10 | |
| pdf.ln(3) # Reduced spacing from 4 to 3 | |
| def add_subheading(pdf: FPDF, title: str) -> None: | |
| """Render a muted subheading label within the document. | |
| Args: | |
| pdf (FPDF): Active PDF document. | |
| title (str): Subheading text to display. | |
| """ | |
| pdf.set_text_color(*THEME_PRIMARY) # Changed to primary color | |
| pdf.set_font("Helvetica", "B", 12) # Reduced from 13 to 12 | |
| pdf.cell(0, 7, title, 0, 1) # Removed .upper(), reduced height from 8 to 7 | |
| pdf.ln(8) # Increased spacing significantly to prevent conflicts with table headers | |
| def draw_stat_card(pdf: FPDF, title: str, value: str, note: str, width: float) -> None: | |
| """Render a single metric card with title, value, and note. | |
| Args: | |
| pdf (FPDF): Active PDF instance. | |
| title (str): Brief descriptor for the metric. | |
| value (str): Primary value to highlight. | |
| note (str): Supporting text beneath the value. | |
| width (float): Width to allocate for the card (points). | |
| """ | |
| x_position = pdf.get_x() | |
| y_position = pdf.get_y() | |
| height = 32 | |
| pdf.set_fill_color(*CARD_BACKGROUND) | |
| pdf.set_draw_color(255, 255, 255) | |
| pdf.rect(x_position, y_position, width, height, "F") | |
| pdf.set_text_color(*THEME_MUTED) | |
| pdf.set_font("Helvetica", "B", 9) | |
| pdf.set_xy(x_position + 6, y_position + 5) | |
| pdf.cell(width - 12, 4, title.upper(), 0, 2, "L") | |
| pdf.set_text_color(*THEME_PRIMARY) | |
| pdf.set_font("Helvetica", "B", 18) | |
| pdf.cell(width - 12, 10, value, 0, 2, "L") | |
| pdf.set_text_color(*TEXT_DARK) | |
| pdf.set_font("Helvetica", "", 10) | |
| pdf.multi_cell(width - 12, 5, note, 0, "L") | |
| pdf.set_xy(x_position + width + 8, y_position) | |
| def render_summary_cards(pdf: FPDF, models: list[RiskModel]) -> None: | |
| """Render key summary metric cards derived from all models. | |
| Args: | |
| pdf (FPDF): Active PDF instance. | |
| models (list[RiskModel]): List of instantiated risk models. | |
| """ | |
| stats: list[tuple[str, str, str]] = [] | |
| # Calculate total cancer type coverage instances (sum of all bars in chart) | |
| total_coverage_instances = sum( | |
| len(cancer_types_for_model(model)) for model in models | |
| ) | |
| total_cancers = len( | |
| { | |
| cancer_type | |
| for model in models | |
| for cancer_type in cancer_types_for_model(model) | |
| } | |
| ) | |
| stats.append( | |
| ( | |
| "Risk Models", | |
| str(total_coverage_instances), | |
| "Total cancer type coverage instances", | |
| ) | |
| ) | |
| stats.append(("Cancer Types", str(total_cancers), "Unique cancer sites covered")) | |
| # New metrics | |
| stats.append( | |
| ( | |
| "Genes", | |
| str(count_genomic_mutations()), | |
| "Genetic mutations tracked", | |
| ) | |
| ) | |
| stats.append( | |
| ( | |
| "Lifestyle Factors", | |
| str(count_lifestyle_factors()), | |
| "Lifestyle fields available", | |
| ) | |
| ) | |
| stats.append( | |
| ( | |
| "Input Categories", | |
| str(count_total_input_categories()), | |
| "Total input fields across all categories", | |
| ) | |
| ) | |
| stats.append( | |
| ( | |
| "Discrete Choices", | |
| str(count_total_discrete_choices()), | |
| "Total number of categorical values available", | |
| ) | |
| ) | |
| available_width = pdf.w - 2 * pdf.l_margin | |
| columns = 3 # Changed from 2 to accommodate 6 cards | |
| gutter = 8 | |
| card_width = ( | |
| available_width - 2 * gutter | |
| ) / columns # Account for 2 gutters between 3 cards | |
| start_x = pdf.l_margin | |
| start_y = pdf.get_y() | |
| max_height_in_row = 0 | |
| for idx, (title, value, note) in enumerate(stats): | |
| col = idx % columns | |
| row = idx // columns | |
| x_position = start_x + col * (card_width + gutter) | |
| y_position = start_y + row * 38 # fixed row height | |
| pdf.set_xy(x_position, y_position) | |
| draw_stat_card(pdf, title, value, note, card_width) | |
| max_height_in_row = max(max_height_in_row, 36) | |
| rows = (len(stats) + columns - 1) // columns | |
| pdf.set_xy(pdf.l_margin, start_y + rows * (max_height_in_row + 2)) | |
| pdf.ln(4) | |
| def add_model_heading(pdf: FPDF, section_index: int, model_name: str) -> None: | |
| """Render the heading for an individual model section. | |
| Args: | |
| pdf (FPDF): Active PDF instance. | |
| section_index (int): Sequence number for the model section. | |
| model_name (str): Model identifier used in headings. | |
| """ | |
| pdf.set_text_color(*THEME_PRIMARY) | |
| pdf.set_font("Helvetica", "B", 15) | |
| display_name = model_name.replace("_", " ").title() | |
| pdf.cell(0, 10, f"2.{section_index} {display_name}", 0, 1) | |
| pdf.set_text_color(*TEXT_DARK) | |
| def render_model_summary(pdf: FPDF, model: RiskModel, cancer_types: list[str]) -> None: | |
| """Render textual summary for a risk model. | |
| Args: | |
| pdf (FPDF): Active PDF instance. | |
| model (RiskModel): The risk model instance. | |
| cancer_types (list[str]): Cancer types covered by the model. | |
| """ | |
| # Description | |
| pdf.set_font("Helvetica", "B", 10) | |
| pdf.set_text_color(*TEXT_DARK) | |
| pdf.cell(0, 5, "Description", 0, 1) | |
| pdf.set_font("Helvetica", "", 9) | |
| pdf.set_text_color(*TEXT_DARK) | |
| pdf.multi_cell(0, 4.5, model.description(), 0, "L") | |
| pdf.ln(1) | |
| # Interpretation | |
| pdf.set_font("Helvetica", "B", 10) | |
| pdf.set_text_color(*TEXT_DARK) | |
| pdf.cell(0, 5, "Interpretation", 0, 1) | |
| pdf.set_font("Helvetica", "", 9) | |
| pdf.set_text_color(*TEXT_DARK) | |
| pdf.multi_cell(0, 4.5, model.interpretation(), 0, "L") | |
| pdf.ln(1) | |
| # Cancer Types | |
| pdf.set_font("Helvetica", "B", 10) | |
| pdf.set_text_color(*TEXT_DARK) | |
| pdf.cell(0, 5, "Cancer Types", 0, 1) | |
| pdf.set_font("Helvetica", "", 9) | |
| pdf.set_text_color(*TEXT_DARK) | |
| pdf.multi_cell(0, 4.5, ", ".join(cancer_types), 0, "L") | |
| pdf.ln(1) | |
| # References | |
| pdf.set_font("Helvetica", "B", 10) | |
| pdf.set_text_color(*TEXT_DARK) | |
| pdf.cell(0, 5, "References", 0, 1) | |
| pdf.set_font("Helvetica", "", 8) | |
| pdf.set_text_color(*TEXT_DARK) | |
| references = model.references() | |
| for ref_index, ref in enumerate(references, 1): | |
| pdf.multi_cell(0, 4, f"{ref_index}. {ref}", 0, "L") | |
| pdf.ln(2) | |
| def prettify_field_name(segment: str) -> str: | |
| """Convert a field segment to a readable name. | |
| Args: | |
| segment: Raw field segment (e.g., "female_specific"). | |
| Returns: | |
| str: Title-cased, readable field name. | |
| """ | |
| plain = segment.replace("[]", "") | |
| return plain.replace("_", " ").title() | |
| def group_fields_by_requirements( | |
| requirements: list[tuple[str, type, bool]], | |
| ) -> list[tuple[str, list[tuple[str, type, bool]]]]: | |
| """Group field requirements by their parent field. | |
| Args: | |
| requirements: List of (field_path, field_type, is_required) tuples. | |
| Returns: | |
| List of (parent_name, sub_fields) tuples where sub_fields are the | |
| child fields under that parent. | |
| """ | |
| groups: dict[str, list[tuple[str, type, bool]]] = {} | |
| for field_path, field_type, is_required in requirements: | |
| segments = field_path.split(".") | |
| if len(segments) == 1: | |
| # Top-level field | |
| parent = prettify_field_name(segments[0]) | |
| if parent not in groups: | |
| groups[parent] = [] | |
| groups[parent].append((field_path, field_type, is_required)) | |
| else: | |
| # Nested field | |
| parent = prettify_field_name(segments[0]) | |
| if parent not in groups: | |
| groups[parent] = [] | |
| groups[parent].append((field_path, field_type, is_required)) | |
| return list(groups.items()) | |
| def format_field_path(path: str) -> str: | |
| """Format a dotted field path to highlight hierarchy. | |
| Args: | |
| path (str): Dotted field path (e.g., "family_history[].relation"). | |
| Returns: | |
| str: Multi-line string emphasising hierarchy with indentation markers. | |
| """ | |
| segments = path.split(".") | |
| if not segments: | |
| return path | |
| # Show parent labels with proper hierarchy | |
| lines = [] | |
| for idx, segment in enumerate(segments): | |
| cleaned = prettify_field_name(segment.strip()) | |
| if idx == 0: | |
| lines.append(cleaned) | |
| else: | |
| lines.append(f" - {cleaned}") | |
| return "\n".join(lines) | |
| def gather_spec_details( | |
| spec: FieldSpec | None, field_type: type | None = None, note: str = "" | |
| ) -> tuple[str, str, str, str]: | |
| """Prepare note, required status, unit, and constraint strings for a metadata row. | |
| Args: | |
| spec: Field metadata from UserInput introspection, if available. | |
| field_type: Type annotation from model's REQUIRED_INPUTS, if available. | |
| note: Model-specific note to include. | |
| Returns: | |
| tuple of (note_text, required_text, unit_text, range_text) strings for display. | |
| """ | |
| notes: list[str] = [] | |
| ranges: list[str] = [] | |
| units: list[str] = [] | |
| required_status = "Optional" | |
| # Extract constraints from field_type if provided | |
| type_constraints: dict[str, Any] = {} | |
| if field_type is not None: | |
| _, type_constraints = parse_annotated_type(field_type) | |
| # Special handling for clinical observations | |
| if note and " - " in note: | |
| obs_name, obs_values = note.split(" - ", 1) | |
| # Provide meaningful descriptions for clinical observations | |
| obs_descriptions = { | |
| "multivitamin": "Multivitamin usage status", | |
| "aspirin": "Aspirin usage history", | |
| "activity": "Physical activity level", | |
| "total_meat": "Red meat consumption", | |
| "pain_med": "NSAID/pain medication usage", | |
| "estrogen": "Estrogen therapy history", | |
| "diabetes": "Diabetes status", | |
| "height": "Height measurement", | |
| "weight": "Weight measurement", | |
| "years_of_education": "Years of formal education", | |
| "psa": "Prostate-specific antigen level", | |
| "percent_free_psa": "Percent free PSA", | |
| "pca3": "PCA3 score", | |
| "t2_erg": "T2:ERG score", | |
| "dre": "Digital rectal examination result", | |
| "prior_biopsy": "Prior biopsy history", | |
| "prior_psa": "Prior PSA screening history", | |
| } | |
| description = obs_descriptions.get( | |
| obs_name, f"Clinical observation: {obs_name}" | |
| ) | |
| notes.append(description) | |
| # Handle special cases for numeric clinical observations | |
| if obs_values.startswith("Numeric ("): | |
| # Extract unit from "Numeric (unit)" format | |
| unit_match = re.search(r"Numeric \(([^)]+)\)", obs_values) | |
| if unit_match: | |
| units.append(unit_match.group(1)) | |
| ranges.append("Numeric") | |
| else: | |
| ranges.append(obs_values) | |
| else: | |
| ranges.append(obs_values) | |
| note_text = "\n".join(notes) if notes else "-" | |
| range_text = "\n".join(ranges) if ranges else "-" | |
| unit_text = "\n".join(units) if units else "-" | |
| return note_text, required_status, unit_text, range_text | |
| # Handle model-specific range constraints (e.g., "Age 45-85") | |
| if note and any(char.isdigit() for char in note): | |
| # Check if note contains a range pattern like "45-85" or "35-85" | |
| range_match = re.search(r"(\d+)-(\d+)", note) | |
| if range_match: | |
| min_val, max_val = range_match.groups() | |
| ranges.append(f">={min_val} to <={max_val}") | |
| else: | |
| # If it's just a description without range, add to notes | |
| notes.append(note) | |
| elif note: | |
| # Regular note without range information | |
| notes.append(note) | |
| if spec: | |
| if spec.required: | |
| required_status = "Required" | |
| # Only add field description if we don't already have a model-specific note | |
| if spec.description and not note: | |
| notes.append(spec.description) | |
| # Special handling for specific fields | |
| if spec and spec.path == "demographics.sex": | |
| sex_choices = [choice.value for choice in Sex] | |
| ranges.append(", ".join(sex_choices)) | |
| elif spec and spec.path == "demographics.ethnicity": | |
| # CRC-PRO ethnicity choices | |
| ranges.append("hawaiian, japanese, latino, white, black") | |
| elif spec and spec.path == "lifestyle.smoking.pack_years": | |
| units.append("pack-years") | |
| ranges.append(">=0") | |
| elif spec and spec.path == "lifestyle.alcohol.drinks_per_week": | |
| units.append("drinks/week") | |
| ranges.append(">=0") | |
| elif spec and spec.path == "family_history[].cancer_type": | |
| # Predefined cancer types | |
| ranges.append( | |
| "breast, cervical, colorectal, endometrial, kidney, leukemia, liver, lung, lymphoma, ovarian, pancreatic, prostate, skin, stomach, testicular, thyroid, uterine, other" | |
| ) | |
| elif spec and spec.path == "demographics.anthropometrics.height_cm": | |
| units.append("cm") | |
| elif spec and spec.path == "demographics.anthropometrics.weight_kg": | |
| units.append("kg") | |
| elif spec and spec.path == "demographics.socioeconomic.education_level": | |
| units.append("years") | |
| # Add more specific field examples based on common patterns | |
| if spec: | |
| field_path = spec.path | |
| if "age" in field_path: | |
| if not ranges: | |
| ranges.append("Age in years") | |
| elif "height" in field_path: | |
| if not units: | |
| units.append("cm") | |
| if not ranges: | |
| ranges.append("Height measurement") | |
| elif "weight" in field_path: | |
| if not units: | |
| units.append("kg") | |
| if not ranges: | |
| ranges.append("Weight measurement") | |
| elif "bmi" in field_path: | |
| if not ranges: | |
| ranges.append("Body Mass Index") | |
| elif "smoking" in field_path: | |
| if not ranges: | |
| ranges.append("Smoking-related values") | |
| elif "alcohol" in field_path: | |
| if not ranges: | |
| ranges.append("Alcohol consumption values") | |
| elif "family_history" in field_path: | |
| if not ranges: | |
| ranges.append("Family history information") | |
| elif "clinical" in field_path or "test" in field_path: | |
| if not ranges: | |
| ranges.append("Clinical test values") | |
| elif "symptoms" in field_path: | |
| if not ranges: | |
| ranges.append("Symptom descriptions") | |
| elif "medical_history" in field_path: | |
| if not ranges: | |
| ranges.append("Medical history information") | |
| # Use type constraints from REQUIRED_INPUTS if available, otherwise fall back to spec constraints | |
| constraints_to_use = ( | |
| type_constraints if type_constraints else (spec.constraints if spec else {}) | |
| ) | |
| # Only use generic constraints if we don't have model-specific constraints | |
| # Model-specific constraints (from note) take precedence | |
| if not note and constraints_to_use: | |
| # Handle min/max constraints as a single range | |
| min_val = None | |
| min_exclusive = False | |
| max_val = None | |
| max_exclusive = False | |
| for key, value in constraints_to_use.items(): | |
| if key == "ge": # ge from Field constraint | |
| min_val = value | |
| elif key == "gt": # gt from Field constraint | |
| min_val = value | |
| min_exclusive = True | |
| elif key == "le": # le from Field constraint | |
| max_val = value | |
| elif key == "lt": # lt from Field constraint | |
| max_val = value | |
| max_exclusive = True | |
| elif key == "min": # Legacy min from spec | |
| min_val = value | |
| elif key == "min_exclusive": # Legacy min_exclusive from spec | |
| min_val = value | |
| min_exclusive = True | |
| elif key == "max": # Legacy max from spec | |
| max_val = value | |
| elif key == "max_exclusive": # Legacy max_exclusive from spec | |
| max_val = value | |
| max_exclusive = True | |
| elif key == "min_length": | |
| ranges.append(f"Min length {value}") | |
| elif key == "max_length": | |
| ranges.append(f"Max length {value}") | |
| else: | |
| ranges.append(f"{key.replace('_', ' ').title()}: {value}") | |
| # Format min/max as a single range | |
| if min_val is not None or max_val is not None: | |
| range_parts = [] | |
| if min_val is not None: | |
| range_parts.append(f"{'>' if min_exclusive else '>='}{min_val}") | |
| if max_val is not None: | |
| range_parts.append(f"{'<' if max_exclusive else '<='}{max_val}") | |
| if range_parts: | |
| ranges.append(" to ".join(range_parts)) | |
| # Add choices from spec if available and no note | |
| if not note and spec and spec.choices: | |
| ranges.append(", ".join(str(choice) for choice in spec.choices)) | |
| # If we still don't have any ranges/choices, try to provide examples based on field type | |
| if not ranges and not note: | |
| # Get the base type from field_type if available | |
| base_type = field_type | |
| if field_type is not None: | |
| base_type, _ = parse_annotated_type(field_type) | |
| # Provide examples based on type | |
| if base_type is bool: | |
| ranges.append("True, False") | |
| elif base_type is int: | |
| ranges.append("Integer values") | |
| elif base_type is float: | |
| ranges.append("Decimal values") | |
| elif base_type is str: | |
| ranges.append("Text values") | |
| elif hasattr(base_type, "__name__"): | |
| # For enum types, try to get the values | |
| if hasattr(base_type, "__members__"): | |
| enum_values = [ | |
| str(member.value) for member in base_type.__members__.values() | |
| ] | |
| if enum_values: | |
| ranges.append(", ".join(enum_values)) | |
| elif base_type.__name__ in [ | |
| "Sex", | |
| "Ethnicity", | |
| "CancerType", | |
| "FamilyRelation", | |
| "RelationshipDegree", | |
| ]: | |
| # Handle specific enum types we know about | |
| if base_type.__name__ == "Sex": | |
| ranges.append("male, female") | |
| elif base_type.__name__ == "Ethnicity": | |
| ranges.append( | |
| "white, black, asian, hispanic, pacific_islander, other" | |
| ) | |
| elif base_type.__name__ == "CancerType": | |
| ranges.append( | |
| "breast, cervical, colorectal, endometrial, kidney, leukemia, liver, lung, lymphoma, ovarian, pancreatic, prostate, skin, stomach, testicular, thyroid, uterine, other" | |
| ) | |
| elif base_type.__name__ == "FamilyRelation": | |
| ranges.append( | |
| "mother, father, sister, brother, daughter, son, grandmother, grandfather, aunt, uncle, cousin, other" | |
| ) | |
| elif base_type.__name__ == "RelationshipDegree": | |
| ranges.append("first, second, third") | |
| else: | |
| ranges.append(f"{base_type.__name__} values") | |
| elif str(base_type).startswith("typing.Union"): | |
| # Handle Union types | |
| ranges.append("Multiple types allowed") | |
| else: | |
| ranges.append("See model documentation") | |
| note_text = "\n".join(notes) if notes else "-" | |
| range_text = "\n".join(ranges) if ranges else "-" | |
| unit_text = "\n".join(units) if units else "-" | |
| return note_text, required_status, unit_text, range_text | |
| def wrap_text_to_width( | |
| pdf: FPDF, text: str, width: float, _line_height: float | |
| ) -> list[str]: | |
| """Wrap text to fit within the specified width. | |
| Args: | |
| pdf: FPDF instance for font metrics | |
| text: Text to wrap | |
| width: Maximum width in points | |
| _line_height: Height per line (unused) | |
| Returns: | |
| List of wrapped lines | |
| """ | |
| if not text or text == "-": | |
| return [text] | |
| # Get current font info | |
| font_size = pdf.font_size | |
| font_family = pdf.font_family | |
| # Estimate character width (rough approximation) | |
| char_width = font_size * 0.6 # Rough estimate for most fonts | |
| # Calculate max characters per line | |
| max_chars = int(width / char_width) | |
| if len(text) <= max_chars: | |
| return [text] | |
| # Split text into words and wrap | |
| words = text.split() | |
| lines = [] | |
| current_line = "" | |
| for word in words: | |
| test_line = current_line + (" " if current_line else "") + word | |
| if len(test_line) <= max_chars: | |
| current_line = test_line | |
| else: | |
| if current_line: | |
| lines.append(current_line) | |
| current_line = word | |
| else: | |
| # Word is too long, force break | |
| lines.append(word[:max_chars]) | |
| current_line = word[max_chars:] | |
| if current_line: | |
| lines.append(current_line) | |
| return lines | |
| def render_table_row( | |
| pdf: FPDF, | |
| texts: list[str], | |
| widths: list[float], | |
| line_height: float, | |
| fill_color: tuple[int, int, int], | |
| ) -> None: | |
| """Render a single row of the metadata table. | |
| Args: | |
| pdf (FPDF): Active PDF instance. | |
| texts (list[str]): Column label/value text. | |
| widths (list[float]): Column widths in points. | |
| line_height (float): Base line height to use for wrapped text. | |
| fill_color (tuple[int, int, int]): RGB tuple for row background. | |
| """ | |
| # Calculate the maximum height needed for this row by wrapping text | |
| max_lines = 0 | |
| wrapped_texts = [] | |
| for text, width in zip(texts, widths, strict=False): | |
| # Wrap text to fit the cell width | |
| wrapped_lines = wrap_text_to_width(pdf, text, width, line_height) | |
| wrapped_texts.append(wrapped_lines) | |
| max_lines = max(max_lines, len(wrapped_lines)) | |
| row_height = max_lines * line_height + 2 | |
| # Check if we need a page break before rendering the row | |
| if pdf.get_y() + row_height > pdf.h - pdf.b_margin: | |
| pdf.add_page() | |
| # Reset text color after page break to ensure readability | |
| pdf.set_text_color(*TEXT_DARK) | |
| x_position = pdf.get_x() | |
| y_position = pdf.get_y() | |
| # First, draw the background rectangle for the entire row | |
| pdf.set_fill_color(*fill_color) | |
| pdf.rect(x_position, y_position, sum(widths), row_height, "F") | |
| # Then draw the text in each cell with consistent height | |
| current_x = x_position | |
| for width, wrapped_lines in zip(widths, wrapped_texts, strict=False): | |
| pdf.set_xy(current_x, y_position) | |
| pdf.set_fill_color(*fill_color) | |
| # Draw each line of wrapped text | |
| for line_index, line in enumerate(wrapped_lines): | |
| pdf.set_xy(current_x, y_position + line_index * line_height) | |
| pdf.cell(width, line_height, line, border=0, align="L", fill=False) | |
| current_x += width | |
| # Draw the border around the entire row | |
| pdf.set_draw_color(*TABLE_BORDER) | |
| pdf.rect(x_position, y_position, sum(widths), row_height) | |
| pdf.set_xy(x_position, y_position + row_height) | |
| def render_table_row_with_enum_link( | |
| pdf: FPDF, | |
| texts: list[str], | |
| widths: list[float], | |
| line_height: float, | |
| fill_color: tuple[int, int, int], | |
| enum_class: type, | |
| link_manager: LinkManager, | |
| ) -> None: | |
| """Render a table row with clickable enum choices in the constraints column. | |
| Args: | |
| pdf: Active PDF instance | |
| texts: Column texts (constraints should be in the last column) | |
| widths: Column widths | |
| line_height: Line height for text | |
| fill_color: Background color for the row | |
| enum_class: The enum class to link to | |
| link_manager: Link manager for creating hyperlinks | |
| """ | |
| # Calculate the maximum height needed for this row by wrapping text | |
| max_lines = 0 | |
| wrapped_texts = [] | |
| for text, width in zip(texts, widths, strict=False): | |
| if not text: | |
| wrapped_lines = [""] | |
| else: | |
| # Wrap text to fit within the column width | |
| wrapped_lines = pdf.multi_cell( | |
| width, line_height, text, border=0, align="L", split_only=True | |
| ) | |
| wrapped_texts.append(wrapped_lines) | |
| max_lines = max(max_lines, len(wrapped_lines)) | |
| # Calculate the total row height | |
| row_height = max_lines * line_height | |
| # Check if we need a page break before rendering the row | |
| if pdf.get_y() + row_height > pdf.h - pdf.b_margin: | |
| pdf.add_page() | |
| # Reset text color after page break to ensure readability | |
| pdf.set_text_color(*TEXT_DARK) | |
| x_position = pdf.get_x() | |
| y_position = pdf.get_y() | |
| # First, draw the background rectangle for the entire row | |
| pdf.set_fill_color(*fill_color) | |
| pdf.rect(x_position, y_position, sum(widths), row_height, "F") | |
| # Then draw the text in each cell with consistent height | |
| current_x = x_position | |
| for column_index, (width, wrapped_lines) in enumerate( | |
| zip(widths, wrapped_texts, strict=False) | |
| ): | |
| pdf.set_xy(current_x, y_position) | |
| pdf.set_fill_color(*fill_color) | |
| # Draw each line of wrapped text | |
| for line_index, line in enumerate(wrapped_lines): | |
| pdf.set_xy(current_x, y_position + line_index * line_height) | |
| # If this is the constraints column (last column) and contains choices, make it clickable | |
| if column_index == len(texts) - 1 and "choices" in line and enum_class: | |
| # Store link information for later creation (after destinations are created) | |
| enum_link_id = f"enum_{enum_class.__name__}" | |
| link_manager.store_link_info( | |
| enum_link_id, | |
| pdf.get_x(), | |
| pdf.get_y(), | |
| pdf.get_string_width(line), | |
| line_height, | |
| ) | |
| # Render the text | |
| pdf.cell(width, line_height, line, border=0, align="L", fill=False) | |
| else: | |
| pdf.cell(width, line_height, line, border=0, align="L", fill=False) | |
| current_x += width | |
| # Draw the border around the entire row | |
| pdf.set_draw_color(*TABLE_BORDER) | |
| pdf.rect(x_position, y_position, sum(widths), row_height) | |
| pdf.set_xy(x_position, y_position + row_height) | |
| def render_field_table(pdf: FPDF, model: RiskModel) -> None: | |
| """Render the table of UserInput fields referenced by a model. | |
| Args: | |
| pdf (FPDF): Active PDF instance. | |
| model (RiskModel): The risk model to extract field requirements from. | |
| """ | |
| # Extract requirements from the model | |
| requirements = extract_model_requirements(model) | |
| if not requirements: | |
| pdf.set_font("Helvetica", "", 10) | |
| pdf.cell(0, 6, "No input requirements defined for this model.", 0, 1) | |
| pdf.ln(4) | |
| return | |
| table_width = pdf.w - 2 * pdf.l_margin | |
| field_width = table_width * 0.26 | |
| detail_width = table_width * 0.25 | |
| required_width = table_width * 0.10 | |
| unit_width = table_width * 0.10 | |
| range_width = table_width - field_width - detail_width - required_width - unit_width | |
| col_widths = [field_width, detail_width, required_width, unit_width, range_width] | |
| # Table header function for reuse on page breaks | |
| def render_table_header(): | |
| pdf.set_font("Helvetica", "B", 10) | |
| pdf.set_fill_color(*TABLE_HEADER_BACKGROUND) | |
| pdf.set_text_color(*TEXT_LIGHT) | |
| pdf.cell(field_width, 8, "Field", 0, 0, "L", True) | |
| pdf.cell(detail_width, 8, "Details", 0, 0, "L", True) | |
| pdf.cell(required_width, 8, "Required", 0, 0, "L", True) | |
| pdf.cell(unit_width, 8, "Unit", 0, 0, "L", True) | |
| pdf.cell(range_width, 8, "Choices / Range", 0, 1, "L", True) | |
| # Add minimal spacing before table header | |
| pdf.ln(1) | |
| render_table_header() | |
| pdf.set_font("Helvetica", "", 9) | |
| pdf.set_text_color(*TEXT_DARK) | |
| line_height = 5.5 | |
| # Group fields by parent | |
| grouped_fields = group_fields_by_requirements(requirements) | |
| # Define a fixed color for parent field rows (slightly darker than regular rows) | |
| PARENT_ROW_COLOR = (245, 245, 245) # Light gray for parent rows | |
| # Global index for alternating colors across all sub-fields | |
| global_sub_idx = 0 | |
| for parent_name, sub_fields in grouped_fields: | |
| # Check if we need a page break before the parent field | |
| if pdf.get_y() + 20 > pdf.h - pdf.b_margin: | |
| pdf.add_page() | |
| # Add spacing after page header and before table header | |
| pdf.ln(3) | |
| render_table_header() | |
| # Reset text color after page break to ensure readability | |
| pdf.set_text_color(*TEXT_DARK) | |
| # Render parent field name (bold) with fixed color | |
| pdf.set_font("Helvetica", "B", 9) | |
| pdf.set_text_color(*TEXT_DARK) | |
| render_table_row( | |
| pdf, | |
| [parent_name, "", "", "", ""], | |
| col_widths, | |
| line_height, | |
| PARENT_ROW_COLOR, | |
| ) | |
| # Render sub-fields (normal weight, indented) with alternating colors | |
| pdf.set_font("Helvetica", "", 9) | |
| for field_path, field_type, is_required in sub_fields: | |
| # Get the spec from UserInput introspection | |
| spec = USER_INPUT_FIELD_SPECS.get(field_path) | |
| # Use the field_type that was already extracted and parsed | |
| note_text, required_text, unit_text, range_text = gather_spec_details( | |
| spec, field_type, "" | |
| ) | |
| # Override required status based on model requirements | |
| required_text = "Required" if is_required else "Optional" | |
| # Indent the sub-field name | |
| sub_field_name = prettify_field_name(field_path.split(".")[-1]) | |
| indented_name = f" {sub_field_name}" | |
| # Alternate colors for sub-fields using global index | |
| fill_color = ( | |
| ROW_BACKGROUND_LIGHT if global_sub_idx % 2 == 0 else ROW_BACKGROUND_ALT | |
| ) | |
| render_table_row( | |
| pdf, | |
| [indented_name, note_text, required_text, unit_text, range_text], | |
| col_widths, | |
| line_height, | |
| fill_color, | |
| ) | |
| # Increment global index for next sub-field | |
| global_sub_idx += 1 | |
| pdf.ln(4) | |
| # --------------------------------------------------------------------------- | |
| # Risk model requirements are now extracted dynamically from each model's | |
| # REQUIRED_INPUTS class attribute, eliminating the need for hardcoded hints. | |
| # --------------------------------------------------------------------------- | |
| class PDF(FPDF): | |
| """Sentinel-styled PDF document with header and footer. | |
| Extends :class:`FPDF` to provide a branded header bar and page numbering | |
| consistent across the generated report. | |
| """ | |
| def header(self): | |
| """Render a discrete document header on all pages except the first.""" | |
| # Skip header on first page | |
| if self.page_no() == 1: | |
| return | |
| # Discrete header with title | |
| self.set_text_color(*THEME_MUTED) | |
| self.set_font("Helvetica", "", 10) | |
| self.set_y(8) | |
| self.cell(0, 6, "Sentinel Risk Model Documentation", 0, 0, "L") | |
| # Add a subtle line | |
| self.set_draw_color(*THEME_MUTED) | |
| self.line(self.l_margin, 16, self.w - self.r_margin, 16) | |
| # Add spacing after header to prevent overlap with content | |
| self.set_y(25) | |
| def footer(self): | |
| """Render the footer with page numbering.""" | |
| self.set_y(-15) | |
| self.set_text_color(*THEME_MUTED) | |
| self.set_font("Helvetica", "I", 8) | |
| self.cell(0, 10, f"Page {self.page_no()}", 0, 0, "C") | |
| def discover_risk_models() -> list[RiskModel]: | |
| """Discover and instantiate all risk model classes. | |
| Returns: | |
| list[RiskModel]: Instantiated models ordered by name. | |
| """ | |
| models = [] | |
| for file_path in MODELS_DIR.glob("*.py"): | |
| if file_path.stem.startswith("_"): | |
| continue | |
| module_name = f"sentinel.risk_models.{file_path.stem}" | |
| module = importlib.import_module(module_name) | |
| for _, obj in inspect.getmembers(module, inspect.isclass): | |
| if issubclass(obj, RiskModel) and obj is not RiskModel: | |
| models.append(obj()) | |
| return sorted(models, key=lambda m: m.name) | |
| def generate_coverage_chart(models: list[RiskModel]) -> None: | |
| """Generate and save a bar chart of cancer type coverage. | |
| Args: | |
| models (list[RiskModel]): Risk models to analyse for aggregate cancer coverage. | |
| """ | |
| coverage: dict[str, int] = defaultdict(int) | |
| for model in models: | |
| cancer_types = cancer_types_for_model(model) | |
| for cancer_type in cancer_types: | |
| coverage[cancer_type] += 1 | |
| sorted_coverage = sorted(coverage.items(), key=lambda item: item[1], reverse=True) | |
| cancer_types, counts = zip(*sorted_coverage, strict=False) | |
| cancer_types = list(cancer_types) | |
| counts = list(counts) | |
| plt.figure(figsize=(15, 6)) # Keep width, double height (15:6 ratio) | |
| plt.bar( | |
| cancer_types, counts, color=[color_value / 255 for color_value in THEME_PRIMARY] | |
| ) | |
| plt.xlabel("Cancer Type", fontsize=16, fontweight="bold") | |
| plt.ylabel("Number of Models", fontsize=16, fontweight="bold") | |
| plt.title("Cancer Type Coverage by Risk Models", fontsize=18, fontweight="bold") | |
| plt.xticks( | |
| rotation=45, ha="right", fontsize=14 | |
| ) # Rotate x-axis labels for better readability | |
| plt.yticks(fontsize=14) # Set y-axis tick font size | |
| plt.tight_layout() | |
| plt.savefig(CHART_FILE) | |
| plt.close() | |
| def render_summary_page(pdf: FPDF, link_manager: "LinkManager") -> None: | |
| """Render a summary page with hyperlinks to all sections. | |
| Args: | |
| pdf: Active PDF document. | |
| link_manager: Link manager for creating hyperlinks. | |
| """ | |
| # Title | |
| pdf.set_text_color(*TEXT_DARK) | |
| pdf.set_font("Helvetica", "B", 24) | |
| pdf.cell(0, 15, "Sentinel Risk Model Documentation", 0, 1, "C") | |
| pdf.ln(6) | |
| # Subtitle | |
| pdf.set_text_color(*THEME_MUTED) | |
| pdf.set_font("Helvetica", "", 12) | |
| pdf.cell(0, 8, "Comprehensive Guide to Cancer Risk Assessment Models", 0, 1, "C") | |
| pdf.ln(8) | |
| # Table of Contents | |
| pdf.set_text_color(*TEXT_DARK) | |
| pdf.set_font("Helvetica", "B", 16) | |
| pdf.cell(0, 8, "Table of Contents", 0, 1) | |
| pdf.ln(4) | |
| # Section 1: Overview | |
| pdf.set_font("Helvetica", "B", 12) | |
| pdf.set_text_color(*THEME_PRIMARY) | |
| # Create hyperlink for Overview section | |
| overview_link_id = link_manager.get_or_create_link_id("overview") | |
| x_position = pdf.get_x() | |
| y_position = pdf.get_y() | |
| pdf.cell(0, 7, "1. Overview", 0, 1) | |
| pdf.link( | |
| x_position, y_position, pdf.get_string_width("1. Overview"), 7, overview_link_id | |
| ) | |
| pdf.set_font("Helvetica", "", 10) | |
| pdf.set_text_color(*TEXT_DARK) | |
| pdf.cell(0, 5, " Key metrics, cancer coverage, and model statistics", 0, 1) | |
| pdf.ln(2) | |
| # Section 2: User Input Structure | |
| pdf.set_font("Helvetica", "B", 12) | |
| pdf.set_text_color(*THEME_PRIMARY) | |
| # Create hyperlink for User Input Structure section | |
| user_input_link_id = link_manager.get_or_create_link_id("user_input_structure") | |
| x_position = pdf.get_x() | |
| y_position = pdf.get_y() | |
| pdf.cell(0, 7, "2. User Input Structure & Requirements", 0, 1) | |
| pdf.link( | |
| x_position, | |
| y_position, | |
| pdf.get_string_width("2. User Input Structure & Requirements"), | |
| 7, | |
| user_input_link_id, | |
| ) | |
| pdf.set_font("Helvetica", "", 10) | |
| pdf.set_text_color(*TEXT_DARK) | |
| pdf.cell(0, 5, " Complete field definitions, examples, and constraints", 0, 1) | |
| pdf.ln(2) | |
| # Sub-sections for User Input (simplified - no hyperlinks for now) | |
| pdf.set_font("Helvetica", "", 10) | |
| pdf.set_text_color(*THEME_MUTED) | |
| # Get all sections that will be rendered | |
| structure = traverse_user_input_structure(UserInput) | |
| parent_to_items = defaultdict(list) | |
| for field_path, field_name, model_class in structure: | |
| if "." in field_path: | |
| parent_path = ".".join(field_path.split(".")[:-1]) | |
| else: | |
| parent_path = "" | |
| parent_to_items[parent_path].append((field_path, field_name, model_class)) | |
| section_counter = 1 | |
| processed_parents = set() | |
| for field_path, _field_name, _model_class in structure: | |
| if "." in field_path: | |
| parent_path = ".".join(field_path.split(".")[:-1]) | |
| else: | |
| parent_path = "" | |
| if parent_path in processed_parents: | |
| continue | |
| processed_parents.add(parent_path) | |
| items = parent_to_items[parent_path] | |
| leaf_fields = [] | |
| for item_path, item_name, item_model_class in items: | |
| if item_model_class is None: | |
| leaf_fields.append((item_path, item_name)) | |
| if leaf_fields and parent_path: | |
| # This section will be rendered | |
| # Find the model name from the structure | |
| for field_path_check, field_name_check, model_class_check in structure: | |
| if field_path_check == parent_path and model_class_check is not None: | |
| model_name = field_name_check.replace("_", " ").title() | |
| pdf.cell(0, 4, f" {section_counter}. {model_name}", 0, 1) | |
| section_counter += 1 | |
| break | |
| # Section 3: User Input Tabular Choices | |
| pdf.set_font("Helvetica", "B", 12) | |
| pdf.set_text_color(*THEME_PRIMARY) | |
| # Create hyperlink for User Input Tabular Choices section | |
| enum_choices_link_id = link_manager.get_or_create_link_id("user_input_enums") | |
| x_position = pdf.get_x() | |
| y_position = pdf.get_y() | |
| pdf.cell(0, 7, "3. User Input Tabular Choices", 0, 1) | |
| pdf.link( | |
| x_position, | |
| y_position, | |
| pdf.get_string_width("3. User Input Tabular Choices"), | |
| 7, | |
| enum_choices_link_id, | |
| ) | |
| pdf.set_font("Helvetica", "", 10) | |
| pdf.set_text_color(*TEXT_DARK) | |
| pdf.cell(0, 5, " Enum values and descriptions for all choice fields", 0, 1) | |
| pdf.ln(2) | |
| # Add subsections for Section 3 | |
| pdf.set_font("Helvetica", "", 10) | |
| pdf.set_text_color(*THEME_MUTED) | |
| # Get enum information to create subsections | |
| enums_by_parent = collect_all_enums() | |
| section_counter = 1 | |
| for parent_name, enums in enums_by_parent.items(): | |
| if not enums: | |
| continue | |
| # Format parent name | |
| formatted_parent_name = parent_name.replace("_", " ").title() | |
| # Create hyperlink for this subsection | |
| subsection_link_id = f"enum_{parent_name}" | |
| x_position = pdf.get_x() | |
| y_position = pdf.get_y() | |
| pdf.cell(0, 4, f" {section_counter}. {formatted_parent_name}", 0, 1) | |
| pdf.link( | |
| x_position, | |
| y_position, | |
| pdf.get_string_width(f" {section_counter}. {formatted_parent_name}"), | |
| 5, | |
| subsection_link_id, | |
| ) | |
| section_counter += 1 | |
| # Section 4: Risk Score Models | |
| pdf.set_font("Helvetica", "B", 12) | |
| pdf.set_text_color(*THEME_PRIMARY) | |
| # Create hyperlink for Risk Score Models section | |
| risk_models_link_id = link_manager.get_or_create_link_id("risk_models") | |
| x_position = pdf.get_x() | |
| y_position = pdf.get_y() | |
| pdf.cell(0, 7, "4. Risk Score Models", 0, 1) | |
| pdf.link( | |
| x_position, | |
| y_position, | |
| pdf.get_string_width("4. Risk Score Models"), | |
| 7, | |
| risk_models_link_id, | |
| ) | |
| pdf.set_font("Helvetica", "", 10) | |
| pdf.set_text_color(*TEXT_DARK) | |
| pdf.cell(0, 5, " Detailed documentation for each risk assessment model", 0, 1) | |
| pdf.ln(2) | |
| pdf.ln(5) | |
| # Footer note | |
| pdf.set_font("Helvetica", "I", 9) | |
| pdf.set_text_color(*THEME_MUTED) | |
| pdf.cell( | |
| 0, | |
| 6, | |
| "This document provides comprehensive documentation for all Sentinel risk models", | |
| 0, | |
| 1, | |
| "C", | |
| ) | |
| pdf.cell( | |
| 0, | |
| 6, | |
| "and their input requirements. Use the hyperlinks above to navigate to specific sections.", | |
| 0, | |
| 1, | |
| "C", | |
| ) | |
| def create_pdf(models: list[RiskModel], output_path: Path) -> None: | |
| """Create the PDF document summarising risk models and metadata. | |
| Args: | |
| models (list[RiskModel]): Ordered list of risk model instances to document. | |
| output_path (Path): Destination path where the PDF will be saved. | |
| """ | |
| pdf = PDF() | |
| pdf.add_page() | |
| pdf.set_margins(18, 20, 18) | |
| pdf.set_auto_page_break(auto=True, margin=15) | |
| # Initialize link manager | |
| link_manager = LinkManager() | |
| # --- Summary Section --- | |
| render_summary_page(pdf, link_manager) | |
| # --- Overview Section --- | |
| pdf.add_page() | |
| # Create link destination for Overview section | |
| link_manager.create_link_destination(pdf, "overview") | |
| # Add spacing after page header | |
| pdf.ln(3) | |
| add_section_heading(pdf, "1", "Overview") | |
| add_subheading(pdf, "Key Metrics") | |
| render_summary_cards(pdf, models) | |
| # Coverage Chart | |
| add_subheading(pdf, "Cancer Coverage") | |
| pdf.image(str(CHART_FILE), x=pdf.l_margin, w=pdf.w - 2 * pdf.l_margin) | |
| pdf.ln(12) | |
| # --- User Input Structure & Requirements Section --- | |
| pdf.add_page() | |
| # Create link destination for User Input Structure section | |
| link_manager.create_link_destination(pdf, "user_input_structure") | |
| # Add spacing after page header | |
| pdf.ln(3) | |
| add_section_heading(pdf, "2", "User Input Structure & Requirements") | |
| render_user_input_hierarchy(pdf, models, link_manager) | |
| # --- User Input Tabular Choices Section --- | |
| pdf.add_page() | |
| # Create link destination for User Input Tabular Choices section | |
| link_manager.create_link_destination(pdf, "user_input_enums") | |
| # Add spacing after page header | |
| pdf.ln(3) | |
| add_section_heading(pdf, "3", "User Input Tabular Choices") | |
| # Collect all enums and render the section | |
| enums_by_parent = collect_all_enums() | |
| render_enum_choices_section(pdf, enums_by_parent, link_manager) | |
| # --- Risk Models Section --- | |
| pdf.add_page() | |
| # Create link destination for Risk Models section | |
| link_manager.create_link_destination(pdf, "risk_models") | |
| # Add spacing after page header | |
| pdf.ln(3) | |
| add_section_heading(pdf, "4", "Risk Score Models") | |
| render_risk_models_section(pdf, models, link_manager) | |
| # Create all pending links after destinations are created | |
| link_manager.create_pending_links(pdf) | |
| pdf.output(output_path) | |
| print(f"Documentation successfully generated at: {output_path}") | |
| def main(): | |
| """Entry point for the documentation generator CLI.""" | |
| parser = argparse.ArgumentParser( | |
| description="Generate PDF documentation for Sentinel risk models." | |
| ) | |
| parser.add_argument( | |
| "--output", | |
| "-o", | |
| type=Path, | |
| default=OUTPUT_DIR / "risk_model_documentation.pdf", | |
| help="Output path for the PDF file.", | |
| ) | |
| args = parser.parse_args() | |
| OUTPUT_DIR.mkdir(exist_ok=True) | |
| print("Discovering risk models...") | |
| models = discover_risk_models() | |
| print("Generating cancer coverage chart...") | |
| generate_coverage_chart(models) | |
| print("Creating PDF document...") | |
| create_pdf(models, args.output) | |
| # Clean up the chart image | |
| CHART_FILE.unlink() | |
| if __name__ == "__main__": | |
| main() | |