ollama server

This commit is contained in:
lehel 2025-09-25 14:07:42 +02:00
parent c6b3639109
commit 3233692b66
No known key found for this signature in database
GPG Key ID: 9C4F9D6111EE5CFA
6 changed files with 104 additions and 68 deletions

22
config.go Normal file
View File

@ -0,0 +1,22 @@
package main
import "io/ioutil"
import "gopkg.in/yaml.v3"
// Config holds all prompts and settings
type Config struct {
LLM struct {
ExtractKeywordsPrompt string `yaml:"extract_keywords_prompt"`
DisambiguatePrompt string `yaml:"disambiguate_prompt"`
} `yaml:"llm"`
}
var appConfig Config
func loadConfig(path string) error {
data, err := ioutil.ReadFile(path)
if err != nil {
return err
}
return yaml.Unmarshal(data, &appConfig)
}

View File

@ -1,4 +1,4 @@
llm: llm:
extract_keywords_prompt: "Extract 35 key veterinary-related terms from this user message: {{.Message}}" extract_keywords_prompt: "Translate [{{.Message}}] to English, then output only 35 comma-separated veterinary-related keywords derived strictly from [{{.Message}}]. example output [\"keyword1\",\"keyword2\"] No other text, no extra punctuation, no explanations, no quotes, no formatting."
disambiguate_prompt: "Given these possible vet visit reasons: {{.Entries}}, choose the single best match for this user message: {{.Message}}. Reply with the id or none." disambiguate_prompt: "Given these possible vet visit reasons: {{.Entries}}, choose the single best match for this user message: {{.Message}}. Reply with the id or none."

77
llm.go
View File

@ -6,6 +6,7 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"strings" "strings"
"text/template"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
) )
@ -16,31 +17,46 @@ type LLMClient struct {
BaseURL string BaseURL string
} }
// 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 // ExtractKeywords calls LLM to extract keywords from user message
func (llm *LLMClient) ExtractKeywords(ctx context.Context, message string) ([]string, error) { func (llm *LLMClient) ExtractKeywords(ctx context.Context, message string) (map[string]interface{}, error) {
prompt, err := renderPrompt(appConfig.LLM.ExtractKeywordsPrompt, map[string]string{"Message": message}) prompt, err := renderPrompt(appConfig.LLM.ExtractKeywordsPrompt, map[string]string{"Message": message})
if err != nil { if err != nil {
logrus.WithError(err).Error("[CONFIG] Failed to render ExtractKeywords prompt") logrus.WithError(err).Error("[CONFIG] Failed to render ExtractKeywords prompt")
return nil, err return nil, err
} }
logrus.WithField("prompt", prompt).Info("[LLM] ExtractKeywords prompt") logrus.WithField("prompt", prompt).Info("[LLM] ExtractKeywords prompt")
resp, err := llm.openAICompletion(ctx, 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") logrus.WithFields(logrus.Fields{"response": resp, "err": err}).Info("[LLM] ExtractKeywords response")
if err != nil { if err != nil {
return nil, err return nil, err
} }
var keywords []string var result map[string]interface{}
if err := json.Unmarshal([]byte(resp), &keywords); err == nil { if err := json.Unmarshal([]byte(resp), &result); err != nil {
return keywords, nil return nil, err
} }
// fallback: try splitting by comma return result, nil
for _, k := range bytes.Split([]byte(resp), []byte{','}) {
kw := strings.TrimSpace(string(k))
if kw != "" {
keywords = append(keywords, kw)
}
}
return keywords, nil
} }
// DisambiguateBestMatch calls LLM to pick best match from candidates // DisambiguateBestMatch calls LLM to pick best match from candidates
@ -52,7 +68,7 @@ func (llm *LLMClient) DisambiguateBestMatch(ctx context.Context, message string,
return "", err return "", err
} }
logrus.WithField("prompt", prompt).Info("[LLM] DisambiguateBestMatch prompt") logrus.WithField("prompt", prompt).Info("[LLM] DisambiguateBestMatch prompt")
resp, err := llm.openAICompletion(ctx, prompt) resp, err := llm.openAICompletion(ctx, prompt, nil)
logrus.WithFields(logrus.Fields{"response": resp, "err": err}).Info("[LLM] DisambiguateBestMatch response") logrus.WithFields(logrus.Fields{"response": resp, "err": err}).Info("[LLM] DisambiguateBestMatch response")
if err != nil { if err != nil {
return "", err return "", err
@ -64,24 +80,21 @@ func (llm *LLMClient) DisambiguateBestMatch(ctx context.Context, message string,
return id, nil return id, nil
} }
// openAICompletion is a minimal OpenAI API call (text-davinci-003 or gpt-3.5-turbo-instruct) // openAICompletion calls Ollama API with prompt and structure, returns structured result
func (llm *LLMClient) openAICompletion(ctx context.Context, prompt string) (string, error) { func (llm *LLMClient) openAICompletion(ctx context.Context, prompt string, format map[string]interface{}) (string, error) {
apiURL := llm.BaseURL apiURL := llm.BaseURL
if apiURL == "" { if apiURL == "" {
apiURL = "https://api.openai.com/v1/completions" apiURL = "http://localhost:11434/api/chat"
} }
logrus.WithFields(logrus.Fields{"api_url": apiURL, "prompt": prompt}).Info("[LLM] openAICompletion POST") logrus.WithFields(logrus.Fields{"api_url": apiURL, "prompt": prompt, "format": format}).Info("[LLM] openAICompletion POST")
body := map[string]interface{}{ body := map[string]interface{}{
"model": "text-davinci-003", "model": "qwen3:latest",
"prompt": prompt, "messages": []map[string]string{{"role": "user", "content": prompt}},
"max_tokens": 64, "stream": false,
"temperature": 0, "format": format,
} }
jsonBody, _ := json.Marshal(body) jsonBody, _ := json.Marshal(body)
req, _ := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewBuffer(jsonBody)) req, _ := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewBuffer(jsonBody))
if llm.APIKey != "" {
req.Header.Set("Authorization", "Bearer "+llm.APIKey)
}
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
client := &http.Client{} client := &http.Client{}
resp, err := client.Do(req) resp, err := client.Do(req)
@ -91,18 +104,18 @@ func (llm *LLMClient) openAICompletion(ctx context.Context, prompt string) (stri
} }
defer resp.Body.Close() defer resp.Body.Close()
var result struct { var result struct {
Choices []struct { Message struct {
Text string `json:"text"` Content string `json:"content"`
} `json:"choices"` } `json:"message"`
} }
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
logrus.WithError(err).Error("[LLM] openAICompletion decode error") logrus.WithError(err).Error("[LLM] openAICompletion decode error")
return "", err return "", err
} }
if len(result.Choices) == 0 { if result.Message.Content == "" {
logrus.Warn("[LLM] openAICompletion: no choices returned") logrus.Warn("[LLM] openAICompletion: no content returned")
return "", nil return "", nil
} }
logrus.WithField("text", result.Choices[0].Text).Info("[LLM] openAICompletion: got text") logrus.WithField("content", result.Message.Content).Info("[LLM] openAICompletion: got content")
return result.Choices[0].Text, nil return result.Message.Content, nil
} }

