Parent directory

llm.go

4483 bytes
  1// Package adapters provides the concrete game adapters that wrap the existing
  2// HTTP clients (llm, stt, tts). They implement the interfaces defined in
  3// internal/game so the orchestrator can be wired to real services here and to
  4// fakes in tests. This package is an HTTP client only; it never starts or
  5// manages any model service.
  6package adapters
  7
  8import (
  9	"context"
 10	"fmt"
 11	"os"
 12	"strings"
 13	"time"
 14
 15	"git.theedgeofrage.com/TheEdgeOfRage/kaiwari/internal/llm"
 16)
 17
 18// rawLogPath is where raw model outputs are dumped for debugging, since the TUI
 19// owns the terminal and direct stderr writes get mangled by redraws.
 20const rawLogPath = "kaiwari_raw.log"
 21
 22var rawLogEnabled = os.Getenv("DEBUG")
 23
 24// Per-call model timeouts, applied via context so a slow call cannot hang a turn
 25// or exhaust a shared client deadline. Compaction reads long closed segments so it
 26// gets the largest budget; world turns next; one-shot side tasks are fast.
 27const (
 28	gameTurnTimeout   = 20 * time.Second
 29	compactionTimeout = 60 * time.Second
 30	sideTaskTimeout   = 10 * time.Second // judge, sheet, scratch ask, flashcards
 31)
 32
 33func dumpRaw(label, transcript, raw string) {
 34	if rawLogEnabled != "1" {
 35		return
 36	}
 37	f, err := os.OpenFile(rawLogPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
 38	if err != nil {
 39		return
 40	}
 41	defer f.Close()                                                                                                  //nolint:errcheck // best-effort debug log; never block the turn
 42	fmt.Fprintf(f, "--- %s %s ---\ntranscript: %s\n%s\n\n", label, time.Now().Format(time.RFC3339), transcript, raw) //nolint:errcheck
 43}
 44
 45// GameModel adapts an llm.Client to game.GameModel. It sends the reconstructed
 46// prompt and parses the unified six-field reply.
 47type GameModel struct {
 48	Client *llm.Client
 49}
 50
 51func (m *GameModel) Advance(ctx context.Context, msgs []llm.Message) (llm.GameReply, error) {
 52	ctx, cancel := context.WithTimeout(ctx, gameTurnTimeout)
 53	defer cancel()
 54	raw, err := m.Client.Generate(ctx, msgs)
 55	if err != nil {
 56		return llm.GameReply{}, err
 57	}
 58	dumpRaw("game", "", raw)
 59	return llm.ParseGameReply(raw)
 60}
 61
 62// Compactor adapts an llm.Client to game.Compactor. It compresses one stretch of
 63// messages at a location into a short memory using the compaction prompt.
 64type Compactor struct {
 65	Client *llm.Client
 66}
 67
 68func (m *Compactor) Summarize(ctx context.Context, location string, msgs []llm.Message) (string, error) {
 69	ctx, cancel := context.WithTimeout(ctx, compactionTimeout)
 70	defer cancel()
 71	raw, err := m.Client.Generate(ctx, llm.BuildCompactionMessages(location, msgs))
 72	if err != nil {
 73		return "", err
 74	}
 75	dumpRaw("compact:"+location, "", raw)
 76	return strings.TrimSpace(raw), nil
 77}
 78
 79// SheetModel adapts an llm.Client to game.SheetModel. It generates one
 80// person's character sheet from their introducing reply and parses the
 81// six-field sheet contract.
 82type SheetModel struct {
 83	Client *llm.Client
 84}
 85
 86func (m *SheetModel) GenerateSheet(ctx context.Context, brief, location, talk, intro, firstLine string) (llm.Sheet, error) {
 87	ctx, cancel := context.WithTimeout(ctx, sideTaskTimeout)
 88	defer cancel()
 89	raw, err := m.Client.Generate(ctx, llm.BuildSheetMessages(brief, location, talk, intro, firstLine))
 90	if err != nil {
 91		return llm.Sheet{}, err
 92	}
 93	dumpRaw("sheet:"+location+":"+talk, "", raw)
 94	return llm.ParseSheet(raw)
 95}
 96
 97// JudgeModel adapts an llm.Client to game.JudgeModel. It issues the separate,
 98// persona-free judge request and parses the strict three-field result.
 99type JudgeModel struct {
100	Client *llm.Client
101}
102
103func (m *JudgeModel) Judge(ctx context.Context, transcript string) (llm.JudgeResult, error) {
104	ctx, cancel := context.WithTimeout(ctx, sideTaskTimeout)
105	defer cancel()
106	msgs := llm.BuildJudgeMessages(transcript)
107	raw, err := m.Client.Generate(ctx, msgs)
108	if err != nil {
109		return llm.JudgeResult{}, err
110	}
111	dumpRaw("judge", transcript, raw)
112	return llm.ParseJudge(raw)
113}
114
115// ScratchModel adapts an llm.Client to game.ScratchModel. It asks how to say a
116// thing in Japanese and parses the three-field answer; no world context is sent.
117type ScratchModel struct {
118	Client *llm.Client
119}
120
121func (m *ScratchModel) Ask(ctx context.Context, question string) (llm.ScratchAnswer, error) {
122	ctx, cancel := context.WithTimeout(ctx, sideTaskTimeout)
123	defer cancel()
124	raw, err := m.Client.Generate(ctx, llm.BuildScratchMessages(question))
125	if err != nil {
126		return llm.ScratchAnswer{}, err
127	}
128	dumpRaw("scratch", question, raw)
129	return llm.ParseScratch(raw)
130}