feat(cli): add clean analysis export with markdown/json output

This commit is contained in:
zphinx
2026-05-11 21:54:21 +02:00
parent 92ce7da28f
commit 2d8a5a66ca
7 changed files with 206 additions and 7 deletions

View File

@@ -15,15 +15,20 @@ ______________________________________________________________________
- `--session-memory` option on `tai run`
- prior-session retrieval injected into analysis/follow-up prompts
- final response indexing at session end
- `--output-file` option on `tai run` to persist final AI analysis output as Markdown
- `--output-format markdown|json` for `--output-file` exports
- Planner enhancements for broader service detection:
- generic service candidate extraction from free text
- package presence probes in plans (`rpm -q` and `dpkg-query -W`)
- SSH read-only allowlist expanded to permit package presence commands (`rpm`, `dpkg-query`)
- Session memory tests in `tests/test_session_store.py`
- CLI test coverage for analysis output file writing (`tests/test_cli.py`)
- CLI test coverage for JSON export and ANSI stripping in written output (`tests/test_cli.py`)
### Changed
- Documentation alignment updates in README and ROADMAP to reflect implemented session memory and package-presence capabilities.
- Package version metadata alignment: `src/tai/__init__.py` now matches project version `0.4.0`.
______________________________________________________________________

View File

@@ -34,6 +34,7 @@ The tool may suggest remediation commands in output, but does not execute them.
- Live probe mode (`uname -a`)
- Diagnostics collection mode
- AI analysis mode
- Optional analysis export via `--output-file <path>` (`--output-format markdown|json`)
- Interactive loop with `/collect`, `/analyze`, `/help`, `/quit`
### AI and Prompting
@@ -164,6 +165,25 @@ tai run "docker daemon keeps failing" \
--runbooks ~/.tai/runbooks
```
### Write Analysis to File
```bash
tai run "sshd authentication failed" \
--host bastion01 \
--collect --analyze \
--output-file ./reports/sshd-analysis.md
```
JSON export:
```bash
tai run "sshd authentication failed" \
--host bastion01 \
--collect --analyze \
--output-file ./reports/sshd-analysis.json \
--output-format json
```
## Runbook Workflow
1. Write Markdown runbooks in `runbooks/` with frontmatter keys: `service`, `symptoms`, `tags`.
@@ -192,7 +212,7 @@ pytest tests/test_plan.py tests/test_ai.py tests/test_cli.py
## Known Limits
- Deep service-specific probes (known binary/config/package aliases) are richer for recognized services than generic service names.
- Session memory is available via `--session-memory`, but dedicated history UX commands (`tai history`, `/history`) are not implemented yet.
- Clipboard export is intentionally not implemented.
## Changelog and Roadmap

View File

@@ -101,7 +101,7 @@ Polish the interface for real-world use.
- [x] Design CLI interface with run command, interactive prompts, and runbook subcommands
- [x] Implement structured output sections (Root Cause, Evidence, Recommended Actions)
- [x] Add RAG debug mode (`--rag-debug`) showing retrieval scores
- [ ] Support output to file or clipboard
- [x] Support output to file (`--output-file`)
- [x] Provide comprehensive `--help` command documentation via Typer options
______________________________________________________________________

View File

@@ -60,7 +60,7 @@ This document describes tai's current runtime architecture, module responsibilit
- Tier 1 (implemented): in-memory semantic retrieval over diagnostic chunks
- Tier 2 (implemented): persistent semantic retrieval over runbook corpus
- Tier 3 (pending): persistent retrieval over prior sessions
- Tier 3 (implemented core): persistent retrieval over prior sessions (dedicated UX commands pending)
## Safety Boundaries

View File

@@ -2,4 +2,4 @@
__all__ = ["__version__"]
__version__ = "0.1.0"
__version__ = "0.4.0"

View File

@@ -3,6 +3,8 @@
from __future__ import annotations
import asyncio
import json
import re
import sys
from time import perf_counter
from typing import Annotated
@@ -201,6 +203,21 @@ def run(
help="Optional JSONL file path to log AI and session output.",
),
] = None,
output_file: Annotated[
str | None,
typer.Option(
"--output-file",
help="Optional Markdown file path to write final AI analysis output.",
),
] = None,
output_format: Annotated[
str,
typer.Option(
"--output-format",
help="Output format for --output-file: markdown or json.",
case_sensitive=False,
),
] = "markdown",
no_rag: Annotated[
bool,
typer.Option(
@@ -307,7 +324,7 @@ def run(
console.print(f"[yellow]Session memory unavailable:[/yellow] {exc}")
try:
asyncio.run(
final_response = asyncio.run(
_async_main(
config,
req,
@@ -323,6 +340,18 @@ def run(
logger=logger,
)
)
if output_file is not None:
if final_response is None:
console.print("[yellow]No AI analysis output available to write.[/yellow]")
else:
_write_analysis_output(
output_file,
final_response,
output_format=output_format,
issue=req.issue,
host=req.host,
model=model,
)
except typer.Exit:
raise
except TimeoutError as exc:
@@ -347,7 +376,7 @@ async def _async_main(
runbook_store: RunbookStore | None,
session_store: SessionStore | None,
logger: SessionLogger | None,
) -> None:
) -> str | None:
"""Open a single SSH session and run probe / collection / analysis through it."""
client = SSHClient(config)
if logger is not None:
@@ -422,6 +451,7 @@ async def _async_main(
final_response = interactive_response or initial_response
if session_store is not None and final_response:
_index_session_memory(session_store, ai_config, req, final_response, logger=logger)
return final_response
async def _interactive_loop(
@@ -641,7 +671,6 @@ async def _interactive_loop(
logger.log_event("interactive_followup", {"question": "/analyze"})
last_response = response
continue
continue
if report is None:
plan = plan_from_request(req)
@@ -1076,6 +1105,48 @@ def _format_history_markdown(
return "\n".join(lines).rstrip() + "\n"
def _strip_ansi(text: str) -> str:
"""Remove ANSI escape sequences to keep exported files plain and portable."""
ansi_pattern = re.compile(r"\x1B\[[0-?]*[ -/]*[@-~]")
return ansi_pattern.sub("", text)
def _write_analysis_output(
file_path: str,
content: str,
*,
output_format: str,
issue: str,
host: str,
model: str,
) -> None:
"""Persist final AI analysis output to a file in markdown or json format."""
from pathlib import Path
fmt = output_format.strip().lower()
if fmt not in {"markdown", "json"}:
console.print(f"[red]Invalid --output-format:[/red] {output_format}")
raise typer.Exit(code=2)
clean_content = _strip_ansi(content).rstrip() + "\n"
path = Path(file_path).expanduser().resolve()
path.parent.mkdir(parents=True, exist_ok=True)
if fmt == "json":
payload = {
"issue": issue,
"host": host,
"model": model,
"analysis": clean_content.rstrip("\n"),
}
path.write_text(json.dumps(payload, ensure_ascii=True, indent=2) + "\n", encoding="utf-8")
else:
path.write_text(clean_content, encoding="utf-8")
console.print(f"[green]✓ Wrote analysis output[/green] {path}")
# ---------------------------------------------------------------------------
# runbooks sub-app
# ---------------------------------------------------------------------------

View File

@@ -1,3 +1,4 @@
import json
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
@@ -444,3 +445,105 @@ def test_interactive_history_without_store_shows_hint(monkeypatch) -> None: # t
assert result.exit_code == 0
assert "Session memory is disabled" in result.stdout
def test_run_analyze_writes_output_file(monkeypatch, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
_mock_session(monkeypatch)
async def fake_collect_from_plan(_session, _plan) -> CollectionReport: # type: ignore[no-untyped-def]
return CollectionReport(
host="ssh.archflux.net",
items=[
CollectedItem(
name="kernel",
result=SSHCommandResult(
command="uname -a",
exit_code=0,
stdout="Linux test",
stderr="",
),
),
],
)
monkeypatch.setattr("tai.cli.collect_from_plan", fake_collect_from_plan)
monkeypatch.setattr(
"tai.cli.AIClient.complete",
lambda *_args, **_kwargs: SimpleNamespace(content="Root Cause\n\nEvidence\n\nRecommended Actions"),
)
output_path = tmp_path / "analysis.md"
runner = CliRunner()
result = runner.invoke(
app,
[
"run", "apache failed",
"--host",
"ssh.archflux.net",
"--port",
"5566",
"--no-probe",
"--analyze",
"--output-file",
str(output_path),
],
)
assert result.exit_code == 0
assert "Wrote analysis output" in result.stdout
assert output_path.exists()
assert "Root Cause" in output_path.read_text(encoding="utf-8")
def test_run_analyze_writes_json_output_and_strips_ansi(monkeypatch, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
_mock_session(monkeypatch)
async def fake_collect_from_plan(_session, _plan) -> CollectionReport: # type: ignore[no-untyped-def]
return CollectionReport(
host="ssh.archflux.net",
items=[
CollectedItem(
name="kernel",
result=SSHCommandResult(
command="uname -a",
exit_code=0,
stdout="Linux test",
stderr="",
),
),
],
)
monkeypatch.setattr("tai.cli.collect_from_plan", fake_collect_from_plan)
monkeypatch.setattr(
"tai.cli.AIClient.complete",
lambda *_args, **_kwargs: SimpleNamespace(
content="\x1b[31mRoot Cause\x1b[0m\n\nEvidence\n\nRecommended Actions"
),
)
output_path = tmp_path / "analysis.json"
runner = CliRunner()
result = runner.invoke(
app,
[
"run", "apache failed",
"--host",
"ssh.archflux.net",
"--port",
"5566",
"--no-probe",
"--analyze",
"--output-file",
str(output_path),
"--output-format",
"json",
],
)
assert result.exit_code == 0
payload = json.loads(output_path.read_text(encoding="utf-8"))
assert payload["issue"] == "apache failed"
assert payload["host"] == "ssh.archflux.net"
assert "Root Cause" in payload["analysis"]
assert "\u001b" not in payload["analysis"]