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/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/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/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)

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
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
- 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.
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.
Most cookies fall under ANALYTICS, ADVERTISING, or MARKETING.
8. For the category field, use one of: {{.Categories}}.
Most cookies fall under ANALYTICS or MARKETING.
</instructions>

View File

@@ -14,13 +14,13 @@
package cookiebanner
import "go.probo.inc/probo/pkg/coredata"
// TrackerIdentification is the structured output the tracker-mapping
// agent returns. The Category field uses the same values as the
// third_party_category PostgreSQL enum so auto-created CommonThirdParty
// rows get a valid category without mapping.
// agent returns.
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."`
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"`
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."`
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."`
}

View File

@@ -40,7 +40,7 @@ const (
agentMaxPatternConfidence = 0.8
)
//go:embed prompts/tracker_identification.txt
//go:embed prompts/tracker_identification.txt.tmpl
var trackerIdentificationPrompt string
type trackerMappingHandler struct {
@@ -100,7 +100,7 @@ func buildTrackerMappingAgent(
return agent.New(
"tracker-mapping",
cfg.LLMClient,
agent.WithInstructions(trackerIdentificationPrompt),
agent.WithInstructionsFunc(trackerMappingInstructions),
agent.WithModel(cfg.Model),
agent.WithTools(tools...),
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) {
var tp coredata.TrackerPattern
@@ -373,10 +388,7 @@ func (h *trackerMappingHandler) resolveOrCreateCommonThirdParty(
return &party.ID, nil
}
category := coredata.ThirdPartyCategoryOther
if parsed := parseThirdPartyCategory(identification.Category); parsed != "" {
category = parsed
}
category := identification.Category
now := time.Now()
party = coredata.CommonThirdParty{
@@ -411,7 +423,7 @@ func (h *trackerMappingHandler) resolveOrCreateCommonThirdParty(
ctx,
"created common third party from agent identification",
log.String("name", identification.ThirdPartyName),
log.String("category", string(category)),
log.String("category", category.String()),
)
return &party.ID, nil
@@ -473,75 +485,26 @@ func (h *trackerMappingHandler) resolveThirdParty(
}
func buildAgentPrompt(tp coredata.TrackerPattern, domains []string) string {
var b strings.Builder
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)
maxAge := "session"
if tp.MaxAgeSeconds != nil {
fmt.Fprintf(&b, "- Max age: %d seconds\n", *tp.MaxAgeSeconds)
} else {
fmt.Fprintf(&b, "- Max age: session\n")
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 {
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()
}
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 ""
}
return prompt
}

View File

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