package compat
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"regexp"
"slices"
"strings"
"sync"
"time"
thoughttypes "git.warky.dev/wdevs/amcs/internal/types"
)
const metadataSystemPrompt = `You extract structured metadata from short notes.
Return only valid JSON matching this schema:
{
"people": ["string"],
"action_items": ["string"],
"dates_mentioned": ["string"],
"topics": ["string"],
"type": "observation|task|idea|reference|person_note",
"source": "string"
}
Rules:
- Keep arrays concise.
- Use lowercase for type.
- If unsure, prefer "observation".
- Do not include any text outside the JSON object.`
type Client struct {
name string
baseURL string
apiKey string
embeddingModel string
metadataModel string
fallbackMetadataModels []string
temperature float64
headers map[string]string
httpClient *http.Client
log *slog.Logger
dimensions int
logConversations bool
modelHealthMu sync.Mutex
modelHealth map[string]modelHealthState
}
type Config struct {
Name string
BaseURL string
APIKey string
EmbeddingModel string
MetadataModel string
FallbackMetadataModels []string
Temperature float64
Headers map[string]string
HTTPClient *http.Client
Log *slog.Logger
Dimensions int
LogConversations bool
}
type embeddingsRequest struct {
Input string `json:"input"`
Model string `json:"model"`
}
type embeddingsResponse struct {
Data []struct {
Embedding []float32 `json:"embedding"`
} `json:"data"`
Error *providerError `json:"error,omitempty"`
}
type chatCompletionsRequest struct {
Model string `json:"model"`
Temperature float64 `json:"temperature,omitempty"`
ResponseFormat *responseType `json:"response_format,omitempty"`
Stream *bool `json:"stream,omitempty"`
Messages []chatMessage `json:"messages"`
}
type responseType struct {
Type string `json:"type"`
}
type chatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type chatCompletionsResponse struct {
Choices []struct {
Message responseChatMessage `json:"message"`
Text string `json:"text,omitempty"`
} `json:"choices"`
Error *providerError `json:"error,omitempty"`
}
type chatCompletionsChunk struct {
Choices []struct {
Delta responseChatMessage `json:"delta"`
Message responseChatMessage `json:"message"`
Text string `json:"text,omitempty"`
} `json:"choices"`
Error *providerError `json:"error,omitempty"`
}
type responseChatMessage struct {
Role string `json:"role"`
Content json.RawMessage `json:"content"`
ReasoningContent string `json:"reasoning_content,omitempty"`
}
type providerError struct {
Message string `json:"message"`
Type string `json:"type,omitempty"`
}
const maxMetadataAttempts = 3
const (
emptyResponseCircuitThreshold = 3
emptyResponseCircuitTTL = 5 * time.Minute
permanentModelFailureTTL = 24 * time.Hour
)
var (
errMetadataEmptyResponse = errors.New("metadata empty response")
errMetadataNoJSONObject = errors.New("metadata response contains no JSON object")
)
type modelHealthState struct {
consecutiveEmpty int
unhealthyUntil time.Time
}
func New(cfg Config) *Client {
fallbacks := make([]string, 0, len(cfg.FallbackMetadataModels))
seen := make(map[string]struct{}, len(cfg.FallbackMetadataModels))
for _, model := range cfg.FallbackMetadataModels {
model = strings.TrimSpace(model)
if model == "" {
continue
}
if _, ok := seen[model]; ok {
continue
}
seen[model] = struct{}{}
fallbacks = append(fallbacks, model)
}
return &Client{
name: cfg.Name,
baseURL: cfg.BaseURL,
apiKey: cfg.APIKey,
embeddingModel: cfg.EmbeddingModel,
metadataModel: cfg.MetadataModel,
fallbackMetadataModels: fallbacks,
temperature: cfg.Temperature,
headers: cfg.Headers,
httpClient: cfg.HTTPClient,
log: cfg.Log,
dimensions: cfg.Dimensions,
logConversations: cfg.LogConversations,
modelHealth: make(map[string]modelHealthState),
}
}
func (c *Client) Embed(ctx context.Context, input string) ([]float32, error) {
input = strings.TrimSpace(input)
if input == "" {
return nil, fmt.Errorf("%s embed: input must not be empty", c.name)
}
var resp embeddingsResponse
err := c.doJSON(ctx, "/embeddings", embeddingsRequest{
Input: input,
Model: c.embeddingModel,
}, &resp)
if err != nil {
return nil, err
}
if resp.Error != nil {
return nil, fmt.Errorf("%s embed error: %s", c.name, resp.Error.Message)
}
if len(resp.Data) == 0 {
return nil, fmt.Errorf("%s embed: no embedding returned", c.name)
}
if c.dimensions > 0 && len(resp.Data[0].Embedding) != c.dimensions {
return nil, fmt.Errorf("%s embed: expected %d dimensions, got %d", c.name, c.dimensions, len(resp.Data[0].Embedding))
}
return resp.Data[0].Embedding, nil
}
func (c *Client) ExtractMetadata(ctx context.Context, input string) (thoughttypes.ThoughtMetadata, error) {
input = strings.TrimSpace(input)
if input == "" {
return thoughttypes.ThoughtMetadata{}, fmt.Errorf("%s extract metadata: input must not be empty", c.name)
}
start := time.Now()
if c.log != nil {
c.log.Info("metadata client started",
slog.String("provider", c.name),
slog.String("model", c.metadataModel),
)
}
logCompletion := func(model string, err error) {
if c.log == nil {
return
}
attrs := []any{
slog.String("provider", c.name),
slog.String("model", model),
slog.String("duration", formatLogDuration(time.Since(start))),
}
if err != nil {
attrs = append(attrs, slog.String("error", err.Error()))
c.log.Error("metadata client completed", attrs...)
return
}
c.log.Info("metadata client completed", attrs...)
}
result, err := c.extractMetadataWithModel(ctx, input, c.metadataModel)
if errors.Is(err, errMetadataEmptyResponse) {
c.noteEmptyResponse(c.metadataModel)
}
if isPermanentModelError(err) {
c.notePermanentModelFailure(c.metadataModel, err)
}
if err == nil {
c.noteModelSuccess(c.metadataModel)
logCompletion(c.metadataModel, nil)
return result, nil
}
for _, fallbackModel := range c.fallbackMetadataModels {
if ctx.Err() != nil {
break
}
if fallbackModel == "" || fallbackModel == c.metadataModel {
continue
}
if c.shouldBypassModel(fallbackModel) {
continue
}
if c.log != nil {
c.log.Warn("metadata extraction failed, trying fallback model",
slog.String("provider", c.name),
slog.String("primary_model", c.metadataModel),
slog.String("fallback_model", fallbackModel),
slog.String("error", err.Error()),
)
}
fallbackResult, fallbackErr := c.extractMetadataWithModel(ctx, input, fallbackModel)
if errors.Is(fallbackErr, errMetadataEmptyResponse) {
c.noteEmptyResponse(fallbackModel)
}
if isPermanentModelError(fallbackErr) {
c.notePermanentModelFailure(fallbackModel, fallbackErr)
}
if fallbackErr == nil {
c.noteModelSuccess(fallbackModel)
logCompletion(fallbackModel, nil)
return fallbackResult, nil
}
err = fallbackErr
}
if ctx.Err() != nil {
err = fmt.Errorf("%s metadata: %w", c.name, ctx.Err())
logCompletion(c.metadataModel, err)
return thoughttypes.ThoughtMetadata{}, err
}
heuristic := heuristicMetadataFromInput(input)
if c.log != nil {
c.log.Warn("metadata extraction failed for all models, using heuristic fallback",
slog.String("provider", c.name),
slog.String("error", err.Error()),
)
}
logCompletion(c.metadataModel, nil)
return heuristic, nil
}
func formatLogDuration(d time.Duration) string {
if d < 0 {
d = -d
}
totalMilliseconds := d.Milliseconds()
minutes := totalMilliseconds / 60000
seconds := (totalMilliseconds / 1000) % 60
milliseconds := totalMilliseconds % 1000
return fmt.Sprintf("%02d:%02d:%03d", minutes, seconds, milliseconds)
}
func (c *Client) extractMetadataWithModel(ctx context.Context, input, model string) (thoughttypes.ThoughtMetadata, error) {
if c.shouldBypassModel(model) {
return thoughttypes.ThoughtMetadata{}, fmt.Errorf("%s metadata: model %q temporarily bypassed after repeated empty responses", c.name, model)
}
stream := true
req := chatCompletionsRequest{
Model: model,
Temperature: c.temperature,
ResponseFormat: &responseType{
Type: "json_object",
},
Stream: &stream,
Messages: []chatMessage{
{Role: "system", Content: metadataSystemPrompt},
{Role: "user", Content: input},
},
}
metadata, err := c.extractMetadataWithRequest(ctx, req, input, model)
if err == nil || !shouldRetryWithoutJSONMode(err) {
return metadata, err
}
if c.log != nil {
c.log.Warn("metadata json mode failed, retrying without response_format",
slog.String("provider", c.name),
slog.String("model", model),
slog.String("error", err.Error()),
)
}
req.ResponseFormat = nil
return c.extractMetadataWithRequest(ctx, req, input, model)
}
func (c *Client) extractMetadataWithRequest(ctx context.Context, req chatCompletionsRequest, input, model string) (thoughttypes.ThoughtMetadata, error) {
var lastErr error
for attempt := 1; attempt <= maxMetadataAttempts; attempt++ {
if c.logConversations && c.log != nil {
c.log.Info("metadata conversation request",
slog.String("provider", c.name),
slog.String("model", model),
slog.Int("attempt", attempt),
slog.String("system", metadataSystemPrompt),
slog.String("input", input),
)
}
resp, err := c.doChatCompletions(ctx, req)
if err != nil {
return thoughttypes.ThoughtMetadata{}, err
}
if resp.Error != nil {
return thoughttypes.ThoughtMetadata{}, fmt.Errorf("%s metadata error: %s", c.name, resp.Error.Message)
}
if len(resp.Choices) == 0 {
return thoughttypes.ThoughtMetadata{}, fmt.Errorf("%s metadata: no choices returned", c.name)
}
rawResponse := extractChoiceText(resp.Choices[0].Message, resp.Choices[0].Text)
if c.logConversations && c.log != nil {
c.log.Info("metadata conversation response",
slog.String("provider", c.name),
slog.String("model", model),
slog.Int("attempt", attempt),
slog.String("response", rawResponse),
)
}
metadataText := strings.TrimSpace(rawResponse)
metadataText = stripThinkingBlocks(metadataText)
metadataText = stripCodeFence(metadataText)
metadataText = extractJSONObject(metadataText)
if metadataText == "" {
lastErr = fmt.Errorf("%s metadata: %w", c.name, errMetadataNoJSONObject)
if strings.TrimSpace(rawResponse) == "" && attempt < maxMetadataAttempts && ctx.Err() == nil {
lastErr = fmt.Errorf("%s metadata: %w", c.name, errMetadataEmptyResponse)
if c.log != nil {
c.log.Warn("metadata response empty, waiting and retrying",
slog.String("provider", c.name),
slog.String("model", model),
slog.Int("attempt", attempt+1),
)
}
if err := sleepMetadataRetry(ctx, attempt); err != nil {
return thoughttypes.ThoughtMetadata{}, err
}
continue
}
if strings.TrimSpace(rawResponse) == "" {
lastErr = fmt.Errorf("%s metadata: %w", c.name, errMetadataEmptyResponse)
}
return thoughttypes.ThoughtMetadata{}, lastErr
}
var metadata thoughttypes.ThoughtMetadata
if err := json.Unmarshal([]byte(metadataText), &metadata); err != nil {
lastErr = fmt.Errorf("%s metadata: parse json: %w", c.name, err)
return thoughttypes.ThoughtMetadata{}, lastErr
}
return metadata, nil
}
if lastErr != nil {
return thoughttypes.ThoughtMetadata{}, lastErr
}
return thoughttypes.ThoughtMetadata{}, fmt.Errorf("%s metadata: %w", c.name, errMetadataNoJSONObject)
}
func (c *Client) Summarize(ctx context.Context, systemPrompt, userPrompt string) (string, error) {
req := chatCompletionsRequest{
Model: c.metadataModel,
Temperature: 0.2,
Messages: []chatMessage{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: userPrompt},
},
}
var resp chatCompletionsResponse
if err := c.doJSON(ctx, "/chat/completions", req, &resp); err != nil {
return "", err
}
if resp.Error != nil {
return "", fmt.Errorf("%s summarize error: %s", c.name, resp.Error.Message)
}
if len(resp.Choices) == 0 {
return "", fmt.Errorf("%s summarize: no choices returned", c.name)
}
return extractChoiceText(resp.Choices[0].Message, resp.Choices[0].Text), nil
}
func (c *Client) Name() string {
return c.name
}
func (c *Client) EmbeddingModel() string {
return c.embeddingModel
}
func (c *Client) doJSON(ctx context.Context, path string, requestBody any, dest any) error {
body, err := json.Marshal(requestBody)
if err != nil {
return fmt.Errorf("%s request marshal: %w", c.name, err)
}
const maxAttempts = 3
var lastErr error
for attempt := 1; attempt <= maxAttempts; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(c.baseURL, "/")+path, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("%s build request: %w", c.name, err)
}
req.Header.Set("Authorization", "Bearer "+c.apiKey)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
for key, value := range c.headers {
if strings.TrimSpace(key) == "" || strings.TrimSpace(value) == "" {
continue
}
req.Header.Set(key, value)
}
resp, err := c.httpClient.Do(req)
if err != nil {
lastErr = fmt.Errorf("%s request failed: %w", c.name, err)
if attempt < maxAttempts && ctx.Err() == nil && isRetryableError(err) {
if retryErr := sleepRetry(ctx, attempt, c.log, c.name); retryErr != nil {
return retryErr
}
continue
}
return lastErr
}
payload, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
lastErr = fmt.Errorf("%s read response: %w", c.name, readErr)
if attempt < maxAttempts {
if retryErr := sleepRetry(ctx, attempt, c.log, c.name); retryErr != nil {
return retryErr
}
continue
}
return lastErr
}
if resp.StatusCode >= http.StatusBadRequest {
lastErr = fmt.Errorf("%s request failed with status %d: %s", c.name, resp.StatusCode, strings.TrimSpace(string(payload)))
if attempt < maxAttempts && isRetryableStatus(resp.StatusCode) {
if retryErr := sleepRetry(ctx, attempt, c.log, c.name); retryErr != nil {
return retryErr
}
continue
}
return lastErr
}
if err := json.Unmarshal(payload, dest); err != nil {
if c.log != nil {
c.log.Debug("provider response body", slog.String("provider", c.name), slog.String("body", string(payload)))
}
return fmt.Errorf("%s decode response: %w", c.name, err)
}
return nil
}
return lastErr
}
func (c *Client) doChatCompletions(ctx context.Context, reqBody chatCompletionsRequest) (chatCompletionsResponse, error) {
var resp chatCompletionsResponse
body, err := json.Marshal(reqBody)
if err != nil {
return resp, fmt.Errorf("%s request marshal: %w", c.name, err)
}
const maxAttempts = 3
var lastErr error
for attempt := 1; attempt <= maxAttempts; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(c.baseURL, "/")+"/chat/completions", bytes.NewReader(body))
if err != nil {
return resp, fmt.Errorf("%s build request: %w", c.name, err)
}
req.Header.Set("Authorization", "Bearer "+c.apiKey)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/event-stream")
for key, value := range c.headers {
if strings.TrimSpace(key) == "" || strings.TrimSpace(value) == "" {
continue
}
req.Header.Set(key, value)
}
httpResp, err := c.httpClient.Do(req)
if err != nil {
lastErr = fmt.Errorf("%s request failed: %w", c.name, err)
if attempt < maxAttempts && ctx.Err() == nil && isRetryableError(err) {
if retryErr := sleepRetry(ctx, attempt, c.log, c.name); retryErr != nil {
return resp, retryErr
}
continue
}
return resp, lastErr
}
resp, err = c.decodeChatCompletionsResponse(httpResp)
if err == nil {
return resp, nil
}
lastErr = err
if attempt < maxAttempts && (ctx.Err() == nil) && isRetryableChatResponseError(err) {
if retryErr := sleepRetry(ctx, attempt, c.log, c.name); retryErr != nil {
return resp, retryErr
}
continue
}
return resp, lastErr
}
return resp, lastErr
}
func (c *Client) decodeChatCompletionsResponse(resp *http.Response) (chatCompletionsResponse, error) {
defer resp.Body.Close()
contentType := strings.ToLower(resp.Header.Get("Content-Type"))
if strings.Contains(contentType, "text/event-stream") {
streamResp, err := decodeStreamingChatCompletionsResponse(resp.Body)
if err != nil {
return chatCompletionsResponse{}, fmt.Errorf("%s read stream response: %w", c.name, err)
}
if resp.StatusCode >= http.StatusBadRequest {
if streamResp.Error != nil {
return chatCompletionsResponse{}, fmt.Errorf("%s request failed with status %d: %s", c.name, resp.StatusCode, streamResp.Error.Message)
}
return chatCompletionsResponse{}, fmt.Errorf("%s request failed with status %d", c.name, resp.StatusCode)
}
return streamResp, nil
}
payload, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return chatCompletionsResponse{}, fmt.Errorf("%s read response: %w", c.name, readErr)
}
if resp.StatusCode >= http.StatusBadRequest {
return chatCompletionsResponse{}, fmt.Errorf("%s request failed with status %d: %s", c.name, resp.StatusCode, strings.TrimSpace(string(payload)))
}
var decoded chatCompletionsResponse
if err := json.Unmarshal(payload, &decoded); err != nil {
if c.log != nil {
c.log.Debug("provider response body", slog.String("provider", c.name), slog.String("body", string(payload)))
}
return chatCompletionsResponse{}, fmt.Errorf("%s decode response: %w", c.name, err)
}
return decoded, nil
}
func decodeStreamingChatCompletionsResponse(body io.Reader) (chatCompletionsResponse, error) {
scanner := bufio.NewScanner(body)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
var eventLines []string
var textBuilder strings.Builder
var lastMessage responseChatMessage
var streamErr *providerError
flushEvent := func() error {
if len(eventLines) == 0 {
return nil
}
payload := strings.Join(eventLines, "\n")
eventLines = eventLines[:0]
payload = strings.TrimSpace(payload)
if payload == "" {
return nil
}
if payload == "[DONE]" {
return nil
}
var chunk chatCompletionsChunk
if err := json.Unmarshal([]byte(payload), &chunk); err != nil {
return err
}
if chunk.Error != nil {
streamErr = chunk.Error
}
for _, choice := range chunk.Choices {
if text := extractChoiceText(choice.Delta, choice.Text); text != "" {
textBuilder.WriteString(text)
lastMessage = choice.Delta
continue
}
if text := extractChoiceText(choice.Message, choice.Text); text != "" {
textBuilder.WriteString(text)
lastMessage = choice.Message
continue
}
if len(choice.Message.Content) > 0 || choice.Message.ReasoningContent != "" {
lastMessage = choice.Message
}
}
return nil
}
for scanner.Scan() {
line := scanner.Text()
if line == "" {
if err := flushEvent(); err != nil {
return chatCompletionsResponse{}, err
}
continue
}
if strings.HasPrefix(line, ":") {
continue
}
if strings.HasPrefix(line, "data:") {
eventLines = append(eventLines, strings.TrimSpace(strings.TrimPrefix(line, "data:")))
}
}
if err := scanner.Err(); err != nil {
return chatCompletionsResponse{}, err
}
if err := flushEvent(); err != nil {
return chatCompletionsResponse{}, err
}
content := textBuilder.String()
if content != "" || len(lastMessage.Content) > 0 || lastMessage.ReasoningContent != "" {
encoded, _ := json.Marshal(content)
lastMessage.Content = json.RawMessage(encoded)
return chatCompletionsResponse{
Choices: []struct {
Message responseChatMessage `json:"message"`
Text string `json:"text,omitempty"`
}{
{Message: lastMessage, Text: content},
},
Error: streamErr,
}, nil
}
return chatCompletionsResponse{Error: streamErr}, nil
}
func isRetryableChatResponseError(err error) bool {
if err == nil {
return false
}
if isRetryableError(err) {
return true
}
lower := strings.ToLower(err.Error())
return strings.Contains(lower, "read response") || strings.Contains(lower, "read stream response")
}
// extractJSONObject finds the first complete {...} block in s.
// It handles models that prepend prose to a JSON response despite json_object mode.
func extractJSONObject(s string) string {
for start := 0; start < len(s); start++ {
if s[start] != '{' {
continue
}
depth := 0
inString := false
escaped := false
for end := start; end < len(s); end++ {
ch := s[end]
if escaped {
escaped = false
continue
}
if ch == '\\' && inString {
escaped = true
continue
}
if ch == '"' {
inString = !inString
continue
}
if inString {
continue
}
switch ch {
case '{':
depth++
case '}':
depth--
if depth == 0 {
candidate := s[start : end+1]
if json.Valid([]byte(candidate)) {
return candidate
}
}
}
}
}
return ""
}
// stripThinkingBlocks removes ... and ...
// blocks produced by reasoning models (DeepSeek R1, QwQ, etc.) so that the
// remaining text can be parsed as JSON without interference from thinking content
// that may itself contain braces.
func stripThinkingBlocks(s string) string {
for _, tag := range []string{"think", "thinking"} {
open := "<" + tag + ">"
close := "" + tag + ">"
for {
start := strings.Index(s, open)
if start == -1 {
break
}
end := strings.Index(s[start:], close)
if end == -1 {
s = s[:start]
break
}
s = s[:start] + s[start+end+len(close):]
}
}
return strings.TrimSpace(s)
}
func stripCodeFence(value string) string {
value = strings.TrimSpace(value)
if !strings.HasPrefix(value, "```") {
return value
}
value = strings.TrimPrefix(value, "```json")
value = strings.TrimPrefix(value, "```")
value = strings.TrimSuffix(value, "```")
return strings.TrimSpace(value)
}
func extractChoiceText(message responseChatMessage, fallbackText string) string {
if text := strings.TrimSpace(extractMessageContent(message.Content)); text != "" {
return text
}
if text := strings.TrimSpace(message.ReasoningContent); text != "" {
return text
}
return strings.TrimSpace(fallbackText)
}
func extractMessageContent(raw json.RawMessage) string {
raw = bytes.TrimSpace(raw)
if len(raw) == 0 {
return ""
}
var contentString string
if err := json.Unmarshal(raw, &contentString); err == nil {
return contentString
}
var contentArray []any
if err := json.Unmarshal(raw, &contentArray); err == nil {
parts := make([]string, 0, len(contentArray))
for _, item := range contentArray {
if text := strings.TrimSpace(extractTextFromAny(item)); text != "" {
parts = append(parts, text)
}
}
return strings.Join(parts, "\n")
}
var contentObject map[string]any
if err := json.Unmarshal(raw, &contentObject); err == nil {
return extractTextFromAny(contentObject)
}
return strings.TrimSpace(string(raw))
}
func extractTextFromAny(value any) string {
switch typed := value.(type) {
case string:
return typed
case []any:
parts := make([]string, 0, len(typed))
for _, item := range typed {
if text := strings.TrimSpace(extractTextFromAny(item)); text != "" {
parts = append(parts, text)
}
}
return strings.Join(parts, "\n")
case map[string]any:
// Common provider shapes for chat content parts.
for _, key := range []string{"text", "output_text", "content", "value"} {
if nested, ok := typed[key]; ok {
if text := strings.TrimSpace(extractTextFromAny(nested)); text != "" {
return text
}
}
}
}
return ""
}
var (
monthDatePattern = regexp.MustCompile(`(?i)\b\d{1,2}\s+(?:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)\s+\d{4}\b`)
isoDatePattern = regexp.MustCompile(`\b\d{4}-\d{2}-\d{2}\b`)
wordPattern = regexp.MustCompile(`[a-zA-Z][a-zA-Z0-9_/-]{2,}`)
)
func heuristicMetadataFromInput(input string) thoughttypes.ThoughtMetadata {
text := strings.TrimSpace(input)
lower := strings.ToLower(text)
metadata := thoughttypes.ThoughtMetadata{
People: heuristicPeople(text),
ActionItems: heuristicActionItems(text),
DatesMentioned: heuristicDates(text),
Topics: heuristicTopics(lower),
Type: heuristicType(lower),
Source: "",
}
if len(metadata.Topics) == 0 {
metadata.Topics = []string{"uncategorized"}
}
if metadata.Type == "" {
metadata.Type = "observation"
}
return metadata
}
func heuristicType(lower string) string {
switch {
case strings.Contains(lower, "preferred name"), strings.Contains(lower, "personal profile"), strings.Contains(lower, "wife:"), strings.Contains(lower, "daughter:"), strings.Contains(lower, "born:"):
return "person_note"
case strings.Contains(lower, "todo"), strings.Contains(lower, "action item"), strings.Contains(lower, "need to"), strings.Contains(lower, "must "), strings.Contains(lower, "should "):
return "task"
case strings.Contains(lower, "idea"), strings.Contains(lower, "proposal"), strings.Contains(lower, "brainstorm"):
return "idea"
case strings.Contains(lower, "reference"), strings.Contains(lower, "rfc "), strings.Contains(lower, "docs"), strings.Contains(lower, "spec"):
return "reference"
default:
return "observation"
}
}
func heuristicTopics(lower string) []string {
candidates := []string{
"mcp", "auth", "oauth", "api_keys", "token", "middleware", "postgres", "search", "embeddings", "metadata",
"go", "server", "project", "memory", "claude", "automation", "calendar", "email", "atlassian", "n8n",
}
topics := make([]string, 0, 6)
for _, topic := range candidates {
if strings.Contains(lower, topic) {
topics = append(topics, topic)
}
if len(topics) >= 6 {
break
}
}
if len(topics) > 0 {
return topics
}
words := wordPattern.FindAllString(lower, -1)
for _, w := range words {
if len(w) < 4 {
continue
}
if slices.Contains(topics, w) {
continue
}
topics = append(topics, w)
if len(topics) >= 4 {
break
}
}
return topics
}
func heuristicDates(text string) []string {
values := make([]string, 0, 4)
seen := map[string]struct{}{}
for _, match := range monthDatePattern.FindAllString(text, -1) {
key := strings.ToLower(match)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
values = append(values, match)
}
for _, match := range isoDatePattern.FindAllString(text, -1) {
key := strings.ToLower(match)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
values = append(values, match)
}
return values
}
func heuristicPeople(text string) []string {
lines := strings.Split(text, "\n")
people := make([]string, 0, 4)
seen := map[string]struct{}{}
add := func(name string) {
name = strings.TrimSpace(name)
if name == "" {
return
}
key := strings.ToLower(name)
if _, ok := seen[key]; ok {
return
}
seen[key] = struct{}{}
people = append(people, name)
}
for _, line := range lines {
l := strings.TrimSpace(line)
l = strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(l, "-"), "*"))
if strings.Contains(strings.ToLower(l), "preferred name") && strings.Contains(l, " is ") {
parts := strings.SplitN(l, " is ", 2)
add(parts[0])
}
for _, marker := range []string{"Wife:", "Daughter:", "Son:", "Partner:", "Name:"} {
if strings.HasPrefix(l, marker) {
rest := strings.TrimSpace(strings.TrimPrefix(l, marker))
if idx := strings.Index(rest, ","); idx > 0 {
rest = rest[:idx]
}
add(rest)
}
}
}
return people
}
func heuristicActionItems(text string) []string {
lines := strings.Split(text, "\n")
items := make([]string, 0, 5)
for _, line := range lines {
l := strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(line, "-"), "*"))
if l == "" {
continue
}
ll := strings.ToLower(l)
if strings.Contains(ll, "todo") || strings.HasPrefix(ll, "fix ") || strings.HasPrefix(ll, "add ") || strings.HasPrefix(ll, "update ") || strings.HasPrefix(ll, "implement ") {
items = append(items, l)
}
if len(items) >= 5 {
break
}
}
return items
}
func isRetryableStatus(status int) bool {
switch status {
case http.StatusTooManyRequests, http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout:
return true
default:
return false
}
}
func isRetryableError(err error) bool {
if err == nil {
return false
}
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
return true
}
var netErr net.Error
return errors.As(err, &netErr)
}
func shouldRetryWithoutJSONMode(err error) bool {
if err == nil {
return false
}
if errors.Is(err, errMetadataEmptyResponse) || errors.Is(err, errMetadataNoJSONObject) {
return true
}
lower := strings.ToLower(err.Error())
return strings.Contains(lower, "parse json")
}
func isPermanentModelError(err error) bool {
if err == nil {
return false
}
lower := strings.ToLower(err.Error())
for _, marker := range []string{
"invalid model name",
"model_not_found",
"model not found",
"unknown model",
"no such model",
"does not exist",
} {
if strings.Contains(lower, marker) {
return true
}
}
return false
}
func sleepRetry(ctx context.Context, attempt int, log *slog.Logger, provider string) error {
delay := time.Duration(attempt*attempt) * 200 * time.Millisecond
if log != nil {
log.Warn("retrying provider request", slog.String("provider", provider), slog.Duration("delay", delay), slog.Int("attempt", attempt+1))
}
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
func sleepMetadataRetry(ctx context.Context, attempt int) error {
delay := time.Duration(attempt) * 350 * time.Millisecond
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
func (c *Client) shouldBypassModel(model string) bool {
c.modelHealthMu.Lock()
defer c.modelHealthMu.Unlock()
state, ok := c.modelHealth[model]
if !ok {
return false
}
return !state.unhealthyUntil.IsZero() && time.Now().Before(state.unhealthyUntil)
}
func (c *Client) noteEmptyResponse(model string) {
c.modelHealthMu.Lock()
defer c.modelHealthMu.Unlock()
state := c.modelHealth[model]
state.consecutiveEmpty++
if state.consecutiveEmpty >= emptyResponseCircuitThreshold {
state.unhealthyUntil = time.Now().Add(emptyResponseCircuitTTL)
if c.log != nil {
c.log.Warn("metadata model marked temporarily unhealthy after repeated empty responses",
slog.String("provider", c.name),
slog.String("model", model),
slog.Time("until", state.unhealthyUntil),
)
}
}
c.modelHealth[model] = state
}
func (c *Client) noteModelSuccess(model string) {
c.modelHealthMu.Lock()
defer c.modelHealthMu.Unlock()
delete(c.modelHealth, model)
}
func (c *Client) notePermanentModelFailure(model string, err error) {
c.modelHealthMu.Lock()
defer c.modelHealthMu.Unlock()
state := c.modelHealth[model]
state.consecutiveEmpty = emptyResponseCircuitThreshold
state.unhealthyUntil = time.Now().Add(permanentModelFailureTTL)
c.modelHealth[model] = state
if c.log != nil {
c.log.Warn("metadata model marked unhealthy after permanent failure",
slog.String("provider", c.name),
slog.String("model", model),
slog.String("error", err.Error()),
slog.Time("until", state.unhealthyUntil),
)
}
}