Skip to content

Commit 79c7bef

Browse files
committed
Merge remote-tracking branch 'origin/main' into triagem/agenda-ocupacao-nao-e-acionavel
2 parents 84719f3 + f700f3e commit 79c7bef

10 files changed

Lines changed: 744 additions & 8 deletions

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
impacto: nada_mudou
3+
secao: corrigido
4+
titulo: O painel de IA para de avisar que um modelo não enxerga imagens quando ele enxerga
5+
---
6+
7+
Duas informações erradas no painel de provedores, e as duas faziam quem opera
8+
tomar decisão contra o que o sistema realmente faz.
9+
10+
**A primeira:** o painel avisava que um modelo "não enxerga imagens" e que fotos
11+
e comprovantes do cliente seriam ignorados — sobre modelos que enxergam, e num
12+
sistema onde a leitura estava funcionando. Na mesma instalação em que o aviso
13+
aparecia, o print que o cliente enviou virou descrição correta para o atendente.
14+
15+
O painel lia uma tabela de catálogo; o atendimento lia outra coisa. Agora os
16+
dois respondem pela mesma fonte, e o painel não pode mais discordar do que
17+
acontece de verdade. Onde o sistema não conhece o modelo — o seu, ou um de um
18+
serviço próprio —, o catálogo continua sendo a resposta, e a falta de informação
19+
continua sendo dita como falta de informação, não como "não funciona".
20+
21+
**A segunda:** quem usa a OpenRouter tinha o problema INVERTIDO — e ele é pior,
22+
porque não tem sintoma. Ali o sistema não sabia dizer se um modelo enxerga: ele
23+
olhava só o começo do nome. Como `openai/gpt-4o` enxerga e `openai/gpt-3.5-turbo`
24+
não, e os dois começam igual, um palpite pelo começo do nome erra metade das
25+
vezes — e a OpenRouter já informa a resposta certa, modelo por modelo, quando o
26+
catálogo é sincronizado na instalação.
27+
28+
O efeito prático era duplo. O painel deixava de avisar quando o aviso era
29+
verdadeiro, então quem opera achava que o comprovante do cliente estava sendo
30+
lido e não estava. E o atendimento chegava a enviar a imagem para um modelo que
31+
não a aceita, o que fazia a resposta daquela mensagem falhar. Agora, quando a
32+
OpenRouter informa a capacidade, é ela que vale — e quando não informa, o
33+
sistema volta a dizer que não sabe, em vez de afirmar.
34+
35+
**A terceira:** o ponto "Ouvir o áudio do cliente" mostrava um modelo de
36+
conversa, com "usando o padrão da organização" — ao lado do próprio texto do
37+
ponto, que diz que a transcrição usa o padrão da OpenAI. A mesma tela afirmava
38+
duas coisas incompatíveis, e modelo de conversa não transcreve áudio.
39+
40+
Agora ele mostra o que de fato transcreve. Trocar o modelo de conversa nunca
41+
mudou nada ali; o que muda é a tela parar de sugerir que mudaria.
42+
43+
Para quem opera uma instalação, nada muda no dia a dia: nenhuma configuração
44+
nova, nenhum passo de atualização. O que muda é que o painel volta a descrever
45+
o sistema que está rodando.

app/api/v1/ai/providers/route.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
* recusar naquele instante trocaria uma configuração ruim por um atendimento
1212
* perdido.
1313
*/
14+
import { enxergaImagem } from "@/lib/ai/pontos/capacidade-em-vigor";
1415
import type { NextRequest } from "next/server";
1516
import { z } from "zod";
1617

