Restore tracker mapping linking, drop only create

The tracker-mapping worker had been reduced to catalog resolution only,
which removed not just the auto-creation of an org ThirdParty but also
the auto-linking of an existing one. Only the creation needed to go: it
raced the load-then-create check and produced duplicate vendors.

Restore the full org ThirdParty resolution (exact common-id link,
sibling direct-link, high-confidence heuristic, and the disambiguation
agent) and remove only the CreateFromCommon branch and its
categorisation gate. When nothing matches, the worker now leaves
third_party_id unset rather than creating a vendor; creation happens
exclusively through the explicit ImportFromCommon action. Drop the
now-dead CreateFromCommon helper and rename match.go to common_match.go.

Fix a latent test bug surfaced by actually running the DB-backed suite
(skipped in CI without Postgres): the heuristic-match candidate lacked
Level 1, so the level-filtered candidate loader excluded it and the old
fallback create masked the miss.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-06-11 10:55:29 +02:00
parent 9a7bc26d49
commit 145aeaf402
17 changed files with 1323 additions and 238 deletions

217
pkg/thirdparty/common_match.go vendored Normal file
View File

@@ -0,0 +1,217 @@
// Copyright (c) 2026 Probo Inc <hello@probo.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"
"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, leave the pattern unlinked, 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
}

178
pkg/thirdparty/common_match_test.go vendored Normal file
View File

@@ -0,0 +1,178 @@
// Copyright (c) 2026 Probo Inc <hello@probo.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 (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
)
func TestStripCorporateSuffixes(t *testing.T) {
t.Parallel()
tests := []struct {
name string
in string
want string
}{
{name: "llc suffix", in: "google llc", want: "google"},
{name: "comma inc", in: "stripe, inc", want: "stripe"},
{name: "inc dot", in: "meta inc.", want: "meta"},
{name: "ltd", in: "deepmind ltd", want: "deepmind"},
{name: "gmbh", in: "n8n gmbh", want: "n8n"},
{name: "no suffix", in: "cloudflare", want: "cloudflare"},
{name: "trailing space", in: "github inc", want: "github"},
{name: "only one suffix stripped", in: "foo inc llc", want: "foo inc"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tt.want, stripCorporateSuffixes(tt.in))
})
}
}
func TestRankCandidates(t *testing.T) {
t.Parallel()
tenantID := gid.NewTenantID()
mkTP := func(name, website string) *coredata.ThirdParty {
tp := &coredata.ThirdParty{
ID: gid.New(tenantID, coredata.ThirdPartyEntityType),
Name: name,
}
if website != "" {
tp.WebsiteURL = new(website)
}
return tp
}
t.Run("exact name match scores 1.0", func(t *testing.T) {
t.Parallel()
common := coredata.CommonThirdParty{Name: "Google", Slug: "google"}
got := RankCandidates(common, nil, coredata.ThirdParties{
mkTP("Google", ""),
mkTP("Stripe", ""),
})
require.Len(t, got, 1)
assert.Equal(t, 1.0, got[0].Score)
assert.Equal(t, "Google", got[0].ThirdParty.Name)
})
t.Run("suffix-stripped name scores 0.9", func(t *testing.T) {
t.Parallel()
common := coredata.CommonThirdParty{Name: "Google", Slug: "google"}
got := RankCandidates(common, nil, coredata.ThirdParties{
mkTP("Google LLC", ""),
})
require.Len(t, got, 1)
assert.Equal(t, 0.9, got[0].Score)
})
t.Run("slug equality scores 0.85", func(t *testing.T) {
t.Parallel()
common := coredata.CommonThirdParty{Name: "Google", Slug: "google"}
got := RankCandidates(common, nil, coredata.ThirdParties{
mkTP("google!", ""),
})
require.Len(t, got, 1)
assert.Equal(t, 0.85, got[0].Score)
})
t.Run("website host overlap scores 0.8 when name does not match", func(t *testing.T) {
t.Parallel()
common := coredata.CommonThirdParty{
Name: "Google Analytics",
Slug: "google-analytics",
WebsiteURL: new("https://google.com"),
}
got := RankCandidates(common, nil, coredata.ThirdParties{
mkTP("Sundar's Search Co", "https://www.google.com/about"),
})
require.Len(t, got, 1)
assert.Equal(t, 0.8, got[0].Score)
})
t.Run("domain set overlap scores 0.8", func(t *testing.T) {
t.Parallel()
common := coredata.CommonThirdParty{Name: "Stripe", Slug: "stripe"}
domains := coredata.CommonThirdPartyDomains{
{Domain: "stripe.com"},
{Domain: "stripe.network"},
}
got := RankCandidates(common, domains, coredata.ThirdParties{
mkTP("Payment Processor", "https://api.stripe.com/v1"),
})
require.Len(t, got, 1)
assert.Equal(t, 0.8, got[0].Score)
})
t.Run("no match returns empty", func(t *testing.T) {
t.Parallel()
common := coredata.CommonThirdParty{Name: "Stripe", Slug: "stripe"}
got := RankCandidates(common, nil, coredata.ThirdParties{
mkTP("Acme", "https://acme.example"),
mkTP("Widgets Inc", "https://widgets.example"),
})
assert.Empty(t, got)
})
t.Run("ranks descending by score", func(t *testing.T) {
t.Parallel()
common := coredata.CommonThirdParty{
Name: "Google",
Slug: "google",
WebsiteURL: new("https://google.com"),
}
got := RankCandidates(common, nil, coredata.ThirdParties{
mkTP("Random", "https://google.com"),
mkTP("Google", ""),
mkTP("Google LLC", ""),
})
require.Len(t, got, 3)
assert.Equal(t, "Google", got[0].ThirdParty.Name)
assert.Equal(t, 1.0, got[0].Score)
assert.Equal(t, "Google LLC", got[1].ThirdParty.Name)
assert.Equal(t, 0.9, got[1].Score)
assert.Equal(t, "Random", got[2].ThirdParty.Name)
assert.Equal(t, 0.8, got[2].Score)
})
}

