rony-chat-bot/internal/agent/runner.go
Victor Hugo Vargas f33708534a feat: bootstrap rony-chat-bot Go module
Initial implementation of the bot:

- cmd/chat-bot: CLI entrypoint (serve, reindex, ask, version)
- internal/agent: LLM provider client + agent runner with RAG injection
- internal/config: YAML config loader (providers, RAG, persona, server)
- internal/i18n: response-language detection (EN/ES)
- internal/persona: persona system prompt assembly from YAML
- internal/portfolio: heading-based chunker + SQLite FTS5 indexer
- internal/server: chi router with /api/chat (SSE), /api/health, /api/info,
  /api/reindex, middleware (RequestID, Logging, CORS, RateLimit)
- internal/streaming: SSE protocol helpers (start, chunk, sources, done, error)
- web/: drop-in vanilla-JS chat widget (no build, no deps) + demo + README
- bench/: reproducible driver benchmark (modernc vs mattn SQLite)
- configs/portfolio-bot.yaml: llama.cpp default provider, SQLite RAG, canine persona
- docs/architecture.md / .es.md: aligned with SQLite FTS5 + llama.cpp decisions
- data/projects/README*.md: project data documentation
- README.md / .es.md: updated for current implementation

All tests pass (go test ./...). Bot is functional end-to-end with the
configured LLM provider.
2026-07-17 00:56:06 -07:00

125 lines
No EOL
4 KiB
Go

// Package agent wraps the LLM client + RAG pipeline behind a single
// streaming call the HTTP handler can drive.
//
// We use llm.LLMClient directly (not agent.Loop) because the bot is a
// straight Q&A flow: no tools, no multi-iteration reasoning. Calling the
// underlying provider keeps the prompt under our full control so we can
// inject RAG context into the system message exactly where we want it.
package agent
import (
"context"
"fmt"
"iter"
"strings"
"github.com/VictorVargas/rony-llm-agent/pkg/llm"
llmpersona "github.com/VictorVargas/rony-llm-agent/pkg/persona"
botpersona "github.com/VictorVargas/rony-chat-bot/internal/persona"
"github.com/VictorVargas/rony-chat-bot/internal/portfolio"
)
// Message aliases keep the HTTP handler decoupled from the upstream types.
type Message = llm.Message
type Role = llm.Role
const (
RoleSystem = llm.RoleSystem
RoleUser = llm.RoleUser
RoleAssistant = llm.RoleAssistant
)
// Runner ties together an LLM client, the system prompt, and the RAG store.
// The persona struct is kept only for the UI greeting (its name + intro);
// the system prompt itself lives in the YAML and is passed in directly.
type Runner struct {
client llm.LLMClient
persona llmpersona.Persona
systemPrompt string
store *portfolio.Store
topK int
usage *Usage
}
type Usage struct {
InputTokens int
OutputTokens int
}
// New returns a Runner. The store may be nil (RAG disabled).
// systemPrompt is the full hand-written prompt from configs/...yaml.
func New(client llm.LLMClient, p llmpersona.Persona, systemPrompt string, store *portfolio.Store, topK int) *Runner {
return &Runner{client: client, persona: p, systemPrompt: systemPrompt, store: store, topK: topK, usage: &Usage{}}
}
// LastUsage returns the token usage recorded on the most recent call.
func (r *Runner) LastUsage() Usage { return *r.usage }
// BuildMessages prepares the system prompt and turns the chat history into
// the upstream message list. The system prompt includes RAG context for the
// user's last message (if RAG is enabled).
func (r *Runner) BuildMessages(ctx context.Context, history []Message) ([]Message, string, error) {
ragContext := ""
if r.store != nil && len(history) > 0 {
last := history[len(history)-1]
if last.Role == RoleUser {
hits, err := r.store.Search(ctx, last.Content, r.topK)
if err != nil {
return nil, "", fmt.Errorf("rag search: %w", err)
}
if len(hits) > 0 {
ragContext = formatHits(hits)
}
}
}
// The system prompt comes from the YAML, not from the persona struct.
// Keep the persona around only for the UI greeting.
system := botpersona.BuildSystemPrompt(r.systemPrompt, ragContext)
msgs := make([]Message, 0, len(history)+1)
msgs = append(msgs, Message{Role: RoleSystem, Content: system})
msgs = append(msgs, history...)
return msgs, ragContext, nil
}
// Stream runs the model and yields each streamed chunk. The caller
// forwards chunk.Delta to the SSE stream; chunk.Usage on the last chunk
// carries token counts.
func (r *Runner) Stream(ctx context.Context, history []Message) iter.Seq2[llm.StreamChunk, error] {
return func(yield func(llm.StreamChunk, error) bool) {
msgs, _, err := r.BuildMessages(ctx, history)
if err != nil {
yield(llm.StreamChunk{}, err)
return
}
req := llm.CompletionRequest{
Messages: msgs,
// No tools: this is a Q&A bot, not an agent.
}
for chunk, err := range r.client.Stream(ctx, req) {
if chunk.Usage.TotalTokens > 0 || chunk.Usage.InputTokens > 0 || chunk.Usage.OutputTokens > 0 {
r.usage = &Usage{
InputTokens: chunk.Usage.InputTokens,
OutputTokens: chunk.Usage.OutputTokens,
}
}
if !yield(chunk, err) {
return
}
if err != nil {
return
}
}
}
}
func formatHits(hits []portfolio.SearchResult) string {
var b strings.Builder
for i, h := range hits {
fmt.Fprintf(&b, "### [%d] %s — %s\n", i+1, h.ProjectID, h.Section)
b.WriteString(h.Content)
b.WriteString("\n\n")
}
return b.String()
}