Skip to content

Commit ef5016b

Browse files
authored
night-shift: weekly self-review report generator (audit log + NIGHT_SHIFT.md) (#6)
What: src/personal_llm/review/self_review.py - generate_self_review reads a MemoryStore's audit trail (store.recent_audit) plus, if present, the ai-ecosystem Night Shift build log, and renders a deterministic markdown summary via render_self_review_markdown. No model call, unlike review/weekly.py (which reads memory content and calls a model for insights) - this reads the system's own recent activity instead. parse_night_shift_log/ recent_night_shift_entries split NIGHT_SHIFT.md on its own "## YYYY-MM-DD" heading convention and filter to a day window. Wired to a new self-review CLI command and night_shift_log_path config setting (defaults to ../NIGHT_SHIFT.md, degrades gracefully to an audit-only report if the sibling repo isn't checked out next to this one). Why: PROJECT-GENESIS.md section 9 Tier 5 item 37 (aliased Tier 9 item 75); section 6 "AI that designs better AIs" names the audit log and NIGHT_SHIFT.md as its first two self-review data sources. Verified: 19 new tests (143/143 full suite green offline, up from 124, full requirements.txt installed). Also live-verified against the real ai-ecosystem/NIGHT_SHIFT.md - correctly parsed and window-filtered its real dated entries.
1 parent 1b95167 commit ef5016b

5 files changed

Lines changed: 394 additions & 2 deletions

File tree

README.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Personal LLM
22

33
[![CI](https://github.com/syzayd/personal-llm/actions/workflows/ci.yml/badge.svg)](https://github.com/syzayd/personal-llm/actions/workflows/ci.yml)
4-
![Tests](https://img.shields.io/badge/tests-124%20passed%20offline-brightgreen)
4+
![Tests](https://img.shields.io/badge/tests-143%20passed%20offline-brightgreen)
55
![Python](https://img.shields.io/badge/python-3.12-blue)
66
[![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
77

@@ -36,6 +36,11 @@ Full design docs live in [`docs/`](docs/): [PRD](docs/PRD.md), [Technical Design
3636
ingestion history against the equal-length window right before it - which topics
3737
you're writing about more, and which have faded. Pure frequency counting, no model
3838
call, fully offline.
39+
- **Weekly self-review** (`self-review`) - a deterministic markdown summary of the
40+
system's own recent activity: the audit log (every ingest/ask/remember/... call) plus
41+
the ai-ecosystem Night Shift build log, no model call. Different from `review` above,
42+
which reads memory content and calls a model for insights; this reads what the system
43+
itself has been doing.
3944
- **Ingest real Gmail/Drive content** (v1.0, `ingest-external`) fetched via Claude Code's
4045
own already-authenticated MCP connectors - `personal_llm` holds no Google credentials
4146
of its own; see [ADR 0005](docs/DECISIONS/0005-external-integrations-via-mcp-bridge.md).
@@ -75,6 +80,9 @@ py -3.12 -m venv venv
7580
& "venv\Scripts\python" -m personal_llm.interfaces.cli review --days 7
7681
& "venv\Scripts\python" -m personal_llm.interfaces.cli ask "what is this project?" --verify
7782
83+
# Weekly self-review of the system's own activity (audit log + Night Shift build log)
84+
& "venv\Scripts\python" -m personal_llm.interfaces.cli self-review --days 7
85+
7886
# External content (Gmail/Drive fetched elsewhere, e.g. via Claude Code's MCP - see ADR 0005)
7987
& "venv\Scripts\python" -m personal_llm.interfaces.cli ingest-external "path\to\items.json"
8088
@@ -111,7 +119,7 @@ under 5 minutes with no API key.
111119
```powershell
112120
& "venv\Scripts\python" -m pytest tests/ -q
113121
```
114-
124 tests, fully mocked - no API key, network, real model, or real Tesseract binary
122+
143 tests, fully mocked - no API key, network, real model, or real Tesseract binary
115123
required. CI runs this on every push (keyless by design).
116124

117125
## Architecture at a glance

src/personal_llm/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ class Settings(BaseSettings):
2424
personal_llm_workspace_dir: str = "./data/workspace"
2525
personal_llm_voice_dir: str = "./data/voice"
2626
personal_llm_gateway_token_path: str = "./data/gateway_token"
27+
night_shift_log_path: str = "../NIGHT_SHIFT.md"
2728

2829
retrieval_top_k: int = 8
2930
retrieval_min_similarity: float = 0.25

src/personal_llm/interfaces/cli.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from personal_llm.memory.retrieve import semantic_search
2121
from personal_llm.memory.types import MemoryRecord
2222
from personal_llm.rag.pipeline import ask as rag_ask
23+
from personal_llm.review.self_review import generate_self_review
2324
from personal_llm.review.weekly import generate_review
2425
from personal_llm.router.providers import RouterError
2526
from personal_llm.tools import build_default_registry
@@ -172,6 +173,21 @@ def review(days: int = typer.Option(7, help="How many days back counts as 'recen
172173
typer.echo(f" - {item}")
173174

174175

176+
@app.command(name="self-review")
177+
def self_review(
178+
days: int = typer.Option(7, help="How many days back counts as 'recent'."),
179+
night_shift_log: str = typer.Option(
180+
None, help="Path to NIGHT_SHIFT.md (defaults to config night_shift_log_path)."
181+
),
182+
) -> None:
183+
"""Weekly self-review: deterministic markdown summary of the audit log plus the
184+
ai-ecosystem Night Shift build log - no model call, unlike `review`."""
185+
settings = get_settings()
186+
engine = build_engine()
187+
log_path = night_shift_log if night_shift_log is not None else settings.night_shift_log_path
188+
typer.echo(generate_self_review(engine.store, night_shift_log_path=log_path, days=days))
189+
190+
175191
@app.command()
176192
def trends(
177193
window_days: float = typer.Option(7.0, help="Size of the 'recent' and 'previous' comparison windows, in days."),
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
"""Weekly self-review report generator: audit log + NIGHT_SHIFT.md -> deterministic markdown.
2+
3+
PROJECT-GENESIS.md section 9 Tier 5 item 37 (aliased Tier 9 item 75); section 6 "AI that
4+
designs better AIs" names the audit log and NIGHT_SHIFT.md as its first two data sources
5+
for a self-review. This is a different thing from `review/weekly.py`'s proactive review,
6+
which reads memory CONTENT and calls a model to surface insights about what Zaid has been
7+
thinking about. This module reads the SYSTEM's own recent activity instead: MemoryStore's
8+
audit trail (every ingest/ask/remember/... call already logs itself via `store.log()`)
9+
plus the ai-ecosystem Night Shift build log, and renders a deterministic markdown summary
10+
of both - no model call, so the same inputs always produce the same report, byte for byte
11+
(the explicit verification bar the task queue names).
12+
13+
No personal_llm.router import: this never calls a model.
14+
"""
15+
16+
from __future__ import annotations
17+
18+
import re
19+
from collections import Counter
20+
from dataclasses import dataclass
21+
from datetime import datetime, timedelta, timezone
22+
from pathlib import Path
23+
from typing import Sequence
24+
25+
from personal_llm.memory.store import MemoryStore
26+
27+
_ENTRY_HEADING_RE = re.compile(r"^## (\d{4}-\d{2}-\d{2}).*$", re.MULTILINE)
28+
29+
30+
@dataclass(frozen=True)
31+
class NightShiftEntry:
32+
date: str
33+
heading: str
34+
body: str
35+
36+
37+
def parse_night_shift_log(text: str) -> list[NightShiftEntry]:
38+
"""Split NIGHT_SHIFT.md's append-only log into per-heading entries.
39+
40+
Each entry starts at a `## YYYY-MM-DD...` heading (the log's own convention, e.g.
41+
"## 2026-07-23 (Night Shift)") and runs to the next such heading or end of file. Text
42+
before the first heading (the file's own top-level title/intro line) is ignored.
43+
"""
44+
matches = list(_ENTRY_HEADING_RE.finditer(text))
45+
entries: list[NightShiftEntry] = []
46+
for i, match in enumerate(matches):
47+
heading = match.group(0)[3:].strip()
48+
start = match.end()
49+
end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
50+
body = text[start:end].strip()
51+
entries.append(NightShiftEntry(date=match.group(1), heading=heading, body=body))
52+
return entries
53+
54+
55+
def _entry_date(entry: NightShiftEntry) -> datetime:
56+
return datetime.strptime(entry.date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
57+
58+
59+
def recent_night_shift_entries(
60+
entries: Sequence[NightShiftEntry], *, days: int = 7, as_of: datetime | None = None
61+
) -> list[NightShiftEntry]:
62+
"""Entries dated within the last `days` days of `as_of` (default: now), oldest first."""
63+
as_of = as_of or datetime.now(timezone.utc)
64+
cutoff = as_of - timedelta(days=days)
65+
recent = [e for e in entries if _entry_date(e) >= cutoff]
66+
recent.sort(key=lambda e: e.date)
67+
return recent
68+
69+
70+
@dataclass(frozen=True)
71+
class AuditSummary:
72+
total: int
73+
by_actor: dict[str, int]
74+
by_action: dict[str, int]
75+
76+
77+
def summarize_audit(entries: Sequence[dict]) -> AuditSummary:
78+
"""Deterministic counts over a list of audit records (MemoryStore.recent_audit shape:
79+
each a dict with at least `actor` and `action` keys). Sorted alphabetically by key so
80+
two calls with the same entries in a different order produce identical output.
81+
"""
82+
by_actor = Counter(e["actor"] for e in entries)
83+
by_action = Counter(e["action"] for e in entries)
84+
return AuditSummary(
85+
total=len(entries),
86+
by_actor=dict(sorted(by_actor.items())),
87+
by_action=dict(sorted(by_action.items())),
88+
)
89+
90+
91+
def _within_days(iso_ts: str, days: int, as_of: datetime) -> bool:
92+
try:
93+
ts = datetime.fromisoformat(iso_ts)
94+
except ValueError:
95+
return False
96+
if ts.tzinfo is None:
97+
ts = ts.replace(tzinfo=timezone.utc)
98+
return ts >= as_of - timedelta(days=days)
99+
100+
101+
def render_self_review_markdown(
102+
*,
103+
generated_at: str,
104+
days: int,
105+
audit_summary: AuditSummary,
106+
night_shift_entries: Sequence[NightShiftEntry],
107+
) -> str:
108+
"""Deterministic markdown - no randomness, no data besides what is passed in."""
109+
lines: list[str] = []
110+
lines.append(f"# Weekly Self-Review ({days}d)")
111+
lines.append("")
112+
lines.append(f"Generated: {generated_at}")
113+
lines.append("")
114+
lines.append("## Audit activity")
115+
lines.append("")
116+
lines.append(f"**Total events:** {audit_summary.total}")
117+
lines.append("")
118+
lines.append("By actor:")
119+
if audit_summary.by_actor:
120+
for actor, count in audit_summary.by_actor.items():
121+
lines.append(f"- {actor}: {count}")
122+
else:
123+
lines.append("- (no audit events in this window)")
124+
lines.append("")
125+
lines.append("By action:")
126+
if audit_summary.by_action:
127+
for action, count in audit_summary.by_action.items():
128+
lines.append(f"- {action}: {count}")
129+
else:
130+
lines.append("- (no audit events in this window)")
131+
lines.append("")
132+
lines.append("## Night Shift entries")
133+
lines.append("")
134+
if night_shift_entries:
135+
for entry in night_shift_entries:
136+
lines.append(f"- {entry.heading}")
137+
else:
138+
lines.append("- (no NIGHT_SHIFT.md entries in this window, or the log was not available)")
139+
lines.append("")
140+
return "\n".join(lines)
141+
142+
143+
def generate_self_review(
144+
store: MemoryStore,
145+
*,
146+
night_shift_log_path: str | Path | None = None,
147+
days: int = 7,
148+
audit_limit: int = 500,
149+
as_of: datetime | None = None,
150+
) -> str:
151+
"""Convenience wrapper: reads a real MemoryStore's audit log (and, if present, the
152+
Night Shift build log at `night_shift_log_path`) and renders the markdown report.
153+
154+
A missing `night_shift_log_path` (unset, or the file does not exist) degrades to an
155+
audit-only report rather than raising - the log lives in the sibling ai-ecosystem
156+
repo and is not guaranteed to be checked out next to this one.
157+
"""
158+
as_of = as_of or datetime.now(timezone.utc)
159+
audit = [
160+
entry
161+
for entry in store.recent_audit(limit=audit_limit)
162+
if _within_days(entry["ts"], days, as_of)
163+
]
164+
summary = summarize_audit(audit)
165+
166+
night_shift_entries: list[NightShiftEntry] = []
167+
if night_shift_log_path is not None:
168+
path = Path(night_shift_log_path)
169+
if path.exists():
170+
all_entries = parse_night_shift_log(path.read_text(encoding="utf-8"))
171+
night_shift_entries = recent_night_shift_entries(all_entries, days=days, as_of=as_of)
172+
173+
return render_self_review_markdown(
174+
generated_at=as_of.isoformat(),
175+
days=days,
176+
audit_summary=summary,
177+
night_shift_entries=night_shift_entries,
178+
)

0 commit comments

Comments
 (0)