163 lines
5.0 KiB
Go
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())
|
|
}
|