@@ -98,7 +99,18 @@ export async function GET(): Promise<Response> {
9899
? { provider: versao.provider, credentialId: versao.credential_id, model: versao.model }
99100
: null;
100101

101-
const modelos = (modelosRes.data ?? []) as ModeloDoCatalogo[];
102+
// ⚠️ A LISTA QUE A TELA DESENHA sai daqui, e `supports_vision` dela vinha da
103+
// coluna — a mesma que discordava do motor. Reconciliar aqui, uma vez, é o
104+
// que faz a lista, o aviso do binding e o motor darem a MESMA resposta.
105+
// Ver `lib/ai/pontos/capacidade-em-vigor.ts`.
106+
const modelos = ((modelosRes.data ?? []) as ModeloDoCatalogo[]).map((m) => ({
107+
...m,
108+
supports_vision: enxergaImagem({
109+
provider: m.provider,
110+
modelId: m.model_id,
111+
doCatalogo: m.supports_vision,
112+
}),
113+
}));
102114
const capacidadePorModelo = new Map(modelos.map((m) => [`${m.provider}|${m.model_id}`, m]));
103115

104116
const pontos = PONTOS_DE_IA.map((ponto) => {
@@ -231,7 +243,14 @@ export async function PUT(req: NextRequest): Promise<Response> {
231243
modelo: {
232244
model_id: corpo.model_id,
233245
supports_tools: modelo?.supports_tools ?? false,
234-
supports_vision: modelo?.supports_vision ?? false,
246+
// A capacidade vem do MOTOR, não da coluna: os dois discordavam e a tela
247+
// avisava "não enxerga imagens" sobre modelo que enxerga. Ver
248+
// `lib/ai/pontos/capacidade-em-vigor.ts`.
249+
supports_vision: enxergaImagem({
250+
provider: corpo.provider,
251+
modelId: corpo.model_id,
252+
doCatalogo: modelo?.supports_vision ?? null,
253+
}),
235254
conhecido: modelo !== null,
236255
},
237256
});

lib/agent-engine/agent/media-parts.test.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,3 +97,123 @@ describe("buildNativeMediaParts — regressão da visão nativa", () => {
9797
expect(parts).toEqual([]);
9898
});
9999
});
100+
101+
/**
102+
* ═══ O ROTEADOR: o call site REAL consulta o catálogo, e o direto NÃO ═══════
103+
*
104+
* A regra vive em `lib/ai/pontos/capacidade-em-vigor.ts` e tem teste próprio.
105+
* O que faltava é o que este bloco guarda: que ESTE arquivo a chama, e com o
106+
* catálogo ligado. Sem isso, trocar `visaoEmVigor` de volta por
107+
* `modelCapabilities(...).image` deixaria a suíte verde e o motor voltaria a
108+
* anexar a imagem a um modelo que a recusa — teste guarda a função, não o
109+
* call site.
110+
*
111+
* ⚠️ O dublê deste arquivo NÃO tinha `.from`, então nenhum caso passava pelo
112+
* ramo do roteador. A ausência de `.from` também é o caso de "banco fora", e
113+
* ele tem um teste aqui de propósito: falha de catálogo não pode derrubar o
114+
* turno — cai no palpite, que é o comportamento de antes da regra existir.
115+
*/
116+
function fakeAdminComCatalogo(bytes: Buffer, supports_vision: boolean | null) {
117+
const consultas: string[] = [];
118+
const chain: Record<string, unknown> = {};
119+
chain.select = () => chain;
120+
chain.eq = (_c: string, v: string) => {
121+
consultas.push(v);
122+
return chain;
123+
};
124+
chain.is = () => chain;
125+
chain.maybeSingle = async () => ({ data: supports_vision === null ? null : { supports_vision } });
126+
return {
127+
consultas,
128+
admin: {
129+
from: () => chain,
130+
storage: {
131+
from: () => ({
132+
download: vi.fn(async () => ({
133+
data: { arrayBuffer: async () => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) },
134+
error: null,
135+
})),
136+
}),
137+
},
138+
} as never,
139+
};
140+
}
141+
142+
describe("buildNativeMediaParts — no roteador quem decide é o catálogo", () => {
143+
const bytes = Buffer.from([0xff, 0xd8, 0xff, 0xe0]);
144+
145+
it("openrouter + catálogo diz que NÃO enxerga → nenhuma parte nativa", async () => {
146+
// O defeito que este caso existe para impedir: o registro casa o prefixo
147+
// `openai/` e afirma que enxerga, o motor anexa os bytes, e o provedor
148+
// recusa — derrubando a resposta daquela mensagem para o cliente.
149+
const db = fakeAdminComCatalogo(bytes, false);
150+
const parts = await buildNativeMediaParts({
151+
messages: [imageInbound],
152+
provider: "openrouter",
153+
model: "openai/gpt-3.5-turbo",
154+
multimodalInput: true,
155+
admin: db.admin,
156+
});
157+
expect(parts).toEqual([]);
158+
expect(db.consultas, "o catálogo nem foi consultado").toContain("openai/gpt-3.5-turbo");
159+
});
160+
161+
it("openrouter + catálogo diz que enxerga → a parte nativa vai", async () => {
162+
// Controle: sem ele, "no roteador nunca anexe" satisfaria o caso acima e a
163+
// visão morreria para quem usa OpenRouter com um modelo que enxerga.
164+
const db = fakeAdminComCatalogo(bytes, true);
165+
const parts = await buildNativeMediaParts({
166+
messages: [imageInbound],
167+
provider: "openrouter",
168+
model: "openai/gpt-4o",
169+
multimodalInput: true,
170+
admin: db.admin,
171+
});
172+
expect(parts).toHaveLength(1);
173+
});
174+
175+
it("openrouter SEM linha no catálogo → cai no prefixo, que é melhor que nada", async () => {
176+
const db = fakeAdminComCatalogo(bytes, null);
177+
const parts = await buildNativeMediaParts({
178+
messages: [imageInbound],
179+
provider: "openrouter",
180+
model: "openai/gpt-4o",
181+
multimodalInput: true,
182+
admin: db.admin,
183+
});
184+
expect(parts).toHaveLength(1);
185+
});
186+
187+
it("⚠️ provedor DIRETO não paga ida ao banco — o dublê explode se for consultado", async () => {
188+
// Guarda de custo: um roundtrip por turno com mídia, para confirmar o que o
189+
// registro já sabe. Se alguém tirar o atalho de `visaoEmVigor`, este caso
190+
// vira vermelho em vez de a conta do banco subir em silêncio.
191+
const db = fakeAdminComCatalogo(bytes, false);
192+
(db.admin as unknown as { from: () => never }).from = () => {
193+
throw new Error("provedor direto NÃO pode consultar o catálogo");
194+
};
195+
const parts = await buildNativeMediaParts({
196+
messages: [imageInbound],
197+
provider: "openai",
198+
model: "gpt-4o",
199+
multimodalInput: true,
200+
admin: db.admin,
201+
});
202+
expect(parts).toHaveLength(1);
203+
});
204+
205+
it("catálogo fora do ar não derruba o turno — cai no palpite", async () => {
206+
const db = fakeAdminComCatalogo(bytes, false);
207+
(db.admin as unknown as { from: () => never }).from = () => {
208+
throw new Error("banco fora");
209+
};
210+
const parts = await buildNativeMediaParts({
211+
messages: [imageInbound],
212+
provider: "openrouter",
213+
model: "openai/gpt-4o",
214+
multimodalInput: true,
215+
admin: db.admin,
216+
});
217+
expect(parts).toHaveLength(1);
218+
});
219+
});

