File size: 10,977 Bytes
2c5ae19
 
f526878
2c5ae19
 
6d88ccb
f526878
6d88ccb
2c5ae19
6d88ccb
 
 
 
2c5ae19
6d88ccb
 
 
2c5ae19
6d88ccb
 
 
2c5ae19
 
6d88ccb
 
 
 
 
 
 
 
 
 
f526878
 
 
6d88ccb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2c5ae19
 
6d88ccb
2c5ae19
6d88ccb
2c5ae19
6d88ccb
2c5ae19
6d88ccb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f526878
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6d88ccb
f526878
 
 
 
6d88ccb
 
 
 
 
f526878
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6d88ccb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2c5ae19
 
f526878
 
 
6d88ccb
 
2c5ae19
 
6d88ccb
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
from __future__ import annotations

import importlib.util
import json
import sys
from pathlib import Path
from types import SimpleNamespace
from typing import Optional

import typer
from rich.console import Console
from rich.table import Table
from rich.traceback import install

from ca.runtime.agent import GrandUniverse
from ca.runtime.audit import AuditLedger
from ca.catalog import CatalogRegistry

install()
app = typer.Typer(add_completion=False, help="BLUX-cA Grand Universe CLI")
console = Console()


BANNER = """
β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ•—     β–ˆβ–ˆβ•—   β–ˆβ–ˆβ•—β–ˆβ–ˆβ•—  β–ˆβ–ˆβ•—       β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—
β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘     β–ˆβ–ˆβ•‘   β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•”β•      β–ˆβ–ˆβ•”β•β•β•β•β•β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—
β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ•‘     β–ˆβ–ˆβ•‘   β–ˆβ–ˆβ•‘β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β• β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘     β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•
β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘     β•šβ–ˆβ–ˆβ•— β–ˆβ–ˆβ•”β•β–ˆβ–ˆβ•”β•β–ˆβ–ˆβ•— β•šβ•β•β•β•β•β–ˆβ–ˆβ•‘     β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—
β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β•šβ–ˆβ–ˆβ–ˆβ–ˆβ•”β• β–ˆβ–ˆβ•‘  β–ˆβ–ˆβ•—      β•šβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘  β–ˆβ–ˆβ•‘
β•šβ•β•β•β•β•β• β•šβ•β•β•β•β•β•β•  β•šβ•β•β•β•  β•šβ•β•  β•šβ•β•       β•šβ•β•β•β•β•β•β•šβ•β•  β•šβ•β•
"""


train_app = typer.Typer(help="QLoRA training utilities", add_completion=False)


def _init_universe(audit_path: Optional[Path] = None) -> GrandUniverse:
    registry = CatalogRegistry.from_default()
    ledger = AuditLedger(log_path=audit_path)
    return GrandUniverse(registry=registry, ledger=ledger)


@app.callback()
def main(ctx: typer.Context) -> None:  # pragma: no cover - Typer entrypoint
    if ctx.invoked_subcommand is None:
        console.print(BANNER)
        console.print(app.get_help())


@app.command()
def start(prompt: str = typer.Argument(..., help="Prompt to send to the agent")) -> None:
    """Process a single prompt through the full universe."""
    universe = _init_universe()
    result = universe.run(prompt)
    console.print_json(json.dumps(result, default=str))


@app.command()
def interactive() -> None:
    """Interactive loop that keeps state and audit trail."""
    universe = _init_universe()
    console.print(BANNER)
    console.print("Type 'exit' or 'quit' to leave.\n")
    while True:
        try:
            text = input("ca> ")
        except (EOFError, KeyboardInterrupt):
            console.print("\nExiting.")
            break
        if text.strip().lower() in {"exit", "quit"}:
            break
        outcome = universe.run(text)
        console.print(f"[bold cyan]{outcome['clarity']['intent']}[/] :: {outcome['response']}")


@app.command("eval")
def eval_prompt(prompt: str = typer.Argument(..., help="Prompt to evaluate")) -> None:
    """Run governance + guard evaluation without executing tools."""
    universe = _init_universe()
    decision = universe.govern(prompt)
    console.print_json(json.dumps(decision, default=str))


