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)
}
}