Skip to content

Commit f829a5b

Browse files
committed
Improve large-guideline parsing and WHO extraction handling.
Add chunked parse/merge for large guidelines and harden query normalization so real WHO guideline runs produce much broader, more usable output instead of failing on LLM formatting and vocabulary mismatches. Made-with: Cursor
1 parent cdfe7b8 commit f829a5b

11 files changed

Lines changed: 1298 additions & 118 deletions

File tree

CHANGES_LOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22

33
## 2026-03-25
44

5+
- **Add**: automatic chunked parsing for large guidelines in `parse.py`. Herald now narrows to recommendation-heavy sections, splits by subsection headings, parses chunks independently, and merges metadata/fields/synonyms/decisions with deterministic ID conflict handling.
6+
- **Fix**: chunk parsing now prefers the longest real recommendations chapter instead of the table-of-contents hit, and sanitizes common LLM schema-shape errors before validation (for example bool field value arrays and `null` recommendation strings). Rationale: the first real WHO chunked runs exposed TOC mis-detection and predictable malformed field shapes.
7+
- **Fix**: query normalization now handles canonical enum values with underscores, pluralized guideline vocab (`adult``adults`), null enum vocab lists, and composite values like `children_and_adolescents`. Rationale: the WHO chunked parse exposed valid recommendations that were being missed at query time due to vocab-shape mismatches.
8+
- **Fix**: `parse.py` now preserves `field_synonyms`, extracts JSON from common LLM wrapper text, and raises the default LLM output budget to `20000` tokens. Rationale: the real WHO mhGAP parse exposed dropped synonyms, prose-before-JSON responses, and mid-JSON truncation.
9+
- **Fix**: `query.py` now handles parsed `patient_fields` with `values: null` / `known_values: null`, and `any_match` now supports scalar membership against expected enum lists. Rationale: the WHO parse produced these shapes and they broke or suppressed valid queries.
10+
- **Fix**: CLI commands now surface expected runtime/data failures as clean `Error: ...` messages instead of raw Python tracebacks. Added `tests/test_cli.py` to lock in missing-API-key and invalid-JSON behavior.
11+
- **Fix**: `convert.py` now strips form-feed page-break characters from `markitdown` output before whitespace normalization. This reduces real-PDF ingestion noise from the WHO mhGAP guideline. Also corrected stale install hints from `guideline-as-code[...]` to `herald-cpg[...]`.
12+
- **Add**: `workflow_state.md` — bounded Planner packet for a repo-wide quality pass using the WHO mhGAP guideline PDF; keeps execution and judging disciplined.
513
- **Fix**: `herald --version` crashed with `RuntimeError: 'herald_cli' is not installed`. Root cause: `@click.version_option()` inferred package name from module (`herald_cli`) instead of the installed package (`herald-cpg`). Fixed by passing `package_name="herald-cpg"`.
614

715
- **Add**: `tests/test_diff.py` — 21 tests covering `diff_guidelines()` (identical, added, removed, modified nodes/metadata/fields, complex mixed diffs) and `format_markdown()` (all output sections). `diff.py` now at 99% coverage.

PROJECT_TECHNICAL_OVERVIEW.md

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,10 @@ src/herald_cli/
2020
└── export.py # Export decision tree to FHIR PlanDefinition (CPG-on-FHIR)
2121
2222
tests/
23-
├── test_convert.py # Markdown normalization (4 tests)
24-
├── test_parse.py # Schema models + reference validation (8 tests)
25-
├── test_query.py # Query engine + NLP patient parser (20 tests)
23+
├── test_convert.py # Markdown normalization (5 tests)
24+
├── test_cli.py # CLI error handling (3 tests)
25+
├── test_parse.py # Schema models + chunk split/merge + reference validation
26+
├── test_query.py # Query engine + NLP patient parser + enum normalization
2627
├── test_validate.py # Source text verification (5 tests)
2728
├── test_diff.py # Guideline version diffing (21 tests)
2829
└── test_export.py # FHIR PlanDefinition export (40 tests)
@@ -41,12 +42,36 @@ examples/
4142
- `MultiQueryEngine`: Queries multiple guidelines simultaneously with conflict detection.
4243
- `parse_patient_description()`: NLP parser that converts natural language ("34F ADHD, comorbid anxiety") into structured patient dicts. Handles age/sex extraction, medical abbreviations, negation detection, vital signs, medication history, and guideline-driven dynamic field extraction via synonyms.
4344
- `parse_csv_patients()`: Batch processing from CSV.
45+
- Query extraction now tolerates `values: null` / `known_values: null` in parsed `patient_fields`.
46+
- Condition matching now treats `any_match` with a scalar patient value plus a list of expected enum values as membership, which makes the engine more resilient to imperfect LLM condition encoding.
47+
- Enum extraction now matches canonical values with underscores normalized to spaces (for example `bipolar_disorder``bipolar disorder`), aligns derived enum values to guideline vocab (`adult``adults`), and treats composite values like `children_and_adolescents` as matching child/adolescent patients.
48+
49+
### `cli.py` — User-Facing Command Layer
50+
51+
- Wraps expected runtime/data failures (`RuntimeError`, `ValueError`, `OSError`, JSON decode errors) as `click.ClickException`, so the CLI prints clean `Error: ...` messages instead of Python tracebacks.
52+
- Manual CLI coverage now includes `--help`, `--version`, `convert`, `parse` missing-key failure path, `query`, `validate`, `diff`, `export`, batch query, audit logging, and multi-guideline query.
4453

