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 07:56:06 +00:00
|
|
|
package server
|
|
|
|
|
|
|
|
|
|
import (
|
feat: persistent conversation storage (Phase 4)
Conversations survive page reloads and work for any frontend, not just
the widget. Server-side SQLite, conversation ID as bearer token, browser
identity via localStorage.
Backend
-------
- internal/portfolio/conversations.go: schema + CRUD. Conversations and
messages tables in the same SQLite DB as the RAG index, with
foreign-key cascade delete. Conv IDs are 16-byte random hex
(128 bits of entropy).
- internal/portfolio/indexer.go: applies conversation schema + enables
foreign_keys pragma in OpenStore.
- internal/server/handlers.go: POST /api/chat accepts an optional
conversation_id, mints one if absent, persists user message before
the LLM runs and assistant message (with sources) after the stream
completes. New handlers: GetConversation, ListConversations,
DeleteConversation.
- internal/server/server.go: routes for GET /api/conversations,
GET/DELETE /api/conversations/{id}.
- internal/server/conversations_test.go: 6 tests (round-trip, continue,
list, 404, delete, streaming).
Widget
------
- web/chat-widget.js: stores conv_id in localStorage["rony-chat-conv"],
includes it in the chat request body, captures new IDs from the
server's 'start' SSE event, and calls GET /api/conversations/{id} on
load to restore history. On 404 it clears the stored ID and starts
fresh.
Docs
----
- docs/architecture.md: §3.1 documents the conversation_id field and
new REST endpoints; new §3.4 covers persistence lifecycle, schema,
client responsibilities, and auth model. §5.6 updated; filetree
reflects the new files.
- web/README.md: new 'Conversation persistence' section explains the
browser-scoped behavior and how to opt out or persist across devices.
2026-07-17 07:56:35 +00:00
|
|
|
"context"
|
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 07:56:06 +00:00
|
|
|
"encoding/json"
|
|
|
|
|
"errors"
|
|
|
|
|
"fmt"
|
|
|
|
|
"log/slog"
|
|
|
|
|
"net/http"
|
feat: persistent conversation storage (Phase 4)
Conversations survive page reloads and work for any frontend, not just
the widget. Server-side SQLite, conversation ID as bearer token, browser
identity via localStorage.
Backend
-------
- internal/portfolio/conversations.go: schema + CRUD. Conversations and
messages tables in the same SQLite DB as the RAG index, with
foreign-key cascade delete. Conv IDs are 16-byte random hex
(128 bits of entropy).
- internal/portfolio/indexer.go: applies conversation schema + enables
foreign_keys pragma in OpenStore.
- internal/server/handlers.go: POST /api/chat accepts an optional
conversation_id, mints one if absent, persists user message before
the LLM runs and assistant message (with sources) after the stream
completes. New handlers: GetConversation, ListConversations,
DeleteConversation.
- internal/server/server.go: routes for GET /api/conversations,
GET/DELETE /api/conversations/{id}.
- internal/server/conversations_test.go: 6 tests (round-trip, continue,
list, 404, delete, streaming).
Widget
------
- web/chat-widget.js: stores conv_id in localStorage["rony-chat-conv"],
includes it in the chat request body, captures new IDs from the
server's 'start' SSE event, and calls GET /api/conversations/{id} on
load to restore history. On 404 it clears the stored ID and starts
fresh.
Docs
----
- docs/architecture.md: §3.1 documents the conversation_id field and
new REST endpoints; new §3.4 covers persistence lifecycle, schema,
client responsibilities, and auth model. §5.6 updated; filetree
reflects the new files.
- web/README.md: new 'Conversation persistence' section explains the
browser-scoped behavior and how to opt out or persist across devices.
2026-07-17 07:56:35 +00:00
|
|
|
"strconv"
|
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 07:56:06 +00:00
|
|
|
"strings"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/VictorVargas/rony-chat-bot/internal/agent"
|
|
|
|
|
"github.com/VictorVargas/rony-chat-bot/internal/config"
|
|
|
|
|
"github.com/VictorVargas/rony-chat-bot/internal/portfolio"
|
|
|
|
|
"github.com/VictorVargas/rony-chat-bot/internal/streaming"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type Handlers struct {
|
|
|
|
|
cfg *config.Config
|
|
|
|
|
runner *agent.Runner
|
|
|
|
|
store *portfolio.Store
|
|
|
|
|
version string
|
feat(rag): hybrid retrieval, reference documents, and vendor sampling
Answers were short, sometimes in the wrong language, and occasionally about
projects that do not exist. Measured on a 20-question battery against the real
corpus in both Spanish and English, this takes grounded content from 3/10 to
9/10 and language matching from 7/10 to 10/10.
Retrieval
- Fuse FTS5 keyword search with dense vectors via Reciprocal Rank Fusion.
Both halves are load-bearing: the corpus is English and visitors ask in
Spanish, so the meaningful words score zero. "paga" appears 0 times in a
document that says "Payments: Stripe" — the question "¿Con qué se paga en la
tienda de ropa?" retrieved nothing at all. Embeddings put all three of that
project's chunks on top. RRF ranks by agreement rather than comparing a BM25
score against a cosine, quantities with no shared scale.
- internal/embed: OpenAI-compatible embeddings client, unit-normalised so a
dot product is the cosine. Reorders by the response `index` field.
- Store a content hash beside each vector and skip rows where it no longer
matches the chunk. Chunk ids survive body edits, so without this an edited
document keeps serving embeddings that describe text that is gone —
reproduced live by changing a payment provider and watching the old one keep
coming back.
- Degrade to keyword-only when the embedder is down instead of failing.
Reference documents that are not projects
- Index `.mdx` alongside `.md`, and split sources into projects (announced in
the catalogue) and reference material (retrievable, never listed). A CV is
what someone deciding whether to hire actually reads, and it was unreachable
while it lived only in the Astro site — but filing it under projects made
the bot list "cv" as one of Victor's works.
- Skip each directory's README. `data/projects/README.md` was being indexed,
so the catalogue injected into every prompt announced "README" and
"README.es" as projects of Victor's.
- Exclude frontmatter from retrieval. It is dense metadata in a very short
chunk, which makes it a magnet for short queries: a CV's `location:` field
answered "¿Dónde ha trabajado Victor?" with a city instead of a work history.
- Split oversized sections at `###` before falling back to byte offsets. A CV's
Experience section is a list of jobs, and size-splitting cut one mid-word,
stranding the employer's name in the previous chunk.
Prompt and sampling
- Inject the full project catalogue every turn. Top-K search returns the best
matching sections, so "list every project" cannot be answered from retrieval
alone, and a small model asked to enumerate from partial hits invents the
rest. ~10 tokens per project; this is what stopped the invented names.
- Wire the sampling parameters the model authors publish (top_k, top_p, min_p,
repeat_penalty, presence_penalty) through config to llama.cpp. Leaving them
at llama.cpp's defaults produced 16-token stub answers.
- Localised system prompt selected by detected language. The English prompt
plus "reply in the user's language" answered 1/5 Spanish questions in
Spanish; few-shot examples fixed the language but got copied verbatim into
real answers.
- Fold compaction's system notes into the leading system message. Gemma's chat
template rejects a system message that is not first, and the whole request
failed with HTTP 400 the moment compaction fired.
Configuration and docs
- context_size 4096, down from 8192. The largest prompt this bot ever built
over 20 real requests was 1255 tokens, compaction starts at ~3070, and the
cut saved 212 MB resident with zero truncations and identical throughput.
- Correct the RAM figures throughout. They were measured with a GPU absorbing
llama.cpp's buffers; on a GPU-less VPS those come out of system RAM, which
is 1.1 GB more for qwen2.5-3b and 2.8 GB more for granite. Both READMEs
still started gemma-3-1b while the config defaulted to qwen, and neither
started the embedder at all.
Measured on the 2-core, 8 GB CPU-only target: 3.64 GB LLM + 0.91 GB embedder
+ 0.02 GB bot, 21.0 tok/s steady state.
Known and unfixed, so they are not re-filed as new bugs: the model reads dates
out of the CV correctly but does the arithmetic on them wrong, and "¿Dónde ha
trabajado Victor?" still answers with projects rather than employers, though
"¿En qué empresas ha trabajado?" works.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 21:45:37 +00:00
|
|
|
// embedder is used only by the /api/reindex endpoint, so a rebuild
|
|
|
|
|
// triggered over HTTP refreshes the vectors too instead of silently
|
|
|
|
|
// leaving them describing the previous corpus. May be nil.
|
|
|
|
|
embedder portfolio.Embedder
|
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 07:56:06 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func NewHandlers(cfg *config.Config, runner *agent.Runner, store *portfolio.Store, version string) *Handlers {
|
|
|
|
|
return &Handlers{cfg: cfg, runner: runner, store: store, version: version}
|
|
|
|
|
}
|
|
|
|
|
|
feat(rag): hybrid retrieval, reference documents, and vendor sampling
Answers were short, sometimes in the wrong language, and occasionally about
projects that do not exist. Measured on a 20-question battery against the real
corpus in both Spanish and English, this takes grounded content from 3/10 to
9/10 and language matching from 7/10 to 10/10.
Retrieval
- Fuse FTS5 keyword search with dense vectors via Reciprocal Rank Fusion.
Both halves are load-bearing: the corpus is English and visitors ask in
Spanish, so the meaningful words score zero. "paga" appears 0 times in a
document that says "Payments: Stripe" — the question "¿Con qué se paga en la
tienda de ropa?" retrieved nothing at all. Embeddings put all three of that
project's chunks on top. RRF ranks by agreement rather than comparing a BM25
score against a cosine, quantities with no shared scale.
- internal/embed: OpenAI-compatible embeddings client, unit-normalised so a
dot product is the cosine. Reorders by the response `index` field.
- Store a content hash beside each vector and skip rows where it no longer
matches the chunk. Chunk ids survive body edits, so without this an edited
document keeps serving embeddings that describe text that is gone —
reproduced live by changing a payment provider and watching the old one keep
coming back.
- Degrade to keyword-only when the embedder is down instead of failing.
Reference documents that are not projects
- Index `.mdx` alongside `.md`, and split sources into projects (announced in
the catalogue) and reference material (retrievable, never listed). A CV is
what someone deciding whether to hire actually reads, and it was unreachable
while it lived only in the Astro site — but filing it under projects made
the bot list "cv" as one of Victor's works.
- Skip each directory's README. `data/projects/README.md` was being indexed,
so the catalogue injected into every prompt announced "README" and
"README.es" as projects of Victor's.
- Exclude frontmatter from retrieval. It is dense metadata in a very short
chunk, which makes it a magnet for short queries: a CV's `location:` field
answered "¿Dónde ha trabajado Victor?" with a city instead of a work history.
- Split oversized sections at `###` before falling back to byte offsets. A CV's
Experience section is a list of jobs, and size-splitting cut one mid-word,
stranding the employer's name in the previous chunk.
Prompt and sampling
- Inject the full project catalogue every turn. Top-K search returns the best
matching sections, so "list every project" cannot be answered from retrieval
alone, and a small model asked to enumerate from partial hits invents the
rest. ~10 tokens per project; this is what stopped the invented names.
- Wire the sampling parameters the model authors publish (top_k, top_p, min_p,
repeat_penalty, presence_penalty) through config to llama.cpp. Leaving them
at llama.cpp's defaults produced 16-token stub answers.
- Localised system prompt selected by detected language. The English prompt
plus "reply in the user's language" answered 1/5 Spanish questions in
Spanish; few-shot examples fixed the language but got copied verbatim into
real answers.
- Fold compaction's system notes into the leading system message. Gemma's chat
template rejects a system message that is not first, and the whole request
failed with HTTP 400 the moment compaction fired.
Configuration and docs
- context_size 4096, down from 8192. The largest prompt this bot ever built
over 20 real requests was 1255 tokens, compaction starts at ~3070, and the
cut saved 212 MB resident with zero truncations and identical throughput.
- Correct the RAM figures throughout. They were measured with a GPU absorbing
llama.cpp's buffers; on a GPU-less VPS those come out of system RAM, which
is 1.1 GB more for qwen2.5-3b and 2.8 GB more for granite. Both READMEs
still started gemma-3-1b while the config defaulted to qwen, and neither
started the embedder at all.
Measured on the 2-core, 8 GB CPU-only target: 3.64 GB LLM + 0.91 GB embedder
+ 0.02 GB bot, 21.0 tok/s steady state.
Known and unfixed, so they are not re-filed as new bugs: the model reads dates
out of the CV correctly but does the arithmetic on them wrong, and "¿Dónde ha
trabajado Victor?" still answers with projects rather than employers, though
"¿En qué empresas ha trabajado?" works.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 21:45:37 +00:00
|
|
|
// WithEmbedder attaches the embeddings client used when reindexing over HTTP.
|
|
|
|
|
func (h *Handlers) WithEmbedder(e portfolio.Embedder) *Handlers {
|
|
|
|
|
h.embedder = e
|
|
|
|
|
return h
|
|
|
|
|
}
|
|
|
|
|
|
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 07:56:06 +00:00
|
|
|
type ChatRequest struct {
|
feat(rag): hybrid retrieval, reference documents, and vendor sampling
Answers were short, sometimes in the wrong language, and occasionally about
projects that do not exist. Measured on a 20-question battery against the real
corpus in both Spanish and English, this takes grounded content from 3/10 to
9/10 and language matching from 7/10 to 10/10.
Retrieval
- Fuse FTS5 keyword search with dense vectors via Reciprocal Rank Fusion.
Both halves are load-bearing: the corpus is English and visitors ask in
Spanish, so the meaningful words score zero. "paga" appears 0 times in a
document that says "Payments: Stripe" — the question "¿Con qué se paga en la
tienda de ropa?" retrieved nothing at all. Embeddings put all three of that
project's chunks on top. RRF ranks by agreement rather than comparing a BM25
score against a cosine, quantities with no shared scale.
- internal/embed: OpenAI-compatible embeddings client, unit-normalised so a
dot product is the cosine. Reorders by the response `index` field.
- Store a content hash beside each vector and skip rows where it no longer
matches the chunk. Chunk ids survive body edits, so without this an edited
document keeps serving embeddings that describe text that is gone —
reproduced live by changing a payment provider and watching the old one keep
coming back.
- Degrade to keyword-only when the embedder is down instead of failing.
Reference documents that are not projects
- Index `.mdx` alongside `.md`, and split sources into projects (announced in
the catalogue) and reference material (retrievable, never listed). A CV is
what someone deciding whether to hire actually reads, and it was unreachable
while it lived only in the Astro site — but filing it under projects made
the bot list "cv" as one of Victor's works.
- Skip each directory's README. `data/projects/README.md` was being indexed,
so the catalogue injected into every prompt announced "README" and
"README.es" as projects of Victor's.
- Exclude frontmatter from retrieval. It is dense metadata in a very short
chunk, which makes it a magnet for short queries: a CV's `location:` field
answered "¿Dónde ha trabajado Victor?" with a city instead of a work history.
- Split oversized sections at `###` before falling back to byte offsets. A CV's
Experience section is a list of jobs, and size-splitting cut one mid-word,
stranding the employer's name in the previous chunk.
Prompt and sampling
- Inject the full project catalogue every turn. Top-K search returns the best
matching sections, so "list every project" cannot be answered from retrieval
alone, and a small model asked to enumerate from partial hits invents the
rest. ~10 tokens per project; this is what stopped the invented names.
- Wire the sampling parameters the model authors publish (top_k, top_p, min_p,
repeat_penalty, presence_penalty) through config to llama.cpp. Leaving them
at llama.cpp's defaults produced 16-token stub answers.
- Localised system prompt selected by detected language. The English prompt
plus "reply in the user's language" answered 1/5 Spanish questions in
Spanish; few-shot examples fixed the language but got copied verbatim into
real answers.
- Fold compaction's system notes into the leading system message. Gemma's chat
template rejects a system message that is not first, and the whole request
failed with HTTP 400 the moment compaction fired.
Configuration and docs
- context_size 4096, down from 8192. The largest prompt this bot ever built
over 20 real requests was 1255 tokens, compaction starts at ~3070, and the
cut saved 212 MB resident with zero truncations and identical throughput.
- Correct the RAM figures throughout. They were measured with a GPU absorbing
llama.cpp's buffers; on a GPU-less VPS those come out of system RAM, which
is 1.1 GB more for qwen2.5-3b and 2.8 GB more for granite. Both READMEs
still started gemma-3-1b while the config defaulted to qwen, and neither
started the embedder at all.
Measured on the 2-core, 8 GB CPU-only target: 3.64 GB LLM + 0.91 GB embedder
+ 0.02 GB bot, 21.0 tok/s steady state.
Known and unfixed, so they are not re-filed as new bugs: the model reads dates
out of the CV correctly but does the arithmetic on them wrong, and "¿Dónde ha
trabajado Victor?" still answers with projects rather than employers, though
"¿En qué empresas ha trabajado?" works.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 21:45:37 +00:00
|
|
|
Messages []ChatMessage `json:"messages"`
|
|
|
|
|
Stream *bool `json:"stream,omitempty"`
|
feat: persistent conversation storage (Phase 4)
Conversations survive page reloads and work for any frontend, not just
the widget. Server-side SQLite, conversation ID as bearer token, browser
identity via localStorage.
Backend
-------
- internal/portfolio/conversations.go: schema + CRUD. Conversations and
messages tables in the same SQLite DB as the RAG index, with
foreign-key cascade delete. Conv IDs are 16-byte random hex
(128 bits of entropy).
- internal/portfolio/indexer.go: applies conversation schema + enables
foreign_keys pragma in OpenStore.
- internal/server/handlers.go: POST /api/chat accepts an optional
conversation_id, mints one if absent, persists user message before
the LLM runs and assistant message (with sources) after the stream
completes. New handlers: GetConversation, ListConversations,
DeleteConversation.
- internal/server/server.go: routes for GET /api/conversations,
GET/DELETE /api/conversations/{id}.
- internal/server/conversations_test.go: 6 tests (round-trip, continue,
list, 404, delete, streaming).
Widget
------
- web/chat-widget.js: stores conv_id in localStorage["rony-chat-conv"],
includes it in the chat request body, captures new IDs from the
server's 'start' SSE event, and calls GET /api/conversations/{id} on
load to restore history. On 404 it clears the stored ID and starts
fresh.
Docs
----
- docs/architecture.md: §3.1 documents the conversation_id field and
new REST endpoints; new §3.4 covers persistence lifecycle, schema,
client responsibilities, and auth model. §5.6 updated; filetree
reflects the new files.
- web/README.md: new 'Conversation persistence' section explains the
browser-scoped behavior and how to opt out or persist across devices.
2026-07-17 07:56:35 +00:00
|
|
|
// ConversationID is optional. If empty, the server creates a new
|
|
|
|
|
// conversation and returns its ID in the response (or in the SSE
|
|
|
|
|
// `start` event). Pass an existing ID to continue a previous thread.
|
|
|
|
|
ConversationID string `json:"conversation_id,omitempty"`
|
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 07:56:06 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type ChatMessage struct {
|
|
|
|
|
Role string `json:"role"`
|
|
|
|
|
Content string `json:"content"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type ChatResponse struct {
|
feat(rag): hybrid retrieval, reference documents, and vendor sampling
Answers were short, sometimes in the wrong language, and occasionally about
projects that do not exist. Measured on a 20-question battery against the real
corpus in both Spanish and English, this takes grounded content from 3/10 to
9/10 and language matching from 7/10 to 10/10.
Retrieval
- Fuse FTS5 keyword search with dense vectors via Reciprocal Rank Fusion.
Both halves are load-bearing: the corpus is English and visitors ask in
Spanish, so the meaningful words score zero. "paga" appears 0 times in a
document that says "Payments: Stripe" — the question "¿Con qué se paga en la
tienda de ropa?" retrieved nothing at all. Embeddings put all three of that
project's chunks on top. RRF ranks by agreement rather than comparing a BM25
score against a cosine, quantities with no shared scale.
- internal/embed: OpenAI-compatible embeddings client, unit-normalised so a
dot product is the cosine. Reorders by the response `index` field.
- Store a content hash beside each vector and skip rows where it no longer
matches the chunk. Chunk ids survive body edits, so without this an edited
document keeps serving embeddings that describe text that is gone —
reproduced live by changing a payment provider and watching the old one keep
coming back.
- Degrade to keyword-only when the embedder is down instead of failing.
Reference documents that are not projects
- Index `.mdx` alongside `.md`, and split sources into projects (announced in
the catalogue) and reference material (retrievable, never listed). A CV is
what someone deciding whether to hire actually reads, and it was unreachable
while it lived only in the Astro site — but filing it under projects made
the bot list "cv" as one of Victor's works.
- Skip each directory's README. `data/projects/README.md` was being indexed,
so the catalogue injected into every prompt announced "README" and
"README.es" as projects of Victor's.
- Exclude frontmatter from retrieval. It is dense metadata in a very short
chunk, which makes it a magnet for short queries: a CV's `location:` field
answered "¿Dónde ha trabajado Victor?" with a city instead of a work history.
- Split oversized sections at `###` before falling back to byte offsets. A CV's
Experience section is a list of jobs, and size-splitting cut one mid-word,
stranding the employer's name in the previous chunk.
Prompt and sampling
- Inject the full project catalogue every turn. Top-K search returns the best
matching sections, so "list every project" cannot be answered from retrieval
alone, and a small model asked to enumerate from partial hits invents the
rest. ~10 tokens per project; this is what stopped the invented names.
- Wire the sampling parameters the model authors publish (top_k, top_p, min_p,
repeat_penalty, presence_penalty) through config to llama.cpp. Leaving them
at llama.cpp's defaults produced 16-token stub answers.
- Localised system prompt selected by detected language. The English prompt
plus "reply in the user's language" answered 1/5 Spanish questions in
Spanish; few-shot examples fixed the language but got copied verbatim into
real answers.
- Fold compaction's system notes into the leading system message. Gemma's chat
template rejects a system message that is not first, and the whole request
failed with HTTP 400 the moment compaction fired.
Configuration and docs
- context_size 4096, down from 8192. The largest prompt this bot ever built
over 20 real requests was 1255 tokens, compaction starts at ~3070, and the
cut saved 212 MB resident with zero truncations and identical throughput.
- Correct the RAM figures throughout. They were measured with a GPU absorbing
llama.cpp's buffers; on a GPU-less VPS those come out of system RAM, which
is 1.1 GB more for qwen2.5-3b and 2.8 GB more for granite. Both READMEs
still started gemma-3-1b while the config defaulted to qwen, and neither
started the embedder at all.
Measured on the 2-core, 8 GB CPU-only target: 3.64 GB LLM + 0.91 GB embedder
+ 0.02 GB bot, 21.0 tok/s steady state.
Known and unfixed, so they are not re-filed as new bugs: the model reads dates
out of the CV correctly but does the arithmetic on them wrong, and "¿Dónde ha
trabajado Victor?" still answers with projects rather than employers, though
"¿En qué empresas ha trabajado?" works.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 21:45:37 +00:00
|
|
|
ConversationID string `json:"conversation_id"`
|
|
|
|
|
Content string `json:"content"`
|
|
|
|
|
Sources []string `json:"sources,omitempty"`
|
feat: persistent conversation storage (Phase 4)
Conversations survive page reloads and work for any frontend, not just
the widget. Server-side SQLite, conversation ID as bearer token, browser
identity via localStorage.
Backend
-------
- internal/portfolio/conversations.go: schema + CRUD. Conversations and
messages tables in the same SQLite DB as the RAG index, with
foreign-key cascade delete. Conv IDs are 16-byte random hex
(128 bits of entropy).
- internal/portfolio/indexer.go: applies conversation schema + enables
foreign_keys pragma in OpenStore.
- internal/server/handlers.go: POST /api/chat accepts an optional
conversation_id, mints one if absent, persists user message before
the LLM runs and assistant message (with sources) after the stream
completes. New handlers: GetConversation, ListConversations,
DeleteConversation.
- internal/server/server.go: routes for GET /api/conversations,
GET/DELETE /api/conversations/{id}.
- internal/server/conversations_test.go: 6 tests (round-trip, continue,
list, 404, delete, streaming).
Widget
------
- web/chat-widget.js: stores conv_id in localStorage["rony-chat-conv"],
includes it in the chat request body, captures new IDs from the
server's 'start' SSE event, and calls GET /api/conversations/{id} on
load to restore history. On 404 it clears the stored ID and starts
fresh.
Docs
----
- docs/architecture.md: §3.1 documents the conversation_id field and
new REST endpoints; new §3.4 covers persistence lifecycle, schema,
client responsibilities, and auth model. §5.6 updated; filetree
reflects the new files.
- web/README.md: new 'Conversation persistence' section explains the
browser-scoped behavior and how to opt out or persist across devices.
2026-07-17 07:56:35 +00:00
|
|
|
Usage streaming.Usage `json:"usage"`
|
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 07:56:06 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *Handlers) Chat(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if r.Method != http.MethodPost {
|
|
|
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
var req ChatRequest
|
|
|
|
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
|
|
|
|
|
http.Error(w, "invalid JSON body: "+err.Error(), http.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if err := req.validate(); err != nil {
|
|
|
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
stream := true
|
|
|
|
|
if req.Stream != nil {
|
|
|
|
|
stream = *req.Stream
|
|
|
|
|
}
|
feat: persistent conversation storage (Phase 4)
Conversations survive page reloads and work for any frontend, not just
the widget. Server-side SQLite, conversation ID as bearer token, browser
identity via localStorage.
Backend
-------
- internal/portfolio/conversations.go: schema + CRUD. Conversations and
messages tables in the same SQLite DB as the RAG index, with
foreign-key cascade delete. Conv IDs are 16-byte random hex
(128 bits of entropy).
- internal/portfolio/indexer.go: applies conversation schema + enables
foreign_keys pragma in OpenStore.
- internal/server/handlers.go: POST /api/chat accepts an optional
conversation_id, mints one if absent, persists user message before
the LLM runs and assistant message (with sources) after the stream
completes. New handlers: GetConversation, ListConversations,
DeleteConversation.
- internal/server/server.go: routes for GET /api/conversations,
GET/DELETE /api/conversations/{id}.
- internal/server/conversations_test.go: 6 tests (round-trip, continue,
list, 404, delete, streaming).
Widget
------
- web/chat-widget.js: stores conv_id in localStorage["rony-chat-conv"],
includes it in the chat request body, captures new IDs from the
server's 'start' SSE event, and calls GET /api/conversations/{id} on
load to restore history. On 404 it clears the stored ID and starts
fresh.
Docs
----
- docs/architecture.md: §3.1 documents the conversation_id field and
new REST endpoints; new §3.4 covers persistence lifecycle, schema,
client responsibilities, and auth model. §5.6 updated; filetree
reflects the new files.
- web/README.md: new 'Conversation persistence' section explains the
browser-scoped behavior and how to opt out or persist across devices.
2026-07-17 07:56:35 +00:00
|
|
|
|
|
|
|
|
// Resolve or create the conversation. If the client passed a non-empty
|
|
|
|
|
// ID we use it as-is; otherwise we mint a new one.
|
|
|
|
|
ctx := r.Context()
|
|
|
|
|
convID := req.ConversationID
|
|
|
|
|
if convID == "" {
|
|
|
|
|
var err error
|
|
|
|
|
convID, err = h.store.CreateConversation(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
http.Error(w, "create conversation: "+err.Error(), http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Persist the incoming user message. Even if the LLM fails afterward
|
|
|
|
|
// the user sees their question in the conversation history.
|
|
|
|
|
if userMsg := lastUserMessage(req.Messages); userMsg != "" {
|
|
|
|
|
if err := h.store.SaveMessage(ctx, convID, "user", userMsg, nil); err != nil {
|
|
|
|
|
slog.Error("save user message", "err", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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 07:56:06 +00:00
|
|
|
history := toAgentMessages(req.Messages)
|
|
|
|
|
if stream {
|
feat: persistent conversation storage (Phase 4)
Conversations survive page reloads and work for any frontend, not just
the widget. Server-side SQLite, conversation ID as bearer token, browser
identity via localStorage.
Backend
-------
- internal/portfolio/conversations.go: schema + CRUD. Conversations and
messages tables in the same SQLite DB as the RAG index, with
foreign-key cascade delete. Conv IDs are 16-byte random hex
(128 bits of entropy).
- internal/portfolio/indexer.go: applies conversation schema + enables
foreign_keys pragma in OpenStore.
- internal/server/handlers.go: POST /api/chat accepts an optional
conversation_id, mints one if absent, persists user message before
the LLM runs and assistant message (with sources) after the stream
completes. New handlers: GetConversation, ListConversations,
DeleteConversation.
- internal/server/server.go: routes for GET /api/conversations,
GET/DELETE /api/conversations/{id}.
- internal/server/conversations_test.go: 6 tests (round-trip, continue,
list, 404, delete, streaming).
Widget
------
- web/chat-widget.js: stores conv_id in localStorage["rony-chat-conv"],
includes it in the chat request body, captures new IDs from the
server's 'start' SSE event, and calls GET /api/conversations/{id} on
load to restore history. On 404 it clears the stored ID and starts
fresh.
Docs
----
- docs/architecture.md: §3.1 documents the conversation_id field and
new REST endpoints; new §3.4 covers persistence lifecycle, schema,
client responsibilities, and auth model. §5.6 updated; filetree
reflects the new files.
- web/README.md: new 'Conversation persistence' section explains the
browser-scoped behavior and how to opt out or persist across devices.
2026-07-17 07:56:35 +00:00
|
|
|
h.streamChat(w, r, history, convID)
|
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 07:56:06 +00:00
|
|
|
return
|
|
|
|
|
}
|
feat: persistent conversation storage (Phase 4)
Conversations survive page reloads and work for any frontend, not just
the widget. Server-side SQLite, conversation ID as bearer token, browser
identity via localStorage.
Backend
-------
- internal/portfolio/conversations.go: schema + CRUD. Conversations and
messages tables in the same SQLite DB as the RAG index, with
foreign-key cascade delete. Conv IDs are 16-byte random hex
(128 bits of entropy).
- internal/portfolio/indexer.go: applies conversation schema + enables
foreign_keys pragma in OpenStore.
- internal/server/handlers.go: POST /api/chat accepts an optional
conversation_id, mints one if absent, persists user message before
the LLM runs and assistant message (with sources) after the stream
completes. New handlers: GetConversation, ListConversations,
DeleteConversation.
- internal/server/server.go: routes for GET /api/conversations,
GET/DELETE /api/conversations/{id}.
- internal/server/conversations_test.go: 6 tests (round-trip, continue,
list, 404, delete, streaming).
Widget
------
- web/chat-widget.js: stores conv_id in localStorage["rony-chat-conv"],
includes it in the chat request body, captures new IDs from the
server's 'start' SSE event, and calls GET /api/conversations/{id} on
load to restore history. On 404 it clears the stored ID and starts
fresh.
Docs
----
- docs/architecture.md: §3.1 documents the conversation_id field and
new REST endpoints; new §3.4 covers persistence lifecycle, schema,
client responsibilities, and auth model. §5.6 updated; filetree
reflects the new files.
- web/README.md: new 'Conversation persistence' section explains the
browser-scoped behavior and how to opt out or persist across devices.
2026-07-17 07:56:35 +00:00
|
|
|
h.completeChat(w, r, history, convID)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func lastUserMessage(msgs []ChatMessage) string {
|
|
|
|
|
for i := len(msgs) - 1; i >= 0; i-- {
|
|
|
|
|
if msgs[i].Role == "user" {
|
|
|
|
|
return msgs[i].Content
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return ""
|
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 07:56:06 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func toAgentMessages(in []ChatMessage) []agent.Message {
|
|
|
|
|
out := make([]agent.Message, len(in))
|
|
|
|
|
for i, m := range in {
|
|
|
|
|
out[i] = agent.Message{
|
|
|
|
|
Role: agent.Role(m.Role),
|
|
|
|
|
Content: m.Content,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (req *ChatRequest) validate() error {
|
|
|
|
|
if len(req.Messages) == 0 {
|
|
|
|
|
return errors.New("messages must not be empty")
|
|
|
|
|
}
|
|
|
|
|
for i, m := range req.Messages {
|
|
|
|
|
switch m.Role {
|
|
|
|
|
case "user", "assistant", "system":
|
|
|
|
|
default:
|
|
|
|
|
return fmt.Errorf("messages[%d].role %q is invalid", i, m.Role)
|
|
|
|
|
}
|
|
|
|
|
if strings.TrimSpace(m.Content) == "" {
|
|
|
|
|
return fmt.Errorf("messages[%d].content is empty", i)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// streamChat drives the LLM agent and forwards each chunk to the SSE
|
|
|
|
|
// stream using the protocol described in docs/architecture.md §3.2.
|
feat: persistent conversation storage (Phase 4)
Conversations survive page reloads and work for any frontend, not just
the widget. Server-side SQLite, conversation ID as bearer token, browser
identity via localStorage.
Backend
-------
- internal/portfolio/conversations.go: schema + CRUD. Conversations and
messages tables in the same SQLite DB as the RAG index, with
foreign-key cascade delete. Conv IDs are 16-byte random hex
(128 bits of entropy).
- internal/portfolio/indexer.go: applies conversation schema + enables
foreign_keys pragma in OpenStore.
- internal/server/handlers.go: POST /api/chat accepts an optional
conversation_id, mints one if absent, persists user message before
the LLM runs and assistant message (with sources) after the stream
completes. New handlers: GetConversation, ListConversations,
DeleteConversation.
- internal/server/server.go: routes for GET /api/conversations,
GET/DELETE /api/conversations/{id}.
- internal/server/conversations_test.go: 6 tests (round-trip, continue,
list, 404, delete, streaming).
Widget
------
- web/chat-widget.js: stores conv_id in localStorage["rony-chat-conv"],
includes it in the chat request body, captures new IDs from the
server's 'start' SSE event, and calls GET /api/conversations/{id} on
load to restore history. On 404 it clears the stored ID and starts
fresh.
Docs
----
- docs/architecture.md: §3.1 documents the conversation_id field and
new REST endpoints; new §3.4 covers persistence lifecycle, schema,
client responsibilities, and auth model. §5.6 updated; filetree
reflects the new files.
- web/README.md: new 'Conversation persistence' section explains the
browser-scoped behavior and how to opt out or persist across devices.
2026-07-17 07:56:35 +00:00
|
|
|
// convID is the persistent conversation ID — passed in from Chat() after
|
|
|
|
|
// resolve-or-create. The assistant reply is saved on success.
|
|
|
|
|
func (h *Handlers) streamChat(w http.ResponseWriter, r *http.Request, history []agent.Message, convID string) {
|
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 07:56:06 +00:00
|
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
|
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
|
|
|
w.Header().Set("Connection", "keep-alive")
|
|
|
|
|
w.Header().Set("X-Accel-Buffering", "no")
|
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
|
|
|
|
|
|
flusher, ok := w.(http.Flusher)
|
|
|
|
|
if !ok {
|
|
|
|
|
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ctx := r.Context()
|
|
|
|
|
if err := streaming.WriteStart(w, convID); err != nil {
|
|
|
|
|
slog.Error("sse start failed", "err", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
flusher.Flush()
|
|
|
|
|
|
2026-07-18 07:08:22 +00:00
|
|
|
// Auto-compact the older part of the conversation when the previous
|
|
|
|
|
// turn's input tokens have crossed the configured threshold. Done
|
|
|
|
|
// before RAG so the LLM's compaction summary call is counted in the
|
|
|
|
|
// next request's reported usage, not the current one. A failure here
|
|
|
|
|
// is logged and swallowed: the request still proceeds with the
|
|
|
|
|
// original (uncompacted) history.
|
|
|
|
|
history, _ = h.runner.Compact(ctx, history)
|
|
|
|
|
if stats := h.runner.LastCompaction(); stats.Happened {
|
|
|
|
|
_ = streaming.WriteCompaction(w, map[string]any{
|
|
|
|
|
"older_turns": stats.OlderTurns,
|
|
|
|
|
"kept_turns": stats.KeptTurns,
|
|
|
|
|
"summary_tokens": stats.SummaryTokens,
|
|
|
|
|
"window_tokens": stats.WindowTokens,
|
|
|
|
|
"used_tokens": stats.UsedTokens,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
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 07:56:06 +00:00
|
|
|
// Report which RAG sources were used (search happens in BuildMessages).
|
|
|
|
|
_, ragContext, err := h.runner.BuildMessages(ctx, history)
|
|
|
|
|
if err != nil {
|
|
|
|
|
_ = streaming.WriteError(w, "rag: "+err.Error())
|
|
|
|
|
return
|
|
|
|
|
}
|
feat: persistent conversation storage (Phase 4)
Conversations survive page reloads and work for any frontend, not just
the widget. Server-side SQLite, conversation ID as bearer token, browser
identity via localStorage.
Backend
-------
- internal/portfolio/conversations.go: schema + CRUD. Conversations and
messages tables in the same SQLite DB as the RAG index, with
foreign-key cascade delete. Conv IDs are 16-byte random hex
(128 bits of entropy).
- internal/portfolio/indexer.go: applies conversation schema + enables
foreign_keys pragma in OpenStore.
- internal/server/handlers.go: POST /api/chat accepts an optional
conversation_id, mints one if absent, persists user message before
the LLM runs and assistant message (with sources) after the stream
completes. New handlers: GetConversation, ListConversations,
DeleteConversation.
- internal/server/server.go: routes for GET /api/conversations,
GET/DELETE /api/conversations/{id}.
- internal/server/conversations_test.go: 6 tests (round-trip, continue,
list, 404, delete, streaming).
Widget
------
- web/chat-widget.js: stores conv_id in localStorage["rony-chat-conv"],
includes it in the chat request body, captures new IDs from the
server's 'start' SSE event, and calls GET /api/conversations/{id} on
load to restore history. On 404 it clears the stored ID and starts
fresh.
Docs
----
- docs/architecture.md: §3.1 documents the conversation_id field and
new REST endpoints; new §3.4 covers persistence lifecycle, schema,
client responsibilities, and auth model. §5.6 updated; filetree
reflects the new files.
- web/README.md: new 'Conversation persistence' section explains the
browser-scoped behavior and how to opt out or persist across devices.
2026-07-17 07:56:35 +00:00
|
|
|
var sources []string
|
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 07:56:06 +00:00
|
|
|
if ragContext != "" {
|
feat: persistent conversation storage (Phase 4)
Conversations survive page reloads and work for any frontend, not just
the widget. Server-side SQLite, conversation ID as bearer token, browser
identity via localStorage.
Backend
-------
- internal/portfolio/conversations.go: schema + CRUD. Conversations and
messages tables in the same SQLite DB as the RAG index, with
foreign-key cascade delete. Conv IDs are 16-byte random hex
(128 bits of entropy).
- internal/portfolio/indexer.go: applies conversation schema + enables
foreign_keys pragma in OpenStore.
- internal/server/handlers.go: POST /api/chat accepts an optional
conversation_id, mints one if absent, persists user message before
the LLM runs and assistant message (with sources) after the stream
completes. New handlers: GetConversation, ListConversations,
DeleteConversation.
- internal/server/server.go: routes for GET /api/conversations,
GET/DELETE /api/conversations/{id}.
- internal/server/conversations_test.go: 6 tests (round-trip, continue,
list, 404, delete, streaming).
Widget
------
- web/chat-widget.js: stores conv_id in localStorage["rony-chat-conv"],
includes it in the chat request body, captures new IDs from the
server's 'start' SSE event, and calls GET /api/conversations/{id} on
load to restore history. On 404 it clears the stored ID and starts
fresh.
Docs
----
- docs/architecture.md: §3.1 documents the conversation_id field and
new REST endpoints; new §3.4 covers persistence lifecycle, schema,
client responsibilities, and auth model. §5.6 updated; filetree
reflects the new files.
- web/README.md: new 'Conversation persistence' section explains the
browser-scoped behavior and how to opt out or persist across devices.
2026-07-17 07:56:35 +00:00
|
|
|
sources = extractSources(ragContext)
|
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 07:56:06 +00:00
|
|
|
_ = streaming.WriteSources(w, sources)
|
|
|
|
|
}
|
|
|
|
|
|
feat: persistent conversation storage (Phase 4)
Conversations survive page reloads and work for any frontend, not just
the widget. Server-side SQLite, conversation ID as bearer token, browser
identity via localStorage.
Backend
-------
- internal/portfolio/conversations.go: schema + CRUD. Conversations and
messages tables in the same SQLite DB as the RAG index, with
foreign-key cascade delete. Conv IDs are 16-byte random hex
(128 bits of entropy).
- internal/portfolio/indexer.go: applies conversation schema + enables
foreign_keys pragma in OpenStore.
- internal/server/handlers.go: POST /api/chat accepts an optional
conversation_id, mints one if absent, persists user message before
the LLM runs and assistant message (with sources) after the stream
completes. New handlers: GetConversation, ListConversations,
DeleteConversation.
- internal/server/server.go: routes for GET /api/conversations,
GET/DELETE /api/conversations/{id}.
- internal/server/conversations_test.go: 6 tests (round-trip, continue,
list, 404, delete, streaming).
Widget
------
- web/chat-widget.js: stores conv_id in localStorage["rony-chat-conv"],
includes it in the chat request body, captures new IDs from the
server's 'start' SSE event, and calls GET /api/conversations/{id} on
load to restore history. On 404 it clears the stored ID and starts
fresh.
Docs
----
- docs/architecture.md: §3.1 documents the conversation_id field and
new REST endpoints; new §3.4 covers persistence lifecycle, schema,
client responsibilities, and auth model. §5.6 updated; filetree
reflects the new files.
- web/README.md: new 'Conversation persistence' section explains the
browser-scoped behavior and how to opt out or persist across devices.
2026-07-17 07:56:35 +00:00
|
|
|
// Stream from the model, collecting the full reply as we go.
|
|
|
|
|
var full strings.Builder
|
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 07:56:06 +00:00
|
|
|
for chunk, err := range h.runner.Stream(ctx, history) {
|
|
|
|
|
if err != nil {
|
|
|
|
|
slog.Error("llm stream", "err", err)
|
|
|
|
|
_ = streaming.WriteError(w, "llm: "+err.Error())
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if chunk.Delta != "" {
|
feat: persistent conversation storage (Phase 4)
Conversations survive page reloads and work for any frontend, not just
the widget. Server-side SQLite, conversation ID as bearer token, browser
identity via localStorage.
Backend
-------
- internal/portfolio/conversations.go: schema + CRUD. Conversations and
messages tables in the same SQLite DB as the RAG index, with
foreign-key cascade delete. Conv IDs are 16-byte random hex
(128 bits of entropy).
- internal/portfolio/indexer.go: applies conversation schema + enables
foreign_keys pragma in OpenStore.
- internal/server/handlers.go: POST /api/chat accepts an optional
conversation_id, mints one if absent, persists user message before
the LLM runs and assistant message (with sources) after the stream
completes. New handlers: GetConversation, ListConversations,
DeleteConversation.
- internal/server/server.go: routes for GET /api/conversations,
GET/DELETE /api/conversations/{id}.
- internal/server/conversations_test.go: 6 tests (round-trip, continue,
list, 404, delete, streaming).
Widget
------
- web/chat-widget.js: stores conv_id in localStorage["rony-chat-conv"],
includes it in the chat request body, captures new IDs from the
server's 'start' SSE event, and calls GET /api/conversations/{id} on
load to restore history. On 404 it clears the stored ID and starts
fresh.
Docs
----
- docs/architecture.md: §3.1 documents the conversation_id field and
new REST endpoints; new §3.4 covers persistence lifecycle, schema,
client responsibilities, and auth model. §5.6 updated; filetree
reflects the new files.
- web/README.md: new 'Conversation persistence' section explains the
browser-scoped behavior and how to opt out or persist across devices.
2026-07-17 07:56:35 +00:00
|
|
|
full.WriteString(chunk.Delta)
|
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 07:56:06 +00:00
|
|
|
if err := streaming.WriteChunk(w, chunk.Delta); err != nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if chunk.FinishReason != "" && chunk.Usage.TotalTokens > 0 {
|
feat: persistent conversation storage (Phase 4)
Conversations survive page reloads and work for any frontend, not just
the widget. Server-side SQLite, conversation ID as bearer token, browser
identity via localStorage.
Backend
-------
- internal/portfolio/conversations.go: schema + CRUD. Conversations and
messages tables in the same SQLite DB as the RAG index, with
foreign-key cascade delete. Conv IDs are 16-byte random hex
(128 bits of entropy).
- internal/portfolio/indexer.go: applies conversation schema + enables
foreign_keys pragma in OpenStore.
- internal/server/handlers.go: POST /api/chat accepts an optional
conversation_id, mints one if absent, persists user message before
the LLM runs and assistant message (with sources) after the stream
completes. New handlers: GetConversation, ListConversations,
DeleteConversation.
- internal/server/server.go: routes for GET /api/conversations,
GET/DELETE /api/conversations/{id}.
- internal/server/conversations_test.go: 6 tests (round-trip, continue,
list, 404, delete, streaming).
Widget
------
- web/chat-widget.js: stores conv_id in localStorage["rony-chat-conv"],
includes it in the chat request body, captures new IDs from the
server's 'start' SSE event, and calls GET /api/conversations/{id} on
load to restore history. On 404 it clears the stored ID and starts
fresh.
Docs
----
- docs/architecture.md: §3.1 documents the conversation_id field and
new REST endpoints; new §3.4 covers persistence lifecycle, schema,
client responsibilities, and auth model. §5.6 updated; filetree
reflects the new files.
- web/README.md: new 'Conversation persistence' section explains the
browser-scoped behavior and how to opt out or persist across devices.
2026-07-17 07:56:35 +00:00
|
|
|
h.persistAssistant(ctx, convID, full.String(), sources)
|
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 07:56:06 +00:00
|
|
|
_ = streaming.WriteDone(w, streaming.Usage{
|
|
|
|
|
InputTokens: chunk.Usage.InputTokens,
|
|
|
|
|
OutputTokens: chunk.Usage.OutputTokens,
|
|
|
|
|
})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Final usage chunk may come on the last iteration; if we never saw a
|
|
|
|
|
// finish_reason + usage in-band, surface what we recorded.
|
|
|
|
|
usage := h.runner.LastUsage()
|
feat: persistent conversation storage (Phase 4)
Conversations survive page reloads and work for any frontend, not just
the widget. Server-side SQLite, conversation ID as bearer token, browser
identity via localStorage.
Backend
-------
- internal/portfolio/conversations.go: schema + CRUD. Conversations and
messages tables in the same SQLite DB as the RAG index, with
foreign-key cascade delete. Conv IDs are 16-byte random hex
(128 bits of entropy).
- internal/portfolio/indexer.go: applies conversation schema + enables
foreign_keys pragma in OpenStore.
- internal/server/handlers.go: POST /api/chat accepts an optional
conversation_id, mints one if absent, persists user message before
the LLM runs and assistant message (with sources) after the stream
completes. New handlers: GetConversation, ListConversations,
DeleteConversation.
- internal/server/server.go: routes for GET /api/conversations,
GET/DELETE /api/conversations/{id}.
- internal/server/conversations_test.go: 6 tests (round-trip, continue,
list, 404, delete, streaming).
Widget
------
- web/chat-widget.js: stores conv_id in localStorage["rony-chat-conv"],
includes it in the chat request body, captures new IDs from the
server's 'start' SSE event, and calls GET /api/conversations/{id} on
load to restore history. On 404 it clears the stored ID and starts
fresh.
Docs
----
- docs/architecture.md: §3.1 documents the conversation_id field and
new REST endpoints; new §3.4 covers persistence lifecycle, schema,
client responsibilities, and auth model. §5.6 updated; filetree
reflects the new files.
- web/README.md: new 'Conversation persistence' section explains the
browser-scoped behavior and how to opt out or persist across devices.
2026-07-17 07:56:35 +00:00
|
|
|
h.persistAssistant(ctx, convID, full.String(), sources)
|
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 07:56:06 +00:00
|
|
|
_ = streaming.WriteDone(w, streaming.Usage{
|
|
|
|
|
InputTokens: usage.InputTokens,
|
|
|
|
|
OutputTokens: usage.OutputTokens,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
feat: persistent conversation storage (Phase 4)
Conversations survive page reloads and work for any frontend, not just
the widget. Server-side SQLite, conversation ID as bearer token, browser
identity via localStorage.
Backend
-------
- internal/portfolio/conversations.go: schema + CRUD. Conversations and
messages tables in the same SQLite DB as the RAG index, with
foreign-key cascade delete. Conv IDs are 16-byte random hex
(128 bits of entropy).
- internal/portfolio/indexer.go: applies conversation schema + enables
foreign_keys pragma in OpenStore.
- internal/server/handlers.go: POST /api/chat accepts an optional
conversation_id, mints one if absent, persists user message before
the LLM runs and assistant message (with sources) after the stream
completes. New handlers: GetConversation, ListConversations,
DeleteConversation.
- internal/server/server.go: routes for GET /api/conversations,
GET/DELETE /api/conversations/{id}.
- internal/server/conversations_test.go: 6 tests (round-trip, continue,
list, 404, delete, streaming).
Widget
------
- web/chat-widget.js: stores conv_id in localStorage["rony-chat-conv"],
includes it in the chat request body, captures new IDs from the
server's 'start' SSE event, and calls GET /api/conversations/{id} on
load to restore history. On 404 it clears the stored ID and starts
fresh.
Docs
----
- docs/architecture.md: §3.1 documents the conversation_id field and
new REST endpoints; new §3.4 covers persistence lifecycle, schema,
client responsibilities, and auth model. §5.6 updated; filetree
reflects the new files.
- web/README.md: new 'Conversation persistence' section explains the
browser-scoped behavior and how to opt out or persist across devices.
2026-07-17 07:56:35 +00:00
|
|
|
// persistAssistant saves the full assistant reply. Best-effort: a failure
|
|
|
|
|
// here doesn't fail the user's request (the response is already streamed).
|
|
|
|
|
func (h *Handlers) persistAssistant(ctx context.Context, convID, content string, sources []string) {
|
|
|
|
|
if strings.TrimSpace(content) == "" {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if err := h.store.SaveMessage(ctx, convID, "assistant", content, sources); err != nil {
|
|
|
|
|
slog.Error("save assistant message", "err", err, "conv", convID)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *Handlers) completeChat(w http.ResponseWriter, r *http.Request, history []agent.Message, convID string) {
|
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 07:56:06 +00:00
|
|
|
w.Header().Set("Content-Type", "application/json")
|
feat: persistent conversation storage (Phase 4)
Conversations survive page reloads and work for any frontend, not just
the widget. Server-side SQLite, conversation ID as bearer token, browser
identity via localStorage.
Backend
-------
- internal/portfolio/conversations.go: schema + CRUD. Conversations and
messages tables in the same SQLite DB as the RAG index, with
foreign-key cascade delete. Conv IDs are 16-byte random hex
(128 bits of entropy).
- internal/portfolio/indexer.go: applies conversation schema + enables
foreign_keys pragma in OpenStore.
- internal/server/handlers.go: POST /api/chat accepts an optional
conversation_id, mints one if absent, persists user message before
the LLM runs and assistant message (with sources) after the stream
completes. New handlers: GetConversation, ListConversations,
DeleteConversation.
- internal/server/server.go: routes for GET /api/conversations,
GET/DELETE /api/conversations/{id}.
- internal/server/conversations_test.go: 6 tests (round-trip, continue,
list, 404, delete, streaming).
Widget
------
- web/chat-widget.js: stores conv_id in localStorage["rony-chat-conv"],
includes it in the chat request body, captures new IDs from the
server's 'start' SSE event, and calls GET /api/conversations/{id} on
load to restore history. On 404 it clears the stored ID and starts
fresh.
Docs
----
- docs/architecture.md: §3.1 documents the conversation_id field and
new REST endpoints; new §3.4 covers persistence lifecycle, schema,
client responsibilities, and auth model. §5.6 updated; filetree
reflects the new files.
- web/README.md: new 'Conversation persistence' section explains the
browser-scoped behavior and how to opt out or persist across devices.
2026-07-17 07:56:35 +00:00
|
|
|
ctx := r.Context()
|
2026-07-18 07:08:22 +00:00
|
|
|
// Apply compaction before streaming — same as streamChat. The
|
|
|
|
|
// compaction event isn't surfaced in the non-streaming JSON response
|
|
|
|
|
// (would be redundant noise), but LastCompaction is still recorded so
|
|
|
|
|
// any future caller can introspect it.
|
|
|
|
|
history, _ = h.runner.Compact(ctx, history)
|
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 07:56:06 +00:00
|
|
|
var full strings.Builder
|
feat: persistent conversation storage (Phase 4)
Conversations survive page reloads and work for any frontend, not just
the widget. Server-side SQLite, conversation ID as bearer token, browser
identity via localStorage.
Backend
-------
- internal/portfolio/conversations.go: schema + CRUD. Conversations and
messages tables in the same SQLite DB as the RAG index, with
foreign-key cascade delete. Conv IDs are 16-byte random hex
(128 bits of entropy).
- internal/portfolio/indexer.go: applies conversation schema + enables
foreign_keys pragma in OpenStore.
- internal/server/handlers.go: POST /api/chat accepts an optional
conversation_id, mints one if absent, persists user message before
the LLM runs and assistant message (with sources) after the stream
completes. New handlers: GetConversation, ListConversations,
DeleteConversation.
- internal/server/server.go: routes for GET /api/conversations,
GET/DELETE /api/conversations/{id}.
- internal/server/conversations_test.go: 6 tests (round-trip, continue,
list, 404, delete, streaming).
Widget
------
- web/chat-widget.js: stores conv_id in localStorage["rony-chat-conv"],
includes it in the chat request body, captures new IDs from the
server's 'start' SSE event, and calls GET /api/conversations/{id} on
load to restore history. On 404 it clears the stored ID and starts
fresh.
Docs
----
- docs/architecture.md: §3.1 documents the conversation_id field and
new REST endpoints; new §3.4 covers persistence lifecycle, schema,
client responsibilities, and auth model. §5.6 updated; filetree
reflects the new files.
- web/README.md: new 'Conversation persistence' section explains the
browser-scoped behavior and how to opt out or persist across devices.
2026-07-17 07:56:35 +00:00
|
|
|
var sources []string
|
|
|
|
|
for chunk, err := range h.runner.Stream(ctx, history) {
|
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 07:56:06 +00:00
|
|
|
if err != nil {
|
|
|
|
|
http.Error(w, "llm: "+err.Error(), http.StatusBadGateway)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
full.WriteString(chunk.Delta)
|
|
|
|
|
}
|
|
|
|
|
usage := h.runner.LastUsage()
|
feat: persistent conversation storage (Phase 4)
Conversations survive page reloads and work for any frontend, not just
the widget. Server-side SQLite, conversation ID as bearer token, browser
identity via localStorage.
Backend
-------
- internal/portfolio/conversations.go: schema + CRUD. Conversations and
messages tables in the same SQLite DB as the RAG index, with
foreign-key cascade delete. Conv IDs are 16-byte random hex
(128 bits of entropy).
- internal/portfolio/indexer.go: applies conversation schema + enables
foreign_keys pragma in OpenStore.
- internal/server/handlers.go: POST /api/chat accepts an optional
conversation_id, mints one if absent, persists user message before
the LLM runs and assistant message (with sources) after the stream
completes. New handlers: GetConversation, ListConversations,
DeleteConversation.
- internal/server/server.go: routes for GET /api/conversations,
GET/DELETE /api/conversations/{id}.
- internal/server/conversations_test.go: 6 tests (round-trip, continue,
list, 404, delete, streaming).
Widget
------
- web/chat-widget.js: stores conv_id in localStorage["rony-chat-conv"],
includes it in the chat request body, captures new IDs from the
server's 'start' SSE event, and calls GET /api/conversations/{id} on
load to restore history. On 404 it clears the stored ID and starts
fresh.
Docs
----
- docs/architecture.md: §3.1 documents the conversation_id field and
new REST endpoints; new §3.4 covers persistence lifecycle, schema,
client responsibilities, and auth model. §5.6 updated; filetree
reflects the new files.
- web/README.md: new 'Conversation persistence' section explains the
browser-scoped behavior and how to opt out or persist across devices.
2026-07-17 07:56:35 +00:00
|
|
|
_, ragContext, _ := h.runner.BuildMessages(ctx, history)
|
|
|
|
|
if ragContext != "" {
|
|
|
|
|
sources = extractSources(ragContext)
|
|
|
|
|
}
|
|
|
|
|
h.persistAssistant(ctx, convID, full.String(), sources)
|
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 07:56:06 +00:00
|
|
|
resp := ChatResponse{
|
feat: persistent conversation storage (Phase 4)
Conversations survive page reloads and work for any frontend, not just
the widget. Server-side SQLite, conversation ID as bearer token, browser
identity via localStorage.
Backend
-------
- internal/portfolio/conversations.go: schema + CRUD. Conversations and
messages tables in the same SQLite DB as the RAG index, with
foreign-key cascade delete. Conv IDs are 16-byte random hex
(128 bits of entropy).
- internal/portfolio/indexer.go: applies conversation schema + enables
foreign_keys pragma in OpenStore.
- internal/server/handlers.go: POST /api/chat accepts an optional
conversation_id, mints one if absent, persists user message before
the LLM runs and assistant message (with sources) after the stream
completes. New handlers: GetConversation, ListConversations,
DeleteConversation.
- internal/server/server.go: routes for GET /api/conversations,
GET/DELETE /api/conversations/{id}.
- internal/server/conversations_test.go: 6 tests (round-trip, continue,
list, 404, delete, streaming).
Widget
------
- web/chat-widget.js: stores conv_id in localStorage["rony-chat-conv"],
includes it in the chat request body, captures new IDs from the
server's 'start' SSE event, and calls GET /api/conversations/{id} on
load to restore history. On 404 it clears the stored ID and starts
fresh.
Docs
----
- docs/architecture.md: §3.1 documents the conversation_id field and
new REST endpoints; new §3.4 covers persistence lifecycle, schema,
client responsibilities, and auth model. §5.6 updated; filetree
reflects the new files.
- web/README.md: new 'Conversation persistence' section explains the
browser-scoped behavior and how to opt out or persist across devices.
2026-07-17 07:56:35 +00:00
|
|
|
ConversationID: convID,
|
|
|
|
|
Content: full.String(),
|
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 07:56:06 +00:00
|
|
|
Usage: streaming.Usage{
|
|
|
|
|
InputTokens: usage.InputTokens,
|
|
|
|
|
OutputTokens: usage.OutputTokens,
|
|
|
|
|
},
|
feat: persistent conversation storage (Phase 4)
Conversations survive page reloads and work for any frontend, not just
the widget. Server-side SQLite, conversation ID as bearer token, browser
identity via localStorage.
Backend
-------
- internal/portfolio/conversations.go: schema + CRUD. Conversations and
messages tables in the same SQLite DB as the RAG index, with
foreign-key cascade delete. Conv IDs are 16-byte random hex
(128 bits of entropy).
- internal/portfolio/indexer.go: applies conversation schema + enables
foreign_keys pragma in OpenStore.
- internal/server/handlers.go: POST /api/chat accepts an optional
conversation_id, mints one if absent, persists user message before
the LLM runs and assistant message (with sources) after the stream
completes. New handlers: GetConversation, ListConversations,
DeleteConversation.
- internal/server/server.go: routes for GET /api/conversations,
GET/DELETE /api/conversations/{id}.
- internal/server/conversations_test.go: 6 tests (round-trip, continue,
list, 404, delete, streaming).
Widget
------
- web/chat-widget.js: stores conv_id in localStorage["rony-chat-conv"],
includes it in the chat request body, captures new IDs from the
server's 'start' SSE event, and calls GET /api/conversations/{id} on
load to restore history. On 404 it clears the stored ID and starts
fresh.
Docs
----
- docs/architecture.md: §3.1 documents the conversation_id field and
new REST endpoints; new §3.4 covers persistence lifecycle, schema,
client responsibilities, and auth model. §5.6 updated; filetree
reflects the new files.
- web/README.md: new 'Conversation persistence' section explains the
browser-scoped behavior and how to opt out or persist across devices.
2026-07-17 07:56:35 +00:00
|
|
|
Sources: sources,
|
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 07:56:06 +00:00
|
|
|
}
|
|
|
|
|
_ = json.NewEncoder(w).Encode(resp)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func extractSources(ragContext string) []string {
|
|
|
|
|
// The formatted hits look like "### [N] projectID — section\n..."
|
|
|
|
|
// so we scan the lines for that prefix and dedupe by projectID.
|
|
|
|
|
seen := map[string]bool{}
|
|
|
|
|
var out []string
|
|
|
|
|
for _, line := range strings.Split(ragContext, "\n") {
|
|
|
|
|
if !strings.HasPrefix(line, "### [") {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
// between "### [N] " and " — "
|
|
|
|
|
rest := strings.TrimPrefix(line, "### [")
|
|
|
|
|
rest = rest[strings.Index(rest, " ")+1:]
|
|
|
|
|
project := rest
|
|
|
|
|
if i := strings.Index(rest, " — "); i > 0 {
|
|
|
|
|
project = rest[:i]
|
|
|
|
|
}
|
|
|
|
|
if !seen[project] {
|
|
|
|
|
seen[project] = true
|
|
|
|
|
out = append(out, project)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func truncate(s string, n int) string {
|
|
|
|
|
if len(s) <= n {
|
|
|
|
|
return s
|
|
|
|
|
}
|
|
|
|
|
return s[:n] + "…"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *Handlers) Info(w http.ResponseWriter, _ *http.Request) {
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
p := h.cfg.DefaultProvider()
|
|
|
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
|
|
|
"name": h.cfg.Persona.Name,
|
|
|
|
|
"version": h.version,
|
|
|
|
|
"provider": p.Type,
|
feat(rag): hybrid retrieval, reference documents, and vendor sampling
Answers were short, sometimes in the wrong language, and occasionally about
projects that do not exist. Measured on a 20-question battery against the real
corpus in both Spanish and English, this takes grounded content from 3/10 to
9/10 and language matching from 7/10 to 10/10.
Retrieval
- Fuse FTS5 keyword search with dense vectors via Reciprocal Rank Fusion.
Both halves are load-bearing: the corpus is English and visitors ask in
Spanish, so the meaningful words score zero. "paga" appears 0 times in a
document that says "Payments: Stripe" — the question "¿Con qué se paga en la
tienda de ropa?" retrieved nothing at all. Embeddings put all three of that
project's chunks on top. RRF ranks by agreement rather than comparing a BM25
score against a cosine, quantities with no shared scale.
- internal/embed: OpenAI-compatible embeddings client, unit-normalised so a
dot product is the cosine. Reorders by the response `index` field.
- Store a content hash beside each vector and skip rows where it no longer
matches the chunk. Chunk ids survive body edits, so without this an edited
document keeps serving embeddings that describe text that is gone —
reproduced live by changing a payment provider and watching the old one keep
coming back.
- Degrade to keyword-only when the embedder is down instead of failing.
Reference documents that are not projects
- Index `.mdx` alongside `.md`, and split sources into projects (announced in
the catalogue) and reference material (retrievable, never listed). A CV is
what someone deciding whether to hire actually reads, and it was unreachable
while it lived only in the Astro site — but filing it under projects made
the bot list "cv" as one of Victor's works.
- Skip each directory's README. `data/projects/README.md` was being indexed,
so the catalogue injected into every prompt announced "README" and
"README.es" as projects of Victor's.
- Exclude frontmatter from retrieval. It is dense metadata in a very short
chunk, which makes it a magnet for short queries: a CV's `location:` field
answered "¿Dónde ha trabajado Victor?" with a city instead of a work history.
- Split oversized sections at `###` before falling back to byte offsets. A CV's
Experience section is a list of jobs, and size-splitting cut one mid-word,
stranding the employer's name in the previous chunk.
Prompt and sampling
- Inject the full project catalogue every turn. Top-K search returns the best
matching sections, so "list every project" cannot be answered from retrieval
alone, and a small model asked to enumerate from partial hits invents the
rest. ~10 tokens per project; this is what stopped the invented names.
- Wire the sampling parameters the model authors publish (top_k, top_p, min_p,
repeat_penalty, presence_penalty) through config to llama.cpp. Leaving them
at llama.cpp's defaults produced 16-token stub answers.
- Localised system prompt selected by detected language. The English prompt
plus "reply in the user's language" answered 1/5 Spanish questions in
Spanish; few-shot examples fixed the language but got copied verbatim into
real answers.
- Fold compaction's system notes into the leading system message. Gemma's chat
template rejects a system message that is not first, and the whole request
failed with HTTP 400 the moment compaction fired.
Configuration and docs
- context_size 4096, down from 8192. The largest prompt this bot ever built
over 20 real requests was 1255 tokens, compaction starts at ~3070, and the
cut saved 212 MB resident with zero truncations and identical throughput.
- Correct the RAM figures throughout. They were measured with a GPU absorbing
llama.cpp's buffers; on a GPU-less VPS those come out of system RAM, which
is 1.1 GB more for qwen2.5-3b and 2.8 GB more for granite. Both READMEs
still started gemma-3-1b while the config defaulted to qwen, and neither
started the embedder at all.
Measured on the 2-core, 8 GB CPU-only target: 3.64 GB LLM + 0.91 GB embedder
+ 0.02 GB bot, 21.0 tok/s steady state.
Known and unfixed, so they are not re-filed as new bugs: the model reads dates
out of the CV correctly but does the arithmetic on them wrong, and "¿Dónde ha
trabajado Victor?" still answers with projects rather than employers, though
"¿En qué empresas ha trabajado?" works.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 21:45:37 +00:00
|
|
|
"model": p.Model,
|
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 07:56:06 +00:00
|
|
|
"rag": h.cfg.RAG.Enabled,
|
|
|
|
|
"top_k": h.cfg.RAG.TopK,
|
|
|
|
|
"started": time.Now().UTC().Format(time.RFC3339),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *Handlers) Reindex(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if r.Method != http.MethodPost {
|
|
|
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
start := time.Now()
|
|
|
|
|
dbPath := h.cfg.RAG.DBPath
|
|
|
|
|
if dbPath == "" {
|
|
|
|
|
http.Error(w, "rag.db_path not configured", http.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
feat(rag): hybrid retrieval, reference documents, and vendor sampling
Answers were short, sometimes in the wrong language, and occasionally about
projects that do not exist. Measured on a 20-question battery against the real
corpus in both Spanish and English, this takes grounded content from 3/10 to
9/10 and language matching from 7/10 to 10/10.
Retrieval
- Fuse FTS5 keyword search with dense vectors via Reciprocal Rank Fusion.
Both halves are load-bearing: the corpus is English and visitors ask in
Spanish, so the meaningful words score zero. "paga" appears 0 times in a
document that says "Payments: Stripe" — the question "¿Con qué se paga en la
tienda de ropa?" retrieved nothing at all. Embeddings put all three of that
project's chunks on top. RRF ranks by agreement rather than comparing a BM25
score against a cosine, quantities with no shared scale.
- internal/embed: OpenAI-compatible embeddings client, unit-normalised so a
dot product is the cosine. Reorders by the response `index` field.
- Store a content hash beside each vector and skip rows where it no longer
matches the chunk. Chunk ids survive body edits, so without this an edited
document keeps serving embeddings that describe text that is gone —
reproduced live by changing a payment provider and watching the old one keep
coming back.
- Degrade to keyword-only when the embedder is down instead of failing.
Reference documents that are not projects
- Index `.mdx` alongside `.md`, and split sources into projects (announced in
the catalogue) and reference material (retrievable, never listed). A CV is
what someone deciding whether to hire actually reads, and it was unreachable
while it lived only in the Astro site — but filing it under projects made
the bot list "cv" as one of Victor's works.
- Skip each directory's README. `data/projects/README.md` was being indexed,
so the catalogue injected into every prompt announced "README" and
"README.es" as projects of Victor's.
- Exclude frontmatter from retrieval. It is dense metadata in a very short
chunk, which makes it a magnet for short queries: a CV's `location:` field
answered "¿Dónde ha trabajado Victor?" with a city instead of a work history.
- Split oversized sections at `###` before falling back to byte offsets. A CV's
Experience section is a list of jobs, and size-splitting cut one mid-word,
stranding the employer's name in the previous chunk.
Prompt and sampling
- Inject the full project catalogue every turn. Top-K search returns the best
matching sections, so "list every project" cannot be answered from retrieval
alone, and a small model asked to enumerate from partial hits invents the
rest. ~10 tokens per project; this is what stopped the invented names.
- Wire the sampling parameters the model authors publish (top_k, top_p, min_p,
repeat_penalty, presence_penalty) through config to llama.cpp. Leaving them
at llama.cpp's defaults produced 16-token stub answers.
- Localised system prompt selected by detected language. The English prompt
plus "reply in the user's language" answered 1/5 Spanish questions in
Spanish; few-shot examples fixed the language but got copied verbatim into
real answers.
- Fold compaction's system notes into the leading system message. Gemma's chat
template rejects a system message that is not first, and the whole request
failed with HTTP 400 the moment compaction fired.
Configuration and docs
- context_size 4096, down from 8192. The largest prompt this bot ever built
over 20 real requests was 1255 tokens, compaction starts at ~3070, and the
cut saved 212 MB resident with zero truncations and identical throughput.
- Correct the RAM figures throughout. They were measured with a GPU absorbing
llama.cpp's buffers; on a GPU-less VPS those come out of system RAM, which
is 1.1 GB more for qwen2.5-3b and 2.8 GB more for granite. Both READMEs
still started gemma-3-1b while the config defaulted to qwen, and neither
started the embedder at all.
Measured on the 2-core, 8 GB CPU-only target: 3.64 GB LLM + 0.91 GB embedder
+ 0.02 GB bot, 21.0 tok/s steady state.
Known and unfixed, so they are not re-filed as new bugs: the model reads dates
out of the CV correctly but does the arithmetic on them wrong, and "¿Dónde ha
trabajado Victor?" still answers with projects rather than employers, though
"¿En qué empresas ha trabajado?" works.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 21:45:37 +00:00
|
|
|
dur, files, chunks, err := portfolio.ReindexOnDisk(dbPath, portfolio.SourcesFor(h.cfg.RAG.DataPath, h.cfg.RAG.DocsPath), portfolio.DefaultChunkerConfig(), h.embedder)
|
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 07:56:06 +00:00
|
|
|
if err != nil {
|
|
|
|
|
http.Error(w, "reindex failed: "+err.Error(), http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
_ = dur
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
|
|
|
"indexed_files": files,
|
|
|
|
|
"total_chunks": chunks,
|
|
|
|
|
"duration_ms": time.Since(start).Milliseconds(),
|
|
|
|
|
"db_path": dbPath,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
feat: persistent conversation storage (Phase 4)
Conversations survive page reloads and work for any frontend, not just
the widget. Server-side SQLite, conversation ID as bearer token, browser
identity via localStorage.
Backend
-------
- internal/portfolio/conversations.go: schema + CRUD. Conversations and
messages tables in the same SQLite DB as the RAG index, with
foreign-key cascade delete. Conv IDs are 16-byte random hex
(128 bits of entropy).
- internal/portfolio/indexer.go: applies conversation schema + enables
foreign_keys pragma in OpenStore.
- internal/server/handlers.go: POST /api/chat accepts an optional
conversation_id, mints one if absent, persists user message before
the LLM runs and assistant message (with sources) after the stream
completes. New handlers: GetConversation, ListConversations,
DeleteConversation.
- internal/server/server.go: routes for GET /api/conversations,
GET/DELETE /api/conversations/{id}.
- internal/server/conversations_test.go: 6 tests (round-trip, continue,
list, 404, delete, streaming).
Widget
------
- web/chat-widget.js: stores conv_id in localStorage["rony-chat-conv"],
includes it in the chat request body, captures new IDs from the
server's 'start' SSE event, and calls GET /api/conversations/{id} on
load to restore history. On 404 it clears the stored ID and starts
fresh.
Docs
----
- docs/architecture.md: §3.1 documents the conversation_id field and
new REST endpoints; new §3.4 covers persistence lifecycle, schema,
client responsibilities, and auth model. §5.6 updated; filetree
reflects the new files.
- web/README.md: new 'Conversation persistence' section explains the
browser-scoped behavior and how to opt out or persist across devices.
2026-07-17 07:56:35 +00:00
|
|
|
// ---- Conversation REST endpoints -------------------------------------------
|
|
|
|
|
|
|
|
|
|
// GetConversation returns the full history of one conversation. The
|
|
|
|
|
// conversation ID is treated as a bearer token: anyone who knows it can
|
|
|
|
|
// read the history. For a public bot this is fine; for private contexts
|
|
|
|
|
// add auth at the proxy layer.
|
|
|
|
|
func (h *Handlers) GetConversation(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if r.Method != http.MethodGet {
|
|
|
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
id := conversationIDFromPath(r.URL.Path)
|
|
|
|
|
if id == "" {
|
|
|
|
|
http.Error(w, "missing conversation id", http.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
conv, err := h.store.GetConversation(r.Context(), id)
|
|
|
|
|
if errors.Is(err, portfolio.ErrConversationNotFound) {
|
|
|
|
|
http.Error(w, "conversation not found", http.StatusNotFound)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if err != nil {
|
|
|
|
|
http.Error(w, "get conversation: "+err.Error(), http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
_ = json.NewEncoder(w).Encode(conv)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ListConversations returns the most recent N conversation summaries.
|
|
|
|
|
// Useful for a "show my chats" sidebar in the widget or a custom UI.
|
|
|
|
|
func (h *Handlers) ListConversations(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if r.Method != http.MethodGet {
|
|
|
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
limit := 50
|
|
|
|
|
if s := r.URL.Query().Get("limit"); s != "" {
|
|
|
|
|
if n, err := strconv.Atoi(s); err == nil && n > 0 && n <= 200 {
|
|
|
|
|
limit = n
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
items, err := h.store.ListConversations(r.Context(), limit)
|
|
|
|
|
if err != nil {
|
|
|
|
|
http.Error(w, "list conversations: "+err.Error(), http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
if items == nil {
|
|
|
|
|
items = []portfolio.ConversationSummary{}
|
|
|
|
|
}
|
|
|
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
|
|
|
"conversations": items,
|
|
|
|
|
"count": len(items),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// DeleteConversation removes a conversation and all its messages.
|
|
|
|
|
func (h *Handlers) DeleteConversation(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if r.Method != http.MethodDelete {
|
|
|
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
id := conversationIDFromPath(r.URL.Path)
|
|
|
|
|
if id == "" {
|
|
|
|
|
http.Error(w, "missing conversation id", http.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
err := h.store.DeleteConversation(r.Context(), id)
|
|
|
|
|
if errors.Is(err, portfolio.ErrConversationNotFound) {
|
|
|
|
|
http.Error(w, "conversation not found", http.StatusNotFound)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if err != nil {
|
|
|
|
|
http.Error(w, "delete conversation: "+err.Error(), http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// conversationIDFromPath pulls the ID out of "/api/conversations/{id}".
|
|
|
|
|
// chi does this with chi.URLParam(r, "id"); for clarity (and so this
|
|
|
|
|
// handler works even without chi) we do it by hand.
|
|
|
|
|
func conversationIDFromPath(path string) string {
|
|
|
|
|
const prefix = "/api/conversations/"
|
|
|
|
|
if !strings.HasPrefix(path, prefix) {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
id := strings.TrimPrefix(path, prefix)
|
|
|
|
|
// strip trailing slash and any further segments
|
|
|
|
|
if i := strings.IndexByte(id, '/'); i >= 0 {
|
|
|
|
|
id = id[:i]
|
|
|
|
|
}
|
|
|
|
|
return id
|
|
|
|
|
}
|