lib/agent-engine/agent/media-parts.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type { SupabaseClient } from "@supabase/supabase-js";
88

99
import type { LeadContextMessage } from "@/lib/agent-engine/edge/crm/get-lead-context";
1010
import { modelCapabilities } from "@/lib/agent-engine/edge/llm/capabilities";
11+
import { visaoEmVigor } from "@/lib/ai/pontos/capacidade-em-vigor";
1112

1213

1314
/**
@@ -32,7 +33,38 @@ export interface BuildNativeMediaPartsArgs {
3233

3334
export async function buildNativeMediaParts(args: BuildNativeMediaPartsArgs): Promise<NativeMediaPart[]> {
3435
if (!args.multimodalInput) return [];
35-
const caps = modelCapabilities(args.provider, args.model);
36+
// ⚠️ A imagem NÃO pergunta ao registro direto. Num roteador (openrouter) o
37+
// registro responde pelo prefixo do fabricante — `openai/gpt-3.5-turbo` casa
38+
// `openai/` e o registro afirma que enxerga, quando não enxerga. Anexar a
39+
// parte nativa ali manda bytes que o provedor recusa, e o custo é o turno,
40+
// não um aviso. `visaoEmVigor` deixa o catálogo (sincronizado das
41+
// modalidades que a própria OpenRouter declara) decidir nesse caso, e só
42+
// nesse — no provedor direto ele nem toca o banco.
43+
//
44+
// O PDF continua no registro: o catálogo não tem coluna de PDF, então não há
45+
// medida para preferir ao palpite.
46+
//
47+
// ⚠️ A consulta é escrita aqui, e no worker também, em vez de morar num helper
48+
// compartilhado: casar o client do Supabase contra a interface estreita de um
49+
// helper faz o checador estourar em TS2589 ("type instantiation is excessively
50+
// deep") — é o parser de colunas do PostgREST, não incompatibilidade real. E
51+
// ele estoura de forma DESIGUAL: `tsc --noEmit` passava e o `next build`
52+
// reprovava, no mesmo arquivo e na mesma linha. O que precisava ser único é a
53+
// REGRA, e ela é: `visaoEmVigor` decide aqui e em `media-derive-worker.ts`.
54+
const catalogo = async (): Promise<boolean | null> => {
55+
const { data } = await args.admin
56+
.from("ai_models")
57+
.select("supports_vision")
58+
.eq("provider", args.provider)
59+
.eq("model_id", args.model)
60+
.is("deprecated_at", null)
61+
.maybeSingle();
62+
return (data?.supports_vision as boolean | null) ?? null;
63+
};
64+
const caps = {
65+
image: (await visaoEmVigor({ provider: args.provider, modelId: args.model, catalogo })).enxerga,
66+
pdf: modelCapabilities(args.provider, args.model).pdf,
67+
};
3668
if (!caps.image && !caps.pdf) return [];
3769

3870
const maxItems = args.maxItems ?? 1;

lib/agent-engine/edge/llm/capabilities.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,20 @@ const PROVIDER_DEFAULT: Record<string, ModelCapabilities> = {
3636
*/
3737
const ROTEADORES = new Set(["openrouter"]);
3838

39+
/**
40+
* Este provedor é um ROTEADOR (revende modelos de vários fabricantes)?
41+
*
42+
* Importa para quem compõe esta resposta com o catálogo: num roteador, tudo que
43+
* este registro tem é o PREFIXO do id — que diz o fabricante, não o modelo.
44+
* `openai/gpt-4o` enxerga imagem e `openai/gpt-3.5-turbo` não, e os dois têm o
45+
* mesmo prefixo. Já o catálogo (`ai_models.supports_vision`) é sincronizado das
46+
* modalidades que a própria OpenRouter declara, então ali ele é MEDIDA e este
47+
* registro é PALPITE. Ver `enxergaImagem` em `lib/ai/pontos/capacidade-em-vigor.ts`.
48+
*/
49+
export function ehRoteador(provider: string): boolean {
50+
return ROTEADORES.has(provider?.toLowerCase() ?? "");
51+
}
52+
3953
// Substrings de modelos que NÃO são de chat multimodal (embeddings, TTS, etc.)
4054
// — rebaixam mesmo num provider capaz. Deny-list explícita e pequena.
4155
const TEXT_ONLY_HINTS = ["embedding", "tts", "whisper", "moderation"];

0 commit comments

Comments
 (0)