From cd0ca6d232878d9d2d266d6f1f0fa821a6310f4f Mon Sep 17 00:00:00 2001 From: Lizandro GD Date: Mon, 3 Aug 2026 02:01:12 +0000 Subject: [PATCH] feat: leer el contenido real del documento adjunto con Claude MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Antes solo se avisaba en texto "llegó un documento" — la IA nunca veía el archivo, por eso siempre tenía que preguntar cliente y monto. Ahora, cuando el proveedor activo es Anthropic, se le adjunta la imagen/PDF real (base64) al mensaje para que Claude lo lea directamente y extraiga cliente, monto y número de factura, llamando a adjuntar_factura sin pedir confirmación salvo que de verdad no pueda determinar cliente o monto. Co-Authored-By: Claude Sonnet 5 --- pkg/services/factura_attachment_service.go | 36 ++++++++++++ pkg/services/telegram_agent_service.go | 64 +++++++++++++++++++--- 2 files changed, 91 insertions(+), 9 deletions(-) diff --git a/pkg/services/factura_attachment_service.go b/pkg/services/factura_attachment_service.go index 9950876..a47290f 100644 --- a/pkg/services/factura_attachment_service.go +++ b/pkg/services/factura_attachment_service.go @@ -7,6 +7,7 @@ import ( "net/http" "os" "path/filepath" + "strings" "sync" "time" ) @@ -100,3 +101,38 @@ func PopFacturaAttachment(chatID int64) (*FacturaAttachment, bool) { } return a, true } + +// PeekFacturaAttachment consulta el archivo pendiente de un chat sin removerlo +// (se usa para leer su contenido y pasárselo al modelo antes de que la tool +// adjuntar_factura lo consuma de verdad). +func PeekFacturaAttachment(chatID int64) (*FacturaAttachment, bool) { + facturaAttachmentsMu.Lock() + defer facturaAttachmentsMu.Unlock() + a, ok := facturaAttachments[chatID] + if !ok || time.Since(a.StagedAt) > facturaAttachmentTTL { + return nil, false + } + return a, true +} + +// ResolverMimeType usa el mime_type que mandó Telegram si vino, y si no, +// adivina por la extensión del archivo (Claude solo acepta ciertos tipos). +func ResolverMimeType(mimeType, filename string) string { + if mimeType != "" { + return mimeType + } + switch strings.ToLower(filepath.Ext(filename)) { + case ".pdf": + return "application/pdf" + case ".jpg", ".jpeg": + return "image/jpeg" + case ".png": + return "image/png" + case ".webp": + return "image/webp" + case ".gif": + return "image/gif" + default: + return "application/octet-stream" + } +} diff --git a/pkg/services/telegram_agent_service.go b/pkg/services/telegram_agent_service.go index 0d9e8ca..141ddc3 100644 --- a/pkg/services/telegram_agent_service.go +++ b/pkg/services/telegram_agent_service.go @@ -2,6 +2,7 @@ package services import ( "bytes" + "encoding/base64" "encoding/json" "fmt" "io" @@ -43,6 +44,9 @@ type agentMessage struct { ToolCalls []agentToolCall `json:"tool_calls,omitempty"` ToolCallID string `json:"tool_call_id,omitempty"` Name string `json:"name,omitempty"` + // Adjunto opcional para que el modelo lea el archivo real (solo Anthropic). + AttachmentBase64 string `json:"-"` + AttachmentMime string `json:"-"` } type agentToolCall struct { @@ -951,13 +955,22 @@ type anthropicTool struct { } type anthropicContentBlock struct { - Type string `json:"type"` - Text string `json:"text,omitempty"` - ID string `json:"id,omitempty"` - Name string `json:"name,omitempty"` - Input json.RawMessage `json:"input,omitempty"` - ToolUseID string `json:"tool_use_id,omitempty"` - Content string `json:"content,omitempty"` + Type string `json:"type"` + Text string `json:"text,omitempty"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Input json.RawMessage `json:"input,omitempty"` + ToolUseID string `json:"tool_use_id,omitempty"` + Content string `json:"content,omitempty"` + Source *anthropicBlockSource `json:"source,omitempty"` +} + +// anthropicBlockSource es el contenido en base64 de una imagen o PDF adjunto, +// para que Claude lea el archivo real en vez de solo un aviso en texto. +type anthropicBlockSource struct { + Type string `json:"type"` // "base64" + MediaType string `json:"media_type"` + Data string `json:"data"` } type anthropicMsg struct { @@ -1077,6 +1090,25 @@ func callAnthropicAI(ai *models.AiConfig, messages []agentMessage, tools []agent continue } } + if m.AttachmentBase64 != "" { + blockType := "image" + if m.AttachmentMime == "application/pdf" { + blockType = "document" + } + blocks := []anthropicContentBlock{{ + Type: blockType, + Source: &anthropicBlockSource{ + Type: "base64", + MediaType: m.AttachmentMime, + Data: m.AttachmentBase64, + }, + }} + if content != "" { + blocks = append(blocks, anthropicContentBlock{Type: "text", Text: content}) + } + anthropicMsgs = append(anthropicMsgs, anthropicMsg{Role: "user", Content: blocks}) + continue + } anthropicMsgs = append(anthropicMsgs, anthropicMsg{Role: "user", Content: content}) case "assistant": @@ -1237,7 +1269,7 @@ COMPORTAMIENTO: - Los valores monetarios son en COP (pesos colombianos) - Si una herramienta falla, explica el error y sugiere alternativas - Para acciones en Coolify, primero usa coolify_instancias para saber qué IDs hay disponibles si el usuario no lo especifica -- Cuando el mensaje empiece con "[Documento adjunto recibido: ...]", el usuario acaba de enviar un PDF o foto por Telegram. Identifica el cliente (usa listar_clientes si hace falta) y el monto de la factura a partir de lo que escribió; si falta alguno de los dos, pregúntalo antes de guardar. Cuando tengas cliente y monto, usa la tool adjuntar_factura para guardarlo — no la uses si el mensaje no menciona ningún documento adjunto +- Cuando el mensaje empiece con "[Documento adjunto recibido: ...]", el usuario acaba de enviar un PDF o foto por Telegram y puedes leerlo directamente (está adjunto a este mismo mensaje, no es solo un nombre de archivo). Léelo para identificar cliente, monto y número de factura. Usa listar_clientes para encontrar el cliente_id que mejor coincida con el nombre/empresa que aparece en el documento o en el texto del usuario. Si logras identificar cliente y monto (del documento o de lo que escribió el usuario), llama a adjuntar_factura directamente sin pedir confirmación — solo pregunta si de verdad no hay forma de determinar el cliente o el monto. No uses esta tool si el mensaje no menciona ningún documento adjunto COMANDOS ESPECIALES (el usuario puede escribirlos): - /reset — olvidar el historial de esta conversación @@ -1305,7 +1337,21 @@ Comandos: /reset /instancias /ayuda`, nil } messages = append(messages, agentMessage{Role: h.Role, Content: h.Content}) } - messages = append(messages, agentMessage{Role: "user", Content: userText}) + userMsg := agentMessage{Role: "user", Content: userText} + // Si hay un documento/foto pendiente en este chat, se lo pasamos al modelo + // como archivo real (solo Anthropic lo lee de verdad) en vez de que la IA + // tenga que adivinar cliente/monto a partir del nombre del archivo. + if strings.ToLower(ai.Provider) == "anthropic" { + if att, ok := PeekFacturaAttachment(chatID); ok { + if data, err := os.ReadFile(att.Path); err == nil { + userMsg.AttachmentBase64 = base64.StdEncoding.EncodeToString(data) + userMsg.AttachmentMime = ResolverMimeType(att.MimeType, att.OriginalName) + } else { + log.Printf("[AGENT] No se pudo leer el adjunto pendiente (%s): %v", att.Path, err) + } + } + } + messages = append(messages, userMsg) tools := agentTools()