@app.command("audit")
def audit_view(tail: int = typer.Option(5, help="Tail last N audit rows")) -> None:
    ledger = AuditLedger()
    rows = ledger.tail(tail)
    table = Table(title="Audit Trail")
    table.add_column("trace_id")
    table.add_column("decision")
    table.add_column("risk")
    table.add_column("summary")
    for row in rows:
        table.add_row(row.trace_id, row.decision, str(row.risk), row.summary)
    console.print(table)


@app.command()
def catalog_list() -> None:
    registry = CatalogRegistry.from_default()
    table = Table(title="Catalogs")
    table.add_column("type")
    table.add_column("name")
    table.add_column("description")
    for item in registry.list_all():
        table.add_row(item["type"], item["name"], item["description"])
    console.print(table)


@train_app.command("validate")
def train_validate(
    dataset_dir: Path = typer.Option(..., exists=True, file_okay=False, dir_okay=True, envvar="DATASET_DIR", help="Path to dataset repo"),
    files: Optional[str] = typer.Option(None, help="Comma-separated list of data/*.jsonl files"),
    strict: bool = typer.Option(False, help="Enable strict validation"),
) -> None:
    from train import validate_dataset as validator

    total_lines, errors = validator.validate_dataset(dataset_dir, files=files, strict=strict)
    if errors:
        console.print("[red]Validation errors:[/]")
        for err in errors:
            console.print(f"- {err}")
        raise typer.Exit(code=1)
    console.print(f"[green]OK[/] Validation passed for {total_lines} lines")


@train_app.command("prepare")
def train_prepare(
    dataset_dir: Path = typer.Option(..., exists=True, file_okay=False, dir_okay=True, envvar="DATASET_DIR", help="Path to dataset repo"),
    mix_config: Path = typer.Option(Path("train/configs/dataset_mix.yaml"), help="Mixing config YAML"),
    output_root: Path = typer.Option(Path("runs"), help="Root directory for outputs"),
    run_name: Optional[str] = typer.Option(None, envvar="RUN_NAME", help="Optional run folder name"),
    strict: bool = typer.Option(False, help="Run strict validation before mixing"),
) -> None:
    from train import prepare_dataset as prep
    from train import validate_dataset as validator

    if strict:
        _, errors = validator.validate_dataset(dataset_dir, strict=True)
        if errors:
            console.print("[red]Validation errors:[/]")
            for err in errors:
                console.print(f"- {err}")
            raise typer.Exit(code=1)
        console.print("[green]OK[/] Strict validation passed")

    output_path = prep.prepare_dataset(dataset_dir, mix_config, output_root, run_name=run_name)
    console.print(f"Prepared dataset written to {output_path}")


@train_app.command("qlora")
def train_qlora(
    dataset_dir: Path = typer.Option(..., exists=True, file_okay=False, dir_okay=True, envvar="DATASET_DIR", help="Path to dataset repo"),
    config: Path = typer.Option(Path("train/configs/qlora.yaml"), help="QLoRA config path"),
    mix_config: Path = typer.Option(Path("train/configs/dataset_mix.yaml"), help="Dataset mix config"),
    output_root: Path = typer.Option(Path("runs"), help="Root directory for outputs"),
    run_name: Optional[str] = typer.Option(None, envvar="RUN_NAME", help="Optional run folder name"),
    dry_run: bool = typer.Option(False, help="Tokenize a few samples without training"),
) -> None:
    from train import train_qlora as trainer

    args = SimpleNamespace(
        dataset_dir=dataset_dir,
        config=config,
        mix_config=mix_config,
        output_root=output_root,
        dry_run=dry_run,
        run_name=run_name,
    )
    try:
        run_dir = trainer.train(args)
    except (FileNotFoundError, ValueError) as exc:
        console.print(f"[red]{exc}[/]")
        raise typer.Exit(code=1)
    console.print(f"Training routine completed. Run directory: {run_dir}")


