@@ -447,46 +447,261 @@ def _run_literature_search_sync(variant: Variant) -> tuple[list, str | None]:
447447def classification_node (state : AnalysisState ) -> dict [str , Any ]:
448448 """Apply ACMG criteria and classify the variant.
449449
450- TODO: Implement LLM-based criterion assessment + deterministic rule engine.
451- Currently returns a placeholder VUS classification.
450+ Two-phase approach:
451+ 1. Rule-based criterion assessment using annotation data (no LLM needed
452+ for criteria that can be evaluated deterministically from database evidence)
453+ 2. Deterministic ACMG combining rules via acmg_engine.classify()
454+
455+ LLM-based criterion assessment for ambiguous criteria will be added
456+ once the rule-based approach is validated.
452457 """
453458 start = time .time ()
454459 variant = state ["variant" ]
460+ annotation = state ["annotation" ]
455461
456462 from variantagent .models .classification import (
457463 ACMGClassification ,
458- ACMGClassificationResult ,
459464 ACMGCriteria ,
465+ EvidenceCode ,
466+ EvidenceDirection ,
467+ EvidenceStrength ,
460468 )
469+ from variantagent .tools .acmg_engine import classify as acmg_classify
470+
471+ criteria = ACMGCriteria ()
472+
473+ # Evaluate criteria from annotation evidence (deterministic, no LLM)
474+ if annotation :
475+ criteria = _evaluate_criteria_from_evidence (variant , annotation )
476+
477+ # Run deterministic ACMG combining rules
478+ classification_result , rule_description = acmg_classify (criteria )
479+
480+ # Calculate confidence based on evidence completeness
481+ confidence = _calculate_confidence (annotation , criteria )
482+
483+ applied_codes = criteria .get_applied_codes ()
484+ applied_summary = [c .code for c in applied_codes ]
485+
486+ reasoning_parts = [f"Classification: { classification_result .value } " ]
487+ reasoning_parts .append (f"Rule applied: { rule_description } " )
488+ if applied_codes :
489+ reasoning_parts .append (f"Evidence codes: { ', ' .join (applied_summary )} " )
490+ for code in applied_codes :
491+ reasoning_parts .append (f" { code .code } : { code .reasoning } " )
492+ else :
493+ reasoning_parts .append ("No ACMG evidence criteria were met from available data." )
461494
462- # Placeholder classification — will be replaced with real ACMG assessment
463495 classification = ACMGClassification (
464- classification = ACMGClassificationResult . VUS ,
465- criteria = ACMGCriteria () ,
466- confidence = 0.5 ,
467- reasoning = "Placeholder — ACMG criterion assessment not yet implemented" ,
468- applied_codes_summary = [] ,
469- classification_rule = "No evidence criteria met" ,
496+ classification = classification_result ,
497+ criteria = criteria ,
498+ confidence = confidence ,
499+ reasoning = "\n " . join ( reasoning_parts ) ,
500+ applied_codes_summary = applied_summary ,
501+ classification_rule = rule_description ,
470502 )
471503
472504 duration_ms = int ((time .time () - start ) * 1000 )
473505 provenance_entry = ProvenanceEntry (
474506 step = 5 ,
475507 agent = "classification_agent" ,
476508 action = "ACMG classification" ,
477- input_summary = f"Variant: { variant .variant_id } , annotation available: { state ['annotation' ] is not None } " ,
478- output_summary = f"Classification: { classification .classification .value } "
479- f"(confidence: { classification .confidence } )" ,
509+ input_summary = f"Variant: { variant .variant_id } , "
510+ f"ClinVar: { annotation .clinvar .found if annotation else 'N/A' } , "
511+ f"gnomAD AF: { annotation .gnomad .overall_af if annotation and annotation .gnomad .found else 'N/A' } " ,
512+ output_summary = f"{ classification_result .value } ({ ', ' .join (applied_summary ) or 'no criteria met' } ) "
513+ f"confidence: { confidence :.2f} " ,
480514 duration_ms = duration_ms ,
481515 )
482516
517+ logger .info (
518+ "Classification for %s: %s (confidence: %.2f, codes: %s)" ,
519+ variant .variant_id ,
520+ classification_result .value ,
521+ confidence ,
522+ applied_summary ,
523+ )
524+
483525 return {
484526 "classification" : classification ,
485- "overall_confidence" : classification . confidence ,
527+ "overall_confidence" : confidence ,
486528 "provenance" : [provenance_entry ],
487529 }
488530
489531
532+ def _evaluate_criteria_from_evidence (
533+ variant : Variant ,
534+ annotation : VariantAnnotation ,
535+ ) -> "ACMGCriteria" :
536+ """Evaluate ACMG criteria deterministically from annotation evidence.
537+
538+ These criteria can be assessed without LLM judgment — they depend on
539+ objective data from databases.
540+ """
541+ from variantagent .models .classification import (
542+ ACMGCriteria ,
543+ EvidenceCode ,
544+ EvidenceDirection ,
545+ EvidenceStrength ,
546+ )
547+
548+ kwargs : dict [str , EvidenceCode ] = {}
549+
550+ # --- BA1: Allele frequency > 5% (standalone benign) ---
551+ if annotation .gnomad .found and annotation .gnomad .overall_af is not None :
552+ if annotation .gnomad .overall_af > 0.05 :
553+ kwargs ["ba1" ] = EvidenceCode (
554+ code = "BA1" , name = "Allele frequency > 5%" ,
555+ direction = EvidenceDirection .BENIGN , strength = EvidenceStrength .VERY_STRONG ,
556+ applied = True ,
557+ reasoning = f"gnomAD overall AF = { annotation .gnomad .overall_af :.4f} (> 0.05 threshold)" ,
558+ data_source = "gnomAD" , confidence = 0.99 ,
559+ )
560+
561+ # --- BS1: Allele frequency greater than expected for disorder ---
562+ if annotation .gnomad .found and annotation .gnomad .overall_af is not None :
563+ if 0.01 < annotation .gnomad .overall_af <= 0.05 :
564+ kwargs ["bs1" ] = EvidenceCode (
565+ code = "BS1" , name = "Allele frequency greater than expected" ,
566+ direction = EvidenceDirection .BENIGN , strength = EvidenceStrength .STRONG ,
567+ applied = True ,
568+ reasoning = f"gnomAD overall AF = { annotation .gnomad .overall_af :.4f} (> 0.01, suggesting common variant)" ,
569+ data_source = "gnomAD" , confidence = 0.90 ,
570+ )
571+
572+ # --- PM2: Absent from population databases ---
573+ if annotation .gnomad .found and annotation .gnomad .overall_af is not None :
574+ if annotation .gnomad .overall_af < 0.0001 :
575+ kwargs ["pm2" ] = EvidenceCode (
576+ code = "PM2" , name = "Absent/extremely low frequency in population databases" ,
577+ direction = EvidenceDirection .PATHOGENIC , strength = EvidenceStrength .MODERATE ,
578+ applied = True ,
579+ reasoning = f"gnomAD overall AF = { annotation .gnomad .overall_af :.6f} (< 0.0001 threshold)" ,
580+ data_source = "gnomAD" , confidence = 0.85 ,
581+ )
582+ elif annotation .gnomad .found is False :
583+ kwargs ["pm2" ] = EvidenceCode (
584+ code = "PM2" , name = "Absent from population databases" ,
585+ direction = EvidenceDirection .PATHOGENIC , strength = EvidenceStrength .MODERATE ,
586+ applied = True ,
587+ reasoning = "Variant not found in gnomAD — absent from population databases" ,
588+ data_source = "gnomAD" , confidence = 0.80 ,
589+ )
590+
591+ # --- PP5: Reputable source reports pathogenic ---
592+ if annotation .clinvar .found and annotation .clinvar .clinical_significance :
593+ sig = annotation .clinvar .clinical_significance .lower ()
594+ stars = annotation .clinvar .review_stars or 0
595+
596+ if "pathogenic" in sig and "likely" not in sig and stars >= 2 :
597+ kwargs ["pp5" ] = EvidenceCode (
598+ code = "PP5" , name = "Reputable source reports pathogenic" ,
599+ direction = EvidenceDirection .PATHOGENIC , strength = EvidenceStrength .SUPPORTING ,
600+ applied = True ,
601+ reasoning = f"ClinVar: '{ annotation .clinvar .clinical_significance } ' "
602+ f"({ stars } -star review, { annotation .clinvar .submitter_count } submitters)" ,
603+ data_source = "ClinVar" , confidence = 0.90 ,
604+ )
605+ elif "likely pathogenic" in sig and stars >= 2 :
606+ kwargs ["pp5" ] = EvidenceCode (
607+ code = "PP5" , name = "Reputable source reports likely pathogenic" ,
608+ direction = EvidenceDirection .PATHOGENIC , strength = EvidenceStrength .SUPPORTING ,
609+ applied = True ,
610+ reasoning = f"ClinVar: '{ annotation .clinvar .clinical_significance } ' ({ stars } -star review)" ,
611+ data_source = "ClinVar" , confidence = 0.80 ,
612+ )
613+
614+ # --- BP6: Reputable source reports benign ---
615+ if annotation .clinvar .found and annotation .clinvar .clinical_significance :
616+ sig = annotation .clinvar .clinical_significance .lower ()
617+ stars = annotation .clinvar .review_stars or 0
618+
619+ if ("benign" in sig or "likely benign" in sig ) and stars >= 2 :
620+ kwargs ["bp6" ] = EvidenceCode (
621+ code = "BP6" , name = "Reputable source reports benign" ,
622+ direction = EvidenceDirection .BENIGN , strength = EvidenceStrength .SUPPORTING ,
623+ applied = True ,
624+ reasoning = f"ClinVar: '{ annotation .clinvar .clinical_significance } ' ({ stars } -star review)" ,
625+ data_source = "ClinVar" , confidence = 0.90 ,
626+ )
627+
628+ # --- PP3: Computational evidence supports deleterious ---
629+ if annotation .ensembl_vep .found :
630+ sift_del = annotation .ensembl_vep .sift_prediction == "deleterious"
631+ polyphen_dam = annotation .ensembl_vep .polyphen_prediction in (
632+ "probably_damaging" , "possibly_damaging"
633+ )
634+ if sift_del and polyphen_dam :
635+ kwargs ["pp3" ] = EvidenceCode (
636+ code = "PP3" , name = "Computational evidence supports deleterious" ,
637+ direction = EvidenceDirection .PATHOGENIC , strength = EvidenceStrength .SUPPORTING ,
638+ applied = True ,
639+ reasoning = f"SIFT: { annotation .ensembl_vep .sift_prediction } "
640+ f"(score: { annotation .ensembl_vep .sift_score } ), "
641+ f"PolyPhen: { annotation .ensembl_vep .polyphen_prediction } "
642+ f"(score: { annotation .ensembl_vep .polyphen_score } )" ,
643+ data_source = "Ensembl VEP" , confidence = 0.70 ,
644+ )
645+
646+ # --- BP4: Computational evidence supports benign ---
647+ if annotation .ensembl_vep .found :
648+ sift_tol = annotation .ensembl_vep .sift_prediction == "tolerated"
649+ polyphen_ben = annotation .ensembl_vep .polyphen_prediction == "benign"
650+ if sift_tol and polyphen_ben :
651+ kwargs ["bp4" ] = EvidenceCode (
652+ code = "BP4" , name = "Computational evidence supports benign" ,
653+ direction = EvidenceDirection .BENIGN , strength = EvidenceStrength .SUPPORTING ,
654+ applied = True ,
655+ reasoning = f"SIFT: tolerated (score: { annotation .ensembl_vep .sift_score } ), "
656+ f"PolyPhen: benign (score: { annotation .ensembl_vep .polyphen_score } )" ,
657+ data_source = "Ensembl VEP" , confidence = 0.70 ,
658+ )
659+
660+ # --- PM1: Located in mutational hot spot / functional domain ---
661+ if annotation .ensembl_vep .found and annotation .ensembl_vep .protein_domain :
662+ kwargs ["pm1" ] = EvidenceCode (
663+ code = "PM1" , name = "Located in mutational hot spot / functional domain" ,
664+ direction = EvidenceDirection .PATHOGENIC , strength = EvidenceStrength .MODERATE ,
665+ applied = True ,
666+ reasoning = f"Variant falls in protein domain: { annotation .ensembl_vep .protein_domain } " ,
667+ data_source = "Ensembl VEP" , confidence = 0.65 ,
668+ )
669+
670+ return ACMGCriteria (** kwargs )
671+
672+
673+ def _calculate_confidence (
674+ annotation : VariantAnnotation | None ,
675+ criteria : "ACMGCriteria" ,
676+ ) -> float :
677+ """Calculate confidence score based on evidence completeness.
678+
679+ Higher confidence when more data sources contributed evidence.
680+ """
681+ if annotation is None :
682+ return 0.2
683+
684+ score = 0.3 # Base confidence
685+
686+ # Bonus for each data source that returned results
687+ if annotation .clinvar .found :
688+ score += 0.15
689+ if annotation .gnomad .found :
690+ score += 0.15
691+ if annotation .ensembl_vep .found :
692+ score += 0.10
693+
694+ # Bonus for number of criteria evaluated
695+ applied = criteria .get_applied_codes ()
696+ score += min (len (applied ) * 0.05 , 0.20 )
697+
698+ # Bonus for ClinVar review quality
699+ if annotation .clinvar .found and (annotation .clinvar .review_stars or 0 ) >= 2 :
700+ score += 0.10
701+
702+ return min (score , 1.0 )
703+
704+
490705def review_node (state : AnalysisState ) -> dict [str , Any ]:
491706 """Self-evaluation: cross-check conclusions and detect contradictions.
492707
0 commit comments