This commit is contained in:
2026-08-16 16:57:36 +02:00
parent 96f8c1115c
commit 532d439c41
106 changed files with 10147 additions and 494 deletions
+166 -12
View File
@@ -16,6 +16,7 @@ import (
"sync"
"time"
"github.com/descrybe/descrybe-v2/apps/api/internal/company"
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
"github.com/descrybe/descrybe-v2/apps/api/internal/security"
)
@@ -56,11 +57,14 @@ func NewOpenAIClient(apiKey, baseURL, model string, rpm, maxRetries int) *OpenAI
}
// Dial-time SSRF. Loopback when base URL is local; RFC1918 only in non-prod.
policy := openAIDialPolicy(baseURL)
// Header/body timeout: code-fast often exceeds 180s; 240s reduces false timeouts.
// Tradeoff: slower fail on hung upstream. Prefer synthesize fallback over infinite wait
// (RunSteps always synthesizes on timeout/error/empty — do not raise unboundedly).
return &OpenAIClient{
APIKey: apiKey,
BaseURL: strings.TrimRight(baseURL, "/"),
Model: model,
HTTPClient: security.SafeHTTPClientPolicy(60*time.Second, policy),
HTTPClient: security.SafeHTTPClientPolicy(240*time.Second, policy),
MinInterval: interval,
MaxRetries: maxRetries,
ModeLabel: AIProviderInternal,
@@ -112,16 +116,31 @@ func (c *OpenAIClient) Enabled() bool {
}
// ProviderModeLabel implements ProviderLabeler for analytics writes.
// Loopback / mock-llm base URLs must not report as managed "internal".
func (c *OpenAIClient) ProviderModeLabel() string {
if c == nil {
return AIProviderUnknown
}
if label := strings.TrimSpace(c.ModeLabel); label != "" {
label := strings.TrimSpace(c.ModeLabel)
if IsMockOrLoopbackBaseURL(c.BaseURL) && (label == "" || label == AIProviderInternal) {
return AIProviderCustom
}
if label != "" {
return label
}
return AIProviderInternal
}
// 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")
}
type chatRequest struct {
Model string `json:"model"`
Messages []chatMessage `json:"messages"`
@@ -137,8 +156,11 @@ type chatMessage struct {
type chatResponse struct {
Model string `json:"model"`
Choices []struct {
Message struct {
Content string `json:"content"`
FinishReason string `json:"finish_reason"`
Message struct {
Content json.RawMessage `json:"content"`
ReasoningContent string `json:"reasoning_content"`
Reasoning string `json:"reasoning"`
} `json:"message"`
} `json:"choices"`
Usage struct {
@@ -152,6 +174,12 @@ type chatResponse struct {
} `json:"error"`
}
var errEmptyModelResponse = errors.New("empty model response")
// maxTokensReasoningBudget is used when a capped completion returns empty
// content with finish_reason=length (reasoning models).
const maxTokensReasoningBudget = 4096
func (c *OpenAIClient) Complete(ctx context.Context, system, user string) (Completion, error) {
return c.CompleteWithOptions(ctx, system, user, CompleteOptions{})
}
@@ -169,6 +197,7 @@ func (c *OpenAIClient) CompleteWithOptions(ctx context.Context, system, user str
if temp > 0.3 {
temp = 0.3
}
maxTok := opts.MaxTokens
var lastErr error
for attempt := 0; attempt <= c.MaxRetries; attempt++ {
@@ -184,11 +213,15 @@ func (c *OpenAIClient) CompleteWithOptions(ctx context.Context, system, user str
if err := c.waitRate(ctx); err != nil {
return Completion{}, err
}
comp, retryable, err := c.doComplete(ctx, system, user, temp, opts.MaxTokens)
comp, retryable, err := c.doComplete(ctx, system, user, temp, maxTok)
if err == nil {
return comp, nil
}
lastErr = err
if errors.Is(err, errEmptyModelResponse) && maxTok > 0 && maxTok < maxTokensReasoningBudget {
maxTok = maxTokensReasoningBudget
retryable = true
}
if !retryable {
return Completion{}, err
}
@@ -386,11 +419,15 @@ func (c *OpenAIClient) doComplete(ctx context.Context, system, user string, temp
return Completion{}, false, errors.New(msg)
}
text := ""
finishReason := ""
if len(parsed.Choices) > 0 {
text = SanitizeOutput(parsed.Choices[0].Message.Content)
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))
}
if text == "" {
return Completion{}, false, errors.New("empty model response")
// Reasoning models often return empty content when max_tokens cuts mid-thought.
retryable := strings.EqualFold(finishReason, "length")
return Completion{}, retryable, errEmptyModelResponse
}
return Completion{
Text: text,
@@ -399,13 +436,61 @@ func (c *OpenAIClient) doComplete(ctx context.Context, system, user string, temp
TotalTokens: parsed.Usage.TotalTokens,
Model: parsed.Model,
Raw: map[string]any{
"model": parsed.Model,
"usage": parsed.Usage,
"status": res.StatusCode,
"model": parsed.Model,
"usage": parsed.Usage,
"status": res.StatusCode,
"finish_reason": finishReason,
},
}, false, nil
}
// 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 ""
}
// HeuristicCompleter is used when OpenAI is not configured (local/dev fallback).
type HeuristicCompleter struct{}
@@ -427,8 +512,8 @@ func (h HeuristicCompleter) Complete(_ context.Context, system, user string) (Co
name = "Product"
}
desc := labeledPromptValue(user, "desc:", "description:", "current description:")
if desc == "" {
desc = "Product description"
if desc == "" || descriptionEchoesTitle(desc, name) || isWeakPriorEnhanceDescription(desc, name) {
desc = inventHeuristicDescription(system, user, name)
}
b, _ := json.Marshal(map[string]string{"name": name, "description": desc})
text = string(b)
@@ -450,3 +535,72 @@ func (h HeuristicCompleter) Complete(_ context.Context, system, user string) (Co
Raw: map[string]any{"provider": "heuristic"},
}, nil
}
// 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:")
if isPromptLabelTitle(cat) {
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
}