Cap 6: Historial y Contexto de Conversación

Por: Artiko
historialsqlitecontextosesionestelegramclaude

El problema del historial en memoria

En el capítulo 4 guardamos el historial en un dict de Python. Esto tiene problemas:

La solución: SQLite para persistencia ligera sin servidor de base de datos.

storage/history.py con SQLite

import sqlite3
import json
from pathlib import Path
from datetime import datetime, timedelta

DB_PATH = Path("bot_history.db")
MAX_MESSAGES_PER_USER = 30
SESSION_TIMEOUT_HOURS = 24  # reset automático si inactivo por 24h

def _get_conn() -> sqlite3.Connection:
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    return conn

def init_db():
    with _get_conn() as conn:
        conn.execute("""
            CREATE TABLE IF NOT EXISTS messages (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                user_id INTEGER NOT NULL,
                role TEXT NOT NULL,
                content TEXT NOT NULL,
                created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
            )
        """)
        conn.execute("""
            CREATE INDEX IF NOT EXISTS idx_user_id ON messages(user_id, created_at)
        """)
        conn.execute("""
            CREATE TABLE IF NOT EXISTS user_settings (
                user_id INTEGER PRIMARY KEY,
                system_prompt TEXT,
                model TEXT,
                last_active TIMESTAMP DEFAULT CURRENT_TIMESTAMP
            )
        """)

def get_history(user_id: int) -> list[dict]:
    """Retorna historial reciente del usuario, respetando timeout de sesión."""
    cutoff = datetime.utcnow() - timedelta(hours=SESSION_TIMEOUT_HOURS)

    with _get_conn() as conn:
        rows = conn.execute("""
            SELECT role, content FROM messages
            WHERE user_id = ? AND created_at > ?
            ORDER BY created_at DESC
            LIMIT ?
        """, (user_id, cutoff.isoformat(), MAX_MESSAGES_PER_USER)).fetchall()

    # Retornar en orden cronológico (de más antiguo a más reciente)
    return [{"role": r["role"], "content": r["content"]} for r in reversed(rows)]

def add_message(user_id: int, role: str, content: str):
    with _get_conn() as conn:
        conn.execute(
            "INSERT INTO messages (user_id, role, content) VALUES (?, ?, ?)",
            (user_id, role, content)
        )
        conn.execute("""
            INSERT INTO user_settings (user_id, last_active)
            VALUES (?, CURRENT_TIMESTAMP)
            ON CONFLICT(user_id) DO UPDATE SET last_active = CURRENT_TIMESTAMP
        """, (user_id,))

def clear_history(user_id: int):
    with _get_conn() as conn:
        conn.execute("DELETE FROM messages WHERE user_id = ?", (user_id,))

def get_stats(user_id: int) -> dict:
    with _get_conn() as conn:
        total = conn.execute(
            "SELECT COUNT(*) as n FROM messages WHERE user_id = ?", (user_id,)
        ).fetchone()["n"]
        last = conn.execute(
            "SELECT MAX(created_at) as t FROM messages WHERE user_id = ?", (user_id,)
        ).fetchone()["t"]
    return {"total_messages": total, "last_active": last}

Inicializar la DB al arrancar el bot:

# bot.py
from storage.history import init_db

def main():
    init_db()  # crear tablas si no existen
    app = Application.builder().token(TELEGRAM_TOKEN).build()
    # ...

Gestión de la ventana de contexto

Claude tiene un límite de tokens. Con mensajes largos, el historial puede exceder el límite. Estrategias:

1. Límite de mensajes (ya implementado)

LIMIT ? # últimos N mensajes

2. Resumen automático cuando el historial crece

from services.claude_api import ask_claude

async def resumir_historial(user_id: int) -> str:
    """Condensa el historial en un resumen para ahorrar tokens."""
    history = get_history(user_id)
    if len(history) < 10:
        return ""

    prompt_resumen = [
        {"role": "user", "content":
            f"Resume esta conversación en 3-5 puntos clave, "
            f"preservando contexto técnico importante:\n\n"
            + "\n".join(f"{m['role']}: {m['content'][:200]}" for m in history)
        }
    ]

    resumen = ask_claude(prompt_resumen)
    # Reemplazar historial con el resumen
    clear_history(user_id)
    add_message(user_id, "user", "[Resumen de conversación previa]")
    add_message(user_id, "assistant", resumen)
    return resumen

