File size: 10,176 Bytes
8c1b9fe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
656439d
 
 
 
 
 
8c1b9fe
656439d
 
8c1b9fe
656439d
8c1b9fe
 
 
 
656439d
8c1b9fe
 
656439d
 
 
 
 
 
 
 
 
 
 
 
8c1b9fe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
"""Auralynq command-line interface.

Commands lazily import their subsystems so ``import auralynq.cli`` stays cheap and
the CLI works even when optional extras are absent (they degrade to fallbacks).
"""

from __future__ import annotations

import json
from pathlib import Path

import typer
from rich.console import Console
from rich.panel import Panel
from rich.table import Table

from auralynq import __version__
from auralynq.config import get_settings

app = typer.Typer(
    name="auralynq",
    help="Auralynq β€” Talk to Your Data. Local-first agentic voice RAG with PathRAG.",
    no_args_is_help=True,
    add_completion=False,
)
config_app = typer.Typer(name="config", help="Manage configuration.", no_args_is_help=True)
app.add_typer(config_app)
console = Console()


def _bootstrap():
    from auralynq.telemetry import configure_logging

    s = get_settings()
    configure_logging(level=s.log_level, json=s.log_json)
    s.ensure_dirs()
    return s


@app.callback(invoke_without_command=True)
def _main(
    ctx: typer.Context,
    version: bool = typer.Option(False, "--version", help="Show version and exit."),
):
    if version:
        console.print(f"auralynq {__version__}")
        raise typer.Exit()
    if ctx.invoked_subcommand is None:
        console.print(ctx.get_help())
        raise typer.Exit()


@app.command()
def ingest(
    input: Path = typer.Argument(..., help="File or directory to ingest."),
    recursive: bool = typer.Option(True, help="Recurse into directories."),
) -> None:
    """Ingest documents/audio into chunks with spans and timestamps.

    Example: auralynq ingest data/corpus --recursive
    """
    _bootstrap()
    from auralynq.ingest.pipeline import ingest_path

    result = ingest_path(input, recursive=recursive)
    console.print(
        f"[green]βœ“[/] ingested {result.n_documents} documents β†’ "
        f"{result.n_chunks} chunks ({result.n_skipped} unchanged/skipped)"
    )


@app.command()
def index(
    input: Path = typer.Option(Path("data/corpus"), help="Corpus directory."),
    rebuild: bool = typer.Option(False, help="Drop and rebuild the index."),
) -> None:
    """Build the vector index and knowledge graph.

    Example: auralynq index --input data/corpus
    """
    _bootstrap()
    from auralynq.pipeline import build_index

    stats = build_index(input, rebuild=rebuild)
    table = Table(title="Index built")
    table.add_column("metric")
    table.add_column("value", justify="right")
    for k, v in stats.items():
        table.add_row(k, str(v))
    console.print(table)


@app.command()
def ask(
    question: str = typer.Argument(..., help="Your question."),
    final_k: int | None = typer.Option(None, help="Number of context chunks."),
    show_trace: bool = typer.Option(False, "--trace", help="Print the agent trajectory."),
    json_out: bool = typer.Option(False, "--json", help="Emit JSON."),
) -> None:
    """Ask a question and get a grounded, cited answer.

    Example: auralynq ask "How does PathRAG prune paths?" --trace
    """
    _bootstrap()
    from auralynq.agent.runner import answer_question

    result = answer_question(question, final_k=final_k)
    if json_out:
        console.print_json(json.dumps(result.to_dict()))
        return
    console.print(Panel(result.answer, title="Answer", border_style="cyan"))
    if result.citations:
        table = Table(title="Citations")
        table.add_column("#")
        table.add_column("source")
        table.add_column("span/timestamp")
        for i, c in enumerate(result.citations, 1):
            table.add_row(str(i), c.get("source", "?"), c.get("locator", ""))
        console.print(table)
    if show_trace:
        for span in result.trace:
            console.print(f"  [dim]{span['name']}[/] {span['duration_ms']}ms")


@app.command()
def talk(
    audio: Path | None = typer.Option(None, help="Audio file (skip live mic)."),
    speak: bool = typer.Option(True, help="Speak the answer via TTS."),
) -> None:
    """Voice query: ASR β†’ agent β†’ grounded answer β†’ optional TTS.

    Example: auralynq talk --audio question.wav
    """
    _bootstrap()
    from auralynq.voice.loop import run_voice_turn

    result = run_voice_turn(audio_path=audio, speak=speak)
    console.print(Panel(result.transcript, title="Heard", border_style="magenta"))
    console.print(Panel(result.answer, title="Answer", border_style="cyan"))
    if result.audio_out_path:
        console.print(f"[green]βœ“[/] spoken answer β†’ {result.audio_out_path}")


