Build tracker identification prompt categories dynamically

Generate the category list from coredata.ThirdPartyCategories() at
runtime instead of hardcoding it in the prompt text. Type the
TrackerIdentification.Category field as coredata.ThirdPartyCategory so
JSON unmarshaling validates values automatically.

Also documents the .txt.tmpl template file naming convention.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-05-19 10:59:16 +04:00
parent 848f8964ac
commit f81168bc18
7 changed files with 96 additions and 81 deletions

View File

@@ -25,6 +25,7 @@ Detailed guides for specific subsystems live in `contrib/claude/`:
- [`contrib/claude/react-components.md`](contrib/claude/react-components.md) — React component shape (file/export, props, configure vs data via hooks) - [`contrib/claude/react-components.md`](contrib/claude/react-components.md) — React component shape (file/export, props, configure vs data via hooks)
- [`contrib/claude/ui.md`](contrib/claude/ui.md) — @probo/ui, Tailwind, tailwind-variants, folders, skeletons, compound components - [`contrib/claude/ui.md`](contrib/claude/ui.md) — @probo/ui, Tailwind, tailwind-variants, folders, skeletons, compound components
- [`contrib/claude/config.md`](contrib/claude/config.md) — Configuration propagation (all files to update when config changes) - [`contrib/claude/config.md`](contrib/claude/config.md) — Configuration propagation (all files to update when config changes)
- [`contrib/claude/file-naming.md`](contrib/claude/file-naming.md) — File naming conventions (template files, extensions)
- [`contrib/claude/commit.md`](contrib/claude/commit.md) — Commit message conventions - [`contrib/claude/commit.md`](contrib/claude/commit.md) — Commit message conventions
- [`contrib/claude/license.md`](contrib/claude/license.md) — ISC license header (all file types) - [`contrib/claude/license.md`](contrib/claude/license.md) — ISC license header (all file types)
- [`contrib/claude/release.md`](contrib/claude/release.md) — Release process (version bump, changelog, tag, push) - [`contrib/claude/release.md`](contrib/claude/release.md) — Release process (version bump, changelog, tag, push)

View File

@@ -72,6 +72,27 @@ shutdown broadcast onto a per-run `cancelRun(agent.ErrSuspendForCheckpoint)`,
so the same contract drives both the public Go API and the worker so the same contract drives both the public Go API and the worker
infrastructure path. infrastructure path.
## Prompt templates
Prompt files with placeholders use `.txt.tmpl` (see general template naming
convention in `.cursor/rules/template-files.mdc`).
Use `agent.WithInstructionsFunc` to build prompts dynamically at runtime:
```go
//go:embed prompts/tracker_identification.txt.tmpl
var trackerIdentificationPrompt string
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)
}
```
## Limits ## Limits
- Max turns: 10 (default) - Max turns: 10 (default)

View File

@@ -0,0 +1,29 @@
# File naming conventions
## Template files
Template files use the extension pattern `<name>.<output-ext>.tmpl`:
```
pkg/trust/sitemap.xml.tmpl
pkg/server/mailactions/templates/page.html.tmpl
pkg/cookiebanner/prompts/tracker_identification.txt.tmpl
pkg/probo/templates/risk_list.json.tmpl
compose/keycloak/probo-realm.json.tmpl
pkg/esign/certificate.html.tmpl
```
The `<output-ext>` indicates the format produced after rendering (`.xml`, `.html`, `.txt`, `.json`). The final `.tmpl` suffix marks the file as a template requiring substitution before use.
### Dynamic values over hardcoded lists
Never hardcode enum values or source-of-truth lists in template text. Use a
placeholder and substitute at runtime so the template stays in sync with the
code:
```
Use one of: {{.Categories}}.
```
For single-placeholder templates, `strings.Replace` is sufficient. For multiple
placeholders, use `text/template`.

View File

