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 portfolio
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"regexp"
|
|
|
|
|
"strings"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Chunk is one indexable unit of a project document.
|
|
|
|
|
type Chunk struct {
|
|
|
|
|
ID string
|
|
|
|
|
ProjectID string
|
|
|
|
|
SourceFile string
|
|
|
|
|
Index int
|
|
|
|
|
Section string // frontmatter | H1 title | H2 section name
|
|
|
|
|
Content string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ChunkerConfig controls the heading-based chunker.
|
|
|
|
|
type ChunkerConfig struct {
|
|
|
|
|
MaxSectionChars int // sections longer than this are sub-split (default 1000)
|
|
|
|
|
MergeUnderChars int // sections shorter than this are merged with the next (default 50)
|
|
|
|
|
OverlapChars int // overlap when sub-splitting (default 80)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func DefaultChunkerConfig() ChunkerConfig {
|
|
|
|
|
return ChunkerConfig{
|
|
|
|
|
MaxSectionChars: 1000,
|
|
|
|
|
MergeUnderChars: 50,
|
|
|
|
|
OverlapChars: 80,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// SplitMarkdownSections produces chunks using heading boundaries:
|
|
|
|
|
// 1. Frontmatter (between leading --- ... ---) → "frontmatter" chunk
|
|
|
|
|
// 2. Each H1 (project title) → one chunk
|
|
|
|
|
// 3. Each H2 section → one chunk (with its sub-content under H3/H4 etc.)
|
|
|
|
|
// 4. Sections > MaxSectionChars are sub-split by size with overlap
|
|
|
|
|
// 5. Sections < MergeUnderChars are merged with the next section
|
|
|
|
|
func SplitMarkdownSections(markdown string, cfg ChunkerConfig) []section {
|
|
|
|
|
if cfg.MaxSectionChars <= 0 {
|
|
|
|
|
cfg = DefaultChunkerConfig()
|
|
|
|
|
}
|
|
|
|
|
if cfg.MergeUnderChars < 0 {
|
|
|
|
|
cfg.MergeUnderChars = 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
body := markdown
|
|
|
|
|
frontmatter := ""
|
|
|
|
|
if fm, rest, ok := extractFrontmatter(markdown); ok {
|
|
|
|
|
frontmatter = fm
|
|
|
|
|
body = rest
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var sections []section
|
|
|
|
|
if frontmatter != "" {
|
|
|
|
|
sections = append(sections, section{Heading: "frontmatter", Body: frontmatter})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, s := range splitByHeadings(body) {
|
|
|
|
|
sections = append(sections, s)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
sections = dropEmpty(sections)
|
|
|
|
|
sections = subSplit(sections, cfg.MaxSectionChars, cfg.OverlapChars)
|
|
|
|
|
|
|
|
|
|
return sections
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type section struct {
|
|
|
|
|
Heading string
|
|
|
|
|
Body string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var (
|
|
|
|
|
frontmatterRe = regexp.MustCompile(`^---\n([\s\S]*?)\n---\n?`)
|
|
|
|
|
h1Re = regexp.MustCompile(`(?m)^# [^#].*$`)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func extractFrontmatter(s string) (string, string, bool) {
|
|
|
|
|
m := frontmatterRe.FindStringSubmatchIndex(s)
|
|
|
|
|
if m == nil {
|
|
|
|
|
return "", s, false
|
|
|
|
|
}
|
|
|
|
|
return s[m[2]:m[3]], s[m[1]:], true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// splitByHeadings splits the body into sections keyed by H1 or H2.
|
|
|
|
|
// H3+ content stays attached to its parent H2 (no extra split), which keeps
|
|
|
|
|
// the natural unit coherent: the bot answers about "Description" or "Tech
|
|
|
|
|
// stack", not about individual bullet points.
|
|
|
|
|
func splitByHeadings(body string) []section {
|
|
|
|
|
lines := strings.Split(body, "\n")
|
|
|
|
|
var sections []section
|
|
|
|
|
var current section
|
|
|
|
|
inSection := false
|
|
|
|
|
|
|
|
|
|
for _, line := range lines {
|
|
|
|
|
if isH1(line) || isH2(line) {
|
|
|
|
|
if inSection {
|
|
|
|
|
sections = append(sections, current)
|
|
|
|
|
}
|
|
|
|
|
current = section{Heading: strings.TrimSpace(strings.TrimLeft(strings.TrimSpace(line), "# "))}
|
|
|
|
|
inSection = true
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if inSection {
|
|
|
|
|
current.Body += line + "\n"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if inSection {
|
|
|
|
|
sections = append(sections, current)
|
|
|
|
|
}
|
|
|
|
|
// Strip trailing whitespace per section
|
|
|
|
|
for i := range sections {
|
|
|
|
|
sections[i].Body = strings.TrimRight(sections[i].Body, "\n ")
|
|
|
|
|
}
|
|
|
|
|
return sections
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func isH1(l string) bool { return strings.HasPrefix(l, "# ") && !strings.HasPrefix(l, "## ") }
|
|
|
|
|
func isH2(l string) bool { return strings.HasPrefix(l, "## ") }
|
|
|
|
|
|
|
|
|
|
// dropEmpty removes sections whose body is empty or just whitespace.
|
|
|
|
|
// Keeps the heading-level chunking honest: a "Description" with no body
|
|
|
|
|
// is not a useful retrieval target.
|
|
|
|
|
func dropEmpty(sections []section) []section {
|
|
|
|
|
out := sections[:0]
|
|
|
|
|
for _, s := range sections {
|
|
|
|
|
if strings.TrimSpace(s.Body) == "" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
out = append(out, s)
|
|
|
|
|
}
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// subSplit breaks down any section whose body exceeds max into overlapping
|
|
|
|
|
// slices, preserving the heading as a prefix on each piece so context isn't
|
|
|
|
|
// lost mid-section.
|
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
|
|
|
var h3Re = regexp.MustCompile(`(?m)^### +(.+)$`)
|
|
|
|
|
|
|
|
|
|
// splitByH3 breaks an oversized section at its H3 boundaries, if it has any.
|
|
|
|
|
// The sub-heading is folded into the section name ("Experience — Metrimex")
|
|
|
|
|
// so the piece still says what it is once it's out of context.
|
|
|
|
|
//
|
|
|
|
|
// This exists because character splitting mangles exactly the content that
|
|
|
|
|
// matters most. A CV's Experience section is a list of jobs; splitting it by
|
|
|
|
|
// size cut one entry mid-word, producing a chunk that began "... id app for
|
|
|
|
|
// an on-demand ride-sharing service". A chunk like that matches no question,
|
|
|
|
|
// and the employer name it belonged to was stranded in the previous piece.
|
|
|
|
|
// Splitting per job keeps the company, the role and the dates together.
|
|
|
|
|
func splitByH3(s section) ([]section, bool) {
|
|
|
|
|
locs := h3Re.FindAllStringSubmatchIndex(s.Body, -1)
|
|
|
|
|
if len(locs) < 2 {
|
|
|
|
|
return nil, false
|
|
|
|
|
}
|
|
|
|
|
var out []section
|
|
|
|
|
// Text before the first H3 (a lead-in paragraph) stays with the parent.
|
|
|
|
|
if lead := strings.TrimSpace(s.Body[:locs[0][0]]); lead != "" {
|
|
|
|
|
out = append(out, section{Heading: s.Heading, Body: lead})
|
|
|
|
|
}
|
|
|
|
|
for i, loc := range locs {
|
|
|
|
|
end := len(s.Body)
|
|
|
|
|
if i+1 < len(locs) {
|
|
|
|
|
end = locs[i+1][0]
|
|
|
|
|
}
|
|
|
|
|
title := strings.TrimSpace(s.Body[loc[2]:loc[3]])
|
|
|
|
|
body := strings.TrimSpace(s.Body[loc[0]:end])
|
|
|
|
|
if body == "" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
out = append(out, section{Heading: s.Heading + " — " + title, Body: body})
|
|
|
|
|
}
|
|
|
|
|
return out, len(out) > 0
|
|
|
|
|
}
|
|
|
|
|
|
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 subSplit(sections []section, max, overlap int) []section {
|
|
|
|
|
if max <= 0 {
|
|
|
|
|
return sections
|
|
|
|
|
}
|
|
|
|
|
if overlap < 0 || overlap >= max {
|
|
|
|
|
overlap = max / 10
|
|
|
|
|
}
|
|
|
|
|
var out []section
|
|
|
|
|
for _, s := range sections {
|
|
|
|
|
if len(s.Body) <= max {
|
|
|
|
|
out = append(out, s)
|
|
|
|
|
continue
|
|
|
|
|
}
|
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
|
|
|
// Prefer semantic boundaries over byte offsets.
|
|
|
|
|
if pieces, ok := splitByH3(s); ok {
|
|
|
|
|
out = append(out, pieces...)
|
|
|
|
|
continue
|
|
|
|
|
}
|
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 i := 0; i < len(s.Body); {
|
|
|
|
|
end := i + max
|
|
|
|
|
if end > len(s.Body) {
|
|
|
|
|
end = len(s.Body)
|
|
|
|
|
}
|
|
|
|
|
piece := s.Body[i:end]
|
|
|
|
|
if i > 0 {
|
|
|
|
|
piece = "... " + piece
|
|
|
|
|
}
|
|
|
|
|
if end < len(s.Body) {
|
|
|
|
|
piece = piece + " ..."
|
|
|
|
|
}
|
|
|
|
|
out = append(out, section{Heading: s.Heading, Body: piece})
|
|
|
|
|
if end == len(s.Body) {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
i += max - overlap
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return out
|
|
|
|
|
}
|