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>
248 lines
7.9 KiB
Go
248 lines
7.9 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"
|
|
_ "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()
|
|
}
|