feat(portfolio): consume content from HTTP sources; per-source isolation #1

Merged
victorvargas merged 2 commits from feat/http-content-sources into main 2026-08-14 04:10:05 +00:00
Owner

Contexto

El bot duplicaba los .md en data/projects/ y se desincronizaba del portfolio. Este PR elimina la duplicación: el bot consume el contenido por HTTP desde cada entrada de sources: en el YAML. Una sola fuente de verdad por proyecto; añadir una refaccionaria o portfolio2 después es un cambio solo del YAML.

Match con portfolio PR #7 (lado emisor: /projects.json y /docs.json).

Cambios principales

  • internal/portfolio/fetcher.go: Fetcher interface + HTTPFetcher (15s timeout, ETag-aware) + LocalFetcher (test/dev). ProjectJSON valida campos requeridos (slug, title, description); vacíos → ErrSourceMalformed.
  • internal/portfolio/indexer.go: Source{Name, Endpoint, Kind, Fetcher} reemplaza el modelo basado en paths. Cada fuente reindexa en su propia transacción: DELETE WHERE source_name = X para portfolio_chunks y los vectores joined, reinserta, commit. Un fallo hace rollback sólo de esa fuente; las demás sobreviven. Nueva columna source_name (drop+recreate via ensureChunkSchema si falta). Nueva tabla portfolio_catalog con (project_id, title, source_name) poblada desde el JSON — independiente de los chunks para que una sección H1 vacía no rompa el título del catálogo.
  • internal/config/config.go: cfg.Sources []Source. os.Expand con envWithDefault soporta ${PORTFOLIO_URL:-...} para reindex local sin tocar el YAML.
  • configs/portfolio-bot.yaml: data_path/docs_path eliminados; sources: los reemplaza. CORS actualizado a sersofts.org.
  • Tests: actualizados para project_id con prefijo (portfolio:tienda-ropa); SourcesFromPaths helper para tests con LocalFetcher. Cobertura nueva: HTTP source, source vacío, 5xx aislado, malformed JSON, scope-por-fuente.
  • Cleanup: data/projects/*.md, data/docs/README.md, ejemplos y READMEs legacy eliminados. .gitignore actualizado.
  • docker-compose.yml: :Z SELinux labels en los volúmenes. Sin esto, podman-compose up en Fedora/RHEL falla con "Permission denied" en /models porque la imagen llama.cpp corre como nobody. En Debian/Ubuntu es no-op.

Verificación

PORTFOLIO_URL=http://127.0.0.1:4321/projects.json PORTFOLIO_DOCS_URL=http://127.0.0.1:4321/docs.json ./bin/chat-bot reindex
→ 7 files / 30 chunks

Tras borrar tienda-ropa.mdx del portfolio y reindexear:
→ 6 files / 27 chunks, catálogo sin portfolio:tienda-ropa, search tienda=0 hits, search stripe=0 hits.
→ Bot en widget: "Lo siento, no tengo información sobre un proyecto llamado Tienda de Ropa" (grounding honrado, no alucina).

Tests: go test ./... OK. go build ./... limpio.

Plan completo en portfolio/docs/single-source-of-truth.md.

## Contexto El bot duplicaba los .md en data/projects/ y se desincronizaba del portfolio. Este PR elimina la duplicación: el bot consume el contenido por HTTP desde cada entrada de sources: en el YAML. Una sola fuente de verdad por proyecto; añadir una refaccionaria o portfolio2 después es un cambio solo del YAML. Match con portfolio PR #7 (lado emisor: /projects.json y /docs.json). ## Cambios principales - internal/portfolio/fetcher.go: Fetcher interface + HTTPFetcher (15s timeout, ETag-aware) + LocalFetcher (test/dev). ProjectJSON valida campos requeridos (slug, title, description); vacíos → ErrSourceMalformed. - internal/portfolio/indexer.go: Source{Name, Endpoint, Kind, Fetcher} reemplaza el modelo basado en paths. Cada fuente reindexa en su propia transacción: DELETE WHERE source_name = X para portfolio_chunks y los vectores joined, reinserta, commit. Un fallo hace rollback sólo de esa fuente; las demás sobreviven. Nueva columna source_name (drop+recreate via ensureChunkSchema si falta). Nueva tabla portfolio_catalog con (project_id, title, source_name) poblada desde el JSON — independiente de los chunks para que una sección H1 vacía no rompa el título del catálogo. - internal/config/config.go: cfg.Sources []Source. os.Expand con envWithDefault soporta ${PORTFOLIO_URL:-...} para reindex local sin tocar el YAML. - configs/portfolio-bot.yaml: data_path/docs_path eliminados; sources: los reemplaza. CORS actualizado a sersofts.org. - Tests: actualizados para project_id con prefijo (portfolio:tienda-ropa); SourcesFromPaths helper para tests con LocalFetcher. Cobertura nueva: HTTP source, source vacío, 5xx aislado, malformed JSON, scope-por-fuente. - Cleanup: data/projects/*.md, data/docs/README.md, ejemplos y READMEs legacy eliminados. .gitignore actualizado. - docker-compose.yml: :Z SELinux labels en los volúmenes. Sin esto, podman-compose up en Fedora/RHEL falla con "Permission denied" en /models porque la imagen llama.cpp corre como nobody. En Debian/Ubuntu es no-op. ## Verificación PORTFOLIO_URL=http://127.0.0.1:4321/projects.json PORTFOLIO_DOCS_URL=http://127.0.0.1:4321/docs.json ./bin/chat-bot reindex → 7 files / 30 chunks Tras borrar tienda-ropa.mdx del portfolio y reindexear: → 6 files / 27 chunks, catálogo sin portfolio:tienda-ropa, search tienda=0 hits, search stripe=0 hits. → Bot en widget: "Lo siento, no tengo información sobre un proyecto llamado Tienda de Ropa" (grounding honrado, no alucina). Tests: go test ./... OK. go build ./... limpio. Plan completo en portfolio/docs/single-source-of-truth.md.
The bot no longer owns its corpus. Each 'sources:' entry in the YAML
points at an HTTP endpoint that returns a JSON array; the bot fetches
it on every reindex. Chunks are namespaced by 'name:' so two sources
cannot collide on project_id, and a failed reindex in one source does
not touch the others' corpus. Adding a refaccionaria or portfolio2
later is a YAML-only change.

- internal/portfolio/fetcher.go (new)
    Fetcher interface + HTTPFetcher (15s timeout, ETag-aware) +
    LocalFetcher (test/dev fallback) + ProjectJSON. Required fields
    (slug, title, description) are validated; empty ones return
    ErrSourceMalformed.

- internal/portfolio/indexer.go
    Source{Name, Endpoint, Kind, Fetcher} replaces Path-based sources.
    reindexSource runs each source in its own transaction: DELETE WHERE
    source_name=X for both portfolio_chunks and the joined vectors,
    re-insert, commit. A failure rolls back that source only.
    New 'source_name' column on portfolio_chunks; existing
    ensureChunkSchema drops+recreates on schema mismatch (the standard
    pattern for FTS5 virtual tables).
    New portfolio_catalog table tracks (project_id, title, source_name)
    populated from the JSON title — independent from chunks so an empty
    H1 section (e.g. '# Title\n\n## Purpose') doesn't break the
    catalogue display.

- internal/portfolio/hybrid.go
    VectorSearch scans the new source_name column.

- internal/config/config.go
    cfg.Sources []Source; new envWithDefault expander supports
    ${PORTFOLIO_URL:-https://sersofts.org/projects.json} so a local
    reindex is one env var away.

- configs/portfolio-bot.yaml
    rag.data_path / rag.docs_path removed. sources: replaces them with
    the two production endpoints (portfolio and portfolio-docs).
    server.corsors: 'https://victorvargas.dev' -> 'https://sersofts.org'.

- cmd/chat-bot/main.go, internal/server/handlers.go
    SourcesFor(...) -> SourcesFromConfig(cfg).

- Tests updated for namespaced project_ids (e.g. 'portfolio:tienda-ropa')
    and the new SourcesFromPaths helper for the local-fetcher path.

- Legacy data/projects/*.md and data/docs/README.md deleted; the bot
    no longer reads local files in production.

- .gitignore: ignore legacy data/projects and data/docs (kept on disk
    during migration).

- README.md, docs/architecture.md, docs/architecture.es.md
    Document the new sources: model and the JSON contract each
    endpoint must honour. Endpoints for a refaccionaria or portfolio2
    slot in without code changes.

End-to-end verified: 'chat-bot reindex' against a local Astro dev
indexed 6 projects + 1 CV into 30 chunks; deleting a project from the
collection and reindexing correctly wiped its chunks, catalogue entry
and FTS5 hits. Asking the bot about the deleted project produced a
'no tengo información' answer (grounding rule honoured), not a
hallucination.

Match with portfolio PR #7: portfolio serves /projects.json and
/docs.json with the same shape this bot consumes.
fix(dev): add :Z SELinux labels to docker-compose volumes for podman
All checks were successful
Build (Forgejo) / build (pull_request) Successful in 33s
12008470d1
The published llama.cpp image runs the server as nobody (UID 65534),
which can't read files labelled unconfined_u:object_r:default_t:s0 from
inside the container's SELinux domain. On Fedora/RHEL hosts this
manifests as 'Permission denied' on /models when llama-server starts.

The :Z suffix tells podman to relabel the source directory with a
private container-only label so the server process can read it. On
Debian/Ubuntu/AppArmor-only hosts this is a no-op — the volume mounts
still work the same way.

Encountered while running 'podman-compose up' on Fedora 41. Without
this, the embedder healthcheck fails and the bot reports 'embedding
failed; keyword search still works' (vector half degrades silently,
which is correct behavior — but the real cause is SELinux, not a
broken model).
victorvargas deleted branch feat/http-content-sources 2026-08-14 04:10:14 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
victorvargas/rony-chat-bot!1
No description provided.