241
pkg/thirdparty/disambiguation_agent.go vendored Normal file
View File

@@ -0,0 +1,241 @@
// Copyright (c) 2026 Probo Inc <hello@probo.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"
_ "embed"
"fmt"
"strings"
"time"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/agent"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/llm"
)
//go:embed prompts/disambiguation.txt.tmpl
var disambiguationPrompt string
const (
// disambiguationConfidenceThreshold is the floor below which we
// treat the agent's pick as "no confident match" even when it
// returned a non-nil matched_id. Mirrors the conservative bias
// described in the prompt.
disambiguationConfidenceThreshold = 0.6
// defaultDisambiguationTimeout caps a single disambiguation run
// when the config supplies none. The agent has no tools and a
// single turn, so this is mostly a guard against a hung LLM
// provider, not a real budget.
defaultDisambiguationTimeout = 45 * time.Second
// defaultDisambiguationMaxTokens caps the agent's output when the
// config carries no max-tokens budget. The final output is tiny (a
// single id plus a one-sentence rationale), but the budget must
// leave ample headroom for reasoning models (e.g. the GPT-5
// family): their reasoning tokens count against max_tokens, so too
// small a budget gets consumed by reasoning and truncates the JSON,
// surfacing as "unexpected end of JSON input".
defaultDisambiguationMaxTokens = 4096
)
// DisambiguationAgentConfig configures the third-party disambiguation
// agent. The agent has no DB tools and no web-search tools: the
// candidate list is supplied entirely in the prompt and the agent
// only picks among it.
//
// MaxTokens and Temperature bound and steer the single LLM call, and
// Timeout caps a single run. Zero-valued fields fall back to package
// defaults.
type DisambiguationAgentConfig struct {
LLMClient *llm.Client
Model string
MaxTokens *int
Temperature *float64
Timeout time.Duration
}
// DisambiguationResult is the structured output the disambiguation
// agent returns when picking the best existing org ThirdParty for a
// catalog entry.
type DisambiguationResult struct {
MatchedID *string `json:"matched_id" jsonschema:"GID of the org third party that best matches, or null if none of the candidates is a confident match."`
Confidence float64 `json:"confidence" jsonschema:"Confidence level from 0.0 to 1.0. Below 0.6 means 'no confident match' and matched_id MUST be null."`
Reasoning string `json:"reasoning" jsonschema:"One short sentence describing the rationale."`
}
// BuildDisambiguationAgent wires the agent that picks the best
// existing org ThirdParty for a catalog entry. It deliberately has
// no tools: the candidate list is supplied in the prompt and the
// agent must only choose among it.
func BuildDisambiguationAgent(
cfg DisambiguationAgentConfig,
logger *log.Logger,
) *agent.Agent {
outputType, err := agent.NewOutputType[DisambiguationResult]("third_party_disambiguation")
if err != nil {
panic(fmt.Sprintf("thirdparty: cannot build disambiguation output type: %s", err))
}
maxTokens := defaultDisambiguationMaxTokens
if cfg.MaxTokens != nil && *cfg.MaxTokens > 0 {
maxTokens = *cfg.MaxTokens
}
opts := []agent.Option{
agent.WithInstructions(disambiguationPrompt),
agent.WithModel(cfg.Model),
agent.WithOutputType(outputType),
agent.WithMaxTurns(1),
agent.WithMaxTokens(maxTokens),
agent.WithLogger(logger),
}
if cfg.Temperature != nil {
opts = append(opts, agent.WithTemperature(*cfg.Temperature))
}
return agent.New("third-party-disambiguation", cfg.LLMClient, opts...)
}
// Disambiguate runs the agent against the given catalog third party
// and candidate list, and returns the matched candidate's ID — or
// nil when the agent picks "none", returns a confidence below the
// threshold, or fails. Errors from the agent itself are returned;
// "no confident match" is not an error.
//
// The matched candidate is identified by string equality against the
// IDs supplied in `candidates`; we never invent IDs from the agent's
// output, so a model that hallucinates an ID is treated as "none".
func Disambiguate(
ctx context.Context,
a *agent.Agent,
logger *log.Logger,
commonParty coredata.CommonThirdParty,
commonDomains coredata.CommonThirdPartyDomains,
candidates []ScoredCandidate,
timeout time.Duration,
) (*gid.GID, error) {
if a == nil || len(candidates) == 0 {
return nil, nil
}
if timeout <= 0 {
timeout = defaultDisambiguationTimeout
}
prompt := buildDisambiguationPrompt(commonParty, commonDomains, candidates)
agentCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
result, err := agent.RunTyped[DisambiguationResult](
agentCtx,
a,
[]llm.Message{
{
Role: llm.RoleUser,
Parts: []llm.Part{llm.TextPart{Text: prompt}},
},
},
)
if err != nil {
return nil, fmt.Errorf("cannot run disambiguation agent: %w", err)
}
out := result.Output
if out.MatchedID == nil || *out.MatchedID == "" {
return nil, nil
}
if out.Confidence < disambiguationConfidenceThreshold {
logger.InfoCtx(
ctx,
"disambiguation agent below confidence threshold",
log.String("matched_id", *out.MatchedID),
log.Float64("confidence", out.Confidence),
)
return nil, nil
}
for _, c := range candidates {
if c.ThirdParty.ID.String() == *out.MatchedID {
id := c.ThirdParty.ID
return &id, nil
}
}
logger.WarnCtx(
ctx,
"disambiguation agent returned id not in candidate list",
log.String("matched_id", *out.MatchedID),
)
return nil, nil
}
// buildDisambiguationPrompt formats the catalog third party and the
// heuristic-ranked candidate list into the user message for the
// disambiguation agent. The prompt is intentionally compact: the
// agent only needs ids, names, websites, and the heuristic score to
// decide.
func buildDisambiguationPrompt(
commonParty coredata.CommonThirdParty,
commonDomains coredata.CommonThirdPartyDomains,
candidates []ScoredCandidate,
) string {
var b strings.Builder
b.WriteString("Catalog third party:\n")
fmt.Fprintf(&b, " name: %s\n", commonParty.Name)
if commonParty.WebsiteURL != nil && *commonParty.WebsiteURL != "" {
fmt.Fprintf(&b, " website: %s\n", *commonParty.WebsiteURL)
}
if len(commonDomains) > 0 {
domains := make([]string, len(commonDomains))
for i, d := range commonDomains {
domains[i] = d.Domain
}
fmt.Fprintf(&b, " domains: %s\n", strings.Join(domains, ", "))
}
b.WriteString("\nCandidate organisation third parties (heuristic-ranked):\n")
for i, c := range candidates {
fmt.Fprintf(&b, "- id: %s\n", c.ThirdParty.ID.String())
fmt.Fprintf(&b, " name: %s\n", c.ThirdParty.Name)
if c.ThirdParty.WebsiteURL != nil && *c.ThirdParty.WebsiteURL != "" {
fmt.Fprintf(&b, " website: %s\n", *c.ThirdParty.WebsiteURL)
}
fmt.Fprintf(&b, " heuristic_score: %.2f\n", c.Score)
if i < len(candidates)-1 {
b.WriteString("\n")
}
}
return b.String()
}