mirror of
https://github.com/prosolis/gogobee.git
synced 2026-09-14 10:51:09 +00:00
llm: route every LLM caller through a shared backend client
Replaces the per-plugin Ollama HTTP calls with internal/llm, which picks a backend from the environment (vLLM or Ollama) behind one Chat interface, plus internal/plugin/llm_client.go as the plugin-facing wrapper. Startup now logs llm_backend/llm_endpoint/llm_model instead of the two OLLAMA_* vars, which no longer describe where inference actually goes. These files were already running in prod from the vLLM migration but had never been committed; this is that live state, byte-for-byte.
This commit is contained in:
@@ -0,0 +1,161 @@
|
|||||||
|
// Package llm wraps the local inference endpoint behind a backend-agnostic
|
||||||
|
// interface. Two concrete backends — Ollama (native /api/generate) and vLLM
|
||||||
|
// (OpenAI-compatible /v1/chat/completions) — implement Client; plugin code
|
||||||
|
// calls the interface only and never knows which one is active.
|
||||||
|
//
|
||||||
|
// Deliberately not routed through internal/safehttp: that client blocks
|
||||||
|
// RFC1918 and loopback destinations to defend against SSRF from feed-supplied
|
||||||
|
// URLs, and the inference endpoint is precisely such a destination. The URL
|
||||||
|
// here comes from our own config, never from user input.
|
||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Request is the backend-neutral generation request. Zero-valued fields fall
|
||||||
|
// back to backend defaults.
|
||||||
|
type Request struct {
|
||||||
|
// Prompt is a single raw instruction. The vLLM backend wraps it as one
|
||||||
|
// user message so the model's chat template still applies; sending it to
|
||||||
|
// /v1/completions instead would bypass the template and degrade an
|
||||||
|
// instruction-tuned model badly.
|
||||||
|
Prompt string
|
||||||
|
// System is an optional system message. Empty means none, which keeps the
|
||||||
|
// single-message shape the majority of callers use.
|
||||||
|
System string
|
||||||
|
// NumCtx is the per-request context window. Ollama honours it directly;
|
||||||
|
// vLLM fixes the window server-side at launch (--max-model-len), so this
|
||||||
|
// is ignored there rather than silently misapplied.
|
||||||
|
NumCtx int
|
||||||
|
// MaxTokens caps the completion length. 0 means the backend default.
|
||||||
|
MaxTokens int
|
||||||
|
// Temperature is passed through when non-zero.
|
||||||
|
Temperature float64
|
||||||
|
// Timeout overrides the client's default per-request budget.
|
||||||
|
Timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client is the single surface plugin code depends on.
|
||||||
|
type Client interface {
|
||||||
|
// Generate returns the full completion in one shot. Reasoning blocks are
|
||||||
|
// stripped before returning (see StripThink) — every caller in this repo
|
||||||
|
// wants the visible answer, not the chain of thought.
|
||||||
|
Generate(ctx context.Context, req Request) (string, error)
|
||||||
|
// Model reports the configured model id, for logging and /botinfo.
|
||||||
|
Model() string
|
||||||
|
// Ping reports the model ids the backend is currently serving. Used by
|
||||||
|
// /botinfo for a liveness line; the two backends expose this on different
|
||||||
|
// paths (/api/tags vs /v1/models), which is exactly the sort of difference
|
||||||
|
// this interface exists to hide.
|
||||||
|
Ping(ctx context.Context) ([]string, error)
|
||||||
|
// Backend reports "ollama" or "vllm", for logging and /botinfo.
|
||||||
|
Backend() string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config selects and configures a backend.
|
||||||
|
type Config struct {
|
||||||
|
Backend string // "ollama" | "vllm"
|
||||||
|
Endpoint string
|
||||||
|
Model string
|
||||||
|
Timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultTimeout matches the budget the pre-refactor callOllama used.
|
||||||
|
const DefaultTimeout = 120 * time.Second
|
||||||
|
|
||||||
|
// ConfigFromEnv reads backend settings, preferring the new LLM_* names and
|
||||||
|
// falling back to the legacy OLLAMA_* pair so an existing deployment keeps
|
||||||
|
// working untouched after this refactor.
|
||||||
|
func ConfigFromEnv() Config {
|
||||||
|
backend := strings.ToLower(strings.TrimSpace(os.Getenv("LLM_BACKEND")))
|
||||||
|
if backend == "" {
|
||||||
|
backend = "ollama"
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint := firstNonEmpty(os.Getenv("LLM_ENDPOINT"), os.Getenv("OLLAMA_HOST"))
|
||||||
|
model := firstNonEmpty(os.Getenv("LLM_MODEL"), os.Getenv("OLLAMA_MODEL"))
|
||||||
|
|
||||||
|
timeout := DefaultTimeout
|
||||||
|
if d, err := time.ParseDuration(os.Getenv("LLM_TIMEOUT")); err == nil && d > 0 {
|
||||||
|
timeout = d
|
||||||
|
}
|
||||||
|
|
||||||
|
return Config{Backend: backend, Endpoint: endpoint, Model: model, Timeout: timeout}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configured reports whether enough config is present to talk to a backend.
|
||||||
|
// Plugins check this to stay dormant rather than erroring on every invocation,
|
||||||
|
// which is what the old `if ollamaHost == "" || ollamaModel == ""` guards did.
|
||||||
|
func (c Config) Configured() bool {
|
||||||
|
return c.Endpoint != "" && c.Model != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds the client for cfg.Backend. An unrecognised backend falls back to
|
||||||
|
// Ollama, which is what every existing deployment runs.
|
||||||
|
func New(cfg Config) Client {
|
||||||
|
if cfg.Timeout <= 0 {
|
||||||
|
cfg.Timeout = DefaultTimeout
|
||||||
|
}
|
||||||
|
base := backend{
|
||||||
|
endpoint: strings.TrimRight(cfg.Endpoint, "/"),
|
||||||
|
model: cfg.Model,
|
||||||
|
timeout: cfg.Timeout,
|
||||||
|
}
|
||||||
|
switch cfg.Backend {
|
||||||
|
case "vllm":
|
||||||
|
return &VLLMClient{base}
|
||||||
|
default:
|
||||||
|
return &OllamaClient{base}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// backend holds the fields shared by both concrete clients.
|
||||||
|
type backend struct {
|
||||||
|
endpoint string
|
||||||
|
model string
|
||||||
|
timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b backend) Model() string { return b.model }
|
||||||
|
|
||||||
|
// timeoutFor lets a single call widen or narrow the client default. The two
|
||||||
|
// dispatch-voice callers rely on this: a dispatch is authored on a game
|
||||||
|
// chokepoint and must not stall it, while a run summary rides a background
|
||||||
|
// ticker and can afford a bigger model.
|
||||||
|
func (b backend) timeoutFor(req Request) time.Duration {
|
||||||
|
if req.Timeout > 0 {
|
||||||
|
return req.Timeout
|
||||||
|
}
|
||||||
|
return b.timeout
|
||||||
|
}
|
||||||
|
|
||||||
|
// StripThink removes a leading <think>...</think> reasoning block, which Qwen
|
||||||
|
// models emit even when thinking is disabled by some backends. Callers that
|
||||||
|
// parse JSON out of the completion depend on this running first.
|
||||||
|
func StripThink(s string) string {
|
||||||
|
for {
|
||||||
|
i := strings.Index(s, "<think>")
|
||||||
|
if i < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
j := strings.Index(s, "</think>")
|
||||||
|
if j < 0 || j < i {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
s = s[:i] + s[j+len("</think>"):]
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(vals ...string) string {
|
||||||
|
for _, v := range vals {
|
||||||
|
if v = strings.TrimSpace(v); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OllamaClient talks to Ollama's native /api/generate endpoint.
|
||||||
|
type OllamaClient struct {
|
||||||
|
backend
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OllamaClient) Backend() string { return "ollama" }
|
||||||
|
|
||||||
|
type ollamaOptions struct {
|
||||||
|
NumCtx int `json:"num_ctx,omitempty"`
|
||||||
|
NumPredict int `json:"num_predict,omitempty"`
|
||||||
|
Temperature float64 `json:"temperature,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ollamaRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Prompt string `json:"prompt"`
|
||||||
|
System string `json:"system,omitempty"`
|
||||||
|
Stream bool `json:"stream"`
|
||||||
|
Think bool `json:"think"`
|
||||||
|
Options ollamaOptions `json:"options,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate posts a single non-streaming generation and returns the completion.
|
||||||
|
func (c *OllamaClient) Generate(ctx context.Context, req Request) (string, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, c.timeoutFor(req))
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
body, err := json.Marshal(ollamaRequest{
|
||||||
|
Model: c.model,
|
||||||
|
Prompt: req.Prompt,
|
||||||
|
System: req.System,
|
||||||
|
Stream: false,
|
||||||
|
Think: false,
|
||||||
|
Options: ollamaOptions{
|
||||||
|
NumCtx: req.NumCtx,
|
||||||
|
NumPredict: req.MaxTokens,
|
||||||
|
Temperature: req.Temperature,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("ollama: marshal payload: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||||
|
c.endpoint+"/api/generate", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("ollama: build request: %w", err)
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("ollama request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("ollama: read response: %w", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("ollama HTTP %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Response string `json:"response"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||||
|
return "", fmt.Errorf("ollama: parse response: %w", err)
|
||||||
|
}
|
||||||
|
return StripThink(result.Response), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ping lists locally installed models via Ollama's native /api/tags.
|
||||||
|
func (c *OllamaClient) Ping(ctx context.Context) ([]string, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, pingTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var out struct {
|
||||||
|
Models []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"models"`
|
||||||
|
}
|
||||||
|
if err := getJSON(ctx, c.endpoint+"/api/tags", &out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(out.Models))
|
||||||
|
for _, m := range out.Models {
|
||||||
|
names = append(names, m.Name)
|
||||||
|
}
|
||||||
|
return names, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pingTimeout keeps a liveness probe short — /botinfo renders synchronously and
|
||||||
|
// a hung endpoint must not hold the reply.
|
||||||
|
const pingTimeout = 5 * time.Second
|
||||||
|
|
||||||
|
func getJSON(ctx context.Context, url string, out any) error {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return json.NewDecoder(resp.Body).Decode(out)
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// VLLMClient talks to an OpenAI-compatible /v1/chat/completions endpoint.
|
||||||
|
type VLLMClient struct {
|
||||||
|
backend
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *VLLMClient) Backend() string { return "vllm" }
|
||||||
|
|
||||||
|
type vllmMessage struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type vllmRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Messages []vllmMessage `json:"messages"`
|
||||||
|
MaxTokens int `json:"max_tokens,omitempty"`
|
||||||
|
Temperature float64 `json:"temperature,omitempty"`
|
||||||
|
Stream bool `json:"stream"`
|
||||||
|
// ChatTemplateKwargs is a vLLM extension to the OpenAI schema. It is how
|
||||||
|
// Qwen3-family reasoning is switched off; the Ollama backend spells the
|
||||||
|
// same intent as its native "think": false.
|
||||||
|
ChatTemplateKwargs map[string]any `json:"chat_template_kwargs,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate posts a single non-streaming completion and returns the message
|
||||||
|
// content. The raw prompt is sent as one user message so the server-side chat
|
||||||
|
// template still wraps it.
|
||||||
|
func (c *VLLMClient) Generate(ctx context.Context, req Request) (string, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, c.timeoutFor(req))
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
msgs := make([]vllmMessage, 0, 2)
|
||||||
|
if req.System != "" {
|
||||||
|
msgs = append(msgs, vllmMessage{Role: "system", Content: req.System})
|
||||||
|
}
|
||||||
|
msgs = append(msgs, vllmMessage{Role: "user", Content: req.Prompt})
|
||||||
|
|
||||||
|
body, err := json.Marshal(vllmRequest{
|
||||||
|
Model: c.model,
|
||||||
|
Messages: msgs,
|
||||||
|
MaxTokens: req.MaxTokens,
|
||||||
|
Temperature: req.Temperature,
|
||||||
|
Stream: false,
|
||||||
|
ChatTemplateKwargs: map[string]any{"enable_thinking": false},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("vllm: marshal payload: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||||
|
c.endpoint+"/v1/chat/completions", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("vllm: build request: %w", err)
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("vllm request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("vllm: read response: %w", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("vllm HTTP %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Choices []struct {
|
||||||
|
Message struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"message"`
|
||||||
|
} `json:"choices"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||||
|
return "", fmt.Errorf("vllm: parse response: %w", err)
|
||||||
|
}
|
||||||
|
if len(result.Choices) == 0 {
|
||||||
|
return "", fmt.Errorf("vllm: empty choices in response")
|
||||||
|
}
|
||||||
|
return StripThink(result.Choices[0].Message.Content), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ping lists served models via the OpenAI-compatible /v1/models.
|
||||||
|
func (c *VLLMClient) Ping(ctx context.Context) ([]string, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, pingTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var out struct {
|
||||||
|
Data []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := getJSON(ctx, c.endpoint+"/v1/models", &out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(out.Data))
|
||||||
|
for _, m := range out.Data {
|
||||||
|
names = append(names, m.ID)
|
||||||
|
}
|
||||||
|
return names, nil
|
||||||
|
}
|
||||||
+12
-43
@@ -1,12 +1,9 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
@@ -121,10 +118,8 @@ func (p *BotInfoPlugin) handleBotInfo(ctx MessageContext) error {
|
|||||||
sb.WriteString(fmt.Sprintf("Active reminders: %d\n", activeReminders))
|
sb.WriteString(fmt.Sprintf("Active reminders: %d\n", activeReminders))
|
||||||
|
|
||||||
// LLM status
|
// LLM status
|
||||||
ollamaHost := os.Getenv("OLLAMA_HOST")
|
if llmConfigured() {
|
||||||
if ollamaHost != "" {
|
sb.WriteString(fmt.Sprintf("LLM status: %s\n", p.checkLLMStatus()))
|
||||||
llmStatus := p.checkLLMStatus(ollamaHost)
|
|
||||||
sb.WriteString(fmt.Sprintf("LLM status: %s\n", llmStatus))
|
|
||||||
} else {
|
} else {
|
||||||
sb.WriteString("LLM status: not configured\n")
|
sb.WriteString("LLM status: not configured\n")
|
||||||
}
|
}
|
||||||
@@ -159,42 +154,16 @@ func (p *BotInfoPlugin) handleBotInfo(ctx MessageContext) error {
|
|||||||
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *BotInfoPlugin) checkLLMStatus(ollamaHost string) string {
|
// checkLLMStatus reports backend liveness for /botinfo. The endpoint it probes
|
||||||
client := &http.Client{Timeout: 5 * time.Second}
|
// differs per backend, which the llm package hides behind Ping.
|
||||||
apiURL := strings.TrimRight(ollamaHost, "/") + "/api/tags"
|
func (p *BotInfoPlugin) checkLLMStatus() string {
|
||||||
|
c := llmClient()
|
||||||
resp, err := client.Get(apiURL)
|
models, err := c.Ping(context.Background())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Sprintf("offline (%s)", err.Error())
|
return fmt.Sprintf("offline (%s: %s)", c.Backend(), err.Error())
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
if len(models) == 0 {
|
||||||
|
return fmt.Sprintf("online (%s, no models loaded)", c.Backend())
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return fmt.Sprintf("error (HTTP %d)", resp.StatusCode)
|
|
||||||
}
|
}
|
||||||
|
return fmt.Sprintf("online (%s, %d models: %s)", c.Backend(), len(models), strings.Join(models, ", "))
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return "online (could not read response)"
|
|
||||||
}
|
|
||||||
|
|
||||||
var result struct {
|
|
||||||
Models []struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
} `json:"models"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(body, &result); err != nil {
|
|
||||||
return "online (could not parse response)"
|
|
||||||
}
|
|
||||||
|
|
||||||
modelNames := make([]string, 0, len(result.Models))
|
|
||||||
for _, m := range result.Models {
|
|
||||||
modelNames = append(modelNames, m.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(modelNames) == 0 {
|
|
||||||
return "online (no models loaded)"
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf("online (%d models: %s)", len(modelNames), strings.Join(modelNames, ", "))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -856,9 +856,7 @@ func (p *HangmanPlugin) handleSubmit(ctx MessageContext, phrase string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// LLM screening
|
// LLM screening
|
||||||
ollamaHost := os.Getenv("OLLAMA_HOST")
|
if !llmConfigured() {
|
||||||
ollamaModel := os.Getenv("OLLAMA_MODEL")
|
|
||||||
if ollamaHost == "" || ollamaModel == "" {
|
|
||||||
// No LLM available — add directly
|
// No LLM available — add directly
|
||||||
if err := p.addPhrase(phrase); err != nil {
|
if err := p.addPhrase(phrase); err != nil {
|
||||||
if err.Error() == "duplicate phrase" {
|
if err.Error() == "duplicate phrase" {
|
||||||
@@ -879,7 +877,7 @@ or
|
|||||||
|
|
||||||
Phrase: %s`, phrase)
|
Phrase: %s`, phrase)
|
||||||
|
|
||||||
result, err := callOllama(ollamaHost, ollamaModel, prompt)
|
result, err := callLLM(prompt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("hangman: LLM screening failed", "err", err)
|
slog.Error("hangman: LLM screening failed", "err", err)
|
||||||
// Fail open — add it
|
// Fail open — add it
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gogobee/internal/db"
|
"gogobee/internal/db"
|
||||||
|
"gogobee/internal/llm"
|
||||||
|
|
||||||
"github.com/chehsunliu/poker"
|
"github.com/chehsunliu/poker"
|
||||||
"maunium.net/go/mautrix/id"
|
"maunium.net/go/mautrix/id"
|
||||||
)
|
)
|
||||||
|
|
||||||
var holdemTipsClient = &http.Client{Timeout: 60 * time.Second}
|
// holdemTipTimeout preserves the 60s budget the tip rewriter's own http.Client
|
||||||
|
// enforced. Tips are delivered as private messages during a hand, so this sits
|
||||||
|
// between the passive 30s paths and the interactive 120s default.
|
||||||
|
const holdemTipTimeout = 60 * time.Second
|
||||||
|
|
||||||
// loadTipsPref loads a user's tip preference from the database.
|
// loadTipsPref loads a user's tip preference from the database.
|
||||||
func loadTipsPref(userID id.UserID) bool {
|
func loadTipsPref(userID id.UserID) bool {
|
||||||
@@ -491,11 +491,8 @@ func cardSuitIndex(c poker.Card) int {
|
|||||||
func generateTip(ctx holdemTipContext) string {
|
func generateTip(ctx holdemTipContext) string {
|
||||||
base := generateRulesTip(ctx)
|
base := generateRulesTip(ctx)
|
||||||
|
|
||||||
host := os.Getenv("OLLAMA_HOST")
|
if llmConfigured() {
|
||||||
model := os.Getenv("OLLAMA_MODEL")
|
rewritten, err := rewriteTipWithLLM(ctx, base)
|
||||||
|
|
||||||
if host != "" && model != "" {
|
|
||||||
rewritten, err := rewriteTipWithLLM(host, model, ctx, base)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("holdem: LLM tip rewrite failed, using rules tip", "err", err)
|
slog.Warn("holdem: LLM tip rewrite failed, using rules tip", "err", err)
|
||||||
} else if rewritten != "" {
|
} else if rewritten != "" {
|
||||||
@@ -626,40 +623,19 @@ func buildTipUserPrompt(ctx holdemTipContext) string {
|
|||||||
// variety. The rules tip is the source of truth — if the rewrite diverges
|
// variety. The rules tip is the source of truth — if the rewrite diverges
|
||||||
// (empty, action vocabulary changed, etc.) we reject it and the caller falls
|
// (empty, action vocabulary changed, etc.) we reject it and the caller falls
|
||||||
// back to the original.
|
// back to the original.
|
||||||
func rewriteTipWithLLM(host, model string, ctx holdemTipContext, base string) (string, error) {
|
func rewriteTipWithLLM(ctx holdemTipContext, base string) (string, error) {
|
||||||
userMsg := buildTipUserPrompt(ctx) + "\nTIP:\n" + base + "\n"
|
userMsg := buildTipUserPrompt(ctx) + "\nTIP:\n" + base + "\n"
|
||||||
req := ollamaChatRequest{
|
|
||||||
Model: model,
|
|
||||||
Messages: []chatMessage{
|
|
||||||
{Role: "system", Content: buildTipSystemPrompt()},
|
|
||||||
{Role: "user", Content: userMsg},
|
|
||||||
},
|
|
||||||
Stream: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := json.Marshal(req)
|
raw, err := llmGenerate(context.Background(), llm.Request{
|
||||||
|
System: buildTipSystemPrompt(),
|
||||||
|
Prompt: userMsg,
|
||||||
|
Timeout: holdemTipTimeout,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("marshal: %w", err)
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
url := strings.TrimRight(host, "/") + "/api/chat"
|
tip := extractTipFromResponse(raw)
|
||||||
resp, err := holdemTipsClient.Post(url, "application/json", bytes.NewReader(body))
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("request: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
respBody, _ := io.ReadAll(resp.Body)
|
|
||||||
return "", fmt.Errorf("status %d: %s", resp.StatusCode, string(respBody))
|
|
||||||
}
|
|
||||||
|
|
||||||
var ollamaResp ollamaChatResponse
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&ollamaResp); err != nil {
|
|
||||||
return "", fmt.Errorf("decode: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tip := extractTipFromResponse(ollamaResp.Message.Content)
|
|
||||||
if tip == "" {
|
if tip == "" {
|
||||||
return "", fmt.Errorf("empty response")
|
return "", fmt.Errorf("empty response")
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-61
@@ -1,18 +1,15 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"gogobee/internal/db"
|
"gogobee/internal/db"
|
||||||
|
"gogobee/internal/llm"
|
||||||
|
|
||||||
"maunium.net/go/mautrix"
|
"maunium.net/go/mautrix"
|
||||||
"maunium.net/go/mautrix/id"
|
"maunium.net/go/mautrix/id"
|
||||||
@@ -47,9 +44,7 @@ func (p *HowAmIPlugin) OnMessage(ctx MessageContext) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
ollamaHost := os.Getenv("OLLAMA_HOST")
|
if !llmConfigured() {
|
||||||
ollamaModel := os.Getenv("OLLAMA_MODEL")
|
|
||||||
if ollamaHost == "" || ollamaModel == "" {
|
|
||||||
return p.SendReply(ctx.RoomID, ctx.EventID, "LLM is not configured.")
|
return p.SendReply(ctx.RoomID, ctx.EventID, "LLM is not configured.")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,9 +79,9 @@ Write the roast now. Do not include any preamble or explanation, just the roast
|
|||||||
botName, string(target), profile,
|
botName, string(target), profile,
|
||||||
)
|
)
|
||||||
|
|
||||||
response, err := callOllama(ollamaHost, ollamaModel, prompt)
|
response, err := callLLM(prompt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("howami: ollama call", "err", err)
|
slog.Error("howami: llm call", "err", err)
|
||||||
p.SendReply(ctx.RoomID, ctx.EventID, "Couldn't generate the profile. Thanks, Ollama.")
|
p.SendReply(ctx.RoomID, ctx.EventID, "Couldn't generate the profile. Thanks, Ollama.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -181,55 +176,13 @@ func (p *HowAmIPlugin) gatherProfile(userID id.UserID) string {
|
|||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// callOllama sends a prompt to the Ollama generate endpoint and returns the response.
|
// callLLM sends a prompt to whichever backend is configured and returns the
|
||||||
func callOllama(host, model, prompt string) (string, error) {
|
// completion. Reasoning blocks are stripped by the client. The 8192 context
|
||||||
apiURL := strings.TrimRight(host, "/") + "/api/generate"
|
// hint is what this path has always asked Ollama for; vLLM ignores it and uses
|
||||||
|
// the window fixed at server launch.
|
||||||
payload := map[string]interface{}{
|
func callLLM(prompt string) (string, error) {
|
||||||
"model": model,
|
return llmGenerate(context.Background(), llm.Request{
|
||||||
"prompt": prompt,
|
Prompt: prompt,
|
||||||
"stream": false,
|
NumCtx: 8192,
|
||||||
"think": false,
|
})
|
||||||
"options": map[string]interface{}{
|
|
||||||
"num_ctx": 8192,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := json.Marshal(payload)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("marshal payload: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
client := &http.Client{Timeout: 120 * time.Second}
|
|
||||||
resp, err := client.Post(apiURL, "application/json", bytes.NewReader(data))
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("ollama request: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("read response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return "", fmt.Errorf("ollama HTTP %d: %s", resp.StatusCode, string(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
var result struct {
|
|
||||||
Response string `json:"response"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(body, &result); err != nil {
|
|
||||||
return "", fmt.Errorf("parse response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
response := result.Response
|
|
||||||
// Strip <think>...</think> blocks (Qwen 3.5 reasoning)
|
|
||||||
if i := strings.Index(response, "<think>"); i != -1 {
|
|
||||||
if j := strings.Index(response, "</think>"); j != -1 {
|
|
||||||
response = response[:i] + response[j+len("</think>"):]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.TrimSpace(response), nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"gogobee/internal/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The inference backend is process-wide: one endpoint, one model, selected by
|
||||||
|
// env at startup. Plugins share a single client rather than each rebuilding one
|
||||||
|
// per invocation, and read config through llmConfigured/llmGenerate so that
|
||||||
|
// swapping Ollama for vLLM is a config change rather than a code change.
|
||||||
|
var (
|
||||||
|
llmOnce sync.Once
|
||||||
|
llmShared llm.Client
|
||||||
|
llmCfg llm.Config
|
||||||
|
)
|
||||||
|
|
||||||
|
func llmInit() {
|
||||||
|
llmOnce.Do(func() {
|
||||||
|
llmCfg = llm.ConfigFromEnv()
|
||||||
|
llmShared = llm.New(llmCfg)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// llmConfigured reports whether an endpoint and model are set. Plugins call
|
||||||
|
// this to stay dormant instead of erroring on every invocation — the same role
|
||||||
|
// the old `if ollamaHost == "" || ollamaModel == ""` guards played.
|
||||||
|
func llmConfigured() bool {
|
||||||
|
llmInit()
|
||||||
|
return llmCfg.Configured()
|
||||||
|
}
|
||||||
|
|
||||||
|
// llmClient returns the shared backend client.
|
||||||
|
func llmClient() llm.Client {
|
||||||
|
llmInit()
|
||||||
|
return llmShared
|
||||||
|
}
|
||||||
|
|
||||||
|
// llmGenerate is the one-line path for the common case: a raw prompt in,
|
||||||
|
// visible completion out, reasoning blocks already stripped.
|
||||||
|
func llmGenerate(ctx context.Context, req llm.Request) (string, error) {
|
||||||
|
return llmClient().Generate(ctx, req)
|
||||||
|
}
|
||||||
@@ -1,13 +1,11 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -18,6 +16,7 @@ import (
|
|||||||
|
|
||||||
"gogobee/internal/db"
|
"gogobee/internal/db"
|
||||||
"gogobee/internal/dreamclient"
|
"gogobee/internal/dreamclient"
|
||||||
|
"gogobee/internal/llm"
|
||||||
|
|
||||||
"maunium.net/go/mautrix"
|
"maunium.net/go/mautrix"
|
||||||
"maunium.net/go/mautrix/id"
|
"maunium.net/go/mautrix/id"
|
||||||
@@ -54,13 +53,18 @@ type queueItem struct {
|
|||||||
FormattedBody string
|
FormattedBody string
|
||||||
}
|
}
|
||||||
|
|
||||||
// LLMPassivePlugin classifies messages using Ollama and reacts accordingly.
|
// classifyTimeout is the per-message budget for passive classification. It
|
||||||
|
// preserves the 30s cap the plugin's own http.Client used to enforce, which is
|
||||||
|
// deliberately tighter than the interactive default: classification runs on
|
||||||
|
// sampled traffic and must never back up the queue.
|
||||||
|
const classifyTimeout = 30 * time.Second
|
||||||
|
|
||||||
|
// LLMPassivePlugin classifies messages using the configured LLM backend and
|
||||||
|
// reacts accordingly.
|
||||||
type LLMPassivePlugin struct {
|
type LLMPassivePlugin struct {
|
||||||
Base
|
Base
|
||||||
xp *XPPlugin
|
xp *XPPlugin
|
||||||
dict *dreamclient.Client
|
dict *dreamclient.Client
|
||||||
ollamaHost string
|
|
||||||
ollamaModel string
|
|
||||||
sampleRate float64
|
sampleRate float64
|
||||||
enabled bool
|
enabled bool
|
||||||
|
|
||||||
@@ -68,15 +72,12 @@ type LLMPassivePlugin struct {
|
|||||||
queue []queueItem
|
queue []queueItem
|
||||||
backoff time.Duration
|
backoff time.Duration
|
||||||
|
|
||||||
httpClient *http.Client
|
|
||||||
stopCh chan struct{}
|
stopCh chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewLLMPassivePlugin creates a new LLM passive classification plugin.
|
// NewLLMPassivePlugin creates a new LLM passive classification plugin.
|
||||||
func NewLLMPassivePlugin(client *mautrix.Client, xp *XPPlugin, dict *dreamclient.Client) *LLMPassivePlugin {
|
func NewLLMPassivePlugin(client *mautrix.Client, xp *XPPlugin, dict *dreamclient.Client) *LLMPassivePlugin {
|
||||||
host := os.Getenv("OLLAMA_HOST")
|
enabled := llmConfigured()
|
||||||
model := os.Getenv("OLLAMA_MODEL")
|
|
||||||
enabled := host != "" && model != ""
|
|
||||||
|
|
||||||
sampleRate := 0.15
|
sampleRate := 0.15
|
||||||
if v := os.Getenv("LLM_SAMPLE_RATE"); v != "" {
|
if v := os.Getenv("LLM_SAMPLE_RATE"); v != "" {
|
||||||
@@ -89,12 +90,9 @@ func NewLLMPassivePlugin(client *mautrix.Client, xp *XPPlugin, dict *dreamclient
|
|||||||
Base: NewBase(client),
|
Base: NewBase(client),
|
||||||
xp: xp,
|
xp: xp,
|
||||||
dict: dict,
|
dict: dict,
|
||||||
ollamaHost: host,
|
|
||||||
ollamaModel: model,
|
|
||||||
sampleRate: sampleRate,
|
sampleRate: sampleRate,
|
||||||
enabled: enabled,
|
enabled: enabled,
|
||||||
backoff: 5 * time.Second,
|
backoff: 5 * time.Second,
|
||||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
|
||||||
stopCh: make(chan struct{}),
|
stopCh: make(chan struct{}),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,11 +114,11 @@ func (p *LLMPassivePlugin) Commands() []CommandDef {
|
|||||||
|
|
||||||
func (p *LLMPassivePlugin) Init() error {
|
func (p *LLMPassivePlugin) Init() error {
|
||||||
if p.enabled {
|
if p.enabled {
|
||||||
slog.Info("llm_passive: enabled", "host", p.ollamaHost, "model", p.ollamaModel, "sample_rate", p.sampleRate)
|
slog.Info("llm_passive: enabled", "backend", llmClient().Backend(),
|
||||||
|
"model", llmClient().Model(), "sample_rate", p.sampleRate)
|
||||||
go p.processQueue()
|
go p.processQueue()
|
||||||
} else {
|
} else {
|
||||||
slog.Warn("llm_passive: disabled (OLLAMA_HOST or OLLAMA_MODEL not set)",
|
slog.Warn("llm_passive: disabled (LLM endpoint or model not set)")
|
||||||
"host", p.ollamaHost, "model", p.ollamaModel)
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -329,9 +327,9 @@ func (p *LLMPassivePlugin) classifyAndProcess(item queueItem) error {
|
|||||||
var todayWOTD string
|
var todayWOTD string
|
||||||
db.Get().QueryRow(`SELECT word FROM wotd_log WHERE date = ?`, today).Scan(&todayWOTD)
|
db.Get().QueryRow(`SELECT word FROM wotd_log WHERE date = ?`, today).Scan(&todayWOTD)
|
||||||
|
|
||||||
result, err := p.callOllama(item.Body+mentionHint, todayWOTD)
|
result, err := p.classify(item.Body+mentionHint, todayWOTD)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("ollama call: %w", err)
|
return fmt.Errorf("llm call: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve any display names in LLM targets back to MXIDs
|
// Resolve any display names in LLM targets back to MXIDs
|
||||||
@@ -464,21 +462,9 @@ func (p *LLMPassivePlugin) classifyAndProcess(item queueItem) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ollamaRequest is the request body for the Ollama API.
|
// classify sends a classification prompt to the configured backend and parses
|
||||||
type ollamaRequest struct {
|
// the JSON result.
|
||||||
Model string `json:"model"`
|
func (p *LLMPassivePlugin) classify(messageText, wotd string) (*classificationResult, error) {
|
||||||
Prompt string `json:"prompt"`
|
|
||||||
Stream bool `json:"stream"`
|
|
||||||
Think bool `json:"think"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ollamaResponse is the response from the Ollama API.
|
|
||||||
type ollamaResponse struct {
|
|
||||||
Response string `json:"response"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// callOllama sends a classification prompt to Ollama and parses the JSON result.
|
|
||||||
func (p *LLMPassivePlugin) callOllama(messageText, wotd string) (*classificationResult, error) {
|
|
||||||
wotdInstruction := `"wotd_used": false`
|
wotdInstruction := `"wotd_used": false`
|
||||||
if wotd != "" {
|
if wotd != "" {
|
||||||
wotdInstruction = fmt.Sprintf(`"wotd_used": true | false (whether the message uses the word "%s" correctly and meaningfully — not just mentioning or quoting it)`, wotd)
|
wotdInstruction = fmt.Sprintf(`"wotd_used": true | false (whether the message uses the word "%s" correctly and meaningfully — not just mentioning or quoting it)`, wotd)
|
||||||
@@ -500,36 +486,18 @@ JSON schema:
|
|||||||
|
|
||||||
Message: %s`, wotdInstruction, messageText)
|
Message: %s`, wotdInstruction, messageText)
|
||||||
|
|
||||||
reqBody := ollamaRequest{
|
slog.Debug("llm_passive: calling backend", "backend", llmClient().Backend(), "model", llmClient().Model())
|
||||||
Model: p.ollamaModel,
|
// Classification rides the passive path on every sampled message, so it keeps
|
||||||
|
// the tighter budget it always had rather than the interactive default.
|
||||||
|
raw, err := llmGenerate(context.Background(), llm.Request{
|
||||||
Prompt: prompt,
|
Prompt: prompt,
|
||||||
Stream: false,
|
Timeout: classifyTimeout,
|
||||||
}
|
})
|
||||||
|
|
||||||
body, err := json.Marshal(reqBody)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("marshal request: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
url := strings.TrimRight(p.ollamaHost, "/") + "/api/generate"
|
result, err := parseClassification(raw)
|
||||||
slog.Debug("llm_passive: calling ollama", "url", url, "model", p.ollamaModel)
|
|
||||||
resp, err := p.httpClient.Post(url, "application/json", bytes.NewReader(body))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("ollama request: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
respBody, _ := io.ReadAll(resp.Body)
|
|
||||||
return nil, fmt.Errorf("ollama status %d: %s", resp.StatusCode, string(respBody))
|
|
||||||
}
|
|
||||||
|
|
||||||
var ollamaResp ollamaResponse
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&ollamaResp); err != nil {
|
|
||||||
return nil, fmt.Errorf("decode ollama response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := parseClassification(ollamaResp.Response)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("parse classification: %w", err)
|
return nil, fmt.Errorf("parse classification: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-61
@@ -1,10 +1,9 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -15,6 +14,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gogobee/internal/db"
|
"gogobee/internal/db"
|
||||||
|
"gogobee/internal/llm"
|
||||||
|
|
||||||
"maunium.net/go/mautrix"
|
"maunium.net/go/mautrix"
|
||||||
"maunium.net/go/mautrix/id"
|
"maunium.net/go/mautrix/id"
|
||||||
@@ -311,9 +311,7 @@ Do not use em dashes. Do not use exclamation marks. Do not offer financial advic
|
|||||||
If markets are closed or data is stale, note it briefly and move on.`
|
If markets are closed or data is stale, note it briefly and move on.`
|
||||||
|
|
||||||
func (p *MarketPlugin) generateDailySummary(date string) string {
|
func (p *MarketPlugin) generateDailySummary(date string) string {
|
||||||
host := os.Getenv("OLLAMA_HOST")
|
if !llmConfigured() {
|
||||||
model := os.Getenv("OLLAMA_MODEL")
|
|
||||||
if host == "" || model == "" {
|
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,7 +347,7 @@ func (p *MarketPlugin) generateDailySummary(date string) string {
|
|||||||
}
|
}
|
||||||
prompt.WriteString("\nWrite a 2-3 sentence summary.")
|
prompt.WriteString("\nWrite a 2-3 sentence summary.")
|
||||||
|
|
||||||
result, err := p.callOllamaChat(host, model, marketSystemPrompt, prompt.String())
|
result, err := p.chatLLM(marketSystemPrompt, prompt.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("market: ollama summary failed", "err", err)
|
slog.Error("market: ollama summary failed", "err", err)
|
||||||
return ""
|
return ""
|
||||||
@@ -358,9 +356,7 @@ func (p *MarketPlugin) generateDailySummary(date string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *MarketPlugin) generateReportSummary(snapsByDate map[string][]marketSnapshot, dateRange string) string {
|
func (p *MarketPlugin) generateReportSummary(snapsByDate map[string][]marketSnapshot, dateRange string) string {
|
||||||
host := os.Getenv("OLLAMA_HOST")
|
if !llmConfigured() {
|
||||||
model := os.Getenv("OLLAMA_MODEL")
|
|
||||||
if host == "" || model == "" {
|
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,7 +391,7 @@ func (p *MarketPlugin) generateReportSummary(snapsByDate map[string][]marketSnap
|
|||||||
}
|
}
|
||||||
prompt.WriteString("\nDescribe the trend in 2-3 sentences. Note any significant moves or divergences between indices.\nBe sardonic but accurate. Reference the VIX trajectory when relevant.")
|
prompt.WriteString("\nDescribe the trend in 2-3 sentences. Note any significant moves or divergences between indices.\nBe sardonic but accurate. Reference the VIX trajectory when relevant.")
|
||||||
|
|
||||||
result, err := p.callOllamaChat(host, model, marketSystemPrompt, prompt.String())
|
result, err := p.chatLLM(marketSystemPrompt, prompt.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("market: ollama report summary failed", "err", err)
|
slog.Warn("market: ollama report summary failed", "err", err)
|
||||||
return ""
|
return ""
|
||||||
@@ -403,49 +399,14 @@ func (p *MarketPlugin) generateReportSummary(snapsByDate map[string][]marketSnap
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// callOllamaChat calls the Ollama /api/chat endpoint with a system and user message.
|
// chatLLM sends a system+user pair to the configured backend. Both backends
|
||||||
// Uses the types already defined in holdem_tips.go (same package).
|
// map this onto their own chat shape, so the market summaries read the same
|
||||||
func (p *MarketPlugin) callOllamaChat(host, model, systemPrompt, userPrompt string) (string, error) {
|
// whichever one is serving.
|
||||||
req := ollamaChatRequest{
|
func (p *MarketPlugin) chatLLM(systemPrompt, userPrompt string) (string, error) {
|
||||||
Model: model,
|
return llmGenerate(context.Background(), llm.Request{
|
||||||
Messages: []chatMessage{
|
System: systemPrompt,
|
||||||
{Role: "system", Content: systemPrompt},
|
Prompt: userPrompt,
|
||||||
{Role: "user", Content: userPrompt},
|
})
|
||||||
},
|
|
||||||
Stream: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := json.Marshal(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("marshal: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
url := strings.TrimRight(host, "/") + "/api/chat"
|
|
||||||
resp, err := p.httpClient.Post(url, "application/json", bytes.NewReader(body))
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("request: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
respBody, _ := io.ReadAll(resp.Body)
|
|
||||||
return "", fmt.Errorf("status %d: %s", resp.StatusCode, string(respBody))
|
|
||||||
}
|
|
||||||
|
|
||||||
var ollamaResp ollamaChatResponse
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&ollamaResp); err != nil {
|
|
||||||
return "", fmt.Errorf("decode: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
text := ollamaResp.Message.Content
|
|
||||||
// Strip <think>...</think> blocks (reasoning models)
|
|
||||||
if i := strings.Index(text, "<think>"); i != -1 {
|
|
||||||
if j := strings.Index(text, "</think>"); j != -1 {
|
|
||||||
text = text[:i] + text[j+len("</think>"):]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.TrimSpace(text), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── DB Helpers ───────────────────────────────────────────────────────────────
|
// ── DB Helpers ───────────────────────────────────────────────────────────────
|
||||||
@@ -916,12 +877,10 @@ func (p *MarketPlugin) handleVixReport(ctx MessageContext) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var summary string
|
var summary string
|
||||||
host := os.Getenv("OLLAMA_HOST")
|
if llmConfigured() {
|
||||||
model := os.Getenv("OLLAMA_MODEL")
|
|
||||||
if host != "" && model != "" {
|
|
||||||
prompt := fmt.Sprintf("VIX (fear index) data over %d days (%s to %s):\n%s\n\nDescribe the fear/greed trajectory in 2-3 sentences. Be sardonic but accurate.",
|
prompt := fmt.Sprintf("VIX (fear index) data over %d days (%s to %s):\n%s\n\nDescribe the fear/greed trajectory in 2-3 sentences. Be sardonic but accurate.",
|
||||||
len(entries), entries[0].Date, entries[len(entries)-1].Date, strings.Join(prices, ", "))
|
len(entries), entries[0].Date, entries[len(entries)-1].Date, strings.Join(prices, ", "))
|
||||||
summary, _ = p.callOllamaChat(host, model, marketSystemPrompt, prompt)
|
summary, _ = p.chatLLM(marketSystemPrompt, prompt)
|
||||||
}
|
}
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
@@ -1020,12 +979,10 @@ func (p *MarketPlugin) handleCompare(ctx MessageContext, args []string) error {
|
|||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
prices = append(prices, fmt.Sprintf("%.2f", e.Price))
|
prices = append(prices, fmt.Sprintf("%.2f", e.Price))
|
||||||
}
|
}
|
||||||
host := os.Getenv("OLLAMA_HOST")
|
if llmConfigured() {
|
||||||
model := os.Getenv("OLLAMA_MODEL")
|
|
||||||
if host != "" && model != "" {
|
|
||||||
prompt := fmt.Sprintf("%s over %d days (%s to %s):\n%s\n\nDescribe the trend in 2-3 sentences. Be sardonic but accurate.",
|
prompt := fmt.Sprintf("%s over %d days (%s to %s):\n%s\n\nDescribe the trend in 2-3 sentences. Be sardonic but accurate.",
|
||||||
idx.DisplayName, len(entries), entries[0].Date, entries[len(entries)-1].Date, strings.Join(prices, ", "))
|
idx.DisplayName, len(entries), entries[0].Date, entries[len(entries)-1].Date, strings.Join(prices, ", "))
|
||||||
if summary, err := p.callOllamaChat(host, model, marketSystemPrompt, prompt); err == nil && summary != "" {
|
if summary, err := p.chatLLM(marketSystemPrompt, prompt); err == nil && summary != "" {
|
||||||
sb.WriteString(summary)
|
sb.WriteString(summary)
|
||||||
sb.WriteString("\n\n")
|
sb.WriteString("\n\n")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/llm"
|
||||||
"gogobee/internal/peteclient"
|
"gogobee/internal/peteclient"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -39,22 +37,18 @@ const (
|
|||||||
maxDispatchLede = 800
|
maxDispatchLede = 800
|
||||||
)
|
)
|
||||||
|
|
||||||
var dispatchHTTP = &http.Client{Timeout: dispatchLLMTimeout}
|
|
||||||
|
|
||||||
// authorDispatch turns a fact into a headline+lede in Pete's voice, or returns
|
// authorDispatch turns a fact into a headline+lede in Pete's voice, or returns
|
||||||
// two empty strings if the model is unconfigured, errors, times out, or produces
|
// two empty strings if the model is unconfigured, errors, times out, or produces
|
||||||
// anything malformed. The fact must already have its FINAL Actors set (post
|
// anything malformed. The fact must already have its FINAL Actors set (post
|
||||||
// opt-out anonymisation) — that list is the only set of names the prose may use,
|
// opt-out anonymisation) — that list is the only set of names the prose may use,
|
||||||
// and it is what Pete's guard checks the output against.
|
// and it is what Pete's guard checks the output against.
|
||||||
func authorDispatch(f peteclient.Fact) (headline, lede string) {
|
func authorDispatch(f peteclient.Fact) (headline, lede string) {
|
||||||
host := os.Getenv("OLLAMA_HOST")
|
if !llmConfigured() {
|
||||||
model := os.Getenv("OLLAMA_MODEL")
|
|
||||||
if host == "" || model == "" {
|
|
||||||
return "", ""
|
return "", ""
|
||||||
}
|
}
|
||||||
|
|
||||||
prompt := buildDispatchPrompt(f)
|
prompt := buildDispatchPrompt(f)
|
||||||
raw, err := callOllamaDispatch(dispatchHTTP, host, model, prompt)
|
raw, err := callLLMDispatch(dispatchLLMTimeout, prompt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("pete dispatch: LLM authoring failed, Pete will template", "guid", f.GUID, "err", err)
|
slog.Warn("pete dispatch: LLM authoring failed, Pete will template", "guid", f.GUID, "err", err)
|
||||||
return "", ""
|
return "", ""
|
||||||
@@ -128,45 +122,17 @@ The event:
|
|||||||
%s`, names, facts.String())
|
%s`, names, facts.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// callOllamaDispatch posts a single non-streaming generation and returns the raw
|
// callLLMDispatch posts a single non-streaming generation and returns the raw
|
||||||
// completion (think-tags stripped). The client is a parameter because the two
|
// completion. The timeout is a parameter because the two callers have genuinely
|
||||||
// callers have genuinely different patience: a dispatch is authored on a game
|
// different patience: a dispatch is authored on a game chokepoint and must not
|
||||||
// chokepoint and must not stall it, while a run summary rides a background
|
// stall it, while a run summary rides a background ticker and can afford to wait
|
||||||
// ticker and can afford to wait for a bigger model. See runSummaryHTTP.
|
// for a bigger model. See runSummaryTimeout.
|
||||||
func callOllamaDispatch(client *http.Client, host, model, prompt string) (string, error) {
|
func callLLMDispatch(timeout time.Duration, prompt string) (string, error) {
|
||||||
payload := map[string]interface{}{
|
return llmGenerate(context.Background(), llm.Request{
|
||||||
"model": model,
|
Prompt: prompt,
|
||||||
"prompt": prompt,
|
NumCtx: 4096,
|
||||||
"stream": false,
|
Timeout: timeout,
|
||||||
"think": false,
|
})
|
||||||
"options": map[string]interface{}{
|
|
||||||
"num_ctx": 4096,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
data, err := json.Marshal(payload)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("marshal payload: %w", err)
|
|
||||||
}
|
|
||||||
apiURL := strings.TrimRight(host, "/") + "/api/generate"
|
|
||||||
resp, err := client.Post(apiURL, "application/json", bytes.NewReader(data))
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("ollama request: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("read response: %w", err)
|
|
||||||
}
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return "", fmt.Errorf("ollama HTTP %d: %s", resp.StatusCode, string(body))
|
|
||||||
}
|
|
||||||
var result struct {
|
|
||||||
Response string `json:"response"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(body, &result); err != nil {
|
|
||||||
return "", fmt.Errorf("parse response: %w", err)
|
|
||||||
}
|
|
||||||
return result.Response, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseDispatch pulls {headline, lede} out of the model's completion, tolerating
|
// parseDispatch pulls {headline, lede} out of the model's completion, tolerating
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -70,8 +68,6 @@ const maxRunSummary = 1200
|
|||||||
// for load-then-generate, and a timeout here really does mean the box is down.
|
// for load-then-generate, and a timeout here really does mean the box is down.
|
||||||
const runSummaryTimeout = 5 * time.Minute
|
const runSummaryTimeout = 5 * time.Minute
|
||||||
|
|
||||||
var runSummaryHTTP = &http.Client{Timeout: runSummaryTimeout}
|
|
||||||
|
|
||||||
// runSummaryBusy is the whole concurrency story: at most one sweep in flight,
|
// runSummaryBusy is the whole concurrency story: at most one sweep in flight,
|
||||||
// ever. The ticker starts one and moves on, so a cold model loading for minutes
|
// ever. The ticker starts one and moves on, so a cold model loading for minutes
|
||||||
// costs the board nothing, and the ticks that fire meanwhile find the flag set
|
// costs the board nothing, and the ticks that fire meanwhile find the flag set
|
||||||
@@ -105,7 +101,7 @@ func (p *AdventurePlugin) sweepRunSummaries() {
|
|||||||
if !peteclient.Enabled() || !newsEmissionOn() {
|
if !peteclient.Enabled() || !newsEmissionOn() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if os.Getenv("OLLAMA_HOST") == "" || os.Getenv("OLLAMA_MODEL") == "" {
|
if !llmConfigured() {
|
||||||
return // no model, no summary, no wasted queries asking which run needs one
|
return // no model, no summary, no wasted queries asking which run needs one
|
||||||
}
|
}
|
||||||
runID := nextRunNeedingSummary()
|
runID := nextRunNeedingSummary()
|
||||||
@@ -174,8 +170,7 @@ func authorRunSummary(runID string) (summary, name string) {
|
|||||||
return "", ""
|
return "", ""
|
||||||
}
|
}
|
||||||
|
|
||||||
raw, err := callOllamaDispatch(runSummaryHTTP, os.Getenv("OLLAMA_HOST"), os.Getenv("OLLAMA_MODEL"),
|
raw, err := callLLMDispatch(runSummaryTimeout, buildRunSummaryPrompt(name, beats))
|
||||||
buildRunSummaryPrompt(name, beats))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("run summary: LLM authoring failed", "run", runID, "err", err)
|
slog.Warn("run summary: LLM authoring failed", "run", runID, "err", err)
|
||||||
return "", name
|
return "", name
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gogobee/internal/db"
|
"gogobee/internal/db"
|
||||||
@@ -116,9 +115,7 @@ func (p *TarotPlugin) handleTarot(ctx MessageContext) error {
|
|||||||
return p.SendReply(ctx.RoomID, ctx.EventID, "You've used up your readings for today. The cards need rest, even if you don't.")
|
return p.SendReply(ctx.RoomID, ctx.EventID, "You've used up your readings for today. The cards need rest, even if you don't.")
|
||||||
}
|
}
|
||||||
|
|
||||||
ollamaHost := os.Getenv("OLLAMA_HOST")
|
if !llmConfigured() {
|
||||||
ollamaModel := os.Getenv("OLLAMA_MODEL")
|
|
||||||
if ollamaHost == "" || ollamaModel == "" {
|
|
||||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Tarot reader is on a union-mandated vacation and will return when morale improves.")
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Tarot reader is on a union-mandated vacation and will return when morale improves.")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,7 +148,7 @@ func (p *TarotPlugin) handleTarot(ctx MessageContext) error {
|
|||||||
}
|
}
|
||||||
prompt := fmt.Sprintf("%s\n%s\n\n%s\n\nCard drawn: %s%s\nGive the reading.", tarotBasePrompt, tarotSingleSuffix, tarotFewShot, card, extraLines)
|
prompt := fmt.Sprintf("%s\n%s\n\n%s\n\nCard drawn: %s%s\nGive the reading.", tarotBasePrompt, tarotSingleSuffix, tarotFewShot, card, extraLines)
|
||||||
|
|
||||||
response, err := callOllama(ollamaHost, ollamaModel, prompt)
|
response, err := callLLM(prompt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("tarot: ollama call", "err", err)
|
slog.Error("tarot: ollama call", "err", err)
|
||||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Tarot reader is on a union-mandated vacation and will return when morale improves.")
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Tarot reader is on a union-mandated vacation and will return when morale improves.")
|
||||||
@@ -166,9 +163,7 @@ func (p *TarotPlugin) handleSpread(ctx MessageContext) error {
|
|||||||
return p.SendReply(ctx.RoomID, ctx.EventID, "You've used up your readings for today. The cards need rest, even if you don't.")
|
return p.SendReply(ctx.RoomID, ctx.EventID, "You've used up your readings for today. The cards need rest, even if you don't.")
|
||||||
}
|
}
|
||||||
|
|
||||||
ollamaHost := os.Getenv("OLLAMA_HOST")
|
if !llmConfigured() {
|
||||||
ollamaModel := os.Getenv("OLLAMA_MODEL")
|
|
||||||
if ollamaHost == "" || ollamaModel == "" {
|
|
||||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Tarot reader is on a union-mandated vacation and will return when morale improves.")
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Tarot reader is on a union-mandated vacation and will return when morale improves.")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,7 +197,7 @@ func (p *TarotPlugin) handleSpread(ctx MessageContext) error {
|
|||||||
prompt := fmt.Sprintf("%s\n%s\n\n%s\n\nCards drawn:\n- Past: %s\n- Present: %s\n- Future: %s%s\nGive the reading.",
|
prompt := fmt.Sprintf("%s\n%s\n\n%s\n\nCards drawn:\n- Past: %s\n- Present: %s\n- Future: %s%s\nGive the reading.",
|
||||||
tarotBasePrompt, tarotSpreadSuffix, tarotFewShot, cards[0], cards[1], cards[2], extraLines)
|
tarotBasePrompt, tarotSpreadSuffix, tarotFewShot, cards[0], cards[1], cards[2], extraLines)
|
||||||
|
|
||||||
response, err := callOllama(ollamaHost, ollamaModel, prompt)
|
response, err := callLLM(prompt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("tarot: ollama call", "err", err)
|
slog.Error("tarot: ollama call", "err", err)
|
||||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Tarot reader is on a union-mandated vacation and will return when morale improves.")
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Tarot reader is on a union-mandated vacation and will return when morale improves.")
|
||||||
|
|||||||
@@ -120,9 +120,7 @@ func (p *VibePlugin) resetCooldown(roomID id.RoomID) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *VibePlugin) handleVibe(ctx MessageContext) error {
|
func (p *VibePlugin) handleVibe(ctx MessageContext) error {
|
||||||
ollamaHost := os.Getenv("OLLAMA_HOST")
|
if !llmConfigured() {
|
||||||
ollamaModel := os.Getenv("OLLAMA_MODEL")
|
|
||||||
if ollamaHost == "" || ollamaModel == "" {
|
|
||||||
return p.SendReply(ctx.RoomID, ctx.EventID, "LLM is not configured.")
|
return p.SendReply(ctx.RoomID, ctx.EventID, "LLM is not configured.")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,7 +152,7 @@ Describe the room's current vibe:`, botName, transcript)
|
|||||||
slog.Error("vibe: send thinking", "err", err)
|
slog.Error("vibe: send thinking", "err", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
response, err := callOllama(ollamaHost, ollamaModel, prompt)
|
response, err := callLLM(prompt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("vibe: ollama call", "err", err)
|
slog.Error("vibe: ollama call", "err", err)
|
||||||
p.resetCooldown(ctx.RoomID) // Don't consume cooldown on failure
|
p.resetCooldown(ctx.RoomID) // Don't consume cooldown on failure
|
||||||
@@ -165,9 +163,7 @@ Describe the room's current vibe:`, botName, transcript)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *VibePlugin) handleTLDR(ctx MessageContext) error {
|
func (p *VibePlugin) handleTLDR(ctx MessageContext) error {
|
||||||
ollamaHost := os.Getenv("OLLAMA_HOST")
|
if !llmConfigured() {
|
||||||
ollamaModel := os.Getenv("OLLAMA_MODEL")
|
|
||||||
if ollamaHost == "" || ollamaModel == "" {
|
|
||||||
return p.SendReply(ctx.RoomID, ctx.EventID, "LLM is not configured.")
|
return p.SendReply(ctx.RoomID, ctx.EventID, "LLM is not configured.")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,7 +195,7 @@ Summary:`, tldrBotName, transcript)
|
|||||||
slog.Error("vibe: send thinking", "err", err)
|
slog.Error("vibe: send thinking", "err", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
response, err := callOllama(ollamaHost, ollamaModel, prompt)
|
response, err := callLLM(prompt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("vibe: ollama call", "err", err)
|
slog.Error("vibe: ollama call", "err", err)
|
||||||
p.resetCooldown(ctx.RoomID) // Don't consume cooldown on failure
|
p.resetCooldown(ctx.RoomID) // Don't consume cooldown on failure
|
||||||
|
|||||||
+17
-78
@@ -1,19 +1,17 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gogobee/internal/db"
|
"gogobee/internal/db"
|
||||||
"gogobee/internal/dreamclient"
|
"gogobee/internal/dreamclient"
|
||||||
|
"gogobee/internal/llm"
|
||||||
|
|
||||||
"maunium.net/go/mautrix"
|
"maunium.net/go/mautrix"
|
||||||
"maunium.net/go/mautrix/id"
|
"maunium.net/go/mautrix/id"
|
||||||
@@ -533,9 +531,7 @@ func (p *WOTDPlugin) trackUsage(ctx MessageContext) {
|
|||||||
// verifyUsage asks the LLM whether the word was used correctly in context.
|
// verifyUsage asks the LLM whether the word was used correctly in context.
|
||||||
// Returns false if LLM is not configured or on any error.
|
// Returns false if LLM is not configured or on any error.
|
||||||
func (p *WOTDPlugin) verifyUsage(word, message string) bool {
|
func (p *WOTDPlugin) verifyUsage(word, message string) bool {
|
||||||
host := os.Getenv("OLLAMA_HOST")
|
if !llmConfigured() {
|
||||||
model := os.Getenv("OLLAMA_MODEL")
|
|
||||||
if host == "" || model == "" {
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -545,58 +541,30 @@ The Word of the Day is "%s". Was this word used correctly and meaningfully in th
|
|||||||
|
|
||||||
Respond with ONLY "yes" or "no".`, message, word)
|
Respond with ONLY "yes" or "no".`, message, word)
|
||||||
|
|
||||||
payload := map[string]interface{}{
|
response, err := llmGenerate(context.Background(), llm.Request{
|
||||||
"model": model,
|
Prompt: prompt,
|
||||||
"prompt": prompt,
|
Timeout: wotdLLMTimeout,
|
||||||
"stream": false,
|
})
|
||||||
"think": false,
|
|
||||||
}
|
|
||||||
data, err := json.Marshal(payload)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
apiURL := strings.TrimRight(host, "/") + "/api/generate"
|
|
||||||
slog.Debug("wotd: sending LLM verification request", "url", apiURL, "word", word)
|
|
||||||
client := &http.Client{Timeout: 30 * time.Second}
|
|
||||||
resp, err := client.Post(apiURL, "application/json", bytes.NewReader(data))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("wotd: LLM verify request failed", "err", err)
|
slog.Error("wotd: LLM verify request failed", "err", err)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil || resp.StatusCode != http.StatusOK {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
var result struct {
|
|
||||||
Response string `json:"response"`
|
|
||||||
}
|
|
||||||
if json.Unmarshal(body, &result) != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
response := result.Response
|
|
||||||
// Strip <think>...</think> blocks (Qwen 3.5 reasoning)
|
|
||||||
if i := strings.Index(response, "<think>"); i != -1 {
|
|
||||||
if j := strings.Index(response, "</think>"); j != -1 {
|
|
||||||
response = response[:i] + response[j+len("</think>"):]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
answer := strings.ToLower(strings.TrimSpace(response))
|
answer := strings.ToLower(strings.TrimSpace(response))
|
||||||
accepted := strings.HasPrefix(answer, "yes")
|
accepted := strings.HasPrefix(answer, "yes")
|
||||||
slog.Debug("wotd: LLM verification", "word", word, "answer", answer, "accepted", accepted)
|
slog.Debug("wotd: LLM verification", "word", word, "answer", answer, "accepted", accepted)
|
||||||
return accepted
|
return accepted
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wotdLLMTimeout preserves the 30s cap both WOTD paths enforced with their own
|
||||||
|
// http.Client. These run on message traffic, so they stay well under the
|
||||||
|
// interactive default.
|
||||||
|
const wotdLLMTimeout = 30 * time.Second
|
||||||
|
|
||||||
// llmTranslate asks the LLM for a brief English translation of a foreign word.
|
// llmTranslate asks the LLM for a brief English translation of a foreign word.
|
||||||
// Returns empty string on failure.
|
// Returns empty string on failure.
|
||||||
func (p *WOTDPlugin) llmTranslate(word, lang string) string {
|
func (p *WOTDPlugin) llmTranslate(word, lang string) string {
|
||||||
host := os.Getenv("OLLAMA_HOST")
|
if !llmConfigured() {
|
||||||
model := os.Getenv("OLLAMA_MODEL")
|
|
||||||
if host == "" || model == "" {
|
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -612,44 +580,15 @@ func (p *WOTDPlugin) llmTranslate(word, lang string) string {
|
|||||||
`Translate the %s word "%s" into English. Reply with ONLY the English translation — one or two words, no explanation, no punctuation.`,
|
`Translate the %s word "%s" into English. Reply with ONLY the English translation — one or two words, no explanation, no punctuation.`,
|
||||||
langName, word)
|
langName, word)
|
||||||
|
|
||||||
payload := map[string]interface{}{
|
response, err := llmGenerate(context.Background(), llm.Request{
|
||||||
"model": model,
|
Prompt: prompt,
|
||||||
"prompt": prompt,
|
Timeout: wotdLLMTimeout,
|
||||||
"stream": false,
|
})
|
||||||
"think": false,
|
|
||||||
}
|
|
||||||
data, err := json.Marshal(payload)
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
apiURL := strings.TrimRight(host, "/") + "/api/generate"
|
|
||||||
client := &http.Client{Timeout: 30 * time.Second}
|
|
||||||
resp, err := client.Post(apiURL, "application/json", bytes.NewReader(data))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("wotd: LLM translate request failed", "err", err)
|
slog.Error("wotd: LLM translate request failed", "err", err)
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil || resp.StatusCode != http.StatusOK {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
var result struct {
|
|
||||||
Response string `json:"response"`
|
|
||||||
}
|
|
||||||
if json.Unmarshal(body, &result) != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
response := result.Response
|
|
||||||
if i := strings.Index(response, "<think>"); i != -1 {
|
|
||||||
if j := strings.Index(response, "</think>"); j != -1 {
|
|
||||||
response = response[:i] + response[j+len("</think>"):]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
translation := strings.TrimSpace(response)
|
translation := strings.TrimSpace(response)
|
||||||
if translation == "" || len(translation) > 50 {
|
if translation == "" || len(translation) > 50 {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"gogobee/internal/bot"
|
"gogobee/internal/bot"
|
||||||
"gogobee/internal/db"
|
"gogobee/internal/db"
|
||||||
"gogobee/internal/dreamclient"
|
"gogobee/internal/dreamclient"
|
||||||
|
"gogobee/internal/llm"
|
||||||
"gogobee/internal/peteclient"
|
"gogobee/internal/peteclient"
|
||||||
"gogobee/internal/plugin"
|
"gogobee/internal/plugin"
|
||||||
"gogobee/internal/util"
|
"gogobee/internal/util"
|
||||||
@@ -37,9 +38,11 @@ func main() {
|
|||||||
logLevel = "info"
|
logLevel = "info"
|
||||||
}
|
}
|
||||||
util.InitLogger(logLevel)
|
util.InitLogger(logLevel)
|
||||||
|
llmCfg := llm.ConfigFromEnv()
|
||||||
slog.Info(version.Full(), "level", logLevel,
|
slog.Info(version.Full(), "level", logLevel,
|
||||||
"ollama_host", os.Getenv("OLLAMA_HOST"),
|
"llm_backend", llmCfg.Backend,
|
||||||
"ollama_model", os.Getenv("OLLAMA_MODEL"))
|
"llm_endpoint", llmCfg.Endpoint,
|
||||||
|
"llm_model", llmCfg.Model)
|
||||||
|
|
||||||
dataDir := os.Getenv("DATA_DIR")
|
dataDir := os.Getenv("DATA_DIR")
|
||||||
if dataDir == "" {
|
if dataDir == "" {
|
||||||
|
|||||||
Reference in New Issue
Block a user