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