|
14 | 14 |
|
15 | 15 | import hashlib |
16 | 16 | import json |
| 17 | +from collections.abc import Sequence |
17 | 18 | from concurrent.futures import ThreadPoolExecutor |
18 | 19 | from datetime import UTC, datetime |
19 | 20 | from importlib import import_module, resources |
20 | 21 | from pathlib import Path |
21 | | -from typing import Any, Self |
| 22 | +from typing import Any, Self, cast |
22 | 23 |
|
23 | 24 | import polars as pl |
24 | 25 | from clinical_mining.provider.aact import extract_clinical_report |
|
28 | 29 | parse_batch_results, |
29 | 30 | sample_report, |
30 | 31 | ) |
| 32 | +from clinical_mining.workflows import llm as llm_workflow |
31 | 33 | from clinical_mining.workflows.llm import run_extraction |
32 | 34 | from loguru import logger |
33 | 35 | from otter.manifest.model import Artifact |
@@ -381,5 +383,52 @@ def _run_extraction_in_thread(**kwargs: Any) -> pl.DataFrame | None: |
381 | 383 | a short-lived worker thread gives it a thread-local loop without changing |
382 | 384 | either library's public API. |
383 | 385 | """ |
| 386 | + def run_with_full_schema_inference() -> pl.DataFrame | None: |
| 387 | + # clinical-mining's default inference samples only the first 100 model |
| 388 | + # responses. Later validated nested values can then disagree with that |
| 389 | + # inferred schema, losing the whole shard before cached_map can stage it. |
| 390 | + original = llm_workflow._extractions_to_df |
| 391 | + |
| 392 | + def to_df(extractions: Sequence[BaseModel]) -> pl.DataFrame: |
| 393 | + return _models_to_dataframe(extractions) |
| 394 | + |
| 395 | + workflow = cast(Any, llm_workflow) |
| 396 | + workflow._extractions_to_df = to_df |
| 397 | + try: |
| 398 | + return run_extraction(**kwargs) |
| 399 | + finally: |
| 400 | + workflow._extractions_to_df = original |
| 401 | + |
384 | 402 | with ThreadPoolExecutor(max_workers=1, thread_name_prefix='llm-extraction') as executor: |
385 | | - return executor.submit(run_extraction, **kwargs).result() |
| 403 | + return executor.submit(run_with_full_schema_inference).result() |
| 404 | + |
| 405 | + |
| 406 | +def _models_to_dataframe(extractions: Sequence[BaseModel]) -> pl.DataFrame: |
| 407 | + """Convert validated model results without losing a whole shard on one row. |
| 408 | +
|
| 409 | + Full inference handles nullable fields that only become populated late in a |
| 410 | + shard. The row-wise fallback protects the checkpoint boundary if a model |
| 411 | + response still exposes a value Polars cannot combine with its neighbours. |
| 412 | + """ |
| 413 | + if not extractions: |
| 414 | + return pl.DataFrame() |
| 415 | + |
| 416 | + rows = [extraction.model_dump() for extraction in extractions] |
| 417 | + try: |
| 418 | + return pl.from_dicts(rows, infer_schema_length=None) |
| 419 | + except pl.exceptions.PolarsError as error: |
| 420 | + logger.warning( |
| 421 | + f'could not combine {len(rows)} extractions at once: {error}; ' |
| 422 | + 'falling back to row-wise conversion' |
| 423 | + ) |
| 424 | + |
| 425 | + converted: list[pl.DataFrame] = [] |
| 426 | + for row in rows: |
| 427 | + try: |
| 428 | + converted.append(pl.from_dicts([row], infer_schema_length=None)) |
| 429 | + except pl.exceptions.PolarsError as error: |
| 430 | + logger.error(f'dropping one validated extraction during dataframe conversion: {error}') |
| 431 | + |
| 432 | + if not converted: |
| 433 | + return pl.DataFrame() |
| 434 | + return pl.concat(converted, how='diagonal_relaxed') |
0 commit comments