This commit is contained in:
2026-08-24 03:41:06 +02:00
parent a246ed8fe5
commit 8a690a0464
22 changed files with 1253 additions and 358 deletions
+18 -2
View File
@@ -685,8 +685,21 @@ const (
// looked unchanged. Review must compare against these instead.
processedOriginalNameSQL = `COALESCE(NULLIF(p.name, ''), NULLIF(r.mapped_data->>'name', ''), NULLIF(r.mapped_data->>'title', ''), '')`
processedOriginalDescriptionSQL = `COALESCE(NULLIF(p.description, ''), NULLIF(r.mapped_data->>'description', ''), '')`
// Category the feed supplied (before categorize assigned one).
processedFeedCategorySQL = `COALESCE(NULLIF(BTRIM(r.mapped_data->>'category'), ''), NULLIF(BTRIM(r.mapped_data->>'category_unique_id'), ''), '')`
// Category the feed supplied, before categorize assigned one.
//
// mapped_data.category is NOT proof of a feed category: the pipeline writes its
// own pick back there so the Products UI and reprocess keep it
// (processing.persistMappedCategorySQL). Reading it blindly made Review show an
// AI-chosen category as the product's Original. category_source records who
// chose it — anything other than "mapped" means we determined it, not the feed.
// field_sources.category is the second signal and the one that works for rows
// processed before category_source existed: it records which step chose the
// category on the last run, so an llm/vector pick is never shown as the feed's.
processedFeedCategorySQL = `CASE
WHEN COALESCE(NULLIF(BTRIM(r.mapped_data->>'category_source'), ''), 'mapped') <> 'mapped' THEN ''
WHEN COALESCE(p.field_sources->>'category', '') IN ('llm', 'vector') THEN ''
ELSE COALESCE(NULLIF(BTRIM(r.mapped_data->>'category'), ''), NULLIF(BTRIM(r.mapped_data->>'category_unique_id'), ''), '')
END`
processedHasNameSQL = `(` + processedPreferredNameSQL + ` <> '')`
processedHasDescriptionSQL = `(` + processedPreferredDescriptionSQL + ` <> '')`
processedHasCategorySQL = `(COALESCE(NULLIF(p.category, ''), '') <> '' AND lower(p.category) <> 'none')`
@@ -1284,6 +1297,8 @@ func (s *Service) GetProcessedProduct(ctx context.Context, companyID, id uuid.UU
`+processedPreferredDescriptionSQL+` AS description,
`+processedOriginalDescriptionSQL+` AS original_description,
p.processed_name, p.processed_description,
COALESCE(p.meta_title, '') AS meta_title,
COALESCE(p.meta_description, '') AS meta_description,
p.status, p.attributes, p.processed_attributes, r.gtin, r.mapped_data,
COALESCE(p.feed_id, r.feed_id) AS feed_id,
f.name AS feed_name,
@@ -1304,6 +1319,7 @@ func (s *Service) GetProcessedProduct(ctx context.Context, companyID, id uuid.UU
"id", "product_id", "name", "original_name", "category", "category_name", "category_unique_id",
"feed_category",
"description", "original_description", "processed_name", "processed_description",
"meta_title", "meta_description",
"status", "attributes", "processed_attributes", "gtin", "mapped_data",
"feed_id", "feed_name", "feed_last_synced_at", "raw_updated_at",
"has_processed_name", "has_processed_description", "has_attributes", "has_processed_attributes",
+25 -9
View File
@@ -440,7 +440,16 @@ func (s *Server) handleGetProduct(w http.ResponseWriter, r *http.Request) {
return
}
}
stripCatalogSEOMetaIfOmitted(processing.CompanyOmitsSEOMeta(r.Context(), s.Pool, cid), item)
// Deliberately NOT stripped here. The omit rule hides SEO meta from list views
// for cohorts that do not sell on it, but this payload feeds the review screen
// and meta the pipeline actually generated has to be reviewable.
//
// The cohort flag still travels, so the panel can say "disabled for this
// company" instead of "the AI generated nothing" — without it an empty block is
// unexplained, which is exactly what made SEO meta look missing.
if item != nil && processing.CompanyOmitsSEOMeta(r.Context(), s.Pool, cid) {
item["seo_meta_omitted"] = true
}
JSON(w, http.StatusOK, item)
}
@@ -467,6 +476,12 @@ func (s *Server) handleUpdateProduct(w http.ResponseWriter, r *http.Request) {
// stripCatalogSEOMetaIfOmitted drops meta_title / meta_description from dashboard
// product payloads when omit is true (same detection as V1 process / CompanyOmitsSEOMeta).
//
// Only EMPTY values are dropped. The omit cohort does not generate SEO meta, but
// rows enriched before that rule (or by an earlier pipeline) still carry it, and
// hiding stored copy from the dashboard meant Review could not show what the AI had
// actually produced. Generation still respects the cohort — this only stops the
// read path from concealing data that exists.
func stripCatalogSEOMetaIfOmitted(omit bool, items ...map[string]any) {
if !omit {
return
@@ -475,14 +490,15 @@ func stripCatalogSEOMetaIfOmitted(omit bool, items ...map[string]any) {
if item == nil {
continue
}
delete(item, "meta_title")
delete(item, "meta_description")
if loc, ok := item["localized_content"].(company.LocalizedContent); ok {
for lang, fields := range loc {
fields.MetaTitle = ""
fields.MetaDescription = ""
loc[lang] = fields
}
// Tell the client the cohort does not generate SEO meta, so an empty block
// reads as "disabled here" instead of "the AI produced nothing".
item["seo_meta_omitted"] = true
if asMapString(item["meta_title"]) == "" {
delete(item, "meta_title")
}
if asMapString(item["meta_description"]) == "" {
delete(item, "meta_description")
}
// Localized meta is left exactly as stored, for the same reason.
}
}
@@ -19,23 +19,53 @@ func catalogSEOItem() map[string]any {
}
}
// The omit cohort does not GENERATE SEO meta, but rows enriched before that rule
// still carry it. Hiding stored copy meant Review could not show what the AI had
// actually produced, so the read path now keeps non-empty values and only flags
// that the cohort is opted out. Empty values are still dropped.
func assertCatalogSEOOmitted(t *testing.T, item map[string]any) {
t.Helper()
if _, ok := item["meta_title"]; ok {
t.Fatalf("must omit meta_title: %v", item)
if item["seo_meta_omitted"] != true {
t.Fatalf("omit cohort must be flagged so an empty block explains itself: %v", item)
}
if _, ok := item["meta_description"]; ok {
t.Fatalf("must omit meta_description: %v", item)
if item["meta_title"] != "T" || item["meta_description"] != "D" {
t.Fatalf("stored meta must stay readable for Review: %v", item)
}
loc := item["localized_content"].(company.LocalizedContent)
if loc["sl"].MetaTitle != "" || loc["sl"].MetaDescription != "" {
t.Fatalf("localized meta leaked: %#v", loc["sl"])
}
if loc["sl"].ProcessedName != "N" {
t.Fatalf("stripped too much: %#v", loc["sl"])
}
}
// Empty meta on an omit-cohort product is dropped, leaving only the flag — the UI
// then says "disabled for this company" instead of showing two blank boxes.
func TestStripCatalogSEOMetaIfOmitted_dropsEmptyValues(t *testing.T) {
t.Parallel()
other := uuid.MustParse("11111111-1111-1111-1111-111111111111")
item := map[string]any{"name": "Widget", "meta_title": "", "meta_description": ""}
stripCatalogSEOMetaIfOmitted(processing.CompanyOmitsSEOMetaLookup(other, "", "Platform Demo"), item)
if _, ok := item["meta_title"]; ok {
t.Fatalf("empty meta_title should be dropped: %v", item)
}
if item["seo_meta_omitted"] != true {
t.Fatalf("omit flag missing: %v", item)
}
}
// An ordinary tenant is never flagged and never touched.
func TestStripCatalogSEOMetaIfOmitted_ordinaryTenantUnflagged(t *testing.T) {
t.Parallel()
other := uuid.MustParse("11111111-1111-1111-1111-111111111111")
item := catalogSEOItem()
stripCatalogSEOMetaIfOmitted(processing.CompanyOmitsSEOMetaLookup(other, "", "Acme"), item)
if _, ok := item["seo_meta_omitted"]; ok {
t.Fatalf("ordinary company must not be flagged: %v", item)
}
if item["meta_title"] != "T" {
t.Fatalf("ordinary company must keep meta: %v", item["meta_title"])
}
}
func TestStripCatalogSEOMetaIfOmitted(t *testing.T) {
t.Parallel()
a1 := uuid.MustParse("604f23a8-b66e-4b21-8b45-0d72b68f4790")
+18 -4
View File
@@ -358,14 +358,28 @@ func runCategorizeStep(ctx context.Context, e *Engine, companyID string, out *St
return nil
}
// MappedCategorySourceKey marks a mapped_data.category this pipeline wrote itself.
//
// Without it the write is indistinguishable from a feed-supplied category on the
// next run, so Review showed an AI-chosen category as the product's "Original" and
// every product looked already-categorised. $4 carries the step that chose it
// (mapped / vector / llm); only "mapped" means the feed actually supplied one.
const MappedCategorySourceKey = "category_source"
// persistMappedCategorySQL writes a taxonomy unique_id onto mapped_data.category
// when the mapped value is still empty (so Products UI + reprocess stick).
// when the mapped value is still empty (so Products UI + reprocess stick), and
// records who chose it alongside.
const persistMappedCategorySQL = `
UPDATE raw_products
SET mapped_data = jsonb_set(
COALESCE(mapped_data, '{}'::jsonb),
'{category}',
to_jsonb($3::text),
jsonb_set(
COALESCE(mapped_data, '{}'::jsonb),
'{category}',
to_jsonb($3::text),
true
),
'{category_source}',
to_jsonb($4::text),
true
),
updated_at = now()
+7 -1
View File
@@ -1701,7 +1701,13 @@ func (p *Pipeline) processOne(ctx context.Context, companyID, jobID uuid.UUID, i
// Stick taxonomy unique_id onto mapped_data.category when still empty so the
// Products UI (reads mapped) and reprocess keep the AI/vector/mapped pick.
if cat := strings.TrimSpace(result.Category); cat != "" {
if _, err := tx.Exec(ctx, persistMappedCategorySQL, it.RawID, companyID, cat); err != nil {
// Record who chose it: a category this pipeline derived must not read back
// as the feed's own on the next run (see MappedCategorySourceKey).
catSource, _ := result.FieldSources["category"].(string)
if strings.TrimSpace(catSource) == "" {
catSource = "pipeline"
}
if _, err := tx.Exec(ctx, persistMappedCategorySQL, it.RawID, companyID, cat, catSource); err != nil {
return false, 0, result, fmt.Errorf("persist mapped category: %w", err)
}
}
+103
View File
@@ -0,0 +1,103 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { attributeAliasKey, buildAttributeDiff } from "./attribute-diff.ts";
describe("attribute diff", () => {
it("folds EPREL alias keys into one row", () => {
// eprel_pdf / eprel_pdf_url and energy_class / eprel_energy_class are the
// same fact stored twice — the review table showed "energy class twice".
const diff = buildAttributeDiff(
[],
[
{ key: "energy_class", value: "F" },
{ key: "eprel_energy_class", value: "F" },
{ key: "eprel_pdf", value: "https://x/f.pdf" },
{ key: "eprel_pdf_url", value: "https://x/f.pdf" }
]
);
assert.equal(diff.rows.length, 2);
const energy = diff.rows.find((r) => r.key === "energy_class");
assert.ok(energy, "shortest name should label the group");
assert.deepEqual(energy?.aliases, ["eprel_energy_class"]);
const pdf = diff.rows.find((r) => r.key === "eprel_pdf");
assert.deepEqual(pdf?.aliases, ["eprel_pdf_url"]);
});
it("only folds aliases that share a value", () => {
// Same alias family, different values: two separate facts, two rows.
const diff = buildAttributeDiff(
[],
[
{ key: "eprel_label", value: "A" },
{ key: "eprel_label_url", value: "B" }
]
);
assert.equal(diff.rows.length, 2);
});
it("never folds unrelated keys that happen to share a value", () => {
const diff = buildAttributeDiff(
[],
[
{ key: "net_width", value: "0.5 m" },
{ key: "net_height", value: "0.5 m" }
]
);
assert.equal(diff.rows.length, 2);
});
it("marks pipeline-derived attributes as added, not matched", () => {
// The feed had nothing; enrichment supplied everything.
const diff = buildAttributeDiff(
[{ key: "brand", value: "Vox" }],
[
{ key: "brand", value: "Vox" },
{ key: "eprel_id", value: "1517569" }
]
);
assert.equal(diff.added.length, 1);
assert.equal(diff.added[0].key, "eprel_id");
assert.equal(diff.added[0].before, "");
assert.equal(diff.same.length, 1);
assert.equal(diff.same[0].key, "brand");
});
it("reports changed and removed separately from added", () => {
const diff = buildAttributeDiff(
[
{ key: "warranty", value: "24" },
{ key: "colour", value: "black" }
],
[
{ key: "warranty", value: "36" },
{ key: "brand", value: "Vox" }
]
);
assert.deepEqual(
diff.changed.map((r) => [r.key, r.state]),
[
["warranty", "changed"],
["colour", "removed"]
]
);
assert.deepEqual(diff.added.map((r) => r.key), ["brand"]);
});
it("sorts rows so changes come before unchanged", () => {
const diff = buildAttributeDiff(
[{ key: "a_same", value: "1" }],
[
{ key: "a_same", value: "1" },
{ key: "z_added", value: "2" }
]
);
assert.deepEqual(diff.rows.map((r) => r.key), ["z_added", "a_same"]);
});
it("normalises alias keys", () => {
assert.equal(attributeAliasKey("eprel-pdf-url"), "pdf");
assert.equal(attributeAliasKey("eprel_pdf"), "pdf");
assert.equal(attributeAliasKey("energy_class"), "energyclass");
assert.equal(attributeAliasKey("eprel_energy_class"), "energyclass");
});
});
+100
View File
@@ -0,0 +1,100 @@
/**
* Feed-vs-enriched attribute diff for the product review screen.
*
* Two things make a naive diff wrong here:
*
* 1. `processed_products.attributes` is NOT the feed. The pipeline writes it and
* `processed_attributes` to the same value, so comparing them showed EPREL
* lookups and AI-extracted keys as if the supplier had sent them, and every row
* read "Matched". The "before" side must come from the feed's own specs.
* 2. Enrichment stores the same fact under several names — `eprel_pdf` and
* `eprel_pdf_url`, `energy_class` and `eprel_energy_class` — which listed the
* same value repeatedly ("energy class twice"). Aliases are folded into one row.
*/
export type AttributeEntry = { key: string; value: string };
export type AttributeDiffState = "added" | "changed" | "removed" | "same";
export type AttributeDiffRow = {
/** Shortest name for the fact; the others are in `aliases`. */
key: string;
aliases: string[];
before: string;
after: string;
state: AttributeDiffState;
};
export type AttributeDiff = {
rows: AttributeDiffRow[];
added: AttributeDiffRow[];
/** Changed and removed — anything that had a feed value and no longer matches. */
changed: AttributeDiffRow[];
same: AttributeDiffRow[];
};
/**
* Grouping key for attributes carrying the same fact under different names.
*
* Folding needs BOTH a related name and an identical value — matching on value
* alone would merge width and height whenever they happen to be equal.
*/
export function attributeAliasKey(key: string): string {
let k = key.toLowerCase().replace(/[^a-z0-9]/g, "");
if (k.startsWith("eprel") && k.length > 5) k = k.slice(5);
if (k.endsWith("url") && k.length > 3) k = k.slice(0, -3);
return k;
}
function stateFor(before: string | undefined, after: string | undefined): AttributeDiffState {
if (!before) return "added";
if (!after) return "removed";
return before === after ? "same" : "changed";
}
/** buildAttributeDiff compares feed entries against enriched entries. */
export function buildAttributeDiff(
feed: AttributeEntry[],
enriched: AttributeEntry[]
): AttributeDiff {
const before = new Map(feed.map((a) => [a.key.toLowerCase(), a]));
const after = new Map(enriched.map((a) => [a.key.toLowerCase(), a]));
const keys = [...new Set([...before.keys(), ...after.keys()])].sort();
const grouped = new Map<string, AttributeDiffRow>();
for (const k of keys) {
const b = before.get(k);
const a = after.get(k);
const row: AttributeDiffRow = {
key: (a ?? b)?.key ?? k,
aliases: [],
before: b?.value ?? "",
after: a?.value ?? "",
state: stateFor(b?.value, a?.value)
};
const groupKey = `${attributeAliasKey(row.key)}::${row.before}::${row.after}`;
const existing = grouped.get(groupKey);
if (!existing) {
grouped.set(groupKey, row);
continue;
}
// Keep the shortest name as the label; the rest become aliases.
const [keep, alias] =
row.key.length < existing.key.length ? [row.key, existing.key] : [existing.key, row.key];
existing.aliases = [...existing.aliases, alias].filter((name) => name !== keep);
existing.key = keep;
}
// Anything that needs attention first; unchanged rows last.
const rank: Record<AttributeDiffState, number> = { added: 0, changed: 1, removed: 2, same: 3 };
const rows = [...grouped.values()].sort(
(x, y) => rank[x.state] - rank[y.state] || x.key.localeCompare(y.key)
);
return {
rows,
added: rows.filter((r) => r.state === "added"),
changed: rows.filter((r) => r.state === "changed" || r.state === "removed"),
same: rows.filter((r) => r.state === "same")
};
}
@@ -28,6 +28,7 @@
productEnrichmentTitle,
productEprelLabel,
productFeedAttributeEntries,
productAttributeDiff,
productFeedFieldSummary,
productFeedMappedEntries,
productFeedSyncLabel,
@@ -430,27 +431,56 @@
assignedCategoryLabel.trim().toLowerCase() !== feedCategoryLabel.trim().toLowerCase()
);
/** Per-key attribute diff: what enrichment added or rewrote versus the feed. */
const attrDiff = $derived.by(() => {
const before = new Map(
productAttrEntries(product?.attributes).map((a) => [a.key.toLowerCase(), a])
);
const after = new Map(
productAttrEntries(product?.processed_attributes).map((a) => [a.key.toLowerCase(), a])
);
const keys = [...new Set([...before.keys(), ...after.keys()])].sort();
const rows = keys.map((k) => {
const b = before.get(k);
const a = after.get(k);
const state = !b ? "added" : !a ? "removed" : b.value === a.value ? "same" : "changed";
return { key: (a ?? b)?.key ?? k, before: b?.value ?? "", after: a?.value ?? "", state };
});
return {
rows,
changed: rows.filter((r) => r.state !== "same")
};
/** Feed-vs-enriched attribute diff, alias-collapsed (see buildAttributeDiff). */
const attrDiff = $derived.by(() => productAttributeDiff(product));
const attrsChanged = $derived(
kind === "processed" && (attrDiff.added.length > 0 || attrDiff.changed.length > 0)
);
/** Unchanged attributes are noise in a review; keep them one click away. */
let showUnchangedAttrs = $state(false);
// Nothing in the feed to compare against is "Added", not "Changed" — saying
// Matched (or Changed) about a field the supplier never sent reads as if the
// feed had one.
const categoryAdded = $derived(kind === "processed" && !feedCategoryLabel && !!assignedCategoryLabel);
/** SEO meta the feed supplied, when it had any. */
function feedMetaValue(...keys: string[]): string {
const mapped = product?.mapped_data as Record<string, unknown> | null | undefined;
if (!mapped || typeof mapped !== "object") return "";
for (const k of keys) {
const v = (mapped as Record<string, unknown>)[k];
if (typeof v === "string" && v.trim()) return v.trim();
}
return "";
}
const originalMetaTitle = $derived(feedMetaValue("meta_title", "metaTitle", "seo_title"));
const originalMetaDescription = $derived(
feedMetaValue("meta_description", "metaDescription", "seo_description")
);
const metaFields = $derived.by(() => {
const loc = product?.localized_content?.[contentLang];
const title = String(loc?.meta_title ?? product?.meta_title ?? "").trim();
const desc = String(loc?.meta_description ?? product?.meta_description ?? "").trim();
return [
{ key: "title", label: i18n.t("products.edit.metaTitle"), before: originalMetaTitle, after: title },
{
key: "description",
label: i18n.t("products.edit.metaDescription"),
before: originalMetaDescription,
after: desc
}
];
});
const attrsChanged = $derived(kind === "processed" && attrDiff.changed.length > 0);
const metaChanged = $derived(
kind === "processed" && metaFields.some((f) => f.after && f.after !== f.before)
);
const metaOmitted = $derived(product?.seo_meta_omitted === true);
/**
* Show the block whenever there is meta to show OR the company is opted out —
* silently hiding it is what made "where is the meta description?" unanswerable.
*/
const metaPresent = $derived(metaFields.some((f) => f.before || f.after) || metaOmitted);
const feedAttrs = $derived.by(() => {
const rows = productFeedAttributeEntries(product);
@@ -796,7 +826,9 @@
>
<div class="flex items-center justify-between gap-2">
<Label for="review-category">{i18n.t("products.enrichment.piece.category")}</Label>
{#if categoryChanged}
{#if categoryAdded}
<Badge variant="outline" class="text-[10px]">{i18n.t("products.edit.added")}</Badge>
{:else if categoryChanged}
<Badge variant="outline" class="text-[10px]">{i18n.t("products.edit.changed")}</Badge>
{:else}
<Badge variant="secondary" class="text-[10px]">{i18n.t("products.edit.matched")}</Badge>
@@ -808,7 +840,9 @@
{i18n.t("products.edit.original")}
</p>
<div
class="min-h-10 rounded-md border border-border bg-muted/30 px-3 py-2 text-sm text-muted-foreground"
class="min-h-10 rounded-md border border-border bg-muted/30 px-3 py-2 text-sm {feedCategoryLabel
? 'text-muted-foreground'
: 'italic text-muted-foreground/70'}"
>
{feedCategoryLabel || i18n.t("products.edit.noCategoryFromFeed")}
</div>
@@ -837,46 +871,155 @@
</div>
</div>
{#if kind === "processed"}
<div
class="space-y-2 rounded-md border border-transparent p-1 {metaChanged
? 'border-chart-amber/40 bg-chart-amber/10'
: ''}"
>
<div class="flex items-center justify-between gap-2">
<Label>{i18n.t("products.edit.seoMeta")}</Label>
{#if metaOmitted && !metaChanged}
<Badge variant="secondary" class="text-[10px]">
{i18n.t("products.edit.seoMetaDisabled")}
</Badge>
{:else if metaChanged}
<Badge variant="outline" class="text-[10px]">
{i18n.t(
metaFields.every((f) => !f.before || f.before === f.after)
? "products.edit.added"
: "products.edit.changed"
)}
</Badge>
{:else}
<Badge variant="secondary" class="text-[10px]">{i18n.t("products.edit.matched")}</Badge>
{/if}
</div>
{#if metaOmitted && !metaFields.some((f) => f.after)}
<p class="text-xs text-muted-foreground">
{i18n.t("products.edit.seoMetaDisabledHint")}
</p>
{/if}
{#each metaFields as field (field.key)}
<div class="space-y-1">
<p class="text-xs font-medium text-muted-foreground">{field.label}</p>
<div class="grid gap-3 sm:grid-cols-2">
<div
class="min-h-10 rounded-md border border-border bg-muted/30 px-3 py-2 text-xs {field.before
? 'text-muted-foreground'
: 'italic text-muted-foreground/60'}"
>
{field.before || i18n.t("products.edit.notInFeed")}
</div>
<div
class="min-h-10 rounded-md border border-border bg-muted/50 px-3 py-2 text-xs {field.after
? 'text-foreground'
: 'italic text-muted-foreground/70'}"
>
{field.after || i18n.t("products.edit.metaNotGenerated")}
</div>
</div>
</div>
{/each}
</div>
{/if}
<div
class="space-y-2 rounded-md border border-transparent p-1 {attrsChanged
? 'border-chart-amber/40 bg-chart-amber/10'
: ''}"
>
<div class="flex items-center justify-between gap-2">
<div class="flex flex-wrap items-center justify-between gap-2">
<Label>{i18n.t("products.enrichment.piece.attributes")}</Label>
{#if attrsChanged}
<Badge variant="outline" class="text-[10px]">
{i18n.t("products.edit.attrsChangedCount", { count: attrDiff.changed.length })}
</Badge>
{:else}
<Badge variant="secondary" class="text-[10px]">{i18n.t("products.edit.matched")}</Badge>
{/if}
<div class="flex items-center gap-1">
{#if attrDiff.added.length > 0}
<Badge variant="outline" class="text-[10px]">
{i18n.t("products.edit.attrsAddedCount", { count: attrDiff.added.length })}
</Badge>
{/if}
{#if attrDiff.changed.length > 0}
<Badge variant="outline" class="text-[10px]">
{i18n.t("products.edit.attrsChangedCount", { count: attrDiff.changed.length })}
</Badge>
{/if}
{#if !attrsChanged}
<Badge variant="secondary" class="text-[10px]">{i18n.t("products.edit.matched")}</Badge>
{/if}
</div>
</div>
{#if attrDiff.rows.length === 0}
<p class="text-xs text-muted-foreground">{i18n.t("products.edit.noAttributes")}</p>
{:else}
<div class="overflow-x-auto rounded-md border border-border">
<table class="w-full text-xs">
<thead class="bg-muted/40 text-muted-foreground">
<tr>
<th class="px-2 py-1.5 text-left font-medium">{i18n.t("products.edit.attrKey")}</th>
<th class="px-2 py-1.5 text-left font-medium">{i18n.t("products.edit.original")}</th>
<th class="px-2 py-1.5 text-left font-medium">{i18n.t("products.edit.enriched")}</th>
</tr>
</thead>
<tbody>
{#each attrDiff.rows as row (row.key)}
<tr class="border-t border-border {row.state === 'same' ? '' : 'bg-chart-amber/5'}">
<td class="px-2 py-1.5 font-mono">{row.key}</td>
<td class="px-2 py-1.5 text-muted-foreground">{row.before || "—"}</td>
<td class="px-2 py-1.5 {row.state === 'same' ? 'text-muted-foreground' : 'font-medium text-foreground'}">
{row.after || "—"}
</td>
{@const visible = showUnchangedAttrs
? attrDiff.rows
: [...attrDiff.added, ...attrDiff.changed]}
{#if visible.length === 0}
<p class="text-xs text-muted-foreground">
{i18n.t("products.edit.attrsAllUnchanged", { count: attrDiff.same.length })}
</p>
{:else}
<div class="overflow-x-auto rounded-md border border-border">
<table class="w-full text-xs">
<thead class="bg-muted/40 text-muted-foreground">
<tr>
<th class="px-2 py-1.5 text-left font-medium">{i18n.t("products.edit.attrKey")}</th>
<th class="px-2 py-1.5 text-left font-medium">{i18n.t("products.edit.original")}</th>
<th class="px-2 py-1.5 text-left font-medium">{i18n.t("products.edit.enriched")}</th>
<th class="px-2 py-1.5 text-right font-medium">{i18n.t("products.edit.attrState")}</th>
</tr>
{/each}
</tbody>
</table>
</div>
</thead>
<tbody>
{#each visible as row (row.key)}
<tr class="border-t border-border {row.state === 'same' ? '' : 'bg-chart-amber/5'}">
<td class="px-2 py-1.5 align-top">
<span class="font-mono">{row.key}</span>
{#if row.aliases.length > 0}
<span
class="block text-[10px] text-muted-foreground/70"
title={i18n.t("products.edit.attrAliasHint")}
>
= {row.aliases.join(", ")}
</span>
{/if}
</td>
<td
class="px-2 py-1.5 align-top {row.before
? 'text-muted-foreground'
: 'italic text-muted-foreground/60'}"
>
{row.before || i18n.t("products.edit.notInFeed")}
</td>
<td
class="px-2 py-1.5 align-top {row.state === 'same'
? 'text-muted-foreground'
: 'font-medium text-foreground'}"
>
{row.after || "—"}
</td>
<td class="px-2 py-1.5 text-right align-top">
{#if row.state !== "same"}
<Badge variant="outline" class="text-[10px]">
{i18n.t(`products.edit.attrState.${row.state}`)}
</Badge>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{#if attrDiff.same.length > 0}
<button
type="button"
class="text-[11px] text-primary underline-offset-2 hover:underline"
onclick={() => (showUnchangedAttrs = !showUnchangedAttrs)}
>
{showUnchangedAttrs
? i18n.t("products.edit.hideUnchanged")
: i18n.t("products.edit.showUnchanged", { count: attrDiff.same.length })}
</button>
{/if}
{/if}
</div>
</TabsContent>
@@ -1113,6 +1256,19 @@
</TabsContent>
<TabsContent value="content" class="mt-4 space-y-4">
{#if metaPresent}
<section class="space-y-2 rounded-md border border-border bg-muted/20 p-3">
<h3 class="text-sm font-medium">{i18n.t("products.edit.seoMeta")}</h3>
{#each metaFields as field (field.key)}
<div class="space-y-1">
<p class="text-xs font-medium text-muted-foreground">{field.label}</p>
<p class="rounded-md border border-border bg-background px-3 py-2 text-sm">
{field.after || field.before || "—"}
</p>
</div>
{/each}
</section>
{/if}
<div class="space-y-2">
<div class="flex items-center justify-between gap-2">
<Label for="edit-processed-desc">{i18n.t("products.edit.aiProcessedDescription")}</Label>
@@ -1170,6 +1326,36 @@
</div>
{/if}
</div>
{#if kind === "processed"}
<div class="space-y-2 rounded-md border border-border p-3">
<div class="flex items-center justify-between gap-2">
<Label>{i18n.t("products.edit.seoMeta")}</Label>
<span class="text-[11px] text-muted-foreground">
{i18n.t("products.edit.metaLengthHint")}
</span>
</div>
{#each metaFields as field (field.key)}
<div class="space-y-1">
<div class="flex items-center justify-between gap-2">
<p class="text-xs font-medium text-muted-foreground">{field.label}</p>
{#if field.after}
<span class="text-[11px] tabular-nums text-muted-foreground">
{field.after.length}
</span>
{/if}
</div>
<div
class="min-h-10 rounded-md border border-border bg-muted/50 px-3 py-2 text-sm {field.after
? 'text-foreground'
: 'italic text-muted-foreground/70'}"
>
{field.after || i18n.t("products.edit.metaNotGenerated")}
</div>
</div>
{/each}
</div>
{/if}
</TabsContent>
<TabsContent value="attributes" class="mt-4 space-y-5">
+42 -284
View File
@@ -1,4 +1,28 @@
import { i18n } from "$lib/i18n";
import { buildAttributeDiff, type AttributeDiff } from "$lib/attribute-diff";
import {
asRecord,
formatProductAttrValue,
productAttrEntries,
formatMappedFeedValue,
attributeKeyFromLabel,
isValidAttributeKey,
canonicalizeAttributeKey,
productFeedAttributeEntries
} from "../../product-attributes";
export {
asRecord,
formatProductAttrValue,
productAttrEntries,
formatMappedFeedValue,
attributeKeyFromLabel,
isValidAttributeKey,
canonicalizeAttributeKey,
productFeedAttributeEntries
};
export type {
ProductListURLFilters,
@@ -72,6 +96,12 @@ export type ProductRow = {
processed_description?: string | null;
meta_title?: string | null;
meta_description?: string | null;
/**
* The company's cohort does not generate SEO meta (A1 / Platform Demo).
* Set by the API so an empty meta block can say "disabled here" instead of
* looking like the AI produced nothing.
*/
seo_meta_omitted?: boolean | null;
localized_content?: Record<
string,
{
@@ -455,67 +485,8 @@ export function formatRelativeUpdated(value?: string | Date | null): string {
return rtf.format(-Math.round(months / 12), "year");
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
/**
* Format a product attribute value for display.
* Legacy dumps store select-like attrs as `{ key, name }` and empties as JSON null
* never show raw JSON or the literal string "null".
*/
export function formatProductAttrValue(value: unknown): string {
if (value == null) return "";
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
if (Array.isArray(value)) {
return value
.map((item) => formatProductAttrValue(item))
.filter((s) => s.trim() !== "")
.join(", ");
}
const rec = asRecord(value);
if (!rec) return "";
for (const key of ["value", "name", "label", "text", "#text"]) {
const nested = formatProductAttrValue(rec[key]);
if (nested.trim() !== "") return nested;
}
return "";
}
/** Flatten an attributes blob into display rows; skips empty / junk keys. */
export function productAttrEntries(raw: unknown): { key: string; value: string }[] {
if (!raw) return [];
const out: { key: string; value: string }[] = [];
const seen = new Set<string>();
const push = (rawKey: string, rawValue: unknown) => {
const value = formatProductAttrValue(rawValue).trim();
if (!value) return;
const key = canonicalizeAttributeKey(rawKey);
if (!key) return;
const norm = key.toLowerCase();
if (seen.has(norm)) return;
seen.add(norm);
out.push({ key, value });
};
if (Array.isArray(raw)) {
for (const item of raw) {
if (item && typeof item === "object") {
const rec = item as Record<string, unknown>;
push(String(rec.key ?? rec.name ?? rec.attribute_key ?? ""), rec.value ?? rec.name ?? item);
}
}
return out;
}
const rec = asRecord(raw);
if (!rec) return [];
for (const [key, value] of Object.entries(rec)) {
if (key === "specifications" || key === "specs" || key === "eprel") continue;
push(key, value);
}
return out;
}
/** Original feed description when processed_products.description was never filled (legacy). */
export function resolveOriginalDescription(product: ProductRow | null | undefined): string {
@@ -573,22 +544,6 @@ export function productFeedFieldSummary(
return productFeedMappedEntries(product, options).filter((e) => e.preferred);
}
/** Format any mapped_data value for display (scalars + nested JSON). */
export function formatMappedFeedValue(value: unknown): string {
if (value == null) return "";
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
const simple = formatProductAttrValue(value);
if (simple.trim() !== "") return simple;
if (typeof value === "object") {
try {
return JSON.stringify(value, null, 2);
} catch {
return "";
}
}
return String(value);
}
export type FeedMappedEntry = {
key: string;
@@ -642,224 +597,15 @@ export function productFeedMappedEntries(
});
}
const FEED_ATTR_KEYS = [
"specifications",
"specs",
"warranty",
"productmodel",
"product_model",
"netwidth",
"net_width",
"netheight",
"net_height",
"netdepth",
"net_depth",
"netmass",
"net_mass",
"visina",
"sirina",
"globina",
"teza",
"eprel_id",
"eprel"
] as const;
/** Locale / supplier aliases → Descrybe standard field keys (snake_case). */
const ATTR_KEY_ALIASES: Record<string, string> = {
visina: "net_height",
height: "net_height",
netheight: "net_height",
net_height: "net_height",
sirina: "net_width",
width: "net_width",
netwidth: "net_width",
net_width: "net_width",
globina: "net_depth",
depth: "net_depth",
netdepth: "net_depth",
net_depth: "net_depth",
netmass: "net_mass",
mass: "net_mass",
weight: "net_mass",
teza: "net_mass",
net_mass: "net_mass",
productmodel: "product_model",
product_model: "product_model",
model: "product_model",
eprel_id: "eprel_id",
eprelid: "eprel_id",
eprel: "eprel_id",
energyclass: "energy_class",
energijskirazred: "energy_class",
warranty: "warranty"
};
function compactAttributeKey(key: string): string {
return key
.trim()
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[šśş]/g, "s")
.replace(/[čćç]/g, "c")
.replace(/[žźż]/g, "z")
.replace(/đ/g, "d")
.replace(/[^a-z0-9]+/g, "");
}
/** Kebab-case slug from a human label (matches A1 attribute_key style). */
export function attributeKeyFromLabel(label: string): string {
const folded = label
.trim()
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[šśş]/g, "s")
.replace(/[čćç]/g, "c")
.replace(/[žźż]/g, "z")
.replace(/đ/g, "d");
return folded
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
export function isValidAttributeKey(key: string): boolean {
const compact = compactAttributeKey(key);
if (compact.length < 2) return false;
if (!/[a-z]/.test(compact)) return false;
switch (compact) {
case "true":
case "false":
case "yes":
case "no":
case "null":
case "undefined":
case "none":
case "n":
case "y":
return false;
default:
return true;
}
}
/** Normalize feed/UI attribute labels onto standard keys; rejects junk. */
export function canonicalizeAttributeKey(label: string): string {
const slug = attributeKeyFromLabel(label);
const compact = compactAttributeKey(slug || label);
if (!compact) return "";
const alias = ATTR_KEY_ALIASES[compact];
if (alias) return alias;
if (!slug || !isValidAttributeKey(slug)) return "";
return slug;
}
function stripHtmlLight(value: string): string {
return value
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<\/(li|p|div|tr)>/gi, "\n")
.replace(/<\/>/g, "\n")
.replace(/<[^>]+>/g, " ")
.replace(/[ \t]+\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.replace(/[ \t]{2,}/g, " ")
.trim();
}
function pushFeedAttr(
out: { key: string; value: string; linked?: boolean; label?: string }[],
seen: Set<string>,
key: string,
value: string,
label?: string
) {
const formatted = value.trim();
if (!formatted) return;
const canonical = canonicalizeAttributeKey(key);
if (!canonical) return;
const norm = canonical.toLowerCase();
if (seen.has(norm)) return;
seen.add(norm);
const sourceLabel = (label || key).trim();
out.push({
key: canonical,
value: formatted,
linked: Boolean(sourceLabel && sourceLabel !== canonical),
label: sourceLabel && sourceLabel !== canonical ? sourceLabel : canonical
});
}
function parseSpecificationPairs(raw: string): { key: string; value: string; label: string }[] {
const plain = stripHtmlLight(raw);
if (!plain) return [];
const chunks = plain
.split(/\n+|;|\|/)
.map((c) => c.trim())
.filter(Boolean);
const out: { key: string; value: string; label: string }[] = [];
for (const chunk of chunks) {
const m = chunk.match(/^([^:=]{1,120})\s*[:=]\s*(.+)$/);
if (!m) continue;
const label = m[1].trim();
const value = m[2].trim();
if (!label || !value) continue;
const key = canonicalizeAttributeKey(label);
if (!key) continue;
out.push({ key, value, label });
}
return out;
}
/** Feed-sourced attribute-like fields (specs, dimensions, eprel) for the Attributes tab. */
export function productFeedAttributeEntries(
product: ProductRow | null | undefined
): { key: string; value: string; linked?: boolean; label?: string }[] {
const mapped = asRecord(product?.mapped_data);
if (!mapped) return [];
const out: { key: string; value: string; linked?: boolean; label?: string }[] = [];
const seen = new Set<string>();
for (const key of ["specifications", "specs"] as const) {
if (!(key in mapped)) continue;
const raw = mapped[key];
if (typeof raw === "string") {
for (const pair of parseSpecificationPairs(raw)) {
pushFeedAttr(out, seen, pair.key, pair.value, pair.label);
}
continue;
}
const rec = asRecord(raw);
if (rec) {
for (const [k, v] of Object.entries(rec)) {
if (k === "_raw" && typeof v === "string") {
for (const pair of parseSpecificationPairs(v)) {
pushFeedAttr(out, seen, pair.key, pair.value, pair.label);
}
continue;
}
const formatted = formatMappedFeedValue(v);
if (!formatted.trim()) continue;
const attrKey = canonicalizeAttributeKey(k);
if (!attrKey) continue;
pushFeedAttr(out, seen, attrKey, formatted, k);
}
}
}
for (const key of FEED_ATTR_KEYS) {
if (key === "specifications" || key === "specs") continue;
if (!(key in mapped) || seen.has(key.toLowerCase())) continue;
let formatted = formatMappedFeedValue(mapped[key]);
if (/<\/?[a-z][\s\S]*>/i.test(formatted) || formatted.includes("</>")) {
formatted = stripHtmlLight(formatted);
}
if (formatted.trim() === "") continue;
const linkedKey = canonicalizeAttributeKey(key);
if (!linkedKey) continue;
pushFeedAttr(out, seen, linkedKey, formatted, key);
}
return out;
}
function digAttrBag(
product: ProductRow | null | undefined,
@@ -927,3 +673,15 @@ export function productEprelLabel(product: ProductRow | null | undefined): strin
if (energy != null && String(energy).trim()) return `EPREL ${String(energy).trim()}`;
return "EPREL";
}
/**
* Review diff between the attributes the FEED supplied and the enriched set.
* The comparison itself lives in $lib/attribute-diff (dependency-free so it can be
* unit-tested); this only supplies the two extracted lists.
*/
export function productAttributeDiff(product: ProductRow | null | undefined): AttributeDiff {
return buildAttributeDiff(
productFeedAttributeEntries(product),
productAttrEntries(product?.processed_attributes ?? product?.attributes)
);
}
+23
View File
@@ -5772,4 +5772,27 @@ export const de: MessageDict = {
"admin.aiCosts.colOut": "Ausgabe",
"admin.aiCosts.colCost": "Kosten",
"admin.aiCosts.unattributed": "Nicht zugeordnet",
"products.edit.added": "Hinzugefuegt",
"products.edit.notInFeed": "nicht im Feed",
"products.edit.attrsAddedCount": "{count} hinzugefuegt",
"products.edit.seoMeta": "SEO-Meta",
"products.edit.metaTitle": "Meta-Titel",
"products.edit.metaDescription": "Meta-Beschreibung",
"products.edit.attrState": "Aenderung",
"products.edit.attrState.added": "Hinzugefuegt",
"products.edit.attrState.changed": "Geaendert",
"products.edit.attrState.removed": "Entfernt",
"products.edit.attrAliasHint": "Gleicher Wert unter diesen anderen Attributnamen gespeichert",
"products.edit.attrsAllUnchanged": "Alle {count} Attribute entsprechen dem Feed.",
"products.edit.showUnchanged": "{count} unveraenderte anzeigen",
"products.edit.hideUnchanged": "Unveraenderte ausblenden",
"products.edit.alsoStoredAs": "+{count} Alias",
"products.edit.attrsAddedHeading": "Durch Anreicherung ergaenzt ({count})",
"products.edit.attrsChangedHeading": "Gegenueber Feed geaendert ({count})",
"products.edit.attrsShowUnchanged": "{count} unveraenderte anzeigen",
"products.edit.attrsHideUnchanged": "{count} unveraenderte ausblenden",
"products.edit.metaNotGenerated": "nicht erzeugt",
"products.edit.metaLengthHint": "Titel 50-60 / Beschreibung 120-155 Zeichen",
"products.edit.seoMetaDisabled": "Nicht genutzt",
"products.edit.seoMetaDisabledHint": "SEO-Meta ist fuer diese Firma deaktiviert und wird daher nicht erzeugt.",
};
+23
View File
@@ -5858,4 +5858,27 @@ export const en: MessageDict = {
"admin.aiCosts.colOut": "Output",
"admin.aiCosts.colCost": "Cost",
"admin.aiCosts.unattributed": "Unattributed",
"products.edit.added": "Added",
"products.edit.notInFeed": "not in feed",
"products.edit.attrsAddedCount": "{count} added",
"products.edit.seoMeta": "SEO meta",
"products.edit.metaTitle": "Meta title",
"products.edit.metaDescription": "Meta description",
"products.edit.attrState": "Change",
"products.edit.attrState.added": "Added",
"products.edit.attrState.changed": "Changed",
"products.edit.attrState.removed": "Removed",
"products.edit.attrAliasHint": "Same value stored under these other attribute names",
"products.edit.attrsAllUnchanged": "All {count} attributes match the feed.",
"products.edit.showUnchanged": "Show {count} unchanged",
"products.edit.hideUnchanged": "Hide unchanged",
"products.edit.alsoStoredAs": "+{count} alias",
"products.edit.attrsAddedHeading": "Added by enrichment ({count})",
"products.edit.attrsChangedHeading": "Changed from feed ({count})",
"products.edit.attrsShowUnchanged": "Show {count} unchanged",
"products.edit.attrsHideUnchanged": "Hide {count} unchanged",
"products.edit.metaNotGenerated": "not generated",
"products.edit.metaLengthHint": "title 50-60 / description 120-155 characters",
"products.edit.seoMetaDisabled": "Not used",
"products.edit.seoMetaDisabledHint": "SEO meta is turned off for this company, so processing does not generate it.",
};
+23
View File
@@ -5781,4 +5781,27 @@ export const es: MessageDict = {
"admin.aiCosts.colOut": "Salida",
"admin.aiCosts.colCost": "Coste",
"admin.aiCosts.unattributed": "Sin asignar",
"products.edit.added": "Anadido",
"products.edit.notInFeed": "no esta en el feed",
"products.edit.attrsAddedCount": "{count} anadidos",
"products.edit.seoMeta": "Meta SEO",
"products.edit.metaTitle": "Meta titulo",
"products.edit.metaDescription": "Meta descripcion",
"products.edit.attrState": "Cambio",
"products.edit.attrState.added": "Anadido",
"products.edit.attrState.changed": "Modificado",
"products.edit.attrState.removed": "Eliminado",
"products.edit.attrAliasHint": "El mismo valor guardado con estos otros nombres de atributo",
"products.edit.attrsAllUnchanged": "Los {count} atributos coinciden con el feed.",
"products.edit.showUnchanged": "Mostrar {count} sin cambios",
"products.edit.hideUnchanged": "Ocultar sin cambios",
"products.edit.alsoStoredAs": "+{count} alias",
"products.edit.attrsAddedHeading": "Anadido por el enriquecimiento ({count})",
"products.edit.attrsChangedHeading": "Modificado respecto al feed ({count})",
"products.edit.attrsShowUnchanged": "Mostrar {count} sin cambios",
"products.edit.attrsHideUnchanged": "Ocultar {count} sin cambios",
"products.edit.metaNotGenerated": "no generado",
"products.edit.metaLengthHint": "titulo 50-60 / descripcion 120-155 caracteres",
"products.edit.seoMetaDisabled": "No se usa",
"products.edit.seoMetaDisabledHint": "El meta SEO esta desactivado para esta empresa, por lo que no se genera.",
};
+23
View File
@@ -5781,4 +5781,27 @@ export const fr: MessageDict = {
"admin.aiCosts.colOut": "Sortie",
"admin.aiCosts.colCost": "Cout",
"admin.aiCosts.unattributed": "Non attribue",
"products.edit.added": "Ajoute",
"products.edit.notInFeed": "absent du flux",
"products.edit.attrsAddedCount": "{count} ajoutes",
"products.edit.seoMeta": "Meta SEO",
"products.edit.metaTitle": "Meta titre",
"products.edit.metaDescription": "Meta description",
"products.edit.attrState": "Changement",
"products.edit.attrState.added": "Ajoute",
"products.edit.attrState.changed": "Modifie",
"products.edit.attrState.removed": "Supprime",
"products.edit.attrAliasHint": "Meme valeur enregistree sous ces autres noms d attribut",
"products.edit.attrsAllUnchanged": "Les {count} attributs correspondent au flux.",
"products.edit.showUnchanged": "Afficher {count} inchanges",
"products.edit.hideUnchanged": "Masquer les inchanges",
"products.edit.alsoStoredAs": "+{count} alias",
"products.edit.attrsAddedHeading": "Ajoute par l enrichissement ({count})",
"products.edit.attrsChangedHeading": "Modifie par rapport au flux ({count})",
"products.edit.attrsShowUnchanged": "Afficher {count} inchanges",
"products.edit.attrsHideUnchanged": "Masquer {count} inchanges",
"products.edit.metaNotGenerated": "non genere",
"products.edit.metaLengthHint": "titre 50-60 / description 120-155 caracteres",
"products.edit.seoMetaDisabled": "Non utilise",
"products.edit.seoMetaDisabledHint": "Le meta SEO est desactive pour cette entreprise, il n est donc pas genere.",
};
+23
View File
@@ -5781,4 +5781,27 @@ export const it: MessageDict = {
"admin.aiCosts.colOut": "Output",
"admin.aiCosts.colCost": "Costo",
"admin.aiCosts.unattributed": "Non attribuito",
"products.edit.added": "Aggiunto",
"products.edit.notInFeed": "non nel feed",
"products.edit.attrsAddedCount": "{count} aggiunti",
"products.edit.seoMeta": "Meta SEO",
"products.edit.metaTitle": "Meta titolo",
"products.edit.metaDescription": "Meta descrizione",
"products.edit.attrState": "Modifica",
"products.edit.attrState.added": "Aggiunto",
"products.edit.attrState.changed": "Modificato",
"products.edit.attrState.removed": "Rimosso",
"products.edit.attrAliasHint": "Stesso valore salvato con questi altri nomi di attributo",
"products.edit.attrsAllUnchanged": "Tutti i {count} attributi corrispondono al feed.",
"products.edit.showUnchanged": "Mostra {count} invariati",
"products.edit.hideUnchanged": "Nascondi invariati",
"products.edit.alsoStoredAs": "+{count} alias",
"products.edit.attrsAddedHeading": "Aggiunto dall arricchimento ({count})",
"products.edit.attrsChangedHeading": "Modificato rispetto al feed ({count})",
"products.edit.attrsShowUnchanged": "Mostra {count} invariati",
"products.edit.attrsHideUnchanged": "Nascondi {count} invariati",
"products.edit.metaNotGenerated": "non generato",
"products.edit.metaLengthHint": "titolo 50-60 / descrizione 120-155 caratteri",
"products.edit.seoMetaDisabled": "Non usato",
"products.edit.seoMetaDisabledHint": "Il meta SEO e disattivato per questa azienda, quindi non viene generato.",
};
+23
View File
@@ -5781,4 +5781,27 @@ export const ja: MessageDict = {
"admin.aiCosts.colOut": "出力",
"admin.aiCosts.colCost": "コスト",
"admin.aiCosts.unattributed": "未割り当て",
"products.edit.added": "追加",
"products.edit.notInFeed": "フィードにありません",
"products.edit.attrsAddedCount": "{count} 件追加",
"products.edit.seoMeta": "SEO メタ",
"products.edit.metaTitle": "メタタイトル",
"products.edit.metaDescription": "メタディスクリプション",
"products.edit.attrState": "変更",
"products.edit.attrState.added": "追加",
"products.edit.attrState.changed": "変更",
"products.edit.attrState.removed": "削除",
"products.edit.attrAliasHint": "同じ値が別の属性名でも保存されています",
"products.edit.attrsAllUnchanged": "{count} 件の属性はすべてフィードと一致します。",
"products.edit.showUnchanged": "変更なし {count} 件を表示",
"products.edit.hideUnchanged": "変更なしを隠す",
"products.edit.alsoStoredAs": "+{count} 別名",
"products.edit.attrsAddedHeading": "エンリッチメントで追加 ({count})",
"products.edit.attrsChangedHeading": "フィードから変更 ({count})",
"products.edit.attrsShowUnchanged": "変更なし {count} 件を表示",
"products.edit.attrsHideUnchanged": "変更なし {count} 件を隠す",
"products.edit.metaNotGenerated": "未生成",
"products.edit.metaLengthHint": "タイトル 50-60 / 説明 120-155 文字",
"products.edit.seoMetaDisabled": "未使用",
"products.edit.seoMetaDisabledHint": "この会社では SEO メタが無効なため生成されません。",
};
+23
View File
@@ -5781,4 +5781,27 @@ export const nl: MessageDict = {
"admin.aiCosts.colOut": "Uitvoer",
"admin.aiCosts.colCost": "Kosten",
"admin.aiCosts.unattributed": "Niet toegewezen",
"products.edit.added": "Toegevoegd",
"products.edit.notInFeed": "niet in de feed",
"products.edit.attrsAddedCount": "{count} toegevoegd",
"products.edit.seoMeta": "SEO-meta",
"products.edit.metaTitle": "Metatitel",
"products.edit.metaDescription": "Metabeschrijving",
"products.edit.attrState": "Wijziging",
"products.edit.attrState.added": "Toegevoegd",
"products.edit.attrState.changed": "Gewijzigd",
"products.edit.attrState.removed": "Verwijderd",
"products.edit.attrAliasHint": "Dezelfde waarde opgeslagen onder deze andere attribuutnamen",
"products.edit.attrsAllUnchanged": "Alle {count} attributen komen overeen met de feed.",
"products.edit.showUnchanged": "Toon {count} ongewijzigd",
"products.edit.hideUnchanged": "Verberg ongewijzigde",
"products.edit.alsoStoredAs": "+{count} alias",
"products.edit.attrsAddedHeading": "Toegevoegd door verrijking ({count})",
"products.edit.attrsChangedHeading": "Gewijzigd t.o.v. feed ({count})",
"products.edit.attrsShowUnchanged": "Toon {count} ongewijzigd",
"products.edit.attrsHideUnchanged": "Verberg {count} ongewijzigd",
"products.edit.metaNotGenerated": "niet gegenereerd",
"products.edit.metaLengthHint": "titel 50-60 / beschrijving 120-155 tekens",
"products.edit.seoMetaDisabled": "Niet gebruikt",
"products.edit.seoMetaDisabledHint": "SEO-meta is uitgeschakeld voor dit bedrijf en wordt dus niet gegenereerd.",
};
+23
View File
@@ -5781,4 +5781,27 @@ export const pl: MessageDict = {
"admin.aiCosts.colOut": "Wyjscie",
"admin.aiCosts.colCost": "Koszt",
"admin.aiCosts.unattributed": "Nieprzypisane",
"products.edit.added": "Dodano",
"products.edit.notInFeed": "brak w zrodle",
"products.edit.attrsAddedCount": "Dodano: {count}",
"products.edit.seoMeta": "Meta SEO",
"products.edit.metaTitle": "Meta tytul",
"products.edit.metaDescription": "Meta opis",
"products.edit.attrState": "Zmiana",
"products.edit.attrState.added": "Dodano",
"products.edit.attrState.changed": "Zmieniono",
"products.edit.attrState.removed": "Usunieto",
"products.edit.attrAliasHint": "Ta sama wartosc zapisana pod tymi innymi nazwami atrybutow",
"products.edit.attrsAllUnchanged": "Wszystkie {count} atrybuty zgadzaja sie ze zrodlem.",
"products.edit.showUnchanged": "Pokaz {count} niezmienionych",
"products.edit.hideUnchanged": "Ukryj niezmienione",
"products.edit.alsoStoredAs": "+{count} alias",
"products.edit.attrsAddedHeading": "Dodane przez wzbogacanie ({count})",
"products.edit.attrsChangedHeading": "Zmienione wzgledem zrodla ({count})",
"products.edit.attrsShowUnchanged": "Pokaz niezmienione ({count})",
"products.edit.attrsHideUnchanged": "Ukryj niezmienione ({count})",
"products.edit.metaNotGenerated": "nie wygenerowano",
"products.edit.metaLengthHint": "tytul 50-60 / opis 120-155 znakow",
"products.edit.seoMetaDisabled": "Nieuzywane",
"products.edit.seoMetaDisabledHint": "Meta SEO jest wylaczone dla tej firmy, wiec nie jest generowane.",
};
+23
View File
@@ -5781,4 +5781,27 @@ export const pt: MessageDict = {
"admin.aiCosts.colOut": "Saida",
"admin.aiCosts.colCost": "Custo",
"admin.aiCosts.unattributed": "Nao atribuido",
"products.edit.added": "Adicionado",
"products.edit.notInFeed": "nao esta no feed",
"products.edit.attrsAddedCount": "{count} adicionados",
"products.edit.seoMeta": "Meta SEO",
"products.edit.metaTitle": "Meta titulo",
"products.edit.metaDescription": "Meta descricao",
"products.edit.attrState": "Alteracao",
"products.edit.attrState.added": "Adicionado",
"products.edit.attrState.changed": "Alterado",
"products.edit.attrState.removed": "Removido",
"products.edit.attrAliasHint": "O mesmo valor guardado sob estes outros nomes de atributo",
"products.edit.attrsAllUnchanged": "Os {count} atributos correspondem ao feed.",
"products.edit.showUnchanged": "Mostrar {count} sem alteracoes",
"products.edit.hideUnchanged": "Ocultar sem alteracoes",
"products.edit.alsoStoredAs": "+{count} alias",
"products.edit.attrsAddedHeading": "Adicionado pelo enriquecimento ({count})",
"products.edit.attrsChangedHeading": "Alterado face ao feed ({count})",
"products.edit.attrsShowUnchanged": "Mostrar {count} sem alteracoes",
"products.edit.attrsHideUnchanged": "Ocultar {count} sem alteracoes",
"products.edit.metaNotGenerated": "nao gerado",
"products.edit.metaLengthHint": "titulo 50-60 / descricao 120-155 caracteres",
"products.edit.seoMetaDisabled": "Nao utilizado",
"products.edit.seoMetaDisabledHint": "O meta SEO esta desativado para esta empresa, por isso nao e gerado.",
};
+23
View File
@@ -92,4 +92,27 @@ export const sl: MessageDict = {
"admin.aiCosts.colOut": "Izhod",
"admin.aiCosts.colCost": "Strosek",
"admin.aiCosts.unattributed": "Nedodeljeno",
"products.edit.added": "Dodano",
"products.edit.notInFeed": "ni v viru",
"products.edit.attrsAddedCount": "Dodanih: {count}",
"products.edit.seoMeta": "SEO meta",
"products.edit.metaTitle": "Meta naslov",
"products.edit.metaDescription": "Meta opis",
"products.edit.attrState": "Sprememba",
"products.edit.attrState.added": "Dodano",
"products.edit.attrState.changed": "Spremenjeno",
"products.edit.attrState.removed": "Odstranjeno",
"products.edit.attrAliasHint": "Ista vrednost je shranjena tudi pod temi imeni atributov",
"products.edit.attrsAllUnchanged": "Vseh {count} atributov se ujema z virom.",
"products.edit.showUnchanged": "Prikazi {count} nespremenjenih",
"products.edit.hideUnchanged": "Skrij nespremenjene",
"products.edit.alsoStoredAs": "+{count} vzdevek",
"products.edit.attrsAddedHeading": "Dodano z obogatitvijo ({count})",
"products.edit.attrsChangedHeading": "Spremenjeno glede na vir ({count})",
"products.edit.attrsShowUnchanged": "Prikazi nespremenjene ({count})",
"products.edit.attrsHideUnchanged": "Skrij nespremenjene ({count})",
"products.edit.metaNotGenerated": "ni ustvarjeno",
"products.edit.metaLengthHint": "naslov 50-60 / opis 120-155 znakov",
"products.edit.seoMetaDisabled": "Ni v uporabi",
"products.edit.seoMetaDisabledHint": "SEO meta je za to podjetje izklopljen, zato ga obdelava ne ustvari.",
};
@@ -0,0 +1,75 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
canonicalizeAttributeKey,
productAttrEntries,
productFeedAttributeEntries
} from "./product-attributes.ts";
/**
* EPREL enrichment stores the same fact under several names. Review listed each of
* them as its own row, so a product showed "energy class" twice with identical
* values reading as if it carried two different measurements.
*
* Key set below is copied from a real enriched row (Dell monitor, EPREL 1870858).
*/
const ENRICHED_ATTRS = {
brand: "Dell",
energy_class: "D",
eprel_energy_class: "D",
eprel_energy_scale: "A_G",
eprel_id: "1870858",
eprel_label: "https://eprel.ec.europa.eu/api/product/1870858/labels?format=png",
eprel_label_url: "https://eprel.ec.europa.eu/api/product/1870858/labels?format=png",
eprel_pdf: "https://eprel.ec.europa.eu/fiches/electronicdisplays/Fiche_1870858_EN.pdf",
eprel_pdf_url: "https://eprel.ec.europa.eu/fiches/electronicdisplays/Fiche_1870858_EN.pdf",
eprel: { energy_class: "D", energy_scale: "A_G", id: "1870858" },
product_model: "210-BMJD",
warranty: "36M"
};
describe("product attribute aliases", () => {
it("folds every EPREL alias onto one key", () => {
assert.equal(canonicalizeAttributeKey("eprel_energy_class"), "energy_class");
assert.equal(canonicalizeAttributeKey("eprel-energy-class"), "energy_class");
assert.equal(canonicalizeAttributeKey("energy_class"), "energy_class");
assert.equal(canonicalizeAttributeKey("eprel_pdf_url"), "eprel_pdf");
assert.equal(canonicalizeAttributeKey("eprel-pdf"), "eprel_pdf");
assert.equal(canonicalizeAttributeKey("eprel_label_url"), "eprel_label");
assert.equal(canonicalizeAttributeKey("eprel_energy_scale"), "energy_scale");
});
it("lists each enriched fact exactly once", () => {
const keys = productAttrEntries(ENRICHED_ATTRS).map((a) => a.key);
const counts = new Map<string, number>();
for (const k of keys) counts.set(k, (counts.get(k) ?? 0) + 1);
const duplicated = [...counts.entries()].filter(([, n]) => n > 1);
assert.deepEqual(duplicated, [], `duplicate attribute rows: ${JSON.stringify(duplicated)}`);
assert.equal(counts.get("energy_class"), 1, "energy class must appear once");
assert.equal(counts.get("eprel_pdf"), 1, "EPREL pdf must appear once");
assert.equal(counts.get("eprel_label"), 1, "EPREL label must appear once");
// The nested eprel object duplicates all of the above and must not add a row.
assert.ok(!keys.includes("eprel"), "nested eprel object should not be listed");
});
it("lines the feed's own energy class up with the enriched key", () => {
// Slovenian feed label for the same fact — if it canonicalised differently the
// review would show it as Added instead of Unchanged.
assert.equal(canonicalizeAttributeKey("energijski-razred"), "energy_class");
const feed = productFeedAttributeEntries({
id: "x",
mapped_data: { specifications: { "energijski-razred": "D", barva: "črna" } }
});
const byKey = new Map(feed.map((a) => [a.key, a.value]));
assert.equal(byKey.get("energy_class"), "D");
});
it("reports nothing for a feed with no specs", () => {
// Products whose specs the pipeline derived entirely (EPREL / AI) must show an
// empty Original, not a copy of the enriched values.
assert.deepEqual(productFeedAttributeEntries({ id: "x", mapped_data: {} }), []);
assert.deepEqual(productFeedAttributeEntries({ id: "x", mapped_data: { specifications: "" } }), []);
});
});
+320
View File
@@ -0,0 +1,320 @@
/**
* Product attribute keys and values, extracted from the product types module so it
* can be unit tested: that module imports $lib/i18n, which the node test runner
* cannot resolve.
*
* The canonical-key map here is what stops enrichment aliases (energy_class vs
* eprel_energy_class, eprel_pdf vs eprel_pdf_url) from showing up as separate rows
* in the review diff.
*/
/** Minimal shape needed here; the full ProductRow lives in components/products/types. */
type ProductRow = { mapped_data?: unknown; [k: string]: unknown };
const FEED_ATTR_KEYS = [
"specifications",
"specs",
"warranty",
"productmodel",
"product_model",
"netwidth",
"net_width",
"netheight",
"net_height",
"netdepth",
"net_depth",
"netmass",
"net_mass",
"visina",
"sirina",
"globina",
"teza",
"eprel_id",
"eprel"
] as const;
export function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
/**
* Format a product attribute value for display.
* Legacy dumps store select-like attrs as `{ key, name }` and empties as JSON null
* never show raw JSON or the literal string "null".
*/
export function formatProductAttrValue(value: unknown): string {
if (value == null) return "";
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
if (Array.isArray(value)) {
return value
.map((item) => formatProductAttrValue(item))
.filter((s) => s.trim() !== "")
.join(", ");
}
const rec = asRecord(value);
if (!rec) return "";
for (const key of ["value", "name", "label", "text", "#text"]) {
const nested = formatProductAttrValue(rec[key]);
if (nested.trim() !== "") return nested;
}
return "";
}
/** Flatten an attributes blob into display rows; skips empty / junk keys. */
export function productAttrEntries(raw: unknown): { key: string; value: string }[] {
if (!raw) return [];
const out: { key: string; value: string }[] = [];
const seen = new Set<string>();
const push = (rawKey: string, rawValue: unknown) => {
const value = formatProductAttrValue(rawValue).trim();
if (!value) return;
const key = canonicalizeAttributeKey(rawKey);
if (!key) return;
const norm = key.toLowerCase();
if (seen.has(norm)) return;
seen.add(norm);
out.push({ key, value });
};
if (Array.isArray(raw)) {
for (const item of raw) {
if (item && typeof item === "object") {
const rec = item as Record<string, unknown>;
push(String(rec.key ?? rec.name ?? rec.attribute_key ?? ""), rec.value ?? rec.name ?? item);
}
}
return out;
}
const rec = asRecord(raw);
if (!rec) return [];
for (const [key, value] of Object.entries(rec)) {
if (key === "specifications" || key === "specs" || key === "eprel") continue;
push(key, value);
}
return out;
}
/** Format any mapped_data value for display (scalars + nested JSON). */
export function formatMappedFeedValue(value: unknown): string {
if (value == null) return "";
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
const simple = formatProductAttrValue(value);
if (simple.trim() !== "") return simple;
if (typeof value === "object") {
try {
return JSON.stringify(value, null, 2);
} catch {
return "";
}
}
return String(value);
}
/** Locale / supplier aliases → Descrybe standard field keys (snake_case). */
export const ATTR_KEY_ALIASES: Record<string, string> = {
visina: "net_height",
height: "net_height",
netheight: "net_height",
net_height: "net_height",
sirina: "net_width",
width: "net_width",
netwidth: "net_width",
net_width: "net_width",
globina: "net_depth",
depth: "net_depth",
netdepth: "net_depth",
net_depth: "net_depth",
netmass: "net_mass",
mass: "net_mass",
weight: "net_mass",
teza: "net_mass",
net_mass: "net_mass",
productmodel: "product_model",
product_model: "product_model",
model: "product_model",
eprel_id: "eprel_id",
eprelid: "eprel_id",
eprel: "eprel_id",
energyclass: "energy_class",
energijskirazred: "energy_class",
// EPREL enrichment writes the same fact under several names (energy_class +
// eprel_energy_class, eprel_pdf + eprel_pdf_url, …). Without these the review
// table listed energy class twice and each EPREL link twice, which reads as if
// the product carried two different values.
eprelenergyclass: "energy_class",
eprelenergyscale: "energy_scale",
energyscale: "energy_scale",
eprelpdf: "eprel_pdf",
eprelpdfurl: "eprel_pdf",
eprellabel: "eprel_label",
eprellabelurl: "eprel_label",
warranty: "warranty"
};
export function compactAttributeKey(key: string): string {
return key
.trim()
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[šśş]/g, "s")
.replace(/[čćç]/g, "c")
.replace(/[žźż]/g, "z")
.replace(/đ/g, "d")
.replace(/[^a-z0-9]+/g, "");
}
/** Kebab-case slug from a human label (matches A1 attribute_key style). */
export function attributeKeyFromLabel(label: string): string {
const folded = label
.trim()
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[šśş]/g, "s")
.replace(/[čćç]/g, "c")
.replace(/[žźż]/g, "z")
.replace(/đ/g, "d");
return folded
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
export function isValidAttributeKey(key: string): boolean {
const compact = compactAttributeKey(key);
if (compact.length < 2) return false;
if (!/[a-z]/.test(compact)) return false;
switch (compact) {
case "true":
case "false":
case "yes":
case "no":
case "null":
case "undefined":
case "none":
case "n":
case "y":
return false;
default:
return true;
}
}
/** Normalize feed/UI attribute labels onto standard keys; rejects junk. */
export function canonicalizeAttributeKey(label: string): string {
const slug = attributeKeyFromLabel(label);
const compact = compactAttributeKey(slug || label);
if (!compact) return "";
const alias = ATTR_KEY_ALIASES[compact];
if (alias) return alias;
if (!slug || !isValidAttributeKey(slug)) return "";
return slug;
}
export function stripHtmlLight(value: string): string {
return value
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<\/(li|p|div|tr)>/gi, "\n")
.replace(/<\/>/g, "\n")
.replace(/<[^>]+>/g, " ")
.replace(/[ \t]+\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.replace(/[ \t]{2,}/g, " ")
.trim();
}
export function pushFeedAttr(
out: { key: string; value: string; linked?: boolean; label?: string }[],
seen: Set<string>,
key: string,
value: string,
label?: string
) {
const formatted = value.trim();
if (!formatted) return;
const canonical = canonicalizeAttributeKey(key);
if (!canonical) return;
const norm = canonical.toLowerCase();
if (seen.has(norm)) return;
seen.add(norm);
const sourceLabel = (label || key).trim();
out.push({
key: canonical,
value: formatted,
linked: Boolean(sourceLabel && sourceLabel !== canonical),
label: sourceLabel && sourceLabel !== canonical ? sourceLabel : canonical
});
}
export function parseSpecificationPairs(raw: string): { key: string; value: string; label: string }[] {
const plain = stripHtmlLight(raw);
if (!plain) return [];
const chunks = plain
.split(/\n+|;|\|/)
.map((c) => c.trim())
.filter(Boolean);
const out: { key: string; value: string; label: string }[] = [];
for (const chunk of chunks) {
const m = chunk.match(/^([^:=]{1,120})\s*[:=]\s*(.+)$/);
if (!m) continue;
const label = m[1].trim();
const value = m[2].trim();
if (!label || !value) continue;
const key = canonicalizeAttributeKey(label);
if (!key) continue;
out.push({ key, value, label });
}
return out;
}
/** Feed-sourced attribute-like fields (specs, dimensions, eprel) for the Attributes tab. */
export function productFeedAttributeEntries(
product: ProductRow | null | undefined
): { key: string; value: string; linked?: boolean; label?: string }[] {
const mapped = asRecord(product?.mapped_data);
if (!mapped) return [];
const out: { key: string; value: string; linked?: boolean; label?: string }[] = [];
const seen = new Set<string>();
for (const key of ["specifications", "specs"] as const) {
if (!(key in mapped)) continue;
const raw = mapped[key];
if (typeof raw === "string") {
for (const pair of parseSpecificationPairs(raw)) {
pushFeedAttr(out, seen, pair.key, pair.value, pair.label);
}
continue;
}
const rec = asRecord(raw);
if (rec) {
for (const [k, v] of Object.entries(rec)) {
if (k === "_raw" && typeof v === "string") {
for (const pair of parseSpecificationPairs(v)) {
pushFeedAttr(out, seen, pair.key, pair.value, pair.label);
}
continue;
}
const formatted = formatMappedFeedValue(v);
if (!formatted.trim()) continue;
const attrKey = canonicalizeAttributeKey(k);
if (!attrKey) continue;
pushFeedAttr(out, seen, attrKey, formatted, k);
}
}
}
for (const key of FEED_ATTR_KEYS) {
if (key === "specifications" || key === "specs") continue;
if (!(key in mapped) || seen.has(key.toLowerCase())) continue;
let formatted = formatMappedFeedValue(mapped[key]);
if (/<\/?[a-z][\s\S]*>/i.test(formatted) || formatted.includes("</>")) {
formatted = stripHtmlLight(formatted);
}
if (formatted.trim() === "") continue;
const linkedKey = canonicalizeAttributeKey(key);
if (!linkedKey) continue;
pushFeedAttr(out, seen, linkedKey, formatted, key);
}
return out;
}
+41
View File
@@ -78,6 +78,47 @@ go run ./cmd/formula-e2e -new-tenant -llm-base http://127.0.0.1:18767/v1
`-llm-base` pins the provider for the run; without it the harness uses whatever
`platformsettings.ResolveOpenAI` returns (admin DB setting first, then env).
## What Review compares
Review shows Original vs Enriched for name, description, category, attributes and
SEO meta. Each "Original" must come from a source the pipeline never writes, which
is less obvious than it sounds:
| field | Original comes from | trap |
|---|---|---|
| name / description | `original_name` / `original_description` | `name`/`description` from the API are display-preferred and resolve to the ENRICHED copy |
| category | `feed_category` | `mapped_data.category` is written back by the pipeline; `category_source` says who chose it |
| attributes | `mapped_data.specifications` (feed specs) | `processed_products.attributes` is written to the same value as `processed_attributes` |
| | | EPREL writes one fact under several names — `product-attributes.ts` folds the aliases so it is listed once |
| SEO meta | `mapped_data.meta_*` | most feeds have none, so Enriched shows as **Added** |
Attributes are grouped **Added / Changed / Unchanged**, with unchanged collapsed —
a review should lead with what the run actually did. Alias keys are folded into one
row: enrichment stores `eprel_pdf` and `eprel_pdf_url`, `energy_class` and
`eprel_energy_class`, which otherwise listed the same fact two or three times.
Folding needs a related name AND an identical value, so width and height are never
merged just because they match (`$lib/attribute-diff`, unit-tested).
The product detail endpoint deliberately does **not** apply the SEO-meta omit rule.
That rule keeps meta out of list views for cohorts that do not sell on it, but the
detail payload feeds review, and meta the pipeline actually generated has to be
reviewable — the panel hides the block itself when both sides are empty.
Anything with no Original is labelled **Added** and shown as "not in feed", not
"Matched" — saying Matched about a field the supplier never sent reads as if the
feed had one.
Two things behave this way by design, not by accident:
- **A1 Slovenija and Platform Demo do not GENERATE SEO meta**
(`processing.CompanyOmitsSEOMetaID`). Meta already stored on their rows is still
shown — the read path only hides empty values, so Review and Content display what
the AI actually produced.
- **Rows processed before `category_source` existed** cannot say whether their
`mapped_data.category` came from the feed or from an earlier AI run, so Review
still treats it as feed-supplied. Reprocessing the product records the real
provenance.
## Low-confidence categorisation fails the product
The categorize model always answers with *some* id from the list, so a reply like