Files
jbergner 112c13f9f0
release-tag / release-image (push) Successful in 3m44s
init
2026-08-28 11:29:58 +02:00

163 lines
5.0 KiB
Go

package main
import (
"fmt"
"math"
"strings"
"homehub/internal/ollama"
"homehub/internal/rag"
"homehub/internal/store"
)
// contextConfig trennt das physische Ollama-Kontextfenster vom logischen
// Gesprächs-, State- und RAG-Budget. So kann ein kleines lokales Modell nicht
// durch einen riesigen HOME-STATE die letzten Nutzeraussagen aus dem Kontext
// drücken.
type contextConfig struct {
NumCtx int `json:"numCtx"`
NumPredict int `json:"numPredict"`
Temperature float64 `json:"temperature"`
PlannerNumCtx int `json:"plannerNumCtx"`
PlannerNumPredict int `json:"plannerNumPredict"`
PlannerTemperature float64 `json:"plannerTemperature"`
AgentNumCtx int `json:"agentNumCtx"`
AgentNumPredict int `json:"agentNumPredict"`
AgentTemperature float64 `json:"agentTemperature"`
AgentMaxSteps int `json:"agentMaxSteps"`
HistoryTokens int `json:"historyTokens"`
PlannerHistoryTokens int `json:"plannerHistoryTokens"`
StateTokens int `json:"stateTokens"`
RAGTokens int `json:"ragTokens"`
}
func defaultContextConfig() contextConfig {
return contextConfig{
NumCtx: 16384, NumPredict: 1200, Temperature: 0.15,
PlannerNumCtx: 8192, PlannerNumPredict: 700, PlannerTemperature: 0.0,
AgentNumCtx: 16384, AgentNumPredict: 700, AgentTemperature: 0.0, AgentMaxSteps: 6,
HistoryTokens: 3200, PlannerHistoryTokens: 1800,
StateTokens: 3200, RAGTokens: 4200,
}
}
func (c contextConfig) chatOptions() ollama.GenerationOptions {
return ollama.GenerationOptions{NumCtx: c.NumCtx, NumPredict: c.NumPredict, Temperature: c.Temperature}
}
func (c contextConfig) plannerOptions() ollama.GenerationOptions {
return ollama.GenerationOptions{NumCtx: c.PlannerNumCtx, NumPredict: c.PlannerNumPredict, Temperature: c.PlannerTemperature}
}
func (c contextConfig) agentOptions() ollama.GenerationOptions {
return ollama.GenerationOptions{NumCtx: c.AgentNumCtx, NumPredict: c.AgentNumPredict, Temperature: c.AgentTemperature}
}
// estimateTokens ist absichtlich konservativ. Für deutsche Prosa sind ca. 3-4
// Zeichen pro Token eine brauchbare lokale Näherung, ohne einen Modell-spezifischen
// Tokenizer in den Home-Core einzubauen.
func estimateTokens(s string) int {
r := len([]rune(strings.TrimSpace(s)))
if r == 0 {
return 0
}
return int(math.Ceil(float64(r)/3.5)) + 4
}
func trimToTokenBudget(s string, budget int) string {
if budget <= 0 || estimateTokens(s) <= budget {
return s
}
runes := []rune(s)
maxRunes := int(float64(budget-8) * 3.5)
if maxRunes < 64 {
maxRunes = 64
}
if maxRunes >= len(runes) {
return s
}
// Bei State/RAG ist der Anfang wichtiger als ein zufälliger Mittelteil.
return strings.TrimSpace(string(runes[:maxRunes])) + "\n[… Kontext gekürzt …]"
}
// conversationHistory liefert echte user/assistant Messages an /api/chat statt
// den Verlauf als Text in einen einzelnen Prompt zu kleben. Neueste Turns haben
// Priorität. Die aktuell bereits persistierte Nutzerzeile wird nicht dupliziert.
func conversationHistory(chat []store.ChatTurn, current string, budget int) []ollama.Turn {
if budget <= 0 || len(chat) == 0 {
return nil
}
current = strings.TrimSpace(current)
selected := make([]ollama.Turn, 0, 16)
used := 0
skippedCurrent := false
for i := len(chat) - 1; i >= 0; i-- {
t := chat[i]
role := strings.ToLower(strings.TrimSpace(t.Role))
if role != "user" && role != "assistant" {
continue
}
content := strings.TrimSpace(t.Content)
if content == "" {
continue
}
if !skippedCurrent && role == "user" && content == current {
skippedCurrent = true
continue
}
cost := estimateTokens(content) + 6
if used+cost > budget {
if len(selected) > 0 {
break
}
content = trimToTokenBudget(content, budget-6)
cost = estimateTokens(content) + 6
}
selected = append(selected, ollama.Turn{Role: role, Content: content})
used += cost
}
// Rückwärts gesammelt -> chronologische Reihenfolge herstellen.
for i, j := 0, len(selected)-1; i < j; i, j = i+1, j-1 {
selected[i], selected[j] = selected[j], selected[i]
}
return selected
}
func estimateHistoryTokens(history []ollama.Turn) int {
total := 0
for _, h := range history {
total += estimateTokens(h.Content) + 6
}
return total
}
func ragContext(hits []rag.Hit, budget int) string {
if len(hits) == 0 {
return "(keine passenden Dokumentauszüge)"
}
var b strings.Builder
remaining := budget
for i, h := range hits {
header := fmt.Sprintf("[%d] Quelle: %s | Score %.3f\n", i+1, h.Name, h.Score)
hc := estimateTokens(header)
if remaining-hc < 40 {
break
}
remaining -= hc
text := trimToTokenBudget(h.Text, remaining)
tc := estimateTokens(text)
if tc > remaining {
break
}
b.WriteString(header)
b.WriteString(text)
b.WriteString("\n\n")
remaining -= tc
if remaining < 60 {
break
}
}
if b.Len() == 0 {
return "(RAG-Treffer außerhalb des Tokenbudgets)"
}
return strings.TrimSpace(b.String())
}