Add common catalog query layer and enricher service

Introduce an API-style data layer for the global common tracker pattern
and common third party catalogs: typed filters, order fields, CursorKey,
cursor-paginated Load and CountAll, plus by-id enrichment re-queue and a
scoped reset/remap helper for a banner's tracker patterns. These reuse
the same page.Cursor/filter/order types the GraphQL API consumes, so a
future proboctl API can back them unchanged.

Extract the common-pattern enrichment logic out of the worker into a
CommonPatternEnricher service so it can run either from the background
queue or synchronously over a known set of ids; the worker becomes a
thin poller that delegates to it.

Extract the LLM client and tracker-agents config wiring into
pkg/agentsbuild so probod and other binaries build agents identically;
probod now delegates to it.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-06-03 13:37:36 +02:00
parent aebb2a1ed0
commit 662c0ae428
13 changed files with 1452 additions and 335 deletions

View File

@@ -15,20 +15,17 @@
package probod
import (
"fmt"
"github.com/prometheus/client_golang/prometheus"
"go.gearno.de/kit/httpclient"
"go.gearno.de/kit/log"
"go.opentelemetry.io/otel/trace"
"go.probo.inc/probo/pkg/agentsbuild"
"go.probo.inc/probo/pkg/llm"
llmanthropic "go.probo.inc/probo/pkg/llm/anthropic"
llmopenai "go.probo.inc/probo/pkg/llm/openai"
)
// resolveAgentClient resolves the agent's effective config from defaults and
// builds an LLM client for it. The name parameter is used in the logger and
// in error messages.
// in error messages. It delegates to pkg/agentsbuild so probod and proboctl
// wire LLM clients identically.
func (impl *Implm) resolveAgentClient(
name string,
agent LLMAgentConfig,
@@ -36,61 +33,5 @@ func (impl *Implm) resolveAgentClient(
tp trace.TracerProvider,
r prometheus.Registerer,
) (LLMAgentConfig, *llm.Client, error) {
resolved := impl.cfg.Agents.ResolveAgent(agent)
providerCfg, ok := impl.cfg.Agents.Providers[resolved.Provider]
if !ok {
return LLMAgentConfig{}, nil, fmt.Errorf("unknown LLM provider %q for %s agent", resolved.Provider, name)
}
client, err := buildLLMClient(providerCfg, l.Named("llm."+name), tp, r)
if err != nil {
return LLMAgentConfig{}, nil, fmt.Errorf("cannot create %s LLM client: %w", name, err)
}
return resolved, client, nil
}
func buildLLMClient(cfg LLMProviderConfig, l *log.Logger, tp trace.TracerProvider, r prometheus.Registerer) (*llm.Client, error) {
providerType := cfg.Type
if providerType == "" {
providerType = "openai"
}
httpClient := httpclient.DefaultPooledClient(
httpclient.WithLogger(l),
httpclient.WithTracerProvider(tp),
httpclient.WithRegisterer(r),
)
switch providerType {
case "openai":
p := llmopenai.NewProvider(
cfg.APIKey,
llmopenai.WithHTTPClient(httpClient),
)
return llm.NewClient(
p,
"openai",
llm.WithLogger(l),
llm.WithTracerProvider(tp),
), nil
case "anthropic":
p := llmanthropic.NewProvider(
cfg.APIKey,
llmanthropic.WithHTTPClient(httpClient),
)
return llm.NewClient(
p,
"anthropic",
llm.WithLogger(l),
llm.WithTracerProvider(tp),
), nil
case "bedrock":
return nil, fmt.Errorf("bedrock provider not yet wired; requires aws.Config")
default:
return nil, fmt.Errorf("unsupported LLM provider type: %q", providerType)
}
return agentsbuild.ResolveAgentClient(impl.cfg.Agents, name, agent, l, tp, r)
}

View File

@@ -15,78 +15,22 @@
package probod
import (
"fmt"
"time"
"github.com/prometheus/client_golang/prometheus"
"go.gearno.de/kit/log"
"go.opentelemetry.io/otel/trace"
"go.probo.inc/probo/pkg/agentsbuild"
"go.probo.inc/probo/pkg/cookiebanner"
"go.probo.inc/probo/pkg/thirdparty"
)
// buildTrackerAgentsConfig wires the tracker agents that share one LLM
// client and model: the tracker-mapping agent (catalog identification),
// the common-pattern enrichment agent (description research), and the
// third-party disambiguation agent that the tracker-mapping worker uses
// to promote patterns to org ThirdParties. All are opt-in: deployments
// that do not set `llm.tracker-mapping.provider` get zero configs (nil
// LLM client) so the workers run without agent fallback.
//
// The agents are sourced from the same `tracker-mapping` config slot
// because they share the LLM client, model, and lifecycle. The
// disambiguation agent has no Firecrawl/DB tools, so its config
// surface is narrower and it lives in the cross-domain pkg/thirdparty
// package.
// buildTrackerAgentsConfig wires the tracker agents (mapping, enrichment,
// disambiguation) from the probod config. It delegates to pkg/agentsbuild
// so probod and proboctl build the same agent configuration; see that
// package for the wiring rationale.
func (impl *Implm) buildTrackerAgentsConfig(
l *log.Logger,
tp trace.TracerProvider,
r prometheus.Registerer,
) (cookiebanner.TrackerAgentsConfig, thirdparty.DisambiguationConfig, error) {
if impl.cfg.Agents.TrackerMapping.Provider == "" {
return cookiebanner.TrackerAgentsConfig{}, thirdparty.DisambiguationConfig{}, nil
}
agentCfg, llmClient, err := impl.resolveAgentClient(
"tracker-mapping",
impl.cfg.Agents.TrackerMapping,
l,
tp,
r,
)
if err != nil {
return cookiebanner.TrackerAgentsConfig{}, thirdparty.DisambiguationConfig{}, fmt.Errorf("cannot resolve tracker mapping agent client: %w", err)
}
mappingWorkerCfg := impl.cfg.TrackerMappingWorker
enrichmentWorkerCfg := impl.cfg.CommonPatternEnrichmentWorker
// The mapping and enrichment agents share one config slot but run
// from separate workers with separate max-turns. AgentTimeout here
// carries the mapping worker's value (also reused by the
// disambiguation agent); the enrichment worker overrides it on its
// own copy at registration.
trackerAgentsCfg := cookiebanner.TrackerAgentsConfig{
LLMClient: llmClient,
Model: agentCfg.ModelName,
FirecrawlAPIKey: impl.cfg.Agents.Tools.FirecrawlAPIKey,
MaxTokens: agentCfg.MaxTokens,
Temperature: agentCfg.Temperature,
AgentTimeout: time.Duration(mappingWorkerCfg.AgentTimeout) * time.Second,
MappingMaxTurns: mappingWorkerCfg.AgentMaxTurns,
EnrichmentMaxTurns: enrichmentWorkerCfg.AgentMaxTurns,
}
// The disambiguation agent emits a single id plus a short rationale,
// so it keeps its own smaller token budget (left unset here) rather
// than inheriting the mapping agent's. It shares the mapping worker's
// timeout.
disambiguationCfg := thirdparty.DisambiguationConfig{
LLMClient: llmClient,
Model: agentCfg.ModelName,
Temperature: agentCfg.Temperature,
Timeout: time.Duration(mappingWorkerCfg.AgentTimeout) * time.Second,
}
return trackerAgentsCfg, disambiguationCfg, nil
return agentsbuild.BuildTrackerAgentsConfig(impl.cfg, l, tp, r)
}