ollama server
This commit is contained in:
parent
c6b3639109
commit
3233692b66
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
llm:
|
llm:
|
||||||
extract_keywords_prompt: "Extract 3–5 key veterinary-related terms from this user message: {{.Message}}"
|
extract_keywords_prompt: "Translate [{{.Message}}] to English, then output only 3–5 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
77
llm.go
|
|
@ -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
48
main.go
|
|
@ -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
2
run.sh
|
|
@ -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 .
|
||||||
|
|
|
||||||
|
|
@ -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 3–5 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"]
|
||||||
|
}
|
||||||
|
}'
|
||||||
Loading…
Reference in New Issue