Files
renamer/internal/ai/flow/rename_flow.go
2025-11-05 16:06:09 +08:00

198 lines
4.7 KiB
Go

package flow
import (
"context"
_ "embed"
"errors"
"fmt"
"path"
"sort"
"strings"
"text/template"
"unicode"
"github.com/firebase/genkit/go/core"
"github.com/firebase/genkit/go/genkit"
)
//go:embed prompt.tmpl
var promptTemplateSource string
var (
promptTemplate = template.Must(template.New("renameFlowPrompt").Parse(promptTemplateSource))
)
// RenameFlowInput mirrors the JSON payload passed into the Genkit flow.
type RenameFlowInput struct {
FileNames []string `json:"fileNames"`
UserPrompt string `json:"userPrompt"`
SequenceSeparator string `json:"sequenceSeparator,omitempty"`
}
// Validate ensures the flow input is well formed.
func (in *RenameFlowInput) Validate() error {
if in == nil {
return errors.New("rename flow input cannot be nil")
}
if len(in.FileNames) == 0 {
return errors.New("no file names provided to rename flow")
}
if len(in.FileNames) > 200 {
return fmt.Errorf("rename flow supports up to 200 files per invocation (received %d)", len(in.FileNames))
}
normalized := make([]string, len(in.FileNames))
for i, name := range in.FileNames {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
return fmt.Errorf("file name at index %d is empty", i)
}
normalized[i] = toSlash(trimmed)
}
// Ensure no duplicates to simplify downstream validation.
if dup := firstDuplicate(normalized); dup != "" {
return fmt.Errorf("duplicate file name %q detected in flow input", dup)
}
in.FileNames = normalized
sep := strings.TrimSpace(in.SequenceSeparator)
if sep == "" {
sep = "."
}
if strings.ContainsAny(sep, "/\\") {
return fmt.Errorf("sequence separator %q cannot contain path separators", sep)
}
if strings.ContainsAny(sep, "\n\r") {
return errors.New("sequence separator cannot contain newline characters")
}
in.SequenceSeparator = sep
return nil
}
// RenderPrompt materialises the prompt template for the provided input.
func RenderPrompt(input RenameFlowInput) (string, error) {
if err := input.Validate(); err != nil {
return "", err
}
var builder strings.Builder
if err := promptTemplate.Execute(&builder, input); err != nil {
return "", fmt.Errorf("render rename prompt: %w", err)
}
return builder.String(), nil
}
// Define registers the rename flow on the supplied Genkit instance.
func Define(g *genkit.Genkit) *core.Flow[*RenameFlowInput, *Output, struct{}] {
if g == nil {
panic("genkit instance cannot be nil")
}
return genkit.DefineFlow(g, "renameFlow", flowFn)
}
func flowFn(ctx context.Context, input *RenameFlowInput) (*Output, error) {
if err := input.Validate(); err != nil {
return nil, err
}
prefix := slugify(input.UserPrompt)
suggestions := make([]Suggestion, 0, len(input.FileNames))
dirCounters := make(map[string]int)
for _, name := range input.FileNames {
suggestion := deterministicSuggestion(name, prefix, dirCounters, input.SequenceSeparator)
suggestions = append(suggestions, Suggestion{
Original: name,
Suggested: suggestion,
})
}
sort.SliceStable(suggestions, func(i, j int) bool {
return suggestions[i].Original < suggestions[j].Original
})
return &Output{Suggestions: suggestions}, nil
}
func deterministicSuggestion(rel string, promptPrefix string, dirCounters map[string]int, separator string) string {
rel = toSlash(rel)
dir := path.Dir(rel)
if dir == "." {
dir = ""
}
base := path.Base(rel)
ext := path.Ext(base)
name := strings.TrimSuffix(base, ext)
sanitizedName := slugify(name)
candidate := sanitizedName
if promptPrefix != "" {
switch {
case candidate == "":
candidate = promptPrefix
default:
candidate = fmt.Sprintf("%s-%s", promptPrefix, candidate)
}
}
if candidate == "" {
candidate = "renamed"
}
counterKey := dir
dirCounters[counterKey]++
seq := dirCounters[counterKey]
sep := separator
if sep == "" {
sep = "."
}
numbered := fmt.Sprintf("%02d%s%s", seq, sep, candidate)
proposed := numbered + ext
if dir != "" {
return path.Join(dir, proposed)
}
return proposed
}
func slugify(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
var b strings.Builder
b.Grow(len(value))
lastHyphen := false
for _, r := range value {
switch {
case unicode.IsLetter(r) || unicode.IsDigit(r):
b.WriteRune(unicode.ToLower(r))
lastHyphen = false
case r == ' ' || r == '-' || r == '_' || r == '.':
if !lastHyphen && b.Len() > 0 {
b.WriteRune('-')
lastHyphen = true
}
}
}
result := strings.Trim(b.String(), "-")
return result
}
func toSlash(pathStr string) string {
return strings.ReplaceAll(pathStr, "\\", "/")
}
func firstDuplicate(values []string) string {
seen := make(map[string]struct{}, len(values))
for _, v := range values {
lower := strings.ToLower(v)
if _, exists := seen[lower]; exists {
return v
}
seen[lower] = struct{}{}
}
return ""
}