Compare commits
3 Commits
a2e9e6e273
...
4647a3ad43
| Author | SHA1 | Date |
|---|---|---|
|
|
4647a3ad43 | |
|
|
0ec0ef10d8 | |
|
|
8e0fc65bb2 |
|
|
@ -0,0 +1,37 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, master ]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
# Provide dummy/default env vars so code paths that read them won't fail.
|
||||
OPENAI_API_KEY: dummy
|
||||
# Default to local Ollama endpoint for tests (tests mock LLM so it's unused).
|
||||
OPENAI_BASE_URL: http://localhost:11434/api/chat
|
||||
OPENAI_MODEL: qwen3:latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Go Vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: Run Tests
|
||||
run: go test -count=1 ./...
|
||||
|
||||
- name: Build (sanity)
|
||||
run: go build -v ./...
|
||||
|
||||
|
|
@ -1 +1,2 @@
|
|||
reasons.bleve
|
||||
visits.bleve
|
||||
|
|
|
|||
|
|
@ -18,14 +18,14 @@ type ChatServiceAPI interface {
|
|||
|
||||
// ChatService handles chat interactions and orchestrates LLM and DB calls.
|
||||
type ChatService struct {
|
||||
LLM LLMClientAPI
|
||||
reasonsDB ReasonDBAPI
|
||||
LLM LLMClientAPI
|
||||
visitsDB VisitDBAPI
|
||||
}
|
||||
|
||||
var _ ChatServiceAPI = (*ChatService)(nil)
|
||||
|
||||
func NewChatService(llm LLMClientAPI, db ReasonDBAPI) ChatServiceAPI {
|
||||
return &ChatService{LLM: llm, reasonsDB: db}
|
||||
func NewChatService(llm LLMClientAPI, db VisitDBAPI) ChatServiceAPI {
|
||||
return &ChatService{LLM: llm, visitsDB: db}
|
||||
}
|
||||
|
||||
// HandleChat is the main entrypoint for chat requests. It delegates to modular helpers.
|
||||
|
|
@ -40,7 +40,7 @@ func (cs *ChatService) HandleChat(c *gin.Context) {
|
|||
cs.respondWithError(c, req, keywords, err)
|
||||
return
|
||||
}
|
||||
best, err := cs.findBestReason(ctx, req, keywords)
|
||||
best, err := cs.findBestVisit(ctx, req, keywords)
|
||||
resp := cs.buildResponse(best)
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
|
@ -65,10 +65,10 @@ func (cs *ChatService) extractKeywords(ctx context.Context, message string) ([]s
|
|||
return cs.keywordsToStrings(kwResp["keyword"]), nil
|
||||
}
|
||||
|
||||
// findBestReason finds candidate reasons and disambiguates the best match.
|
||||
func (cs *ChatService) findBestReason(ctx context.Context, req ChatRequest, keywords []string) (*Reason, error) {
|
||||
// findBestVisit finds candidate visits and disambiguates the best match.
|
||||
func (cs *ChatService) findBestVisit(ctx context.Context, req ChatRequest, keywords []string) (*Visit, error) {
|
||||
cs.logKeywords(keywords, req.Message)
|
||||
candidates, err := cs.reasonsDB.FindCandidates(keywords)
|
||||
candidates, err := cs.visitsDB.FindCandidates(keywords)
|
||||
cs.logCandidates(candidates, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -78,15 +78,15 @@ func (cs *ChatService) findBestReason(ctx context.Context, req ChatRequest, keyw
|
|||
bestID, err = cs.LLM.DisambiguateBestMatch(ctx, req.Message, candidates)
|
||||
cs.logBestID(bestID, err)
|
||||
}
|
||||
reason, err := cs.reasonsDB.FindById(bestID)
|
||||
visit, err := cs.visitsDB.FindById(bestID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("FindById: %w", err)
|
||||
}
|
||||
return &reason, nil
|
||||
return &visit, nil
|
||||
}
|
||||
|
||||
// buildResponse constructs the ChatResponse from the best Reason.
|
||||
func (cs *ChatService) buildResponse(best *Reason) ChatResponse {
|
||||
// buildResponse constructs the ChatResponse from the best Visit.
|
||||
func (cs *ChatService) buildResponse(best *Visit) ChatResponse {
|
||||
if best == nil {
|
||||
resp := ChatResponse{Match: nil}
|
||||
logrus.WithFields(logrus.Fields{"response": resp}).Info("Build response: no match")
|
||||
|
|
@ -132,15 +132,15 @@ func (cs *ChatService) logKeywords(keywords []string, message string) {
|
|||
logrus.WithFields(logrus.Fields{
|
||||
"keywords": keywords,
|
||||
"message": message,
|
||||
}).Info("Finding visit reason candidates")
|
||||
}).Info("Finding visit candidates")
|
||||
}
|
||||
|
||||
// logCandidates logs candidate reasons.
|
||||
func (cs *ChatService) logCandidates(candidates []Reason, err error) {
|
||||
// logCandidates logs candidate visits.
|
||||
func (cs *ChatService) logCandidates(candidates []Visit, err error) {
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"candidates": candidates,
|
||||
"error": err,
|
||||
}).Info("Candidate reasons found")
|
||||
}).Info("Candidate visits found")
|
||||
}
|
||||
|
||||
// logBestID logs the best candidate ID.
|
||||
|
|
@ -152,7 +152,7 @@ func (cs *ChatService) logBestID(bestID string, err error) {
|
|||
}
|
||||
|
||||
// logChat logs the chat request and result details.
|
||||
func (cs *ChatService) logChat(req ChatRequest, keywords interface{}, candidates []Reason, bestID string, err error) {
|
||||
func (cs *ChatService) logChat(req ChatRequest, keywords interface{}, candidates []Visit, bestID string, err error) {
|
||||
var kwMap map[string]interface{}
|
||||
switch v := keywords.(type) {
|
||||
case []string:
|
||||
|
|
@ -164,7 +164,7 @@ func (cs *ChatService) logChat(req ChatRequest, keywords interface{}, candidates
|
|||
}
|
||||
logRequest(req, kwMap, candidates, bestID, err)
|
||||
if candidates != nil && bestID != "" {
|
||||
var best *Reason
|
||||
var best *Visit
|
||||
for i := range candidates {
|
||||
if candidates[i].ID == bestID {
|
||||
best = &candidates[i]
|
||||
|
|
|
|||
|
|
@ -24,26 +24,26 @@ var _ LLMClientAPI = (*mockLLM)(nil)
|
|||
func (m *mockLLM) ExtractKeywords(ctx context.Context, msg string) (map[string]interface{}, error) {
|
||||
return m.keywordsResp, m.keywordsErr
|
||||
}
|
||||
func (m *mockLLM) DisambiguateBestMatch(ctx context.Context, msg string, candidates []Reason) (string, error) {
|
||||
func (m *mockLLM) DisambiguateBestMatch(ctx context.Context, msg string, candidates []Visit) (string, error) {
|
||||
return m.disambigID, m.disambigErr
|
||||
}
|
||||
|
||||
// --- Test ReasonDB ---
|
||||
type testReasonDB struct {
|
||||
candidates []Reason
|
||||
// --- Test VisitDB ---
|
||||
type testVisitDB struct {
|
||||
candidates []Visit
|
||||
findErr error
|
||||
byID map[string]Reason
|
||||
byID map[string]Visit
|
||||
}
|
||||
|
||||
var _ ReasonDBAPI = (*testReasonDB)(nil)
|
||||
var _ VisitDBAPI = (*testVisitDB)(nil)
|
||||
|
||||
func (db *testReasonDB) FindCandidates(keywords []string) ([]Reason, error) {
|
||||
func (db *testVisitDB) FindCandidates(keywords []string) ([]Visit, error) {
|
||||
return db.candidates, db.findErr
|
||||
}
|
||||
func (db *testReasonDB) FindById(id string) (Reason, error) {
|
||||
func (db *testVisitDB) FindById(id string) (Visit, error) {
|
||||
r, ok := db.byID[id]
|
||||
if !ok {
|
||||
return Reason{}, context.DeadlineExceeded
|
||||
return Visit{}, context.DeadlineExceeded
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
|
@ -55,14 +55,14 @@ func TestChatService_MatchFound(t *testing.T) {
|
|||
keywordsResp: map[string]interface{}{"keyword": []string{"worms", "deworming"}},
|
||||
disambigID: "deworming",
|
||||
}
|
||||
reason := Reason{
|
||||
visit := Visit{
|
||||
ID: "deworming",
|
||||
Procedures: []Procedure{{Name: "Deworming tablet", Price: 30, DurationMin: 10}},
|
||||
Notes: "Bloodwork ensures organs are safe for treatment.",
|
||||
}
|
||||
var db ReasonDBAPI = &testReasonDB{
|
||||
candidates: []Reason{reason},
|
||||
byID: map[string]Reason{"deworming": reason},
|
||||
var db VisitDBAPI = &testVisitDB{
|
||||
candidates: []Visit{visit},
|
||||
byID: map[string]Visit{"deworming": visit},
|
||||
}
|
||||
var cs ChatServiceAPI = NewChatService(llm, db)
|
||||
r := gin.New()
|
||||
|
|
@ -88,8 +88,8 @@ func TestChatService_MatchFound(t *testing.T) {
|
|||
if len(resp.Procedures) != 1 || resp.Procedures[0].Name != "Deworming tablet" {
|
||||
t.Errorf("Expected procedure 'Deworming tablet', got %+v", resp.Procedures)
|
||||
}
|
||||
if resp.Notes != reason.Notes {
|
||||
t.Errorf("Expected notes '%s', got '%s'", reason.Notes, resp.Notes)
|
||||
if resp.Notes != visit.Notes {
|
||||
t.Errorf("Expected notes '%s', got '%s'", visit.Notes, resp.Notes)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -99,9 +99,9 @@ func TestChatService_NoMatch(t *testing.T) {
|
|||
keywordsResp: map[string]interface{}{"keyword": []string{"unknown"}},
|
||||
disambigID: "",
|
||||
}
|
||||
db := &testReasonDB{
|
||||
candidates: []Reason{},
|
||||
byID: map[string]Reason{},
|
||||
db := &testVisitDB{
|
||||
candidates: []Visit{},
|
||||
byID: map[string]Visit{},
|
||||
}
|
||||
cs := NewChatService(llm, db)
|
||||
r := gin.New()
|
||||
|
|
@ -131,7 +131,7 @@ func TestChatService_LLMError(t *testing.T) {
|
|||
llm := &mockLLM{
|
||||
keywordsErr: context.DeadlineExceeded,
|
||||
}
|
||||
db := &testReasonDB{}
|
||||
db := &testVisitDB{}
|
||||
cs := NewChatService(llm, db)
|
||||
r := gin.New()
|
||||
r.POST("/chat", cs.HandleChat)
|
||||
|
|
|
|||
66
db.go
66
db.go
|
|
@ -12,79 +12,79 @@ import (
|
|||
"github.com/blevesearch/bleve/v2"
|
||||
)
|
||||
|
||||
type ReasonDB struct {
|
||||
reasonsDB []Reason
|
||||
reasonsIdx bleve.Index
|
||||
type VisitDB struct {
|
||||
visitsDB []Visit
|
||||
visitsIdx bleve.Index
|
||||
}
|
||||
|
||||
func NewReasonDB() ReasonDB {
|
||||
db := ReasonDB{}
|
||||
func NewVisitDB() VisitDB {
|
||||
db := VisitDB{}
|
||||
db.init()
|
||||
return db
|
||||
}
|
||||
func (rdb *ReasonDB) FindById(reasonId string) (Reason, error) {
|
||||
for _, reason := range rdb.reasonsDB {
|
||||
if reason.ID == reasonId {
|
||||
return reason, nil
|
||||
func (vdb *VisitDB) FindById(visitId string) (Visit, error) {
|
||||
for _, visit := range vdb.visitsDB {
|
||||
if visit.ID == visitId {
|
||||
return visit, nil
|
||||
}
|
||||
}
|
||||
return Reason{}, errors.New("reason not found")
|
||||
return Visit{}, errors.New("visit not found")
|
||||
}
|
||||
func (rdb *ReasonDB) init() {
|
||||
idxPath := "reasons.bleve"
|
||||
func (vdb *VisitDB) init() {
|
||||
idxPath := "visits.bleve"
|
||||
if _, err := os.Stat(idxPath); err == nil {
|
||||
rdb.reasonsIdx, err = bleve.Open(idxPath)
|
||||
vdb.visitsIdx, err = bleve.Open(idxPath)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
} else if os.IsNotExist(err) {
|
||||
rdb.reasonsIdx, err = bleve.New(idxPath, bleve.NewIndexMapping())
|
||||
vdb.visitsIdx, err = bleve.New(idxPath, bleve.NewIndexMapping())
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
} else {
|
||||
panic(err)
|
||||
}
|
||||
err := rdb.loadYAMLDB("db.yaml")
|
||||
err := vdb.loadYAMLDB("db.yaml")
|
||||
if err != nil {
|
||||
logrus.Fatalf("Failed to load db.yaml: %v", err)
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (rdb *ReasonDB) loadYAMLDB(path string) error {
|
||||
func (vdb *VisitDB) loadYAMLDB(path string) error {
|
||||
data, err := ioutil.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := yaml.Unmarshal(data, &rdb.reasonsDB); err != nil {
|
||||
if err := yaml.Unmarshal(data, &vdb.visitsDB); err != nil {
|
||||
return err
|
||||
}
|
||||
return rdb.indexReasons(rdb.reasonsDB)
|
||||
return vdb.indexVisits(vdb.visitsDB)
|
||||
}
|
||||
|
||||
func (rdb *ReasonDB) indexReasons(reasons []Reason) error {
|
||||
batch := rdb.reasonsIdx.NewBatch()
|
||||
for _, reason := range reasons {
|
||||
if err := batch.Index(reason.ID, reason); err != nil {
|
||||
func (vdb *VisitDB) indexVisits(visits []Visit) error {
|
||||
batch := vdb.visitsIdx.NewBatch()
|
||||
for _, visit := range visits {
|
||||
if err := batch.Index(visit.ID, visit); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return rdb.reasonsIdx.Batch(batch)
|
||||
return vdb.visitsIdx.Batch(batch)
|
||||
}
|
||||
|
||||
// FindCandidates returns reasons with overlapping keywords
|
||||
func (rdb *ReasonDB) FindCandidates(keywords []string) ([]Reason, error) {
|
||||
// FindCandidates returns visits with overlapping keywords
|
||||
func (vdb *VisitDB) FindCandidates(keywords []string) ([]Visit, error) {
|
||||
query := bleve.NewMatchQuery(strings.Join(keywords, " "))
|
||||
search := bleve.NewSearchRequest(query)
|
||||
searchResults, err := rdb.reasonsIdx.Search(search)
|
||||
searchResults, err := vdb.visitsIdx.Search(search)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var candidates []Reason
|
||||
var candidates []Visit
|
||||
for _, hit := range searchResults.Hits {
|
||||
for _, r := range rdb.reasonsDB {
|
||||
for _, r := range vdb.visitsDB {
|
||||
if r.ID == hit.ID {
|
||||
candidates = append(candidates, r)
|
||||
break
|
||||
|
|
@ -105,12 +105,12 @@ func sumProcedures(procs []Procedure) (int, int) {
|
|||
return totalPrice, totalDuration
|
||||
}
|
||||
|
||||
// ReasonDBAPI allows mocking ReasonDB in other places
|
||||
// VisitDBAPI allows mocking VisitDB in other places
|
||||
// Only public methods should be included
|
||||
|
||||
type ReasonDBAPI interface {
|
||||
FindById(reasonId string) (Reason, error)
|
||||
FindCandidates(keywords []string) ([]Reason, error)
|
||||
type VisitDBAPI interface {
|
||||
FindById(visitId string) (Visit, error)
|
||||
FindCandidates(keywords []string) ([]Visit, error)
|
||||
}
|
||||
|
||||
var _ ReasonDBAPI = (*ReasonDB)(nil)
|
||||
var _ VisitDBAPI = (*VisitDB)(nil)
|
||||
|
|
|
|||
20
db.yaml
20
db.yaml
|
|
@ -1,5 +1,5 @@
|
|||
- id: deworming
|
||||
reason: "Féregtelenítés kutyának"
|
||||
visit: "Féregtelenítés kutyának"
|
||||
keywords: ["worm", "deworming", "parasite", "intestinal worm", "dog"]
|
||||
procedures:
|
||||
- name: "Alap vérvizsgálat"
|
||||
|
|
@ -11,7 +11,7 @@
|
|||
notes: "A kezelés előtt vérvizsgálat szükséges a biztonságos gyógyszeradás miatt."
|
||||
|
||||
- id: vaccination
|
||||
reason: "Oltás kutyának"
|
||||
visit: "Oltás kutyának"
|
||||
keywords: ["vaccination", "vaccine", "to vaccinate", "dog disease", "rabies"]
|
||||
procedures:
|
||||
- name: "Általános állapotfelmérés"
|
||||
|
|
@ -23,7 +23,7 @@
|
|||
notes: "A kutyák oltási programja eltérhet az életkortól és korábbi oltásoktól függően."
|
||||
|
||||
- id: neutering
|
||||
reason: "Ivartalanítás macskának"
|
||||
visit: "Ivartalanítás macskának"
|
||||
keywords: ["neutering", "surgery", "cat", "tomcat", "female cat"]
|
||||
procedures:
|
||||
- name: "Műtéti előzetes vizsgálat"
|
||||
|
|
@ -35,7 +35,7 @@
|
|||
notes: "A műtét után 2-3 nap pihenő szükséges."
|
||||
|
||||
- id: dental_cleaning
|
||||
reason: "Fogkő eltávolítás kutyának"
|
||||
visit: "Fogkő eltávolítás kutyának"
|
||||
keywords: ["tooth", "tartar", "dentition", "tooth cleaning", "dog teeth","plaque"]
|
||||
procedures:
|
||||
- name: "Altatás előtti vizsgálat"
|
||||
|
|
@ -47,7 +47,7 @@
|
|||
notes: "Az altatás kockázata miatt minden esetben szükséges előzetes vizsgálat."
|
||||
|
||||
- id: checkup
|
||||
reason: "Általános állapotfelmérés"
|
||||
visit: "Általános állapotfelmérés"
|
||||
keywords: ["examination", "checkup", "check-up", "general assessment"]
|
||||
procedures:
|
||||
- name: "Teljes fizikai vizsgálat"
|
||||
|
|
@ -56,7 +56,7 @@
|
|||
notes: "Évente legalább egyszer javasolt a rutin állapotfelmérés."
|
||||
|
||||
- id: allergy
|
||||
reason: "Allergiás tünetek vizsgálata"
|
||||
visit: "Allergiás tünetek vizsgálata"
|
||||
keywords: ["allergy", "itching", "rash", "redness", "allergic"]
|
||||
procedures:
|
||||
- name: "Bőr- és vérvizsgálat"
|
||||
|
|
@ -65,7 +65,7 @@
|
|||
notes: "Az allergia gyakran étel vagy környezeti tényező miatt alakul ki."
|
||||
|
||||
- id: ultrasound
|
||||
reason: "Ultrahangos vizsgálat"
|
||||
visit: "Ultrahangos vizsgálat"
|
||||
keywords: ["ultrasound", "abdomen", "examination", "US"]
|
||||
procedures:
|
||||
- name: "Ultrahang vizsgálat"
|
||||
|
|
@ -74,7 +74,7 @@
|
|||
notes: "Terhesség vagy belső szervi problémák vizsgálatára gyakran használt módszer."
|
||||
|
||||
- id: bloodwork
|
||||
reason: "Laborvizsgálat vérből"
|
||||
visit: "Laborvizsgálat vérből"
|
||||
keywords: ["blood", "blood test", "lab", "test"]
|
||||
procedures:
|
||||
- name: "Teljes vérkép"
|
||||
|
|
@ -83,7 +83,7 @@
|
|||
notes: "Sok más vizsgálat alapja a laboreredmény."
|
||||
|
||||
- id: xray
|
||||
reason: "Röntgenfelvétel"
|
||||
visit: "Röntgenfelvétel"
|
||||
keywords: ["x-ray", "bone", "scan", "fracture"]
|
||||
procedures:
|
||||
- name: "Röntgen vizsgálat"
|
||||
|
|
@ -92,7 +92,7 @@
|
|||
notes: "Törések, csontelváltozások vizsgálatára javasolt."
|
||||
|
||||
- id: diarrhea
|
||||
reason: "Hasmenés vizsgálata"
|
||||
visit: "Hasmenés vizsgálata"
|
||||
keywords: ["diarrhea", "vomiting", "stomach", "intestine"]
|
||||
procedures:
|
||||
- name: "Állatorvosi konzultáció"
|
||||
|
|
|
|||
103
llm.go
103
llm.go
|
|
@ -5,6 +5,7 @@ import (
|
|||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
|
@ -71,7 +72,7 @@ func (llm *LLMClient) ExtractKeywords(ctx context.Context, message string) (map[
|
|||
}
|
||||
|
||||
// DisambiguateBestMatch calls LLM to pick best match from candidates
|
||||
func (llm *LLMClient) DisambiguateBestMatch(ctx context.Context, message string, candidates []Reason) (string, error) {
|
||||
func (llm *LLMClient) DisambiguateBestMatch(ctx context.Context, message string, candidates []Visit) (string, error) {
|
||||
format := map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
|
|
@ -104,45 +105,105 @@ func (llm *LLMClient) DisambiguateBestMatch(ctx context.Context, message string,
|
|||
return visitReason, nil
|
||||
}
|
||||
|
||||
// openAICompletion calls Ollama API with prompt and structure, returns structured result
|
||||
// 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"
|
||||
}
|
||||
logrus.WithFields(logrus.Fields{"api_url": apiURL, "prompt": prompt, "format": format}).Info("[LLM] openAICompletion POST")
|
||||
body := map[string]interface{}{
|
||||
"model": llm.Model, // "qwen3:latest",
|
||||
"messages": []map[string]string{{"role": "user", "content": prompt}},
|
||||
"stream": false,
|
||||
"format": format,
|
||||
|
||||
isOpenAIStyle := strings.Contains(apiURL, "openrouter.ai") || strings.Contains(apiURL, "/v1/")
|
||||
|
||||
// Build request body depending on style
|
||||
var body map[string]interface{}
|
||||
if isOpenAIStyle {
|
||||
// OpenAI / OpenRouter style (chat.completions)
|
||||
// Use response_format with JSON schema when provided.
|
||||
responseFormat := map[string]interface{}{
|
||||
"type": "json_schema",
|
||||
"json_schema": map[string]interface{}{
|
||||
"name": "structured_output",
|
||||
"schema": format,
|
||||
},
|
||||
}
|
||||
body = map[string]interface{}{
|
||||
"model": llm.Model,
|
||||
"messages": []map[string]string{{"role": "user", "content": prompt}},
|
||||
"response_format": responseFormat,
|
||||
}
|
||||
} else {
|
||||
// Ollama structured output extension
|
||||
body = map[string]interface{}{
|
||||
"model": llm.Model,
|
||||
"messages": []map[string]string{{"role": "user", "content": prompt}},
|
||||
"stream": false,
|
||||
"format": format,
|
||||
}
|
||||
}
|
||||
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewBuffer(jsonBody))
|
||||
req.Header.Set("Authorization", "Bearer "+llm.APIKey)
|
||||
logrus.WithFields(logrus.Fields{"api_url": apiURL, "prompt": prompt, "is_openai_style": isOpenAIStyle}).Info("[LLM] completion POST")
|
||||
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewBuffer(jsonBody))
|
||||
if llm.APIKey != "" {
|
||||
// OpenRouter expects: Authorization: Bearer sk-... or OR-... depending on key type
|
||||
req.Header.Set("Authorization", "Bearer "+llm.APIKey)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
logrus.WithError(err).Error("[LLM] openAICompletion error")
|
||||
logrus.WithError(err).Error("[LLM] completion HTTP error")
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var result struct {
|
||||
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed reading response body: %w", err)
|
||||
}
|
||||
logrus.WithFields(logrus.Fields{"status": resp.StatusCode, "raw": string(raw)}).Debug("[LLM] completion raw response")
|
||||
|
||||
// Attempt Ollama format first (backwards compatible)
|
||||
var ollama struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
logrus.WithError(err).Error("[LLM] openAICompletion decode error")
|
||||
return "", err
|
||||
if err := json.Unmarshal(raw, &ollama); err == nil && ollama.Message.Content != "" {
|
||||
logrus.WithField("content", ollama.Message.Content).Info("[LLM] completion (ollama) parsed")
|
||||
return ollama.Message.Content, nil
|
||||
}
|
||||
if result.Message.Content == "" {
|
||||
logrus.Warn("[LLM] openAICompletion: no content returned")
|
||||
return "", nil
|
||||
|
||||
// Attempt OpenAI / OpenRouter style
|
||||
var openAI struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
Type string `json:"type"`
|
||||
} `json:"error"`
|
||||
}
|
||||
logrus.WithField("content", result.Message.Content).Info("[LLM] openAICompletion: got content")
|
||||
return result.Message.Content, nil
|
||||
if err := json.Unmarshal(raw, &openAI); err == nil {
|
||||
if openAI.Error != nil {
|
||||
return "", fmt.Errorf("provider error: %s (%s)", openAI.Error.Message, openAI.Error.Type)
|
||||
}
|
||||
if len(openAI.Choices) > 0 && openAI.Choices[0].Message.Content != "" {
|
||||
content := openAI.Choices[0].Message.Content
|
||||
logrus.WithField("content", content).Info("[LLM] completion (openai) parsed")
|
||||
return content, nil
|
||||
}
|
||||
}
|
||||
|
||||
// If still nothing, return error with snippet
|
||||
return "", fmt.Errorf("unrecognized LLM response format: %.200s", string(raw))
|
||||
}
|
||||
|
||||
// LLMClientAPI allows mocking LLMClient in other places
|
||||
|
|
@ -150,7 +211,7 @@ func (llm *LLMClient) openAICompletion(ctx context.Context, prompt string, forma
|
|||
|
||||
type LLMClientAPI interface {
|
||||
ExtractKeywords(ctx context.Context, message string) (map[string]interface{}, error)
|
||||
DisambiguateBestMatch(ctx context.Context, message string, candidates []Reason) (string, error)
|
||||
DisambiguateBestMatch(ctx context.Context, message string, candidates []Visit) (string, error)
|
||||
}
|
||||
|
||||
var _ LLMClientAPI = (*LLMClient)(nil)
|
||||
|
|
|
|||
12
log.go
12
log.go
|
|
@ -5,7 +5,7 @@ import (
|
|||
)
|
||||
|
||||
// logRequest logs incoming chat requests and extracted info
|
||||
func logRequest(req ChatRequest, keywords map[string]interface{}, candidates []Reason, bestID string, err error) {
|
||||
func logRequest(req ChatRequest, keywords map[string]interface{}, candidates []Visit, bestID string, err error) {
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"message": req.Message,
|
||||
"keywords": keywords,
|
||||
|
|
@ -15,7 +15,7 @@ func logRequest(req ChatRequest, keywords map[string]interface{}, candidates []R
|
|||
}).Info("Chat request trace")
|
||||
}
|
||||
|
||||
func getCandidateIDs(candidates []Reason) []string {
|
||||
func getCandidateIDs(candidates []Visit) []string {
|
||||
ids := make([]string, len(candidates))
|
||||
for i, c := range candidates {
|
||||
ids[i] = c.ID
|
||||
|
|
@ -23,17 +23,17 @@ func getCandidateIDs(candidates []Reason) []string {
|
|||
return ids
|
||||
}
|
||||
|
||||
// Procedure represents a single procedure for a visit reason
|
||||
// Procedure represents a single procedure for a visit
|
||||
type Procedure struct {
|
||||
Name string `yaml:"name" json:"name"`
|
||||
Price int `yaml:"price" json:"price"`
|
||||
DurationMin int `yaml:"duration_minutes" json:"duration_minutes"`
|
||||
}
|
||||
|
||||
// Reason represents a visit reason entry
|
||||
type Reason struct {
|
||||
// Visit represents a visit entry
|
||||
type Visit struct {
|
||||
ID string `yaml:"id" json:"id"`
|
||||
Reason string `yaml:"reason" json:"reason"`
|
||||
Visit string `yaml:"visit" json:"visit"`
|
||||
Keywords []string `yaml:"keywords" json:"keywords"`
|
||||
Procedures []Procedure `yaml:"procedures" json:"procedures"`
|
||||
Notes string `yaml:"notes" json:"notes,omitempty"`
|
||||
|
|
|
|||
4
main.go
4
main.go
|
|
@ -15,7 +15,7 @@ func main() {
|
|||
logrus.Fatalf("Failed to load config.yaml: %v", err)
|
||||
}
|
||||
logrus.Infof("Loaded config: %+v", appConfig)
|
||||
reasonDB := NewReasonDB()
|
||||
visitDB := NewVisitDB()
|
||||
|
||||
if err := loadUITemplate("ui.html"); err != nil {
|
||||
logrus.Fatalf("Failed to load ui.html: %v", err)
|
||||
|
|
@ -25,7 +25,7 @@ func main() {
|
|||
os.Getenv("OPENAI_BASE_URL"),
|
||||
os.Getenv("OPENAI_MODEL"),
|
||||
)
|
||||
chatService := NewChatService(llm, &reasonDB)
|
||||
chatService := NewChatService(llm, &visitDB)
|
||||
r := gin.Default()
|
||||
r.GET("/", func(c *gin.Context) {
|
||||
c.Status(200)
|
||||
|
|
|
|||
20
maindb.yaml
20
maindb.yaml
|
|
@ -1,5 +1,5 @@
|
|||
- id: deworming
|
||||
reason: "Féregtelenítés kutyának"
|
||||
visit: "Féregtelenítés kutyának"
|
||||
keywords: ["féreg", "féregtelenítés", "parazita", "bélféreg", "kutya"]
|
||||
procedures:
|
||||
- name: "Alap vérvizsgálat"
|
||||
|
|
@ -11,7 +11,7 @@
|
|||
notes: "A kezelés előtt vérvizsgálat szükséges a biztonságos gyógyszeradás miatt."
|
||||
|
||||
- id: vaccination
|
||||
reason: "Oltás kutyának"
|
||||
visit: "Oltás kutyának"
|
||||
keywords: ["oltás", "vakcina", "oltani", "kutyabetegség", "veszettség"]
|
||||
procedures:
|
||||
- name: "Általános állapotfelmérés"
|
||||
|
|
@ -23,7 +23,7 @@
|
|||
notes: "A kutyák oltási programja eltérhet az életkortól és korábbi oltásoktól függően."
|
||||
|
||||
- id: neutering
|
||||
reason: "Ivartalanítás macskának"
|
||||
visit: "Ivartalanítás macskának"
|
||||
keywords: ["ivartalanítás", "műtét", "macska", "kandúr", "nőstény"]
|
||||
procedures:
|
||||
- name: "Műtéti előzetes vizsgálat"
|
||||
|
|
@ -35,7 +35,7 @@
|
|||
notes: "A műtét után 2-3 nap pihenő szükséges."
|
||||
|
||||
- id: dental_cleaning
|
||||
reason: "Fogkő eltávolítás kutyának"
|
||||
visit: "Fogkő eltávolítás kutyának"
|
||||
keywords: ["fog", "fogkő", "fogsor", "fogtisztítás", "kutyafog"]
|
||||
procedures:
|
||||
- name: "Altatás előtti vizsgálat"
|
||||
|
|
@ -47,7 +47,7 @@
|
|||
notes: "Az altatás kockázata miatt minden esetben szükséges előzetes vizsgálat."
|
||||
|
||||
- id: checkup
|
||||
reason: "Általános állapotfelmérés"
|
||||
visit: "Általános állapotfelmérés"
|
||||
keywords: ["vizsgálat", "ellenőrzés", "checkup", "állapotfelmérés"]
|
||||
procedures:
|
||||
- name: "Teljes fizikai vizsgálat"
|
||||
|
|
@ -56,7 +56,7 @@
|
|||
notes: "Évente legalább egyszer javasolt a rutin állapotfelmérés."
|
||||
|
||||
- id: allergy
|
||||
reason: "Allergiás tünetek vizsgálata"
|
||||
visit: "Allergiás tünetek vizsgálata"
|
||||
keywords: ["allergia", "viszketés", "kiütés", "bőrpír", "allergiás"]
|
||||
procedures:
|
||||
- name: "Bőr- és vérvizsgálat"
|
||||
|
|
@ -65,7 +65,7 @@
|
|||
notes: "Az allergia gyakran étel vagy környezeti tényező miatt alakul ki."
|
||||
|
||||
- id: ultrasound
|
||||
reason: "Ultrahangos vizsgálat"
|
||||
visit: "Ultrahangos vizsgálat"
|
||||
keywords: ["ultrahang", "has", "vizsgálat", "UH"]
|
||||
procedures:
|
||||
- name: "Ultrahang vizsgálat"
|
||||
|
|
@ -74,7 +74,7 @@
|
|||
notes: "Terhesség vagy belső szervi problémák vizsgálatára gyakran használt módszer."
|
||||
|
||||
- id: bloodwork
|
||||
reason: "Laborvizsgálat vérből"
|
||||
visit: "Laborvizsgálat vérből"
|
||||
keywords: ["vér", "vérvizsgálat", "labor", "teszt"]
|
||||
procedures:
|
||||
- name: "Teljes vérkép"
|
||||
|
|
@ -83,7 +83,7 @@
|
|||
notes: "Sok más vizsgálat alapja a laboreredmény."
|
||||
|
||||
- id: xray
|
||||
reason: "Röntgenfelvétel"
|
||||
visit: "Röntgenfelvétel"
|
||||
keywords: ["röntgen", "csont", "felvétel", "törés"]
|
||||
procedures:
|
||||
- name: "Röntgen vizsgálat"
|
||||
|
|
@ -92,7 +92,7 @@
|
|||
notes: "Törések, csontelváltozások vizsgálatára javasolt."
|
||||
|
||||
- id: diarrhea
|
||||
reason: "Hasmenés vizsgálata"
|
||||
visit: "Hasmenés vizsgálata"
|
||||
keywords: ["hasmenés", "hányás", "gyomor", "bél"]
|
||||
procedures:
|
||||
- name: "Állatorvosi konzultáció"
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
{"storage":"boltdb","index_type":"scorch"}
|
||||
Binary file not shown.
Binary file not shown.
Loading…
Reference in New Issue