WFDASH-WEBAPP/src/lib/server/quote-items-insights-service.ts
SJCoetz 63048e51c9 feat: add AI quote processing pipeline and admin settings UI
Add complete end-to-end AI-powered quote extraction workflow:
- Disable PB SDK auto-cancellation to support parallel batch requests
- Add required runtime dependencies for AI processing and document parsing
- Implement AI settings backend, operator UI, and REST API routes
- Add quote ingestion, insight population, and manual re-run AI endpoints
- Build service layers for quote items, AI settings, and structured item insights
- Fix attachment upload handling and quote item history blob parsing
- Add supporting migrations, smoke tests, and shared utility functions
- Export previously private helper functions for cross-service reuse
2026-08-09 16:17:29 +02:00

778 lines
26 KiB
TypeScript

/**
* quote-items-insights-service — deterministic compute of
* `quote_items.ai_item_insights` so the agent (Hermes / Giellie) can
* populate every quote item with structured historical context
* without an LLM round-trip.
*
* Replaces the snapshot-from-n8n pattern. Same field, same renderer
* (`components/ai-insights-renderer.tsx` is shape agnostic), but the
* content is computed locally by reading PB directly.
*
* ## What it answers for one quote_item
*
* - "When did we last quote this SKU?" → `last_quoted`
* - "Is this SKU on our price list?" → `on_price_list` + `price_list_entries[]`
* - "Did we place an order for this SKU?" → `last_order` (any item_status past `po_generated`)
* - "When did we confirm an invoice for it?" → `last_invoiced`
*
* Plus the deterministic side the existing `item-history-service`
* already surfaces: stock status and supplier cost history.
*
* ## Matching strategy (SKU first, description as fallback)
*
* 1. If the target quote_item has a SKU, use it directly
* (`sku_exact`). Also try normalised-SKU (whitespace/_ → "-",
* leading zeros stripped) against price_list rows whose SKU
* doesn't match exactly — that catches `DRL-6MM` vs `DRL 6MM`
* and `BOLT-006` vs `BOLT-6` (`sku_normalised`).
* 2. If no SKU or no matches, run Jaccard token-set similarity on
* `description` against recent quote_items / order_items /
* stock_items / price_list rows (`description_fuzzy`,
* threshold 0.6). Use the matched row's SKU as the effective
* filter, then re-run the SKU matches.
* 3. If still nothing, return `match_kind: "none"` with an empty
* blob and `summary: "No history yet for this item."` so the
* renderer doesn't show a blank card.
*
* ## Auth + writes
*
* Uses the admin PB client. Hermes calls this through the REST
* endpoint (`POST /api/v1/quotes/[id]/populate-insights`) using the
* `AGENT_API_KEY` bearer. Writes `quote_items.ai_item_insights`
* directly. The MCP whitelist intentionally excludes this field —
* this service is the only sanctioned writer.
*
* Idempotent — safe to re-run. Overwrites the previous blob.
*/
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import { tokenize, jaccard } from "@/lib/server/order-quote-match";
import type {
QuoteItemInsights,
QuoteItemInsightsBatchResult,
QuoteItemInsightsLastInvoiced,
QuoteItemInsightsLastOrder,
QuoteItemInsightsLastQuoted,
QuoteItemInsightsMatch,
QuoteItemInsightsPriceListEntry,
QuoteItemInsightsResult,
QuoteItemInsightsStock,
QuoteItemInsightsSupplier,
} from "@/lib/types";
// ─── Pure matching helpers ───────────────────────────────────────────
const DESCRIPTION_MATCH_THRESHOLD = 0.6;
const PRICE_LIST_CANDIDATE_LIMIT = 500;
/** Uppercase, trim, collapse whitespace/underscore to dash, strip leading zero runs after dash. */
export function normaliseInsightSku(sku: string): string {
if (!sku) return "";
return sku
.trim()
.toUpperCase()
.replace(/[\s_]+/g, "-")
.replace(/(?<=^|-)0{2,}(?=\d)/g, "");
}
/** Jaccard score on tokenised descriptions. Reuses the same primitives as `order-quote-match`. */
function descriptionScore(a: string, b: string): number {
const at = tokenize(a);
const bt = tokenize(b);
if (at.size === 0 || bt.size === 0) return 0;
return jaccard(at, bt);
}
/**
* Overlap coefficient: |A ∩ B| / min(|A|, |B|).
*
* Better than Jaccard for the "short query against long candidate"
* case (which is most of real data — customer writes "MTS TOOL TRUNK",
* catalogue has "SERMAT Tool Trunk Steel 610x315x230 blue"). Jaccard
* penalises extra tokens on the candidate side; overlap only cares
* that the query's tokens are all present somewhere in the candidate.
*
* Returns 0 when either side is empty.
*/
function overlapScore(a: string, b: string): number {
const at = tokenize(a);
const bt = tokenize(b);
if (at.size === 0 || bt.size === 0) return 0;
let inter = 0;
for (const t of at) if (bt.has(t)) inter++;
return inter / Math.min(at.size, bt.size);
}
/**
* Combined score: max of Jaccard and overlap coefficient. Lets the
* fuzzy fallback work for both "near-identical" descriptions (high
* Jaccard) and "short query vs long description" (high overlap).
*/
function descriptionScoreCombined(a: string, b: string): number {
return Math.max(descriptionScore(a, b), overlapScore(a, b));
}
/**
* Pick the candidate with the best combined score above the threshold.
* Returns the row + confidence, or null. Skips rows with empty
* descriptions.
*/
function bestDescriptionMatch<T extends { description?: string | null }>(
targetDescription: string,
candidates: T[]
): { item: T; confidence: number; score_kind: "jaccard" | "overlap" } | null {
if (!targetDescription.trim()) return null;
let best: { item: T; score: number; score_kind: "jaccard" | "overlap" } | null = null;
for (const c of candidates) {
const desc = (c.description || "").trim();
if (!desc) continue;
const j = descriptionScore(targetDescription, desc);
const o = overlapScore(targetDescription, desc);
const score_kind: "jaccard" | "overlap" = j >= o ? "jaccard" : "overlap";
const score = Math.max(j, o);
if (!best || score > best.score) best = { item: c, score, score_kind };
}
if (!best || best.score < DESCRIPTION_MATCH_THRESHOLD) return null;
return { item: best.item, confidence: best.score, score_kind: best.score_kind };
}
// ─── Aggregation helpers ─────────────────────────────────────────────
/** Pick the cheapest supplier with a positive last_cost. */
function pickCheapest(
suppliers: QuoteItemInsightsSupplier[]
): QuoteItemInsightsSupplier | null {
const valid = suppliers.filter((s) => s.last_cost > 0);
if (valid.length === 0) return null;
return valid.reduce((min, s) => (s.last_cost < min.last_cost ? s : min));
}
/** Group order_items by supplier. Track latest cost, latest date, count, sample order id. */
function aggregateSuppliers(
rows: Array<{
id: string;
supplier: string;
cost_price: number;
updated: string;
}>,
nameById: Map<string, string>
): QuoteItemInsightsSupplier[] {
const bySupplier = new Map<
string,
{ last_cost: number; last_bought_at: string; last_order_id: string; times_bought: number }
>();
for (const r of rows) {
if (!r.supplier) continue;
const prev = bySupplier.get(r.supplier);
if (!prev) {
bySupplier.set(r.supplier, {
last_cost: r.cost_price,
last_bought_at: r.updated,
last_order_id: r.id,
times_bought: 1,
});
} else {
prev.times_bought += 1;
if (new Date(r.updated).getTime() > new Date(prev.last_bought_at).getTime()) {
prev.last_cost = r.cost_price;
prev.last_bought_at = r.updated;
prev.last_order_id = r.id;
}
}
}
return Array.from(bySupplier.entries()).map(([id, agg]) => ({
supplier_id: id,
supplier_name: nameById.get(id) || id,
last_cost: agg.last_cost,
last_bought_at: agg.last_bought_at,
times_bought: agg.times_bought,
last_order_id: agg.last_order_id,
}));
}
function daysAgo(iso: string | null | undefined): number {
if (!iso) return 0;
const t = new Date(iso).getTime();
if (Number.isNaN(t)) return 0;
return Math.max(0, Math.floor((Date.now() - t) / 86_400_000));
}
/** Compose the short human-readable summary that goes at the top of the renderer. */
function composeSummary(match: QuoteItemInsightsMatch, found: {
last_quoted?: QuoteItemInsightsLastQuoted;
last_order?: QuoteItemInsightsLastOrder;
last_invoiced?: QuoteItemInsightsLastInvoiced;
on_price_list?: boolean;
stock?: QuoteItemInsightsStock;
}): string {
if (match.match_kind === "none") return "No history yet for this item.";
const bits: string[] = [];
if (found.last_quoted) {
bits.push(`last quoted R${found.last_quoted.selling_price.toFixed(2)} ${found.last_quoted.days_ago}d ago (${found.last_quoted.quote_number})`);
} else {
bits.push("no prior quote");
}
if (found.last_order) {
bits.push(`last ordered ${found.last_order.days_ago}d ago @ ${found.last_order.item_status}`);
}
if (found.last_invoiced) {
bits.push(`invoiced ${found.last_invoiced.days_ago}d ago (${found.last_invoiced.supplier_invoice_number || "?"})`);
}
if (found.on_price_list) {
bits.push("on price list");
}
if (found.stock) {
bits.push(`stock ${found.stock.status} (${found.stock.current_qty})`);
}
return bits.join(" · ");
}
// ─── Service entry points ────────────────────────────────────────────
/**
* Compute and write `ai_item_insights` for one quote_item.
*
* Returns the structured result so the caller (REST handler, MCP
* tool, batch driver) can echo it back or roll it up into a batch
* response.
*
* Throws on PB I/O failures. Returns a `match_kind: "none"` result
* (still written to PB) when there is genuinely no history.
*/
export async function populateQuoteItemInsights(
itemId: string,
options: { write?: boolean } = {}
): Promise<QuoteItemInsightsResult> {
const write = options.write !== false;
const pb = await getAdminPocketBase();
const generatedAt = new Date().toISOString();
// 1. Load target. Anchors everything.
const target = await pb.collection("quote_items").getOne<{
id: string;
sku?: string;
description?: string;
cost_price?: number;
selling_price?: number;
quantity?: number;
}>(itemId, { fields: "id,sku,description,cost_price,selling_price,quantity" });
const targetSku = (target.sku || "").trim();
const targetDesc = (target.description || "").trim();
const targetNorm = normaliseInsightSku(targetSku);
const match: QuoteItemInsightsMatch = {
sku: targetSku,
match_kind: "none",
match_confidence: 0,
};
// 2. If we don't have a SKU but DO have a description, run the
// description-fuzzy fallback to recover a SKU.
let effectiveSku = targetSku;
if (!effectiveSku && targetDesc) {
// Fan out across three collections. We pull recent rows that have
// a description AND something to seed the SKU queries with — either
// their own SKU (quote_items / order_items) or a normalised-SKU
// lookup (price_list has `sku_normalised`). Items without any of
// these don't help us recover a SKU.
const [quoteFuzz, orderFuzz, priceFuzz] = await Promise.all([
safeList(
() =>
pb.collection("quote_items").getList<{ id: string; sku: string; description: string }>(
1,
PRICE_LIST_CANDIDATE_LIMIT,
{
filter: `id != "${itemId}" && description != ""`,
sort: "-created",
fields: "id,sku,description",
}
),
{ items: [] }
),
safeList(
() =>
pb.collection("order_items").getList<{ id: string; sku: string; description: string }>(
1,
PRICE_LIST_CANDIDATE_LIMIT,
{
filter: `description != ""`,
sort: "-updated",
fields: "id,sku,description",
}
),
{ items: [] }
),
safeList(
() =>
pb.collection("price_list").getList<{ id: string; sku: string; sku_normalised?: string; description: string }>(
1,
PRICE_LIST_CANDIDATE_LIMIT,
{
filter: `description != ""`,
sort: "-updated",
fields: "id,sku,sku_normalised,description",
}
),
{ items: [] }
),
]);
const fuzzyPool = [
...quoteFuzz.items,
...orderFuzz.items,
...priceFuzz.items,
];
const fm = bestDescriptionMatch(targetDesc, fuzzyPool);
if (fm && (fm.item.sku || "").trim()) {
effectiveSku = (fm.item.sku || "").trim();
match.sku = effectiveSku;
match.match_kind = "description_fuzzy";
match.match_confidence = fm.confidence;
}
} else if (effectiveSku) {
match.match_kind = "sku_exact";
match.match_confidence = 1;
}
const skuFilter = effectiveSku
? `sku = "${effectiveSku.replace(/"/g, '\\"')}"`
: "";
const normFilter =
!targetSku && effectiveSku && match.match_kind === "description_fuzzy"
? "" // we already have the SKU from fuzzy
: targetNorm && effectiveSku && normaliseInsightSku(effectiveSku) !== targetNorm
? "" // description fallback gave us a SKU; don't bother with normalised re-query
: targetNorm
? `(sku = "${targetNorm.replace(/"/g, '\\"')}" || sku = "${targetSku.replace(/"/g, '\\"')}")`
: "";
// 3. Fan out reads. Each branch returns [] if we have no usable SKU.
//
// The invoiced_rows query needs fields that don't exist on every
// PB instance yet (live 2026-08-09: `invoiced_date`,
// `supplier_invoice_number`, `customer_invoice_number`, `qty_invoiced`
// are not on `order_items`). PB rejects the filter with HTTP 400
// "Something went wrong while processing your request." until the
// migration lands. We try-catch that one branch and degrade
// gracefully — the other three answers still populate, and the
// summary says "no invoiced history yet" until the schema is
// upgraded. The same defensive pattern would apply to any field
// the deployment hasn't yet migrated.
const hasFilter = !!skuFilter;
type PbListResult<T> = { items: T[]; totalItems?: number };
async function safeList<T>(
buildQuery: () => Promise<PbListResult<T>>,
fallback: PbListResult<T>
): Promise<PbListResult<T>> {
try {
return await buildQuery();
} catch (err) {
// 400 = field referenced by the filter doesn't exist on this
// collection yet. Anything else (5xx, network) we let bubble so
// the operator sees the real failure.
const status =
err && typeof err === "object" && "status" in err
? (err as { status?: number }).status
: undefined;
if (status === 400) {
console.warn(
`[populateQuoteItemInsights] PB rejected one branch with 400 — likely a field the live schema is missing. Treating as no data.`,
);
return fallback;
}
throw err;
}
}
const [
priceListExactRows,
priceListNormalisedRows,
otherQuoteRows,
orderRows,
invoicedRows,
stockRows,
posRows,
suppliersRows,
] = await Promise.all([
safeList(
() =>
hasFilter
? pb.collection("price_list").getList<{
id: string;
sku: string;
description: string;
cost_price: number;
selling_price: number;
supplier: string;
brand: string;
category: string;
subcategory: string;
updated: string;
}>(1, 10, { filter: skuFilter, sort: "-updated" })
: Promise.resolve({ items: [] }),
{ items: [] }
),
safeList(
() =>
normFilter
? pb.collection("price_list").getList<{
id: string;
sku: string;
description: string;
cost_price: number;
selling_price: number;
supplier: string;
brand: string;
category: string;
subcategory: string;
updated: string;
}>(1, 10, { filter: normFilter, sort: "-updated" })
: Promise.resolve({ items: [] }),
{ items: [] }
),
safeList(
() =>
hasFilter
? pb.collection("quote_items").getList<{
id: string;
quote: string;
cost_price: number;
selling_price: number;
created: string;
expand?: { quote?: { quote_number: string; customer?: string } };
}>(1, 5, {
filter: `${skuFilter} && id != "${itemId}"`,
sort: "-created",
expand: "quote",
})
: Promise.resolve({ items: [] }),
{ items: [] }
),
safeList(
() =>
hasFilter
? pb.collection("order_items").getList<{
id: string;
order: string;
supplier: string;
cost_price: number;
selling_price: number;
item_status: string;
updated: string;
delivered_at?: string;
expand?: { order?: { order_number: string } };
}>(1, 5, {
filter: `${skuFilter} && item_status != "cancelled" && item_status != "removed"`,
sort: "-updated",
expand: "order",
})
: Promise.resolve({ items: [] }),
{ items: [] }
),
safeList(
() =>
hasFilter
? pb.collection("order_items").getList<{
id: string;
order: string;
supplier_invoice_number: string;
customer_invoice_number: string;
invoiced_date: string;
cost_price: number;
quantity: number;
qty_invoiced: number;
expand?: { order?: { order_number: string } };
}>(1, 1, {
filter: `${skuFilter} && invoiced_date != null && invoiced_date != "" && invoiced_date != "0001-01-01T00:00:00Z" && (supplier_invoice_number != "" || customer_invoice_number != "")`,
sort: "-invoiced_date",
expand: "order",
})
: Promise.resolve({ items: [] }),
{ items: [] }
),
safeList(
() =>
hasFilter
? pb.collection("stock_items").getList<{
id: string;
name: string;
sku: string;
current_qty: number;
min_qty: number | null;
selling_price: number | null;
markup: number | null;
}>(1, 5, { filter: skuFilter })
: Promise.resolve({ items: [] }),
{ items: [] }
),
safeList(
() =>
hasFilter
? pb.collection("pos").getList<{ sku: string; stock_qty: number }>(1, 5, { filter: skuFilter })
: Promise.resolve({ items: [] }),
{ items: [] }
),
pb.collection("suppliers").getList<{ id: string; suppliername: string }>(1, 500, {
fields: "id,suppliername",
}),
]);
const supplierNameById = new Map(
suppliersRows.items.map((s) => [s.id, s.suppliername])
);
// ── last_quoted ─────────────────────────────────────────────────
let last_quoted: QuoteItemInsightsLastQuoted | undefined;
const q = otherQuoteRows.items.find(
(r) => (r.cost_price || 0) > 0 || (r.selling_price || 0) > 0
);
if (q) {
last_quoted = {
quote_item_id: q.id,
quote_id: q.quote,
quote_number: q.expand?.quote?.quote_number || "",
quote_date: q.created,
cost_price: q.cost_price || 0,
selling_price: q.selling_price || 0,
customer_id: "",
customer_name: "",
days_ago: daysAgo(q.created),
};
}
// ── on_price_list + price_list_entries ──────────────────────────
const allPriceRows = [
...priceListExactRows.items,
...priceListNormalisedRows.items,
];
let on_price_list: boolean | undefined;
let price_list_entries: QuoteItemInsightsPriceListEntry[] | undefined;
if (allPriceRows.length > 0) {
on_price_list = true;
// Dedup by (supplier_id, sku) so the same row doesn't appear twice
// when we hit it via exact + normalised.
const seen = new Set<string>();
price_list_entries = [];
for (const r of allPriceRows) {
const key = `${r.supplier}|${r.sku}`;
if (seen.has(key)) continue;
seen.add(key);
price_list_entries.push({
price_list_id: r.id,
sku: r.sku,
description: r.description || "",
supplier_id: r.supplier,
supplier_name: supplierNameById.get(r.supplier) || r.supplier,
cost_price: r.cost_price || 0,
selling_price: r.selling_price || 0,
brand: r.brand || "",
category: r.category || "",
subcategory: r.subcategory || "",
updated: r.updated,
});
}
} else if (hasFilter) {
on_price_list = false;
}
// If we got exact + normalised matches, we matched by normalised
// SKU — surface that in `match.match_kind`.
if (
match.match_kind === "sku_exact" &&
priceListNormalisedRows.items.length > 0 &&
priceListExactRows.items.length === 0
) {
match.match_kind = "sku_normalised";
}
// ── last_order ──────────────────────────────────────────────────
let last_order: QuoteItemInsightsLastOrder | undefined;
const o = orderRows.items.find(
(r) => (r.cost_price || 0) > 0 || (r.selling_price || 0) > 0
);
if (o) {
last_order = {
order_item_id: o.id,
order_id: o.order,
order_number: o.expand?.order?.order_number || "",
order_date: o.updated,
delivered_at: o.delivered_at || null,
cost_price: o.cost_price || 0,
selling_price: o.selling_price || 0,
item_status: o.item_status,
supplier_id: o.supplier,
supplier_name: supplierNameById.get(o.supplier) || o.supplier,
days_ago: daysAgo(o.updated),
};
}
// ── last_invoiced ───────────────────────────────────────────────
let last_invoiced: QuoteItemInsightsLastInvoiced | undefined;
const i = invoicedRows.items[0];
if (i) {
last_invoiced = {
order_item_id: i.id,
order_id: i.order,
order_number: i.expand?.order?.order_number || "",
supplier_invoice_number: i.supplier_invoice_number || "",
customer_invoice_number: i.customer_invoice_number || "",
invoiced_date: i.invoiced_date,
cost_price: i.cost_price || 0,
quantity: i.quantity || 0,
qty_invoiced: i.qty_invoiced || 0,
days_ago: daysAgo(i.invoiced_date),
};
}
// ── stock (prefer stock_items; fall back to pos for legacy) ─────
let stock: QuoteItemInsightsStock | undefined;
const s = stockRows.items[0];
if (s) {
let status: QuoteItemInsightsStock["status"];
if (s.current_qty <= 0) status = "out";
else if (s.min_qty != null && s.current_qty <= s.min_qty) status = "low";
else status = "in_stock";
stock = {
stock_item_id: s.id,
name: s.name,
sku: s.sku,
current_qty: s.current_qty,
min_qty: s.min_qty,
selling_price: s.selling_price,
markup_percent: s.markup,
status,
};
} else {
const p = posRows.items[0];
if (p) {
stock = {
stock_item_id: "",
name: "",
sku: p.sku,
current_qty: p.stock_qty || 0,
min_qty: null,
selling_price: null,
markup_percent: null,
status:
(p.stock_qty || 0) <= 0
? "out"
: (p.stock_qty || 0) > 0
? "low"
: "out",
};
}
}
// ── suppliers (from all matching order_rows) ────────────────────
const suppliers = aggregateSuppliers(
orderRows.items.map((r) => ({
id: r.id,
supplier: r.supplier,
cost_price: r.cost_price || 0,
updated: r.updated,
})),
supplierNameById
).filter((s) => s.last_cost > 0);
const cheapest = pickCheapest(suppliers);
// Whether the join produced any useful data — independent of how we
// matched (SKU exact / normalised / description fuzzy). An item with
// `match_kind: "sku_exact"` but no historical rows still has
// `found_data: false` — useful for the batch rollup that distinguishes
// "populated" from "no history".
const found_data =
!!last_quoted ||
!!last_order ||
!!last_invoiced ||
on_price_list === true ||
!!stock ||
suppliers.length > 0;
const insights: QuoteItemInsights = {
id: itemId,
summary: composeSummary(match, {
last_quoted,
last_order,
last_invoiced,
on_price_list,
stock,
}),
match,
generated_at: generatedAt,
};
if (last_quoted) insights.last_quoted = last_quoted;
if (on_price_list !== undefined) insights.on_price_list = on_price_list;
if (price_list_entries) insights.price_list_entries = price_list_entries;
if (last_order) insights.last_order = last_order;
if (last_invoiced) insights.last_invoiced = last_invoiced;
if (stock) insights.stock = stock;
if (suppliers.length > 0) insights.suppliers = suppliers;
// Cheapest as a top-level convenience field so the renderer doesn't
// have to recompute it from the suppliers array.
if (cheapest) {
(insights as QuoteItemInsights & { cheapest?: QuoteItemInsightsSupplier }).cheapest = cheapest;
}
if (write) {
await pb.collection("quote_items").update(itemId, {
ai_item_insights: insights,
});
}
return {
item_id: itemId,
insights,
match_kind: match.match_kind,
found_data,
};
}
/**
* Populate `ai_item_insights` for every active line item on a quote.
*
* "Active" = `moved_to_quote = null || moved_to_quote = ""`. Items
* already moved to a continuation quote are skipped — they belong to
* a different quote's history now.
*
* Hermes's typical pattern:
*
* for each quote in quotes.list:
* POST /api/v1/quotes/{quote.id}/populate-insights
*
* The route is parallel-safe within a single quote (we Promise.all
* the items). Across quotes, Hermes runs sequentially or in small
* batches to avoid hammering PB.
*/
export async function populateQuoteInsightsForQuote(
quoteId: string,
options: { write?: boolean } = {}
): Promise<QuoteItemInsightsBatchResult> {
const pb = await getAdminPocketBase();
const items = await pb.collection("quote_items").getFullList<{ id: string }>({
filter: `quote = "${quoteId}" && (moved_to_quote = null || moved_to_quote = "")`,
fields: "id",
});
const results = await Promise.all(
items.map((it) => populateQuoteItemInsights(it.id, options))
);
return {
quote_id: quoteId,
total: items.length,
/** Items where the join produced at least one historical row. */
populated: results.filter((r) => r.found_data).length,
/** Items where we found a SKU / fuzzy match but no history. Plus items with no match at all. */
no_match: results.filter((r) => !r.found_data).length,
results,
};
}