4554
### `parse.py` — LLM Extraction
4655

4756
- Pydantic models: `GuidelineDecisionTree`, `DecisionNode`, `Condition`, `Recommendation`, `Branch`, `PatientField`, `GuidelineMeta`.
4857
- Supports Anthropic (claude-sonnet-4-20250514 default) and OpenAI (gpt-4o default).
4958
- `_validate_references()`: Ensures all branch `next_decision` IDs point to valid nodes.
59+
- Provider dependency/install error strings now reference the correct package extras: `herald-cpg[anthropic]` and `herald-cpg[openai]`.
60+
- Parsed trees now preserve `field_synonyms`, matching the documented schema and what `query.py` expects.
61+
- The parser now strips common LLM wrapper text and extracts fenced/embedded JSON payloads before `json.loads()`.
62+
- Default output budget for LLM parse calls is now `20000` tokens so large real-world guideline parses are less likely to truncate mid-JSON.
63+
- Large guidelines now automatically switch to chunked parsing:
64+
- tries to narrow to a recommendations chapter when one is clearly present
65+
- splits by numbered subsection headings (fallback: markdown headings, then paragraph-size chunks)
66+
- sends each chunk with shared top-of-document metadata context
67+
- merges guideline metadata, patient fields, synonyms, and decisions deterministically
68+
- renames conflicting decision IDs safely and validates references after merge
69+
- Before schema validation, parse output is sanitized to clean up common LLM shape mistakes seen in real runs (for example `bool` fields incorrectly carrying `[true, false]` value arrays, or nullable recommendation string fields arriving as `null`).
70+
71+
### `convert.py` — PDF Conversion
72+
73+
- Uses Microsoft `markitdown`, then post-processes output with `_normalize_markdown()`.
74+
- Normalization now strips embedded form-feed/page-break characters before collapsing whitespace so real PDF conversions do not leak `\f` artifacts into downstream parse input.
5075

5176
### `export.py` — FHIR Export
5277

@@ -64,10 +89,15 @@ examples/
6489
```bash
6590
pip install -e ".[dev]" # Install with dev deps (pytest, ruff, pytest-cov)
6691
ruff check src/ tests/ # Lint
67-
pytest tests/ -v --tb=short # Run tests (98 tests)
92+
pytest tests/ -v --tb=short # Run tests (101 tests)
6893
pytest tests/ --cov=herald_cli # Coverage report
6994
```
7095

96+
## Workflow Discipline
97+
98+
- `workflow_state.md` tracks the active Planner-owned task packet, success criteria, and verification steps for non-trivial work.
99+
- Real-system validation matters for this project because the highest-risk paths are CLI execution, PDF conversion fidelity, and LLM-backed parsing behavior against real guideline documents.
100+
71101
## Schema
72102

73103
Decision trees follow Herald Schema v0.1 (see `SCHEMA.md`). Every recommendation traces back to `source_section`, `source_page`, and `source_text` for auditability.
@@ -83,4 +113,9 @@ Decision trees follow Herald Schema v0.1 (see `SCHEMA.md`). Every recommendation
83113
## Known Issues
84114

