@meuecommerce/frete-adapter-node 0.3.0 → 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 +137 -0
- package/dist/cjs/envSecretStore.js +1 -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.d.ts +41 -0
- package/dist/correiosLabelHttpClient.js +134 -0
- package/dist/envSecretStore.d.ts +2 -2
- package/dist/envSecretStore.js +1 -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 +12 -3
|
@@ -1,28 +1,38 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
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
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
|
|
113
|
-
|
|
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 };
|
|
@@ -0,0 +1,157 @@
|
|
|
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 { MEMORY_LAYER_ORDER, canOverwrite, productKey } from "@meuecommerce/frete";
|
|
28
|
+
export const DIMENSION_MEMORY_SCHEMA_SQL = `
|
|
29
|
+
CREATE TABLE IF NOT EXISTS dimension_memory (
|
|
30
|
+
key TEXT PRIMARY KEY,
|
|
31
|
+
instance_id TEXT NOT NULL,
|
|
32
|
+
length_cm DOUBLE PRECISION NOT NULL,
|
|
33
|
+
width_cm DOUBLE PRECISION NOT NULL,
|
|
34
|
+
height_cm DOUBLE PRECISION NOT NULL,
|
|
35
|
+
attrs JSONB,
|
|
36
|
+
confidence DOUBLE PRECISION NOT NULL,
|
|
37
|
+
source TEXT NOT NULL,
|
|
38
|
+
layer_rank INTEGER NOT NULL,
|
|
39
|
+
prompt_version TEXT,
|
|
40
|
+
model TEXT,
|
|
41
|
+
updated_at TIMESTAMPTZ NOT NULL
|
|
42
|
+
);
|
|
43
|
+
CREATE INDEX IF NOT EXISTS dimension_memory_instance_idx ON dimension_memory (instance_id);
|
|
44
|
+
`;
|
|
45
|
+
/** Lower rank = stronger layer, matching `MEMORY_LAYER_ORDER`. */
|
|
46
|
+
function layerRank(layer) {
|
|
47
|
+
const rank = MEMORY_LAYER_ORDER.indexOf(layer);
|
|
48
|
+
// Uma camada desconhecida é tratada como a mais fraca possível, em vez de
|
|
49
|
+
// virar -1 e passar por cima de tudo.
|
|
50
|
+
return rank === -1 ? MEMORY_LAYER_ORDER.length : rank;
|
|
51
|
+
}
|
|
52
|
+
/** `$1, $2, …` para `count` parâmetros. */
|
|
53
|
+
function placeholders(count) {
|
|
54
|
+
return Array.from({ length: count }, (_, i) => `$${i + 1}`).join(", ");
|
|
55
|
+
}
|
|
56
|
+
/** `DOUBLE PRECISION` chega como string em alguns drivers. */
|
|
57
|
+
function num(value) {
|
|
58
|
+
return typeof value === "number" ? value : Number(value);
|
|
59
|
+
}
|
|
60
|
+
function toRecord(row) {
|
|
61
|
+
const updatedAt = row.updated_at instanceof Date ? row.updated_at.toISOString() : String(row.updated_at);
|
|
62
|
+
return {
|
|
63
|
+
dims: { length: num(row.length_cm), width: num(row.width_cm), height: num(row.height_cm) },
|
|
64
|
+
...(row.attrs ? { attrs: row.attrs } : {}),
|
|
65
|
+
confidence: num(row.confidence),
|
|
66
|
+
source: row.source,
|
|
67
|
+
...(row.prompt_version ? { promptVersion: row.prompt_version } : {}),
|
|
68
|
+
...(row.model ? { model: row.model } : {}),
|
|
69
|
+
updatedAt,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
export class PgDimensionMemory {
|
|
73
|
+
sql;
|
|
74
|
+
constructor(options) {
|
|
75
|
+
this.sql = options.sql;
|
|
76
|
+
}
|
|
77
|
+
/** Idempotent — safe to call on every boot. */
|
|
78
|
+
async migrate() {
|
|
79
|
+
await this.sql.query(DIMENSION_MEMORY_SCHEMA_SQL);
|
|
80
|
+
}
|
|
81
|
+
async get(identities) {
|
|
82
|
+
const keys = identities.flatMap((identity) => {
|
|
83
|
+
const key = productKey(identity);
|
|
84
|
+
return key ? [key.value] : [];
|
|
85
|
+
});
|
|
86
|
+
const found = new Map();
|
|
87
|
+
if (keys.length === 0)
|
|
88
|
+
return found;
|
|
89
|
+
const { rows } = await this.sql.query(`SELECT key, length_cm, width_cm, height_cm, attrs, confidence, source, prompt_version, model, updated_at
|
|
90
|
+
FROM dimension_memory
|
|
91
|
+
WHERE key IN (${placeholders(keys.length)})`, keys);
|
|
92
|
+
for (const row of rows)
|
|
93
|
+
found.set(row.key, toRecord(row));
|
|
94
|
+
return found;
|
|
95
|
+
}
|
|
96
|
+
async set(entries) {
|
|
97
|
+
const values = [];
|
|
98
|
+
const tuples = [];
|
|
99
|
+
for (const { identity, record } of entries) {
|
|
100
|
+
const key = productKey(identity);
|
|
101
|
+
// Sem chave derivável não há o que gravar: inventar uma faria produtos
|
|
102
|
+
// sem relação compartilharem entrada.
|
|
103
|
+
if (!key)
|
|
104
|
+
continue;
|
|
105
|
+
const base = values.length;
|
|
106
|
+
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);
|
|
107
|
+
const p = (offset) => `$${base + offset}`;
|
|
108
|
+
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)`);
|
|
109
|
+
}
|
|
110
|
+
if (tuples.length === 0)
|
|
111
|
+
return;
|
|
112
|
+
await this.sql.query(`INSERT INTO dimension_memory
|
|
113
|
+
(key, instance_id, length_cm, width_cm, height_cm, attrs, confidence, source, layer_rank, prompt_version, model, updated_at)
|
|
114
|
+
VALUES ${tuples.join(", ")}
|
|
115
|
+
ON CONFLICT (key) DO UPDATE SET
|
|
116
|
+
length_cm = EXCLUDED.length_cm,
|
|
117
|
+
width_cm = EXCLUDED.width_cm,
|
|
118
|
+
height_cm = EXCLUDED.height_cm,
|
|
119
|
+
attrs = EXCLUDED.attrs,
|
|
120
|
+
confidence = EXCLUDED.confidence,
|
|
121
|
+
source = EXCLUDED.source,
|
|
122
|
+
layer_rank = EXCLUDED.layer_rank,
|
|
123
|
+
prompt_version = EXCLUDED.prompt_version,
|
|
124
|
+
model = EXCLUDED.model,
|
|
125
|
+
updated_at = EXCLUDED.updated_at
|
|
126
|
+
WHERE EXCLUDED.layer_rank <= dimension_memory.layer_rank`, values);
|
|
127
|
+
}
|
|
128
|
+
async invalidate(identities) {
|
|
129
|
+
const keys = identities.flatMap((identity) => {
|
|
130
|
+
const key = productKey(identity);
|
|
131
|
+
return key ? [key.value] : [];
|
|
132
|
+
});
|
|
133
|
+
if (keys.length === 0)
|
|
134
|
+
return;
|
|
135
|
+
await this.sql.query(`DELETE FROM dimension_memory WHERE key IN (${placeholders(keys.length)})`, keys);
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Drop agent estimates produced by an older prompt.
|
|
139
|
+
*
|
|
140
|
+
* Only touches `L3`: what the merchant declared and what a real label proved
|
|
141
|
+
* do not go stale because we changed a prompt. Meant to run in background
|
|
142
|
+
* after a prompt bump — never on the quote path.
|
|
143
|
+
*/
|
|
144
|
+
async invalidateStaleEstimates(currentPromptVersion) {
|
|
145
|
+
const { rows } = await this.sql.query(`DELETE FROM dimension_memory
|
|
146
|
+
WHERE source = 'L3' AND (prompt_version IS NULL OR prompt_version <> $1)
|
|
147
|
+
RETURNING key`, [currentPromptVersion]);
|
|
148
|
+
return rows.length;
|
|
149
|
+
}
|
|
150
|
+
/** Forget everything for one merchant — uninstall, or a support reset. */
|
|
151
|
+
async forgetInstance(instanceId) {
|
|
152
|
+
const { rows } = await this.sql.query(`DELETE FROM dimension_memory WHERE instance_id = $1 RETURNING key`, [instanceId]);
|
|
153
|
+
return rows.length;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/** Re-exported so callers can reason about precedence without importing core. */
|
|
157
|
+
export { canOverwrite };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meuecommerce/frete-adapter-node",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Node implementation of @meuecommerce/frete ports (Correios HTTP client) using global fetch. Used by the Shopify/Fly deployment and the MCP server.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/cjs/index.js",
|
|
@@ -31,7 +31,16 @@
|
|
|
31
31
|
"access": "public"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@meuecommerce/frete": "^0.
|
|
34
|
+
"@meuecommerce/frete": "^0.4.0"
|
|
35
35
|
},
|
|
36
|
-
"module": "./dist/index.js"
|
|
36
|
+
"module": "./dist/index.js",
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "git+https://github.com/devstudio163/frete.git",
|
|
40
|
+
"directory": "packages/adapter-node"
|
|
41
|
+
},
|
|
42
|
+
"homepage": "https://github.com/devstudio163/frete",
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"pg-mem": "^3.0.14"
|
|
45
|
+
}
|
|
37
46
|
}
|