Compare commits

...

10 Commits

Author SHA1 Message Date
benzjeremy
efcf5ebb54 ci: update Go version to 1.27 in workflows to match go.mod
Some checks failed
CI (Arch Linux) / build (push) Has been cancelled
2026-09-17 22:34:11 +02:00
benzjeremy
f1e23da524 feat: add KI-Tutor HTTP server with model selection and Ollama integration
- New internal/domain package: ModelInfo, SelectBestModel, ChooseModelForCodeAnalysis
- Currency conversion (ValidatePriceForCurrency) for subscription pricing
- Course/Section/Chapter domain types for lesson content
- Ollama model discovery and code analysis via AnalyzeWithModel
- internal/ui: StartTutorServer with /tutor/query endpoint
- cmd/learn: --tutor-port flag to launch KI-Tutor server
- Unit tests for model selection, currency conversion, and tutor server

Co-Authored-By: Google Antigravity (AI Pair Programming)
2026-09-17 22:31:08 +02:00
benzjeremy
2f030ee580 fix(ci): remove go mod tidy step, use build/test/vet only 2026-09-17 09:43:48 +02:00
benzjeremy
cded667ecc fix(ci): remove npm-dependent ci.yml, use Go-only ci_arch 2026-09-17 09:41:19 +02:00
benzjeremy
ec6086dfb3 Add CI report 2026-09-16 23:26:51 +02:00
benzjeremy
043b22b32d Add CI report 2026-09-16 23:26:37 +02:00
benzjeremy
8eda8b314a Add CI report 2026-09-16 22:38:57 +02:00
benzjeremy
352a4892ab Add standardized CI pipeline & report 2026-09-16 22:21:22 +02:00
benzjeremy
1cb9dda1e6 Add CI report 2026-09-16 21:49:15 +02:00
benzjeremy
ff831cf5d0 Add standardized CI pipeline 2026-09-16 21:49:13 +02:00
15 changed files with 447 additions and 49 deletions

View File

@ -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
View 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 ./...

View File

@ -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

View File

@ -1,10 +1,13 @@
# 📱 learn
[![CI Pipeline](https://github.com/benzjeremy/learn/actions/workflows/ci.yml/badge.svg)](https://github.com/benzjeremy/learn/actions/workflows/ci.yml)
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)
[![License: GPL-3.0](https://img.shields.io/badge/License-GPL--3.0-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)
[![Status: Pre-Release](https://img.shields.io/badge/Status-Pre--Release%20%2F%20WIP-orange.svg)](https://benzjeremy.github.io/learn/)
[![Go Report Card](https://goreportcard.com/badge/github.com/benzjeremy/learn)](https://goreportcard.com/report/github.com/benzjeremy/learn)
[![Website](https://img.shields.io/badge/Web-Live%20Cockpit-brightgreen)](https://benzjeremy.github.io/learn/)
[![Awesome Go](https://awesome.re/mentioned-badge.svg)](https://github.com/avelino/awesome-go)
[![Go Version](https://img.shields.io/badge/Go-1.22%2B-00ADD8?logo=go&logoColor=white)](https://go.dev/)
[![Security: Zero-Dummy](https://img.shields.io/badge/Security-Zero--Dummy--Standard-10b981.svg)](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).

View File

@ -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 KITutor HTTP server (Phase5).
tutorPortFlag := flag.Int("tutor-port", 0, "Start KITutor 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("KITutor server started on port %d (background)\n", port)
}
if *helpFlag {
printHelp()
return

View File

@ -0,0 +1,5 @@
# CI Report
Commit: 043b22b32dde7c40f68c92f232cc678e4afe03da
Standardisierte CI-Pipeline vorhanden.

View 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
}

View 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 (hardcoded based on current benchmarks):
// 1. any model containing "mistral" (caseinsensitive)
// 2. any model containing "llama" (caseinsensitive)
// 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]
}

View 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)
}
}
}

View 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 KITutorAbonnementPreisValidierung (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
}

View 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
View File

@ -0,0 +1,36 @@
package domain
// ModelInfo describes a language model candidate for the KITutor.
type ModelInfo struct {
Name string // e.g. "mistral-7b-instruct"
SupportsCodeAnalysis bool // true if the model can handle codeanalysis 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 zerovalue 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 codeanalysis support)
return models[0]
}

View 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
View 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 echostyle 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
}

View 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 wellformed 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)
}