Conecta PLANILLA_DOCENTE_IA con Gemini usando Google Apps Script. El docente puede escribir o grabar una instrucción; el audio se transcribe, aparece para revisión y solo después de confirmar se modifica la hoja.
Debe llamarse exactamente PLANILLA_DOCENTE_IA.
En la URL copia solo lo que está entre /d/ y /edit.
La cuenta de Apps Script debe poder editar esa hoja.
Llámalo ASISTENTE_DOCENTE_IA.
Configuración → Mostrar appsscript.json.
Permisos mínimos para Sheets y conexión externa a Gemini.
{
"timeZone": "America/Bogota",
"dependencies": {},
"exceptionLogging": "STACKDRIVER",
"runtimeVersion": "V8",
"oauthScopes": [
"https://www.googleapis.com/auth/spreadsheets",
"https://www.googleapis.com/auth/script.external_request"
],
"urlFetchWhitelist": [
"https://generativelanguage.googleapis.com/"
],
"webapp": {
"executeAs": "USER_DEPLOYING",
"access": "MYSELF"
}
}| GEMINI_API_KEY | tu_clave |
| SPREADSHEET_ID | ID_de_PLANILLA_DOCENTE_IA |
| GEMINI_MODEL_PRIMARY | gemini-3.8-flash |
| GEMINI_MODEL_FALLBACK | gemini-3.5-flash-lite |
https://anfaliz1703.github.io/grabadorAudioMasterClass/. No debes crear la propiedad RECORDER_URL.Incluye Gemini, detección automática de IDs, notas, fallas, observaciones, puntos, consultas, diagnóstico y transcripción de audio.
const CONFIG = {
PROP_API_KEY: 'GEMINI_API_KEY',
PROP_SPREADSHEET_ID: 'SPREADSHEET_ID',
PROP_MODEL_PRIMARY: 'GEMINI_MODEL_PRIMARY',
PROP_MODEL_FALLBACK: 'GEMINI_MODEL_FALLBACK',
DEFAULT_PRIMARY_MODEL: 'gemini-3.8-flash',
DEFAULT_FALLBACK_MODEL: 'gemini-3.5-flash-lite',
RECORDER_URL: 'https://anfaliz1703.github.io/grabadorAudioMasterClass/',
EXPECTED_SPREADSHEET_NAME: 'PLANILLA_DOCENTE_IA'
};
// =====================================================
// WEB APP
// =====================================================
function doGet() {
return HtmlService
.createHtmlOutputFromFile('Index')
.setTitle('Asistente Docente IA')
.addMetaTag('viewport', 'width=device-width, initial-scale=1');
}
// =====================================================
// CONFIGURACIÓN
// =====================================================
function getConfig_() {
const props = PropertiesService.getScriptProperties();
const apiKey = props.getProperty(CONFIG.PROP_API_KEY);
const spreadsheetId = props.getProperty(CONFIG.PROP_SPREADSHEET_ID);
const primaryModel =
props.getProperty(CONFIG.PROP_MODEL_PRIMARY) ||
CONFIG.DEFAULT_PRIMARY_MODEL;
const fallbackModel =
props.getProperty(CONFIG.PROP_MODEL_FALLBACK) ||
CONFIG.DEFAULT_FALLBACK_MODEL;
if (!apiKey) {
throw new Error('Falta GEMINI_API_KEY en Propiedades del script.');
}
if (!spreadsheetId) {
throw new Error('Falta SPREADSHEET_ID en Propiedades del script.');
}
return {
apiKey,
spreadsheetId,
primaryModel,
fallbackModel
};
}
function validarNombrePlanilla_(ss) {
const nombre = ss.getName();
if (nombre !== CONFIG.EXPECTED_SPREADSHEET_NAME) {
throw new Error(
'La hoja conectada debe llamarse exactamente ' +
CONFIG.EXPECTED_SPREADSHEET_NAME +
'. Nombre actual: ' +
nombre
);
}
}
// =====================================================
// CHAT
// =====================================================
function procesarMensaje(texto) {
texto = String(texto || '').trim();
if (!texto) {
return {
ok: false,
mensaje: 'Escribe o graba una instrucción.'
};
}
const cfg = getConfig_();
const ss = SpreadsheetApp.openById(cfg.spreadsheetId);
validarNombrePlanilla_(ss);
const hojas = ss.getSheets().map(function(sheet) {
return sheet.getName();
});
const op = interpretarConGemini_(texto, hojas, cfg);
if (!op || !op.accion) {
return {
ok: false,
mensaje: 'No pude interpretar la instrucción. Intenta decirla de otra forma.'
};
}
if (op.accion === 'ayuda' || op.accion === 'desconocida') {
return {
ok: true,
mensaje: op.respuesta || 'Necesito un poco más de información.'
};
}
if (op.accion === 'consultar') {
return consultarEstudiante_(ss, op);
}
const validacion = validarOperacion_(ss, op);
if (!validacion.ok) {
return validacion;
}
return {
ok: true,
requiereConfirmacion: true,
mensaje: resumenOperacion_(op),
operacion: op
};
}
// =====================================================
// INTERPRETAR CON GEMINI
// =====================================================
function interpretarConGemini_(texto, hojas, cfg) {
const instruccion = [
'Eres un intérprete de instrucciones para una planilla docente.',
'Devuelve SOLO JSON válido, sin markdown.',
'No inventes datos que el docente no dijo.',
'Acciones permitidas: registrar_nota, registrar_falla, registrar_observacion, registrar_punto, consultar, ayuda, desconocida.',
'Estructura esperada:',
'{"accion":"...","hoja":"...","estudianteId":"...","actividad":"...","valor":null,"cantidad":1,"tipoPunto":"positivo|negativo","observacion":"...","respuesta":"..."}',
'Hojas disponibles: ' + JSON.stringify(hojas),
'Los estudiantes se identifican con códigos como 701-03, 801-12 o 1101-05.',
'No solicites nombres, documentos, teléfonos, correos ni datos sensibles.',
'Si falta un dato indispensable, usa accion desconocida y explica qué falta en respuesta.',
'Para registrar nota, conserva el nombre o número de actividad mencionado por el docente.',
'Si el docente dice Actividad 2, devuelve "actividad":"Actividad 2".',
'Si el código comienza por 701 y existe una hoja 701, usa esa hoja.',
'Para consultar, identifica estudianteId y hoja cuando sea posible.',
'Instrucción del docente: ' + texto
].join('\n');
const modelos = [cfg.primaryModel, cfg.fallbackModel]
.filter(function(modelo, index, array) {
return modelo && array.indexOf(modelo) === index;
});
const errores = [];
for (let i = 0; i < modelos.length; i++) {
try {
return llamarGeminiJson_(modelos[i], instruccion, cfg.apiKey);
} catch (error) {
errores.push(
modelos[i] + ': ' +
(error && error.message ? error.message : String(error))
);
}
}
throw new Error(
'No fue posible interpretar la instrucción. ' +
errores.join(' | ')
);
}
function llamarGeminiJson_(modelo, instruccion, apiKey) {
const url =
'https://generativelanguage.googleapis.com/v1beta/models/' +
encodeURIComponent(modelo) +
':generateContent';
const payload = {
contents: [
{
role: 'user',
parts: [{ text: instruccion }]
}
],
generationConfig: {
responseMimeType: 'application/json'
}
};
const response = UrlFetchApp.fetch(url, {
method: 'post',
contentType: 'application/json',
headers: {
'x-goog-api-key': apiKey
},
payload: JSON.stringify(payload),
muteHttpExceptions: true
});
const status = response.getResponseCode();
const raw = response.getContentText();
if (status < 200 || status >= 300) {
throw new Error(
'HTTP ' + status + ' · ' + raw.slice(0, 300)
);
}
const data = JSON.parse(raw);
const candidates = data && data.candidates ? data.candidates : [];
const parts =
candidates[0] &&
candidates[0].content &&
candidates[0].content.parts
? candidates[0].content.parts
: [];
const salida = parts
.map(function(part) {
return part.text || '';
})
.join('')
.trim();
if (!salida) {
throw new Error('Gemini devolvió una respuesta vacía.');
}
try {
return JSON.parse(salida);
} catch (error) {
throw new Error(
'Gemini no devolvió JSON válido: ' + salida.slice(0, 200)
);
}
}
// =====================================================
// VALIDACIÓN Y BÚSQUEDA DE HOJA
// =====================================================
function validarOperacion_(ss, op) {
if (!op.estudianteId) {
return {
ok: false,
mensaje: 'Me falta el ID del estudiante.'
};
}
let sheet = buscarHoja_(ss, op.hoja);
if (!sheet) {
sheet = buscarHojaPorCodigoEstudiante_(ss, op.estudianteId);
}
if (!sheet) {
return {
ok: false,
mensaje: 'No pude identificar la pestaña o grupo. Dime también el grupo, por ejemplo 701.'
};
}
op.hoja = sheet.getName();
if (
op.accion === 'registrar_nota' &&
(!op.actividad || op.valor === null || op.valor === undefined)
) {
return {
ok: false,
mensaje: 'Para una nota necesito actividad y valor.'
};
}
if (
op.accion === 'registrar_observacion' &&
!String(op.observacion || '').trim()
) {
return {
ok: false,
mensaje: 'Dime qué observación deseas registrar.'
};
}
return { ok: true };
}
function buscarHoja_(ss, nombre) {
const sheets = ss.getSheets();
if (nombre) {
const exacta = ss.getSheetByName(String(nombre));
if (exacta) {
return exacta;
}
const objetivo = normalizar_(nombre);
const parcial = sheets.find(function(sheet) {
const actual = normalizar_(sheet.getName());
return (
actual.includes(objetivo) ||
objetivo.includes(actual)
);
});
if (parcial) {
return parcial;
}
}
return sheets.length === 1 ? sheets[0] : null;
}
function buscarHojaPorCodigoEstudiante_(ss, estudianteId) {
const id = String(estudianteId || '').trim();
const match = id.match(/^(\d{3,4})-/);
if (!match) {
return null;
}
const codigoGrupo = match[1];
const exacta = ss.getSheetByName(codigoGrupo);
if (exacta) {
return exacta;
}
const encontrada = ss.getSheets().find(function(sheet) {
const nombre = normalizar_(sheet.getName());
return (
nombre === normalizar_(codigoGrupo) ||
nombre.includes(codigoGrupo)
);
});
return encontrada || null;
}
// =====================================================
// ENCABEZADOS E IDENTIFICADORES
// =====================================================
function detectarFilaEncabezados_(sheet) {
const rows = Math.min(12, Math.max(1, sheet.getLastRow()));
const cols = Math.min(60, Math.max(1, sheet.getLastColumn()));
const values = sheet
.getRange(1, 1, rows, cols)
.getDisplayValues();
let bestRow = 1;
let bestScore = -1;
values.forEach(function(row, index) {
const normalizados = row.map(normalizar_);
let score = 0;
if (
normalizados.some(function(value) {
return [
'ID',
'CODIGO',
'IDENTIFICADOR',
'ESTUDIANTE'
].includes(value);
})
) {
score += 5;
}
score += normalizados.filter(function(value) {
return (
value.includes('ACT') ||
value.includes('NOTA') ||
value.includes('DEFINITIVA') ||
value.includes('POSIT') ||
value.includes('NEGAT') ||
value.includes('FALLA') ||
value.includes('OBSERV')
);
}).length;
if (score > bestScore) {
bestScore = score;
bestRow = index + 1;
}
});
return bestRow;
}
function resolverColumnaId_(sheet, headerRow, headers) {
let idCol = buscarColumna_(
headers,
[
'ID',
'CODIGO',
'CÓDIGO',
'IDENTIFICADOR',
'COD ESTUDIANTE',
'CÓDIGO ESTUDIANTE'
]
);
if (idCol) {
return idCol;
}
const lastRow = sheet.getLastRow();
const lastCol = Math.min(60, Math.max(1, sheet.getLastColumn()));
if (lastRow <= headerRow) {
return 0;
}
const cantidadFilas = Math.min(60, lastRow - headerRow);
const values = sheet
.getRange(
headerRow + 1,
1,
cantidadFilas,
lastCol
)
.getDisplayValues();
let mejorColumna = 0;
let mejorPuntaje = 0;
for (let col = 0; col < lastCol; col++) {
let coincidencias = 0;
for (let fila = 0; fila < values.length; fila++) {
const valor = String(values[fila][col] || '').trim();
if (/^\d{3,4}-\d{1,3}$/.test(valor)) {
coincidencias++;
}
}
if (coincidencias > mejorPuntaje) {
mejorPuntaje = coincidencias;
mejorColumna = col + 1;
}
}
return mejorPuntaje >= 2 ? mejorColumna : 0;
}
function buscarColumna_(headers, candidatos) {
const cand = candidatos.map(normalizar_);
for (let i = 0; i < headers.length; i++) {
if (cand.includes(normalizar_(headers[i]))) {
return i + 1;
}
}
return 0;
}
function buscarColumnaActividad_(headers, actividad) {
const target = normalizar_(actividad);
for (let i = 0; i < headers.length; i++) {
if (normalizar_(headers[i]) === target) {
return i + 1;
}
}
const match = String(actividad || '').match(/(\d{1,2})/);
if (match) {
const num = Number(match[1]);
for (let i = 0; i < headers.length; i++) {
const h = normalizar_(headers[i]);
const hm = h.match(/(\d{1,2})/);
if (
hm &&
Number(hm[1]) === num &&
(
h.includes('ACT') ||
h.includes('ACTIVIDAD')
)
) {
return i + 1;
}
}
}
return 0;
}
function buscarFilaEstudiante_(sheet, headerRow, idCol, id) {
const last = sheet.getLastRow();
if (last <= headerRow) {
return 0;
}
const values = sheet
.getRange(
headerRow + 1,
idCol,
last - headerRow,
1
)
.getDisplayValues()
.flat();
const target = String(id || '').trim().toUpperCase();
const idx = values.findIndex(function(value) {
return String(value).trim().toUpperCase() === target;
});
return idx < 0 ? 0 : headerRow + 1 + idx;
}
function obtenerOCrearColumna_(sheet, headerRow, candidatos, titulo) {
const headers = sheet
.getRange(
headerRow,
1,
1,
Math.max(1, sheet.getLastColumn())
)
.getDisplayValues()[0];
const found = buscarColumna_(headers, candidatos);
if (found) {
return found;
}
const col = sheet.getLastColumn() + 1;
sheet.getRange(headerRow, col).setValue(titulo);
return col;
}
// =====================================================
// CONFIRMAR Y GUARDAR
// =====================================================
function confirmarOperacion(operacionJson) {
const op =
typeof operacionJson === 'string'
? JSON.parse(operacionJson)
: operacionJson;
const cfg = getConfig_();
const ss = SpreadsheetApp.openById(cfg.spreadsheetId);
validarNombrePlanilla_(ss);
const validacion = validarOperacion_(ss, op);
if (!validacion.ok) {
return validacion;
}
const sheet = buscarHoja_(ss, op.hoja);
const headerRow = detectarFilaEncabezados_(sheet);
const headers = sheet
.getRange(
headerRow,
1,
1,
Math.max(1, sheet.getLastColumn())
)
.getDisplayValues()[0];
const idCol = resolverColumnaId_(sheet, headerRow, headers);
if (!idCol) {
return {
ok: false,
mensaje: 'No pude localizar la columna con códigos como 701-01, 701-02, etc.'
};
}
const studentRow = buscarFilaEstudiante_(
sheet,
headerRow,
idCol,
op.estudianteId
);
if (!studentRow) {
return {
ok: false,
mensaje:
'No encontré el ID ' +
op.estudianteId +
' en la hoja ' +
sheet.getName() +
'.'
};
}
if (op.accion === 'registrar_nota') {
const activityCol = buscarColumnaActividad_(
headers,
op.actividad
);
if (!activityCol) {
return {
ok: false,
mensaje:
'No encontré la actividad “' +
op.actividad +
'” en los encabezados.'
};
}
const valor = Number(op.valor);
if (!Number.isFinite(valor)) {
return {
ok: false,
mensaje: 'La nota no parece ser un número válido.'
};
}
sheet.getRange(studentRow, activityCol).setValue(valor);
return {
ok: true,
mensaje:
'Listo. Guardé ' +
valor +
' para ' +
op.estudianteId +
' en ' +
headers[activityCol - 1] +
'.'
};
}
if (op.accion === 'registrar_falla') {
const col = obtenerOCrearColumna_(
sheet,
headerRow,
['FALLAS', 'INASISTENCIAS'],
'FALLAS'
);
const cell = sheet.getRange(studentRow, col);
const actual = Number(cell.getValue()) || 0;
const cantidad = Math.max(1, Number(op.cantidad) || 1);
cell.setValue(actual + cantidad);
return {
ok: true,
mensaje:
'Listo. Registré ' +
cantidad +
' falla(s) para ' +
op.estudianteId +
'. Total: ' +
(actual + cantidad) +
'.'
};
}
if (op.accion === 'registrar_observacion') {
const col = obtenerOCrearColumna_(
sheet,
headerRow,
['OBSERVACIONES', 'OBSERVACION'],
'OBSERVACIONES'
);
const cell = sheet.getRange(studentRow, col);
const anterior = String(cell.getValue() || '').trim();
const marca = Utilities.formatDate(
new Date(),
Session.getScriptTimeZone() || 'America/Bogota',
'yyyy-MM-dd'
);
const nueva =
(anterior ? anterior + ' | ' : '') +
marca +
': ' +
String(op.observacion || '').trim();
cell.setValue(nueva);
return {
ok: true,
mensaje:
'Listo. Agregué la observación para ' +
op.estudianteId +
'.'
};
}
if (op.accion === 'registrar_punto') {
const positivo = String(op.tipoPunto || '')
.toLowerCase()
.includes('pos');
const candidatos = positivo
? ['POSITIVOS', 'PUNTOS POSITIVOS', 'POS']
: ['NEGATIVOS', 'PUNTOS NEGATIVOS', 'NEG'];
const titulo = positivo ? 'POSITIVOS' : 'NEGATIVOS';
const col = obtenerOCrearColumna_(
sheet,
headerRow,
candidatos,
titulo
);
const cell = sheet.getRange(studentRow, col);
const actual = Number(cell.getValue()) || 0;
const cantidad = Math.max(1, Number(op.cantidad) || 1);
cell.setValue(actual + cantidad);
return {
ok: true,
mensaje:
'Listo. Sumé ' +
cantidad +
' punto(s) ' +
(positivo ? 'positivo(s)' : 'negativo(s)') +
' a ' +
op.estudianteId +
'.'
};
}
return {
ok: false,
mensaje: 'La operación llegó al servidor, pero no está configurada.'
};
}
// =====================================================
// CONSULTA
// =====================================================
function consultarEstudiante_(ss, op) {
let sheet = buscarHoja_(ss, op.hoja);
if (!sheet) {
sheet = buscarHojaPorCodigoEstudiante_(ss, op.estudianteId);
}
if (!sheet) {
return {
ok: false,
mensaje: 'Dime el grupo o pestaña para poder consultar.'
};
}
const headerRow = detectarFilaEncabezados_(sheet);
const headers = sheet
.getRange(
headerRow,
1,
1,
Math.max(1, sheet.getLastColumn())
)
.getDisplayValues()[0];
const idCol = resolverColumnaId_(sheet, headerRow, headers);
if (!idCol) {
return {
ok: false,
mensaje:
'No pude identificar la columna de códigos de estudiantes en ' +
sheet.getName() +
'.'
};
}
const row = buscarFilaEstudiante_(
sheet,
headerRow,
idCol,
op.estudianteId
);
if (!row) {
return {
ok: false,
mensaje:
'No encuentro ' +
op.estudianteId +
' en ' +
sheet.getName() +
'.'
};
}
const values = sheet
.getRange(row, 1, 1, headers.length)
.getDisplayValues()[0];
const items = [];
headers.forEach(function(header, index) {
if (
header &&
values[index] !== '' &&
index !== idCol - 1
) {
items.push(header + ': ' + values[index]);
}
});
return {
ok: true,
mensaje:
'Registro de ' +
op.estudianteId +
' en ' +
sheet.getName() +
':\n' +
(
items.join(' · ') ||
'No hay datos registrados todavía.'
)
};
}
// =====================================================
// RESUMEN DE CONFIRMACIÓN
// =====================================================
function resumenOperacion_(op) {
if (op.accion === 'registrar_nota') {
return (
'Entendí: ' +
op.estudianteId +
' · ' +
op.actividad +
' · nota ' +
op.valor +
' · grupo ' +
op.hoja +
'. ¿Confirmas antes de guardar?'
);
}
if (op.accion === 'registrar_falla') {
return (
'Entendí: registrar ' +
(op.cantidad || 1) +
' falla(s) a ' +
op.estudianteId +
' · grupo ' +
op.hoja +
'. ¿Confirmas?'
);
}
if (op.accion === 'registrar_observacion') {
return (
'Entendí: agregar a ' +
op.estudianteId +
' la observación “' +
op.observacion +
'” · grupo ' +
op.hoja +
'. ¿Confirmas?'
);
}
if (op.accion === 'registrar_punto') {
return (
'Entendí: sumar ' +
(op.cantidad || 1) +
' punto(s) ' +
op.tipoPunto +
' a ' +
op.estudianteId +
' · grupo ' +
op.hoja +
'. ¿Confirmas?'
);
}
return '¿Confirmas esta operación?';
}
// =====================================================
// AUDIO
// =====================================================
function getRecorderUrl() {
const url = CONFIG.RECORDER_URL;
if (!/^https:\/\//i.test(url)) {
throw new Error(
'La URL fija del grabador no es HTTPS.'
);
}
return url;
}
function transcribirAudio(audioBase64, mimeType) {
if (!audioBase64) {
throw new Error('No se recibió audio.');
}
const cfg = getConfig_();
let base64 = String(audioBase64).trim();
base64 = base64.replace(
/^data:[^;]+;base64,/,
''
);
const tipo = String(mimeType || 'audio/webm')
.split(';')[0];
const bytesEstimados = Math.floor(base64.length * 3 / 4);
if (bytesEstimados > 8 * 1024 * 1024) {
throw new Error(
'El audio es demasiado grande. Graba una instrucción más corta.'
);
}
const modelos = [cfg.primaryModel, cfg.fallbackModel]
.filter(function(modelo, index, array) {
return modelo && array.indexOf(modelo) === index;
});
const errores = [];
for (let i = 0; i < modelos.length; i++) {
try {
return transcribirConModelo_(
modelos[i],
base64,
tipo,
cfg.apiKey
);
} catch (error) {
errores.push(
modelos[i] + ': ' +
(error && error.message ? error.message : String(error))
);
}
}
throw new Error(
'No pude transcribir el audio. ' +
errores.join(' | ')
);
}
function transcribirConModelo_(modelo, base64, mimeType, apiKey) {
const url =
'https://generativelanguage.googleapis.com/v1beta/models/' +
encodeURIComponent(modelo) +
':generateContent';
const payload = {
contents: [
{
role: 'user',
parts: [
{
text:
'Transcribe únicamente la instrucción hablada por un docente. ' +
'El audio está principalmente en español de Colombia. ' +
'Conserva con precisión códigos como 701-03, 801-12 o 1101-05, ' +
'notas como 4.7, números de actividades, fallas, observaciones ' +
'y puntos positivos o negativos. ' +
'Devuelve únicamente la transcripción, sin explicación ni markdown.'
},
{
inlineData: {
mimeType: mimeType,
data: base64
}
}
]
}
]
};
const response = UrlFetchApp.fetch(url, {
method: 'post',
contentType: 'application/json',
headers: {
'x-goog-api-key': apiKey
},
payload: JSON.stringify(payload),
muteHttpExceptions: true
});
const status = response.getResponseCode();
const raw = response.getContentText();
if (status < 200 || status >= 300) {
throw new Error(
'HTTP ' + status + ' · ' + raw.slice(0, 300)
);
}
const data = JSON.parse(raw);
const parts =
data &&
data.candidates &&
data.candidates[0] &&
data.candidates[0].content &&
data.candidates[0].content.parts
? data.candidates[0].content.parts
: [];
const texto = parts
.map(function(part) {
return part.text || '';
})
.join('')
.trim();
if (!texto) {
throw new Error('Gemini devolvió una transcripción vacía.');
}
return {
ok: true,
texto
};
}
// =====================================================
// NORMALIZAR
// =====================================================
function normalizar_(text) {
return String(text || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.trim()
.toUpperCase()
.replace(/[^A-Z0-9]+/g, ' ')
.trim();
}
// =====================================================
// DIAGNÓSTICO
// =====================================================
function diagnosticoSistema() {
const resultado = [];
try {
const cfg = getConfig_();
resultado.push('✅ GEMINI_API_KEY encontrada');
resultado.push('✅ SPREADSHEET_ID encontrado');
resultado.push('✅ Modelo principal: ' + cfg.primaryModel);
resultado.push('✅ Modelo respaldo: ' + cfg.fallbackModel);
const ss = SpreadsheetApp.openById(cfg.spreadsheetId);
resultado.push('✅ Acceso a Google Sheets');
if (ss.getName() === CONFIG.EXPECTED_SPREADSHEET_NAME) {
resultado.push('✅ Nombre: PLANILLA_DOCENTE_IA');
} else {
resultado.push(
'❌ Nombre incorrecto. Actual: ' +
ss.getName()
);
}
const hojas = ss.getSheets();
resultado.push(
'✅ Pestañas: ' +
hojas.map(function(sheet) {
return sheet.getName();
}).join(', ')
);
hojas.forEach(function(sheet) {
const headerRow = detectarFilaEncabezados_(sheet);
const headers = sheet
.getRange(
headerRow,
1,
1,
Math.max(1, sheet.getLastColumn())
)
.getDisplayValues()[0];
const idCol = resolverColumnaId_(
sheet,
headerRow,
headers
);
if (idCol) {
resultado.push(
'✅ ' +
sheet.getName() +
': columna de IDs detectada en columna ' +
idCol
);
} else {
resultado.push(
'⚠️ ' +
sheet.getName() +
': no detecté columna de IDs'
);
}
});
const url =
'https://generativelanguage.googleapis.com/v1beta/models/' +
encodeURIComponent(cfg.primaryModel);
const response = UrlFetchApp.fetch(url, {
method: 'get',
headers: {
'x-goog-api-key': cfg.apiKey
},
muteHttpExceptions: true
});
if (
response.getResponseCode() >= 200 &&
response.getResponseCode() < 300
) {
resultado.push('✅ Gemini API conectada');
} else {
resultado.push(
'❌ Gemini respondió HTTP ' +
response.getResponseCode()
);
}
resultado.push('✅ Grabador fijo: https://anfaliz1703.github.io/grabadorAudioMasterClass/');
} catch (error) {
resultado.push('❌ ERROR: ' + error.message);
}
const informe = resultado.join('\n');
Logger.log(informe);
return informe;
}
Archivos → + → HTML → escribe Index. Pega este contenido completo.
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Asistente Docente IA</title>
<style>
:root{
--blue:#1b6cea;
--blue2:#1556bf;
--navy:#17315f;
--soft:#eef6ff;
--border:#d8e8ff;
--green:#17a66a;
--red:#e95b70;
--amber:#f5a623;
}
*{box-sizing:border-box}
body{
margin:0;
min-height:100vh;
display:grid;
place-items:center;
padding:16px;
font-family:Arial,sans-serif;
color:var(--navy);
background:linear-gradient(135deg,#eef6ff,#f9fbff);
}
.app{
width:min(860px,100%);
height:min(820px,calc(100vh - 32px));
display:flex;
flex-direction:column;
overflow:hidden;
border:1px solid var(--border);
border-radius:28px;
background:rgba(255,255,255,.96);
box-shadow:0 24px 70px rgba(34,79,160,.16);
}
.top{
padding:18px 20px;
color:#fff;
background:linear-gradient(135deg,var(--blue),var(--blue2));
}
.top h1{margin:0;font-size:1.2rem}
.top p{margin:5px 0 0;font-size:.86rem;opacity:.9}
.chat{
flex:1;
overflow:auto;
padding:18px;
background:linear-gradient(180deg,#f9fbff,#edf5ff);
}
.bubble{
max-width:84%;
margin-bottom:12px;
padding:12px 14px;
line-height:1.45;
white-space:pre-wrap;
border-radius:18px;
}
.user{
margin-left:auto;
color:#fff;
background:var(--blue);
border-bottom-right-radius:5px;
}
.bot{
background:#fff;
border:1px solid var(--border);
border-bottom-left-radius:5px;
}
.pending{
color:#73551d;
background:#fff8e8;
border-color:#ffe0a0;
}
.actions{
display:flex;
flex-wrap:wrap;
gap:8px;
margin-top:10px;
}
.btn{
min-height:40px;
border:0;
border-radius:999px;
padding:9px 14px;
font-weight:800;
cursor:pointer;
}
.yes{color:#fff;background:var(--green)}
.no{color:var(--navy);background:#fff;border:1px solid var(--border)}
.examples{
display:flex;
flex-wrap:wrap;
gap:7px;
padding:10px 12px 0;
background:#fff;
}
.chip{
min-height:36px;
border:1px solid var(--border);
border-radius:999px;
padding:7px 10px;
background:#f8fbff;
color:#31568f;
font-size:.8rem;
cursor:pointer;
}
.composer{
display:grid;
grid-template-columns:auto 1fr auto;
gap:9px;
align-items:center;
padding:12px;
background:#fff;
border-top:1px solid var(--border);
}
.composer input{
width:100%;
min-height:48px;
border:1px solid var(--border);
border-radius:16px;
padding:13px 14px;
font-size:1rem;
}
.round{
width:48px;
height:48px;
border:0;
border-radius:15px;
background:#eaf3ff;
color:var(--blue);
font-size:1.2rem;
cursor:pointer;
}
.round.send{
color:#fff;
background:var(--blue);
}
.round:disabled,
.btn:disabled,
.chip:disabled{
opacity:.45;
cursor:not-allowed;
}
.status{
min-height:28px;
padding:0 14px 10px;
color:#647da3;
background:#fff;
font-size:.78rem;
}
.voice-status{
display:none;
margin:0 12px 10px;
padding:10px 12px;
border:1px solid #cfe2ff;
border-radius:14px;
background:#f3f8ff;
color:#31568f;
font-size:.84rem;
}
.voice-status.show{display:block}
:focus-visible{
outline:3px solid rgba(27,108,234,.24);
outline-offset:2px;
}
@media(max-width:600px){
body{padding:6px}
.app{
height:calc(100vh - 12px);
border-radius:18px;
}
.bubble{max-width:94%}
.chip{font-size:.76rem}
}
</style>
</head>
<body>
<div class="app">
<div class="top">
<h1>Asistente Docente IA</h1>
<p>Escribe o graba una instrucción. Antes de modificar la planilla, siempre pide confirmación.</p>
</div>
<div class="chat" id="chat"></div>
<div class="examples">
<button class="chip" type="button">Pon 4.7 a 701-03 en Actividad 2</button>
<button class="chip" type="button">Registra una falla a 701-04</button>
<button class="chip" type="button">Consulta 701-03 del grupo 701</button>
</div>
<div class="voice-status" id="voiceStatus"></div>
<div class="composer">
<button class="round" id="mic" type="button" title="Grabar voz">🎤</button>
<input id="input" placeholder="Escribe o graba tu instrucción..." autocomplete="off">
<button class="round send" id="send" type="button" title="Enviar">➤</button>
</div>
<div class="status" id="status">
Listo. Puedes escribir o pulsar 🎤.
</div>
</div>
<script>
let pendingOperation = null;
let recorderPopup = null;
let recorderOrigin = null;
const chat = document.getElementById('chat');
const input = document.getElementById('input');
const send = document.getElementById('send');
const mic = document.getElementById('mic');
const status = document.getElementById('status');
const voiceStatus = document.getElementById('voiceStatus');
function esc(text){
const d = document.createElement('div');
d.textContent = String(text || '');
return d.innerHTML;
}
function addBubble(text,type='bot',extra=''){
const div = document.createElement('div');
div.className = 'bubble ' + type + (extra ? ' ' + extra : '');
div.innerHTML = esc(text).replace(/\n/g,'<br>');
chat.appendChild(div);
chat.scrollTop = chat.scrollHeight;
return div;
}
function setStatus(text){
status.textContent = text;
}
function setVoiceStatus(text,show=true){
voiceStatus.textContent = text || '';
voiceStatus.classList.toggle('show',show && !!text);
}
function setBusy(on){
send.disabled = on;
mic.disabled = on;
input.disabled = on;
if (on) {
setStatus('Procesando...');
} else {
input.disabled = false;
setStatus('Listo. Puedes escribir o pulsar 🎤.');
}
}
function speak(text){
if (!('speechSynthesis' in window)) return;
const u = new SpeechSynthesisUtterance(String(text || ''));
u.lang = 'es-CO';
speechSynthesis.cancel();
speechSynthesis.speak(u);
}
function handleError(err){
setBusy(false);
const message =
err && err.message
? err.message
: String(err || 'Error no identificado');
addBubble('Error: ' + message,'bot');
}
function handleResponse(res){
setBusy(false);
if (!res){
addBubble('No recibí respuesta del servidor.','bot');
return;
}
const bubble = addBubble(
res.mensaje || 'Listo.',
'bot',
res.requiereConfirmacion ? 'pending' : ''
);
if (res.requiereConfirmacion && res.operacion){
pendingOperation = res.operacion;
const actions = document.createElement('div');
actions.className = 'actions';
const yes = document.createElement('button');
yes.type = 'button';
yes.className = 'btn yes';
yes.textContent = 'Confirmar y guardar';
const no = document.createElement('button');
no.type = 'button';
no.className = 'btn no';
no.textContent = 'Cancelar';
yes.addEventListener('click',confirmPending);
no.addEventListener('click',function(){
pendingOperation = null;
yes.disabled = true;
no.disabled = true;
addBubble('Operación cancelada.','bot');
});
actions.append(yes,no);
bubble.appendChild(actions);
} else {
speak(res.mensaje || '');
}
}
function sendMessage(){
const text = input.value.trim();
if (!text) return;
addBubble(text,'user');
input.value = '';
setVoiceStatus('',false);
setBusy(true);
google.script.run
.withSuccessHandler(handleResponse)
.withFailureHandler(handleError)
.procesarMensaje(text);
}
function confirmPending(){
if (!pendingOperation) return;
const data = JSON.stringify(pendingOperation);
pendingOperation = null;
setBusy(true);
google.script.run
.withSuccessHandler(function(res){
setBusy(false);
addBubble(res.mensaje || 'Operación completada.','bot');
speak(res.mensaje || '');
})
.withFailureHandler(handleError)
.confirmarOperacion(data);
}
function abrirGrabador(){
setVoiceStatus('Abriendo grabador seguro...',true);
setStatus('Preparando micrófono...');
recorderPopup = window.open(
'about:blank',
'grabadorDocenteIA',
'width=520,height=760,resizable=yes,scrollbars=yes'
);
if (!recorderPopup){
setVoiceStatus(
'El navegador bloqueó la ventana del grabador. Permite ventanas emergentes y vuelve a pulsar 🎤.',
true
);
setStatus('No pude abrir el grabador.');
return;
}
try {
recorderPopup.document.write(
'<p style="font-family:Arial;padding:24px">Preparando grabador...</p>'
);
} catch (e) {}
google.script.run
.withSuccessHandler(function(url){
try {
recorderOrigin = new URL(url).origin;
const separador = url.includes('?') ? '&' : '?';
const destino =
url +
separador +
'openerOrigin=' +
encodeURIComponent(location.origin);
recorderPopup.location.href = destino;
setVoiceStatus(
'En la ventana del grabador, pulsa “Grabar”, habla y luego “Usar grabación”.',
true
);
setStatus('Esperando grabación...');
} catch (error) {
if (recorderPopup && !recorderPopup.closed) {
recorderPopup.close();
}
handleError(error);
}
})
.withFailureHandler(function(error){
if (recorderPopup && !recorderPopup.closed) {
recorderPopup.close();
}
handleError(error);
})
.getRecorderUrl();
}
window.addEventListener('message',function(event){
if (!recorderOrigin || event.origin !== recorderOrigin) {
return;
}
const data = event.data || {};
if (data.type === 'AUDIO_DOCENTE_CANCELADO'){
setVoiceStatus('Grabación cancelada.',true);
setStatus('Listo. Puedes escribir o pulsar 🎤.');
return;
}
if (data.type !== 'AUDIO_DOCENTE'){
return;
}
if (!data.base64){
addBubble('No recibí datos de audio.','bot');
return;
}
setBusy(true);
setVoiceStatus('Audio recibido. Transcribiendo con Gemini...',true);
setStatus('Transcribiendo audio...');
google.script.run
.withSuccessHandler(function(res){
setBusy(false);
if (!res || !res.ok || !res.texto){
addBubble('No pude transcribir el audio.','bot');
return;
}
input.value = res.texto;
input.focus();
setVoiceStatus(
'Transcripción lista: revísala y pulsa Enviar.',
true
);
setStatus('Transcripción lista para revisar.');
})
.withFailureHandler(handleError)
.transcribirAudio(
data.base64,
data.mimeType || 'audio/webm'
);
});
send.addEventListener('click',sendMessage);
input.addEventListener('keydown',function(event){
if (event.key === 'Enter'){
sendMessage();
}
});
document.querySelectorAll('.chip').forEach(function(chip){
chip.addEventListener('click',function(){
input.value = chip.textContent;
input.focus();
});
});
mic.addEventListener('click',abrirGrabador);
addBubble(
'Hola, profe. Puedes escribir una instrucción o pulsar 🎤 para grabarla. Antes de cambiar la planilla te pediré confirmación.'
);
</script>
</body>
</html>
Para esta Master Class usaremos siempre el grabador oficial ya publicado.
https://anfaliz1703.github.io/grabadorAudioMasterClass/
La URL está incorporada directamente en el código. No debes crear ni pegar RECORDER_URL.
Autoriza el proyecto cuando Google lo solicite.
Sheets, nombre, pestañas, IDs, Gemini y el grabador fijo.
Ejecutar como: Yo. Acceso: Solo yo.
“Pon 4.7 a 701-03 en Actividad 2”.
🎤 → Grabar → Detener → Usar grabación. La transcripción vuelve al chat para revisión.