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/cli.py
ADDED
|
@@ -0,0 +1,1448 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ct CLI entry point.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
ct # Interactive mode
|
|
6
|
+
ct "your question" # Single query
|
|
7
|
+
ct --smiles "CCO" "Profile" # With compound context
|
|
8
|
+
ct config set key value # Configuration
|
|
9
|
+
ct data pull depmap # Data management
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import json
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import typer
|
|
18
|
+
from typing import Optional
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from datetime import datetime, timezone
|
|
21
|
+
from rich.console import Console
|
|
22
|
+
from rich.panel import Panel
|
|
23
|
+
from rich.table import Table
|
|
24
|
+
|
|
25
|
+
from ct import __version__
|
|
26
|
+
from ct.agent.session import Session
|
|
27
|
+
from ct.ui.terminal import InteractiveTerminal
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ─── Startup banner ─────────────────────────────────────────
|
|
31
|
+
BANNER = """
|
|
32
|
+
[bold #50fa7b] ██████╗███████╗██╗ ██╗ ████████╗██╗ ██╗██████╗ ███████╗[/]
|
|
33
|
+
[bold #40f695]██╔════╝██╔════╝██║ ██║ ╚══██╔══╝╚██╗ ██╔╝██╔══██╗██╔════╝[/]
|
|
34
|
+
[bold #30f1b0]██║ █████╗ ██║ ██║ ██║ ╚████╔╝ ██████╔╝█████╗ [/]
|
|
35
|
+
[bold #20edca]██║ ██╔══╝ ██║ ██║ ██║ ╚██╔╝ ██╔═══╝ ██╔══╝ [/]
|
|
36
|
+
[bold #10e9e4]╚██████╗███████╗███████╗███████╗ ██║ ██║ ██║ ███████╗[/]
|
|
37
|
+
[bold #00e5ff] ╚═════╝╚══════╝╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝[/]
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
app = typer.Typer(
|
|
41
|
+
name="ct",
|
|
42
|
+
help=(
|
|
43
|
+
"CellType CLI — An autonomous agent for drug discovery research.\n\n"
|
|
44
|
+
"Common usage:\n"
|
|
45
|
+
' ct "your research question"\n'
|
|
46
|
+
' ct --smiles "CCO" "Profile this compound"\n'
|
|
47
|
+
" ct config show\n"
|
|
48
|
+
" ct tool list"
|
|
49
|
+
),
|
|
50
|
+
no_args_is_help=False,
|
|
51
|
+
)
|
|
52
|
+
console = Console()
|
|
53
|
+
|
|
54
|
+
# ─── Config subcommand ────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
config_app = typer.Typer(help="Manage ct configuration")
|
|
57
|
+
app.add_typer(config_app, name="config")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@config_app.command("set")
|
|
61
|
+
def config_set(key: str, value: str):
|
|
62
|
+
"""Set a configuration value."""
|
|
63
|
+
from ct.agent.config import Config
|
|
64
|
+
cfg = Config.load()
|
|
65
|
+
try:
|
|
66
|
+
cfg.set(key, value)
|
|
67
|
+
except ValueError as exc:
|
|
68
|
+
console.print(f"[red]{exc}[/red]")
|
|
69
|
+
raise typer.Exit(code=2)
|
|
70
|
+
cfg.save()
|
|
71
|
+
if key == "agent.profile":
|
|
72
|
+
console.print(
|
|
73
|
+
f" [green]Set[/green] {key} = {cfg.get('agent.profile')} "
|
|
74
|
+
"(applied preset settings)"
|
|
75
|
+
)
|
|
76
|
+
else:
|
|
77
|
+
console.print(f" [green]Set[/green] {key} = {value}")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@config_app.command("get")
|
|
81
|
+
def config_get(key: str):
|
|
82
|
+
"""Get a configuration value."""
|
|
83
|
+
from ct.agent.config import Config
|
|
84
|
+
cfg = Config.load()
|
|
85
|
+
val = cfg.get(key)
|
|
86
|
+
console.print(f" {key} = {val}")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@config_app.command("show")
|
|
90
|
+
def config_show():
|
|
91
|
+
"""Show all configuration."""
|
|
92
|
+
from ct.agent.config import Config
|
|
93
|
+
cfg = Config.load()
|
|
94
|
+
console.print(cfg.to_table())
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@config_app.command("validate")
|
|
98
|
+
def config_validate():
|
|
99
|
+
"""Validate configuration and report issues."""
|
|
100
|
+
from ct.agent.config import Config
|
|
101
|
+
cfg = Config.load()
|
|
102
|
+
issues = cfg.validate()
|
|
103
|
+
if not issues:
|
|
104
|
+
console.print("[green]Configuration is valid. No issues found.[/green]")
|
|
105
|
+
return
|
|
106
|
+
console.print(f"[yellow]Found {len(issues)} issue(s):[/yellow]")
|
|
107
|
+
for issue in issues:
|
|
108
|
+
console.print(f" - {issue}")
|
|
109
|
+
raise typer.Exit(code=2)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ─── Keys command ────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
@app.command("keys")
|
|
115
|
+
def keys_cmd():
|
|
116
|
+
"""Show status of optional API keys and what they unlock."""
|
|
117
|
+
from ct.agent.config import Config
|
|
118
|
+
cfg = Config.load()
|
|
119
|
+
console.print(cfg.keys_table())
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@app.command("setup")
|
|
123
|
+
def setup_cmd(
|
|
124
|
+
api_key: Optional[str] = typer.Option(None, "--api-key", help="Anthropic API key (non-interactive mode)"),
|
|
125
|
+
):
|
|
126
|
+
"""Interactive setup wizard — configure ct for first use."""
|
|
127
|
+
from ct.agent.config import Config
|
|
128
|
+
|
|
129
|
+
cfg = Config.load()
|
|
130
|
+
|
|
131
|
+
console.print()
|
|
132
|
+
console.print(
|
|
133
|
+
Panel(
|
|
134
|
+
"[bold]Welcome to CellType[/bold]\n\n"
|
|
135
|
+
"This wizard will configure ct for first use.\n"
|
|
136
|
+
"You need an Anthropic API key to get started.",
|
|
137
|
+
title="[cyan]ct setup[/cyan]",
|
|
138
|
+
border_style="cyan",
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
console.print()
|
|
142
|
+
|
|
143
|
+
# Determine the key — non-interactive flag, existing config, env var, or prompt
|
|
144
|
+
existing_key = cfg.llm_api_key()
|
|
145
|
+
|
|
146
|
+
if api_key:
|
|
147
|
+
# Non-interactive mode
|
|
148
|
+
chosen_key = api_key
|
|
149
|
+
elif existing_key:
|
|
150
|
+
masked = existing_key[:7] + "..." + existing_key[-4:] if len(existing_key) > 11 else "***"
|
|
151
|
+
console.print(f" API key already configured: [green]{masked}[/green]")
|
|
152
|
+
try:
|
|
153
|
+
keep = input(" Keep existing key? [Y/n] ").strip().lower()
|
|
154
|
+
except (EOFError, KeyboardInterrupt):
|
|
155
|
+
console.print("\n [dim]Setup cancelled.[/dim]")
|
|
156
|
+
raise typer.Exit()
|
|
157
|
+
if keep in ("", "y", "yes"):
|
|
158
|
+
chosen_key = existing_key
|
|
159
|
+
console.print(" [green]Keeping existing key.[/green]")
|
|
160
|
+
else:
|
|
161
|
+
chosen_key = _prompt_api_key()
|
|
162
|
+
else:
|
|
163
|
+
# Check env var
|
|
164
|
+
env_key = os.environ.get("ANTHROPIC_API_KEY")
|
|
165
|
+
if env_key:
|
|
166
|
+
masked = env_key[:7] + "..." + env_key[-4:] if len(env_key) > 11 else "***"
|
|
167
|
+
console.print(f" Found ANTHROPIC_API_KEY in environment: [green]{masked}[/green]")
|
|
168
|
+
try:
|
|
169
|
+
save_it = input(" Save to ct config? [Y/n] ").strip().lower()
|
|
170
|
+
except (EOFError, KeyboardInterrupt):
|
|
171
|
+
console.print("\n [dim]Setup cancelled.[/dim]")
|
|
172
|
+
raise typer.Exit()
|
|
173
|
+
if save_it in ("", "y", "yes"):
|
|
174
|
+
chosen_key = env_key
|
|
175
|
+
else:
|
|
176
|
+
chosen_key = _prompt_api_key()
|
|
177
|
+
else:
|
|
178
|
+
chosen_key = _prompt_api_key()
|
|
179
|
+
|
|
180
|
+
# Validate key format
|
|
181
|
+
if not chosen_key or not chosen_key.startswith("sk-ant-"):
|
|
182
|
+
console.print(
|
|
183
|
+
"\n [yellow]Warning:[/yellow] Key doesn't start with 'sk-ant-'. "
|
|
184
|
+
"Anthropic API keys typically begin with 'sk-ant-api03-'."
|
|
185
|
+
)
|
|
186
|
+
try:
|
|
187
|
+
proceed = input(" Continue anyway? [y/N] ").strip().lower()
|
|
188
|
+
except (EOFError, KeyboardInterrupt):
|
|
189
|
+
console.print("\n [dim]Setup cancelled.[/dim]")
|
|
190
|
+
raise typer.Exit()
|
|
191
|
+
if proceed not in ("y", "yes"):
|
|
192
|
+
console.print(" [dim]Setup cancelled.[/dim]")
|
|
193
|
+
raise typer.Exit()
|
|
194
|
+
|
|
195
|
+
# Save
|
|
196
|
+
cfg.set("llm.api_key", chosen_key)
|
|
197
|
+
cfg.set("llm.provider", "anthropic")
|
|
198
|
+
cfg.save()
|
|
199
|
+
console.print("\n [green]API key saved to ~/.ct/config.json[/green]")
|
|
200
|
+
|
|
201
|
+
# Quick health check
|
|
202
|
+
console.print()
|
|
203
|
+
console.print(" [cyan]Running health check...[/cyan]")
|
|
204
|
+
from ct.agent.doctor import run_checks, to_table, has_errors
|
|
205
|
+
checks = run_checks(cfg)
|
|
206
|
+
console.print(to_table(checks))
|
|
207
|
+
|
|
208
|
+
if has_errors(checks):
|
|
209
|
+
console.print(
|
|
210
|
+
"\n [yellow]Some issues detected.[/yellow] Run `ct doctor` for details."
|
|
211
|
+
)
|
|
212
|
+
else:
|
|
213
|
+
console.print("\n [green]All checks passed.[/green]")
|
|
214
|
+
|
|
215
|
+
# Done
|
|
216
|
+
console.print()
|
|
217
|
+
console.print(
|
|
218
|
+
Panel(
|
|
219
|
+
"[bold green]You're all set![/bold green]\n\n"
|
|
220
|
+
" [cyan]ct[/cyan] Interactive mode\n"
|
|
221
|
+
' [cyan]ct "your question"[/cyan] Single query\n'
|
|
222
|
+
" [cyan]ct doctor[/cyan] Full health check\n"
|
|
223
|
+
" [cyan]ct keys[/cyan] Optional API keys",
|
|
224
|
+
title="[green]Quick Start[/green]",
|
|
225
|
+
border_style="green",
|
|
226
|
+
)
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _prompt_api_key() -> str:
|
|
231
|
+
"""Prompt user for API key with masked input."""
|
|
232
|
+
import getpass
|
|
233
|
+
console.print(" Get your key at: [link=https://console.anthropic.com/settings/keys]console.anthropic.com/settings/keys[/link]")
|
|
234
|
+
console.print()
|
|
235
|
+
try:
|
|
236
|
+
key = getpass.getpass(" Enter your Anthropic API key: ")
|
|
237
|
+
except (EOFError, KeyboardInterrupt):
|
|
238
|
+
console.print("\n [dim]Setup cancelled.[/dim]")
|
|
239
|
+
raise typer.Exit()
|
|
240
|
+
return key.strip()
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@app.command("doctor")
|
|
244
|
+
def doctor_cmd():
|
|
245
|
+
"""Run environment and configuration health checks."""
|
|
246
|
+
from ct.agent.config import Config
|
|
247
|
+
from ct.agent.doctor import run_checks, to_table, has_errors
|
|
248
|
+
|
|
249
|
+
cfg = Config.load()
|
|
250
|
+
checks = run_checks(cfg, session=Session(config=cfg, mode="batch"))
|
|
251
|
+
console.print(to_table(checks))
|
|
252
|
+
|
|
253
|
+
if has_errors(checks):
|
|
254
|
+
console.print(
|
|
255
|
+
"\n[red]Blocking issues found.[/red] "
|
|
256
|
+
"Fix errors above, then rerun `ct doctor`."
|
|
257
|
+
)
|
|
258
|
+
raise typer.Exit(code=1)
|
|
259
|
+
|
|
260
|
+
console.print("\n[green]No blocking issues found.[/green]")
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# ─── Data subcommand ──────────────────────────────────────────
|
|
264
|
+
|
|
265
|
+
data_app = typer.Typer(help="Manage local datasets")
|
|
266
|
+
app.add_typer(data_app, name="data")
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@data_app.command("pull")
|
|
270
|
+
def data_pull(
|
|
271
|
+
dataset: str = typer.Argument(help="Dataset to download (depmap, prism, msigdb, alphafold)"),
|
|
272
|
+
output: Optional[Path] = typer.Option(None, help="Output directory"),
|
|
273
|
+
):
|
|
274
|
+
"""Download a dataset for local use."""
|
|
275
|
+
from ct.data.downloader import download_dataset
|
|
276
|
+
download_dataset(dataset, output)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@data_app.command("status")
|
|
280
|
+
def data_status():
|
|
281
|
+
"""Show status of local datasets."""
|
|
282
|
+
from ct.data.downloader import dataset_status
|
|
283
|
+
console.print(dataset_status())
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# ─── Tool subcommands (direct tool access) ────────────────────
|
|
287
|
+
|
|
288
|
+
tool_app = typer.Typer(help="Run individual tools directly")
|
|
289
|
+
app.add_typer(tool_app, name="tool")
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
@tool_app.command("list")
|
|
293
|
+
def tool_list():
|
|
294
|
+
"""List all available tools."""
|
|
295
|
+
from ct.tools import registry, ensure_loaded, tool_load_errors
|
|
296
|
+
ensure_loaded()
|
|
297
|
+
console.print(registry.list_tools_table())
|
|
298
|
+
errors = tool_load_errors()
|
|
299
|
+
if errors:
|
|
300
|
+
names = ", ".join(sorted(errors.keys())[:8])
|
|
301
|
+
extra = "" if len(errors) <= 8 else f" (+{len(errors) - 8} more)"
|
|
302
|
+
console.print(
|
|
303
|
+
f"[yellow]Warning:[/yellow] {len(errors)} tool module(s) failed to load: "
|
|
304
|
+
f"{names}{extra}"
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
# ─── Knowledge subcommands ────────────────────────────────────
|
|
309
|
+
|
|
310
|
+
knowledge_app = typer.Typer(help="Manage knowledge substrate, ingestion, and quality gates")
|
|
311
|
+
app.add_typer(knowledge_app, name="knowledge")
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
# ─── Trace subcommands ────────────────────────────────────────
|
|
315
|
+
|
|
316
|
+
trace_app = typer.Typer(help="Inspect and diagnose execution traces")
|
|
317
|
+
app.add_typer(trace_app, name="trace")
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _latest_trace_path() -> Optional[Path]:
|
|
321
|
+
from ct.agent.trace import TraceLogger
|
|
322
|
+
|
|
323
|
+
traces_dir = TraceLogger.traces_dir()
|
|
324
|
+
traces = list(traces_dir.glob("*.trace.jsonl"))
|
|
325
|
+
if not traces:
|
|
326
|
+
return None
|
|
327
|
+
return max(traces, key=lambda p: p.stat().st_mtime)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _resolve_trace_path(path: Optional[Path], session_id: Optional[str]) -> Optional[Path]:
|
|
331
|
+
from ct.agent.trace import TraceLogger
|
|
332
|
+
|
|
333
|
+
if path is not None and session_id is not None:
|
|
334
|
+
console.print("[red]Use either --path or --session-id, not both.[/red]")
|
|
335
|
+
raise typer.Exit(code=2)
|
|
336
|
+
|
|
337
|
+
if path is not None:
|
|
338
|
+
return path
|
|
339
|
+
if session_id:
|
|
340
|
+
return TraceLogger.traces_dir() / f"{session_id}.trace.jsonl"
|
|
341
|
+
return _latest_trace_path()
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _latest_report_path(output_base: Optional[str] = None) -> Optional[Path]:
|
|
345
|
+
reports_dir = (
|
|
346
|
+
Path(output_base) / "reports"
|
|
347
|
+
if output_base
|
|
348
|
+
else Path.cwd() / "outputs" / "reports"
|
|
349
|
+
)
|
|
350
|
+
if not reports_dir.exists():
|
|
351
|
+
return None
|
|
352
|
+
reports = list(reports_dir.glob("*.md"))
|
|
353
|
+
if not reports:
|
|
354
|
+
return None
|
|
355
|
+
return max(reports, key=lambda p: p.stat().st_mtime)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _trace_has_issues(diag: dict) -> bool:
|
|
359
|
+
return any(
|
|
360
|
+
(
|
|
361
|
+
diag.get("unclosed_queries"),
|
|
362
|
+
diag.get("queries_with_no_plan"),
|
|
363
|
+
diag.get("queries_with_no_completion"),
|
|
364
|
+
diag.get("queries_with_synthesis_mismatch"),
|
|
365
|
+
)
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _print_trace_diagnostics_table(diag: dict, title: str):
|
|
370
|
+
table = Table(title=title)
|
|
371
|
+
table.add_column("Metric", style="cyan")
|
|
372
|
+
table.add_column("Value")
|
|
373
|
+
table.add_row("Session", diag.get("session_id", "(unknown)") or "(unknown)")
|
|
374
|
+
table.add_row("Events", str(diag.get("event_count", 0)))
|
|
375
|
+
table.add_row("Queries", str(diag.get("query_count", 0)))
|
|
376
|
+
table.add_row(
|
|
377
|
+
"Query starts / ends",
|
|
378
|
+
f"{diag.get('query_start_count', 0)} / {diag.get('query_end_count', 0)}",
|
|
379
|
+
)
|
|
380
|
+
table.add_row("Step starts", str(diag.get("total_step_start_count", 0)))
|
|
381
|
+
table.add_row("Step completes", str(diag.get("total_step_complete_count", 0)))
|
|
382
|
+
table.add_row("Step fails", str(diag.get("total_step_fail_count", 0)))
|
|
383
|
+
table.add_row("Step retries", str(diag.get("total_step_retry_count", 0)))
|
|
384
|
+
table.add_row("Unclosed queries", str(diag.get("unclosed_queries", [])))
|
|
385
|
+
table.add_row("Queries with failures", str(diag.get("queries_with_failures", [])))
|
|
386
|
+
table.add_row("Queries with no plan", str(diag.get("queries_with_no_plan", [])))
|
|
387
|
+
table.add_row(
|
|
388
|
+
"Queries with no completion",
|
|
389
|
+
str(diag.get("queries_with_no_completion", [])),
|
|
390
|
+
)
|
|
391
|
+
table.add_row(
|
|
392
|
+
"Synthesis mismatches",
|
|
393
|
+
str(diag.get("queries_with_synthesis_mismatch", [])),
|
|
394
|
+
)
|
|
395
|
+
console.print(table)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _run_step_command(label: str, cmd: list[str], env: Optional[dict] = None) -> bool:
|
|
399
|
+
console.print(f"\n[bold cyan]{label}[/bold cyan]")
|
|
400
|
+
console.print(f"[dim]$ {' '.join(cmd)}[/dim]")
|
|
401
|
+
proc = subprocess.run(cmd, capture_output=True, text=True, env=env)
|
|
402
|
+
stdout = (proc.stdout or "").strip()
|
|
403
|
+
stderr = (proc.stderr or "").strip()
|
|
404
|
+
if stdout:
|
|
405
|
+
console.print(stdout)
|
|
406
|
+
if stderr:
|
|
407
|
+
style = "yellow" if proc.returncode == 0 else "red"
|
|
408
|
+
console.print(stderr, style=style)
|
|
409
|
+
if proc.returncode == 0:
|
|
410
|
+
console.print(f"[green]PASS[/green] {label}")
|
|
411
|
+
return True
|
|
412
|
+
console.print(f"[red]FAIL[/red] {label} (exit={proc.returncode})")
|
|
413
|
+
return False
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
@trace_app.command("diagnose")
|
|
417
|
+
def trace_diagnose(
|
|
418
|
+
path: Optional[Path] = typer.Option(None, "--path", "-p", help="Path to a trace JSONL file"),
|
|
419
|
+
session_id: Optional[str] = typer.Option(None, "--session-id", "-s", help="Session ID (looks up ~/.ct/traces/<id>.trace.jsonl)"),
|
|
420
|
+
as_json: bool = typer.Option(False, "--json", help="Print diagnostics as JSON"),
|
|
421
|
+
show_queries: bool = typer.Option(False, "--show-queries", help="Show per-query diagnostics table"),
|
|
422
|
+
strict: bool = typer.Option(False, "--strict", help="Exit non-zero if health issues are detected"),
|
|
423
|
+
):
|
|
424
|
+
"""Diagnose trace health (query integrity, failures, synthesis lifecycle)."""
|
|
425
|
+
from ct.agent.trace import TraceLogger
|
|
426
|
+
|
|
427
|
+
trace_path = _resolve_trace_path(path, session_id)
|
|
428
|
+
if trace_path is None:
|
|
429
|
+
console.print("[yellow]No trace files found in ~/.ct/traces[/yellow]")
|
|
430
|
+
raise typer.Exit(code=2)
|
|
431
|
+
if not trace_path.exists():
|
|
432
|
+
console.print(f"[red]Trace file not found:[/red] {trace_path}")
|
|
433
|
+
raise typer.Exit(code=2)
|
|
434
|
+
|
|
435
|
+
trace = TraceLogger.load(trace_path)
|
|
436
|
+
diag = trace.diagnostics()
|
|
437
|
+
|
|
438
|
+
if as_json:
|
|
439
|
+
console.print_json(data=diag)
|
|
440
|
+
else:
|
|
441
|
+
_print_trace_diagnostics_table(diag, title=f"Trace Diagnostics: {trace_path.name}")
|
|
442
|
+
|
|
443
|
+
if show_queries:
|
|
444
|
+
q_table = Table(title="Per-Query Diagnostics")
|
|
445
|
+
q_table.add_column("#", style="cyan")
|
|
446
|
+
q_table.add_column("Closed")
|
|
447
|
+
q_table.add_column("Plans")
|
|
448
|
+
q_table.add_column("Step OK")
|
|
449
|
+
q_table.add_column("Step Fail")
|
|
450
|
+
q_table.add_column("Retries")
|
|
451
|
+
q_table.add_column("Synth start/end")
|
|
452
|
+
q_table.add_column("Query")
|
|
453
|
+
for q in diag["queries"]:
|
|
454
|
+
q_table.add_row(
|
|
455
|
+
str(q["query_number"]),
|
|
456
|
+
"yes" if q["closed"] else "no",
|
|
457
|
+
str(q["plan_count"]),
|
|
458
|
+
str(q["step_complete_count"]),
|
|
459
|
+
str(q["step_fail_count"]),
|
|
460
|
+
str(q["step_retry_count"]),
|
|
461
|
+
f"{q['synthesize_start_count']}/{q['synthesize_end_count']}",
|
|
462
|
+
(q["query"] or "")[:80],
|
|
463
|
+
)
|
|
464
|
+
console.print(q_table)
|
|
465
|
+
|
|
466
|
+
has_issues = _trace_has_issues(diag)
|
|
467
|
+
if strict and has_issues:
|
|
468
|
+
raise typer.Exit(code=2)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
@trace_app.command("export")
|
|
472
|
+
def trace_export(
|
|
473
|
+
path: Optional[Path] = typer.Option(None, "--path", "-p", help="Path to a trace JSONL file"),
|
|
474
|
+
session_id: Optional[str] = typer.Option(None, "--session-id", "-s", help="Session ID (looks up ~/.ct/traces/<id>.trace.jsonl)"),
|
|
475
|
+
report: Optional[Path] = typer.Option(None, "--report", "-r", help="Optional markdown report to include"),
|
|
476
|
+
out_dir: Optional[Path] = typer.Option(None, "--out-dir", help="Bundle output directory (default: ~/.ct/exports)"),
|
|
477
|
+
zip_bundle: bool = typer.Option(True, "--zip/--no-zip", help="Also produce a zip archive"),
|
|
478
|
+
):
|
|
479
|
+
"""Export a reproducible run bundle (trace, diagnostics, report, metadata)."""
|
|
480
|
+
from ct.agent.config import Config
|
|
481
|
+
from ct.agent.trace import TraceLogger
|
|
482
|
+
from ct.agent.trajectory import Trajectory
|
|
483
|
+
|
|
484
|
+
trace_path = _resolve_trace_path(path, session_id)
|
|
485
|
+
if trace_path is None:
|
|
486
|
+
console.print("[yellow]No trace files found in ~/.ct/traces[/yellow]")
|
|
487
|
+
raise typer.Exit(code=2)
|
|
488
|
+
if not trace_path.exists():
|
|
489
|
+
console.print(f"[red]Trace file not found:[/red] {trace_path}")
|
|
490
|
+
raise typer.Exit(code=2)
|
|
491
|
+
|
|
492
|
+
trace = TraceLogger.load(trace_path)
|
|
493
|
+
diag = trace.diagnostics()
|
|
494
|
+
|
|
495
|
+
cfg = Config.load()
|
|
496
|
+
resolved_report = report
|
|
497
|
+
if resolved_report is None:
|
|
498
|
+
resolved_report = _latest_report_path(cfg.get("sandbox.output_dir"))
|
|
499
|
+
if resolved_report is not None and not resolved_report.exists():
|
|
500
|
+
console.print(f"[red]Report file not found:[/red] {resolved_report}")
|
|
501
|
+
raise typer.Exit(code=2)
|
|
502
|
+
|
|
503
|
+
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
504
|
+
base = out_dir or (Path.home() / ".ct" / "exports")
|
|
505
|
+
bundle_dir = base / f"ct_run_bundle_{trace.session_id or 'session'}_{ts}"
|
|
506
|
+
bundle_dir.mkdir(parents=True, exist_ok=True)
|
|
507
|
+
|
|
508
|
+
trace_copy = bundle_dir / "trace.jsonl"
|
|
509
|
+
shutil.copy2(trace_path, trace_copy)
|
|
510
|
+
(bundle_dir / "trace.txt").write_text(trace.to_text(), encoding="utf-8")
|
|
511
|
+
(bundle_dir / "trace_diagnostics.json").write_text(
|
|
512
|
+
json.dumps(diag, indent=2, sort_keys=True),
|
|
513
|
+
encoding="utf-8",
|
|
514
|
+
)
|
|
515
|
+
(bundle_dir / "query_summaries.json").write_text(
|
|
516
|
+
json.dumps(trace.query_summaries(), indent=2),
|
|
517
|
+
encoding="utf-8",
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
copied_report = None
|
|
521
|
+
if resolved_report is not None:
|
|
522
|
+
copied_report = bundle_dir / "report.md"
|
|
523
|
+
shutil.copy2(resolved_report, copied_report)
|
|
524
|
+
|
|
525
|
+
copied_session = None
|
|
526
|
+
session_file = None
|
|
527
|
+
if trace.session_id:
|
|
528
|
+
session_file = Trajectory.sessions_dir() / f"{trace.session_id}.jsonl"
|
|
529
|
+
if session_file is not None and session_file.exists():
|
|
530
|
+
copied_session = bundle_dir / "session.jsonl"
|
|
531
|
+
shutil.copy2(session_file, copied_session)
|
|
532
|
+
|
|
533
|
+
manifest = {
|
|
534
|
+
"generated_at_utc": ts,
|
|
535
|
+
"session_id": trace.session_id,
|
|
536
|
+
"source_trace": str(trace_path),
|
|
537
|
+
"included_files": {
|
|
538
|
+
"trace_jsonl": str(trace_copy),
|
|
539
|
+
"trace_txt": str(bundle_dir / "trace.txt"),
|
|
540
|
+
"trace_diagnostics_json": str(bundle_dir / "trace_diagnostics.json"),
|
|
541
|
+
"query_summaries_json": str(bundle_dir / "query_summaries.json"),
|
|
542
|
+
"report_md": str(copied_report) if copied_report else None,
|
|
543
|
+
"session_jsonl": str(copied_session) if copied_session else None,
|
|
544
|
+
},
|
|
545
|
+
"note": (
|
|
546
|
+
"If report was auto-selected, it is the latest markdown report by mtime "
|
|
547
|
+
"from sandbox.output_dir/reports."
|
|
548
|
+
if report is None
|
|
549
|
+
else "Report path explicitly provided."
|
|
550
|
+
),
|
|
551
|
+
}
|
|
552
|
+
(bundle_dir / "manifest.json").write_text(
|
|
553
|
+
json.dumps(manifest, indent=2, sort_keys=True),
|
|
554
|
+
encoding="utf-8",
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
console.print(f"[green]Bundle exported:[/green] {bundle_dir}")
|
|
558
|
+
if copied_report:
|
|
559
|
+
console.print(f"[dim]Included report:[/dim] {resolved_report}")
|
|
560
|
+
else:
|
|
561
|
+
console.print("[yellow]No report included (none found/provided).[/yellow]")
|
|
562
|
+
|
|
563
|
+
if zip_bundle:
|
|
564
|
+
archive = shutil.make_archive(str(bundle_dir), "zip", root_dir=bundle_dir)
|
|
565
|
+
console.print(f"[green]Zip archive:[/green] {archive}")
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
@app.command("release-check")
|
|
569
|
+
def release_check_cmd(
|
|
570
|
+
run_tests: bool = typer.Option(True, "--tests/--no-tests", help="Run local pytest regression suite"),
|
|
571
|
+
run_benchmark: bool = typer.Option(True, "--benchmark/--no-benchmark", help="Run strict knowledge benchmark gate"),
|
|
572
|
+
run_trace: bool = typer.Option(True, "--trace/--no-trace", help="Run strict diagnostics on latest trace"),
|
|
573
|
+
trace_path: Optional[Path] = typer.Option(None, "--trace-path", help="Trace path for diagnostics"),
|
|
574
|
+
trace_required: bool = typer.Option(False, "--trace-required", help="Fail if no trace file is found"),
|
|
575
|
+
include_live: bool = typer.Option(False, "--live", help="Also run live API smoke + live E2E prompt matrix"),
|
|
576
|
+
matrix_limit: int = typer.Option(10, "--matrix-limit", help="Prompt limit for live E2E matrix"),
|
|
577
|
+
matrix_strict: bool = typer.Option(True, "--matrix-strict/--no-matrix-strict", help="Enable strict assertions in live E2E matrix"),
|
|
578
|
+
matrix_max_failed: int = typer.Option(1, "--matrix-max-failed", help="Max failed prompts allowed in strict matrix mode"),
|
|
579
|
+
require_profile: Optional[str] = typer.Option(None, "--require-profile", help="Require agent.profile to match (e.g. pharma)"),
|
|
580
|
+
pharma: bool = typer.Option(False, "--pharma", help="Enforce pharma deployment policy checks"),
|
|
581
|
+
):
|
|
582
|
+
"""Run a production release gate: doctor + tests + benchmark + trace diagnostics."""
|
|
583
|
+
from ct.agent.config import Config
|
|
584
|
+
from ct.agent.doctor import has_errors, run_checks, to_table
|
|
585
|
+
from ct.agent.trace import TraceLogger
|
|
586
|
+
from ct.kb.benchmarks import BenchmarkSuite
|
|
587
|
+
|
|
588
|
+
failed = False
|
|
589
|
+
|
|
590
|
+
console.print("\n[bold]Release Check[/bold]")
|
|
591
|
+
|
|
592
|
+
cfg = Config.load()
|
|
593
|
+
if pharma and not require_profile:
|
|
594
|
+
require_profile = "pharma"
|
|
595
|
+
|
|
596
|
+
if require_profile:
|
|
597
|
+
expected = require_profile.strip().lower()
|
|
598
|
+
actual = str(cfg.get("agent.profile", "research")).strip().lower()
|
|
599
|
+
if actual != expected:
|
|
600
|
+
console.print(
|
|
601
|
+
f"[red]Profile mismatch:[/red] expected '{expected}', got '{actual}'."
|
|
602
|
+
)
|
|
603
|
+
failed = True
|
|
604
|
+
|
|
605
|
+
if pharma:
|
|
606
|
+
policy_issues = []
|
|
607
|
+
if str(cfg.get("agent.synthesis_style", "standard")).strip().lower() != "pharma":
|
|
608
|
+
policy_issues.append("agent.synthesis_style must be 'pharma'")
|
|
609
|
+
if not bool(cfg.get("agent.quality_gate_strict", False)):
|
|
610
|
+
policy_issues.append("agent.quality_gate_strict must be true")
|
|
611
|
+
if bool(cfg.get("agent.enable_experimental_tools", False)):
|
|
612
|
+
policy_issues.append("agent.enable_experimental_tools must be false")
|
|
613
|
+
if bool(cfg.get("agent.enable_claude_code_tool", False)):
|
|
614
|
+
policy_issues.append("agent.enable_claude_code_tool must be false")
|
|
615
|
+
if policy_issues:
|
|
616
|
+
console.print("[red]Pharma policy checks failed:[/red]")
|
|
617
|
+
for issue in policy_issues:
|
|
618
|
+
console.print(f"- {issue}")
|
|
619
|
+
failed = True
|
|
620
|
+
|
|
621
|
+
checks = run_checks(cfg)
|
|
622
|
+
console.print(to_table(checks))
|
|
623
|
+
if has_errors(checks):
|
|
624
|
+
console.print("[red]Doctor checks have blocking errors.[/red]")
|
|
625
|
+
failed = True
|
|
626
|
+
|
|
627
|
+
if run_tests:
|
|
628
|
+
ok = _run_step_command(
|
|
629
|
+
"Local test suite",
|
|
630
|
+
["pytest", "-q", "tests", "-m", "not api_smoke and not e2e and not e2e_matrix"],
|
|
631
|
+
)
|
|
632
|
+
failed = failed or (not ok)
|
|
633
|
+
|
|
634
|
+
if run_benchmark:
|
|
635
|
+
suite = BenchmarkSuite.load()
|
|
636
|
+
summary = suite.run()
|
|
637
|
+
gate = suite.gate(summary, min_pass_rate=0.9)
|
|
638
|
+
|
|
639
|
+
table = Table(title="Release Benchmark Gate")
|
|
640
|
+
table.add_column("Metric", style="cyan")
|
|
641
|
+
table.add_column("Value")
|
|
642
|
+
table.add_row("Total cases", str(summary["total_cases"]))
|
|
643
|
+
table.add_row("Expected behavior matches", str(summary["expected_behavior_matches"]))
|
|
644
|
+
table.add_row("Pass rate", str(summary["pass_rate"]))
|
|
645
|
+
table.add_row("Gate", gate["message"])
|
|
646
|
+
console.print(table)
|
|
647
|
+
|
|
648
|
+
if not gate["ok"]:
|
|
649
|
+
console.print("[red]Benchmark release gate failed.[/red]")
|
|
650
|
+
failed = True
|
|
651
|
+
|
|
652
|
+
if run_trace:
|
|
653
|
+
resolved_trace = trace_path or _latest_trace_path()
|
|
654
|
+
if resolved_trace is None or not resolved_trace.exists():
|
|
655
|
+
msg = "No trace file found for diagnostics."
|
|
656
|
+
if trace_required:
|
|
657
|
+
console.print(f"[red]{msg}[/red]")
|
|
658
|
+
failed = True
|
|
659
|
+
else:
|
|
660
|
+
console.print(f"[yellow]{msg}[/yellow]")
|
|
661
|
+
else:
|
|
662
|
+
trace = TraceLogger.load(resolved_trace)
|
|
663
|
+
diag = trace.diagnostics()
|
|
664
|
+
_print_trace_diagnostics_table(diag, title=f"Trace Diagnostics: {resolved_trace.name}")
|
|
665
|
+
if _trace_has_issues(diag):
|
|
666
|
+
console.print("[red]Trace diagnostics detected integrity issues.[/red]")
|
|
667
|
+
failed = True
|
|
668
|
+
|
|
669
|
+
if include_live:
|
|
670
|
+
smoke_env = dict(os.environ)
|
|
671
|
+
smoke_env["CT_RUN_API_SMOKE"] = "1"
|
|
672
|
+
smoke_env.setdefault("CT_API_SMOKE_STRICT", "1")
|
|
673
|
+
smoke_ok = _run_step_command(
|
|
674
|
+
"Live API smoke checks",
|
|
675
|
+
["pytest", "-q", "tests/test_api_smoke.py"],
|
|
676
|
+
env=smoke_env,
|
|
677
|
+
)
|
|
678
|
+
failed = failed or (not smoke_ok)
|
|
679
|
+
|
|
680
|
+
matrix_env = dict(os.environ)
|
|
681
|
+
matrix_env["CT_RUN_E2E_MATRIX"] = "1"
|
|
682
|
+
matrix_env["CT_E2E_MATRIX_LIMIT"] = str(max(1, matrix_limit))
|
|
683
|
+
matrix_env["CT_E2E_MATRIX_STRICT"] = "1" if matrix_strict else "0"
|
|
684
|
+
matrix_env["CT_E2E_MATRIX_MAX_FAILED_QUERIES"] = str(max(0, matrix_max_failed))
|
|
685
|
+
matrix_ok = _run_step_command(
|
|
686
|
+
"Live E2E prompt matrix",
|
|
687
|
+
["pytest", "-q", "tests/test_e2e_matrix.py", "--run-e2e"],
|
|
688
|
+
env=matrix_env,
|
|
689
|
+
)
|
|
690
|
+
failed = failed or (not matrix_ok)
|
|
691
|
+
|
|
692
|
+
if failed:
|
|
693
|
+
console.print("\n[red]Release check failed.[/red]")
|
|
694
|
+
raise typer.Exit(code=2)
|
|
695
|
+
|
|
696
|
+
console.print("\n[green]Release check passed.[/green]")
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
@knowledge_app.command("status")
|
|
700
|
+
def knowledge_status():
|
|
701
|
+
"""Show knowledge substrate status."""
|
|
702
|
+
from ct.kb.substrate import KnowledgeSubstrate
|
|
703
|
+
|
|
704
|
+
substrate = KnowledgeSubstrate()
|
|
705
|
+
summary = substrate.summary()
|
|
706
|
+
table = Table(title="Knowledge Substrate")
|
|
707
|
+
table.add_column("Metric", style="cyan")
|
|
708
|
+
table.add_column("Value")
|
|
709
|
+
table.add_row("Path", summary["path"])
|
|
710
|
+
table.add_row("Schema Version", str(summary["schema_version"]))
|
|
711
|
+
table.add_row("Entities", str(summary["n_entities"]))
|
|
712
|
+
table.add_row("Relations", str(summary["n_relations"]))
|
|
713
|
+
table.add_row("Evidence", str(summary["n_evidence"]))
|
|
714
|
+
for et, count in sorted(summary.get("entity_types", {}).items()):
|
|
715
|
+
table.add_row(f"entity_type:{et}", str(count))
|
|
716
|
+
console.print(table)
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
@knowledge_app.command("ingest")
|
|
720
|
+
def knowledge_ingest(
|
|
721
|
+
source: str = typer.Argument(..., help="Source: evidence_store | pubmed | openalex | opentargets"),
|
|
722
|
+
query: Optional[str] = typer.Option(None, "--query", "-q", help="Query for API sources"),
|
|
723
|
+
max_results: int = typer.Option(10, "--max-results", help="Max records for API sources"),
|
|
724
|
+
scan_limit: int = typer.Option(1000, "--scan-limit", help="Max local evidence rows to scan"),
|
|
725
|
+
):
|
|
726
|
+
"""Ingest knowledge into canonical substrate."""
|
|
727
|
+
from ct.kb.ingest import KnowledgeIngestionPipeline
|
|
728
|
+
|
|
729
|
+
pipeline = KnowledgeIngestionPipeline()
|
|
730
|
+
result = pipeline.ingest(
|
|
731
|
+
source=source,
|
|
732
|
+
query=query,
|
|
733
|
+
max_results=max_results,
|
|
734
|
+
scan_limit=scan_limit,
|
|
735
|
+
)
|
|
736
|
+
if result.get("error"):
|
|
737
|
+
console.print(f"[red]{result['error']}[/red]")
|
|
738
|
+
raise typer.Exit(code=2)
|
|
739
|
+
console.print(result.get("summary", "Ingestion completed."))
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
@knowledge_app.command("search")
|
|
743
|
+
def knowledge_search(
|
|
744
|
+
query: str = typer.Argument(..., help="Search text"),
|
|
745
|
+
limit: int = typer.Option(20, "--limit", help="Maximum entities to return"),
|
|
746
|
+
):
|
|
747
|
+
"""Search canonical entities."""
|
|
748
|
+
from ct.kb.substrate import KnowledgeSubstrate
|
|
749
|
+
|
|
750
|
+
substrate = KnowledgeSubstrate()
|
|
751
|
+
entities = substrate.search_entities(query, limit=limit)
|
|
752
|
+
table = Table(title=f"Knowledge Search: {query}")
|
|
753
|
+
table.add_column("Entity ID", style="cyan")
|
|
754
|
+
table.add_column("Type")
|
|
755
|
+
table.add_column("Name")
|
|
756
|
+
table.add_column("Synonyms", style="dim")
|
|
757
|
+
for entity in entities:
|
|
758
|
+
table.add_row(entity.id, entity.entity_type, entity.name, ", ".join(entity.synonyms[:4]))
|
|
759
|
+
console.print(table)
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
@knowledge_app.command("related")
|
|
763
|
+
def knowledge_related(
|
|
764
|
+
entity_id: str = typer.Argument(..., help="Canonical entity id (e.g., gene:TP53)"),
|
|
765
|
+
predicate: Optional[str] = typer.Option(None, "--predicate", help="Filter predicate"),
|
|
766
|
+
limit: int = typer.Option(20, "--limit", help="Maximum relations"),
|
|
767
|
+
):
|
|
768
|
+
"""Show related entities for an entity."""
|
|
769
|
+
from ct.kb.substrate import KnowledgeSubstrate
|
|
770
|
+
|
|
771
|
+
substrate = KnowledgeSubstrate()
|
|
772
|
+
rows = substrate.related_entities(entity_id, predicate=predicate, limit=limit)
|
|
773
|
+
table = Table(title=f"Related Entities: {entity_id}")
|
|
774
|
+
table.add_column("Predicate", style="cyan")
|
|
775
|
+
table.add_column("Other Entity")
|
|
776
|
+
table.add_column("Support")
|
|
777
|
+
table.add_column("Contradict")
|
|
778
|
+
table.add_column("Avg Score")
|
|
779
|
+
for row in rows:
|
|
780
|
+
table.add_row(
|
|
781
|
+
row["predicate"],
|
|
782
|
+
row["other_entity_id"],
|
|
783
|
+
str(row["support_claims"]),
|
|
784
|
+
str(row["contradict_claims"]),
|
|
785
|
+
str(row["average_claim_score"]),
|
|
786
|
+
)
|
|
787
|
+
console.print(table)
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
@knowledge_app.command("rank")
|
|
791
|
+
def knowledge_rank(
|
|
792
|
+
entity_id: Optional[str] = typer.Option(None, "--entity-id", help="Entity id filter"),
|
|
793
|
+
predicate: Optional[str] = typer.Option(None, "--predicate", help="Predicate filter"),
|
|
794
|
+
limit: int = typer.Option(20, "--limit", help="Maximum relations"),
|
|
795
|
+
):
|
|
796
|
+
"""Rank relations by evidence strength."""
|
|
797
|
+
from ct.kb.reasoning import EvidenceReasoner
|
|
798
|
+
from ct.kb.substrate import KnowledgeSubstrate
|
|
799
|
+
|
|
800
|
+
reasoner = EvidenceReasoner(KnowledgeSubstrate())
|
|
801
|
+
rows = reasoner.rank_relations(entity_id=entity_id, predicate=predicate, limit=limit)
|
|
802
|
+
table = Table(title="Ranked Relations")
|
|
803
|
+
table.add_column("Relation", style="cyan")
|
|
804
|
+
table.add_column("Score")
|
|
805
|
+
table.add_column("Claims")
|
|
806
|
+
for row in rows:
|
|
807
|
+
relation = f"{row['subject_id']} --{row['predicate']}--> {row['object_id']}"
|
|
808
|
+
table.add_row(relation, str(row["score"]), str(row["n_claims"]))
|
|
809
|
+
console.print(table)
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
@knowledge_app.command("contradictions")
|
|
813
|
+
def knowledge_contradictions(
|
|
814
|
+
entity_id: Optional[str] = typer.Option(None, "--entity-id", help="Entity id filter"),
|
|
815
|
+
predicate: Optional[str] = typer.Option(None, "--predicate", help="Predicate filter"),
|
|
816
|
+
):
|
|
817
|
+
"""Detect contradictory evidence clusters."""
|
|
818
|
+
from ct.kb.reasoning import EvidenceReasoner
|
|
819
|
+
from ct.kb.substrate import KnowledgeSubstrate
|
|
820
|
+
|
|
821
|
+
reasoner = EvidenceReasoner(KnowledgeSubstrate())
|
|
822
|
+
rows = reasoner.detect_contradictions(entity_id=entity_id, predicate=predicate)
|
|
823
|
+
table = Table(title="Contradictions")
|
|
824
|
+
table.add_column("Relation", style="cyan")
|
|
825
|
+
table.add_column("Support")
|
|
826
|
+
table.add_column("Contradict")
|
|
827
|
+
table.add_column("Support Score")
|
|
828
|
+
table.add_column("Contradict Score")
|
|
829
|
+
for row in rows:
|
|
830
|
+
relation = f"{row['subject_id']} --{row['predicate']}--> {row['object_id']}"
|
|
831
|
+
table.add_row(
|
|
832
|
+
relation,
|
|
833
|
+
str(row["support_claims"]),
|
|
834
|
+
str(row["contradict_claims"]),
|
|
835
|
+
str(row["support_score"]),
|
|
836
|
+
str(row["contradict_score"]),
|
|
837
|
+
)
|
|
838
|
+
console.print(table)
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
@knowledge_app.command("schema-check")
|
|
842
|
+
def knowledge_schema_check():
|
|
843
|
+
"""Run schema drift checks against external integration baselines."""
|
|
844
|
+
from ct.kb.schema_monitor import SchemaMonitor
|
|
845
|
+
|
|
846
|
+
monitor = SchemaMonitor()
|
|
847
|
+
results = monitor.check()
|
|
848
|
+
summary = monitor.summarize(results)
|
|
849
|
+
table = Table(title="Schema Drift Monitor")
|
|
850
|
+
table.add_column("Monitor", style="cyan")
|
|
851
|
+
table.add_column("Status")
|
|
852
|
+
table.add_column("Added")
|
|
853
|
+
table.add_column("Removed")
|
|
854
|
+
table.add_column("Error")
|
|
855
|
+
for row in summary["results"]:
|
|
856
|
+
table.add_row(
|
|
857
|
+
row["monitor"],
|
|
858
|
+
row["status"],
|
|
859
|
+
str(len(row["added_paths"])),
|
|
860
|
+
str(len(row["removed_paths"])),
|
|
861
|
+
row.get("error", ""),
|
|
862
|
+
)
|
|
863
|
+
console.print(table)
|
|
864
|
+
if summary["counts"].get("drift", 0) > 0 or summary["counts"].get("error", 0) > 0:
|
|
865
|
+
raise typer.Exit(code=2)
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
@knowledge_app.command("schema-update")
|
|
869
|
+
def knowledge_schema_update(monitor: Optional[str] = typer.Option(None, "--monitor", help="Single monitor to update")):
|
|
870
|
+
"""Update schema drift baselines from current responses."""
|
|
871
|
+
from ct.kb.schema_monitor import SchemaMonitor
|
|
872
|
+
|
|
873
|
+
mon = SchemaMonitor()
|
|
874
|
+
results = mon.update_baseline(monitor=monitor)
|
|
875
|
+
summary = mon.summarize(results)
|
|
876
|
+
console.print(f"Updated schema baseline for {summary['total']} monitor(s).")
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
@knowledge_app.command("benchmark")
|
|
880
|
+
def knowledge_benchmark(
|
|
881
|
+
min_pass_rate: float = typer.Option(0.9, "--min-pass-rate", help="Release gate threshold"),
|
|
882
|
+
strict: bool = typer.Option(False, "--strict", help="Exit non-zero if gate fails"),
|
|
883
|
+
):
|
|
884
|
+
"""Run benchmark suite and evaluate release gate."""
|
|
885
|
+
from ct.kb.benchmarks import BenchmarkSuite
|
|
886
|
+
|
|
887
|
+
suite = BenchmarkSuite.load()
|
|
888
|
+
summary = suite.run()
|
|
889
|
+
gate = suite.gate(summary, min_pass_rate=min_pass_rate)
|
|
890
|
+
|
|
891
|
+
table = Table(title="Knowledge Benchmarks")
|
|
892
|
+
table.add_column("Metric", style="cyan")
|
|
893
|
+
table.add_column("Value")
|
|
894
|
+
table.add_row("Total cases", str(summary["total_cases"]))
|
|
895
|
+
table.add_row("Expected behavior matches", str(summary["expected_behavior_matches"]))
|
|
896
|
+
table.add_row("Pass rate", str(summary["pass_rate"]))
|
|
897
|
+
table.add_row("Gate", gate["message"])
|
|
898
|
+
console.print(table)
|
|
899
|
+
if strict and not gate["ok"]:
|
|
900
|
+
raise typer.Exit(code=2)
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
# ─── Report subcommands ──────────────────────────────────────
|
|
904
|
+
|
|
905
|
+
report_app = typer.Typer(help="Generate and publish reports")
|
|
906
|
+
app.add_typer(report_app, name="report")
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
@report_app.command("list")
|
|
910
|
+
def report_list():
|
|
911
|
+
"""List available markdown reports."""
|
|
912
|
+
from ct.agent.config import Config
|
|
913
|
+
|
|
914
|
+
cfg = Config.load()
|
|
915
|
+
reports_dir = (
|
|
916
|
+
Path(cfg.get("sandbox.output_dir")) / "reports"
|
|
917
|
+
if cfg.get("sandbox.output_dir")
|
|
918
|
+
else Path.cwd() / "outputs" / "reports"
|
|
919
|
+
)
|
|
920
|
+
if not reports_dir.exists():
|
|
921
|
+
console.print("[dim]No reports directory found.[/dim]")
|
|
922
|
+
raise typer.Exit()
|
|
923
|
+
|
|
924
|
+
reports = sorted(reports_dir.glob("*.md"), key=lambda p: p.stat().st_mtime, reverse=True)
|
|
925
|
+
if not reports:
|
|
926
|
+
console.print("[dim]No reports found.[/dim]")
|
|
927
|
+
raise typer.Exit()
|
|
928
|
+
|
|
929
|
+
table = Table(title="Reports")
|
|
930
|
+
table.add_column("#", style="dim")
|
|
931
|
+
table.add_column("File", style="cyan")
|
|
932
|
+
table.add_column("Size", style="dim")
|
|
933
|
+
table.add_column("Modified")
|
|
934
|
+
for i, r in enumerate(reports[:20], 1):
|
|
935
|
+
size = r.stat().st_size
|
|
936
|
+
size_str = f"{size / 1024:.1f}K" if size > 1024 else f"{size}B"
|
|
937
|
+
mtime = datetime.fromtimestamp(r.stat().st_mtime).strftime("%Y-%m-%d %H:%M")
|
|
938
|
+
table.add_row(str(i), r.name, size_str, mtime)
|
|
939
|
+
console.print(table)
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
@report_app.command("publish")
|
|
943
|
+
def report_publish(
|
|
944
|
+
path: Optional[Path] = typer.Option(None, "--path", "-p", help="Markdown report to convert"),
|
|
945
|
+
out: Optional[Path] = typer.Option(None, "--out", "-o", help="Output HTML path"),
|
|
946
|
+
):
|
|
947
|
+
"""Convert a markdown report to a shareable HTML page."""
|
|
948
|
+
from ct.agent.config import Config
|
|
949
|
+
from ct.reports.html import publish_report
|
|
950
|
+
|
|
951
|
+
if path is None:
|
|
952
|
+
cfg = Config.load()
|
|
953
|
+
path = _latest_report_path(cfg.get("sandbox.output_dir"))
|
|
954
|
+
if path is None:
|
|
955
|
+
console.print("[yellow]No reports found. Run a query first.[/yellow]")
|
|
956
|
+
raise typer.Exit(code=2)
|
|
957
|
+
|
|
958
|
+
if not path.exists():
|
|
959
|
+
console.print(f"[red]File not found:[/red] {path}")
|
|
960
|
+
raise typer.Exit(code=2)
|
|
961
|
+
|
|
962
|
+
result = publish_report(path, out_path=out)
|
|
963
|
+
console.print(f"[green]Published:[/green] {result}")
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
@report_app.command("show")
|
|
967
|
+
def report_show(
|
|
968
|
+
path: Optional[Path] = typer.Option(None, "--path", "-p", help="HTML report to open"),
|
|
969
|
+
):
|
|
970
|
+
"""Open an HTML report in the default browser."""
|
|
971
|
+
import webbrowser
|
|
972
|
+
|
|
973
|
+
from ct.agent.config import Config
|
|
974
|
+
from ct.reports.html import publish_report
|
|
975
|
+
|
|
976
|
+
if path is None:
|
|
977
|
+
cfg = Config.load()
|
|
978
|
+
md_path = _latest_report_path(cfg.get("sandbox.output_dir"))
|
|
979
|
+
if md_path is None:
|
|
980
|
+
console.print("[yellow]No reports found.[/yellow]")
|
|
981
|
+
raise typer.Exit(code=2)
|
|
982
|
+
html_path = md_path.with_suffix(".html")
|
|
983
|
+
if not html_path.exists():
|
|
984
|
+
html_path = publish_report(md_path)
|
|
985
|
+
console.print(f"[dim]Auto-published: {html_path}[/dim]")
|
|
986
|
+
path = html_path
|
|
987
|
+
|
|
988
|
+
if not path.exists():
|
|
989
|
+
console.print(f"[red]File not found:[/red] {path}")
|
|
990
|
+
raise typer.Exit(code=2)
|
|
991
|
+
|
|
992
|
+
webbrowser.open(f"file://{path.resolve()}")
|
|
993
|
+
console.print(f"[green]Opened in browser:[/green] {path}")
|
|
994
|
+
|
|
995
|
+
|
|
996
|
+
@report_app.command("notebook")
|
|
997
|
+
def report_notebook(
|
|
998
|
+
session: Optional[str] = typer.Option(None, "--session", "-s", help="Session ID (prefix or full). Default: most recent"),
|
|
999
|
+
out: Optional[Path] = typer.Option(None, "--out", "-o", help="Output notebook path"),
|
|
1000
|
+
html: bool = typer.Option(False, "--html", help="Also export as HTML"),
|
|
1001
|
+
):
|
|
1002
|
+
"""Export an agent trace as a Jupyter notebook (.ipynb)."""
|
|
1003
|
+
import re
|
|
1004
|
+
from ct.agent.trace_store import TraceStore
|
|
1005
|
+
|
|
1006
|
+
# Find trace file
|
|
1007
|
+
trace_path = TraceStore.find_trace(session)
|
|
1008
|
+
if trace_path is None:
|
|
1009
|
+
console.print("[yellow]No trace files found. Run a query first to generate a trace.[/yellow]")
|
|
1010
|
+
raise typer.Exit(code=2)
|
|
1011
|
+
|
|
1012
|
+
console.print(f" [dim]Trace:[/dim] {trace_path.name}")
|
|
1013
|
+
|
|
1014
|
+
# Lazy import nbformat
|
|
1015
|
+
try:
|
|
1016
|
+
from ct.reports.notebook import trace_to_notebook, save_notebook
|
|
1017
|
+
except ImportError:
|
|
1018
|
+
console.print("[red]nbformat is required. Install with:[/red] pip install nbformat")
|
|
1019
|
+
raise typer.Exit(code=2)
|
|
1020
|
+
|
|
1021
|
+
# Convert trace to notebook
|
|
1022
|
+
nb = trace_to_notebook(trace_path)
|
|
1023
|
+
|
|
1024
|
+
# Determine output path
|
|
1025
|
+
if out is None:
|
|
1026
|
+
from ct.agent.config import Config
|
|
1027
|
+
cfg = Config.load()
|
|
1028
|
+
reports_dir = (
|
|
1029
|
+
Path(cfg.get("sandbox.output_dir")) / "reports"
|
|
1030
|
+
if cfg.get("sandbox.output_dir")
|
|
1031
|
+
else Path.cwd() / "outputs" / "reports"
|
|
1032
|
+
)
|
|
1033
|
+
slug = re.sub(r"[^a-zA-Z0-9]+", "_", trace_path.stem.replace(".trace", "")).strip("_")
|
|
1034
|
+
out = reports_dir / f"{slug}.ipynb"
|
|
1035
|
+
|
|
1036
|
+
out_path = save_notebook(nb, out)
|
|
1037
|
+
console.print(f" [green]Notebook:[/green] {out_path}")
|
|
1038
|
+
|
|
1039
|
+
# Optional HTML export
|
|
1040
|
+
if html:
|
|
1041
|
+
try:
|
|
1042
|
+
import nbconvert
|
|
1043
|
+
from nbconvert import HTMLExporter
|
|
1044
|
+
exporter = HTMLExporter()
|
|
1045
|
+
html_body, _ = exporter.from_notebook_node(nb)
|
|
1046
|
+
html_path = out_path.with_suffix(".html")
|
|
1047
|
+
html_path.write_text(html_body, encoding="utf-8")
|
|
1048
|
+
console.print(f" [green]HTML:[/green] {html_path}")
|
|
1049
|
+
except ImportError:
|
|
1050
|
+
console.print(
|
|
1051
|
+
"[yellow]nbconvert not installed. Falling back to markdown-based HTML.[/yellow]\n"
|
|
1052
|
+
" [dim]Install with: pip install nbconvert[/dim]"
|
|
1053
|
+
)
|
|
1054
|
+
# Fall back to existing HTML renderer on markdown cells
|
|
1055
|
+
from ct.reports.html import render_html_report
|
|
1056
|
+
md_parts = [c.source for c in nb.cells if c.cell_type == "markdown"]
|
|
1057
|
+
md_text = "\n\n".join(md_parts)
|
|
1058
|
+
html_content = render_html_report(md_text, title="ct Notebook Export")
|
|
1059
|
+
html_path = out_path.with_suffix(".html")
|
|
1060
|
+
html_path.write_text(html_content, encoding="utf-8")
|
|
1061
|
+
console.print(f" [green]HTML (markdown only):[/green] {html_path}")
|
|
1062
|
+
|
|
1063
|
+
|
|
1064
|
+
# ─── Case study subcommands ─────────────────────────────────
|
|
1065
|
+
|
|
1066
|
+
case_study_app = typer.Typer(help="Run curated drug case studies")
|
|
1067
|
+
app.add_typer(case_study_app, name="case-study")
|
|
1068
|
+
|
|
1069
|
+
|
|
1070
|
+
@case_study_app.command("list")
|
|
1071
|
+
def case_study_list():
|
|
1072
|
+
"""List available curated case studies."""
|
|
1073
|
+
from ct.agent.case_studies import CASE_STUDIES
|
|
1074
|
+
|
|
1075
|
+
table = Table(title="Case Studies")
|
|
1076
|
+
table.add_column("ID", style="cyan")
|
|
1077
|
+
table.add_column("Drug")
|
|
1078
|
+
table.add_column("Threads", style="dim")
|
|
1079
|
+
table.add_column("Description")
|
|
1080
|
+
for case_id, case in CASE_STUDIES.items():
|
|
1081
|
+
table.add_row(
|
|
1082
|
+
case_id,
|
|
1083
|
+
case.name,
|
|
1084
|
+
str(len(case.thread_goals)),
|
|
1085
|
+
case.description[:80] + ("..." if len(case.description) > 80 else ""),
|
|
1086
|
+
)
|
|
1087
|
+
console.print(table)
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
@case_study_app.command("run")
|
|
1091
|
+
def case_study_run(
|
|
1092
|
+
case_id: str = typer.Argument(..., help="Case study ID (e.g., revlimid, gleevec)"),
|
|
1093
|
+
threads: Optional[int] = typer.Option(None, "--threads", "-t", help="Number of parallel threads"),
|
|
1094
|
+
model: Optional[str] = typer.Option(None, "--model", "-m", help="LLM model to use"),
|
|
1095
|
+
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
|
|
1096
|
+
):
|
|
1097
|
+
"""Run a curated drug case study with multi-agent analysis."""
|
|
1098
|
+
from ct.agent.case_studies import CASE_STUDIES, run_case_study
|
|
1099
|
+
from ct.agent.config import Config
|
|
1100
|
+
from ct.reports.html import publish_report
|
|
1101
|
+
|
|
1102
|
+
if case_id not in CASE_STUDIES:
|
|
1103
|
+
available = ", ".join(sorted(CASE_STUDIES.keys()))
|
|
1104
|
+
console.print(f"[red]Unknown case study '{case_id}'.[/red] Available: {available}")
|
|
1105
|
+
raise typer.Exit(code=2)
|
|
1106
|
+
|
|
1107
|
+
cfg = Config.load()
|
|
1108
|
+
if model:
|
|
1109
|
+
cfg.set("llm.model", model)
|
|
1110
|
+
|
|
1111
|
+
llm_issue = cfg.llm_preflight_issue()
|
|
1112
|
+
if llm_issue:
|
|
1113
|
+
console.print(
|
|
1114
|
+
Panel(
|
|
1115
|
+
f"[bold red]LLM is not configured[/bold red]\n\n{llm_issue}",
|
|
1116
|
+
title="[red]Configuration Error[/red]",
|
|
1117
|
+
border_style="red",
|
|
1118
|
+
)
|
|
1119
|
+
)
|
|
1120
|
+
raise typer.Exit(code=2)
|
|
1121
|
+
|
|
1122
|
+
session = Session(config=cfg, verbose=verbose)
|
|
1123
|
+
case = CASE_STUDIES[case_id]
|
|
1124
|
+
|
|
1125
|
+
print_banner()
|
|
1126
|
+
console.print(Panel(
|
|
1127
|
+
f"[bold]{case.name}[/bold]\n[dim]{case.description}[/dim]",
|
|
1128
|
+
title="[cyan]Case Study[/cyan]",
|
|
1129
|
+
border_style="cyan",
|
|
1130
|
+
))
|
|
1131
|
+
console.print()
|
|
1132
|
+
|
|
1133
|
+
result = run_case_study(session, case_id, n_threads=threads)
|
|
1134
|
+
|
|
1135
|
+
# Auto-publish HTML
|
|
1136
|
+
md_path = _latest_report_path(cfg.get("sandbox.output_dir"))
|
|
1137
|
+
if md_path:
|
|
1138
|
+
html_path = publish_report(md_path)
|
|
1139
|
+
console.print(f"\n [green]HTML report:[/green] {html_path}")
|
|
1140
|
+
|
|
1141
|
+
console.print()
|
|
1142
|
+
|
|
1143
|
+
|
|
1144
|
+
# ─── Main entry point ─────────────────────────────────────────
|
|
1145
|
+
|
|
1146
|
+
@app.command("run", hidden=True)
|
|
1147
|
+
def run_cmd(
|
|
1148
|
+
query_parts: list[str] = typer.Argument(None, help="Research question to investigate"),
|
|
1149
|
+
smiles: Optional[str] = typer.Option(None, "--smiles", "-s", help="Compound SMILES string"),
|
|
1150
|
+
target: Optional[str] = typer.Option(None, "--target", "-t", help="Target protein (UniProt ID or gene symbol)"),
|
|
1151
|
+
indication: Optional[str] = typer.Option(None, "--indication", "-i", help="Cancer type / indication"),
|
|
1152
|
+
output: Optional[Path] = typer.Option(None, "--output", "-o", help="Output directory for reports"),
|
|
1153
|
+
model: Optional[str] = typer.Option(None, "--model", "-m", help="LLM model to use"),
|
|
1154
|
+
agents: Optional[int] = typer.Option(None, "--agents", "-a", help="Run with N parallel research agents"),
|
|
1155
|
+
resume: Optional[str] = typer.Option(None, "--resume", "-r", help="Resume a previous session (ID or 'last')"),
|
|
1156
|
+
continue_last: bool = typer.Option(False, "--continue", "-c", help="Continue the most recent session"),
|
|
1157
|
+
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
|
|
1158
|
+
version: bool = typer.Option(False, "--version", "-V", help="Show version"),
|
|
1159
|
+
):
|
|
1160
|
+
"""
|
|
1161
|
+
CellType CLI — An autonomous agent for drug discovery research.
|
|
1162
|
+
|
|
1163
|
+
Run without arguments for interactive mode.
|
|
1164
|
+
Pass a question for single-query mode.
|
|
1165
|
+
"""
|
|
1166
|
+
if version:
|
|
1167
|
+
console.print(f"ct v{__version__}")
|
|
1168
|
+
raise typer.Exit()
|
|
1169
|
+
|
|
1170
|
+
query = " ".join(query_parts).strip() if query_parts else None
|
|
1171
|
+
|
|
1172
|
+
# Build context from flags
|
|
1173
|
+
context = {}
|
|
1174
|
+
if smiles:
|
|
1175
|
+
context["compound_smiles"] = smiles
|
|
1176
|
+
if target:
|
|
1177
|
+
context["target"] = target
|
|
1178
|
+
if indication:
|
|
1179
|
+
context["indication"] = indication
|
|
1180
|
+
|
|
1181
|
+
# Determine session resume
|
|
1182
|
+
resume_id = None
|
|
1183
|
+
if continue_last:
|
|
1184
|
+
resume_id = "last"
|
|
1185
|
+
elif resume:
|
|
1186
|
+
resume_id = resume
|
|
1187
|
+
|
|
1188
|
+
if query:
|
|
1189
|
+
# Single query mode
|
|
1190
|
+
run_query(query, context, output, model, verbose, agents=agents)
|
|
1191
|
+
else:
|
|
1192
|
+
# Interactive mode
|
|
1193
|
+
run_interactive(context, output, model, verbose, resume_id=resume_id)
|
|
1194
|
+
|
|
1195
|
+
|
|
1196
|
+
def run_query(query: str, context: dict, output: Optional[Path],
|
|
1197
|
+
model: Optional[str], verbose: bool, agents: Optional[int] = None):
|
|
1198
|
+
"""Execute a single research query."""
|
|
1199
|
+
from ct.agent.config import Config
|
|
1200
|
+
|
|
1201
|
+
cfg = Config.load()
|
|
1202
|
+
if model:
|
|
1203
|
+
cfg.set("llm.model", model)
|
|
1204
|
+
|
|
1205
|
+
llm_issue = cfg.llm_preflight_issue()
|
|
1206
|
+
if llm_issue:
|
|
1207
|
+
console.print(
|
|
1208
|
+
Panel(
|
|
1209
|
+
(
|
|
1210
|
+
f"[bold red]LLM is not configured[/bold red]\n\n{llm_issue}\n\n"
|
|
1211
|
+
"Run `ct doctor` for a full readiness check."
|
|
1212
|
+
),
|
|
1213
|
+
title="[red]Configuration Error[/red]",
|
|
1214
|
+
border_style="red",
|
|
1215
|
+
)
|
|
1216
|
+
)
|
|
1217
|
+
raise typer.Exit(code=2)
|
|
1218
|
+
|
|
1219
|
+
session = Session(config=cfg, verbose=verbose)
|
|
1220
|
+
|
|
1221
|
+
print_banner()
|
|
1222
|
+
console.print(Panel(
|
|
1223
|
+
f"[bold]{query}[/bold]",
|
|
1224
|
+
title="[cyan]ct[/cyan]",
|
|
1225
|
+
border_style="cyan",
|
|
1226
|
+
))
|
|
1227
|
+
console.print()
|
|
1228
|
+
|
|
1229
|
+
# Multi-agent mode
|
|
1230
|
+
if agents is not None and agents > 1:
|
|
1231
|
+
from ct.agent.orchestrator import ResearchOrchestrator
|
|
1232
|
+
orchestrator = ResearchOrchestrator(session, n_threads=agents)
|
|
1233
|
+
result = orchestrator.run(query, context)
|
|
1234
|
+
|
|
1235
|
+
if output:
|
|
1236
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
1237
|
+
report_path = output / "report.md"
|
|
1238
|
+
report_path.write_text(result.to_markdown())
|
|
1239
|
+
console.print(f"\n Report saved to {report_path}")
|
|
1240
|
+
|
|
1241
|
+
console.print()
|
|
1242
|
+
return
|
|
1243
|
+
|
|
1244
|
+
# Execute via Agent SDK runner (default) or legacy AgentLoop (fallback)
|
|
1245
|
+
use_sdk = cfg.get("agent.use_sdk", True)
|
|
1246
|
+
|
|
1247
|
+
if use_sdk:
|
|
1248
|
+
from ct.agent.runner import AgentRunner
|
|
1249
|
+
agent = AgentRunner(session)
|
|
1250
|
+
result = agent.run(query, context)
|
|
1251
|
+
else:
|
|
1252
|
+
from ct.agent.loop import AgentLoop, ClarificationNeeded
|
|
1253
|
+
agent = AgentLoop(session)
|
|
1254
|
+
try:
|
|
1255
|
+
result = agent.run(query, context)
|
|
1256
|
+
except ClarificationNeeded as e:
|
|
1257
|
+
console.print(f"\n [cyan]{e.clarification.question}[/cyan]")
|
|
1258
|
+
if e.clarification.suggestions:
|
|
1259
|
+
console.print(f" [dim]e.g. {', '.join(e.clarification.suggestions[:3])}[/dim]")
|
|
1260
|
+
console.print(f"\n [dim]Tip: provide context with --smiles, --target, or --indication flags.[/dim]")
|
|
1261
|
+
return
|
|
1262
|
+
|
|
1263
|
+
# Output
|
|
1264
|
+
if output:
|
|
1265
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
1266
|
+
report_path = output / "report.md"
|
|
1267
|
+
report_path.write_text(result.to_markdown())
|
|
1268
|
+
console.print(f"\n Report saved to {report_path}")
|
|
1269
|
+
|
|
1270
|
+
# Summary already streamed to stdout during synthesis
|
|
1271
|
+
console.print()
|
|
1272
|
+
|
|
1273
|
+
|
|
1274
|
+
@app.command("bench")
|
|
1275
|
+
def bench(
|
|
1276
|
+
question: Optional[str] = typer.Option(None, "--question", "-q", help="Run a single question by ID"),
|
|
1277
|
+
parallel: int = typer.Option(10, "--parallel", "-p", help="Number of parallel workers"),
|
|
1278
|
+
timeout: int = typer.Option(300, "--timeout", help="Timeout per question in seconds"),
|
|
1279
|
+
max_turns: int = typer.Option(15, "--max-turns", help="Max agentic loop turns"),
|
|
1280
|
+
model: Optional[str] = typer.Option(None, "--model", "-m", help="LLM model override"),
|
|
1281
|
+
eval_model: str = typer.Option("claude-sonnet-4-5-20250929", "--eval-model", help="Model for LLM-as-judge evaluation"),
|
|
1282
|
+
manifest: str = typer.Option("/mnt/bixbench/manifest.json", "--manifest", help="Path to manifest JSON"),
|
|
1283
|
+
output: str = typer.Option("/mnt/bixbench/outputs", "--output", "-o", help="Output directory"),
|
|
1284
|
+
only_failed: bool = typer.Option(False, "--only-failed", help="Re-run only failed questions"),
|
|
1285
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Preview questions without executing"),
|
|
1286
|
+
no_eval: bool = typer.Option(False, "--no-eval", help="Skip inline LLM evaluation"),
|
|
1287
|
+
force: bool = typer.Option(False, "--force", "-f", help="Clear previous results and re-run everything"),
|
|
1288
|
+
max_questions: Optional[int] = typer.Option(None, "--max-questions", "-n", help="Limit to first N questions"),
|
|
1289
|
+
):
|
|
1290
|
+
"""Run the BixBench-50 benchmark suite."""
|
|
1291
|
+
import shutil as _shutil
|
|
1292
|
+
from ct.bench.runner import BenchRunner
|
|
1293
|
+
|
|
1294
|
+
if force:
|
|
1295
|
+
out = Path(output)
|
|
1296
|
+
for sub in ("results", "evals", ".preview_cache"):
|
|
1297
|
+
d = out / sub
|
|
1298
|
+
if d.exists():
|
|
1299
|
+
_shutil.rmtree(d)
|
|
1300
|
+
for f in ("all_results.json", "llm_eval.json"):
|
|
1301
|
+
p = out / f
|
|
1302
|
+
if p.exists():
|
|
1303
|
+
p.unlink()
|
|
1304
|
+
console.print(f" [dim]Cleared {out}[/dim]")
|
|
1305
|
+
|
|
1306
|
+
if dry_run:
|
|
1307
|
+
import json as _json
|
|
1308
|
+
with open(manifest) as f:
|
|
1309
|
+
questions = _json.load(f)
|
|
1310
|
+
if question:
|
|
1311
|
+
questions = [q for q in questions if q["question_id"] == question]
|
|
1312
|
+
if max_questions:
|
|
1313
|
+
questions = questions[:max_questions]
|
|
1314
|
+
|
|
1315
|
+
table = Table(title=f"BixBench Dry Run — {len(questions)} questions")
|
|
1316
|
+
table.add_column("#", width=4)
|
|
1317
|
+
table.add_column("Question ID", style="cyan", width=14)
|
|
1318
|
+
table.add_column("Data", width=5)
|
|
1319
|
+
table.add_column("Question", max_width=60)
|
|
1320
|
+
table.add_column("Ideal", max_width=30)
|
|
1321
|
+
|
|
1322
|
+
for i, q in enumerate(questions, 1):
|
|
1323
|
+
has_data = "Y" if q.get("data_dir") and Path(q["data_dir"]).exists() else "N"
|
|
1324
|
+
table.add_row(
|
|
1325
|
+
str(i), q["question_id"], has_data,
|
|
1326
|
+
q["question"][:60], q["ideal"][:30],
|
|
1327
|
+
)
|
|
1328
|
+
console.print(table)
|
|
1329
|
+
return
|
|
1330
|
+
|
|
1331
|
+
runner = BenchRunner(
|
|
1332
|
+
manifest_path=manifest,
|
|
1333
|
+
output_dir=output,
|
|
1334
|
+
parallel=parallel,
|
|
1335
|
+
timeout=timeout,
|
|
1336
|
+
max_turns=max_turns,
|
|
1337
|
+
model=model,
|
|
1338
|
+
eval_model=eval_model,
|
|
1339
|
+
no_eval=no_eval,
|
|
1340
|
+
only_failed=only_failed,
|
|
1341
|
+
question_id=question,
|
|
1342
|
+
max_questions=max_questions,
|
|
1343
|
+
)
|
|
1344
|
+
|
|
1345
|
+
summary = runner.run()
|
|
1346
|
+
if summary.get("total"):
|
|
1347
|
+
console.print(
|
|
1348
|
+
f"\n[bold]Score: {summary['passed']}/{summary['total']} "
|
|
1349
|
+
f"({summary['accuracy']:.1%})[/bold]"
|
|
1350
|
+
)
|
|
1351
|
+
|
|
1352
|
+
|
|
1353
|
+
def print_banner():
|
|
1354
|
+
"""Print the startup banner with molecule illustration."""
|
|
1355
|
+
from ct.tools import registry, ensure_loaded
|
|
1356
|
+
from rich.panel import Panel
|
|
1357
|
+
from rich.text import Text
|
|
1358
|
+
ensure_loaded()
|
|
1359
|
+
n_tools = len(registry.list_tools())
|
|
1360
|
+
|
|
1361
|
+
# Print the ASCII logo (just the CELLTYPE art)
|
|
1362
|
+
console.print(BANNER)
|
|
1363
|
+
|
|
1364
|
+
# Create a nice enclosed dashboard panel for the metadata
|
|
1365
|
+
meta_text = Text.from_markup(
|
|
1366
|
+
f"[bold white]Autonomous Drug Discovery Agent[/]\n"
|
|
1367
|
+
f"[dim]v{__version__} · {n_tools} tools loaded · backed by[/dim] [bold white on #f26522] Y [/][bold #f26522] Combinator[/]",
|
|
1368
|
+
justify="center"
|
|
1369
|
+
)
|
|
1370
|
+
|
|
1371
|
+
console.print(Panel(
|
|
1372
|
+
meta_text,
|
|
1373
|
+
title="[bold cyan]CellType CLI[/]",
|
|
1374
|
+
border_style="dim",
|
|
1375
|
+
width=65
|
|
1376
|
+
))
|
|
1377
|
+
|
|
1378
|
+
|
|
1379
|
+
def run_interactive(context: dict, output: Optional[Path],
|
|
1380
|
+
model: Optional[str], verbose: bool, resume_id: str = None):
|
|
1381
|
+
"""Run interactive session."""
|
|
1382
|
+
from ct.agent.config import Config
|
|
1383
|
+
|
|
1384
|
+
cfg = Config.load()
|
|
1385
|
+
if model:
|
|
1386
|
+
cfg.set("llm.model", model)
|
|
1387
|
+
|
|
1388
|
+
llm_issue = cfg.llm_preflight_issue()
|
|
1389
|
+
if llm_issue:
|
|
1390
|
+
console.print(
|
|
1391
|
+
Panel(
|
|
1392
|
+
(
|
|
1393
|
+
f"[bold red]LLM is not configured[/bold red]\n\n{llm_issue}\n\n"
|
|
1394
|
+
"Set your key/provider via `ct config set ...`, then run `ct` again.\n"
|
|
1395
|
+
"Tip: run `ct doctor` for a full readiness check."
|
|
1396
|
+
),
|
|
1397
|
+
title="[red]Configuration Error[/red]",
|
|
1398
|
+
border_style="red",
|
|
1399
|
+
)
|
|
1400
|
+
)
|
|
1401
|
+
return
|
|
1402
|
+
|
|
1403
|
+
print_banner()
|
|
1404
|
+
|
|
1405
|
+
# Show model info like Claude Code does
|
|
1406
|
+
console.print(" [dim]Type a research question, or /help for commands.[/dim]")
|
|
1407
|
+
console.print()
|
|
1408
|
+
|
|
1409
|
+
terminal = InteractiveTerminal(config=cfg, verbose=verbose)
|
|
1410
|
+
terminal.run(initial_context=context, resume_id=resume_id)
|
|
1411
|
+
|
|
1412
|
+
|
|
1413
|
+
def entry():
|
|
1414
|
+
"""Package entry point."""
|
|
1415
|
+
argv = list(sys.argv[1:])
|
|
1416
|
+
passthrough = {
|
|
1417
|
+
"config",
|
|
1418
|
+
"data",
|
|
1419
|
+
"tool",
|
|
1420
|
+
"trace",
|
|
1421
|
+
"knowledge",
|
|
1422
|
+
"keys",
|
|
1423
|
+
"doctor",
|
|
1424
|
+
"setup",
|
|
1425
|
+
"release-check",
|
|
1426
|
+
"report",
|
|
1427
|
+
"case-study",
|
|
1428
|
+
"bench",
|
|
1429
|
+
"run",
|
|
1430
|
+
"--help",
|
|
1431
|
+
"-h",
|
|
1432
|
+
"--install-completion",
|
|
1433
|
+
"--show-completion",
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
# Route plain invocations to hidden `run` command so:
|
|
1437
|
+
# ct -> interactive mode
|
|
1438
|
+
# ct "question" -> single-query mode
|
|
1439
|
+
# ct --smiles ... "q" -> single-query with context
|
|
1440
|
+
# while preserving explicit subcommands like `ct config ...`.
|
|
1441
|
+
if not argv or argv[0] not in passthrough:
|
|
1442
|
+
argv = ["run", *argv]
|
|
1443
|
+
|
|
1444
|
+
app(args=argv, prog_name="ct")
|
|
1445
|
+
|
|
1446
|
+
|
|
1447
|
+
if __name__ == "__main__":
|
|
1448
|
+
entry()
|