2026-08-09 22:47:43 +02:00
|
|
|
|
package processing
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"bytes"
|
|
|
|
|
|
"context"
|
|
|
|
|
|
"encoding/json"
|
|
|
|
|
|
"errors"
|
|
|
|
|
|
"fmt"
|
|
|
|
|
|
"io"
|
2026-08-16 18:59:29 +02:00
|
|
|
|
"log"
|
2026-08-09 22:47:43 +02:00
|
|
|
|
"math"
|
|
|
|
|
|
"math/rand"
|
|
|
|
|
|
"net"
|
|
|
|
|
|
"net/http"
|
|
|
|
|
|
"net/url"
|
|
|
|
|
|
"strings"
|
|
|
|
|
|
"sync"
|
|
|
|
|
|
"time"
|
|
|
|
|
|
|
2026-08-16 16:57:36 +02:00
|
|
|
|
"github.com/descrybe/descrybe-v2/apps/api/internal/company"
|
2026-08-09 22:47:43 +02:00
|
|
|
|
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
|
|
|
|
|
|
"github.com/descrybe/descrybe-v2/apps/api/internal/security"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// OpenAIClient calls an OpenAI-compatible Chat Completions API with rate limiting and retries.
|
|
|
|
|
|
type OpenAIClient struct {
|
|
|
|
|
|
APIKey string
|
|
|
|
|
|
BaseURL string
|
|
|
|
|
|
Model string
|
|
|
|
|
|
HTTPClient *http.Client
|
|
|
|
|
|
MinInterval time.Duration
|
|
|
|
|
|
MaxRetries int
|
|
|
|
|
|
// ModeLabel is recorded on products/jobs for analytics
|
|
|
|
|
|
// ("internal" | "popular:<name>" | "custom"). Defaults to internal.
|
|
|
|
|
|
ModeLabel string
|
|
|
|
|
|
|
|
|
|
|
|
mu sync.Mutex
|
|
|
|
|
|
lastCall time.Time
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const maxOpenAIRetries = 8
|
|
|
|
|
|
|
2026-08-16 18:59:29 +02:00
|
|
|
|
// openAIHTTPTimeout is the full-request limit for OpenAI-compatible chat/embeddings.
|
|
|
|
|
|
// Reasoning models (e.g. code-fast) often need 60–180s; 240s reduces false timeouts.
|
|
|
|
|
|
// Timeouts are not retried (see classifyOpenAITransportErr) so a hung upstream fails
|
|
|
|
|
|
// the item in ~4m instead of MaxRetries×240s of silent PROCESSING.
|
|
|
|
|
|
const openAIHTTPTimeout = 240 * time.Second
|
|
|
|
|
|
|
|
|
|
|
|
// openAIWaitLogInterval emits progress while HTTPClient.Do blocks (cancel is only
|
|
|
|
|
|
// checked between job items, so mid-call silence looks like a stuck job).
|
|
|
|
|
|
const openAIWaitLogInterval = 30 * time.Second
|
|
|
|
|
|
|
2026-08-09 22:47:43 +02:00
|
|
|
|
func NewOpenAIClient(apiKey, baseURL, model string, rpm, maxRetries int) *OpenAIClient {
|
|
|
|
|
|
if baseURL == "" {
|
|
|
|
|
|
baseURL = "https://api.openai.com/v1"
|
|
|
|
|
|
}
|
|
|
|
|
|
if model == "" {
|
|
|
|
|
|
model = "gpt-4o-mini"
|
|
|
|
|
|
}
|
|
|
|
|
|
if maxRetries <= 0 {
|
|
|
|
|
|
maxRetries = 3
|
|
|
|
|
|
} else if maxRetries > maxOpenAIRetries {
|
|
|
|
|
|
maxRetries = maxOpenAIRetries
|
|
|
|
|
|
}
|
|
|
|
|
|
interval := time.Duration(0)
|
|
|
|
|
|
if rpm > 0 {
|
|
|
|
|
|
interval = time.Minute / time.Duration(rpm)
|
|
|
|
|
|
}
|
|
|
|
|
|
// Dial-time SSRF. Loopback when base URL is local; RFC1918 only in non-prod.
|
|
|
|
|
|
policy := openAIDialPolicy(baseURL)
|
2026-08-16 18:59:29 +02:00
|
|
|
|
// Prefer synthesize fallback on timeout/error/empty (RunSteps) over raising this unboundedly.
|
2026-08-09 22:47:43 +02:00
|
|
|
|
return &OpenAIClient{
|
|
|
|
|
|
APIKey: apiKey,
|
|
|
|
|
|
BaseURL: strings.TrimRight(baseURL, "/"),
|
|
|
|
|
|
Model: model,
|
2026-08-16 18:59:29 +02:00
|
|
|
|
HTTPClient: security.SafeHTTPClientPolicy(openAIHTTPTimeout, policy),
|
2026-08-09 22:47:43 +02:00
|
|
|
|
MinInterval: interval,
|
|
|
|
|
|
MaxRetries: maxRetries,
|
|
|
|
|
|
ModeLabel: AIProviderInternal,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func openAIDialPolicy(baseURL string) security.DialPolicy {
|
|
|
|
|
|
if openAIBaseAllowsLoopback(baseURL) {
|
|
|
|
|
|
return security.DialPolicy{AllowLoopback: true}
|
|
|
|
|
|
}
|
|
|
|
|
|
if openAIBaseAllowsPrivate(baseURL) {
|
|
|
|
|
|
return security.DialPolicy{AllowLoopback: true, AllowPrivate: true}
|
|
|
|
|
|
}
|
|
|
|
|
|
return security.DialPolicy{}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func openAIBaseAllowsLoopback(baseURL string) bool {
|
|
|
|
|
|
u, err := url.Parse(baseURL)
|
|
|
|
|
|
if err != nil || u.Hostname() == "" {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
host := strings.ToLower(u.Hostname())
|
|
|
|
|
|
if host == "localhost" {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
ip := net.ParseIP(host)
|
|
|
|
|
|
return ip != nil && ip.IsLoopback()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// openAIBaseAllowsPrivate permits RFC1918/ULA literal OPENAI_BASE_URL hosts
|
|
|
|
|
|
// when APP_ENV is not production/prod (local LAN OpenAI-compatible proxies).
|
|
|
|
|
|
func openAIBaseAllowsPrivate(baseURL string) bool {
|
|
|
|
|
|
if config.IsProductionEnv() {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
u, err := url.Parse(baseURL)
|
|
|
|
|
|
if err != nil || u.Hostname() == "" {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
ip := net.ParseIP(strings.ToLower(u.Hostname()))
|
|
|
|
|
|
if ip == nil || ip.IsLinkLocalUnicast() {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
return ip.IsPrivate()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (c *OpenAIClient) Enabled() bool {
|
|
|
|
|
|
return c != nil && strings.TrimSpace(c.APIKey) != ""
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ProviderModeLabel implements ProviderLabeler for analytics writes.
|
2026-08-16 16:57:36 +02:00
|
|
|
|
// Loopback / mock-llm base URLs must not report as managed "internal".
|
2026-08-09 22:47:43 +02:00
|
|
|
|
func (c *OpenAIClient) ProviderModeLabel() string {
|
|
|
|
|
|
if c == nil {
|
|
|
|
|
|
return AIProviderUnknown
|
|
|
|
|
|
}
|
2026-08-16 16:57:36 +02:00
|
|
|
|
label := strings.TrimSpace(c.ModeLabel)
|
|
|
|
|
|
if IsMockOrLoopbackBaseURL(c.BaseURL) && (label == "" || label == AIProviderInternal) {
|
|
|
|
|
|
return AIProviderCustom
|
|
|
|
|
|
}
|
|
|
|
|
|
if label != "" {
|
2026-08-09 22:47:43 +02:00
|
|
|
|
return label
|
|
|
|
|
|
}
|
|
|
|
|
|
return AIProviderInternal
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-16 16:57:36 +02:00
|
|
|
|
// IsMockOrLoopbackBaseURL reports local mock / loopback OpenAI-compatible bases
|
|
|
|
|
|
// (e.g. mock-llm on 127.0.0.1:18767). Used for analytics ModeLabel accuracy.
|
|
|
|
|
|
func IsMockOrLoopbackBaseURL(baseURL string) bool {
|
|
|
|
|
|
if openAIBaseAllowsLoopback(baseURL) {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
lower := strings.ToLower(strings.TrimSpace(baseURL))
|
|
|
|
|
|
return strings.Contains(lower, "mock-llm")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-16 18:59:29 +02:00
|
|
|
|
// startWaitLogger logs request start and periodic progress while Do blocks.
|
|
|
|
|
|
// Call the returned stop with the Do error (or nil) when the request finishes.
|
|
|
|
|
|
func (c *OpenAIClient) startWaitLogger(op string, maxTokens int) func(error) {
|
|
|
|
|
|
if c == nil {
|
|
|
|
|
|
return func(error) {}
|
|
|
|
|
|
}
|
|
|
|
|
|
start := time.Now()
|
|
|
|
|
|
log.Printf("openai: request start op=%s model=%s base=%s max_tokens=%d timeout=%s",
|
|
|
|
|
|
op, c.Model, c.BaseURL, maxTokens, openAIHTTPTimeout)
|
|
|
|
|
|
done := make(chan struct{})
|
|
|
|
|
|
var once sync.Once
|
|
|
|
|
|
go func() {
|
|
|
|
|
|
t := time.NewTicker(openAIWaitLogInterval)
|
|
|
|
|
|
defer t.Stop()
|
|
|
|
|
|
for {
|
|
|
|
|
|
select {
|
|
|
|
|
|
case <-done:
|
|
|
|
|
|
return
|
|
|
|
|
|
case <-t.C:
|
|
|
|
|
|
log.Printf("openai: still waiting op=%s model=%s base=%s elapsed=%s timeout=%s",
|
|
|
|
|
|
op, c.Model, c.BaseURL, time.Since(start).Round(time.Second), openAIHTTPTimeout)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}()
|
|
|
|
|
|
return func(err error) {
|
|
|
|
|
|
once.Do(func() {
|
|
|
|
|
|
close(done)
|
|
|
|
|
|
elapsed := time.Since(start).Round(time.Millisecond)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
log.Printf("openai: request done op=%s model=%s elapsed=%s err=%s",
|
|
|
|
|
|
op, c.Model, elapsed, TruncateError(err))
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
log.Printf("openai: request done op=%s model=%s elapsed=%s ok=1", op, c.Model, elapsed)
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// isOpenAITimeoutErr reports HTTP client / context deadline timeouts.
|
|
|
|
|
|
// These must not be retried: each attempt costs openAIHTTPTimeout and leaves the job looking stuck.
|
|
|
|
|
|
func isOpenAITimeoutErr(err error) bool {
|
|
|
|
|
|
if err == nil {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
if errors.Is(err, context.DeadlineExceeded) {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
var ne net.Error
|
|
|
|
|
|
if errors.As(err, &ne) && ne.Timeout() {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
s := err.Error()
|
|
|
|
|
|
return strings.Contains(s, "Client.Timeout exceeded") ||
|
|
|
|
|
|
strings.Contains(s, "context deadline exceeded")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// classifyOpenAITransportErr maps Do/Read errors to (wrappedErr, retryable).
|
|
|
|
|
|
// Timeouts and cancel are fatal for this attempt chain; other network errors may retry.
|
|
|
|
|
|
func classifyOpenAITransportErr(model string, err error) (error, bool) {
|
|
|
|
|
|
if err == nil {
|
|
|
|
|
|
return nil, false
|
|
|
|
|
|
}
|
|
|
|
|
|
if errors.Is(err, context.Canceled) {
|
|
|
|
|
|
return err, false
|
|
|
|
|
|
}
|
|
|
|
|
|
if isOpenAITimeoutErr(err) {
|
|
|
|
|
|
return fmt.Errorf(
|
|
|
|
|
|
"openai timed out waiting for model %q after %s (not retrying; use a faster model or fix upstream latency): %w",
|
|
|
|
|
|
model, openAIHTTPTimeout, err), false
|
|
|
|
|
|
}
|
|
|
|
|
|
return err, true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-09 22:47:43 +02:00
|
|
|
|
type chatRequest struct {
|
|
|
|
|
|
Model string `json:"model"`
|
|
|
|
|
|
Messages []chatMessage `json:"messages"`
|
|
|
|
|
|
Temperature float64 `json:"temperature"`
|
|
|
|
|
|
MaxTokens int `json:"max_tokens,omitempty"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type chatMessage struct {
|
|
|
|
|
|
Role string `json:"role"`
|
|
|
|
|
|
Content string `json:"content"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type chatResponse struct {
|
|
|
|
|
|
Model string `json:"model"`
|
|
|
|
|
|
Choices []struct {
|
2026-08-16 16:57:36 +02:00
|
|
|
|
FinishReason string `json:"finish_reason"`
|
|
|
|
|
|
Message struct {
|
|
|
|
|
|
Content json.RawMessage `json:"content"`
|
|
|
|
|
|
ReasoningContent string `json:"reasoning_content"`
|
|
|
|
|
|
Reasoning string `json:"reasoning"`
|
2026-08-09 22:47:43 +02:00
|
|
|
|
} `json:"message"`
|
|
|
|
|
|
} `json:"choices"`
|
|
|
|
|
|
Usage struct {
|
|
|
|
|
|
PromptTokens int `json:"prompt_tokens"`
|
|
|
|
|
|
CompletionTokens int `json:"completion_tokens"`
|
|
|
|
|
|
TotalTokens int `json:"total_tokens"`
|
|
|
|
|
|
} `json:"usage"`
|
|
|
|
|
|
Error *struct {
|
|
|
|
|
|
Message string `json:"message"`
|
|
|
|
|
|
Type string `json:"type"`
|
|
|
|
|
|
} `json:"error"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-16 21:35:48 +02:00
|
|
|
|
var (
|
|
|
|
|
|
errEmptyModelResponse = errors.New("empty model response")
|
|
|
|
|
|
errLengthCappedResponse = errors.New("response truncated at max_tokens")
|
|
|
|
|
|
)
|
2026-08-16 16:57:36 +02:00
|
|
|
|
|
2026-08-16 21:35:48 +02:00
|
|
|
|
func isLengthBudgetErr(err error) bool {
|
|
|
|
|
|
return errors.Is(err, errEmptyModelResponse) || errors.Is(err, errLengthCappedResponse)
|
|
|
|
|
|
}
|
2026-08-16 16:57:36 +02:00
|
|
|
|
|
2026-08-09 22:47:43 +02:00
|
|
|
|
func (c *OpenAIClient) Complete(ctx context.Context, system, user string) (Completion, error) {
|
|
|
|
|
|
return c.CompleteWithOptions(ctx, system, user, CompleteOptions{})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (c *OpenAIClient) CompleteWithOptions(ctx context.Context, system, user string, opts CompleteOptions) (Completion, error) {
|
|
|
|
|
|
if !c.Enabled() {
|
|
|
|
|
|
return Completion{}, errors.New("openai api key not configured")
|
|
|
|
|
|
}
|
|
|
|
|
|
system = SanitizeText(system)
|
|
|
|
|
|
user = SanitizeText(user)
|
|
|
|
|
|
temp := opts.Temperature
|
|
|
|
|
|
if temp <= 0 {
|
|
|
|
|
|
temp = DefaultStructuredTemp
|
|
|
|
|
|
}
|
|
|
|
|
|
if temp > 0.3 {
|
|
|
|
|
|
temp = 0.3
|
|
|
|
|
|
}
|
2026-08-16 16:57:36 +02:00
|
|
|
|
maxTok := opts.MaxTokens
|
2026-08-09 22:47:43 +02:00
|
|
|
|
|
|
|
|
|
|
var lastErr error
|
|
|
|
|
|
for attempt := 0; attempt <= c.MaxRetries; attempt++ {
|
|
|
|
|
|
if attempt > 0 {
|
|
|
|
|
|
backoff := time.Duration(math.Pow(2, float64(attempt-1))) * 200 * time.Millisecond
|
|
|
|
|
|
jitter := time.Duration(rand.Intn(100)) * time.Millisecond
|
|
|
|
|
|
select {
|
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
|
return Completion{}, ctx.Err()
|
|
|
|
|
|
case <-time.After(backoff + jitter):
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := c.waitRate(ctx); err != nil {
|
|
|
|
|
|
return Completion{}, err
|
|
|
|
|
|
}
|
2026-08-16 16:57:36 +02:00
|
|
|
|
comp, retryable, err := c.doComplete(ctx, system, user, temp, maxTok)
|
2026-08-09 22:47:43 +02:00
|
|
|
|
if err == nil {
|
|
|
|
|
|
return comp, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
lastErr = err
|
2026-08-16 21:35:48 +02:00
|
|
|
|
if isLengthBudgetErr(err) {
|
|
|
|
|
|
if maxTok <= 0 || maxTok < MaxTokensEnhanceRetry {
|
|
|
|
|
|
prev := maxTok
|
|
|
|
|
|
if maxTok < MaxTokensEnhanceRetry {
|
|
|
|
|
|
maxTok = MaxTokensEnhanceRetry
|
2026-08-16 18:59:29 +02:00
|
|
|
|
}
|
2026-08-16 21:35:48 +02:00
|
|
|
|
log.Printf("openai: length-cap retry model=%s prev_max_tokens=%d next_max_tokens=%d err=%s",
|
|
|
|
|
|
c.Model, prev, maxTok, TruncateError(err))
|
2026-08-16 18:59:29 +02:00
|
|
|
|
retryable = true
|
|
|
|
|
|
} else {
|
2026-08-16 21:35:48 +02:00
|
|
|
|
// Already at enhance retry ceiling — another long call will not help.
|
2026-08-16 18:59:29 +02:00
|
|
|
|
return Completion{}, fmt.Errorf(
|
2026-08-16 21:35:48 +02:00
|
|
|
|
"openai length-capped at max_tokens=%d for model %q (prefer a faster non-reasoning product-enhance model): %w",
|
2026-08-16 18:59:29 +02:00
|
|
|
|
maxTok, c.Model, err)
|
|
|
|
|
|
}
|
2026-08-16 16:57:36 +02:00
|
|
|
|
}
|
2026-08-09 22:47:43 +02:00
|
|
|
|
if !retryable {
|
|
|
|
|
|
return Completion{}, err
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return Completion{}, fmt.Errorf("openai retries exhausted: %w", lastErr)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (c *OpenAIClient) waitRate(ctx context.Context) error {
|
|
|
|
|
|
c.mu.Lock()
|
|
|
|
|
|
if c.MinInterval <= 0 {
|
|
|
|
|
|
c.lastCall = time.Now()
|
|
|
|
|
|
c.mu.Unlock()
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
now := time.Now()
|
|
|
|
|
|
wait := c.MinInterval - now.Sub(c.lastCall)
|
|
|
|
|
|
if wait < 0 {
|
|
|
|
|
|
wait = 0
|
|
|
|
|
|
}
|
|
|
|
|
|
// Reserve the next slot under the lock so concurrent callers cannot both
|
|
|
|
|
|
// observe the same lastCall and bypass MinInterval.
|
|
|
|
|
|
c.lastCall = now.Add(wait)
|
|
|
|
|
|
c.mu.Unlock()
|
|
|
|
|
|
if wait > 0 {
|
|
|
|
|
|
select {
|
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
|
return ctx.Err()
|
|
|
|
|
|
case <-time.After(wait):
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type embeddingRequest struct {
|
|
|
|
|
|
Model string `json:"model"`
|
|
|
|
|
|
Input []string `json:"input"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type embeddingResponse struct {
|
|
|
|
|
|
Data []struct {
|
|
|
|
|
|
Embedding []float32 `json:"embedding"`
|
|
|
|
|
|
Index int `json:"index"`
|
|
|
|
|
|
} `json:"data"`
|
|
|
|
|
|
Error *struct {
|
|
|
|
|
|
Message string `json:"message"`
|
|
|
|
|
|
} `json:"error"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Embed implements Embedder via OpenAI-compatible POST /embeddings.
|
|
|
|
|
|
func (c *OpenAIClient) Embed(ctx context.Context, texts []string) ([][]float32, error) {
|
|
|
|
|
|
if !c.Enabled() {
|
|
|
|
|
|
return nil, errors.New("openai api key not configured")
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(texts) == 0 {
|
|
|
|
|
|
return nil, errors.New("empty embedding input")
|
|
|
|
|
|
}
|
|
|
|
|
|
clean := make([]string, 0, len(texts))
|
|
|
|
|
|
for _, t := range texts {
|
|
|
|
|
|
t = SanitizeText(t)
|
|
|
|
|
|
if t == "" {
|
|
|
|
|
|
return nil, errors.New("empty embedding input")
|
|
|
|
|
|
}
|
|
|
|
|
|
clean = append(clean, t)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var lastErr error
|
|
|
|
|
|
for attempt := 0; attempt <= c.MaxRetries; attempt++ {
|
|
|
|
|
|
if attempt > 0 {
|
|
|
|
|
|
backoff := time.Duration(math.Pow(2, float64(attempt-1))) * 200 * time.Millisecond
|
|
|
|
|
|
jitter := time.Duration(rand.Intn(100)) * time.Millisecond
|
|
|
|
|
|
select {
|
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
|
return nil, ctx.Err()
|
|
|
|
|
|
case <-time.After(backoff + jitter):
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := c.waitRate(ctx); err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
vecs, retryable, err := c.doEmbed(ctx, clean)
|
|
|
|
|
|
if err == nil {
|
|
|
|
|
|
return vecs, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
lastErr = err
|
|
|
|
|
|
if !retryable {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil, fmt.Errorf("openai embedding retries exhausted: %w", lastErr)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (c *OpenAIClient) doEmbed(ctx context.Context, texts []string) ([][]float32, bool, error) {
|
|
|
|
|
|
body, err := json.Marshal(embeddingRequest{Model: c.Model, Input: texts})
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, false, err
|
|
|
|
|
|
}
|
|
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/embeddings", bytes.NewReader(body))
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, false, err
|
|
|
|
|
|
}
|
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
|
req.Header.Set("Authorization", "Bearer "+c.APIKey)
|
|
|
|
|
|
|
2026-08-16 18:59:29 +02:00
|
|
|
|
stopWait := c.startWaitLogger("embeddings", 0)
|
2026-08-09 22:47:43 +02:00
|
|
|
|
res, err := c.HTTPClient.Do(req)
|
2026-08-16 18:59:29 +02:00
|
|
|
|
stopWait(err)
|
2026-08-09 22:47:43 +02:00
|
|
|
|
if err != nil {
|
2026-08-16 18:59:29 +02:00
|
|
|
|
wrapped, retryable := classifyOpenAITransportErr(c.Model, err)
|
|
|
|
|
|
return nil, retryable, wrapped
|
2026-08-09 22:47:43 +02:00
|
|
|
|
}
|
|
|
|
|
|
defer res.Body.Close()
|
|
|
|
|
|
raw, err := io.ReadAll(io.LimitReader(res.Body, 4<<20))
|
|
|
|
|
|
if err != nil {
|
2026-08-16 18:59:29 +02:00
|
|
|
|
wrapped, retryable := classifyOpenAITransportErr(c.Model, err)
|
|
|
|
|
|
return nil, retryable, wrapped
|
2026-08-09 22:47:43 +02:00
|
|
|
|
}
|
|
|
|
|
|
var parsed embeddingResponse
|
|
|
|
|
|
if err := json.Unmarshal(raw, &parsed); err != nil {
|
|
|
|
|
|
return nil, false, fmt.Errorf("openai embeddings decode: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if res.StatusCode == http.StatusTooManyRequests || res.StatusCode >= 500 {
|
|
|
|
|
|
msg := "rate limited or server error"
|
|
|
|
|
|
if parsed.Error != nil && parsed.Error.Message != "" {
|
|
|
|
|
|
msg = TruncateError(errors.New(parsed.Error.Message))
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil, true, errors.New(msg)
|
|
|
|
|
|
}
|
|
|
|
|
|
if res.StatusCode >= 400 {
|
|
|
|
|
|
msg := fmt.Sprintf("openai embeddings http %d", res.StatusCode)
|
|
|
|
|
|
if parsed.Error != nil && parsed.Error.Message != "" {
|
|
|
|
|
|
msg = TruncateError(errors.New(parsed.Error.Message))
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil, false, errors.New(msg)
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(parsed.Data) == 0 {
|
|
|
|
|
|
return nil, false, errors.New("empty embedding response")
|
|
|
|
|
|
}
|
|
|
|
|
|
out := make([][]float32, len(texts))
|
|
|
|
|
|
for _, row := range parsed.Data {
|
|
|
|
|
|
if row.Index < 0 || row.Index >= len(out) {
|
|
|
|
|
|
return nil, false, errors.New("embedding index out of range")
|
|
|
|
|
|
}
|
|
|
|
|
|
out[row.Index] = row.Embedding
|
|
|
|
|
|
}
|
|
|
|
|
|
for i, v := range out {
|
|
|
|
|
|
if len(v) == 0 {
|
|
|
|
|
|
return nil, false, fmt.Errorf("missing embedding at index %d", i)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return out, false, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (c *OpenAIClient) doComplete(ctx context.Context, system, user string, temperature float64, maxTokens int) (Completion, bool, error) {
|
|
|
|
|
|
reqBody := chatRequest{
|
|
|
|
|
|
Model: c.Model,
|
|
|
|
|
|
Messages: []chatMessage{
|
|
|
|
|
|
{Role: "system", Content: system},
|
|
|
|
|
|
{Role: "user", Content: user},
|
|
|
|
|
|
},
|
|
|
|
|
|
Temperature: temperature,
|
|
|
|
|
|
MaxTokens: maxTokens,
|
|
|
|
|
|
}
|
|
|
|
|
|
body, err := json.Marshal(reqBody)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return Completion{}, false, err
|
|
|
|
|
|
}
|
|
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/chat/completions", bytes.NewReader(body))
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return Completion{}, false, err
|
|
|
|
|
|
}
|
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
|
req.Header.Set("Authorization", "Bearer "+c.APIKey)
|
|
|
|
|
|
|
2026-08-16 18:59:29 +02:00
|
|
|
|
stopWait := c.startWaitLogger("chat/completions", maxTokens)
|
2026-08-09 22:47:43 +02:00
|
|
|
|
res, err := c.HTTPClient.Do(req)
|
|
|
|
|
|
if err != nil {
|
2026-08-16 21:35:48 +02:00
|
|
|
|
stopWait(err)
|
2026-08-16 18:59:29 +02:00
|
|
|
|
wrapped, retryable := classifyOpenAITransportErr(c.Model, err)
|
|
|
|
|
|
return Completion{}, retryable, wrapped
|
2026-08-09 22:47:43 +02:00
|
|
|
|
}
|
|
|
|
|
|
defer res.Body.Close()
|
|
|
|
|
|
raw, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
|
|
|
|
|
|
if err != nil {
|
2026-08-16 21:35:48 +02:00
|
|
|
|
stopWait(err)
|
2026-08-16 18:59:29 +02:00
|
|
|
|
wrapped, retryable := classifyOpenAITransportErr(c.Model, err)
|
|
|
|
|
|
return Completion{}, retryable, wrapped
|
2026-08-09 22:47:43 +02:00
|
|
|
|
}
|
|
|
|
|
|
var parsed chatResponse
|
|
|
|
|
|
if err := json.Unmarshal(raw, &parsed); err != nil {
|
2026-08-16 21:35:48 +02:00
|
|
|
|
decErr := fmt.Errorf("openai decode: %w", err)
|
|
|
|
|
|
stopWait(decErr)
|
|
|
|
|
|
return Completion{}, false, decErr
|
2026-08-09 22:47:43 +02:00
|
|
|
|
}
|
|
|
|
|
|
if res.StatusCode == http.StatusTooManyRequests || res.StatusCode >= 500 {
|
|
|
|
|
|
msg := "rate limited or server error"
|
|
|
|
|
|
if parsed.Error != nil && parsed.Error.Message != "" {
|
|
|
|
|
|
msg = TruncateError(errors.New(parsed.Error.Message))
|
|
|
|
|
|
}
|
2026-08-16 21:35:48 +02:00
|
|
|
|
httpErr := errors.New(msg)
|
|
|
|
|
|
stopWait(httpErr)
|
|
|
|
|
|
return Completion{}, true, httpErr
|
2026-08-09 22:47:43 +02:00
|
|
|
|
}
|
|
|
|
|
|
if res.StatusCode >= 400 {
|
|
|
|
|
|
msg := fmt.Sprintf("openai http %d", res.StatusCode)
|
|
|
|
|
|
if parsed.Error != nil && parsed.Error.Message != "" {
|
|
|
|
|
|
msg = TruncateError(errors.New(parsed.Error.Message))
|
|
|
|
|
|
}
|
2026-08-16 21:35:48 +02:00
|
|
|
|
httpErr := errors.New(msg)
|
|
|
|
|
|
stopWait(httpErr)
|
|
|
|
|
|
return Completion{}, false, httpErr
|
2026-08-09 22:47:43 +02:00
|
|
|
|
}
|
|
|
|
|
|
text := ""
|
2026-08-16 16:57:36 +02:00
|
|
|
|
finishReason := ""
|
2026-08-09 22:47:43 +02:00
|
|
|
|
if len(parsed.Choices) > 0 {
|
2026-08-16 16:57:36 +02:00
|
|
|
|
finishReason = strings.TrimSpace(parsed.Choices[0].FinishReason)
|
|
|
|
|
|
text = SanitizeOutput(choiceMessageText(parsed.Choices[0].Message.Content, parsed.Choices[0].Message.ReasoningContent, parsed.Choices[0].Message.Reasoning))
|
2026-08-09 22:47:43 +02:00
|
|
|
|
}
|
2026-08-16 21:35:48 +02:00
|
|
|
|
usagePrompt := parsed.Usage.PromptTokens
|
|
|
|
|
|
usageOut := parsed.Usage.CompletionTokens
|
|
|
|
|
|
usageTotal := parsed.Usage.TotalTokens
|
|
|
|
|
|
canBump := maxTokens <= 0 || maxTokens < MaxTokensEnhanceRetry
|
|
|
|
|
|
lengthCapped := strings.EqualFold(finishReason, "length")
|
2026-08-09 22:47:43 +02:00
|
|
|
|
if text == "" {
|
2026-08-16 16:57:36 +02:00
|
|
|
|
// Reasoning models often return empty content when max_tokens cuts mid-thought.
|
2026-08-16 18:59:29 +02:00
|
|
|
|
// Only retry when CompleteWithOptions can still raise max_tokens.
|
2026-08-16 21:35:48 +02:00
|
|
|
|
// Never log HTTP-ok as ok=1 when content is empty (prod looked "successful" at 30–272ms).
|
|
|
|
|
|
retryable := lengthCapped && canBump
|
|
|
|
|
|
emptyErr := fmt.Errorf("%w (finish_reason=%s max_tokens=%d prompt_tokens=%d completion_tokens=%d)",
|
|
|
|
|
|
errEmptyModelResponse, finishReason, maxTokens, usagePrompt, usageOut)
|
|
|
|
|
|
stopWait(emptyErr)
|
|
|
|
|
|
log.Printf("openai: chat content empty model=%s finish_reason=%s max_tokens=%d prompt_tokens=%d completion_tokens=%d total_tokens=%d retryable=%t",
|
|
|
|
|
|
c.Model, finishReason, maxTokens, usagePrompt, usageOut, usageTotal, retryable)
|
2026-08-16 16:57:36 +02:00
|
|
|
|
return Completion{}, retryable, errEmptyModelResponse
|
2026-08-09 22:47:43 +02:00
|
|
|
|
}
|
2026-08-16 21:35:48 +02:00
|
|
|
|
if lengthCapped {
|
|
|
|
|
|
// Truncated JSON was previously treated as success → parse_failed → synthesize.
|
|
|
|
|
|
// Accept only when the truncated body still parses as a JSON object.
|
|
|
|
|
|
if _, err := ParseJSONObject(text); err != nil {
|
|
|
|
|
|
retryable := canBump
|
|
|
|
|
|
truncErr := fmt.Errorf("%w (finish_reason=length max_tokens=%d prompt_tokens=%d completion_tokens=%d content_runes=%d)",
|
|
|
|
|
|
errLengthCappedResponse, maxTokens, usagePrompt, usageOut, len([]rune(text)))
|
|
|
|
|
|
stopWait(truncErr)
|
|
|
|
|
|
log.Printf("openai: chat content truncated model=%s finish_reason=length max_tokens=%d prompt_tokens=%d completion_tokens=%d total_tokens=%d content_runes=%d retryable=%t",
|
|
|
|
|
|
c.Model, maxTokens, usagePrompt, usageOut, usageTotal, len([]rune(text)), retryable)
|
|
|
|
|
|
return Completion{}, retryable, errLengthCappedResponse
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
stopWait(nil)
|
|
|
|
|
|
log.Printf("openai: chat content ok model=%s finish_reason=%s content_runes=%d prompt_tokens=%d completion_tokens=%d total_tokens=%d",
|
|
|
|
|
|
c.Model, finishReason, len([]rune(text)), usagePrompt, usageOut, usageTotal)
|
2026-08-09 22:47:43 +02:00
|
|
|
|
return Completion{
|
|
|
|
|
|
Text: text,
|
2026-08-16 21:35:48 +02:00
|
|
|
|
PromptTokens: usagePrompt,
|
|
|
|
|
|
OutputTokens: usageOut,
|
|
|
|
|
|
TotalTokens: usageTotal,
|
2026-08-09 22:47:43 +02:00
|
|
|
|
Model: parsed.Model,
|
|
|
|
|
|
Raw: map[string]any{
|
2026-08-16 16:57:36 +02:00
|
|
|
|
"model": parsed.Model,
|
|
|
|
|
|
"usage": parsed.Usage,
|
|
|
|
|
|
"status": res.StatusCode,
|
|
|
|
|
|
"finish_reason": finishReason,
|
2026-08-09 22:47:43 +02:00
|
|
|
|
},
|
|
|
|
|
|
}, false, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-16 16:57:36 +02:00
|
|
|
|
// choiceMessageText reads assistant text from OpenAI-compatible chat responses.
|
|
|
|
|
|
// Prefer message.content (string or multipart text parts). When empty — common for
|
|
|
|
|
|
// reasoning models that only fill reasoning_content under a tight max_tokens —
|
|
|
|
|
|
// fall back to reasoning fields and prefer an embedded JSON object when present.
|
|
|
|
|
|
func choiceMessageText(content json.RawMessage, reasoningContent, reasoning string) string {
|
|
|
|
|
|
if text := decodeChatContent(content); text != "" {
|
|
|
|
|
|
return text
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, alt := range []string{reasoningContent, reasoning} {
|
|
|
|
|
|
alt = strings.TrimSpace(alt)
|
|
|
|
|
|
if alt == "" {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if obj := StripJSONFences(alt); strings.HasPrefix(obj, "{") {
|
|
|
|
|
|
if _, err := ParseJSONObject(obj); err == nil {
|
|
|
|
|
|
return obj
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func decodeChatContent(raw json.RawMessage) string {
|
|
|
|
|
|
raw = bytes.TrimSpace(raw)
|
|
|
|
|
|
if len(raw) == 0 || string(raw) == "null" {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
var s string
|
|
|
|
|
|
if err := json.Unmarshal(raw, &s); err == nil {
|
|
|
|
|
|
return strings.TrimSpace(s)
|
|
|
|
|
|
}
|
|
|
|
|
|
var parts []struct {
|
|
|
|
|
|
Type string `json:"type"`
|
|
|
|
|
|
Text string `json:"text"`
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := json.Unmarshal(raw, &parts); err == nil {
|
|
|
|
|
|
var b strings.Builder
|
|
|
|
|
|
for _, p := range parts {
|
|
|
|
|
|
if strings.TrimSpace(p.Text) != "" {
|
|
|
|
|
|
b.WriteString(p.Text)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return strings.TrimSpace(b.String())
|
|
|
|
|
|
}
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-09 22:47:43 +02:00
|
|
|
|
// HeuristicCompleter is used when OpenAI is not configured (local/dev fallback).
|
|
|
|
|
|
type HeuristicCompleter struct{}
|
|
|
|
|
|
|
|
|
|
|
|
// ProviderModeLabel labels heuristic output for analytics (not a paid provider).
|
|
|
|
|
|
func (h HeuristicCompleter) ProviderModeLabel() string {
|
|
|
|
|
|
return AIProviderInternal
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (h HeuristicCompleter) Complete(_ context.Context, system, user string) (Completion, error) {
|
|
|
|
|
|
systemL := strings.ToLower(system)
|
|
|
|
|
|
user = SanitizeText(user)
|
|
|
|
|
|
text := "General"
|
|
|
|
|
|
switch {
|
2026-08-16 23:07:32 +02:00
|
|
|
|
case strings.Contains(systemL, "categoryid") || (strings.Contains(systemL, "categor") && strings.Contains(systemL, "available categories")):
|
|
|
|
|
|
// Taxonomy pick: return first Available Categories (ID: …) from the user prompt.
|
|
|
|
|
|
id := firstAvailableCategoryIDFromPrompt(user)
|
|
|
|
|
|
if id == "" {
|
|
|
|
|
|
id = "unknown"
|
|
|
|
|
|
}
|
|
|
|
|
|
b, _ := json.Marshal(map[string]any{"categoryId": id, "confidence": 0.5})
|
|
|
|
|
|
text = string(b)
|
2026-08-09 22:47:43 +02:00
|
|
|
|
case strings.Contains(systemL, `"name"`) || strings.Contains(systemL, "titles and descriptions"):
|
|
|
|
|
|
// Prefer explicit Name:/Desc: (ProductEnhanceUser) or Current name: labels.
|
|
|
|
|
|
// Never use firstLine(user) alone — that line is often "Category: …".
|
|
|
|
|
|
name := labeledPromptValue(user, "name:", "current name:")
|
|
|
|
|
|
if name == "" || isPromptLabelTitle(name) {
|
|
|
|
|
|
name = "Product"
|
|
|
|
|
|
}
|
|
|
|
|
|
desc := labeledPromptValue(user, "desc:", "description:", "current description:")
|
2026-08-16 16:57:36 +02:00
|
|
|
|
if desc == "" || descriptionEchoesTitle(desc, name) || isWeakPriorEnhanceDescription(desc, name) {
|
|
|
|
|
|
desc = inventHeuristicDescription(system, user, name)
|
2026-08-09 22:47:43 +02:00
|
|
|
|
}
|
2026-08-16 23:42:00 +02:00
|
|
|
|
payload := map[string]any{"name": name, "description": desc}
|
|
|
|
|
|
if strings.Contains(systemL, "meta_title") {
|
|
|
|
|
|
payload["meta_title"] = truncateRunes(name, 60)
|
|
|
|
|
|
payload["meta_description"] = truncateRunes(stripMetaTags(desc), 155)
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.Contains(systemL, `"attrs"`) {
|
|
|
|
|
|
if attrs := CompactAttrs(parseAttrsFromPrompt(user), MaxAttrKeys); len(attrs) > 0 {
|
|
|
|
|
|
payload["attrs"] = attrs
|
|
|
|
|
|
} else {
|
|
|
|
|
|
payload["attrs"] = map[string]any{}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
b, _ := json.Marshal(payload)
|
2026-08-09 22:47:43 +02:00
|
|
|
|
text = string(b)
|
|
|
|
|
|
case strings.Contains(systemL, "attributes") && strings.Contains(systemL, "json"):
|
|
|
|
|
|
text = `{"material":"unknown","brand":"unknown"}`
|
|
|
|
|
|
default:
|
|
|
|
|
|
// Never echo ProductEnhanceUser's first line ("Category: …") as title/output.
|
|
|
|
|
|
text = labeledPromptValue(user, "name:", "current name:")
|
|
|
|
|
|
if text == "" || isPromptLabelTitle(text) {
|
|
|
|
|
|
text = "ok"
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return Completion{
|
|
|
|
|
|
Text: text,
|
|
|
|
|
|
TotalTokens: 0,
|
|
|
|
|
|
Model: "heuristic",
|
|
|
|
|
|
Raw: map[string]any{"provider": "heuristic"},
|
|
|
|
|
|
}, nil
|
|
|
|
|
|
}
|
2026-08-16 16:57:36 +02:00
|
|
|
|
|
|
|
|
|
|
// parseAttrsFromPrompt extracts the Attrs:/Attributes: JSON object from an enhance user message.
|
|
|
|
|
|
func parseAttrsFromPrompt(user string) map[string]any {
|
|
|
|
|
|
lower := strings.ToLower(user)
|
|
|
|
|
|
label := "attrs:"
|
|
|
|
|
|
at := strings.Index(lower, label)
|
|
|
|
|
|
if at < 0 {
|
|
|
|
|
|
label = "attributes:"
|
|
|
|
|
|
at = strings.Index(lower, label)
|
|
|
|
|
|
if at < 0 {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
rest := strings.TrimSpace(user[at+len(label):])
|
|
|
|
|
|
if i := strings.Index(rest, "{"); i >= 0 {
|
|
|
|
|
|
rest = rest[i:]
|
|
|
|
|
|
if end := strings.Index(rest, "}"); end >= 0 {
|
|
|
|
|
|
rest = rest[:end+1]
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
rest = firstLine(rest)
|
|
|
|
|
|
}
|
|
|
|
|
|
var m map[string]any
|
|
|
|
|
|
if err := json.Unmarshal([]byte(rest), &m); err != nil || len(m) == 0 {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
return m
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// inventHeuristicDescription builds a short factual description from Category + Attrs
|
|
|
|
|
|
// (+ brand / model / dims) for thin inputs. Matches Slovenian vs English from the
|
|
|
|
|
|
// enhance system/user prompt language hint. Never returns sole retail-filler copy
|
|
|
|
|
|
// when a title or attributes exist.
|
|
|
|
|
|
func inventHeuristicDescription(system, user, name string) string {
|
|
|
|
|
|
name = strings.TrimSpace(name)
|
|
|
|
|
|
if name == "" || name == "<nil>" || isPromptLabelTitle(name) {
|
|
|
|
|
|
name = labeledPromptValue(user, "name:", "current name:")
|
|
|
|
|
|
}
|
|
|
|
|
|
if name == "" || isPromptLabelTitle(name) {
|
|
|
|
|
|
name = "Product"
|
|
|
|
|
|
}
|
|
|
|
|
|
cat := labeledPromptValue(user, "category:")
|
2026-08-16 21:35:48 +02:00
|
|
|
|
if isUnusableCategoryValue(cat, name) {
|
2026-08-16 16:57:36 +02:00
|
|
|
|
cat = ""
|
|
|
|
|
|
}
|
|
|
|
|
|
lang := languageCodeFromEnhancePrompt(system, user)
|
|
|
|
|
|
attrs := CompactAttrs(parseAttrsFromPrompt(user), MaxAttrKeys)
|
|
|
|
|
|
if out := synthesizeDescriptionFromTitle(name, cat, lang, attrs); out != "" {
|
|
|
|
|
|
return out
|
|
|
|
|
|
}
|
|
|
|
|
|
return SanitizeOutput(fmt.Sprintf("%s is a catalog product with the known attributes.", name))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func languageCodeFromEnhancePrompt(system, user string) string {
|
|
|
|
|
|
blob := strings.ToLower(system + "\n" + user)
|
|
|
|
|
|
for _, code := range company.ContentLanguages {
|
|
|
|
|
|
label := strings.ToLower(company.LanguageLabel(code))
|
|
|
|
|
|
if label == "" {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.Contains(blob, "in "+label) || strings.Contains(blob, "language: "+label) {
|
|
|
|
|
|
return code
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if code, err := company.ParseLanguage(labeledPromptValue(user, "language:", "content language:"), false); err == nil {
|
|
|
|
|
|
return code
|
|
|
|
|
|
}
|
|
|
|
|
|
return company.DefaultLanguage
|
|
|
|
|
|
}
|