Skip to content

Latest commit

 

History

206 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Citebound

Un tutor de normativa de circulación que no puede inventarse una cita

No «que procura no inventárselas»: que no puede, por construcción.
El modelo nunca escribe una referencia legal — escribe un hueco numerado, y es el código quien lo resuelve contra lo que la búsqueda trajo.


Licencia Python Tests Mutación Referencias inventadas Cita literal


La interfaz de práctica respondiendo una pregunta: el fragmento llega token a token, aparece el hueco [[REF:1]], el código lo resuelve al artículo 82 y la cita se muestra verificada, con el registro de la API al lado.

La respuesta llega token a token, aparece el hueco [[REF:1]], y el código lo resuelve al artículo 82 · ver la demo completa (35 s)


Qué resuelve · Arranque · Uso · Arquitectura · Los números · Lo que no garantiza · Stack


Qué resuelve

Un RAG convencional recupera documentos y deja que el modelo redacte la respuesta mencionando sus fuentes. El fallo vive en esa última palabra: mencionar es escribir. Nada impide que el modelo escriba «artículo 47» cuando el 47 no dice eso, y una referencia que suena bien es indistinguible de una correcta para quien la lee.

En un tutor de normativa eso no es un defecto estético: un estudiante que memoriza un artículo equivocado suspende, y no tiene forma de saber que la cita era falsa.

RAG convencionalCitebound
Quién escribe la referenciael modeloel código
Qué puede citarcualquier cosa que sepa escribirsolo lo que la búsqueda trajo
El fragmento citadolo redacta el modelose copia del BOE, literal
Si no hay respuestaimprovisase abstiene, y dice su motivo

Arranque

Python3.12, fijado — ver STACK.md
Gestoruv
Dockersolo para PostgreSQL con pgvector
ModelosOllama, en el host o en otra máquina de la red
uv sync                                    # dependencias, desde el lockfile

ollama pull qwen3.5:4b                     # generador
ollama pull qwen3-embedding:0.6b           # recuperación

export OLLAMA_URL=http://localhost:11434   # lo leen `make up` y `make warm`

make up                                    # PostgreSQL + pgvector, y espera a que esté sano
make ingest                                # indexa el corpus congelado (RD 1428/2003)
make smoke-f0                              # humo: ingesta + una cita que existe de verdad
make api    # el motor, en :8000     (CITEBOUND_API_PORT=8100 si el puerto está ocupado)
make ui     # la web, en :8080       — en otra terminal

Y abrir http://localhost:8080.

Note

Los modelos no corren dentro de Docker: el runtime vive en el host y el reordenador en proceso — decidido y medido en docs/STACK.md. El reordenador (BAAI/bge-reranker-v2-m3) se descarga solo la primera vez.

Arráncalo con make y no con uvicorn a pelo: el Makefile exporta CITEBOUND_MODELO, y sin esa variable el código pide qwen3.5:4b-mlx, otro tag que hay que descargar aparte.

Si los modelos corren en otra máquina de la red

Hacen falta las tres variables: OLLAMA_URL la leen los objetivos del Makefile, y las otras dos el código.

export OLLAMA_URL=http://192.168.1.50:11434
export OPENAI_BASE_URL=http://192.168.1.50:11434/v1
export CITEBOUND_MODELO=qwen3.5:4b

Uso

La web de práctica

Un examen tipo test, pregunta a pregunta. Cada respuesta trae el fragmento del reglamento que la sostiene con su referencia exacta, y un modelo bayesiano estima tu dominio por materia para elegir la siguiente pregunta por la que menos se puede predecir — la que más información aporta sobre lo que sabes.

La API

El motor habla HTTP y emite SSE, así que la respuesta llega mientras se escribe.

curl -N -X POST 'http://localhost:8000/ask/stream?pregunta=¿Límite%20en%20vía%20urbana?'
event: sources
data: {"fuentes": [{"n": 1, "legal_ref": "RD-1428/2003#art50.1", "titulo": "Artículo 50…"}]}

event: token
data: {"texto": "El límite genérico en vía urbana es de "}

event: citations
data: {"citas": [{"n": 1, "legal_ref": "RD-1428/2003#art50.1", "quote": "…"}]}

event: done
data: {"latencias_ms": {"ttfs": 553, "ttft": 1188}, "modelo": "qwen3.5:4b", …}

Siete eventos con contrato congelado en docs/RULES.md §2.2 y su snapshot en tests/contract/: sources, token, retract, citations, abstain, done, error.