85115
- Repo state: `Desktop/herald` has source files deleted from HEAD. Full project only in Downloads copies.
86-
- Coverage: `cli.py` at 0% (no CLI integration tests). `query.py` at 47% — many extractors and batch/multi-engine paths uncovered.
116+
- `query.py` coverage is still relatively low for the full NLP extraction surface and multi-guideline behavior.
117+
- Real WHO PDF conversion still includes repeated running headers/page numbers and some OCR-style spacing artifacts such as `E xecutive summar y`, even though form-feed control characters are now stripped.
118+
- Multi-guideline queries return duplicate recommendations if equivalent guidelines are loaded more than once; no deduplication layer exists yet.
119+
- After chunked parsing, the WHO mhGAP parse improved from 14 to 19 decision nodes and became materially more queryable (anxiety, ADHD, bipolar mania, and canonical-phrase depression queries returned results), but fidelity remains low at 50% with partial-only matches. The remaining issue is extraction quality, not parser/runtime stability.
120+
- Real-world large-guideline extraction still depends heavily on the model producing good local condition vocab. For example, some WHO diagnoses/age buckets were emitted in awkward canonical forms (`moderate_to_severe_depression`, `children_and_adolescents`) that required extra query normalization.
121+
- The fully chunked WHO rerun currently requires more than five minutes for the complete 23-chunk pass. Under the current workflow rules, that means it must be treated as a long-running operation and discussed before retrying.

src/herald_cli/cli.py

Lines changed: 103 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@
88
from rich.console import Console
99

1010
console = Console()
11+
_CLI_HANDLED_EXCEPTIONS = (RuntimeError, ValueError, OSError, json.JSONDecodeError)
12+
13+
14+
def _raise_click_error(exc: Exception) -> None:
15+
"""Render expected runtime failures as clean CLI errors."""
16+
raise click.ClickException(str(exc)) from exc
1117

1218

1319
@click.group()
@@ -30,12 +36,15 @@ def cli():
3036
def convert(input_file: Path, output: Path | None):
3137
"""Convert a guideline PDF to structured markdown."""
3238
from herald_cli.convert import convert_pdf
33-
if output is None:
34-
output = input_file.with_suffix(".md")
35-
console.print(f"[bold]Converting[/bold] {input_file.name}{output.name}")
36-
result = convert_pdf(input_file)
37-
output.write_text(result, encoding="utf-8")
38-
console.print(f"[green]✓[/green] Written {output} ({result.count(chr(10))} lines)")
39+
try:
40+
if output is None:
41+
output = input_file.with_suffix(".md")
42+
console.print(f"[bold]Converting[/bold] {input_file.name}{output.name}")
43+
result = convert_pdf(input_file)
44+
output.write_text(result, encoding="utf-8")
45+
console.print(f"[green]✓[/green] Written {output} ({result.count(chr(10))} lines)")
46+
except _CLI_HANDLED_EXCEPTIONS as exc:
47+
_raise_click_error(exc)
3948

4049

4150
@cli.command()
@@ -46,14 +55,17 @@ def convert(input_file: Path, output: Path | None):
4655
def parse(input_file: Path, output: Path | None, provider: str, model: str | None):
4756
"""Parse guideline markdown into a decision tree JSON."""
4857
from herald_cli.parse import parse_guideline
49-
if output is None:
50-
output = input_file.with_suffix(".json")
51-
console.print(f"[bold]Parsing[/bold] {input_file.name} with {provider}")
52-
md = input_file.read_text(encoding="utf-8")
53-
tree = parse_guideline(md, provider=provider, model=model)
54-
output.write_text(json.dumps(tree, indent=2, ensure_ascii=False), encoding="utf-8")
55-
n = len(tree.get("decisions", []))
56-
console.print(f"[green]✓[/green] Extracted {n} decision nodes → {output}")
58+
try:
59+
if output is None:
60+
output = input_file.with_suffix(".json")
61+
console.print(f"[bold]Parsing[/bold] {input_file.name} with {provider}")
62+
md = input_file.read_text(encoding="utf-8")
63+
tree = parse_guideline(md, provider=provider, model=model)
64+
output.write_text(json.dumps(tree, indent=2, ensure_ascii=False), encoding="utf-8")
65+
n = len(tree.get("decisions", []))
66+
console.print(f"[green]✓[/green] Extracted {n} decision nodes → {output}")
67+
except _CLI_HANDLED_EXCEPTIONS as exc:
68+
_raise_click_error(exc)
5769

