Open curriculum · verified editionContribute on GitHub
Lab20 min19 words

Lab 03 — Semantic cache with isolation, versioning, TTL, and a calibrable threshold

Localized walkthrough. Run the canonical public lab locally and preserve the result as evidence.

SourceImprove this page

Localized walkthrough. Run the canonical public lab locally and preserve the result as evidence.

Run it#

python modulo-05-llmops/labs/03_cache_semantico.py

Localized source walkthrough#

"""Lab 03 — Semantic cache with isolation, versioning, TTL, and a calibrable threshold.

Local lexical similarity makes the lab reproducible; in production, this is replaced by embeddings.
Sensitive requests are never cached, and the logical key includes the tenant, model,
prompt, and corpus version.

Execution:
    python modulo-05-llmops/labs/03_cache_semantico.py
"""

from __future__ import annotations

import argparse
import math
import re
import time
from dataclasses import dataclass
from hashlib import sha256

from rich.console import Console
from rich.table import Table

console = Console()
WORD_RE = re.compile(r"[a-z0-9áéíóúüñ]+", re.IGNORECASE)
STOPWORDS = {
    "a", "al", "cómo", "cuánto", "de", "del", "durante", "el", "es", "la", "las",
    "los", "o", "para", "por", "qué", "se", "son", "tiempo", "un", "una", "y",
}


def tokens(text: str) -> set[str]:
    return {
        normalized
        for word in WORD_RE.findall(text)
        if (normalized := word.casefold()) not in STOPWORDS
    }


def cosine_binary(left: str, right: str) -> float:
    a, b = tokens(left), tokens(right)
    return len(a & b) / math.sqrt(max(1, len(a)) * max(1, len(b)))


@dataclass(frozen=True)
class CacheScope:
    tenant: str
    model: str
    prompt_version: str
    corpus_version: str

    @property
    def key(self) -> str:
        raw = f"{self.tenant}\0{self.model}\0{self.prompt_version}\0{self.corpus_version}"
        return sha256(raw.encode()).hexdigest()


@dataclass
class CacheEntry:
    scope_key: str
    question: str
    answer: str
    expires_at: float


class SemanticCache:
    def __init__(self, threshold: float = 0.72) -> None:
        if not 0 < threshold <= 1:
            raise ValueError("threshold debe estar en (0, 1]")
        self.threshold = threshold
        self.entries: list[CacheEntry] = []

    def get(self, scope: CacheScope, question: str) -> tuple[str | None, float]:
        now = time.time()
        self.entries = [entry for entry in self.entries if entry.expires_at > now]
        candidates = [entry for entry in self.entries if entry.scope_key == scope.key]
        if not candidates:
            return None, 0.0
        scored = [(cosine_binary(question, entry.question), entry) for entry in candidates]
        score, entry = max(scored, key=lambda item: item[0])
        return (entry.answer, score) if score >= self.threshold else (None, score)

    def put(
        self,
        scope: CacheScope,
        question: str,
        answer: str,
        *,
        ttl_seconds: int,
        sensitive: bool,
    ) -> bool:
        if sensitive:
            return False
        if not 1 <= ttl_seconds <= 86_400:
            raise ValueError("ttl_seconds debe estar entre 1 y 86400")
        self.entries.append(
            CacheEntry(scope.key, question.strip(), answer.strip(), time.time() + ttl_seconds)
        )
        return True


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--threshold", type=float, default=0.72)
    return parser.parse_args()


def main() -> int:
    args = parse_args()
    cache = SemanticCache(args.threshold)
    scope = CacheScope("tenant-a", "gpt-5.6-luna", "support-v3", "corpus-2026-08")
    cache.put(
        scope,
        "¿Cuánto dura el enlace para recuperar la contraseña?",
        "El enlace dura 30 minutos.",
        ttl_seconds=300,
        sensitive=False,
    )
    cases = [
        ("paráfrasis", scope, "¿Durante cuánto tiempo es válido el enlace de recuperar contraseña?"),
        ("distinta", scope, "¿Cómo cambio la dirección de facturación?"),
        (
            "otro tenant",
            CacheScope("tenant-b", scope.model, scope.prompt_version, scope.corpus_version),
            "¿Cuánto dura el enlace para recuperar la contraseña?",
        ),
    ]
    table = Table(title=f"Cache semántico · threshold={args.threshold}")
    table.add_column("caso")
    table.add_column("hit")
    table.add_column("score")
    for label, case_scope, question in cases:
        answer, score = cache.get(case_scope, question)
        table.add_row(label, "sí" if answer else "no", f"{score:.3f}")
    console.print(table)
    stored = cache.put(
        scope,
        "Mi tarjeta termina en 4242, ¿por qué falló?",
        "Revisa el estado del método de pago.",
        ttl_seconds=300,
        sensitive=True,
    )
    console.print(f"[dim]Entrada sensible almacenada: {stored}[/dim]")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())