GET /metrics expone Prometheus y GET /health el chequeo de vida. Ninguno pasa por el límite de tasa, porque el día que el sistema está saturado el recolector no puede llevarse un 429.

La CLI

uv run citebound ask "¿Cuándo hay que usar la luz antiniebla trasera?"

Important

Es el recuperador desnudo, no el producto: imprime el artículo que la búsqueda trajo, y lo dice en su última línea. La cita cerrada —con verificación literal, retractaciones y abstención— se sirve solo por POST /ask/stream.


Arquitectura


Tres decisiones sostienen la garantía, y cada una está donde está por un motivo medido.

1 · El modelo escribe huecos

Se le dan cinco artículos numerados [1]…[5] y solo puede escribir [[REF:n]] con n entre 1 y 5. No hay forma de escribir otra cosa.

Si aparece un número fuera de rango, el guardia corta en el token en que aparece, no al final: el marcador malo nunca llega a la pantalla.

2 · El fragmento se copia

El modelo señala qué parte le respalda; el texto lo copia el programa del BOE y lo coteja carácter a carácter, tras una normalización declarada.

Una cita no literal no se publica. Es un invariante, no una métrica: no tiene umbral que bajar.

3 · Callarse es una salida

Cuando lo recuperado no viene a cuento, el sistema se abstiene con su motivo.

Y se mide en los dos sentidos: callarse cuando había respuesta es un fallo, y responder cuando no la había, otro.

La búsqueda

Híbrida: vectorial (HNSW coseno sobre pgvector) y léxica (ts_rank_cd de PostgreSQL — se llama así, no BM25, porque no hay extensión BM25 instalada), fusionadas con RRF y reordenadas por un cross-encoder que corre en proceso.

Recall por canal: solo vectorial 0,745@5 y 0,926@30; solo léxico 0,250@5 y 0,787@30; híbrido con reordenador 0,792@5 y 0,963@30.

Cómo se sabe que funciona

TDD por zonas domain/ e ingest/ exigen test primero y propiedades con Hypothesis; api/ y providers/ lo prohíben y lo sustituyen por contrato y grabación
Mutación mutmut sobre las zonas de TDD obligatorio — lo único que distingue cobertura de verificación
Una sola definición de «hecho» make done MILESTONE=N devuelve 0 o 1. No hay «casi hecho»
Suite adversarial permanente chunk envenenado, fuga de prompt, fuera de dominio y homoglifos. El chunk envenenado no se borra: es el test, no un residuo
Determinismo dos corridas de make eval producen el mismo informe byte a byte
make gate-fast              # lint + tipos + tests rápidos + anti-gaming
make gate-full              # + integración + contrato + secretos
make done MILESTONE=6       # + metas, lock y mutación. La única definición de «hecho»

Los números

Medido con qwen3.5:4b sobre el golden set v2, 274 preguntas, anotado a mano por una persona —15,3 h de revisión sobre la cola de 304 casos de la que salió la v1— e índice apartado-v1.

Cada número lleva la versión de prompt con la que se midió. No todos se remidieron a la vez, y poner una sola versión para toda la tabla sería falso.

Garantizado

valor n prompt qué significa
G-HALLUC 0 207 v22 referencias inventadas. Cota superior al 95 %: ≤ 1,44 %
G-HALLUC-AMPLIO 0 2.000 v20 lo mismo sobre 2.000 preguntas sintéticas. Cota: ≤ 0,15 %
G-QUOTE-LIT 1,00 207 v22 fragmentos que están literalmente en su artículo
G-INJECT 0 13 v20 ataques de inyección obedecidos

Warning

Un cero sin su n no dice nada. Cero de 2.000 no autoriza a decir «no alucina nunca»: autoriza a decir «menos de un 0,15 %, con un 95 % de confianza». Es la diferencia entre una medida y un eslogan.

Medido, y se puede mover

valor n prompt
G-CITA-PRECISION 0,700 207 v22
G-COBERTURA 0,912 216 v22
G-FAITH-JUEZ 0,705 207 v22
G-ABST-FP · G-ABST-FN 0,088 · 0,172 216 · 58 v22
G-RECALL5 · G-RECALL30 0,792 · 0,963 216
G-TTFT p95 hasta el primer token 1.188 ms 55×3 v22
G-COLD-CACHE de un clon a la primera cita 18,7 s 1 v20

Los dos de recall no llevan versión de prompt: miden la búsqueda, que es anterior al generador.

Latencias con el protocolo de bench/protocol.md: 60 peticiones, se descartan las 5 primeras, 3 repeticiones, se publica el máximo de los tres p95, con el hardware declarado en docs/GOALS.yaml.


