04-oct: [conocimiento] sistema tareas hijas (Drive) + helpers + bot Ines VPS documentado
This commit is contained in:
109
docs/tareas-hijas-google-drive.md
Normal file
109
docs/tareas-hijas-google-drive.md
Normal file
@@ -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/<Tema>/ → 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 - <asignatura>"
|
||||||
|
```
|
||||||
|
TAREAS INES - <ASIGNATURA>
|
||||||
|
Profesor/a: <nombre>
|
||||||
|
Actualizado: DD/MM/AAAA
|
||||||
|
|
||||||
|
TAREAS Y ACTIVIDADES
|
||||||
|
========================================
|
||||||
|
[Pendiente|Pasada|Revisar] <titulo>
|
||||||
|
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)`.
|
||||||
161
scripts/tareas_hijas/drive.py
Normal file
161
scripts/tareas_hijas/drive.py
Normal file
@@ -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 <DOC_ID> # exporta texto de un Google Doc
|
||||||
|
python scripts/tareas_hijas/drive.py pdf <FILE_ID> <out.pdf>
|
||||||
|
python scripts/tareas_hijas/drive.py make "Nombre doc" <FOLDER_ID> fichero.html
|
||||||
|
python scripts/tareas_hijas/drive.py move <FILE_ID> <FOLDER_ID>
|
||||||
|
|
||||||
|
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:])
|
||||||
Reference in New Issue
Block a user