feat(cli): add clean analysis export with markdown/json output
This commit is contained in:
@@ -15,15 +15,20 @@ ______________________________________________________________________
|
|||||||
- `--session-memory` option on `tai run`
|
- `--session-memory` option on `tai run`
|
||||||
- prior-session retrieval injected into analysis/follow-up prompts
|
- prior-session retrieval injected into analysis/follow-up prompts
|
||||||
- final response indexing at session end
|
- 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:
|
- Planner enhancements for broader service detection:
|
||||||
- generic service candidate extraction from free text
|
- generic service candidate extraction from free text
|
||||||
- package presence probes in plans (`rpm -q` and `dpkg-query -W`)
|
- package presence probes in plans (`rpm -q` and `dpkg-query -W`)
|
||||||
- SSH read-only allowlist expanded to permit package presence commands (`rpm`, `dpkg-query`)
|
- SSH read-only allowlist expanded to permit package presence commands (`rpm`, `dpkg-query`)
|
||||||
- Session memory tests in `tests/test_session_store.py`
|
- 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
|
### Changed
|
||||||
|
|
||||||
- Documentation alignment updates in README and ROADMAP to reflect implemented session memory and package-presence capabilities.
|
- 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`.
|
||||||
|
|
||||||
______________________________________________________________________
|
______________________________________________________________________
|
||||||
|
|
||||||
|
|||||||
22
README.md
22
README.md
@@ -34,6 +34,7 @@ The tool may suggest remediation commands in output, but does not execute them.
|
|||||||
- Live probe mode (`uname -a`)
|
- Live probe mode (`uname -a`)
|
||||||
- Diagnostics collection mode
|
- Diagnostics collection mode
|
||||||
- AI analysis mode
|
- AI analysis mode
|
||||||
|
- Optional analysis export via `--output-file <path>` (`--output-format markdown|json`)
|
||||||
- Interactive loop with `/collect`, `/analyze`, `/help`, `/quit`
|
- Interactive loop with `/collect`, `/analyze`, `/help`, `/quit`
|
||||||
|
|
||||||
### AI and Prompting
|
### AI and Prompting
|
||||||
@@ -164,6 +165,25 @@ tai run "docker daemon keeps failing" \
|
|||||||
--runbooks ~/.tai/runbooks
|
--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
|
## Runbook Workflow
|
||||||
|
|
||||||
1. Write Markdown runbooks in `runbooks/` with frontmatter keys: `service`, `symptoms`, `tags`.
|
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
|
## Known Limits
|
||||||
|
|
||||||
- Deep service-specific probes (known binary/config/package aliases) are richer for recognized services than generic service names.
|
- 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
|
## Changelog and Roadmap
|
||||||
|
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ Polish the interface for real-world use.
|
|||||||
- [x] Design CLI interface with run command, interactive prompts, and runbook subcommands
|
- [x] Design CLI interface with run command, interactive prompts, and runbook subcommands
|
||||||
- [x] Implement structured output sections (Root Cause, Evidence, Recommended Actions)
|
- [x] Implement structured output sections (Root Cause, Evidence, Recommended Actions)
|
||||||
- [x] Add RAG debug mode (`--rag-debug`) showing retrieval scores
|
- [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
|
- [x] Provide comprehensive `--help` command documentation via Typer options
|
||||||
|
|
||||||
______________________________________________________________________
|
______________________________________________________________________
|
||||||
|
|||||||
@@ -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 1 (implemented): in-memory semantic retrieval over diagnostic chunks
|
||||||
- Tier 2 (implemented): persistent semantic retrieval over runbook corpus
|
- 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
|
## Safety Boundaries
|
||||||
|
|
||||||
|
|||||||
@@ -2,4 +2,4 @@
|
|||||||
|
|
||||||
__all__ = ["__version__"]
|
__all__ = ["__version__"]
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
__version__ = "0.4.0"
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
from time import perf_counter
|
from time import perf_counter
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
@@ -201,6 +203,21 @@ def run(
|
|||||||
help="Optional JSONL file path to log AI and session output.",
|
help="Optional JSONL file path to log AI and session output.",
|
||||||
),
|
),
|
||||||
] = None,
|
] = 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[
|
no_rag: Annotated[
|
||||||
bool,
|
bool,
|
||||||
typer.Option(
|
typer.Option(
|
||||||
@@ -307,7 +324,7 @@ def run(
|
|||||||
console.print(f"[yellow]Session memory unavailable:[/yellow] {exc}")
|
console.print(f"[yellow]Session memory unavailable:[/yellow] {exc}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
asyncio.run(
|
final_response = asyncio.run(
|
||||||
_async_main(
|
_async_main(
|
||||||
config,
|
config,
|
||||||
req,
|
req,
|
||||||
@@ -323,6 +340,18 @@ def run(
|
|||||||
logger=logger,
|
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:
|
except typer.Exit:
|
||||||
raise
|
raise
|
||||||
except TimeoutError as exc:
|
except TimeoutError as exc:
|
||||||
@@ -347,7 +376,7 @@ async def _async_main(
|
|||||||
runbook_store: RunbookStore | None,
|
runbook_store: RunbookStore | None,
|
||||||
session_store: SessionStore | None,
|
session_store: SessionStore | None,
|
||||||
logger: SessionLogger | None,
|
logger: SessionLogger | None,
|
||||||
) -> None:
|
) -> str | None:
|
||||||
"""Open a single SSH session and run probe / collection / analysis through it."""
|
"""Open a single SSH session and run probe / collection / analysis through it."""
|
||||||
client = SSHClient(config)
|
client = SSHClient(config)
|
||||||
if logger is not None:
|
if logger is not None:
|
||||||
@@ -422,6 +451,7 @@ async def _async_main(
|
|||||||
final_response = interactive_response or initial_response
|
final_response = interactive_response or initial_response
|
||||||
if session_store is not None and final_response:
|
if session_store is not None and final_response:
|
||||||
_index_session_memory(session_store, ai_config, req, final_response, logger=logger)
|
_index_session_memory(session_store, ai_config, req, final_response, logger=logger)
|
||||||
|
return final_response
|
||||||
|
|
||||||
|
|
||||||
async def _interactive_loop(
|
async def _interactive_loop(
|
||||||
@@ -641,7 +671,6 @@ async def _interactive_loop(
|
|||||||
logger.log_event("interactive_followup", {"question": "/analyze"})
|
logger.log_event("interactive_followup", {"question": "/analyze"})
|
||||||
last_response = response
|
last_response = response
|
||||||
continue
|
continue
|
||||||
continue
|
|
||||||
|
|
||||||
if report is None:
|
if report is None:
|
||||||
plan = plan_from_request(req)
|
plan = plan_from_request(req)
|
||||||
@@ -1076,6 +1105,48 @@ def _format_history_markdown(
|
|||||||
return "\n".join(lines).rstrip() + "\n"
|
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
|
# runbooks sub-app
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
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 result.exit_code == 0
|
||||||
assert "Session memory is disabled" in result.stdout
|
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"]
|
||||||
|
|||||||
Reference in New Issue
Block a user