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()
+71
View File
@@ -0,0 +1,71 @@
import pytest
import os
from datetime import datetime, timezone
from pathlib import Path
from unittest.mock import MagicMock
def _seed_utterances(db, room_id, count=3):
for i in range(count):
db.execute(
"INSERT INTO utterances (room_id, transcript, match_status, start_time, end_time) VALUES (?,?,?,?,?)",
(room_id, f"utterance {i}", "unknown",
"2026-05-27 10:00:00", "2026-05-27 10:00:03"),
)
db.commit()
def test_daily_summary_inserts_row(mocker, settings, db):
db.execute("INSERT INTO rooms (name) VALUES (?)", ("Kitchen",))
db.commit()
room = db.execute("SELECT id FROM rooms WHERE name='Kitchen'").fetchone()
_seed_utterances(db, room["id"])
mock_post = mocker.patch("app.cron.daily_summary.httpx.post")
mock_post.return_value.json.return_value = {"message": {"content": "Busy day."}}
mock_post.return_value.raise_for_status = MagicMock()
from app.cron.daily_summary import generate_daily_summary
generate_daily_summary("2026-05-27", settings, db)
row = db.execute("SELECT * FROM daily_summaries WHERE date='2026-05-27'").fetchone()
assert row is not None
assert row["summary_text"] == "Busy day."
def test_daily_summary_skips_if_no_utterances(mocker, settings, db):
mock_post = mocker.patch("app.cron.daily_summary.httpx.post")
from app.cron.daily_summary import generate_daily_summary
generate_daily_summary("2026-05-27", settings, db)
mock_post.assert_not_called()
def test_purge_clips_deletes_old_files(settings, db, tmp_path):
from app.cron.purge_clips import purge_old_clips
clips_dir = tmp_path / "clips"
old_dir = clips_dir / "2026-04-01"
old_dir.mkdir(parents=True)
old_file = old_dir / "1.wav"
old_file.write_bytes(b"\x00" * 100)
new_dir = clips_dir / "2026-05-27"
new_dir.mkdir(parents=True)
new_file = new_dir / "2.wav"
new_file.write_bytes(b"\x00" * 100)
# Create a settings with clips_dir pointing to tmp
from app.shared.config import Settings
settings_override = Settings(
db_path=settings.db_path,
clips_dir=str(clips_dir),
clip_retention_days=30,
speaches_url=settings.speaches_url,
ollama_url=settings.ollama_url,
livekit_api_key=settings.livekit_api_key,
livekit_api_secret=settings.livekit_api_secret,
)
purge_old_clips("2026-05-28", settings_override, db)
assert not old_file.exists()
assert new_file.exists()