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}