Parallelize enrichment agents, calibrate prompts

The common-third-party enrichment pipeline ran Agent B (compliance
docs), Agent C (owned domains), and the deterministic logo step
sequentially even though, once Agent A resolves the website, the
three depend only on that website and not on each other. Fan them
out across goroutines under a WaitGroup so wall time is the slowest
of the three rather than their sum. Each step builds its own per-run
browser and writes only into its own locals; the shared LLM, HTTP,
and FileManager clients are safe for concurrent use and the database
is untouched until persist. Results merge in a fixed order so
runErrors and log output stay deterministic.

Also replace the single-sentence confidence guidance in the three
agent prompts with an explicit, calibrated rubric tied to evidence
strength, and remind the model that a downstream threshold gates
persistence so it should neither inflate nor deflate its estimates.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-06-12 10:36:54 +02:00
parent d226a8be9a
commit 3281f2b81c
4 changed files with 75 additions and 20 deletions

View File

@@ -21,6 +21,7 @@ import (
"fmt"
"net/http"
"strings"
"sync"
"time"
"go.gearno.de/kit/httpclient"
@@ -215,11 +216,13 @@ func (h *enrichmentHandler) Claim(ctx context.Context) (coredata.CommonThirdPart
}
// Process runs the enrichment pipeline for one catalog row: Agent A
// (company profile) first, then Agent B (compliance docs) and the
// deterministic logo step, all outside any transaction. The merged
// result and per-field provenance are persisted in a single final
// transaction. Process always writes an enrichment payload, even on a
// no-result run, so stale recovery does not re-queue the row.
// (company profile) first, then Agent B (compliance docs), Agent C
// (owned domains), and the deterministic logo step concurrently, all
// outside any transaction. Agent A runs first because the others depend
// on the website it resolves. The merged result and per-field provenance
// are persisted in a single final transaction. Process always writes an
// enrichment payload, even on a no-result run, so stale recovery does not
// re-queue the row.
func (h *enrichmentHandler) Process(ctx context.Context, party coredata.CommonThirdParty) error {
if h.cfg.LLMClient == nil {
return nil
@@ -275,11 +278,50 @@ func (h *enrichmentHandler) Process(ctx context.Context, party coredata.CommonTh
legalName := effectiveLegalName(party, company, h.cfg.ConfidenceThreshold)
// Agent B (compliance docs), Agent C (owned domains), and the
// deterministic logo step all depend only on the resolved website
// (Agent B also on the legal name) and are independent of each other.
// Run them concurrently so wall time is the slowest of the three
// rather than their sum. Each builds its own per-run browser and
// writes only into its own locals; the shared LLM/HTTP/FileManager
// clients are safe for concurrent use and the database is not touched
// until persist below. Results are merged after Wait in a fixed order
// to keep runErrors and log output deterministic.
var (
compliance ComplianceDocsResult
complianceErr error
domainsResult DomainsResult
domainsErr error
logoFile *coredata.File
)
var wg sync.WaitGroup
wg.Add(3)
go func() {
defer wg.Done()
compliance, complianceErr = h.runComplianceDocs(ctx, party.Name, website, legalName)
}()
go func() {
defer wg.Done()
domainsResult, domainsErr = h.runDomains(ctx, party.Name, website)
}()
go func() {
defer wg.Done()
logoFile = h.prepareLogo(ctx, party, website)
}()
wg.Wait()
// Agent B: compliance documents and trust pages.
compliance, err := h.runComplianceDocs(ctx, party.Name, website, legalName)
if err != nil {
h.logger.WarnCtx(ctx, "compliance docs agent failed", log.Error(err), log.String("common_third_party_id", party.ID.String()))
runErrors = append(runErrors, "compliance_docs: "+sanitizeAgentError(err))
if complianceErr != nil {
h.logger.WarnCtx(ctx, "compliance docs agent failed", log.Error(complianceErr), log.String("common_third_party_id", party.ID.String()))
runErrors = append(runErrors, "compliance_docs: "+sanitizeAgentError(complianceErr))
} else {
anySuccess = true
}
@@ -288,19 +330,14 @@ func (h *enrichmentHandler) Process(ctx context.Context, party coredata.CommonTh
// resolved website, so it runs only on this website-resolved path.
var owned []ownedDomain
domainsResult, err := h.runDomains(ctx, party.Name, website)
if err != nil {
h.logger.WarnCtx(ctx, "domains agent failed", log.Error(err), log.String("common_third_party_id", party.ID.String()))
runErrors = append(runErrors, "domains: "+sanitizeAgentError(err))
if domainsErr != nil {
h.logger.WarnCtx(ctx, "domains agent failed", log.Error(domainsErr), log.String("common_third_party_id", party.ID.String()))
runErrors = append(runErrors, "domains: "+sanitizeAgentError(domainsErr))
} else {
anySuccess = true
owned = resolveOwnedDomains(party.Name, website, domainsResult, defaultEnrichmentDomainConfidenceThreshold)
}
// Deterministic logo step (no LLM). Uploads to S3 outside the final
// transaction; the File row is inserted below.
logoFile := h.prepareLogo(ctx, party, website)
for _, field := range scalarFields(company, compliance) {
applyScalarField(&party, meta, prior, field, h.cfg.ConfidenceThreshold, now)
}