La retención iba por la edad de la SESIÓN y cascadeaba
`DELETE FROM outputs WHERE session_id = ?`, así que un output generado ayer
sobre una sesión de julio moría en el siguiente arranque del bot.
Pasó hoy, 2026-09-01, al desplegar 198b0e62:
Startup purge done sessions=12 outputs=27 chunks=2030 sources=3466
api_usage=1118 shorts=6
El pod llevaba 18 días sin reiniciarse, así que tres semanas de material
cumplieron los 30 días de golpe. Y lo que se llevó por delante no fue lo viejo:
los outputs 132-139 tenían 18,8 días —los tres Shorts re-renderizados el día
antes entre ellos— pero colgaban de las sesiones 161-165, de hace 40. Murieron
por la edad de su madre mientras 128-131, más antiguos, sobrevivían.
Ahora la purga va en dos fases:
1. Outputs por SU propia fecha, vivan en la sesión que vivan.
2. Sesiones viejas que ya no sostienen ningún output.
El orden importa: la sesión cuyos outputs eran todos viejos se queda sin
ninguno en la fase 1 y resulta purgable en la fase 2, así que el caso normal
—sesión vieja, material viejo— sigue limpiándose entero en UNA pasada. Hay un
test que lo fija, y es el único de los cuatro nuevos que pasa también con la
lógica anterior: está para probar que no se rompió lo que funcionaba.
Una sesión con un output vivo sobrevive entera, con sus sources y sus chunks.
No es generosidad: los chunks son contra lo que se comprueba el fundamento de
ese output, y conservar el spec tirando aquello con lo que se verifica deja
algo que ya no se puede auditar. El precio es que la retención afloja, y se
paga a sabiendas.
Dos cosas más que salieron al mirarlo:
- `_purge_on_startup` sólo escribía en el log `if result["sessions"] > 0`. Con
la retención por output, una pasada puede borrar 27 outputs y CERO sesiones,
y eso no habría dejado ni una línea. Una purga silenciosa es como se
descubre tres semanas tarde. Ahora informa si borró cualquier cosa.
- El MP4 se llama por sesión, así que cuando la fase 1 se lleva el último
short_en de una sesión que sigue viva, el fichero queda sin nada que lo
nombre. Se borra ahí también, o el PVC acumula vídeos que no aparecen en
ninguna fila.
`purge_old_sessions` pasa a llamarse `purge_old_data`: ya no purga sólo por
sesiones y el nombre viejo describía justo el defecto.
La BD anterior a la purga está a salvo y verificada en
~/rescates/researchowl-purga-2026-09-01 (integrity_check ok, 31 outputs, los
17 short_en). Con esta lógica, un restore conserva 16 de los 17.
Suite: 278 pasan.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
747 lines
29 KiB
Python
747 lines
29 KiB
Python
import asyncio
|
|
import aiosqlite
|
|
import json
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
from enum import Enum
|
|
|
|
import structlog
|
|
|
|
from src.config import settings
|
|
|
|
logger = structlog.get_logger()
|
|
|
|
|
|
class ResearchStatus(str, Enum):
|
|
RUNNING = "running"
|
|
SATURATED = "saturated"
|
|
FINISHED = "finished"
|
|
ERROR = "error"
|
|
INTERRUPTED = "interrupted" # el pod se reinició con el research en marcha
|
|
|
|
|
|
class OutputType(str, Enum):
|
|
PODCAST = "podcast"
|
|
BLOG = "blog"
|
|
REPORT = "report"
|
|
THREAD = "thread"
|
|
REPORT_EXTENDED = "report_extended"
|
|
BLOG_EXTENDED = "blog_extended"
|
|
PODCAST_EXTENDED = "podcast_extended"
|
|
# El contenido de un short_en NO es prosa: es el shot spec (JSON) que
|
|
# shortsmith convierte en vídeo. El MP4 vive en disco, nunca en SQLite.
|
|
SHORT_EN = "short_en"
|
|
|
|
|
|
SCHEMA = """
|
|
CREATE TABLE IF NOT EXISTS research_sessions (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
topic TEXT NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'running',
|
|
telegram_chat_id INTEGER NOT NULL,
|
|
telegram_message_id INTEGER,
|
|
created_at REAL NOT NULL,
|
|
updated_at REAL NOT NULL,
|
|
iterations INTEGER DEFAULT 0,
|
|
total_sources INTEGER DEFAULT 0,
|
|
total_chunks INTEGER DEFAULT 0,
|
|
total_words INTEGER DEFAULT 0,
|
|
meta JSON DEFAULT '{}'
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS sources (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
session_id INTEGER NOT NULL REFERENCES research_sessions(id),
|
|
url TEXT NOT NULL,
|
|
title TEXT,
|
|
source_type TEXT, -- wikipedia, reddit, youtube, pdf, web, rss
|
|
depth INTEGER DEFAULT 0,
|
|
quality_score REAL DEFAULT 0,
|
|
word_count INTEGER DEFAULT 0,
|
|
scraped_at REAL,
|
|
status TEXT DEFAULT 'pending', -- pending, scraped, failed, skipped
|
|
error TEXT,
|
|
UNIQUE(session_id, url)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS chunks (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
session_id INTEGER NOT NULL REFERENCES research_sessions(id),
|
|
source_id INTEGER NOT NULL REFERENCES sources(id),
|
|
content TEXT NOT NULL,
|
|
chunk_index INTEGER NOT NULL,
|
|
token_count INTEGER,
|
|
quality_score REAL DEFAULT 0,
|
|
embedding JSON, -- stored as JSON array for sqlite-vec compat
|
|
created_at REAL NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS outputs (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
session_id INTEGER NOT NULL REFERENCES research_sessions(id),
|
|
output_type TEXT NOT NULL,
|
|
content TEXT NOT NULL,
|
|
created_at REAL NOT NULL,
|
|
published_url TEXT -- URL del artículo publicado (blog -> Ghost)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS source_contents (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
source_id INTEGER NOT NULL UNIQUE REFERENCES sources(id),
|
|
content TEXT NOT NULL,
|
|
created_at REAL NOT NULL
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_sources_session ON sources(session_id);
|
|
CREATE INDEX IF NOT EXISTS idx_chunks_session ON chunks(session_id);
|
|
CREATE INDEX IF NOT EXISTS idx_chunks_quality ON chunks(session_id, quality_score DESC);
|
|
CREATE INDEX IF NOT EXISTS idx_source_contents ON source_contents(source_id);
|
|
|
|
CREATE TABLE IF NOT EXISTS api_usage (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
session_id INTEGER REFERENCES research_sessions(id),
|
|
call_type TEXT NOT NULL,
|
|
model TEXT NOT NULL,
|
|
input_tokens INTEGER NOT NULL,
|
|
output_tokens INTEGER NOT NULL,
|
|
cost_usd REAL NOT NULL,
|
|
created_at REAL NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS watched_topics (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
topic TEXT NOT NULL,
|
|
chat_id INTEGER NOT NULL,
|
|
interval_hours INTEGER NOT NULL DEFAULT 24,
|
|
next_run_at REAL NOT NULL,
|
|
last_run_at REAL,
|
|
enabled INTEGER NOT NULL DEFAULT 1,
|
|
created_at REAL NOT NULL,
|
|
UNIQUE(topic, chat_id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS news_seen (
|
|
id INTEGER PRIMARY KEY,
|
|
source TEXT NOT NULL,
|
|
guid TEXT NOT NULL,
|
|
title TEXT,
|
|
link TEXT,
|
|
published_at TEXT,
|
|
matched_keywords TEXT,
|
|
seen_at TEXT DEFAULT (datetime('now')),
|
|
notified INTEGER DEFAULT 0,
|
|
UNIQUE(source, guid)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_news_seen_source ON news_seen(source);
|
|
CREATE INDEX IF NOT EXISTS idx_news_seen_seen ON news_seen(seen_at);
|
|
"""
|
|
|
|
|
|
# Conexión única compartida por proceso. aiosqlite serializa todas las
|
|
# operaciones en el hilo de la conexión, así que compartirla entre coroutines
|
|
# es seguro y además evita la contención del lock de escritura a nivel de
|
|
# fichero que sufrían las conexiones múltiples (scheduler, /compare).
|
|
_shared_conn: Optional[aiosqlite.Connection] = None
|
|
_init_lock = asyncio.Lock()
|
|
|
|
|
|
class _SharedConnection:
|
|
"""Proxy sobre la conexión compartida cuyo close() es no-op.
|
|
|
|
Permite que los handlers sigan usando el patrón `db = await get_db()` …
|
|
`finally: await db.close()` sin cambios, mientras por debajo se reutiliza
|
|
una sola conexión real durante toda la vida del proceso.
|
|
"""
|
|
def __init__(self, conn: aiosqlite.Connection):
|
|
self._conn = conn
|
|
|
|
def __getattr__(self, name):
|
|
return getattr(self._conn, name)
|
|
|
|
async def close(self):
|
|
# No cerrar: la conexión es compartida por todo el proceso.
|
|
pass
|
|
|
|
|
|
async def _init_shared() -> aiosqlite.Connection:
|
|
global _shared_conn
|
|
if _shared_conn is None:
|
|
async with _init_lock:
|
|
if _shared_conn is None: # doble check tras adquirir el lock
|
|
Path(settings.db_path).parent.mkdir(parents=True, exist_ok=True)
|
|
conn = await aiosqlite.connect(settings.db_path)
|
|
conn.row_factory = aiosqlite.Row
|
|
await conn.execute("PRAGMA journal_mode=WAL")
|
|
await conn.execute("PRAGMA synchronous=NORMAL")
|
|
# Espera hasta 5s si OTRO proceso tiene el lock de escritura
|
|
# antes de fallar con "database is locked".
|
|
await conn.execute("PRAGMA busy_timeout=5000")
|
|
await conn.executescript(SCHEMA)
|
|
await _ensure_columns(conn)
|
|
await conn.commit()
|
|
_shared_conn = conn
|
|
logger.info("Shared DB connection initialized", path=settings.db_path)
|
|
return _shared_conn
|
|
|
|
|
|
#: Columnas añadidas después de que la tabla existiera en producción. El
|
|
#: `CREATE TABLE IF NOT EXISTS` no toca una tabla ya creada, así que una
|
|
#: columna nueva necesita su ALTER — guardado por PRAGMA table_info para que
|
|
#: sea idempotente. No es una migración: no hay versiones ni orden, sólo
|
|
#: "¿existe la columna? si no, créala".
|
|
_ADDED_COLUMNS: dict[str, dict[str, str]] = {
|
|
"outputs": {"published_url": "TEXT"},
|
|
}
|
|
|
|
|
|
async def _ensure_columns(conn: aiosqlite.Connection) -> None:
|
|
for table, columns in _ADDED_COLUMNS.items():
|
|
async with conn.execute(f"PRAGMA table_info({table})") as cur:
|
|
existing = {row[1] for row in await cur.fetchall()}
|
|
for name, decl in columns.items():
|
|
if name not in existing:
|
|
await conn.execute(f"ALTER TABLE {table} ADD COLUMN {name} {decl}")
|
|
logger.info("Columna añadida", table=table, column=name)
|
|
|
|
|
|
async def get_db() -> aiosqlite.Connection:
|
|
conn = await _init_shared()
|
|
return _SharedConnection(conn)
|
|
|
|
|
|
async def close_db() -> None:
|
|
"""Cierra la conexión compartida (checkpoint WAL). Llamar al apagar el bot."""
|
|
global _shared_conn
|
|
if _shared_conn is not None:
|
|
try:
|
|
await _shared_conn.close()
|
|
finally:
|
|
_shared_conn = None
|
|
|
|
|
|
class ResearchDB:
|
|
def __init__(self, db: aiosqlite.Connection):
|
|
self.db = db
|
|
|
|
# --- Sessions ---
|
|
|
|
async def create_session(self, topic: str, chat_id: int) -> int:
|
|
now = time.time()
|
|
cursor = await self.db.execute(
|
|
"""INSERT INTO research_sessions (topic, status, telegram_chat_id, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?)""",
|
|
(topic, ResearchStatus.RUNNING, chat_id, now, now)
|
|
)
|
|
await self.db.commit()
|
|
return cursor.lastrowid
|
|
|
|
async def get_session(self, session_id: int) -> Optional[dict]:
|
|
cursor = await self.db.execute(
|
|
"SELECT * FROM research_sessions WHERE id = ?", (session_id,)
|
|
)
|
|
row = await cursor.fetchone()
|
|
return dict(row) if row else None
|
|
|
|
async def get_latest_session(self, chat_id: int) -> Optional[dict]:
|
|
cursor = await self.db.execute(
|
|
"SELECT * FROM research_sessions WHERE telegram_chat_id = ? ORDER BY created_at DESC LIMIT 1",
|
|
(chat_id,)
|
|
)
|
|
row = await cursor.fetchone()
|
|
return dict(row) if row else None
|
|
|
|
async def get_session_urls(self, session_id: int) -> set:
|
|
async with self.db.execute(
|
|
"SELECT url FROM sources WHERE session_id = ?", (session_id,)
|
|
) as cur:
|
|
rows = await cur.fetchall()
|
|
return {r[0] for r in rows}
|
|
|
|
async def get_previous_session(self, chat_id: int, topic: str,
|
|
exclude_session_id: int) -> Optional[dict]:
|
|
async with self.db.execute(
|
|
"""SELECT id, topic, status, created_at FROM research_sessions
|
|
WHERE telegram_chat_id = ? AND topic = ? AND id != ?
|
|
ORDER BY created_at DESC LIMIT 1""",
|
|
(chat_id, topic, exclude_session_id)
|
|
) as cur:
|
|
row = await cur.fetchone()
|
|
if not row:
|
|
return None
|
|
return {"id": row[0], "topic": row[1],
|
|
"status": row[2], "created_at": row[3]}
|
|
|
|
async def get_active_session(self, chat_id: int) -> Optional[dict]:
|
|
cursor = await self.db.execute(
|
|
"""SELECT * FROM research_sessions
|
|
WHERE telegram_chat_id = ? AND status = 'running'
|
|
ORDER BY created_at DESC LIMIT 1""",
|
|
(chat_id,)
|
|
)
|
|
row = await cursor.fetchone()
|
|
return dict(row) if row else None
|
|
|
|
async def update_session(self, session_id: int, **kwargs):
|
|
kwargs["updated_at"] = time.time()
|
|
sets = ", ".join(f"{k} = ?" for k in kwargs)
|
|
values = list(kwargs.values()) + [session_id]
|
|
await self.db.execute(
|
|
f"UPDATE research_sessions SET {sets} WHERE id = ?", values
|
|
)
|
|
await self.db.commit()
|
|
|
|
async def get_session_stats(self, session_id: int) -> dict:
|
|
cursor = await self.db.execute(
|
|
"""SELECT
|
|
COUNT(*) as total,
|
|
SUM(CASE WHEN status='scraped' THEN 1 ELSE 0 END) as scraped,
|
|
SUM(CASE WHEN status='failed' THEN 1 ELSE 0 END) as failed,
|
|
SUM(CASE WHEN status='pending' THEN 1 ELSE 0 END) as pending,
|
|
SUM(CASE WHEN status='skipped' THEN 1 ELSE 0 END) as skipped
|
|
FROM sources WHERE session_id = ?""",
|
|
(session_id,)
|
|
)
|
|
row = await cursor.fetchone()
|
|
return dict(row) if row else {}
|
|
|
|
# --- Sources ---
|
|
|
|
async def add_source(self, session_id: int, url: str, source_type: str,
|
|
depth: int = 0, title: str = None) -> Optional[int]:
|
|
try:
|
|
cursor = await self.db.execute(
|
|
"""INSERT OR IGNORE INTO sources (session_id, url, title, source_type, depth)
|
|
VALUES (?, ?, ?, ?, ?)""",
|
|
(session_id, url, title, source_type, depth)
|
|
)
|
|
await self.db.commit()
|
|
return cursor.lastrowid if cursor.rowcount > 0 else None
|
|
except Exception:
|
|
return None
|
|
|
|
async def update_source(self, source_id: int, **kwargs):
|
|
sets = ", ".join(f"{k} = ?" for k in kwargs)
|
|
values = list(kwargs.values()) + [source_id]
|
|
await self.db.execute(f"UPDATE sources SET {sets} WHERE id = ?", values)
|
|
await self.db.commit()
|
|
|
|
async def get_pending_sources(self, session_id: int, limit: int = 10) -> list[dict]:
|
|
cursor = await self.db.execute(
|
|
"""SELECT * FROM sources WHERE session_id = ? AND status = 'pending'
|
|
ORDER BY depth ASC, id ASC LIMIT ?""",
|
|
(session_id, limit)
|
|
)
|
|
rows = await cursor.fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
async def get_all_sources(self, session_id: int) -> list[dict]:
|
|
cursor = await self.db.execute(
|
|
"SELECT * FROM sources WHERE session_id = ? ORDER BY quality_score DESC",
|
|
(session_id,)
|
|
)
|
|
rows = await cursor.fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
async def source_exists(self, session_id: int, url: str) -> bool:
|
|
cursor = await self.db.execute(
|
|
"SELECT 1 FROM sources WHERE session_id = ? AND url = ?",
|
|
(session_id, url)
|
|
)
|
|
return await cursor.fetchone() is not None
|
|
|
|
# --- Chunks ---
|
|
|
|
async def add_chunk(self, session_id: int, source_id: int, content: str,
|
|
chunk_index: int, token_count: int, quality_score: float,
|
|
embedding: Optional[list] = None) -> int:
|
|
cursor = await self.db.execute(
|
|
"""INSERT INTO chunks (session_id, source_id, content, chunk_index,
|
|
token_count, quality_score, embedding, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
(session_id, source_id, content, chunk_index,
|
|
token_count, quality_score,
|
|
json.dumps(embedding) if embedding else None,
|
|
time.time())
|
|
)
|
|
await self.db.commit()
|
|
return cursor.lastrowid
|
|
|
|
async def get_top_chunks(self, session_id: int, limit: int = 50) -> list[dict]:
|
|
cursor = await self.db.execute(
|
|
"""SELECT c.*, s.url, s.title, s.source_type FROM chunks c
|
|
JOIN sources s ON c.source_id = s.id
|
|
WHERE c.session_id = ? AND c.quality_score >= ?
|
|
ORDER BY c.quality_score DESC LIMIT ?""",
|
|
(session_id, settings.quality_threshold, limit)
|
|
)
|
|
rows = await cursor.fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
async def get_chunks_count(self, session_id: int) -> int:
|
|
cursor = await self.db.execute(
|
|
"SELECT COUNT(*) FROM chunks WHERE session_id = ?", (session_id,)
|
|
)
|
|
row = await cursor.fetchone()
|
|
return row[0]
|
|
|
|
# --- Outputs ---
|
|
|
|
async def save_output(self, session_id: int, output_type: str, content: str) -> int:
|
|
cursor = await self.db.execute(
|
|
"INSERT INTO outputs (session_id, output_type, content, created_at) VALUES (?, ?, ?, ?)",
|
|
(session_id, output_type, content, time.time())
|
|
)
|
|
await self.db.commit()
|
|
return cursor.lastrowid
|
|
|
|
async def save_source_content(self, source_id: int, content: str):
|
|
await self.db.execute(
|
|
"""INSERT OR REPLACE INTO source_contents (source_id, content, created_at)
|
|
VALUES (?, ?, ?)""",
|
|
(source_id, content, time.time())
|
|
)
|
|
await self.db.commit()
|
|
|
|
async def get_source_content(self, source_id: int) -> Optional[str]:
|
|
cursor = await self.db.execute(
|
|
"SELECT content FROM source_contents WHERE source_id = ?", (source_id,)
|
|
)
|
|
row = await cursor.fetchone()
|
|
return row[0] if row else None
|
|
|
|
async def get_cached_content(self, url: str,
|
|
max_age_days: int = 7) -> Optional[str]:
|
|
threshold = time.time() - (max_age_days * 86400)
|
|
async with self.db.execute(
|
|
"""SELECT sc.content FROM source_contents sc
|
|
JOIN sources s ON s.id = sc.source_id
|
|
WHERE s.url = ? AND sc.created_at > ?
|
|
ORDER BY sc.created_at DESC LIMIT 1""",
|
|
(url, threshold)
|
|
) as cur:
|
|
row = await cur.fetchone()
|
|
return row[0] if row else None
|
|
|
|
async def set_output_url(self, output_id: int, url: str) -> None:
|
|
"""Guarda la URL pública del artículo en su fila de `outputs`.
|
|
|
|
Hace falta porque el Short enlaza al artículo en su descripción, y hasta
|
|
ahora la URL sólo viajaba en el aviso de Telegram: se perdía en cuanto
|
|
se cerraba la conversación.
|
|
"""
|
|
await self.db.execute(
|
|
"UPDATE outputs SET published_url = ? WHERE id = ?", (url, output_id))
|
|
await self.db.commit()
|
|
|
|
async def get_latest_output(self, session_id: int,
|
|
output_type: Optional[str] = None) -> Optional[dict]:
|
|
query = "SELECT * FROM outputs WHERE session_id = ?"
|
|
params: list = [session_id]
|
|
if output_type:
|
|
query += " AND output_type = ?"
|
|
params.append(output_type)
|
|
query += " ORDER BY created_at DESC LIMIT 1"
|
|
cursor = await self.db.execute(query, params)
|
|
row = await cursor.fetchone()
|
|
return dict(row) if row else None
|
|
|
|
async def get_article_url(self, session_id: int) -> Optional[str]:
|
|
"""La URL del artículo publicado más reciente de la sesión, si la hay.
|
|
|
|
Excluye las filas `short_en`: su `published_url` es la del vídeo en
|
|
YouTube, no la de un artículo. Sin este filtro, subir un Short y volver
|
|
a generar otro haría que el segundo enlazara al primero — un bucle
|
|
silencioso, porque la URL es válida y nadie la miraría dos veces.
|
|
"""
|
|
cursor = await self.db.execute(
|
|
"""SELECT published_url FROM outputs
|
|
WHERE session_id = ? AND published_url IS NOT NULL AND published_url != ''
|
|
AND output_type NOT IN (?)
|
|
ORDER BY created_at DESC LIMIT 1""",
|
|
(session_id, OutputType.SHORT_EN.value)
|
|
)
|
|
row = await cursor.fetchone()
|
|
return row[0] if row else None
|
|
|
|
async def get_outputs(self, session_id: int) -> list[dict]:
|
|
cursor = await self.db.execute(
|
|
"SELECT * FROM outputs WHERE session_id = ? ORDER BY created_at DESC",
|
|
(session_id,)
|
|
)
|
|
rows = await cursor.fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
# --- API Usage ---
|
|
|
|
# Precios Claude en USD por 1M de tokens (input, output).
|
|
# Se busca por substring del id del modelo; fallback a Haiku.
|
|
_MODEL_PRICING = {
|
|
"opus": (15.00, 75.00),
|
|
"sonnet": (3.00, 15.00),
|
|
"haiku": (0.80, 4.00),
|
|
}
|
|
|
|
@classmethod
|
|
def _price_for_model(cls, model: str) -> tuple[float, float]:
|
|
m = (model or "").lower()
|
|
for key, price in cls._MODEL_PRICING.items():
|
|
if key in m:
|
|
return price
|
|
return cls._MODEL_PRICING["haiku"]
|
|
|
|
async def log_api_call(self, session_id, call_type: str, model: str,
|
|
input_tokens: int, output_tokens: int):
|
|
in_price, out_price = self._price_for_model(model)
|
|
cost = (input_tokens * in_price + output_tokens * out_price) / 1_000_000
|
|
await self.db.execute(
|
|
"""INSERT INTO api_usage
|
|
(session_id, call_type, model, input_tokens, output_tokens, cost_usd, created_at)
|
|
VALUES (?,?,?,?,?,?,?)""",
|
|
(session_id, call_type, model, input_tokens, output_tokens, cost, time.time())
|
|
)
|
|
await self.db.commit()
|
|
|
|
async def get_usage_stats(self, session_id: int) -> list[dict]:
|
|
cursor = await self.db.execute(
|
|
"""SELECT call_type,
|
|
COUNT(*) as calls,
|
|
SUM(input_tokens + output_tokens) as total_tokens,
|
|
SUM(cost_usd) as total_cost
|
|
FROM api_usage WHERE session_id = ?
|
|
GROUP BY call_type""",
|
|
(session_id,)
|
|
)
|
|
rows = await cursor.fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
async def get_total_usage_stats(self) -> dict:
|
|
cursor = await self.db.execute(
|
|
"""SELECT COUNT(DISTINCT session_id) as sessions,
|
|
SUM(cost_usd) as total_cost
|
|
FROM api_usage"""
|
|
)
|
|
row = await cursor.fetchone()
|
|
return dict(row) if row else {"sessions": 0, "total_cost": 0}
|
|
|
|
# --- Watched Topics ---
|
|
|
|
async def add_watch(self, topic: str, chat_id: int, interval_hours: int,
|
|
next_run_at: Optional[float] = None) -> int:
|
|
now = time.time()
|
|
if next_run_at is None:
|
|
next_run_at = now + interval_hours * 3600
|
|
cursor = await self.db.execute(
|
|
"""INSERT OR REPLACE INTO watched_topics
|
|
(topic, chat_id, interval_hours, next_run_at, created_at)
|
|
VALUES (?, ?, ?, ?, ?)""",
|
|
(topic, chat_id, interval_hours, next_run_at, now)
|
|
)
|
|
await self.db.commit()
|
|
return cursor.lastrowid
|
|
|
|
async def remove_watch(self, topic: str, chat_id: int) -> bool:
|
|
cursor = await self.db.execute(
|
|
"DELETE FROM watched_topics WHERE topic = ? AND chat_id = ?",
|
|
(topic, chat_id)
|
|
)
|
|
await self.db.commit()
|
|
return cursor.rowcount > 0
|
|
|
|
async def list_watches(self, chat_id: int) -> list[dict]:
|
|
cursor = await self.db.execute(
|
|
"SELECT * FROM watched_topics WHERE chat_id = ? ORDER BY created_at ASC",
|
|
(chat_id,)
|
|
)
|
|
rows = await cursor.fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
async def get_due_watches(self) -> list[dict]:
|
|
cursor = await self.db.execute(
|
|
"SELECT * FROM watched_topics WHERE enabled = 1 AND next_run_at <= ?",
|
|
(time.time(),)
|
|
)
|
|
rows = await cursor.fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
async def update_watch_run(self, watch_id: int):
|
|
cursor = await self.db.execute(
|
|
"SELECT interval_hours FROM watched_topics WHERE id = ?", (watch_id,)
|
|
)
|
|
row = await cursor.fetchone()
|
|
if not row:
|
|
return
|
|
now = time.time()
|
|
await self.db.execute(
|
|
"UPDATE watched_topics SET last_run_at = ?, next_run_at = ? WHERE id = ?",
|
|
(now, now + row[0] * 3600, watch_id)
|
|
)
|
|
await self.db.commit()
|
|
|
|
# --- News monitor ---
|
|
|
|
async def source_seeded(self, source: str) -> bool:
|
|
"""True si ya hemos visto algún item de esta fuente (cold-start por fuente:
|
|
el primer poll siembra sin notificar)."""
|
|
cursor = await self.db.execute(
|
|
"SELECT 1 FROM news_seen WHERE source = ? LIMIT 1", (source,)
|
|
)
|
|
return await cursor.fetchone() is not None
|
|
|
|
async def record_news_item(self, source: str, guid: str, title: str,
|
|
link: str, published_at: str, matched: str,
|
|
notified: int) -> bool:
|
|
"""INSERT OR IGNORE de un item. Devuelve True solo si es una inserción
|
|
real (item nuevo); False si ya existía (UNIQUE(source, guid))."""
|
|
cursor = await self.db.execute(
|
|
"""INSERT OR IGNORE INTO news_seen
|
|
(source, guid, title, link, published_at, matched_keywords, notified)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
|
(source, guid, title, link, published_at, matched, notified)
|
|
)
|
|
await self.db.commit()
|
|
return cursor.rowcount > 0
|
|
|
|
async def mark_news_notified(self, ids: list[int]):
|
|
if not ids:
|
|
return
|
|
placeholders = ",".join("?" for _ in ids)
|
|
await self.db.execute(
|
|
f"UPDATE news_seen SET notified = 1 WHERE id IN ({placeholders})",
|
|
tuple(ids)
|
|
)
|
|
await self.db.commit()
|
|
|
|
async def get_recent_news(self, hours: int = 24) -> list[dict]:
|
|
"""Items matcheados cuya seen_at (o published_at parseable) cae en la
|
|
ventana. Orden por published_at desc, con seen_at como desempate."""
|
|
window = f"-{int(hours)} hours"
|
|
cursor = await self.db.execute(
|
|
"""SELECT * FROM news_seen
|
|
WHERE seen_at >= datetime('now', ?)
|
|
OR (published_at IS NOT NULL AND published_at != ''
|
|
AND datetime(published_at) >= datetime('now', ?))
|
|
ORDER BY published_at DESC, seen_at DESC
|
|
LIMIT 50""",
|
|
(window, window)
|
|
)
|
|
rows = await cursor.fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
async def get_unnotified(self, limit: Optional[int] = None) -> list[dict]:
|
|
"""Items aún sin notificar (notified=0), más recientes primero. El
|
|
scheduler (F2) los empuja a Telegram y luego los marca con
|
|
mark_news_notified. Aplica LIMIT solo si `limit` no es None."""
|
|
sql = (
|
|
"SELECT id, source, title, link, published_at, matched_keywords "
|
|
"FROM news_seen WHERE notified = 0 "
|
|
"ORDER BY published_at DESC NULLS LAST, seen_at DESC"
|
|
)
|
|
params: tuple = ()
|
|
if limit is not None:
|
|
sql += " LIMIT ?"
|
|
params = (int(limit),)
|
|
cursor = await self.db.execute(sql, params)
|
|
rows = await cursor.fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
# --- Maintenance ---
|
|
|
|
async def purge_old_data(self, max_age_days: int = 30) -> dict:
|
|
"""Retención en dos fases: primero los outputs por SU fecha, luego las
|
|
sesiones que ya no sostienen nada.
|
|
|
|
Antes iba todo por la edad de la sesión, y la cascada
|
|
`DELETE FROM outputs WHERE session_id = ?` se llevaba por delante lo
|
|
generado ayer si colgaba de una sesión de hace dos meses. El
|
|
2026-09-01 eso borró 27 outputs, entre ellos los tres Shorts
|
|
re-renderizados el día antes: sobrevivieron los outputs 128-131 y
|
|
murieron los 132-139, que eran **más nuevos**. Un spec no envejece con
|
|
la investigación que lo originó.
|
|
|
|
Las dos fases van en este orden por una razón: la sesión cuyos outputs
|
|
eran todos viejos se queda sin ninguno en la fase 1 y resulta purgable
|
|
en la fase 2, así que el caso normal —sesión vieja, material viejo—
|
|
sigue limpiándose igual que antes en una sola pasada.
|
|
|
|
**Y una sesión con un output vivo sobrevive entera**, con sus sources y
|
|
sus chunks. No es generosidad: los `chunks` son contra lo que se
|
|
comprueba el fundamento de ese output, así que conservar el spec y tirar
|
|
aquello con lo que se verifica deja algo que ya no se puede auditar. El
|
|
precio es que la retención afloja — una sesión de julio con un Short de
|
|
ayer mantiene vivos sus cientos de sources — y ese precio se paga a
|
|
sabiendas.
|
|
"""
|
|
await self.db.execute("PRAGMA foreign_keys = ON")
|
|
|
|
threshold = time.time() - max_age_days * 86400
|
|
counts = {"sessions": 0, "sources": 0, "chunks": 0, "outputs": 0,
|
|
"api_usage": 0, "shorts": 0}
|
|
|
|
# --- fase 1: outputs por su propia fecha, vivan donde vivan ---------
|
|
# Se apuntan las sesiones tocadas antes de borrar: si una se queda sin
|
|
# ningún short_en, su MP4 no lo referencia ya nadie.
|
|
cursor = await self.db.execute(
|
|
"SELECT DISTINCT session_id FROM outputs WHERE created_at < ?",
|
|
(threshold,)
|
|
)
|
|
touched = [row[0] for row in await cursor.fetchall()]
|
|
cur = await self.db.execute("DELETE FROM outputs WHERE created_at < ?",
|
|
(threshold,))
|
|
counts["outputs"] += cur.rowcount
|
|
|
|
for sid in touched:
|
|
cursor = await self.db.execute(
|
|
"SELECT 1 FROM outputs WHERE session_id = ? AND output_type = ?"
|
|
" LIMIT 1", (sid, "short_en")
|
|
)
|
|
if await cursor.fetchone() is None:
|
|
counts["shorts"] += self._drop_short(sid)
|
|
|
|
# --- fase 2: sesiones viejas que ya no sostienen ningún output ------
|
|
cursor = await self.db.execute(
|
|
"SELECT id FROM research_sessions WHERE created_at < ?"
|
|
" AND status != 'running'"
|
|
" AND NOT EXISTS (SELECT 1 FROM outputs WHERE session_id ="
|
|
" research_sessions.id)",
|
|
(threshold,)
|
|
)
|
|
session_ids = [row[0] for row in await cursor.fetchall()]
|
|
|
|
for sid in session_ids:
|
|
counts["shorts"] += self._drop_short(sid)
|
|
await self.db.execute(
|
|
"DELETE FROM source_contents WHERE source_id IN (SELECT id FROM sources WHERE session_id = ?)",
|
|
(sid,)
|
|
)
|
|
cur = await self.db.execute("DELETE FROM chunks WHERE session_id = ?", (sid,))
|
|
counts["chunks"] += cur.rowcount
|
|
cur = await self.db.execute("DELETE FROM api_usage WHERE session_id = ?", (sid,))
|
|
counts["api_usage"] += cur.rowcount
|
|
cur = await self.db.execute("DELETE FROM sources WHERE session_id = ?", (sid,))
|
|
counts["sources"] += cur.rowcount
|
|
cur = await self.db.execute("DELETE FROM research_sessions WHERE id = ?", (sid,))
|
|
counts["sessions"] += cur.rowcount
|
|
|
|
await self.db.commit()
|
|
logger.info("Purga por antigüedad", days=max_age_days, **counts)
|
|
return counts
|
|
|
|
def _drop_short(self, sid: int) -> int:
|
|
"""Borra el MP4 de una sesión, si queda. Devuelve 1 si borró algo.
|
|
|
|
El vídeo vive en disco (los blobs en SQLite hacen patológico el WAL),
|
|
así que su borrado no lo arrastra ninguna FK y hay que hacerlo aquí.
|
|
Best-effort: un fichero que no se puede borrar no va a impedir la purga.
|
|
"""
|
|
try:
|
|
video = Path(settings.shorts_dir) / f"{sid}.mp4"
|
|
if video.is_file():
|
|
video.unlink()
|
|
return 1
|
|
except OSError as e:
|
|
logger.warning("No se pudo borrar el Short de una sesión purgada",
|
|
session_id=sid, error=str(e))
|
|
return 0
|