18 lines
26 KiB
XML
18 lines
26 KiB
XML
<?xml version="1.0" encoding="UTF-8"?>
|
|
<project version="4">
|
|
<component name="CopilotDiffPersistence">
|
|
<option name="pendingDiffs">
|
|
<map>
|
|
<entry key="$PROJECT_DIR$/llm.go">
|
|
<value>
|
|
<PendingDiffInfo>
|
|
<option name="filePath" value="$PROJECT_DIR$/llm.go" />
|
|
<option name="originalContent" value="package main import ( 	"bytes" 	"context" 	"encoding/json" 	"fmt" 	"io" 	"net/http" 	"strings" 	"text/template" 	"github.com/sirupsen/logrus" ) // LLMClient abstracts LLM API calls type LLMClient struct { 	APIKey string 	BaseURL string 	Model string } // NewLLMClient constructs a new LLMClient with the given API key and base URL func NewLLMClient(apiKey, baseURL string, model string) *LLMClient { 	return &LLMClient{ 		APIKey: apiKey, 		BaseURL: baseURL, 		Model: model, 	} } // renderPrompt renders a Go template with the given data func renderPrompt(tmplStr string, data any) (string, error) { 	tmpl, err := template.New("").Parse(tmplStr) 	if err != nil { 		return "", err 	} 	var buf bytes.Buffer 	if err := tmpl.Execute(&buf, data); err != nil { 		return "", err 	} 	return buf.String(), nil } // ExtractKeywords calls LLM to extract keywords from user message func (llm *LLMClient) ExtractKeywords(ctx context.Context, message string) (map[string]interface{}, error) { 	prompt, err := renderPrompt(appConfig.LLM.ExtractKeywordsPrompt, map[string]string{"Message": message}) 	if err != nil { 		logrus.WithError(err).Error("[CONFIG] Failed to render ExtractKeywords prompt") 		return nil, err 	} 	logrus.WithField("prompt", prompt).Info("[LLM] ExtractKeywords prompt") 	format := map[string]interface{}{ 		"type": "object", 		"properties": map[string]interface{}{ 			"translate": map[string]interface{}{"type": "string"}, 			"keyword": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}}, 			"animal": map[string]interface{}{"type": "string"}, 		}, 		"required": []string{"translate", "keyword", "animal"}, 	} 	resp, err := llm.openAICompletion(ctx, prompt, format) 	logrus.WithFields(logrus.Fields{"response": resp, "err": err}).Info("[LLM] ExtractKeywords response") 	if err != nil { 		return nil, err 	} 	var result map[string]interface{} 	if err := json.Unmarshal([]byte(resp), &result); err != nil { 		return nil, err 	} 	return result, nil } // DisambiguateBestMatch calls LLM to pick best match from candidates func (llm *LLMClient) DisambiguateBestMatch(ctx context.Context, message string, candidates []Visit) (string, error) { 	format := map[string]interface{}{ 		"type": "object", 		"properties": map[string]interface{}{ 			"visitReason": map[string]interface{}{"type": "string"}, 		}, 		"required": []string{"visitReason"}, 	} 	entries, _ := json.Marshal(candidates) 	prompt, err := renderPrompt(appConfig.LLM.DisambiguatePrompt, map[string]string{"Entries": string(entries), "Message": message}) 	if err != nil { 		logrus.WithError(err).Error("[CONFIG] Failed to render Disambiguate prompt") 		return "", err 	} 	logrus.WithField("prompt", prompt).Info("[LLM] DisambiguateBestMatch prompt") 	resp, err := llm.openAICompletion(ctx, prompt, format) 	logrus.WithFields(logrus.Fields{"response": resp, "err": err}).Info("[LLM] DisambiguateBestMatch response") 	if err != nil { 		return "", err 	} 	var parsed map[string]string 	if err := json.Unmarshal([]byte(resp), &parsed); err != nil { 		return "", fmt.Errorf("failed to unmarshal disambiguation response: %w", err) 	} 	visitReason := strings.TrimSpace(parsed["visitReason"]) 	if visitReason == "" { 		return "", fmt.Errorf("visitReason not found in response") 	} 	return visitReason, nil } // openAICompletion now supports both Ollama (default local) and OpenRouter/OpenAI-compatible APIs without external branching. // It auto-detects by inspecting the BaseURL. If the URL contains "openrouter.ai" or "/v1/", it assumes OpenAI-style. func (llm *LLMClient) openAICompletion(ctx context.Context, prompt string, format map[string]interface{}) (string, error) { 	apiURL := llm.BaseURL 	if apiURL == "" { 		// Default to Ollama local chat endpoint 		apiURL = "http://localhost:11434/api/chat" 	} 	isOpenAIStyle := strings.Contains(apiURL, "openrouter.ai") || strings.Contains(apiURL, "/v1/") 	// Helper to stringify the expected JSON schema for instructions 	schemaDesc := func() string { 		b, _ := json.MarshalIndent(format, "", " ") 		return string(b) 	} 	buildBody := func(useJSONSchema bool) map[string]interface{} { 		if isOpenAIStyle { 			// For OpenAI style we send system + user messages; use response_format type json_object (schema variant often unsupported on some providers) 			rfType := "json_object" 			if useJSONSchema { 				// We attempt json_schema only if explicitly requested; default false. 				rfType = "json_schema" 			} 			body := map[string]interface{}{ 				"model": llm.Model, 				"messages": []map[string]string{ 					{"role": "system", "content": "You are a strict JSON generator. ONLY output valid JSON matching this schema: " + schemaDesc() + " Do not add explanations."}, 					{"role": "user", "content": prompt}, 				}, 				"response_format": map[string]interface{}{"type": rfType}, 			} 			if rfType == "json_schema" { 				body["response_format"] = map[string]interface{}{ 					"type": "json_schema", 					"json_schema": map[string]interface{}{ 						"name": "structured_output", 						"schema": format, 					}, 				} 			} 			return body 		} 		// Ollama style 		return map[string]interface{}{ 			"model": llm.Model, 			"messages": []map[string]string{{"role": "user", "content": prompt}}, 			"stream": false, 			"format": format, 		} 	} 	// First attempt (json_object for OpenAI style) 	useJSONSchemaAttempt := false 	body := buildBody(useJSONSchemaAttempt) 	doRequest := func(body map[string]interface{}) (raw []byte, status int, err error) { 		jsonBody, _ := json.Marshal(body) 		logrus.WithFields(logrus.Fields{"api_url": apiURL, "prompt": prompt, "is_openai_style": isOpenAIStyle, "json_schema": body["response_format"]}).Info("[LLM] completion POST") 		req, _ := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewBuffer(jsonBody)) 		if llm.APIKey != "" { 			req.Header.Set("Authorization", "Bearer "+llm.APIKey) 		} 		req.Header.Set("Content-Type", "application/json") 		if strings.Contains(apiURL, "openrouter.ai") { 			// Correct standard header field name is Referer 			req.Header.Set("Referer", "https://github.com/") 			req.Header.Set("X-Title", "vetrag-app") 		} 		client := &http.Client{} 		resp, err := client.Do(req) 		if err != nil { 			return nil, 0, err 		} 		defer resp.Body.Close() 		raw, rerr := io.ReadAll(resp.Body) 		return raw, resp.StatusCode, rerr 	} 	raw, status, err := doRequest(body) 	if err != nil { 		logrus.WithError(err).Error("[LLM] completion HTTP error") 		return "", err 	} 	logrus.WithFields(logrus.Fields{"status": status, "raw": string(raw)}).Debug("[LLM] completion raw response") 	// If OpenAI style and provider specifically complains wanting json / json_object etc and we tried json_schema (future path), fallback handled below. 	if isOpenAIStyle && status >= 400 { 		// Detect unsupported json_schema (if we ever attempted it) or response_format issues and retry without schema if not already json_object. 		if strings.Contains(string(raw), "response_format") && strings.Contains(string(raw), "json_schema") && useJSONSchemaAttempt { 			logrus.Warn("[LLM] json_schema rejected; retrying with json_object") 			useJSONSchemaAttempt = false 			body = buildBody(false) 			raw, status, err = doRequest(body) 			if err != nil { 				return "", fmt.Errorf("retry after json_schema failure: %w", err) 			} 			logrus.WithFields(logrus.Fields{"status": status, "raw": string(raw)}).Debug("[LLM] completion raw response (retry)") 		} 		// If still error, surface provider error below on unmarshal path. 	} 	// Attempt Ollama format parse 	var ollama struct { 		Message struct { 			Content string `json:"content"` 		} `json:"message"` 		Error string `json:"error"` 		} `json:"message"` 		Error string `json:"error"` 	} 	if err := json.Unmarshal(raw, &ollama); err == nil && ollama.Message.Content != "" { 		return ollama.Message.Content, nil 	// OpenAI style parse 		Choices []struct { 			Message struct { 				Content string `json:"content"` 			} `json:"message"` 		} `json:"choices"` 		Error *struct { 			Message string `json:"message"` 			Type string `json:"type"` 		} `json:"error"` 		Choices []struct { 			Message struct { 				Content string `json:"content"` 			} `json:"message"` 		} `json:"choices"` 		Error *struct { 			Message string `json:"message"` 			Type string `json:"type"` 		} `json:"error"` 	} 	if err := json.Unmarshal(raw, &openAI); err == nil { 		if openAI.Error != nil || status >= 400 { 			var msg string 			if openAI.Error != nil { 				msg = openAI.Error.Message 			} else { 				msg = string(raw) 			} 			return "", fmt.Errorf("provider error: %s", msg) 		} 		if len(openAI.Choices) > 0 && openAI.Choices[0].Message.Content != "" { 			return openAI.Choices[0].Message.Content, nil 		} 	} 	return "", fmt.Errorf("unrecognized LLM response format: %.200s", string(raw)) } // LLMClientAPI allows mocking LLMClient in other places // Only public methods should be included type LLMClientAPI interface { 	ExtractKeywords(ctx context.Context, message string) (map[string]interface{}, error) 	DisambiguateBestMatch(ctx context.Context, message string, candidates []Visit) (string, error) } var _ LLMClientAPI = (*LLMClient)(nil) " />
|
|
<option name="updatedContent" value="package main import ( 	"bytes" 	"context" 	"encoding/json" 	"fmt" 	"io" 	"net/http" 	"strings" 	"text/template" 	"time" 	"github.com/sirupsen/logrus" ) // LLMClient abstracts LLM API calls type LLMClient struct { 	APIKey string 	BaseURL string 	Model string } // NewLLMClient constructs a new LLMClient with the given API key and base URL func NewLLMClient(apiKey, baseURL string, model string) *LLMClient { 	return &LLMClient{ 		APIKey: apiKey, 		BaseURL: baseURL, 		Model: model, 	} } // renderPrompt renders a Go template with the given data func renderPrompt(tmplStr string, data any) (string, error) { 	tmpl, err := template.New("").Parse(tmplStr) 	if err != nil { 		return "", err 	} 	var buf bytes.Buffer 	if err := tmpl.Execute(&buf, data); err != nil { 		return "", err 	} 	return buf.String(), nil } // ExtractKeywords calls LLM to extract keywords from user message func (llm *LLMClient) ExtractKeywords(ctx context.Context, message string) (map[string]interface{}, error) { 	prompt, err := renderPrompt(appConfig.LLM.ExtractKeywordsPrompt, map[string]string{"Message": message}) 	if err != nil { 		logrus.WithError(err).Error("[CONFIG] Failed to render ExtractKeywords prompt") 		return nil, err 	} 	logrus.WithField("prompt", prompt).Info("[LLM] ExtractKeywords prompt") 	format := map[string]interface{}{ 		"type": "object", 		"properties": map[string]interface{}{ 			"translate": map[string]interface{}{"type": "string"}, 			"keyword": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}}, 			"animal": map[string]interface{}{"type": "string"}, 		}, 		"required": []string{"translate", "keyword", "animal"}, 	} 	resp, err := llm.openAICompletion(ctx, prompt, format) 	logrus.WithFields(logrus.Fields{"response": resp, "err": err}).Info("[LLM] ExtractKeywords response") 	if err != nil { 		return nil, err 	} 	var result map[string]interface{} 	if err := json.Unmarshal([]byte(resp), &result); err != nil { 		return nil, err 	} 	return result, nil } // DisambiguateBestMatch calls LLM to pick best match from candidates func (llm *LLMClient) DisambiguateBestMatch(ctx context.Context, message string, candidates []Visit) (string, error) { 	format := map[string]interface{}{ 		"type": "object", 		"properties": map[string]interface{}{ 			"visitReason": map[string]interface{}{"type": "string"}, 		}, 		"required": []string{"visitReason"}, 	} 	entries, _ := json.Marshal(candidates) 	prompt, err := renderPrompt(appConfig.LLM.DisambiguatePrompt, map[string]string{"Entries": string(entries), "Message": message}) 	if err != nil { 		logrus.WithError(err).Error("[CONFIG] Failed to render Disambiguate prompt") 		return "", err 	} 	logrus.WithField("prompt", prompt).Info("[LLM] DisambiguateBestMatch prompt") 	resp, err := llm.openAICompletion(ctx, prompt, format) 	logrus.WithFields(logrus.Fields{"response": resp, "err": err}).Info("[LLM] DisambiguateBestMatch response") 	if err != nil { 		return "", err 	} 	var parsed map[string]string 	if err := json.Unmarshal([]byte(resp), &parsed); err != nil { 		return "", fmt.Errorf("failed to unmarshal disambiguation response: %w", err) 	} 	visitReason := strings.TrimSpace(parsed["visitReason"]) 	if visitReason == "" { 		return "", fmt.Errorf("visitReason not found in response") 	} 	return visitReason, nil } // openAICompletion now supports both Ollama (default local) and OpenRouter/OpenAI-compatible APIs without external branching. // It auto-detects by inspecting the BaseURL. If the URL contains "openrouter.ai" or "/v1/", it assumes OpenAI-style. func (llm *LLMClient) openAICompletion(ctx context.Context, prompt string, format map[string]interface{}) (string, error) { 	apiURL := llm.BaseURL 	if apiURL == "" { 		// Default to Ollama local chat endpoint 		apiURL = "http://localhost:11434/api/chat" 	} 	isOpenAIStyle := strings.Contains(apiURL, "openrouter.ai") || strings.Contains(apiURL, "/v1/") 	// Helper to stringify the expected JSON schema for instructions 	schemaDesc := func() string { 		b, _ := json.MarshalIndent(format, "", " ") 		return string(b) 	} 	truncate := func(s string, n int) string { 		if len(s) <= n { 			return s 		} 		return s[:n] + "...<truncated>" 	} 	buildBody := func() map[string]interface{} { 		if isOpenAIStyle { 			return map[string]interface{}{ 				"model": llm.Model, 				"messages": []map[string]string{ 					{"role": "system", "content": "You are a strict JSON generator. ONLY output valid JSON matching this schema: " + schemaDesc() + " Do not add explanations."}, 					{"role": "user", "content": prompt}, 				}, 				"response_format": map[string]interface{}{"type": "json_object"}, 			} 		} 		// Ollama style 		return map[string]interface{}{ 			"model": llm.Model, 			"messages": []map[string]string{{"role": "user", "content": prompt}}, 			"stream": false, 			"format": format, 		} 	} 	body := buildBody() 	doRequest := func(body map[string]interface{}) (raw []byte, status int, err error, dur time.Duration) { 		jsonBody, _ := json.Marshal(body) 		bodySize := len(jsonBody) 		logrus.WithFields(logrus.Fields{ 			"event": "llm_request", 			"api_url": apiURL, 			"model": llm.Model, 			"is_openai_style": isOpenAIStyle, 			"prompt_len": len(prompt), 			"body_size": bodySize, 		}).Info("[LLM] sending request") 		req, _ := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewBuffer(jsonBody)) 		if llm.APIKey != "" { 			req.Header.Set("Authorization", "Bearer "+llm.APIKey) 		} 		req.Header.Set("Content-Type", "application/json") 		req.Header.Set("Accept", "application/json") 		if strings.Contains(apiURL, "openrouter.ai") { 			req.Header.Set("Referer", "https://github.com/") 			req.Header.Set("X-Title", "vetrag-app") 		} 		start := time.Now() 		client := &http.Client{} 		resp, err := client.Do(req) 		if err != nil { 			return nil, 0, err, time.Since(start) 		} 		defer resp.Body.Close() 		raw, rerr := io.ReadAll(resp.Body) 		return raw, resp.StatusCode, rerr, time.Since(start) 	} 	raw, status, err, dur := doRequest(body) 	if err != nil { 		logrus.WithFields(logrus.Fields{ 			"event": "llm_response", 			"status": status, 			"latency_ms": dur.Milliseconds(), 			"error": err, 		}).Error("[LLM] request failed") 		return "", err 	} 	logrus.WithFields(logrus.Fields{ 		"event": "llm_raw_response", 		"status": status, 		"latency_ms": dur.Milliseconds(), 		"raw_trunc": truncate(string(raw), 600), 		"raw_len": len(raw), 	}).Debug("[LLM] raw response body") 	parseVariant := "unknown" 	// Attempt Ollama format parse 	var ollama struct { 		Message struct{ Content string `json:"content"` } `json:"message"` 		Error string `json:"error"` 	} 	if err := json.Unmarshal(raw, &ollama); err == nil && ollama.Message.Content != "" { 		parseVariant = "ollama" 		content := ollama.Message.Content 		logrus.WithFields(logrus.Fields{ 			"event": "llm_response", 			"status": status, 			"latency_ms": dur.Milliseconds(), 			"parse_variant": parseVariant, 			"content_len": len(content), 			"content_snip": truncate(content, 300), 		}).Info("[LLM] parsed response") 		return content, nil 	} 	// Attempt OpenAI/OpenRouter style parse 	var openAI struct { 		Choices []struct{ Message struct{ Content string `json:"content"` } `json:"message"` } `json:"choices"` 		Error *struct{ Message string `json:"message"`; Type string `json:"type"` } `json:"error"` 	} 	if err := json.Unmarshal(raw, &openAI); err == nil { 		if openAI.Error != nil || status >= 400 { 			parseVariant = "openai" 			var msg string 			if openAI.Error != nil { 				msg = openAI.Error.Message 			} else { 				msg = string(raw) 			} 			logrus.WithFields(logrus.Fields{ 				"event": "llm_response", 				"status": status, 				"latency_ms": dur.Milliseconds(), 				"parse_variant": parseVariant, 				"error": msg, 			}).Error("[LLM] provider error") 			return "", fmt.Errorf("provider error: %s", msg) 		} 		if len(openAI.Choices) > 0 && openAI.Choices[0].Message.Content != "" { 			parseVariant = "openai" 			content := openAI.Choices[0].Message.Content 			logrus.WithFields(logrus.Fields{ 				"event": "llm_response", 				"status": status, 				"latency_ms": dur.Milliseconds(), 				"parse_variant": parseVariant, 				"content_len": len(content), 				"content_snip": truncate(content, 300), 			}).Info("[LLM] parsed response") 			return content, nil 		} 	} 	logrus.WithFields(logrus.Fields{ 		"event": "llm_response", 		"status": status, 		"latency_ms": dur.Milliseconds(), 		"parse_variant": parseVariant, 		"raw_snip": truncate(string(raw), 300), 	}).Error("[LLM] unrecognized response format") 	return "", fmt.Errorf("unrecognized LLM response format: %.200s", string(raw)) } // LLMClientAPI allows mocking LLMClient in other places // Only public methods should be included type LLMClientAPI interface { 	ExtractKeywords(ctx context.Context, message string) (map[string]interface{}, error) 	DisambiguateBestMatch(ctx context.Context, message string, candidates []Visit) (string, error) } var _ LLMClientAPI = (*LLMClient)(nil)" />
|
|
</PendingDiffInfo>
|
|
</value>
|
|
</entry>
|
|
</map>
|
|
</option>
|
|
</component>
|
|
</project> |