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:
co-authored by
Claude Sonnet 5
parent
b292d98cf6
commit
cd0ca6d232
@@ -7,6 +7,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -100,3 +101,38 @@ func PopFacturaAttachment(chatID int64) (*FacturaAttachment, bool) {
|
|||||||
}
|
}
|
||||||
return a, true
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package services
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -43,6 +44,9 @@ type agentMessage struct {
|
|||||||
ToolCalls []agentToolCall `json:"tool_calls,omitempty"`
|
ToolCalls []agentToolCall `json:"tool_calls,omitempty"`
|
||||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||||
Name string `json:"name,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 {
|
type agentToolCall struct {
|
||||||
@@ -951,13 +955,22 @@ type anthropicTool struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type anthropicContentBlock struct {
|
type anthropicContentBlock struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Text string `json:"text,omitempty"`
|
Text string `json:"text,omitempty"`
|
||||||
ID string `json:"id,omitempty"`
|
ID string `json:"id,omitempty"`
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
Input json.RawMessage `json:"input,omitempty"`
|
Input json.RawMessage `json:"input,omitempty"`
|
||||||
ToolUseID string `json:"tool_use_id,omitempty"`
|
ToolUseID string `json:"tool_use_id,omitempty"`
|
||||||
Content string `json:"content,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 {
|
type anthropicMsg struct {
|
||||||
@@ -1077,6 +1090,25 @@ func callAnthropicAI(ai *models.AiConfig, messages []agentMessage, tools []agent
|
|||||||
continue
|
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})
|
anthropicMsgs = append(anthropicMsgs, anthropicMsg{Role: "user", Content: content})
|
||||||
|
|
||||||
case "assistant":
|
case "assistant":
|
||||||
@@ -1237,7 +1269,7 @@ COMPORTAMIENTO:
|
|||||||
- Los valores monetarios son en COP (pesos colombianos)
|
- Los valores monetarios son en COP (pesos colombianos)
|
||||||
- Si una herramienta falla, explica el error y sugiere alternativas
|
- 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
|
- 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):
|
COMANDOS ESPECIALES (el usuario puede escribirlos):
|
||||||
- /reset — olvidar el historial de esta conversación
|
- /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: 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()
|
tools := agentTools()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user