celltype-cli 0.1.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.
- celltype_cli-0.1.0.dist-info/METADATA +267 -0
- celltype_cli-0.1.0.dist-info/RECORD +89 -0
- celltype_cli-0.1.0.dist-info/WHEEL +4 -0
- celltype_cli-0.1.0.dist-info/entry_points.txt +2 -0
- celltype_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- ct/__init__.py +3 -0
- ct/agent/__init__.py +0 -0
- ct/agent/case_studies.py +426 -0
- ct/agent/config.py +523 -0
- ct/agent/doctor.py +544 -0
- ct/agent/knowledge.py +523 -0
- ct/agent/loop.py +99 -0
- ct/agent/mcp_server.py +478 -0
- ct/agent/orchestrator.py +733 -0
- ct/agent/runner.py +656 -0
- ct/agent/sandbox.py +481 -0
- ct/agent/session.py +145 -0
- ct/agent/system_prompt.py +186 -0
- ct/agent/trace_store.py +228 -0
- ct/agent/trajectory.py +169 -0
- ct/agent/types.py +182 -0
- ct/agent/workflows.py +462 -0
- ct/api/__init__.py +1 -0
- ct/api/app.py +211 -0
- ct/api/config.py +120 -0
- ct/api/engine.py +124 -0
- ct/cli.py +1448 -0
- ct/data/__init__.py +0 -0
- ct/data/compute_providers.json +59 -0
- ct/data/cro_database.json +395 -0
- ct/data/downloader.py +238 -0
- ct/data/loaders.py +252 -0
- ct/kb/__init__.py +5 -0
- ct/kb/benchmarks.py +147 -0
- ct/kb/governance.py +106 -0
- ct/kb/ingest.py +415 -0
- ct/kb/reasoning.py +129 -0
- ct/kb/schema_monitor.py +162 -0
- ct/kb/substrate.py +387 -0
- ct/models/__init__.py +0 -0
- ct/models/llm.py +370 -0
- ct/tools/__init__.py +195 -0
- ct/tools/_compound_resolver.py +297 -0
- ct/tools/biomarker.py +368 -0
- ct/tools/cellxgene.py +282 -0
- ct/tools/chemistry.py +1371 -0
- ct/tools/claude.py +390 -0
- ct/tools/clinical.py +1153 -0
- ct/tools/clue.py +249 -0
- ct/tools/code.py +1069 -0
- ct/tools/combination.py +397 -0
- ct/tools/compute.py +402 -0
- ct/tools/cro.py +413 -0
- ct/tools/data_api.py +2114 -0
- ct/tools/design.py +295 -0
- ct/tools/dna.py +575 -0
- ct/tools/experiment.py +604 -0
- ct/tools/expression.py +655 -0
- ct/tools/files.py +957 -0
- ct/tools/genomics.py +1387 -0
- ct/tools/http_client.py +146 -0
- ct/tools/imaging.py +319 -0
- ct/tools/intel.py +223 -0
- ct/tools/literature.py +743 -0
- ct/tools/network.py +422 -0
- ct/tools/notification.py +111 -0
- ct/tools/omics.py +3330 -0
- ct/tools/ops.py +1230 -0
- ct/tools/parity.py +649 -0
- ct/tools/pk.py +245 -0
- ct/tools/protein.py +678 -0
- ct/tools/regulatory.py +643 -0
- ct/tools/remote_data.py +179 -0
- ct/tools/report.py +181 -0
- ct/tools/repurposing.py +376 -0
- ct/tools/safety.py +1280 -0
- ct/tools/shell.py +178 -0
- ct/tools/singlecell.py +533 -0
- ct/tools/statistics.py +552 -0
- ct/tools/structure.py +882 -0
- ct/tools/target.py +901 -0
- ct/tools/translational.py +123 -0
- ct/tools/viability.py +218 -0
- ct/ui/__init__.py +0 -0
- ct/ui/markdown.py +31 -0
- ct/ui/status.py +258 -0
- ct/ui/suggestions.py +567 -0
- ct/ui/terminal.py +1456 -0
- ct/ui/traces.py +112 -0
ct/agent/doctor.py
ADDED
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Deployment readiness checks for ct.
|
|
3
|
+
|
|
4
|
+
Used by `ct doctor` and interactive `/doctor` to surface actionable setup issues.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
import logging
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
from ct.agent.config import CONFIG_FILE, Config
|
|
14
|
+
from ct.tools import EXPERIMENTAL_CATEGORIES, ensure_loaded, tool_load_errors
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger("ct.doctor")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class DoctorCheck:
|
|
21
|
+
name: str
|
|
22
|
+
status: str # "ok" | "warn" | "error"
|
|
23
|
+
detail: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _status_markup(status: str) -> str:
|
|
27
|
+
if status == "ok":
|
|
28
|
+
return "[green]ok[/green]"
|
|
29
|
+
if status == "warn":
|
|
30
|
+
return "[yellow]warn[/yellow]"
|
|
31
|
+
return "[red]error[/red]"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def run_checks(config: Config | None = None, session=None) -> list[DoctorCheck]:
|
|
35
|
+
"""Run production-readiness checks and return structured results.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
config: Optional Config instance. Loaded from disk if not provided.
|
|
39
|
+
session: Optional Session instance. When provided, runtime tool health
|
|
40
|
+
data (suppressed tools, failure counts) is included in the report.
|
|
41
|
+
"""
|
|
42
|
+
cfg = config or Config.load()
|
|
43
|
+
checks: list[DoctorCheck] = []
|
|
44
|
+
|
|
45
|
+
# 1) Config file readability (best-effort: load already handled parse errors)
|
|
46
|
+
if CONFIG_FILE.exists():
|
|
47
|
+
checks.append(
|
|
48
|
+
DoctorCheck(
|
|
49
|
+
name="config_file",
|
|
50
|
+
status="ok",
|
|
51
|
+
detail=f"Using {CONFIG_FILE}",
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
else:
|
|
55
|
+
checks.append(
|
|
56
|
+
DoctorCheck(
|
|
57
|
+
name="config_file",
|
|
58
|
+
status="warn",
|
|
59
|
+
detail=f"No config file yet at {CONFIG_FILE} (defaults/env vars are used)",
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# 2) LLM configuration readiness
|
|
64
|
+
llm_issue = cfg.llm_preflight_issue()
|
|
65
|
+
provider = cfg.get("llm.provider", "anthropic")
|
|
66
|
+
model = cfg.get("llm.model")
|
|
67
|
+
if llm_issue:
|
|
68
|
+
checks.append(DoctorCheck(name="llm", status="error", detail=llm_issue))
|
|
69
|
+
else:
|
|
70
|
+
checks.append(
|
|
71
|
+
DoctorCheck(
|
|
72
|
+
name="llm",
|
|
73
|
+
status="ok",
|
|
74
|
+
detail=f"provider={provider}, model={model}",
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# 3) Output directory availability
|
|
79
|
+
out_dir = Path(cfg.get("sandbox.output_dir", str(Path.cwd() / "outputs")))
|
|
80
|
+
try:
|
|
81
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
82
|
+
checks.append(
|
|
83
|
+
DoctorCheck(name="output_dir", status="ok", detail=f"Writable: {out_dir}")
|
|
84
|
+
)
|
|
85
|
+
except OSError as exc:
|
|
86
|
+
checks.append(
|
|
87
|
+
DoctorCheck(name="output_dir", status="error", detail=f"{out_dir}: {exc}")
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# 4) Data base directory availability
|
|
91
|
+
data_base = Path(cfg.get("data.base", str(Path.home() / ".ct" / "data")))
|
|
92
|
+
try:
|
|
93
|
+
data_base.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
checks.append(
|
|
95
|
+
DoctorCheck(name="data_base", status="ok", detail=f"Writable: {data_base}")
|
|
96
|
+
)
|
|
97
|
+
except OSError as exc:
|
|
98
|
+
checks.append(
|
|
99
|
+
DoctorCheck(name="data_base", status="warn", detail=f"{data_base}: {exc}")
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# 5) Tool module import health
|
|
103
|
+
ensure_loaded()
|
|
104
|
+
load_errors = tool_load_errors()
|
|
105
|
+
if load_errors:
|
|
106
|
+
sample = ", ".join(sorted(load_errors.keys())[:8])
|
|
107
|
+
extra = "" if len(load_errors) <= 8 else f" (+{len(load_errors) - 8} more)"
|
|
108
|
+
checks.append(
|
|
109
|
+
DoctorCheck(
|
|
110
|
+
name="tool_modules",
|
|
111
|
+
status="warn",
|
|
112
|
+
detail=f"{len(load_errors)} module(s) failed to load: {sample}{extra}",
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
else:
|
|
116
|
+
checks.append(
|
|
117
|
+
DoctorCheck(name="tool_modules", status="ok", detail="All tool modules loaded")
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# 6) Experimental categories planning status
|
|
121
|
+
if cfg.get("agent.enable_experimental_tools", False):
|
|
122
|
+
checks.append(
|
|
123
|
+
DoctorCheck(
|
|
124
|
+
name="experimental_tools",
|
|
125
|
+
status="warn",
|
|
126
|
+
detail=(
|
|
127
|
+
f"Experimental categories enabled for planning: "
|
|
128
|
+
f"{', '.join(sorted(EXPERIMENTAL_CATEGORIES))}"
|
|
129
|
+
),
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
else:
|
|
133
|
+
checks.append(
|
|
134
|
+
DoctorCheck(
|
|
135
|
+
name="experimental_tools",
|
|
136
|
+
status="ok",
|
|
137
|
+
detail=(
|
|
138
|
+
f"Experimental categories hidden from planning by default: "
|
|
139
|
+
f"{', '.join(sorted(EXPERIMENTAL_CATEGORIES))}"
|
|
140
|
+
),
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# 7) Grounding guardrail status
|
|
145
|
+
if cfg.get("agent.enforce_grounded_synthesis", True):
|
|
146
|
+
checks.append(
|
|
147
|
+
DoctorCheck(
|
|
148
|
+
name="grounding_guard",
|
|
149
|
+
status="ok",
|
|
150
|
+
detail="Grounded synthesis enforcement is enabled",
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
else:
|
|
154
|
+
checks.append(
|
|
155
|
+
DoctorCheck(
|
|
156
|
+
name="grounding_guard",
|
|
157
|
+
status="warn",
|
|
158
|
+
detail="Grounded synthesis enforcement is disabled",
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# 8) Runtime profile
|
|
163
|
+
profile = str(cfg.get("agent.profile", "research"))
|
|
164
|
+
if profile not in {"research", "pharma", "enterprise"}:
|
|
165
|
+
checks.append(
|
|
166
|
+
DoctorCheck(
|
|
167
|
+
name="runtime_profile",
|
|
168
|
+
status="warn",
|
|
169
|
+
detail=f"Unknown agent.profile '{profile}'",
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
else:
|
|
173
|
+
checks.append(
|
|
174
|
+
DoctorCheck(
|
|
175
|
+
name="runtime_profile",
|
|
176
|
+
status="ok",
|
|
177
|
+
detail=f"{profile}",
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
synthesis_style = str(cfg.get("agent.synthesis_style", "standard")).strip().lower()
|
|
182
|
+
if synthesis_style not in {"standard", "pharma"}:
|
|
183
|
+
checks.append(
|
|
184
|
+
DoctorCheck(
|
|
185
|
+
name="synthesis_style",
|
|
186
|
+
status="warn",
|
|
187
|
+
detail=f"Unknown agent.synthesis_style '{synthesis_style}'",
|
|
188
|
+
)
|
|
189
|
+
)
|
|
190
|
+
elif profile == "pharma" and synthesis_style != "pharma":
|
|
191
|
+
checks.append(
|
|
192
|
+
DoctorCheck(
|
|
193
|
+
name="synthesis_style",
|
|
194
|
+
status="warn",
|
|
195
|
+
detail="agent.profile=pharma but synthesis style is not pharma",
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
else:
|
|
199
|
+
checks.append(
|
|
200
|
+
DoctorCheck(
|
|
201
|
+
name="synthesis_style",
|
|
202
|
+
status="ok",
|
|
203
|
+
detail=synthesis_style,
|
|
204
|
+
)
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# 9) Quality gate policy
|
|
208
|
+
if cfg.get("agent.quality_gate_enabled", True):
|
|
209
|
+
strict = bool(cfg.get("agent.quality_gate_strict", False))
|
|
210
|
+
checks.append(
|
|
211
|
+
DoctorCheck(
|
|
212
|
+
name="quality_gate",
|
|
213
|
+
status="ok" if strict else "warn",
|
|
214
|
+
detail=(
|
|
215
|
+
"Strict quality gate enabled (must pass citation/actionability checks)"
|
|
216
|
+
if strict
|
|
217
|
+
else "Quality gate is warn-only (strict mode disabled)"
|
|
218
|
+
),
|
|
219
|
+
)
|
|
220
|
+
)
|
|
221
|
+
else:
|
|
222
|
+
checks.append(
|
|
223
|
+
DoctorCheck(
|
|
224
|
+
name="quality_gate",
|
|
225
|
+
status="warn",
|
|
226
|
+
detail="Quality gate is disabled",
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
# 10) Enterprise policy layer
|
|
231
|
+
enforce_policy = bool(cfg.get("enterprise.enforce_policy", False))
|
|
232
|
+
checks.append(
|
|
233
|
+
DoctorCheck(
|
|
234
|
+
name="enterprise_policy",
|
|
235
|
+
status="ok" if enforce_policy else "warn",
|
|
236
|
+
detail=(
|
|
237
|
+
"Policy enforcement enabled"
|
|
238
|
+
if enforce_policy
|
|
239
|
+
else "Policy enforcement disabled (research mode)"
|
|
240
|
+
),
|
|
241
|
+
)
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
# 11) Knowledge substrate path
|
|
245
|
+
substrate_path = Path(
|
|
246
|
+
cfg.get("knowledge.substrate_path", str(Path.home() / ".ct" / "knowledge" / "substrate.json"))
|
|
247
|
+
)
|
|
248
|
+
try:
|
|
249
|
+
substrate_path.parent.mkdir(parents=True, exist_ok=True)
|
|
250
|
+
checks.append(
|
|
251
|
+
DoctorCheck(
|
|
252
|
+
name="knowledge_substrate",
|
|
253
|
+
status="ok",
|
|
254
|
+
detail=f"Writable substrate path: {substrate_path}",
|
|
255
|
+
)
|
|
256
|
+
)
|
|
257
|
+
except OSError as exc:
|
|
258
|
+
checks.append(
|
|
259
|
+
DoctorCheck(
|
|
260
|
+
name="knowledge_substrate",
|
|
261
|
+
status="warn",
|
|
262
|
+
detail=f"Could not prepare substrate path {substrate_path}: {exc}",
|
|
263
|
+
)
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
# 12) Schema monitor readiness
|
|
267
|
+
if cfg.get("knowledge.schema_monitor_enabled", False):
|
|
268
|
+
baseline = Path.home() / ".ct" / "knowledge" / "schema_baselines.json"
|
|
269
|
+
if baseline.exists():
|
|
270
|
+
checks.append(
|
|
271
|
+
DoctorCheck(
|
|
272
|
+
name="schema_monitor",
|
|
273
|
+
status="ok",
|
|
274
|
+
detail=f"Baseline present: {baseline}",
|
|
275
|
+
)
|
|
276
|
+
)
|
|
277
|
+
else:
|
|
278
|
+
checks.append(
|
|
279
|
+
DoctorCheck(
|
|
280
|
+
name="schema_monitor",
|
|
281
|
+
status="warn",
|
|
282
|
+
detail="Schema monitor enabled but no baseline found. Run: ct knowledge schema-update",
|
|
283
|
+
)
|
|
284
|
+
)
|
|
285
|
+
else:
|
|
286
|
+
checks.append(
|
|
287
|
+
DoctorCheck(
|
|
288
|
+
name="schema_monitor",
|
|
289
|
+
status="warn",
|
|
290
|
+
detail="Schema monitor disabled",
|
|
291
|
+
)
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
# 13) Claude Code delegation policy
|
|
295
|
+
if cfg.get("agent.enable_claude_code_tool", False):
|
|
296
|
+
checks.append(
|
|
297
|
+
DoctorCheck(
|
|
298
|
+
name="claude_code_policy",
|
|
299
|
+
status="warn",
|
|
300
|
+
detail="claude.code is enabled for autonomous use (high privilege)",
|
|
301
|
+
)
|
|
302
|
+
)
|
|
303
|
+
else:
|
|
304
|
+
checks.append(
|
|
305
|
+
DoctorCheck(
|
|
306
|
+
name="claude_code_policy",
|
|
307
|
+
status="ok",
|
|
308
|
+
detail="claude.code is disabled by default (opt-in)",
|
|
309
|
+
)
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
# 14) Data availability — verify key datasets can be found
|
|
313
|
+
checks.append(_check_data_availability(cfg))
|
|
314
|
+
|
|
315
|
+
# 15) Downloads directory
|
|
316
|
+
checks.append(_check_downloads_dir())
|
|
317
|
+
|
|
318
|
+
# 16) API connectivity (lightweight HEAD probes)
|
|
319
|
+
checks.extend(_check_api_connectivity())
|
|
320
|
+
|
|
321
|
+
# 17) Runtime tool health
|
|
322
|
+
checks.append(_check_tool_health(session))
|
|
323
|
+
|
|
324
|
+
# 18) Preflight validation config
|
|
325
|
+
if cfg.get("agent.preflight_validation_enabled", True):
|
|
326
|
+
checks.append(
|
|
327
|
+
DoctorCheck(
|
|
328
|
+
name="preflight_validation",
|
|
329
|
+
status="ok",
|
|
330
|
+
detail="Pre-query API key validation is enabled",
|
|
331
|
+
)
|
|
332
|
+
)
|
|
333
|
+
else:
|
|
334
|
+
checks.append(
|
|
335
|
+
DoctorCheck(
|
|
336
|
+
name="preflight_validation",
|
|
337
|
+
status="warn",
|
|
338
|
+
detail="Pre-query API key validation is disabled",
|
|
339
|
+
)
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
return checks
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def has_errors(checks: list[DoctorCheck]) -> bool:
|
|
346
|
+
"""Return True if any check has error status."""
|
|
347
|
+
return any(c.status == "error" for c in checks)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def to_table(checks: list[DoctorCheck]) -> Table:
|
|
351
|
+
"""Render doctor checks as a rich table."""
|
|
352
|
+
table = Table(title="ct Doctor")
|
|
353
|
+
table.add_column("Check", style="cyan")
|
|
354
|
+
table.add_column("Status")
|
|
355
|
+
table.add_column("Details")
|
|
356
|
+
|
|
357
|
+
for check in checks:
|
|
358
|
+
table.add_row(check.name, _status_markup(check.status), check.detail)
|
|
359
|
+
|
|
360
|
+
return table
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
# ---------------------------------------------------------------------------
|
|
364
|
+
# Runtime health check helpers
|
|
365
|
+
# ---------------------------------------------------------------------------
|
|
366
|
+
|
|
367
|
+
# Key datasets and the file patterns used by loaders
|
|
368
|
+
_KEY_DATASETS = {
|
|
369
|
+
"depmap": ("CRISPRGeneEffect.csv", ["", "depmap"]),
|
|
370
|
+
"prism": ("prism_LFC_COLLAPSED.csv", ["", "prism"]),
|
|
371
|
+
"l1000": ("l1000_landmark_only.parquet", ["", "l1000"]),
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _check_data_availability(cfg: Config) -> DoctorCheck:
|
|
376
|
+
"""Check whether key datasets can be found on disk."""
|
|
377
|
+
data_base = Path(cfg.get("data.base", str(Path.home() / ".ct" / "data")))
|
|
378
|
+
search_dirs = [data_base]
|
|
379
|
+
# Also check ct-data sister project
|
|
380
|
+
ct_data = Path.home() / "Projects" / "CellType" / "ct-data"
|
|
381
|
+
if ct_data.exists():
|
|
382
|
+
search_dirs.append(ct_data)
|
|
383
|
+
|
|
384
|
+
found = []
|
|
385
|
+
missing = []
|
|
386
|
+
for name, (filename, subdirs) in _KEY_DATASETS.items():
|
|
387
|
+
located = False
|
|
388
|
+
stem = Path(filename).stem
|
|
389
|
+
for base_dir in search_dirs:
|
|
390
|
+
for sub in subdirs:
|
|
391
|
+
d = base_dir / sub if sub else base_dir
|
|
392
|
+
if (d / filename).exists():
|
|
393
|
+
located = True
|
|
394
|
+
break
|
|
395
|
+
parquet = d / f"{stem}.parquet"
|
|
396
|
+
if parquet.exists():
|
|
397
|
+
located = True
|
|
398
|
+
break
|
|
399
|
+
if located:
|
|
400
|
+
break
|
|
401
|
+
if located:
|
|
402
|
+
found.append(name)
|
|
403
|
+
else:
|
|
404
|
+
missing.append(name)
|
|
405
|
+
|
|
406
|
+
if not missing:
|
|
407
|
+
return DoctorCheck(
|
|
408
|
+
name="data_availability",
|
|
409
|
+
status="ok",
|
|
410
|
+
detail=f"Key datasets found: {', '.join(sorted(found))}",
|
|
411
|
+
)
|
|
412
|
+
if found:
|
|
413
|
+
return DoctorCheck(
|
|
414
|
+
name="data_availability",
|
|
415
|
+
status="warn",
|
|
416
|
+
detail=f"Missing datasets: {', '.join(sorted(missing))} (found: {', '.join(sorted(found))}). Run: ct data pull <name>",
|
|
417
|
+
)
|
|
418
|
+
return DoctorCheck(
|
|
419
|
+
name="data_availability",
|
|
420
|
+
status="warn",
|
|
421
|
+
detail=f"No key datasets found ({', '.join(sorted(missing))}). Run: ct data pull depmap",
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _check_downloads_dir() -> DoctorCheck:
|
|
426
|
+
"""Verify ~/.ct/downloads/ exists and is writable."""
|
|
427
|
+
downloads = Path.home() / ".ct" / "downloads"
|
|
428
|
+
try:
|
|
429
|
+
downloads.mkdir(parents=True, exist_ok=True)
|
|
430
|
+
return DoctorCheck(
|
|
431
|
+
name="downloads_dir",
|
|
432
|
+
status="ok",
|
|
433
|
+
detail=f"Writable: {downloads}",
|
|
434
|
+
)
|
|
435
|
+
except OSError as exc:
|
|
436
|
+
return DoctorCheck(
|
|
437
|
+
name="downloads_dir",
|
|
438
|
+
status="warn",
|
|
439
|
+
detail=f"{downloads}: {exc}",
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
# APIs to probe with HEAD requests (short timeout, best-effort)
|
|
444
|
+
_API_PROBES = [
|
|
445
|
+
("PubMed eutils", "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/einfo.fcgi"),
|
|
446
|
+
("Enrichr", "https://maayanlab.cloud/Enrichr/"),
|
|
447
|
+
("GDC API", "https://api.gdc.cancer.gov/status"),
|
|
448
|
+
]
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _check_api_connectivity() -> list[DoctorCheck]:
|
|
452
|
+
"""Quick HEAD/GET probe against key public APIs."""
|
|
453
|
+
checks = []
|
|
454
|
+
try:
|
|
455
|
+
import httpx
|
|
456
|
+
except ImportError:
|
|
457
|
+
checks.append(
|
|
458
|
+
DoctorCheck(
|
|
459
|
+
name="api_connectivity",
|
|
460
|
+
status="warn",
|
|
461
|
+
detail="httpx not installed — skipping API connectivity probes",
|
|
462
|
+
)
|
|
463
|
+
)
|
|
464
|
+
return checks
|
|
465
|
+
|
|
466
|
+
reachable = []
|
|
467
|
+
unreachable = []
|
|
468
|
+
for label, url in _API_PROBES:
|
|
469
|
+
try:
|
|
470
|
+
resp = httpx.head(url, timeout=5, follow_redirects=True)
|
|
471
|
+
if resp.status_code < 500:
|
|
472
|
+
reachable.append(label)
|
|
473
|
+
else:
|
|
474
|
+
unreachable.append(f"{label} (HTTP {resp.status_code})")
|
|
475
|
+
except Exception:
|
|
476
|
+
unreachable.append(label)
|
|
477
|
+
|
|
478
|
+
if not unreachable:
|
|
479
|
+
checks.append(
|
|
480
|
+
DoctorCheck(
|
|
481
|
+
name="api_connectivity",
|
|
482
|
+
status="ok",
|
|
483
|
+
detail=f"All probes passed: {', '.join(reachable)}",
|
|
484
|
+
)
|
|
485
|
+
)
|
|
486
|
+
elif reachable:
|
|
487
|
+
checks.append(
|
|
488
|
+
DoctorCheck(
|
|
489
|
+
name="api_connectivity",
|
|
490
|
+
status="warn",
|
|
491
|
+
detail=f"Unreachable: {', '.join(unreachable)} (reachable: {', '.join(reachable)})",
|
|
492
|
+
)
|
|
493
|
+
)
|
|
494
|
+
else:
|
|
495
|
+
checks.append(
|
|
496
|
+
DoctorCheck(
|
|
497
|
+
name="api_connectivity",
|
|
498
|
+
status="warn",
|
|
499
|
+
detail=f"All API probes failed: {', '.join(unreachable)}. Check network connectivity.",
|
|
500
|
+
)
|
|
501
|
+
)
|
|
502
|
+
return checks
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _check_tool_health(session) -> DoctorCheck:
|
|
506
|
+
"""Report runtime tool suppression state from session."""
|
|
507
|
+
if session is None:
|
|
508
|
+
return DoctorCheck(
|
|
509
|
+
name="tool_health",
|
|
510
|
+
status="warn",
|
|
511
|
+
detail="No active session context; run /doctor in interactive mode for runtime tool-health diagnostics",
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
suppressed = set()
|
|
515
|
+
failure_counts: dict[str, int] = {}
|
|
516
|
+
if hasattr(session, "tool_health_suppressed_tools"):
|
|
517
|
+
suppressed = session.tool_health_suppressed_tools()
|
|
518
|
+
if hasattr(session, "_tool_health_failures"):
|
|
519
|
+
failure_counts = {
|
|
520
|
+
name: len(timestamps)
|
|
521
|
+
for name, timestamps in session._tool_health_failures.items()
|
|
522
|
+
if timestamps
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if not suppressed and not failure_counts:
|
|
526
|
+
return DoctorCheck(
|
|
527
|
+
name="tool_health",
|
|
528
|
+
status="ok",
|
|
529
|
+
detail="No tool failures or suppressions in this session",
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
parts = []
|
|
533
|
+
if suppressed:
|
|
534
|
+
parts.append(f"Suppressed: {', '.join(sorted(suppressed))}")
|
|
535
|
+
if failure_counts:
|
|
536
|
+
failing = [f"{n}({c})" for n, c in sorted(failure_counts.items()) if n not in suppressed]
|
|
537
|
+
if failing:
|
|
538
|
+
parts.append(f"Recent failures: {', '.join(failing)}")
|
|
539
|
+
|
|
540
|
+
return DoctorCheck(
|
|
541
|
+
name="tool_health",
|
|
542
|
+
status="warn",
|
|
543
|
+
detail="; ".join(parts),
|
|
544
|
+
)
|