125 lines
4 KiB
Go
125 lines
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()
|
||
|
|
}
|