diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..2455710 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,87 @@ +# Contribuir + +JARVIS es software libre (GPL-3.0) y se agradecen los commits. Esta guía dice +cómo está montado, cómo probarlo y cómo añadir lo más común. Las ideas acotadas +están en [ROADMAP.md](ROADMAP.md). + +## Cómo está montado + +``` +nucleo/jarvis.py el bucle: oír → pensar → hacer → hablar +nucleo/oido/ whisper.cpp (STT) y la captura del micrófono +nucleo/cerebro/ Ollama, tool-calling, el RAG y la elección de modelo +nucleo/boca/ Piper / voz clonada (TTS) +nucleo/manos/ las acciones, el diario, el sudo, la fonética +nucleo/cara/ el panel web (servidor WebSocket + HUD en cara/hud/) +nucleo/saber/ el RAG: indexa, busca, y enriquece/ para mejorarlo +config/ scripts: modelo, voz, whisper, ventanas +``` + +Nada de esto necesita compilar: es Python, bash y una página web. Se edita y se +prueba en el sitio. + +## Probarlo + +```bash +./install.sh # entorno, modelo de Ollama, lanzador de escritorio +./nucleo/arranca.sh # el asistente y el panel en 127.0.0.1 +``` + +El panel se puede abrir **sin arrancar nada**, para trabajar el diseño: +`nucleo/cara/hud/index.html` en un navegador se llena solo con datos de ejemplo. + +Hay dos suites de comprobaciones (`nucleo/pruebas/`) y un eval del RAG +(`nucleo/saber/enriquece/evalua.py`, mide `hit@k`). Si tocas el RAG, corre el +eval y pon los números en el commit. + +## Añadir una acción + +Una acción es una entrada en `nucleo/datos/acciones.json`: + +```json +{ + "id": "brillo_sube", + "grupo": "sistema", + "frases": ["sube el brillo", "más brillo", "más luz"], + "acuse": "Voy, señor.", + "comando": "brightnessctl set +10%", + "host": true, + "respuesta": "Brillo al {campo1}, señor.", + "confirmar": false, + "captura": false +} +``` + +- `frases`: cómo se dice, con variantes. Se emparejan ANTES de llamar al modelo, + así que una acción del catálogo es instantánea. +- `comando`: shell. `{campoN}` en `respuesta` es el enésimo campo de la salida. +- `confirmar: true` para lo que borra o cambia; `captura: true` si necesita un + argumento hablado ("busca el fichero **X**"). + +Si el comando depende de una herramienta que puede no estar, que degrade con +gracia (mira `config/ventanas.sh`). + +## Probar con otro modelo + +```bash +JARVIS_MODELO=qwen3.5:9b ./nucleo/arranca.sh +``` + +O, en marcha, dile a JARVIS "usa un modelo más potente" (usa la herramienta +`elegir_modelo`). Para medir si un modelo hace bien el tool-calling, mira el +patrón del eval del RAG y adáptalo. + +## Un backend de ventanas nuevo (Wayland u otro) + +`config/ventanas-wayland.sh` es el ejemplo: detecta el compositor y traduce los +verbos (maximiza, izquierda, centro...) a sus órdenes. Para KDE, GNOME Wayland u +otro, se copia el patrón. Actúa sobre la ventana ACTIVA (en Wayland no se mueven +ventanas ajenas). Prueba cada orden en tu compositor antes del commit. + +## Estilo + +- Registro técnico y conciso, en castellano. Los comentarios explican el + **porqué**, no el qué. +- Los commits describen la decisión, no solo el cambio. +- Sin datos personales en el repo: los apuntes indexados, el diario y la voz de + referencia son del usuario (ver [PRIVACIDAD.md](PRIVACIDAD.md)). diff --git a/README.md b/README.md index d1094ef..761fc08 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,10 @@ preguntas de pentesting, la recuperación sube de **hit@1 48 % → 82 %** y **hit@5 77 % → 95 %**. El método, el pipeline y esos números están en [docs/rag.md](docs/rag.md). +Y **JARVIS elige su propio cerebro**: si una pregunta es más dura de lo normal +puede cambiar a un modelo más potente (`elegir_modelo`), y volver al rápido para +lo simple. Dile "usa un modelo más potente" o fija uno con `JARVIS_MODELO`. + ## Órdenes con root — importante Algunas órdenes que gestionan el sistema (instalar un paquete, reiniciar un @@ -148,6 +152,14 @@ Proyecto en desarrollo. En el radar: acabar de generalizar los scripts de `voz/` **Wayland** (hoy solo X11), y el fine-tuning de `entrena/`, pendiente de un ajuste para modelos multimodales. +## Contribuir + +Se agradecen los commits: es software libre y hay mucho por hacer. Las ideas +acotadas están en [ROADMAP.md](ROADMAP.md) (más gestores de ventanas, más +acciones, probar otros modelos, el RAG...) y cómo empezar en +[CONTRIBUTING.md](CONTRIBUTING.md). No hay que compilar nada: es Python, bash y +una página web. + ## Licencia [GPL-3.0](LICENSE). Software libre y copyleft: si lo modificas y lo distribuyes, diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..f0f12c8 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,80 @@ +# Roadmap + +Cosas por hacer, ordenadas por dónde aporta más un commit. No es una lista de +deseos: cada punto está acotado para que se pueda coger, hacer y medir. Si algo +te interesa, [CONTRIBUTING.md](CONTRIBUTING.md) dice cómo probarlo. + +## Ventanas en más gestores + +El motor de ventanas es X11 (`config/ventanas.sh`, con `xdotool`). En Wayland ya +hay un backend para **Sway e Hyprland** (`config/ventanas-wayland.sh`), pero está +a medias: + +- **Terminar Sway/Hyprland.** `maximiza`, `restaura` y `minimiza` son fiables; + las mitades (izquierda/derecha/centro) están escritas de la documentación y + **necesitan probarse** en un equipo real —el flotante y las unidades (`ppt`, + `%`) cambian por versión—. Es un primer commit ideal si usas uno de los dos. +- **KDE Plasma (Wayland).** Vía `kdotool` o scripting de KWin. Falta el backend. +- **GNOME Wayland.** No hay API general para mover ventanas ajenas (es su modelo + de seguridad); requiere una extensión. Documentar la limitación o escribir el + puente a una extensión. +- **Mover entre pantallas y a escritorios virtuales** en Wayland: el X11 lo hace, + los backends de Wayland aún no. + +## Más acciones + +El catálogo son 150 órdenes (`nucleo/datos/acciones.json`). Cada una es JSON: +frases que la disparan, un comando de shell, y una plantilla de respuesta. Añadir +una es un commit pequeño y satisfactorio. Ideas que faltan: + +- **Multimedia**: play/pausa, siguiente, volumen (`playerctl`, `wpctl`). +- **Brillo** de pantalla (`brightnessctl`). +- **Portapapeles**: leer/copiar (`wl-clipboard` / `xclip`). +- **Capturas** de pantalla (`grim`, `spectacle`, `gnome-screenshot`). +- **Energía**: bloquear, suspender, apagar (con confirmación). +- **Red**: a qué wifi conectar (`nmcli`). +- **Notificaciones** recientes, calendario, temporizadores. + +Al añadir acciones que dependan de una herramienta, degradar con gracia si no +está (como hacen las de ventanas). + +## Probar con otros modelos + +JARVIS ya puede **elegir su modelo** en caliente (herramienta `elegir_modelo`, y +`JARVIS_MODELO` para fijarlo). Lo que falta es saber cuáles van bien: + +- **Medir el tool-calling** de más modelos locales (llama, mistral, gemma, + phi...) con el mismo criterio del RAG (`nucleo/saber/enriquece/eval_set.jsonl` + da la idea). Cuáles emiten `tool_calls` fiables y cuáles no. +- **Auto-escalado**: que suba a un modelo potente solo cuando la pregunta lo + pida, y vuelva al rápido. Hoy lo decide el propio modelo; se puede afinar. +- **Un modelo pensado para voz**: respuestas cortas, sin divagar. + +## El RAG + +- **Reranker**: un cross-encoder sobre los primeros k. Medido que un embedder + transformer no compensa (ver [docs/rag.md](docs/rag.md)); un reranker es la + otra palanca sin probar. +- **Corpus de demostración público** para poder ver el RAG funcionando sin los + apuntes privados de nadie (man pages, docs de una herramienta libre). +- **Troceado que mantenga el bloque de comandos con su encabezado** (hoy separa + algún comando de su contexto). + +## Voz + +- **Más idiomas.** Hoy es castellano de punta a punta (STT, prompt, TTS). Piper + y whisper tienen modelos de otros idiomas; falta parametrizarlo. +- **Fine-tuning de la voz** (Piper/VITS) para clonar mejor con poco audio. + +## Fine-tuning del modelo + +`entrena/` tiene el pipeline QLoRA, bloqueado porque `qwen3.5` es multimodal y +`FastLanguageModel` lo mete por el camino de solo texto. El arreglo apunta a +`FastVisionModel` con `finetune_vision_layers=False`. Sin probar. + +## Empaquetado + +- **Perfiles de hardware** en `install.sh` (4 GB / 12 GB / solo-CPU) que ajusten + el modelo y el contexto solos. +- **Detección de rutas** más fina en los scripts de `config/` y `voz/`, que aún + asumen parte de la disposición del autor. diff --git a/config/ventanas-wayland.sh b/config/ventanas-wayland.sh new file mode 100755 index 0000000..6d1e368 --- /dev/null +++ b/config/ventanas-wayland.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Backend de ventanas para Wayland (Sway e Hyprland). ventanas.sh delega aqui +# cuando detecta uno de esos compositores. +# +# Por que un fichero aparte y no dentro de ventanas.sh: aquel es X11 puro +# (xdotool, xrandr) y esta probado; mezclar aqui lo romperia. Cada backend en su +# sitio, y este se puede iterar sin tocar el que ya funciona. +# +# En Wayland NO se pueden mover ventanas AJENAS sin permisos del compositor, asi +# que esto actua sobre la ventana ACTIVA (la que tiene el foco), que es lo que +# el compositor si deja hacer. Por eso ignora el que si usa el X11. +# +# ── ESTADO ────────────────────────────────────────────────────────────────── +# +# maximiza / restaura / minimiza: confiables (pantalla completa y scratchpad son +# ordenes de una linea, bien documentadas en los dos compositores). +# izquierda / derecha / centro: escritas de la documentacion, A PROBAR en un +# equipo real —el flotante y las unidades (ppt, %) varian por version—. Es un +# primer commit ideal para quien use Sway o Hyprland. Ver ROADMAP.md. +set -uo pipefail + +verbo=${1:-} + +if command -v swaymsg >/dev/null 2>&1 && swaymsg -t get_version >/dev/null 2>&1; then + COMP=sway +elif command -v hyprctl >/dev/null 2>&1 && hyprctl version >/dev/null 2>&1; then + COMP=hypr +else + echo "no encuentro Sway ni Hyprland en marcha"; exit 1 +fi + +sway() { swaymsg "$@" >/dev/null 2>&1; } +hypr() { hyprctl dispatch "$@" >/dev/null 2>&1; } + +case "$verbo:$COMP" in + # --- pantalla completa: confiable --- + maximiza:sway) sway fullscreen enable; echo "Maximizada, señor" ;; + maximiza:hypr) hypr fullscreen 1; echo "Maximizada, señor" ;; + restaura:sway) sway fullscreen disable; echo "Restaurada, señor" ;; + restaura:hypr) hypr fullscreen 0; echo "Restaurada, señor" ;; + minimiza:sway) sway move scratchpad; echo "Minimizada, señor" ;; + minimiza:hypr) hypr movetoworkspacesilent special; echo "Minimizada, señor" ;; + + # --- mitades y centro: A PROBAR (unidades/flotante dependen de la version) --- + izquierda:sway) sway floating enable; sway resize set width 50 ppt height 100 ppt; sway move absolute position 0 0; echo "A la izquierda, señor" ;; + derecha:sway) sway floating enable; sway resize set width 50 ppt height 100 ppt; sway move absolute position 50 ppt 0; echo "A la derecha, señor" ;; + centro:sway) sway floating enable; sway resize set width 70 ppt height 80 ppt; sway move absolute position center; echo "Al centro, señor" ;; + izquierda:hypr) hypr setfloating; hypr resizeactive exact 50% 100%; hypr moveactive exact 0 0; echo "A la izquierda, señor" ;; + derecha:hypr) hypr setfloating; hypr resizeactive exact 50% 100%; hypr moveactive exact 50% 0; echo "A la derecha, señor" ;; + centro:hypr) hypr setfloating; hypr resizeactive exact 70% 80%; hypr centerwindow; echo "Al centro, señor" ;; + + *) + echo "en $COMP todavia no esta esa orden de ventanas. Implementadas:" \ + "maximiza, restaura, minimiza, izquierda, derecha, centro." \ + "Las demas (mover entre pantallas, escritorios) estan en el roadmap." + exit 1 ;; +esac diff --git a/config/ventanas.sh b/config/ventanas.sh index 92b7b72..71a8edf 100755 --- a/config/ventanas.sh +++ b/config/ventanas.sh @@ -33,12 +33,24 @@ ALIAS_FICHERO=${JARVIS_ALIAS_PANTALLAS:-$HOME/.config/jarvis-pantallas.json} hay_x11() { [ -n "${DISPLAY:-}" ] && command -v xdotool >/dev/null 2>&1; } if ! hay_x11; then - # Diagnostico segun el entorno, no un "no X11" seco. - if [ "${XDG_SESSION_TYPE:-}" = "wayland" ] || [ -n "${WAYLAND_DISPLAY:-}" ]; then - echo "las acciones de ventanas usan xdotool (X11) y estas en Wayland" \ - "(${XDG_CURRENT_DESKTOP:-tu compositor}). En Wayland mover ventanas" \ - "ajenas depende del compositor; por ahora estas ordenes quedan fuera." \ - "El resto de JARVIS funciona igual." + en_wayland() { [ "${XDG_SESSION_TYPE:-}" = "wayland" ] || [ -n "${WAYLAND_DISPLAY:-}" ]; } + # En Wayland con Sway o Hyprland, delega en el backend propio (actua sobre la + # ventana activa). El verbo va en $1 y para las ordenes de ventana el destino + # (izquierda/derecha/centro/maximiza...) suele venir en $3; se le pasa el que + # tenga sentido. + if en_wayland && (command -v swaymsg >/dev/null 2>&1 || command -v hyprctl >/dev/null 2>&1); then + BACKEND="$(dirname "${BASH_SOURCE[0]:-$0}")/ventanas-wayland.sh" + case "${1:-}" in + zona) exec bash "$BACKEND" "${3:-centro}" ;; # zona + maximiza|restaura|minimiza) exec bash "$BACKEND" "$1" ;; + *) echo "en Wayland de momento van: maximiza, restaura, minimiza," \ + "y mitades (izquierda/derecha/centro). El resto, en el roadmap."; exit 1 ;; + esac + fi + if en_wayland; then + echo "estas en Wayland (${XDG_CURRENT_DESKTOP:-tu compositor}) sin Sway ni" \ + "Hyprland: mover ventanas ajenas no tiene via universal ahi. El resto" \ + "de JARVIS funciona igual. Soporte por compositor: ver ROADMAP.md." elif [ -n "${DISPLAY:-}" ]; then echo "estas en X11 pero falta xdotool. Instalalo con tu gestor de paquetes." else diff --git a/docs/rag.md b/docs/rag.md index 4ead34a..ecf0f15 100644 --- a/docs/rag.md +++ b/docs/rag.md @@ -80,3 +80,19 @@ reproduce con `enriquece/experimento_embedder.py`. conocimiento personal; `diario.jsonl` es lo que se le dice al asistente. Son del usuario. El `.gitignore` los bloquea. Lo reproducible —el método, el código y la forma de medirlo— sí está; el corpus lo pone cada quien con sus notas. + +## Qué se publica y qué no, en concreto + +| | En el repo | +|---|---| +| El código: `indexa`, `busca`, `glosario`, `sistema`, `enriquece/` | **sí** | +| El eval (`enriquece/eval_set.jsonl`) | **sí** | +| El glosario de tareas de Linux en castellano (`glosario.py`) | **sí** (es conocimiento genérico, no personal) | +| El índice (`saber.jsonl`) y sus copias | **no** — los apuntes | +| El diario (`diario.jsonl`) | **no** — las conversaciones | +| Lo derivado (`cosecha.jsonl`, `sintesis.jsonl`) | **no** — salen de los apuntes | + +Un detalle útil: `indexa.py` en un equipo recién clonado ya construye un índice +**útil sin ningún dato privado** —de las *man pages* del sistema y del glosario—, +así que se puede ver el RAG funcionando de inmediato; los apuntes de COFRE solo +lo enriquecen si los tienes. diff --git a/nucleo/cara/panel.py b/nucleo/cara/panel.py index 522f65a..cc2f85f 100644 --- a/nucleo/cara/panel.py +++ b/nucleo/cara/panel.py @@ -262,9 +262,16 @@ class Panel: self.manda_controles() self.cara.manda("voz", {"nombre": self.boca.voz, "id": self.boca.voz}) if self.cerebro: - self.cara.manda("cerebro", {"nombre": "local", "id": "local", - "detalle": self.cerebro.modelo, - "fuera": False}) + self._manda_cerebro(self.cerebro.modelo) + # Cuando JARVIS elige otro modelo, el rotulo del boton "cerebro" se + # actualiza solo: si no, dice uno y piensa con otro. + self.cerebro.al_cambiar_modelo = self._manda_cerebro + + def _manda_cerebro(self, modelo): + corto = modelo.partition(":") + corto = corto[0] if corto[2] in ("", "latest") else corto[2] + self.cara.manda("cerebro", {"nombre": corto, "id": modelo, + "detalle": modelo, "fuera": False}) def _bucle_telemetria(self): tic = 0 diff --git a/nucleo/cerebro/ollama.py b/nucleo/cerebro/ollama.py index cb968d1..9cac6be 100644 --- a/nucleo/cerebro/ollama.py +++ b/nucleo/cerebro/ollama.py @@ -43,6 +43,16 @@ import urllib.request ENDPOINT = os.environ.get("JARVIS_OLLAMA", "http://127.0.0.1:11434") MODELO = os.environ.get("JARVIS_MODELO", "qwen3.5:4b-jarvis") + +def modelos_disponibles(endpoint=ENDPOINT) -> list: + """Los modelos que tiene Ollama en disco, para poder elegir entre ellos.""" + try: + with urllib.request.urlopen(endpoint + "/api/tags", timeout=5) as r: + datos = json.load(r) + return sorted(m["name"] for m in datos.get("models", [])) + except Exception: + return [] + # Cuantas vueltas de "llama a una herramienta -> toma el resultado" se permiten # antes de cortar. Sin tope, un modelo que se atasca pidiendo lo mismo deja al # usuario esperando para siempre. @@ -75,6 +85,12 @@ usa buscar_en_apuntes con palabras clave ANTES de contestar. Ahi estan los comandos que el usuario ya ha probado; es mejor buscarlos que inventarlos. Si la primera busqueda no da, reformula y prueba otra vez. +Eliges tu propio cerebro. Si una pregunta es mas dificil de lo normal —razonar, +codigo, varios pasos— cambia a un modelo mas potente con elegir_modelo("potente") +antes de contestarla, y para lo simple vuelve a elegir_modelo("rapido"). Si el +usuario pide un modelo concreto, cambialo. Avisa en una frase de que has +cambiado. + Tu respuesta se convierte en AUDIO y se escucha en voz alta: - Castellano siempre. Trata al usuario de usted y llamale "señor" en la primera frase. @@ -169,6 +185,32 @@ def herramientas(catalogo) -> list: }, }, }, + { + "type": "function", + "function": { + "name": "elegir_modelo", + "description": ( + "Cambia el modelo con el que piensas. Usalo cuando una " + "pregunta sea mas dificil de lo normal (razonar, codigo, " + "varios pasos) y quieras uno mas potente, o cuando el usuario " + "pida expresamente cambiar de modelo. Valores: 'rapido' " + "(ligero, para lo simple), 'potente' (mas grande, mas lento), " + "'razonador' (piensa en voz alta antes de responder), o el " + "nombre exacto de un modelo de Ollama. El cambio dura hasta " + "que lo vuelvas a cambiar. Contesta breve tras cambiar." + ), + "parameters": { + "type": "object", + "properties": { + "cual": { + "type": "string", + "description": "rapido | potente | razonador | nombre exacto", + } + }, + "required": ["cual"], + }, + }, + }, { "type": "function", "function": { @@ -203,6 +245,57 @@ class Cerebro: self.capas = 0 if en_ram else None self.manos = manos # a quien pedirle que ejecute self.historia = [] + # A quien avisar cuando JARVIS cambia de modelo, para que el panel + # actualice el rotulo del boton "cerebro". Lo conecta el panel. + self.al_cambiar_modelo = None + + # Atajos hablados -> familia de modelo. El modelo elige por INTENCION + # ("potente") y aqui se traduce a lo que hay instalado, para no obligarle a + # saberse los nombres exactos. Se resuelve contra lo que de verdad hay en + # Ollama, cogiendo el mayor/menor de la familia qwen que exista. + def _resuelve_modelo(self, cual: str) -> str | None: + cual = (cual or "").strip().lower() + hay = modelos_disponibles(self.endpoint) + if not hay: + return None + # nombre exacto o casi (permite "9b" -> "qwen3.5:9b") + for m in hay: + if cual == m.lower() or cual == m.lower().split(":")[-1]: + return m + qwen = sorted(m for m in hay if "qwen" in m.lower()) + def por_tam(sufijo): + return next((m for m in qwen if m.lower().endswith(sufijo)), None) + if cual in ("rapido", "ligero", "pequeno", "pequeño"): + return por_tam(":2b") or por_tam(":4b-jarvis") or (qwen[0] if qwen else None) + if cual in ("potente", "grande", "mejor", "fuerte"): + return por_tam(":9b") or (qwen[-1] if qwen else None) + if cual in ("razonador", "razona", "piensa", "deepseek"): + return next((m for m in hay if "deepseek" in m.lower() or "-r1" in m.lower()), None) + # por defecto, el de trabajo del naranja si existe + return por_tam(":4b-jarvis") or (qwen[0] if qwen else None) + + @staticmethod + def _nombre_corto(m: str) -> str: + # "qwen3.5:9b" -> "9b"; "deepseek-r1:latest" -> "deepseek-r1" + base, _, tag = m.partition(":") + return base if tag in ("", "latest") else tag + + def cambia_modelo(self, cual: str) -> str: + """Cambia el cerebro en caliente. Devuelve lo que decir tras cambiar.""" + nuevo = self._resuelve_modelo(cual) + if not nuevo: + return f"no tengo ningun modelo que encaje con «{cual}», señor." + if nuevo == self.modelo: + return f"ya estoy pensando con {self._nombre_corto(nuevo)}, señor." + self.modelo = nuevo + if self.al_cambiar_modelo: + try: + self.al_cambiar_modelo(nuevo) + except Exception: + pass + # Ojo: un modelo mas grande no cabe en la grafica pequeña y tirara de CPU + # —mas lento pero mas capaz—; eso es cosa de Ollama, no de aqui. + return f"Cambiado a {self._nombre_corto(nuevo)}, señor." def _saber(self, texto: str) -> str: """Los apuntes de COFRE que vengan a cuento, para meterlos al prompt. @@ -283,6 +376,8 @@ class Cerebro: # toca la maquina, asi que funciona aunque el candado este echado. if nombre == "buscar_en_apuntes": return self._busca_apuntes(args.get("consulta", "")) + if nombre == "elegir_modelo": + return self.cambia_modelo(args.get("cual", "")) if self.manos is None: return "no hay manos conectadas" if nombre == "orden_del_catalogo":