5870

5971
@cli.command()
@@ -90,7 +102,10 @@ def query(input_files, ask, fmt, log, batch, output):
90102
console.print("[red]Error: provide at least one guideline JSON.[/red]")
91103
raise SystemExit(1)
92104

93-
guidelines = [json.loads(f.read_text(encoding="utf-8")) for f in input_files]
105+
try:
106+
guidelines = [json.loads(f.read_text(encoding="utf-8")) for f in input_files]
107+
except _CLI_HANDLED_EXCEPTIONS as exc:
108+
_raise_click_error(exc)
94109

95110
# --- Batch mode ---
96111
if batch:
@@ -433,9 +448,12 @@ def _show_fields(engine):
433448
def validate(tree_file: Path, source: Path):
434449
"""Validate parsed tree against its source markdown."""
435450
from herald_cli.validate import print_validation_report, validate_tree
436-
console.print(f"[bold]Validating[/bold] {tree_file.name} against {source.name}\n")
437-
results = validate_tree(tree_file, source)
438-
print_validation_report(results, console)
451+
try:
452+
console.print(f"[bold]Validating[/bold] {tree_file.name} against {source.name}\n")
453+
results = validate_tree(tree_file, source)
454+
print_validation_report(results, console)
455+
except _CLI_HANDLED_EXCEPTIONS as exc:
456+
_raise_click_error(exc)
439457

440458

441459
@cli.command()
@@ -446,61 +464,63 @@ def validate(tree_file: Path, source: Path):
446464
def diff(old_file: Path, new_file: Path, fmt: str, output: Path | None):
447465
"""Compare two versions of a parsed guideline."""
448466
from herald_cli.diff import diff_guidelines, format_markdown
467+
try:
468+
old = json.loads(old_file.read_text(encoding="utf-8"))
469+
new = json.loads(new_file.read_text(encoding="utf-8"))
470+
result = diff_guidelines(old, new)
449471

450-
old = json.loads(old_file.read_text(encoding="utf-8"))
451-
new = json.loads(new_file.read_text(encoding="utf-8"))
452-
result = diff_guidelines(old, new)
453-
454-
if fmt == "json":
455-
out_text = json.dumps(result, indent=2, ensure_ascii=False)
456-
if output:
457-
output.write_text(out_text, encoding="utf-8")
458-
else:
459-
click.echo(out_text)
460-
return
461-
462-
if fmt == "markdown":
463-
md = format_markdown(result, old_file.name, new_file.name)
464-
if output:
465-
output.write_text(md, encoding="utf-8")
466-
console.print(f"[green]✓[/green] Written {output}")
467-
else:
468-
click.echo(md)
469-
return
470-
471-
# Text format
472-
s = result["summary"]
473-
console.print(
474-
f"\n[bold]Guideline diff:[/bold] {old_file.name}{new_file.name}\n"
475-
)
476-
console.print(
477-
f" [green]+{s['nodes_added']} added[/green] "
478-
f"[red]-{s['nodes_removed']} removed[/red] "
479-
f"[yellow]~{s['nodes_modified']} modified[/yellow] "
480-
f"[dim]={s['nodes_unchanged']} unchanged[/dim]"
481-
)
482-
if result["metadata_changes"]:
483-
console.print("\n[bold]Metadata changes:[/bold]")
484-
for c in result["metadata_changes"]:
485-
console.print(f" {c['field']}: {c['old']}{c['new']}")
486-
if result["added"]:
487-
console.print("\n[bold green]Added:[/bold green]")
488-
for a in result["added"]:
489-
console.print(f" + {a['id']}: {a['description']}")
490-
if result["removed"]:
491-
console.print("\n[bold red]Removed:[/bold red]")
492-
for r in result["removed"]:
493-
console.print(f" - {r['id']}: {r['description']}")
494-
if result["modified"]:
495-
console.print("\n[bold yellow]Modified:[/bold yellow]")
496-
for m in result["modified"]:
497-
console.print(f" ~ {m['id']}:")
498-
for c in m["changes"]:
499-
if "old" in c and "new" in c:
500-
console.print(f" {c['field']}:")
501-
console.print(f" [red]- {str(c['old'])[:80]}[/red]")
502-
console.print(f" [green]+ {str(c['new'])[:80]}[/green]")
503-
console.print()
472+
if fmt == "json":
473+
out_text = json.dumps(result, indent=2, ensure_ascii=False)
474+
if output:
475+
output.write_text(out_text, encoding="utf-8")
476+
else:
477+
click.echo(out_text)
478+
return
479+
480+
if fmt == "markdown":
481+
md = format_markdown(result, old_file.name, new_file.name)
482+
if output:
483+
output.write_text(md, encoding="utf-8")
484+
console.print(f"[green]✓[/green] Written {output}")
485+
else:
486+
click.echo(md)
487+
return
488+
489+
# Text format
490+
s = result["summary"]
491+
console.print(
492+
f"\n[bold]Guideline diff:[/bold] {old_file.name}{new_file.name}\n"
493+
)
494+
console.print(
495+
f" [green]+{s['nodes_added']} added[/green] "
496+
f"[red]-{s['nodes_removed']} removed[/red] "
497+
f"[yellow]~{s['nodes_modified']} modified[/yellow] "
498+
f"[dim]={s['nodes_unchanged']} unchanged[/dim]"
499+
)
500+
if result["metadata_changes"]:
501+
console.print("\n[bold]Metadata changes:[/bold]")
502+
for c in result["metadata_changes"]:
503+
console.print(f" {c['field']}: {c['old']}{c['new']}")
504+
if result["added"]:
505+
console.print("\n[bold green]Added:[/bold green]")
506+
for a in result["added"]:
507+
console.print(f" + {a['id']}: {a['description']}")
508+
if result["removed"]:
509+
console.print("\n[bold red]Removed:[/bold red]")
510+
for r in result["removed"]:
511+
console.print(f" - {r['id']}: {r['description']}")
512+
if result["modified"]:
513+
console.print("\n[bold yellow]Modified:[/bold yellow]")
514+
for m in result["modified"]:
515+
console.print(f" ~ {m['id']}:")
516+
for c in m["changes"]:
517+
if "old" in c and "new" in c:
518+
console.print(f" {c['field']}:")
519+
console.print(f" [red]- {str(c['old'])[:80]}[/red]")
520+
console.print(f" [green]+ {str(c['new'])[:80]}[/green]")
521+
console.print()
522+
except _CLI_HANDLED_EXCEPTIONS as exc:
523+
_raise_click_error(exc)
504524

