feat: implement AI-assisted rename prompting feature
- Added data model for AI-assisted renaming including structures for prompts, responses, and policies. - Created implementation plan detailing the integration of Google Genkit into the CLI for renaming tasks. - Developed quickstart guide for setting up and using the new AI rename functionality. - Documented research decisions regarding Genkit orchestration and prompt composition. - Established tasks for phased implementation, including setup, foundational work, and user stories. - Implemented contract tests to ensure AI rename policies and ledger metadata are correctly applied. - Developed integration tests for validating AI rename flows, including preview, apply, and undo functionalities. - Added tooling to pin Genkit dependency for consistent builds.
This commit is contained in:
201
internal/ai/prompt/builder.go
Normal file
201
internal/ai/prompt/builder.go
Normal file
@@ -0,0 +1,201 @@
|
||||
package prompt
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const defaultMaxSamples = 10
|
||||
|
||||
// SequenceRule captures the numbering instructions forwarded to the AI.
|
||||
type SequenceRule struct {
|
||||
Style string
|
||||
Width int
|
||||
Start int
|
||||
Separator string
|
||||
}
|
||||
|
||||
// PolicyConfig enumerates naming policy directives for the AI prompt.
|
||||
type PolicyConfig struct {
|
||||
Prefix string
|
||||
Casing string
|
||||
AllowSpaces bool
|
||||
KeepOriginalOrder bool
|
||||
ForbiddenTokens []string
|
||||
}
|
||||
|
||||
// SampleCandidate represents a traversal sample considered for inclusion in the prompt.
|
||||
type SampleCandidate struct {
|
||||
RelativePath string
|
||||
SizeBytes int64
|
||||
Depth int
|
||||
}
|
||||
|
||||
// BuildInput aggregates the contextual data required to assemble the AI prompt payload.
|
||||
type BuildInput struct {
|
||||
WorkingDir string
|
||||
Samples []SampleCandidate
|
||||
TotalCount int
|
||||
Sequence SequenceRule
|
||||
Policies PolicyConfig
|
||||
BannedTerms []string
|
||||
Metadata map[string]string
|
||||
}
|
||||
|
||||
// Builder constructs RenamePrompt payloads from traversal context.
|
||||
type Builder struct {
|
||||
maxSamples int
|
||||
clock func() time.Time
|
||||
}
|
||||
|
||||
// Option mutates builder configuration.
|
||||
type Option func(*Builder)
|
||||
|
||||
// WithMaxSamples overrides the number of sampled files emitted in the prompt (default 10).
|
||||
func WithMaxSamples(n int) Option {
|
||||
return func(b *Builder) {
|
||||
if n > 0 {
|
||||
b.maxSamples = n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithClock injects a deterministic clock for metadata generation.
|
||||
func WithClock(clock func() time.Time) Option {
|
||||
return func(b *Builder) {
|
||||
if clock != nil {
|
||||
b.clock = clock
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NewBuilder instantiates a Builder with default configuration.
|
||||
func NewBuilder(opts ...Option) *Builder {
|
||||
builder := &Builder{
|
||||
maxSamples: defaultMaxSamples,
|
||||
clock: time.Now().UTC,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(builder)
|
||||
}
|
||||
return builder
|
||||
}
|
||||
|
||||
// Build produces a RenamePrompt populated with traversal context.
|
||||
func (b *Builder) Build(input BuildInput) (RenamePrompt, error) {
|
||||
if strings.TrimSpace(input.WorkingDir) == "" {
|
||||
return RenamePrompt{}, errors.New("prompt builder: working directory required")
|
||||
}
|
||||
if input.TotalCount <= 0 {
|
||||
return RenamePrompt{}, errors.New("prompt builder: total count must be positive")
|
||||
}
|
||||
if strings.TrimSpace(input.Sequence.Style) == "" {
|
||||
return RenamePrompt{}, errors.New("prompt builder: sequence style required")
|
||||
}
|
||||
if input.Sequence.Width <= 0 {
|
||||
return RenamePrompt{}, errors.New("prompt builder: sequence width must be positive")
|
||||
}
|
||||
if input.Sequence.Start <= 0 {
|
||||
return RenamePrompt{}, errors.New("prompt builder: sequence start must be positive")
|
||||
}
|
||||
if strings.TrimSpace(input.Policies.Casing) == "" {
|
||||
return RenamePrompt{}, errors.New("prompt builder: naming casing required")
|
||||
}
|
||||
|
||||
samples := make([]SampleCandidate, 0, len(input.Samples))
|
||||
for _, sample := range input.Samples {
|
||||
if strings.TrimSpace(sample.RelativePath) == "" {
|
||||
continue
|
||||
}
|
||||
samples = append(samples, sample)
|
||||
}
|
||||
|
||||
sort.Slice(samples, func(i, j int) bool {
|
||||
a := strings.ToLower(samples[i].RelativePath)
|
||||
b := strings.ToLower(samples[j].RelativePath)
|
||||
if a == b {
|
||||
return samples[i].RelativePath < samples[j].RelativePath
|
||||
}
|
||||
return a < b
|
||||
})
|
||||
|
||||
max := b.maxSamples
|
||||
if max <= 0 || max > len(samples) {
|
||||
max = len(samples)
|
||||
}
|
||||
|
||||
promptSamples := make([]PromptSample, 0, max)
|
||||
for i := 0; i < max; i++ {
|
||||
sample := samples[i]
|
||||
ext := filepath.Ext(sample.RelativePath)
|
||||
promptSamples = append(promptSamples, PromptSample{
|
||||
OriginalName: sample.RelativePath,
|
||||
Extension: ext,
|
||||
SizeBytes: sample.SizeBytes,
|
||||
PathDepth: sample.Depth,
|
||||
})
|
||||
}
|
||||
|
||||
banned := normalizeBannedTerms(input.BannedTerms)
|
||||
|
||||
metadata := make(map[string]string, len(input.Metadata)+1)
|
||||
for k, v := range input.Metadata {
|
||||
if strings.TrimSpace(k) == "" || strings.TrimSpace(v) == "" {
|
||||
continue
|
||||
}
|
||||
metadata[k] = v
|
||||
}
|
||||
metadata["generatedAt"] = b.clock().Format(time.RFC3339)
|
||||
|
||||
return RenamePrompt{
|
||||
WorkingDir: promptAbs(input.WorkingDir),
|
||||
Samples: promptSamples,
|
||||
TotalCount: input.TotalCount,
|
||||
SequenceRule: SequenceRuleConfig{
|
||||
Style: input.Sequence.Style,
|
||||
Width: input.Sequence.Width,
|
||||
Start: input.Sequence.Start,
|
||||
Separator: input.Sequence.Separator,
|
||||
},
|
||||
Policies: NamingPolicyConfig{
|
||||
Prefix: input.Policies.Prefix,
|
||||
Casing: input.Policies.Casing,
|
||||
AllowSpaces: input.Policies.AllowSpaces,
|
||||
KeepOriginalOrder: input.Policies.KeepOriginalOrder,
|
||||
ForbiddenTokens: append([]string(nil), input.Policies.ForbiddenTokens...),
|
||||
},
|
||||
BannedTerms: banned,
|
||||
Metadata: metadata,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func promptAbs(dir string) string {
|
||||
return strings.TrimSpace(dir)
|
||||
}
|
||||
|
||||
func normalizeBannedTerms(values []string) []string {
|
||||
unique := make(map[string]struct{})
|
||||
for _, value := range values {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
lower := strings.ToLower(trimmed)
|
||||
if lower == "" {
|
||||
continue
|
||||
}
|
||||
unique[lower] = struct{}{}
|
||||
}
|
||||
if len(unique) == 0 {
|
||||
return nil
|
||||
}
|
||||
terms := make([]string, 0, len(unique))
|
||||
for term := range unique {
|
||||
terms = append(terms, term)
|
||||
}
|
||||
sort.Strings(terms)
|
||||
return terms
|
||||
}
|
||||
Reference in New Issue
Block a user