diff --git a/docs/implementation-log.md b/docs/implementation-log.md index 539253b..264cd43 100644 --- a/docs/implementation-log.md +++ b/docs/implementation-log.md @@ -25,7 +25,7 @@ Each sub-agent appends its entry to the **Branch Log** section below. You (the r | 3 | `feat/phase-1-cli-restore` | `feat/phase-0b-artifacts-config` | 001 Phase 1: T1.1, T1.2, T1.3 | ✅ done | | 4 | `feat/phase-2-cli-features` | `feat/phase-1-cli-restore` | 001 Phase 2: T2.1, T2.2, T2.3 | ✅ done | | 5 | `feat/figma-phase-b-client` | `feat/phase-2-cli-features` | 002 Phase B: T2F.0, T2F.1, T2F.2, T2F.3, T2F.4 | ✅ done | -| 6 | `feat/figma-phase-c-mapping` | `feat/figma-phase-b-client` | 002 Phase C: T2F.5, T2F.6, T2F.7 | ⏳ pending | +| 6 | `feat/figma-phase-c-mapping` | `feat/figma-phase-b-client` | 002 Phase C: T2F.5, T2F.6, T2F.7 | ✅ done | | 7 | `feat/figma-phase-d-cli` | `feat/figma-phase-c-mapping` | 002 Phase D: T2F.8, T2F.9 | ⏳ pending | | 8 | `feat/figma-phase-e-drift` | `feat/figma-phase-d-cli` | 002 Phase E: T2F.10 | ⏳ pending | | 9 | `feat/phase-3-api-foundation` | `feat/figma-phase-e-drift` | 001 Phase 3: T3.0, T3.1, T3.2, T3.3, T3.4 | ⏳ pending | @@ -217,9 +217,17 @@ _Parent: `feat/figma-phase-b-client`_ **Tasks:** T2F.5, T2F.6, T2F.7 -**Summary:** _(to be filled by agent)_ +**Summary:** Introduced `internal/source/mapping` — a resolver that joins `gdocs.LocationGroupedSuggestions` with `figma.NormalizedDesign` data into `ResolvedChunk` values. The resolver uses a four-strategy priority chain: (1) user-supplied node ID from URL (confidence 1.0), (2) Jaccard text-layer similarity against `NearestText` (threshold 0.30, confidence 0.50–0.95), (3) frame-name overlap (threshold 0.50, confidence 0.50–0.85), (4) fallback to first anchor (confidence 0.50, status "unresolved"). Resolved Figma comments are excluded from `ResolvedChunk.Comments`; screenshots are matched by node ID. Updated `internal/prompt/engine.go`: added `FigmaContextJSON` and `FigmaURL` fields to `PromptData`; added `GenerateChunksFromResolved` that batches `[]mapping.ResolvedChunk` into `[]PromptData` and serializes figma context as JSON; updated `RenderChunk` to parse `FigmaContextJSON` and render the figma-context template with `text/template` when non-empty. Created `internal/prompt/templates/figma-context.md` with anchor, screenshot, and comment sections. Extended `internal/artifacts/manager.go` with `WriteFigmaExtraction`, `WriteMappings`, and `WriteFigmaComments` methods that persist design data to `extraction/` alongside the existing gdocs extraction. -**Files changed:** _(to be filled by agent)_ +**Files changed:** +- `internal/source/mapping/types.go` — new: `ResolvedChunk`, `DesignAnchorRef`, `DesignCommentRef`, `MappingMetadata` +- `internal/source/mapping/resolver.go` — new: `Resolver.Build`, `resolveAnchor`, `matchByTextLayers` (Jaccard), `matchByFrameName`, `screenshotsForAnchors`, `commentsForAnchors`, `tokenize`, `tokenizeFromSuggestion`, `toSet`, `intersect`, `unionSets` +- `internal/source/mapping/resolver_test.go` — new: 9 test cases covering nil design, URL method, text method, name method, fallback, no-anchors, resolved/unresolved comments, screenshots, empty input +- `internal/prompt/engine.go` — `PromptData` gains `FigmaContextJSON` and `FigmaURL`; added `figmaContextTemplate` embed; added `figmaChunkContext` struct; added `GenerateChunksFromResolved`, `buildFigmaContextJSON`, `batchResolvedChunks`; `RenderChunk` appends figma section via `text/template` when `FigmaContextJSON != ""`; added imports `text/template`, `bauer/internal/source/mapping` +- `internal/prompt/templates/figma-context.md` — new: design context template with anchors, screenshots, and comments sections rendered via `text/template` +- `internal/prompt/engine_test.go` — added `mapping` import; 6 new test functions: `TestGenerateChunksFromResolved_NoFigma`, `TestGenerateChunksFromResolved_WithFigma`, `TestGenerateChunksFromResolved_MultiChunkBatching`, `TestRenderChunk_NoFigma_NoFigmaSection`, `TestRenderChunk_WithFigma_IncludesFigmaSection`; `makeResolvedChunk` helper +- `internal/artifacts/manager.go` — added imports `bauer/internal/figma`, `bauer/internal/source/mapping`; added `WriteFigmaExtraction`, `WriteMappings`, `WriteFigmaComments` +- `internal/artifacts/manager_test.go` — added imports for `figma`, `gdocs`, `mapping`; added `TestWriteFigmaExtraction`, `TestWriteMappings`, `TestWriteFigmaComments` --- diff --git a/internal/artifacts/manager.go b/internal/artifacts/manager.go index b5810f5..17070eb 100644 --- a/internal/artifacts/manager.go +++ b/internal/artifacts/manager.go @@ -8,6 +8,9 @@ import ( "os" "path/filepath" "time" + + "bauer/internal/figma" + "bauer/internal/source/mapping" ) // RunMetadata is written to metadata.json inside each run directory. @@ -172,6 +175,21 @@ func (m *Manager) EnsureScreenshotsDir(runID string) (string, error) { return dir, os.MkdirAll(dir, 0o755) } +// WriteFigmaExtraction persists the normalized design to extraction/figma.json. +func (m *Manager) WriteFigmaExtraction(runID string, design *figma.NormalizedDesign) error { + return m.writeJSON(runID, filepath.Join("extraction", "figma.json"), design) +} + +// WriteMappings persists all resolved chunk mappings to extraction/mappings.json. +func (m *Manager) WriteMappings(runID string, chunks []mapping.ResolvedChunk) error { + return m.writeJSON(runID, filepath.Join("extraction", "mappings.json"), chunks) +} + +// WriteFigmaComments persists all extracted comments (including resolved) to extraction/comments.json. +func (m *Manager) WriteFigmaComments(runID string, comments []figma.DesignComment) error { + return m.writeJSON(runID, filepath.Join("extraction", "comments.json"), comments) +} + // RunDir returns the path to the run's directory. func (m *Manager) RunDir(runID string) string { return filepath.Join(m.base, runID) diff --git a/internal/artifacts/manager_test.go b/internal/artifacts/manager_test.go index 0aab2d9..70bc5fa 100644 --- a/internal/artifacts/manager_test.go +++ b/internal/artifacts/manager_test.go @@ -8,6 +8,9 @@ import ( "testing" "bauer/internal/artifacts" + "bauer/internal/figma" + "bauer/internal/gdocs" + "bauer/internal/source/mapping" ) func TestNewRunID_Format(t *testing.T) { @@ -160,3 +163,120 @@ func splitLines(data []byte) []string { } return lines } + +func TestWriteFigmaExtraction(t *testing.T) { + tmpDir := t.TempDir() + mgr := artifacts.NewManager(tmpDir) + runID, err := mgr.StartRun(artifacts.RunMetadata{DocID: "doc-figma", Mode: "dry-run"}) + if err != nil { + t.Fatalf("StartRun() error = %v", err) + } + + design := &figma.NormalizedDesign{ + FileKey: "abc123", + RootNodeID: "1:1", + Version: "v1", + Anchors: []figma.DesignAnchor{ + {NodeID: "1:1", NodeName: "Frame A"}, + }, + } + + if err := mgr.WriteFigmaExtraction(runID, design); err != nil { + t.Fatalf("WriteFigmaExtraction() error = %v", err) + } + + path := filepath.Join(tmpDir, runID, "extraction", "figma.json") + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("expected extraction/figma.json to exist: %v", err) + } + + var result figma.NormalizedDesign + if err := json.Unmarshal(data, &result); err != nil { + t.Fatalf("failed to parse figma.json: %v", err) + } + if result.FileKey != "abc123" { + t.Errorf("FileKey = %q, want %q", result.FileKey, "abc123") + } + if len(result.Anchors) != 1 { + t.Errorf("expected 1 anchor, got %d", len(result.Anchors)) + } +} + +func TestWriteMappings(t *testing.T) { + tmpDir := t.TempDir() + mgr := artifacts.NewManager(tmpDir) + runID, err := mgr.StartRun(artifacts.RunMetadata{DocID: "doc-mappings", Mode: "dry-run"}) + if err != nil { + t.Fatalf("StartRun() error = %v", err) + } + + chunks := []mapping.ResolvedChunk{ + { + Locations: []gdocs.LocationGroupedSuggestions{ + {Location: gdocs.SuggestionLocation{Section: "Body"}}, + }, + DesignAnchors: []mapping.DesignAnchorRef{ + {FileKey: "file1", NodeID: "1:1", NodeName: "Frame"}, + }, + Mapping: mapping.MappingMetadata{Method: "url", Confidence: 1.0, Status: "healthy"}, + }, + } + + if err := mgr.WriteMappings(runID, chunks); err != nil { + t.Fatalf("WriteMappings() error = %v", err) + } + + path := filepath.Join(tmpDir, runID, "extraction", "mappings.json") + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("expected extraction/mappings.json to exist: %v", err) + } + + var result []mapping.ResolvedChunk + if err := json.Unmarshal(data, &result); err != nil { + t.Fatalf("failed to parse mappings.json: %v", err) + } + if len(result) != 1 { + t.Fatalf("expected 1 chunk, got %d", len(result)) + } + if result[0].Mapping.Method != "url" { + t.Errorf("Method = %q, want %q", result[0].Mapping.Method, "url") + } +} + +func TestWriteFigmaComments(t *testing.T) { + tmpDir := t.TempDir() + mgr := artifacts.NewManager(tmpDir) + runID, err := mgr.StartRun(artifacts.RunMetadata{DocID: "doc-comments", Mode: "dry-run"}) + if err != nil { + t.Fatalf("StartRun() error = %v", err) + } + + comments := []figma.DesignComment{ + {ID: "c1", NodeID: "1:1", Message: "open comment", Author: "alice", Resolved: false}, + {ID: "c2", NodeID: "1:1", Message: "resolved comment", Author: "bob", Resolved: true}, + } + + if err := mgr.WriteFigmaComments(runID, comments); err != nil { + t.Fatalf("WriteFigmaComments() error = %v", err) + } + + path := filepath.Join(tmpDir, runID, "extraction", "comments.json") + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("expected extraction/comments.json to exist: %v", err) + } + + var result []figma.DesignComment + if err := json.Unmarshal(data, &result); err != nil { + t.Fatalf("failed to parse comments.json: %v", err) + } + if len(result) != 2 { + t.Fatalf("expected 2 comments, got %d", len(result)) + } + // Both resolved and unresolved are persisted + if result[0].ID != "c1" || result[1].ID != "c2" { + t.Errorf("unexpected comment IDs: %q, %q", result[0].ID, result[1].ID) + } +} diff --git a/internal/prompt/engine.go b/internal/prompt/engine.go index 63c87a9..7ac053e 100644 --- a/internal/prompt/engine.go +++ b/internal/prompt/engine.go @@ -7,8 +7,10 @@ import ( "fmt" "os" "path/filepath" + "text/template" "bauer/internal/gdocs" + "bauer/internal/source/mapping" ) //go:embed templates/page-refresh-instructions.md @@ -20,6 +22,9 @@ var copyDocsInstructionsTemplate string //go:embed templates/vanilla-patterns.md var vanillaPatterns string +//go:embed templates/figma-context.md +var figmaContextTemplate string + // Engine handles prompt generation for Copilot type Engine struct { // UsePageRefresh determines which instruction template to use @@ -41,6 +46,12 @@ type PromptData struct { // Location-grouped suggestions for this chunk (raw JSON) SuggestionsJSON string + + // FigmaContextJSON is serialised figma enrichment — empty string when no Figma URL was supplied. + FigmaContextJSON string + + // FigmaURL is the optional Figma URL, stored for metadata/artifact output (not rendered in templates). + FigmaURL string } // ChunkResult contains the rendered prompt and metadata for a chunk @@ -130,6 +141,22 @@ func (e *Engine) RenderChunk(data PromptData) (string, error) { buf.WriteString(data.SuggestionsJSON) buf.WriteString("\n```\n") + // Append figma context section when design data is present + if data.FigmaContextJSON != "" { + var ctx figmaChunkContext + if err := json.Unmarshal([]byte(data.FigmaContextJSON), &ctx); err != nil { + return "", fmt.Errorf("parsing figma context JSON: %w", err) + } + tmpl, err := template.New("figma-context").Parse(figmaContextTemplate) + if err != nil { + return "", fmt.Errorf("parsing figma context template: %w", err) + } + buf.WriteString("\n\n---\n\n") + if err := tmpl.Execute(&buf, ctx); err != nil { + return "", fmt.Errorf("rendering figma context: %w", err) + } + } + return buf.String(), nil } @@ -202,6 +229,107 @@ func (e *Engine) GenerateAllChunks( return results, nil } +// figmaChunkContext is the data structure serialized into FigmaContextJSON. +type figmaChunkContext struct { + Anchors []mapping.DesignAnchorRef `json:"anchors,omitempty"` + Screenshots []string `json:"screenshots,omitempty"` + Comments []mapping.DesignCommentRef `json:"comments,omitempty"` +} + +// GenerateChunksFromResolved creates one PromptData per batch of resolved chunks. +// chunkSize controls how many ResolvedChunks are combined into one prompt. +// When FigmaContextJSON is non-empty, the rendered prompt includes the figma-context section. +func (e *Engine) GenerateChunksFromResolved( + docTitle, suggestedURL, figmaURL string, + chunks []mapping.ResolvedChunk, + chunkSize int, +) ([]PromptData, error) { + batches := batchResolvedChunks(chunks, chunkSize) + result := make([]PromptData, len(batches)) + + for i, batch := range batches { + var locations []gdocs.LocationGroupedSuggestions + for _, rc := range batch { + locations = append(locations, rc.Locations...) + } + + suggestionsJSON, err := json.MarshalIndent(locations, "", " ") + if err != nil { + return nil, fmt.Errorf("marshaling suggestions for chunk %d: %w", i+1, err) + } + + figmaJSON, err := buildFigmaContextJSON(batch) + if err != nil { + return nil, fmt.Errorf("building figma context for chunk %d: %w", i+1, err) + } + + result[i] = PromptData{ + DocumentTitle: docTitle, + SuggestedURL: suggestedURL, + ChunkNumber: i + 1, + TotalChunks: len(batches), + LocationCount: len(locations), + SuggestionsJSON: string(suggestionsJSON), + FigmaContextJSON: figmaJSON, + FigmaURL: figmaURL, + } + } + return result, nil +} + +func buildFigmaContextJSON(batch []mapping.ResolvedChunk) (string, error) { + ctx := figmaChunkContext{} + seenAnchors := map[string]bool{} + seenScreenshots := map[string]bool{} + seenComments := map[string]bool{} + + for _, rc := range batch { + for _, a := range rc.DesignAnchors { + if !seenAnchors[a.NodeID] { + seenAnchors[a.NodeID] = true + ctx.Anchors = append(ctx.Anchors, a) + } + } + for _, s := range rc.ScreenshotPaths { + if !seenScreenshots[s] { + seenScreenshots[s] = true + ctx.Screenshots = append(ctx.Screenshots, s) + } + } + for _, c := range rc.Comments { + if !seenComments[c.CommentID] { + seenComments[c.CommentID] = true + ctx.Comments = append(ctx.Comments, c) + } + } + } + + if len(ctx.Anchors) == 0 && len(ctx.Screenshots) == 0 && len(ctx.Comments) == 0 { + return "", nil + } + + b, err := json.MarshalIndent(ctx, "", " ") + if err != nil { + return "", err + } + return string(b), nil +} + +func batchResolvedChunks(chunks []mapping.ResolvedChunk, size int) [][]mapping.ResolvedChunk { + if size <= 0 { + size = 1 + } + var batches [][]mapping.ResolvedChunk + for i := 0; i < len(chunks); i += size { + end := i + size + if end > len(chunks) { + end = len(chunks) + } + batches = append(batches, chunks[i:end]) + } + return batches +} + // replaceVar is a simple string replacement helper for template variables func replaceVar(template, key, value string) string { placeholder := "{{." + key + "}}" diff --git a/internal/prompt/engine_test.go b/internal/prompt/engine_test.go index f492081..e0720b4 100644 --- a/internal/prompt/engine_test.go +++ b/internal/prompt/engine_test.go @@ -2,9 +2,11 @@ package prompt import ( "os" + "strings" "testing" "bauer/internal/gdocs" + "bauer/internal/source/mapping" ) func TestNewEngine(t *testing.T) { @@ -452,6 +454,202 @@ func TestReplaceVar(t *testing.T) { // Helper functions +func makeResolvedChunk(heading, section string, anchors []mapping.DesignAnchorRef, screenshots []string, comments []mapping.DesignCommentRef, method string, conf float64) mapping.ResolvedChunk { + return mapping.ResolvedChunk{ + Locations: []gdocs.LocationGroupedSuggestions{ + { + Location: gdocs.SuggestionLocation{Section: section, ParentHeading: heading}, + Suggestions: makeTestSuggestions(1), + }, + }, + DesignAnchors: anchors, + ScreenshotPaths: screenshots, + Comments: comments, + Mapping: mapping.MappingMetadata{Method: method, Confidence: conf, Status: "healthy"}, + } +} + +func TestGenerateChunksFromResolved_NoFigma(t *testing.T) { + engine, err := NewEngine(false) + if err != nil { + t.Fatalf("NewEngine() failed: %v", err) + } + + chunks := []mapping.ResolvedChunk{ + makeResolvedChunk("Heading 1", "Body", nil, nil, nil, "none", 0), + makeResolvedChunk("Heading 2", "Body", nil, nil, nil, "none", 0), + } + + prompts, err := engine.GenerateChunksFromResolved("Test Doc", "ubuntu.com/page", "", chunks, 10) + if err != nil { + t.Fatalf("GenerateChunksFromResolved() failed: %v", err) + } + + if len(prompts) != 1 { + t.Fatalf("expected 1 prompt batch, got %d", len(prompts)) + } + + p := prompts[0] + if p.FigmaContextJSON != "" { + t.Errorf("expected empty FigmaContextJSON, got %q", p.FigmaContextJSON) + } + if p.ChunkNumber != 1 { + t.Errorf("ChunkNumber = %d, want 1", p.ChunkNumber) + } + if p.TotalChunks != 1 { + t.Errorf("TotalChunks = %d, want 1", p.TotalChunks) + } + if p.LocationCount != 2 { + t.Errorf("LocationCount = %d, want 2", p.LocationCount) + } +} + +func TestGenerateChunksFromResolved_WithFigma(t *testing.T) { + engine, err := NewEngine(false) + if err != nil { + t.Fatalf("NewEngine() failed: %v", err) + } + + anchors := []mapping.DesignAnchorRef{{FileKey: "f1", NodeID: "1:1", NodeName: "Login Frame"}} + screenshots := []string{"/tmp/screenshot.png"} + comments := []mapping.DesignCommentRef{{CommentID: "c1", Message: "check spacing", Author: "alice", NodeID: "1:1"}} + + chunks := []mapping.ResolvedChunk{ + makeResolvedChunk("Login", "Body", anchors, screenshots, comments, "url", 1.0), + } + + prompts, err := engine.GenerateChunksFromResolved("Test Doc", "ubuntu.com/page", "https://figma.com/file/f1", chunks, 10) + if err != nil { + t.Fatalf("GenerateChunksFromResolved() failed: %v", err) + } + + if len(prompts) != 1 { + t.Fatalf("expected 1 prompt, got %d", len(prompts)) + } + + p := prompts[0] + if p.FigmaContextJSON == "" { + t.Fatal("expected non-empty FigmaContextJSON") + } + if p.FigmaURL != "https://figma.com/file/f1" { + t.Errorf("FigmaURL = %q, want %q", p.FigmaURL, "https://figma.com/file/f1") + } + if !strings.Contains(p.FigmaContextJSON, "Login Frame") { + t.Errorf("FigmaContextJSON missing anchor name: %s", p.FigmaContextJSON) + } + if !strings.Contains(p.FigmaContextJSON, "/tmp/screenshot.png") { + t.Errorf("FigmaContextJSON missing screenshot: %s", p.FigmaContextJSON) + } + if !strings.Contains(p.FigmaContextJSON, "check spacing") { + t.Errorf("FigmaContextJSON missing comment: %s", p.FigmaContextJSON) + } +} + +func TestGenerateChunksFromResolved_MultiChunkBatching(t *testing.T) { + engine, err := NewEngine(false) + if err != nil { + t.Fatalf("NewEngine() failed: %v", err) + } + + // 5 chunks, batch size 2 → 3 batches (2+2+1) + var chunks []mapping.ResolvedChunk + for i := range 5 { + chunks = append(chunks, makeResolvedChunk("Heading", "Body", nil, nil, nil, "none", 0)) + _ = i + } + + prompts, err := engine.GenerateChunksFromResolved("Test Doc", "ubuntu.com/page", "", chunks, 2) + if err != nil { + t.Fatalf("GenerateChunksFromResolved() failed: %v", err) + } + + if len(prompts) != 3 { + t.Fatalf("expected 3 batches, got %d", len(prompts)) + } + + // Verify ChunkNumber and TotalChunks + for i, p := range prompts { + if p.ChunkNumber != i+1 { + t.Errorf("prompts[%d].ChunkNumber = %d, want %d", i, p.ChunkNumber, i+1) + } + if p.TotalChunks != 3 { + t.Errorf("prompts[%d].TotalChunks = %d, want 3", i, p.TotalChunks) + } + } + + // First two batches have 2 locations each, last has 1 + if prompts[0].LocationCount != 2 { + t.Errorf("batch 0: LocationCount = %d, want 2", prompts[0].LocationCount) + } + if prompts[1].LocationCount != 2 { + t.Errorf("batch 1: LocationCount = %d, want 2", prompts[1].LocationCount) + } + if prompts[2].LocationCount != 1 { + t.Errorf("batch 2: LocationCount = %d, want 1", prompts[2].LocationCount) + } +} + +func TestRenderChunk_NoFigma_NoFigmaSection(t *testing.T) { + engine, err := NewEngine(false) + if err != nil { + t.Fatalf("NewEngine() failed: %v", err) + } + + data := PromptData{ + DocumentTitle: "Test Doc", + SuggestedURL: "ubuntu.com/page", + ChunkNumber: 1, + TotalChunks: 1, + LocationCount: 1, + SuggestionsJSON: `[]`, + } + + content, err := engine.RenderChunk(data) + if err != nil { + t.Fatalf("RenderChunk() failed: %v", err) + } + + if strings.Contains(content, "Design Context") { + t.Error("expected no 'Design Context' section when FigmaContextJSON is empty") + } +} + +func TestRenderChunk_WithFigma_IncludesFigmaSection(t *testing.T) { + engine, err := NewEngine(false) + if err != nil { + t.Fatalf("NewEngine() failed: %v", err) + } + + data := PromptData{ + DocumentTitle: "Test Doc", + SuggestedURL: "ubuntu.com/page", + ChunkNumber: 1, + TotalChunks: 1, + LocationCount: 1, + SuggestionsJSON: `[]`, + FigmaContextJSON: `{"anchors":[{"file_key":"f1","node_id":"1:1","node_name":"Hero Frame"}],"screenshots":["/tmp/hero.png"],"comments":[{"comment_id":"c1","message":"align left","author":"bob","node_id":"1:1"}]}`, + FigmaURL: "https://figma.com/file/f1", + } + + content, err := engine.RenderChunk(data) + if err != nil { + t.Fatalf("RenderChunk() failed: %v", err) + } + + expectedStrings := []string{ + "Design Context", + "Hero Frame", + "/tmp/hero.png", + "align left", + "bob", + } + for _, expected := range expectedStrings { + if !strings.Contains(content, expected) { + t.Errorf("rendered content missing expected string: %q", expected) + } + } +} + func makeTestSuggestions(count int) []gdocs.GroupedActionableSuggestion { suggestions := make([]gdocs.GroupedActionableSuggestion, count) for i := range count { diff --git a/internal/prompt/templates/figma-context.md b/internal/prompt/templates/figma-context.md new file mode 100644 index 0000000..737dea7 --- /dev/null +++ b/internal/prompt/templates/figma-context.md @@ -0,0 +1,32 @@ +## Design Context + +Design information has been extracted from Figma for the suggestions in this chunk. +{{if .Anchors}} +### Referenced design nodes +{{range .Anchors}} +- **{{.NodeName}}** (node: `{{.NodeID}}`) +{{- end}} +{{end}} +{{if .Screenshots}} +### Screenshots + +The following screenshots are available locally for the regions related to this chunk: +{{range .Screenshots}} +- `{{.}}` +{{- end}} + +Examine them carefully to validate spacing, component usage, and text content before making changes. +{{end}} +{{if .Comments}} +### Designer comments (treat as hard requirements unless they conflict with the Google Doc) +{{range .Comments}} +- **{{.Author}}**: {{.Message}} _(node: `{{.NodeID}}`)_ +{{- end}} + +The Google Doc is the canonical intent source. Designer comments are requirements within that intent. +{{end}} +### Instructions for design alignment + +- Verify your implementation matches the visual design for the suggestion locations in this chunk. +- Do not invent new UI components if the design shows an existing one. +- If the design shows a spacing or typography token, check whether an equivalent exists in the codebase. diff --git a/internal/source/mapping/resolver.go b/internal/source/mapping/resolver.go new file mode 100644 index 0000000..356b1ad --- /dev/null +++ b/internal/source/mapping/resolver.go @@ -0,0 +1,247 @@ +package mapping + +import ( + "sort" + "strings" + "unicode" + + "bauer/internal/figma" + "bauer/internal/gdocs" +) + +// Resolver builds ResolvedChunk values by joining gdocs groups with figma design data. +type Resolver struct{} + +// Build returns one ResolvedChunk per gdocs LocationGroupedSuggestions. +// If design is nil (no figma URL was supplied), each chunk has empty design fields +// and Mapping.Method == "none". +func (r *Resolver) Build( + groups []gdocs.LocationGroupedSuggestions, + design *figma.NormalizedDesign, +) []ResolvedChunk { + chunks := make([]ResolvedChunk, len(groups)) + for i, group := range groups { + chunks[i] = ResolvedChunk{ + Locations: []gdocs.LocationGroupedSuggestions{group}, + Mapping: MappingMetadata{Method: "none", Confidence: 0, Status: "none"}, + } + if design != nil { + anchors, meta := r.resolveAnchor(group, design) + chunks[i].DesignAnchors = anchors + chunks[i].Mapping = meta + chunks[i].ScreenshotPaths = r.screenshotsForAnchors(anchors, design) + chunks[i].Comments = r.commentsForAnchors(anchors, design) + } + } + return chunks +} + +func (r *Resolver) resolveAnchor( + group gdocs.LocationGroupedSuggestions, + design *figma.NormalizedDesign, +) ([]DesignAnchorRef, MappingMetadata) { + // Strategy 1: user-supplied node ID from URL + if design.RootNodeID != "" { + for _, anchor := range design.Anchors { + if anchor.NodeID == design.RootNodeID { + return []DesignAnchorRef{{ + FileKey: design.FileKey, + NodeID: anchor.NodeID, + NodeName: anchor.NodeName, + }}, MappingMetadata{Method: "url", Confidence: 1.0, Status: "healthy"} + } + } + } + + // Strategy 2: text layer matching (Jaccard) + if anchor, conf := matchByTextLayers(group, design.Anchors); anchor != nil { + return []DesignAnchorRef{{ + FileKey: design.FileKey, + NodeID: anchor.NodeID, + NodeName: anchor.NodeName, + }}, MappingMetadata{Method: "text", Confidence: conf, Status: "healthy"} + } + + // Strategy 3: frame name matching + if anchor, conf := matchByFrameName(group.Location, design.Anchors); anchor != nil { + return []DesignAnchorRef{{ + FileKey: design.FileKey, + NodeID: anchor.NodeID, + NodeName: anchor.NodeName, + }}, MappingMetadata{Method: "name", Confidence: conf, Status: "healthy"} + } + + // Strategy 4: fallback — picks the anchor with the lowest NodeID for determinism + if len(design.Anchors) > 0 { + sorted := make([]figma.DesignAnchor, len(design.Anchors)) + copy(sorted, design.Anchors) + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].NodeID < sorted[j].NodeID + }) + return []DesignAnchorRef{{ + FileKey: design.FileKey, + NodeID: sorted[0].NodeID, + NodeName: sorted[0].NodeName, + }}, MappingMetadata{Method: "fallback", Confidence: 0.50, Status: "unresolved"} + } + + return nil, MappingMetadata{Method: "none", Confidence: 0, Status: "unresolved"} +} + +// matchByTextLayers uses Jaccard similarity on token sets. +// Returns the best matching anchor and its confidence, or nil if no match meets threshold. +func matchByTextLayers(group gdocs.LocationGroupedSuggestions, anchors []figma.DesignAnchor) (*figma.DesignAnchor, float64) { + // Build token set from gdocs suggestion group + gdocsTokens := tokenize(group.Location.ParentHeading + " " + group.Location.Section) + for _, sug := range group.Suggestions { + gdocsTokens = append(gdocsTokens, tokenizeFromSuggestion(sug)...) + } + gdocsSet := toSet(gdocsTokens) + + if len(gdocsSet) == 0 { + return nil, 0 + } + + var best *figma.DesignAnchor + bestConf := 0.0 + + for i := range anchors { + figmaTokens := tokenize(strings.Join(anchors[i].NearestText, " ")) + figmaSet := toSet(figmaTokens) + + shared := intersect(gdocsSet, figmaSet) + union := unionSets(gdocsSet, figmaSet) + if len(union) == 0 { + continue + } + + jacc := float64(len(shared)) / float64(len(union)) + conf := 0.50 + (jacc * 0.45) + + if jacc >= 0.30 && (conf > bestConf || (conf == bestConf && (best == nil || anchors[i].NodeID < best.NodeID))) { + bestConf = conf + best = &anchors[i] + } + } + return best, bestConf +} + +// matchByFrameName compares the gdocs heading to the Figma frame name. +func matchByFrameName(loc gdocs.SuggestionLocation, anchors []figma.DesignAnchor) (*figma.DesignAnchor, float64) { + headingTokens := toSet(tokenize(loc.ParentHeading)) + if len(headingTokens) == 0 { + return nil, 0 + } + + var best *figma.DesignAnchor + bestConf := 0.0 + + for i := range anchors { + frameTokens := toSet(tokenize(anchors[i].NodeName)) + shared := intersect(headingTokens, frameTokens) + + maxLen := len(headingTokens) + if len(frameTokens) > maxLen { + maxLen = len(frameTokens) + } + if maxLen == 0 { + continue + } + + overlap := float64(len(shared)) / float64(maxLen) + conf := 0.50 + (overlap * 0.35) + + if overlap >= 0.50 && (conf > bestConf || (conf == bestConf && (best == nil || anchors[i].NodeID < best.NodeID))) { + bestConf = conf + best = &anchors[i] + } + } + return best, bestConf +} + +func (r *Resolver) screenshotsForAnchors(anchors []DesignAnchorRef, design *figma.NormalizedDesign) []string { + var paths []string + for _, anchor := range anchors { + for _, shot := range design.Screenshots { + if shot.NodeID == anchor.NodeID { + paths = append(paths, shot.LocalPath) + } + } + } + return paths +} + +func (r *Resolver) commentsForAnchors(anchors []DesignAnchorRef, design *figma.NormalizedDesign) []DesignCommentRef { + anchorIDs := map[string]bool{} + for _, a := range anchors { + anchorIDs[a.NodeID] = true + } + var refs []DesignCommentRef + for _, c := range design.Comments { + if c.Resolved { + continue // resolved comments not included in prompt context + } + if anchorIDs[c.NodeID] { + refs = append(refs, DesignCommentRef{ + CommentID: c.ID, + Message: c.Message, + Author: c.Author, + NodeID: c.NodeID, + }) + } + } + return refs +} + +// tokenizeFromSuggestion returns tokenized original text from a suggestion's change. +func tokenizeFromSuggestion(sug gdocs.GroupedActionableSuggestion) []string { + return tokenize(sug.Change.OriginalText) +} + +// tokenize normalizes text into a lowercase token slice, removing stop words and short tokens. +func tokenize(text string) []string { + stop := map[string]bool{ + "the": true, "a": true, "an": true, "and": true, "or": true, + "in": true, "of": true, "to": true, "for": true, "is": true, "are": true, + "it": true, "at": true, "on": true, "by": true, "be": true, + } + words := strings.FieldsFunc(strings.ToLower(text), func(r rune) bool { + return !unicode.IsLetter(r) && !unicode.IsDigit(r) + }) + var result []string + for _, w := range words { + if len(w) >= 3 && !stop[w] { + result = append(result, w) + } + } + return result +} + +func toSet(tokens []string) map[string]bool { + s := make(map[string]bool, len(tokens)) + for _, t := range tokens { + s[t] = true + } + return s +} + +func intersect(a, b map[string]bool) map[string]bool { + result := map[string]bool{} + for k := range a { + if b[k] { + result[k] = true + } + } + return result +} + +func unionSets(a, b map[string]bool) map[string]bool { + result := map[string]bool{} + for k := range a { + result[k] = true + } + for k := range b { + result[k] = true + } + return result +} diff --git a/internal/source/mapping/resolver_test.go b/internal/source/mapping/resolver_test.go new file mode 100644 index 0000000..cd73d26 --- /dev/null +++ b/internal/source/mapping/resolver_test.go @@ -0,0 +1,319 @@ +package mapping + +import ( + "testing" + + "bauer/internal/figma" + "bauer/internal/gdocs" +) + +func makeGroup(heading, section string, suggestions ...gdocs.GroupedActionableSuggestion) gdocs.LocationGroupedSuggestions { + return gdocs.LocationGroupedSuggestions{ + Location: gdocs.SuggestionLocation{ + Section: section, + ParentHeading: heading, + }, + Suggestions: suggestions, + } +} + +func makeSuggestion(originalText string) gdocs.GroupedActionableSuggestion { + return gdocs.GroupedActionableSuggestion{ + ID: "test-id", + Change: gdocs.SuggestionChange{ + Type: "replace", + OriginalText: originalText, + NewText: "new text", + }, + AtomicCount: 1, + } +} + +func TestBuild_NilDesign_AllNone(t *testing.T) { + r := &Resolver{} + groups := []gdocs.LocationGroupedSuggestions{ + makeGroup("Heading One", "Body"), + makeGroup("Heading Two", "Body"), + } + + chunks := r.Build(groups, nil) + + if len(chunks) != 2 { + t.Fatalf("expected 2 chunks, got %d", len(chunks)) + } + for i, chunk := range chunks { + if chunk.Mapping.Method != "none" { + t.Errorf("chunk[%d]: expected method 'none', got %q", i, chunk.Mapping.Method) + } + if chunk.Mapping.Confidence != 0 { + t.Errorf("chunk[%d]: expected confidence 0, got %f", i, chunk.Mapping.Confidence) + } + if len(chunk.DesignAnchors) != 0 { + t.Errorf("chunk[%d]: expected no anchors, got %d", i, len(chunk.DesignAnchors)) + } + if len(chunk.ScreenshotPaths) != 0 { + t.Errorf("chunk[%d]: expected no screenshots, got %d", i, len(chunk.ScreenshotPaths)) + } + if len(chunk.Comments) != 0 { + t.Errorf("chunk[%d]: expected no comments, got %d", i, len(chunk.Comments)) + } + } +} + +func TestBuild_UserSuppliedNodeID_URLMethod(t *testing.T) { + r := &Resolver{} + groups := []gdocs.LocationGroupedSuggestions{ + makeGroup("Any Heading", "Body"), + } + design := &figma.NormalizedDesign{ + FileKey: "file123", + RootNodeID: "1:23", + Anchors: []figma.DesignAnchor{ + {NodeID: "1:23", NodeName: "Login Frame"}, + {NodeID: "4:56", NodeName: "Other Frame"}, + }, + } + + chunks := r.Build(groups, design) + + if len(chunks) != 1 { + t.Fatalf("expected 1 chunk, got %d", len(chunks)) + } + chunk := chunks[0] + if chunk.Mapping.Method != "url" { + t.Errorf("expected method 'url', got %q", chunk.Mapping.Method) + } + if chunk.Mapping.Confidence != 1.0 { + t.Errorf("expected confidence 1.0, got %f", chunk.Mapping.Confidence) + } + if chunk.Mapping.Status != "healthy" { + t.Errorf("expected status 'healthy', got %q", chunk.Mapping.Status) + } + if len(chunk.DesignAnchors) != 1 { + t.Fatalf("expected 1 anchor, got %d", len(chunk.DesignAnchors)) + } + if chunk.DesignAnchors[0].NodeID != "1:23" { + t.Errorf("expected node ID '1:23', got %q", chunk.DesignAnchors[0].NodeID) + } +} + +func TestBuild_TextMatching_TextMethod(t *testing.T) { + r := &Resolver{} + // Use a heading with distinct tokens that appear in the figma anchor's NearestText + groups := []gdocs.LocationGroupedSuggestions{ + makeGroup("Dashboard Analytics Overview", "Body", + makeSuggestion("revenue chart monthly"), + ), + } + design := &figma.NormalizedDesign{ + FileKey: "file123", + Anchors: []figma.DesignAnchor{ + { + NodeID: "10:1", + NodeName: "Unrelated Frame", + NearestText: []string{"dashboard", "analytics", "overview", "revenue", "chart", "monthly"}, + }, + }, + } + + chunks := r.Build(groups, design) + + if len(chunks) != 1 { + t.Fatalf("expected 1 chunk, got %d", len(chunks)) + } + chunk := chunks[0] + if chunk.Mapping.Method != "text" { + t.Errorf("expected method 'text', got %q", chunk.Mapping.Method) + } + if chunk.Mapping.Confidence <= 0.50 { + t.Errorf("expected confidence > 0.50, got %f", chunk.Mapping.Confidence) + } + if chunk.Mapping.Status != "healthy" { + t.Errorf("expected status 'healthy', got %q", chunk.Mapping.Status) + } +} + +func TestBuild_FrameNameMatching_NameMethod(t *testing.T) { + r := &Resolver{} + // Use heading tokens that overlap ≥50% with frame name tokens + groups := []gdocs.LocationGroupedSuggestions{ + makeGroup("Settings Panel", "Body"), + } + design := &figma.NormalizedDesign{ + FileKey: "file123", + Anchors: []figma.DesignAnchor{ + // NearestText is empty so text matching won't fire + {NodeID: "20:5", NodeName: "Settings Panel"}, + }, + } + + chunks := r.Build(groups, design) + + if len(chunks) != 1 { + t.Fatalf("expected 1 chunk, got %d", len(chunks)) + } + chunk := chunks[0] + if chunk.Mapping.Method != "name" { + t.Errorf("expected method 'name', got %q", chunk.Mapping.Method) + } + if chunk.Mapping.Status != "healthy" { + t.Errorf("expected status 'healthy', got %q", chunk.Mapping.Status) + } +} + +func TestBuild_Fallback_UnresolvedStatus(t *testing.T) { + r := &Resolver{} + // heading has no tokens matching figma frame name or nearest text + groups := []gdocs.LocationGroupedSuggestions{ + makeGroup("Completely Different Topic", "Body"), + } + design := &figma.NormalizedDesign{ + FileKey: "file123", + Anchors: []figma.DesignAnchor{ + {NodeID: "30:1", NodeName: "XYZ Frame"}, + }, + } + + chunks := r.Build(groups, design) + + if len(chunks) != 1 { + t.Fatalf("expected 1 chunk, got %d", len(chunks)) + } + chunk := chunks[0] + if chunk.Mapping.Method != "fallback" { + t.Errorf("expected method 'fallback', got %q", chunk.Mapping.Method) + } + if chunk.Mapping.Confidence != 0.50 { + t.Errorf("expected confidence 0.50, got %f", chunk.Mapping.Confidence) + } + if chunk.Mapping.Status != "unresolved" { + t.Errorf("expected status 'unresolved', got %q", chunk.Mapping.Status) + } +} + +func TestBuild_NoAnchors_NoneUnresolved(t *testing.T) { + r := &Resolver{} + groups := []gdocs.LocationGroupedSuggestions{ + makeGroup("Some Heading", "Body"), + } + design := &figma.NormalizedDesign{ + FileKey: "file123", + Anchors: []figma.DesignAnchor{}, // no anchors + } + + chunks := r.Build(groups, design) + + if len(chunks) != 1 { + t.Fatalf("expected 1 chunk, got %d", len(chunks)) + } + chunk := chunks[0] + if chunk.Mapping.Method != "none" { + t.Errorf("expected method 'none', got %q", chunk.Mapping.Method) + } + if chunk.Mapping.Status != "unresolved" { + t.Errorf("expected status 'unresolved', got %q", chunk.Mapping.Status) + } +} + +func TestBuild_ResolvedCommentsExcluded(t *testing.T) { + r := &Resolver{} + groups := []gdocs.LocationGroupedSuggestions{ + makeGroup("Any Heading", "Body"), + } + design := &figma.NormalizedDesign{ + FileKey: "file123", + RootNodeID: "1:1", + Anchors: []figma.DesignAnchor{ + {NodeID: "1:1", NodeName: "Frame"}, + }, + Comments: []figma.DesignComment{ + {ID: "c1", NodeID: "1:1", Message: "resolved comment", Author: "alice", Resolved: true}, + {ID: "c2", NodeID: "1:1", Message: "open comment", Author: "bob", Resolved: false}, + }, + } + + chunks := r.Build(groups, design) + + if len(chunks) != 1 { + t.Fatalf("expected 1 chunk, got %d", len(chunks)) + } + comments := chunks[0].Comments + if len(comments) != 1 { + t.Fatalf("expected 1 comment (unresolved only), got %d", len(comments)) + } + if comments[0].CommentID != "c2" { + t.Errorf("expected comment ID 'c2', got %q", comments[0].CommentID) + } + if comments[0].Author != "bob" { + t.Errorf("expected author 'bob', got %q", comments[0].Author) + } +} + +func TestBuild_UnresolvedCommentsIncluded(t *testing.T) { + r := &Resolver{} + groups := []gdocs.LocationGroupedSuggestions{ + makeGroup("Any Heading", "Body"), + } + design := &figma.NormalizedDesign{ + FileKey: "file123", + RootNodeID: "1:1", + Anchors: []figma.DesignAnchor{ + {NodeID: "1:1", NodeName: "Frame"}, + }, + Comments: []figma.DesignComment{ + {ID: "c3", NodeID: "1:1", Message: "first open", Author: "carol", Resolved: false}, + {ID: "c4", NodeID: "1:1", Message: "second open", Author: "dave", Resolved: false}, + }, + } + + chunks := r.Build(groups, design) + + if len(chunks) != 1 { + t.Fatalf("expected 1 chunk, got %d", len(chunks)) + } + comments := chunks[0].Comments + if len(comments) != 2 { + t.Fatalf("expected 2 comments, got %d", len(comments)) + } +} + +func TestBuild_ScreenshotsAttachedToCorrectAnchor(t *testing.T) { + r := &Resolver{} + groups := []gdocs.LocationGroupedSuggestions{ + makeGroup("Any Heading", "Body"), + } + design := &figma.NormalizedDesign{ + FileKey: "file123", + RootNodeID: "1:1", + Anchors: []figma.DesignAnchor{ + {NodeID: "1:1", NodeName: "Frame A"}, + {NodeID: "2:2", NodeName: "Frame B"}, + }, + Screenshots: []figma.ScreenshotArtifact{ + {NodeID: "1:1", LocalPath: "/tmp/frame-a.png", Scale: 2}, + {NodeID: "2:2", LocalPath: "/tmp/frame-b.png", Scale: 2}, + }, + } + + chunks := r.Build(groups, design) + + if len(chunks) != 1 { + t.Fatalf("expected 1 chunk, got %d", len(chunks)) + } + // URL match → only node 1:1 is the anchor + paths := chunks[0].ScreenshotPaths + if len(paths) != 1 { + t.Fatalf("expected 1 screenshot for matched anchor, got %d", len(paths)) + } + if paths[0] != "/tmp/frame-a.png" { + t.Errorf("expected '/tmp/frame-a.png', got %q", paths[0]) + } +} + +func TestBuild_EmptyGroups(t *testing.T) { + r := &Resolver{} + chunks := r.Build(nil, nil) + if len(chunks) != 0 { + t.Errorf("expected 0 chunks for nil input, got %d", len(chunks)) + } +} diff --git a/internal/source/mapping/types.go b/internal/source/mapping/types.go new file mode 100644 index 0000000..c378e97 --- /dev/null +++ b/internal/source/mapping/types.go @@ -0,0 +1,36 @@ +package mapping + +import "bauer/internal/gdocs" + +// ResolvedChunk is a group of suggestion locations enriched with figma design context. +// It is the unit that the prompt engine receives and renders into a PromptData. +// When no figma URL was supplied, DesignAnchors, ScreenshotPaths, and Comments are empty. +type ResolvedChunk struct { + Locations []gdocs.LocationGroupedSuggestions `json:"locations"` + DesignAnchors []DesignAnchorRef `json:"design_anchors,omitempty"` + ScreenshotPaths []string `json:"screenshot_paths,omitempty"` + Comments []DesignCommentRef `json:"comments,omitempty"` + Mapping MappingMetadata `json:"mapping"` +} + +// DesignAnchorRef is a lightweight reference to a matched Figma node. +type DesignAnchorRef struct { + FileKey string `json:"file_key"` + NodeID string `json:"node_id"` + NodeName string `json:"node_name"` +} + +// DesignCommentRef is a lightweight reference to a matched Figma comment. +type DesignCommentRef struct { + CommentID string `json:"comment_id"` + Message string `json:"message"` + Author string `json:"author"` + NodeID string `json:"node_id"` +} + +// MappingMetadata describes how a suggestion group was matched to a figma anchor. +type MappingMetadata struct { + Method string `json:"method"` // "url", "cache", "text", "name", "fallback", or "none" + Confidence float64 `json:"confidence"` // 0.0 to 1.0; 0 for the "none" case + Status string `json:"status"` // "healthy", "stale", "unresolved", or "none" +}