fix
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user