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 ADDED
@@ -0,0 +1,3 @@
1
+ """NGS-Agent: pipeline log watcher and VCF/QC interpreter for wet-lab researchers."""
2
+
3
+ __version__ = "0.2.0"
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,6 @@
1
+ """LLM backend registry."""
2
+
3
+ from ngs_agent.backends.base import LLMBackend, NoBackend
4
+ from ngs_agent.backends.factory import get_backend
5
+
6
+ __all__ = ["LLMBackend", "NoBackend", "get_backend"]
@@ -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