505525

506526
@cli.command(name="export")
@@ -510,12 +530,17 @@ def diff(old_file: Path, new_file: Path, fmt: str, output: Path | None):
510530
def export_cmd(input_file: Path, output: Path | None, fmt: str):
511531
"""Export a parsed guideline to FHIR PlanDefinition."""
512532
from herald_cli.export import export_fhir
513-
data = json.loads(input_file.read_text(encoding="utf-8"))
514-
result = export_fhir(data)
515-
if output is None:
516-
output = input_file.with_suffix(f".{fmt}.json")
517-
output.write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding="utf-8")
518-
console.print(f"[green]✓[/green] Exported {len(result.get('action', []))} actions → {output}")
533+
try:
534+
data = json.loads(input_file.read_text(encoding="utf-8"))
535+
result = export_fhir(data)
536+
if output is None:
537+
output = input_file.with_suffix(f".{fmt}.json")
538+
output.write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding="utf-8")
539+
console.print(
540+
f"[green]✓[/green] Exported {len(result.get('action', []))} actions → {output}"
541+
)
542+
except _CLI_HANDLED_EXCEPTIONS as exc:
543+
_raise_click_error(exc)
519544

520545

521546
if __name__ == "__main__":

src/herald_cli/convert.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ def convert_pdf(input_path: Path) -> str:
2727
except ImportError:
2828
raise RuntimeError(
2929
"markitdown is required for PDF conversion. "
30-
"Install it with: pip install 'guideline-as-code[all]' "
30+
"Install it with: pip install 'herald-cpg[all]' "
3131
"or: pip install markitdown"
3232
)
3333

@@ -49,10 +49,12 @@ def _normalize_markdown(text: str) -> str:
4949
"""Clean up markitdown output for better parsing.
5050
5151
Fixes common markitdown artifacts:
52+
- Form feed/page break characters embedded in the text stream
5253
- Excessive blank lines
5354
- Inconsistent heading markers
5455
- Trailing whitespace
5556
"""
57+
text = text.replace("\f", "\n")
5658
lines = text.split("\n")
5759
cleaned = []
5860
prev_blank = False

0 commit comments

Comments
 (0)