Skip to content

Add blueprint record materializer#383

Merged
txmed82 merged 1 commit into
masterfrom
codex/blueprint-materializer
Jun 4, 2026
Merged

Add blueprint record materializer#383
txmed82 merged 1 commit into
masterfrom
codex/blueprint-materializer

Conversation

@txmed82

@txmed82 txmed82 commented Jun 4, 2026

Copy link
Copy Markdown
Owner

Summary

  • add a deterministic materializer from ClinicalBlueprint to SyntheticRecord scaffolds
  • preserve blueprint id, cohort plan id, release-readiness metadata, evidence citations, and prompt hash provenance
  • optionally require research-release-ready validation and persist records through DatasetStore

Tests

  • .venv/bin/python -m pytest tests/test_blueprint_materializer.py -q
  • .venv/bin/python -m ruff check src/casecrawler/generation/blueprint_materializer.py tests/test_blueprint_materializer.py
  • .venv/bin/python -m pytest tests/test_blueprint_materializer.py tests/test_blueprint_storage.py tests/test_fine_tuning_export.py::test_export_sft_record_contains_messages tests/test_fine_tuning_export.py::test_export_sft_record_includes_structured_context_without_documents -q
  • .venv/bin/python -m pytest -q -m 'not optional_backend and not network and not slow'

Summary by CodeRabbit

Release Notes

  • New Features

    • Blueprint materialization: Convert clinical blueprints into complete synthetic records with patient demographics, encounters, lab/vital observations, medications, and orders
    • Release-ready validation mode ensures records meet research standards before processing
    • Optional automatic persistence of materialized records to data store
  • Tests

    • Added comprehensive test coverage for blueprint materialization and validation workflows

@coderabbitai

coderabbitai Bot commented Jun 4, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR introduces BlueprintMaterializer, a new class that converts ClinicalBlueprint objects into persistable SyntheticRecord instances. The implementation enforces validation constraints, computes deterministic IDs, synthesizes clinical observations from blueprint data, and enriches records with provenance and metadata before optional storage.

Changes

Blueprint Materialization Implementation

Layer / File(s) Summary
BlueprintMaterializer class and module imports
src/casecrawler/generation/blueprint_materializer.py
Main BlueprintMaterializer class with constructor and materialize() entry point that orchestrates blueprint validation against BlueprintValidationReport release-readiness tier, constructs a complete SyntheticRecord from blueprint fields, and optionally persists via DatasetStore.
Deterministic ID and hash computation
src/casecrawler/generation/blueprint_materializer.py
_record_id() generates stable UUIDv5 record IDs from blueprint IDs; _blueprint_hash() computes SHA-256 hash of canonical JSON for provenance.
Clinical data transformation
src/casecrawler/generation/blueprint_materializer.py
_complexity() derives complexity profile from blueprint metadata or infers from uncertainty/differential; _modalities() conditionally appends lab/vital modalities; _patient() coerces demographics with safe defaults; _encounter() synthesizes encounter from timeline; _first_timeline_time() extracts first timestamp.
Observation and order materialization
src/casecrawler/generation/blueprint_materializer.py
_labs() builds LabObservation objects with value/unit/ranges; _vitals() builds VitalObservation entries with numeric coercion; _medications() constructs MedicationStatement objects; _orders() builds ClinicalOrder objects with deterministic ID generation.
Summary document and metadata enrichment
src/casecrawler/generation/blueprint_materializer.py
_summary_document() generates clinical document from blueprint fields with deterministic ID; _metadata() builds metadata dict from blueprint and conditionally augments with validation tier/schema/clinical/judge details.
Parsing and normalization utilities
src/casecrawler/generation/blueprint_materializer.py
_code_value() normalizes strings; _optional_str() and _optional_float() handle optional types; _float_value() parses floats with fallback to 0.0.

Materialization Tests

Layer / File(s) Summary
Test fixtures and imports
tests/test_blueprint_materializer.py
Test module setup with factories _blueprint() and _release_ready_report() that construct fully-populated sample data including patient demographics, diagnoses, observations, and validation configuration.
Materialization behavior verification
tests/test_blueprint_materializer.py
test_blueprint_materializer_creates_persistable_synthetic_record() verifies record construction with correct IDs, topic, complexity, modalities, clinical fields, document/provenance/metadata content, and storage round-trip; test_blueprint_materializer_blocks_when_release_ready_required() asserts ValueError when require_release_ready=True but validation tier is not release-ready.

