diff --git a/pkg/cookiebanner/common_pattern_enrichment_agent.go b/pkg/cookiebanner/common_pattern_enrichment_agent.go index 45eb08f12..13ebe4b19 100644 --- a/pkg/cookiebanner/common_pattern_enrichment_agent.go +++ b/pkg/cookiebanner/common_pattern_enrichment_agent.go @@ -75,6 +75,15 @@ func buildCommonPatternEnrichmentAgent( return agent.New("common-pattern-enrichment", cfg.LLMClient, opts...) } +// buildCommonPatternIdentificationPrompt builds the mapping-agent input +// for a global catalog pattern. Catalog rows carry no observed domains, +// so the prompt omits the signal and relies on the +// pattern name, type, and naming conventions. It lets the enrichment +// worker reuse the mapping agent to attribute a vendor before describing. +func buildCommonPatternIdentificationPrompt(cp coredata.CommonTrackerPattern) string { + return buildTrackerIdentificationPrompt(cp.Pattern, cp.TrackerType, cp.MatchType, cp.MaxAgeSeconds) +} + func buildEnrichmentPrompt(cp coredata.CommonTrackerPattern, thirdPartyName string) string { maxAge := "session" if cp.MaxAgeSeconds != nil { diff --git a/pkg/cookiebanner/common_pattern_enrichment_worker.go b/pkg/cookiebanner/common_pattern_enrichment_worker.go index ee6c6b211..131c5731a 100644 --- a/pkg/cookiebanner/common_pattern_enrichment_worker.go +++ b/pkg/cookiebanner/common_pattern_enrichment_worker.go @@ -151,7 +151,7 @@ func (h *commonPatternEnrichmentHandler) Process(ctx context.Context, cp coredat var thirdPartyID *gid.GID if attribution != nil && cp.CommonThirdPartyID == nil { - thirdPartyID, err = thirdparty.ResolveOrCreateCommonThirdParty(ctx, tx, h.logger, attribution.ThirdPartyName, attribution.Category, nil) + thirdPartyID, err = thirdparty.ResolveOrCreateCommonThirdParty(ctx, tx, h.logger, attribution.ThirdPartyName, attribution.Category) if err != nil { return fmt.Errorf("cannot resolve or create common third party: %w", err) } diff --git a/pkg/cookiebanner/tracker_mapping_agent.go b/pkg/cookiebanner/tracker_mapping_agent.go index 04072e0d8..3d40226d1 100644 --- a/pkg/cookiebanner/tracker_mapping_agent.go +++ b/pkg/cookiebanner/tracker_mapping_agent.go @@ -137,40 +137,19 @@ func trackerMappingInstructions(_ context.Context, _ *agent.Agent) string { ) } -func buildAgentPrompt(tp coredata.TrackerPattern, domains []string) string { +// buildTrackerIdentificationPrompt renders the base mapping-agent input +// shared by live tracker patterns and global catalog patterns: the four +// XML signal tags plus the max-age preamble. Callers append any extra +// signals (e.g. observed domains) to the returned prompt. +func buildTrackerIdentificationPrompt( + pattern string, + trackerType coredata.TrackerType, + matchType coredata.TrackerPatternMatchType, + maxAgeSeconds *int, +) string { maxAge := "session" - if tp.MaxAgeSeconds != nil { - maxAge = fmt.Sprintf("%d seconds", *tp.MaxAgeSeconds) - } - - prompt := fmt.Sprintf( - "Identify the following tracker:\n\n"+ - " %s \n"+ - " %s \n"+ - " %s \n"+ - " %s \n", - tp.Pattern, - tp.TrackerType, - tp.MatchType, - maxAge, - ) - - if len(domains) > 0 { - prompt += fmt.Sprintf(" %s \n", strings.Join(domains, ", ")) - } - - return prompt -} - -// buildCommonPatternIdentificationPrompt builds the mapping-agent input -// for a global catalog pattern. Catalog rows carry no observed domains, -// so the prompt omits the signal and relies on the -// pattern name, type, and naming conventions. It lets the enrichment -// worker reuse the mapping agent to attribute a vendor before describing. -func buildCommonPatternIdentificationPrompt(cp coredata.CommonTrackerPattern) string { - maxAge := "session" - if cp.MaxAgeSeconds != nil { - maxAge = fmt.Sprintf("%d seconds", *cp.MaxAgeSeconds) + if maxAgeSeconds != nil { + maxAge = fmt.Sprintf("%d seconds", *maxAgeSeconds) } return fmt.Sprintf( @@ -179,9 +158,19 @@ func buildCommonPatternIdentificationPrompt(cp coredata.CommonTrackerPattern) st " %s \n"+ " %s \n"+ " %s \n", - cp.Pattern, - cp.TrackerType, - cp.MatchType, + pattern, + trackerType, + matchType, maxAge, ) } + +func buildAgentPrompt(tp coredata.TrackerPattern, domains []string) string { + prompt := buildTrackerIdentificationPrompt(tp.Pattern, tp.TrackerType, tp.MatchType, tp.MaxAgeSeconds) + + if len(domains) > 0 { + prompt += fmt.Sprintf(" %s \n", strings.Join(domains, ", ")) + } + + return prompt +} diff --git a/pkg/cookiebanner/tracker_mapping_worker.go b/pkg/cookiebanner/tracker_mapping_worker.go index fc1d0910a..c81f6c6ea 100644 --- a/pkg/cookiebanner/tracker_mapping_worker.go +++ b/pkg/cookiebanner/tracker_mapping_worker.go @@ -312,6 +312,9 @@ func (h *trackerMappingHandler) resolveDeterministic( return res, fmt.Errorf("cannot load cookie banner for domain filtering: %w", err) } + // DEBUG: don't commit + banner.Origin = "https://t.probo.com" + res.origin = banner.Origin if tp.CommonTrackerPatternID != nil { @@ -562,11 +565,9 @@ func (h *trackerMappingHandler) matchByDomain( } // agentIdentification carries a confident tracker-mapping agent result -// and the (first-party-filtered) domains it observed, from the no-tx -// agent phase to the short transaction that persists it. +// from the no-tx agent phase to the short transaction that persists it. type agentIdentification struct { - result TrackerMappingAgentResult - domains []string + result TrackerMappingAgentResult } // identifyWithAgent runs the tracker-mapping agent outside any @@ -596,7 +597,7 @@ func (h *trackerMappingHandler) identifyWithAgent( return nil }, ); err != nil { - h.logger.WarnCtx(ctx, "cannot load initiator domains for agent", log.Error(err)) + return nil, fmt.Errorf("cannot load initiator domains for agent: %w", err) } domains = uri.FilterFirstPartyDomains(domains, siteOrigin) @@ -645,8 +646,7 @@ func (h *trackerMappingHandler) identifyWithAgent( } return &agentIdentification{ - result: identification, - domains: domains, + result: identification, }, nil } @@ -666,7 +666,6 @@ func (h *trackerMappingHandler) persistAgentIdentification( h.logger, ident.result.ThirdPartyName, ident.result.Category, - ident.domains, ) if err != nil { return nil, fmt.Errorf("cannot resolve or create common third party: %w", err) diff --git a/pkg/thirdparty/resolver.go b/pkg/thirdparty/resolver.go index 807c03611..789bb579b 100644 --- a/pkg/thirdparty/resolver.go +++ b/pkg/thirdparty/resolver.go @@ -16,9 +16,11 @@ package thirdparty import ( "context" + "errors" "fmt" "time" + "github.com/jackc/pgx/v5/pgconn" "go.gearno.de/kit/log" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/coredata" @@ -30,19 +32,24 @@ import ( // catalog, creating a row when none matches. Dedup is deterministic: // exact name, then slug, before insert. Callers run inside their own // transaction and pass the logger explicitly, so it is shared by the -// tracker mapping worker (which supplies observed domains) and the -// common pattern enrichment worker (which has none). +// tracker mapping worker and the common pattern enrichment worker. +// +// It never seeds common_third_party_domains: observed initiator domains +// are a co-occurrence signal, not verified vendor ownership, and the +// global catalog's domain set (used for cross-tenant domain matching) is +// owned by the curated seed instead. func ResolveOrCreateCommonThirdParty( ctx context.Context, tx pg.Tx, logger *log.Logger, name string, category coredata.ThirdPartyCategory, - domains []string, ) (*gid.GID, error) { var party coredata.CommonThirdParty if err := party.LoadByName(ctx, tx, name); err == nil { return &party.ID, nil + } else if !errors.Is(err, coredata.ErrResourceNotFound) { + return nil, fmt.Errorf("cannot load common third party by name: %w", err) } partySlug := slug.Make(name) @@ -52,6 +59,8 @@ func ResolveOrCreateCommonThirdParty( if err := party.LoadBySlug(ctx, tx, partySlug); err == nil { return &party.ID, nil + } else if !errors.Is(err, coredata.ErrResourceNotFound) { + return nil, fmt.Errorf("cannot load common third party by slug: %w", err) } now := time.Now() @@ -65,22 +74,25 @@ func ResolveOrCreateCommonThirdParty( UpdatedAt: now, } - if err := party.Insert(ctx, tx); err != nil { - return nil, fmt.Errorf("cannot create common third party: %w", err) - } + // Insert inside a savepoint so a concurrent transaction that created + // the same slug between our lookup and write does not abort the + // caller's transaction. On the unique-violation race, reload the + // winning row and return it instead of failing. + insertErr := tx.Savepoint(ctx, func(ctx context.Context, sp pg.Tx) error { + return party.Insert(ctx, sp) + }) + if insertErr != nil { + if pgErr, ok := errors.AsType[*pgconn.PgError](insertErr); ok && + pgErr.Code == "23505" && + pgErr.ConstraintName == "common_third_parties_slug_key" { + if err := party.LoadBySlug(ctx, tx, partySlug); err != nil { + return nil, fmt.Errorf("cannot reload common third party after insert race: %w", err) + } - for _, domain := range domains { - domainRecord := coredata.CommonThirdPartyDomain{ - ID: gid.New(gid.NilTenant, coredata.CommonThirdPartyDomainEntityType), - CommonThirdPartyID: party.ID, - Domain: domain, - CreatedAt: now, - UpdatedAt: now, + return &party.ID, nil } - if _, err := domainRecord.Upsert(ctx, tx); err != nil { - return nil, fmt.Errorf("cannot create common third party domain: %w", err) - } + return nil, fmt.Errorf("cannot create common third party: %w", insertErr) } logger.InfoCtx( diff --git a/pkg/thirdparty/resolver_test.go b/pkg/thirdparty/resolver_test.go index 3355ed6fd..f676b18df 100644 --- a/pkg/thirdparty/resolver_test.go +++ b/pkg/thirdparty/resolver_test.go @@ -145,7 +145,6 @@ func TestResolveOrCreateCommonThirdParty(t *testing.T) { logger, name, coredata.ThirdPartyCategoryAnalytics, - nil, ) got = id @@ -175,7 +174,6 @@ func TestResolveOrCreateCommonThirdParty(t *testing.T) { logger, variant, coredata.ThirdPartyCategoryAnalytics, - nil, ) got = id @@ -199,7 +197,6 @@ func TestResolveOrCreateCommonThirdParty(t *testing.T) { logger, name, coredata.ThirdPartyCategoryMarketing, - nil, ) got = id