|
| 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