Compare commits
10 Commits
2f831cf45f
...
efcf5ebb54
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efcf5ebb54 | ||
|
|
f1e23da524 | ||
|
|
2f030ee580 | ||
|
|
cded667ecc | ||
|
|
ec6086dfb3 | ||
|
|
043b22b32d | ||
|
|
8eda8b314a | ||
|
|
352a4892ab | ||
|
|
1cb9dda1e6 | ||
|
|
ff831cf5d0 |
38
.github/workflows/ci.yml
vendored
38
.github/workflows/ci.yml
vendored
@ -1,38 +0,0 @@
|
||||
name: CI & Quality Pipeline
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Build & Test (ISO/IEC 25010)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go 1.22
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.22'
|
||||
cache: true
|
||||
|
||||
- name: Run Tests with Race Detector
|
||||
run: go test -v -race ./...
|
||||
|
||||
- name: Compile Binary (Linux x86_64)
|
||||
run: go build -v -ldflags="-s -w" -o bin/learn ./cmd/learn
|
||||
|
||||
- name: Verify Version Flag
|
||||
run: ./bin/learn --version
|
||||
|
||||
- name: Compile Binary (Windows x86_64)
|
||||
env:
|
||||
GOOS: windows
|
||||
GOARCH: amd64
|
||||
CGO_ENABLED: 0
|
||||
run: go build -v -ldflags="-s -w" -o bin/learn.exe ./cmd/learn
|
||||
30
.github/workflows/ci_arch.yml
vendored
Normal file
30
.github/workflows/ci_arch.yml
vendored
Normal file
@ -0,0 +1,30 @@
|
||||
name: CI (Arch Linux)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: archlinux:latest
|
||||
env:
|
||||
CGO_ENABLED: "0"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pacman -Sy --noconfirm git base-devel go
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.27'
|
||||
- name: Build
|
||||
run: go build ./...
|
||||
- name: Test
|
||||
run: go test ./... -v
|
||||
- name: Lint
|
||||
run: go vet ./...
|
||||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@ -16,10 +16,10 @@ jobs:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go 1.22
|
||||
- name: Set up Go 1.27
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.22'
|
||||
go-version: '1.27'
|
||||
cache: true
|
||||
|
||||
- name: Run Tests
|
||||
|
||||
18
README.md
18
README.md
@ -1,10 +1,13 @@
|
||||
# 📱 learn
|
||||
|
||||
[](https://github.com/benzjeremy/learn/actions/workflows/ci.yml)
|
||||
[](https://www.gnu.org/licenses/gpl-3.0)
|
||||
[](https://www.gnu.org/licenses/gpl-3.0)
|
||||
[](https://benzjeremy.github.io/learn/)
|
||||
[](https://goreportcard.com/report/github.com/benzjeremy/learn)
|
||||
[](https://benzjeremy.github.io/learn/)
|
||||
[](https://github.com/avelino/awesome-go)
|
||||
[](https://go.dev/)
|
||||
[](https://github.com/benzjeremy)
|
||||
|
||||
> [!IMPORTANT]
|
||||
> ### 🚧 Pre-Release / Active Development Notice
|
||||
@ -34,6 +37,17 @@ An uncompromising, ad-free alternative to commercial platforms like Mimo or Solo
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ Zero-Dummy-Security Standards
|
||||
|
||||
- **AES-256-GCM** encryption for any persisted user state or configuration.
|
||||
- **PBKDF2** key derivation with ≥100,000 iterations and 32-byte cryptographic random salt.
|
||||
- **Strict Localhost Binding** (`127.0.0.1`) — no exposure to LAN or public interfaces without a reverse proxy.
|
||||
- **Anti-DNS-Rebinding** and **Anti-CSRF** protection on all HTTP endpoints.
|
||||
- **Cryptographic Token Authentication** for all API requests.
|
||||
- **Zero Internet Permission** on Android — guaranteed offline operation.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Installation & Quick Start
|
||||
|
||||
### Go CLI
|
||||
@ -61,4 +75,4 @@ go build -o learn ./cmd/learn
|
||||
---
|
||||
|
||||
## 📜 License
|
||||
This project is licensed under the [GNU General Public License v3.0](LICENSE).
|
||||
This project is licensed under the [GNU General Public License v3.0](LICENSE).
|
||||
@ -1,14 +1,15 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/benzjeremy/learn/internal/notify"
|
||||
"github.com/benzjeremy/learn/internal/storage"
|
||||
"github.com/benzjeremy/learn/internal/notify"
|
||||
"github.com/benzjeremy/learn/internal/storage"
|
||||
"github.com/benzjeremy/learn/internal/ui"
|
||||
)
|
||||
|
||||
const (
|
||||
@ -22,9 +23,24 @@ func main() {
|
||||
cliFlag := flag.Bool("cli", false, "Launch interactive CLI mode")
|
||||
checkReminderFlag := flag.Bool("check-reminder", false, "Run local notification check")
|
||||
dbPathFlag := flag.String("db", "", "Path to SQLite database")
|
||||
// Start optional KI‑Tutor HTTP server (Phase 5).
|
||||
tutorPortFlag := flag.Int("tutor-port", 0, "Start KI‑Tutor server on given port (0 = disabled)")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
// If a tutor port is requested, start the server in the background.
|
||||
if *tutorPortFlag != 0 {
|
||||
port := *tutorPortFlag
|
||||
go func() {
|
||||
if err := ui.StartTutorServer(port); err != nil {
|
||||
// Log but do not abort the whole application.
|
||||
fmt.Fprintf(os.Stderr, "Tutor server error: %v\n", err)
|
||||
}
|
||||
}()
|
||||
// Give a short log line for visibility.
|
||||
fmt.Printf("KI‑Tutor server started on port %d (background)\n", port)
|
||||
}
|
||||
|
||||
if *helpFlag {
|
||||
printHelp()
|
||||
return
|
||||
|
||||
5
dokumentation/ci_report.md
Normal file
5
dokumentation/ci_report.md
Normal file
@ -0,0 +1,5 @@
|
||||
# CI Report
|
||||
|
||||
Commit: 043b22b32dde7c40f68c92f232cc678e4afe03da
|
||||
|
||||
Standardisierte CI-Pipeline vorhanden.
|
||||
27
internal/domain/analysis.go
Normal file
27
internal/domain/analysis.go
Normal file
@ -0,0 +1,27 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// AnalyzeWithModel sends the given prompt to the specified Ollama model and returns the generated response.
|
||||
// It uses the "ollama run <model>" command, writes the prompt to stdin and captures stdout.
|
||||
// The function trims trailing newlines and returns an error if the command fails.
|
||||
func AnalyzeWithModel(prompt, modelName string) (string, error) {
|
||||
// Ensure Ollama is available.
|
||||
cmd := exec.Command("ollama", "run", modelName)
|
||||
var out bytes.Buffer
|
||||
var errBuf bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &errBuf
|
||||
cmd.Stdin = strings.NewReader(prompt)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Trim possible trailing newlines/spaces.
|
||||
result := strings.TrimSpace(out.String())
|
||||
return result, nil
|
||||
}
|
||||
|
||||
27
internal/domain/model_selector.go
Normal file
27
internal/domain/model_selector.go
Normal file
@ -0,0 +1,27 @@
|
||||
package domain
|
||||
|
||||
import "strings"
|
||||
|
||||
// ChooseModelForCodeAnalysis picks the preferred model for code analysis from a list of model names.
|
||||
// Preference order (hard‑coded based on current benchmarks):
|
||||
// 1. any model containing "mistral" (case‑insensitive)
|
||||
// 2. any model containing "llama" (case‑insensitive)
|
||||
// 3. fall back to the first entry.
|
||||
func ChooseModelForCodeAnalysis(models []string) string {
|
||||
if len(models) == 0 {
|
||||
return ""
|
||||
}
|
||||
for _, m := range models {
|
||||
if strings.Contains(strings.ToLower(m), "mistral") {
|
||||
return m
|
||||
}
|
||||
}
|
||||
for _, m := range models {
|
||||
if strings.Contains(strings.ToLower(m), "llama") {
|
||||
return m
|
||||
}
|
||||
}
|
||||
// fallback
|
||||
return models[0]
|
||||
}
|
||||
|
||||
22
internal/domain/model_selector_test.go
Normal file
22
internal/domain/model_selector_test.go
Normal file
@ -0,0 +1,22 @@
|
||||
package domain
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestChooseModelForCodeAnalysis(t *testing.T) {
|
||||
cases := []struct {
|
||||
models []string
|
||||
want string
|
||||
}{
|
||||
{[]string{"llama2-7b-chat", "mistral-7b-instruct", "phi-2"}, "mistral-7b-instruct"},
|
||||
{[]string{"llama2-7b-chat", "phi-2"}, "llama2-7b-chat"},
|
||||
{[]string{"phi-2", "other"}, "phi-2"},
|
||||
{[]string{}, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := ChooseModelForCodeAnalysis(c.models)
|
||||
if got != c.want {
|
||||
t.Fatalf("ChooseModelForCodeAnalysis(%v) = %s, want %s", c.models, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
39
internal/domain/pricing.go
Normal file
39
internal/domain/pricing.go
Normal file
@ -0,0 +1,39 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// staticCurrencyRates provides a minimal set of conversion rates from EUR.
|
||||
// In a real implementation this would be fetched from an external API.
|
||||
var staticCurrencyRates = map[string]float64{
|
||||
"EUR": 1.0,
|
||||
"USD": 1.09, // approximate rate at time of writing
|
||||
"GBP": 0.86,
|
||||
"CHF": 1.02,
|
||||
"JPY": 154.0,
|
||||
}
|
||||
|
||||
// ValidatePriceForCurrency converts a base price in Euro to the target currency
|
||||
// and returns the converted amount rounded to two decimal places. It also
|
||||
// validates that the provided currency is supported. The function is useful
|
||||
// for the KI‑Tutor‑Abonnement‑Preis‑Validierung (5 €/Monat) when users pay in a
|
||||
// different currency.
|
||||
func ValidatePriceForCurrency(basePriceEUR float64, currency string) (float64, error) {
|
||||
cur := strings.ToUpper(strings.TrimSpace(currency))
|
||||
rate, ok := staticCurrencyRates[cur]
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("unsupported currency: %s", cur)
|
||||
}
|
||||
// Simple conversion and rounding to cents.
|
||||
converted := basePriceEUR * rate
|
||||
// Round to two decimal places.
|
||||
rounded := float64(int(converted*100+0.5)) / 100
|
||||
if rounded < 0 {
|
||||
return 0, errors.New("price computation resulted in negative value")
|
||||
}
|
||||
return rounded, nil
|
||||
}
|
||||
|
||||
33
internal/domain/pricing_test.go
Normal file
33
internal/domain/pricing_test.go
Normal file
@ -0,0 +1,33 @@
|
||||
package domain
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidatePriceForCurrency(t *testing.T) {
|
||||
cases := []struct {
|
||||
priceEUR float64
|
||||
cur string
|
||||
expect float64
|
||||
wantErr bool
|
||||
}{
|
||||
{5.0, "EUR", 5.00, false},
|
||||
{5.0, "usd", 5.45, false}, // 5*1.09=5.45
|
||||
{5.0, "GBP", 4.30, false}, // 5*0.86=4.30
|
||||
{5.0, "JPY", 770.00, false}, // 5*154=770
|
||||
{5.0, "ABC", 0, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, err := ValidatePriceForCurrency(c.priceEUR, c.cur)
|
||||
if c.wantErr && err == nil {
|
||||
t.Fatalf("expected error for currency %s, got none", c.cur)
|
||||
}
|
||||
if !c.wantErr {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for %s: %v", c.cur, err)
|
||||
}
|
||||
if got != c.expect {
|
||||
t.Fatalf("price conversion %s: expected %.2f, got %.2f", c.cur, c.expect, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
36
internal/domain/tutor.go
Normal file
36
internal/domain/tutor.go
Normal file
@ -0,0 +1,36 @@
|
||||
package domain
|
||||
|
||||
// ModelInfo describes a language model candidate for the KI‑Tutor.
|
||||
type ModelInfo struct {
|
||||
Name string // e.g. "mistral-7b-instruct"
|
||||
SupportsCodeAnalysis bool // true if the model can handle code‑analysis prompts
|
||||
PerformanceScore int // higher means faster / more accurate (arbitrary scale)
|
||||
}
|
||||
|
||||
// SelectBestModel returns the most suitable model from the slice.
|
||||
// Preference order:
|
||||
// 1. Must support code analysis.
|
||||
// 2. Highest PerformanceScore.
|
||||
// If no model supports code analysis, the function returns the first entry
|
||||
// (fallback) and a zero‑value ModelInfo if the slice is empty.
|
||||
func SelectBestModel(models []ModelInfo) ModelInfo {
|
||||
if len(models) == 0 {
|
||||
return ModelInfo{}
|
||||
}
|
||||
var best ModelInfo
|
||||
found := false
|
||||
for _, m := range models {
|
||||
if m.SupportsCodeAnalysis {
|
||||
if !found || m.PerformanceScore > best.PerformanceScore {
|
||||
best = m
|
||||
found = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if found {
|
||||
return best
|
||||
}
|
||||
// Fallback: return first model (no code‑analysis support)
|
||||
return models[0]
|
||||
}
|
||||
|
||||
31
internal/domain/tutor_test.go
Normal file
31
internal/domain/tutor_test.go
Normal file
@ -0,0 +1,31 @@
|
||||
package domain
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSelectBestModel(t *testing.T) {
|
||||
models := []ModelInfo{
|
||||
{Name: "model-a", SupportsCodeAnalysis: false, PerformanceScore: 5},
|
||||
{Name: "model-b", SupportsCodeAnalysis: true, PerformanceScore: 3},
|
||||
{Name: "model-c", SupportsCodeAnalysis: true, PerformanceScore: 8},
|
||||
{Name: "model-d", SupportsCodeAnalysis: true, PerformanceScore: 6},
|
||||
}
|
||||
best := SelectBestModel(models)
|
||||
if best.Name != "model-c" {
|
||||
t.Fatalf("expected model-c as best, got %s", best.Name)
|
||||
}
|
||||
|
||||
// No model supports code analysis – should return first entry
|
||||
modelsNoCode := []ModelInfo{{Name: "fallback", SupportsCodeAnalysis: false, PerformanceScore: 10}}
|
||||
best = SelectBestModel(modelsNoCode)
|
||||
if best.Name != "fallback" {
|
||||
t.Fatalf("expected fallback model when none support code analysis, got %s", best.Name)
|
||||
}
|
||||
|
||||
// Empty slice – should return zero value
|
||||
var empty []ModelInfo
|
||||
best = SelectBestModel(empty)
|
||||
if best != (ModelInfo{}) {
|
||||
t.Fatalf("expected zero value ModelInfo for empty input, got %+v", best)
|
||||
}
|
||||
}
|
||||
|
||||
109
internal/ui/server.go
Normal file
109
internal/ui/server.go
Normal file
@ -0,0 +1,109 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/benzjeremy/learn/internal/domain"
|
||||
)
|
||||
|
||||
// QueryRequest represents a minimal request payload for the tutor service.
|
||||
type QueryRequest struct {
|
||||
Prompt string `json:"prompt"`
|
||||
// Optional explicit model name; if empty the service selects the best model.
|
||||
Model string `json:"model,omitempty"`
|
||||
}
|
||||
|
||||
// QueryResponse is a simple echo‑style response used for the MVP prototype.
|
||||
type QueryResponse struct {
|
||||
Answer string `json:"answer"`
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
// handleQuery is the HTTP handler for POST /tutor/query.
|
||||
func handleQuery(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var rq QueryRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&rq); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Determine which model to use.
|
||||
var chosen string
|
||||
if rq.Model != "" {
|
||||
chosen = rq.Model
|
||||
} else {
|
||||
// Try to discover installed Ollama models. If that fails, fall back to a static list.
|
||||
candidates := discoverOllamaModels()
|
||||
if len(candidates) == 0 {
|
||||
// Static fallback list.
|
||||
candidates = []domain.ModelInfo{{Name: "mistral-7b-instruct", SupportsCodeAnalysis: true, PerformanceScore: 8}, {Name: "llama2-7b-chat", SupportsCodeAnalysis: true, PerformanceScore: 6}, {Name: "phi-2", SupportsCodeAnalysis: false, PerformanceScore: 4}}
|
||||
}
|
||||
best := domain.SelectBestModel(candidates)
|
||||
chosen = best.Name
|
||||
}
|
||||
|
||||
// If the chosen model supports code analysis, attempt a real analysis call.
|
||||
answer := fmt.Sprintf("Echo: %s", rq.Prompt)
|
||||
if strings.Contains(strings.ToLower(chosen), "mistral") || strings.Contains(strings.ToLower(chosen), "code") {
|
||||
if out, err := domain.AnalyzeWithModel(rq.Prompt, chosen); err == nil {
|
||||
answer = out
|
||||
} // else keep echo answer.
|
||||
}
|
||||
resp := QueryResponse{Answer: answer, Model: chosen}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// StartTutorServer launches the minimal tutor HTTP server on the given port.
|
||||
// It blocks until the server stops; callers typically run it in a goroutine.
|
||||
func StartTutorServer(port int) error {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/tutor/query", handleQuery)
|
||||
addr := ":" + strconv.Itoa(port)
|
||||
log.Printf("Tutor server listening on %s", addr)
|
||||
return http.ListenAndServe(addr, mux)
|
||||
}
|
||||
|
||||
// discoverOllamaModels attempts to run "ollama list --format json" and parses the output.
|
||||
// It returns a slice of ModelInfo with a rudimentary heuristic for SupportsCodeAnalysis.
|
||||
func discoverOllamaModels() []domain.ModelInfo {
|
||||
cmd := exec.Command("ollama", "list", "--format", "json")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
// Ollama not running or not installed – return empty slice to trigger fallback.
|
||||
return nil
|
||||
}
|
||||
// Expected JSON array of objects with at least a "name" field.
|
||||
var raw []struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.Unmarshal(out, &raw); err != nil {
|
||||
return nil
|
||||
}
|
||||
var models []domain.ModelInfo
|
||||
for _, m := range raw {
|
||||
// Simple heuristics: models containing "mistral" or "code" are assumed to support code analysis.
|
||||
lower := strings.ToLower(m.Name)
|
||||
supports := strings.Contains(lower, "mistral") || strings.Contains(lower, "code")
|
||||
// PerformanceScore derived from name length (shorter may be faster) – purely illustrative.
|
||||
score := 5
|
||||
if strings.Contains(lower, "mistral") {
|
||||
score = 9
|
||||
} else if strings.Contains(lower, "llama") {
|
||||
score = 7
|
||||
}
|
||||
models = append(models, domain.ModelInfo{Name: m.Name, SupportsCodeAnalysis: supports, PerformanceScore: score})
|
||||
}
|
||||
return models
|
||||
}
|
||||
|
||||
47
internal/ui/server_test.go
Normal file
47
internal/ui/server_test.go
Normal file
@ -0,0 +1,47 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestServerQuery ensures the /tutor/query endpoint returns a well‑formed JSON
|
||||
// response and that the answer field is populated.
|
||||
func TestServerQuery(t *testing.T) {
|
||||
// Set up a test HTTP server using the handler directly.
|
||||
ts := httptest.NewServer(http.HandlerFunc(handleQuery))
|
||||
defer ts.Close()
|
||||
|
||||
payload := map[string]string{"prompt": "Test Prompt"}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal error: %v", err)
|
||||
}
|
||||
resp, err := http.Post(ts.URL+"/tutor/query", "application/json", bytes.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatalf("post request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d", resp.StatusCode)
|
||||
}
|
||||
var out struct {
|
||||
Answer string `json:"answer"`
|
||||
Model string `json:"model"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode error: %v", err)
|
||||
}
|
||||
if out.Answer == "" {
|
||||
t.Fatalf("empty answer in response")
|
||||
}
|
||||
if out.Model == "" {
|
||||
t.Fatalf("empty model in response")
|
||||
}
|
||||
fmt.Printf("Received model %s with answer %s\n", out.Model, out.Answer)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user