Promote tracker patterns to org third parties via worker
Manual moves of a non-extension TrackerPattern lacking a ThirdPartyID now request mapping, which the tracker-mapping worker resolves with a four-stage pipeline: exact common_third_party_id link, heuristic ranking, agent disambiguation, and finally CreateFromCommon. Existing fuzzy-matched org rows are tagged with common_third_party_id so the next promotion takes the O(1) exact-link path. The matching primitives live in pkg/thirdparty (RankCandidates, LinkToCommon, CreateFromCommon, ScoredCandidate, threshold constants) so the disambiguation agent and the heuristic share one candidate type. Cookiebanner orchestrates them; cookie-banner-specific concerns (pattern -> common-pattern -> common-party navigation, the EXTENSION gate, and structured logs) stay in the worker. Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
274
pkg/thirdparty/match.go
vendored
Normal file
274
pkg/thirdparty/match.go
vendored
Normal file
@@ -0,0 +1,274 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package thirdparty
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/slug"
|
||||
"go.probo.inc/probo/pkg/uri"
|
||||
)
|
||||
|
||||
// Heuristic thresholds for matching a CommonThirdParty to an existing
|
||||
// org ThirdParty. Exported so callers can short-circuit explicitly
|
||||
// (skip the agent, fall back to creating, etc.) instead of duplicating
|
||||
// magic numbers.
|
||||
const (
|
||||
// HighConfidenceScore is the floor at which a heuristic match
|
||||
// is treated as obvious (exact name, suffix-stripped name, slug
|
||||
// equality). Callers typically link without consulting the
|
||||
// agent at this score.
|
||||
HighConfidenceScore = 0.85
|
||||
|
||||
// MinAgentScore is the floor below which a candidate is
|
||||
// statistical noise and should not be shown to the
|
||||
// disambiguation agent. Below this, callers typically prefer
|
||||
// to create a fresh row over asking the model to pick among
|
||||
// weak candidates.
|
||||
MinAgentScore = 0.6
|
||||
|
||||
// MaxAgentCandidates caps the candidate list shown to the
|
||||
// disambiguation agent. The list is heuristic-ranked, so the
|
||||
// top few are the only ones worth the agent's tokens.
|
||||
MaxAgentCandidates = 5
|
||||
)
|
||||
|
||||
// ScoredCandidate is a scored heuristic-match candidate. It is the
|
||||
// unified currency between the heuristic ranker (RankCandidates) and
|
||||
// the disambiguation agent (Disambiguate): the agent renders the
|
||||
// `ThirdParty` fields plus the score directly into its prompt, with
|
||||
// no intermediate DTO.
|
||||
type ScoredCandidate struct {
|
||||
ThirdParty *coredata.ThirdParty
|
||||
Score float64
|
||||
}
|
||||
|
||||
// corporateSuffixes are the legal-form noise words stripped when
|
||||
// comparing third-party names heuristically. The list is intentionally
|
||||
// short and conservative: matching "Foo Inc" to "Foo" is safe, but
|
||||
// stripping "Group" or "Services" would over-match unrelated entries.
|
||||
//
|
||||
// Order matters: stripCorporateSuffixes returns on the first match,
|
||||
// so longer / comma-prefixed forms must come before their shorter
|
||||
// siblings (", inc." before " inc.", which itself comes before " inc").
|
||||
var corporateSuffixes = []string{
|
||||
" incorporated",
|
||||
" corporation",
|
||||
", inc.",
|
||||
", inc",
|
||||
" l.l.c.",
|
||||
" s.a.s.",
|
||||
" inc.",
|
||||
" inc",
|
||||
" llc",
|
||||
" ltd.",
|
||||
" ltd",
|
||||
" limited",
|
||||
" gmbh",
|
||||
" s.a.",
|
||||
" sas",
|
||||
" sa",
|
||||
" ag",
|
||||
" plc",
|
||||
" corp.",
|
||||
" corp",
|
||||
" co.",
|
||||
" co",
|
||||
" b.v.",
|
||||
" bv",
|
||||
}
|
||||
|
||||
// RankCandidates ranks org ThirdParty rows by how likely each is to
|
||||
// represent the given CommonThirdParty. Returned slice is sorted by
|
||||
// descending score; only candidates with score > 0 are kept. Pure
|
||||
// function: no I/O, deterministic on its inputs.
|
||||
//
|
||||
// Scoring (highest match wins; website-host overlap can lift a name
|
||||
// miss to 0.8):
|
||||
//
|
||||
// - exact lowercase name = 1.0
|
||||
// - lowercase name with corporate suffix stripped, equal = 0.9
|
||||
// - slug equality (slug.Make on the org's name) = 0.85
|
||||
// - website host (eTLD+1) overlap with the catalog domain set = 0.8
|
||||
func RankCandidates(
|
||||
commonParty coredata.CommonThirdParty,
|
||||
commonDomains coredata.CommonThirdPartyDomains,
|
||||
candidates coredata.ThirdParties,
|
||||
) []ScoredCandidate {
|
||||
commonName := strings.ToLower(strings.TrimSpace(commonParty.Name))
|
||||
commonStripped := stripCorporateSuffixes(commonName)
|
||||
commonSlug := commonParty.Slug
|
||||
|
||||
commonHost := ""
|
||||
if commonParty.WebsiteURL != nil {
|
||||
commonHost = uri.ExtractDomain(*commonParty.WebsiteURL)
|
||||
}
|
||||
|
||||
commonDomainSet := make(map[string]struct{}, len(commonDomains))
|
||||
for _, d := range commonDomains {
|
||||
commonDomainSet[strings.ToLower(d.Domain)] = struct{}{}
|
||||
}
|
||||
|
||||
if commonHost != "" {
|
||||
commonDomainSet[commonHost] = struct{}{}
|
||||
}
|
||||
|
||||
scored := make([]ScoredCandidate, 0, len(candidates))
|
||||
|
||||
for _, tp := range candidates {
|
||||
score := 0.0
|
||||
|
||||
orgName := strings.ToLower(strings.TrimSpace(tp.Name))
|
||||
orgStripped := stripCorporateSuffixes(orgName)
|
||||
|
||||
switch {
|
||||
case orgName != "" && orgName == commonName:
|
||||
score = 1.0
|
||||
case orgStripped != "" && orgStripped == commonStripped:
|
||||
score = 0.9
|
||||
case commonSlug != "" && slug.Make(tp.Name) == commonSlug:
|
||||
score = 0.85
|
||||
}
|
||||
|
||||
if tp.WebsiteURL != nil {
|
||||
orgHost := uri.ExtractDomain(*tp.WebsiteURL)
|
||||
if orgHost != "" {
|
||||
if _, hit := commonDomainSet[orgHost]; hit {
|
||||
if score < 0.8 {
|
||||
score = 0.8
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if score == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
scored = append(scored, ScoredCandidate{
|
||||
ThirdParty: tp,
|
||||
Score: score,
|
||||
})
|
||||
}
|
||||
|
||||
sort.SliceStable(scored, func(i, j int) bool {
|
||||
return scored[i].Score > scored[j].Score
|
||||
})
|
||||
|
||||
return scored
|
||||
}
|
||||
|
||||
// stripCorporateSuffixes removes a single trailing legal-form suffix
|
||||
// from a lowercased name. Only one suffix is stripped to avoid
|
||||
// mangling names that happen to end in two stop-words (e.g. "Foo Inc
|
||||
// LLC" → "Foo Inc", not "Foo").
|
||||
func stripCorporateSuffixes(lowerName string) string {
|
||||
for _, s := range corporateSuffixes {
|
||||
if before, ok := strings.CutSuffix(lowerName, s); ok {
|
||||
return strings.TrimSpace(before)
|
||||
}
|
||||
}
|
||||
|
||||
return lowerName
|
||||
}
|
||||
|
||||
// LinkToCommon writes common_third_party_id onto an org ThirdParty so
|
||||
// future matches against the same CommonThirdParty can short-circuit
|
||||
// to the exact-link path in O(1). No-op when the field is already set
|
||||
// to commonID; otherwise writes the field via ThirdParty.Update and
|
||||
// updates the receiver in place.
|
||||
func LinkToCommon(
|
||||
ctx context.Context,
|
||||
tx pg.Tx,
|
||||
scope coredata.Scoper,
|
||||
orgThirdParty *coredata.ThirdParty,
|
||||
commonID gid.GID,
|
||||
) error {
|
||||
if orgThirdParty.CommonThirdPartyID != nil && *orgThirdParty.CommonThirdPartyID == commonID {
|
||||
return nil
|
||||
}
|
||||
|
||||
orgThirdParty.CommonThirdPartyID = &commonID
|
||||
|
||||
if err := orgThirdParty.Update(ctx, tx, scope); err != nil {
|
||||
return fmt.Errorf("cannot update third party with common id: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateFromCommon inserts a new org ThirdParty seeded from the catalog
|
||||
// row (name, category, addresses, URLs, certifications, …). The new row
|
||||
// has common_third_party_id pointed at commonParty, an empty Countries
|
||||
// list, and ShowOnTrustCenter / FirstLevel both false — mirroring the
|
||||
// front-end CreateThirdPartyDialog's "pick from catalog" seeding shape
|
||||
// so the result is indistinguishable from a manual creation.
|
||||
//
|
||||
// Deliberately bypasses any service-level webhook emission: callers
|
||||
// that need a webhook for the implicit creation should emit it
|
||||
// themselves.
|
||||
func CreateFromCommon(
|
||||
ctx context.Context,
|
||||
tx pg.Tx,
|
||||
scope coredata.Scoper,
|
||||
organizationID gid.GID,
|
||||
commonParty coredata.CommonThirdParty,
|
||||
) (*coredata.ThirdParty, error) {
|
||||
commonID := commonParty.ID
|
||||
now := time.Now()
|
||||
|
||||
tp := &coredata.ThirdParty{
|
||||
ID: gid.New(scope.GetTenantID(), coredata.ThirdPartyEntityType),
|
||||
OrganizationID: organizationID,
|
||||
CommonThirdPartyID: &commonID,
|
||||
Name: commonParty.Name,
|
||||
Category: commonParty.Category,
|
||||
HeadquarterAddress: commonParty.HeadquarterAddress,
|
||||
LegalName: commonParty.LegalName,
|
||||
WebsiteURL: commonParty.WebsiteURL,
|
||||
PrivacyPolicyURL: commonParty.PrivacyPolicyURL,
|
||||
ServiceLevelAgreementURL: commonParty.ServiceLevelAgreementURL,
|
||||
DataProcessingAgreementURL: commonParty.DataProcessingAgreementURL,
|
||||
BusinessAssociateAgreementURL: commonParty.BusinessAssociateAgreementURL,
|
||||
SubprocessorsListURL: commonParty.SubprocessorsListURL,
|
||||
Certifications: commonParty.Certifications,
|
||||
Countries: coredata.CountryCodes{},
|
||||
StatusPageURL: commonParty.StatusPageURL,
|
||||
TermsOfServiceURL: commonParty.TermsOfServiceURL,
|
||||
SecurityPageURL: commonParty.SecurityPageURL,
|
||||
TrustPageURL: commonParty.TrustPageURL,
|
||||
ShowOnTrustCenter: false,
|
||||
FirstLevel: false,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if tp.Certifications == nil {
|
||||
tp.Certifications = []string{}
|
||||
}
|
||||
|
||||
if err := tp.Insert(ctx, tx, scope); err != nil {
|
||||
return nil, fmt.Errorf("cannot insert org third party: %w", err)
|
||||
}
|
||||
|
||||
return tp, nil
|
||||
}
|
||||
Reference in New Issue
Block a user