@meuecommerce/frete-adapter-node 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/correiosHttpClient.js +31 -0
- package/dist/cjs/correiosLabelHttpClient.js +23 -1
- package/dist/cjs/inMemoryDimensionMemory.js +59 -0
- package/dist/cjs/index.js +11 -1
- package/dist/cjs/openAIDimensionEstimator.js +53 -63
- package/dist/cjs/openAIResponsesAgentRunner.js +119 -0
- package/dist/cjs/pgDimensionMemory.js +160 -0
- package/dist/correiosHttpClient.js +32 -1
- package/dist/correiosLabelHttpClient.js +23 -1
- package/dist/inMemoryDimensionMemory.d.ts +10 -0
- package/dist/inMemoryDimensionMemory.js +55 -0
- package/dist/index.d.ts +8 -1
- package/dist/index.js +5 -1
- package/dist/openAIDimensionEstimator.d.ts +24 -4
- package/dist/openAIDimensionEstimator.js +52 -63
- package/dist/openAIResponsesAgentRunner.d.ts +50 -0
- package/dist/openAIResponsesAgentRunner.js +114 -0
- package/dist/pgDimensionMemory.d.ts +62 -0
- package/dist/pgDimensionMemory.js +157 -0
- package/package.json +6 -3
|
@@ -115,5 +115,36 @@ function createCorreiosHttpClient(options = {}) {
|
|
|
115
115
|
getTime(payload, token) {
|
|
116
116
|
return estimate("/prazo/v1/nacional", payload, token, "correios.getTime");
|
|
117
117
|
},
|
|
118
|
+
async track(codes, token) {
|
|
119
|
+
if (!codes.length)
|
|
120
|
+
return [];
|
|
121
|
+
// SRO Rastro takes the codes as repeated `codigosObjetos` query params and
|
|
122
|
+
// `resultado=T` for the full event history (vs. `U` for the latest only).
|
|
123
|
+
// The language MUST go in the `Accept-Language` header (pt-BR/en/es-ES) —
|
|
124
|
+
// an `idioma` query param is rejected with SRO-018.
|
|
125
|
+
const params = new URLSearchParams();
|
|
126
|
+
for (const code of codes)
|
|
127
|
+
params.append("codigosObjetos", code);
|
|
128
|
+
params.append("resultado", "T");
|
|
129
|
+
const url = `${baseUrl}${frete_1.correiosSroPath}?${params.toString()}`;
|
|
130
|
+
const response = await fetchWithRetry(url, {
|
|
131
|
+
method: "GET",
|
|
132
|
+
headers: {
|
|
133
|
+
Accept: "application/json",
|
|
134
|
+
"Accept-Language": "pt-BR",
|
|
135
|
+
Authorization: `Bearer ${token}`,
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
if (response.status === 401) {
|
|
139
|
+
throw { status: 401, message: "unauthorized" };
|
|
140
|
+
}
|
|
141
|
+
if (response.status !== 200) {
|
|
142
|
+
const json = await response.json().catch(() => ({}));
|
|
143
|
+
logger.warn(`[correios.track] invalid response. status=${response.status} body=${JSON.stringify(json)}`);
|
|
144
|
+
return [];
|
|
145
|
+
}
|
|
146
|
+
const body = (await response.json());
|
|
147
|
+
return Array.isArray(body?.objetos) ? body.objetos : [];
|
|
148
|
+
},
|
|
118
149
|
};
|
|
119
150
|
}
|
|
@@ -20,6 +20,16 @@ exports.createCorreiosLabelHttpClient = createCorreiosLabelHttpClient;
|
|
|
20
20
|
*/
|
|
21
21
|
const frete_1 = require("@meuecommerce/frete");
|
|
22
22
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
23
|
+
/**
|
|
24
|
+
* Some Correios label messages are transient: the pré-postagem is fine and the
|
|
25
|
+
* PDF is still being produced, so a later poll succeeds (e.g. PPN-291 "O rótulo
|
|
26
|
+
* ainda não foi gerado. Por favor, consulte novamente."). Distinguish those from
|
|
27
|
+
* terminal ones — notably PPN-288 "status Pendente" — which won't clear by
|
|
28
|
+
* polling and must be surfaced. Retryable → keep polling; terminal → `message`.
|
|
29
|
+
*/
|
|
30
|
+
function isRetryableLabelMessage(msg) {
|
|
31
|
+
return /consulte novamente|ainda n[ãa]o foi gerado|n[ãa]o (foi )?gerado|PPN-291/i.test(msg);
|
|
32
|
+
}
|
|
23
33
|
function createCorreiosLabelHttpClient(options = {}) {
|
|
24
34
|
const { fetch = globalThis.fetch, baseUrl = frete_1.correiosApiUrl, timeoutMs = 20000, retries = 2, retryDelayMs = 500, tipoRotulo = "P", downloadPath = "/prepostagem/v1/prepostagens/rotulo/download/assincrono/{idRecibo}", logger = console, } = options;
|
|
25
35
|
if (typeof fetch !== "function") {
|
|
@@ -69,6 +79,12 @@ function createCorreiosLabelHttpClient(options = {}) {
|
|
|
69
79
|
}
|
|
70
80
|
return json;
|
|
71
81
|
},
|
|
82
|
+
async getPrepostagemStatus(id, token) {
|
|
83
|
+
const response = await fetchWithRetry(`${baseUrl}/prepostagem/v2/prepostagens?id=${encodeURIComponent(id)}&page=0&size=1`, { method: "GET", headers: authHeaders(token) });
|
|
84
|
+
const json = (await response.json().catch(() => ({})));
|
|
85
|
+
const item = Array.isArray(json.itens) ? json.itens[0] : undefined;
|
|
86
|
+
return item ?? null;
|
|
87
|
+
},
|
|
72
88
|
async requestLabelPdf(ids, token) {
|
|
73
89
|
const response = await fetchWithRetry(`${baseUrl}/prepostagem/v1/prepostagens/rotulo/assincrono/pdf`, {
|
|
74
90
|
method: "POST",
|
|
@@ -95,6 +111,9 @@ function createCorreiosLabelHttpClient(options = {}) {
|
|
|
95
111
|
return { ready: false };
|
|
96
112
|
if (response.status !== 200) {
|
|
97
113
|
const body = await response.text().catch(() => "");
|
|
114
|
+
// A retryable business message can arrive with a non-2xx status too.
|
|
115
|
+
if (isRetryableLabelMessage(body))
|
|
116
|
+
return { ready: false };
|
|
98
117
|
return { ready: false, message: `http_${response.status}: ${body.slice(0, 200)}` };
|
|
99
118
|
}
|
|
100
119
|
// Correios may return the PDF as raw bytes (application/pdf) or wrapped in
|
|
@@ -107,8 +126,11 @@ function createCorreiosLabelHttpClient(options = {}) {
|
|
|
107
126
|
const pdfBase64 = (json.dados ?? json.pdf ?? json.base64 ?? json.arquivo);
|
|
108
127
|
if (pdfBase64)
|
|
109
128
|
return { ready: true, pdfBase64 };
|
|
110
|
-
// 200 without a PDF carries a business message (e.g. "status Pendente"
|
|
129
|
+
// 200 without a PDF carries a business message (e.g. "status Pendente" or
|
|
130
|
+
// "ainda não foi gerado, consulte novamente"). Retryable ones → keep polling.
|
|
111
131
|
const message = typeof json.mensagem === "string" ? json.mensagem : undefined;
|
|
132
|
+
if (message && isRetryableLabelMessage(message))
|
|
133
|
+
return { ready: false };
|
|
112
134
|
return message ? { ready: false, message } : { ready: false };
|
|
113
135
|
},
|
|
114
136
|
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.InMemoryDimensionMemory = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* In-memory `DimensionMemory` — local dev, the MCP server's local mode, and
|
|
6
|
+
* tests. Production swaps it for the Postgres-backed one; the core does not
|
|
7
|
+
* change.
|
|
8
|
+
*
|
|
9
|
+
* It enforces the layer rule rather than just storing what it is given: an
|
|
10
|
+
* agent guess (`L3`) must never overwrite what the merchant declared (`L0`).
|
|
11
|
+
* Putting that here as well as in the Postgres implementation is deliberate —
|
|
12
|
+
* it is a property of the memory, not of one backend, and the tests that pin it
|
|
13
|
+
* run against this one.
|
|
14
|
+
*/
|
|
15
|
+
const frete_1 = require("@meuecommerce/frete");
|
|
16
|
+
class InMemoryDimensionMemory {
|
|
17
|
+
records = new Map();
|
|
18
|
+
constructor(seed = []) {
|
|
19
|
+
// O seed passa pela mesma regra de camada que uma escrita normal.
|
|
20
|
+
void this.set(seed);
|
|
21
|
+
}
|
|
22
|
+
/** How many products the memory holds. For tests and local inspection. */
|
|
23
|
+
get size() {
|
|
24
|
+
return this.records.size;
|
|
25
|
+
}
|
|
26
|
+
async get(identities) {
|
|
27
|
+
const found = new Map();
|
|
28
|
+
for (const identity of identities) {
|
|
29
|
+
const key = (0, frete_1.productKey)(identity);
|
|
30
|
+
if (!key)
|
|
31
|
+
continue;
|
|
32
|
+
const record = this.records.get(key.value);
|
|
33
|
+
if (record)
|
|
34
|
+
found.set(key.value, record);
|
|
35
|
+
}
|
|
36
|
+
return found;
|
|
37
|
+
}
|
|
38
|
+
async set(entries) {
|
|
39
|
+
for (const { identity, record } of entries) {
|
|
40
|
+
const key = (0, frete_1.productKey)(identity);
|
|
41
|
+
// Sem chave derivável não há o que gravar: inventar uma faria produtos
|
|
42
|
+
// sem relação compartilharem entrada.
|
|
43
|
+
if (!key)
|
|
44
|
+
continue;
|
|
45
|
+
const existing = this.records.get(key.value);
|
|
46
|
+
if (existing && !(0, frete_1.canOverwrite)(existing.source, record.source))
|
|
47
|
+
continue;
|
|
48
|
+
this.records.set(key.value, record);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
async invalidate(identities) {
|
|
52
|
+
for (const identity of identities) {
|
|
53
|
+
const key = (0, frete_1.productKey)(identity);
|
|
54
|
+
if (key)
|
|
55
|
+
this.records.delete(key.value);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
exports.InMemoryDimensionMemory = InMemoryDimensionMemory;
|
package/dist/cjs/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.createOpenAIBoxDistributor = exports.createOpenAIDimensionEstimator = exports.EnvSecretStore = exports.InMemorySettingsStore = exports.createCorreiosHttpClient = void 0;
|
|
3
|
+
exports.createOpenAIBoxDistributor = exports.PgDimensionMemory = exports.DIMENSION_MEMORY_SCHEMA_SQL = exports.createOpenAIResponsesAgentRunner = exports.createOpenAIDimensionEstimator = exports.DIMENSION_PROMPT_VERSION = exports.EnvSecretStore = exports.InMemoryDimensionMemory = exports.InMemorySettingsStore = exports.createCorreiosLabelHttpClient = exports.createCorreiosHttpClient = void 0;
|
|
4
4
|
/**
|
|
5
5
|
* @meuecommerce/frete-adapter-node — Node implementations of @meuecommerce/frete ports.
|
|
6
6
|
*
|
|
@@ -9,11 +9,21 @@ exports.createOpenAIBoxDistributor = exports.createOpenAIDimensionEstimator = ex
|
|
|
9
9
|
*/
|
|
10
10
|
var correiosHttpClient_js_1 = require("./correiosHttpClient.js");
|
|
11
11
|
Object.defineProperty(exports, "createCorreiosHttpClient", { enumerable: true, get: function () { return correiosHttpClient_js_1.createCorreiosHttpClient; } });
|
|
12
|
+
var correiosLabelHttpClient_js_1 = require("./correiosLabelHttpClient.js");
|
|
13
|
+
Object.defineProperty(exports, "createCorreiosLabelHttpClient", { enumerable: true, get: function () { return correiosLabelHttpClient_js_1.createCorreiosLabelHttpClient; } });
|
|
12
14
|
var inMemorySettingsStore_js_1 = require("./inMemorySettingsStore.js");
|
|
13
15
|
Object.defineProperty(exports, "InMemorySettingsStore", { enumerable: true, get: function () { return inMemorySettingsStore_js_1.InMemorySettingsStore; } });
|
|
16
|
+
var inMemoryDimensionMemory_js_1 = require("./inMemoryDimensionMemory.js");
|
|
17
|
+
Object.defineProperty(exports, "InMemoryDimensionMemory", { enumerable: true, get: function () { return inMemoryDimensionMemory_js_1.InMemoryDimensionMemory; } });
|
|
14
18
|
var envSecretStore_js_1 = require("./envSecretStore.js");
|
|
15
19
|
Object.defineProperty(exports, "EnvSecretStore", { enumerable: true, get: function () { return envSecretStore_js_1.EnvSecretStore; } });
|
|
16
20
|
var openAIDimensionEstimator_js_1 = require("./openAIDimensionEstimator.js");
|
|
21
|
+
Object.defineProperty(exports, "DIMENSION_PROMPT_VERSION", { enumerable: true, get: function () { return openAIDimensionEstimator_js_1.DIMENSION_PROMPT_VERSION; } });
|
|
17
22
|
Object.defineProperty(exports, "createOpenAIDimensionEstimator", { enumerable: true, get: function () { return openAIDimensionEstimator_js_1.createOpenAIDimensionEstimator; } });
|
|
23
|
+
var openAIResponsesAgentRunner_js_1 = require("./openAIResponsesAgentRunner.js");
|
|
24
|
+
Object.defineProperty(exports, "createOpenAIResponsesAgentRunner", { enumerable: true, get: function () { return openAIResponsesAgentRunner_js_1.createOpenAIResponsesAgentRunner; } });
|
|
25
|
+
var pgDimensionMemory_js_1 = require("./pgDimensionMemory.js");
|
|
26
|
+
Object.defineProperty(exports, "DIMENSION_MEMORY_SCHEMA_SQL", { enumerable: true, get: function () { return pgDimensionMemory_js_1.DIMENSION_MEMORY_SCHEMA_SQL; } });
|
|
27
|
+
Object.defineProperty(exports, "PgDimensionMemory", { enumerable: true, get: function () { return pgDimensionMemory_js_1.PgDimensionMemory; } });
|
|
18
28
|
var openAIBoxDistributor_js_1 = require("./openAIBoxDistributor.js");
|
|
19
29
|
Object.defineProperty(exports, "createOpenAIBoxDistributor", { enumerable: true, get: function () { return openAIBoxDistributor_js_1.createOpenAIBoxDistributor; } });
|
|
@@ -1,31 +1,42 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DIMENSION_PROMPT_VERSION = void 0;
|
|
3
4
|
exports.createOpenAIDimensionEstimator = createOpenAIDimensionEstimator;
|
|
4
|
-
const
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
5
|
+
const openAIResponsesAgentRunner_js_1 = require("./openAIResponsesAgentRunner.js");
|
|
6
|
+
/**
|
|
7
|
+
* Bump whenever the prompt or the schema changes. It is written next to every
|
|
8
|
+
* `L3` record so a prompt change can invalidate stale estimates in background
|
|
9
|
+
* instead of at checkout.
|
|
10
|
+
*/
|
|
11
|
+
exports.DIMENSION_PROMPT_VERSION = "2026-09-04.responses.v1";
|
|
12
|
+
/**
|
|
13
|
+
* Strict JSON schema for the response.
|
|
14
|
+
*
|
|
15
|
+
* `strict: true` has two requirements that are easy to miss and reject the
|
|
16
|
+
* whole request when absent: every object needs `additionalProperties: false`,
|
|
17
|
+
* and `required` has to list every property — optional fields are not allowed.
|
|
18
|
+
*/
|
|
19
|
+
const DIMENSIONS_SCHEMA = {
|
|
20
|
+
type: "object",
|
|
21
|
+
properties: {
|
|
22
|
+
products: {
|
|
23
|
+
type: "array",
|
|
24
|
+
description: "Uma entrada por produto pedido, na mesma ordem.",
|
|
25
|
+
items: {
|
|
26
|
+
type: "object",
|
|
27
|
+
properties: {
|
|
28
|
+
name: { type: "string", description: "O nome EXATO recebido, sem reescrever" },
|
|
29
|
+
length: { type: "number", description: "Comprimento em cm" },
|
|
30
|
+
width: { type: "number", description: "Largura em cm" },
|
|
31
|
+
height: { type: "number", description: "Altura em cm" },
|
|
24
32
|
},
|
|
33
|
+
required: ["name", "length", "width", "height"],
|
|
34
|
+
additionalProperties: false,
|
|
25
35
|
},
|
|
26
|
-
required: ["products"],
|
|
27
36
|
},
|
|
28
37
|
},
|
|
38
|
+
required: ["products"],
|
|
39
|
+
additionalProperties: false,
|
|
29
40
|
};
|
|
30
41
|
function largestBox(boxes) {
|
|
31
42
|
return boxes.reduce((largest, current) => current.width * current.height * current.length > largest.width * largest.height * largest.length ? current : largest);
|
|
@@ -43,8 +54,7 @@ function createPrompt(products, boxes, samples) {
|
|
|
43
54
|
`🚨 LIMITES FÍSICOS OBRIGATÓRIOS:\n- Largura máxima: ${maxWidth}cm\n- Altura máxima: ${maxHeight}cm\n- Comprimento máximo: ${maxLength}cm\n(Maior caixa: ${box.name ?? "—"})\n\n` +
|
|
44
55
|
`📦 PRODUTOS PARA ESTIMAR:\n${products.map((p, i) => `${i + 1}. ${p.name} - ${p.quantity}x (${p.weight}kg cada)`).join("\n")}\n` +
|
|
45
56
|
samplesSection +
|
|
46
|
-
`\n⚠️ REGRAS: respeite os limites; preserve os nomes EXATOS; retorne length×width×height em cm
|
|
47
|
-
`Responda usando a função 'estimate_dimensions'.`);
|
|
57
|
+
`\n⚠️ REGRAS: respeite os limites; preserve os nomes EXATOS; retorne length×width×height em cm.`);
|
|
48
58
|
}
|
|
49
59
|
/** Apply the largest-box physical limits + a minimum-volume floor to AI output. */
|
|
50
60
|
function validateAndOptimize(aiResult, products, boxes) {
|
|
@@ -79,52 +89,32 @@ function validateAndOptimize(aiResult, products, boxes) {
|
|
|
79
89
|
return { products: optimized };
|
|
80
90
|
}
|
|
81
91
|
function createOpenAIDimensionEstimator(options) {
|
|
82
|
-
const { apiKey, fetch
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
92
|
+
const { apiKey, fetch, model = "gpt-4o-mini", temperature = 0.1, apiUrl, timeoutMs, runner } = options;
|
|
93
|
+
const agent = runner ??
|
|
94
|
+
(0, openAIResponsesAgentRunner_js_1.createOpenAIResponsesAgentRunner)({
|
|
95
|
+
apiKey: apiKey ?? "",
|
|
96
|
+
...(fetch ? { fetch } : {}),
|
|
97
|
+
model,
|
|
98
|
+
...(apiUrl ? { apiUrl } : {}),
|
|
99
|
+
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
|
100
|
+
});
|
|
87
101
|
return {
|
|
88
102
|
async estimate(products, boxes, productSamples = []) {
|
|
89
103
|
if (!boxes.length)
|
|
90
104
|
throw new Error("estimate: no boxes provided");
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
},
|
|
98
|
-
{ role: "user", content: createPrompt(products, boxes, productSamples) },
|
|
99
|
-
],
|
|
100
|
-
tools: [ESTIMATE_DIMENSIONS_TOOL],
|
|
101
|
-
tool_choice: { type: "function", function: { name: "estimate_dimensions" } },
|
|
105
|
+
const result = await agent.run({
|
|
106
|
+
task: "estimate-dimensions",
|
|
107
|
+
promptVersion: exports.DIMENSION_PROMPT_VERSION,
|
|
108
|
+
system: "Você é um especialista em dimensões de produtos. Use os exemplos personalizados como prioridade máxima quando o nome bater.",
|
|
109
|
+
user: createPrompt(products, boxes, productSamples),
|
|
110
|
+
schema: { name: "product_dimensions", schema: DIMENSIONS_SCHEMA },
|
|
102
111
|
temperature,
|
|
103
|
-
};
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
let response;
|
|
107
|
-
try {
|
|
108
|
-
response = await fetch(apiUrl, {
|
|
109
|
-
method: "POST",
|
|
110
|
-
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
|
|
111
|
-
body: JSON.stringify(body),
|
|
112
|
-
signal: controller.signal,
|
|
113
|
-
});
|
|
112
|
+
});
|
|
113
|
+
if (!Array.isArray(result.output?.products)) {
|
|
114
|
+
throw new Error("Resposta da IA sem a lista de produtos");
|
|
114
115
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
}
|
|
118
|
-
if (!response.ok)
|
|
119
|
-
throw new Error(`OpenAI API Error: ${response.status} ${response.statusText}`);
|
|
120
|
-
const json = (await response.json());
|
|
121
|
-
if (json.error)
|
|
122
|
-
throw new Error(`OpenAI Error: ${json.error.message}`);
|
|
123
|
-
const args = json.choices?.[0]?.message?.tool_calls?.[0]?.function?.arguments;
|
|
124
|
-
if (!args)
|
|
125
|
-
throw new Error("IA não conseguiu estimar dimensões dos produtos");
|
|
126
|
-
const parsed = JSON.parse(args);
|
|
127
|
-
return validateAndOptimize(parsed, products, boxes);
|
|
116
|
+
const validated = validateAndOptimize(result.output, products, boxes);
|
|
117
|
+
return result.usage ? { ...validated, usage: result.usage } : validated;
|
|
128
118
|
},
|
|
129
119
|
};
|
|
130
120
|
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.extractOutputText = extractOutputText;
|
|
4
|
+
exports.findRefusal = findRefusal;
|
|
5
|
+
exports.createOpenAIResponsesAgentRunner = createOpenAIResponsesAgentRunner;
|
|
6
|
+
/** The model's text, accepting both shapes the Responses API delivers it in. */
|
|
7
|
+
function extractOutputText(json) {
|
|
8
|
+
if (typeof json.output_text === "string" && json.output_text.trim())
|
|
9
|
+
return json.output_text;
|
|
10
|
+
const parts = [];
|
|
11
|
+
for (const item of json.output ?? []) {
|
|
12
|
+
for (const content of item.content ?? []) {
|
|
13
|
+
if (typeof content.text === "string")
|
|
14
|
+
parts.push(content.text);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return parts.join("").trim();
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* A refusal is a *successful* response in which the model declined to fill the
|
|
21
|
+
* schema. Without this check it would read as empty text and be reported as
|
|
22
|
+
* "the model could not answer", hiding the real reason.
|
|
23
|
+
*/
|
|
24
|
+
function findRefusal(json) {
|
|
25
|
+
for (const item of json.output ?? []) {
|
|
26
|
+
for (const content of item.content ?? []) {
|
|
27
|
+
if (typeof content.refusal === "string" && content.refusal)
|
|
28
|
+
return content.refusal;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
function createOpenAIResponsesAgentRunner(options) {
|
|
34
|
+
const { apiKey, fetch = globalThis.fetch, model: defaultModel = "gpt-4o-mini", apiUrl = "https://api.openai.com/v1/responses", timeoutMs = 20000, } = options;
|
|
35
|
+
if (!apiKey)
|
|
36
|
+
throw new Error("createOpenAIResponsesAgentRunner: apiKey is required");
|
|
37
|
+
if (typeof fetch !== "function")
|
|
38
|
+
throw new Error("createOpenAIResponsesAgentRunner: no fetch available");
|
|
39
|
+
return {
|
|
40
|
+
async run(request) {
|
|
41
|
+
const input = [];
|
|
42
|
+
if (request.system)
|
|
43
|
+
input.push({ role: "system", content: request.system });
|
|
44
|
+
// `images` ainda não é usado pelo estimador; quando a fase 2 ligar, o
|
|
45
|
+
// conteúdo do turno do usuário vira lista em vez de string.
|
|
46
|
+
if (request.images?.length) {
|
|
47
|
+
input.push({
|
|
48
|
+
role: "user",
|
|
49
|
+
content: [
|
|
50
|
+
{ type: "input_text", text: request.user },
|
|
51
|
+
...request.images.map((url) => ({ type: "input_image", image_url: url })),
|
|
52
|
+
],
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
input.push({ role: "user", content: request.user });
|
|
57
|
+
}
|
|
58
|
+
const body = {
|
|
59
|
+
model: defaultModel,
|
|
60
|
+
input,
|
|
61
|
+
text: {
|
|
62
|
+
format: {
|
|
63
|
+
type: "json_schema",
|
|
64
|
+
name: request.schema.name,
|
|
65
|
+
strict: true,
|
|
66
|
+
schema: request.schema.schema,
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
if (request.temperature !== undefined)
|
|
71
|
+
body.temperature = request.temperature;
|
|
72
|
+
if (request.maxOutputTokens !== undefined)
|
|
73
|
+
body.max_output_tokens = request.maxOutputTokens;
|
|
74
|
+
const controller = new AbortController();
|
|
75
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
76
|
+
let response;
|
|
77
|
+
try {
|
|
78
|
+
response = await fetch(apiUrl, {
|
|
79
|
+
method: "POST",
|
|
80
|
+
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
|
|
81
|
+
body: JSON.stringify(body),
|
|
82
|
+
signal: controller.signal,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
clearTimeout(timer);
|
|
87
|
+
}
|
|
88
|
+
if (!response.ok)
|
|
89
|
+
throw new Error(`OpenAI API Error: ${response.status} ${response.statusText}`);
|
|
90
|
+
const json = (await response.json());
|
|
91
|
+
if (json.error)
|
|
92
|
+
throw new Error(`OpenAI Error: ${json.error.message}`);
|
|
93
|
+
const refusal = findRefusal(json);
|
|
94
|
+
if (refusal)
|
|
95
|
+
throw new Error(`OpenAI recusou a resposta de ${request.task}: ${refusal}`);
|
|
96
|
+
const text = extractOutputText(json);
|
|
97
|
+
if (!text)
|
|
98
|
+
throw new Error(`Resposta vazia da IA para ${request.task}`);
|
|
99
|
+
let output;
|
|
100
|
+
try {
|
|
101
|
+
output = JSON.parse(text);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
throw new Error(`Resposta da IA para ${request.task} não é JSON válido`);
|
|
105
|
+
}
|
|
106
|
+
const model = json.model ?? defaultModel;
|
|
107
|
+
const usage = json.usage
|
|
108
|
+
? {
|
|
109
|
+
model,
|
|
110
|
+
...(typeof json.usage.input_tokens === "number" ? { inputTokens: json.usage.input_tokens } : {}),
|
|
111
|
+
...(typeof json.usage.output_tokens === "number" ? { outputTokens: json.usage.output_tokens } : {}),
|
|
112
|
+
}
|
|
113
|
+
: undefined;
|
|
114
|
+
// Sempre 1 nesta fase: a repescagem por falha de validação é o próximo
|
|
115
|
+
// passo, e é ela que vai fazer este número variar.
|
|
116
|
+
return usage ? { output, usage, model, attempts: 1 } : { output, model, attempts: 1 };
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.canOverwrite = exports.PgDimensionMemory = exports.DIMENSION_MEMORY_SCHEMA_SQL = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* Postgres-backed `DimensionMemory` — the production counterpart of
|
|
6
|
+
* {@link InMemoryDimensionMemory}.
|
|
7
|
+
*
|
|
8
|
+
* Two rules from the port are enforced here in SQL rather than in application
|
|
9
|
+
* code, because the memory is written from more than one place (the quote path,
|
|
10
|
+
* the label feedback, the dashboard) and a check that lives in one caller is a
|
|
11
|
+
* check that the next caller forgets:
|
|
12
|
+
*
|
|
13
|
+
* - **A weaker layer never overwrites a stronger one.** The upsert carries the
|
|
14
|
+
* layer's rank and only updates when the incoming rank is at least as strong,
|
|
15
|
+
* so an agent estimate racing with a merchant's correction loses regardless of
|
|
16
|
+
* which arrives last.
|
|
17
|
+
* - **One round trip per cart.** `get` takes every product at once, because a
|
|
18
|
+
* query per line item is exactly what makes a hot path slow.
|
|
19
|
+
*
|
|
20
|
+
* The client is the structural {@link SqlClient} rather than a `pg` import, so
|
|
21
|
+
* this package gains no runtime dependency and the tests can run real SQL
|
|
22
|
+
* against pg-mem with no network.
|
|
23
|
+
*
|
|
24
|
+
* Lookups expand to `IN ($1, $2, …)` instead of the more idiomatic
|
|
25
|
+
* `= ANY($1)`. Real Postgres accepts both, but pg-mem silently returns zero
|
|
26
|
+
* rows for the array form, and running the tests against real SQL is worth more
|
|
27
|
+
* than the tidier query — a cart is a handful of line items, so the parameter
|
|
28
|
+
* count is not a concern. Revisit if this is ever used for bulk reads.
|
|
29
|
+
*/
|
|
30
|
+
const frete_1 = require("@meuecommerce/frete");
|
|
31
|
+
Object.defineProperty(exports, "canOverwrite", { enumerable: true, get: function () { return frete_1.canOverwrite; } });
|
|
32
|
+
exports.DIMENSION_MEMORY_SCHEMA_SQL = `
|
|
33
|
+
CREATE TABLE IF NOT EXISTS dimension_memory (
|
|
34
|
+
key TEXT PRIMARY KEY,
|
|
35
|
+
instance_id TEXT NOT NULL,
|
|
36
|
+
length_cm DOUBLE PRECISION NOT NULL,
|
|
37
|
+
width_cm DOUBLE PRECISION NOT NULL,
|
|
38
|
+
height_cm DOUBLE PRECISION NOT NULL,
|
|
39
|
+
attrs JSONB,
|
|
40
|
+
confidence DOUBLE PRECISION NOT NULL,
|
|
41
|
+
source TEXT NOT NULL,
|
|
42
|
+
layer_rank INTEGER NOT NULL,
|
|
43
|
+
prompt_version TEXT,
|
|
44
|
+
model TEXT,
|
|
45
|
+
updated_at TIMESTAMPTZ NOT NULL
|
|
46
|
+
);
|
|
47
|
+
CREATE INDEX IF NOT EXISTS dimension_memory_instance_idx ON dimension_memory (instance_id);
|
|
48
|
+
`;
|
|
49
|
+
/** Lower rank = stronger layer, matching `MEMORY_LAYER_ORDER`. */
|
|
50
|
+
function layerRank(layer) {
|
|
51
|
+
const rank = frete_1.MEMORY_LAYER_ORDER.indexOf(layer);
|
|
52
|
+
// Uma camada desconhecida é tratada como a mais fraca possível, em vez de
|
|
53
|
+
// virar -1 e passar por cima de tudo.
|
|
54
|
+
return rank === -1 ? frete_1.MEMORY_LAYER_ORDER.length : rank;
|
|
55
|
+
}
|
|
56
|
+
/** `$1, $2, …` para `count` parâmetros. */
|
|
57
|
+
function placeholders(count) {
|
|
58
|
+
return Array.from({ length: count }, (_, i) => `$${i + 1}`).join(", ");
|
|
59
|
+
}
|
|
60
|
+
/** `DOUBLE PRECISION` chega como string em alguns drivers. */
|
|
61
|
+
function num(value) {
|
|
62
|
+
return typeof value === "number" ? value : Number(value);
|
|
63
|
+
}
|
|
64
|
+
function toRecord(row) {
|
|
65
|
+
const updatedAt = row.updated_at instanceof Date ? row.updated_at.toISOString() : String(row.updated_at);
|
|
66
|
+
return {
|
|
67
|
+
dims: { length: num(row.length_cm), width: num(row.width_cm), height: num(row.height_cm) },
|
|
68
|
+
...(row.attrs ? { attrs: row.attrs } : {}),
|
|
69
|
+
confidence: num(row.confidence),
|
|
70
|
+
source: row.source,
|
|
71
|
+
...(row.prompt_version ? { promptVersion: row.prompt_version } : {}),
|
|
72
|
+
...(row.model ? { model: row.model } : {}),
|
|
73
|
+
updatedAt,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
class PgDimensionMemory {
|
|
77
|
+
sql;
|
|
78
|
+
constructor(options) {
|
|
79
|
+
this.sql = options.sql;
|
|
80
|
+
}
|
|
81
|
+
/** Idempotent — safe to call on every boot. */
|
|
82
|
+
async migrate() {
|
|
83
|
+
await this.sql.query(exports.DIMENSION_MEMORY_SCHEMA_SQL);
|
|
84
|
+
}
|
|
85
|
+
async get(identities) {
|
|
86
|
+
const keys = identities.flatMap((identity) => {
|
|
87
|
+
const key = (0, frete_1.productKey)(identity);
|
|
88
|
+
return key ? [key.value] : [];
|
|
89
|
+
});
|
|
90
|
+
const found = new Map();
|
|
91
|
+
if (keys.length === 0)
|
|
92
|
+
return found;
|
|
93
|
+
const { rows } = await this.sql.query(`SELECT key, length_cm, width_cm, height_cm, attrs, confidence, source, prompt_version, model, updated_at
|
|
94
|
+
FROM dimension_memory
|
|
95
|
+
WHERE key IN (${placeholders(keys.length)})`, keys);
|
|
96
|
+
for (const row of rows)
|
|
97
|
+
found.set(row.key, toRecord(row));
|
|
98
|
+
return found;
|
|
99
|
+
}
|
|
100
|
+
async set(entries) {
|
|
101
|
+
const values = [];
|
|
102
|
+
const tuples = [];
|
|
103
|
+
for (const { identity, record } of entries) {
|
|
104
|
+
const key = (0, frete_1.productKey)(identity);
|
|
105
|
+
// Sem chave derivável não há o que gravar: inventar uma faria produtos
|
|
106
|
+
// sem relação compartilharem entrada.
|
|
107
|
+
if (!key)
|
|
108
|
+
continue;
|
|
109
|
+
const base = values.length;
|
|
110
|
+
values.push(key.value, identity.instanceId, record.dims.length, record.dims.width, record.dims.height, record.attrs ? JSON.stringify(record.attrs) : null, record.confidence, record.source, layerRank(record.source), record.promptVersion ?? null, record.model ?? null, record.updatedAt);
|
|
111
|
+
const p = (offset) => `$${base + offset}`;
|
|
112
|
+
tuples.push(`(${p(1)}, ${p(2)}, ${p(3)}, ${p(4)}, ${p(5)}, ${p(6)}::jsonb, ${p(7)}, ${p(8)}, ${p(9)}, ${p(10)}, ${p(11)}, ${p(12)}::timestamptz)`);
|
|
113
|
+
}
|
|
114
|
+
if (tuples.length === 0)
|
|
115
|
+
return;
|
|
116
|
+
await this.sql.query(`INSERT INTO dimension_memory
|
|
117
|
+
(key, instance_id, length_cm, width_cm, height_cm, attrs, confidence, source, layer_rank, prompt_version, model, updated_at)
|
|
118
|
+
VALUES ${tuples.join(", ")}
|
|
119
|
+
ON CONFLICT (key) DO UPDATE SET
|
|
120
|
+
length_cm = EXCLUDED.length_cm,
|
|
121
|
+
width_cm = EXCLUDED.width_cm,
|
|
122
|
+
height_cm = EXCLUDED.height_cm,
|
|
123
|
+
attrs = EXCLUDED.attrs,
|
|
124
|
+
confidence = EXCLUDED.confidence,
|
|
125
|
+
source = EXCLUDED.source,
|
|
126
|
+
layer_rank = EXCLUDED.layer_rank,
|
|
127
|
+
prompt_version = EXCLUDED.prompt_version,
|
|
128
|
+
model = EXCLUDED.model,
|
|
129
|
+
updated_at = EXCLUDED.updated_at
|
|
130
|
+
WHERE EXCLUDED.layer_rank <= dimension_memory.layer_rank`, values);
|
|
131
|
+
}
|
|
132
|
+
async invalidate(identities) {
|
|
133
|
+
const keys = identities.flatMap((identity) => {
|
|
134
|
+
const key = (0, frete_1.productKey)(identity);
|
|
135
|
+
return key ? [key.value] : [];
|
|
136
|
+
});
|
|
137
|
+
if (keys.length === 0)
|
|
138
|
+
return;
|
|
139
|
+
await this.sql.query(`DELETE FROM dimension_memory WHERE key IN (${placeholders(keys.length)})`, keys);
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Drop agent estimates produced by an older prompt.
|
|
143
|
+
*
|
|
144
|
+
* Only touches `L3`: what the merchant declared and what a real label proved
|
|
145
|
+
* do not go stale because we changed a prompt. Meant to run in background
|
|
146
|
+
* after a prompt bump — never on the quote path.
|
|
147
|
+
*/
|
|
148
|
+
async invalidateStaleEstimates(currentPromptVersion) {
|
|
149
|
+
const { rows } = await this.sql.query(`DELETE FROM dimension_memory
|
|
150
|
+
WHERE source = 'L3' AND (prompt_version IS NULL OR prompt_version <> $1)
|
|
151
|
+
RETURNING key`, [currentPromptVersion]);
|
|
152
|
+
return rows.length;
|
|
153
|
+
}
|
|
154
|
+
/** Forget everything for one merchant — uninstall, or a support reset. */
|
|
155
|
+
async forgetInstance(instanceId) {
|
|
156
|
+
const { rows } = await this.sql.query(`DELETE FROM dimension_memory WHERE instance_id = $1 RETURNING key`, [instanceId]);
|
|
157
|
+
return rows.length;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
exports.PgDimensionMemory = PgDimensionMemory;
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*
|
|
8
8
|
* The only host dependency is `fetch`, injectable for tests.
|
|
9
9
|
*/
|
|
10
|
-
import { correiosApiUrl, } from "@meuecommerce/frete";
|
|
10
|
+
import { correiosApiUrl, correiosSroPath, } from "@meuecommerce/frete";
|
|
11
11
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
12
12
|
function base64(input) {
|
|
13
13
|
return Buffer.from(input, "utf-8").toString("base64");
|
|
@@ -112,5 +112,36 @@ export function createCorreiosHttpClient(options = {}) {
|
|
|
112
112
|
getTime(payload, token) {
|
|
113
113
|
return estimate("/prazo/v1/nacional", payload, token, "correios.getTime");
|
|
114
114
|
},
|
|
115
|
+
async track(codes, token) {
|
|
116
|
+
if (!codes.length)
|
|
117
|
+
return [];
|
|
118
|
+
// SRO Rastro takes the codes as repeated `codigosObjetos` query params and
|
|
119
|
+
// `resultado=T` for the full event history (vs. `U` for the latest only).
|
|
120
|
+
// The language MUST go in the `Accept-Language` header (pt-BR/en/es-ES) —
|
|
121
|
+
// an `idioma` query param is rejected with SRO-018.
|
|
122
|
+
const params = new URLSearchParams();
|
|
123
|
+
for (const code of codes)
|
|
124
|
+
params.append("codigosObjetos", code);
|
|
125
|
+
params.append("resultado", "T");
|
|
126
|
+
const url = `${baseUrl}${correiosSroPath}?${params.toString()}`;
|
|
127
|
+
const response = await fetchWithRetry(url, {
|
|
128
|
+
method: "GET",
|
|
129
|
+
headers: {
|
|
130
|
+
Accept: "application/json",
|
|
131
|
+
"Accept-Language": "pt-BR",
|
|
132
|
+
Authorization: `Bearer ${token}`,
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
if (response.status === 401) {
|
|
136
|
+
throw { status: 401, message: "unauthorized" };
|
|
137
|
+
}
|
|
138
|
+
if (response.status !== 200) {
|
|
139
|
+
const json = await response.json().catch(() => ({}));
|
|
140
|
+
logger.warn(`[correios.track] invalid response. status=${response.status} body=${JSON.stringify(json)}`);
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
const body = (await response.json());
|
|
144
|
+
return Array.isArray(body?.objetos) ? body.objetos : [];
|
|
145
|
+
},
|
|
115
146
|
};
|
|
116
147
|
}
|