[object Object]

← back to Cli Printing Press

feat(llmpolish): add LLM Vision Synthesis for domain-aware customization

611f4b67c4fc1c5ae94106c3d99918ab8caeea32 · 2026-03-25 21:35:14 -0700 · Matt Van Horn

SynthesizeVision() takes profiler output + spec and asks Claude/Codex
to reason about which fields to index in FTS5, what to name workflow
commands, sync priorities, and realistic examples. Returns
VisionCustomization struct. Graceful nil,nil when no LLM available.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Files touched

Diff

commit 611f4b67c4fc1c5ae94106c3d99918ab8caeea32
Author: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Date:   Wed Mar 25 21:35:14 2026 -0700

    feat(llmpolish): add LLM Vision Synthesis for domain-aware customization
    
    SynthesizeVision() takes profiler output + spec and asks Claude/Codex
    to reason about which fields to index in FTS5, what to name workflow
    commands, sync priorities, and realistic examples. Returns
    VisionCustomization struct. Graceful nil,nil when no LLM available.
    
    Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---
 internal/llmpolish/vision.go      | 183 ++++++++++++++++++++++++++++++++++++++
 internal/llmpolish/vision_test.go |  72 +++++++++++++++
 2 files changed, 255 insertions(+)

diff --git a/internal/llmpolish/vision.go b/internal/llmpolish/vision.go
new file mode 100644
index 00000000..cd428d92
--- /dev/null
+++ b/internal/llmpolish/vision.go
@@ -0,0 +1,183 @@
+package llmpolish
+
+import (
+	"encoding/json"
+	"fmt"
+	"sort"
+	"strings"
+
+	"github.com/mvanhorn/cli-printing-press/internal/llm"
+	"github.com/mvanhorn/cli-printing-press/internal/profiler"
+	"github.com/mvanhorn/cli-printing-press/internal/spec"
+)
+
+// VisionCustomization holds LLM-generated customizations for vision templates.
+type VisionCustomization struct {
+	// ResourcePriority is the suggested sync order (most important first)
+	ResourcePriority []string `json:"resource_priority"`
+	// FTSFields maps resource name to fields that should be indexed in FTS5
+	FTSFields map[string][]string `json:"fts_fields"`
+	// WorkflowNames maps generic workflow names to domain-specific names
+	WorkflowNames map[string]string `json:"workflow_names"`
+	// ExampleOverrides maps command names to better domain-specific examples
+	ExampleOverrides map[string]string `json:"example_overrides"`
+	// DescOverrides maps command names to better domain-specific descriptions
+	DescOverrides map[string]string `json:"desc_overrides"`
+	// SyncHints maps resource name to sync strategy hints
+	SyncHints map[string]SyncHint `json:"sync_hints"`
+}
+
+// SyncHint describes how to sync a specific resource.
+type SyncHint struct {
+	Direction string `json:"direction"` // "newest_first" or "oldest_first"
+	BatchSize int    `json:"batch_size"`
+	Priority  int    `json:"priority"` // 1 = highest
+}
+
+func SynthesizeVision(profile *profiler.APIProfile, apiSpec *spec.APISpec) (*VisionCustomization, error) {
+	if !llm.Available() {
+		return nil, nil
+	}
+
+	prompt := buildVisionPrompt(profile, apiSpec)
+	response, err := llm.Run(prompt)
+	if err != nil {
+		return nil, nil
+	}
+
+	var customization VisionCustomization
+	if err := json.Unmarshal([]byte(strings.TrimSpace(response)), &customization); err != nil {
+		return nil, nil
+	}
+
+	return &customization, nil
+}
+
+func buildVisionPrompt(profile *profiler.APIProfile, apiSpec *spec.APISpec) string {
+	if profile == nil {
+		profile = &profiler.APIProfile{}
+	}
+	if apiSpec == nil {
+		apiSpec = &spec.APISpec{}
+	}
+
+	var builder strings.Builder
+	fmt.Fprintf(&builder, "You are analyzing the %s API to customize a CLI's power-user features.\n\n", defaultString(apiSpec.Name, "unknown"))
+	builder.WriteString("The API profiler detected these signals:\n")
+	fmt.Fprintf(&builder, "- High volume: %t\n", profile.HighVolume)
+	fmt.Fprintf(&builder, "- Needs local search: %t\n", profile.NeedsSearch)
+	fmt.Fprintf(&builder, "- Has real-time events: %t\n", profile.HasRealtime)
+	fmt.Fprintf(&builder, "- Has chronological data: %t\n", profile.HasChronological)
+	fmt.Fprintf(&builder, "- Syncable resources: %s\n", formatStringSlice(profile.SyncableResources))
+	fmt.Fprintf(&builder, "- Searchable fields per resource: %s\n", formatSearchableFields(profile.SearchableFields))
+	if apiSpec.Description != "" {
+		fmt.Fprintf(&builder, "\nAPI description: %s\n", apiSpec.Description)
+	}
+
+	fmt.Fprintf(&builder, "\nProfiler recommended features: %s\n", formatStringSlice(profile.RecommendedFeatures()))
+	builder.WriteString("\nResources and their endpoints:\n")
+	builder.WriteString(formatResourceSummaries(apiSpec.Resources, ""))
+	builder.WriteString(`
+Based on this API's domain, answer these questions as a JSON object:
+
+1. resource_priority: What order should resources be synced? Put the most valuable/frequently-accessed resources first.
+
+2. fts_fields: For each syncable resource, which string fields contain actual searchable content (not IDs, not timestamps, not enum values)? Be selective - index content users would search for.
+
+3. workflow_names: Map these generic workflow names to domain-specific names that make sense for this API:
+   - "archive" -> what would a power user call this? (e.g., "archive" for Discord, "backup" for a CMS)
+   - "audit" -> what would they call this? (e.g., "audit" for Discord, "activity" for GitHub)
+
+4. example_overrides: For the sync, search, export, and tail commands, write realistic example command lines using actual resource names and plausible IDs.
+
+5. desc_overrides: For the sync, search, export, and tail commands, write developer-friendly one-line descriptions specific to this API's domain.
+
+6. sync_hints: For the top 3 syncable resources, specify:
+   - direction: "newest_first" or "oldest_first" (which makes more sense for this data?)
+   - batch_size: optimal page size (usually 100, but some APIs differ)
+   - priority: 1-3 ranking
+
+Return ONLY valid JSON, no markdown fences, no explanation.
+`)
+
+	return builder.String()
+}
+
+func defaultString(value, fallback string) string {
+	if strings.TrimSpace(value) == "" {
+		return fallback
+	}
+	return value
+}
+
+func formatStringSlice(values []string) string {
+	if len(values) == 0 {
+		return "[]"
+	}
+
+	sorted := append([]string(nil), values...)
+	sort.Strings(sorted)
+
+	return "[" + strings.Join(sorted, ", ") + "]"
+}
+
+func formatSearchableFields(fields map[string][]string) string {
+	if len(fields) == 0 {
+		return "{}"
+	}
+
+	resources := make([]string, 0, len(fields))
+	for resource := range fields {
+		resources = append(resources, resource)
+	}
+	sort.Strings(resources)
+
+	parts := make([]string, 0, len(resources))
+	for _, resource := range resources {
+		parts = append(parts, fmt.Sprintf("%s: %s", resource, formatStringSlice(fields[resource])))
+	}
+
+	return "{" + strings.Join(parts, "; ") + "}"
+}
+
+func formatResourceSummaries(resources map[string]spec.Resource, prefix string) string {
+	if len(resources) == 0 {
+		return "- none\n"
+	}
+
+	names := make([]string, 0, len(resources))
+	for name := range resources {
+		names = append(names, name)
+	}
+	sort.Strings(names)
+
+	var builder strings.Builder
+	for _, name := range names {
+		resource := resources[name]
+		fullName := name
+		if prefix != "" {
+			fullName = prefix + "." + name
+		}
+
+		endpoints := make([]string, 0, len(resource.Endpoints))
+		for endpointName, endpoint := range resource.Endpoints {
+			endpoints = append(endpoints, fmt.Sprintf("%s (%s)", endpointName, strings.ToUpper(endpoint.Method)))
+		}
+		sort.Strings(endpoints)
+
+		line := fmt.Sprintf("- %s", fullName)
+		if resource.Description != "" {
+			line += ": " + resource.Description
+		}
+		if len(endpoints) > 0 {
+			line += " | endpoints: " + strings.Join(endpoints, ", ")
+		}
+		builder.WriteString(line + "\n")
+
+		if len(resource.SubResources) > 0 {
+			builder.WriteString(formatResourceSummaries(resource.SubResources, fullName))
+		}
+	}
+
+	return builder.String()
+}
diff --git a/internal/llmpolish/vision_test.go b/internal/llmpolish/vision_test.go
new file mode 100644
index 00000000..90940dee
--- /dev/null
+++ b/internal/llmpolish/vision_test.go
@@ -0,0 +1,72 @@
+package llmpolish
+
+import (
+	"encoding/json"
+	"os"
+	"testing"
+
+	"github.com/mvanhorn/cli-printing-press/internal/profiler"
+	"github.com/mvanhorn/cli-printing-press/internal/spec"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestSynthesizeVisionReturnsNilWhenLLMUnavailable(t *testing.T) {
+	originalPath := os.Getenv("PATH")
+	t.Setenv("PATH", t.TempDir())
+	defer os.Setenv("PATH", originalPath)
+
+	customization, err := SynthesizeVision(nil, nil)
+
+	require.NoError(t, err)
+	assert.Nil(t, customization)
+}
+
+func TestParseVisionCustomization(t *testing.T) {
+	raw := `{"resource_priority":["messages","members","channels"],"fts_fields":{"messages":["content"],"members":["username","bio"]},"workflow_names":{"archive":"archive","audit":"audit-log"},"example_overrides":{"sync":"discord-cli sync --guild 1234567890"},"desc_overrides":{"sync":"Sync guild messages to local SQLite for offline search"},"sync_hints":{"messages":{"direction":"newest_first","batch_size":100,"priority":1}}}`
+
+	var vc VisionCustomization
+	err := json.Unmarshal([]byte(raw), &vc)
+
+	require.NoError(t, err)
+	assert.Len(t, vc.ResourcePriority, 3)
+	assert.Equal(t, []string{"content"}, vc.FTSFields["messages"])
+	assert.Equal(t, "archive", vc.WorkflowNames["archive"])
+}
+
+func TestBuildVisionPrompt(t *testing.T) {
+	profile := &profiler.APIProfile{
+		HighVolume:        true,
+		NeedsSearch:       true,
+		SyncableResources: []string{"messages", "channels"},
+		SearchableFields: map[string][]string{
+			"messages": {"content", "author"},
+		},
+	}
+	apiSpec := &spec.APISpec{
+		Name:        "discord",
+		Description: "Discord API",
+		Resources: map[string]spec.Resource{
+			"channels": {
+				Endpoints: map[string]spec.Endpoint{
+					"list": {Method: "GET", Path: "/channels"},
+				},
+			},
+		},
+	}
+
+	prompt := buildVisionPrompt(profile, apiSpec)
+
+	assert.Contains(t, prompt, "discord")
+	assert.Contains(t, prompt, "messages")
+	assert.Contains(t, prompt, "High volume: true")
+}
+
+func TestBuildVisionPromptNilSafe(t *testing.T) {
+	assert.NotPanics(t, func() {
+		prompt := buildVisionPrompt(nil, nil)
+		assert.Contains(t, prompt, "unknown")
+		assert.Contains(t, prompt, "High volume: false")
+		assert.Contains(t, prompt, "Resources and their endpoints:")
+	})
+}

← a783ac12 fix(profiler): improve HighVolume and NeedsSearch heuristics  ·  back to Cli Printing Press  ·  feat(store): generate per-resource SQLite tables from profil bbd0a47c →