ngs-agent 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ngs_agent/__init__.py +3 -0
- ngs_agent/acmg.py +229 -0
- ngs_agent/analyzer.py +221 -0
- ngs_agent/backends/__init__.py +6 -0
- ngs_agent/backends/anthropic.py +30 -0
- ngs_agent/backends/base.py +24 -0
- ngs_agent/backends/factory.py +80 -0
- ngs_agent/backends/gemini.py +61 -0
- ngs_agent/backends/ollama.py +38 -0
- ngs_agent/backends/openai_compat.py +114 -0
- ngs_agent/cli.py +212 -0
- ngs_agent/common.py +85 -0
- ngs_agent/config.py +156 -0
- ngs_agent/debate.py +209 -0
- ngs_agent/doctor.py +98 -0
- ngs_agent/health.py +128 -0
- ngs_agent/nibi.py +519 -0
- ngs_agent/qc.py +130 -0
- ngs_agent/reports.py +241 -0
- ngs_agent/signatures/adapter_contamination.yaml +20 -0
- ngs_agent/signatures/high_duplication.yaml +21 -0
- ngs_agent/signatures/low_alignment_rate.yaml +20 -0
- ngs_agent/signatures/low_coverage.yaml +21 -0
- ngs_agent/signatures/poor_insert_size.yaml +21 -0
- ngs_agent/tui.py +725 -0
- ngs_agent/watcher.py +134 -0
- ngs_agent-0.2.0.dist-info/METADATA +290 -0
- ngs_agent-0.2.0.dist-info/RECORD +31 -0
- ngs_agent-0.2.0.dist-info/WHEEL +4 -0
- ngs_agent-0.2.0.dist-info/entry_points.txt +3 -0
- ngs_agent-0.2.0.dist-info/licenses/LICENSE +201 -0
ngs_agent/__init__.py
ADDED
ngs_agent/acmg.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""ACMG/AMP guidelines variant classification engine."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
ACMG_CRITERIA: dict[str, dict[str, Any]] = {
|
|
10
|
+
"PVS1": {
|
|
11
|
+
"code": "PVS1",
|
|
12
|
+
"strength": "Very Strong Pathogenic",
|
|
13
|
+
"category": "pathogenic",
|
|
14
|
+
"description": "Null variant in a gene where loss of function is a known disease mechanism.",
|
|
15
|
+
},
|
|
16
|
+
"PS1": {
|
|
17
|
+
"code": "PS1",
|
|
18
|
+
"strength": "Strong Pathogenic",
|
|
19
|
+
"category": "pathogenic",
|
|
20
|
+
"description": "Same amino acid change as a previously established pathogenic variant.",
|
|
21
|
+
},
|
|
22
|
+
"PS2": {
|
|
23
|
+
"code": "PS2",
|
|
24
|
+
"strength": "Strong Pathogenic",
|
|
25
|
+
"category": "pathogenic",
|
|
26
|
+
"description": "De novo variant (with confirmed maternity and paternity).",
|
|
27
|
+
},
|
|
28
|
+
"PS3": {
|
|
29
|
+
"code": "PS3",
|
|
30
|
+
"strength": "Strong Pathogenic",
|
|
31
|
+
"category": "pathogenic",
|
|
32
|
+
"description": "Functional studies show a validated damaging effect on protein function.",
|
|
33
|
+
},
|
|
34
|
+
"PS4": {
|
|
35
|
+
"code": "PS4",
|
|
36
|
+
"strength": "Strong Pathogenic",
|
|
37
|
+
"category": "pathogenic",
|
|
38
|
+
"description": "Prevalence in affected individuals is significantly increased over controls.",
|
|
39
|
+
},
|
|
40
|
+
"PM1": {
|
|
41
|
+
"code": "PM1",
|
|
42
|
+
"strength": "Moderate Pathogenic",
|
|
43
|
+
"category": "pathogenic",
|
|
44
|
+
"description": "Located in a known mutational hotspot or well-established functional domain.",
|
|
45
|
+
},
|
|
46
|
+
"PM2": {
|
|
47
|
+
"code": "PM2",
|
|
48
|
+
"strength": "Moderate Pathogenic",
|
|
49
|
+
"category": "pathogenic",
|
|
50
|
+
"description": "Absent or extremely rare in population databases (gnomAD).",
|
|
51
|
+
},
|
|
52
|
+
"PM4": {
|
|
53
|
+
"code": "PM4",
|
|
54
|
+
"strength": "Moderate Pathogenic",
|
|
55
|
+
"category": "pathogenic",
|
|
56
|
+
"description": "Protein length changes due to in-frame deletion/insertion or stop-loss.",
|
|
57
|
+
},
|
|
58
|
+
"PM5": {
|
|
59
|
+
"code": "PM5",
|
|
60
|
+
"strength": "Moderate Pathogenic",
|
|
61
|
+
"category": "pathogenic",
|
|
62
|
+
"description": "Novel missense at an amino acid residue where a different pathogenic missense is known.",
|
|
63
|
+
},
|
|
64
|
+
"PP1": {
|
|
65
|
+
"code": "PP1",
|
|
66
|
+
"strength": "Supporting Pathogenic",
|
|
67
|
+
"category": "pathogenic",
|
|
68
|
+
"description": "Cosegregation with disease in multiple affected family members.",
|
|
69
|
+
},
|
|
70
|
+
"PP2": {
|
|
71
|
+
"code": "PP2",
|
|
72
|
+
"strength": "Supporting Pathogenic",
|
|
73
|
+
"category": "pathogenic",
|
|
74
|
+
"description": "Missense variant in a gene where missense is a common disease mechanism.",
|
|
75
|
+
},
|
|
76
|
+
"PP3": {
|
|
77
|
+
"code": "PP3",
|
|
78
|
+
"strength": "Supporting Pathogenic",
|
|
79
|
+
"category": "pathogenic",
|
|
80
|
+
"description": "Multiple in silico computational tools predict a damaging effect.",
|
|
81
|
+
},
|
|
82
|
+
"PP5": {
|
|
83
|
+
"code": "PP5",
|
|
84
|
+
"strength": "Supporting Pathogenic",
|
|
85
|
+
"category": "pathogenic",
|
|
86
|
+
"description": "Reputable source reports pathogenic, but detailed evidence is not available.",
|
|
87
|
+
},
|
|
88
|
+
"BA1": {
|
|
89
|
+
"code": "BA1",
|
|
90
|
+
"strength": "Standalone Benign",
|
|
91
|
+
"category": "benign",
|
|
92
|
+
"description": "Allele frequency is > 5% in a major population database (gnomAD).",
|
|
93
|
+
},
|
|
94
|
+
"BS1": {
|
|
95
|
+
"code": "BS1",
|
|
96
|
+
"strength": "Strong Benign",
|
|
97
|
+
"category": "benign",
|
|
98
|
+
"description": "Allele frequency is greater than expected for disorder.",
|
|
99
|
+
},
|
|
100
|
+
"BS2": {
|
|
101
|
+
"code": "BS2",
|
|
102
|
+
"strength": "Strong Benign",
|
|
103
|
+
"category": "benign",
|
|
104
|
+
"description": "Observed in a healthy adult individual for a penetrant disorder.",
|
|
105
|
+
},
|
|
106
|
+
"BS3": {
|
|
107
|
+
"code": "BS3",
|
|
108
|
+
"strength": "Strong Benign",
|
|
109
|
+
"category": "benign",
|
|
110
|
+
"description": "Well-established functional studies show no damaging effect.",
|
|
111
|
+
},
|
|
112
|
+
"BP1": {
|
|
113
|
+
"code": "BP1",
|
|
114
|
+
"strength": "Supporting Benign",
|
|
115
|
+
"category": "benign",
|
|
116
|
+
"description": "Missense variant in a gene for which only truncating mutations cause disease.",
|
|
117
|
+
},
|
|
118
|
+
"BP4": {
|
|
119
|
+
"code": "BP4",
|
|
120
|
+
"strength": "Supporting Benign",
|
|
121
|
+
"category": "benign",
|
|
122
|
+
"description": "Multiple lines of computational evidence predict no damaging effect.",
|
|
123
|
+
},
|
|
124
|
+
"BP6": {
|
|
125
|
+
"code": "BP6",
|
|
126
|
+
"strength": "Supporting Benign",
|
|
127
|
+
"category": "benign",
|
|
128
|
+
"description": "Reputable source reports benign, but detailed evidence is not available.",
|
|
129
|
+
},
|
|
130
|
+
"BP7": {
|
|
131
|
+
"code": "BP7",
|
|
132
|
+
"strength": "Supporting Benign",
|
|
133
|
+
"category": "benign",
|
|
134
|
+
"description": "Synonymous variant with no predicted splice impact.",
|
|
135
|
+
},
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass
|
|
140
|
+
class ACMGEvaluation:
|
|
141
|
+
codes: list[str] = field(default_factory=list)
|
|
142
|
+
classification: str = "VUS"
|
|
143
|
+
explanation: str = ""
|
|
144
|
+
confidence: float = 0.0
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def compute_acmg_classification(criteria_codes: list[str]) -> ACMGEvaluation:
|
|
148
|
+
"""Compute ACMG/AMP 5-tier classification from criteria codes."""
|
|
149
|
+
codes = [c.upper().strip() for c in criteria_codes if c.upper().strip() in ACMG_CRITERIA]
|
|
150
|
+
|
|
151
|
+
pvs1 = codes.count("PVS1")
|
|
152
|
+
ps = sum(1 for c in codes if c.startswith("PS"))
|
|
153
|
+
pm = sum(1 for c in codes if c.startswith("PM"))
|
|
154
|
+
pp = sum(1 for c in codes if c.startswith("PP"))
|
|
155
|
+
|
|
156
|
+
ba1 = codes.count("BA1")
|
|
157
|
+
bs = sum(1 for c in codes if c.startswith("BS"))
|
|
158
|
+
bp = sum(1 for c in codes if c.startswith("BP"))
|
|
159
|
+
|
|
160
|
+
has_pathogenic = (pvs1 > 0 or ps > 0 or pm > 0 or pp > 0)
|
|
161
|
+
has_benign = (ba1 > 0 or bs > 0 or bp > 0)
|
|
162
|
+
|
|
163
|
+
# Standalone Benign
|
|
164
|
+
if ba1 >= 1 and not has_pathogenic:
|
|
165
|
+
return ACMGEvaluation(
|
|
166
|
+
codes=codes,
|
|
167
|
+
classification="Benign",
|
|
168
|
+
explanation="Standalone Benign: Allele frequency > 5% in population databases (BA1).",
|
|
169
|
+
confidence=0.99,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# Pathogenic combinations
|
|
173
|
+
is_pathogenic = (
|
|
174
|
+
(pvs1 >= 1 and (ps >= 1 or pm >= 2 or (pm >= 1 and pp >= 1) or pp >= 2))
|
|
175
|
+
or (ps >= 2)
|
|
176
|
+
or (ps >= 1 and (pm >= 3 or (pm >= 2 and pp >= 2) or (pm >= 1 and pp >= 4)))
|
|
177
|
+
)
|
|
178
|
+
if is_pathogenic and not has_benign:
|
|
179
|
+
return ACMGEvaluation(
|
|
180
|
+
codes=codes,
|
|
181
|
+
classification="Pathogenic",
|
|
182
|
+
explanation=f"Meets ACMG/AMP Pathogenic criteria rules ({len(codes)} criteria applied).",
|
|
183
|
+
confidence=0.95,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
# Likely Pathogenic combinations
|
|
187
|
+
is_likely_pathogenic = (
|
|
188
|
+
(pvs1 >= 1 and pm >= 1)
|
|
189
|
+
or (ps >= 1 and (pm in (1, 2) or pp >= 2))
|
|
190
|
+
or (pm >= 3)
|
|
191
|
+
or (pm >= 2 and pp >= 2)
|
|
192
|
+
or (pm >= 1 and pp >= 4)
|
|
193
|
+
)
|
|
194
|
+
if is_likely_pathogenic and not has_benign:
|
|
195
|
+
return ACMGEvaluation(
|
|
196
|
+
codes=codes,
|
|
197
|
+
classification="Likely Pathogenic",
|
|
198
|
+
explanation=f"Meets ACMG/AMP Likely Pathogenic criteria rules ({len(codes)} criteria applied).",
|
|
199
|
+
confidence=0.90,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
# Benign combinations
|
|
203
|
+
if bs >= 2 and not has_pathogenic:
|
|
204
|
+
return ACMGEvaluation(
|
|
205
|
+
codes=codes,
|
|
206
|
+
classification="Benign",
|
|
207
|
+
explanation=f"Meets ACMG/AMP Benign criteria rules ({len(codes)} criteria applied).",
|
|
208
|
+
confidence=0.95,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
# Likely Benign combinations
|
|
212
|
+
is_likely_benign = (
|
|
213
|
+
(bs >= 1 and bp >= 1)
|
|
214
|
+
or (bp >= 2)
|
|
215
|
+
)
|
|
216
|
+
if is_likely_benign and not has_pathogenic:
|
|
217
|
+
return ACMGEvaluation(
|
|
218
|
+
codes=codes,
|
|
219
|
+
classification="Likely Benign",
|
|
220
|
+
explanation=f"Meets ACMG/AMP Likely Benign criteria rules ({len(codes)} criteria applied).",
|
|
221
|
+
confidence=0.90,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
return ACMGEvaluation(
|
|
225
|
+
codes=codes,
|
|
226
|
+
classification="VUS",
|
|
227
|
+
explanation="Criteria do not reach Pathogenic or Benign thresholds or exhibit conflicting evidence; remains Variant of Uncertain Significance.",
|
|
228
|
+
confidence=0.75,
|
|
229
|
+
)
|
ngs_agent/analyzer.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""VCF parser, QC scanner, and report rendering."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.panel import Panel
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
|
|
14
|
+
from ngs_agent.qc import QCMetric, QCParser
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class Variant:
|
|
19
|
+
chrom: str
|
|
20
|
+
pos: int
|
|
21
|
+
ref: str
|
|
22
|
+
alt: str
|
|
23
|
+
gene: str
|
|
24
|
+
consequence: str
|
|
25
|
+
clinvar: str
|
|
26
|
+
af: float | None
|
|
27
|
+
depth: int | None
|
|
28
|
+
vaf: float | None
|
|
29
|
+
is_pathogenic: bool = False
|
|
30
|
+
is_vus: bool = False
|
|
31
|
+
samples: dict[str, dict[str, Any]] = field(default_factory=dict)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _parse_consequence(csq_or_ann: str | None) -> str:
|
|
35
|
+
if not csq_or_ann or csq_or_ann == ".":
|
|
36
|
+
return "."
|
|
37
|
+
first_allele_csq = csq_or_ann.split(",")[0]
|
|
38
|
+
if "|" in first_allele_csq:
|
|
39
|
+
parts = first_allele_csq.split("|")
|
|
40
|
+
if len(parts) > 1 and parts[1]:
|
|
41
|
+
return parts[1]
|
|
42
|
+
return parts[0]
|
|
43
|
+
return first_allele_csq
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def parse_vcf(path: Path) -> list[Variant]:
|
|
47
|
+
variants: list[Variant] = []
|
|
48
|
+
with path.open(encoding="utf-8") as fh:
|
|
49
|
+
sample_names: list[str] = []
|
|
50
|
+
for line in fh:
|
|
51
|
+
if line.startswith("##"):
|
|
52
|
+
continue
|
|
53
|
+
if line.startswith("#CHROM"):
|
|
54
|
+
headers = line.strip().split("\t")
|
|
55
|
+
if len(headers) > 9:
|
|
56
|
+
sample_names = headers[9:]
|
|
57
|
+
continue
|
|
58
|
+
|
|
59
|
+
parts = line.strip().split("\t")
|
|
60
|
+
if len(parts) < 8:
|
|
61
|
+
continue
|
|
62
|
+
chrom, pos_s, _id, ref, alt = parts[0], parts[1], parts[2], parts[3], parts[4]
|
|
63
|
+
info = parts[7]
|
|
64
|
+
format_keys = parts[8].split(":") if len(parts) > 8 else []
|
|
65
|
+
fmt = parts[9] if len(parts) > 9 else ""
|
|
66
|
+
|
|
67
|
+
csq_raw = _info_field(info, "CSQ") or _info_field(info, "ANN") or _info_field(info, "CONSEQUENCE") or "."
|
|
68
|
+
consequence = _parse_consequence(csq_raw)
|
|
69
|
+
|
|
70
|
+
gene = _info_field(info, "GENE") or _info_field(info, "SYMBOL")
|
|
71
|
+
if not gene or gene == ".":
|
|
72
|
+
if csq_raw != ".":
|
|
73
|
+
first_csq = csq_raw.split(",")[0]
|
|
74
|
+
csq_parts = first_csq.split("|")
|
|
75
|
+
if len(csq_parts) >= 4 and csq_parts[3] and csq_parts[3] != ".":
|
|
76
|
+
gene = csq_parts[3]
|
|
77
|
+
elif len(csq_parts) >= 5 and csq_parts[4] and csq_parts[4] != ".":
|
|
78
|
+
gene = csq_parts[4]
|
|
79
|
+
if not gene:
|
|
80
|
+
gene = "."
|
|
81
|
+
|
|
82
|
+
clinvar = _info_field(info, "CLNSIG") or _info_field(info, "CLINVAR") or "."
|
|
83
|
+
af = _parse_float(_info_field(info, "AF"))
|
|
84
|
+
depth, vaf = _parse_sample(fmt, format_keys)
|
|
85
|
+
|
|
86
|
+
clinvar_lower = clinvar.lower()
|
|
87
|
+
is_pathogenic = "pathogenic" in clinvar_lower and "conflict" not in clinvar_lower
|
|
88
|
+
is_vus = (
|
|
89
|
+
"uncertain" in clinvar_lower
|
|
90
|
+
or "vus" in clinvar_lower
|
|
91
|
+
or "unknown significance" in clinvar_lower
|
|
92
|
+
or "unknown_significance" in clinvar_lower
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# Parse multisample columns if available
|
|
96
|
+
sample_data = {}
|
|
97
|
+
if sample_names and len(parts) > 9:
|
|
98
|
+
for idx, sname in enumerate(sample_names):
|
|
99
|
+
if 9 + idx < len(parts):
|
|
100
|
+
sfmt = parts[9 + idx]
|
|
101
|
+
s_dp, s_vaf = _parse_sample(sfmt, format_keys)
|
|
102
|
+
sample_data[sname] = {"dp": s_dp, "vaf": s_vaf, "raw": sfmt}
|
|
103
|
+
|
|
104
|
+
variants.append(
|
|
105
|
+
Variant(
|
|
106
|
+
chrom=chrom,
|
|
107
|
+
pos=int(pos_s),
|
|
108
|
+
ref=ref,
|
|
109
|
+
alt=alt,
|
|
110
|
+
gene=gene,
|
|
111
|
+
consequence=consequence,
|
|
112
|
+
clinvar=clinvar,
|
|
113
|
+
af=af,
|
|
114
|
+
depth=depth,
|
|
115
|
+
vaf=vaf,
|
|
116
|
+
is_pathogenic=is_pathogenic,
|
|
117
|
+
is_vus=is_vus,
|
|
118
|
+
samples=sample_data,
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
return variants
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _info_field(info: str, key: str) -> str | None:
|
|
125
|
+
for part in info.split(";"):
|
|
126
|
+
if part.startswith(f"{key}="):
|
|
127
|
+
return part.split("=", 1)[1]
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _parse_float(value: str | None) -> float | None:
|
|
132
|
+
if value is None:
|
|
133
|
+
return None
|
|
134
|
+
try:
|
|
135
|
+
return float(value.split(",")[0])
|
|
136
|
+
except ValueError:
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _parse_sample(fmt: str, format_keys: list[str]) -> tuple[int | None, float | None]:
|
|
141
|
+
if not fmt or fmt == ".":
|
|
142
|
+
return None, None
|
|
143
|
+
values = fmt.split(":")
|
|
144
|
+
fields = dict(zip(format_keys, values, strict=False))
|
|
145
|
+
|
|
146
|
+
depth = None
|
|
147
|
+
vaf = None
|
|
148
|
+
if "DP" in fields:
|
|
149
|
+
try:
|
|
150
|
+
depth = int(float(fields["DP"]))
|
|
151
|
+
except (ValueError, TypeError):
|
|
152
|
+
depth = None
|
|
153
|
+
if "AD" in fields:
|
|
154
|
+
try:
|
|
155
|
+
ad_parts = fields["AD"].split(",")
|
|
156
|
+
ads = [int(float(x)) for x in ad_parts if x not in (".", "")]
|
|
157
|
+
if len(ads) >= 2 and sum(ads) > 0:
|
|
158
|
+
vaf = ads[1] / sum(ads)
|
|
159
|
+
if depth is None:
|
|
160
|
+
depth = sum(ads)
|
|
161
|
+
except (ValueError, TypeError):
|
|
162
|
+
vaf = None
|
|
163
|
+
return depth, vaf
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def scan_qc(path: Path) -> list[QCMetric]:
|
|
167
|
+
"""Scan any QC file (FastQC, MultiQC, Samtools, or plain text)."""
|
|
168
|
+
return QCParser.parse(path)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def render_report(
|
|
172
|
+
variants: list[Variant],
|
|
173
|
+
qc_metrics: list[QCMetric] | None = None,
|
|
174
|
+
console: Console | None = None,
|
|
175
|
+
) -> None:
|
|
176
|
+
con = console or Console()
|
|
177
|
+
pathogenic = [v for v in variants if v.is_pathogenic]
|
|
178
|
+
vus = [v for v in variants if v.is_vus]
|
|
179
|
+
other = [v for v in variants if not v.is_pathogenic and not v.is_vus]
|
|
180
|
+
|
|
181
|
+
con.print(Panel("[bold]NGS-Agent Variant Report[/bold]", style="blue"))
|
|
182
|
+
|
|
183
|
+
if qc_metrics:
|
|
184
|
+
qc_table = Table(title="QC Summary", show_header=True)
|
|
185
|
+
qc_table.add_column("Metric")
|
|
186
|
+
qc_table.add_column("Value")
|
|
187
|
+
qc_table.add_column("Status")
|
|
188
|
+
for m in qc_metrics:
|
|
189
|
+
style = {"pass": "green", "warn": "yellow", "fail": "red"}.get(m.status, "white")
|
|
190
|
+
qc_table.add_row(m.name, m.value, f"[{style}]{m.status.upper()}[/{style}]")
|
|
191
|
+
con.print(qc_table)
|
|
192
|
+
con.print()
|
|
193
|
+
|
|
194
|
+
def _variant_table(title: str, rows: list[Variant], style: str) -> None:
|
|
195
|
+
if not rows:
|
|
196
|
+
return
|
|
197
|
+
table = Table(title=title, show_header=True, header_style=style)
|
|
198
|
+
table.add_column("Gene")
|
|
199
|
+
table.add_column("Variant")
|
|
200
|
+
table.add_column("Consequence")
|
|
201
|
+
table.add_column("ClinVar")
|
|
202
|
+
table.add_column("Depth/VAF")
|
|
203
|
+
for v in rows:
|
|
204
|
+
loc = f"{v.chrom}:{v.pos} {v.ref}>{v.alt}"
|
|
205
|
+
dv = f"{v.depth or '—'}/{f'{v.vaf:.0%}' if v.vaf is not None else '—'}"
|
|
206
|
+
table.add_row(v.gene, loc, v.consequence[:40], v.clinvar, dv)
|
|
207
|
+
con.print(table)
|
|
208
|
+
con.print()
|
|
209
|
+
|
|
210
|
+
_variant_table("Pathogenic / Likely Pathogenic", pathogenic, "bold red")
|
|
211
|
+
_variant_table("Variants of Uncertain Significance (VUS)", vus, "bold yellow")
|
|
212
|
+
_variant_table("Other Variants", other, "bold")
|
|
213
|
+
|
|
214
|
+
con.print(
|
|
215
|
+
Panel(
|
|
216
|
+
f"Total: {len(variants)} variants | "
|
|
217
|
+
f"Pathogenic: {len(pathogenic)} | VUS: {len(vus)} | Other: {len(other)}\n"
|
|
218
|
+
"Run [bold]ngsagent debate[/bold] on VUS entries (requires LLM).",
|
|
219
|
+
title="Summary",
|
|
220
|
+
)
|
|
221
|
+
)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Anthropic Claude backend."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from ngs_agent.backends.base import LLMBackend
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AnthropicBackend(LLMBackend):
|
|
11
|
+
def __init__(self, model: str = "claude-sonnet-4-20250514", api_key: str | None = None) -> None:
|
|
12
|
+
self.model = model
|
|
13
|
+
self.api_key = api_key or os.environ.get("ANTHROPIC_API_KEY", "")
|
|
14
|
+
|
|
15
|
+
def complete(self, prompt: str, system: str = "") -> str:
|
|
16
|
+
if not self.api_key:
|
|
17
|
+
raise RuntimeError("ANTHROPIC_API_KEY is not set.")
|
|
18
|
+
|
|
19
|
+
import anthropic
|
|
20
|
+
|
|
21
|
+
client = anthropic.Anthropic(api_key=self.api_key)
|
|
22
|
+
kwargs: dict = {
|
|
23
|
+
"model": self.model,
|
|
24
|
+
"max_tokens": 2048,
|
|
25
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
26
|
+
}
|
|
27
|
+
if system:
|
|
28
|
+
kwargs["system"] = system
|
|
29
|
+
message = client.messages.create(**kwargs)
|
|
30
|
+
return message.content[0].text
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Abstract LLM backend interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class LLMBackend(ABC):
|
|
9
|
+
"""Base class for LLM providers."""
|
|
10
|
+
|
|
11
|
+
@abstractmethod
|
|
12
|
+
def complete(self, prompt: str, system: str = "") -> str:
|
|
13
|
+
"""Return model text for a prompt."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class NoBackend(LLMBackend):
|
|
17
|
+
"""Placeholder when no LLM is configured."""
|
|
18
|
+
|
|
19
|
+
def complete(self, prompt: str, system: str = "") -> str:
|
|
20
|
+
raise RuntimeError(
|
|
21
|
+
"No LLM backend configured. Run `ngsagent config wizard` or set llm in "
|
|
22
|
+
"~/.ngsagent/config.yaml. Only `debate` requires an LLM; `watch` and "
|
|
23
|
+
"`analyze` work without one."
|
|
24
|
+
)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Backend factory supporting multi-provider LLMs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ngs_agent.backends.anthropic import AnthropicBackend
|
|
9
|
+
from ngs_agent.backends.base import LLMBackend, NoBackend
|
|
10
|
+
from ngs_agent.backends.gemini import GeminiBackend
|
|
11
|
+
from ngs_agent.backends.ollama import OllamaBackend
|
|
12
|
+
from ngs_agent.backends.openai_compat import OpenAICompatBackend, PROVIDER_PRESETS
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_backend(cfg: dict[str, Any]) -> LLMBackend:
|
|
16
|
+
llm = cfg.get("llm", "none").lower()
|
|
17
|
+
|
|
18
|
+
if not llm or llm == "none":
|
|
19
|
+
# Auto-detect from environment variables if not set in config
|
|
20
|
+
if os.environ.get("GEMINI_API_KEY"):
|
|
21
|
+
return GeminiBackend(model=cfg.get("gemini_model", "gemini-2.0-flash"))
|
|
22
|
+
if os.environ.get("ANTHROPIC_API_KEY"):
|
|
23
|
+
return AnthropicBackend(model=cfg.get("anthropic_model", "claude-3-7-sonnet-20250219"))
|
|
24
|
+
if os.environ.get("OPENAI_API_KEY"):
|
|
25
|
+
return OpenAICompatBackend(
|
|
26
|
+
base_url="https://api.openai.com/v1",
|
|
27
|
+
api_key=os.environ.get("OPENAI_API_KEY", ""),
|
|
28
|
+
model=cfg.get("openai_model", "gpt-4o"),
|
|
29
|
+
)
|
|
30
|
+
return NoBackend()
|
|
31
|
+
|
|
32
|
+
if llm == "gemini":
|
|
33
|
+
return GeminiBackend(
|
|
34
|
+
model=cfg.get("gemini_model", "gemini-2.0-flash"),
|
|
35
|
+
api_key=cfg.get("gemini_api_key"),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
if llm == "anthropic":
|
|
39
|
+
return AnthropicBackend(
|
|
40
|
+
model=cfg.get("anthropic_model", "claude-3-7-sonnet-20250219"),
|
|
41
|
+
api_key=cfg.get("anthropic_api_key"),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
if llm == "ollama":
|
|
45
|
+
return OllamaBackend(
|
|
46
|
+
model=cfg.get("ollama_model", "llama3.2"),
|
|
47
|
+
host=cfg.get("ollama_host", "http://localhost:11434"),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
if llm == "openai":
|
|
51
|
+
return OpenAICompatBackend(
|
|
52
|
+
base_url="https://api.openai.com/v1",
|
|
53
|
+
api_key=cfg.get("openai_api_key") or os.environ.get("OPENAI_API_KEY", ""),
|
|
54
|
+
model=cfg.get("openai_model", "gpt-4o"),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
if llm == "openai_compat":
|
|
58
|
+
return OpenAICompatBackend(
|
|
59
|
+
base_url=cfg.get("openai_compat_base_url", "https://openrouter.ai/api/v1"),
|
|
60
|
+
api_key=cfg.get("openai_compat_api_key"),
|
|
61
|
+
model=cfg.get("openai_compat_model", "openrouter/auto"),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
if llm in PROVIDER_PRESETS:
|
|
65
|
+
base_url, default_model = PROVIDER_PRESETS[llm]
|
|
66
|
+
env_key = f"{llm.upper()}_API_KEY"
|
|
67
|
+
api_key = cfg.get(f"{llm}_api_key") or os.environ.get(env_key, "")
|
|
68
|
+
model = cfg.get(f"{llm}_model", default_model)
|
|
69
|
+
extra: dict[str, str] = {}
|
|
70
|
+
if llm == "openrouter":
|
|
71
|
+
extra["HTTP-Referer"] = "https://github.com/ranaalyan1/NGS-Agent"
|
|
72
|
+
extra["X-Title"] = "NGS-Agent"
|
|
73
|
+
return OpenAICompatBackend(
|
|
74
|
+
base_url=base_url,
|
|
75
|
+
api_key=api_key,
|
|
76
|
+
model=model,
|
|
77
|
+
extra_headers=extra,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
return NoBackend()
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Google Gemini native LLM backend."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from ngs_agent.backends.base import LLMBackend
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GeminiBackend(LLMBackend):
|
|
14
|
+
"""Direct Google Gemini REST backend using Google AI Studio / Gemini API."""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
api_key: Optional[str] = None,
|
|
19
|
+
model: str = "gemini-2.0-flash",
|
|
20
|
+
) -> None:
|
|
21
|
+
self.api_key = api_key or os.environ.get("GEMINI_API_KEY", "")
|
|
22
|
+
self.model = model
|
|
23
|
+
|
|
24
|
+
def complete(self, prompt: str, system: Optional[str] = None) -> str:
|
|
25
|
+
if not self.api_key:
|
|
26
|
+
raise RuntimeError(
|
|
27
|
+
"Gemini API key not found. Set GEMINI_API_KEY environment variable or run `ngsagent config wizard`."
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
url = f"https://generativelanguage.googleapis.com/v1beta/models/{self.model}:generateContent?key={self.api_key}"
|
|
31
|
+
|
|
32
|
+
contents = []
|
|
33
|
+
if system:
|
|
34
|
+
contents.append({"role": "user", "parts": [{"text": f"System Instructions: {system}"}]})
|
|
35
|
+
contents.append({"role": "model", "parts": [{"text": "Understood. I will follow these instructions."}]})
|
|
36
|
+
|
|
37
|
+
contents.append({"role": "user", "parts": [{"text": prompt}]})
|
|
38
|
+
|
|
39
|
+
payload = {
|
|
40
|
+
"contents": contents,
|
|
41
|
+
"generationConfig": {
|
|
42
|
+
"temperature": 0.2,
|
|
43
|
+
"maxOutputTokens": 1024,
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
with httpx.Client(timeout=30.0) as client:
|
|
49
|
+
response = client.post(url, json=payload)
|
|
50
|
+
response.raise_for_status()
|
|
51
|
+
data = response.json()
|
|
52
|
+
candidates = data.get("candidates", [])
|
|
53
|
+
if candidates:
|
|
54
|
+
parts = candidates[0].get("content", {}).get("parts", [])
|
|
55
|
+
if parts:
|
|
56
|
+
return parts[0].get("text", "").strip()
|
|
57
|
+
return "[No response text generated by Gemini]"
|
|
58
|
+
except httpx.HTTPStatusError as exc:
|
|
59
|
+
raise RuntimeError(f"Gemini API error ({exc.response.status_code}): {exc.response.text}") from exc
|
|
60
|
+
except Exception as exc:
|
|
61
|
+
raise RuntimeError(f"Failed to communicate with Gemini API: {exc}") from exc
|