mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
H8 — a primeira execução da spec (HEAD 2c0181eff) passou verde; o único
achado da tela foi "percebeu 0 pedidos", que o H1 troca por frase própria.
A spec passa a conferir, pela tela:
- as frases novas do cartão (nenhuma / uma / duas mensagens, nos dois
pedidos) e a frase do cartão observando;
- o pedido de parar de receber em "Avisar a equipe": o diálogo ("o Jev nunca
bloqueia ninguém"), o pedido natural de sair que a regra não reconhece
("apaguem meu telefone do cadastro" — medido com ehPedidoDeOptOut e
ehOptOutProvavel reais) abrindo UM aviso na Central com a instrução
("…peça que ele responda PARAR…"), sem a frase do cliente, com "Abrir a
conversa", e o contato sem bloqueio;
- o H3 pelo caminho de produção: abrir a conversa pelo aviso, "Assumir" no
cabeçalho, e o aviso de parar de receber segue aberto na Central.
O dublê ganha DUBLE_JEV_RESPOSTAS_POR_TRECHO (a resposta por mensagem, por
cima da geral): a mensagem de quem sai pede parar, e não uma pessoa. Unit do
dublê cobre o trecho, a precedência e o mapa ilegível.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
195 lines
8.1 KiB
JavaScript
195 lines
8.1 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Dublê HTTP do Jev (System One, da TypeSafe AI) para o e2e.
|
|
*
|
|
* Servidor de verdade, em processo separado, pelo mesmo motivo do
|
|
* `scripts/duble-saas-e2e.mjs`: quem fala com o Jev é o servidor Next (o
|
|
* worker de clima dentro do dreno do event_log), não o processo do Playwright.
|
|
* Um intercept de `fetch` no teste deixaria o produto batendo na internet.
|
|
*
|
|
* Responde o formato REAL, medido contra a API em 2026-09-23 (o plano do Jev
|
|
* guarda as amostras):
|
|
* GET /v1/models → 200 { models: [...] } com o Bearer certo; 401 com
|
|
* outro; 403 sem nenhum. É o que valida a chave.
|
|
* POST /v1/systemone → 200 { model, answers, usage }. Para `score`, a
|
|
* posição na escala (0..n-1) vem de DUBLE_JEV_SCORE.
|
|
* DUBLE_JEV_RESPOSTAS (JSON, id da pergunta → resposta
|
|
* no formato do fornecedor) troca a resposta das
|
|
* perguntas que nomeia — é como a spec força uma
|
|
* discordância; as outras seguem o padrão acima.
|
|
* DUBLE_JEV_RESPOSTAS_POR_TRECHO (JSON, trecho da
|
|
* mensagem → { id da pergunta → resposta }) vale só
|
|
* para a chamada cujo `state` contém o trecho, e por
|
|
* cima das duas acima: uma spec com duas mensagens que
|
|
* pedem coisas diferentes responde cada uma.
|
|
*
|
|
* E GRAVA cada chamada num arquivo JSON (DUBLE_JEV_ARQUIVO), que a spec LÊ
|
|
* para provar que o Jev foi chamado e com o quê. Grava o corpo (é por ele que
|
|
* se prova que o telefone saiu apagado) e se o Bearer bateu — nunca o Bearer.
|
|
*
|
|
* GET /__duble/saude → { ok, porta, arquivo } — a spec confere que o
|
|
* dublê que respondeu é o dela, e não um que sobrou.
|
|
*
|
|
* Zero dependências. Uso:
|
|
* DUBLE_JEV_ARQUIVO=/tmp/chamadas.json node scripts/duble-jev-e2e.mjs
|
|
*/
|
|
|
|
import { writeFileSync } from "node:fs";
|
|
import http from "node:http";
|
|
|
|
/** 3996: vizinha de 3997 (dublê dos SaaS), 3998 (Redis HTTP) e 3999 (WAHA). */
|
|
const PORTA = Number(process.env.DUBLE_JEV_PORTA ?? 3996);
|
|
const HOST = process.env.DUBLE_JEV_HOST ?? "127.0.0.1";
|
|
const CHAVE = process.env.DUBLE_JEV_CHAVE ?? "apikey_e2e_duble_do_jev_0123456789abcdef";
|
|
const ARQUIVO = process.env.DUBLE_JEV_ARQUIVO ?? "";
|
|
/** Posição na escala de 5 níveis: 3 = "satisfeito" — longe do limiar de passagem. */
|
|
const SCORE = Number(process.env.DUBLE_JEV_SCORE ?? 3);
|
|
const MODELO = "jev-1.13.0";
|
|
|
|
if (!ARQUIVO) {
|
|
console.error("[duble-jev] DUBLE_JEV_ARQUIVO é obrigatório: é por ele que a spec prova a chamada");
|
|
process.exit(2);
|
|
}
|
|
|
|
/**
|
|
* Um mapa JSON de uma variável de ambiente. Ilegível derruba o dublê na subida:
|
|
* um mapa ignorado em silêncio faria a spec provar o padrão achando que provou
|
|
* a discordância.
|
|
*/
|
|
function lerMapa(variavel, oQue) {
|
|
const cru = process.env[variavel];
|
|
if (!cru) return {};
|
|
let lido;
|
|
try {
|
|
lido = JSON.parse(cru);
|
|
} catch {
|
|
lido = null;
|
|
}
|
|
if (lido === null || typeof lido !== "object" || Array.isArray(lido)) {
|
|
console.error(`[duble-jev] ${variavel} precisa ser um objeto JSON: ${oQue}`);
|
|
process.exit(2);
|
|
}
|
|
return lido;
|
|
}
|
|
const RESPOSTAS_FORCADAS = lerMapa("DUBLE_JEV_RESPOSTAS", "id da pergunta → resposta");
|
|
const RESPOSTAS_POR_TRECHO = lerMapa("DUBLE_JEV_RESPOSTAS_POR_TRECHO", "trecho da mensagem → { id da pergunta → resposta }");
|
|
|
|
/** @type {Array<{t: string, metodo: string, caminho: string, autorizado: boolean, corpo: unknown}>} */
|
|
const chamadas = [];
|
|
|
|
function gravar() {
|
|
writeFileSync(ARQUIVO, JSON.stringify(chamadas, null, 2));
|
|
}
|
|
gravar();
|
|
|
|
function responder(res, status, corpo) {
|
|
res.writeHead(status, { "content-type": "application/json" });
|
|
res.end(JSON.stringify(corpo));
|
|
}
|
|
|
|
async function lerCorpo(req) {
|
|
const pedacos = [];
|
|
for await (const p of req) pedacos.push(p);
|
|
const cru = Buffer.concat(pedacos).toString("utf8");
|
|
if (!cru) return undefined;
|
|
try {
|
|
return JSON.parse(cru);
|
|
} catch {
|
|
// Guardado como veio: formato novo do produto é o que o teste precisa ver.
|
|
return cru;
|
|
}
|
|
}
|
|
|
|
/** A resposta de uma pergunta, no formato do fornecedor. */
|
|
function responderPergunta(pergunta) {
|
|
if (pergunta?.type === "score") {
|
|
const niveis = Array.isArray(pergunta.criteria) ? pergunta.criteria : [];
|
|
const teto = Math.max(niveis.length - 1, 0);
|
|
const score = Math.min(Math.max(SCORE, 0), teto);
|
|
const legend = {};
|
|
const probabilities = {};
|
|
niveis.forEach((texto, i) => {
|
|
legend[String(i)] = texto;
|
|
probabilities[String(i)] = i === Math.round(score) ? 0.9 : 0.1 / Math.max(teto, 1);
|
|
});
|
|
return { type: "score", score, confidence: 0.9, legend, probabilities };
|
|
}
|
|
if (pergunta?.type === "choice") {
|
|
const opcoes = Object.keys(pergunta.criteria ?? {});
|
|
const probabilities = Object.fromEntries(opcoes.map((o, i) => [o, i === 0 ? 0.9 : 0.1]));
|
|
return { type: "choice", choice: opcoes[0] ?? "", confidence: 0.9, probabilities };
|
|
}
|
|
return { type: "noul", noul: 0.5 };
|
|
}
|
|
|
|
const servidor = http.createServer(async (req, res) => {
|
|
try {
|
|
const url = new URL(req.url ?? "/", `http://${HOST}:${PORTA}`);
|
|
const metodo = req.method ?? "GET";
|
|
|
|
if (url.pathname === "/__duble/saude") {
|
|
return responder(res, 200, { ok: true, porta: PORTA, arquivo: ARQUIVO, chamadas: chamadas.length });
|
|
}
|
|
|
|
const corpo = metodo === "POST" ? await lerCorpo(req) : undefined;
|
|
const bearer = /^Bearer\s+(\S+)$/i.exec(req.headers.authorization ?? "")?.[1] ?? null;
|
|
const autorizado = bearer === CHAVE;
|
|
chamadas.push({ t: new Date().toISOString(), metodo, caminho: url.pathname, autorizado, corpo });
|
|
gravar();
|
|
|
|
// A API real devolve 403 sem chave e 401 com chave errada — os dois viram
|
|
// "chave recusada" no produto, e o dublê não pode ser mais permissivo que ela.
|
|
if (bearer === null) return responder(res, 403, { detail: "Not authenticated" });
|
|
if (!autorizado) {
|
|
return responder(res, 401, {
|
|
detail: { error_type: "authentication_error", message: "Invalid API key" },
|
|
});
|
|
}
|
|
|
|
if (metodo === "GET" && url.pathname === "/v1/models") {
|
|
return responder(res, 200, {
|
|
models: [{ name: MODELO, description: "dublê do e2e", release_date: "2026-09-01" }],
|
|
});
|
|
}
|
|
|
|
if (metodo === "POST" && url.pathname === "/v1/systemone") {
|
|
const perguntas = corpo && typeof corpo === "object" ? (corpo.questions ?? {}) : {};
|
|
const estado = typeof corpo?.state === "string" ? corpo.state : JSON.stringify(corpo?.state ?? "");
|
|
const doTrecho = Object.entries(RESPOSTAS_POR_TRECHO).find(([trecho]) => estado.includes(trecho))?.[1] ?? {};
|
|
const answers = Object.fromEntries(
|
|
Object.entries(perguntas).map(([id, p]) => [
|
|
id,
|
|
Object.hasOwn(doTrecho, id)
|
|
? doTrecho[id]
|
|
: Object.hasOwn(RESPOSTAS_FORCADAS, id)
|
|
? RESPOSTAS_FORCADAS[id]
|
|
: responderPergunta(p),
|
|
]),
|
|
);
|
|
return responder(res, 200, {
|
|
model: MODELO,
|
|
answers,
|
|
usage: { input_tokens: Math.max(1, Math.ceil(estado.length / 4)), output_tokens: 1 },
|
|
});
|
|
}
|
|
|
|
return responder(res, 404, { error_type: "not_found", message: `rota desconhecida: ${url.pathname}` });
|
|
} catch (erro) {
|
|
// O detalhe fica no log deste processo (a spec imprime a saída do dublê);
|
|
// a resposta HTTP não carrega mensagem nem pilha de erro.
|
|
console.error("[duble-jev] falhou ao responder:", erro);
|
|
return responder(res, 500, { error_type: "erro_no_duble", message: "o dublê falhou — veja o log dele" });
|
|
}
|
|
});
|
|
|
|
servidor.listen(PORTA, HOST, () => {
|
|
console.info(`[duble-jev] ouvindo em http://${HOST}:${PORTA} — chamadas em ${ARQUIVO}`);
|
|
});
|
|
|
|
// Sai na hora, sem `servidor.close()`: ele espera as conexões keep-alive do
|
|
// servidor Next fecharem, e o dublê seguiria preso à porta depois da spec. Não
|
|
// há o que perder — cada chamada já foi gravada no arquivo, de forma síncrona.
|
|
for (const sinal of ["SIGINT", "SIGTERM"]) {
|
|
process.on(sinal, () => process.exit(0));
|
|
}
|