@purveyors/cli 0.15.1 → 0.20.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/README.md +130 -32
- package/dist/commands/auth.d.ts +17 -0
- package/dist/commands/auth.d.ts.map +1 -1
- package/dist/commands/auth.js +160 -62
- package/dist/commands/auth.js.map +1 -1
- package/dist/commands/catalog.d.ts +3 -1
- package/dist/commands/catalog.d.ts.map +1 -1
- package/dist/commands/catalog.js +330 -42
- package/dist/commands/catalog.js.map +1 -1
- package/dist/commands/roast.d.ts.map +1 -1
- package/dist/commands/roast.js +10 -8
- package/dist/commands/roast.js.map +1 -1
- package/dist/lib/catalog.d.ts +424 -1
- package/dist/lib/catalog.d.ts.map +1 -1
- package/dist/lib/catalog.js +918 -2
- package/dist/lib/catalog.js.map +1 -1
- package/dist/lib/interactive/forms.d.ts +10 -0
- package/dist/lib/interactive/forms.d.ts.map +1 -1
- package/dist/lib/interactive/forms.js +10 -8
- package/dist/lib/interactive/forms.js.map +1 -1
- package/dist/lib/interactive/watch.d.ts +1 -1
- package/dist/lib/interactive/watch.d.ts.map +1 -1
- package/dist/lib/interactive/watch.js +60 -8
- package/dist/lib/interactive/watch.js.map +1 -1
- package/dist/lib/manifest.d.ts.map +1 -1
- package/dist/lib/manifest.js +222 -13
- package/dist/lib/manifest.js.map +1 -1
- package/dist/lib/path-input.d.ts +3 -0
- package/dist/lib/path-input.d.ts.map +1 -0
- package/dist/lib/path-input.js +13 -0
- package/dist/lib/path-input.js.map +1 -0
- package/dist/program.d.ts.map +1 -1
- package/dist/program.js +7 -3
- package/dist/program.js.map +1 -1
- package/package.json +1 -1
package/dist/lib/catalog.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { AuthError, PrvrsError } from './errors.js';
|
|
2
3
|
// ─── Zod schemas ──────────────────────────────────────────────────────────────
|
|
3
4
|
export const catalogSortFields = ['price', 'price-desc', 'name', 'origin', 'newest'];
|
|
4
5
|
export const searchCatalogSchema = z.object({
|
|
@@ -17,11 +18,122 @@ export const searchCatalogSchema = z.object({
|
|
|
17
18
|
variety: z.string().optional(),
|
|
18
19
|
dryingMethod: z.string().optional(),
|
|
19
20
|
stockedDays: z.number().int().positive().optional(),
|
|
21
|
+
processingBaseMethod: z.string().optional(),
|
|
22
|
+
fermentationType: z.string().optional(),
|
|
23
|
+
processAdditive: z.string().optional(),
|
|
24
|
+
processingDisclosureLevel: z.string().optional(),
|
|
25
|
+
processingConfidenceMin: z.number().min(0).max(1).optional(),
|
|
26
|
+
includeProof: z.boolean().optional(),
|
|
20
27
|
});
|
|
21
28
|
export const getCatalogSchema = z.object({
|
|
22
29
|
id: z.number().int().positive(),
|
|
30
|
+
includeProof: z.boolean().optional(),
|
|
23
31
|
});
|
|
24
32
|
export const getCatalogStatsSchema = z.object({});
|
|
33
|
+
const CATALOG_INTELLIGENCE_MAX_SAMPLE_SIZE = 5000;
|
|
34
|
+
const CATALOG_PREMIUM_DEFAULT_SAMPLE_SIZE = 250;
|
|
35
|
+
const SUPABASE_DATA_API_MAX_PAGE_SIZE = 1000;
|
|
36
|
+
const SUPPLIER_AGGREGATE_DEFAULT_SAMPLE_SIZE = CATALOG_INTELLIGENCE_MAX_SAMPLE_SIZE;
|
|
37
|
+
export const catalogRankPremiumSchema = z.object({
|
|
38
|
+
origin: z.string().optional(),
|
|
39
|
+
process: z.string().optional(),
|
|
40
|
+
supplier: z.string().optional(),
|
|
41
|
+
stocked: z.boolean().optional(),
|
|
42
|
+
priceMax: z.number().optional(),
|
|
43
|
+
minScore: z.number().optional(),
|
|
44
|
+
includeUnscored: z.boolean().default(false).optional(),
|
|
45
|
+
limit: z.number().int().min(1).max(50).default(10).optional(),
|
|
46
|
+
sampleSize: z
|
|
47
|
+
.number()
|
|
48
|
+
.int()
|
|
49
|
+
.min(1)
|
|
50
|
+
.max(CATALOG_INTELLIGENCE_MAX_SAMPLE_SIZE)
|
|
51
|
+
.default(CATALOG_PREMIUM_DEFAULT_SAMPLE_SIZE)
|
|
52
|
+
.optional(),
|
|
53
|
+
});
|
|
54
|
+
export const supplierAggregateSchema = z.object({
|
|
55
|
+
supplier: z.string().optional(),
|
|
56
|
+
stocked: z.boolean().optional(),
|
|
57
|
+
minCoffees: z.number().int().min(1).default(1).optional(),
|
|
58
|
+
topCoffees: z.number().int().min(1).max(25).default(5).optional(),
|
|
59
|
+
limit: z.number().int().min(1).max(100).default(25).optional(),
|
|
60
|
+
sampleSize: z
|
|
61
|
+
.number()
|
|
62
|
+
.int()
|
|
63
|
+
.min(1)
|
|
64
|
+
.max(CATALOG_INTELLIGENCE_MAX_SAMPLE_SIZE)
|
|
65
|
+
.default(SUPPLIER_AGGREGATE_DEFAULT_SAMPLE_SIZE)
|
|
66
|
+
.optional(),
|
|
67
|
+
});
|
|
68
|
+
export const catalogFacetFields = [
|
|
69
|
+
'supplier',
|
|
70
|
+
'country',
|
|
71
|
+
'processing_base_method',
|
|
72
|
+
'fermentation_type',
|
|
73
|
+
'drying_method',
|
|
74
|
+
'grade',
|
|
75
|
+
'wholesale',
|
|
76
|
+
];
|
|
77
|
+
export const catalogFacetsSchema = z.object({
|
|
78
|
+
field: z.enum(catalogFacetFields),
|
|
79
|
+
stockedOnly: z.boolean().default(true).optional(),
|
|
80
|
+
limit: z.number().int().min(1).max(100).default(60).optional(),
|
|
81
|
+
sampleSize: z
|
|
82
|
+
.number()
|
|
83
|
+
.int()
|
|
84
|
+
.min(1)
|
|
85
|
+
.max(CATALOG_INTELLIGENCE_MAX_SAMPLE_SIZE)
|
|
86
|
+
.default(SUPPLIER_AGGREGATE_DEFAULT_SAMPLE_SIZE)
|
|
87
|
+
.optional(),
|
|
88
|
+
});
|
|
89
|
+
export const catalogRankObjectives = ['premium', 'value', 'fresh_arrival', 'rare_origin'];
|
|
90
|
+
export const catalogRankSchema = z.object({
|
|
91
|
+
objective: z.enum(catalogRankObjectives).default('premium').optional(),
|
|
92
|
+
stockedOnly: z.boolean().default(true).optional(),
|
|
93
|
+
supplier: z.string().optional(),
|
|
94
|
+
country: z.string().optional(),
|
|
95
|
+
process: z.string().optional(),
|
|
96
|
+
priceMax: z.number().optional(),
|
|
97
|
+
minScore: z.number().optional(),
|
|
98
|
+
nonWholesaleOnly: z.boolean().default(false).optional(),
|
|
99
|
+
limit: z.number().int().min(1).max(50).default(10).optional(),
|
|
100
|
+
sampleSize: z
|
|
101
|
+
.number()
|
|
102
|
+
.int()
|
|
103
|
+
.min(1)
|
|
104
|
+
.max(CATALOG_INTELLIGENCE_MAX_SAMPLE_SIZE)
|
|
105
|
+
.default(SUPPLIER_AGGREGATE_DEFAULT_SAMPLE_SIZE)
|
|
106
|
+
.optional(),
|
|
107
|
+
});
|
|
108
|
+
export const catalogSimilarityModes = ['all', 'likely_same', 'similar_profile'];
|
|
109
|
+
export const getCatalogSimilaritySchema = z.object({
|
|
110
|
+
coffee_id: z
|
|
111
|
+
.number()
|
|
112
|
+
.int()
|
|
113
|
+
.positive()
|
|
114
|
+
.describe('The coffee_catalog ID to request canonical similarity for'),
|
|
115
|
+
threshold: z
|
|
116
|
+
.number()
|
|
117
|
+
.min(0.5)
|
|
118
|
+
.max(0.99)
|
|
119
|
+
.default(0.7)
|
|
120
|
+
.optional()
|
|
121
|
+
.describe('Minimum canonical similarity threshold (0.5-0.99)'),
|
|
122
|
+
limit: z
|
|
123
|
+
.number()
|
|
124
|
+
.int()
|
|
125
|
+
.min(1)
|
|
126
|
+
.max(25)
|
|
127
|
+
.default(10)
|
|
128
|
+
.optional()
|
|
129
|
+
.describe('Maximum canonical similarity results to request'),
|
|
130
|
+
stockedOnly: z
|
|
131
|
+
.boolean()
|
|
132
|
+
.default(false)
|
|
133
|
+
.optional()
|
|
134
|
+
.describe('Restrict similarity results to currently stocked coffees'),
|
|
135
|
+
mode: z.enum(catalogSimilarityModes).default('all').optional().describe('Canonical group filter'),
|
|
136
|
+
});
|
|
25
137
|
export const findSimilarBeansSchema = z.object({
|
|
26
138
|
coffee_id: z
|
|
27
139
|
.number()
|
|
@@ -57,6 +169,377 @@ export function sanitizeFilterValue(value) {
|
|
|
57
169
|
function getPerLbPrice(item) {
|
|
58
170
|
return item.price_tiers?.[0]?.price ?? item.price_per_lb ?? item.cost_lb ?? null;
|
|
59
171
|
}
|
|
172
|
+
function round(value, places = 2) {
|
|
173
|
+
const multiplier = 10 ** places;
|
|
174
|
+
return Math.round(value * multiplier) / multiplier;
|
|
175
|
+
}
|
|
176
|
+
function average(values) {
|
|
177
|
+
if (values.length === 0)
|
|
178
|
+
return null;
|
|
179
|
+
return round(values.reduce((sum, value) => sum + value, 0) / values.length);
|
|
180
|
+
}
|
|
181
|
+
function uniqueSorted(values, limit = 10) {
|
|
182
|
+
return [
|
|
183
|
+
...new Set(values.map((value) => value?.trim()).filter((value) => Boolean(value))),
|
|
184
|
+
]
|
|
185
|
+
.sort((a, b) => a.localeCompare(b))
|
|
186
|
+
.slice(0, limit);
|
|
187
|
+
}
|
|
188
|
+
export function summarizePurveyorScore(scoreValueOrItem) {
|
|
189
|
+
const item = typeof scoreValueOrItem === 'object' && scoreValueOrItem !== null
|
|
190
|
+
? scoreValueOrItem
|
|
191
|
+
: undefined;
|
|
192
|
+
const value = item
|
|
193
|
+
? (item.purveyor_score ?? null)
|
|
194
|
+
: typeof scoreValueOrItem === 'number'
|
|
195
|
+
? scoreValueOrItem
|
|
196
|
+
: null;
|
|
197
|
+
const base = {
|
|
198
|
+
source: 'purveyor_score',
|
|
199
|
+
confidence: item?.purveyor_score_confidence ?? null,
|
|
200
|
+
tier: item?.purveyor_score_tier ?? null,
|
|
201
|
+
factors: item?.purveyor_score_factors ?? null,
|
|
202
|
+
version: item?.purveyor_score_version ?? null,
|
|
203
|
+
updated_at: item?.purveyor_score_updated_at ?? null,
|
|
204
|
+
};
|
|
205
|
+
if (value === null) {
|
|
206
|
+
return {
|
|
207
|
+
value: null,
|
|
208
|
+
band: 'unscored',
|
|
209
|
+
...base,
|
|
210
|
+
note: 'No Purveyor Score is available on this catalog row.',
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
if (value >= 90) {
|
|
214
|
+
return {
|
|
215
|
+
value,
|
|
216
|
+
band: 'premium',
|
|
217
|
+
...base,
|
|
218
|
+
note: 'High Purveyor Score; inspect confidence and factor breakdown before treating it as a premium catalog candidate.',
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
if (value >= 85) {
|
|
222
|
+
return {
|
|
223
|
+
value,
|
|
224
|
+
band: 'strong',
|
|
225
|
+
...base,
|
|
226
|
+
note: 'Strong Purveyor Score; compare confidence, factor breakdown, price, provenance, and fit before selecting.',
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
return {
|
|
230
|
+
value,
|
|
231
|
+
band: 'scored',
|
|
232
|
+
...base,
|
|
233
|
+
note: 'Purveyor Score is available; ranking still depends on confidence, factor breakdown, and sourcing context.',
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
function getPurveyorScoreValue(item) {
|
|
237
|
+
return item.purveyor_score ?? null;
|
|
238
|
+
}
|
|
239
|
+
function buildRankSignals(item) {
|
|
240
|
+
const signals = [];
|
|
241
|
+
const score = summarizePurveyorScore(item);
|
|
242
|
+
if (score.value !== null)
|
|
243
|
+
signals.push(`purveyor_score=${score.value}`);
|
|
244
|
+
if (score.confidence !== null)
|
|
245
|
+
signals.push(`purveyor_score_confidence=${score.confidence}`);
|
|
246
|
+
if (score.tier)
|
|
247
|
+
signals.push(`purveyor_score_tier=${score.tier}`);
|
|
248
|
+
const price = getPerLbPrice(item);
|
|
249
|
+
if (price !== null)
|
|
250
|
+
signals.push(`price_per_lb=${price}`);
|
|
251
|
+
if (item.stocked === true)
|
|
252
|
+
signals.push('currently_stocked');
|
|
253
|
+
if (item.country)
|
|
254
|
+
signals.push(`origin=${item.country}`);
|
|
255
|
+
if (item.processing_base_method ?? item.processing) {
|
|
256
|
+
signals.push(`process=${item.processing_base_method ?? item.processing}`);
|
|
257
|
+
}
|
|
258
|
+
return signals;
|
|
259
|
+
}
|
|
260
|
+
function toPremiumRankedItem(item, rank) {
|
|
261
|
+
return {
|
|
262
|
+
rank,
|
|
263
|
+
id: item.id,
|
|
264
|
+
name: item.name,
|
|
265
|
+
supplier: item.source,
|
|
266
|
+
origin: {
|
|
267
|
+
continent: item.continent,
|
|
268
|
+
country: item.country,
|
|
269
|
+
region: item.region,
|
|
270
|
+
},
|
|
271
|
+
processing: {
|
|
272
|
+
label: item.processing,
|
|
273
|
+
base_method: item.processing_base_method,
|
|
274
|
+
fermentation_type: item.fermentation_type,
|
|
275
|
+
drying_method: item.drying_method,
|
|
276
|
+
},
|
|
277
|
+
purveyor_score: summarizePurveyorScore(item),
|
|
278
|
+
pricing: {
|
|
279
|
+
price_per_lb: item.price_per_lb,
|
|
280
|
+
cost_lb: item.cost_lb,
|
|
281
|
+
price_tiers: item.price_tiers,
|
|
282
|
+
},
|
|
283
|
+
stocked: item.stocked,
|
|
284
|
+
stocked_date: item.stocked_date,
|
|
285
|
+
signals: buildRankSignals(item),
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
export function computeCatalogPremiumRanking(items, opts = {}) {
|
|
289
|
+
const limit = opts.limit ?? 10;
|
|
290
|
+
const includeUnscored = opts.includeUnscored ?? false;
|
|
291
|
+
const minScore = opts.minScore;
|
|
292
|
+
return items
|
|
293
|
+
.filter((item) => includeUnscored || getPurveyorScoreValue(item) !== null)
|
|
294
|
+
.filter((item) => minScore === undefined || (getPurveyorScoreValue(item) ?? -Infinity) >= minScore)
|
|
295
|
+
.sort((a, b) => {
|
|
296
|
+
const scoreDelta = (getPurveyorScoreValue(b) ?? -Infinity) - (getPurveyorScoreValue(a) ?? -Infinity);
|
|
297
|
+
if (scoreDelta !== 0)
|
|
298
|
+
return scoreDelta;
|
|
299
|
+
const aPrice = getPerLbPrice(a) ?? Infinity;
|
|
300
|
+
const bPrice = getPerLbPrice(b) ?? Infinity;
|
|
301
|
+
if (aPrice !== bPrice)
|
|
302
|
+
return aPrice - bPrice;
|
|
303
|
+
return (a.name ?? '').localeCompare(b.name ?? '');
|
|
304
|
+
})
|
|
305
|
+
.slice(0, limit)
|
|
306
|
+
.map((item, index) => toPremiumRankedItem(item, index + 1));
|
|
307
|
+
}
|
|
308
|
+
export function computeSupplierAggregates(items, opts = {}) {
|
|
309
|
+
const bySupplier = new Map();
|
|
310
|
+
for (const item of items) {
|
|
311
|
+
const supplier = item.source?.trim() || 'Unknown supplier';
|
|
312
|
+
const group = bySupplier.get(supplier) ?? [];
|
|
313
|
+
group.push(item);
|
|
314
|
+
bySupplier.set(supplier, group);
|
|
315
|
+
}
|
|
316
|
+
const topCoffees = opts.topCoffees ?? 5;
|
|
317
|
+
const minCoffees = opts.minCoffees ?? 1;
|
|
318
|
+
return [...bySupplier.entries()]
|
|
319
|
+
.map(([supplier, supplierItems]) => {
|
|
320
|
+
const scores = supplierItems
|
|
321
|
+
.map((item) => getPurveyorScoreValue(item))
|
|
322
|
+
.filter((score) => score !== null);
|
|
323
|
+
const confidences = supplierItems
|
|
324
|
+
.map((item) => item.purveyor_score_confidence)
|
|
325
|
+
.filter((confidence) => confidence !== null);
|
|
326
|
+
const prices = supplierItems
|
|
327
|
+
.map((item) => getPerLbPrice(item))
|
|
328
|
+
.filter((p) => p !== null);
|
|
329
|
+
return {
|
|
330
|
+
supplier,
|
|
331
|
+
total: supplierItems.length,
|
|
332
|
+
stocked: supplierItems.filter((item) => item.stocked === true).length,
|
|
333
|
+
score: {
|
|
334
|
+
average: average(scores),
|
|
335
|
+
coverage: supplierItems.length === 0 ? 0 : round(scores.length / supplierItems.length, 3),
|
|
336
|
+
scored_count: scores.length,
|
|
337
|
+
top_score: scores.length > 0 ? Math.max(...scores) : null,
|
|
338
|
+
average_confidence: average(confidences),
|
|
339
|
+
confidence_coverage: supplierItems.length === 0 ? 0 : round(confidences.length / supplierItems.length, 3),
|
|
340
|
+
},
|
|
341
|
+
price: {
|
|
342
|
+
average_per_lb: average(prices),
|
|
343
|
+
min_per_lb: prices.length > 0 ? Math.min(...prices) : null,
|
|
344
|
+
max_per_lb: prices.length > 0 ? Math.max(...prices) : null,
|
|
345
|
+
},
|
|
346
|
+
origins: uniqueSorted(supplierItems.flatMap((item) => [item.country, item.continent])),
|
|
347
|
+
processing_methods: uniqueSorted(supplierItems.map((item) => item.processing_base_method ?? item.processing)),
|
|
348
|
+
top_coffees: computeCatalogPremiumRanking(supplierItems, {
|
|
349
|
+
limit: topCoffees,
|
|
350
|
+
includeUnscored: true,
|
|
351
|
+
}),
|
|
352
|
+
};
|
|
353
|
+
})
|
|
354
|
+
.filter((supplier) => supplier.total >= minCoffees)
|
|
355
|
+
.sort((a, b) => {
|
|
356
|
+
const scoreDelta = (b.score.average ?? -Infinity) - (a.score.average ?? -Infinity);
|
|
357
|
+
if (scoreDelta !== 0)
|
|
358
|
+
return scoreDelta;
|
|
359
|
+
const stockedDelta = b.stocked - a.stocked;
|
|
360
|
+
if (stockedDelta !== 0)
|
|
361
|
+
return stockedDelta;
|
|
362
|
+
return a.supplier.localeCompare(b.supplier);
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
const CATALOG_API_SORT_MAP = {
|
|
366
|
+
price: { field: 'price_per_lb', direction: 'asc' },
|
|
367
|
+
'price-desc': { field: 'price_per_lb', direction: 'desc' },
|
|
368
|
+
name: { field: 'name', direction: 'asc' },
|
|
369
|
+
origin: { field: 'country', direction: 'asc' },
|
|
370
|
+
newest: { field: 'stocked_date', direction: 'desc' },
|
|
371
|
+
};
|
|
372
|
+
function getCatalogApiBaseUrl() {
|
|
373
|
+
return (process.env.PURVEYORS_BASE_URL ?? 'https://www.purveyors.io').replace(/\/+$/, '');
|
|
374
|
+
}
|
|
375
|
+
async function getCatalogApiAuthHeader(supabase) {
|
|
376
|
+
const apiKey = process.env.PARCHMENT_API_KEY ?? process.env.PURVEYORS_API_KEY;
|
|
377
|
+
if (apiKey) {
|
|
378
|
+
return `Bearer ${apiKey}`;
|
|
379
|
+
}
|
|
380
|
+
const { data: { session }, } = await supabase.auth.getSession();
|
|
381
|
+
if (!session?.access_token) {
|
|
382
|
+
throw new AuthError('Catalog API reads require a Purveyors session or API key. Run `purvey auth login`, or set PARCHMENT_API_KEY/PURVEYORS_API_KEY for API-backed catalog reads.');
|
|
383
|
+
}
|
|
384
|
+
return `Bearer ${session.access_token}`;
|
|
385
|
+
}
|
|
386
|
+
function appendSearchParam(params, name, value) {
|
|
387
|
+
if (value === undefined)
|
|
388
|
+
return;
|
|
389
|
+
params.append(name, String(value));
|
|
390
|
+
}
|
|
391
|
+
function hasCatalogIdFilter(parsed) {
|
|
392
|
+
return parsed.ids !== undefined && parsed.ids.length > 0;
|
|
393
|
+
}
|
|
394
|
+
function assertCatalogApiCompatibleSearch(parsed) {
|
|
395
|
+
const unsupportedFilters = [];
|
|
396
|
+
if (parsed.flavor)
|
|
397
|
+
unsupportedFilters.push('--flavor');
|
|
398
|
+
if (parsed.supplier)
|
|
399
|
+
unsupportedFilters.push('--supplier');
|
|
400
|
+
if (parsed.dryingMethod)
|
|
401
|
+
unsupportedFilters.push('--drying-method');
|
|
402
|
+
if (parsed.sort === 'newest')
|
|
403
|
+
unsupportedFilters.push('--sort newest');
|
|
404
|
+
if (unsupportedFilters.length > 0) {
|
|
405
|
+
throw new PrvrsError('INVALID_ARGUMENT', `--include-proof uses /v1/catalog and cannot safely preserve ${unsupportedFilters.join(', ')} yet. Omit those filters or run the default catalog search without --include-proof.`);
|
|
406
|
+
}
|
|
407
|
+
if (hasCatalogIdFilter(parsed))
|
|
408
|
+
return;
|
|
409
|
+
const offset = parsed.offset ?? 0;
|
|
410
|
+
if (offset > 0 && offset % parsed.limit !== 0) {
|
|
411
|
+
throw new PrvrsError('INVALID_ARGUMENT', `--include-proof uses /v1/catalog page-based pagination; --offset must be a multiple of --limit. Received offset=${offset}, limit=${parsed.limit}.`);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
function buildCatalogApiUrl(parsed) {
|
|
415
|
+
assertCatalogApiCompatibleSearch(parsed);
|
|
416
|
+
const url = new URL('/v1/catalog', getCatalogApiBaseUrl());
|
|
417
|
+
const params = url.searchParams;
|
|
418
|
+
params.set('include', 'proof');
|
|
419
|
+
params.set('stocked', parsed.stocked ? 'true' : 'all');
|
|
420
|
+
appendSearchParam(params, 'origin', parsed.origin);
|
|
421
|
+
appendSearchParam(params, 'processing', parsed.process);
|
|
422
|
+
appendSearchParam(params, 'processing_base_method', parsed.processingBaseMethod);
|
|
423
|
+
appendSearchParam(params, 'fermentation_type', parsed.fermentationType);
|
|
424
|
+
appendSearchParam(params, 'process_additive', parsed.processAdditive);
|
|
425
|
+
appendSearchParam(params, 'processing_disclosure_level', parsed.processingDisclosureLevel);
|
|
426
|
+
appendSearchParam(params, 'processing_confidence_min', parsed.processingConfidenceMin);
|
|
427
|
+
appendSearchParam(params, 'price_per_lb_min', parsed.priceMin);
|
|
428
|
+
appendSearchParam(params, 'price_per_lb_max', parsed.priceMax);
|
|
429
|
+
appendSearchParam(params, 'name', parsed.name);
|
|
430
|
+
appendSearchParam(params, 'cultivar_detail', parsed.variety);
|
|
431
|
+
appendSearchParam(params, 'stocked_days', parsed.stockedDays);
|
|
432
|
+
for (const id of parsed.ids ?? []) {
|
|
433
|
+
params.append('ids', String(id));
|
|
434
|
+
}
|
|
435
|
+
const sort = parsed.sort ? CATALOG_API_SORT_MAP[parsed.sort] : undefined;
|
|
436
|
+
if (sort) {
|
|
437
|
+
params.set('sortField', sort.field);
|
|
438
|
+
params.set('sortDirection', sort.direction);
|
|
439
|
+
}
|
|
440
|
+
if (!hasCatalogIdFilter(parsed)) {
|
|
441
|
+
const offset = parsed.offset ?? 0;
|
|
442
|
+
const limit = parsed.limit;
|
|
443
|
+
params.set('limit', String(limit));
|
|
444
|
+
if (offset > 0) {
|
|
445
|
+
params.set('page', String(Math.floor(offset / limit) + 1));
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return url;
|
|
449
|
+
}
|
|
450
|
+
async function parseCatalogApiError(response, context = 'include=proof') {
|
|
451
|
+
let body;
|
|
452
|
+
try {
|
|
453
|
+
body = (await response.json());
|
|
454
|
+
}
|
|
455
|
+
catch {
|
|
456
|
+
body = undefined;
|
|
457
|
+
}
|
|
458
|
+
const serverMessage = typeof body?.message === 'string'
|
|
459
|
+
? body.message
|
|
460
|
+
: typeof body?.error === 'string'
|
|
461
|
+
? body.error
|
|
462
|
+
: response.statusText;
|
|
463
|
+
const details = { status: response.status, body };
|
|
464
|
+
if (response.status === 401 || response.status === 403) {
|
|
465
|
+
return new AuthError(`Catalog API authentication failed: ${serverMessage}`, details);
|
|
466
|
+
}
|
|
467
|
+
if (response.status === 400) {
|
|
468
|
+
return new PrvrsError('INVALID_ARGUMENT', `Catalog API rejected ${context}: ${serverMessage}. Verify the configured Purveyors API endpoint supports this canonical catalog contract.`, details);
|
|
469
|
+
}
|
|
470
|
+
if (response.status === 404) {
|
|
471
|
+
if (context === '/v1/catalog/{id}/similar') {
|
|
472
|
+
if (/^Catalog coffee \d+ was not found$/i.test(serverMessage)) {
|
|
473
|
+
return new PrvrsError('NOT_FOUND', `Catalog similarity target not found: ${serverMessage}`, details);
|
|
474
|
+
}
|
|
475
|
+
return new PrvrsError('CONFIG_ERROR', `Catalog similarity API endpoint not found. Set PURVEYORS_BASE_URL to a Purveyors deployment that supports ${context}.`, details);
|
|
476
|
+
}
|
|
477
|
+
return new PrvrsError('CONFIG_ERROR', `Catalog API endpoint not found. Set PURVEYORS_BASE_URL to a Purveyors deployment that supports ${context}.`, details);
|
|
478
|
+
}
|
|
479
|
+
return new PrvrsError('GENERAL_ERROR', `Catalog API request failed (${response.status}): ${serverMessage}`, details);
|
|
480
|
+
}
|
|
481
|
+
function buildCatalogSimilarityApiUrl(parsed) {
|
|
482
|
+
const url = new URL(`/v1/catalog/${parsed.coffee_id}/similar`, getCatalogApiBaseUrl());
|
|
483
|
+
const params = url.searchParams;
|
|
484
|
+
params.set('threshold', String(parsed.threshold ?? 0.7));
|
|
485
|
+
params.set('limit', String(parsed.limit ?? 10));
|
|
486
|
+
params.set('stocked_only', String(parsed.stockedOnly ?? false));
|
|
487
|
+
if ((parsed.mode ?? 'all') !== 'all') {
|
|
488
|
+
params.set('mode', parsed.mode ?? 'all');
|
|
489
|
+
}
|
|
490
|
+
return url;
|
|
491
|
+
}
|
|
492
|
+
function isCatalogSimilarityResponse(value) {
|
|
493
|
+
if (!value || typeof value !== 'object')
|
|
494
|
+
return false;
|
|
495
|
+
const record = value;
|
|
496
|
+
const data = record.data;
|
|
497
|
+
const groups = data?.groups;
|
|
498
|
+
const meta = record.meta;
|
|
499
|
+
return Boolean(data &&
|
|
500
|
+
typeof data === 'object' &&
|
|
501
|
+
data.target &&
|
|
502
|
+
groups &&
|
|
503
|
+
Array.isArray(groups.canonical_candidates) &&
|
|
504
|
+
Array.isArray(groups.similar_recommendations) &&
|
|
505
|
+
meta &&
|
|
506
|
+
typeof meta.classification_version === 'string' &&
|
|
507
|
+
typeof meta.query_strategy === 'string');
|
|
508
|
+
}
|
|
509
|
+
async function fetchCatalogSimilarityApi(supabase, parsed) {
|
|
510
|
+
const url = buildCatalogSimilarityApiUrl(parsed);
|
|
511
|
+
const response = await fetch(url, {
|
|
512
|
+
headers: {
|
|
513
|
+
Accept: 'application/json',
|
|
514
|
+
Authorization: await getCatalogApiAuthHeader(supabase),
|
|
515
|
+
},
|
|
516
|
+
});
|
|
517
|
+
if (!response.ok) {
|
|
518
|
+
throw await parseCatalogApiError(response, '/v1/catalog/{id}/similar');
|
|
519
|
+
}
|
|
520
|
+
const envelope = (await response.json());
|
|
521
|
+
if (!isCatalogSimilarityResponse(envelope)) {
|
|
522
|
+
throw new PrvrsError('GENERAL_ERROR', 'Catalog similarity API returned an unexpected response shape; expected canonical { data: { target, groups: { canonical_candidates, similar_recommendations } }, meta }.', { body: envelope });
|
|
523
|
+
}
|
|
524
|
+
return envelope;
|
|
525
|
+
}
|
|
526
|
+
async function fetchCatalogApiItems(supabase, parsed) {
|
|
527
|
+
const url = buildCatalogApiUrl(parsed);
|
|
528
|
+
const response = await fetch(url, {
|
|
529
|
+
headers: {
|
|
530
|
+
Accept: 'application/json',
|
|
531
|
+
Authorization: await getCatalogApiAuthHeader(supabase),
|
|
532
|
+
},
|
|
533
|
+
});
|
|
534
|
+
if (!response.ok) {
|
|
535
|
+
throw await parseCatalogApiError(response);
|
|
536
|
+
}
|
|
537
|
+
const envelope = (await response.json());
|
|
538
|
+
if (!Array.isArray(envelope.data)) {
|
|
539
|
+
throw new PrvrsError('GENERAL_ERROR', 'Catalog API returned an unexpected response shape for include=proof; expected { data: [...] }.', { body: envelope });
|
|
540
|
+
}
|
|
541
|
+
return envelope.data;
|
|
542
|
+
}
|
|
60
543
|
/**
|
|
61
544
|
* Aggregate stats from an array of catalog items.
|
|
62
545
|
* Pure function — no I/O, safe to unit test.
|
|
@@ -84,6 +567,9 @@ export function computeCatalogStats(items) {
|
|
|
84
567
|
*/
|
|
85
568
|
export async function searchCatalog(supabase, opts) {
|
|
86
569
|
const parsed = searchCatalogSchema.parse(opts);
|
|
570
|
+
if (parsed.includeProof) {
|
|
571
|
+
return fetchCatalogApiItems(supabase, parsed);
|
|
572
|
+
}
|
|
87
573
|
let query = supabase.from('coffee_catalog').select('*');
|
|
88
574
|
if (parsed.origin) {
|
|
89
575
|
const o = sanitizeFilterValue(parsed.origin);
|
|
@@ -93,6 +579,21 @@ export async function searchCatalog(supabase, opts) {
|
|
|
93
579
|
const p = sanitizeFilterValue(parsed.process);
|
|
94
580
|
query = query.ilike('processing', `%${p}%`);
|
|
95
581
|
}
|
|
582
|
+
if (parsed.processingBaseMethod) {
|
|
583
|
+
query = query.eq('processing_base_method', sanitizeFilterValue(parsed.processingBaseMethod));
|
|
584
|
+
}
|
|
585
|
+
if (parsed.fermentationType) {
|
|
586
|
+
query = query.eq('fermentation_type', sanitizeFilterValue(parsed.fermentationType));
|
|
587
|
+
}
|
|
588
|
+
if (parsed.processAdditive) {
|
|
589
|
+
query = query.contains('process_additives', [sanitizeFilterValue(parsed.processAdditive)]);
|
|
590
|
+
}
|
|
591
|
+
if (parsed.processingDisclosureLevel) {
|
|
592
|
+
query = query.eq('processing_disclosure_level', sanitizeFilterValue(parsed.processingDisclosureLevel));
|
|
593
|
+
}
|
|
594
|
+
if (parsed.processingConfidenceMin !== undefined) {
|
|
595
|
+
query = query.gte('processing_confidence', parsed.processingConfidenceMin);
|
|
596
|
+
}
|
|
96
597
|
if (parsed.priceMin !== undefined) {
|
|
97
598
|
query = query.gte('price_per_lb', parsed.priceMin);
|
|
98
599
|
}
|
|
@@ -175,8 +676,20 @@ export async function searchCatalog(supabase, opts) {
|
|
|
175
676
|
/**
|
|
176
677
|
* Fetch a single catalog item by ID.
|
|
177
678
|
*/
|
|
178
|
-
export async function getCatalog(supabase, id) {
|
|
179
|
-
getCatalogSchema.parse({ id });
|
|
679
|
+
export async function getCatalog(supabase, id, opts = {}) {
|
|
680
|
+
const parsed = getCatalogSchema.parse({ id, includeProof: opts.includeProof });
|
|
681
|
+
if (parsed.includeProof) {
|
|
682
|
+
const rows = await fetchCatalogApiItems(supabase, {
|
|
683
|
+
ids: [parsed.id],
|
|
684
|
+
limit: 1,
|
|
685
|
+
includeProof: true,
|
|
686
|
+
});
|
|
687
|
+
const item = rows[0];
|
|
688
|
+
if (!item) {
|
|
689
|
+
throw new PrvrsError('NOT_FOUND', `Coffee ID ${parsed.id} not found in catalog.`);
|
|
690
|
+
}
|
|
691
|
+
return item;
|
|
692
|
+
}
|
|
180
693
|
const { data, error } = await supabase.from('coffee_catalog').select('*').eq('id', id).single();
|
|
181
694
|
if (error)
|
|
182
695
|
throw error;
|
|
@@ -193,6 +706,409 @@ export async function getCatalogStats(supabase) {
|
|
|
193
706
|
throw error;
|
|
194
707
|
return computeCatalogStats((data ?? []));
|
|
195
708
|
}
|
|
709
|
+
function buildCatalogIntelligenceQuery(supabase, parsed, options = {}) {
|
|
710
|
+
let query = supabase.from('coffee_catalog').select('*');
|
|
711
|
+
if (parsed.origin) {
|
|
712
|
+
const origin = sanitizeFilterValue(parsed.origin);
|
|
713
|
+
query = query.or(`country.ilike.%${origin}%,continent.ilike.%${origin}%,region.ilike.%${origin}%`);
|
|
714
|
+
}
|
|
715
|
+
if (parsed.process) {
|
|
716
|
+
const process = sanitizeFilterValue(parsed.process);
|
|
717
|
+
query = query.or(`processing.ilike.%${process}%,processing_base_method.ilike.%${process}%`);
|
|
718
|
+
}
|
|
719
|
+
if (parsed.supplier) {
|
|
720
|
+
query = query.ilike('source', `%${sanitizeFilterValue(parsed.supplier)}%`);
|
|
721
|
+
}
|
|
722
|
+
if (parsed.stocked !== undefined) {
|
|
723
|
+
query = query.eq('stocked', parsed.stocked);
|
|
724
|
+
}
|
|
725
|
+
if (parsed.priceMax !== undefined) {
|
|
726
|
+
query = query.lte('price_per_lb', parsed.priceMax);
|
|
727
|
+
}
|
|
728
|
+
if (parsed.minScore !== undefined) {
|
|
729
|
+
query = query.gte('purveyor_score', parsed.minScore);
|
|
730
|
+
}
|
|
731
|
+
if (parsed.orderByScore) {
|
|
732
|
+
query = query.order('purveyor_score', { ascending: false, nullsFirst: false });
|
|
733
|
+
}
|
|
734
|
+
else {
|
|
735
|
+
query = query.order('source', { ascending: true, nullsFirst: false });
|
|
736
|
+
}
|
|
737
|
+
if (options.applyRange ?? true) {
|
|
738
|
+
const sampleSize = parsed.sampleSize ?? CATALOG_PREMIUM_DEFAULT_SAMPLE_SIZE;
|
|
739
|
+
query = query.range(0, sampleSize - 1);
|
|
740
|
+
}
|
|
741
|
+
return query;
|
|
742
|
+
}
|
|
743
|
+
async function fetchSupplierAggregateRows(supabase, parsed) {
|
|
744
|
+
const sampleSize = parsed.sampleSize ?? SUPPLIER_AGGREGATE_DEFAULT_SAMPLE_SIZE;
|
|
745
|
+
const pageSize = Math.min(sampleSize, SUPABASE_DATA_API_MAX_PAGE_SIZE);
|
|
746
|
+
const rows = [];
|
|
747
|
+
for (let offset = 0;; offset += pageSize) {
|
|
748
|
+
const { data, error } = await buildCatalogIntelligenceQuery(supabase, {
|
|
749
|
+
supplier: parsed.supplier,
|
|
750
|
+
stocked: parsed.stocked,
|
|
751
|
+
}, { applyRange: false })
|
|
752
|
+
.order('id', { ascending: true })
|
|
753
|
+
.range(offset, offset + pageSize - 1);
|
|
754
|
+
if (error)
|
|
755
|
+
throw error;
|
|
756
|
+
const page = (data ?? []);
|
|
757
|
+
rows.push(...page);
|
|
758
|
+
if (page.length < pageSize)
|
|
759
|
+
break;
|
|
760
|
+
}
|
|
761
|
+
return rows;
|
|
762
|
+
}
|
|
763
|
+
async function fetchCatalogPremiumSampleRows(supabase, parsed, sampleSize) {
|
|
764
|
+
const targetRows = sampleSize + 1;
|
|
765
|
+
const pageSize = Math.min(targetRows, SUPABASE_DATA_API_MAX_PAGE_SIZE);
|
|
766
|
+
const rows = [];
|
|
767
|
+
for (let offset = 0; rows.length < targetRows; offset += pageSize) {
|
|
768
|
+
const pageEnd = Math.min(offset + pageSize - 1, targetRows - 1);
|
|
769
|
+
const { data, error } = await buildCatalogIntelligenceQuery(supabase, {
|
|
770
|
+
...parsed,
|
|
771
|
+
orderByScore: true,
|
|
772
|
+
}, { applyRange: false })
|
|
773
|
+
.order('id', { ascending: true })
|
|
774
|
+
.range(offset, pageEnd);
|
|
775
|
+
if (error)
|
|
776
|
+
throw error;
|
|
777
|
+
const page = (data ?? []);
|
|
778
|
+
rows.push(...page);
|
|
779
|
+
if (page.length < pageEnd - offset + 1)
|
|
780
|
+
break;
|
|
781
|
+
}
|
|
782
|
+
return {
|
|
783
|
+
sampledRows: rows.slice(0, sampleSize),
|
|
784
|
+
truncated: rows.length > sampleSize,
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
const catalogIntelligenceCaveats = [
|
|
788
|
+
'Purveyor Score is read from coffee_catalog.purveyor_score with confidence, tier, factor breakdown, version, and update metadata; the CLI does not recompute the upstream score model.',
|
|
789
|
+
'Ranking is catalog-only and does not account for a roaster’s owned inventory, roast history, or target menu fit.',
|
|
790
|
+
];
|
|
791
|
+
const catalogPremiumRankingCaveats = [
|
|
792
|
+
...catalogIntelligenceCaveats,
|
|
793
|
+
'Premium ranking samples catalog rows ordered by purveyor_score descending, then id ascending, before applying agent-facing ranking logic; meta.truncated indicates more rows matched than the requested sample_size.',
|
|
794
|
+
];
|
|
795
|
+
const supplierAggregateCaveats = [
|
|
796
|
+
...catalogIntelligenceCaveats,
|
|
797
|
+
'Supplier aggregates paginate catalog rows ordered by source ascending, then id ascending; meta.sample_size is the requested fetch page size and meta.rows_examined reports the rows included in aggregation.',
|
|
798
|
+
];
|
|
799
|
+
const catalogFacetColumns = {
|
|
800
|
+
supplier: 'source',
|
|
801
|
+
country: 'country',
|
|
802
|
+
processing_base_method: 'processing_base_method',
|
|
803
|
+
fermentation_type: 'fermentation_type',
|
|
804
|
+
drying_method: 'drying_method',
|
|
805
|
+
grade: 'grade',
|
|
806
|
+
wholesale: 'wholesale',
|
|
807
|
+
};
|
|
808
|
+
const catalogFacetCaveats = [
|
|
809
|
+
'Facet counts are computed from catalog rows visible to the current client and are intended for value discovery, not inventory guarantees.',
|
|
810
|
+
];
|
|
811
|
+
const catalogRankingCaveats = [
|
|
812
|
+
...catalogIntelligenceCaveats,
|
|
813
|
+
'Generic catalog ranking samples catalog rows ordered by id ascending before applying deterministic objective-specific ranking; meta.truncated indicates more rows matched than the requested sample_size.',
|
|
814
|
+
];
|
|
815
|
+
function asPositiveNumber(value) {
|
|
816
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null;
|
|
817
|
+
}
|
|
818
|
+
function scoreBasis(item) {
|
|
819
|
+
const score = summarizePurveyorScore(item);
|
|
820
|
+
if (score.value === null)
|
|
821
|
+
return 'no Purveyor Score yet';
|
|
822
|
+
const tier = score.tier ? `, ${score.tier}` : '';
|
|
823
|
+
return `Purveyor Score ${score.value}${tier}`;
|
|
824
|
+
}
|
|
825
|
+
async function fetchCatalogFacetRows(supabase, parsed) {
|
|
826
|
+
const sampleSize = parsed.sampleSize ?? SUPPLIER_AGGREGATE_DEFAULT_SAMPLE_SIZE;
|
|
827
|
+
const targetRows = sampleSize + 1;
|
|
828
|
+
const pageSize = Math.min(targetRows, SUPABASE_DATA_API_MAX_PAGE_SIZE);
|
|
829
|
+
const rows = [];
|
|
830
|
+
const column = catalogFacetColumns[parsed.field];
|
|
831
|
+
for (let offset = 0; rows.length < targetRows; offset += pageSize) {
|
|
832
|
+
const pageEnd = Math.min(offset + pageSize - 1, targetRows - 1);
|
|
833
|
+
let query = supabase.from('coffee_catalog').select(`id, ${String(column)}`);
|
|
834
|
+
if (parsed.stockedOnly ?? true)
|
|
835
|
+
query = query.eq('stocked', true);
|
|
836
|
+
const { data, error } = await query.order('id', { ascending: true }).range(offset, pageEnd);
|
|
837
|
+
if (error)
|
|
838
|
+
throw error;
|
|
839
|
+
const page = (data ?? []);
|
|
840
|
+
rows.push(...page);
|
|
841
|
+
if (page.length < pageEnd - offset + 1)
|
|
842
|
+
break;
|
|
843
|
+
}
|
|
844
|
+
return { rows: rows.slice(0, sampleSize), truncated: rows.length > sampleSize };
|
|
845
|
+
}
|
|
846
|
+
async function fetchCatalogRankRows(supabase, parsed) {
|
|
847
|
+
const sampleSize = parsed.sampleSize ?? SUPPLIER_AGGREGATE_DEFAULT_SAMPLE_SIZE;
|
|
848
|
+
const targetRows = sampleSize + 1;
|
|
849
|
+
const pageSize = Math.min(targetRows, SUPABASE_DATA_API_MAX_PAGE_SIZE);
|
|
850
|
+
const rows = [];
|
|
851
|
+
for (let offset = 0; rows.length < targetRows; offset += pageSize) {
|
|
852
|
+
const pageEnd = Math.min(offset + pageSize - 1, targetRows - 1);
|
|
853
|
+
let query = supabase.from('coffee_catalog').select('*');
|
|
854
|
+
if (parsed.stockedOnly ?? true)
|
|
855
|
+
query = query.eq('stocked', true);
|
|
856
|
+
if (parsed.supplier)
|
|
857
|
+
query = query.ilike('source', `%${sanitizeFilterValue(parsed.supplier)}%`);
|
|
858
|
+
if (parsed.country)
|
|
859
|
+
query = query.ilike('country', `%${sanitizeFilterValue(parsed.country)}%`);
|
|
860
|
+
if (parsed.process) {
|
|
861
|
+
const process = sanitizeFilterValue(parsed.process);
|
|
862
|
+
query = query.or(`processing.ilike.%${process}%,processing_base_method.ilike.%${process}%`);
|
|
863
|
+
}
|
|
864
|
+
if (parsed.priceMax !== undefined)
|
|
865
|
+
query = query.lte('price_per_lb', parsed.priceMax);
|
|
866
|
+
if (parsed.minScore !== undefined)
|
|
867
|
+
query = query.gte('purveyor_score', parsed.minScore);
|
|
868
|
+
if (parsed.nonWholesaleOnly)
|
|
869
|
+
query = query.or('wholesale.is.null,wholesale.eq.false');
|
|
870
|
+
const { data, error } = await query.order('id', { ascending: true }).range(offset, pageEnd);
|
|
871
|
+
if (error)
|
|
872
|
+
throw error;
|
|
873
|
+
const page = (data ?? []);
|
|
874
|
+
rows.push(...page);
|
|
875
|
+
if (page.length < pageEnd - offset + 1)
|
|
876
|
+
break;
|
|
877
|
+
}
|
|
878
|
+
return { rows: rows.slice(0, sampleSize), truncated: rows.length > sampleSize };
|
|
879
|
+
}
|
|
880
|
+
function rankCatalogRows(rows, objective, limit) {
|
|
881
|
+
const byScoreDesc = (a, b) => (getPurveyorScoreValue(b) ?? -Infinity) - (getPurveyorScoreValue(a) ?? -Infinity) ||
|
|
882
|
+
String(b.stocked_date ?? '').localeCompare(String(a.stocked_date ?? '')) ||
|
|
883
|
+
a.id - b.id;
|
|
884
|
+
let ranked;
|
|
885
|
+
switch (objective) {
|
|
886
|
+
case 'premium':
|
|
887
|
+
ranked = [...rows].sort(byScoreDesc).map((row) => ({ row, basis: scoreBasis(row) }));
|
|
888
|
+
break;
|
|
889
|
+
case 'value': {
|
|
890
|
+
ranked = rows
|
|
891
|
+
.map((row) => {
|
|
892
|
+
const score = getPurveyorScoreValue(row);
|
|
893
|
+
const price = asPositiveNumber(getPerLbPrice(row));
|
|
894
|
+
if (score === null || price === null)
|
|
895
|
+
return null;
|
|
896
|
+
return { row, ratio: score / price };
|
|
897
|
+
})
|
|
898
|
+
.filter((entry) => entry !== null)
|
|
899
|
+
.sort((a, b) => b.ratio - a.ratio || byScoreDesc(a.row, b.row))
|
|
900
|
+
.map(({ row, ratio }) => ({
|
|
901
|
+
row,
|
|
902
|
+
basis: `${scoreBasis(row)} at $${getPerLbPrice(row)}/lb (${round(ratio, 1)} score points per dollar)`,
|
|
903
|
+
}));
|
|
904
|
+
break;
|
|
905
|
+
}
|
|
906
|
+
case 'fresh_arrival':
|
|
907
|
+
ranked = [...rows]
|
|
908
|
+
.sort((a, b) => String(b.stocked_date ?? '').localeCompare(String(a.stocked_date ?? '')) ||
|
|
909
|
+
String(b.arrival_date ?? '').localeCompare(String(a.arrival_date ?? '')) ||
|
|
910
|
+
byScoreDesc(a, b))
|
|
911
|
+
.map((row) => ({
|
|
912
|
+
row,
|
|
913
|
+
basis: `stocked ${row.stocked_date ?? 'date unknown'}; ${scoreBasis(row)}`,
|
|
914
|
+
}));
|
|
915
|
+
break;
|
|
916
|
+
case 'rare_origin': {
|
|
917
|
+
const originCounts = new Map();
|
|
918
|
+
for (const row of rows) {
|
|
919
|
+
const country = row.country?.trim();
|
|
920
|
+
if (country)
|
|
921
|
+
originCounts.set(country, (originCounts.get(country) ?? 0) + 1);
|
|
922
|
+
}
|
|
923
|
+
const rarity = (row) => {
|
|
924
|
+
const country = row.country?.trim();
|
|
925
|
+
return country
|
|
926
|
+
? (originCounts.get(country) ?? Number.MAX_SAFE_INTEGER)
|
|
927
|
+
: Number.MAX_SAFE_INTEGER;
|
|
928
|
+
};
|
|
929
|
+
ranked = [...rows]
|
|
930
|
+
.filter((row) => Boolean(row.country?.trim()))
|
|
931
|
+
.sort((a, b) => rarity(a) - rarity(b) || byScoreDesc(a, b))
|
|
932
|
+
.map((row) => ({
|
|
933
|
+
row,
|
|
934
|
+
basis: `${row.country} has ${rarity(row)} matching listing(s); ${scoreBasis(row)}`,
|
|
935
|
+
}));
|
|
936
|
+
break;
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
return ranked.slice(0, limit).map(({ row, basis }, index) => ({
|
|
940
|
+
...row,
|
|
941
|
+
rank: index + 1,
|
|
942
|
+
rank_basis: basis,
|
|
943
|
+
}));
|
|
944
|
+
}
|
|
945
|
+
/** List distinct catalog facet values with counts for agent/client filter discovery. */
|
|
946
|
+
export async function listCatalogFacets(supabase, input) {
|
|
947
|
+
const parsed = catalogFacetsSchema.parse(input);
|
|
948
|
+
const limit = parsed.limit ?? 60;
|
|
949
|
+
const sampleSize = parsed.sampleSize ?? SUPPLIER_AGGREGATE_DEFAULT_SAMPLE_SIZE;
|
|
950
|
+
const { rows, truncated } = await fetchCatalogFacetRows(supabase, parsed);
|
|
951
|
+
const column = catalogFacetColumns[parsed.field];
|
|
952
|
+
const counts = new Map();
|
|
953
|
+
for (const row of rows) {
|
|
954
|
+
const raw = row[column];
|
|
955
|
+
const value = typeof raw === 'boolean' ? String(raw) : typeof raw === 'string' ? raw.trim() : '';
|
|
956
|
+
if (!value)
|
|
957
|
+
continue;
|
|
958
|
+
counts.set(value, (counts.get(value) ?? 0) + 1);
|
|
959
|
+
}
|
|
960
|
+
const values = [...counts.entries()]
|
|
961
|
+
.map(([value, count]) => ({ value, count }))
|
|
962
|
+
.sort((a, b) => b.count - a.count || a.value.localeCompare(b.value));
|
|
963
|
+
return {
|
|
964
|
+
data: values.slice(0, limit),
|
|
965
|
+
meta: {
|
|
966
|
+
resource: 'catalog-facets',
|
|
967
|
+
field: parsed.field,
|
|
968
|
+
source_column: String(column),
|
|
969
|
+
stocked_only: parsed.stockedOnly ?? true,
|
|
970
|
+
scope: (parsed.stockedOnly ?? true) ? 'stocked_only' : 'all_visible',
|
|
971
|
+
sample_size: sampleSize,
|
|
972
|
+
sample_limited: true,
|
|
973
|
+
sample_order: 'id_asc',
|
|
974
|
+
truncated: truncated || values.length > limit,
|
|
975
|
+
rows_examined: rows.length,
|
|
976
|
+
distinct_values: values.length,
|
|
977
|
+
returned: Math.min(values.length, limit),
|
|
978
|
+
caveats: catalogFacetCaveats,
|
|
979
|
+
},
|
|
980
|
+
};
|
|
981
|
+
}
|
|
982
|
+
/** Rank catalog candidates by deterministic product-intelligence objectives. */
|
|
983
|
+
export async function rankCatalog(supabase, input = {}) {
|
|
984
|
+
const parsed = catalogRankSchema.parse(input);
|
|
985
|
+
const sampleSize = parsed.sampleSize ?? SUPPLIER_AGGREGATE_DEFAULT_SAMPLE_SIZE;
|
|
986
|
+
const objective = parsed.objective ?? 'premium';
|
|
987
|
+
const limit = parsed.limit ?? 10;
|
|
988
|
+
const { rows, truncated } = await fetchCatalogRankRows(supabase, parsed);
|
|
989
|
+
const data = rankCatalogRows(rows, objective, limit);
|
|
990
|
+
const caveats = [...catalogRankingCaveats];
|
|
991
|
+
if (objective === 'value') {
|
|
992
|
+
caveats.push('Value objective ranks by Purveyor Score per dollar; rows without score or price are excluded.');
|
|
993
|
+
}
|
|
994
|
+
if (objective === 'rare_origin' && parsed.country) {
|
|
995
|
+
caveats.push('rare_origin combined with a country filter measures rarity only within that country filter.');
|
|
996
|
+
}
|
|
997
|
+
return {
|
|
998
|
+
data,
|
|
999
|
+
meta: {
|
|
1000
|
+
resource: 'catalog-ranking',
|
|
1001
|
+
objective,
|
|
1002
|
+
scoring_source: 'coffee_catalog.purveyor_score',
|
|
1003
|
+
sample_size: sampleSize,
|
|
1004
|
+
sample_limited: true,
|
|
1005
|
+
sample_order: 'id_asc',
|
|
1006
|
+
truncated,
|
|
1007
|
+
candidates_considered: rows.length,
|
|
1008
|
+
returned: data.length,
|
|
1009
|
+
filters: {
|
|
1010
|
+
supplier: parsed.supplier,
|
|
1011
|
+
country: parsed.country,
|
|
1012
|
+
process: parsed.process,
|
|
1013
|
+
stocked_only: parsed.stockedOnly ?? true,
|
|
1014
|
+
scope: (parsed.stockedOnly ?? true) ? 'stocked_only' : 'all_visible',
|
|
1015
|
+
priceMax: parsed.priceMax,
|
|
1016
|
+
minScore: parsed.minScore,
|
|
1017
|
+
nonWholesaleOnly: parsed.nonWholesaleOnly ?? false,
|
|
1018
|
+
},
|
|
1019
|
+
caveats,
|
|
1020
|
+
},
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
/**
|
|
1024
|
+
* Rank premium catalog candidates by Purveyor Score, with pricing and sourcing signals.
|
|
1025
|
+
*/
|
|
1026
|
+
export async function catalogRankPremium(supabase, input = {}) {
|
|
1027
|
+
const parsed = catalogRankPremiumSchema.parse(input);
|
|
1028
|
+
const sampleSize = parsed.sampleSize ?? CATALOG_PREMIUM_DEFAULT_SAMPLE_SIZE;
|
|
1029
|
+
const { sampledRows, truncated } = await fetchCatalogPremiumSampleRows(supabase, parsed, sampleSize);
|
|
1030
|
+
const ranking = computeCatalogPremiumRanking(sampledRows, {
|
|
1031
|
+
limit: parsed.limit ?? 10,
|
|
1032
|
+
includeUnscored: parsed.includeUnscored ?? false,
|
|
1033
|
+
minScore: parsed.minScore,
|
|
1034
|
+
});
|
|
1035
|
+
return {
|
|
1036
|
+
data: ranking,
|
|
1037
|
+
meta: {
|
|
1038
|
+
resource: 'catalog-premium-ranking',
|
|
1039
|
+
scoring_source: 'coffee_catalog.purveyor_score',
|
|
1040
|
+
sample_size: sampleSize,
|
|
1041
|
+
sample_limited: true,
|
|
1042
|
+
sample_order: 'purveyor_score_desc_nulls_last',
|
|
1043
|
+
truncated,
|
|
1044
|
+
returned: ranking.length,
|
|
1045
|
+
filters: {
|
|
1046
|
+
origin: parsed.origin,
|
|
1047
|
+
process: parsed.process,
|
|
1048
|
+
supplier: parsed.supplier,
|
|
1049
|
+
stocked: parsed.stocked,
|
|
1050
|
+
priceMax: parsed.priceMax,
|
|
1051
|
+
minScore: parsed.minScore,
|
|
1052
|
+
includeUnscored: parsed.includeUnscored ?? false,
|
|
1053
|
+
},
|
|
1054
|
+
caveats: catalogPremiumRankingCaveats,
|
|
1055
|
+
},
|
|
1056
|
+
};
|
|
1057
|
+
}
|
|
1058
|
+
async function getSupplierAggregates(supabase, input, resource) {
|
|
1059
|
+
const parsed = supplierAggregateSchema.parse(input);
|
|
1060
|
+
const sampleSize = parsed.sampleSize ?? SUPPLIER_AGGREGATE_DEFAULT_SAMPLE_SIZE;
|
|
1061
|
+
const rows = await fetchSupplierAggregateRows(supabase, {
|
|
1062
|
+
supplier: parsed.supplier,
|
|
1063
|
+
stocked: parsed.stocked,
|
|
1064
|
+
sampleSize,
|
|
1065
|
+
});
|
|
1066
|
+
const aggregates = computeSupplierAggregates(rows, {
|
|
1067
|
+
topCoffees: parsed.topCoffees ?? 5,
|
|
1068
|
+
minCoffees: parsed.minCoffees ?? 1,
|
|
1069
|
+
}).slice(0, parsed.limit ?? 25);
|
|
1070
|
+
return {
|
|
1071
|
+
data: aggregates,
|
|
1072
|
+
meta: {
|
|
1073
|
+
resource,
|
|
1074
|
+
sample_size: sampleSize,
|
|
1075
|
+
sample_limited: false,
|
|
1076
|
+
sample_order: 'source_asc_nulls_last',
|
|
1077
|
+
truncated: false,
|
|
1078
|
+
rows_examined: rows.length,
|
|
1079
|
+
returned: aggregates.length,
|
|
1080
|
+
filters: {
|
|
1081
|
+
supplier: parsed.supplier,
|
|
1082
|
+
stocked: parsed.stocked,
|
|
1083
|
+
minCoffees: parsed.minCoffees ?? 1,
|
|
1084
|
+
},
|
|
1085
|
+
caveats: supplierAggregateCaveats,
|
|
1086
|
+
},
|
|
1087
|
+
};
|
|
1088
|
+
}
|
|
1089
|
+
/** List supplier aggregates from catalog rows. */
|
|
1090
|
+
export async function supplierList(supabase, input = {}) {
|
|
1091
|
+
return getSupplierAggregates(supabase, input, 'supplier-list');
|
|
1092
|
+
}
|
|
1093
|
+
/** Return aggregate detail for a supplier query. */
|
|
1094
|
+
export async function supplierDetail(supabase, input) {
|
|
1095
|
+
const parsed = supplierAggregateSchema.parse(input);
|
|
1096
|
+
if (!parsed.supplier?.trim()) {
|
|
1097
|
+
throw new PrvrsError('INVALID_ARGUMENT', 'supplierDetail requires a non-empty supplier name.');
|
|
1098
|
+
}
|
|
1099
|
+
return getSupplierAggregates(supabase, { ...parsed, limit: parsed.limit ?? 10 }, 'supplier-detail');
|
|
1100
|
+
}
|
|
1101
|
+
/** Rank suppliers by average Purveyor Score, then currently stocked coverage. */
|
|
1102
|
+
export async function supplierRank(supabase, input = {}) {
|
|
1103
|
+
return getSupplierAggregates(supabase, input, 'supplier-rank');
|
|
1104
|
+
}
|
|
1105
|
+
/**
|
|
1106
|
+
* Fetch canonical catalog similarity groups from the beta /v1/catalog/{id}/similar API.
|
|
1107
|
+
*/
|
|
1108
|
+
export async function getCatalogSimilarity(supabase, input) {
|
|
1109
|
+
const parsed = getCatalogSimilaritySchema.parse(input);
|
|
1110
|
+
return fetchCatalogSimilarityApi(supabase, parsed);
|
|
1111
|
+
}
|
|
196
1112
|
/**
|
|
197
1113
|
* Find beans similar to a target coffee using pgvector embedding similarity.
|
|
198
1114
|
* Calls the `find_similar_beans_aggregated` RPC and returns ranked matches.
|