Files
probo/pkg/thirdparty/match.go
Émile Ré 44aca07de3 Guard LinkToCommon against overwriting existing catalog link
When an org third party already has a common_third_party_id set,
LinkToCommon now skips the write instead of overwriting it with a
different catalog ID. This prevents heuristic or agent false
positives from corrupting a previous, more accurate association.

Signed-off-by: Émile Ré <emile@probo.com>
2026-05-29 10:07:31 +02:00

277 lines
8.8 KiB
Go

// 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 any value) — we never overwrite an existing catalog link because
// a heuristic or agent false-positive must not corrupt a previous,
// possibly more accurate, association.
func LinkToCommon(
ctx context.Context,
tx pg.Tx,
scope coredata.Scoper,
orgThirdParty *coredata.ThirdParty,
commonID gid.GID,
) error {
if orgThirdParty.CommonThirdPartyID != nil {
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, ShowOnTrustCenter false, and FirstLevel true — the caller has
// already confirmed the vendor is actively present on the
// organization's cookie banner, which makes it a first-level third
// party by definition.
//
// 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: true,
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
}