System prompts por usuario

Permite que cada usuario personalice el comportamiento de Claude:

# storage/history.py (agregar)

def get_user_system_prompt(user_id: int) -> str | None:
    with _get_conn() as conn:
        row = conn.execute(
            "SELECT system_prompt FROM user_settings WHERE user_id = ?", (user_id,)
        ).fetchone()
    return row["system_prompt"] if row else None

def set_user_system_prompt(user_id: int, prompt: str):
    with _get_conn() as conn:
        conn.execute("""
            INSERT INTO user_settings (user_id, system_prompt)
            VALUES (?, ?)
            ON CONFLICT(user_id) DO UPDATE SET system_prompt = ?
        """, (user_id, prompt, prompt))
# handlers/commands.py

async def cmd_persona(update: Update, context: ContextTypes.DEFAULT_TYPE):
    """
    /persona <descripción> — personalizar el comportamiento de Claude
    Ejemplo: /persona Responde siempre en inglés y sé muy conciso
    """
    if not context.args:
        current = get_user_system_prompt(update.effective_user.id)
        await update.message.reply_text(
            f"System prompt actual:\n_{current or 'Por defecto'}_\n\n"
            "Uso: /persona <nueva descripción>",
            parse_mode="Markdown"
        )
        return

    prompt = " ".join(context.args)
    set_user_system_prompt(update.effective_user.id, prompt)
    await update.message.reply_text(f"Personalidad actualizada: _{prompt}_", parse_mode="Markdown")

Actualizar claude_api.py para usar el system prompt del usuario:

from storage.history import get_user_system_prompt

DEFAULT_SYSTEM = "Eres un asistente de programación experto..."

def ask_claude(messages: list[dict], user_id: int | None = None) -> str:
    system = DEFAULT_SYSTEM
    if user_id:
        custom = get_user_system_prompt(user_id)
        if custom:
            system = f"{DEFAULT_SYSTEM}\n\nInstrucciones adicionales: {custom}"

    response = client.messages.create(
        model=CLAUDE_MODEL,
        max_tokens=2048,
        system=system,
        messages=messages,
    )
    return response.content[0].text

Comando /stats

async def cmd_stats(update: Update, context: ContextTypes.DEFAULT_TYPE):
    """Muestra estadísticas del usuario."""
    user_id = update.effective_user.id
    stats = get_stats(user_id)
    history = get_history(user_id)

    await update.message.reply_text(
        f"*Tus estadísticas*\n\n"
        f"Mensajes totales: {stats['total_messages']}\n"
        f"En contexto actual: {len(history)}\n"
        f"Último activo: {stats['last_active'] or 'nunca'}\n",
        parse_mode="Markdown"
    )

Limpieza automática

Purga mensajes viejos para evitar que la DB crezca indefinidamente:

# storage/history.py

def purge_old_messages(days: int = 30):
    """Elimina mensajes más viejos que N días."""
    cutoff = (datetime.utcnow() - timedelta(days=days)).isoformat()
    with _get_conn() as conn:
        deleted = conn.execute(
            "DELETE FROM messages WHERE created_at < ?", (cutoff,)
        ).rowcount
    return deleted
# bot.py — job periódico

async def purge_job(context):
    n = purge_old_messages(days=30)
    logging.info(f"Purge: {n} mensajes eliminados")

app.job_queue.run_repeating(purge_job, interval=86400, first=10)  # cada 24h

Flujo de sesión completo

sequenceDiagram
    participant U as Usuario
    participant BOT as Bot
    participant DB as SQLite
    participant CL as Claude

    U->>BOT: mensaje
    BOT->>DB: get_history(user_id)
    Note over DB: últimos 30 msgs, últimas 24h
    DB-->>BOT: historial

    BOT->>CL: historial + nuevo mensaje
    CL-->>BOT: respuesta

    BOT->>DB: add_message(user, texto)
    BOT->>DB: add_message(assistant, respuesta)
    BOT->>U: respuesta