mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
Merge pull request #2008 from webtecnica/fix/1998-backfill-0068-duplicate-pricing
fix(baseline): deduplica backfill 0068 por model_id
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
---
|
||||
impacto: nada_mudou
|
||||
secao: corrigido
|
||||
titulo: O update.sh não falha mais com "duplicate key ... ai_pricing_pkey" quando um mesmo modelo está cadastrado em dois provedores
|
||||
---
|
||||
|
||||
A atualização do aplicativo parava na etapa "Atualizando o banco de dados" com erro
|
||||
`duplicate key value violates unique constraint "ai_pricing_pkey"` quando o mesmo
|
||||
modelo existia ativo, com preço, em dois provedores ao mesmo tempo (ex.: openrouter
|
||||
e requesty) e ainda não tinha linha em `ai_pricing`. Como `ai_models` permite um
|
||||
`model_id` por provedor, o backfill de preços gerava duas linhas iguais dentro do
|
||||
mesmo comando e a chave primária (que é só o `model`) recusava a segunda. O erro
|
||||
não deixava nenhuma versão atualizar nem voltar com `--force` naquela instalação.
|
||||
|
||||
Agora o backfill emite uma única linha por modelo, escolhendo de forma determinística
|
||||
o provedor de menor preço quando houver o mesmo modelo em mais de um provedor;
|
||||
o mecanismo idempotente (não reescrever preço já existente) continua intacto. Quem
|
||||
estava travado por esse erro basta rodar a atualização de novo: ela completa sem
|
||||
ação manual no banco.
|
||||
|
||||
Contribuição de @webtecnica (#2008).
|
||||
@@ -191,6 +191,15 @@ insert into public.calendar_connections
|
||||
values ('22222222-0000-4000-8000-00000000000a', '11111111-0000-4000-8000-000000000002',
|
||||
'agenda@update-com-dados.test', 'healthy')
|
||||
on conflict do nothing;
|
||||
|
||||
-- #1998: o mesmo model_id em dois provedores e sem linha em ai_pricing. O
|
||||
-- backfill 0068 roda ANTES do seed da Requesty, então depois do install
|
||||
-- 'openai/gpt-4o-mini' existe só sob requesty e sem preço. A re-aplicação
|
||||
-- abaixo tem de criar UMA linha de preço, e não quebrar em ai_pricing_pkey.
|
||||
insert into public.ai_models
|
||||
(provider, model_id, display_name, input_price_per_million_cents, output_price_per_million_cents)
|
||||
values ('openrouter', 'openai/gpt-4o-mini', 'GPT-4o mini (OpenRouter)', 14, 60)
|
||||
on conflict (provider, model_id) do nothing;
|
||||
SQL
|
||||
linhas=$(docker exec "$CONTAINER" psql -U postgres -d postgres -tAc "
|
||||
select (select count(*) from public.organizations)
|
||||
@@ -267,6 +276,19 @@ fi
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Guarda de vacuidade do caso #1998: sem os dois provedores e sem preço, o
|
||||
# update abaixo passaria verde sem exercitar o backfill 0068.
|
||||
caso_1998=$(docker exec "$CONTAINER" psql -U postgres -d postgres -tAc "
|
||||
select (select count(*) from public.ai_models
|
||||
where model_id = 'openai/gpt-4o-mini' and deprecated_at is null)
|
||||
|| '/' || (select count(*) from public.ai_pricing
|
||||
where model = 'openai/gpt-4o-mini' and superseded_at is null);")
|
||||
[ "$caso_1998" = "2/0" ] || {
|
||||
echo "FATAL: o caso do #1998 não foi plantado (modelos/preços = $caso_1998, esperado 2/0)." >&2
|
||||
echo " Sem ele, o update não exercita o backfill 0068 com model_id em dois provedores." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo "==> UPDATE: re-aplicando baseline.sql SOBRE OS DADOS, com ON_ERROR_STOP=1"
|
||||
# O OID é lido ANTES da passada que o aceite da issue #1086 mede: este banco já
|
||||
# está no estado final (o install acabou de rodar), então a view não pode ser
|
||||
@@ -279,6 +301,15 @@ oid_antes=$(oid_da_view)
|
||||
}
|
||||
psql_stop < "$BASELINE" >/dev/null
|
||||
echo " ✓ update ok — nenhuma constraint quebrou sobre dado existente"
|
||||
preco_1998=$(docker exec "$CONTAINER" psql -U postgres -d postgres -tAc "
|
||||
select count(*) || '/' || coalesce(min(prompt_cents_per_million_tokens)::int::text, '-')
|
||||
from public.ai_pricing where model = 'openai/gpt-4o-mini' and superseded_at is null;")
|
||||
[ "$preco_1998" = "1/14" ] || {
|
||||
echo "FATAL: o backfill 0068 deixou $preco_1998 (linhas/menor preço) para openai/gpt-4o-mini;" >&2
|
||||
echo " o esperado é 1/14: UMA linha, com o menor preço de entrada (#1998)." >&2
|
||||
exit 1
|
||||
}
|
||||
echo " ✓ #1998: model_id em dois provedores virou UMA linha de preço ($preco_1998)"
|
||||
|
||||
echo "==> a view de ocupação não pode ser derrubada nem recriada pelo update (issue #1086)"
|
||||
oid_depois=$(oid_da_view)
|
||||
|
||||
+13
-2
@@ -7970,8 +7970,18 @@ $seed$;
|
||||
-- não ai_pricing. Com a tabela vazia, computeCost() devolve 0 sem log e o teto
|
||||
-- de ai_budgets nunca dispara. Derivado de ai_models: idempotente e
|
||||
-- auto-curativo, cobre qualquer modelo futuro do catálogo.
|
||||
--
|
||||
-- `distinct on (m.model_id)`: ai_models é único por (provider, model_id), então
|
||||
-- o MESMO model_id pode existir sob dois provedores (ex.: openrouter e requesty)
|
||||
-- e, sem a deduplicação, o INSERT gerava DUAS linhas iguais dentro da mesma
|
||||
-- passada e a PK `ai_pricing_pkey` (só `model`) recusava com
|
||||
-- `duplicate key ... ai_pricing_pkey`. O `not exists` abaixo não resolve: os
|
||||
-- duplicados estão dentro do MESMO select. `distinct on` devolve UMA linha por
|
||||
-- model_id, e o `order by m.model_id, m.input_price_per_million_cents asc`
|
||||
-- escolhe o provedor de MENOR preço de entrada; empate por saída e depois por
|
||||
-- provedor, para a escolha ser determinística.
|
||||
insert into public.ai_pricing (model, prompt_cents_per_million_tokens, completion_cents_per_million_tokens, notes)
|
||||
select
|
||||
select distinct on (m.model_id)
|
||||
m.model_id,
|
||||
m.input_price_per_million_cents,
|
||||
m.output_price_per_million_cents,
|
||||
@@ -7983,7 +7993,8 @@ where m.deprecated_at is null
|
||||
and not exists (
|
||||
select 1 from public.ai_pricing p
|
||||
where p.model = m.model_id and p.superseded_at is null
|
||||
);
|
||||
)
|
||||
order by m.model_id, m.input_price_per_million_cents asc, m.output_price_per_million_cents asc, m.provider asc;
|
||||
|
||||
-- Embedding do RAG — não vive em ai_models.
|
||||
insert into public.ai_pricing (model, embedding_cents_per_million_tokens, notes)
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* O BACKFILL 0068 NUNCA PODE GERAR DUAS LINHAS PARA O MESMO `model_id`.
|
||||
*
|
||||
* ## Por que este arquivo existe
|
||||
*
|
||||
* `ai_pricing` tem PK só em `model` (`ai_pricing_pkey`), enquanto `ai_models` é
|
||||
* único por `(provider, model_id)`. O backfill 0068 deriva `ai_pricing` de
|
||||
* `ai_models` num `insert ... select` idempotente embrulhado em `not exists`.
|
||||
* Quando o MESMO `model_id` existe sob DOIS provedores (ex.: `openrouter` e
|
||||
* `requesty`), o select devolve DUAS linhas iguais DENTRO da mesma passada — o
|
||||
* `not exists` não enxerga, porque os duplicados ainda não foram inseridos — e a
|
||||
* PK recusa com `duplicate key value violates unique constraint
|
||||
* "ai_pricing_pkey"`. Foi o bug relatado (issue): o `update.sh` para na etapa do
|
||||
* banco e o rollback `--force` falha no mesmo ponto.
|
||||
*
|
||||
* A correção é o `select distinct on (m.model_id) ... order by m.model_id,
|
||||
* m.input_price_per_million_cents asc`, que devolve UMA linha por `model_id`
|
||||
* (escolhendo deterministicamente o provedor de menor preço em caso de empate)
|
||||
* e preserva a idempotência com o `not exists` existente.
|
||||
*
|
||||
* Este teste lê o texto do `baseline.sql` e afirma que o bloco deduplica por
|
||||
* `model_id`. Ele roda na malha unitária (não exige Postgres/Docker), então pega
|
||||
* a regressão antes mesmo do `test:db` de CI; e é a CATRACA do fix: reverter o
|
||||
* bloco para `select` cru (sem `distinct on`) deixa este arquivo VERMELHO.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const RAIZ = process.cwd();
|
||||
const BASELINE = readFileSync(join(RAIZ, "supabase", "baseline.sql"), "utf8");
|
||||
|
||||
/** Extrai o corpo do primeiro `insert into public.ai_pricing (model, prompt_cents...` até o `;` que o fecha. */
|
||||
function blocoDoBackfillDePrecos(): string {
|
||||
const inicio = BASELINE.indexOf(
|
||||
"insert into public.ai_pricing (model, prompt_cents_per_million_tokens, completion_cents_per_million_tokens, notes)",
|
||||
);
|
||||
expect(inicio, "bloco do backfill 0068 não encontrado no baseline.sql").toBeGreaterThan(-1);
|
||||
const fim = BASELINE.indexOf(";", inicio);
|
||||
expect(fim, "bloco do backfill sem fechamento ';'").toBeGreaterThan(inicio);
|
||||
return BASELINE.slice(inicio, fim);
|
||||
}
|
||||
|
||||
describe("o backfill 0068 deduplica por model_id", () => {
|
||||
const bloco = blocoDoBackfillDePrecos();
|
||||
|
||||
it("o bloco existe e é o das strings 'backfill 0068 a partir de ai_models'", () => {
|
||||
expect(bloco).toContain("'backfill 0068 a partir de ai_models'");
|
||||
});
|
||||
|
||||
it("o select usa `distinct on (m.model_id)` para UMA linha por model_id", () => {
|
||||
expect(
|
||||
bloco,
|
||||
"ai_models é único por (provider, model_id): SEM distinct on (m.model_id), o MESMO " +
|
||||
"model_id sob dois provedores gera DUAS linhas no mesmo INSERT e a PK ai_pricing_pkey " +
|
||||
"(só model) recusa — o update.sh para na etapa do banco (issue #1998).",
|
||||
).toMatch(/select distinct on \(m\.model_id\)/);
|
||||
});
|
||||
|
||||
it("o order by escolhe deterministicamente o provedor de menor preço no empate", () => {
|
||||
expect(
|
||||
bloco,
|
||||
"o `order by` precisa ter model_id primeiro (domínio do distinct on) e " +
|
||||
"input_price em segundo, para escolher o provedor mais barato em caso de empate " +
|
||||
"de forma determinística.",
|
||||
).toMatch(/order by m\.model_id,\s*m\.input_price_per_million_cents asc\b/);
|
||||
});
|
||||
|
||||
it("a idempotência do `not exists` é preservada", () => {
|
||||
expect(bloco).toContain("not exists");
|
||||
expect(bloco).toContain("p.model = m.model_id and p.superseded_at is null");
|
||||
});
|
||||
|
||||
it("só existe UM bloco desses no baseline (dump + apêndice), e é o único INSERT de preço de modelo", () => {
|
||||
const ocorrencias = BASELINE.split(
|
||||
"insert into public.ai_pricing (model, prompt_cents_per_million_tokens, completion_cents_per_million_tokens, notes)",
|
||||
).length;
|
||||
expect(ocorrencias).toBe(2); // 1 (antes) + 1 (a própria ocorrência)
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user