Cómo los documentos subidos son analizados por IA y enlazados automáticamente a las solicitudes de acceso.
Fuente editable: diagrams/document-upload-flow.drawio
El paso de embeddings es "dispara y olvida" desde el punto de vista de los handlers de documentos: los fallos no revierten la persistencia del documento. Los embeddings precomputados son consumidos de forma perezosa por SuccessAnalyzer y ComplaintGenerator a través de DocumentEmbeddingsRetriever::loadVectorsForRequest(); cuando aún no hay vectores almacenados (subida reciente, cola pendiente, sin texto extraído) ambos servicios recurren a la ruta inline de consulta basada en cadenas (buildContextQuery), por lo que la corrección se mantiene durante el intervalo.
Para hacer un backfill de embeddings de documentos existentes (tras el despliegue, o tras un borrado del corpus):
php bin/console app:documents:backfill-embeddings [--limit N] [--source upload|email|portal] [--type Response] [--force] [--sync] [--dry-run]Por defecto, el comando despacha GenerateDocumentEmbeddingsMessage al transporte analysis (los workers lo procesan de forma asíncrona). --sync ejecuta el handler en línea, --force vuelve a generar embeddings de documentos que ya tienen filas, y --dry-run informa sin hacer nada.
La deduplicación basada en hash es uniforme en todas las rutas de ingesta: subida manual (DocumentController::upload), webhook del agente (AgentWebhookProcessor) y email entrante (InboundEmailController). Todas se basan en (uploadedBy, contentHash), por lo que el mismo archivo subido a través de cualquier combinación de canales acaba como un único Document.
| Formato | Tipos MIME |
|---|---|
application/pdf |
|
| Word | application/vnd.openxmlformats-officedocument.wordprocessingml.document (.docx), application/msword (.doc) — se extrae el texto (WordTextExtractor: PhpWord para .docx, antiword para .doc) y se envía como texto al modelo |
| Texto | text/plain (.txt) — se envía inline como texto |
| Imágenes | image/jpeg, image/png, image/gif |
| ZIP | application/zip (el contenido se extrae y procesa individualmente) |
Tamaño máximo de subida: 50 MB. Tamaño máximo para análisis con IA: 14 MB (DocumentAnalyzer::MAX_FILE_SIZE); los ficheros entre 14 y 50 MB se almacenan pero el análisis falla con un error registrado en processingError.
src/Service/AI/DocumentAgent/AgenticDocumentAnalyzer.php
El análisis es agéntico: un loop de tool-calling (mismo patrón que AgentChatOrchestrator, sobre CustomModelClient::chatWithTools) que siempre recibe el inventario del expediente (tipos, fechas, orígenes y resúmenes de los documentos ya registrados, vía CaseDocumentInventoryBuilder) y termina con una llamada final con structured output (DocumentAnalysisSchema, maxOutputTokens 20k). El orden del handler es pre-match → análisis → post-match: antes de analizar se intenta resolver el expediente (documento ya enlazado o sourceMetadata.expedienteRef), de modo que el agente analice con contexto; si sigue huérfano, el post-match usa el matchedRequestId del agente (validando propietario), la referencia extraída y las keywords.
Tools disponibles (en src/Service/AI/DocumentAgent/Tool/, autorizadas por AnalysisToolContext porque los workers de Messenger no tienen token de Security — las tools nunca aceptan IDs de usuario del modelo):
| Tool | Uso |
|---|---|
list_case_documents |
Refresca el inventario del expediente |
read_case_document(documentId) |
Texto completo de otro documento del expediente (validado: mismo expediente y dueño) |
read_document_pages(first, last) |
Texto por páginas del documento en análisis (≤10 págs/llamada) — para expedientes compuestos |
search_user_requests(reference?, query?) |
Solicitudes registradas del dueño (organismo, fecha, expediente, materia) para el matching agéntico de huérfanos; el agente devuelve matchedRequestId |
Novedades del análisis frente al one-shot:
origin(ciudadano / administracion / organismo_transparencia / organismo_judicial / otro): quién emite el documento. Solo se persiste enaiMetadata. Cross-check enDocumentAnalysisNormalizer:alegaciones+ origen ciudadano ⇒respuesta_alegaciones.phase(solicitud / reclamacion / judicial), validada contra el tipo (el tipo gana).- Justificantes REG:
isRegistrationReceipt+ inventario ⇒ si el expediente ya tiene una solicitud, un justificante que la contiene se clasificaacuse_recibo(cinturón determinista en el normalizer conhasRequestDocument). - Expedientes compuestos:
isComposite+subdocuments[{pages,type,description}]; se clasifica por la pieza de mayor relevancia jurídica (p. ej. las alegaciones al final de un expediente completo del CTBG). publicBodyType(ayuntamiento / diputacion / consejeria_autonomica / ministerio / organismo_autonomo / universidad / otro): al crear elPublicBodyse deriva sulevel(AccessRequestMatcher::deriveLevel()), en vez del antiguootherfijo.courtOutcome(estimatorio / desestimatorio / parcial / inadmision) para sentencias.
Prompts: pideinfo-document-agent-analyze-system / -user (config/prompts/document/agent-analyze-*.md). Trazas Langfuse: root DocumentAnalysisAgent, doc-agent.tool-loop por iteración, doc-agent.tool.{name} por ejecución de tool y doc-agent.final. Límites: 4 iteraciones de tools; si el modelo no llama a ninguna tool el resultado sigue siendo válido (inventario pre-inyectado + JSON por schema). Peor caso ~6 llamadas/documento — coincide con el burst del rate limiter llm_api (6 tokens, 1/s), que serializa sin fallar.
Fallback one-shot. Cualquier fallo del loop agéntico (error del backend, JSON inválido tras retries) recurre automáticamente a DocumentAnalyzer (una única llamada con los prompts analyze-single/-multi, sin contexto de expediente), con warning en logs. Ambos caminos comparten DocumentPartsBuilder (payloads), ComplaintReceiptSniffer (override determinista de acuses CTBG) y DocumentAnalysisNormalizer (normalización), así que el fallback también entiende los campos nuevos si el modelo los emite.
src/Service/AI/DocumentAnalyzer.php
El analizador lee el documento desde S3, lo codifica en base64 y lo envía a través de LlmClient al cliente compatible con OpenAI (CustomModelClient, modelo CUSTOM_MODEL) para un análisis rápido y rentable.
Tratamiento de PDF en el backend personalizado. Las APIs de chat compatibles con OpenAI solo aceptan imágenes vía image_url, por lo que los PDF no pueden reenviarse tal cual (el decodificador de imágenes upstream no consigue identificar los bytes). Cuando el documento es un PDF, DocumentAnalyzer intenta primero PdfTextExtractor::extractFullTextFromContent y decide qué payload enviar en función de si el texto extraído es utilizable:
- PDFs con texto seleccionable (la extracción devuelve al menos 200 caracteres y un ratio alfanumérico/no-espacio ≥ 0.5): se envía únicamente el texto extraído. Se omite la rasterización para mantener el payload pequeño.
- PDFs escaneados o solo imagen (extracción vacía, demasiado corta o mayoritariamente glifos basura): las primeras 30 páginas se rasterizan a PNG mediante
PdfRasterizer(que ejecutapdftoppmdepoppler-utils) y se adjuntan como partesimage_url, junto con cualquier texto parcial que haya salido del extractor.
La comprobación "¿es útil el texto extraído?" vive en DocumentAnalyzer::isExtractedTextUseful(). Las imágenes simples y los documentos text/plain no se ven afectados por esta rama.
Documentos Word (.doc/.docx). Tampoco pueden reenviarse como binario al backend OpenAI-compatible, así que DocumentAnalyzer extrae el texto con WordTextExtractor (PhpWord para .docx, antiword para .doc) y lo envía como una parte de texto. Si la extracción no devuelve texto, se envía solo el contexto. text/plain se inlinea directamente como texto.
Muchas resoluciones de los órganos de transparencia (de forma notable las del CTRM, Región de Murcia) llegan como PDF sin capa de texto en algunas o todas las páginas (escaneos solo-imagen). El pipeline de ingesta de resoluciones aplica un fallback de OCR por visión, global a todas las fuentes, encapsulado en App\Service\Document\PdfOcrTranscriber:
PdfTextExtractor::extractPageTexts()devuelve el texto por página (sin el descarte de legibilidad de todo-o-nada que usaextractPages()), de modo que las páginas sin texto vienen como cadenas vacías y se pueden detectar individualmente.- Para cada página sin capa de texto utilizable (
pageNeedsOcr(): menos de ~20 caracteres alfanuméricos),PdfRasterizer::rasterizePageFromContent()rasteriza solo esa página conpdftoppm -f N -l Na 200 DPI. - La imagen se envía al LLM multimodal vía
LlmClient::chat()(partesContentPart::inlineData('image/png', …)) para transcribirla literalmente; el texto transcrito se fusiona en la posición de su página. - Ruta barata: si todas las páginas ya tienen texto, no se rasteriza ni se llama al LLM (comportamiento idéntico al anterior para el 99% de los PDFs).
Se respeta un tope de páginas a transcribir por documento (MAX_OCR_PAGES = 30) para acotar el coste. El fallback se aplica por igual en la ruta inline (ResolutionProcessingTrait::extractText()) y en la asíncrona (ProcessResolutionHandler::extractText()), que delegan ambas en el mismo servicio para mantener la paridad.
Forzar visión en TODAS las páginas (--vision): además del fallback automático (que solo transcribe las páginas sin capa de texto), se puede forzar la transcripción por visión de todas las páginas pasando forceVision = true a PdfOcrTranscriber::extractTextWithOcr(). Esto ignora la capa de texto embebida (útil cuando pdftotext devuelve glifos mal mapeados). El flag está expuesto como --vision en:
app:resolutions:analyze --vision(re-extracción de resoluciones ya cargadas; implica--re-extract).- Todos los
app:*:load-resolutions --vision(extracción en el momento de la carga), tanto inline como con--async.$visionse enhebra porprocessInline()/downloadAndProcessPdf()/processMissingPdfs()en la ruta inline y porProcessResolutionMessage::$forceVisionen la asíncrona. No-op en fuentes Word (CVAIP).
Las importaciones nocturnas programadas (App\Schedule) usan --vision para todas las fuentes basadas en PDF; CVAIP queda excluida por ser Word.
La fecha de la resolución a menudo solo aparece en el sello de firma electrónica (repetido en cada pie de página), en un margen lateral o en el título, y la limpieza de texto (cleanRawText, dedup de líneas repetidas de ResolutionAnalyzer::cleanText) la elimina antes de que el LLM la vea. Un regex por fuente no escala a los 13 consejos, así que en lugar de re-extraer la fecha aparte, se le da contexto visual al mismo paso de análisis:
ResolutionAnalyzer::extractAnalysis()/analyze()aceptan un parámetro opcional?string $pdfBytes. Cuando se proporciona (y no se omite la fecha),ResolutionAnalyzerrasteriza la primera y la última página conPdfRasterizer::rasterizePageFromContent()(200 DPI) y las adjunta como imágenes (ContentPart::inlineData) a la llamadallm.chatde la trazaresolution.extractAnalysis. No hay llamada LLM adicional ni doble extracción.- El prompt (
pideinfo-resolution-extract-analysis, bloque[resolution_date]) instruye al modelo a dar prioridad a las imágenes adjuntas para la fecha (sello de firma, pie, margen, título), por encima del texto. El bloque vive en el prompt (Langfuse y el bundleconfig/prompts/resolution/extract-analysis.md), ya no se inyecta como variable. - Quien pasa los bytes: la ruta inline de carga (
ResolutionProcessingTrait::analyzeResolution), la asíncrona (ProcessResolutionHandler::analyzeResolution) yAnalyzeResolutionsCommand(rutas inline). Todos leen el PDF deresolutions.storageporpdfStoragePath(helperreadStoredPdfBytes); devuelvennullpara fuentes Word, que siguen siendo solo-texto. - Coherencia:
applyAnalysisResult()descarta una fecha de resolución anterior a laclaimDate/infoRequestDate(cronológicamente imposible) en vez de fijarla.
Así, re-analizar (app:resolutions:analyze) basta para rellenar/corregir la fecha por visión, sin un comando de fechas aparte.
Estructura de la llamada a la API:
- Backend: todas las llamadas (texto y visión) pasan por
LlmClient→CustomModelClient, una API compatible con OpenAI configurada conCUSTOM_MODEL/CUSTOM_MODEL_ENDPOINT/CUSTOM_MODEL_API_KEY. Ya no hay ruta directa a Gemini para análisis/visión. - Formato de respuesta: JSON vía
response_format(json_schemacuando se pasa esquema, si nojson_object). - Temperatura: el backend ignora la temperatura por petición y aplica siempre
CUSTOM_MODEL_TEMP(por defecto1). - Timeout: 600 segundos (los documentos pueden ser grandes).
El prompt instruye al modelo a devolver un objeto JSON con:
| Campo | Tipo | Descripción |
|---|---|---|
documentType |
string | Uno de los tipos reconocidos (ver abajo) |
referenceNumber |
string | Número de expediente/referencia gubernamental |
publicBodyName |
string | Nombre del organismo público |
autonomousCommunityCode |
string | Código de CCAA (AND, AST, CAT, etc.) |
applicableLaw |
string | Nombre de la ley de transparencia aplicable |
documentDate |
string | Fecha del documento |
status |
string | Estado de la resolución extraído si aplica |
summary |
string | Resumen breve del contenido del documento |
requestTitle |
string | Título de la solicitud de acceso |
requestDescription |
string | Descripción de lo que se solicitó |
isExtension |
boolean | Si se trata de un aviso de ampliación de plazo |
newDeadlineDate |
string | Nuevo plazo explícito si se menciona |
extensionDays |
integer | Número de días de ampliación |
denialReason |
string | Motivo de la denegación |
isRedirection |
boolean | Si la solicitud fue redirigida |
redirectedToPublicBody |
string | Nombre del nuevo organismo público |
isThirdPartyRights |
boolean | Si se ven afectados derechos de terceros |
processingStartDate |
string | Fecha en que comenzó formalmente la tramitación |
alegationPoints |
array | Argumentos clave de las alegaciones de la administración |
keyPoints |
array | Puntos clave del documento (para respuestas, reclamaciones, resoluciones de reclamación y respuestas a alegaciones) |
hearing_days |
integer | Días para alegar cuando el documento abre un trámite de audiencia (documentType = audiencia) |
hearing_days_type |
string | Tipo de días del trámite de audiencia: business (hábiles) o calendar (naturales); por defecto business |
origin |
string | Quién emite el documento: ciudadano, administracion, organismo_transparencia, organismo_judicial, otro |
phase |
string | Fase del procedimiento: solicitud, reclamacion, judicial (coherente con el tipo; el tipo gana) |
isRegistrationReceipt |
boolean | Si el PDF contiene un justificante de registro (REG/REGAGE) |
isComposite |
boolean | Si el PDF es un expediente compuesto por varias piezas |
subdocuments |
array | Piezas de un expediente compuesto: {pages, type, description} |
matchedRequestId |
string | UUID de la solicitud del usuario identificada por el agente (tool search_user_requests) |
courtOutcome |
string | Fallo de una sentencia: estimatorio, desestimatorio, parcial, inadmision |
publicBodyType |
string | Naturaleza del organismo: ayuntamiento, diputacion, consejeria_autonomica, ministerio, organismo_autonomo, universidad, otro — deriva PublicBody.level al crear el organismo |
Trámite de audiencia. Cuando documentType = audiencia y el análisis trae hearing_days, ambos
handlers (single y batch) delegan en HearingProcessManager, que crea de forma idempotente (clave: el
documento que lo dispara) un HearingProcess asociado a la reclamación: startDate = fecha del documento
y endDate calculada con DeadlineCalculator::calculateHearingDeadline() (el cómputo empieza el día
siguiente; en días hábiles salta fines de semana y festivos nacionales). También registra la entrada
correspondiente en DeadlineHistory (tipo hearing) y la nota del timeline incluye el plazo y la fecha
límite. El plazo se muestra en el dossier-notice del detalle (mientras está vivo), en la zona Plazos del
detalle (RequestStatusSidebar) y en la box de Plazos de la home (DeadlineAlerts).
La IA clasifica los documentos en estos tipos:
| Valor IA | Enum DocumentType | Etiqueta |
|---|---|---|
solicitud |
Request | Solicitud |
acuse_recibo |
Receipt | Acuse de recibo |
inicio_tramitacion |
ProcessingStart | Inicio de tramitación |
resolucion |
Response | Respuesta (denegada o concedida total) |
inadmitida |
Response | Respuesta — además fija posición finished + resolutionResult = inadmitted |
parcialmente_concedida |
Response | Respuesta — además fija posición finished + resolutionResult = partially_granted |
notificacion |
Notification | Notificación pura (sin contener decisión de fondo) |
prorroga |
Extension | Prórroga |
traslado |
Redirection | Traslado a otro órgano |
afectacion_terceros |
ThirdPartyRights | Afectación derechos terceros |
reclamacion |
Complaint | Reclamación |
acuse_recibo_reclamacion |
ComplaintReceipt | Acuse recibo reclamación |
inicio_tramitacion_reclamacion |
ComplaintProcessingStart | Inicio tramitación reclamación |
resolucion_ctbg |
ComplaintResolution | Resolución CTBG |
alegaciones |
Alegaciones | Alegaciones |
respuesta_alegaciones |
AlegationResponse | Respuesta a alegaciones |
subsanacion |
Subsanacion | Subsanación solicitada |
subsanacion_respuesta |
SubsanacionResponse | Subsanación presentada |
audiencia |
Audiencia | Trámite de audiencia |
ampliacion_reclamacion |
ComplaintExtension | Ampliación de reclamación |
comunicacion_consejo_administracion |
ComplaintInterAdmin | Comunicación Consejo–Administración (requerimiento de remisión de expediente, justificante REGAGE de la administración ante el Consejo…) — procedural, sin efectos de estado |
recurso_contencioso (recurso_contencioso_administrativo) |
CourtAppeal | Recurso contencioso-administrativo — fija courtStatus = in_court |
sentencia |
CourtRuling | Sentencia judicial — courtOutcome estimatorio/parcial ⇒ court_granted; desestimatorio/inadmision ⇒ court_denied; sin fallo ⇒ solo timeline |
auto_judicial (auto, providencia) |
CourtOrder | Auto / providencia — solo timeline |
senalamiento |
CourtHearingNotice | Señalamiento de vista — solo timeline |
escrito_judicial |
CourtOther | Escrito judicial — solo timeline |
Los labels
inadmitidayparcialmente_concedidaclasifican el sentido de la resolución (no son tipos de documento aparte). El normalizer enDocumentAnalyzer::normalizeDocumentAnalysismapea ambos aDocumentType::Responsey expone unaccessRequestStatusextra queProcessDocumentHandleraplica alAccessRequest(verAccessRequest::STATUS_INADMITTED/STATUS_PARTIALLY_GRANTED).
Cuando se suben varios archivos juntos (por ejemplo, el contenido de un ZIP), ProcessDocumentBatchHandler los analiza secuencialmente con el agente, pasando a cada documento el contexto incremental del lote (batchSiblings: los anteriores ya clasificados con tipo y resumen, los pendientes por nombre). Es la clave del caso REG (justificante + solicitud en el mismo envío): al analizar el segundo documento, el primero ya consta como solicitud. En cuanto un análisis identifica el expediente (matchedRequestId o referencia), el resto del lote se analiza con el inventario real. Un documento fallido (p. ej. demasiado grande) no tumba el lote: se registra su processingError y se continúa. La llamada multi one-shot (DocumentAnalyzer::analyzeMultiple()) se conserva solo como fallback.
El matching vive en App\Service\Document\AccessRequestMatcher (compartido por ambos handlers). Antes del análisis se hace un pre-match sin IA (documento ya enlazado o sourceMetadata.expedienteRef); tras el análisis, si sigue huérfano, se prueban por orden: el matchedRequestId propuesto por el agente (tool search_user_requests, validando que la solicitud pertenece al dueño del documento; método registrado MATCH_REFERENCE) y después las estrategias clásicas:
El handler busca una solicitud existente por número de referencia. Comprueba tanto el referenceNumber extraído por la IA como el expedienteRef del sourceMetadata del documento (establecido por el webhook del agente):
$referenceNumber = $analysis['referenceNumber'] ?? null;
$sourceRef = $document->getSourceMetadata()['expedienteRef'] ?? null;Ambos se prueban contra findByExternalId(), que también busca en el campo JSON alternativeReferences.
Método de emparejamiento registrado: Document::MATCH_REFERENCE
Si no se encuentra coincidencia por número de referencia, el handler extrae palabras clave del análisis — identificadores de contrato, códigos de plataforma, números de expediente, referencias de NIF/CIF — y busca solicitudes cuyo título o descripción las contenga:
$existing = $this->accessRequestRepository->findByKeywords($keywords, $user);Patrones de palabras clave extraídos:
- Números de contrato:
2020/011739 - Códigos de ruta:
VCM-036,DIV-123 - Números de expediente:
AYTOZAM-SEIS-4420/2025 - NIF/CIF:
A12345678
Método de emparejamiento registrado: Document::MATCH_KEYWORDS
Si el documento es una solicitud (DocumentType::Request) o un acuse de recibo (DocumentType::Receipt) y ninguna solicitud existente coincide, el handler crea una nueva AccessRequest:
- Busca o crea el
PublicBodya partir del nombre extraído por la IA - Determina la
ApplicableLaw— primero por comunidad autónoma, luego por nombre de ley, recurriendo a la ley estatal como último recurso - Extrae la fecha de envío a partir de la fecha del documento
- Crea la solicitud vía
AccessRequestManager::create()
Método de emparejamiento registrado: Document::MATCH_CREATED
Si el tipo de documento es cualquier otro y no se encuentra coincidencia, el documento queda huérfano (sin solicitud de acceso enlazada). El usuario puede enlazarlo más tarde manualmente a través del modal "Importar documento sin asignar" en la página de detalle de cualquier solicitud.
Una vez que un documento se enlaza a una solicitud, el handler actualiza la solicitud según el tipo de documento:
| Tipo de documento | Cambio de estado |
|---|---|
| Receipt | Status → processing, establece acknowledgedAt |
| Response | Status → granted/denied según el análisis de IA, establece resolvedAt |
| Extension | Amplía el plazo según el periodo legal, incrementa el contador de ampliaciones |
| ProcessingStart | Recalcula el plazo a partir de la fecha de inicio de tramitación |
| Redirection | Actualiza el organismo público, registra el original, establece redirectedAt |
| ThirdPartyRights | Suspende el plazo, establece periodo de alegaciones de 15 días |
| Complaint | Crea AccessRequestComplaint, establece plazo de 3 meses |
| ComplaintReceipt | Asegura que existe la reclamación, recalcula el plazo desde la fecha del acuse |
| ComplaintProcessingStart | Asegura que existe la reclamación, recalcula el plazo desde la fecha de tramitación |
| ComplaintResolution | Establece el estado de la reclamación a granted/denied según el análisis de IA |
| Alegaciones | Asegura que existe la reclamación, extrae los puntos de alegación |
| Subsanacion | Asegura que existe la reclamación, registra entrada en el timeline |
| SubsanacionResponse | Asegura que existe la reclamación, registra entrada en el timeline |
| Audiencia | Asegura que existe la reclamación, registra entrada en el timeline |
| ComplaintExtension | Asegura que existe la reclamación, registra entrada en el timeline |
| ComplaintInterAdmin | Asegura que existe la reclamación; solo timeline (sin cambio de estado ni notificación) |
| CourtAppeal | courtStatus → in_court vía AccessRequestManager::changeStatus |
| CourtRuling | courtStatus → court_granted/court_denied según courtOutcome; sin fallo, solo timeline |
| CourtOrder / CourtHearingNotice / CourtOther / Court | Solo timeline (statusType = courtStatus) |
Todos los cambios de estado crean entradas en StatusHistory. Los cambios de plazo crean entradas en DeadlineHistory. Los efectos viven en App\Service\Document\DocumentEffectsApplier, compartido por el handler single y el batch (paridad garantizada). En los tipos judiciales el referenceNumber extraído (nº de procedimiento) no escribe el externalId de la solicitud ni de la reclamación.
El referenceNumber extraído se guarda como externalId de la entidad correspondiente:
- Documentos de solicitud: se escribe en
AccessRequest::externalIdsolo si todavía está vacío (write-once); el expediente de la solicitud no cambia una vez asignado. - Documentos de reclamación (
Complaint,ComplaintReceipt,ComplaintProcessingStart,ComplaintResolution,Alegaciones,AlegationResponse,Subsanacion,SubsanacionResponse,Audiencia,ComplaintExtension): se escribe enAccessRequestComplaint::externalIdy siempre se actualiza al más reciente. El CTBG asigna o reemplaza el número de expediente a lo largo de las fases (acuse → inicio de tramitación → resolución), por lo que se refleja siempre el último.AccessRequestComplaint::setExternalId()conserva los valores anteriores enexternalIds[]para mantener el histórico.
Los documentos se pueden reprocesar haciendo clic en el botón de refrescar en la página de detalle de la solicitud. Esto despacha un nuevo ProcessDocumentMessage para el documento. El handler vuelve a ejecutar el análisis de IA y reaplica las actualizaciones de estado.
Desde la lista de documentos de una solicitud, cada documento tiene un botón de lápiz que abre el modal "Editar documento" (POST /documentos/{id}/editar, DocumentController::edit()). Permite:
- Renombrar: fija
Document.customName, un nombre visible que anula el derivado"{TipoLabel} - {original}". Se guarda vacío ⇒ vuelve al nombre automático. ComogetDisplayFilename()devuelvecustomNamecuando está presente, el nombre elegido sobrevive a un reprocesado (el handler vuelve a llamar asetOriginalFilename(getDisplayFilename())). - Reclasificar / mover de fase: cambia
Document.type. La "fase" de un documento (solicitud / reclamación / judicial) se deriva por completo deDocumentType::isCourtRelated()eisComplaintRelated(), así que asignar un tipo de otra fase mueve el documento a la sección correcta (p. ej. corregir el resguardo de una reclamación mal clasificado como documento de la solicitud →ComplaintReceipt). El desplegable se construye desde el enum vía la extensión Twigdocument_type_groups()(agrupado en "Fase de solicitud" / "Fase de reclamación" / "Fase judicial";Unprocessedno es asignable a mano).
El endpoint valida propiedad (canAccessDocument()) y token CSRF (edit-document). Es una corrección de metadatos: a diferencia de link, no reejecuta efectos de estado/plazo (evita duplicarlos). Si cambia el tipo y hay texto extraído, refresca los embeddings (GenerateDocumentEmbeddingsMessage). El tipo fijado a mano persiste a un reprocesado posterior porque ProcessDocumentHandler trata cualquier tipo distinto de Unprocessed como preasignado y lo prioriza sobre la IA (salvo el refinamiento Alegaciones/Complaint→Audiencia).
Tras guardar, la lista se refresca sin recargar la página volviendo a pedir el fragmento app_solicitudes_documents_fragment.
DocumentType::isProcedural() marca la documentación administrativa de bajo valor informativo — acuses de recibo (solicitud y reclamación), inicios de tramitación (solicitud y reclamación), prórrogas/ampliaciones, traslados a otro órgano, afectación a terceros y comunicaciones Consejo–Administración. En la lista de documentos estos se muestran con menor peso visual (clase docs-row--muted: nombre/icono atenuados) conservando su resumen y su posición, para no competir con la documentación básica de procedimiento (solicitudes, respuestas/resoluciones, reclamaciones, resoluciones de reclamación, alegaciones).
Los documentos subidos sin estar enlazados a una solicitud (o que la IA no ha podido emparejar) están disponibles en el modal "Importar documento sin asignar". El modal muestra:
- Nombre y tipo del documento
- Fecha de subida
- Nombre del organismo público detectado
- Resumen de la IA
El usuario hace clic en "Enlazar" para enlazar un documento huérfano a la solicitud actual a través de POST /documentos/{id}/link.
Al enlazar manualmente, el documento aplica sus efectos de estado completos (mismo DocumentEffectsApplier que el pipeline automático) solo si es el documento más actual: si el expediente ya contiene otro documento con fecha posterior cuyo tipo también cambia el estado (DocumentType::affectsState()), se enlaza sin efectos para no pisar un estado más reciente (DocumentEffectsApplier::isMostRecentStateChanging()). Las fechas comparan por documentDate con fallback a createdAt.
Los usuarios pueden recibir una dirección de email virtual (por ejemplo, usuario-df49302da@pideinfo.es) que proporcionan a las administraciones públicas. Los correos enviados a esta dirección se procesan automáticamente y sus adjuntos entran en el pipeline de documentos.
Email arrives at usuario-xxx@pideinfo.es
│
▼
Cloudflare Email Routing (catch-all on pideinfo.es)
│
▼
Cloudflare Email Worker (filters usuario-* prefix)
├── Parses MIME with postal-mime
├── Extracts body text + attachments
└── POSTs JSON to /webhook/inbound-email
│
▼
InboundEmailController
├── Validates X-Webhook-Secret header
├── Looks up user by virtual email address
├── Stores email body as .txt document in S3
├── Stores each attachment in S3
├── Creates Document entities (sourceType: 'email')
└── Dispatches ProcessDocumentBatchMessage
│
▼
Existing AI pipeline (same as manual uploads)
- Cada usuario puede generar una dirección de email virtual bajo demanda desde el dashboard
- Formato:
usuario-{token hex de 10 caracteres}@pideinfo.es - Generado por el servicio
VirtualEmailManager - Almacenado en
User.virtualEmail(único, nullable) - Solo los usuarios verificados pueden generar una dirección
- El cuerpo del email se almacena como documento
text/plainy es analizado por el modelo para extraer números de referencia y contexto - Los adjuntos se filtran por tipos MIME permitidos (PDF, imágenes, Word, texto)
- Todos los documentos del mismo email comparten un
emailGroupIden su campo JSONsourceMetadata Document.sourceTypese establece a'email'para distinguirlo de subidas manuales y de la sincronización del portalDocument.sourceMetadataalmacena:{from, subject, date, emailGroupId, emailHash}para emails, o metadatos específicos del portal para documentos sincronizados desde el portalDocument.contentHashalmacena el SHA-256 del contenido del archivo para deduplicación entre fuentes- La detección de duplicados para emails utiliza un hash de
from + date + subject + número de adjuntos - Los documentos recibidos por email se pueden gestionar (descargar, vincular, eliminar, reprocesar) en la vista
/comunicaciones, agrupados poremailGroupId; el resto de documentos del usuario se listan en/documentos(ver inbound-email.md)
Ubicado en pideinfo-worker/. Proyecto TypeScript desplegado con Wrangler:
cd pideinfo-worker
npm install
wrangler secret put WEBHOOK_SECRET
wrangler deployLa WEBHOOK_URL se configura en las vars de wrangler.jsonc. WEBHOOK_SECRET debe establecerse como secret de Wrangler (no se commitea al código fuente).
- Webhook autenticado mediante secret compartido (
INBOUND_EMAIL_WEBHOOK_SECRET) - Rate limit: 30 peticiones/minuto por IP
- Ruta excluida de la autenticación del firewall de Symfony (path
/webhook/) - Las direcciones desconocidas devuelven 200 silenciosamente (sin fuga de información)
Si el análisis del modelo falla:
- El mensaje de error se almacena en
document.processingError - El documento se marca como no procesado
- El documento sigue accesible para clasificación manual
- El usuario puede reintentar mediante el botón de reprocesar
Si falla el emparejamiento con una solicitud:
- El documento se deja como huérfano
- No se aplica ningún cambio de estado
- El usuario puede enlazarlo manualmente más tarde