@@ -45,6 +45,6 @@ Return a structured JSON response with:
7. If you truly cannot identify the tracker, set third_party_name to an empty string and confidence below 0.3. 7. If you truly cannot identify the tracker, set third_party_name to an empty string and confidence below 0.3.
8. For the category field, use one of: ANALYTICS, ADVERTISING, CLOUD_MONITORING, CLOUD_PROVIDER, COLLABORATION, CUSTOMER_SUPPORT, DATA_STORAGE_AND_PROCESSING, DOCUMENT_MANAGEMENT, EMPLOYEE_MANAGEMENT, ENGINEERING, FINANCE, IDENTITY_PROVIDER, IT, MARKETING, OFFICE_OPERATIONS, OTHER, PASSWORD_MANAGEMENT, PRODUCT_AND_DESIGN, PROFESSIONAL_SERVICES, RECRUITING, SALES, SECURITY, VERSION_CONTROL. 8. For the category field, use one of: {{.Categories}}.
Most cookies fall under ANALYTICS, ADVERTISING, or MARKETING. Most cookies fall under ANALYTICS or MARKETING.
</instructions> </instructions>

View File

@@ -14,13 +14,13 @@
package cookiebanner package cookiebanner
import "go.probo.inc/probo/pkg/coredata"
// TrackerIdentification is the structured output the tracker-mapping // TrackerIdentification is the structured output the tracker-mapping
// agent returns. The Category field uses the same values as the // agent returns.
// third_party_category PostgreSQL enum so auto-created CommonThirdParty
// rows get a valid category without mapping.
type TrackerIdentification struct { type TrackerIdentification 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."` 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 string `json:"category" jsonschema:"Third party category. One of: ANALYTICS, ADVERTISING, CLOUD_MONITORING, CLOUD_PROVIDER, COLLABORATION, CUSTOMER_SUPPORT, DATA_STORAGE_AND_PROCESSING, DOCUMENT_MANAGEMENT, EMPLOYEE_MANAGEMENT, ENGINEERING, FINANCE, IDENTITY_PROVIDER, IT, MARKETING, OFFICE_OPERATIONS, OTHER, PASSWORD_MANAGEMENT, PRODUCT_AND_DESIGN, PROFESSIONAL_SERVICES, RECRUITING, SALES, SECURITY, VERSION_CONTROL"` Category coredata.ThirdPartyCategory `json:"category" jsonschema:"Third party category"`
Description string `json:"description" jsonschema:"What this tracker does in one sentence"` 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."` Confidence float64 `json:"confidence" jsonschema:"Confidence level from 0.0 to 1.0. Set below 0.5 if unsure."`
} }

View File

