Files
probo/pkg/cookiebanner/tracker_mapping_agent.go
Émile Ré a99a4dde14 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>
2026-05-29 10:07:30 +02:00

131 lines
4.0 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 cookiebanner
import (
"context"
_ "embed"
"fmt"
"strings"
"time"
"go.gearno.de/kit/log"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/agent"
"go.probo.inc/probo/pkg/agent/tools/search"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/llm"
)
const (
agentTimeout = 60 * time.Second
agentMaxTurns = 5
agentConfidenceThreshold = 0.6
agentMaxPatternConfidence = 0.8
)
//go:embed prompts/tracker_identification.txt.tmpl
var trackerIdentificationPrompt string
// TrackerMappingAgentResult is the structured output the tracker-mapping
// agent returns.
type TrackerMappingAgentResult struct {
ThirdPartyName string `json:"third_party_name" jsonschema:"Name of the company or service that sets this tracker (e.g. 'Google Analytics', 'Meta Pixel'). Empty string if truly unknown."`
Category coredata.ThirdPartyCategory `json:"category" jsonschema:"Third party category"`
Description string `json:"description" jsonschema:"What this tracker does in one sentence"`
Confidence float64 `json:"confidence" jsonschema:"Confidence level from 0.0 to 1.0. Set below 0.5 if unsure."`
}
// TrackerMappingConfig configures the tracker-mapping agent (catalog
// identification). The agent uses DB-backed search tools and may also
// use Firecrawl for web search when an API key is supplied.
type TrackerMappingConfig struct {
LLMClient *llm.Client
Model string
FirecrawlAPIKey string
}
func buildTrackerMappingAgent(
cfg TrackerMappingConfig,
pgClient *pg.Client,
logger *log.Logger,
) *agent.Agent {
tools := []agent.Tool{
searchTrackerPatternsTool(pgClient),
searchThirdPartiesTool(pgClient),
}
if cfg.FirecrawlAPIKey != "" {
tools = append(tools, search.FirecrawlSearchTool(cfg.FirecrawlAPIKey))
}
outputType, err := agent.NewOutputType[TrackerMappingAgentResult]("tracker_identification")
if err != nil {
panic(fmt.Sprintf("cookiebanner: cannot build tracker identification output type: %s", err))
}
return agent.New(
"tracker-mapping",
cfg.LLMClient,
agent.WithInstructionsFunc(trackerMappingInstructions),
agent.WithModel(cfg.Model),
agent.WithTools(tools...),
agent.WithOutputType(outputType),
agent.WithMaxTurns(agentMaxTurns),
agent.WithLogger(logger),
)
}
func trackerMappingInstructions(_ context.Context, _ *agent.Agent) string {
categories := coredata.ThirdPartyCategories()
parts := make([]string, len(categories))
for i, c := range categories {
parts[i] = string(c)
}
return strings.Replace(
trackerIdentificationPrompt,
"{{.Categories}}",
strings.Join(parts, ", "),
1,
)
}
func buildAgentPrompt(tp coredata.TrackerPattern, domains []string) string {
maxAge := "session"
if tp.MaxAgeSeconds != nil {
maxAge = fmt.Sprintf("%d seconds", *tp.MaxAgeSeconds)
}
prompt := fmt.Sprintf(
"Identify the following tracker:\n\n"+
"<pattern> %s </pattern>\n"+
"<type> %s </type>\n"+
"<match_type> %s </match_type>\n"+
"<max_age> %s </max_age>\n",
tp.Pattern,
tp.TrackerType,
tp.MatchType,
maxAge,
)
if len(domains) > 0 {
prompt += fmt.Sprintf("<observed_domains> %s </observed_domains>\n", strings.Join(domains, ", "))
}
return prompt
}