feat: experiment log append/read utilities and rich viewer
This commit is contained in:
@@ -0,0 +1,30 @@
|
|||||||
|
"""Append-only experiment log utilities."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
LOG_PATH = os.path.expanduser('~/.claude/autoresearch/log.jsonl')
|
||||||
|
|
||||||
|
|
||||||
|
def append_entry(entry: dict, path: str = LOG_PATH) -> None:
|
||||||
|
entry = dict(entry) # don't mutate caller's dict
|
||||||
|
entry.setdefault('ts', datetime.now(timezone.utc).isoformat())
|
||||||
|
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
|
||||||
|
with open(path, 'a') as f:
|
||||||
|
f.write(json.dumps(entry) + '\n')
|
||||||
|
|
||||||
|
|
||||||
|
def read_entries(path: str = LOG_PATH) -> list:
|
||||||
|
if not os.path.exists(path):
|
||||||
|
return []
|
||||||
|
entries = []
|
||||||
|
with open(path) as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
entries.append(json.loads(line))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
return entries
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""Live log viewer for overview pane. Run: python3 bin/log_view.py [--follow]"""
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import os
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
from log_append import read_entries
|
||||||
|
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.table import Table
|
||||||
|
from rich import box
|
||||||
|
|
||||||
|
LOG_PATH = os.path.expanduser('~/.claude/autoresearch/log.jsonl')
|
||||||
|
console = Console()
|
||||||
|
|
||||||
|
|
||||||
|
def render_table(entries):
|
||||||
|
table = Table(box=box.SIMPLE, show_header=True, header_style="bold #DA251C")
|
||||||
|
table.add_column("Time", style="dim", width=8)
|
||||||
|
table.add_column("Project", width=14)
|
||||||
|
table.add_column("Exp", width=6)
|
||||||
|
table.add_column("Description", width=42)
|
||||||
|
table.add_column("Δ", width=8, justify="right")
|
||||||
|
table.add_column("", width=2)
|
||||||
|
for e in entries[-40:]:
|
||||||
|
ts = (e.get('ts') or '')[11:16] # HH:MM from ISO8601
|
||||||
|
before = e.get('metric_before')
|
||||||
|
after = e.get('metric_after')
|
||||||
|
if before is not None and after is not None:
|
||||||
|
delta = f"{after - before:+.3f}"
|
||||||
|
delta_style = "green" if after < before else "red"
|
||||||
|
else:
|
||||||
|
delta, delta_style = "n/a", "dim"
|
||||||
|
kept = "✓" if e.get('kept') else "✗"
|
||||||
|
kept_style = "green" if e.get('kept') else "red"
|
||||||
|
table.add_row(
|
||||||
|
ts,
|
||||||
|
(e.get('project') or '?')[:14],
|
||||||
|
str(e.get('exp') or '?'),
|
||||||
|
(e.get('description') or '')[:42],
|
||||||
|
f"[{delta_style}]{delta}[/{delta_style}]",
|
||||||
|
f"[{kept_style}]{kept}[/{kept_style}]",
|
||||||
|
)
|
||||||
|
return table
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
follow = '--follow' in sys.argv
|
||||||
|
while True:
|
||||||
|
entries = read_entries()
|
||||||
|
console.clear()
|
||||||
|
console.print(
|
||||||
|
f"[bold #DA251C]autoresearch[/bold #DA251C] — {len(entries)} experiments",
|
||||||
|
justify="center"
|
||||||
|
)
|
||||||
|
console.print(render_table(entries))
|
||||||
|
if not follow:
|
||||||
|
break
|
||||||
|
time.sleep(5)
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import sys, os, json, tempfile, pytest
|
||||||
|
sys.path.insert(0, 'bin')
|
||||||
|
from log_append import append_entry, read_entries, LOG_PATH
|
||||||
|
|
||||||
|
def test_append_creates_file(tmp_path):
|
||||||
|
log = str(tmp_path / "log.jsonl")
|
||||||
|
append_entry({"project": "test"}, path=log)
|
||||||
|
assert os.path.exists(log)
|
||||||
|
|
||||||
|
def test_append_writes_valid_json(tmp_path):
|
||||||
|
log = str(tmp_path / "log.jsonl")
|
||||||
|
append_entry({"project": "x", "kept": True}, path=log)
|
||||||
|
with open(log) as f:
|
||||||
|
data = json.loads(f.read().strip())
|
||||||
|
assert data["project"] == "x"
|
||||||
|
assert data["kept"] is True
|
||||||
|
|
||||||
|
def test_append_adds_timestamp(tmp_path):
|
||||||
|
log = str(tmp_path / "log.jsonl")
|
||||||
|
append_entry({"project": "x"}, path=log)
|
||||||
|
with open(log) as f:
|
||||||
|
data = json.loads(f.read().strip())
|
||||||
|
assert "ts" in data
|
||||||
|
|
||||||
|
def test_append_multiple_entries(tmp_path):
|
||||||
|
log = str(tmp_path / "log.jsonl")
|
||||||
|
for i in range(3):
|
||||||
|
append_entry({"n": i}, path=log)
|
||||||
|
entries = read_entries(path=log)
|
||||||
|
assert len(entries) == 3
|
||||||
|
assert entries[1]["n"] == 1
|
||||||
|
|
||||||
|
def test_read_entries_empty_if_no_file(tmp_path):
|
||||||
|
log = str(tmp_path / "nonexistent.jsonl")
|
||||||
|
assert read_entries(path=log) == []
|
||||||
|
|
||||||
|
def test_read_entries_skips_malformed_lines(tmp_path):
|
||||||
|
log = str(tmp_path / "log.jsonl")
|
||||||
|
with open(log, 'w') as f:
|
||||||
|
f.write('{"project": "ok"}\n')
|
||||||
|
f.write('not-valid-json\n')
|
||||||
|
f.write('{"project": "also-ok"}\n')
|
||||||
|
entries = read_entries(path=log)
|
||||||
|
assert len(entries) == 2
|
||||||
Reference in New Issue
Block a user