forked from Rockachopa/Timmy-time-dashboard
feat(briefing): morning briefing + approval queue
Implements the Morning Briefing and Approval Queue feature — the first step
from tool to companion. Timmy now shows up before the owner asks.
New modules
-----------
• src/timmy/approvals.py — ApprovalItem dataclass, GOLDEN_TIMMY governance
constant, full SQLite CRUD (create / list / approve / reject / expire).
Items auto-expire after 7 days if not actioned.
• src/timmy/briefing.py — BriefingEngine that queries swarm activity and
chat history, calls Timmy's Agno agent for a prose summary, and caches
the result in SQLite (~/.timmy/briefings.db). get_or_generate() skips
regeneration if a fresh briefing (< 30 min) already exists.
New routes (src/dashboard/routes/briefing.py)
----------------------------------------------
GET /briefing — full briefing page
GET /briefing/approvals — HTMX partial: pending approval cards
POST /briefing/approvals/{id}/approve — approve via HTMX (no page reload)
POST /briefing/approvals/{id}/reject — reject via HTMX (no page reload)
New templates
-------------
• briefing.html — clean, mobile-first prose layout (max 680px)
• partials/approval_cards.html — list of approval cards
• partials/approval_card_single.html — single approval card with
Approve/Reject HTMX buttons
App wiring (src/dashboard/app.py)
----------------------------------
• Added asynccontextmanager lifespan with _briefing_scheduler background task.
Generates a briefing at startup and every 6 hours; skips if fresh.
Push notification hook (src/notifications/push.py)
---------------------------------------------------
• notify_briefing_ready(briefing) — logs + triggers local notifier.
Placeholder for APNs/Pushover wiring later.
Navigation
----------
• Added BRIEFING link to the header nav in base.html.
Tests
-----
• tests/test_approvals.py — 17 tests: GOLDEN_TIMMY, CRUD, expiry, ordering
• tests/test_briefing.py — 22 tests: dataclass, freshness, cache round-trip,
generate/get_or_generate, push notification hook
354 tests, 354 passing.
https://claude.ai/code/session_01D7p5w91KX3grBeioGiiGy8
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
@@ -16,6 +18,7 @@ from dashboard.routes.voice import router as voice_router
|
||||
from dashboard.routes.voice_enhanced import router as voice_enhanced_router
|
||||
from dashboard.routes.mobile import router as mobile_router
|
||||
from dashboard.routes.swarm_ws import router as swarm_ws_router
|
||||
from dashboard.routes.briefing import router as briefing_router
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
@@ -27,9 +30,50 @@ logger = logging.getLogger(__name__)
|
||||
BASE_DIR = Path(__file__).parent
|
||||
PROJECT_ROOT = BASE_DIR.parent.parent
|
||||
|
||||
_BRIEFING_INTERVAL_HOURS = 6
|
||||
|
||||
|
||||
async def _briefing_scheduler() -> None:
|
||||
"""Background task: regenerate Timmy's briefing every 6 hours.
|
||||
|
||||
Runs once at startup (after a short delay to let the server settle),
|
||||
then on a 6-hour cadence. Skips generation if a fresh briefing already
|
||||
exists (< 30 min old).
|
||||
"""
|
||||
from timmy.briefing import engine as briefing_engine
|
||||
from notifications.push import notify_briefing_ready
|
||||
|
||||
await asyncio.sleep(2) # Let server finish starting before first run
|
||||
|
||||
while True:
|
||||
try:
|
||||
if briefing_engine.needs_refresh():
|
||||
logger.info("Generating morning briefing…")
|
||||
briefing = briefing_engine.generate()
|
||||
await notify_briefing_ready(briefing)
|
||||
else:
|
||||
logger.info("Briefing is fresh; skipping generation.")
|
||||
except Exception as exc:
|
||||
logger.error("Briefing scheduler error: %s", exc)
|
||||
|
||||
await asyncio.sleep(_BRIEFING_INTERVAL_HOURS * 3600)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
task = asyncio.create_task(_briefing_scheduler())
|
||||
yield
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Timmy Time — Mission Control",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
# Docs disabled unless DEBUG=true in env / .env
|
||||
docs_url="/docs" if settings.debug else None,
|
||||
redoc_url="/redoc" if settings.debug else None,
|
||||
@@ -47,6 +91,7 @@ app.include_router(voice_router)
|
||||
app.include_router(voice_enhanced_router)
|
||||
app.include_router(mobile_router)
|
||||
app.include_router(swarm_ws_router)
|
||||
app.include_router(briefing_router)
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
|
||||
70
src/dashboard/routes/briefing.py
Normal file
70
src/dashboard/routes/briefing.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""Briefing routes — Morning briefing and approval queue.
|
||||
|
||||
GET /briefing — render the briefing page
|
||||
GET /briefing/approvals — HTMX partial: pending approval cards
|
||||
POST /briefing/approvals/{id}/approve — approve an item (HTMX)
|
||||
POST /briefing/approvals/{id}/reject — reject an item (HTMX)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from timmy.briefing import engine as briefing_engine
|
||||
from timmy import approvals as approval_store
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/briefing", tags=["briefing"])
|
||||
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def get_briefing(request: Request):
|
||||
"""Return today's briefing page (generated or cached)."""
|
||||
briefing = briefing_engine.get_or_generate()
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"briefing.html",
|
||||
{"briefing": briefing},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/approvals", response_class=HTMLResponse)
|
||||
async def get_approvals(request: Request):
|
||||
"""Return HTMX partial with all pending approval items."""
|
||||
items = approval_store.list_pending()
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"partials/approval_cards.html",
|
||||
{"items": items},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/approvals/{item_id}/approve", response_class=HTMLResponse)
|
||||
async def approve_item(request: Request, item_id: str):
|
||||
"""Approve an approval item; return the updated card via HTMX."""
|
||||
item = approval_store.approve(item_id)
|
||||
if item is None:
|
||||
return HTMLResponse("<p class='text-danger'>Item not found.</p>", status_code=404)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"partials/approval_card_single.html",
|
||||
{"item": item},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/approvals/{item_id}/reject", response_class=HTMLResponse)
|
||||
async def reject_item(request: Request, item_id: str):
|
||||
"""Reject an approval item; return the updated card via HTMX."""
|
||||
item = approval_store.reject(item_id)
|
||||
if item is None:
|
||||
return HTMLResponse("<p class='text-danger'>Item not found.</p>", status_code=404)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"partials/approval_card_single.html",
|
||||
{"item": item},
|
||||
)
|
||||
@@ -21,6 +21,7 @@
|
||||
<span class="mc-subtitle">MISSION CONTROL</span>
|
||||
</div>
|
||||
<div class="mc-header-right">
|
||||
<a href="/briefing" class="mc-test-link">BRIEFING</a>
|
||||
<a href="/swarm/live" class="mc-test-link">SWARM</a>
|
||||
<a href="/marketplace/ui" class="mc-test-link">MARKET</a>
|
||||
<a href="/mobile" class="mc-test-link">MOBILE</a>
|
||||
|
||||
208
src/dashboard/templates/briefing.html
Normal file
208
src/dashboard/templates/briefing.html
Normal file
@@ -0,0 +1,208 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Timmy Time — Morning Briefing{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container briefing-container py-4">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="briefing-header mb-4">
|
||||
<div class="briefing-greeting">Good morning.</div>
|
||||
<div class="briefing-timestamp">
|
||||
Briefing generated
|
||||
<span class="briefing-ts-val">{{ briefing.generated_at.strftime('%Y-%m-%d %H:%M UTC') }}</span>
|
||||
— covering
|
||||
<span class="briefing-ts-val">{{ briefing.period_start.strftime('%H:%M') }}</span>
|
||||
to
|
||||
<span class="briefing-ts-val">{{ briefing.period_end.strftime('%H:%M UTC') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary -->
|
||||
<div class="card mc-panel briefing-summary mb-5">
|
||||
<div class="card-header mc-panel-header">// TIMMY’S REPORT</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="briefing-prose">{{ briefing.summary }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Approval Queue -->
|
||||
<div class="card mc-panel">
|
||||
<div class="card-header mc-panel-header d-flex justify-content-between align-items-center">
|
||||
<span>// APPROVAL QUEUE</span>
|
||||
<span class="badge bg-warning text-dark"
|
||||
id="approval-count">{{ briefing.approval_items | length }} pending</span>
|
||||
</div>
|
||||
<div class="card-body p-3"
|
||||
id="approval-queue"
|
||||
hx-get="/briefing/approvals"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML">
|
||||
<!-- HTMX fills this on load with live data -->
|
||||
<div class="text-center text-muted py-3">Loading approval items…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Briefing-specific styles — mobile-first */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
.briefing-container {
|
||||
max-width: 680px;
|
||||
}
|
||||
|
||||
.briefing-header {
|
||||
border-left: 3px solid var(--mc-amber, #ffc107);
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.briefing-greeting {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
color: var(--mc-amber, #ffc107);
|
||||
letter-spacing: 0.04em;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.briefing-timestamp {
|
||||
font-size: 0.75rem;
|
||||
color: #6c757d;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.briefing-ts-val {
|
||||
color: #adb5bd;
|
||||
}
|
||||
|
||||
.briefing-prose {
|
||||
font-size: 1rem;
|
||||
line-height: 1.75;
|
||||
color: #dee2e6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Approval cards */
|
||||
.approval-card {
|
||||
border: 1px solid #2a3a4a;
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
background: #0d1b2a;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.approval-card.approved {
|
||||
border-color: #198754;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.approval-card.rejected {
|
||||
border-color: #dc3545;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.approval-card-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
color: #f8f9fa;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.approval-card-desc {
|
||||
font-size: 0.85rem;
|
||||
color: #adb5bd;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.approval-card-action {
|
||||
font-size: 0.8rem;
|
||||
color: #6c757d;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
margin-bottom: 0.75rem;
|
||||
border-left: 2px solid #495057;
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
.impact-badge {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.2em 0.5em;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.impact-low { background: #198754; color: #fff; }
|
||||
.impact-medium { background: #fd7e14; color: #fff; }
|
||||
.impact-high { background: #dc3545; color: #fff; }
|
||||
|
||||
.approval-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-approve {
|
||||
background: #198754;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 0.4rem 0.9rem;
|
||||
font-size: 0.82rem;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
cursor: pointer;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.btn-approve:hover { background: #157347; }
|
||||
|
||||
.btn-reject {
|
||||
background: transparent;
|
||||
color: #dc3545;
|
||||
border: 1px solid #dc3545;
|
||||
border-radius: 4px;
|
||||
padding: 0.4rem 0.9rem;
|
||||
font-size: 0.82rem;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
cursor: pointer;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.btn-reject:hover { background: #dc354520; }
|
||||
|
||||
.no-approvals {
|
||||
text-align: center;
|
||||
color: #6c757d;
|
||||
padding: 2rem 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Refresh button */
|
||||
.btn-refresh {
|
||||
background: transparent;
|
||||
color: #adb5bd;
|
||||
border: 1px solid #495057;
|
||||
border-radius: 4px;
|
||||
padding: 0.3rem 0.7rem;
|
||||
font-size: 0.75rem;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.btn-refresh:hover {
|
||||
color: #f8f9fa;
|
||||
border-color: #6c757d;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.briefing-greeting { font-size: 1.3rem; }
|
||||
.briefing-prose { font-size: 0.95rem; }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
33
src/dashboard/templates/partials/approval_card_single.html
Normal file
33
src/dashboard/templates/partials/approval_card_single.html
Normal file
@@ -0,0 +1,33 @@
|
||||
<div class="approval-card {{ item.status }}" id="approval-{{ item.id }}">
|
||||
<div class="d-flex justify-content-between align-items-start mb-1">
|
||||
<div class="approval-card-title">{{ item.title }}</div>
|
||||
<span class="impact-badge impact-{{ item.impact }}">{{ item.impact }}</span>
|
||||
</div>
|
||||
<div class="approval-card-desc">{{ item.description }}</div>
|
||||
<div class="approval-card-action">▶ {{ item.proposed_action }}</div>
|
||||
|
||||
{% if item.status == "pending" %}
|
||||
<div class="approval-actions">
|
||||
<button class="btn-approve"
|
||||
hx-post="/briefing/approvals/{{ item.id }}/approve"
|
||||
hx-target="#approval-{{ item.id }}"
|
||||
hx-swap="outerHTML">
|
||||
APPROVE
|
||||
</button>
|
||||
<button class="btn-reject"
|
||||
hx-post="/briefing/approvals/{{ item.id }}/reject"
|
||||
hx-target="#approval-{{ item.id }}"
|
||||
hx-swap="outerHTML">
|
||||
REJECT
|
||||
</button>
|
||||
</div>
|
||||
{% elif item.status == "approved" %}
|
||||
<div class="text-success" style="font-size:0.82rem; font-family:'JetBrains Mono',monospace;">
|
||||
✓ Approved
|
||||
</div>
|
||||
{% elif item.status == "rejected" %}
|
||||
<div class="text-danger" style="font-size:0.82rem; font-family:'JetBrains Mono',monospace;">
|
||||
✗ Rejected
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
9
src/dashboard/templates/partials/approval_cards.html
Normal file
9
src/dashboard/templates/partials/approval_cards.html
Normal file
@@ -0,0 +1,9 @@
|
||||
{% if items %}
|
||||
{% for item in items %}
|
||||
{% include "partials/approval_card_single.html" %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="no-approvals">
|
||||
No pending approvals. Timmy is standing by.
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -121,3 +121,25 @@ class PushNotifier:
|
||||
|
||||
# Module-level singleton
|
||||
notifier = PushNotifier()
|
||||
|
||||
|
||||
async def notify_briefing_ready(briefing) -> None:
|
||||
"""Placeholder: notify the owner that a new morning briefing is ready.
|
||||
|
||||
Logs to console now. Wire to real push (APNs/Pushover) later.
|
||||
|
||||
Args:
|
||||
briefing: A timmy.briefing.Briefing instance.
|
||||
"""
|
||||
n_approvals = len(briefing.approval_items) if briefing.approval_items else 0
|
||||
message = (
|
||||
f"Your morning briefing is ready. "
|
||||
f"{n_approvals} item(s) await your approval."
|
||||
)
|
||||
notifier.notify(
|
||||
title="Morning Briefing Ready",
|
||||
message=message,
|
||||
category="briefing",
|
||||
native=True,
|
||||
)
|
||||
logger.info("Briefing push notification dispatched (%d approval(s))", n_approvals)
|
||||
|
||||
185
src/timmy/approvals.py
Normal file
185
src/timmy/approvals.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""Approval item management — the governance layer for autonomous Timmy actions.
|
||||
|
||||
The GOLDEN_TIMMY constant is the single source of truth for whether Timmy
|
||||
may act autonomously. All features that want to take action must:
|
||||
|
||||
1. Create an ApprovalItem
|
||||
2. Check GOLDEN_TIMMY
|
||||
3. If True → wait for owner approval before executing
|
||||
4. If False → log the action and proceed (Dark Timmy mode)
|
||||
|
||||
Default is always True. The owner changes this intentionally.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GOLDEN TIMMY RULE
|
||||
# ---------------------------------------------------------------------------
|
||||
GOLDEN_TIMMY = True
|
||||
# When True: no autonomous action executes without an approved ApprovalItem.
|
||||
# When False: Dark Timmy mode — Timmy may act on his own judgment.
|
||||
# Default is always True. Owner changes this intentionally.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Persistence
|
||||
# ---------------------------------------------------------------------------
|
||||
_DEFAULT_DB = Path.home() / ".timmy" / "approvals.db"
|
||||
_EXPIRY_DAYS = 7
|
||||
|
||||
|
||||
@dataclass
|
||||
class ApprovalItem:
|
||||
id: str
|
||||
title: str
|
||||
description: str
|
||||
proposed_action: str # what Timmy wants to do
|
||||
impact: str # "low" | "medium" | "high"
|
||||
created_at: datetime
|
||||
status: str # "pending" | "approved" | "rejected"
|
||||
|
||||
|
||||
def _get_conn(db_path: Path = _DEFAULT_DB) -> sqlite3.Connection:
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS approval_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
proposed_action TEXT NOT NULL,
|
||||
impact TEXT NOT NULL DEFAULT 'low',
|
||||
created_at TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
return conn
|
||||
|
||||
|
||||
def _row_to_item(row: sqlite3.Row) -> ApprovalItem:
|
||||
return ApprovalItem(
|
||||
id=row["id"],
|
||||
title=row["title"],
|
||||
description=row["description"],
|
||||
proposed_action=row["proposed_action"],
|
||||
impact=row["impact"],
|
||||
created_at=datetime.fromisoformat(row["created_at"]),
|
||||
status=row["status"],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def create_item(
|
||||
title: str,
|
||||
description: str,
|
||||
proposed_action: str,
|
||||
impact: str = "low",
|
||||
db_path: Path = _DEFAULT_DB,
|
||||
) -> ApprovalItem:
|
||||
"""Create and persist a new approval item."""
|
||||
item = ApprovalItem(
|
||||
id=str(uuid.uuid4()),
|
||||
title=title,
|
||||
description=description,
|
||||
proposed_action=proposed_action,
|
||||
impact=impact,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
status="pending",
|
||||
)
|
||||
conn = _get_conn(db_path)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO approval_items
|
||||
(id, title, description, proposed_action, impact, created_at, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
item.id,
|
||||
item.title,
|
||||
item.description,
|
||||
item.proposed_action,
|
||||
item.impact,
|
||||
item.created_at.isoformat(),
|
||||
item.status,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return item
|
||||
|
||||
|
||||
def list_pending(db_path: Path = _DEFAULT_DB) -> list[ApprovalItem]:
|
||||
"""Return all pending approval items, newest first."""
|
||||
conn = _get_conn(db_path)
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM approval_items WHERE status = 'pending' ORDER BY created_at DESC"
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return [_row_to_item(r) for r in rows]
|
||||
|
||||
|
||||
def list_all(db_path: Path = _DEFAULT_DB) -> list[ApprovalItem]:
|
||||
"""Return all approval items regardless of status, newest first."""
|
||||
conn = _get_conn(db_path)
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM approval_items ORDER BY created_at DESC"
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return [_row_to_item(r) for r in rows]
|
||||
|
||||
|
||||
def get_item(item_id: str, db_path: Path = _DEFAULT_DB) -> Optional[ApprovalItem]:
|
||||
conn = _get_conn(db_path)
|
||||
row = conn.execute(
|
||||
"SELECT * FROM approval_items WHERE id = ?", (item_id,)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
return _row_to_item(row) if row else None
|
||||
|
||||
|
||||
def approve(item_id: str, db_path: Path = _DEFAULT_DB) -> Optional[ApprovalItem]:
|
||||
"""Mark an approval item as approved."""
|
||||
conn = _get_conn(db_path)
|
||||
conn.execute(
|
||||
"UPDATE approval_items SET status = 'approved' WHERE id = ?", (item_id,)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return get_item(item_id, db_path)
|
||||
|
||||
|
||||
def reject(item_id: str, db_path: Path = _DEFAULT_DB) -> Optional[ApprovalItem]:
|
||||
"""Mark an approval item as rejected."""
|
||||
conn = _get_conn(db_path)
|
||||
conn.execute(
|
||||
"UPDATE approval_items SET status = 'rejected' WHERE id = ?", (item_id,)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return get_item(item_id, db_path)
|
||||
|
||||
|
||||
def expire_old(db_path: Path = _DEFAULT_DB) -> int:
|
||||
"""Auto-expire pending items older than EXPIRY_DAYS. Returns count removed."""
|
||||
cutoff = (datetime.now(timezone.utc) - timedelta(days=_EXPIRY_DAYS)).isoformat()
|
||||
conn = _get_conn(db_path)
|
||||
cursor = conn.execute(
|
||||
"DELETE FROM approval_items WHERE status = 'pending' AND created_at < ?",
|
||||
(cutoff,),
|
||||
)
|
||||
conn.commit()
|
||||
count = cursor.rowcount
|
||||
conn.close()
|
||||
return count
|
||||
306
src/timmy/briefing.py
Normal file
306
src/timmy/briefing.py
Normal file
@@ -0,0 +1,306 @@
|
||||
"""Morning Briefing Engine — Timmy shows up before you ask.
|
||||
|
||||
BriefingEngine queries recent swarm activity and chat history, asks Timmy's
|
||||
Agno agent to summarise the period, and returns a Briefing with an embedded
|
||||
list of ApprovalItems the owner needs to action.
|
||||
|
||||
Briefings are cached in SQLite so page loads are instant. A background task
|
||||
regenerates the briefing every 6 hours.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import sqlite3
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_DB = Path.home() / ".timmy" / "briefings.db"
|
||||
_CACHE_MINUTES = 30
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data structures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class ApprovalItem:
|
||||
"""Lightweight representation used inside a Briefing.
|
||||
|
||||
The canonical mutable version (with persistence) lives in timmy.approvals.
|
||||
This one travels with the Briefing dataclass as a read-only snapshot.
|
||||
"""
|
||||
id: str
|
||||
title: str
|
||||
description: str
|
||||
proposed_action: str
|
||||
impact: str
|
||||
created_at: datetime
|
||||
status: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Briefing:
|
||||
generated_at: datetime
|
||||
summary: str # 150-300 words
|
||||
approval_items: list[ApprovalItem] = field(default_factory=list)
|
||||
period_start: datetime = field(
|
||||
default_factory=lambda: datetime.now(timezone.utc) - timedelta(hours=6)
|
||||
)
|
||||
period_end: datetime = field(
|
||||
default_factory=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SQLite cache
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _get_cache_conn(db_path: Path = _DEFAULT_DB) -> sqlite3.Connection:
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS briefings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
generated_at TEXT NOT NULL,
|
||||
period_start TEXT NOT NULL,
|
||||
period_end TEXT NOT NULL,
|
||||
summary TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
return conn
|
||||
|
||||
|
||||
def _save_briefing(briefing: Briefing, db_path: Path = _DEFAULT_DB) -> None:
|
||||
conn = _get_cache_conn(db_path)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO briefings (generated_at, period_start, period_end, summary)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
briefing.generated_at.isoformat(),
|
||||
briefing.period_start.isoformat(),
|
||||
briefing.period_end.isoformat(),
|
||||
briefing.summary,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def _load_latest(db_path: Path = _DEFAULT_DB) -> Optional[Briefing]:
|
||||
"""Load the most-recently cached briefing, or None if there is none."""
|
||||
conn = _get_cache_conn(db_path)
|
||||
row = conn.execute(
|
||||
"SELECT * FROM briefings ORDER BY generated_at DESC LIMIT 1"
|
||||
).fetchone()
|
||||
conn.close()
|
||||
if row is None:
|
||||
return None
|
||||
return Briefing(
|
||||
generated_at=datetime.fromisoformat(row["generated_at"]),
|
||||
period_start=datetime.fromisoformat(row["period_start"]),
|
||||
period_end=datetime.fromisoformat(row["period_end"]),
|
||||
summary=row["summary"],
|
||||
)
|
||||
|
||||
|
||||
def is_fresh(briefing: Briefing, max_age_minutes: int = _CACHE_MINUTES) -> bool:
|
||||
"""Return True if the briefing was generated within max_age_minutes."""
|
||||
now = datetime.now(timezone.utc)
|
||||
age = now - briefing.generated_at.replace(tzinfo=timezone.utc) if briefing.generated_at.tzinfo is None else now - briefing.generated_at
|
||||
return age.total_seconds() < max_age_minutes * 60
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Activity gathering helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _gather_swarm_summary(since: datetime) -> str:
|
||||
"""Pull recent task/agent stats from swarm.db. Graceful if DB missing."""
|
||||
swarm_db = Path("data/swarm.db")
|
||||
if not swarm_db.exists():
|
||||
return "No swarm activity recorded yet."
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(str(swarm_db))
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
since_iso = since.isoformat()
|
||||
|
||||
completed = conn.execute(
|
||||
"SELECT COUNT(*) as c FROM tasks WHERE status = 'completed' AND created_at > ?",
|
||||
(since_iso,),
|
||||
).fetchone()["c"]
|
||||
|
||||
failed = conn.execute(
|
||||
"SELECT COUNT(*) as c FROM tasks WHERE status = 'failed' AND created_at > ?",
|
||||
(since_iso,),
|
||||
).fetchone()["c"]
|
||||
|
||||
agents = conn.execute(
|
||||
"SELECT COUNT(*) as c FROM agents WHERE registered_at > ?",
|
||||
(since_iso,),
|
||||
).fetchone()["c"]
|
||||
|
||||
conn.close()
|
||||
|
||||
parts = []
|
||||
if completed:
|
||||
parts.append(f"{completed} task(s) completed")
|
||||
if failed:
|
||||
parts.append(f"{failed} task(s) failed")
|
||||
if agents:
|
||||
parts.append(f"{agents} new agent(s) joined the swarm")
|
||||
|
||||
return "; ".join(parts) if parts else "No swarm activity in this period."
|
||||
except Exception as exc:
|
||||
logger.debug("Swarm summary error: %s", exc)
|
||||
return "Swarm data unavailable."
|
||||
|
||||
|
||||
def _gather_chat_summary(since: datetime) -> str:
|
||||
"""Pull recent chat messages from the in-memory log."""
|
||||
try:
|
||||
from dashboard.store import message_log
|
||||
messages = message_log.all()
|
||||
# Filter to messages in the briefing window (best-effort: no timestamps)
|
||||
recent = messages[-10:] if len(messages) > 10 else messages
|
||||
if not recent:
|
||||
return "No recent conversations."
|
||||
lines = []
|
||||
for msg in recent:
|
||||
role = "Owner" if msg.role == "user" else "Timmy"
|
||||
lines.append(f"{role}: {msg.content[:120]}")
|
||||
return "\n".join(lines)
|
||||
except Exception as exc:
|
||||
logger.debug("Chat summary error: %s", exc)
|
||||
return "No recent conversations."
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BriefingEngine
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class BriefingEngine:
|
||||
"""Generates morning briefings by querying activity and asking Timmy."""
|
||||
|
||||
def __init__(self, db_path: Path = _DEFAULT_DB) -> None:
|
||||
self._db_path = db_path
|
||||
|
||||
def get_cached(self) -> Optional[Briefing]:
|
||||
"""Return the cached briefing if it exists, without regenerating."""
|
||||
return _load_latest(self._db_path)
|
||||
|
||||
def needs_refresh(self) -> bool:
|
||||
"""True if there is no fresh briefing cached."""
|
||||
cached = _load_latest(self._db_path)
|
||||
if cached is None:
|
||||
return True
|
||||
return not is_fresh(cached)
|
||||
|
||||
def generate(self) -> Briefing:
|
||||
"""Generate a fresh briefing. May take a few seconds (LLM call)."""
|
||||
now = datetime.now(timezone.utc)
|
||||
period_start = now - timedelta(hours=6)
|
||||
|
||||
swarm_info = _gather_swarm_summary(period_start)
|
||||
chat_info = _gather_chat_summary(period_start)
|
||||
|
||||
prompt = (
|
||||
"You are Timmy, a sovereign local AI companion.\n"
|
||||
"Here is what happened since the last briefing:\n\n"
|
||||
f"SWARM ACTIVITY:\n{swarm_info}\n\n"
|
||||
f"RECENT CONVERSATIONS:\n{chat_info}\n\n"
|
||||
"Summarize the last period of activity into a 5-minute morning briefing. "
|
||||
"Be concise, warm, and direct. "
|
||||
"Use plain prose — no bullet points. "
|
||||
"Maximum 300 words. "
|
||||
"End with a short paragraph listing any items that need the owner's approval, "
|
||||
"or say 'No approvals needed today.' if there are none."
|
||||
)
|
||||
|
||||
try:
|
||||
summary = self._call_agent(prompt)
|
||||
except Exception as exc:
|
||||
logger.warning("generate(): agent call raised unexpectedly: %s", exc)
|
||||
summary = (
|
||||
"Good morning. Timmy is offline right now, so this briefing "
|
||||
"could not be generated from live data. Check that Ollama is "
|
||||
"running and try again."
|
||||
)
|
||||
|
||||
# Attach any outstanding pending approval items
|
||||
approval_items = self._load_pending_items()
|
||||
|
||||
briefing = Briefing(
|
||||
generated_at=now,
|
||||
summary=summary,
|
||||
approval_items=approval_items,
|
||||
period_start=period_start,
|
||||
period_end=now,
|
||||
)
|
||||
|
||||
_save_briefing(briefing, self._db_path)
|
||||
logger.info("Briefing generated at %s", now.isoformat())
|
||||
return briefing
|
||||
|
||||
def get_or_generate(self) -> Briefing:
|
||||
"""Return a fresh cached briefing or generate a new one."""
|
||||
cached = _load_latest(self._db_path)
|
||||
if cached is not None and is_fresh(cached):
|
||||
# Reattach live pending items (they change between page loads)
|
||||
cached.approval_items = self._load_pending_items()
|
||||
return cached
|
||||
return self.generate()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Private helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _call_agent(self, prompt: str) -> str:
|
||||
"""Call Timmy's Agno agent and return the response text."""
|
||||
try:
|
||||
from timmy.agent import create_timmy
|
||||
agent = create_timmy()
|
||||
run = agent.run(prompt, stream=False)
|
||||
return run.content if hasattr(run, "content") else str(run)
|
||||
except Exception as exc:
|
||||
logger.warning("Agent call failed during briefing generation: %s", exc)
|
||||
return (
|
||||
"Good morning. Timmy is offline right now, so this briefing "
|
||||
"could not be generated from live data. Check that Ollama is "
|
||||
"running and try again."
|
||||
)
|
||||
|
||||
def _load_pending_items(self) -> list[ApprovalItem]:
|
||||
"""Return pending ApprovalItems from the approvals DB."""
|
||||
try:
|
||||
from timmy import approvals as _approvals
|
||||
raw_items = _approvals.list_pending()
|
||||
return [
|
||||
ApprovalItem(
|
||||
id=item.id,
|
||||
title=item.title,
|
||||
description=item.description,
|
||||
proposed_action=item.proposed_action,
|
||||
impact=item.impact,
|
||||
created_at=item.created_at,
|
||||
status=item.status,
|
||||
)
|
||||
for item in raw_items
|
||||
]
|
||||
except Exception as exc:
|
||||
logger.debug("Could not load approval items: %s", exc)
|
||||
return []
|
||||
|
||||
|
||||
# Module-level singleton
|
||||
engine = BriefingEngine()
|
||||
Reference in New Issue
Block a user