diff --git a/apps/api/internal/catalog/service.go b/apps/api/internal/catalog/service.go index 13d4534..d5ae561 100644 --- a/apps/api/internal/catalog/service.go +++ b/apps/api/internal/catalog/service.go @@ -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", diff --git a/apps/api/internal/httpapi/catalog_handlers.go b/apps/api/internal/httpapi/catalog_handlers.go index 094a541..611636d 100644 --- a/apps/api/internal/httpapi/catalog_handlers.go +++ b/apps/api/internal/httpapi/catalog_handlers.go @@ -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. } } diff --git a/apps/api/internal/httpapi/catalog_handlers_seo_test.go b/apps/api/internal/httpapi/catalog_handlers_seo_test.go index 33e6ccb..7c28793 100644 --- a/apps/api/internal/httpapi/catalog_handlers_seo_test.go +++ b/apps/api/internal/httpapi/catalog_handlers_seo_test.go @@ -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") diff --git a/apps/api/internal/processing/categorize_ai.go b/apps/api/internal/processing/categorize_ai.go index 3d1579c..b34f2a0 100644 --- a/apps/api/internal/processing/categorize_ai.go +++ b/apps/api/internal/processing/categorize_ai.go @@ -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() diff --git a/apps/api/internal/processing/pipeline.go b/apps/api/internal/processing/pipeline.go index 13ad20d..7c9fc39 100644 --- a/apps/api/internal/processing/pipeline.go +++ b/apps/api/internal/processing/pipeline.go @@ -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) } } diff --git a/apps/web/src/lib/attribute-diff.test.ts b/apps/web/src/lib/attribute-diff.test.ts new file mode 100644 index 0000000..3edde04 --- /dev/null +++ b/apps/web/src/lib/attribute-diff.test.ts @@ -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"); + }); +}); diff --git a/apps/web/src/lib/attribute-diff.ts b/apps/web/src/lib/attribute-diff.ts new file mode 100644 index 0000000..8626924 --- /dev/null +++ b/apps/web/src/lib/attribute-diff.ts @@ -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(); + 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 = { 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") + }; +} diff --git a/apps/web/src/lib/components/products/ProductEditPanel.svelte b/apps/web/src/lib/components/products/ProductEditPanel.svelte index 515bb69..c9ab531 100644 --- a/apps/web/src/lib/components/products/ProductEditPanel.svelte +++ b/apps/web/src/lib/components/products/ProductEditPanel.svelte @@ -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 | null | undefined; + if (!mapped || typeof mapped !== "object") return ""; + for (const k of keys) { + const v = (mapped as Record)[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 @@ >
- {#if categoryChanged} + {#if categoryAdded} + {i18n.t("products.edit.added")} + {:else if categoryChanged} {i18n.t("products.edit.changed")} {:else} {i18n.t("products.edit.matched")} @@ -808,7 +840,9 @@ {i18n.t("products.edit.original")}

{feedCategoryLabel || i18n.t("products.edit.noCategoryFromFeed")}
@@ -837,46 +871,155 @@
+ {#if kind === "processed"} +
+
+ + {#if metaOmitted && !metaChanged} + + {i18n.t("products.edit.seoMetaDisabled")} + + {:else if metaChanged} + + {i18n.t( + metaFields.every((f) => !f.before || f.before === f.after) + ? "products.edit.added" + : "products.edit.changed" + )} + + {:else} + {i18n.t("products.edit.matched")} + {/if} +
+ {#if metaOmitted && !metaFields.some((f) => f.after)} +

+ {i18n.t("products.edit.seoMetaDisabledHint")} +

+ {/if} + {#each metaFields as field (field.key)} +
+

{field.label}

+
+
+ {field.before || i18n.t("products.edit.notInFeed")} +
+
+ {field.after || i18n.t("products.edit.metaNotGenerated")} +
+
+
+ {/each} +
+ {/if} +
-
+
- {#if attrsChanged} - - {i18n.t("products.edit.attrsChangedCount", { count: attrDiff.changed.length })} - - {:else} - {i18n.t("products.edit.matched")} - {/if} +
+ {#if attrDiff.added.length > 0} + + {i18n.t("products.edit.attrsAddedCount", { count: attrDiff.added.length })} + + {/if} + {#if attrDiff.changed.length > 0} + + {i18n.t("products.edit.attrsChangedCount", { count: attrDiff.changed.length })} + + {/if} + {#if !attrsChanged} + {i18n.t("products.edit.matched")} + {/if} +
{#if attrDiff.rows.length === 0}

{i18n.t("products.edit.noAttributes")}

{:else} -
- - - - - - - - - - {#each attrDiff.rows as row (row.key)} - - - - + {@const visible = showUnchangedAttrs + ? attrDiff.rows + : [...attrDiff.added, ...attrDiff.changed]} + {#if visible.length === 0} +

+ {i18n.t("products.edit.attrsAllUnchanged", { count: attrDiff.same.length })} +

+ {:else} +
+
{i18n.t("products.edit.attrKey")}{i18n.t("products.edit.original")}{i18n.t("products.edit.enriched")}
{row.key}{row.before || "—"} - {row.after || "—"} -
+ + + + + + - {/each} - -
{i18n.t("products.edit.attrKey")}{i18n.t("products.edit.original")}{i18n.t("products.edit.enriched")}{i18n.t("products.edit.attrState")}
-
+ + + {#each visible as row (row.key)} + + + {row.key} + {#if row.aliases.length > 0} + + = {row.aliases.join(", ")} + + {/if} + + + {row.before || i18n.t("products.edit.notInFeed")} + + + {row.after || "—"} + + + {#if row.state !== "same"} + + {i18n.t(`products.edit.attrState.${row.state}`)} + + {/if} + + + {/each} + + +
+ {/if} + {#if attrDiff.same.length > 0} + + {/if} {/if}
@@ -1113,6 +1256,19 @@ + {#if metaPresent} +
+

{i18n.t("products.edit.seoMeta")}

+ {#each metaFields as field (field.key)} +
+

{field.label}

+

+ {field.after || field.before || "—"} +

+
+ {/each} +
+ {/if}
@@ -1170,6 +1326,36 @@
{/if}
+ + {#if kind === "processed"} +
+
+ + + {i18n.t("products.edit.metaLengthHint")} + +
+ {#each metaFields as field (field.key)} +
+
+

{field.label}

+ {#if field.after} + + {field.after.length} + + {/if} +
+
+ {field.after || i18n.t("products.edit.metaNotGenerated")} +
+
+ {/each} +
+ {/if}
diff --git a/apps/web/src/lib/components/products/types.ts b/apps/web/src/lib/components/products/types.ts index d94984b..c42e7b5 100644 --- a/apps/web/src/lib/components/products/types.ts +++ b/apps/web/src/lib/components/products/types.ts @@ -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 | null { - if (!value || typeof value !== "object" || Array.isArray(value)) return null; - return value as Record; -} -/** - * 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(); - 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; - 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 = { - 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(//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, - 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(); - - 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) + ); +} diff --git a/apps/web/src/lib/i18n/messages/de.ts b/apps/web/src/lib/i18n/messages/de.ts index 168ac3e..68918fc 100644 --- a/apps/web/src/lib/i18n/messages/de.ts +++ b/apps/web/src/lib/i18n/messages/de.ts @@ -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.", }; diff --git a/apps/web/src/lib/i18n/messages/en.ts b/apps/web/src/lib/i18n/messages/en.ts index b059e95..a759d60 100644 --- a/apps/web/src/lib/i18n/messages/en.ts +++ b/apps/web/src/lib/i18n/messages/en.ts @@ -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.", }; diff --git a/apps/web/src/lib/i18n/messages/es.ts b/apps/web/src/lib/i18n/messages/es.ts index f37614e..2420365 100644 --- a/apps/web/src/lib/i18n/messages/es.ts +++ b/apps/web/src/lib/i18n/messages/es.ts @@ -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.", }; diff --git a/apps/web/src/lib/i18n/messages/fr.ts b/apps/web/src/lib/i18n/messages/fr.ts index 8cd47eb..df2da0e 100644 --- a/apps/web/src/lib/i18n/messages/fr.ts +++ b/apps/web/src/lib/i18n/messages/fr.ts @@ -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.", }; diff --git a/apps/web/src/lib/i18n/messages/it.ts b/apps/web/src/lib/i18n/messages/it.ts index 2a2e472..b009c65 100644 --- a/apps/web/src/lib/i18n/messages/it.ts +++ b/apps/web/src/lib/i18n/messages/it.ts @@ -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.", }; diff --git a/apps/web/src/lib/i18n/messages/ja.ts b/apps/web/src/lib/i18n/messages/ja.ts index 9e208d8..6d8978d 100644 --- a/apps/web/src/lib/i18n/messages/ja.ts +++ b/apps/web/src/lib/i18n/messages/ja.ts @@ -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 メタが無効なため生成されません。", }; diff --git a/apps/web/src/lib/i18n/messages/nl.ts b/apps/web/src/lib/i18n/messages/nl.ts index 4002be7..31f57b7 100644 --- a/apps/web/src/lib/i18n/messages/nl.ts +++ b/apps/web/src/lib/i18n/messages/nl.ts @@ -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.", }; diff --git a/apps/web/src/lib/i18n/messages/pl.ts b/apps/web/src/lib/i18n/messages/pl.ts index 1e08a97..b61c96f 100644 --- a/apps/web/src/lib/i18n/messages/pl.ts +++ b/apps/web/src/lib/i18n/messages/pl.ts @@ -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.", }; diff --git a/apps/web/src/lib/i18n/messages/pt.ts b/apps/web/src/lib/i18n/messages/pt.ts index 9dca60f..f74907e 100644 --- a/apps/web/src/lib/i18n/messages/pt.ts +++ b/apps/web/src/lib/i18n/messages/pt.ts @@ -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.", }; diff --git a/apps/web/src/lib/i18n/messages/sl.ts b/apps/web/src/lib/i18n/messages/sl.ts index b6ac82d..e3350bb 100644 --- a/apps/web/src/lib/i18n/messages/sl.ts +++ b/apps/web/src/lib/i18n/messages/sl.ts @@ -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.", }; diff --git a/apps/web/src/lib/product-attr-aliases.test.ts b/apps/web/src/lib/product-attr-aliases.test.ts new file mode 100644 index 0000000..10e70fd --- /dev/null +++ b/apps/web/src/lib/product-attr-aliases.test.ts @@ -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(); + 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: "" } }), []); + }); +}); diff --git a/apps/web/src/lib/product-attributes.ts b/apps/web/src/lib/product-attributes.ts new file mode 100644 index 0000000..5fd4d19 --- /dev/null +++ b/apps/web/src/lib/product-attributes.ts @@ -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 | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + return value as Record; +} + +/** + * 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(); + 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; + 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 = { + 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(//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, + 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(); + + 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; +} diff --git a/docs/category-formulas.md b/docs/category-formulas.md index 6d2f7c0..3555060 100644 --- a/docs/category-formulas.md +++ b/docs/category-formulas.md @@ -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