feat: leer el contenido real del documento adjunto con Claude

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 <noreply@anthropic.com>
This commit is contained in:
Lizandro GD
2026-08-03 02:01:12 +00:00
co-authored by Claude Sonnet 5
parent b292d98cf6
commit cd0ca6d232
2 changed files with 91 additions and 9 deletions
@@ -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"
}
}
+55 -9
View File
@@ -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()