220 lines
6.3 KiB
Go
220 lines
6.3 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
|
||
// Reasoning-capable OpenAI-compatible models (e.g. code-fast / Qwen3) spend
|
||
// hundreds–thousands of tokens in reasoning_content before writing message.content.
|
||
// 350 capped mid-thought → empty content → "empty model response".
|
||
MaxTokensEnhance = 4096
|
||
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 == "" || isInvalidAttributeKey(k) {
|
||
continue
|
||
}
|
||
keys = append(keys, k)
|
||
}
|
||
sort.Strings(keys)
|
||
// Prefer common retail keys first.
|
||
priority := []string{"brand", "Brand", "product_model", "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()
|
||
}
|