Lo que no garantiza

Esta sección existe porque un producto que solo enseña sus buenos números no está informando.

  • Que el artículo citado sea el más pertinente. La cita siempre es real y siempre es literal; no siempre es la que un experto habría elegido. G-CITA-PRECISION está en 0,700.
  • Que el corpus contenga la respuesta. Solo está el RD 1428/2003, que responde al 49,4 % de un banco de examen real. Mecánica, primeros auxilios, permisos y factores humanos no los regula este reglamento, y ahí el sistema se calla — que es lo correcto, no un fallo.
  • Que la prosa entre citas sea fiel a lo que cita. Lo juzga otro modelo (G-FAITH-JUEZ = 0,705) con un acuerdo humano de κ = 0,4476 — moderado. Es el número más blando del proyecto, y por eso se publica con su κ al lado y como informativo, no como puerta.
  • Que sirva para decidir nada legal. No lo es. Es un tutor de estudio.

Hasta dónde puede llegar, con qué decisiones y con qué techo medido, está cuantificado en docs/spec/futuro.md.

Note

Y lo dice también dentro del producto. La aplicación trae una pestaña «Por qué» que explica la garantía, publica las cuatro medidas con su cota superior —no el cero a secas— y termina con esta misma lista de limitaciones. Un sistema que solo enseña sus buenos números a quien lee el README, y no a quien lo usa, informa a la persona equivocada.


Stack

Python FastAPI PostgreSQL Docker Ollama LangGraph Pytest

Capa Qué se usa Por qué
API FastAPI + SSE streaming real: el guardia tiene que poder cortar en el token, no al final
Orquestación LangGraph como máquina de estados el ciclo redactar → verificar → reintentar es un grafo, no una cadena
Búsqueda PostgreSQL + pgvector (HNSW) y ts_rank_cd un solo motor para los dos canales; sin servicio de búsqueda aparte
Reordenado BAAI/bge-reranker-v2-m3 en proceso, con MPS no pasa por Ollama: no existe /api/rerank
Modelos Ollama en el host — qwen3.5:4b, qwen3-embedding:0.6b reemplazable por una variable de entorno
Calidad pytest · hypothesis · mutmut · ruff · mypy --strict mutación sobre las zonas de TDD obligatorio
Mapa del repositorio
Ruta Qué es
src/citebound/domain/ legalref, citation, retry, knowledge, selector — puro, sin I/O
src/citebound/ingest/ parseo del XML del BOE y troceado por apartado
src/citebound/retrieval/ léxico, vectorial, fusión RRF, constructor de consulta, reordenador
src/citebound/agent/ el grafo como máquina de estados y el guardia de streaming
src/citebound/evals/ scoring, bootstrap, esquema del golden set y suite adversarial
src/citebound/api/ FastAPI + SSE, límite de tasa, métricas
ui/ la web de práctica. No importa citebound: habla por HTTP como cualquier cliente
corpus/ el corpus congelado, inmutable y verificado por sha256
evals/golden/ el golden set versionado, append-only
prompts/ los prompts, en ficheros con frontmatter. Nunca dentro del código
docs/ contratos, metas, reglas, ADR y diario de ingeniería

La historia del proyecto —cada fase, cada número y cada umbral bajado con su motivo— está en CHANGELOG.md y en docs/JOURNAL.md.


Referencias

CorpusRD 1428/2003, Reglamento General de Circulación (BOE-A-2003-23514), texto consolidado del BOE, congelado el 2026-08-10 y verificado por sha256.

Modelosqwen3.5:4b · qwen3-embedding:0.6b · BAAI/bge-reranker-v2-m3

TécnicasReciprocal Rank Fusion (Cormack et al., 2009) para combinar los dos canales · Bayesian Knowledge Tracing (Corbett & Anderson, 1994) para el selector adaptativo · semántica GenAI de OpenTelemetry v1.42.0, con la divergencia declarada en otel-semconv.lock.


Licencia

Apache 2.0.

El corpus es el BOE. El banco de preguntas tipo test es material de terceros —de acceso público en internet— y se incluye en evals/golden/source/ en su versión podada y con la clave del banco original sustituida por identificadores aleatorios (scripts/anonimizar_banco.py, irreversible: el mapa no se guarda). El volcado íntegro no está en el repositorio, y las imágenes de las preguntas tampoco. La procedencia está declarada en evals/golden/source/README.md.

About

Tutor de normativa de circulación española con cita cerrada: no puede citar un artículo que no haya recuperado. Verificación literal del fragmento, abstención medida y puerta de calidad con umbrales firmados.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages