educa-navarra-mcp 1.0.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +164 -67
- package/dist/browser.js +142 -6
- package/dist/config.js +20 -0
- package/dist/grades.js +126 -20
- package/dist/groups.js +39 -0
- package/dist/guide.js +165 -0
- package/dist/index.js +154 -13
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,62 +1,82 @@
|
|
|
1
1
|
<div align="center">
|
|
2
2
|
|
|
3
|
-
<img src="https://raw.githubusercontent.com/vTanco/educa-navarra-mcp/main/assets/icon.png" width="
|
|
3
|
+
<img src="https://raw.githubusercontent.com/vTanco/educa-navarra-mcp/main/assets/icon.png" width="104" alt="">
|
|
4
4
|
|
|
5
5
|
# Educa Navarra · notas con IA
|
|
6
6
|
|
|
7
|
-
**Pásale tu Excel de notas a Claude y él las mete en Educa por ti
|
|
8
|
-
Tú revisas y decides cuándo se guardan.
|
|
7
|
+
**Pásale tu Excel de notas a Claude y él las mete en Educa por ti.**<br>
|
|
8
|
+
Tú revisas la pantalla y decides cuándo se guardan.
|
|
9
9
|
|
|
10
10
|
[](https://www.npmjs.com/package/educa-navarra-mcp)
|
|
11
|
-
[](LICENSE)
|
|
11
|
+
[](https://github.com/vTanco/educa-navarra-mcp/blob/main/LICENSE)
|
|
12
12
|
[](https://github.com/vTanco/educa-navarra-mcp/actions/workflows/ci.yml)
|
|
13
13
|

|
|
14
14
|
|
|
15
|
-
[
|
|
15
|
+
**[Descargar para Claude Desktop](https://github.com/vTanco/educa-navarra-mcp/releases/latest/download/educa-navarra-mcp.mcpb)** ·
|
|
16
|
+
[Instalación](#instalación) ·
|
|
17
|
+
[Primeros pasos](#primeros-pasos) ·
|
|
18
|
+
[Preparar el Excel](https://github.com/vTanco/educa-navarra-mcp/blob/main/docs/BUENAS-PRACTICAS.md) ·
|
|
19
|
+
[Qué se ha probado](#qué-se-ha-probado) ·
|
|
20
|
+
[Preguntas](#preguntas-frecuentes)
|
|
16
21
|
|
|
17
22
|
</div>
|
|
18
23
|
|
|
19
|
-
> [
|
|
20
|
-
> **Herramienta no oficial.** No está hecha ni respaldada por el Gobierno de Navarra ni por el Departamento de Educación. Usas tu propia cuenta de Educa y eres responsable de las notas que guardes. Lee el [aviso legal](AVISO-LEGAL.md) antes de usarla.
|
|
24
|
+
> **Herramienta no oficial.** No está hecha ni respaldada por el Gobierno de Navarra ni por el Departamento de Educación. Usas tu propia cuenta de Educa y eres responsable de las notas que guardes. Lee el [aviso legal](https://github.com/vTanco/educa-navarra-mcp/blob/main/AVISO-LEGAL.md) antes de usarla.
|
|
21
25
|
|
|
22
|
-
|
|
26
|
+
<p align="center">
|
|
27
|
+
<img src="https://raw.githubusercontent.com/vTanco/educa-navarra-mcp/main/assets/flujo.svg" width="100%" alt="Cinco pasos: tu Excel, vista previa, escribe en pantalla sin guardar, tú revisas y pulsa Aceptar solo si lo confirmas.">
|
|
28
|
+
</p>
|
|
23
29
|
|
|
24
|
-
##
|
|
30
|
+
## Qué hace
|
|
25
31
|
|
|
26
32
|
| | |
|
|
27
33
|
|---|---|
|
|
28
|
-
|
|
|
29
|
-
|
|
|
30
|
-
|
|
|
31
|
-
|
|
|
32
|
-
|
|
|
33
|
-
|
|
|
34
|
+
| **Conoce tus grupos** | Lee de Educa tus grupos, agrupamientos, asignaturas y evaluaciones. No hay que escribirlos en ningún ajuste. |
|
|
35
|
+
| **Va directo a la pantalla** | *"Abre 1ºCD, 2ª evaluación"* y abre Calificaciones con el grupo, la materia y la evaluación elegidos. Basta con parte del nombre. |
|
|
36
|
+
| **Encuentra tu fichero** | Busca el Excel o CSV en tu carpeta de notas por evaluación, grupo y materia. También vale adjuntarlo en el chat. |
|
|
37
|
+
| **Cruza los nombres** | Empareja tu lista con la de Educa aunque cambien tildes, mayúsculas o el orden de nombre y apellidos. |
|
|
38
|
+
| **Enseña una vista previa** | Nota actual, nota nueva y avisos, antes de tocar nada. |
|
|
39
|
+
| **Escribe sin guardar** | Rellena las notas en la ventana de Educa para que las revises. |
|
|
40
|
+
| **Guarda solo si lo dices** | Pulsa *Aceptar* únicamente cuando lo confirmas en el chat. |
|
|
41
|
+
|
|
42
|
+
Sirve para **ESO** (IN, SU, BI, NT, SB), **Bachillerato** (nota sobre 10) y **FP de Grado Medio y Superior**: lee la escala de cada pantalla en vez de suponerla.
|
|
43
|
+
|
|
44
|
+
## Instalación
|
|
34
45
|
|
|
35
|
-
|
|
46
|
+
Elige una opción. Las tres instalan lo mismo.
|
|
36
47
|
|
|
37
|
-
|
|
48
|
+
| | Para quién | Qué necesitas |
|
|
49
|
+
|---|---|---|
|
|
50
|
+
| **A · Claude Desktop** | La mayoría de docentes | Claude Desktop. Doble clic y listo |
|
|
51
|
+
| **B · Claude Code** | Si ya usas la terminal | Node.js 20 o superior |
|
|
52
|
+
| **C · Otras aplicaciones** | Cursor, VS Code, Windsurf, LM Studio… | Node.js 20 o superior |
|
|
38
53
|
|
|
39
|
-
###
|
|
54
|
+
### A · Claude Desktop (recomendada)
|
|
40
55
|
|
|
41
56
|
1. Instala [Claude Desktop](https://claude.ai/download) si no lo tienes.
|
|
42
57
|
2. **[Descarga `educa-navarra-mcp.mcpb`](https://github.com/vTanco/educa-navarra-mcp/releases/latest/download/educa-navarra-mcp.mcpb)**.
|
|
43
|
-
3. Haz **doble clic** en el fichero
|
|
44
|
-
4.
|
|
58
|
+
3. Haz **doble clic** en el fichero. Claude Desktop se abre y pregunta si quieres instalarlo: pulsa **Instalar**.
|
|
59
|
+
4. Rellena los ajustes. Todos son opcionales:
|
|
60
|
+
|
|
61
|
+
| Ajuste | Para qué |
|
|
62
|
+
|---|---|
|
|
63
|
+
| Usuario y contraseña de Educa | Para que Claude entre solo. Si los dejas vacíos, entras tú en la ventana que se abre. La contraseña se guarda cifrada en tu ordenador. |
|
|
64
|
+
| Grupos en los que no pongo notas | Por ejemplo, si compartes docencia: `ESO 2º D, ESO 2º E`. Podrás consultarlos, pero el conector se negará a escribir en ellos. |
|
|
65
|
+
| Carpeta de notas | Donde guardas tus Excel. Deja la que sale (*Documentos/Notas Educa*) y créala si no existe. |
|
|
45
66
|
|
|
46
|
-
|
|
67
|
+
No hace falta instalar nada más: usa el **Google Chrome** o **Microsoft Edge** que ya tienes. Si no hay ninguno, descarga un navegador la primera vez (un par de minutos).
|
|
47
68
|
|
|
48
|
-
|
|
49
|
-
> Si el doble clic no funciona: en Claude Desktop ve a **Ajustes → Extensiones → Instalar extensión…** y elige el fichero.
|
|
69
|
+
Si el doble clic no funciona, en Claude Desktop ve a **Ajustes → Extensiones → Instalar extensión…** y elige el fichero. Para actualizar a una versión nueva, descarga el fichero otra vez y repite el doble clic.
|
|
50
70
|
|
|
51
|
-
###
|
|
71
|
+
### B · Claude Code
|
|
52
72
|
|
|
53
73
|
```bash
|
|
54
74
|
claude mcp add educa -- npx -y educa-navarra-mcp
|
|
55
75
|
```
|
|
56
76
|
|
|
57
|
-
###
|
|
77
|
+
### C · Otras aplicaciones compatibles con MCP
|
|
58
78
|
|
|
59
|
-
|
|
79
|
+
Añade esto a la configuración de servidores MCP de tu aplicación:
|
|
60
80
|
|
|
61
81
|
```json
|
|
62
82
|
{
|
|
@@ -70,57 +90,132 @@ Necesitas [Node.js 20 o superior](https://nodejs.org). Añade esto a la configur
|
|
|
70
90
|
```
|
|
71
91
|
|
|
72
92
|
<details>
|
|
73
|
-
<summary
|
|
93
|
+
<summary>Entrar solo en Educa con las opciones B o C</summary>
|
|
74
94
|
|
|
75
|
-
Crea el fichero `credenciales.json` en la carpeta `.educa-mcp` de tu usuario (
|
|
95
|
+
Crea el fichero `credenciales.json` en la carpeta `.educa-mcp` de tu usuario (Mac: `/Users/tu_usuario/.educa-mcp/`; Windows: `C:\Users\tu_usuario\.educa-mcp\`):
|
|
76
96
|
|
|
77
97
|
```json
|
|
78
98
|
{ "usuario": "tu_usuario_de_educa", "clave": "tu_contraseña" }
|
|
79
99
|
```
|
|
80
100
|
|
|
81
|
-
No compartas ese fichero ni lo subas a ningún sitio. También
|
|
101
|
+
No compartas ese fichero ni lo subas a ningún sitio. También valen las variables `EDUCA_USER` y `EDUCA_PASSWORD` en el bloque `env` de la configuración. El resto de ajustes, en [CONFIGURACION.md](https://github.com/vTanco/educa-navarra-mcp/blob/main/docs/CONFIGURACION.md).
|
|
82
102
|
</details>
|
|
83
103
|
|
|
84
|
-
##
|
|
104
|
+
## Primeros pasos
|
|
85
105
|
|
|
86
|
-
Escríbele a Claude con tus palabras.
|
|
106
|
+
Escríbele a Claude con tus palabras. La primera vez:
|
|
87
107
|
|
|
88
|
-
|
|
108
|
+
1. **"Actualiza mis grupos."** Entra en Educa, lee tus grupos y asignaturas y los guarda en tu ordenador. Tarda unos segundos por grupo. Repítelo cuando cambien (curso nuevo, sustitución).
|
|
109
|
+
2. **"Mete las notas de la 1ª evaluación de BTO N 1ºA, TIC."** Busca el fichero, abre la pantalla y te enseña la vista previa.
|
|
110
|
+
3. **"Vale, escríbelas."** Rellena la pantalla de Educa sin guardar. Mira la ventana del navegador.
|
|
111
|
+
4. **"Guarda."** Pulsa *Aceptar*. Si algo no te cuadra, no lo digas: pulsa tú *Cancelar* en la ventana de Educa y no se guarda nada.
|
|
89
112
|
|
|
90
|
-
|
|
91
|
-
flowchart LR
|
|
92
|
-
A["📄 Tu Excel"] --> B["🔐 Entra en Educa"]
|
|
93
|
-
B --> C["🧭 Va a Calificaciones<br/>grupo · materia · evaluación"]
|
|
94
|
-
C --> D["👀 Vista previa<br/>no cambia nada"]
|
|
95
|
-
D --> E["✍️ Escribe en pantalla<br/>sin guardar"]
|
|
96
|
-
E --> F{"¿Confirmas?"}
|
|
97
|
-
F -- "Sí" --> G["✅ Pulsa Aceptar"]
|
|
98
|
-
F -- "No" --> H["↩️ Cancelar"]
|
|
99
|
-
```
|
|
113
|
+
El cierre y la firma de la evaluación los sigues haciendo tú a mano, como siempre.
|
|
100
114
|
|
|
101
|
-
|
|
115
|
+
### Qué puedes pedirle
|
|
102
116
|
|
|
103
|
-
|
|
117
|
+
| Quieres… | Dile… |
|
|
118
|
+
|---|---|
|
|
119
|
+
| Ir directo a un grupo | *"Abre las notas de 1ºAB de Tecnología, 2ª evaluación."* |
|
|
120
|
+
| Consultar sin tocar | *"Enséñame las notas de ESO 3ºDEFH, Matemáticas. No cambies nada."* |
|
|
121
|
+
| Ver qué ficheros tienes | *"¿Qué ficheros de notas tengo?"* |
|
|
122
|
+
| Usar otra columna | *"La nota está en la columna Media T1."* |
|
|
123
|
+
| Aceptar un nombre dudoso | *"Sí, Maite Etxeberria es la misma."* |
|
|
124
|
+
| Ver la pantalla | *"Haz una captura de la pantalla."* |
|
|
125
|
+
| Aprender a preparar el Excel | *"¿Cómo preparo mi Excel de notas?"* |
|
|
126
|
+
|
|
127
|
+
En Claude Desktop tienes además tres atajos en el menú **+** del chat: **Meter notas en Educa**, **Revisar mis notas en Educa** y **Cómo preparo mi Excel de notas**.
|
|
128
|
+
|
|
129
|
+
### Nombres de grupo a medias
|
|
104
130
|
|
|
105
|
-
|
|
131
|
+
No hace falta escribir el nombre exacto de Educa. Si lo que dices encaja con un solo grupo, lo abre; si encaja con varios, te pregunta cuál.
|
|
132
|
+
|
|
133
|
+
| Tus grupos en Educa | Le dices | Abre |
|
|
134
|
+
|---|---|---|
|
|
135
|
+
| ESO 1ºAB plu, ESO 1ºCD plu, ESO 1ºEF plu | *"1ºCD"* | ESO 1ºCD plu |
|
|
136
|
+
| BTO N 1ºA, BTO N 2ºA | *"BTO N 1"* | BTO N 1ºA |
|
|
137
|
+
| ESO 1ºAB plu, ESO 1ºCD plu, ESO 1ºEF plu | *"ESO 1"* | Te pregunta cuál de los tres |
|
|
138
|
+
|
|
139
|
+
Lo mismo con la asignatura (*"Tecnología"*) y la evaluación (*"2"*, *"2a"*, *"Final"*). Si el grupo solo tiene una asignatura, no hace falta decirla.
|
|
140
|
+
|
|
141
|
+
## Tu fichero de notas
|
|
142
|
+
|
|
143
|
+
Vale casi cualquier Excel (`.xlsx`) o CSV con una fila por alumno y una columna de nota. Para que Claude lo encuentre solo:
|
|
144
|
+
|
|
145
|
+
| | Recomendación | Ejemplo |
|
|
146
|
+
|---|---|---|
|
|
147
|
+
| **Carpeta** | Una para todo, con subcarpeta por curso | `Documentos/Notas Educa/2026-27/` |
|
|
148
|
+
| **Nombre** | `EVALUACIÓN_GRUPO_MATERIA` | `1EV_BTO-1A_TIC.xlsx` |
|
|
149
|
+
| **Contenido** | Cabeceras en la primera fila, una fila por alumno | *Apellidos · Nombre · Nota* |
|
|
106
150
|
|
|
107
151
|
| Apellidos | Nombre | Nota |
|
|
108
152
|
|---|---|---|
|
|
109
153
|
| García López | Ana | 7 |
|
|
110
154
|
| Pérez Martínez | Iñigo | 9 |
|
|
155
|
+
| Etxeberria Goñi | Maite | |
|
|
111
156
|
|
|
112
|
-
- Si tus columnas se llaman de otra forma, díselo
|
|
157
|
+
- Si tus columnas se llaman de otra forma, díselo: *"la nota está en la columna Media T1"*.
|
|
113
158
|
- Los alumnos con la nota en blanco se saltan.
|
|
114
|
-
- **No redondea por ti**: si una nota no
|
|
159
|
+
- **No redondea por ti**: si una nota no vale para esa enseñanza, avisa y no la escribe.
|
|
160
|
+
|
|
161
|
+
Todos los consejos, con ejemplos para interinos que cambian de centro, en la **[guía para preparar los Excel](https://github.com/vTanco/educa-navarra-mcp/blob/main/docs/BUENAS-PRACTICAS.md)**.
|
|
162
|
+
|
|
163
|
+
### Así es la vista previa
|
|
164
|
+
|
|
165
|
+
<p align="center">
|
|
166
|
+
<img src="https://raw.githubusercontent.com/vTanco/educa-navarra-mcp/main/assets/vista-previa.svg" width="100%" alt="Ejemplo de vista previa con datos inventados: tabla con alumno en el fichero, alumno en Educa, nota actual, nota nueva y estado.">
|
|
167
|
+
</p>
|
|
168
|
+
|
|
169
|
+
Los nombres que solo coinciden en parte se marcan como *aproximados* y no se escriben salvo que lo confirmes. También avisa de alumnos del fichero que no están en Educa y de alumnos de Educa que no tienen nota en el fichero.
|
|
170
|
+
|
|
171
|
+
## Cualquier etapa
|
|
115
172
|
|
|
116
|
-
|
|
173
|
+
El conector no supone cómo se califica: lee la pantalla de Educa abierta y se adapta. Vale igual si este curso das ESO y el siguiente un ciclo formativo.
|
|
174
|
+
|
|
175
|
+
| Lo que ofrece Educa | Qué acepta de tu fichero |
|
|
176
|
+
|---|---|
|
|
177
|
+
| Desplegable con términos (ESO): IN, SU, BI, NT, SB | El término (*NT*, *Notable*) o un número entero: 0–4 IN · 5 SU · 6 BI · 7–8 NT · 9–10 SB |
|
|
178
|
+
| Nota tecleada (Bachillerato) | Entero de 0 a 10 |
|
|
179
|
+
| Nota tecleada en un ciclo (FP) | Entero de 1 a 10 |
|
|
180
|
+
| Desplegable Apto / No apto | El término; rechaza números |
|
|
181
|
+
| Otros valores (FPEX, EX, NP…) | Solo si la pantalla los ofrece |
|
|
182
|
+
|
|
183
|
+
La vista previa dice siempre qué escala ha detectado. Normativa de referencia (LOMLOE y órdenes forales de Navarra) en [normativa-evaluacion.md](https://github.com/vTanco/educa-navarra-mcp/blob/main/docs/normativa-evaluacion.md).
|
|
184
|
+
|
|
185
|
+
## Qué hace solo y qué necesita tu permiso
|
|
186
|
+
|
|
187
|
+
| Lo hace solo | Necesita que lo confirmes | No lo hace nunca |
|
|
188
|
+
|---|---|---|
|
|
189
|
+
| Entrar en Educa | Escribir notas en pantalla (tras la vista previa) | Cerrar o firmar una evaluación |
|
|
190
|
+
| Leer grupos, alumnos y notas | Escribir un nombre aproximado | Bloquear, borrar o publicar |
|
|
191
|
+
| Abrir la pantalla de Calificaciones | Pulsar *Aceptar* para guardar | Escribir en tus grupos sin notas |
|
|
192
|
+
| Leer tu Excel | | Visitar webs que no sean de Educa |
|
|
193
|
+
| Hacer capturas de pantalla | | Redondear una nota |
|
|
194
|
+
|
|
195
|
+
Antes de escribir comprueba que la tabla no ha cambiado (mismo grupo y mismos alumnos) y, después, vuelve a leer cada campo. No toca campos bloqueados, como los de una evaluación ya cerrada.
|
|
196
|
+
|
|
197
|
+
## Qué se ha probado
|
|
198
|
+
|
|
199
|
+
Cada cambio pasa pruebas automáticas contra un **Educa simulado** (ESO, Bachillerato, FP y Apto / No apto), con todo el recorrido: entrar, abrir el grupo, vista previa, escribir y guardar. Además, se ha probado en el **Educa real** de un instituto de Navarra con una cuenta de profesor, sin guardar nada:
|
|
200
|
+
|
|
201
|
+
| | ESO | Bachillerato | FP |
|
|
202
|
+
|---|---|---|---|
|
|
203
|
+
| Entrar y leer la tabla de notas | Probado en Educa real | Probado en Educa real | Solo simulador |
|
|
204
|
+
| Actualizar grupos y abrir un grupo por nombre parcial | Probado en Educa real | Probado en Educa real | Solo simulador |
|
|
205
|
+
| Detectar la escala | Probado en Educa real (términos) | Probado en Educa real (0 a 10) | Solo simulador |
|
|
206
|
+
| Escribir en pantalla, sin guardar | Probado en Educa real | Probado en Educa real | Solo simulador |
|
|
207
|
+
| Pulsar *Aceptar* para guardar | Solo simulador | Solo simulador | Solo simulador |
|
|
208
|
+
|
|
209
|
+
En el Educa real se ha comprobado que, al escribir una nota, Educa muestra *Aceptar* y *Cancelar* y no guarda hasta pulsar uno de los dos; el guardado definitivo se ha dejado para cuando un docente lo haga con sus propias notas. ¿Lo has usado en tu enseñanza? Cuéntalo en [Issues](https://github.com/vTanco/educa-navarra-mcp/issues).
|
|
210
|
+
|
|
211
|
+
## Privacidad
|
|
117
212
|
|
|
118
213
|
- **Se ejecuta en tu ordenador.** No hay ningún servidor intermedio: el conector maneja un navegador en tu equipo, como harías tú.
|
|
119
214
|
- **Tu contraseña no pasa por el chat.** Se guarda en tu ordenador y solo se escribe en el formulario de acceso de Educa.
|
|
120
215
|
- **Solo visita Educa** (`*.navarra.es`). Cualquier otra dirección se bloquea.
|
|
121
|
-
- **Ojo:** los nombres y notas que aparecen en la vista previa sí pasan por la conversación con la IA. Consulta la política de tu centro sobre el uso de IA con datos del alumnado. Más detalle en el [aviso legal](AVISO-LEGAL.md).
|
|
216
|
+
- **Ojo:** los nombres y notas que aparecen en la vista previa sí pasan por la conversación con la IA. Consulta la política de tu centro sobre el uso de IA con datos del alumnado. Más detalle en el [aviso legal](https://github.com/vTanco/educa-navarra-mcp/blob/main/AVISO-LEGAL.md).
|
|
122
217
|
|
|
123
|
-
##
|
|
218
|
+
## Preguntas frecuentes
|
|
124
219
|
|
|
125
220
|
<details>
|
|
126
221
|
<summary><b>¿Puede guardar algo sin que yo me entere?</b></summary>
|
|
@@ -137,32 +232,28 @@ Sí. Deja usuario y contraseña vacíos y haz tú ese paso en la ventana que se
|
|
|
137
232
|
<details>
|
|
138
233
|
<summary><b>Me cambio de centro o de etapa el curso que viene.</b></summary>
|
|
139
234
|
|
|
140
|
-
|
|
235
|
+
Di *"actualiza mis grupos"* al empezar el curso y listo. La escala de notas la detecta en cada pantalla, así que no hay que configurar nada para pasar de ESO a Bachillerato o a FP.
|
|
141
236
|
</details>
|
|
142
237
|
|
|
143
238
|
<details>
|
|
144
|
-
<summary><b>
|
|
239
|
+
<summary><b>No encuentra mi grupo.</b></summary>
|
|
145
240
|
|
|
146
|
-
|
|
241
|
+
Pídele *"actualiza mis grupos"* y vuelve a intentarlo. Si tu nombre encaja con varios grupos, te dirá cuáles y te pedirá el nombre completo.
|
|
147
242
|
</details>
|
|
148
243
|
|
|
149
244
|
<details>
|
|
150
|
-
<summary><b
|
|
245
|
+
<summary><b>Me dice que no encuentra la tabla de notas.</b></summary>
|
|
151
246
|
|
|
152
|
-
|
|
247
|
+
Educa cambia a veces su diseño. Pide a Claude *"haz una captura de la pantalla"* y abre un aviso en [Issues](https://github.com/vTanco/educa-navarra-mcp/issues) **sin datos de alumnos** (tapa los nombres). Los ajustes avanzados están en [CONFIGURACION.md](https://github.com/vTanco/educa-navarra-mcp/blob/main/docs/CONFIGURACION.md).
|
|
153
248
|
</details>
|
|
154
249
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
| Enseñanza | Leer notas | Escribir notas |
|
|
158
|
-
|---|---|---|
|
|
159
|
-
| ESO | ✅ probado en Educa real | ✅ probado en Educa real |
|
|
160
|
-
| Bachillerato | ✅ probado en Educa real | 🧪 en pruebas |
|
|
161
|
-
| FP Grado Medio y Superior | 🧪 en pruebas | 🧪 en pruebas |
|
|
250
|
+
<details>
|
|
251
|
+
<summary><b>¿Cómo lo desinstalo?</b></summary>
|
|
162
252
|
|
|
163
|
-
|
|
253
|
+
En Claude Desktop: **Ajustes → Extensiones → Educa Navarra → Desinstalar**. Si quieres borrar también la sesión guardada del navegador y la lista de grupos, elimina la carpeta `.educa-mcp` de tu usuario.
|
|
254
|
+
</details>
|
|
164
255
|
|
|
165
|
-
##
|
|
256
|
+
## Para desarrolladores
|
|
166
257
|
|
|
167
258
|
```bash
|
|
168
259
|
git clone https://github.com/vTanco/educa-navarra-mcp.git
|
|
@@ -170,12 +261,18 @@ cd educa-navarra-mcp
|
|
|
170
261
|
npm install
|
|
171
262
|
npm run build
|
|
172
263
|
npm test # ficheros, conversión de notas y emparejamiento de nombres
|
|
173
|
-
npm run smoke #
|
|
264
|
+
npm run smoke # recorrido completo contra un Educa simulado
|
|
174
265
|
npm run pack:mcpb # genera build/educa-navarra-mcp.mcpb para Claude Desktop
|
|
175
266
|
```
|
|
176
267
|
|
|
177
|
-
|
|
268
|
+
| Documento | Contenido |
|
|
269
|
+
|---|---|
|
|
270
|
+
| [CONFIGURACION.md](https://github.com/vTanco/educa-navarra-mcp/blob/main/docs/CONFIGURACION.md) | Herramientas, variables de entorno, fichero de configuración y Educa simulado |
|
|
271
|
+
| [BUENAS-PRACTICAS.md](https://github.com/vTanco/educa-navarra-mcp/blob/main/docs/BUENAS-PRACTICAS.md) | Guía para docentes sobre cómo preparar los Excel |
|
|
272
|
+
| [normativa-evaluacion.md](https://github.com/vTanco/educa-navarra-mcp/blob/main/docs/normativa-evaluacion.md) | Resumen de la normativa de evaluación por etapa |
|
|
273
|
+
| [PUBLICAR.md](https://github.com/vTanco/educa-navarra-mcp/blob/main/docs/PUBLICAR.md) | Cómo sacar una versión (GitHub y npm) |
|
|
274
|
+
| [CHANGELOG.md](https://github.com/vTanco/educa-navarra-mcp/blob/main/CHANGELOG.md) | Cambios de cada versión |
|
|
178
275
|
|
|
179
276
|
---
|
|
180
277
|
|
|
181
|
-
<sub>Licencia [MIT](LICENSE). Proyecto independiente y sin ánimo de lucro, sin relación con el Gobierno de Navarra. "Educa" es el nombre del portal educativo del Gobierno de Navarra y se usa solo para describir con qué funciona esta herramienta.</sub>
|
|
278
|
+
<sub>Licencia [MIT](https://github.com/vTanco/educa-navarra-mcp/blob/main/LICENSE). Proyecto independiente y sin ánimo de lucro, sin relación con el Gobierno de Navarra. "Educa" es el nombre del portal educativo del Gobierno de Navarra y se usa solo para describir con qué funciona esta herramienta.</sub>
|
package/dist/browser.js
CHANGED
|
@@ -258,12 +258,14 @@ export class EducaBrowser {
|
|
|
258
258
|
continue;
|
|
259
259
|
await frame.locator("[data-educa-mcp-dd]").first().click();
|
|
260
260
|
await page.waitForTimeout(600);
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
261
|
+
// Buscador del propio desplegable (o de su panel abierto); nunca otros buscadores de la página
|
|
262
|
+
const searchBox = frame.locator("[data-educa-mcp-dd] input, .p-select-overlay input, .p-dropdown-panel input, .ng-dropdown-panel input").filter({ visible: true }).first();
|
|
263
|
+
const typed = (await searchBox.count()) > 0 && (await searchBox.fill(option).then(() => true).catch(() => false));
|
|
264
264
|
await page.waitForTimeout(600);
|
|
265
265
|
const picked = await frame.evaluate(clickDropdownOption, option);
|
|
266
266
|
if (!picked.ok) {
|
|
267
|
+
if (typed)
|
|
268
|
+
await searchBox.fill("").catch(() => undefined); // no dejar el filtro escrito
|
|
267
269
|
await page.keyboard.press("Escape").catch(() => undefined);
|
|
268
270
|
throw new Error(`El desplegable "${fieldHint}" no tiene la opción "${option}". Opciones visibles: ${picked.options.join(" / ") || "(ninguna)"}`);
|
|
269
271
|
}
|
|
@@ -272,8 +274,105 @@ export class EducaBrowser {
|
|
|
272
274
|
}
|
|
273
275
|
throw new Error(`No encuentro ningún desplegable cuyo nombre o etiqueta contenga "${fieldHint}". Usa educa_inspect_page para ver los campos.`);
|
|
274
276
|
}
|
|
277
|
+
// ---------- Selector de grupo, asignatura y evaluación (Educa-Gestión) ----------
|
|
278
|
+
groupSelect(page, kind) {
|
|
279
|
+
return page.locator(`[role=dialog] p-select[placeholder="Seleccione un ${kind}"]`).first();
|
|
280
|
+
}
|
|
281
|
+
/** Abre el diálogo de Educa-Gestión que pide grupo o agrupamiento y asignatura. */
|
|
282
|
+
async openGroupSelector() {
|
|
283
|
+
let page = await this.page();
|
|
284
|
+
page = await this.goto(new URL(this.config.gradesScreen.path, page.url()).toString());
|
|
285
|
+
await settle(page);
|
|
286
|
+
const selector = this.groupSelect(page, "grupo");
|
|
287
|
+
if (!(await selector.isVisible().catch(() => false))) {
|
|
288
|
+
// Aviso de "Última conexión" que tapa el menú al entrar
|
|
289
|
+
const notice = page.locator("[role=dialog] button[data-pc-name=pcclosebutton]").filter({ visible: true }).first();
|
|
290
|
+
if ((await notice.count()) > 0)
|
|
291
|
+
await notice.click().catch(() => undefined);
|
|
292
|
+
for (const entry of this.config.gradesScreen.menu)
|
|
293
|
+
await this.click({ text: entry });
|
|
294
|
+
}
|
|
295
|
+
await selector.waitFor({ state: "visible", timeout: 10_000 }).catch(() => {
|
|
296
|
+
throw new Error(`No se ha abierto el selector de grupo de Educa tras pulsar ${this.config.gradesScreen.menu.join(" → ")}. Usa educa_inspect_page para ver dónde está el navegador; si el menú de tu centro es distinto, ajusta gradesScreen.menu en la configuración.`);
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
/** Abre un desplegable PrimeNG, lee sus opciones y lo vuelve a cerrar sin elegir nada. */
|
|
300
|
+
async overlayOptions(page, trigger) {
|
|
301
|
+
await trigger.click();
|
|
302
|
+
const items = page.locator(".p-select-overlay li[role=option]").filter({ visible: true });
|
|
303
|
+
await items.first().waitFor({ state: "visible", timeout: 3000 }).catch(() => undefined);
|
|
304
|
+
const texts = (await items.allInnerTexts().catch(() => [])).map(t => t.replace(/\s+/g, " ").trim()).filter(Boolean);
|
|
305
|
+
if ((await items.count()) > 0)
|
|
306
|
+
await trigger.click().catch(() => undefined); // se cierra con su propio botón (Escape cerraría el diálogo)
|
|
307
|
+
await page.waitForTimeout(250);
|
|
308
|
+
return [...new Set(texts)];
|
|
309
|
+
}
|
|
310
|
+
async pickOverlayOption(page, trigger, option) {
|
|
311
|
+
await trigger.click();
|
|
312
|
+
const item = page.locator(".p-select-overlay li[role=option]").filter({ visible: true }).filter({ hasText: new RegExp(`^\\s*${option.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`) }).first();
|
|
313
|
+
try {
|
|
314
|
+
await item.click({ timeout: 5000 });
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
await trigger.click().catch(() => undefined);
|
|
318
|
+
throw new Error(`El desplegable de Educa no ofrece la opción "${option}".`);
|
|
319
|
+
}
|
|
320
|
+
await settle(page, 500);
|
|
321
|
+
}
|
|
322
|
+
/** Con el selector abierto: nombres de los grupos y agrupamientos del docente. */
|
|
323
|
+
async listGroupNames() {
|
|
324
|
+
const page = await this.page();
|
|
325
|
+
const out = { grupo: [], agrupamiento: [] };
|
|
326
|
+
for (const kind of ["grupo", "agrupamiento"]) {
|
|
327
|
+
const select = this.groupSelect(page, kind);
|
|
328
|
+
if ((await select.count()) > 0 && (await select.isVisible().catch(() => false)))
|
|
329
|
+
out[kind] = await this.overlayOptions(page, select);
|
|
330
|
+
}
|
|
331
|
+
return out;
|
|
332
|
+
}
|
|
333
|
+
/** Con el selector abierto: elige el grupo y devuelve las asignaturas que lista Educa para él. */
|
|
334
|
+
async subjectsOfGroup(kind, name) {
|
|
335
|
+
const page = await this.page();
|
|
336
|
+
await this.pickOverlayOption(page, this.groupSelect(page, kind), name);
|
|
337
|
+
const items = page.locator("[role=dialog] p-listbox li[role=option]").filter({ visible: true });
|
|
338
|
+
await items.first().waitFor({ state: "visible", timeout: 4000 }).catch(() => undefined);
|
|
339
|
+
return [...new Set((await items.allInnerTexts().catch(() => [])).map(t => t.replace(/\s+/g, " ").trim()).filter(Boolean))];
|
|
340
|
+
}
|
|
341
|
+
/** Abre la pantalla de notas de un grupo y asignatura (nombres exactos, tal como los da Educa). */
|
|
342
|
+
async openSubject(kind, group, subject) {
|
|
343
|
+
await this.openGroupSelector();
|
|
344
|
+
const page = await this.page();
|
|
345
|
+
const subjects = await this.subjectsOfGroup(kind, group);
|
|
346
|
+
if (!subjects.includes(subject))
|
|
347
|
+
throw new Error(`Educa no lista la asignatura "${subject}" en ${group}. Asignaturas: ${subjects.join(" / ") || "(ninguna)"}`);
|
|
348
|
+
await page.locator("[role=dialog] p-listbox li[role=option]").filter({ visible: true }).filter({ hasText: new RegExp(`^\\s*${subject.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`) }).first().click();
|
|
349
|
+
await settle(page);
|
|
350
|
+
await page.locator(".listadoCalificaciones .elementoLista").first().waitFor({ state: "visible", timeout: 10_000 }).catch(() => undefined);
|
|
351
|
+
}
|
|
352
|
+
evaluationSelect(page) {
|
|
353
|
+
return page.locator('p-select[placeholder="evl"]').first();
|
|
354
|
+
}
|
|
355
|
+
/** Evaluación elegida y evaluaciones disponibles en la pantalla de notas abierta. */
|
|
356
|
+
async evaluations() {
|
|
357
|
+
const page = await this.page();
|
|
358
|
+
const select = this.evaluationSelect(page);
|
|
359
|
+
if ((await select.count()) === 0 || !(await select.isVisible().catch(() => false)))
|
|
360
|
+
return { current: "", options: [] };
|
|
361
|
+
const options = await this.overlayOptions(page, select);
|
|
362
|
+
const current = ((await select.locator("[role=combobox]").first().innerText().catch(() => "")) || "").replace(/\s+/g, " ").trim();
|
|
363
|
+
return { current, options };
|
|
364
|
+
}
|
|
365
|
+
async selectEvaluation(option) {
|
|
366
|
+
const page = await this.page();
|
|
367
|
+
await this.pickOverlayOption(page, this.evaluationSelect(page), option);
|
|
368
|
+
await settle(page);
|
|
369
|
+
}
|
|
275
370
|
// ---------- Tabla de notas ----------
|
|
276
|
-
|
|
371
|
+
/**
|
|
372
|
+
* Lee la tabla de notas. Con probeOptions abre (y cierra con Escape, sin elegir nada) el
|
|
373
|
+
* desplegable de la primera nota editable para conocer las opciones reales de esa pantalla.
|
|
374
|
+
*/
|
|
375
|
+
async readGradeSheet(column, probeOptions = false) {
|
|
277
376
|
const page = await this.page();
|
|
278
377
|
let best = null;
|
|
279
378
|
let bestScore = -1;
|
|
@@ -290,8 +389,36 @@ export class EducaBrowser {
|
|
|
290
389
|
if (!best || best.rows.length === 0) {
|
|
291
390
|
throw new Error("No encuentro una tabla de notas con campos editables en la página actual. Navega en Educa hasta la pantalla de calificaciones del grupo, materia y evaluación (puedes usar educa_click / educa_select_filter) y vuelve a intentarlo.");
|
|
292
391
|
}
|
|
392
|
+
if (probeOptions)
|
|
393
|
+
await this.probeOptions(best).catch(() => undefined);
|
|
293
394
|
return best;
|
|
294
395
|
}
|
|
396
|
+
async probeOptions(sheet) {
|
|
397
|
+
const fields = sheet.rows.map(r => r.field).filter((f) => f !== null && !f.disabled);
|
|
398
|
+
const first = fields.find(f => f.type === "p-select" || f.type === "p-select-editable");
|
|
399
|
+
if (!first)
|
|
400
|
+
return;
|
|
401
|
+
const page = await this.page();
|
|
402
|
+
const loc = await this.locateField(first.key);
|
|
403
|
+
if (!loc)
|
|
404
|
+
return;
|
|
405
|
+
const opener = first.type === "p-select" ? loc : loc.locator("xpath=ancestor::p-select[1]").locator(".p-select-dropdown, [data-pc-section=dropdown]").first();
|
|
406
|
+
if ((await opener.count()) === 0)
|
|
407
|
+
return;
|
|
408
|
+
await opener.scrollIntoViewIfNeeded().catch(() => undefined);
|
|
409
|
+
await opener.click({ timeout: 3000 });
|
|
410
|
+
const items = page.locator(".p-select-overlay li[role=option]").filter({ visible: true });
|
|
411
|
+
await items.first().waitFor({ state: "visible", timeout: 2500 }).catch(() => undefined);
|
|
412
|
+
const texts = (await items.allInnerTexts().catch(() => [])).map(t => t.replace(/\s+/g, " ").trim()).filter(Boolean);
|
|
413
|
+
await page.keyboard.press("Escape").catch(() => undefined);
|
|
414
|
+
await page.waitForTimeout(200);
|
|
415
|
+
if (!texts.length)
|
|
416
|
+
return; // no se han podido leer: se mantiene lo deducido de la leyenda
|
|
417
|
+
const options = [...new Set(texts)].map(t => ({ value: t, text: t }));
|
|
418
|
+
for (const f of fields)
|
|
419
|
+
if (f.type === first.type)
|
|
420
|
+
f.options = options;
|
|
421
|
+
}
|
|
295
422
|
async locateField(key) {
|
|
296
423
|
const page = await this.page();
|
|
297
424
|
for (const frame of page.frames()) {
|
|
@@ -655,7 +782,9 @@ function extractGradeSheet({ sel, column }) {
|
|
|
655
782
|
return { index, name, ...(studentId ? { studentId } : {}), field };
|
|
656
783
|
});
|
|
657
784
|
// Verificado en el Educa real: al cambiar una nota la celda queda "modificado" y aparecen Aceptar/Cancelar; no se guarda sola
|
|
658
|
-
|
|
785
|
+
const ctx = [...document.querySelectorAll("app-selector-grupo-asignatura-anidado p-select [role=combobox]:not(.p-placeholder), app-selector-grupo-asignatura-anidado li[role=option][aria-selected=true], h1, h2, h3")]
|
|
786
|
+
.map(e => clean(e.textContent)).filter((t, i, a) => t && a.indexOf(t) === i).join(" · ").slice(0, 300);
|
|
787
|
+
return { headers: allHeaders, column: target, rows: rowsOut, editableColumns, autoSaves: false, context: ctx };
|
|
659
788
|
}
|
|
660
789
|
// 1. Filas candidatas
|
|
661
790
|
let rows;
|
|
@@ -759,6 +888,8 @@ function extractGradeSheet({ sel, column }) {
|
|
|
759
888
|
value: el.value,
|
|
760
889
|
valueText: isSelect ? clean(el.selectedOptions[0]?.textContent) : el.value,
|
|
761
890
|
disabled: el.disabled || el.readOnly === true,
|
|
891
|
+
...(el instanceof HTMLInputElement && el.min !== "" && !isNaN(Number(el.min)) ? { min: Number(el.min) } : {}),
|
|
892
|
+
...(el instanceof HTMLInputElement && el.max !== "" && !isNaN(Number(el.max)) ? { max: Number(el.max) } : {}),
|
|
762
893
|
...(isSelect ? { options: [...el.options].map(o => ({ value: o.value, text: clean(o.textContent) })) } : {})
|
|
763
894
|
};
|
|
764
895
|
}
|
|
@@ -766,6 +897,11 @@ function extractGradeSheet({ sel, column }) {
|
|
|
766
897
|
out.push({ index, name, ...(studentId ? { studentId } : {}), field });
|
|
767
898
|
});
|
|
768
899
|
const column_ = sel.gradeFieldSelector ? out.find(r => r.field)?.field?.header ?? null : targetPos !== null ? headers[targetPos] || `columna ${targetPos + 1}` : null;
|
|
769
|
-
|
|
900
|
+
// Contexto: títulos y filtros elegidos (grupo, materia, evaluación) fuera de las filas de notas
|
|
901
|
+
const context = [
|
|
902
|
+
...[...document.querySelectorAll("h1, h2, h3")].map(e => clean(e.textContent)),
|
|
903
|
+
...[...document.querySelectorAll("select")].filter(s2 => !rows.some(r => r.contains(s2))).map(s2 => clean(s2.selectedOptions[0]?.textContent))
|
|
904
|
+
].filter((t2, i, a) => t2 && a.indexOf(t2) === i).join(" · ").slice(0, 300);
|
|
905
|
+
return { headers: headers.map((h, i) => h || `columna ${i + 1}`), column: column_, rows: out, editableColumns, context };
|
|
770
906
|
}
|
|
771
907
|
//# sourceMappingURL=browser.js.map
|
package/dist/config.js
CHANGED
|
@@ -84,6 +84,21 @@ const ConfigSchema = z.object({
|
|
|
84
84
|
successPattern: "(correctamente|guardad[oa]s?|grabad[oa]s?|se han actualizado)",
|
|
85
85
|
errorPattern: "(error|no se ha podido|incorrect[oa]|obligatori[oa])"
|
|
86
86
|
}),
|
|
87
|
+
/** Cómo llegar en Educa-Gestión a la pantalla de calificaciones de evaluación por asignatura. */
|
|
88
|
+
gradesScreen: z
|
|
89
|
+
.object({
|
|
90
|
+
/** Ruta de Educa-Gestión dentro del dominio de Educa. */
|
|
91
|
+
path: z.string().default("/Educa/es/"),
|
|
92
|
+
/** Entradas de menú que se pulsan por orden hasta que se abre el selector de grupo. */
|
|
93
|
+
menu: z.array(z.string()).default(["CUADERNO DE AULA", "CALIFICACIONES", "DE EVALUACIÓN: ASIGNATURA"])
|
|
94
|
+
})
|
|
95
|
+
.default({ path: "/Educa/es/", menu: ["CUADERNO DE AULA", "CALIFICACIONES", "DE EVALUACIÓN: ASIGNATURA"] }),
|
|
96
|
+
/**
|
|
97
|
+
* Grupos o agrupamientos en los que este docente no pone notas (p. ej. docencia compartida).
|
|
98
|
+
* El servidor se niega a escribir o guardar notas si la pantalla abierta es de uno de ellos.
|
|
99
|
+
* Basta el principio del nombre: "ESO 2" cubre "ESO 2º D" y "ESO 2º E".
|
|
100
|
+
*/
|
|
101
|
+
noGradeGroups: z.array(z.string()).default([]),
|
|
87
102
|
/** El servidor nunca pulsa elementos cuyo texto encaje con esto. */
|
|
88
103
|
forbiddenClickPattern: z
|
|
89
104
|
.string()
|
|
@@ -149,6 +164,11 @@ function applyEnv(config) {
|
|
|
149
164
|
config.browserExecutablePath = process.env.EDUCA_BROWSER_PATH;
|
|
150
165
|
if (process.env.EDUCA_HEADLESS)
|
|
151
166
|
config.headless = process.env.EDUCA_HEADLESS === "1";
|
|
167
|
+
// Claude Desktop deja "${user_config.x}" sin sustituir cuando el campo opcional se queda vacío
|
|
168
|
+
const noGrade = process.env.EDUCA_NO_GRADE_GROUPS?.trim();
|
|
169
|
+
if (noGrade && !noGrade.startsWith("${")) {
|
|
170
|
+
config.noGradeGroups = [...config.noGradeGroups, ...noGrade.split(/[,;\n]+/).map(g => g.trim()).filter(Boolean)];
|
|
171
|
+
}
|
|
152
172
|
if (process.env.EDUCA_ALLOWED_HOSTS)
|
|
153
173
|
config.allowedHosts = process.env.EDUCA_ALLOWED_HOSTS.split(",");
|
|
154
174
|
return config;
|
package/dist/grades.js
CHANGED
|
@@ -195,6 +195,100 @@ export function parseCsv(text) {
|
|
|
195
195
|
}
|
|
196
196
|
return rows;
|
|
197
197
|
}
|
|
198
|
+
export function detectStage(context) {
|
|
199
|
+
const t = ` ${normalize(context ?? "")} `;
|
|
200
|
+
if (/ (bto|bach|bachiller|bachillerato) /.test(t))
|
|
201
|
+
return "bachillerato";
|
|
202
|
+
if (/ (cfgm|cfgs|cfgb|fpb|fpgb|gm|gs|fp|ciclo formativo|ciclos formativos|grado medio|grado superior|grado basico|curso de especializacion) /.test(t))
|
|
203
|
+
return "fp";
|
|
204
|
+
if (/ (eso|secundaria obligatoria) /.test(t))
|
|
205
|
+
return "eso";
|
|
206
|
+
if (/ (ep|primaria) /.test(t))
|
|
207
|
+
return "primaria";
|
|
208
|
+
return "desconocida";
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Devuelve el grupo de la lista "no pongo notas" al que pertenece la pantalla, o null.
|
|
212
|
+
* Compara palabras completas sin tildes ni mayúsculas; basta el principio del nombre del grupo.
|
|
213
|
+
*/
|
|
214
|
+
export function blockedGroup(context, noGradeGroups) {
|
|
215
|
+
const text = ` ${normalize(context ?? "")} `;
|
|
216
|
+
for (const group of noGradeGroups) {
|
|
217
|
+
const wanted = normalize(group);
|
|
218
|
+
if (wanted && text.includes(` ${wanted} `))
|
|
219
|
+
return group;
|
|
220
|
+
}
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
/** Términos de calificación con sus formas habituales (código y palabra completa). */
|
|
224
|
+
const TERMS = {
|
|
225
|
+
IN: ["in", "insuficiente"],
|
|
226
|
+
SU: ["su", "sf", "suficiente"],
|
|
227
|
+
BI: ["bi", "bien"],
|
|
228
|
+
NT: ["nt", "notable"],
|
|
229
|
+
SB: ["sb", "sobresaliente"],
|
|
230
|
+
AP: ["ap", "apto", "apta", "superado", "superada"],
|
|
231
|
+
NA: ["na", "no apto", "no apta", "no superado", "no superada"],
|
|
232
|
+
NP: ["np", "no presentado", "no presentada"],
|
|
233
|
+
EX: ["ex", "exento", "exenta"],
|
|
234
|
+
CV: ["cv", "convalidado", "convalidada"]
|
|
235
|
+
};
|
|
236
|
+
const ESO_TERMS = ["IN", "SU", "BI", "NT", "SB"];
|
|
237
|
+
/** Código canónico de un término ("Sobresaliente", "SB", "Sobresaliente (SB)" -> "SB"), o null. */
|
|
238
|
+
export function termCode(text) {
|
|
239
|
+
const candidates = [text, text.replace(/\s*\([^)]*\)\s*$/, ""), /\(([^)]*)\)\s*$/.exec(text)?.[1] ?? ""].map(normalize).filter(Boolean);
|
|
240
|
+
for (const [code, forms] of Object.entries(TERMS))
|
|
241
|
+
if (candidates.some(c => forms.includes(c)))
|
|
242
|
+
return code;
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
const STAGE_LABEL = { primaria: "Primaria", eso: "ESO", bachillerato: "Bachillerato", fp: "Formación Profesional", desconocida: "etapa no identificada" };
|
|
246
|
+
const ESO_CONVERSION = "0–4 → IN · 5 → SU · 6 → BI · 7–8 → NT · 9–10 → SB";
|
|
247
|
+
function numericRange(field, cfg, stage) {
|
|
248
|
+
// RD 659/2023: los módulos de FP se califican de 1 a 10; en Bachillerato (RD 243/2022) de 0 a 10
|
|
249
|
+
const lo = field.min ?? (stage === "fp" ? Math.max(cfg.min, 1) : cfg.min);
|
|
250
|
+
return { lo, hi: field.max ?? cfg.max };
|
|
251
|
+
}
|
|
252
|
+
function realOptions(field) {
|
|
253
|
+
return (field.options ?? []).filter(o => o.value.trim() !== "" || o.text.trim() !== "");
|
|
254
|
+
}
|
|
255
|
+
function findTerm(options, code) {
|
|
256
|
+
const hits = options.filter(o => termCode(o.text) === code || termCode(o.value) === code);
|
|
257
|
+
return hits.length === 1 ? hits[0] : null;
|
|
258
|
+
}
|
|
259
|
+
/** Describe la escala que pide la pantalla abierta, a partir de sus campos de nota. */
|
|
260
|
+
export function describeScale(sheet, cfg, stageOverride) {
|
|
261
|
+
const stage = stageOverride ?? detectStage(sheet.context);
|
|
262
|
+
const fields = sheet.rows.map(r => r.field).filter((f) => f !== null);
|
|
263
|
+
const field = fields.find(f => !f.disabled) ?? fields[0];
|
|
264
|
+
const where = STAGE_LABEL[stage];
|
|
265
|
+
if (!field)
|
|
266
|
+
return { kind: "sin_campos", stage, description: `No hay campos de nota en la columna elegida (${where}).` };
|
|
267
|
+
if (field.tag === "select" || field.tag === "pselect") {
|
|
268
|
+
const options = realOptions(field);
|
|
269
|
+
const values = options.map(o => o.text || o.value);
|
|
270
|
+
if (!options.length)
|
|
271
|
+
return { kind: "otra", stage, values, description: `Desplegable sin opciones legibles (${where}); puede que la evaluación esté cerrada.` };
|
|
272
|
+
if (ESO_TERMS.every(c => findTerm(options, c))) {
|
|
273
|
+
return { kind: "terminos", stage, values, conversion: ESO_CONVERSION, description: `Términos (${where}): ${values.join(", ")}. Si el fichero trae números se convierten así: ${ESO_CONVERSION}.` };
|
|
274
|
+
}
|
|
275
|
+
if (findTerm(options, "AP") && findTerm(options, "NA")) {
|
|
276
|
+
return { kind: "apto", stage, values, description: `Apto / No apto (${where}): ${values.join(", ")}. El fichero debe traer el término, no un número.` };
|
|
277
|
+
}
|
|
278
|
+
const nums = options.map(o => { const n = numbersIn(o.text); return n.length === 1 ? n[0] : parseNumber(o.value); }).filter((n) => n !== null);
|
|
279
|
+
if (nums.length >= options.length / 2 && nums.length >= 5) {
|
|
280
|
+
return { kind: "numerica", stage, values, min: Math.min(...nums), max: Math.max(...nums), description: `Nota numérica elegida en un desplegable, de ${Math.min(...nums)} a ${Math.max(...nums)} (${where}). Opciones: ${values.join(", ")}.` };
|
|
281
|
+
}
|
|
282
|
+
return { kind: "otra", stage, values, description: `Desplegable con estas opciones (${where}): ${values.join(", ")}. El fichero debe traer exactamente uno de esos valores.` };
|
|
283
|
+
}
|
|
284
|
+
const { lo, hi } = numericRange(field, cfg, stage);
|
|
285
|
+
const extras = field.options ? realOptions(field).map(o => o.text || o.value) : ["FPEX"];
|
|
286
|
+
const all = [...new Set([...extras, ...cfg.extraValues])];
|
|
287
|
+
return {
|
|
288
|
+
kind: "numerica", stage, min: lo, max: hi, values: all,
|
|
289
|
+
description: `Nota numérica tecleada de ${lo} a ${hi}${cfg.integerOnly ? ", sin decimales" : ""} (${where})${all.length ? `. También admite: ${all.join(", ")}` : ""}.`
|
|
290
|
+
};
|
|
291
|
+
}
|
|
198
292
|
function parseNumber(text) {
|
|
199
293
|
const t = text.trim().replace(",", ".");
|
|
200
294
|
return /^-?\d+(\.\d+)?$/.test(t) ? Number(t) : null;
|
|
@@ -202,17 +296,19 @@ function parseNumber(text) {
|
|
|
202
296
|
function numbersIn(text) {
|
|
203
297
|
return (text.match(/\d+(?:[.,]\d+)?/g) ?? []).map(n => Number(n.replace(",", ".")));
|
|
204
298
|
}
|
|
205
|
-
export function resolveGrade(field, grade, cfg) {
|
|
299
|
+
export function resolveGrade(field, grade, cfg, stage = "desconocida") {
|
|
206
300
|
const raw = grade.trim();
|
|
207
301
|
const n = parseNumber(raw);
|
|
302
|
+
const isChoice = field.tag === "select" || field.tag === "pselect";
|
|
303
|
+
const { lo, hi } = isChoice ? { lo: Math.min(cfg.min, 0), hi: cfg.max } : numericRange(field, cfg, stage);
|
|
208
304
|
if (n !== null) {
|
|
209
|
-
if (n <
|
|
210
|
-
return { ok: false, reason: `la nota ${raw} está fuera del rango ${
|
|
305
|
+
if (n < lo || n > hi)
|
|
306
|
+
return { ok: false, reason: `la nota ${raw} está fuera del rango ${lo}–${hi}${stage === "fp" && n === 0 ? " (en FP los módulos se califican de 1 a 10)" : ""}` };
|
|
211
307
|
if (cfg.integerOnly && !Number.isInteger(n))
|
|
212
308
|
return { ok: false, reason: `la nota ${raw} tiene decimales y Educa espera una nota entera; redondéala tú en el fichero` };
|
|
213
309
|
}
|
|
214
|
-
if (
|
|
215
|
-
const options = (field
|
|
310
|
+
if (isChoice) {
|
|
311
|
+
const options = realOptions(field);
|
|
216
312
|
const byValue = options.filter(o => o.value.trim() === raw);
|
|
217
313
|
if (byValue.length === 1)
|
|
218
314
|
return { ok: true, value: byValue[0].value, display: byValue[0].text };
|
|
@@ -229,35 +325,45 @@ export function resolveGrade(field, grade, cfg) {
|
|
|
229
325
|
return { ok: true, value: byNumber[0].value, display: byNumber[0].text };
|
|
230
326
|
if (byNumber.length > 1)
|
|
231
327
|
return { ok: false, reason: `la nota ${raw} encaja con varias opciones (${byNumber.map(o => o.text).join(" / ")})` };
|
|
328
|
+
// El fichero trae un número y Educa pide términos (ESO, Primaria, ámbitos): equivalencia habitual
|
|
329
|
+
if (ESO_TERMS.every(c => findTerm(options, c))) {
|
|
330
|
+
if (!Number.isInteger(n))
|
|
331
|
+
return { ok: false, reason: `la nota ${raw} tiene decimales; redondéala tú en el fichero o pon directamente IN/SU/BI/NT/SB` };
|
|
332
|
+
const o = findTerm(options, n <= 4 ? "IN" : n === 5 ? "SU" : n === 6 ? "BI" : n <= 8 ? "NT" : "SB");
|
|
333
|
+
return { ok: true, value: o.value, display: `${o.text} (${raw})` };
|
|
334
|
+
}
|
|
335
|
+
if (findTerm(options, "AP") && findTerm(options, "NA")) {
|
|
336
|
+
return { ok: false, reason: `esta pantalla califica con ${options.map(o => o.text).join(" / ")}, no con números; pon el término en el fichero` };
|
|
337
|
+
}
|
|
232
338
|
}
|
|
233
339
|
else {
|
|
340
|
+
// "Sobresaliente" -> SB, "Apto" -> AP...
|
|
341
|
+
const code = termCode(raw);
|
|
342
|
+
const byTerm = code ? findTerm(options, code) : null;
|
|
343
|
+
if (byTerm)
|
|
344
|
+
return { ok: true, value: byTerm.value, display: byTerm.text };
|
|
234
345
|
const byPrefix = options.filter(o => normalize(o.text).startsWith(normalize(raw)));
|
|
235
346
|
if (byPrefix.length === 1)
|
|
236
347
|
return { ok: true, value: byPrefix[0].value, display: byPrefix[0].text };
|
|
237
348
|
}
|
|
238
|
-
// Calificaciones cualitativas (ESO LOMLOE): IN / SU / BI / NT / SB
|
|
239
|
-
if (n !== null) {
|
|
240
|
-
const has = (code) => options.find(o => normalize(o.text) === code.toLowerCase());
|
|
241
|
-
if (has("IN") && has("SU") && has("BI") && has("NT") && has("SB")) {
|
|
242
|
-
if (!Number.isInteger(n))
|
|
243
|
-
return { ok: false, reason: `la nota ${raw} tiene decimales; redondéala tú en el fichero o pon directamente IN/SU/BI/NT/SB` };
|
|
244
|
-
const code = n <= 4 ? "IN" : n === 5 ? "SU" : n === 6 ? "BI" : n <= 8 ? "NT" : "SB";
|
|
245
|
-
const o = has(code);
|
|
246
|
-
return { ok: true, value: o.value, display: `${o.text} (${raw})` };
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
349
|
return { ok: false, reason: `"${raw}" no corresponde a ninguna opción del desplegable (${options.map(o => o.text).slice(0, 15).join(" / ")})` };
|
|
250
350
|
}
|
|
251
351
|
if (n !== null) {
|
|
252
352
|
const text = String(n).replace(".", cfg.decimalSeparator);
|
|
253
353
|
return { ok: true, value: text, display: text };
|
|
254
354
|
}
|
|
255
|
-
|
|
355
|
+
// Campo tecleado con un valor no numérico: solo los que ofrece el propio campo o la configuración
|
|
356
|
+
const offered = field.options ? realOptions(field).map(o => o.text || o.value) : ["FPEX"];
|
|
357
|
+
const extra = [...offered, ...cfg.extraValues].find(v => normalize(v) === normalize(raw) || (termCode(v) !== null && termCode(v) === termCode(raw)));
|
|
256
358
|
if (extra)
|
|
257
359
|
return { ok: true, value: extra, display: extra };
|
|
258
|
-
|
|
360
|
+
if (termCode(raw) && ESO_TERMS.includes(termCode(raw))) {
|
|
361
|
+
return { ok: false, reason: `esta pantalla pide una nota numérica de ${lo} a ${hi}, no un término como "${raw}"; pon el número en el fichero` };
|
|
362
|
+
}
|
|
363
|
+
return { ok: false, reason: `"${raw}" no es una nota numérica de ${lo} a ${hi}${offered.length ? ` ni uno de los valores que admite el campo (${[...offered, ...cfg.extraValues].join(", ")})` : ""}` };
|
|
259
364
|
}
|
|
260
|
-
export function buildPlan(sheet, inputs, cfg) {
|
|
365
|
+
export function buildPlan(sheet, inputs, cfg, stageOverride) {
|
|
366
|
+
const stage = stageOverride ?? detectStage(sheet.context);
|
|
261
367
|
const rows = sheet.rows.filter(r => r.field);
|
|
262
368
|
const used = new Map(); // rowIndex -> item index
|
|
263
369
|
const items = [];
|
|
@@ -312,7 +418,7 @@ export function buildPlan(sheet, inputs, cfg) {
|
|
|
312
418
|
}
|
|
313
419
|
else {
|
|
314
420
|
used.set(match.index, items.length);
|
|
315
|
-
const res = resolveGrade(match.field, input.grade, cfg);
|
|
421
|
+
const res = resolveGrade(match.field, input.grade, cfg, stage);
|
|
316
422
|
if (!res.ok) {
|
|
317
423
|
item.status = "nota_no_valida";
|
|
318
424
|
item.note = res.reason;
|
package/dist/groups.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catálogo local de los grupos del docente (grupos, agrupamientos, asignaturas y evaluaciones),
|
|
3
|
+
* leído de los desplegables de Educa con educa_refresh_groups y guardado en el ordenador
|
|
4
|
+
* para que educa_open_group llegue directo a la pantalla de notas.
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
|
+
import { DATA_DIR } from "./config.js";
|
|
9
|
+
import { normalize } from "./grades.js";
|
|
10
|
+
export const CATALOG_FILE = join(DATA_DIR, "grupos.json");
|
|
11
|
+
export function loadCatalog(file = CATALOG_FILE) {
|
|
12
|
+
if (!existsSync(file))
|
|
13
|
+
return null;
|
|
14
|
+
try {
|
|
15
|
+
const raw = JSON.parse(readFileSync(file, "utf8"));
|
|
16
|
+
return Array.isArray(raw.groups) ? { updatedAt: raw.updatedAt ?? "", groups: raw.groups, evaluations: raw.evaluations ?? [] } : null;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export function saveCatalog(catalog, file = CATALOG_FILE) {
|
|
23
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
24
|
+
writeFileSync(file, JSON.stringify(catalog, null, 2), "utf8");
|
|
25
|
+
}
|
|
26
|
+
/** Busca por nombre sin tildes ni mayúsculas: exacto y, si no, el único que lo contiene. */
|
|
27
|
+
export function findByName(items, wanted, nameOf) {
|
|
28
|
+
const w = normalize(wanted);
|
|
29
|
+
const exact = items.filter(i => normalize(nameOf(i)) === w);
|
|
30
|
+
if (exact.length === 1)
|
|
31
|
+
return { ok: true, item: exact[0] };
|
|
32
|
+
if (exact.length > 1)
|
|
33
|
+
return { ok: false, reason: "varios", candidates: exact };
|
|
34
|
+
const partial = items.filter(i => ` ${normalize(nameOf(i))} `.includes(` ${w} `) || normalize(nameOf(i)).replace(/ /g, "").includes(w.replace(/ /g, "")));
|
|
35
|
+
if (partial.length === 1)
|
|
36
|
+
return { ok: true, item: partial[0] };
|
|
37
|
+
return { ok: false, reason: partial.length ? "varios" : "ninguno", candidates: partial };
|
|
38
|
+
}
|
|
39
|
+
//# sourceMappingURL=groups.js.map
|
package/dist/guide.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ayudas para el docente que no dependen de Educa:
|
|
3
|
+
* - instrucciones del servidor (cómo debe trabajar Claude con este conector),
|
|
4
|
+
* - la carpeta de notas y la herramienta que lista sus ficheros,
|
|
5
|
+
* - prompts listos para usar desde el menú de Claude.
|
|
6
|
+
*
|
|
7
|
+
* Resumen de docs/BUENAS-PRACTICAS.md: si cambias una, revisa la otra.
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { basename, extname, join, relative } from "node:path";
|
|
12
|
+
import * as z from "zod/v4";
|
|
13
|
+
/** Carpeta donde el docente guarda sus Excel/CSV de notas. */
|
|
14
|
+
export function notesDir() {
|
|
15
|
+
const env = process.env.EDUCA_NOTES_DIR?.trim();
|
|
16
|
+
if (env && !env.startsWith("${"))
|
|
17
|
+
return env.replace(/^~(?=$|[\\/])/, homedir());
|
|
18
|
+
return join(homedir(), "Documents", "Notas Educa");
|
|
19
|
+
}
|
|
20
|
+
export const SERVER_INSTRUCTIONS = `Conector no oficial para meter notas de evaluación en Educa (Gobierno de Navarra) desde el Excel o CSV del docente. Quien lo usa suele ser un docente sin conocimientos técnicos: háblale claro y en español, sin jerga.
|
|
21
|
+
|
|
22
|
+
Flujo:
|
|
23
|
+
1. Averigua grupo, materia y evaluación. Si falta algo, pregúntalo antes de abrir Educa.
|
|
24
|
+
2. Busca el fichero con educa_list_grade_files (carpeta de notas del docente). Los ficheros suelen llamarse EVALUACIÓN_GRUPO_MATERIA, p. ej. "1EV_BTO-1A_TIC.xlsx". Si hay varios candidatos o ninguno, pregunta; no adivines. Si el docente adjunta el fichero en el chat en vez de dejarlo en la carpeta, puedes pasar las notas directamente a educa_preview_grades.
|
|
25
|
+
3. educa_login y abre la pantalla de notas con educa_open_group (grupo, materia y evaluación; vale el nombre parcial). Si el grupo no aparece, educa_refresh_groups relee los grupos del docente desde Educa. Solo si eso falla, navega a mano: Educa-Gestión → Cuaderno de aula → Calificaciones (educa_inspect_page, educa_click, educa_select_filter).
|
|
26
|
+
4. educa_preview_grades y enseña la tabla completa: alumnos sin emparejar, nombres aproximados, notas rechazadas y alumnos de Educa sin nota. No cambia nada.
|
|
27
|
+
5. Solo si el docente da el visto bueno, educa_fill_grades (escribe en pantalla sin guardar). Pídele que lo revise en la ventana del navegador.
|
|
28
|
+
6. educa_save_grades únicamente tras una confirmación expresa del docente en este momento ("sí, guarda"). Nunca la des por supuesta.
|
|
29
|
+
|
|
30
|
+
Reglas:
|
|
31
|
+
- No redondees ni inventes notas: si una nota no vale para la escala de la pantalla, díselo y que la corrija en su fichero.
|
|
32
|
+
- Las coincidencias de nombre aproximadas no se escriben sin confirmación.
|
|
33
|
+
- Nunca cierres, firmes, bloquees ni publiques una evaluación: eso lo hace el docente a mano.
|
|
34
|
+
- Minimiza los datos personales en el chat: no pidas capturas ni volcados de la página si no hacen falta.
|
|
35
|
+
- Los grupos de "Grupos en los que no pongo notas" se pueden consultar, pero no prepares ni escribas notas en ellos.
|
|
36
|
+
- La sesión de Educa caduca a los 30-40 minutos; si caduca, vuelve a llamar a educa_login.
|
|
37
|
+
- Si el docente pregunta cómo preparar sus ficheros, resume esto: una hoja por grupo y materia, la primera fila con cabeceras (Alumno o Apellidos + Nombre, y Nota), un alumno por fila con el nombre igual que en Educa, notas enteras (o IN/SU/BI/NT/SB en ESO), celdas vacías para quien no tenga nota, nombre de fichero EVALUACIÓN_GRUPO_MATERIA y todos en la carpeta de notas. Guía completa: https://github.com/vTanco/educa-navarra-mcp/blob/main/docs/BUENAS-PRACTICAS.md`;
|
|
38
|
+
const GRADE_EXTENSIONS = new Set([".xlsx", ".csv"]);
|
|
39
|
+
const MAX_DEPTH = 3;
|
|
40
|
+
const MAX_FILES = 300;
|
|
41
|
+
function listFiles(root) {
|
|
42
|
+
const out = [];
|
|
43
|
+
const walk = (dir, depth) => {
|
|
44
|
+
let entries;
|
|
45
|
+
try {
|
|
46
|
+
entries = readdirSync(dir);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
for (const name of entries) {
|
|
52
|
+
if (out.length >= MAX_FILES)
|
|
53
|
+
return;
|
|
54
|
+
if (name.startsWith(".") || name.startsWith("~$"))
|
|
55
|
+
continue; // ocultos y temporales de Excel
|
|
56
|
+
const full = join(dir, name);
|
|
57
|
+
let st;
|
|
58
|
+
try {
|
|
59
|
+
st = statSync(full);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (st.isDirectory()) {
|
|
65
|
+
if (depth < MAX_DEPTH)
|
|
66
|
+
walk(full, depth + 1);
|
|
67
|
+
}
|
|
68
|
+
else if (GRADE_EXTENSIONS.has(extname(name).toLowerCase())) {
|
|
69
|
+
const parts = basename(name, extname(name)).split("_").map(p => p.trim()).filter(Boolean);
|
|
70
|
+
out.push({
|
|
71
|
+
file: name,
|
|
72
|
+
path: full,
|
|
73
|
+
folder: relative(root, dir) || ".",
|
|
74
|
+
modified: st.mtime.toISOString().slice(0, 16).replace("T", " "),
|
|
75
|
+
...(parts.length >= 3 ? { evaluation: parts[0], group: parts[1], subject: parts.slice(2).join(" ") } : {})
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
walk(root, 0);
|
|
81
|
+
return out.sort((a, b) => b.modified.localeCompare(a.modified));
|
|
82
|
+
}
|
|
83
|
+
const text = (t) => ({ content: [{ type: "text", text: t }] });
|
|
84
|
+
export function registerGuide(server) {
|
|
85
|
+
server.registerTool("educa_list_grade_files", {
|
|
86
|
+
title: "Ver mis ficheros de notas",
|
|
87
|
+
description: `Lista los Excel (.xlsx) y CSV de la carpeta de notas del docente (y sus subcarpetas), del más reciente al más antiguo, con la ruta completa para educa_load_grades_file o educa_preview_grades. Si el nombre sigue la convención EVALUACIÓN_GRUPO_MATERIA (p. ej. "1EV_BTO-1A_TIC.xlsx"), devuelve también esas tres partes para elegir el fichero que encaja con lo que pide el docente. No toca Educa.`,
|
|
88
|
+
inputSchema: z.strictObject({
|
|
89
|
+
filter: z.string().optional().describe("Texto que debe aparecer en el nombre o la carpeta (sin distinguir mayúsculas ni tildes), p. ej. '1A' o 'TIC'")
|
|
90
|
+
}),
|
|
91
|
+
annotations: { readOnlyHint: true, openWorldHint: false }
|
|
92
|
+
}, async ({ filter }) => {
|
|
93
|
+
const root = notesDir();
|
|
94
|
+
if (!existsSync(root)) {
|
|
95
|
+
return text(`La carpeta de notas no existe: ${root}\n\n` +
|
|
96
|
+
`Pide al docente que la cree y guarde ahí sus Excel, o que te diga dónde están sus ficheros. ` +
|
|
97
|
+
`En Claude Desktop se puede cambiar en Ajustes → Extensiones → Educa Navarra → Carpeta de notas; en otras aplicaciones, con la variable EDUCA_NOTES_DIR.`);
|
|
98
|
+
}
|
|
99
|
+
const norm = (s) => s.normalize("NFD").replace(/\p{Diacritic}/gu, "").toLowerCase();
|
|
100
|
+
let files = listFiles(root);
|
|
101
|
+
if (filter)
|
|
102
|
+
files = files.filter(f => norm(`${f.folder}/${f.file}`).includes(norm(filter)));
|
|
103
|
+
const lines = [`Carpeta de notas: ${root}`, ""];
|
|
104
|
+
if (!files.length) {
|
|
105
|
+
lines.push(filter ? `No hay ficheros .xlsx o .csv que contengan "${filter}".` : "No hay ficheros .xlsx o .csv.");
|
|
106
|
+
return text(lines.join("\n"));
|
|
107
|
+
}
|
|
108
|
+
lines.push("| Fichero | Carpeta | Evaluación | Grupo | Materia | Modificado |", "|---|---|---|---|---|---|");
|
|
109
|
+
for (const f of files)
|
|
110
|
+
lines.push(`| ${f.file} | ${f.folder} | ${f.evaluation ?? "—"} | ${f.group ?? "—"} | ${f.subject ?? "—"} | ${f.modified} |`);
|
|
111
|
+
lines.push("", "Rutas completas:", ...files.map(f => `- ${f.path}`));
|
|
112
|
+
if (files.length >= MAX_FILES)
|
|
113
|
+
lines.push("", `(Mostrando los ${MAX_FILES} primeros; usa filter para acotar.)`);
|
|
114
|
+
return text(lines.join("\n"));
|
|
115
|
+
});
|
|
116
|
+
server.registerPrompt("meter_notas", {
|
|
117
|
+
title: "Meter notas en Educa",
|
|
118
|
+
description: "Mete las notas de un grupo y materia desde tu Excel, con vista previa antes de guardar.",
|
|
119
|
+
argsSchema: z.object({
|
|
120
|
+
grupo: z.string().describe("Grupo, como aparece en Educa (p. ej. BTO N 1ºA, ESO 3ºDEFH cd)"),
|
|
121
|
+
materia: z.string().describe("Materia o módulo (p. ej. TIC, Matemáticas)"),
|
|
122
|
+
evaluacion: z.string().describe("Evaluación (p. ej. 1ª evaluación, final, extraordinaria)")
|
|
123
|
+
})
|
|
124
|
+
}, ({ grupo, materia, evaluacion }) => ({
|
|
125
|
+
messages: [
|
|
126
|
+
{
|
|
127
|
+
role: "user",
|
|
128
|
+
content: {
|
|
129
|
+
type: "text",
|
|
130
|
+
text: `Quiero meter en Educa las notas de ${evaluacion} de ${grupo}, ${materia}. Busca el fichero en mi carpeta de notas, enséñame la vista previa y no guardes nada hasta que yo te lo diga.`
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
]
|
|
134
|
+
}));
|
|
135
|
+
server.registerPrompt("revisar_notas", {
|
|
136
|
+
title: "Revisar mis notas en Educa",
|
|
137
|
+
description: "Lee las notas que hay ahora en Educa para un grupo y materia, sin cambiar nada.",
|
|
138
|
+
argsSchema: z.object({
|
|
139
|
+
grupo: z.string().describe("Grupo, como aparece en Educa"),
|
|
140
|
+
materia: z.string().describe("Materia o módulo")
|
|
141
|
+
})
|
|
142
|
+
}, ({ grupo, materia }) => ({
|
|
143
|
+
messages: [
|
|
144
|
+
{
|
|
145
|
+
role: "user",
|
|
146
|
+
content: { type: "text", text: `Abre Educa y enséñame las notas que hay ahora en ${grupo}, ${materia}. Solo lectura: no cambies nada.` }
|
|
147
|
+
}
|
|
148
|
+
]
|
|
149
|
+
}));
|
|
150
|
+
server.registerPrompt("preparar_excel", {
|
|
151
|
+
title: "Cómo preparo mi Excel de notas",
|
|
152
|
+
description: "Consejos para preparar el fichero de notas y dónde guardarlo."
|
|
153
|
+
}, () => ({
|
|
154
|
+
messages: [
|
|
155
|
+
{
|
|
156
|
+
role: "user",
|
|
157
|
+
content: {
|
|
158
|
+
type: "text",
|
|
159
|
+
text: `Explícame en pocos pasos cómo debo preparar y guardar mis Excel de notas para que el conector de Educa los encuentre y los entienda sin problemas. Mi carpeta de notas es: ${notesDir()}`
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
]
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
165
|
+
//# sourceMappingURL=guide.js.map
|
package/dist/index.js
CHANGED
|
@@ -19,9 +19,11 @@ import { join } from "node:path";
|
|
|
19
19
|
import { McpServer } from "@modelcontextprotocol/server";
|
|
20
20
|
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
21
21
|
import * as z from "zod/v4";
|
|
22
|
+
import { CATALOG_FILE, findByName, loadCatalog, saveCatalog } from "./groups.js";
|
|
22
23
|
import { EducaBrowser, FieldGone } from "./browser.js";
|
|
23
24
|
import { loadConfig, PROJECT_DIR } from "./config.js";
|
|
24
|
-
import {
|
|
25
|
+
import { registerGuide, SERVER_INSTRUCTIONS } from "./guide.js";
|
|
26
|
+
import { blockedGroup, buildPlan, describeScale, normalize as normalizeName, parseGradesFile, summarizePlan } from "./grades.js";
|
|
25
27
|
const CHARACTER_LIMIT = 25_000;
|
|
26
28
|
const PACKAGE_VERSION = JSON.parse(readFileSync(join(PROJECT_DIR, "package.json"), "utf8")).version;
|
|
27
29
|
if (process.argv.includes("--version") || process.argv.includes("-v")) {
|
|
@@ -36,6 +38,22 @@ if (process.stdin.isTTY) {
|
|
|
36
38
|
}
|
|
37
39
|
const { config, path: configPath } = loadConfig();
|
|
38
40
|
const browser = new EducaBrowser(config);
|
|
41
|
+
/** Lanza un error si la pantalla abierta es de un grupo en el que el docente ha dicho que no pone notas. */
|
|
42
|
+
function assertGroupAllowed(sheet, action) {
|
|
43
|
+
if (!config.noGradeGroups.length)
|
|
44
|
+
return;
|
|
45
|
+
const blocked = blockedGroup(sheet.context, config.noGradeGroups);
|
|
46
|
+
if (blocked) {
|
|
47
|
+
throw new Error(`No puedo ${action}: la pantalla abierta es de "${sheet.context}" y en los ajustes del conector figura "${blocked}" como grupo en el que no pones notas. Si es un error, quítalo de "Grupos en los que no pongo notas" en los ajustes y reinicia la aplicación.`);
|
|
48
|
+
}
|
|
49
|
+
if (!sheet.context?.trim()) {
|
|
50
|
+
throw new Error(`No puedo ${action}: no consigo identificar el grupo de la pantalla abierta y hay grupos marcados como "no pongo notas" (${config.noGradeGroups.join(", ")}). Abre el grupo desde el selector de Educa y vuelve a intentarlo.`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function blockedNote(sheet) {
|
|
54
|
+
const blocked = blockedGroup(sheet.context, config.noGradeGroups);
|
|
55
|
+
return blocked ? `⛔ Este grupo está en tu lista "Grupos en los que no pongo notas" (${blocked}): se puede consultar, pero el conector no escribirá ni guardará notas aquí.` : null;
|
|
56
|
+
}
|
|
39
57
|
const plans = new Map();
|
|
40
58
|
let lastFilledPlan = null;
|
|
41
59
|
function text(t) {
|
|
@@ -60,7 +78,7 @@ const STATUS_LABEL = {
|
|
|
60
78
|
campo_bloqueado: "🔒 campo bloqueado",
|
|
61
79
|
duplicada: "⚠️ fila duplicada"
|
|
62
80
|
};
|
|
63
|
-
function planMarkdown(stored, sheet) {
|
|
81
|
+
function planMarkdown(stored, sheet, scale) {
|
|
64
82
|
const { plan } = stored;
|
|
65
83
|
const counts = summarizePlan(plan);
|
|
66
84
|
const fuzzy = plan.items.filter(i => i.fuzzyMatch && i.status === "cambia").length;
|
|
@@ -68,6 +86,8 @@ function planMarkdown(stored, sheet) {
|
|
|
68
86
|
`# Vista previa de notas (plan ${stored.id})`,
|
|
69
87
|
"",
|
|
70
88
|
`Columna de Educa: **${stored.column ?? "(única)"}** · ${sheet.rows.filter(r => r.field).length} alumnos en la tabla`,
|
|
89
|
+
...(sheet.context ? [`Pantalla: ${sheet.context}`] : []),
|
|
90
|
+
`Escala detectada: ${scale.description}`,
|
|
71
91
|
"",
|
|
72
92
|
`- Se escribirán: **${counts.cambia}**${fuzzy ? ` (de ellas ${fuzzy} por coincidencia aproximada de nombre, revísalas)` : ""}`,
|
|
73
93
|
`- Ya estaban igual: ${counts.igual}`,
|
|
@@ -90,7 +110,8 @@ function planMarkdown(stored, sheet) {
|
|
|
90
110
|
}
|
|
91
111
|
// ---------- Servidor ----------
|
|
92
112
|
function createServer() {
|
|
93
|
-
const server = new McpServer({ name: "educa-mcp", version: PACKAGE_VERSION });
|
|
113
|
+
const server = new McpServer({ name: "educa-mcp", version: PACKAGE_VERSION }, { instructions: SERVER_INSTRUCTIONS });
|
|
114
|
+
registerGuide(server);
|
|
94
115
|
server.registerTool("educa_login", {
|
|
95
116
|
title: "Iniciar sesión en Educa",
|
|
96
117
|
description: `Abre Educa en una ventana de navegador del ordenador del profesor e inicia sesión. Si el profesor ha guardado sus credenciales en el fichero local de credenciales, el formulario se rellena solo (un único intento); si no, espera a que inicie sesión él mismo (usuario de Educa, Cl@ve o certificado). La contraseña nunca pasa por el modelo. Si ya hay sesión (el perfil del navegador la recuerda), vuelve enseguida.
|
|
@@ -225,7 +246,7 @@ Llámala antes que cualquier otra herramienta. Avisa al profesor de que debe ini
|
|
|
225
246
|
});
|
|
226
247
|
server.registerTool("educa_read_grade_sheet", {
|
|
227
248
|
title: "Leer la tabla de notas",
|
|
228
|
-
description: `Lee la tabla de calificaciones abierta en Educa: alumnos y nota actual de cada uno. No cambia nada. Si la tabla tiene varias columnas editables (p. ej. nota y observaciones, o varias evaluaciones), indica cuál con column; la respuesta lista las columnas editables.`,
|
|
249
|
+
description: `Lee la tabla de calificaciones abierta en Educa: alumnos y nota actual de cada uno, y la escala de calificación que pide esa pantalla (términos IN/SU/BI/NT/SB en ESO, número de 0 a 10 en Bachillerato, de 1 a 10 en FP, Apto/No apto...). La escala se deduce de la propia pantalla, no del nombre del grupo. No cambia nada. Si la tabla tiene varias columnas editables (p. ej. nota y observaciones, o varias evaluaciones), indica cuál con column; la respuesta lista las columnas editables.`,
|
|
229
250
|
inputSchema: z.strictObject({
|
|
230
251
|
column: z.string().optional().describe("Parte del texto de la cabecera de la columna de nota, p. ej. 'Final' o '1ª Ev'"),
|
|
231
252
|
response_format: ResponseFormat
|
|
@@ -233,8 +254,14 @@ Llámala antes que cualquier otra herramienta. Avisa al profesor de que debe ini
|
|
|
233
254
|
annotations: { readOnlyHint: true, openWorldHint: true }
|
|
234
255
|
}, async ({ column, response_format }) => {
|
|
235
256
|
await requireLogin();
|
|
236
|
-
const sheet = await browser.readGradeSheet(column);
|
|
257
|
+
const sheet = await browser.readGradeSheet(column, true);
|
|
258
|
+
const scale = describeScale(sheet, config.grades);
|
|
259
|
+
const blocked = blockedNote(sheet);
|
|
237
260
|
const data = {
|
|
261
|
+
no_grades_group: blocked !== null,
|
|
262
|
+
context: sheet.context ?? null,
|
|
263
|
+
stage: scale.stage,
|
|
264
|
+
scale,
|
|
238
265
|
column: sheet.column,
|
|
239
266
|
editable_columns: sheet.editableColumns,
|
|
240
267
|
students: sheet.rows.map(r => ({ name: r.name, ...(r.studentId ? { id: r.studentId } : {}), grade: r.field ? r.field.valueText || r.field.value : null, editable: r.field ? !r.field.disabled : false }))
|
|
@@ -244,10 +271,118 @@ Llámala antes que cualquier otra herramienta. Avisa al profesor de que debe ini
|
|
|
244
271
|
if (!sheet.column) {
|
|
245
272
|
return text(`La tabla tiene varias columnas editables y no sé cuál es la nota: ${sheet.editableColumns.join(" | ")}. Vuelve a llamar con column.`);
|
|
246
273
|
}
|
|
247
|
-
const lines = [`# Tabla de notas · columna "${sheet.column}" · ${data.students.length} alumnos`, "", "| # | Alumno | Nota actual |", "|---|---|---|"];
|
|
274
|
+
const lines = [`# Tabla de notas · columna "${sheet.column}" · ${data.students.length} alumnos`, ...(sheet.context ? [`Pantalla: ${sheet.context}`] : []), `Escala detectada: ${scale.description}`, ...(blocked ? [blocked] : []), "", "| # | Alumno | Nota actual |", "|---|---|---|"];
|
|
248
275
|
data.students.forEach((s, i) => lines.push(`| ${i + 1} | ${s.name} | ${s.grade || "(vacía)"}${s.editable ? "" : " 🔒"} |`));
|
|
249
276
|
return text(lines.join("\n"));
|
|
250
277
|
});
|
|
278
|
+
// Recorre el selector de Educa y guarda en local grupos, agrupamientos, asignaturas y evaluaciones
|
|
279
|
+
async function refreshCatalog() {
|
|
280
|
+
await browser.openGroupSelector();
|
|
281
|
+
const names = await browser.listGroupNames();
|
|
282
|
+
const groups = [];
|
|
283
|
+
for (const kind of ["grupo", "agrupamiento"]) {
|
|
284
|
+
for (const name of names[kind])
|
|
285
|
+
groups.push({ name, kind, subjects: await browser.subjectsOfGroup(kind, name) });
|
|
286
|
+
}
|
|
287
|
+
if (!groups.length)
|
|
288
|
+
throw new Error("Educa no lista ningún grupo ni agrupamiento en el selector de calificaciones. Comprueba en la ventana del navegador que tu usuario tiene grupos asignados este curso.");
|
|
289
|
+
// Las evaluaciones solo se ven con una pantalla de notas abierta: se abre la primera asignatura (solo lectura)
|
|
290
|
+
let evaluations = [];
|
|
291
|
+
const first = groups.find(g => g.subjects.length);
|
|
292
|
+
if (first) {
|
|
293
|
+
await browser.openSubject(first.kind, first.name, first.subjects[0]);
|
|
294
|
+
evaluations = (await browser.evaluations()).options;
|
|
295
|
+
}
|
|
296
|
+
const catalog = { updatedAt: new Date().toISOString(), groups, evaluations };
|
|
297
|
+
saveCatalog(catalog);
|
|
298
|
+
return catalog;
|
|
299
|
+
}
|
|
300
|
+
function catalogMarkdown(catalog) {
|
|
301
|
+
const lines = [`# Tus grupos en Educa (${catalog.groups.length})`, ""];
|
|
302
|
+
for (const g of catalog.groups) {
|
|
303
|
+
const blocked = blockedGroup(g.name, config.noGradeGroups);
|
|
304
|
+
lines.push(`- **${g.name}** (${g.kind})${blocked ? " ⛔ no pones notas" : ""}: ${g.subjects.join(" · ") || "(sin asignaturas)"}`);
|
|
305
|
+
}
|
|
306
|
+
if (catalog.evaluations.length)
|
|
307
|
+
lines.push("", `Evaluaciones: ${catalog.evaluations.join(" · ")}`);
|
|
308
|
+
lines.push("", `Lista guardada en ${CATALOG_FILE} (${catalog.updatedAt.slice(0, 16).replace("T", " ")} UTC). Para abrir uno: educa_open_group.`);
|
|
309
|
+
return lines.join("\n");
|
|
310
|
+
}
|
|
311
|
+
server.registerTool("educa_refresh_groups", {
|
|
312
|
+
title: "Actualizar mis grupos",
|
|
313
|
+
description: `Lee de Educa los grupos y agrupamientos del docente, con las asignaturas de cada uno y las evaluaciones disponibles, y guarda la lista en su ordenador. No escribe nada en Educa. Tarda unos segundos por grupo.
|
|
314
|
+
|
|
315
|
+
Úsala la primera vez, cuando el profesor diga que han cambiado sus grupos (curso nuevo, sustitución) o cuando educa_open_group no encuentre un grupo. Marca con ⛔ los grupos que el profesor ha indicado en los ajustes como "no pongo notas".`,
|
|
316
|
+
inputSchema: z.strictObject({ response_format: ResponseFormat }),
|
|
317
|
+
annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true }
|
|
318
|
+
}, async ({ response_format }) => {
|
|
319
|
+
await requireLogin();
|
|
320
|
+
const catalog = await refreshCatalog();
|
|
321
|
+
if (response_format === "json")
|
|
322
|
+
return json({ ...catalog, no_grade_groups: catalog.groups.filter(g => blockedGroup(g.name, config.noGradeGroups)).map(g => g.name) });
|
|
323
|
+
return text(catalogMarkdown(catalog));
|
|
324
|
+
});
|
|
325
|
+
server.registerTool("educa_open_group", {
|
|
326
|
+
title: "Abrir las notas de un grupo",
|
|
327
|
+
description: `Abre directamente la pantalla de calificaciones de evaluación de un grupo o agrupamiento y asignatura, y opcionalmente cambia de evaluación. Usa la lista guardada por educa_refresh_groups (la actualiza sola si no existe o si el grupo no aparece). No escribe ninguna nota.
|
|
328
|
+
|
|
329
|
+
Basta parte del nombre si no es ambiguo: group="1ºAB", subject="Tecnología", evaluation="2". Si el grupo solo tiene una asignatura, subject es opcional. Devuelve el grupo abierto, la escala detectada y el número de alumnos; después usa educa_read_grade_sheet o educa_preview_grades.`,
|
|
330
|
+
inputSchema: z.strictObject({
|
|
331
|
+
group: z.string().min(1).describe("Grupo o agrupamiento, p. ej. 'ESO 1ºAB plu' o 'BTO N 1ºA'"),
|
|
332
|
+
subject: z.string().min(1).optional().describe("Asignatura; opcional si el grupo solo tiene una"),
|
|
333
|
+
evaluation: z.string().min(1).optional().describe("Evaluación, p. ej. '1a Evaluación', '2' o 'Final'; por defecto la que Educa abre")
|
|
334
|
+
}),
|
|
335
|
+
annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true }
|
|
336
|
+
}, async ({ group, subject, evaluation }) => {
|
|
337
|
+
await requireLogin();
|
|
338
|
+
let catalog = loadCatalog();
|
|
339
|
+
let refreshed = false;
|
|
340
|
+
let found = catalog ? findByName(catalog.groups, group, g => g.name) : null;
|
|
341
|
+
if (!found || !found.ok) {
|
|
342
|
+
catalog = await refreshCatalog();
|
|
343
|
+
refreshed = true;
|
|
344
|
+
found = findByName(catalog.groups, group, g => g.name);
|
|
345
|
+
}
|
|
346
|
+
if (!found.ok) {
|
|
347
|
+
const list = (found.candidates.length ? found.candidates : catalog.groups).map(g => g.name).join(" / ");
|
|
348
|
+
throw new Error(found.reason === "varios" ? `"${group}" encaja con varios grupos: ${list}. Indica el nombre completo.` : `No tienes ningún grupo que encaje con "${group}" en Educa. Tus grupos: ${list}`);
|
|
349
|
+
}
|
|
350
|
+
const entry = found.item;
|
|
351
|
+
let subjectName;
|
|
352
|
+
if (subject) {
|
|
353
|
+
const s = findByName(entry.subjects, subject, x => x);
|
|
354
|
+
if (!s.ok)
|
|
355
|
+
throw new Error(`${entry.name} no tiene ${s.reason === "varios" ? "una única" : "ninguna"} asignatura que encaje con "${subject}". Asignaturas: ${entry.subjects.join(" / ") || "(ninguna; prueba educa_refresh_groups)"}`);
|
|
356
|
+
subjectName = s.item;
|
|
357
|
+
}
|
|
358
|
+
else if (entry.subjects.length === 1)
|
|
359
|
+
subjectName = entry.subjects[0];
|
|
360
|
+
else
|
|
361
|
+
throw new Error(`${entry.name} tiene varias asignaturas: ${entry.subjects.join(" / ")}. Indica subject.`);
|
|
362
|
+
await browser.openSubject(entry.kind, entry.name, subjectName);
|
|
363
|
+
const evs = await browser.evaluations();
|
|
364
|
+
let current = evs.current;
|
|
365
|
+
if (evaluation) {
|
|
366
|
+
const e = findByName(evs.options, evaluation, x => x);
|
|
367
|
+
if (!e.ok)
|
|
368
|
+
throw new Error(`Esta pantalla no tiene ${e.reason === "varios" ? "una única" : "ninguna"} evaluación que encaje con "${evaluation}". Evaluaciones: ${evs.options.join(" / ") || "(no se ha encontrado el selector de evaluación)"}`);
|
|
369
|
+
if (normalizeName(e.item) !== normalizeName(current))
|
|
370
|
+
await browser.selectEvaluation(e.item);
|
|
371
|
+
current = e.item;
|
|
372
|
+
}
|
|
373
|
+
const sheet = await browser.readGradeSheet(undefined, true).catch(() => null);
|
|
374
|
+
const lines = [`Abierto: **${entry.name}** · ${subjectName}${current ? ` · ${current}` : ""}${refreshed ? " (he actualizado antes tu lista de grupos)" : ""}`];
|
|
375
|
+
if (sheet) {
|
|
376
|
+
lines.push(`Alumnos: ${sheet.rows.length}${sheet.column ? ` · columna editable: ${sheet.column}` : sheet.editableColumns.length ? ` · columnas editables: ${sheet.editableColumns.join(" | ")}` : " · ninguna columna editable (¿evaluación cerrada?)"}`);
|
|
377
|
+
lines.push(`Escala detectada: ${describeScale(sheet, config.grades).description}`);
|
|
378
|
+
const blocked = blockedNote(sheet) ?? (blockedGroup(entry.name, config.noGradeGroups) ? blockedNote({ ...sheet, context: entry.name }) : null);
|
|
379
|
+
if (blocked)
|
|
380
|
+
lines.push(blocked);
|
|
381
|
+
}
|
|
382
|
+
else
|
|
383
|
+
lines.push("No veo alumnos en la pantalla; puede que el grupo esté vacío o que Educa siga cargando (educa_inspect_page).");
|
|
384
|
+
return text(lines.join("\n"));
|
|
385
|
+
});
|
|
251
386
|
server.registerTool("educa_load_grades_file", {
|
|
252
387
|
title: "Leer el fichero de notas",
|
|
253
388
|
description: `Lee un Excel (.xlsx) o CSV del ordenador del profesor y devuelve la lista alumno → nota que se ha entendido. No toca Educa. Detecta solas las columnas tipo "Alumno"/"Apellidos"+"Nombre" y "Nota"/"Nota final"/"Calificación"; si no, indícalas.`,
|
|
@@ -265,7 +400,7 @@ Llámala antes que cualquier otra herramienta. Avisa al profesor de que debe ini
|
|
|
265
400
|
});
|
|
266
401
|
server.registerTool("educa_preview_grades", {
|
|
267
402
|
title: "Vista previa: cruzar notas con Educa",
|
|
268
|
-
description: `Cruza las notas (de un fichero o pasadas directamente) con la tabla de calificaciones abierta en Educa y devuelve, sin cambiar nada, qué se escribiría: alumno del fichero, alumno de Educa, nota actual, nota nueva y avisos (alumno no encontrado, nombre dudoso, nota fuera de rango o que no existe en el desplegable, campo bloqueado). Devuelve un plan_id para educa_fill_grades.
|
|
403
|
+
description: `Cruza las notas (de un fichero o pasadas directamente) con la tabla de calificaciones abierta en Educa y devuelve, sin cambiar nada, qué se escribiría: alumno del fichero, alumno de Educa, nota actual, nota nueva y avisos (alumno no encontrado, nombre dudoso, nota fuera de rango o que no existe en el desplegable, campo bloqueado). Indica también la escala detectada en la pantalla y convierte cada nota a ella: si Educa pide términos (ESO) y el fichero trae números, aplica 0–4 IN, 5 SU, 6 BI, 7–8 NT, 9–10 SB; si pide número (Bachillerato, FP) rechaza los términos y los decimales. Devuelve un plan_id para educa_fill_grades.
|
|
269
404
|
|
|
270
405
|
Los nombres se emparejan sin importar mayúsculas, tildes ni el orden "Apellidos, Nombre" / "Nombre Apellidos". Las coincidencias aproximadas se marcan y no se escriben salvo que se pida.
|
|
271
406
|
|
|
@@ -281,6 +416,7 @@ Enseña siempre el resultado al profesor antes de rellenar.`,
|
|
|
281
416
|
grade_column: z.string().optional(),
|
|
282
417
|
id_column: z.string().optional(),
|
|
283
418
|
column: z.string().optional().describe("Columna de Educa donde va la nota, si hay varias editables"),
|
|
419
|
+
stage: z.enum(["primaria", "eso", "bachillerato", "fp"]).optional().describe("Etapa del grupo, solo si la detección automática falla (afecta al rango numérico: en FP 1–10, en Bachillerato 0–10)"),
|
|
284
420
|
response_format: ResponseFormat
|
|
285
421
|
}),
|
|
286
422
|
annotations: { readOnlyHint: true, openWorldHint: true }
|
|
@@ -294,27 +430,29 @@ Enseña siempre el resultado al profesor antes de rellenar.`,
|
|
|
294
430
|
}
|
|
295
431
|
if (!inputs.length)
|
|
296
432
|
throw new Error("El fichero no contiene ninguna nota.");
|
|
297
|
-
const sheet = await browser.readGradeSheet(args.column);
|
|
433
|
+
const sheet = await browser.readGradeSheet(args.column, true);
|
|
298
434
|
if (!sheet.column && !config.gradeSheet.gradeFieldSelector) {
|
|
299
435
|
throw new Error(`La tabla de Educa tiene varias columnas editables: ${sheet.editableColumns.join(" | ")}. Indica en column cuál es la de la nota.`);
|
|
300
436
|
}
|
|
301
|
-
|
|
437
|
+
assertGroupAllowed(sheet, "preparar notas para este grupo");
|
|
438
|
+
const plan = buildPlan(sheet, inputs, config.grades, args.stage);
|
|
439
|
+
const scale = describeScale(sheet, config.grades, args.stage);
|
|
302
440
|
const stored = { id: randomUUID().slice(0, 8), column: args.column ?? null, createdAt: Date.now(), plan, filled: false };
|
|
303
441
|
plans.set(stored.id, stored);
|
|
304
442
|
if (args.response_format === "json") {
|
|
305
|
-
return json({ plan_id: stored.id, column: sheet.column, summary: summarizePlan(plan), items: plan.items, rows_without_grade: plan.rowsWithoutGrade });
|
|
443
|
+
return json({ plan_id: stored.id, column: sheet.column, context: sheet.context ?? null, scale, summary: summarizePlan(plan), items: plan.items, rows_without_grade: plan.rowsWithoutGrade });
|
|
306
444
|
}
|
|
307
|
-
return text(planMarkdown({ ...stored, column: sheet.column }, sheet));
|
|
445
|
+
return text(planMarkdown({ ...stored, column: sheet.column }, sheet, scale));
|
|
308
446
|
});
|
|
309
447
|
server.registerTool("educa_fill_grades", {
|
|
310
448
|
title: "Escribir las notas (sin guardar)",
|
|
311
|
-
description: `Escribe en los campos de Educa las notas de un plan de educa_preview_grades, solo las marcadas "se escribirá".
|
|
449
|
+
description: `Escribe en los campos de Educa las notas de un plan de educa_preview_grades, solo las marcadas "se escribirá". NO guarda: los cambios quedan en pantalla para que el profesor los revise (en el Cuaderno de aula de Educa-Gestión aparecen los botones Aceptar/Cancelar; se guarda después con educa_save_grades). Comprueba antes que la tabla sigue siendo la misma (mismos alumnos) y después relee cada campo.
|
|
312
450
|
|
|
313
451
|
Úsala solo después de que el profesor haya visto la vista previa y dado el visto bueno.`,
|
|
314
452
|
inputSchema: z.strictObject({
|
|
315
453
|
plan_id: z.string().describe("plan_id devuelto por educa_preview_grades"),
|
|
316
454
|
include_fuzzy: z.boolean().default(false).describe("Escribir también las filas emparejadas por nombre aproximado (solo si el profesor las ha confirmado)"),
|
|
317
|
-
confirm_autosave: z.boolean().default(false).describe("
|
|
455
|
+
confirm_autosave: z.boolean().default(false).describe("Solo hace falta (true) en pantallas que guardan cada nota al elegirla, si alguna lo hiciera: confirma que el profesor ha autorizado que las notas queden guardadas")
|
|
318
456
|
}),
|
|
319
457
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true }
|
|
320
458
|
}, async ({ plan_id, include_fuzzy, confirm_autosave }) => {
|
|
@@ -328,6 +466,7 @@ Enseña siempre el resultado al profesor antes de rellenar.`,
|
|
|
328
466
|
if (sheet.autoSaves && !confirm_autosave) {
|
|
329
467
|
throw new Error("En esta pantalla de Educa no hay botón Guardar: cada nota queda guardada en cuanto se elige. Enseña la vista previa al profesor y, solo si autoriza expresamente guardar, vuelve a llamar con confirm_autosave=true.");
|
|
330
468
|
}
|
|
469
|
+
assertGroupAllowed(sheet, "escribir notas en este grupo");
|
|
331
470
|
const todo = stored.plan.items.filter(i => i.status === "cambia" && (include_fuzzy || !i.fuzzyMatch));
|
|
332
471
|
// La tabla debe seguir teniendo a los mismos alumnos en las mismas filas
|
|
333
472
|
for (const item of todo) {
|
|
@@ -393,6 +532,8 @@ IMPORTANTE: llama solo cuando el profesor haya dicho expresamente que guarde, y
|
|
|
393
532
|
await requireLogin();
|
|
394
533
|
if (!lastFilledPlan)
|
|
395
534
|
throw new Error("No se ha rellenado ninguna nota en esta sesión. Usa educa_preview_grades y educa_fill_grades primero.");
|
|
535
|
+
if (config.noGradeGroups.length)
|
|
536
|
+
assertGroupAllowed(await browser.readGradeSheet(lastFilledPlan.column ?? undefined), "guardar notas de este grupo");
|
|
396
537
|
const before = await browser.screenshot("antes-de-guardar");
|
|
397
538
|
const res = await browser.save();
|
|
398
539
|
const after = await browser.screenshot("despues-de-guardar");
|
package/package.json
CHANGED