feat: cron jobs — daily summary, speaker clustering, clip purge
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user