feat: cron jobs — daily summary, speaker clustering, clip purge

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-28 10:49:24 -05:00
parent 59014f6fe7
commit 8a3e7e2b53
5 changed files with 249 additions and 0 deletions
+64
View File
@@ -0,0 +1,64 @@
import sqlite3
import numpy as np
from app.shared.config import Settings
from app.pipeline.recognition import unpack_embedding, cosine_similarity
def cluster_unknown_speakers(settings: Settings, db: sqlite3.Connection) -> None:
"""
Groups unknown/ambiguous utterances from the last 7 days by voice similarity.
Assigns speaker_id from an existing 'Unknown #N' speaker or creates a new one.
Threshold: 0.70 cosine similarity.
"""
CLUSTER_THRESHOLD = 0.70
rows = db.execute("""
SELECT id, embedding FROM utterances
WHERE match_status IN ('unknown', 'ambiguous')
AND embedding IS NOT NULL
AND date(start_time) >= date('now', '-7 days')
ORDER BY start_time ASC
""").fetchall()
if not rows:
return
clusters: list = []
cluster_centroids: list = []
for row in rows:
emb = unpack_embedding(row["embedding"])
best_cluster = None
best_sim = 0.0
for i, centroid in enumerate(cluster_centroids):
sim = cosine_similarity(emb, centroid)
if sim > best_sim and sim >= CLUSTER_THRESHOLD:
best_sim = sim
best_cluster = i
if best_cluster is not None:
clusters[best_cluster].append(row["id"])
n = len(clusters[best_cluster])
cluster_centroids[best_cluster] = (
(cluster_centroids[best_cluster] * (n - 1) + emb) / n
)
else:
clusters.append([row["id"]])
cluster_centroids.append(emb)
for i, cluster in enumerate(clusters):
if len(cluster) < 2:
continue
name = f"Unknown #{i + 1}"
existing = db.execute("SELECT id FROM speakers WHERE name=?", (name,)).fetchone()
if existing:
speaker_id = existing["id"]
else:
cur = db.execute("INSERT INTO speakers (name) VALUES (?)", (name,))
db.commit()
speaker_id = cur.lastrowid
for utt_id in cluster:
db.execute(
"UPDATE utterances SET speaker_id=?, match_status='ambiguous' WHERE id=?",
(speaker_id, utt_id),
)
db.commit()
+48
View File
@@ -0,0 +1,48 @@
import sqlite3
import httpx
from app.shared.config import Settings
def generate_daily_summary(date: str, settings: Settings, db: sqlite3.Connection) -> None:
rows = db.execute("""
SELECT u.start_time, u.transcript, r.name as room_name, s.name as speaker_name
FROM utterances u
JOIN rooms r ON u.room_id = r.id
LEFT JOIN speakers s ON u.speaker_id = s.id
WHERE date(u.start_time) = ?
ORDER BY u.start_time ASC
""", (date,)).fetchall()
if not rows:
return
lines = [
f"[{r['room_name']} {r['start_time']}] {r['speaker_name'] or 'Unknown'}: {r['transcript']}"
for r in rows
]
context = "\n".join(lines)
prompt = (
f"Summarize the following household conversations from {date} in 2-4 sentences. "
f"Focus on key decisions, activities, and topics discussed. Do not transcribe verbatim.\n\n"
f"{context}"
)
resp = httpx.post(
f"{settings.ollama_url}/api/chat",
json={
"model": settings.ollama_model,
"messages": [{"role": "user", "content": prompt}],
"stream": False,
},
timeout=120.0,
)
resp.raise_for_status()
summary = resp.json()["message"]["content"]
db.execute(
"INSERT INTO daily_summaries (date, summary_text) VALUES (?,?) "
"ON CONFLICT(date) DO UPDATE SET summary_text=?, generated_at=CURRENT_TIMESTAMP",
(date, summary, summary),
)
db.commit()
+35
View File
@@ -0,0 +1,35 @@
import time
from datetime import datetime, timezone, timedelta
from app.shared.config import get_settings
from app.shared.database import get_db, init_schema
from app.cron.daily_summary import generate_daily_summary
from app.cron.cluster_unknowns import cluster_unknown_speakers
from app.cron.purge_clips import purge_old_clips
def run_nightly(settings, db):
yesterday = (datetime.now(timezone.utc) - timedelta(days=1)).strftime("%Y-%m-%d")
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
generate_daily_summary(yesterday, settings, db)
cluster_unknown_speakers(settings, db)
purge_old_clips(today, settings, db)
def main():
settings = get_settings()
init_schema(settings)
db = get_db(settings)
while True:
now = datetime.now(timezone.utc)
next_midnight = now.replace(hour=0, minute=0, second=0, microsecond=0)
if next_midnight <= now:
next_midnight += timedelta(days=1)
sleep_secs = (next_midnight - now).total_seconds()
time.sleep(sleep_secs)
run_nightly(settings, db)
if __name__ == "__main__":
main()
+31
View File
@@ -0,0 +1,31 @@
import sqlite3
from datetime import datetime, timedelta
from pathlib import Path
from app.shared.config import Settings
def purge_old_clips(today: str, settings: Settings, db: sqlite3.Connection) -> None:
cutoff = datetime.strptime(today, "%Y-%m-%d") - timedelta(days=settings.clip_retention_days)
clips_root = Path(settings.clips_dir)
if not clips_root.exists():
return
for date_dir in clips_root.iterdir():
if not date_dir.is_dir():
continue
try:
dir_date = datetime.strptime(date_dir.name, "%Y-%m-%d")
except ValueError:
continue
if dir_date < cutoff:
for wav in date_dir.glob("*.wav"):
wav.unlink()
try:
date_dir.rmdir()
except OSError:
pass
cutoff_str = cutoff.strftime("%Y-%m-%d")
db.execute(
"UPDATE utterances SET audio_clip_path=NULL WHERE date(start_time) < ?",
(cutoff_str,),
)
db.commit()