From 697e83528783d69437281f95dbd3d27bc67ea06a Mon Sep 17 00:00:00 2001 From: minguezsanzjuanjose Date: Sun, 4 Oct 2026 17:26:23 +0200 Subject: [PATCH] 04-oct: [conocimiento] sistema tareas hijas (Drive) + helpers + bot Ines VPS documentado --- docs/tareas-hijas-google-drive.md | 109 ++++++++++++++++++++ scripts/tareas_hijas/drive.py | 161 ++++++++++++++++++++++++++++++ 2 files changed, 270 insertions(+) create mode 100644 docs/tareas-hijas-google-drive.md create mode 100644 scripts/tareas_hijas/drive.py diff --git a/docs/tareas-hijas-google-drive.md b/docs/tareas-hijas-google-drive.md new file mode 100644 index 0000000..b1e248e --- /dev/null +++ b/docs/tareas-hijas-google-drive.md @@ -0,0 +1,109 @@ +# Sistema de tareas de las hijas (Inés y Carolina) — Google Drive + IA + +Documentación para **reproducir** el sistema en cualquier equipo. Última revisión: 04-oct-2026 (portátil). + +--- + +## 1. Qué es + +Ecosistema para gestionar las tareas escolares de las hijas: +- Los **correos de Classroom de Inés** se vigilan y avisan por Telegram. +- Las **tareas entregadas** (fotos/escaneos) se suben a **Google Drive** por asignatura. +- El asistente (opencode/IA) **lee** los documentos, **corrige** y **genera hojas nuevas** de repaso. + +## 2. Cuentas y credenciales + +| Elemento | Valor | +|----------|-------| +| Google Drive (personal) | `juan.minguez.sanz@gmail.com` | +| Proyecto Google Cloud | `tareasnenas` | +| Client secret | `biblioteca_negocio_prolongo/google/client_secret_personal.json` | +| Token OAuth | `biblioteca_negocio_prolongo/google/token_personal.json` | +| Scopes | `drive`, `gmail.readonly`, `documents` | +| Correo escolar de Inés | `ines.minguezlarhziale@alumnos.fundacionvictoria.edu.es` | + +> El `token_personal.json` **se refresca solo** al usarlo (guardar el fichero tras refrescar). Acceso válido desde portátil y sobremesa sin depender del otro equipo. + +## 3. Estructura en Google Drive + +``` +TAREAS/ +├── CAROLINA/ +│ ├── LENGUA/ +│ │ ├── Por hacer/ ← hojas en blanco (SIN soluciones) para que las haga +│ │ └── Respondido/ ← lo ya entregado (escaneos/PDF) y el modelo hecho +│ └── MATEMATICAS/ +└── INES/ + ├── FISICA Y QUÍMICA// → Doc "FEEDBACK ..." + fotos + ├── HISTORIA Y GEOGRAFÍA/ → Doc "Tareas - HISTORIA Y GEOGRAFÍA" + ├── LENGUA/ → Doc "Tareas - LENGUA" + ├── MATEMÁTICAS/ · INGLÉS/ · E.F/ · MÚSICA/ · PLÁSTICA/ · RELIGIÓN/ · TECNOLOGÍA/ · VALORES/ +``` + +### Convención de organización (IMPORTANTE) +- Cada asignatura tiene subcarpetas **`Por hacer`** (pendiente) y **`Respondido`** (hecho). +- Cuando algo se responde, se mueve de `Por hacer` a `Respondido` (no se deja mezclado). +- Las hojas para las niñas van **sin soluciones**; el asistente guarda las respuestas correctas para corregir. + +### Formato de los docs "Tareas - " +``` +TAREAS INES - +Profesor/a: +Actualizado: DD/MM/AAAA + +TAREAS Y ACTIVIDADES +======================================== +[Pendiente|Pasada|Revisar] + Tipo: Anuncio|Tarea|Material | Fecha: DD mes AAAA +``` + +## 4. Bot del VPS (Inés) — `classroom-watcher` + +Servicio systemd en el VPS Contabo que vigila el correo de Inés. + +| Dato | Valor | +|------|-------| +| Ruta | `/opt/classroom_watcher/` | +| Script | `classroom_watcher.py` | +| Servicio | `classroom-watcher.service` (activo, enabled) | +| Ficheros | `token_personal.json`, `client_secret_personal.json`, `.env` (`TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID`), `seen_messages.json`, `pending_alerts.json`, `watcher.log` | +| Filtro Gmail | `from:ines.minguezlarhziale classroom (tarea OR "Nueva tarea" OR "fecha de entrega" OR "Nuevo anuncio")` | +| Horario avisos | 15:00–22:00 | +| Revisión Gmail | cada 5 min | +| Reaviso | cada 30 min hasta que el usuario responde **S** | +| Asignatura | `detectar_asignatura()` mapea profesor→asignatura y si no, por asunto | + +**Estado actual:** solo **avisa por Telegram**. **Pendiente:** volcar los correos/tareas a Google Drive (es lo que faltaba). + +### Gestión del servicio (en el VPS) +```bash +systemctl status classroom-watcher +systemctl restart classroom-watcher +journalctl -u classroom-watcher -n 50 --no-pager +tail -n 50 /opt/classroom_watcher/watcher.log +``` + +### Otros servicios relacionados en el VPS +- `/opt/email-watcher/` (`email-watcher.service`, **disabled**) — incidencia EmailService FaccsaMissa de `jminguez@prolongo.es` → grupo IDD Telegram (ámbito empresa, NO hijas). + +## 5. Spec 004 (diseño completo, en pausa) + +`biblioteca_negocio_prolongo/specs/004-monitor-correo-ines/` (spec + plan + tasks): +- Flujo A: resumen IA de correos escolares → Discord DM de Inés + aviso Telegram al padre. +- Flujo B: entrega de foto en Drive → evaluación IA → mueve a "hechas" o avisa corrección. +- Prerequisitos manuales listados en `plan.md` (Azure AD Graph, Service Account, bot Discord...). + +## 6. Cómo reproducirlo en otro equipo + +1. Clonar `biblioteca_conocimiento_laboratorio` y `biblioteca_negocio_prolongo`. +2. Asegurar `google/client_secret_personal.json` y `google/token_personal.json` (o regenerar con `python google/google_auth.py`). +3. Instalar deps: `pip install google-api-python-client google-auth-oauthlib pymupdf`. +4. Usar `scripts/tareas_hijas/drive.py` (helpers) para listar/leer/crear/mover en Drive. +5. Para el bot: desplegar `/opt/classroom_watcher` en el VPS + `classroom-watcher.service` (ver §4). +6. Para corregir: descargar los PDF/imágenes, convertirlos a PNG (`pymupdf`) y analizarlos con el MCP **image-vision**; corregir contra la hoja. + +## 7. Notas / gotchas +- La consola Windows falla con acentos (`charmap`): usar `python -X utf8` o exportar a fichero. +- Para crear Google Docs desde HTML: `files().create(mimeType='application/vnd.google-apps.document', media=text/html)`. +- Quitar soluciones de un Doc existente: `documents().batchUpdate` con `deleteContentRange` desde la cabecera "SOLUCIONES". +- Mover ficheros: `files().update(fileId, addParents, removeParents)`. diff --git a/scripts/tareas_hijas/drive.py b/scripts/tareas_hijas/drive.py new file mode 100644 index 0000000..f6570f1 --- /dev/null +++ b/scripts/tareas_hijas/drive.py @@ -0,0 +1,161 @@ +# -*- coding: utf-8 -*- +"""drive.py — Helpers para el sistema de tareas de las hijas (Google Drive). + +Autentica con el token personal (proyecto Cloud "tareasnenas") y ofrece +utilidades para listar, leer, descargar, crear y mover ficheros en Drive. + +Uso rapido: + python scripts/tareas_hijas/drive.py list "TAREAS/CAROLINA/LENGUA" + python scripts/tareas_hijas/drive.py read # exporta texto de un Google Doc + python scripts/tareas_hijas/drive.py pdf + python scripts/tareas_hijas/drive.py make "Nombre doc" fichero.html + python scripts/tareas_hijas/drive.py move + +Notas: +- Ejecutar con `python -X utf8` en Windows (evita errores de acentos). +- El token se refresca solo y se re-guarda en disco. +""" +from __future__ import annotations + +import io +import sys +from pathlib import Path + +from google.oauth2.credentials import Credentials +from google.auth.transport.requests import Request +from googleapiclient.discovery import build +from googleapiclient.http import MediaIoBaseDownload, MediaIoBaseUpload + +# Ruta por defecto al token personal dentro del repo de negocio. +DEFAULT_TOKEN = Path( + r"C:\Users\juanm\Documents\GitHub\biblioteca_negocio_prolongo\google\token_personal.json" +) + + +def _creds(token_path: Path = DEFAULT_TOKEN): + creds = Credentials.from_authorized_user_file(str(token_path)) + if creds.expired and creds.refresh_token: + creds.refresh(Request()) + token_path.write_text(creds.to_json(), encoding="utf-8") + return creds + + +def drive(token_path: Path = DEFAULT_TOKEN): + return build("drive", "v3", credentials=_creds(token_path), static_discovery=False) + + +def docs(token_path: Path = DEFAULT_TOKEN): + return build("docs", "v1", credentials=_creds(token_path), static_discovery=False) + + +def find_folder(name: str, parent: str | None = None, svc=None) -> str: + """Devuelve el id de la primera carpeta con ese nombre (opcionalmente bajo `parent`).""" + svc = svc or drive() + q = f"name='{name}' and mimeType='application/vnd.google-apps.folder' and trashed=false" + if parent: + q += f" and '{parent}' in parents" + r = svc.files().list(q=q, fields="files(id,name)").execute() + files = r.get("files", []) + if not files: + raise SystemExit(f"[ERROR] Carpeta no encontrada: {name}") + return files[0]["id"] + + +def list_folder(fid: str, svc=None): + svc = svc or drive() + r = svc.files().list( + q=f"'{fid}' in parents and trashed=false", + fields="files(id,name,mimeType,size,modifiedTime)", orderBy="folder,name", + ).execute() + for f in r.get("files", []): + kind = "DIR " if f["mimeType"] == "application/vnd.google-apps.folder" else "file" + print(f" {kind} {f['name']} id={f['id']}") + + +def read_doc(fid: str, svc=None) -> str: + svc = svc or drive() + return svc.files().export(fileId=fid, mimeType="text/plain").execute().decode("utf-8") + + +def download(fid: str, out: Path, svc=None): + svc = svc or drive() + req = svc.files().get_media(fileId=fid) + with io.FileIO(out, "wb") as fh: + dl = MediaIoBaseDownload(fh, req) + done = False + while not done: + _, done = dl.next_chunk() + + +def create_doc_from_html(name: str, parent: str, html: str, svc=None) -> dict: + svc = svc or drive() + media = MediaIoBaseUpload(io.BytesIO(html.encode("utf-8")), mimetype="text/html", resumable=False) + return svc.files().create( + body={"name": name, "parents": [parent], "mimeType": "application/vnd.google-apps.document"}, + media_body=media, fields="id,name,webViewLink", + ).execute() + + +def move(fid: str, new_parent: str, old_parent: str | None = None, svc=None): + svc = svc or drive() + return svc.files().update( + fileId=fid, addParents=new_parent, removeParents=old_parent, fields="id,parents" + ).execute() + + +def trash(fid: str, svc=None): + svc = svc or drive() + return svc.files().update(fileId=fid, body={"trashed": True}).execute() + + +def strip_solutions(doc_id: str, marker: str = "SOLUCIONES", svc=None): + """Borra todo el contenido de un Google Doc desde la cabecera `marker` hasta el final.""" + svc = svc or docs() + d = svc.documents().get(documentId=doc_id).execute() + start = None + for el in d["body"]["content"]: + para = el.get("paragraph") + if not para: + continue + text = "".join(r.get("textRun", {}).get("content", "") for r in para.get("elements", [])) + if marker in text: + start = el["startIndex"] + break + if start is None: + print("[aviso] No se encontró el marcador:", marker) + return + end = d["body"]["content"][-1]["endIndex"] - 1 + if end > start: + svc.documents().batchUpdate(documentId=doc_id, body={"requests": [ + {"deleteContentRange": {"range": {"startIndex": start, "endIndex": end}}}]}).execute() + print("[ok] Contenido desde '%s' eliminado" % marker) + + +def _main(argv): + if not argv: + print(__doc__) + return + cmd = argv[0] + if cmd == "list": + list_folder(argv[1] if len(argv) > 1 else find_folder("TAREAS")) + elif cmd == "find": + print(find_folder(argv[1], argv[2] if len(argv) > 2 else None)) + elif cmd == "read": + print(read_doc(argv[1])) + elif cmd == "pdf": + download(argv[1], Path(argv[2])) + print("descargado", argv[2]) + elif cmd == "make": + html = Path(argv[3]).read_text(encoding="utf-8") + print(create_doc_from_html(argv[1], argv[2], html)["webViewLink"]) + elif cmd == "move": + move(argv[1], argv[2], argv[3] if len(argv) > 3 else None) + print("movido") + elif cmd == "strip": + strip_solutions(argv[1]) + else: + print(__doc__) + + +if __name__ == "__main__": + _main(sys.argv[1:])