diff --git a/AGENTS.md b/AGENTS.md index 003cceeaf..ffcad1f57 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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) diff --git a/contrib/claude/agent.md b/contrib/claude/agent.md index a1e26fae2..cbd6fcde0 100644 --- a/contrib/claude/agent.md +++ b/contrib/claude/agent.md @@ -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) diff --git a/contrib/claude/file-naming.md b/contrib/claude/file-naming.md new file mode 100644 index 000000000..0c4214434 --- /dev/null +++ b/contrib/claude/file-naming.md @@ -0,0 +1,29 @@ +# File naming conventions + +## Template files + +Template files use the extension pattern `..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 `` 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`. diff --git a/pkg/cookiebanner/prompts/tracker_identification.txt b/pkg/cookiebanner/prompts/tracker_identification.txt.tmpl similarity index 86% rename from pkg/cookiebanner/prompts/tracker_identification.txt rename to pkg/cookiebanner/prompts/tracker_identification.txt.tmpl index ace804bc5..519ad046c 100644 --- a/pkg/cookiebanner/prompts/tracker_identification.txt +++ b/pkg/cookiebanner/prompts/tracker_identification.txt.tmpl @@ -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. diff --git a/pkg/cookiebanner/tracker_identification.go b/pkg/cookiebanner/tracker_identification.go index 9eaf53da4..83629440c 100644 --- a/pkg/cookiebanner/tracker_identification.go +++ b/pkg/cookiebanner/tracker_identification.go @@ -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."` } diff --git a/pkg/cookiebanner/tracker_mapping_worker.go b/pkg/cookiebanner/tracker_mapping_worker.go index b7eb48b78..28972c8bd 100644 --- a/pkg/cookiebanner/tracker_mapping_worker.go +++ b/pkg/cookiebanner/tracker_mapping_worker.go @@ -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"+ + " %s \n"+ + " %s \n"+ + " %s \n"+ + " %s \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(" %s \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 } diff --git a/pkg/coredata/common_third_party.go b/pkg/coredata/common_third_party.go index c58883d1f..41f66857d 100644 --- a/pkg/coredata/common_third_party.go +++ b/pkg/coredata/common_third_party.go @@ -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) }