| name | ai-report-generation |
| version | 1.0.0 |
| description | Generación de informes y documentos profesionales mediante LLM. Arquitectura prompt→LLM→HTML capítulo, plantillas por sección, integración con datos de appState, y patrón demo interactivo. Aplicable a PMST, informes técnicos, auditorías, y cualquier documento estructurado. |
| tags | ["ai","llm","report","document-generation","prompt-engineering","html","templates"] |
| related_skills | ["software-development","frontend-dashboard-patterns","educational-html-pipeline"] |
AI Report Generation — Generación de Informes con IA
Resumen
Patrón para generar documentos profesionales de 50-80+ páginas mediante LLM, donde cada capítulo se construye individualmente con un prompt estructurado que inyecta datos reales de la aplicación.
Arquitectura
appState (datos reales)
↓
Normalización (JSON por capítulo)
↓
Prompt Template (system + datos + instrucciones + formato)
↓
LLM (qwen/gpt/claude via API)
↓
HTML Chapter (sección del informe)
↓
CSS Print (A4, page-break, headers)
↓
Documento Final (PDF/HTML descargable)
Flujo por Capítulo
1. Definir datos de entrada por capítulo
Cada capítulo necesita datos específicos de appState:
| Capítulo | Datos necesarios |
|---|
| Resumen Ejecutivo | centro, diagnostico, comparativas, medidas |
| Análisis Entorno | centro (coords), transportePublico, isocronas |
| Resultados Encuesta | encuesta (departments, modes, distances) |
| Huella Carbono | diagnostico.huellaCO2e, MITECO factors |
| Transporte Público | transportePublico (from NAP DGT API) |
| DAFO | dafo (fortalezas, debilidades, oportunidades, amenazas) |
| Medidas | medidas (from DAFO derivation) |
| Conclusiones | ALL (synthesis) |
2. Construir prompt por capítulo
function buildPrompt(chapterId, appState) {
const data = extractChapterData(chapterId, appState);
return `
Eres un consultor de movilidad sostenible redactando el capítulo "${chapterId}"
de un PMST conforme a la Ley 8/2021 de Movilidad Sostenible.
DATOS DEL CENTRO:
${JSON.stringify(data.centro, null, 2)}
DATOS DEL DIAGNÓSTICO:
${JSON.stringify(data.diagnostico, null, 2)}
INSTRUCCIONES:
1. Redacta entre 400-800 palabras
2. Incluye tablas con datos reales (no genéricos)
3. Menciona cifras específicas del centro
4. Tono: profesional pero accesible para dirección general
5. Formato: HTML con h3, tablas, listas, KPIs inline
FORMATO DE SALIDA: Solo HTML, sin markdown fences.
`;
}
3. Generar y capturar respuesta
async function generateChapter(chapterId, appState) {
const prompt = buildPrompt(chapterId, appState);
const response = await fetch(LLM_ENDPOINT, {
method: 'POST',
headers: { 'Authorization': `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'qwen3-6',
messages: [{ role: 'user', content: prompt }],
max_tokens: 4096
})
});
const data = await response.json();
return data.choices[0].message.content;
}
4. Ensamblar informe final
async function generateFullReport(appState) {
const chapters = [];
for (const ch of CHAPTER_ORDER) {
const html = await generateChapter(ch.id, appState);
chapters.push(`<section class="chapter" id="${ch.id}">${html}</section>`);
}
return wrapInHTMLDocument(chapters.join('\n'), getCSS());
}
Prompt Engineering para Informes
Estructura de prompt efectiva
- Role assignment: "Eres un consultor de [dominio]..."
- Context: Tipo de documento, normativa aplicable
- Data injection: JSON con datos reales (NO placeholders)
- Constraints: Extensión, tono, formato de salida
- Negative instructions: "NO uses jerga técnica sin explicar", "NO inventes datos"
- Output format: HTML tags específicos esperados
Prompt por tipo de capítulo
| Tipo | Enfoque | Extensión |
|---|
| Resumen Ejecutivo | Síntesis + KPIs + prioridades | 400-600 palabras |
| Análisis Técnico | Datos + tablas + interpretación | 600-1000 palabras |
| Marco Legal | Referencia normativa + obligaciones | 300-500 palabras |
| Estratégico (DAFO) | Matriz + estrategias derivadas | 400-600 palabras |
| Plan de Acción | Medidas priorizadas + cronograma | 500-800 palabras |
| Conclusiones | Síntesis + compromisos + hoja de ruta | 300-500 palabras |
CSS Print para Informes
@page { size: A4; margin: 2cm; }
.chapter { page-break-before: always; }
.chapter:first-child { page-break-before: avoid; }
h2 { color: #1e40af; border-bottom: 2px solid #2563eb; padding-bottom: 8px; }
table { width: 100%; border-collapse: collapse; margin: 16px 0; }
th { background: #2563eb; color: white; padding: 10px; }
td { padding: 8px 10px; border-bottom: 1px solid #e5e7eb; }
.kpi-inline { background: #dbeafe; padding: 2px 8px; border-radius: 4px; font-weight: 700; }
Generación PDF con WeasyPrint + staticmap
WeasyPrint no ejecuta JavaScript, así que los mapas Leaflet se renderizan como cuadros vacíos en el PDF. Solución: generar imágenes estáticas con la librería Python staticmap y reemplazar los divs de mapa por <img>.
Pipeline completo:
1. report.js genera HTML con mapas Leaflet interactivos
2. Abre HTML en navegador para que Leaflet renderice (opcional, solo para preview)
3. gen_static_maps.py genera PNG/JPG estáticos con staticmap
4. gen_pdf_static.py reemplaza divs de mapa por <img> con base64
5. WeasyPrint genera el PDF con imágenes estáticas
Paso 3 — Generar mapas estáticos:
import staticmap
def generar_mapa_estatico(center, paradas, gbfs, isocronas, output_path):
m = staticmap.StaticMap(800, 600, url_template='https://tile.openstreetmap.org/{z}/{x}/{y}.png')
for p in paradas:
marker = staticmap.CircleMarker((p['lon'], p['lat']), '#dc2626', 8)
m.add_marker(marker)
for s in gbfs:
color = '#16a34a' if s['bikes'] > 5 else '#eab308' if s['bikes'] > 0 else '#dc2626'
m.add_marker(staticmap.CircleMarker((s['lon'], s['lat']), color, 10))
for iso in isocronas:
m.add_polygon(iso['coords'], fill=iso['color'], outline=iso['color'], width=2)
m.add_text((center[0], center[1]), '🏢 Centro', 24, '#1e40af', 'bold')
image = m.render()
image.save(output_path)
Paso 4 — Reemplazar mapas en HTML y generar PDF:
import base64, re
from weasyprint import HTML
def reemplazar_mapas(html_content, mapas_dir):
for map_id, img_file in [('map-entorno', 'entorno.jpg'), ('map-tp', 'tp.jpg'), ('map-isocronas', 'isocronas.jpg')]:
with open(f'{mapas_dir}/{img_file}', 'rb') as f:
b64 = base64.b64encode(f.read()).decode()
html_content = re.sub(
f'<div id="{map_id}".*?</div>',
f'<img src="data:image/jpeg;base64,{b64}" style="width:100%;border-radius:12px">',
html_content, flags=re.DOTALL
)
return html_content
html = HTML(string=html_modified)
html.write_pdf('PMST_Ineco_Paseo_Habana.pdf')
Instalación:
pip install --break-system-packages weasyprint staticmap Pillow PyPDF2
Resolución de mapas: 800×600px genera JPG de 124-158KB. Para mejor calidad, usar 1200×900 (~300KB).
Ventaja sobre html2canvas: staticmap genera mapas directamente desde tiles de OpenStreetMap sin necesidad de navegador. Es más rápido, reproducible, y funciona en servidores sin GUI.
Patrón Demo Interactivo
Para presentar el sistema al usuario, crear un HTML demo que muestre:
- Diagrama de arquitectura: Encuestas → APIs → Normalización → LLM → HTML
- Tabla de fuentes de datos: Qué datos alimenta cada capítulo
- Ejemplos por capítulo: Sidebar con navegación, prompt box (dark, syntax highlighted), AI response box (green border, formatted HTML)
Ver references/prompt-templates.md para ejemplos de prompts por capítulo.
Ver references/plandemovilidad-case.md para el caso de estudio completo.
Integración con APIs Externas
El informe se enriquece con datos de APIs reales ANTES de la generación:
export async function enrichAppWithAPIs(app) {
const lat = parseFloat(app.centro?.latitud);
const lng = parseFloat(app.centro?.longitud);
if (!lat || !lng) return app;
return app;
}
window.pmstApp.exportPDF = async () => {
await enrichAppWithAPIs(window.pmstApp.appState);
return exportPDF();
};
Dato crítico: El enriquecimiento debe ocurrir ANTES de que el generador de informe lea appState. Si se genera sin enriquecer, los capítulos muestran "datos pendientes" en vez de datos reales.
Fuentes de datos y capítulos que las usan:
| API | Capítulo(s) | Qué aporta |
|---|
| GBFS | 14 (Infraestructura Ciclista) | Estaciones cercanas, disponibilidad bicis |
| Nominatim | 5 (Entorno), 13 (TP) | Dirección del centro, barrio, POIs |
| ORS | 5 (Entorno), 13 (TP) | Isochronas de accesibilidad multi-modo |
| MITECO | 8 (Huella CO₂) | Factores de emisión por modo |
Isochronas Realistas (sin API externa)
Cuando no hay API key de ORS, generar isócronas simuladas pero realistas — no círculos perfectos.
Algoritmo de polígonos irregulares
function generarIsocronaRealista(centro, radioMax, modo, tiempo) {
const PUNTOS = 48;
const coords = [];
const ejes = [
{ angulo: 0, factor: 1.4, nombre: 'Castellana N' },
{ angulo: 45, factor: 1.1, nombre: 'Bravo Murillo' },
{ angulo: 90, factor: 0.7, nombre: 'Río Manzanares' },
{ angulo: 180, factor: 1.3, nombre: 'Paseo Habana S' },
{ angulo: 270, factor: 0.8, nombre: 'Zona menos desarrollada' },
];
const barreras = [
{ anguloInicio: 75, anguloFin: 105, factor: 0.6 },
{ anguloInicio: 260, anguloFin: 285, factor: 0.75 },
];
for (let i = 0; i < PUNTOS; i++) {
const angulo = (i / PUNTOS) * 2 * Math.PI;
const anguloDeg = (i / PUNTOS) * 360;
let factorEje = 1;
for (const eje of ejes) {
const diff = Math.abs(anguloDeg - eje.angulo);
if (diff < 30) factorEje *= 1 + (eje.factor - 1) * (1 - diff / 30);
}
let factorBarrera = 1;
for (const b of barreras) {
if (anguloDeg >= b.anguloInicio && anguloDeg <= b.anguloFin) {
factorBarrera *= b.factor;
}
}
const variacion = 1 + 0.15 * Math.sin(angulo * 0.1) + 0.1 * Math.cos(angulo * 0.23);
const radio = radioMax * factorEje * factorBarrera * variacion;
const lat = centro.lat + (radio / 111320) * Math.cos(angulo);
const lon = centro.lon + (radio / (111320 * Math.cos(centro.lat * Math.PI / 180))) * Math.sin(angulo);
coords.push([lat, lon]);
}
return coords;
}
Velocidad por modo: coche 25 km/h, bici 14 km/h, pie 4.5 km/h.
Resultado: Polígonos tipo "mano de pulpo" que se extienden por ejes viales principales y se contraen en barreras urbanas. 48 puntos de resolución por polígono.
Comentarios IA después de visualizaciones
En informes largos (60-80 páginas), después de cada mapa, gráfico o tabla importante, incluir un bloque de análisis de la IA que:
- Interpreta los datos — "El análisis revela que en 15 min se cubren 150-200 km² en coche..."
- Identifica barreras — "La barrera del río reduce accesibilidad al SE un 40%..."
- Da recomendaciones — "Se recomienda fomentar Metro L9 y líneas 14/27..."
<div class="ai-analysis" style="background:#f0f9ff;border-left:4px solid #2563eb;padding:16px;border-radius:0 8px 8px 0;margin:16px 0">
<h4>🤖 Análisis contextual de la IA</h4>
<p><strong>Principales hallazgos:</strong> El análisis revela que en 15 minutos se cubren 150-200 km² en coche...</p>
<p><strong>Barreras detectadas:</strong> Río Manzanares (-40%), vía tren Chamartín (-25%)...</p>
<p><strong>Recomendación:</strong> Priorizar conexiones transversales que superen las barreras naturales...</p>
</div>
Patrón de color por tipo de mapa:
- Isochromas → fondo azul
#f0f9ff
- Transporte Público → fondo verde
#f0fdf4
- Entorno → fondo amarillo
#fefce8
Pitfalls