Skip to content

Commit a49f889

Browse files
realmarcinclaude
andauthored
Convert 3 more CommunityMech writers (intelligent_snippet_fixer + enhance_strain_data + add_evidence_source) (#87)
* Convert 3 more CommunityMech writers to use shared validate-and-record helpers Brings CommunityMech writer coverage from 5/16 to 8/16. Continues the pattern established in PR #84 and refined by PR #85 (restore-on-failure backup handling): every script that mutates a community YAML loads through yaml.safe_load, mutates the dict, records a CurationEvent via record_curation_event(), and writes back via write_validated_community() which gates on closed-schema LinkML validation. Converted scripts: - scripts/intelligent_snippet_fixer.py (LLM-driven snippet repair; llm_assisted=True; action=FIX_SNIPPETS_LLM). Uses skip_if_recent=True on the curation event so a session of auto-approved fixes collapses into a single trail entry instead of one per snippet. The existing .yaml.bak_intelligent backup created at session start by shutil.copy2 remains the user-visible safety net. - scripts/enhance_strain_data.py (strain-ID enrichment; action=ENHANCE_STRAIN_DATA). Previously the script extracted strain data but only emitted a copy-paste snippets file; this PR adds an --apply mode that writes strain_designation entries back into matching taxonomy[*] entries via write_validated_community(). Default behavior preserves the historical extract-only flow (no kb/communities writes without --apply). --overwrite controls whether to replace existing curator-authored strain_designation values. - scripts/add_evidence_source.py (evidence_source enum backfill; action=BACKFILL_EVIDENCE_SOURCE). Uses the backup-then-rename pattern from PR #85 — the original is moved to .yaml.bak_source before the validated write; on ValidationFailedError the backup is renamed back in place so the batch loop can continue without leaving a half-written community on disk. Each per-record loop continues on ValidationFailedError so one bad file can't kill the batch. CLI surfaces (--auto, --interactive, --dry-run, --auto-approve, --file, --apply, etc.) preserved. After-state: scripts/audit_writers.py reports 8/16 writers gated (was 5/16). The remaining un-converted writers (apply_strain_designations, apply_taxonomy_corrections, backfill_metals, clean_metals_inplace, fix_reference_formats, plus a handful of smaller src/ writers) follow the same conversion pattern; left as future work to keep this PR focused. Note: scripts/add_evidence_source.py and scripts/intelligent_snippet_fixer.py import communitymech.literature_enhanced (a pre-existing module that does not currently exist in the repo) at module top-level. This PR does not introduce or fix that — the scripts have always failed at the import step when invoked from CLI. Out of scope for this conversion; tracked separately. Baseline (unchanged): - validate_strict: 0 ERROR rows / 265 files - pytest tests/: 136 passed, 9 skipped Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Refresh audit TSV after rebase onto #86 PR #86 (Convert clean_metals_inplace.py) just merged into main. After rebasing, scripts/clean_metals_inplace.py is now gated, so the appends_curation_history / validates_before_write columns for it flip to yes. Re-running scripts/audit_writers.py produces a 1-row delta; commit it so the report reflects the actual post-rebase state. Combined post-merge: 9/16 appends_curation_history, 10/16 validates_before_write (was 5/16 and 6/16 respectively at the start of this PR series). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e2894f1 commit a49f889

4 files changed

Lines changed: 239 additions & 29 deletions

File tree

reports/pipeline_writers_audit.tsv

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
path writes_yaml appends_curation_history has_write_safeguard validates_before_write wired_into_just
22
scripts/add_community_ids.py yes yes yes yes no
3-
scripts/add_evidence_source.py yes no yes no no
3+
scripts/add_evidence_source.py yes yes yes yes no
44
scripts/apply_pmc_conversions.py yes yes yes yes no
55
scripts/apply_strain_designations.py yes no no no no
66
scripts/apply_taxonomy_corrections.py yes no no no no
77
scripts/backfill_metals.py yes no yes no no
8-
scripts/clean_metals_inplace.py yes no yes no no
9-
scripts/enhance_strain_data.py yes no no no no
8+
scripts/clean_metals_inplace.py yes yes yes yes no
9+
scripts/enhance_strain_data.py yes yes yes yes no
1010
scripts/fix_network_integrity.py yes yes yes yes no
1111
scripts/fix_reference_formats.py yes no yes no no
12-
scripts/intelligent_snippet_fixer.py yes no no no no
12+
scripts/intelligent_snippet_fixer.py yes yes no yes no
1313
scripts/link_growth_media.py yes yes yes yes yes
1414
src/communitymech/cli.py yes no yes no no
1515
src/communitymech/network/batch_reporter.py yes no no no no

scripts/add_evidence_source.py

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,21 @@
1919
"""
2020

2121
import sys
22-
import yaml
2322
from pathlib import Path
24-
from typing import Dict, List, Optional
25-
import re
23+
from typing import Dict, Optional
24+
25+
import yaml
2626

2727
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
2828

2929
from communitymech.literature_enhanced import EnhancedLiteratureFetcher
3030

31+
from communitymech.curate.curation_event import record_curation_event
32+
from communitymech.validation.write_validated import (
33+
ValidationFailedError,
34+
write_validated_community,
35+
)
36+
3137

3238
class EvidenceSourceAdder:
3339
"""Add evidence_source to evidence items"""
@@ -269,18 +275,40 @@ def process_yaml(
269275

270276
# Write back if changes made
271277
if changes:
272-
# Backup
278+
# Summarize the changes for the curation trail.
279+
auto_count = sum(1 for c in changes if c.get('confidence') == 'auto')
280+
manual_count = sum(1 for c in changes if c.get('confidence') == 'manual')
281+
change_summary = (
282+
f"Backfilled evidence_source on {len(changes)} evidence item(s) "
283+
f"(auto={auto_count}, manual={manual_count})"
284+
)
285+
record_curation_event(
286+
data,
287+
curator="add_evidence_source",
288+
action="BACKFILL_EVIDENCE_SOURCE",
289+
changes=change_summary,
290+
)
291+
292+
# Backup then write via closed-schema-gated writer. If validation
293+
# fails, restore the backup so the loop can continue on the next
294+
# community without leaving the disk in a torn state.
273295
backup_path = yaml_path.with_suffix('.yaml.bak_source')
274296
yaml_path.rename(backup_path)
275-
276-
# Write updated
277-
with open(yaml_path, 'w') as f:
278-
yaml.dump(data, f,
279-
default_flow_style=False,
280-
sort_keys=False,
281-
allow_unicode=True,
282-
width=120,
283-
indent=2)
297+
try:
298+
write_validated_community(data, yaml_path)
299+
except ValidationFailedError as exc:
300+
backup_path.rename(yaml_path)
301+
print(
302+
f" ✗ validation failed for {yaml_path.name}: {exc.summary()} "
303+
"(original restored)",
304+
file=sys.stderr,
305+
)
306+
return {
307+
'file': yaml_path.name,
308+
'changes': [],
309+
'count': 0,
310+
'validation_failed': True,
311+
}
284312

285313
return {
286314
'file': yaml_path.name,

scripts/enhance_strain_data.py

Lines changed: 159 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,26 @@
1818
4. ATCC catalog (type strains, genome links)
1919
"""
2020

21+
import argparse
2122
import re
22-
import yaml
23-
import duckdb
23+
import sys
24+
from collections import defaultdict
25+
from dataclasses import dataclass, field
2426
from pathlib import Path
2527
from typing import Dict, List, Optional, Set, Tuple
26-
from dataclasses import dataclass, field
27-
import requests
28-
import time
29-
from collections import defaultdict
28+
29+
import duckdb
30+
import yaml
31+
32+
# Add src to path for imports
33+
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
34+
35+
from communitymech.curate.curation_event import record_curation_event
36+
from communitymech.validation.write_validated import (
37+
ValidationFailedError,
38+
write_validated_community,
39+
)
40+
3041

3142
# Color codes for output
3243
class Colors:
@@ -343,13 +354,111 @@ def print_summary(self):
343354
print(f" Taxa with culture collections: {self.stats['strains_with_collections']}")
344355
print(f" Taxa with genome accessions: {self.stats['strains_with_genome']}")
345356

357+
def apply_strain_data_to_community(
358+
self,
359+
yaml_path: Path,
360+
strain_data: Dict[str, StrainInfo],
361+
*,
362+
overwrite: bool = False,
363+
) -> int:
364+
"""Write extracted strain_designation entries back into a community YAML.
365+
366+
Loads ``yaml_path``, attaches a ``strain_designation`` to each
367+
matching ``taxonomy[*].taxon_term`` (matched by ``preferred_term``),
368+
appends a ``CurationEvent``, and writes via
369+
:func:`write_validated_community` so closed-schema LinkML validation
370+
gates the disk write. Returns the number of taxa updated.
371+
372+
Args:
373+
yaml_path: Community YAML to update.
374+
strain_data: Mapping ``preferred_term -> StrainInfo`` produced by
375+
:meth:`extract_strain_from_yaml`.
376+
overwrite: When False (default), skip taxa that already carry a
377+
``strain_designation`` so curator-authored data is preserved.
378+
379+
Raises:
380+
ValidationFailedError: re-raised by the caller for visibility;
381+
callers in a batch loop should ``except`` and continue.
382+
"""
383+
with open(yaml_path) as f:
384+
data = yaml.safe_load(f)
385+
386+
if 'taxonomy' not in data:
387+
return 0
388+
389+
updated_taxa = []
390+
for taxon_entry in data['taxonomy']:
391+
taxon_term = taxon_entry.get('taxon_term') or {}
392+
preferred_term = taxon_term.get('preferred_term', '')
393+
if preferred_term not in strain_data:
394+
continue
395+
396+
if 'strain_designation' in taxon_entry and not overwrite:
397+
continue
398+
399+
snippet = self.generate_yaml_snippet(strain_data[preferred_term])
400+
if not snippet:
401+
continue
402+
403+
taxon_entry['strain_designation'] = snippet
404+
updated_taxa.append(preferred_term)
405+
406+
if not updated_taxa:
407+
return 0
408+
409+
record_curation_event(
410+
data,
411+
curator="enhance_strain_data",
412+
action="ENHANCE_STRAIN_DATA",
413+
changes=(
414+
f"Added strain_designation for {len(updated_taxa)} taxa: "
415+
f"{', '.join(updated_taxa[:5])}"
416+
+ ("..." if len(updated_taxa) > 5 else "")
417+
),
418+
)
419+
420+
write_validated_community(data, yaml_path)
421+
return len(updated_taxa)
422+
346423

347424
def main():
425+
parser = argparse.ArgumentParser(
426+
description=(
427+
"Phase 2: extract strain designations and (optionally) apply "
428+
"them to community YAMLs"
429+
)
430+
)
431+
parser.add_argument(
432+
'--apply',
433+
action='store_true',
434+
help=(
435+
"Write extracted strain_designation entries back into "
436+
"kb/communities/*.yaml via write_validated_community(). "
437+
"Without this flag the script only emits the report + snippets "
438+
"files for human review (the historical default)."
439+
),
440+
)
441+
parser.add_argument(
442+
'--overwrite',
443+
action='store_true',
444+
help=(
445+
"With --apply, replace existing strain_designation entries. "
446+
"Default behavior preserves curator-authored values."
447+
),
448+
)
449+
parser.add_argument(
450+
'--kb-dir',
451+
type=Path,
452+
default=Path('kb/communities'),
453+
help="Path to community YAML directory (default: kb/communities)",
454+
)
455+
args = parser.parse_args()
456+
348457
print(f"{Colors.BOLD}{Colors.CYAN}Phase 2: Data Enhancement - Strain Resolution{Colors.RESET}")
349458
print(f"{Colors.CYAN}Strategy: Literature → kg-microbe → APIs{Colors.RESET}\n")
350459

351460
# Paths
352-
kb_dir = Path('/Users/marcin/Documents/VIMSS/ontology/KG-Hub/KG-Microbe/CommunityMech/CommunityMech/kb/communities')
461+
kb_dir = args.kb_dir
353462
kgm_db = Path('kgm_taxonomy.duckdb')
354463
output_dir = Path('.')
355464

@@ -420,14 +529,56 @@ def main():
420529

421530
print(f"{Colors.GREEN}{Colors.RESET} Written: {snippets_path}")
422531

532+
# Apply strain designations to community YAMLs when --apply is set.
533+
# Without --apply the script keeps its historical "extract + report"
534+
# behavior and writes nothing to kb/communities/. With --apply each
535+
# community is loaded, mutated in-memory, gets a CurationEvent appended,
536+
# and is written via write_validated_community() so closed-schema
537+
# validation refuses any doc that drifted into an invalid shape.
538+
if args.apply:
539+
print(f"\n{Colors.CYAN}Applying strain designations to community YAMLs...{Colors.RESET}")
540+
applied_total = 0
541+
applied_files = 0
542+
failed_files = 0
543+
for yaml_path, strain_data in sorted(all_strain_data.items()):
544+
try:
545+
count = extractor.apply_strain_data_to_community(
546+
yaml_path,
547+
strain_data,
548+
overwrite=args.overwrite,
549+
)
550+
except ValidationFailedError as exc:
551+
print(
552+
f" {Colors.RED}{Colors.RESET} validation failed for "
553+
f"{yaml_path.name}: {exc.summary()}",
554+
file=sys.stderr,
555+
)
556+
failed_files += 1
557+
continue
558+
559+
if count > 0:
560+
applied_total += count
561+
applied_files += 1
562+
print(
563+
f" {Colors.GREEN}{Colors.RESET} {yaml_path.name}: "
564+
f"applied strain_designation to {count} taxa"
565+
)
566+
567+
print(
568+
f"\n{Colors.GREEN}Applied strain_designation to {applied_total} "
569+
f"taxa across {applied_files} community file(s); "
570+
f"{failed_files} file(s) failed validation.{Colors.RESET}"
571+
)
572+
423573
# Print summary
424574
extractor.print_summary()
425575

426576
print(f"\n{Colors.GREEN}{Colors.BOLD}✓ Phase 2 strain extraction complete!{Colors.RESET}")
427577
print(f"\n{Colors.CYAN}Next steps:{Colors.RESET}")
428578
print(f" 1. Review {report_path}")
429579
print(f" 2. Review {snippets_path}")
430-
print(f" 3. Apply strain designations to YAML files (Phase 2B)")
580+
if not args.apply:
581+
print(f" 3. Apply strain designations to YAML files: re-run with --apply")
431582
print(f" 4. Query BacDive/ATCC APIs for additional metadata (Phase 2C)")
432583

433584
if __name__ == '__main__':

scripts/intelligent_snippet_fixer.py

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,19 @@
1717
import shutil
1818
import sys
1919
from pathlib import Path
20-
from typing import Dict, List, Tuple, Optional
20+
from typing import Dict, List, Optional, Tuple
21+
2122
import yaml
2223

2324
# Add src to path for imports
2425
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
2526

27+
from communitymech.curate.curation_event import record_curation_event
2628
from communitymech.literature_enhanced import EnhancedLiteratureFetcher
29+
from communitymech.validation.write_validated import (
30+
ValidationFailedError,
31+
write_validated_community,
32+
)
2733

2834

2935
class SnippetSuggestion:
@@ -471,9 +477,34 @@ def apply_snippet_fix_to_yaml(
471477
print(f" ❌ Could not find evidence item with name='{organism}' and reference='{reference}' in section='{section}'")
472478
return False
473479

474-
# Write back to YAML with nice formatting
475-
with open(yaml_path, 'w', encoding='utf-8') as f:
476-
yaml.dump(data, f, default_flow_style=False, allow_unicode=True, sort_keys=False, width=120)
480+
# Record curation event for the LLM-driven snippet fix. ``skip_if_recent``
481+
# collapses repeated per-snippet events in the same session into a single
482+
# FIX_SNIPPETS_LLM trail entry so the history doesn't balloon when a user
483+
# auto-approves dozens of fixes in one run.
484+
record_curation_event(
485+
data,
486+
curator="intelligent_snippet_fixer",
487+
action="FIX_SNIPPETS_LLM",
488+
changes=(
489+
f"Replaced evidence snippet for {organism} "
490+
f"(reference={reference}, section={section})"
491+
),
492+
llm_assisted=True,
493+
skip_if_recent=True,
494+
)
495+
496+
# Write back via closed-schema-gated writer (replaces direct yaml.dump).
497+
# The ``.yaml.bak_intelligent`` backup created at the start of
498+
# ``interactive_fix_workflow`` is the safety net if validation refuses
499+
# the doc — the user can restore from it manually, just like before.
500+
try:
501+
write_validated_community(data, yaml_path)
502+
except ValidationFailedError as exc:
503+
print(
504+
f" ✗ validation failed for {yaml_path.name}: {exc.summary()}",
505+
file=sys.stderr,
506+
)
507+
return False
477508

478509
return True
479510

0 commit comments

Comments
 (0)