@meuecommerce/frete-adapter-node 0.3.1 → 0.4.1

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.
@@ -17,6 +17,16 @@
17
17
  */
18
18
  import { correiosApiUrl, } from "@meuecommerce/frete";
19
19
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
20
+ /**
21
+ * Some Correios label messages are transient: the pré-postagem is fine and the
22
+ * PDF is still being produced, so a later poll succeeds (e.g. PPN-291 "O rótulo
23
+ * ainda não foi gerado. Por favor, consulte novamente."). Distinguish those from
24
+ * terminal ones — notably PPN-288 "status Pendente" — which won't clear by
25
+ * polling and must be surfaced. Retryable → keep polling; terminal → `message`.
26
+ */
27
+ function isRetryableLabelMessage(msg) {
28
+ return /consulte novamente|ainda n[ãa]o foi gerado|n[ãa]o (foi )?gerado|PPN-291/i.test(msg);
29
+ }
20
30
  export function createCorreiosLabelHttpClient(options = {}) {
21
31
  const { fetch = globalThis.fetch, baseUrl = correiosApiUrl, timeoutMs = 20000, retries = 2, retryDelayMs = 500, tipoRotulo = "P", downloadPath = "/prepostagem/v1/prepostagens/rotulo/download/assincrono/{idRecibo}", logger = console, } = options;
22
32
  if (typeof fetch !== "function") {
@@ -66,6 +76,12 @@ export function createCorreiosLabelHttpClient(options = {}) {
66
76
  }
67
77
  return json;
68
78
  },
79
+ async getPrepostagemStatus(id, token) {
80
+ const response = await fetchWithRetry(`${baseUrl}/prepostagem/v2/prepostagens?id=${encodeURIComponent(id)}&page=0&size=1`, { method: "GET", headers: authHeaders(token) });
81
+ const json = (await response.json().catch(() => ({})));
82
+ const item = Array.isArray(json.itens) ? json.itens[0] : undefined;
83
+ return item ?? null;
84
+ },
69
85
  async requestLabelPdf(ids, token) {
70
86
  const response = await fetchWithRetry(`${baseUrl}/prepostagem/v1/prepostagens/rotulo/assincrono/pdf`, {
71
87
  method: "POST",
@@ -92,6 +108,9 @@ export function createCorreiosLabelHttpClient(options = {}) {
92
108
  return { ready: false };
93
109
  if (response.status !== 200) {
94
110
  const body = await response.text().catch(() => "");
111
+ // A retryable business message can arrive with a non-2xx status too.
112
+ if (isRetryableLabelMessage(body))
113
+ return { ready: false };
95
114
  return { ready: false, message: `http_${response.status}: ${body.slice(0, 200)}` };
96
115
  }
97
116
  // Correios may return the PDF as raw bytes (application/pdf) or wrapped in
@@ -104,8 +123,11 @@ export function createCorreiosLabelHttpClient(options = {}) {
104
123
  const pdfBase64 = (json.dados ?? json.pdf ?? json.base64 ?? json.arquivo);
105
124
  if (pdfBase64)
106
125
  return { ready: true, pdfBase64 };
107
- // 200 without a PDF carries a business message (e.g. "status Pendente").
126
+ // 200 without a PDF carries a business message (e.g. "status Pendente" or
127
+ // "ainda não foi gerado, consulte novamente"). Retryable ones → keep polling.
108
128
  const message = typeof json.mensagem === "string" ? json.mensagem : undefined;
129
+ if (message && isRetryableLabelMessage(message))
130
+ return { ready: false };
109
131
  return message ? { ready: false, message } : { ready: false };
110
132
  },
111
133
  };
@@ -0,0 +1,10 @@
1
+ import type { DimensionMemory, DimensionMemoryEntry, DimensionRecord, ProductIdentity } from "@meuecommerce/frete";
2
+ export declare class InMemoryDimensionMemory implements DimensionMemory {
3
+ private readonly records;
4
+ constructor(seed?: DimensionMemoryEntry[]);
5
+ /** How many products the memory holds. For tests and local inspection. */
6
+ get size(): number;
7
+ get(identities: ProductIdentity[]): Promise<Map<string, DimensionRecord>>;
8
+ set(entries: DimensionMemoryEntry[]): Promise<void>;
9
+ invalidate(identities: ProductIdentity[]): Promise<void>;
10
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * In-memory `DimensionMemory` — local dev, the MCP server's local mode, and
3
+ * tests. Production swaps it for the Postgres-backed one; the core does not
4
+ * change.
5
+ *
6
+ * It enforces the layer rule rather than just storing what it is given: an
7
+ * agent guess (`L3`) must never overwrite what the merchant declared (`L0`).
8
+ * Putting that here as well as in the Postgres implementation is deliberate —
9
+ * it is a property of the memory, not of one backend, and the tests that pin it
10
+ * run against this one.
11
+ */
12
+ import { canOverwrite, productKey } from "@meuecommerce/frete";
13
+ export class InMemoryDimensionMemory {
14
+ records = new Map();
15
+ constructor(seed = []) {
16
+ // O seed passa pela mesma regra de camada que uma escrita normal.
17
+ void this.set(seed);
18
+ }
19
+ /** How many products the memory holds. For tests and local inspection. */
20
+ get size() {
21
+ return this.records.size;
22
+ }
23
+ async get(identities) {
24
+ const found = new Map();
25
+ for (const identity of identities) {
26
+ const key = productKey(identity);
27
+ if (!key)
28
+ continue;
29
+ const record = this.records.get(key.value);
30
+ if (record)
31
+ found.set(key.value, record);
32
+ }
33
+ return found;
34
+ }
35
+ async set(entries) {
36
+ for (const { identity, record } of entries) {
37
+ const key = productKey(identity);
38
+ // Sem chave derivável não há o que gravar: inventar uma faria produtos
39
+ // sem relação compartilharem entrada.
40
+ if (!key)
41
+ continue;
42
+ const existing = this.records.get(key.value);
43
+ if (existing && !canOverwrite(existing.source, record.source))
44
+ continue;
45
+ this.records.set(key.value, record);
46
+ }
47
+ }
48
+ async invalidate(identities) {
49
+ for (const identity of identities) {
50
+ const key = productKey(identity);
51
+ if (key)
52
+ this.records.delete(key.value);
53
+ }
54
+ }
55
+ }
package/dist/index.d.ts CHANGED
@@ -6,11 +6,18 @@
6
6
  */
7
7
  export { createCorreiosHttpClient } from "./correiosHttpClient.js";
8
8
  export type { CorreiosHttpClientOptions } from "./correiosHttpClient.js";
9
+ export { createCorreiosLabelHttpClient } from "./correiosLabelHttpClient.js";
10
+ export type { CorreiosLabelHttpClientOptions } from "./correiosLabelHttpClient.js";
9
11
  export { InMemorySettingsStore } from "./inMemorySettingsStore.js";
10
12
  export type { InMemorySettingsStoreOptions } from "./inMemorySettingsStore.js";
13
+ export { InMemoryDimensionMemory } from "./inMemoryDimensionMemory.js";
11
14
  export { EnvSecretStore } from "./envSecretStore.js";
12
15
  export type { EnvSecretStoreOptions } from "./envSecretStore.js";
13
- export { createOpenAIDimensionEstimator } from "./openAIDimensionEstimator.js";
16
+ export { DIMENSION_PROMPT_VERSION, createOpenAIDimensionEstimator } from "./openAIDimensionEstimator.js";
17
+ export { createOpenAIResponsesAgentRunner } from "./openAIResponsesAgentRunner.js";
18
+ export { DIMENSION_MEMORY_SCHEMA_SQL, PgDimensionMemory } from "./pgDimensionMemory.js";
19
+ export type { PgDimensionMemoryOptions, SqlClient } from "./pgDimensionMemory.js";
20
+ export type { OpenAIResponsesAgentRunnerOptions } from "./openAIResponsesAgentRunner.js";
14
21
  export type { OpenAIDimensionEstimatorOptions } from "./openAIDimensionEstimator.js";
15
22
  export { createOpenAIBoxDistributor } from "./openAIBoxDistributor.js";
16
23
  export type { OpenAIBoxDistributorOptions } from "./openAIBoxDistributor.js";
package/dist/index.js CHANGED
@@ -5,7 +5,11 @@
5
5
  * Shopify/Fly deployment today, and the MCP server next.
6
6
  */
7
7
  export { createCorreiosHttpClient } from "./correiosHttpClient.js";
8
+ export { createCorreiosLabelHttpClient } from "./correiosLabelHttpClient.js";
8
9
  export { InMemorySettingsStore } from "./inMemorySettingsStore.js";
10
+ export { InMemoryDimensionMemory } from "./inMemoryDimensionMemory.js";
9
11
  export { EnvSecretStore } from "./envSecretStore.js";
10
- export { createOpenAIDimensionEstimator } from "./openAIDimensionEstimator.js";
12
+ export { DIMENSION_PROMPT_VERSION, createOpenAIDimensionEstimator } from "./openAIDimensionEstimator.js";
13
+ export { createOpenAIResponsesAgentRunner } from "./openAIResponsesAgentRunner.js";
14
+ export { DIMENSION_MEMORY_SCHEMA_SQL, PgDimensionMemory } from "./pgDimensionMemory.js";
11
15
  export { createOpenAIBoxDistributor } from "./openAIBoxDistributor.js";
@@ -1,24 +1,44 @@
1
1
  /**
2
- * `ProductDimensionEstimator` implemented with the OpenAI chat-completions API.
2
+ * `ProductDimensionEstimator` implemented with the OpenAI Responses API and a
3
+ * strict structured output.
3
4
  *
4
5
  * Ports the dimension-estimation path from the Velo `openAI/` modules:
5
- * - `ai-tools.js` -> the `estimate_dimensions` tool + request/response handling
6
+ * - `ai-tools.js` -> request/response handling (was a forced tool call)
6
7
  * - `prompt-methods.js`-> `createDimensionEstimationPrompt`
7
8
  * - `box-methods.js` -> `validateAndOptimizeDimensions` (post-process AI output)
8
9
  *
10
+ * Why the move off chat-completions with a forced tool call: a tool call is a
11
+ * request for a shape, not a guarantee of one — the arguments still arrive as a
12
+ * string that can be truncated or malformed, which is why the old code had to
13
+ * `JSON.parse` and hope. `text.format` with `strict: true` makes the schema a
14
+ * constraint on decoding instead, so a missing field or an extra one cannot
15
+ * come back at all.
16
+ *
9
17
  * `fetch` is injectable (defaults to global fetch) so this also runs under Velo
10
18
  * with `wix-fetch` passed in. The API key is passed in — the caller resolves it
11
19
  * (e.g. via a `SecretStore`), keeping secret handling out of this module.
12
20
  */
13
- import type { ProductDimensionEstimator } from "@meuecommerce/frete";
21
+ import type { LlmAgentRunner, ProductDimensionEstimator } from "@meuecommerce/frete";
14
22
  type FetchLike = typeof globalThis.fetch;
23
+ /**
24
+ * Bump whenever the prompt or the schema changes. It is written next to every
25
+ * `L3` record so a prompt change can invalidate stale estimates in background
26
+ * instead of at checkout.
27
+ */
28
+ export declare const DIMENSION_PROMPT_VERSION = "2026-09-04.responses.v1";
15
29
  export interface OpenAIDimensionEstimatorOptions {
16
- apiKey: string;
30
+ apiKey?: string;
17
31
  fetch?: FetchLike;
18
32
  model?: string;
19
33
  temperature?: number;
20
34
  apiUrl?: string;
21
35
  timeoutMs?: number;
36
+ /**
37
+ * Run the call through an existing {@link LlmAgentRunner} instead of building
38
+ * one. Pass it to share a runner across use cases, or to substitute the
39
+ * transport in tests; omit it and one is created from `apiKey`.
40
+ */
41
+ runner?: LlmAgentRunner;
22
42
  }
23
43
  export declare function createOpenAIDimensionEstimator(options: OpenAIDimensionEstimatorOptions): ProductDimensionEstimator;
24
44
  export {};
@@ -1,28 +1,38 @@
1
- const ESTIMATE_DIMENSIONS_TOOL = {
2
- type: "function",
3
- function: {
4
- name: "estimate_dimensions",
5
- description: "Estima dimensões de produtos PRESERVANDO os nomes exatos fornecidos",
6
- parameters: {
7
- type: "object",
8
- properties: {
9
- products: {
10
- type: "array",
11
- items: {
12
- type: "object",
13
- properties: {
14
- name: { type: "string" },
15
- length: { type: "number", description: "Comprimento em cm" },
16
- width: { type: "number", description: "Largura em cm" },
17
- height: { type: "number", description: "Altura em cm" },
18
- },
19
- required: ["name", "length", "width", "height"],
20
- },
1
+ import { createOpenAIResponsesAgentRunner } from "./openAIResponsesAgentRunner.js";
2
+ /**
3
+ * Bump whenever the prompt or the schema changes. It is written next to every
4
+ * `L3` record so a prompt change can invalidate stale estimates in background
5
+ * instead of at checkout.
6
+ */
7
+ export const DIMENSION_PROMPT_VERSION = "2026-09-04.responses.v1";
8
+ /**
9
+ * Strict JSON schema for the response.
10
+ *
11
+ * `strict: true` has two requirements that are easy to miss and reject the
12
+ * whole request when absent: every object needs `additionalProperties: false`,
13
+ * and `required` has to list every property — optional fields are not allowed.
14
+ */
15
+ const DIMENSIONS_SCHEMA = {
16
+ type: "object",
17
+ properties: {
18
+ products: {
19
+ type: "array",
20
+ description: "Uma entrada por produto pedido, na mesma ordem.",
21
+ items: {
22
+ type: "object",
23
+ properties: {
24
+ name: { type: "string", description: "O nome EXATO recebido, sem reescrever" },
25
+ length: { type: "number", description: "Comprimento em cm" },
26
+ width: { type: "number", description: "Largura em cm" },
27
+ height: { type: "number", description: "Altura em cm" },
21
28
  },
29
+ required: ["name", "length", "width", "height"],
30
+ additionalProperties: false,
22
31
  },
23
- required: ["products"],
24
32
  },
25
33
  },
34
+ required: ["products"],
35
+ additionalProperties: false,
26
36
  };
27
37
  function largestBox(boxes) {
28
38
  return boxes.reduce((largest, current) => current.width * current.height * current.length > largest.width * largest.height * largest.length ? current : largest);
@@ -40,8 +50,7 @@ function createPrompt(products, boxes, samples) {
40
50
  `🚨 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` +
41
51
  `📦 PRODUTOS PARA ESTIMAR:\n${products.map((p, i) => `${i + 1}. ${p.name} - ${p.quantity}x (${p.weight}kg cada)`).join("\n")}\n` +
42
52
  samplesSection +
43
- `\n⚠️ REGRAS: respeite os limites; preserve os nomes EXATOS; retorne length×width×height em cm.\n` +
44
- `Responda usando a função 'estimate_dimensions'.`);
53
+ `\n⚠️ REGRAS: respeite os limites; preserve os nomes EXATOS; retorne length×width×height em cm.`);
45
54
  }
46
55
  /** Apply the largest-box physical limits + a minimum-volume floor to AI output. */
47
56
  function validateAndOptimize(aiResult, products, boxes) {
@@ -76,52 +85,32 @@ function validateAndOptimize(aiResult, products, boxes) {
76
85
  return { products: optimized };
77
86
  }
78
87
  export function createOpenAIDimensionEstimator(options) {
79
- const { apiKey, fetch = globalThis.fetch, model = "gpt-4o-mini", temperature = 0.1, apiUrl = "https://api.openai.com/v1/chat/completions", timeoutMs = 20000, } = options;
80
- if (!apiKey)
81
- throw new Error("createOpenAIDimensionEstimator: apiKey is required");
82
- if (typeof fetch !== "function")
83
- throw new Error("createOpenAIDimensionEstimator: no fetch available");
88
+ const { apiKey, fetch, model = "gpt-4o-mini", temperature = 0.1, apiUrl, timeoutMs, runner } = options;
89
+ const agent = runner ??
90
+ createOpenAIResponsesAgentRunner({
91
+ apiKey: apiKey ?? "",
92
+ ...(fetch ? { fetch } : {}),
93
+ model,
94
+ ...(apiUrl ? { apiUrl } : {}),
95
+ ...(timeoutMs !== undefined ? { timeoutMs } : {}),
96
+ });
84
97
  return {
85
98
  async estimate(products, boxes, productSamples = []) {
86
99
  if (!boxes.length)
87
100
  throw new Error("estimate: no boxes provided");
88
- const body = {
89
- model,
90
- messages: [
91
- {
92
- role: "system",
93
- content: "Você é um especialista em dimensões de produtos. Use os exemplos personalizados como prioridade máxima quando o nome bater.",
94
- },
95
- { role: "user", content: createPrompt(products, boxes, productSamples) },
96
- ],
97
- tools: [ESTIMATE_DIMENSIONS_TOOL],
98
- tool_choice: { type: "function", function: { name: "estimate_dimensions" } },
101
+ const result = await agent.run({
102
+ task: "estimate-dimensions",
103
+ promptVersion: DIMENSION_PROMPT_VERSION,
104
+ system: "Você é um especialista em dimensões de produtos. Use os exemplos personalizados como prioridade máxima quando o nome bater.",
105
+ user: createPrompt(products, boxes, productSamples),
106
+ schema: { name: "product_dimensions", schema: DIMENSIONS_SCHEMA },
99
107
  temperature,
100
- };
101
- const controller = new AbortController();
102
- const timer = setTimeout(() => controller.abort(), timeoutMs);
103
- let response;
104
- try {
105
- response = await fetch(apiUrl, {
106
- method: "POST",
107
- headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
108
- body: JSON.stringify(body),
109
- signal: controller.signal,
110
- });
108
+ });
109
+ if (!Array.isArray(result.output?.products)) {
110
+ throw new Error("Resposta da IA sem a lista de produtos");
111
111
  }
112
- finally {
113
- clearTimeout(timer);
114
- }
115
- if (!response.ok)
116
- throw new Error(`OpenAI API Error: ${response.status} ${response.statusText}`);
117
- const json = (await response.json());
118
- if (json.error)
119
- throw new Error(`OpenAI Error: ${json.error.message}`);
120
- const args = json.choices?.[0]?.message?.tool_calls?.[0]?.function?.arguments;
121
- if (!args)
122
- throw new Error("IA não conseguiu estimar dimensões dos produtos");
123
- const parsed = JSON.parse(args);
124
- return validateAndOptimize(parsed, products, boxes);
112
+ const validated = validateAndOptimize(result.output, products, boxes);
113
+ return result.usage ? { ...validated, usage: result.usage } : validated;
125
114
  },
126
115
  };
127
116
  }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * `LlmAgentRunner` on the OpenAI Responses API.
3
+ *
4
+ * One call, one strict schema, no re-ask. It owns everything provider-shaped —
5
+ * the endpoint, the structured-output envelope, reading the answer out of the
6
+ * two shapes the API returns it in, refusals, and token accounting — so each
7
+ * use case is left with only its prompt, its schema and its own validation.
8
+ *
9
+ * `fetch` is injectable (defaults to global fetch) so this also runs under Velo
10
+ * with `wix-fetch` passed in. The API key is passed in — the caller resolves it,
11
+ * keeping secret handling out of this module.
12
+ */
13
+ import type { LlmAgentRunner } from "@meuecommerce/frete";
14
+ type FetchLike = typeof globalThis.fetch;
15
+ export interface OpenAIResponsesAgentRunnerOptions {
16
+ apiKey: string;
17
+ fetch?: FetchLike;
18
+ model?: string;
19
+ apiUrl?: string;
20
+ timeoutMs?: number;
21
+ }
22
+ interface ResponsesPayload {
23
+ error?: {
24
+ message?: string;
25
+ };
26
+ model?: string;
27
+ /** Convenience field with the text already concatenated. */
28
+ output_text?: string;
29
+ output?: Array<{
30
+ content?: Array<{
31
+ type?: string;
32
+ text?: string;
33
+ refusal?: string;
34
+ }>;
35
+ }>;
36
+ usage?: {
37
+ input_tokens?: number;
38
+ output_tokens?: number;
39
+ };
40
+ }
41
+ /** The model's text, accepting both shapes the Responses API delivers it in. */
42
+ export declare function extractOutputText(json: ResponsesPayload): string;
43
+ /**
44
+ * A refusal is a *successful* response in which the model declined to fill the
45
+ * schema. Without this check it would read as empty text and be reported as
46
+ * "the model could not answer", hiding the real reason.
47
+ */
48
+ export declare function findRefusal(json: ResponsesPayload): string | null;
49
+ export declare function createOpenAIResponsesAgentRunner(options: OpenAIResponsesAgentRunnerOptions): LlmAgentRunner;
50
+ export {};
@@ -0,0 +1,114 @@
1
+ /** The model's text, accepting both shapes the Responses API delivers it in. */
2
+ export function extractOutputText(json) {
3
+ if (typeof json.output_text === "string" && json.output_text.trim())
4
+ return json.output_text;
5
+ const parts = [];
6
+ for (const item of json.output ?? []) {
7
+ for (const content of item.content ?? []) {
8
+ if (typeof content.text === "string")
9
+ parts.push(content.text);
10
+ }
11
+ }
12
+ return parts.join("").trim();
13
+ }
14
+ /**
15
+ * A refusal is a *successful* response in which the model declined to fill the
16
+ * schema. Without this check it would read as empty text and be reported as
17
+ * "the model could not answer", hiding the real reason.
18
+ */
19
+ export function findRefusal(json) {
20
+ for (const item of json.output ?? []) {
21
+ for (const content of item.content ?? []) {
22
+ if (typeof content.refusal === "string" && content.refusal)
23
+ return content.refusal;
24
+ }
25
+ }
26
+ return null;
27
+ }
28
+ export function createOpenAIResponsesAgentRunner(options) {
29
+ const { apiKey, fetch = globalThis.fetch, model: defaultModel = "gpt-4o-mini", apiUrl = "https://api.openai.com/v1/responses", timeoutMs = 20000, } = options;
30
+ if (!apiKey)
31
+ throw new Error("createOpenAIResponsesAgentRunner: apiKey is required");
32
+ if (typeof fetch !== "function")
33
+ throw new Error("createOpenAIResponsesAgentRunner: no fetch available");
34
+ return {
35
+ async run(request) {
36
+ const input = [];
37
+ if (request.system)
38
+ input.push({ role: "system", content: request.system });
39
+ // `images` ainda não é usado pelo estimador; quando a fase 2 ligar, o
40
+ // conteúdo do turno do usuário vira lista em vez de string.
41
+ if (request.images?.length) {
42
+ input.push({
43
+ role: "user",
44
+ content: [
45
+ { type: "input_text", text: request.user },
46
+ ...request.images.map((url) => ({ type: "input_image", image_url: url })),
47
+ ],
48
+ });
49
+ }
50
+ else {
51
+ input.push({ role: "user", content: request.user });
52
+ }
53
+ const body = {
54
+ model: defaultModel,
55
+ input,
56
+ text: {
57
+ format: {
58
+ type: "json_schema",
59
+ name: request.schema.name,
60
+ strict: true,
61
+ schema: request.schema.schema,
62
+ },
63
+ },
64
+ };
65
+ if (request.temperature !== undefined)
66
+ body.temperature = request.temperature;
67
+ if (request.maxOutputTokens !== undefined)
68
+ body.max_output_tokens = request.maxOutputTokens;
69
+ const controller = new AbortController();
70
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
71
+ let response;
72
+ try {
73
+ response = await fetch(apiUrl, {
74
+ method: "POST",
75
+ headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
76
+ body: JSON.stringify(body),
77
+ signal: controller.signal,
78
+ });
79
+ }
80
+ finally {
81
+ clearTimeout(timer);
82
+ }
83
+ if (!response.ok)
84
+ throw new Error(`OpenAI API Error: ${response.status} ${response.statusText}`);
85
+ const json = (await response.json());
86
+ if (json.error)
87
+ throw new Error(`OpenAI Error: ${json.error.message}`);
88
+ const refusal = findRefusal(json);
89
+ if (refusal)
90
+ throw new Error(`OpenAI recusou a resposta de ${request.task}: ${refusal}`);
91
+ const text = extractOutputText(json);
92
+ if (!text)
93
+ throw new Error(`Resposta vazia da IA para ${request.task}`);
94
+ let output;
95
+ try {
96
+ output = JSON.parse(text);
97
+ }
98
+ catch {
99
+ throw new Error(`Resposta da IA para ${request.task} não é JSON válido`);
100
+ }
101
+ const model = json.model ?? defaultModel;
102
+ const usage = json.usage
103
+ ? {
104
+ model,
105
+ ...(typeof json.usage.input_tokens === "number" ? { inputTokens: json.usage.input_tokens } : {}),
106
+ ...(typeof json.usage.output_tokens === "number" ? { outputTokens: json.usage.output_tokens } : {}),
107
+ }
108
+ : undefined;
109
+ // Sempre 1 nesta fase: a repescagem por falha de validação é o próximo
110
+ // passo, e é ela que vai fazer este número variar.
111
+ return usage ? { output, usage, model, attempts: 1 } : { output, model, attempts: 1 };
112
+ },
113
+ };
114
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Postgres-backed `DimensionMemory` — the production counterpart of
3
+ * {@link InMemoryDimensionMemory}.
4
+ *
5
+ * Two rules from the port are enforced here in SQL rather than in application
6
+ * code, because the memory is written from more than one place (the quote path,
7
+ * the label feedback, the dashboard) and a check that lives in one caller is a
8
+ * check that the next caller forgets:
9
+ *
10
+ * - **A weaker layer never overwrites a stronger one.** The upsert carries the
11
+ * layer's rank and only updates when the incoming rank is at least as strong,
12
+ * so an agent estimate racing with a merchant's correction loses regardless of
13
+ * which arrives last.
14
+ * - **One round trip per cart.** `get` takes every product at once, because a
15
+ * query per line item is exactly what makes a hot path slow.
16
+ *
17
+ * The client is the structural {@link SqlClient} rather than a `pg` import, so
18
+ * this package gains no runtime dependency and the tests can run real SQL
19
+ * against pg-mem with no network.
20
+ *
21
+ * Lookups expand to `IN ($1, $2, …)` instead of the more idiomatic
22
+ * `= ANY($1)`. Real Postgres accepts both, but pg-mem silently returns zero
23
+ * rows for the array form, and running the tests against real SQL is worth more
24
+ * than the tidier query — a cart is a handful of line items, so the parameter
25
+ * count is not a concern. Revisit if this is ever used for bulk reads.
26
+ */
27
+ import { canOverwrite } from "@meuecommerce/frete";
28
+ import type { DimensionMemory, DimensionMemoryEntry, DimensionRecord, ProductIdentity } from "@meuecommerce/frete";
29
+ /**
30
+ * Minimal query surface — satisfied structurally by a node-postgres
31
+ * `Pool`/`Client` and by pg-mem's adapter.
32
+ */
33
+ export interface SqlClient {
34
+ query<R extends Record<string, unknown> = Record<string, unknown>>(text: string, params?: unknown[]): Promise<{
35
+ rows: R[];
36
+ }>;
37
+ }
38
+ export declare const DIMENSION_MEMORY_SCHEMA_SQL = "\nCREATE TABLE IF NOT EXISTS dimension_memory (\n key TEXT PRIMARY KEY,\n instance_id TEXT NOT NULL,\n length_cm DOUBLE PRECISION NOT NULL,\n width_cm DOUBLE PRECISION NOT NULL,\n height_cm DOUBLE PRECISION NOT NULL,\n attrs JSONB,\n confidence DOUBLE PRECISION NOT NULL,\n source TEXT NOT NULL,\n layer_rank INTEGER NOT NULL,\n prompt_version TEXT,\n model TEXT,\n updated_at TIMESTAMPTZ NOT NULL\n);\nCREATE INDEX IF NOT EXISTS dimension_memory_instance_idx ON dimension_memory (instance_id);\n";
39
+ export interface PgDimensionMemoryOptions {
40
+ sql: SqlClient;
41
+ }
42
+ export declare class PgDimensionMemory implements DimensionMemory {
43
+ private readonly sql;
44
+ constructor(options: PgDimensionMemoryOptions);
45
+ /** Idempotent — safe to call on every boot. */
46
+ migrate(): Promise<void>;
47
+ get(identities: ProductIdentity[]): Promise<Map<string, DimensionRecord>>;
48
+ set(entries: DimensionMemoryEntry[]): Promise<void>;
49
+ invalidate(identities: ProductIdentity[]): Promise<void>;
50
+ /**
51
+ * Drop agent estimates produced by an older prompt.
52
+ *
53
+ * Only touches `L3`: what the merchant declared and what a real label proved
54
+ * do not go stale because we changed a prompt. Meant to run in background
55
+ * after a prompt bump — never on the quote path.
56
+ */
57
+ invalidateStaleEstimates(currentPromptVersion: string): Promise<number>;
58
+ /** Forget everything for one merchant — uninstall, or a support reset. */
59
+ forgetInstance(instanceId: string): Promise<number>;
60
+ }
61
+ /** Re-exported so callers can reason about precedence without importing core. */
62
+ export { canOverwrite };