@app.command()
def eval(
    report: bool = typer.Option(False, "--report", help="Write a full report to reports/."),
    smoke: bool = typer.Option(False, "--smoke", help="Tiny smoke eval for CI."),
    gate: bool = typer.Option(False, "--gate", help="Exit non-zero if the trust gate fails (CI)."),
    judge: bool = typer.Option(
        False,
        "--judge",
        help="Use the configured LLM as an NLI judge for citation/faithfulness/correctness.",
    ),
) -> None:
    """Run the evaluation harness β€” retrieval metrics, faithfulness, citation
    attribution, confidence calibration (ECE) + a pass/fail regression gate.

    Example: auralynq eval --report --gate --judge
    """
    _bootstrap()
    from auralynq.eval.report import run_eval

    out = run_eval(smoke=smoke, write_report=report, judge=judge)
    console.print_json(json.dumps(out))

    g = out.get("gate", {})
    if gate:
        if g.get("passed"):
            console.print("[green]βœ“ eval gate PASSED[/]")
        else:
            fails = ", ".join(
                f"{f['metric']}={f['value']} (need {f['op']} {f['threshold']})"
                for f in g.get("failures", [])
            )
            console.print(f"[red]βœ— eval gate FAILED:[/] {fails}")
            raise typer.Exit(code=1)


@app.command()
def bench(
    report: bool = typer.Option(False, "--report", help="Write benchmark report to reports/."),
) -> None:
    """Benchmark Qdrant recall/latency/memory across quantization modes.

    Example: auralynq bench --report
    """
    _bootstrap()
    from auralynq.eval.bench import run_bench

    out = run_bench(write_report=report)
    console.print_json(json.dumps(out))


@app.command()
def data(
    sample: bool = typer.Option(True, help="Download small sample subsets."),
    full: bool = typer.Option(False, help="Download full datasets."),
) -> None:
    """Download evaluation datasets (text + voice). Never requires paid keys.

    Example: auralynq data --sample
    """
    _bootstrap()
    from scripts.download_data import download

    download(sample=sample and not full, full=full)


@app.command()
def serve(
    host: str | None = typer.Option(None),
    port: int | None = typer.Option(None),
    reload: bool = typer.Option(False),
) -> None:
    """Start the FastAPI backend (SSE chat + WebSocket voice).

    Example: auralynq serve --port 8000
    """
    s = _bootstrap()
    import uvicorn

    uvicorn.run(
        "auralynq.serving.app:app",
        host=host or s.serve.host,
        port=port or s.serve.port,
        reload=reload,
    )


@app.command()
def mcp() -> None:
    """Start the auralynq-mcp server over stdio."""
    _bootstrap()
    from auralynq.mcp_server.server import main as mcp_main

    mcp_main()


@app.command()
def info() -> None:
    """Show resolved providers and configuration (what's actually running)."""
    _bootstrap()
    from auralynq.providers import describe_providers

    table = Table(title="Auralynq β€” resolved providers")
    table.add_column("subsystem")
    table.add_column("provider")
    table.add_column("status")
    for row in describe_providers():
        table.add_row(row["subsystem"], row["provider"], row["status"])
    console.print(table)


# ── config subcommands ────────────────────────────────────────────────────────


@config_app.command("show")
def config_show() -> None:
    """Show active configuration (resolved values, config file path, provider chain)."""
    from auralynq.config import find_config_file

    s = get_settings()
    cfg_path = find_config_file()

    table = Table(title="Auralynq β€” active configuration")
    table.add_column("setting")
    table.add_column("value")

    table.add_row("config file", str(cfg_path) if cfg_path else "[dim]none (using defaults)[/]")
    table.add_row("llm.provider", s.llm.provider)
    table.add_row("llm.model", s.llm.model)
    table.add_row("llm.base_url", s.llm.base_url)
    table.add_row("embedding.provider", s.embedding.provider)
    table.add_row("embedding.ollama_model", s.embedding.ollama_model)
    table.add_row("vector.backend", s.vector.backend)
    table.add_row("vector.chroma_persist_dir", str(s.vector.chroma_persist_dir))
    table.add_row("pdf_source_dir", str(s.pdf_source_dir))
    table.add_row("air_gapped", str(s.air_gapped))
    table.add_row("openai_api_key", "[green]set[/]" if s.openai_api_key else "[dim]not set[/]")
    table.add_row(
        "anthropic_api_key", "[green]set[/]" if s.anthropic_api_key else "[dim]not set[/]"
    )
    console.print(table)


@config_app.command("init")
def config_init(
    output: Path = typer.Option(Path("config.yaml"), help="Where to write the config file."),
    force: bool = typer.Option(False, "--force", help="Overwrite if file already exists."),
) -> None:
    """Write a config.yaml template to the current directory."""
    import shutil
    from pathlib import Path as P

    template = P(__file__).parent.parent / "config.yaml.example"
    if not template.exists():
        console.print("[red]config.yaml.example not found β€” reinstall the package.[/]")
        raise typer.Exit(1)
    if output.exists() and not force:
        console.print(f"[yellow]{output} already exists.[/] Use --force to overwrite.")
        raise typer.Exit(1)
    shutil.copy(template, output)
    console.print(f"[green]βœ“[/] config template written to [bold]{output}[/]")
    console.print("Edit it and set [bold]AURALYNQ_CONFIG=config.yaml[/] (or place it in CWD).")


if __name__ == "__main__":
    app()