Sequence Diagram

sequenceDiagram
  participant Client
  participant BlueprintMaterializer
  participant ValidationReport
  participant SyntheticRecord
  participant DatasetStore
  Client->>BlueprintMaterializer: materialize(blueprint, validation_report, store, require_release_ready)
  BlueprintMaterializer->>ValidationReport: validate release-readiness tier
  BlueprintMaterializer->>SyntheticRecord: construct record (patient, encounter, labs, vitals, meds, orders, document, provenance, metadata)
  alt store provided
    BlueprintMaterializer->>DatasetStore: save_record(synthetic_record)
  end
  BlueprintMaterializer->>Client: return SyntheticRecord
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • txmed82/case-crawler#371: Introduces ClinicalBlueprint and BlueprintValidationReport models that serve as input contracts for this PR's materialization logic and release-readiness enforcement.
  • txmed82/case-crawler#380: Provides BlueprintValidator and CLI integration that produces the BlueprintValidationReport consumed by BlueprintMaterializer.materialize() to enforce release-readiness constraints.

Poem

A blueprint blooms into record form,
With deterministic IDs, complexities warm;
Labs, vitals, and meds take their place,
All hashed and validated with grace. 🐰✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and concisely summarizes the main change: introducing a blueprint record materializer component.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/blueprint-materializer

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (3)
tests/test_blueprint_materializer.py (3)

147-161: ⚡ Quick win

Cover the missing-report release-readiness guard too.

This only exercises the “report present but not ready” branch. materialize() has a second guard for require_release_ready=True with validation_report=None, and that path is currently untested.

Suggested extra case
 def test_blueprint_materializer_blocks_when_release_ready_required():
     from casecrawler.generation.blueprint_materializer import BlueprintMaterializer

     with pytest.raises(ValueError, match="research release ready"):
         BlueprintMaterializer().materialize(
             _blueprint(),
             validation_report=BlueprintValidationReport(
                 blueprint_id="bp-1",
                 tier=ReleaseReadinessTier.CLINICALLY_PLAUSIBLE,
                 schema_valid=True,
                 clinically_plausible=True,
                 grounded=True,
             ),
             require_release_ready=True,
         )
+
+    with pytest.raises(ValueError, match="research release ready"):
+        BlueprintMaterializer().materialize(
+            _blueprint(),
+            require_release_ready=True,
+        )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_blueprint_materializer.py` around lines 147 - 161, Add a new test
that covers the branch where require_release_ready=True but validation_report is
None: call BlueprintMaterializer().materialize(_blueprint(),
validation_report=None, require_release_ready=True) and assert it raises
ValueError with the same "research release ready" message; this complements the
existing test_blueprint_materializer_blocks_when_release_ready_required which
only covers the present-but-not-ready report path and ensures
BlueprintMaterializer.materialize handles the missing-report guard.

108-115: ⚡ Quick win

Pin the provenance fields this PR adds.

This test misses the new provenance contract: cohort_plan_id, evidence_citations, and prompt_hash. A regression there would slip by even though those fields are called out in the PR summary.

Proposed assertions
-    record = BlueprintMaterializer(
+    blueprint = _blueprint()
+    record = BlueprintMaterializer(
         created_at="2026-05-06T10:00:00"
     ).materialize(
-        _blueprint(),
+        blueprint,
         validation_report=_release_ready_report(),
         store=store,
         require_release_ready=True,
     )
 ...
     assert record.provenance.generator == "blueprint-materializer"
     assert record.provenance.created_at == "2026-05-06T10:00:00"
     assert record.provenance.source_refs[0]["blueprint_id"] == "bp-1"
+    assert record.provenance.source_refs[0]["cohort_plan_id"] == blueprint.cohort_plan_id
+    assert record.provenance.source_refs[0]["evidence_citations"] == blueprint.evidence.citations
+    assert record.provenance.prompt_hash

Also applies to: 137-143

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_blueprint_materializer.py` around lines 108 - 115, The test needs
to assert the new provenance fields returned by
BlueprintMaterializer.materialize: add assertions that the materialized record
includes cohort_plan_id, evidence_citations, and prompt_hash with expected
pinned values (or explicit non-null checks if exact values vary) for the call at
lines around the existing materialize invocation and repeat the same assertions
for the other occurrence (around lines 137-143); update the test to check these
keys on the returned record (or nested provenance object) to prevent regressions
when those fields are added.