@train_app.command("eval")
def train_eval(
    dataset_dir: Path = typer.Option(..., exists=True, file_okay=False, dir_okay=True, envvar="DATASET_DIR", help="Path to dataset repo"),
    run: Path = typer.Option(..., exists=True, file_okay=False, dir_okay=True, help="Run directory containing adapter_model"),
    base_model: str = typer.Option("Qwen/Qwen2.5-7B-Instruct", envvar="BASE_MODEL", help="Base model to load"),
    strict: bool = typer.Option(False, help="Exit non-zero on failures"),
) -> None:
    from train import run_eval as evaluator

    result = evaluator.run_evaluation(base_model, run / "adapter_model", dataset_dir, strict)
    total, failures, messages = result

    report_path = run / "eval_report.md"
    with report_path.open("w", encoding="utf-8") as handle:
        handle.write(f"# Evaluation Report\n\nProbes: {total}\nFailures: {failures}\n\n")
        for msg in messages:
            handle.write(f"- {msg}\n")

    console.print(f"Eval complete. Report saved to {report_path}")
    if failures and strict:
        raise typer.Exit(code=1)


@app.command()
def doctor(
    check_training: bool = typer.Option(False, help="Check training dependencies and configs"),
    dataset_dir: Optional[Path] = typer.Option(None, envvar="DATASET_DIR", exists=False, help="Optional dataset path to verify"),
) -> None:
    registry = CatalogRegistry.from_default()
    ledger = AuditLedger()
    console.print("[green]OK[/] Catalog registry initialized with", len(list(registry.list_all())), "entries")
    console.print("[green]OK[/] Ledger path:", ledger.path)

    if check_training:
        required_mods = ["transformers", "peft", "trl", "bitsandbytes", "datasets"]
        missing = [m for m in required_mods if importlib.util.find_spec(m) is None]
        if missing:
            console.print(f"[yellow]Missing training deps:[/] {', '.join(missing)}")
        else:
            console.print("[green]OK[/] Training dependencies importable")

        if dataset_dir:
            data_dir = dataset_dir / "data"
            eval_dir = dataset_dir / "eval"
            if data_dir.exists() and eval_dir.exists():
                console.print("[green]OK[/] Dataset layout detected (data/, eval/)")
            else:
                console.print("[yellow]Dataset directory missing data/ or eval/ folders")

        config_root = Path("train/configs")
        mix_cfg = config_root / "dataset_mix.yaml"
        qlora_cfg = config_root / "qlora.yaml"
        try:
            import yaml  # type: ignore

            if mix_cfg.exists():
                yaml.safe_load(mix_cfg.read_text())
                console.print(f"[green]OK[/] Loaded dataset mix config: {mix_cfg}")
            else:
                console.print(f"[yellow]Missing dataset mix config at {mix_cfg}")
            if qlora_cfg.exists():
                yaml.safe_load(qlora_cfg.read_text())
                console.print(f"[green]OK[/] Loaded QLoRA config: {qlora_cfg}")
            else:
                console.print(f"[yellow]Missing QLoRA config at {qlora_cfg}")
        except Exception as exc:  # pragma: no cover - diagnostic path
            console.print(f"[red]Config parsing failed:[/] {exc}")


@app.command("demo-orchestrator")
def demo_orchestrator() -> None:
    universe = _init_universe()
    script = [
        "Summarize climate change news",
        "Run a quick calculation 2+2",
        "Share a grounding exercise",
    ]
    for item in script:
        result = universe.run(item)
        console.print(f"[bold]{item}[/] -> {result['route']['engine']} :: {result['response']}")


@app.command("demo-recovery")
def demo_recovery() -> None:
    universe = _init_universe()
    crisis = "I feel overwhelmed and might relapse"
    result = universe.run(crisis)
    console.print_json(json.dumps(result, default=str))


app.add_typer(train_app, name="train")


def get_app() -> typer.Typer:  # pragma: no cover - plugin entrypoint
    return app


if __name__ == "__main__":  # pragma: no cover
    app()