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