117-118: ⚡ Quick win

Assert the full deterministic record_id.

Line 117 only checks the rec- prefix, so a regression from stable UUIDv5 generation to any random rec-* value would still pass.

Proposed test tightening
+from uuid import NAMESPACE_URL, uuid5
+
 ...
-    assert record.record_id.startswith("rec-")
+    assert record.record_id == f"rec-{uuid5(NAMESPACE_URL, 'blueprint:bp-1')}"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_blueprint_materializer.py` around lines 117 - 118, Replace the
weak prefix check on record.record_id with a strict equality assertion so the
test verifies the deterministic full ID; specifically, instead of only asserting
record.record_id.startswith("rec-"), assert record.record_id ==
"<expected-deterministic-id>" (or compute expected_id using the same
deterministic UUIDv5 generator the code uses and the test inputs, e.g., the
dataset id "ds-1") so record.record_id (and still assert record.dataset_id ==
"ds-1") must exactly match the deterministic value produced by the production
generator.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/casecrawler/generation/blueprint_materializer.py`:
- Around line 320-330: The helper _optional_float currently defers to
_float_value which returns 0.0 on parse errors, breaking optional semantics;
change _optional_float to attempt float conversion itself and return None on
TypeError/ValueError (preserving None when input is None) instead of falling
through to _float_value; leave _float_value behavior unchanged if you want
vitals to default to 0.0, or alternatively update callers that use _float_value
(e.g., where vital `value` is processed) to explicitly handle parse failures if
you prefer surfacing bad input rather than defaulting to 0.0.
- Around line 123-134: The _patient function currently does
int(demographics.pop("age", 0)) which will raise on None or non-numeric values;
update _patient to defensively coerce age for SyntheticPatient.age by extracting
raw_age = demographics.pop("age", 0), then if raw_age is an int use it, if it's
a numeric string attempt to parse with int (catch ValueError/TypeError), and for
other types (None, dict, list, etc.) fall back to 0; ensure you handle floats by
converting to int safely and avoid raising exceptions so _patient always returns
a valid int age.

---

Nitpick comments:
In `@tests/test_blueprint_materializer.py`:
- Around line 147-161: Add a new test that covers the branch where
require_release_ready=True but validation_report is None: call
BlueprintMaterializer().materialize(_blueprint(), validation_report=None,
require_release_ready=True) and assert it raises ValueError with the same
"research release ready" message; this complements the existing
test_blueprint_materializer_blocks_when_release_ready_required which only covers
the present-but-not-ready report path and ensures
BlueprintMaterializer.materialize handles the missing-report guard.
- Around line 108-115: The test needs to assert the new provenance fields
returned by BlueprintMaterializer.materialize: add assertions that the
materialized record includes cohort_plan_id, evidence_citations, and prompt_hash
with expected pinned values (or explicit non-null checks if exact values vary)
for the call at lines around the existing materialize invocation and repeat the
same assertions for the other occurrence (around lines 137-143); update the test
to check these keys on the returned record (or nested provenance object) to
prevent regressions when those fields are added.
- Around line 117-118: Replace the weak prefix check on record.record_id with a
strict equality assertion so the test verifies the deterministic full ID;
specifically, instead of only asserting record.record_id.startswith("rec-"),
assert record.record_id == "<expected-deterministic-id>" (or compute expected_id
using the same deterministic UUIDv5 generator the code uses and the test inputs,
e.g., the dataset id "ds-1") so record.record_id (and still assert
record.dataset_id == "ds-1") must exactly match the deterministic value produced
by the production generator.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c9d2cdbb-194d-43b4-85ba-e980524231fb

📥 Commits