@@ -40,7 +40,7 @@ const (
agentMaxPatternConfidence = 0.8 agentMaxPatternConfidence = 0.8
) )
//go:embed prompts/tracker_identification.txt //go:embed prompts/tracker_identification.txt.tmpl
var trackerIdentificationPrompt string var trackerIdentificationPrompt string
type trackerMappingHandler struct { type trackerMappingHandler struct {
@@ -100,7 +100,7 @@ func buildTrackerMappingAgent(
return agent.New( return agent.New(
"tracker-mapping", "tracker-mapping",
cfg.LLMClient, cfg.LLMClient,
agent.WithInstructions(trackerIdentificationPrompt), agent.WithInstructionsFunc(trackerMappingInstructions),
agent.WithModel(cfg.Model), agent.WithModel(cfg.Model),
agent.WithTools(tools...), agent.WithTools(tools...),
agent.WithOutputType(outputType), agent.WithOutputType(outputType),
@@ -109,6 +109,21 @@ func buildTrackerMappingAgent(
) )
} }
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 (h *trackerMappingHandler) Claim(ctx context.Context) (coredata.TrackerPattern, error) { func (h *trackerMappingHandler) Claim(ctx context.Context) (coredata.TrackerPattern, error) {
var tp coredata.TrackerPattern var tp coredata.TrackerPattern
@@ -373,10 +388,7 @@ func (h *trackerMappingHandler) resolveOrCreateCommonThirdParty(
return &party.ID, nil return &party.ID, nil
} }
category := coredata.ThirdPartyCategoryOther category := identification.Category
if parsed := parseThirdPartyCategory(identification.Category); parsed != "" {
category = parsed
}
now := time.Now() now := time.Now()
party = coredata.CommonThirdParty{ party = coredata.CommonThirdParty{
@@ -411,7 +423,7 @@ func (h *trackerMappingHandler) resolveOrCreateCommonThirdParty(
ctx, ctx,
"created common third party from agent identification", "created common third party from agent identification",
log.String("name", identification.ThirdPartyName), log.String("name", identification.ThirdPartyName),
log.String("category", string(category)), log.String("category", category.String()),
) )
return &party.ID, nil return &party.ID, nil
@@ -473,75 +485,26 @@ func (h *trackerMappingHandler) resolveThirdParty(
} }
func buildAgentPrompt(tp coredata.TrackerPattern, domains []string) string { func buildAgentPrompt(tp coredata.TrackerPattern, domains []string) string {
var b strings.Builder maxAge := "session"
fmt.Fprintf(&b, "Identify the following tracker:\n\n")
fmt.Fprintf(&b, "- Pattern: %s\n", tp.Pattern)
fmt.Fprintf(&b, "- Type: %s\n", tp.TrackerType)
fmt.Fprintf(&b, "- Match type: %s\n", tp.MatchType)
if tp.MaxAgeSeconds != nil { if tp.MaxAgeSeconds != nil {
fmt.Fprintf(&b, "- Max age: %d seconds\n", *tp.MaxAgeSeconds) maxAge = fmt.Sprintf("%d seconds", *tp.MaxAgeSeconds)
} else {
fmt.Fprintf(&b, "- Max age: session\n")
} }
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 { if len(domains) > 0 {
fmt.Fprintf(&b, "- Observed on domains: %s\n", strings.Join(domains, ", ")) prompt += fmt.Sprintf("<observed_domains> %s </observed_domains>\n", strings.Join(domains, ", "))
} }
return b.String() return prompt
}
func parseThirdPartyCategory(s string) coredata.ThirdPartyCategory {
switch s {
case "ANALYTICS":
return coredata.ThirdPartyCategoryAnalytics
case "ADVERTISING":
return coredata.ThirdPartyCategoryMarketing
case "CLOUD_MONITORING":
return coredata.ThirdPartyCategoryCloudMonitoring
case "CLOUD_PROVIDER":
return coredata.ThirdPartyCategoryCloudProvider
case "COLLABORATION":
return coredata.ThirdPartyCategoryCollaboration
case "CUSTOMER_SUPPORT":
return coredata.ThirdPartyCategoryCustomerSupport
case "DATA_STORAGE_AND_PROCESSING":
return coredata.ThirdPartyCategoryDataStorageAndProcessing
case "DOCUMENT_MANAGEMENT":
return coredata.ThirdPartyCategoryDocumentManagement
case "EMPLOYEE_MANAGEMENT":
return coredata.ThirdPartyCategoryEmployeeManagement
case "ENGINEERING":
return coredata.ThirdPartyCategoryEngineering
case "FINANCE":
return coredata.ThirdPartyCategoryFinance
case "IDENTITY_PROVIDER":
return coredata.ThirdPartyCategoryIdentityProvider
case "IT":
return coredata.ThirdPartyCategoryIT
case "MARKETING":
return coredata.ThirdPartyCategoryMarketing
case "OFFICE_OPERATIONS":
return coredata.ThirdPartyCategoryOfficeOperations
case "OTHER":
return coredata.ThirdPartyCategoryOther
case "PASSWORD_MANAGEMENT":
return coredata.ThirdPartyCategoryPasswordManagement
case "PRODUCT_AND_DESIGN":
return coredata.ThirdPartyCategoryProductAndDesign
case "PROFESSIONAL_SERVICES":
return coredata.ThirdPartyCategoryProfessionalServices
case "RECRUITING":
return coredata.ThirdPartyCategoryRecruiting
case "SALES":
return coredata.ThirdPartyCategorySales
case "SECURITY":
return coredata.ThirdPartyCategorySecurity
case "VERSION_CONTROL":
return coredata.ThirdPartyCategoryVersionControl
default:
return ""
}
} }

View File

@@ -214,6 +214,7 @@ LIMIT 1;
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return ErrResourceNotFound return ErrResourceNotFound
} }
return fmt.Errorf("cannot collect common third party by slug: %w", err) return fmt.Errorf("cannot collect common third party by slug: %w", err)
} }