Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
217 lines
6.1 KiB
Go
217 lines
6.1 KiB
Go
package processing
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// Local / weak-model defaults (8k-class context). See docs/local-llm-tuning.md.
|
|
const (
|
|
DefaultStructuredTemp = 0.2
|
|
MaxTokensEnhance = 350
|
|
MaxTokensSEO = 180
|
|
MaxTokensCampaign = 650
|
|
MaxProductDescRunes = 400
|
|
MaxAttrKeys = 10
|
|
MaxAttrValueRunes = 60
|
|
MaxBrandInjectRunes = 500
|
|
MaxCampaignProducts = 8
|
|
MaxCampaignNameRunes = 80
|
|
)
|
|
|
|
// CompleteOptions tunes a single chat completion for structured tasks.
|
|
type CompleteOptions struct {
|
|
MaxTokens int
|
|
Temperature float64 // 0 → client default (≤0.3 for structured)
|
|
}
|
|
|
|
// CompleterWithOptions is optional; OpenAIClient implements it.
|
|
type CompleterWithOptions interface {
|
|
CompleteWithOptions(ctx context.Context, system, user string, opts CompleteOptions) (Completion, error)
|
|
}
|
|
|
|
// CompleteOnce calls CompleterWithOptions when available, else Complete.
|
|
func CompleteOnce(ctx context.Context, c Completer, system, user string, opts CompleteOptions) (Completion, error) {
|
|
if c == nil {
|
|
return Completion{}, fmt.Errorf("completer not configured")
|
|
}
|
|
if co, ok := c.(CompleterWithOptions); ok {
|
|
return co.CompleteWithOptions(ctx, system, user, opts)
|
|
}
|
|
return c.Complete(ctx, system, user)
|
|
}
|
|
|
|
// StripJSONFences removes markdown code fences and isolates the outermost JSON object/array.
|
|
func StripJSONFences(text string) string {
|
|
text = strings.TrimSpace(text)
|
|
if text == "" {
|
|
return ""
|
|
}
|
|
text = strings.TrimPrefix(text, "```json")
|
|
text = strings.TrimPrefix(text, "```JSON")
|
|
text = strings.TrimPrefix(text, "```")
|
|
text = strings.TrimSuffix(text, "```")
|
|
text = strings.TrimSpace(text)
|
|
objAt := strings.Index(text, "{")
|
|
arrAt := strings.Index(text, "[")
|
|
// Prefer whichever structure appears first so array-of-objects is not sliced mid-stream.
|
|
if arrAt >= 0 && (objAt < 0 || arrAt < objAt) {
|
|
if j := strings.LastIndex(text, "]"); j > arrAt {
|
|
return strings.TrimSpace(text[arrAt : j+1])
|
|
}
|
|
}
|
|
if objAt >= 0 {
|
|
if j := strings.LastIndex(text, "}"); j > objAt {
|
|
return strings.TrimSpace(text[objAt : j+1])
|
|
}
|
|
}
|
|
return text
|
|
}
|
|
|
|
// ParseJSONObject parses a model reply into a JSON object (fence-tolerant).
|
|
// Some local/weak models wrap the payload in a one-element array; accept that
|
|
// by promoting the first object element (preferring name/description keys).
|
|
func ParseJSONObject(text string) (map[string]any, error) {
|
|
text = StripJSONFences(text)
|
|
if text == "" {
|
|
return nil, fmt.Errorf("empty json")
|
|
}
|
|
var obj map[string]any
|
|
objErr := json.Unmarshal([]byte(text), &obj)
|
|
if objErr == nil {
|
|
return obj, nil
|
|
}
|
|
var arr []any
|
|
if err := json.Unmarshal([]byte(text), &arr); err != nil {
|
|
return nil, objErr
|
|
}
|
|
if len(arr) == 0 {
|
|
return nil, fmt.Errorf("empty json array")
|
|
}
|
|
var fallback map[string]any
|
|
for _, el := range arr {
|
|
m, ok := el.(map[string]any)
|
|
if !ok || m == nil {
|
|
continue
|
|
}
|
|
if fallback == nil {
|
|
fallback = m
|
|
}
|
|
if _, hasName := m["name"]; hasName {
|
|
return m, nil
|
|
}
|
|
if _, hasDesc := m["description"]; hasDesc {
|
|
return m, nil
|
|
}
|
|
}
|
|
if fallback != nil {
|
|
return fallback, nil
|
|
}
|
|
return nil, fmt.Errorf("json array has no object elements")
|
|
}
|
|
|
|
// CompleteJSON runs a structured completion and retries once if JSON parse fails.
|
|
func CompleteJSON(ctx context.Context, c Completer, system, user string, opts CompleteOptions) (Completion, map[string]any, error) {
|
|
comp, err := CompleteOnce(ctx, c, system, user, opts)
|
|
if err != nil {
|
|
return Completion{}, nil, err
|
|
}
|
|
obj, err := ParseJSONObject(comp.Text)
|
|
if err == nil {
|
|
return comp, obj, nil
|
|
}
|
|
retryUser := user + "\n\nINVALID. Reply with ONLY one JSON object. No markdown, no prose."
|
|
comp2, err2 := CompleteOnce(ctx, c, system, retryUser, opts)
|
|
if err2 != nil {
|
|
return comp, nil, err2
|
|
}
|
|
obj2, err3 := ParseJSONObject(comp2.Text)
|
|
if err3 != nil {
|
|
comp2.PromptTokens += comp.PromptTokens
|
|
comp2.OutputTokens += comp.OutputTokens
|
|
comp2.TotalTokens += comp.TotalTokens
|
|
return comp2, nil, err3
|
|
}
|
|
comp2.PromptTokens += comp.PromptTokens
|
|
comp2.OutputTokens += comp.OutputTokens
|
|
comp2.TotalTokens += comp.TotalTokens
|
|
return comp2, obj2, nil
|
|
}
|
|
|
|
// CompactAttrs keeps title-relevant key attributes only (sorted keys, capped).
|
|
func CompactAttrs(attrs map[string]any, maxKeys int) map[string]any {
|
|
if len(attrs) == 0 {
|
|
return map[string]any{}
|
|
}
|
|
if maxKeys <= 0 {
|
|
maxKeys = MaxAttrKeys
|
|
}
|
|
keys := make([]string, 0, len(attrs))
|
|
for k := range attrs {
|
|
k = strings.TrimSpace(k)
|
|
if k == "" {
|
|
continue
|
|
}
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
// Prefer common retail keys first.
|
|
priority := []string{"brand", "Brand", "color", "Color", "material", "Material", "size", "Size", "model", "Model", "gtin", "GTIN", "ean", "EAN"}
|
|
ordered := make([]string, 0, len(keys))
|
|
seen := map[string]bool{}
|
|
for _, p := range priority {
|
|
for _, k := range keys {
|
|
if strings.EqualFold(k, p) && !seen[k] {
|
|
ordered = append(ordered, k)
|
|
seen[k] = true
|
|
}
|
|
}
|
|
}
|
|
for _, k := range keys {
|
|
if !seen[k] {
|
|
ordered = append(ordered, k)
|
|
}
|
|
}
|
|
if len(ordered) > maxKeys {
|
|
ordered = ordered[:maxKeys]
|
|
}
|
|
out := make(map[string]any, len(ordered))
|
|
for _, k := range ordered {
|
|
v := stringFromAny(attrs[k])
|
|
if v == "" {
|
|
continue
|
|
}
|
|
out[k] = truncateRunes(v, MaxAttrValueRunes)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// CompactBrandPrompt caps brand-kit injection for small context windows.
|
|
func CompactBrandPrompt(block string) string {
|
|
block = strings.TrimSpace(block)
|
|
if block == "" {
|
|
return ""
|
|
}
|
|
return truncateRunes(SanitizeText(block), MaxBrandInjectRunes)
|
|
}
|
|
|
|
// ProductEnhanceUser builds a short user prompt for title/description enhance.
|
|
func ProductEnhanceUser(category, name, description string, attrs map[string]any) string {
|
|
var b strings.Builder
|
|
b.WriteString("Category: ")
|
|
b.WriteString(SanitizeText(category))
|
|
b.WriteString("\nName: ")
|
|
b.WriteString(SanitizeText(truncateRunes(name, 200)))
|
|
b.WriteString("\nDesc: ")
|
|
b.WriteString(SanitizeText(truncateRunes(description, MaxProductDescRunes)))
|
|
compact := CompactAttrs(attrs, MaxAttrKeys)
|
|
if len(compact) > 0 {
|
|
b.WriteString("\nAttrs: ")
|
|
b.WriteString(sanitizeJSON(compact))
|
|
}
|
|
return b.String()
|
|
}
|