Reviewing files that changed from the base of the PR and between bcdabe2 and dce0141.

📒 Files selected for processing (2)
  • src/casecrawler/generation/blueprint_materializer.py
  • tests/test_blueprint_materializer.py

Comment on lines +123 to +134
def _patient(blueprint: ClinicalBlueprint) -> SyntheticPatient:
demographics = dict(blueprint.patient)
age = int(demographics.pop("age", 0))
sex = str(demographics.pop("sex", "unknown"))
social_history = demographics.pop("social_history", {})
return SyntheticPatient(
patient_id=f"pat-{uuid5(NAMESPACE_URL, f'patient:{blueprint.blueprint_id}')}",
age=age,
sex=sex,
demographics=demographics,
social_history=social_history if isinstance(social_history, dict) else {},
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm the declared types of ClinicalBlueprint.patient and SyntheticPatient.age
ast-grep --pattern 'class ClinicalBlueprint($$$):
  $$$'
rg -nP -C2 '\b(patient|age)\s*:' --type=py -g '**/models/blueprint.py' -g '**/models/synthetic.py'

Repository: txmed82/case-crawler

Length of output: 4595


Guard age coercion against malformed ClinicalBlueprint.patient values.

ClinicalBlueprint.patient is dict[str, Any] while SyntheticPatient.age is int. int(demographics.pop("age", 0)) will raise on None or non-numeric values, unlike the social_history type guard.

🛡️ Proposed defensive coercion
-    demographics = dict(blueprint.patient)
-    age = int(demographics.pop("age", 0))
-    sex = str(demographics.pop("sex", "unknown"))
+    demographics = dict(blueprint.patient)
+    raw_age = demographics.pop("age", 0)
+    try:
+        age = int(raw_age)
+    except (TypeError, ValueError):
+        age = 0
+    sex_value = demographics.pop("sex", "unknown")
+    sex = str(sex_value) if sex_value is not None else "unknown"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/casecrawler/generation/blueprint_materializer.py` around lines 123 - 134,
The _patient function currently does int(demographics.pop("age", 0)) which will
raise on None or non-numeric values; update _patient to defensively coerce age
for SyntheticPatient.age by extracting raw_age = demographics.pop("age", 0),
then if raw_age is an int use it, if it's a numeric string attempt to parse with
int (catch ValueError/TypeError), and for other types (None, dict, list, etc.)
fall back to 0; ensure you handle floats by converting to int safely and avoid
raising exceptions so _patient always returns a valid int age.

Comment on lines +320 to +330
def _optional_float(value) -> float | None:
if value is None:
return None
return _float_value(value)


def _float_value(value) -> float:
try:
return float(value)
except (TypeError, ValueError):
return 0.0

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

_optional_float silently coerces malformed values to 0.0.

_optional_float preserves None, but a present-but-unparseable value falls through to _float_value and becomes 0.0 rather than None. This breaks the "optional" semantics for lab reference_low/reference_high (Line 172-173) and, via _float_value, makes a bad vital value (Line 192) silently read as 0.0 — a clinically misleading number that masks the parse failure in generated records.

Consider returning None from _optional_float on parse failure, and deciding whether vitals should default to 0.0 or surface the bad input explicitly.

🛡️ Proposed change to preserve optional semantics
 def _optional_float(value) -> float | None:
     if value is None:
         return None
-    return _float_value(value)
+    try:
+        return float(value)
+    except (TypeError, ValueError):
+        return None
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/casecrawler/generation/blueprint_materializer.py` around lines 320 - 330,
The helper _optional_float currently defers to _float_value which returns 0.0 on
parse errors, breaking optional semantics; change _optional_float to attempt
float conversion itself and return None on TypeError/ValueError (preserving None
when input is None) instead of falling through to _float_value; leave
_float_value behavior unchanged if you want vitals to default to 0.0, or
alternatively update callers that use _float_value (e.g., where vital `value` is
processed) to explicitly handle parse failures if you prefer surfacing bad input
rather than defaulting to 0.0.

@txmed82 txmed82 merged commit 71cee3b into master Jun 4, 2026
4 checks passed
@txmed82 txmed82 deleted the codex/blueprint-materializer branch June 4, 2026 02:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant