Unify enrichment tracking and outcome-based status
Make common_tracker_patterns and common_third_parties share one enrichment-tracking model and fix the misleading proboctl status. Both tables now carry the enrichment JSONB provenance payload, an enrichment_attempts counter, and a last_enrichment_attempt_at clock. On common_tracker_patterns the enriched_at done-flag is renamed to last_enrichment_attempt_at and stamped at claim time, so it is truthful to "attempt" rather than "success". A row is considered to have been through the workflow when it carries an enrichment payload, not when a timestamp is set, which lets stale recovery key off the payload being absent with budget remaining, exactly like common_third_parties. The claim path reads the attempt counter and timestamp back via RETURNING so the in-memory receiver matches the database clock instead of a separate app-side time.Now. The enricher builds a per-field provenance payload (description and third-party outcomes plus the mapping attribution) and persists it via UpdateEnrichment, named to mirror the common-third-party sibling. The common pattern enrichment worker gains a max-attempts ceiling so a permanently failing row stops looping. proboctl now shows "enriched" only when every field the last run recorded an outcome for resolved a value, otherwise "partial (X/Y)", replacing the misleading "enriched (no description)" label. Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
@@ -16,6 +16,7 @@ package cookiebanner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -140,6 +141,8 @@ func (e *CommonPatternEnricher) EnrichPattern(ctx context.Context, cp coredata.C
|
||||
return fmt.Errorf("cannot research tracker description: %w", err)
|
||||
}
|
||||
|
||||
alreadyLinked := cp.CommonThirdPartyID != nil
|
||||
|
||||
return e.pg.WithTx(
|
||||
ctx,
|
||||
func(ctx context.Context, tx pg.Tx) error {
|
||||
@@ -156,7 +159,23 @@ func (e *CommonPatternEnricher) EnrichPattern(ctx context.Context, cp coredata.C
|
||||
}
|
||||
}
|
||||
|
||||
if err := cp.SetEnriched(ctx, tx, description, thirdPartyID); err != nil {
|
||||
linked := alreadyLinked || thirdPartyID != nil
|
||||
|
||||
meta := buildCommonPatternEnrichmentMetadata(
|
||||
e.enrichmentCfg.Model,
|
||||
description,
|
||||
attribution,
|
||||
alreadyLinked,
|
||||
linked,
|
||||
time.Now(),
|
||||
)
|
||||
|
||||
payload, err := json.Marshal(meta)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot marshal common tracker pattern enrichment metadata: %w", err)
|
||||
}
|
||||
|
||||
if err := cp.UpdateEnrichment(ctx, tx, description, thirdPartyID, payload); err != nil {
|
||||
return fmt.Errorf("cannot set common tracker pattern enriched: %w", err)
|
||||
}
|
||||
|
||||
@@ -177,7 +196,8 @@ func (e *CommonPatternEnricher) EnrichPattern(ctx context.Context, cp coredata.C
|
||||
log.String("common_tracker_pattern_id", cp.ID.String()),
|
||||
log.String("pattern", cp.Pattern),
|
||||
log.Bool("described", description != ""),
|
||||
log.Bool("third_party_linked", thirdPartyID != nil),
|
||||
log.Bool("third_party_linked", linked),
|
||||
log.Int("enrichment_attempts", cp.EnrichmentAttempts),
|
||||
log.Int64("backfilled_tracker_patterns", backfilled),
|
||||
)
|
||||
|
||||
|
||||
163
pkg/cookiebanner/common_pattern_enrichment_metadata.go
Normal file
163
pkg/cookiebanner/common_pattern_enrichment_metadata.go
Normal file
@@ -0,0 +1,163 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.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 (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Per-field outcomes recorded in the common tracker pattern enrichment
|
||||
// payload. They mirror the common-third-party enrichment provenance so the
|
||||
// proboctl display can compute the same "X/Y resolved" completeness across
|
||||
// both catalogs.
|
||||
const (
|
||||
commonPatternFieldStatusFound = "found"
|
||||
commonPatternFieldStatusNotFound = "not_found"
|
||||
commonPatternFieldStatusExternal = "exists_external"
|
||||
|
||||
// Run-level status recorded at the top of the enrichment payload.
|
||||
commonPatternStatusDone = "done"
|
||||
commonPatternStatusPartial = "partial"
|
||||
commonPatternStatusNoResult = "no_result"
|
||||
|
||||
// Field keys recorded in the payload (the enrichment targets).
|
||||
commonPatternFieldDescription = "description"
|
||||
commonPatternFieldThirdParty = "third_party"
|
||||
)
|
||||
|
||||
type (
|
||||
// CommonPatternFieldMeta is the per-field provenance recorded in the
|
||||
// common_tracker_patterns.enrichment JSON column.
|
||||
CommonPatternFieldMeta struct {
|
||||
Status string `json:"status"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// CommonPatternAttributionMeta records the mapping-agent result the
|
||||
// enricher used to attribute (and possibly link) a vendor, so the
|
||||
// "mapping" decision is auditable from the enrichment payload.
|
||||
CommonPatternAttributionMeta struct {
|
||||
ThirdPartyName string `json:"third_party_name,omitempty"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
Linked bool `json:"linked"`
|
||||
}
|
||||
|
||||
// CommonPatternEnrichmentMetadata is the full payload stored in the
|
||||
// enrichment JSON column: run-level bookkeeping, per-field provenance
|
||||
// keyed by the enrichment target, and the vendor attribution.
|
||||
CommonPatternEnrichmentMetadata struct {
|
||||
Model string `json:"model,omitempty"`
|
||||
AttemptedAt time.Time `json:"attempted_at"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Fields map[string]CommonPatternFieldMeta `json:"fields"`
|
||||
Attribution *CommonPatternAttributionMeta `json:"attribution,omitempty"`
|
||||
}
|
||||
)
|
||||
|
||||
// buildCommonPatternEnrichmentMetadata assembles the per-run provenance for
|
||||
// one common tracker pattern. It records an outcome for both enrichment
|
||||
// targets — the description and the third-party attribution — so the
|
||||
// display can report "X/Y resolved". alreadyLinked marks a vendor the
|
||||
// mapping pipeline resolved before this run (recorded as exists_external),
|
||||
// versus a vendor this enrichment run resolved (found). attribution, when
|
||||
// non-nil, carries the mapping-agent decision this run made.
|
||||
func buildCommonPatternEnrichmentMetadata(
|
||||
model string,
|
||||
description string,
|
||||
attribution *TrackerMappingAgentResult,
|
||||
alreadyLinked bool,
|
||||
linked bool,
|
||||
now time.Time,
|
||||
) CommonPatternEnrichmentMetadata {
|
||||
fields := make(map[string]CommonPatternFieldMeta, 2)
|
||||
|
||||
descStatus := commonPatternFieldStatusNotFound
|
||||
if strings.TrimSpace(description) != "" {
|
||||
descStatus = commonPatternFieldStatusFound
|
||||
}
|
||||
|
||||
fields[commonPatternFieldDescription] = CommonPatternFieldMeta{
|
||||
Status: descStatus,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
thirdPartyStatus := commonPatternFieldStatusNotFound
|
||||
switch {
|
||||
case alreadyLinked:
|
||||
thirdPartyStatus = commonPatternFieldStatusExternal
|
||||
case linked:
|
||||
thirdPartyStatus = commonPatternFieldStatusFound
|
||||
}
|
||||
|
||||
fields[commonPatternFieldThirdParty] = CommonPatternFieldMeta{
|
||||
Status: thirdPartyStatus,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
meta := CommonPatternEnrichmentMetadata{
|
||||
Model: model,
|
||||
AttemptedAt: now,
|
||||
Status: commonPatternRunStatus(fields),
|
||||
Fields: fields,
|
||||
}
|
||||
|
||||
if attribution != nil {
|
||||
meta.Attribution = &CommonPatternAttributionMeta{
|
||||
ThirdPartyName: attribution.ThirdPartyName,
|
||||
Category: string(attribution.Category),
|
||||
Confidence: attribution.ThirdPartyConfidence,
|
||||
Linked: linked,
|
||||
}
|
||||
}
|
||||
|
||||
return meta
|
||||
}
|
||||
|
||||
// commonPatternRunStatus classifies the run from its per-field outcomes:
|
||||
// done when every field resolved a value, no_result when none did, partial
|
||||
// otherwise.
|
||||
func commonPatternRunStatus(fields map[string]CommonPatternFieldMeta) string {
|
||||
var resolved int
|
||||
|
||||
for _, f := range fields {
|
||||
if commonPatternFieldResolved(f.Status) {
|
||||
resolved++
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case resolved == 0:
|
||||
return commonPatternStatusNoResult
|
||||
case resolved == len(fields):
|
||||
return commonPatternStatusDone
|
||||
default:
|
||||
return commonPatternStatusPartial
|
||||
}
|
||||
}
|
||||
|
||||
// commonPatternFieldResolved reports whether a field status carries a
|
||||
// resolved value (found or already present externally) versus an absent
|
||||
// one.
|
||||
func commonPatternFieldResolved(status string) bool {
|
||||
switch status {
|
||||
case commonPatternFieldStatusFound, commonPatternFieldStatusExternal:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -26,17 +26,25 @@ import (
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
const defaultEnrichmentStaleAfter = 10 * time.Minute
|
||||
const (
|
||||
defaultEnrichmentStaleAfter = 10 * time.Minute
|
||||
|
||||
// defaultEnrichmentMaxAttempts caps how many times a row is retried
|
||||
// before stale recovery leaves it alone, so a permanently failing row
|
||||
// does not loop forever.
|
||||
defaultEnrichmentMaxAttempts = 3
|
||||
)
|
||||
|
||||
// commonPatternEnrichmentHandler is the queue poller for common tracker
|
||||
// pattern enrichment. It owns only the claim/dequeue and stale-recovery
|
||||
// mechanics; the enrichment work itself lives in CommonPatternEnricher so
|
||||
// it can also run synchronously from operator tooling.
|
||||
type commonPatternEnrichmentHandler struct {
|
||||
pg *pg.Client
|
||||
logger *log.Logger
|
||||
enricher *CommonPatternEnricher
|
||||
staleAfter time.Duration
|
||||
pg *pg.Client
|
||||
logger *log.Logger
|
||||
enricher *CommonPatternEnricher
|
||||
staleAfter time.Duration
|
||||
maxAttempts int
|
||||
}
|
||||
|
||||
// NewCommonPatternEnrichmentWorker builds the worker that fills
|
||||
@@ -52,17 +60,23 @@ func NewCommonPatternEnrichmentWorker(
|
||||
enrichmentCfg TrackerEnrichmentAgentConfig,
|
||||
mappingCfg TrackerMappingAgentConfig,
|
||||
staleAfter time.Duration,
|
||||
maxAttempts int,
|
||||
opts ...worker.Option,
|
||||
) *worker.Worker[coredata.CommonTrackerPattern] {
|
||||
if staleAfter <= 0 {
|
||||
staleAfter = defaultEnrichmentStaleAfter
|
||||
}
|
||||
|
||||
if maxAttempts <= 0 {
|
||||
maxAttempts = defaultEnrichmentMaxAttempts
|
||||
}
|
||||
|
||||
h := &commonPatternEnrichmentHandler{
|
||||
pg: pgClient,
|
||||
logger: logger,
|
||||
enricher: NewCommonPatternEnricher(pgClient, logger, enrichmentCfg, mappingCfg),
|
||||
staleAfter: staleAfter,
|
||||
pg: pgClient,
|
||||
logger: logger,
|
||||
enricher: NewCommonPatternEnricher(pgClient, logger, enrichmentCfg, mappingCfg),
|
||||
staleAfter: staleAfter,
|
||||
maxAttempts: maxAttempts,
|
||||
}
|
||||
|
||||
return worker.New(
|
||||
@@ -108,7 +122,7 @@ func (h *commonPatternEnrichmentHandler) RecoverStale(ctx context.Context) error
|
||||
return h.pg.WithConn(
|
||||
ctx,
|
||||
func(ctx context.Context, conn pg.Querier) error {
|
||||
if err := coredata.ResetStaleEnrichments(ctx, conn, h.staleAfter); err != nil {
|
||||
if err := coredata.ResetStaleEnrichments(ctx, conn, h.staleAfter, h.maxAttempts); err != nil {
|
||||
return fmt.Errorf("cannot reset stale common tracker pattern enrichments: %w", err)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user