| 1 | #!/usr/bin/env python3 |
| 2 | # -*- coding: utf-8 -*- |
| 3 | """ |
| 4 | 01 — Todo cabe en un solo fichero |
| 5 | ================================= |
| 6 | |
| 7 | Episodio «¿Cómo se le da memoria a una IA?» — Punto 6 del guion, |
| 8 | «Las tres decisiones» (acto III — El relato y lo que lo sostiene). |
| 9 | |
| 10 | La idea del episodio |
| 11 | -------------------- |
| 12 | Todos los recuerdos de un asistente con memoria pueden vivir en **un único |
| 13 | fichero** en tu propia máquina: lo que dijiste, las fichas de lo que pasó, |
| 14 | cómo eres, qué pasó y cuándo, y la bitácora del día. Consecuencias: |
| 15 | |
| 16 | * Tus recuerdos **no salen de tu casa**: no hay nube a la que subir nada. |
| 17 | * Lo copias a un *pendrive* y tienes tu memoria entera en el bolsillo. |
| 18 | * Lo borras y no queda rastro en ningún sitio. |
| 19 | * Lo abres y lees qué sabe el asistente de ti. |
| 20 | |
| 21 | Qué hace este ejemplo |
| 22 | --------------------- |
| 23 | Crea ese fichero único —un `.json` con las cinco capas dentro—, lo enseña por |
| 24 | pantalla, lo copia como si fuera a un *pendrive* y comprueba que la copia |
| 25 | contiene exactamente lo mismo. Los recuerdos que trae son **fragmentos reales |
| 26 | de una instancia de Valet en producción**, generalizados para publicarlos: sin |
| 27 | nombres de terceros, sin lugares y sin datos que permitan identificarla. La |
| 28 | gracia no es el formato, es que **cabe todo junto y es tuyo**. |
| 29 | |
| 30 | No necesita nada instalado (solo Python 3, que ya viene en casi cualquier Linux). |
| 31 | |
| 32 | Uso: |
| 33 | python3 01-un-solo-fichero.py # escribe ./memoria-demo.json |
| 34 | python3 01-un-solo-fichero.py /ruta/mi-memoria.json |
| 35 | """ |
| 36 | |
| 37 | import json |
| 38 | import os |
| 39 | import shutil |
| 40 | import sys |
| 41 | |
| 42 | # ───────────────────────────────────────────────────────────────────────────── |
| 43 | # Las cinco capas del episodio, dentro de un solo diccionario. Son fragmentos |
| 44 | # reales de una instancia de Valet en producción, generalizados para publicar. |
| 45 | # Capa A: lo que dijiste (la grabación en bruto) |
| 46 | # Capa B: lo que pasó (fichas con el sentido de la conversación) |
| 47 | # Capa C: cómo eres (tu retrato + el tono del asistente; en producción, 19 normas) |
| 48 | # Capa D: qué y cuándo (hechos sueltos con fecha) |
| 49 | # Capa E: la bitácora (el relato del día, escrito solo) |
| 50 | # ───────────────────────────────────────────────────────────────────────────── |
| 51 | MEMORIA_DE_EJEMPLO = { |
| 52 | "A_lo_que_dijiste": [ |
| 53 | {"quien": "tú", "texto": "Resúmeme los artículos de RSS que tengo pendientes."}, |
| 54 | { |
| 55 | "quien": "asistente", |
| 56 | "texto": "Hecho. Te dejo por aquí los temas y las decisiones que saco en limpio.", |
| 57 | }, |
| 58 | ], |
| 59 | "B_lo_que_paso": [ |
| 60 | { |
| 61 | "fecha": "2026-10-24", |
| 62 | "temas": [ |
| 63 | "RSS", |
| 64 | "Docker Compose", |
| 65 | "cifrado de disco", |
| 66 | "monitorización de servidores", |
| 67 | "alternativas a Dropbox", |
| 68 | ], |
| 69 | # Fragmento real de la ficha (≈257 tokens en producción). |
| 70 | "sintesis": "Resumen y selección de artículos de RSS pendientes; " |
| 71 | "el usuario pidió un resumen y una selección de lo más relevante.", |
| 72 | } |
| 73 | ], |
| 74 | "C_como_eres": { |
| 75 | # El retrato: verdades que se destilan con el tiempo, no hechos de un día. |
| 76 | "quien_eres": [ |
| 77 | "Sales a correr con regularidad y te gusta anotar distancias y ritmos.", |
| 78 | "Prefieres resolver las cosas por la mañana.", |
| 79 | ], |
| 80 | # Las normas de casa: cómo quieres que se comporte tu asistente. |
| 81 | # Estas tres están citadas tal cual; en producción hay 19. |
| 82 | "normas_de_casa": [ |
| 83 | "No inventes ni rellenes contenidos no disponibles; informa con " |
| 84 | "honestidad sobre limitaciones de acceso.", |
| 85 | "Verifica antes y después de crear recordatorios o eventos.", |
| 86 | "No registres afirmaciones subjetivas o no verificables como hechos " |
| 87 | "en el timeline.", |
| 88 | ], |
| 89 | }, |
| 90 | "D_que_y_cuando": [ |
| 91 | { |
| 92 | # Cambiado de 2026-10-20 a 2026-10-17: el 20 es una de las jornadas |
| 93 | # de la bitácora (18, 19 y 20) y no puede llevar dos cifras distintas. |
| 94 | # El 17 no colisiona con ningún día de la bitácora y mantiene el |
| 95 | # orden ascendente de la línea del tiempo (17 · 21 · 22). |
| 96 | "fecha": "2026-10-17", |
| 97 | "hecho": "Corrí 9,00 km con 119 m de desnivel y ritmo medio de 6:44 min/km.", |
| 98 | }, |
| 99 | {"fecha": "2026-10-21", "hecho": "Cené fuera."}, |
| 100 | {"fecha": "2026-10-22", "hecho": "Corregí el nombre en la ficha de Valet."}, |
| 101 | ], |
| 102 | "E_la_bitacora": [ |
| 103 | { |
| 104 | "fecha": "2026-10-18", |
| 105 | "relato": "Hoy ha sido un día de esos en los que la lluvia y las " |
| 106 | "decisiones se entrelazan de manera inesperada.", |
| 107 | "animo": "buen día", |
| 108 | "logros": [ |
| 109 | "Ajustar el plan de entrenamiento a la lluvia", |
| 110 | "Decidir una tirada de 40 min en vez de 13 km", |
| 111 | ], |
| 112 | "dificultades": ["Condiciones adversas"], |
| 113 | "etiquetas": ["entrenamiento", "meteorología", "decisiones"], |
| 114 | } |
| 115 | ], |
| 116 | } |
| 117 | |
| 118 | |
| 119 | def ruta_por_defecto(): |
| 120 | return os.path.join(os.getcwd(), "memoria-demo.json") |
| 121 | |
| 122 | |
| 123 | def main(): |
| 124 | ruta = sys.argv[1] if len(sys.argv) > 1 else ruta_por_defecto() |
| 125 | |
| 126 | # 1) Escribir el fichero único. |
| 127 | with open(ruta, "w", encoding="utf-8") as f: |
| 128 | json.dump(MEMORIA_DE_EJEMPLO, f, ensure_ascii=False, indent=2) |
| 129 | |
| 130 | print("=" * 68) |
| 131 | print(" UN SOLO FICHERO CON TODA LA MEMORIA") |
| 132 | print("=" * 68) |
| 133 | print(f"\n Fichero: {ruta}") |
| 134 | print(f" Tamaño : {os.path.getsize(ruta)} bytes (ocupa nada, ya lo ves)") |
| 135 | |
| 136 | # 2) Enseñar las cinco capas que viven dentro, sin entrar en tripas. |
| 137 | print("\n Dentro de ese único fichero:\n") |
| 138 | etiquetas = { |
| 139 | "A_lo_que_dijiste": "A · lo que dijiste", |
| 140 | "B_lo_que_paso": "B · lo que pasó", |
| 141 | "C_como_eres": "C · cómo eres", |
| 142 | "D_que_y_cuando": "D · qué y cuándo", |
| 143 | "E_la_bitacora": "E · la bitácora", |
| 144 | } |
| 145 | for clave, etiqueta in etiquetas.items(): |
| 146 | contenido = MEMORIA_DE_EJEMPLO[clave] |
| 147 | n = len(contenido) if isinstance(contenido, list) else len(contenido.keys()) |
| 148 | print(f" • {etiqueta:<20} → {n} elemento(s)") |
| 149 | |
| 150 | # 3) "Copiarlo al pendrive": es un fichero, se copia y punto. |
| 151 | destino = ruta + ".copia-pendrive.json" |
| 152 | shutil.copyfile(ruta, destino) |
| 153 | iguales = open(ruta, "rb").read() == open(destino, "rb").read() |
| 154 | print(f"\n Copia al pendrive: {destino}") |
| 155 | print(f" ¿La copia es idéntica? {'sí ✔' if iguales else 'no ✘'}") |
| 156 | |
| 157 | # 4) "Abrirlo y leer qué sabe de ti": aquí lo enseñamos en crudo. |
| 158 | print("\n Y si lo abres, esto es lo que el asistente sabe de ti:") |
| 159 | print(" " + "-" * 64) |
| 160 | perfil = MEMORIA_DE_EJEMPLO["C_como_eres"]["quien_eres"] |
| 161 | for verdad in perfil: |
| 162 | print(f" · {verdad}") |
| 163 | print(" " + "-" * 64) |
| 164 | |
| 165 | print("\n Lo borras y no queda rastro en ningún sitio. Eso es la gracia.\n") |
| 166 | |
| 167 | |
| 168 | if __name__ == "__main__": |
| 169 | main() |
| 170 |
| 1 | #!/usr/bin/env python3 |
| 2 | # -*- coding: utf-8 -*- |
| 3 | """ |
| 4 | 02 — Buscar por significado, no por palabra |
| 5 | ============================================ |
| 6 | |
| 7 | Episodio «¿Cómo se le da memoria a una IA?» — Punto 3 del guion, |
| 8 | «Lo que dijiste, lo que pasó y cuándo» (acto II — Los hechos y el retrato). |
| 9 | |
| 10 | La idea del episodio |
| 11 | -------------------- |
| 12 | Cuando le preguntas algo, el asistente no busca el libro que se titula |
| 13 | literalmente como tu pregunta. Busca **ideas**. Si tú preguntas por «coche» y |
| 14 | la ficha dice «automóvil», un buscador normal no lo encuentra; este sí. En el |
| 15 | mundo real, el sistema traduce el texto a números (lo que llaman *búsqueda |
| 16 | semántica* o RAG) y compara esos números. Aquí no vamos a meter un modelo: lo |
| 17 | imitamos con un diccionario de conceptos hecho a mano, para que se vea la |
| 18 | diferencia sin instalar nada. |
| 19 | |
| 20 | Qué hace este ejemplo |
| 21 | --------------------- |
| 22 | 1. Compara un **buscador por palabra** (cuenta letras en común) con un |
| 23 | **buscador por significado** (compara conceptos). Misma pregunta, mismos |
| 24 | recuerdos, resultados distintos. |
| 25 | 2. Busca «quiero algo sobre el miedo a hablar en público» y enseña cómo el |
| 26 | buscador de significado trae la ficha que habla de «ansiedad antes de una |
| 27 | presentación», aunque no compartan ni una palabra. |
| 28 | 3. De propina, reproduce el caso «coche / automóvil». |
| 29 | |
| 30 | No necesita nada instalado (solo Python 3). |
| 31 | |
| 32 | Uso: |
| 33 | python3 02-buscar-por-significado.py |
| 34 | """ |
| 35 | |
| 36 | import math |
| 37 | import re |
| 38 | import unicodedata |
| 39 | |
| 40 | |
| 41 | # ───────────────────────────────────────────────────────────────────────────── |
| 42 | # El "traductor a significado" de juguete. |
| 43 | # |
| 44 | # En un sistema real, un modelo convierte cada texto en una lista de números |
| 45 | # (un vector) de forma que los textos que hablan de lo mismo acaban con números |
| 46 | # parecidos. Aquí no hay modelo: definimos a mano unos cuantos CONCEPTOS y |
| 47 | # decimos qué palabras activan cada uno. Es una maqueta, pero enseña la idea. |
| 48 | # ───────────────────────────────────────────────────────────────────────────── |
| 49 | CONCEPTOS = { |
| 50 | "miedo_escenario": [ |
| 51 | "miedo", |
| 52 | "panico", |
| 53 | "nervios", |
| 54 | "ansiedad", |
| 55 | "presentacion", |
| 56 | "presentar", |
| 57 | "publico", |
| 58 | "escenario", |
| 59 | "hablar en publico", |
| 60 | "audiencia", |
| 61 | "examen", |
| 62 | ], |
| 63 | "vehiculo": [ |
| 64 | "coche", |
| 65 | "automovil", |
| 66 | "carro", |
| 67 | "turismo", |
| 68 | "vehiculo", |
| 69 | "conducir", |
| 70 | "volante", |
| 71 | ], |
| 72 | "comida": [ |
| 73 | "comida", |
| 74 | "cena", |
| 75 | "receta", |
| 76 | "cocina", |
| 77 | "carbonara", |
| 78 | "restaurante", |
| 79 | "ingrediente", |
| 80 | ], |
| 81 | "salud": ["dentista", "medico", "salud", "diente", "muela", "consulta", "revision"], |
| 82 | "tecnologia": [ |
| 83 | "disco", |
| 84 | "duro", |
| 85 | "ordenador", |
| 86 | "backup", |
| 87 | "copia", |
| 88 | "linux", |
| 89 | "programa", |
| 90 | ], |
| 91 | } |
| 92 | |
| 93 | |
| 94 | # Palabras vacías del castellano: las que aparecen en todas partes y no |
| 95 | # significan nada por sí solas. Un buscador serio las ignora; si no, cualquier |
| 96 | # pregunta haría "coincidir" con cualquier recuerdo solo por compartir un «el». |
| 97 | STOPWORDS = { |
| 98 | "el", |
| 99 | "la", |
| 100 | "los", |
| 101 | "las", |
| 102 | "un", |
| 103 | "una", |
| 104 | "unos", |
| 105 | "unas", |
| 106 | "de", |
| 107 | "del", |
| 108 | "a", |
| 109 | "al", |
| 110 | "en", |
| 111 | "y", |
| 112 | "o", |
| 113 | "u", |
| 114 | "que", |
| 115 | "por", |
| 116 | "para", |
| 117 | "con", |
| 118 | "sin", |
| 119 | "se", |
| 120 | "su", |
| 121 | "sus", |
| 122 | "mi", |
| 123 | "mis", |
| 124 | "me", |
| 125 | "te", |
| 126 | "tu", |
| 127 | "tus", |
| 128 | "lo", |
| 129 | "es", |
| 130 | "son", |
| 131 | "fue", |
| 132 | "fui", |
| 133 | "soy", |
| 134 | "era", |
| 135 | } |
| 136 | |
| 137 | |
| 138 | def normalizar(texto): |
| 139 | """Minúsculas y sin acentos, para que 'público' y 'publico' se encuentren.""" |
| 140 | texto = unicodedata.normalize("NFKD", texto.lower()) |
| 141 | return "".join(c for c in texto if not unicodedata.combining(c)) |
| 142 | |
| 143 | |
| 144 | def palabras_con_peso(texto): |
| 145 | """Trocea un texto en palabras con significado, tirando las vacías (stopwords).""" |
| 146 | todas = re.findall(r"\w+", normalizar(texto)) |
| 147 | return {p for p in todas if p not in STOPWORDS} |
| 148 | |
| 149 | |
| 150 | def pluralizar(n, singular, plural): |
| 151 | """'1 palabra' / 'N palabras'.""" |
| 152 | return f"{n} {singular if n == 1 else plural}" |
| 153 | |
| 154 | |
| 155 | def vector_de_conceptos(texto): |
| 156 | """Convierte un texto en una pequeña lista de números: cuánto toca cada concepto.""" |
| 157 | limpio = normalizar(texto) |
| 158 | vector = [] |
| 159 | for palabras in CONCEPTOS.values(): |
| 160 | cuenta = sum(1 for p in palabras if p in limpio) |
| 161 | vector.append(float(cuenta)) |
| 162 | return vector |
| 163 | |
| 164 | |
| 165 | def similitud_coseno(a, b): |
| 166 | """Cuánto se parecen dos listas de números. Va de 0 (nada) a 1 (idénticas).""" |
| 167 | producto = sum(x * y for x, y in zip(a, b)) |
| 168 | norma_a = math.sqrt(sum(x * x for x in a)) |
| 169 | norma_b = math.sqrt(sum(y * y for y in b)) |
| 170 | if norma_a == 0 or norma_b == 0: |
| 171 | return 0.0 |
| 172 | return producto / (norma_a * norma_b) |
| 173 | |
| 174 | |
| 175 | def buscar_por_palabra(pregunta, recuerdos): |
| 176 | """Buscador clásico: cuenta cuántas palabras CON SIGNIFICADO comparten.""" |
| 177 | palabras_pregunta = palabras_con_peso(pregunta) |
| 178 | puntuaciones = [] |
| 179 | for r in recuerdos: |
| 180 | comunes = palabras_pregunta & palabras_con_peso(r) |
| 181 | puntuaciones.append(len(comunes)) |
| 182 | return puntuaciones |
| 183 | |
| 184 | |
| 185 | def buscar_por_significado(pregunta, recuerdos): |
| 186 | """Buscador moderno: compara el 'significado' (los conceptos) de cada texto.""" |
| 187 | v_pregunta = vector_de_conceptos(pregunta) |
| 188 | return [similitud_coseno(v_pregunta, vector_de_conceptos(r)) for r in recuerdos] |
| 189 | |
| 190 | |
| 191 | def batalla(pregunta, recuerdos): |
| 192 | print("=" * 68) |
| 193 | print(" BUSCAR POR SIGNIFICADO, NO POR PALABRA") |
| 194 | print("=" * 68) |
| 195 | print(f"\n Pregunta: «{pregunta}»\n") |
| 196 | print(" Recuerdos disponibles:") |
| 197 | for i, r in enumerate(recuerdos, 1): |
| 198 | print(f" {i}. {r}") |
| 199 | |
| 200 | por_palabra = buscar_por_palabra(pregunta, recuerdos) |
| 201 | por_significado = buscar_por_significado(pregunta, recuerdos) |
| 202 | |
| 203 | mejor_palabra = max(range(len(recuerdos)), key=lambda i: por_palabra[i]) |
| 204 | mejor_sig = max(range(len(recuerdos)), key=lambda i: por_significado[i]) |
| 205 | |
| 206 | print("\n ── Buscador por PALABRA ─────────────────────────────────────") |
| 207 | if max(por_palabra) == 0: |
| 208 | print(" ✗ No encuentra nada: ningún recuerdo comparte palabras con") |
| 209 | print(" la pregunta. Aquí se queda sin respuesta.") |
| 210 | else: |
| 211 | for i, r in enumerate(recuerdos): |
| 212 | marca = " ← el primero" if i == mejor_palabra else "" |
| 213 | etiqueta = pluralizar( |
| 214 | por_palabra[i], "palabra en común", "palabras en común" |
| 215 | ) |
| 216 | print(f" {etiqueta} · {r}{marca}") |
| 217 | |
| 218 | print("\n ── Buscador por SIGNIFICADO ─────────────────────────────────") |
| 219 | for i, r in enumerate(recuerdos): |
| 220 | marca = " ← el primero" if i == mejor_sig else "" |
| 221 | print(f" {por_significado[i]:.2f} de parecido · {r}{marca}") |
| 222 | |
| 223 | print("\n Fíjate: el buscador de significado trae la ficha que HABLA DE LO") |
| 224 | print(" MISMO aunque no comparta ni una palabra con tu pregunta. Eso es") |
| 225 | print(" lo que hace que el asistente parezca que te recuerda.\n") |
| 226 | |
| 227 | |
| 228 | def main(): |
| 229 | # Caso 1: el ejemplo estrella del episodio. |
| 230 | batalla( |
| 231 | "quiero algo sobre el miedo a hablar en público", |
| 232 | [ |
| 233 | "Ansiedad antes de una presentación y consejos para respirar.", |
| 234 | "Receta de carbonara para la cena del sábado.", |
| 235 | "El martes fui al dentista por la mañana.", |
| 236 | ], |
| 237 | ) |
| 238 | |
| 239 | # Caso 2: el «coche / automóvil» que cuenta el episodio. |
| 240 | batalla( |
| 241 | "¿qué te conté del coche?", |
| 242 | [ |
| 243 | "Hablamos de comprar un automóvil de segunda mano.", |
| 244 | "Ayer preparé una carbonara bastante decente.", |
| 245 | ], |
| 246 | ) |
| 247 | |
| 248 | print(" Nota: en Valet esto es de verdad, con un modelo que traduce el") |
| 249 | print(" texto a números. Aquí es una maqueta para que se vea la idea.\n") |
| 250 | |
| 251 | |
| 252 | if __name__ == "__main__": |
| 253 | main() |
| 254 |
| 1 | #!/usr/bin/env python3 |
| 2 | # -*- coding: utf-8 -*- |
| 3 | """ |
| 4 | 03 — La Bitácora: la memoria que se escribe sola |
| 5 | ================================================ |
| 6 | |
| 7 | Episodio «¿Cómo se le da memoria a una IA?» — Punto 5 del guion, |
| 8 | «El diario que se escribe solo» (acto III — El relato y lo que lo sostiene), |
| 9 | la Capa E. |
| 10 | |
| 11 | La idea del episodio |
| 12 | -------------------- |
| 13 | La quinta pregunta no es «qué hiciste», es «¿cómo fue el día?». No pide una |
| 14 | lista: pide un **relato**. Y lo mejor es que **se escribe sola**: una vez al |
| 15 | día, de madrugada, el asistente coge lo del día (los hechos, los mensajes y el |
| 16 | diario de ayer para no repetirse) y escribe **una entrada por día**. Con un |
| 17 | detalle de otro nivel: puede decidir que **ese día no hay nada que contar**. |
| 18 | Ese «no escribo» no es un error; es una respuesta válida. |
| 19 | |
| 20 | Qué hace este ejemplo |
| 21 | --------------------- |
| 22 | Lee un fichero con el «material del día» y hace lo mismo que haría Valet: |
| 23 | |
| 24 | * Si el día trae algo que merece memoria → escribe una entrada en |
| 25 | `bitacora/AAAA-MM-DD.md`, en primera persona, y le añade los matices |
| 26 | (ánimo, logros, dificultades, etiquetas). |
| 27 | * Si el día está vacío → **no escribe nada** y lo dice en voz alta. |
| 28 | |
| 29 | Y respeta la regla de oro: **una sola entrada por día**. Si ya existe, no la |
| 30 | duplica. |
| 31 | |
| 32 | En Valet el relato lo escribe un modelo. Aquí lo montamos a base de plantilla, |
| 33 | sin instalar nada, porque lo que queremos enseñar no es la prosa, sino el |
| 34 | **mecanismo**: recoger el día, decidir, y escribir una única entrada. |
| 35 | |
| 36 | Uso: |
| 37 | python3 03-la-bitacora.py # enseña los días de ejemplo |
| 38 | python3 03-la-bitacora.py datos/dia-2026-10-18.json |
| 39 | """ |
| 40 | |
| 41 | import json |
| 42 | import os |
| 43 | import sys |
| 44 | |
| 45 | # Dónde se guardan las entradas, como en un diario real: un fichero por día. |
| 46 | CARPETA_BITACORA = "bitacora" |
| 47 | |
| 48 | |
| 49 | def dia_vacio(material): |
| 50 | """Un día sin hechos, sin mensajes y sin nada que contar no merece entrada.""" |
| 51 | return not material.get("hechos") and not material.get("mensajes") |
| 52 | |
| 53 | |
| 54 | def componer_relato(material): |
| 55 | """ |
| 56 | Escribe el relato del día, en primera persona y con nuestras palabras. |
| 57 | |
| 58 | El episodio lo pide así: «no enumeres los hechos otra vez, descríbelos con |
| 59 | tus palabras. Describe en vez de diagnosticar.» Aquí, sin modelo, hacemos |
| 60 | una versión sencilla de esa idea: unimos los hechos en un párrafo que suene |
| 61 | a cómo lo contarías tú, no a un informe. |
| 62 | """ |
| 63 | hechos = material.get("hechos", []) |
| 64 | frases = [] |
| 65 | |
| 66 | # Abrimos con el primer hecho como arranque del día. |
| 67 | if hechos: |
| 68 | frases.append("Hoy " + hechos[0].rstrip(".").lower() + ".") |
| 69 | # Y seguimos con el resto, enganchándolos con naturalidad. |
| 70 | for hecho in hechos[1:]: |
| 71 | frases.append("También " + hecho.rstrip(".").lower() + ".") |
| 72 | |
| 73 | if material.get("mensajes"): |
| 74 | frases.append("Entre medias he ido hablando con el asistente de lo de siempre.") |
| 75 | |
| 76 | return " ".join(frases) |
| 77 | |
| 78 | |
| 79 | def escribir_entrada(ruta, material): |
| 80 | fecha = material["fecha"] |
| 81 | os.makedirs(CARPETA_BITACORA, exist_ok=True) |
| 82 | destino = os.path.join(CARPETA_BITACORA, f"{fecha}.md") |
| 83 | |
| 84 | # Regla de oro: una sola entrada por día. |
| 85 | if os.path.exists(destino): |
| 86 | print(f" {fecha}: ya había una entrada, no se duplica → {destino}") |
| 87 | return |
| 88 | |
| 89 | relato = componer_relato(material) |
| 90 | animo = material.get("animo", "sin determinar") |
| 91 | logros = material.get("logros", []) |
| 92 | dificultades = material.get("dificultades", []) |
| 93 | etiquetas = material.get("etiquetas", []) |
| 94 | |
| 95 | lineas = [ |
| 96 | f"# {fecha}", |
| 97 | "", |
| 98 | relato, |
| 99 | "", |
| 100 | f"- **Ánimo:** {animo}", |
| 101 | ] |
| 102 | if logros: |
| 103 | lineas.append("- **Logros:** " + "; ".join(logros)) |
| 104 | if dificultades: |
| 105 | lineas.append("- **Dificultades:** " + "; ".join(dificultades)) |
| 106 | if etiquetas: |
| 107 | lineas.append("- **Etiquetas:** " + ", ".join(etiquetas)) |
| 108 | lineas.append("") |
| 109 | |
| 110 | with open(destino, "w", encoding="utf-8") as f: |
| 111 | f.write("\n".join(lineas)) |
| 112 | |
| 113 | print(f" {fecha}: se escribe la entrada → {destino}") |
| 114 | print(" " + "-" * 62) |
| 115 | for linea in lineas: |
| 116 | print(" " + linea) |
| 117 | print(" " + "-" * 62) |
| 118 | |
| 119 | |
| 120 | def procesar(material): |
| 121 | fecha = material.get("fecha", "sin fecha") |
| 122 | print(f"\n Material del día {fecha}:") |
| 123 | print(f" hechos : {len(material.get('hechos', []))}") |
| 124 | print(f" mensajes : {len(material.get('mensajes', []))}") |
| 125 | |
| 126 | if dia_vacio(material): |
| 127 | # Este es el «no escribo» explícito: una respuesta válida, no un error. |
| 128 | print(" → NO se escribe entrada: ese día no hay nada que merezca memoria.") |
| 129 | return |
| 130 | |
| 131 | escribir_entrada(CARPETA_BITACORA, material) |
| 132 | |
| 133 | |
| 134 | def main(): |
| 135 | print("=" * 68) |
| 136 | print(" LA BITÁCORA: LA MEMORIA QUE SE ESCRIBE SOLA") |
| 137 | print("=" * 68) |
| 138 | |
| 139 | if len(sys.argv) > 1: |
| 140 | with open(sys.argv[1], encoding="utf-8") as f: |
| 141 | procesar(json.load(f)) |
| 142 | else: |
| 143 | # Sin argumentos, enseña los días de ejemplo que trae el episodio: |
| 144 | # dos jornadas reales (18 y 20 de octubre) y un día vacío, para ver |
| 145 | # el «no escribo» en acción. |
| 146 | aqui = os.path.dirname(os.path.abspath(__file__)) |
| 147 | carpeta = os.path.join(aqui, "datos") |
| 148 | for nombre in sorted(os.listdir(carpeta)): |
| 149 | if nombre.startswith("dia-") and nombre.endswith(".json"): |
| 150 | with open(os.path.join(carpeta, nombre), encoding="utf-8") as f: |
| 151 | procesar(json.load(f)) |
| 152 | |
| 153 | print("\n Y así, sin que se lo pidas, el asistente te lleva el diario.") |
| 154 | print(" De madrugada, una vez al día, decide y escribe. O decide no escribir.\n") |
| 155 | |
| 156 | |
| 157 | if __name__ == "__main__": |
| 158 | main() |
| 159 |
| 1 | #!/usr/bin/env python3 |
| 2 | # -*- coding: utf-8 -*- |
| 3 | """ |
| 4 | 04 — El olvido y el eco |
| 5 | ======================= |
| 6 | |
| 7 | Episodio «¿Cómo se le da memoria a una IA?» — Punto 7 del guion, |
| 8 | «No hay botón de olvidar» (acto IV — Lo que falta) y cierre. |
| 9 | |
| 10 | La idea del episodio |
| 11 | -------------------- |
| 12 | Lo que el asistente **no puede olvidar**. Hoy no hay un botón de «olvida |
| 13 | esto»: no existe forma de decirle «aquello del dentista, bórralo». La memoria |
| 14 | solo crece. El único botón para empezar de nuevo es brutal: borra **todo**. |
| 15 | Entre «lo recuerdo absolutamente todo» y «no recuerdo nada de ti» no hay |
| 16 | término medio. |
| 17 | |
| 18 | Y hay un hallazgo precioso en el tramo final del episodio: cuando el sistema |
| 19 | borra, borra el texto… pero a veces **el eco se queda**. Una huella que el buscador todavía |
| 20 | puede «oler» pero que ya no tiene texto detrás. Como esa palabra en la punta de |
| 21 | la lengua: sabes que estaba ahí, no la alcanzas. |
| 22 | |
| 23 | Qué hace este ejemplo |
| 24 | --------------------- |
| 25 | Monta una memoria de juguete con unos recuerdos y una lista de «huellas» (lo |
| 26 | que el buscador tiene anotado para poder encontrarlos) y prueba tres cosas: |
| 27 | |
| 28 | 1. Intentar olvidar **un** recuerdo → no existe ese botón. Se niega. |
| 29 | 2. Un borrado chapucero que se lleva el texto pero deja la huella → aparece |
| 30 | **el eco**: el buscador lo huele, pero no hay nada que leer. |
| 31 | 3. El botón brutal de «empezar de nuevo» → borra todo de verdad. |
| 32 | |
| 33 | No necesita nada instalado (solo Python 3). |
| 34 | |
| 35 | Uso: |
| 36 | python3 04-el-olvido-y-el-eco.py |
| 37 | """ |
| 38 | |
| 39 | |
| 40 | # ───────────────────────────────────────────────────────────────────────────── |
| 41 | # La memoria de juguete. Cada recuerdo tiene su texto… y el buscador guarda |
| 42 | # aparte una "huella" para poder encontrarlo por significado (como en el |
| 43 | # ejemplo 02). Texto y huella viven juntos pero no son lo mismo: ahí está la |
| 44 | # trampa del eco. |
| 45 | # ───────────────────────────────────────────────────────────────────────────── |
| 46 | class Memoria: |
| 47 | def __init__(self): |
| 48 | self.recuerdos = { |
| 49 | 1: { |
| 50 | "texto": "Corrí 9,00 km con 119 m de desnivel y ritmo medio de 6:44 min/km.", |
| 51 | "huella": "deporte running entrenamiento kilómetros", |
| 52 | }, |
| 53 | 2: { |
| 54 | "texto": "Cené fuera.", |
| 55 | "huella": "social cena fuera", |
| 56 | }, |
| 57 | 3: { |
| 58 | "texto": "Corregí el nombre en la ficha de Valet.", |
| 59 | "huella": "sistema asistente ficha nombre", |
| 60 | }, |
| 61 | } |
| 62 | |
| 63 | def buscar(self, consulta): |
| 64 | """Buscador de andar por casa: mira las huellas, no el texto.""" |
| 65 | resultados = [] |
| 66 | for rid, r in self.recuerdos.items(): |
| 67 | if consulta.lower() in r["huella"].lower(): |
| 68 | resultados.append((rid, r)) |
| 69 | return resultados |
| 70 | |
| 71 | |
| 72 | def titulo(texto): |
| 73 | print("\n" + "=" * 68) |
| 74 | print(f" {texto}") |
| 75 | print("=" * 68) |
| 76 | |
| 77 | |
| 78 | def main(): |
| 79 | memoria = Memoria() |
| 80 | |
| 81 | titulo("1. Intentar olvidar UN recuerdo concreto") |
| 82 | print("\n Le pedimos: «olvida lo de la salida de ayer, bórralo».\n") |
| 83 | print(" …") |
| 84 | print(" No hay un botón de «olvida esto». No existe.") |
| 85 | print(" La memoria solo crece: lo único que caduca solo son las estadísticas.") |
| 86 | print(" Y el único botón para empezar de nuevo borra TODO, no un recuerdo.") |
| 87 | |
| 88 | titulo("2. El eco: borrar el texto y dejar la huella") |
| 89 | print("\n Imaginemos un borrado chapucero: se lleva el texto del recuerdo,") |
| 90 | print(" pero la huella del buscador se queda. Mirad lo que pasa.\n") |
| 91 | |
| 92 | # Borrado chapucero: quitamos el texto pero dejamos la huella. |
| 93 | del memoria.recuerdos[1]["texto"] |
| 94 | |
| 95 | print(" Recuerdo 1 después del borrado chapucero:") |
| 96 | print(f" texto : {memoria.recuerdos[1].get('texto', '(ya no hay texto)')}") |
| 97 | print(f" huella : {memoria.recuerdos[1]['huella']}") |
| 98 | |
| 99 | print("\n Ahora el buscador intenta encontrarlo por «running»:\n") |
| 100 | for rid, r in memoria.buscar("running"): |
| 101 | print( |
| 102 | f" · recuerdo {rid}: huele a «{r['huella']}»… " |
| 103 | f"pero texto = {r.get('texto', '(nada que leer)')}" |
| 104 | ) |
| 105 | print("\n Ahí está el eco: una huella que el buscador puede oler, pero que") |
| 106 | print(" ya no tiene texto detrás. Un recuerdo al que has dejado de poder") |
| 107 | print(" llegar, pero que sabes que estaba ahí.") |
| 108 | print(" Como la palabra en la punta de la lengua.") |
| 109 | |
| 110 | titulo("3. El botón brutal: empezar de nuevo") |
| 111 | print("\n No hay término medio. El único «empezar de nuevo» borra la memoria") |
| 112 | print(" entera. Como si acabaras de instalar el asistente y nunca le") |
| 113 | print(" hubieras contado nada.\n") |
| 114 | memoria.recuerdos.clear() |
| 115 | print(f" Recuerdos que quedan: {len(memoria.recuerdos)}.") |
| 116 | print(" Ni el eco: nada. Eso sí es olvidar… demasiado.") |
| 117 | |
| 118 | titulo("Para llevarse") |
| 119 | print("\n Un asistente que lo recuerda todo pero no sabe olvidar nada no es") |
| 120 | print(" un buen asistente: es alguien que nunca te perdona nada.") |
| 121 | print(" Y si esto algún día sale de tu máquina, el primer cambio no sería") |
| 122 | print(" una capa nueva: sería un botón de OLVIDAR DE VERDAD.\n") |
| 123 | |
| 124 | |
| 125 | if __name__ == "__main__": |
| 126 | main() |
| 127 |
Ejemplos — «¿Cómo se le da memoria a una IA?»
Material de apoyo para la web y para el oyente curioso. Acompaña al episodio de
atareao con Linux que cuenta —sin una palabra de programación— cómo se le da
memoria a un modelo de lenguaje, usando
valet-ai como caso de estudio.
Los ejemplos de aquí no son «de laboratorio»: son fragmentos reales de una instancia de Valet en producción, generalizados para poder publicarlos. Se han quitado nombres de terceros, lugares, restaurantes, proyectos internos y el nombre propio del asistente; se ha mantenido el resto —incluidas las cifras y los ejemplos de running— tal cual.
Ojo: aquí no se instala ni se despliega Valet. Eso es el episodio siguiente, «Podman, IPs reales y La Liga». Esto de aquí es solo para asomarse a las ideas: cuatro ejemplos autocontenidos que se ejecutan con Python 3 y no tocan nada de nadie.
La estructura del episodio: 7 puntos, 4 actos
El guión va de lo mecánico a lo humano en siete puntos, agrupados en cuatro actos. Solo cuatro de esos puntos llevan demo; los otros tres se apoyan en una imagen, un diagrama o un dato. Este es el orden de emisión, con el ejemplo que se ejecuta en cada punto que lo tiene:
| Acto | Punto | Título | Qué se ve | Ejemplo |
|---|---|---|---|---|
| I · El cajón | 1 | Un buscador con modales | imagen | — |
| I · El cajón | 2 | La memoria vive alrededor | diagrama | — |
| II · Los hechos y el retrato | 3 | Lo que dijiste, lo que pasó y cuándo | demo | 02-buscar-por-significado.py |
| II · Los hechos y el retrato | 4 | El retrato | dato (19 normas) | — |
| III · El relato y lo que lo sostiene | 5 | El diario que se escribe solo | demo | 03-la-bitacora.py |
| III · El relato y lo que lo sostiene | 6 | Las tres decisiones | demo | 01-un-solo-fichero.py |
| IV · Lo que falta | 7 | No hay botón de olvidar | demo | 04-el-olvido-y-el-eco.py |
Fíjate en que los nombres de los ficheros no son demo-01, demo-02…: se
referencian por el nombre con el que el guión, la escaleta y la presentación los
citan. El número del fichero es solo una etiqueta de taller, no el orden de
emisión: el 01 se emite en el punto 6, el 02 en el punto 3, el 03 en el
punto 5 y el 04 en el punto 7.
Las cinco capas del episodio
Todo el programa gira alrededor de cinco preguntas, que en Valet son cinco capas: A · B · C · D · E. De lo mecánico a lo humano.
| Capa | Pregunta | Qué guarda | Ejemplo de aquí |
|---|---|---|---|
| A | ¿Qué guardo? | Lo que dijiste, tal cual. La grabación en bruto. | 01 |
| B | ¿Cómo lo encuentro? | Fichas con el sentido de la conversación, buscadas por significado. | 01, 02 |
| C | ¿Cómo sé cómo eres? | Tu retrato: verdades sobre ti + cómo quieres que se comporte. | 01 |
| D | ¿Qué pasó, y cuándo? | Hechos sueltos con fecha: tu línea del tiempo. | 01 |
| E | ¿Cómo fue el día? | La Bitácora: el relato del día, escrito solo. | 01, 03 |
Y tres decisiones que atraviesan todas las capas:
- Todo cabe en un solo fichero, en tu máquina →
01. - Una sola transacción: las cuatro capas de la vida, de golpe → se cuenta
en el episodio; el problema del «eco» se ve en
04. - La edad no borra, reordena → se cuenta con la imagen de la biblioteca en el episodio.
Cifras reales de la instancia
Medido sobre la instancia de producción de Valet el 25 de octubre de 2026.
| Capa / actividad | Qué hay | Cifra |
|---|---|---|
| Conversación | Mensajes totales (268 usuario / 247 asistente), desde el 6-oct | 515 |
| A · indexado | Mensajes ya procesados por el asistente | 503 |
| B · fichas | Resúmenes de conversación guardados | 81 |
| C · persistente | Una sola ficha, con normas de casa | 1 fila · 19 normas |
| D · timeline | Hechos con fecha (8 deporte, 1 social, 1 sistema) | 10 |
| E · bitácora | Entradas (18–24 oct; 5 con ánimo «buen día», 1 «neutro») | 6 |
| Llamadas al modelo | Chat 654 · router 237 · consolidador 192 · archivista 111 · timeline 74 · journal 6 | 1.274 |
Procedencia: instancia de producción de Valet, 25-oct-2026, consultada en solo lectura. Las cifras se mantienen íntegras; lo único que se ha generalizado es lo identificable (nombres propios, lugares y proyectos).
Los ficheros
| Fichero | Necesita | Qué enseña |
|---|---|---|
01-un-solo-fichero.py |
nada (Python 3) | Cómo las cinco capas caben en un único fichero que es tuyo, con fragmentos reales de las capas A–E. Lo copias al pendrive, lo abres y lees qué sabe de ti. |
02-buscar-por-significado.py |
nada (Python 3) | La diferencia entre buscar por palabra y buscar por significado (el bibliotecario de ideas del episodio). |
03-la-bitacora.py |
nada (Python 3) | La Capa E: una entrada por día, escrita sola… o el «no escribo» cuando el día no da para nada. |
04-el-olvido-y-el-eco.py |
nada (Python 3) | Que no hay botón de «olvida esto», que el único «empezar de nuevo» borra todo, y el eco de un recuerdo mal borrado. |
datos/ |
— | Tres jornadas de ejemplo para el 03: el 18 y el 20 de octubre (reales) y un día vacío, para ver el «no escribo». |
Todos son de solo lectura sobre el mundo exterior: crean sus ficheros de juguete en una carpeta de trabajo y no llaman a ninguna nube.
Cómo probarlos
cd ejemplos-la-memoria-de-valet
python3 01-un-solo-fichero.py
python3 02-buscar-por-significado.py
python3 03-la-bitacora.py
python3 04-el-olvido-y-el-eco.py
El 03 escribe sus entradas en una carpeta bitacora/ del directorio donde lo
ejecutes. Bórrala cuando quieras: así se ve, de paso, que borrar es borrar.
Para qué sirve cada uno, contado en lenguaje llano
02 — El bibliotecario que entiende ideas (punto 3). Un buscador normal
te trae el libro que se titula literalmente como tu pregunta. El de Valet te
trae el libro que habla de lo mismo, aunque no comparta ni una palabra. En
el ejemplo (que es una maqueta, sin modelo) se ve cómo «quiero algo sobre el
miedo a hablar en público» encuentra la ficha de «ansiedad antes de una
presentación», y cómo «coche» encuentra «automóvil».
03 — Alguien llevándote el diario (punto 5). La quinta capa no cataloga:
cuenta. Le das el material de un día y decide si merece una entrada. Si el día
está vacío, no escribe nada, y eso es una respuesta válida. Si escribe, es
una sola entrada por día, con el relato y sus matices (ánimo, logros,
dificultades, etiquetas). Los días de ejemplo son reales: el 18 de octubre
(lluvia, plan de entrenamiento ajustado, tirada de 40 min en vez de 13 km) y el
20 de octubre (7,51 km en 51:42).
01 — Tu memoria en un fichero (punto 6). Todo lo que sabe tu asistente
—lo que le dijiste, lo que pasó, cómo eres, tu línea del tiempo y el diario—
vive en una sola caja fuerte que está en tu casa. La copias a un pendrive y
la llevas en el bolsillo. La abres y lees lo que sabe de ti. La borras y no queda
rastro. Este ejemplo lo enseña con contenido real: una ficha de resumen de
artículos de RSS, tres de las normas de casa, tres hechos de la línea del tiempo
(una salida a correr de 9,00 km, una cena fuera y una corrección del nombre de
Valet) y una entrada de bitácora del 18 de octubre.
04 — La pregunta difícil (punto 7). Pedirle que olvide un recuerdo
concreto no funciona: ese botón no existe. Solo hay uno, y es brutal: borra
todo. Y en medio, el eco —una huella que el buscador todavía huele, pero sin
texto detrás—, que es la mejor metáfora de lo que es la memoria, incluso la
humana. Los recuerdos usados son los tres hechos reales del timeline.
Lo que se cuenta en el episodio y no se ve aquí
Para que no haya sorpresas a quien venga buscando trucos:
- La instalación y el despliegue de Valet no están en este episodio. Llegan en el siguiente, Podman, IPs reales y La Liga.
- La búsqueda por significado de verdad usa un modelo que traduce el texto
a números. El
02lo imita con un diccionario a mano para que puedas ejecutarlo sin instalar nada. - La Bitácora de verdad la escribe un modelo a partir de tus hechos y
mensajes. El
03reproduce el mecanismo (recoger el día, decidir, escribir una única entrada), no la prosa.
El repositorio
- Valet — https://github.com/atareao/valet-ai (público, licencia MIT)
| 1 | { |
| 2 | "fecha": "2026-10-18", |
| 3 | "hechos": [ |
| 4 | "Ajusté el plan de entrenamiento a la lluvia.", |
| 5 | "Decidí una tirada de 40 min en vez de 13 km." |
| 6 | ], |
| 7 | "mensajes": [ |
| 8 | "Con esta lluvia, ¿qué me recomiendas para el entrenamiento de hoy?", |
| 9 | "Apúntame que al final he cambiado la tirada larga por 40 minutos." |
| 10 | ], |
| 11 | "animo": "buen día", |
| 12 | "logros": [ |
| 13 | "Ajustar el plan de entrenamiento a la lluvia", |
| 14 | "Decidir una tirada de 40 min en vez de 13 km" |
| 15 | ], |
| 16 | "dificultades": ["Condiciones adversas"], |
| 17 | "etiquetas": ["entrenamiento", "meteorología", "decisiones"] |
| 18 | } |
| 19 |
| 1 | { |
| 2 | "fecha": "2026-10-19", |
| 3 | "hechos": [], |
| 4 | "mensajes": [], |
| 5 | "animo": "sin determinar", |
| 6 | "logros": [], |
| 7 | "dificultades": [], |
| 8 | "etiquetas": [] |
| 9 | } |
| 10 |
| 1 | { |
| 2 | "fecha": "2026-10-20", |
| 3 | "hechos": [ |
| 4 | "Finalmente pude salir a correr tras varios días de lluvia.", |
| 5 | "Completé 7,51 km en 51:42." |
| 6 | ], |
| 7 | "mensajes": [ |
| 8 | "¿Cómo llevo la semana de entrenamiento?", |
| 9 | "Apunta la salida de hoy: 7,51 km en 51:42." |
| 10 | ], |
| 11 | "animo": "buen día", |
| 12 | "logros": ["Retomar el entrenamiento tras la lluvia"], |
| 13 | "dificultades": [], |
| 14 | "etiquetas": ["entrenamiento", "running"] |
| 15 | } |
| 16 |