The source headers, LICENSE files, and license metadata had drifted apart. Align the entire project to MIT: - Convert every source-file header to the MIT text across all comment styles (Go, TS, TSX, JS, MJS, SQL, CSS, GraphQL, shell), including SPDX-License-Identifier tags - Set the root and cookie-banner LICENSE files to the MIT text with a "MIT License" title line - Switch the package.json license fields, Docker image label, and cookie-banner README to MIT - Update docs and the genmodels header generator accordingly - Normalize copyright lines to a single format (Copyright (c) <year(s)> Probo Inc <hello@probo.com>.): unify the hello@getprobo.com and hello@probo.inc emails to hello@probo.com and the comma-separated years to a hyphenated range Genuine third-party references are intentionally left untouched: the Lucide icon attributions (Lucide is ISC) and the trivy dependency license allowlist. Signed-off-by: Sacha Al Himdani <sacha@probo.com>
224 lines
6.8 KiB
Go
224 lines
6.8 KiB
Go
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
|
//
|
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
// of this software and associated documentation files (the "Software"), to deal
|
|
// in the Software without restriction, including without limitation the rights
|
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
// copies of the Software, and to permit persons to whom the Software is
|
|
// furnished to do so, subject to the following conditions:
|
|
//
|
|
// The above copyright notice and this permission notice shall be included in
|
|
// all copies or substantial portions of the Software.
|
|
//
|
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
// 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
|
|
}
|