48
main.go
View File

@ -1,7 +1,6 @@
package main package main
import ( import (
"bytes"
"context" "context"
"fmt" "fmt"
"html/template" "html/template"
@ -55,24 +54,6 @@ type ChatResponse struct {
Notes string `json:"notes,omitempty"` Notes string `json:"notes,omitempty"`
} }
// Config holds all prompts and settings
type Config struct {
LLM struct {
ExtractKeywordsPrompt string `yaml:"extract_keywords_prompt"`
DisambiguatePrompt string `yaml:"disambiguate_prompt"`
} `yaml:"llm"`
}
var appConfig Config
func loadConfig(path string) error {
data, err := ioutil.ReadFile(path)
if err != nil {
return err
}
return yaml.Unmarshal(data, &appConfig)
}
// naiveKeywordExtract splits message into lowercase words (placeholder for LLM) // naiveKeywordExtract splits message into lowercase words (placeholder for LLM)
func naiveKeywordExtract(msg string) []string { func naiveKeywordExtract(msg string) []string {
// TODO: Replace with LLM call // TODO: Replace with LLM call
@ -118,19 +99,6 @@ func sumProcedures(procs []Procedure) (int, int) {
return totalPrice, totalDuration return totalPrice, totalDuration
} }
// 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
}
var uiTemplate *template.Template var uiTemplate *template.Template
func loadUITemplate(path string) error { func loadUITemplate(path string) error {
@ -175,7 +143,19 @@ func main() {
} }
ctx := context.Background() ctx := context.Background()
keywords, err := llm.ExtractKeywords(ctx, req.Message) keywords, err := llm.ExtractKeywords(ctx, req.Message)
candidates := findCandidates(keywords) kwIface := keywords["keyword"]
var kwArr []string
switch v := kwIface.(type) {
case []interface{}:
for _, item := range v {
if s, ok := item.(string); ok {
kwArr = append(kwArr, s)
}
}
case []string:
kwArr = v
}
candidates := findCandidates(kwArr)
bestID := "" bestID := ""
if len(candidates) > 0 && err == nil { if len(candidates) > 0 && err == nil {
bestID, err = llm.DisambiguateBestMatch(ctx, req.Message, candidates) bestID, err = llm.DisambiguateBestMatch(ctx, req.Message, candidates)
@ -216,7 +196,7 @@ func main() {
} }
// logRequest logs incoming chat requests and extracted info // logRequest logs incoming chat requests and extracted info
func logRequest(req ChatRequest, keywords []string, candidates []Reason, bestID string, err error) { func logRequest(req ChatRequest, keywords map[string]interface{}, candidates []Reason, bestID string, err error) {
logrus.WithFields(logrus.Fields{ logrus.WithFields(logrus.Fields{
"message": req.Message, "message": req.Message,
"keywords": keywords, "keywords": keywords,

2
run.sh
View File

@ -1,3 +1,3 @@
export OPENAI_BASE_URL=http://localhost:1234/v1/completions export OPENAI_BASE_URL=http://localhost:11434/api/chat
export OPENAI_API_KEY=sk-no-key-needed # (if LM Studio doesn't require a real key) export OPENAI_API_KEY=sk-no-key-needed # (if LM Studio doesn't require a real key)
go run . go run .

21
test_copy.sh Executable file
View File

@ -0,0 +1,21 @@
curl -X POST http://localhost:11434/api/chat \
-H 'Content-Type: application/json' \
-d '{
"model": "qwen3:latest",
"messages": [
{
"role": "user",
"content": "Translate [\"megy a kutyam hasa\"] to English, then output only 35 comma-separated veterinary-related keywords derived strictly from [\"megy a kutyam hasa\"]. example output [\"keyword1\",\"keyword2\"] No other text, no extra punctuation, no explanations, no quotes, no formatting."
}
],
"stream": false,
"format": {
"type": "object",
"properties": {
"translate": { "type": "string" },
"keyword": { "type": "array", "items": { "type": "string" } },
"animal": { "type": "string" }
},
"required": ["translate", "keyword", "animal"]
}
}'