educa-navarra-mcp 1.0.0 → 1.1.0
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 +39 -3
- 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
|
@@ -12,7 +12,7 @@ Tú revisas y decides cuándo se guardan.
|
|
|
12
12
|
[](https://github.com/vTanco/educa-navarra-mcp/actions/workflows/ci.yml)
|
|
13
13
|

|
|
14
14
|
|
|
15
|
-
[**⬇️ Descargar para Claude Desktop**](https://github.com/vTanco/educa-navarra-mcp/releases/latest/download/educa-navarra-mcp.mcpb) · [Cómo se usa](#-cómo-se-usa) · [Preguntas frecuentes](#-preguntas-frecuentes) · [Aviso legal](AVISO-LEGAL.md)
|
|
15
|
+
[**⬇️ Descargar para Claude Desktop**](https://github.com/vTanco/educa-navarra-mcp/releases/latest/download/educa-navarra-mcp.mcpb) · [Cómo se usa](#-cómo-se-usa) · [Prepara tus Excel](docs/BUENAS-PRACTICAS.md) · [Preguntas frecuentes](#-preguntas-frecuentes) · [Aviso legal](AVISO-LEGAL.md)
|
|
16
16
|
|
|
17
17
|
</div>
|
|
18
18
|
|
|
@@ -41,7 +41,8 @@ Pensado para **ESO** (IN · SU · BI · NT · SB), **Bachillerato** (nota sobre
|
|
|
41
41
|
1. Instala [Claude Desktop](https://claude.ai/download) si no lo tienes.
|
|
42
42
|
2. **[Descarga `educa-navarra-mcp.mcpb`](https://github.com/vTanco/educa-navarra-mcp/releases/latest/download/educa-navarra-mcp.mcpb)**.
|
|
43
43
|
3. Haz **doble clic** en el fichero descargado. Claude Desktop se abre y te pregunta si quieres instalarlo: pulsa **Instalar**.
|
|
44
|
-
4. Te pedirá tu **usuario y contraseña de Educa**. Son **opcionales**: si los pones, Claude entra solo en Educa; si los dejas vacíos, entras tú en la ventana que se abre. La contraseña se guarda cifrada en tu ordenador.
|
|
44
|
+
4. Te pedirá tu **usuario y contraseña de Educa**. Son **opcionales**: si los pones, Claude entra solo en Educa; si los dejas vacíos, entras tú en la ventana que se abre. La contraseña se guarda cifrada en tu ordenador. También puedes indicar los **grupos en los que no pones notas** (por ejemplo, si compartes docencia): el conector se negará a escribir en ellos.
|
|
45
|
+
5. Te preguntará también por tu **carpeta de notas**. Deja la que sale (*Documentos/Notas Educa*) y créala si no la tienes: ahí guardarás tus Excel.
|
|
45
46
|
|
|
46
47
|
Y ya está. No hace falta instalar nada más: usa el **Google Chrome** o **Microsoft Edge** que ya tienes. Si no tienes ninguno, descarga un navegador la primera vez (tarda un par de minutos).
|
|
47
48
|
|
|
@@ -85,7 +86,19 @@ No compartas ese fichero ni lo subas a ningún sitio. También vale poner las va
|
|
|
85
86
|
|
|
86
87
|
Escríbele a Claude con tus palabras. Por ejemplo:
|
|
87
88
|
|
|
88
|
-
|
|
89
|
+
| Quieres… | Dile… |
|
|
90
|
+
|---|---|
|
|
91
|
+
| Meter notas | *"Mete las notas de la 1ª evaluación de BTO N 1ºA, TIC."* |
|
|
92
|
+
| Cargar tus grupos (la primera vez) | *"Actualiza mis grupos."* |
|
|
93
|
+
| Ir directo a un grupo | *"Abre las notas de 1ºAB de Tecnología, 2ª evaluación."* |
|
|
94
|
+
| Ver qué ficheros tienes | *"¿Qué ficheros de notas tengo?"* |
|
|
95
|
+
| Consultar sin tocar | *"Enséñame las notas que hay ahora en Educa de ESO 3ºDEFH, Matemáticas. No cambies nada."* |
|
|
96
|
+
| Escribir tras la vista previa | *"Vale, escríbelas."* |
|
|
97
|
+
| Guardar tras revisar en pantalla | *"Guarda."* |
|
|
98
|
+
| Usar otra columna | *"La nota está en la columna Media T1."* |
|
|
99
|
+
| Aprender a preparar el Excel | *"¿Cómo preparo mi Excel de notas?"* |
|
|
100
|
+
|
|
101
|
+
En Claude Desktop tienes además atajos en el menú **+** del chat: **Meter notas en Educa**, **Revisar mis notas en Educa** y **Cómo preparo mi Excel de notas**.
|
|
89
102
|
|
|
90
103
|
```mermaid
|
|
91
104
|
flowchart LR
|
|
@@ -100,8 +113,31 @@ flowchart LR
|
|
|
100
113
|
|
|
101
114
|
Verás una ventana del navegador donde ocurre todo: puedes mirar en cualquier momento qué está haciendo. **El cierre y la firma de la evaluación los haces tú a mano**, como siempre.
|
|
102
115
|
|
|
116
|
+
### Tus grupos
|
|
117
|
+
|
|
118
|
+
No hay que configurar los grupos: el conector los lee de Educa. Di *"actualiza mis grupos"* y guardará en tu ordenador la lista de grupos, agrupamientos, asignaturas y evaluaciones; a partir de ahí basta con *"abre las notas de 1ºAB de Tecnología, 2ª evaluación"*. Repítelo cuando cambien tus grupos (curso nuevo, sustitución).
|
|
119
|
+
|
|
120
|
+
Si en algún grupo no pones tú las notas (docencia compartida), indícalo en el ajuste **Grupos en los que no pongo notas**: podrás consultarlo, pero el conector se negará a escribir en él.
|
|
121
|
+
|
|
122
|
+
### Sirve para cualquier etapa
|
|
123
|
+
|
|
124
|
+
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.
|
|
125
|
+
|
|
126
|
+
| Lo que ofrece Educa | Qué acepta de tu fichero |
|
|
127
|
+
|---|---|
|
|
128
|
+
| Desplegable con términos (ESO, Primaria): IN, SU, BI, NT, SB | El término ("NT", "Notable") o un número entero, que convierte así: 0–4 IN, 5 SU, 6 BI, 7–8 NT, 9–10 SB |
|
|
129
|
+
| Nota tecleada (Bachillerato) | Entero de 0 a 10 |
|
|
130
|
+
| Nota tecleada en un ciclo formativo (FP) | Entero de 1 a 10 |
|
|
131
|
+
| Desplegable Apto / No apto | El término; rechaza números |
|
|
132
|
+
| Otros valores (FPEX, EX, NP...) | Solo si la pantalla los ofrece |
|
|
133
|
+
|
|
134
|
+
La vista previa dice siempre qué escala ha detectado y nunca redondea decimales. Las pantallas de FP solo se han probado con el simulador. Normativa de referencia en [docs/normativa-evaluacion.md](docs/normativa-evaluacion.md).
|
|
135
|
+
|
|
103
136
|
### Tu fichero de notas
|
|
104
137
|
|
|
138
|
+
> [!TIP]
|
|
139
|
+
> Guarda tus Excel en **Documentos/Notas Educa** con nombres como `1EV_BTO-1A_TIC.xlsx` (evaluación_grupo_materia) y Claude los encontrará solo. Todos los consejos en la **[guía de buenas prácticas](docs/BUENAS-PRACTICAS.md)**.
|
|
140
|
+
|
|
105
141
|
Vale casi cualquier Excel o CSV con una fila por alumno. Por ejemplo:
|
|
106
142
|
|
|
107
143
|
| Apellidos | Nombre | Nota |
|
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