fixes
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user