Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions docs/implementation-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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`

---

Expand Down
18 changes: 18 additions & 0 deletions internal/artifacts/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
120 changes: 120 additions & 0 deletions internal/artifacts/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
}
}
128 changes: 128 additions & 0 deletions internal/prompt/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
}
Comment on lines +144 to +158

return buf.String(), nil
}

Expand Down Expand Up @@ -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
}
Comment on lines +280 to +316

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 + "}}"
Expand Down
Loading