diff --git a/.env.example b/.env.example
index 943fbdb8..5cdbec2f 100644
--- a/.env.example
+++ b/.env.example
@@ -25,6 +25,11 @@
# Example: VEKTRA_LLM_API_BASE=http://localhost:8000/v1
# VEKTRA_LLM_API_BASE=
+# Extra JSON body forwarded to litellm. Useful for vLLM thinking models
+# (Qwen3.5, DeepSeek-R1) to disable thinking mode.
+# Example: VEKTRA_LLM_EXTRA_BODY={"chat_template_kwargs": {"enable_thinking": false}}
+# VEKTRA_LLM_EXTRA_BODY=
+
# Or set provider-specific keys directly:
# OPENAI_API_KEY=sk-...
# ANTHROPIC_API_KEY=sk-ant-...
@@ -83,6 +88,12 @@
# VEKTRA_CONTEXT_CHUNK_RATIO=0.6
# VEKTRA_PROMPT_TEMPLATES_DIR=
+# Default RAG grounding policy. Per-namespace override via
+# PATCH /api/v1/admin/namespaces/{id}/config.
+# strict - answer from retrieved context + history only
+# hybrid - fall back to model knowledge when confident
+# VEKTRA_PROMPT_GROUNDING_MODE=strict
+
# Query rewriting (AdvancedQueryPipeline)
# VEKTRA_QUERY_REWRITE_ENABLED=true
# VEKTRA_QUERY_REWRITE_MODEL=
@@ -141,6 +152,14 @@
# VEKTRA_AUDIT_RETENTION_DAYS=90
# VEKTRA_EVAL_MODE=false
+# Persist QueryTrace rows for every query (DEBT-011). When unset, defaults
+# to true if vektra-analytics is configured, false otherwise.
+# VEKTRA_ANALYTICS_STORE_TRACES=
+
+# Log original and rewritten query text at debug level. Development only;
+# query text is otherwise redacted from logs.
+# VEKTRA_DEBUG_LOG_QUERIES=false
+
# --------------------------------------------------------------------------
# Security
# --------------------------------------------------------------------------
@@ -174,3 +193,10 @@
# Set to false when an external LMS (e.g. Moodle) manages enrollment.
# When false, namespace is derived from JWT (namespace claim or course_id fallback).
# VEKTRA_LEARN_REQUIRE_ENROLLMENT=true
+
+# Default visibility of the source-citations section in the chatbot widget
+# (FEAT-014). Resolution chain: data-show-sources attr (client) >
+# namespaces.config.show_sources (per-course) > this env var > hardcoded true.
+# The API always returns the full sources list; this flag only instructs the
+# widget whether to render them.
+# VEKTRA_LEARN_SHOW_SOURCES=true
diff --git a/.gemini/styleguide.md b/.gemini/styleguide.md
index 76d5c073..69bb2a7c 100644
--- a/.gemini/styleguide.md
+++ b/.gemini/styleguide.md
@@ -1,4 +1,4 @@
-# Vektra coding conventions for Gemini Code Assist
+# Vektra RAG coding conventions for Gemini Code Assist
## General
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index e49c2a6c..3e5699b4 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -1,5 +1,5 @@
name: Bug report
-description: Report a bug in Vektra
+description: Report a bug in Vektra RAG
labels: ["bug"]
body:
- type: markdown
diff --git a/.s2s/CONTEXT.md b/.s2s/CONTEXT.md
index 16df3387..2f46d50e 100644
--- a/.s2s/CONTEXT.md
+++ b/.s2s/CONTEXT.md
@@ -140,7 +140,7 @@ See [architecture.md](architecture.md) for complete architecture documentation.
- ChunkingStrategy: pluggable chunking. FixedSizeChunking (Phase 1), DualStrategyChunking with semantic splitting (Phase 2)
- QueryPipeline: RAG pipeline abstraction returning QueryResponse + QueryTrace. Phase 2: AdvancedQueryPipeline with query rewriting (ARCH-061), reranking, hybrid search (implemented)
- SafeguardHook: pre/post query safeguards (3 trust boundary points) with content modification support (ARCH-049)
-- EventEmitter: internal event hooks. NoOpEventEmitter (Phase 1), LogEventEmitter (Phase 2)
+- EventEmitter: internal event hooks. NoOpEventEmitter (Phase 1), WebhookEventEmitter (Phase 2: HMAC-SHA256 signed HTTP POST)
**Key decisions** (64 total, 25 ADRs):
- [ADR-0003](decisions/ADR-0003-modular-monolith-phase1.md): Modular monolith for Phase 1
diff --git a/.s2s/architecture.md b/.s2s/architecture.md
index 18712808..148ac79a 100644
--- a/.s2s/architecture.md
+++ b/.s2s/architecture.md
@@ -429,7 +429,7 @@ See [section 8.4](#84-deployment) for Docker Compose specification and resource
- **ARCH-035 - EmbeddingProvider Protocol**: Dedicated Protocol for embedding generation. Separates embed_documents() from embed_query() for asymmetric models. Single shared instance between ingest and core. Configurable provider and model via env vars.
- **ARCH-036 - QueryPipeline Protocol**: Abstraction of RAG query pipeline. execute() returns (QueryResponse, QueryTrace). Phase 1: SimpleQueryPipeline (embed -> search -> relevance_filter -> build_prompt -> LLM, with graceful degradation). relevance_filter applies minimum score threshold and overlap deduplication (ARCH-056). build_prompt calculates token budget from model context window (ARCH-055), renders composable Jinja2 templates (ARCH-054). Phase 2: AdvancedQueryPipeline adds query_rewrite (ARCH-061), query_classify, rerank, confidence scoring steps. Selectable via VEKTRA_QUERY_PIPELINE.
- **ARCH-037 - ChunkingStrategy Protocol**: Abstraction of text chunking. Receives DocumentChunk stream, returns chunked stream. Phase 1: FixedSizeChunking. Selectable via VEKTRA_CHUNKING_STRATEGY.
-- **ARCH-038 - EventEmitter interface**: Internal event hooks with NoOp default. Emission points at document lifecycle, query completion, safeguard triggers, API key operations. Phase 2 (implemented): LogEventEmitter. WebhookEventEmitter with HMAC-SHA256 deferred to Phase 3.
+- **ARCH-038 - EventEmitter interface**: Internal event hooks with NoOp default. Emission points at document lifecycle, query completion, safeguard triggers, API key operations. Phase 2 (implemented): WebhookEventEmitter (HMAC-SHA256 signed HTTP POST, activated via `VEKTRA_WEBHOOK_URL`). LogEventEmitter (structlog-based) was scoped during planning but never implemented; the webhook path subsumed the use case.
- **ARCH-039 - ProviderRegistry pattern**: Unified registry for all Protocol implementations. Dict-based in Phase 1, extensible to entry_points plugin discovery in Phase 2. Consistent VEKTRA_* env var configuration pattern.
- **ARCH-040 - Forward-compatible data model**: Phase 1 schema includes fields for Phase 2 features: response_id and citation_id for feedback loops, document version and supersedes_id for versioning, deleted_at and deletion_reason for soft delete, index_version for zero-downtime reindex. All fields nullable/defaulted, no behavioral change in Phase 1.
- **ARCH-041 - Audit/analytics separation**: QueryTrace (per-step timing, chunk refs, model info) emitted separately from audit log. QueryTrace does not contain query text or response content (REQ-051 compliance). Phase 1: structlog emission. Phase 2 (implemented): dedicated query_traces table with reporting API (vektra-analytics).
@@ -704,7 +704,7 @@ class EventEmitter(Protocol):
async def emit(event_type: str, payload: dict) -> None
```
-Phase 1: NoOpEventEmitter. Emission points: document.indexed, document.failed, query.completed, safeguard.triggered, apikey.created, apikey.revoked. Phase 2 (implemented): LogEventEmitter (structlog-based event logging). WebhookEventEmitter with HMAC-SHA256 deferred to Phase 3.
+Phase 1: NoOpEventEmitter. Emission points: document.indexed, document.failed, query.completed, safeguard.triggered, apikey.created, apikey.revoked. Phase 2 (implemented): WebhookEventEmitter — HMAC-SHA256 signed HTTP POST with configurable URL, secret, and timeout (`VEKTRA_WEBHOOK_URL`, `VEKTRA_WEBHOOK_SECRET`, `VEKTRA_WEBHOOK_TIMEOUT`). LogEventEmitter was originally scoped for Phase 2 but never built; structlog already emits the same events at module boundaries, so the webhook path was prioritized instead.
### 8.3.1 Extended types
@@ -1989,7 +1989,7 @@ Quality scenarios (QS-xx) define measurable targets. Validation scenarios ([vali
| TD-05 | No OAuth/OIDC | API keys sufficient for Phase 1 | Add for vektra-learn in Phase 2 |
| TD-06 | Confidence scoring undefined | Algorithm needs research spike, confidence_tier field exists in QueryResponse | OQ-014, Phase 2 spike, field ready (ARCH-040) |
| TD-07 | Dense-only vector search | Hybrid search Protocol ready (SearchMode enum), implementation deferred | REQ-050, swap PgvectorProvider or add QdrantVectorStoreProvider in Phase 2 (ARCH-051) |
-| TD-08 | NoOp event emission | EventEmitter hooks in place, no handler | REQ-061, swap to WebhookEventEmitter in Phase 2 |
+| TD-08 | NoOp event emission | EventEmitter hooks in place, no handler | REQ-061, WebhookEventEmitter implemented in Phase 2 |
| TD-09 | Passthrough safeguards | SafeguardHook hooks in place, no enforcement | REQ-044, swap to Presidio-based in Phase 2 |
| TD-10 | No sparse embeddings | SparseEmbeddingProvider Protocol defined, not registered | ARCH-053, register FastEmbedBM25Provider or SPLADEProvider in Phase 2 |
@@ -2133,7 +2133,7 @@ Quality scenarios (QS-xx) define measurable targets. Validation scenarios ([vali
| REQ-058 Content type detection | ARCH-042 Magic bytes | Reliable dispatch, mismatch warnings |
| REQ-059 LLM graceful degradation | ARCH-043 Graceful degradation, ARCH-056 Retrieval quality | Fallback model, context-only response, no-relevant-context path |
| REQ-060 QueryTrace | ARCH-041 Audit/analytics separation | Per-step RAG tracing, GDPR-safe |
-| REQ-061 EventEmitter | ARCH-038 EventEmitter interface | NoOp Phase 1, LogEventEmitter Phase 2 |
+| REQ-061 EventEmitter | ARCH-038 EventEmitter interface | NoOp Phase 1, WebhookEventEmitter Phase 2 |
| REQ-062 ProviderRegistry | ARCH-039 ProviderRegistry pattern | Unified provider configuration |
| REQ-063 Metadata filtering | ARCH-044 Chunk metadata filtering | JSONB + GIN index, SearchFilters in search() |
| REQ-064 Zero-downtime reindex | ARCH-045 Index version | Atomic version switch, no downtime |
@@ -2218,4 +2218,4 @@ Quality scenarios (QS-xx) define measurable targets. Validation scenarios ([vali
*Version 1.9 - Quality scenarios: Section 10 expanded from 12 to 18 QS entries, quality tree restructured, 6 architecture-derived scenarios added (ARCH-043 degradation, ARCH-057 startup, ARCH-039 extensibility, ARCH-040 evolvability, NFR-008 retention, NFR-010 progress), validation scenario cross-reference (10.4)*
*Version 1.9.1 - Conversational query rewriting: ARCH-061 (pre-retrieval query rewriting in AdvancedQueryPipeline), ADR-0023, rewrite.j2 template added to ARCH-054, ARCH-036 Phase 2 updated. Multilingual embedding note added to ADR-0013.*
*Version 1.10 - OQ-018/OQ-019 resolution: ARCH-062 (admin UI server-side rendering, ADR-0024), ARCH-063 (learn chatbot widget, ADR-0025), ARCH-064 (Phase 2 hardware target 8GB/4CPU). Glossary: HTMX added. Deferred table: 3 entries added.*
-*Version 2.0 - Phase 2 delivery annotations: all Phase 2 features marked as implemented (v0.2.0). Component table expanded with vektra-analytics and vektra-learn. Protocol implementations updated: QdrantVectorStoreProvider, UnstructuredExtractor, DualStrategyChunking, AdvancedQueryPipeline, FastEmbedBM25Provider, LogEventEmitter. WebhookEventEmitter deferred to Phase 3.*
+*Version 2.0 - Phase 2 delivery annotations: all Phase 2 features marked as implemented (v0.2.0). Component table expanded with vektra-analytics and vektra-learn. Protocol implementations updated: QdrantVectorStoreProvider, UnstructuredExtractor, DualStrategyChunking, AdvancedQueryPipeline, FastEmbedBM25Provider, WebhookEventEmitter. LogEventEmitter was originally scoped for Phase 2 but never built; the webhook path subsumed the use case.*
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b75ed22e..1870e6e3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,7 +4,24 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
-## [Unreleased] — v0.5.0 "Widget production-ready + instructor configuration"
+
+
+## [Unreleased]
+
+
+
+## [0.5.0] - 2026-04-25
+
+Widget production-ready + instructor configuration.
### Added
@@ -14,14 +31,47 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- **vektra-learn**: `GET /api/v1/learn/conversations/{id}/turns` JWT-scoped endpoint so the widget can restore a conversation after a page reload. Returns decrypted question/answer + created_at; admin-only metadata is not exposed. 403 on namespace mismatch, 404 on missing.
- **vektra-core**: `document_name` field on every source citation, joined from `source_documents.filename` so the widget renders `[1] lecture-07.pdf` instead of chunk UUIDs. Propagates through both `SimpleQueryPipeline` and `AdvancedQueryPipeline`, JSON and SSE paths. Soft-deleted source documents (REQ-057) keep their citation with an `(archived)` suffix so answers stay traceable.
- **vektra-learn**: content-access audit entry (`learn_conversation_turns_read`) written on every successful turns fetch via the shared `vektra_shared.audit` interface (NFR-007).
-- **widget**: white-label `data-*` attributes — `data-title`, `data-primary-color`, `data-icon` (emoji or URL), `data-welcome-message`, `data-powered-by`. All rendered via `textContent` / safe color whitelist to avoid XSS.
+- **widget**: white-label `data-*` attributes — `data-title`, `data-primary-color`, `data-icon` (emoji or URL), `data-welcome-message`, `data-powered-by`, `data-powered-by-text`, `data-powered-by-url`. All rendered via `textContent` / safe color whitelist to avoid XSS.
- **widget**: tab-scoped conversation persistence via `sessionStorage` keyed by `course_id` (24h cutoff); history replay on load via the new turns endpoint; explicit "New chat" button in the header.
- **errors**: new codes `ERR-ADMIN-005/006/007` (namespace config) and `ERR-LEARN-005/006` (conversation turns).
+- **docs**: consolidated [Widget integration guide](docs/guides/widget-integration.md) covering the LMS-agnostic embed flow (server-side JWT issuance, all `data-*` attributes, conversation persistence, token refresh, source citation visibility, error states, and end-to-end examples for PHP and Python backends). Linked from `docs/README.md` under Guides.
### Changed
+- **User-facing branding**: README, top-level docs h1s, and external surfaces now use **Vektra RAG** as the product name. Coordinated with [vektralabs/vektra-moodle#14](https://github.com/vektralabs/vektra-moodle/pull/14) which renames its README h1 to "Vektra RAG for Moodle". Repo name (`vektra-stack`), Python package names (`vektra_*`), Docker images, container names, CLI commands, and internal code identifiers are unchanged.
+- **widget footer label**: default "Powered by" link text changed from "Vektra" to "VektraLabs" — clearer maintainer-credit pattern (the link points to vektralabs.github.io, the org landing page) and avoids the residual short form. Integrators using the default branding will see the new label after upgrading the bundle; custom `data-powered-by-text` overrides are unaffected.
- **widget styles**: button and accent colors now use `var(--vektra-primary, …)` so `data-primary-color` takes effect without rebuilding the bundle. Hover states use `filter: brightness()` so custom colors still feel interactive.
+## [0.4.0] - 2026-04-11
+
+QueryTrace observability, eval harness, widget polish, and prompt hardening.
+
+### Added
+
+- **vektra-core**: configurable prompt grounding mode (FEAT-020, DEBT-016). `VEKTRA_PROMPT_GROUNDING_MODE=strict` (default) keeps the LLM tied to retrieved context + history; `hybrid` allows confident fallback to model knowledge. Foundation for the per-namespace override added in v0.5.0.
+- **vektra-core**: multilingual reranker upgrade (BUG-016, DEBT-010). Default reranker switched to `BAAI/bge-reranker-v2-m3` with a lower score threshold so non-English queries are no longer over-filtered.
+- **vektra-core**: `QueryTrace` persistence and an admin `GET /api/v1/admin/conversations/{id}/turns` endpoint (BUG-013, DEBT-011). Reranker scores, eval-mode metadata, and streaming model name are now captured in traces; gaps in the streaming path closed.
+- **vektra-learn**: SSE streaming in the learn query endpoint (FEAT-010). The `done` event now carries `conversation_id` so the widget keeps multi-turn continuity across SSE responses (BUG-018).
+- **widget**: Markdown rendering for assistant messages (FEAT-007), with code-block, list, and inline formatting; raw HTML stays sanitized.
+- **widget**: token auto-refresh on 401 (FEAT-009) via either an `onTokenExpired` callback or a `data-token-refresh-url` endpoint.
+- **widget**: API connectivity check and visible error feedback (FEAT-006) when the backend is unreachable.
+- **eval**: retrieval and end-to-end evaluation harness (TECH-002), plus a curated 55-question bilingual EN/IT evaluation dataset.
+- **config**: `VEKTRA_LLM_CONTEXT_WINDOW` propagation through `VektraSettings`, `VEKTRA_LLM_API_KEY` wired to litellm, and `VEKTRA_LLM_EXTRA_BODY` support for vLLM thinking-mode flags.
+
+### Fixed
+
+- **vektra-core**: warn (not crash) when `VEKTRA_LLM_CONTEXT_WINDOW` is unset for a model litellm does not know, falling back to 4096 (BUG-017).
+- **vektra-core**: conversation row now created before the first turn is persisted (BUG-014).
+- **vektra-core**: reranker scores propagated to `SearchResult` so observability and analytics see the post-rerank ranking (BUG-015).
+- **vektra-core**: register the conversation store in `ProviderRegistry` so dependent endpoints can resolve it.
+- **vektra-core**: distinguish "safeguard blocked" from "no relevant context" in the response so callers can tell apart a refusal from an empty result.
+- **vektra-core**: system prompt iteratively hardened to stop the LLM from leaking RAG internals (chunk IDs, scores, role names) in any language.
+- **vektra-learn**: audit logging on the conversation turns endpoint (NFR-007) was missing in the initial v0.4.0 release-review build.
+- **config**: `grounding_mode` validator added to `VektraSettings` so invalid values fail fast at startup.
+- **widget**: streaming token newlines and code-block formatting; centralized input-disabled state across sending and connection events; `
` no longer wraps inside `` blocks.
+- **build**: removed redundant `uv sync` when `INSTALL_UNSTRUCTURED=true`; eliminated all `uv pip install` invocations outside the lockfile to harden the supply chain.
+- **CI**: latency measurement skipped on PR runs (post-merge only); perf-measurement queries reduced from 100 to 20 to keep workflow time bounded.
+
## [0.3.0] - 2026-03-21
E-learning vertical refinements, widget UX improvements, and Phase 2 stabilization.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index def6de73..6c1353f6 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,4 +1,4 @@
-# Contributing to Vektra
+# Contributing to Vektra RAG
## Quick start
@@ -124,6 +124,31 @@ git log --show-signature -1 # should show "Good ssh signature"
**Remote servers without browser**: use `gh auth login --with-token` and add the
`admin:ssh_signing_key` scope upfront to avoid needing browser-based re-auth later.
+## Changelog
+
+We follow [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/). The
+file must always have an `[Unreleased]` section at the top, even if empty.
+
+**During development** — every PR with user-visible impact adds an entry under
+`[Unreleased]` in `CHANGELOG.md`, using one of the six standard sections:
+
+- **Added** — new features
+- **Changed** — changes in existing functionality
+- **Deprecated** — features that will be removed in upcoming releases
+- **Removed** — features removed in this release
+- **Fixed** — bug fixes
+- **Security** — vulnerability fixes
+
+Internal-only changes (refactors with no behavior change, CI tweaks, test-only
+edits) do not require a changelog entry.
+
+**At release time** — in the release-prep PR (`chore/vX.Y.Z-release`):
+
+1. Rename `## [Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD`
+2. Add a fresh empty `## [Unreleased]` block above the new release entry
+3. Bump versions in all `vektra-*/pyproject.toml` from `X.Y.Z-dev` to `X.Y.Z`
+4. Refresh `uv.lock` to match the new versions
+
## PR workflow
1. Create a branch: `git checkout -b feat/core-streaming`
@@ -148,6 +173,7 @@ Before opening a PR:
- [ ] Unit tests pass: `uv run pytest /tests/ -m "not integration"`
- [ ] New behavior has test coverage
- [ ] Commit messages follow Conventional Commits with `-s` sign-off
+- [ ] `CHANGELOG.md` has an `[Unreleased]` entry for any user-visible change
- [ ] PR description explains the change and motivation
## CI pipeline
diff --git a/GOVERNANCE.md b/GOVERNANCE.md
index 2b28ba43..5cbebf7a 100644
--- a/GOVERNANCE.md
+++ b/GOVERNANCE.md
@@ -1,4 +1,4 @@
-# Vektra Governance
+# Vektra RAG Governance
This document describes the governance model for the Vektra project.
diff --git a/README.md b/README.md
index 23722091..cc2ccf8f 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-# Vektra
+# Vektra RAG
[](https://github.com/vektralabs/vektra-stack/actions/workflows/ci-unit.yml)
[](https://github.com/vektralabs/vektra-stack/actions/workflows/lint.yml)
@@ -19,8 +19,8 @@ Plug in any LLM provider, vector store, or pipeline, keep data on-premises, and
Unlike RAG toolkits (LangChain, LlamaIndex, Haystack), Vektra ships as a **deployable platform**:
-| Aspect | Toolkits | Vektra |
-|--------|----------|--------|
+| Aspect | Toolkits | Vektra RAG |
+|--------|----------|------------|
| Deployment | Build your own | `docker compose up` |
| Configuration | Code changes | YAML/environment |
| On-premises | DIY | First-class support |
@@ -46,12 +46,14 @@ Vektra uses a hybrid monorepo approach ([ADR-0001](.s2s/decisions/ADR-0001-hybri
```text
vektra-stack/ # This repository (monorepo)
-├── vektra-core/ # RAG engine, LLM abstraction
+├── vektra-app/ # FastAPI entrypoint, startup validation, middleware
+├── vektra-shared/ # Protocols, types, config, auth, registry
+├── vektra-core/ # RAG engine, LLM abstraction, conversations
├── vektra-ingest/ # Document processing (PDF, OCR, PPT, Word)
├── vektra-index/ # Vector store abstraction, embedding
-├── vektra-analytics/ # Metrics, reporting, alerting
-├── vektra-learn/ # E-learning vertical (chatbot, dashboard)
-├── vektra-admin/ # System administration
+├── vektra-analytics/ # QueryTrace storage, metrics, reporting
+├── vektra-learn/ # E-learning vertical (chatbot widget, JWT-scoped API)
+├── vektra-admin/ # System administration (health, keys, namespace config)
├── docs/ # Documentation
└── docker-compose.yml # Orchestration
@@ -110,4 +112,4 @@ Apache License 2.0. See [LICENSE](LICENSE) for details.
---
-**Vektra** is maintained by [VektraLabs](https://github.com/vektralabs).
+**Vektra RAG** is maintained by [VektraLabs](https://github.com/vektralabs).
diff --git a/ROADMAP.md b/ROADMAP.md
index ce1e925f..72789ab2 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -1,4 +1,4 @@
-# Vektra Roadmap
+# Vektra RAG Roadmap
This document outlines the phased development plan for Vektra.
diff --git a/docs/README.md b/docs/README.md
index fddb8ab2..bec982ab 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -1,4 +1,4 @@
-# Vektra documentation
+# Vektra RAG documentation
## Getting started
@@ -8,7 +8,7 @@
## Reference
- [API reference](reference/api.md) - all endpoints with curl examples
-- [Configuration](reference/configuration.md) - 40 environment variables with defaults
+- [Configuration](reference/configuration.md) - 64 environment variables with defaults
- [Error codes](reference/error-codes.md) - error response codes and remediation
## Architecture
@@ -17,6 +17,7 @@
## Guides
+- [Widget integration](guides/widget-integration.md) - embed the LMS-agnostic chatbot widget in any course page (data-* attributes, JWT flow, conversation persistence, token refresh)
- [Contributors](guides/contributors/index.md) - dev setup, testing, extending providers
## Workflows
diff --git a/docs/architecture/index.md b/docs/architecture/index.md
index a1a5f129..0700893f 100644
--- a/docs/architecture/index.md
+++ b/docs/architecture/index.md
@@ -8,8 +8,10 @@ Vektra is a modular monolith: a single deployable container with internal packag
vektra-app Application entrypoint, startup validation, middleware
vektra-core RAG query pipeline, LLM abstraction, conversations
vektra-ingest Document processing (PDF, DOCX, PPTX), chunking, async jobs
- vektra-index Vector store (pgvector), embedding, semantic search
- vektra-admin Health endpoints, API key management, audit logging
+ vektra-index Vector store (pgvector / qdrant), embedding, semantic search
+ vektra-analytics QueryTrace storage, metrics aggregation, reporting (Phase 2)
+ vektra-learn E-learning vertical: LMS-agnostic API and chatbot widget (Phase 2)
+ vektra-admin Health endpoints, API key management, namespace config, audit logging
vektra-shared Protocols, types, config, auth, ProviderRegistry
```
@@ -31,8 +33,8 @@ Optional profiles:
Architecture documentation is maintained in the `.s2s/` directory:
-- [`.s2s/architecture.md`](../../.s2s/architecture.md) - arc42 architecture document (60 decisions, 9 Protocol interfaces)
-- [`.s2s/decisions/`](../../.s2s/decisions/) - Architecture Decision Records (ADR-0001 through ADR-0023)
+- [`.s2s/architecture.md`](../../.s2s/architecture.md) - arc42 architecture document (64 decisions, 9 Protocol interfaces)
+- [`.s2s/decisions/`](../../.s2s/decisions/) - Architecture Decision Records (ADR-0001 through ADR-0025)
- [`.s2s/requirements.md`](../../.s2s/requirements.md) - Software Requirements Specification (61 REQs, 13 NFRs)
### Key decisions
@@ -47,30 +49,38 @@ Architecture documentation is maintained in the `.s2s/` directory:
| [ADR-0013](../../.s2s/decisions/ADR-0013-embedding-provider-protocol.md) | EmbeddingProvider Protocol |
| [ADR-0014](../../.s2s/decisions/ADR-0014-query-pipeline-abstraction.md) | QueryPipeline abstraction |
| [ADR-0022](../../.s2s/decisions/ADR-0022-orm-sqlalchemy-async.md) | SQLAlchemy 2.0 async with asyncpg |
+| [ADR-0023](../../.s2s/decisions/ADR-0023-conversational-query-rewriting.md) | Conversational query rewriting (Phase 2) |
+| [ADR-0024](../../.s2s/decisions/ADR-0024-admin-ui-server-side.md) | Admin UI with server-side rendering (Phase 2: HTMX + Jinja2) |
+| [ADR-0025](../../.s2s/decisions/ADR-0025-learn-chatbot-widget.md) | Learn chatbot widget as backend-served JS bundle |
### Protocol interfaces
Vektra defines 9 Protocol interfaces in `vektra_shared` for pluggability:
-1. **LLMProvider** - multi-provider LLM abstraction with graceful degradation
-2. **EmbeddingProvider** - embedding generation (sentence-transformers in Phase 1)
-3. **SparseEmbeddingProvider** - sparse vectors for hybrid search (Phase 2)
-4. **VectorStoreProvider** - pluggable vector store (pgvector in Phase 1)
-5. **DocumentExtractor** - PDF, DOCX, PPTX extraction
-6. **ChunkingStrategy** - document chunking (fixed-size in Phase 1)
-7. **QueryPipeline** - RAG pipeline orchestration
-8. **SafeguardHook** - pre/post query safeguards
-9. **EventEmitter** - internal event hooks (no-op in Phase 1)
+1. **LLMProvider** - multi-provider LLM abstraction (litellm) with graceful degradation to fallback model and context-only mode.
+2. **EmbeddingProvider** - dense embedding generation. Default: `sentence-transformers` with `paraphrase-multilingual-MiniLM-L12-v2`.
+3. **SparseEmbeddingProvider** - sparse vectors for hybrid search. Phase 1: not registered. Phase 2: `FastEmbedBM25Provider` via `fastembed`.
+4. **VectorStoreProvider** - pluggable vector store with `SearchMode` enum (DENSE/SPARSE/HYBRID). Phase 1: pgvector. Phase 2: also Qdrant with native hybrid search.
+5. **DocumentExtractor** - PDF, DOCX, PPTX extraction. Phase 1: pdfplumber. Phase 2: also Unstructured (opt-in, adds OCR).
+6. **ChunkingStrategy** - document chunking. Phase 1: fixed-size. Phase 2: also dual-strategy (semantic + table preservation).
+7. **QueryPipeline** - RAG pipeline orchestration returning `QueryResponse` + `QueryTrace`. Phase 1: SimpleQueryPipeline. Phase 2: AdvancedQueryPipeline (query rewriting, reranking, hybrid retrieval).
+8. **SafeguardHook** - pre/post query safeguards at the three trust boundaries. Phase 1: passthrough. Phase 2: also Presidio (PII detection with content modification, ARCH-049).
+9. **EventEmitter** - internal event hooks. Phase 1: NoOpEventEmitter. Phase 2: WebhookEventEmitter (HMAC-SHA256 signed HTTP POST, activated via `VEKTRA_WEBHOOK_URL`).
### Startup validation
-The application runs an 8-step validation sequence at startup (ARCH-057):
+The application runs an 11-step validation sequence at startup (ARCH-057). Source of truth: `vektra-app/src/vektra_app/main.py:lifespan`.
-1. Configuration validation (Pydantic)
+1. Configuration validation: instantiate the flat `VektraSettings` aggregation. Sub-configs (`RewriteConfig`, `RerankConfig`, `WebhookConfig`) are validated independently by their consumers, not at this step.
2. Database connectivity
3. Database schema verification
4. pgvector extension check
-5. Provider registration
+5. Provider registration (LLM, embedding, sparse embedding, vector store, safeguard, event emitter, key store, conversation store, analytics service, learn service)
6. Embedding model warmup
7. LLM connectivity check (warning-only)
-8. Prompt template verification
+8. Prompt template loading
+9. Analytics check: verify `AnalyticsService` is registered in `ProviderRegistry` (registration check only; no storage I/O).
+10. Learn check: when `VEKTRA_LEARN_JWT_SECRET` is set, verify a `LearnService` is registered (instantiation happens in step 5) and that the secret is at least 32 characters. Skipped when learn is not configured.
+11. Qdrant collection check (when `vector_store_provider=qdrant`)
+
+Steps 9–11 were added in Phase 2 to cover the analytics, e-learning, and hybrid-search features.
diff --git a/docs/getting-started/first-query.md b/docs/getting-started/first-query.md
index a51f5e8a..26ce1f65 100644
--- a/docs/getting-started/first-query.md
+++ b/docs/getting-started/first-query.md
@@ -79,7 +79,8 @@ scripts/query.sh "Summarize the main findings"
"score": 0.912,
"snippet": "Finding 1: The study reveals that...",
"citation_id": "e5f6a7-...",
- "document_version": 1
+ "document_version": 1,
+ "document_name": "your-document.pdf"
},
{
"doc_id": "a1b2c3d4-...",
@@ -87,7 +88,8 @@ scripts/query.sh "Summarize the main findings"
"score": 0.847,
"snippet": "Finding 2: In contrast to previous work...",
"citation_id": "b8c9d0-...",
- "document_version": 1
+ "document_version": 1,
+ "document_name": "your-document.pdf"
}
],
"conversation_id": null,
@@ -105,6 +107,7 @@ scripts/query.sh "Summarize the main findings"
| `sources[].chunk_id` | Unique identifier of the retrieved chunk |
| `sources[].citation_id` | UUID citation reference for traceability |
| `sources[].document_version` | Index version of the chunk |
+| `sources[].document_name` | Filename of the source document, or `null` when the document join returns no row. Soft-deleted documents keep their citation with an `(archived)` suffix so traceability is preserved. |
| `sources[].score` | Cosine similarity score (0.0 - 1.0, higher is more relevant) |
| `sources[].snippet` | Text excerpt from the chunk |
| `conversation_id` | Echoed back if provided in the request (see below) |
diff --git a/docs/guides/contributors/index.md b/docs/guides/contributors/index.md
index ea2035fd..c1270c61 100644
--- a/docs/guides/contributors/index.md
+++ b/docs/guides/contributors/index.md
@@ -16,9 +16,12 @@ The file lives at `.github/CODEOWNERS`. Each component directory is mapped to on
| Directory | Component | Owner(s) |
|-----------|-----------|---------|
| `vektra-shared/` | Shared protocols and types | @fvadicamo |
+| `vektra-app/` | Application entrypoint and startup validation | @fvadicamo |
| `vektra-core/` | RAG engine and query pipeline | @fvadicamo |
| `vektra-ingest/` | Document ingestion pipeline | @fvadicamo |
| `vektra-index/` | Vector store and embedding | @fvadicamo |
+| `vektra-analytics/` | QueryTrace storage and metrics | @fvadicamo |
+| `vektra-learn/` | E-learning vertical (API + chatbot widget) | @fvadicamo |
| `vektra-admin/` | Administration interface | @fvadicamo |
| `.s2s/` | Specifications and architecture | @fvadicamo |
| `docs/` | Documentation | @fvadicamo |
diff --git a/docs/guides/widget-integration.md b/docs/guides/widget-integration.md
new file mode 100644
index 00000000..1110b52f
--- /dev/null
+++ b/docs/guides/widget-integration.md
@@ -0,0 +1,260 @@
+# Widget integration guide
+
+The Vektra RAG chatbot widget is a self-contained vanilla-JS bundle that embeds course-scoped Q&A into any LMS or web page. This guide covers the full integration path: server-side token issuance, embedding the widget, all configuration attributes, and behaviors like conversation persistence, token refresh, and source citations.
+
+The widget is **LMS-agnostic**: it works with Moodle, Canvas, Blackboard, custom platforms, or plain HTML. For the Moodle plugin (which automates the integration), see [vektralabs/vektra-moodle](https://github.com/vektralabs/vektra-moodle).
+
+## Architecture
+
+The widget runs entirely client-side. All API calls go directly from the student's browser to the Vektra RAG backend, authenticated by a short-lived JWT that the LMS backend issues on the user's behalf.
+
+```text
+Student's browser
+ ├── widget bundle (vektra-chat.js)
+ └── HTTP/SSE → Vektra RAG /api/v1/learn/* (JWT auth)
+
+LMS backend (Moodle, custom, ...)
+ └── HTTP → Vektra RAG /api/v1/learn/tokens (API key auth, server-side only)
+ ← {"token": "", "expires_at": "..."}
+```
+
+The API key never leaves the LMS backend. The browser only sees the JWT, which is scoped to a single course (namespace) and short-lived. If the JWT expires, the widget can refresh it transparently via a callback or refresh URL exposed by the LMS.
+
+## Quick start
+
+### 1. Issue a JWT (server-side)
+
+The LMS backend exchanges its admin API key for a JWT bound to a `(student_id, course_id)` pair:
+
+```bash
+curl -X POST https://your-vektra-host/api/v1/learn/tokens \
+ -H "Authorization: Bearer " \
+ -H "Content-Type: application/json" \
+ -d '{"student_id": "stu-12345", "course_id": "CS101"}'
+```
+
+Response (HTTP 201):
+
+```json
+{
+ "token": "eyJ...",
+ "expires_at": "2026-04-26T16:30:00Z"
+}
+```
+
+Required fields: `student_id`, `course_id`. Optional: `namespace` (defaults to `course_id`), `expires_in` (default 3600s, max 86400s). The JWT carries the `course_id` claim and is signed with `VEKTRA_LEARN_JWT_SECRET`. See [API reference > Learn](../reference/api.md#learn) for the full request/response shape.
+
+### 2. Embed the widget (HTML)
+
+Drop a single `
+```
+
+That's the minimum. The floating chat button appears in the bottom-right of the page; clicking it opens the chat panel.
+
+## Configuration: `data-*` attributes
+
+All customization lives on the `
+```
+
+The endpoint must return JSON `{"token": ""}`. Any non-2xx status, malformed shape, or network error aborts the refresh and surfaces the original `401` to the user.
+
+The refresh endpoint validates the LMS user's session (cookie auth), then internally calls `POST /api/v1/learn/tokens` with the LMS's admin API key. The API key never reaches the browser.
+
+### Pattern B — `onTokenExpired` callback
+
+For programmatic embeds (not data-attribute driven), the widget exposes an `onTokenExpired` callback. See `vektra-learn/widget/src/index.js` for the entry-point shape.
+
+## Source citations (FEAT-014)
+
+The widget renders source citations under each assistant answer when sources are present. Visibility is controlled by a 4-level resolution chain (highest priority first):
+
+1. **`data-show-sources` attribute** — client-side override. The trimmed lowercase value is compared to `"false"`: only that exact string hides the section. Any other value (including `"true"`) forces it visible.
+2. **`namespaces.config.show_sources`** — per-course server config, set via `PATCH /api/v1/admin/namespaces/{id}/config`.
+3. **`VEKTRA_LEARN_SHOW_SOURCES`** environment variable — global default for the deployment.
+4. **Hardcoded `true`** — final fallback.
+
+The API always returns the full sources list regardless of visibility, so analytics and `QueryTrace` keep complete data. Visibility is purely a UI concern.
+
+When sources are hidden, the answer still displays normally. When shown, sources are collapsible (per the accessibility refactor in v0.3.0) with `aria-controls` / `aria-expanded`.
+
+## Error states
+
+The widget surfaces explicit feedback for non-happy-path scenarios:
+
+| State | When | UI |
+|---|---|---|
+| `unavailable` | Initial health check fails or repeated network errors. | Banner "The assistant is currently unavailable. Please try again later." Send button disabled. |
+| `reconnecting` | Background re-check after `unavailable`. | Banner "Reconnecting...". |
+| `sessionExpired` | `401` after refresh attempt fails (or no refresh URL configured). | Banner "Your session has expired. Please reload the page." Input disabled. |
+| `noRelevantContext` | Backend reports no chunks above the relevance threshold. | Inline message "I couldn't find relevant information in the course materials for this question." |
+| Generic error | Any other failure during a query. | Inline red message, last assistant message preserved. |
+
+All strings are i18n-localized (`en`, `it`). Add a language by extending `I18N` in `vektra-learn/widget/src/chat-ui.js` and rebuilding the bundle.
+
+## End-to-end example: PHP (Moodle-style)
+
+```php
+ (string) $USER->id,
+ 'course_id' => $course->idnumber,
+]));
+curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+$response = json_decode(curl_exec($ch), true);
+$token = $response['token'];
+?>
+
+
+
+```
+
+The Moodle plugin ([vektra-moodle](https://github.com/vektralabs/vektra-moodle)) automates this flow as a course block.
+
+## End-to-end example: Python (FastAPI)
+
+```python
+import html
+import httpx
+from fastapi import FastAPI, Request
+from fastapi.responses import HTMLResponse
+
+app = FastAPI()
+VEKTRA_HOST = "https://vektra.example.com"
+ADMIN_KEY = "..."
+
+@app.get("/course/{course_id}", response_class=HTMLResponse)
+async def course_page(course_id: str, request: Request):
+ # Validate user's session here and resolve student_id (omitted)
+ student_id = request.session["student_id"]
+ async with httpx.AsyncClient() as client:
+ r = await client.post(
+ f"{VEKTRA_HOST}/api/v1/learn/tokens",
+ headers={"Authorization": f"Bearer {ADMIN_KEY}"},
+ json={"student_id": student_id, "course_id": course_id},
+ )
+ token = r.json()["token"]
+ # Escape every value that lands in HTML: even though course_id is path-typed
+ # by FastAPI, students may control it via routing, and token comes from an
+ # external service — never trust either source for raw HTML interpolation.
+ safe_host = html.escape(VEKTRA_HOST, quote=True)
+ safe_course = html.escape(course_id, quote=True)
+ safe_token = html.escape(token, quote=True)
+ return f"""
+
+
+
Course {safe_course}
+
+
+
+ """
+```
+
+## Security notes
+
+- All `data-*` values are rendered via `textContent`. No XSS via attribute injection.
+- `data-primary-color` is whitelist-validated against safe CSS color formats (hex, `rgb[a]`, `hsl[a]`, named colors). Anything else is silently ignored.
+- `data-powered-by-url` and `data-token-refresh-url` accept only `http://`, `https://`, or same-origin paths. `javascript:`, `data:`, and other schemes are rejected.
+- The JWT is embedded in HTML and visible to the student. This is by design: the JWT is course-scoped and short-lived; the API key stays server-side.
+- `sessionStorage` is preferred over `localStorage` to avoid cross-student conversation leakage on shared computers.
+
+## Troubleshooting
+
+| Symptom | Likely cause | Fix |
+|---|---|---|
+| Floating button doesn't appear | Missing required `data-*` attribute | Browser console will log `[vektra-chat] Missing required attributes: data-api-url, data-course-id, data-token`. |
+| "Session expired" appears | JWT expired and no refresh path configured | Configure `data-token-refresh-url` or `onTokenExpired`. |
+| "Unavailable" banner | Health check failing | Verify `data-api-url` is reachable from the student's browser; check CORS on the Vektra host. |
+| Sources never appear | `data-show-sources="false"` or namespace config | Inspect the resolution chain. `GET /api/v1/admin/namespaces/{id}/config` returns the `resolved.show_sources` value. |
+| Custom color ignored | `data-primary-color` failed whitelist | Use a hex, `rgb()`, `hsl()`, or named color. No quotes, semicolons, or whitespace. |
+
+## Related documentation
+
+- [API reference > Learn](../reference/api.md#learn) — endpoint specs
+- [Configuration](../reference/configuration.md) — `VEKTRA_LEARN_*` environment variables
+- [Error codes](../reference/error-codes.md) — `ERR-LEARN-*` and `ERR-ADMIN-*` series
+- [vektra-learn component README](../../vektra-learn/README.md) — internal architecture
diff --git a/docs/reference/api.md b/docs/reference/api.md
index 493fe120..99dd9ba5 100644
--- a/docs/reference/api.md
+++ b/docs/reference/api.md
@@ -193,7 +193,7 @@ Request body (flat dict, one entry per config key):
| Field | Type | Allowed values | Description |
|-------|------|----------------|-------------|
| `grounding_mode` | string or null | `"strict"`, `"hybrid"`, `null` | RAG grounding policy. `null` removes the key and falls back to `VEKTRA_PROMPT_GROUNDING_MODE`. |
-| `show_sources` | bool or null | `true`, `false`, `null` | Widget citation visibility (FEAT-014). `null` removes the key and falls back to `VEKTRA_LEARN_SHOW_SOURCES`. The API always returns the full sources list; the flag only instructs the widget whether to render them. |
+| `show_sources` | bool or null | `true`, `false`, `null` | Widget citation visibility (FEAT-014). `null` removes the key and falls back to `VEKTRA_LEARN_SHOW_SOURCES`. The API always returns the full sources list; the flag only instructs the widget whether to render them. Resolution chain: client `data-show-sources` attr > `namespaces.config.show_sources` > `VEKTRA_LEARN_SHOW_SOURCES` env > hardcoded `true`. |
Behavior:
- **Partial update**: keys not present in the body are preserved.
@@ -339,6 +339,7 @@ Response:
| `response_id` | Unique response identifier |
| `answer` | LLM-generated answer grounded in sources |
| `sources` | Ranked list of source chunks |
+| `sources[].document_name` | Filename of the source document (e.g. `lecture-07.pdf`), or `null` when the document join returns no row. Soft-deleted documents (REQ-057) keep their citation with an `(archived)` suffix so traceability is preserved. |
| `conversation_id` | Echoed back if provided in request |
| `context_only` | `true` if LLM failed and raw sources returned |
| `no_relevant_context` | `true` if no chunks exceeded relevance threshold |
@@ -368,6 +369,214 @@ curl -s \
http://localhost:8000/api/v1/providers | python3 -m json.tool
```
+## Learn
+
+The e-learning vertical (`vektra-learn`) exposes course-scoped endpoints authenticated via short-lived JWT dashboard tokens issued by `POST /api/v1/learn/tokens`. The `course_id` claim drives namespace isolation; the LMS-side integration (e.g. the Moodle plugin) is responsible for upstream auth before requesting a token.
+
+Two trust tiers:
+- **API key** (called server-side by the LMS): `/tokens`, `/enrollments`, `/content/ingest`. Each endpoint declares the minimum required scope below; `admin` is a project-wide superscope and always satisfies any required scope.
+- **JWT** (called from the browser by the widget, with the token issued by `/tokens`): `/query`, `/conversations/{id}/turns`. Namespace is derived from the JWT claims.
+
+### POST /api/v1/learn/tokens
+
+Issue a short-lived JWT dashboard token bound to a `(student_id, course_id)` pair. Called server-side by the LMS once it has authenticated the student.
+
+**Scopes**: `admin`
+
+```bash
+curl -s \
+ -H "Authorization: Bearer $VEKTRA_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{"student_id":"u123","course_id":"CS101","expires_in":3600}' \
+ http://localhost:8000/api/v1/learn/tokens | python3 -m json.tool
+```
+
+Request body:
+
+| Field | Type | Default | Description |
+|-------|------|---------|-------------|
+| `student_id` | string | (required) | Stable student identifier (1–255 chars). |
+| `course_id` | string | (required) | Stable course identifier (1–255 chars). |
+| `namespace` | string | (course_id) | Namespace embedded in the JWT. Defaults to the course id when omitted. |
+| `expires_in` | int | `3600` | Token lifetime in seconds (max `86400`, i.e. 24h). |
+
+Response (HTTP 201):
+
+```json
+{
+ "token": "eyJhbGciOi...",
+ "expires_at": "2026-04-25T15:00:00Z"
+}
+```
+
+### POST /api/v1/learn/enrollments
+
+Register a student in a course. Optional when `VEKTRA_LEARN_REQUIRE_ENROLLMENT=false` (the LMS is the source of truth for enrollment).
+
+**Scopes**: `ingest`
+
+```bash
+curl -s \
+ -H "Authorization: Bearer $VEKTRA_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{"student_id":"u123","course_id":"CS101","namespace":"CS101"}' \
+ http://localhost:8000/api/v1/learn/enrollments | python3 -m json.tool
+```
+
+Request body:
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `student_id` | string | Stable student identifier (1–255 chars). |
+| `course_id` | string | Stable course identifier (1–255 chars). |
+| `namespace` | string | Target namespace for course content (1–64 chars). Auto-created if absent. |
+| `metadata` | object | Optional free-form metadata. |
+
+Response (HTTP 201): full enrollment record (`id`, `student_id`, `course_id`, `namespace`, `enrolled_at`, `metadata`).
+
+Errors: `409 ERR-LEARN-004` if the same `(student_id, course_id)` is already enrolled.
+
+### GET /api/v1/learn/enrollments
+
+List enrollments. Filter by `course_id`, `student_id`, or both via query parameters.
+
+**Scopes**: `admin`
+
+```bash
+curl -s \
+ -H "Authorization: Bearer $VEKTRA_API_KEY" \
+ "http://localhost:8000/api/v1/learn/enrollments?course_id=CS101&limit=50" | python3 -m json.tool
+```
+
+Query parameters: `course_id`, `student_id`, `limit` (1–500, default 50), `offset` (default 0).
+
+Response: `{"items": [...], "count": }`.
+
+### DELETE /api/v1/learn/enrollments/{enrollment_id}
+
+Remove a single enrollment.
+
+**Scopes**: `admin`
+
+Returns HTTP 204 (no body). `404` if the enrollment id does not exist.
+
+### POST /api/v1/learn/content/ingest
+
+Trigger course-scoped ingestion. Used by automation (e.g. an n8n workflow watching a course folder) to push new material into the course namespace without going through the generic `/api/v1/ingest` endpoint.
+
+**Scopes**: `ingest`
+
+```bash
+curl -s \
+ -H "Authorization: Bearer $VEKTRA_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{"course_id":"CS101","namespace":"CS101","document_url":"https://lms.example/files/lecture-07.pdf"}' \
+ http://localhost:8000/api/v1/learn/content/ingest | python3 -m json.tool
+```
+
+Request body: `course_id`, `namespace`, optional `document_url`, optional `metadata`. Response echoes the ingestion `status`, `namespace`, `course_id`, `metadata`, and (when synchronous) `document_id` + `chunk_count`.
+
+### POST /api/v1/learn/query
+
+Course-scoped RAG query. Authenticated via JWT (not API key). The namespace is derived from the token's `namespace` (or `course_id` fallback) claim.
+
+```bash
+curl -s \
+ -H "Authorization: Bearer $LEARN_JWT" \
+ -H "Content-Type: application/json" \
+ -d '{"question":"What did we cover in lecture 7?"}' \
+ http://localhost:8000/api/v1/learn/query | python3 -m json.tool
+```
+
+Request body:
+
+| Field | Type | Default | Description |
+|-------|------|---------|-------------|
+| `question` | string | (required) | Query text |
+| `conversation_id` | UUID | - | Continue an existing conversation |
+| `top_k` | int | `5` | Number of chunks to retrieve |
+| `stream` | bool | `false` | Enable Server-Sent Events streaming |
+
+Response (HTTP 200, JSON):
+
+```json
+{
+ "response_id": "9f8e7d6c-...",
+ "answer": "Lecture 7 covered ...",
+ "sources": [
+ {
+ "doc_id": "550e8400-...",
+ "chunk_id": "c1a2b3...",
+ "score": 0.912,
+ "snippet": "...",
+ "document_name": "lecture-07.pdf"
+ }
+ ],
+ "conversation_id": "550e8400-...",
+ "no_relevant_context": false,
+ "show_sources": true
+}
+```
+
+| Field | Description |
+|-------|-------------|
+| `show_sources` | Server-resolved citation-visibility hint for the widget (FEAT-014). The full `sources` list is always returned regardless; the widget uses the flag to decide whether to render the citations block. See the resolution chain in [Namespaces PATCH](#patch-apiv1adminnamespacesnamespace_idconfig). |
+| `sources[].document_name` | Filename of the source document. Soft-deleted documents (REQ-057) keep an `(archived)` suffix so traceability is preserved. The field is `null` when the document join returns no row. |
+
+#### Streaming
+
+Set `stream: true` or send `Accept: text/event-stream`:
+
+```bash
+curl -s -N \
+ -H "Authorization: Bearer $LEARN_JWT" \
+ -H "Content-Type: application/json" \
+ -H "Accept: text/event-stream" \
+ -d '{"question":"What did we cover in lecture 7?","stream":true}' \
+ http://localhost:8000/api/v1/learn/query
+```
+
+The `sources` SSE event payload carries the same `show_sources` flag alongside the sources list.
+
+### GET /api/v1/learn/conversations/{conversation_id}/turns
+
+Return the decrypted turns of a conversation belonging to the token's course. Used by the widget to restore history after a page reload (FEAT-004).
+
+**Auth**: JWT dashboard token (same as `/learn/query`). The endpoint enforces `conversation.namespace_id == jwt.namespace`; a mismatch returns 403 (so the widget can distinguish "wrong course" from "deleted conversation" and reset its local state).
+
+```bash
+curl -s \
+ -H "Authorization: Bearer $LEARN_JWT" \
+ http://localhost:8000/api/v1/learn/conversations/550e8400-.../turns | python3 -m json.tool
+```
+
+Response (HTTP 200):
+
+```json
+{
+ "conversation_id": "550e8400-...",
+ "namespace": "course-101",
+ "turns": [
+ {
+ "turn_number": 1,
+ "question": "What is RAG?",
+ "answer": "RAG is ...",
+ "created_at": "2026-04-25T14:00:00Z",
+ "sources": []
+ }
+ ]
+}
+```
+
+The `turns[].sources` array is empty in v0.5.0 (admin-only metadata such as model, prompt tokens, and source enrichment from `query_traces` is intentionally not exposed to students). The `turns[].answer` field may be `null` for a turn that is still in-flight (the question is recorded immediately, the answer is filled in when the LLM completes).
+
+Errors:
+- `404 ERR-LEARN-005` if the conversation does not exist or has been deleted.
+- `403 ERR-LEARN-006` if the conversation belongs to a different course/namespace.
+- `503 ERR-LEARN-001` if the conversation store is not yet initialized or does not support decryption (e.g. in-memory store; production deployments must set `VEKTRA_CONVERSATION_KEY`).
+
+**Audit (NFR-007)**: every successful read writes a `learn_conversation_turns_read` audit row carrying `namespace`, `conversation_id`, `turn_count`, `student_id`, and `course_id`. The audit row is generated even if the upstream middleware did not set a request id.
+
## Index
### POST /api/v1/search
diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md
index e16cdbd4..c0753eed 100644
--- a/docs/reference/configuration.md
+++ b/docs/reference/configuration.md
@@ -84,6 +84,7 @@ The model name must match the vLLM `--model` path exactly (e.g., `/models/qwen35
| `VEKTRA_RESPONSE_TOKEN_RESERVE` | int | `2048` | Tokens reserved for LLM response generation |
| `VEKTRA_CONTEXT_CHUNK_RATIO` | float | `0.6` | Fraction of context window allocated to retrieved chunks (0.0-1.0) |
| `VEKTRA_PROMPT_TEMPLATES_DIR` | str | - | Directory for custom Jinja2 prompt templates (`system.j2`, `context.j2`, `conversation.j2`). Uses built-in defaults if unset. |
+| `VEKTRA_PROMPT_GROUNDING_MODE` | str | `strict` | Default RAG grounding policy. `strict` answers from retrieved context + history only; `hybrid` falls back to model knowledge when confident. Per-namespace override via `PATCH /api/v1/admin/namespaces/{id}/config`. |
### Query rewriting
@@ -138,6 +139,7 @@ The model name must match the vLLM `--model` path exactly (e.g., `/models/qwen35
|----------|------|---------|-------------|
| `VEKTRA_LEARN_JWT_SECRET` | str | - | JWT signing secret for dashboard tokens. Required when vektra-learn is active. Min 32 characters. |
| `VEKTRA_LEARN_REQUIRE_ENROLLMENT` | bool | `true` | Require enrollment record for learn queries. Set to false when LMS manages enrollment externally. |
+| `VEKTRA_LEARN_SHOW_SOURCES` | bool | `true` | Default visibility of the source-citations section in the widget (FEAT-014). Per-namespace override via `PATCH /api/v1/admin/namespaces/{id}/config` > `show_sources`. Per-widget override via the `data-show-sources` script-tag attribute. The API always returns the full sources list; the flag only instructs the widget whether to render them. |
## Observability
@@ -173,9 +175,10 @@ The model name must match the vLLM `--model` path exactly (e.g., `/models/qwen35
| Category | Count |
|----------|-------|
-| VEKTRA_* variables (across VektraSettings + sub-configs) | 53 |
+| VEKTRA_* variables in `VektraSettings` and sub-configs (Pydantic-validated) | 59 |
+| VEKTRA_* variables read directly from env (`VEKTRA_CORS_ORIGINS`) | 1 |
| External API keys (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`) | 2 |
| Infrastructure (`POSTGRES_PASSWORD`, `CMD_TARGET`) | 2 |
-| **Total documented** | **57** |
+| **Total documented** | **64** |
-The 53 VEKTRA_* variables are declared across `VektraSettings` (flat aggregation) and sub-configs (`RewriteConfig`, `RerankConfig`, `WebhookConfig`, `IngestConfig` extensions). Sub-configs are validated independently at startup, not aggregated into VektraSettings. Infrastructure variables (`POSTGRES_PASSWORD`, `CMD_TARGET`) are used by Docker Compose or the entrypoint script.
+The 59 Pydantic-validated VEKTRA_* variables are declared across `VektraSettings` (flat aggregation) and sub-configs (`RewriteConfig`, `RerankConfig`, `WebhookConfig`, `IngestConfig` extensions). Sub-configs are validated independently at startup, not aggregated into VektraSettings. `VEKTRA_CORS_ORIGINS` is read directly via `os.environ.get` at app startup (see `vektra-app/src/vektra_app/main.py`). Infrastructure variables (`POSTGRES_PASSWORD`, `CMD_TARGET`) are used by Docker Compose or the entrypoint script.
diff --git a/docs/reference/error-codes.md b/docs/reference/error-codes.md
index 9bb9cd3d..9eb598fb 100644
--- a/docs/reference/error-codes.md
+++ b/docs/reference/error-codes.md
@@ -86,10 +86,10 @@ These codes are used by specific components and are not part of the REQ-011 regi
| ERR-ADMIN-006 | vektra-admin | 400 | Unknown config key in namespace PATCH body |
| ERR-ADMIN-007 | vektra-admin | 400 | Invalid value for namespace config key |
| ERR-SAFEGUARD-001 | vektra-core | 400 | Query blocked by safeguard pre-check |
-| ERR-LEARN-001 | vektra-learn | 503 | Learn service unavailable or pipeline not configured |
+| ERR-LEARN-001 | vektra-learn | 503 / 500 | Learn service unavailable (503, registry not initialized) or misconfigured (500, conversation store does not support decryption — `VEKTRA_CONVERSATION_KEY` missing) |
| ERR-LEARN-002 | vektra-learn | 404 | No enrollment found for student in course |
| ERR-LEARN-003 | vektra-learn | 401 | Dashboard token missing required course_id claim |
-| ERR-LEARN-004 | vektra-learn | 422 | Invalid learn request (missing fields, bad format) |
+| ERR-LEARN-004 | vektra-learn | 409 | Duplicate enrollment (student already enrolled in course) |
| ERR-LEARN-005 | vektra-learn | 404 | Conversation not found (GET conversations/turns) |
| ERR-LEARN-006 | vektra-learn | 403 | Conversation belongs to a different course/namespace |
diff --git a/uv.lock b/uv.lock
index 652f43f6..2ce11368 100644
--- a/uv.lock
+++ b/uv.lock
@@ -4598,15 +4598,15 @@ dependencies = [
{ name = "typing-extensions", marker = "sys_platform == 'darwin'" },
]
wheels = [
- { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0-1-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:7fbbf409143a4fe0812a40c0b46a436030a7e1d14fe8c5234dfbe44df47f617e" },
- { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0-1-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:b39cafff7229699f9d6e172cac74d85fd71b568268e439e08d9c540e54732a3e" },
- { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0-2-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:358bd7125cbec6e692d60618a5eec7f55a51b29e3652a849fd42af021d818023" },
- { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0-2-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:470de4176007c2700735e003a830828a88d27129032a3add07291da07e2a94e8" },
- { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:45a1c5057629444aeb1c452c18298fa7f30f2f7aeadd4dc41f9d340980294407" },
- { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:339e05502b6c839db40e88720cb700f5a3b50cda332284873e851772d41b2c1e" },
- { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:840351da59cedb7bcbc51981880050813c19ef6b898a7fecf73a3afc71aff3fe" },
- { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:c88b1129fd4e14f0f882963c6728315caae35d2f47374d17edeed1edc7697497" },
- { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f4bea7dc451267c028593751612ad559299589304e68df54ae7672427893ff2c" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0-1-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:7fbbf409143a4fe0812a40c0b46a436030a7e1d14fe8c5234dfbe44df47f617e", upload-time = "2026-02-06T16:27:14Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0-1-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:b39cafff7229699f9d6e172cac74d85fd71b568268e439e08d9c540e54732a3e", upload-time = "2026-02-06T16:27:17Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0-2-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:358bd7125cbec6e692d60618a5eec7f55a51b29e3652a849fd42af021d818023", upload-time = "2026-02-10T19:55:42Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0-2-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:470de4176007c2700735e003a830828a88d27129032a3add07291da07e2a94e8", upload-time = "2026-02-10T19:55:43Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:45a1c5057629444aeb1c452c18298fa7f30f2f7aeadd4dc41f9d340980294407", upload-time = "2026-01-23T15:09:55Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:339e05502b6c839db40e88720cb700f5a3b50cda332284873e851772d41b2c1e", upload-time = "2026-01-23T15:09:57Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:840351da59cedb7bcbc51981880050813c19ef6b898a7fecf73a3afc71aff3fe", upload-time = "2026-01-23T15:09:59Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:c88b1129fd4e14f0f882963c6728315caae35d2f47374d17edeed1edc7697497", upload-time = "2026-01-23T15:09:59Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f4bea7dc451267c028593751612ad559299589304e68df54ae7672427893ff2c", upload-time = "2026-01-23T15:10:01Z" },
]
[[package]]
@@ -4633,33 +4633,33 @@ dependencies = [
{ name = "typing-extensions", marker = "sys_platform != 'darwin'" },
]
wheels = [
- { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp312-cp312-linux_aarch64.whl", hash = "sha256:8de5a36371b775e2d4881ed12cc7f2de400b1ad3d728aa74a281f649f87c9b8c" },
- { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp312-cp312-linux_s390x.whl", hash = "sha256:9accc30b56cb6756d4a9d04fcb8ebc0bb68c7d55c1ed31a8657397d316d31596" },
- { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:179451716487f8cb09b56459667fa1f5c4c0946c1e75fbeae77cfc40a5768d87" },
- { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:ee40b8a4b4b2cf0670c6fd4f35a7ef23871af956fecb238fbf5da15a72650b1d" },
- { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp312-cp312-win_amd64.whl", hash = "sha256:21cb5436978ef47c823b7a813ff0f8c2892e266cfe0f1d944879b5fba81bf4e1" },
- { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp312-cp312-win_arm64.whl", hash = "sha256:3eaa727e6a73affa61564d86b9d03191df45c8650d0666bd3d57c8597ef61e78" },
- { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313-linux_aarch64.whl", hash = "sha256:fd215f3d0f681905c5b56b0630a3d666900a37fcc3ca5b937f95275c66f9fd9c" },
- { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313-linux_s390x.whl", hash = "sha256:170a0623108055be5199370335cf9b41ba6875b3cb6f086db4aee583331a4899" },
- { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:e51994492cdb76edce29da88de3672a3022f9ef0ffd90345436948d4992be2c7" },
- { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:8d316e5bf121f1eab1147e49ad0511a9d92e4c45cc357d1ab0bee440da71a095" },
- { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313-win_amd64.whl", hash = "sha256:b719da5af01b59126ac13eefd6ba3dd12d002dc0e8e79b8b365e55267a8189d3" },
- { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313-win_arm64.whl", hash = "sha256:b67d91326e4ed9eccbd6b7d84ed7ffa43f93103aa3f0b24145f3001f3b11b714" },
- { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313t-linux_aarch64.whl", hash = "sha256:5af75e5f49de21b0bdf7672bc27139bd285f9e8dbcabe2d617a2eb656514ac36" },
- { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313t-linux_s390x.whl", hash = "sha256:ba51ef01a510baf8fff576174f702c47e1aa54389a9f1fba323bb1a5003ff0bf" },
- { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:0fedcb1a77e8f2aaf7bfd21591bf6d1e0b207473268c9be16b17cb7783253969" },
- { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:106dd1930cb30a4a337366ba3f9b25318ebf940f51fd46f789281dd9e736bdc4" },
- { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313t-win_amd64.whl", hash = "sha256:eb1bde1ce198f05c8770017de27e001d404499cf552aaaa014569eff56ca25c0" },
- { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp314-cp314-linux_aarch64.whl", hash = "sha256:ea2bcc9d1fca66974a71d4bf9a502539283f35d61fcab5a799b4e120846f1e02" },
- { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp314-cp314-linux_s390x.whl", hash = "sha256:f8294fd2fc6dd8f4435a891a0122307a043b14b21f0dac1bca63c85bfb59e586" },
- { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:a28fdbcfa2fbacffec81300f24dd1bed2b0ccfdbed107a823cff12bc1db070f6" },
- { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:aada8afc068add586464b2a55adb7cc9091eec55caf5320447204741cb6a0604" },
- { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp314-cp314-win_amd64.whl", hash = "sha256:2adc71fe471e98a608723bfc837f7e1929885ebb912c693597711e139c1cda41" },
- { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp314-cp314t-linux_aarch64.whl", hash = "sha256:9412bd37b70f5ebd1205242c4ba4cabae35a605947f2b30806d5c9b467936db9" },
- { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp314-cp314t-linux_s390x.whl", hash = "sha256:e71c476517c33e7db69825a9ff46c7f47a723ec4dac5b2481cff4246d1c632be" },
- { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:23882f8d882460aca809882fc42f5e343bf07585274f929ced00177d1be1eb67" },
- { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4fcd8b4cc2ae20f2b7749fb275349c55432393868778c2d50a08e81d5ee5591e" },
- { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp314-cp314t-win_amd64.whl", hash = "sha256:ffc8da9a1341092d6a90cb5b1c1a33cd61abf0fb43f0cd88443c27fa372c26ae" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp312-cp312-linux_aarch64.whl", hash = "sha256:8de5a36371b775e2d4881ed12cc7f2de400b1ad3d728aa74a281f649f87c9b8c", upload-time = "2026-01-23T15:10:22Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp312-cp312-linux_s390x.whl", hash = "sha256:9accc30b56cb6756d4a9d04fcb8ebc0bb68c7d55c1ed31a8657397d316d31596", upload-time = "2026-01-23T15:10:24Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:179451716487f8cb09b56459667fa1f5c4c0946c1e75fbeae77cfc40a5768d87", upload-time = "2026-01-23T15:10:25Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:ee40b8a4b4b2cf0670c6fd4f35a7ef23871af956fecb238fbf5da15a72650b1d", upload-time = "2026-01-23T15:10:27Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp312-cp312-win_amd64.whl", hash = "sha256:21cb5436978ef47c823b7a813ff0f8c2892e266cfe0f1d944879b5fba81bf4e1", upload-time = "2026-01-23T15:10:30Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp312-cp312-win_arm64.whl", hash = "sha256:3eaa727e6a73affa61564d86b9d03191df45c8650d0666bd3d57c8597ef61e78", upload-time = "2026-01-23T15:10:31Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313-linux_aarch64.whl", hash = "sha256:fd215f3d0f681905c5b56b0630a3d666900a37fcc3ca5b937f95275c66f9fd9c", upload-time = "2026-01-23T15:10:34Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313-linux_s390x.whl", hash = "sha256:170a0623108055be5199370335cf9b41ba6875b3cb6f086db4aee583331a4899", upload-time = "2026-01-23T15:10:35Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:e51994492cdb76edce29da88de3672a3022f9ef0ffd90345436948d4992be2c7", upload-time = "2026-01-23T15:10:37Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:8d316e5bf121f1eab1147e49ad0511a9d92e4c45cc357d1ab0bee440da71a095", upload-time = "2026-01-23T15:10:38Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313-win_amd64.whl", hash = "sha256:b719da5af01b59126ac13eefd6ba3dd12d002dc0e8e79b8b365e55267a8189d3", upload-time = "2026-01-23T15:10:41Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313-win_arm64.whl", hash = "sha256:b67d91326e4ed9eccbd6b7d84ed7ffa43f93103aa3f0b24145f3001f3b11b714", upload-time = "2026-01-23T15:10:42Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313t-linux_aarch64.whl", hash = "sha256:5af75e5f49de21b0bdf7672bc27139bd285f9e8dbcabe2d617a2eb656514ac36", upload-time = "2026-01-23T15:10:44Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313t-linux_s390x.whl", hash = "sha256:ba51ef01a510baf8fff576174f702c47e1aa54389a9f1fba323bb1a5003ff0bf", upload-time = "2026-01-23T15:10:48Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:0fedcb1a77e8f2aaf7bfd21591bf6d1e0b207473268c9be16b17cb7783253969", upload-time = "2026-01-23T15:10:48Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:106dd1930cb30a4a337366ba3f9b25318ebf940f51fd46f789281dd9e736bdc4", upload-time = "2026-01-23T15:10:50Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313t-win_amd64.whl", hash = "sha256:eb1bde1ce198f05c8770017de27e001d404499cf552aaaa014569eff56ca25c0", upload-time = "2026-01-23T15:10:50Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp314-cp314-linux_aarch64.whl", hash = "sha256:ea2bcc9d1fca66974a71d4bf9a502539283f35d61fcab5a799b4e120846f1e02", upload-time = "2026-01-23T15:10:53Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp314-cp314-linux_s390x.whl", hash = "sha256:f8294fd2fc6dd8f4435a891a0122307a043b14b21f0dac1bca63c85bfb59e586", upload-time = "2026-01-23T15:10:55Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:a28fdbcfa2fbacffec81300f24dd1bed2b0ccfdbed107a823cff12bc1db070f6", upload-time = "2026-01-23T15:10:56Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:aada8afc068add586464b2a55adb7cc9091eec55caf5320447204741cb6a0604", upload-time = "2026-01-23T15:10:58Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp314-cp314-win_amd64.whl", hash = "sha256:2adc71fe471e98a608723bfc837f7e1929885ebb912c693597711e139c1cda41", upload-time = "2026-01-23T15:11:01Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp314-cp314t-linux_aarch64.whl", hash = "sha256:9412bd37b70f5ebd1205242c4ba4cabae35a605947f2b30806d5c9b467936db9", upload-time = "2026-01-23T15:11:03Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp314-cp314t-linux_s390x.whl", hash = "sha256:e71c476517c33e7db69825a9ff46c7f47a723ec4dac5b2481cff4246d1c632be", upload-time = "2026-01-23T15:11:04Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:23882f8d882460aca809882fc42f5e343bf07585274f929ced00177d1be1eb67", upload-time = "2026-01-23T15:11:07Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4fcd8b4cc2ae20f2b7749fb275349c55432393868778c2d50a08e81d5ee5591e", upload-time = "2026-01-23T15:11:07Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp314-cp314t-win_amd64.whl", hash = "sha256:ffc8da9a1341092d6a90cb5b1c1a33cd61abf0fb43f0cd88443c27fa372c26ae", upload-time = "2026-01-23T15:11:10Z" },
]
[[package]]
@@ -4955,7 +4955,7 @@ wheels = [
[[package]]
name = "vektra-admin"
-version = "0.5.0.dev0"
+version = "0.5.0"
source = { editable = "vektra-admin" }
dependencies = [
{ name = "argon2-cffi" },
@@ -5004,7 +5004,7 @@ dev = [
[[package]]
name = "vektra-analytics"
-version = "0.5.0.dev0"
+version = "0.5.0"
source = { editable = "vektra-analytics" }
dependencies = [
{ name = "fastapi" },
@@ -5039,7 +5039,7 @@ dev = [
[[package]]
name = "vektra-app"
-version = "0.5.0.dev0"
+version = "0.5.0"
source = { editable = "vektra-app" }
dependencies = [
{ name = "alembic" },
@@ -5088,7 +5088,7 @@ dev = [
[[package]]
name = "vektra-core"
-version = "0.5.0.dev0"
+version = "0.5.0"
source = { editable = "vektra-core" }
dependencies = [
{ name = "fastapi" },
@@ -5131,7 +5131,7 @@ dev = [
[[package]]
name = "vektra-index"
-version = "0.5.0.dev0"
+version = "0.5.0"
source = { editable = "vektra-index" }
dependencies = [
{ name = "asyncpg" },
@@ -5186,7 +5186,7 @@ dev = [
[[package]]
name = "vektra-ingest"
-version = "0.5.0.dev0"
+version = "0.5.0"
source = { editable = "vektra-ingest" }
dependencies = [
{ name = "arq" },
@@ -5248,7 +5248,7 @@ dev = [
[[package]]
name = "vektra-learn"
-version = "0.5.0.dev0"
+version = "0.5.0"
source = { editable = "vektra-learn" }
dependencies = [
{ name = "fastapi" },
@@ -5285,7 +5285,7 @@ dev = [
[[package]]
name = "vektra-shared"
-version = "0.5.0.dev0"
+version = "0.5.0"
source = { editable = "vektra-shared" }
dependencies = [
{ name = "asyncpg" },
diff --git a/vektra-admin/README.md b/vektra-admin/README.md
index 0eb9ae27..4cf9d594 100644
--- a/vektra-admin/README.md
+++ b/vektra-admin/README.md
@@ -4,4 +4,14 @@ System administration interface for the Vektra platform.
Phase 1: minimal admin endpoints included in vektra-core (namespace management, API key management, system health). Phase 2: full admin SPA with separate deployment.
+## Endpoints
+
+See [API reference](../docs/reference/api.md) for full request/response shapes:
+
+- `POST/GET/DELETE /api/v1/api-keys` — API key lifecycle (`admin` scope)
+- `GET /api/v1/admin/namespaces/{id}/config` — read namespace behavioral config (stored JSONB + resolved view)
+- `PATCH /api/v1/admin/namespaces/{id}/config` — partial update of namespace behavioral config (current whitelist: `grounding_mode`, `show_sources`)
+
+The PATCH endpoint backs upstream LMS plugins (e.g. the Moodle block edit form) so professors can toggle per-course behavior — strict/hybrid RAG, citation visibility — without touching environment variables or invoking platform admins.
+
See [architecture.md](../.s2s/architecture.md) for the component specification.
diff --git a/vektra-admin/pyproject.toml b/vektra-admin/pyproject.toml
index 18c28b92..d5e72887 100644
--- a/vektra-admin/pyproject.toml
+++ b/vektra-admin/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "vektra-admin"
-version = "0.5.0-dev"
+version = "0.5.0"
description = "System administration interface: health, API key management, audit log"
readme = "README.md"
requires-python = ">=3.12"
diff --git a/vektra-analytics/README.md b/vektra-analytics/README.md
index 80cff684..11c7ea6f 100644
--- a/vektra-analytics/README.md
+++ b/vektra-analytics/README.md
@@ -2,4 +2,26 @@
QueryTrace storage, metrics aggregation, and reporting API for the Vektra platform.
-Part of the [vektra-stack](../README.md) monorepo.
+Phase 2 component (ADR-0017). Decouples observability data (per-query traces with retrieval/rerank/LLM metadata) from the audit log so privacy-sensitive content can be retained on a different schedule than operational metrics.
+
+## What it stores
+
+`QueryTrace` rows (`query_traces` table) carry, for every query that runs through the pipeline:
+
+- Pipeline-level: `request_id`, `namespace`, `pipeline_version`, latency, `grounding_mode`, `query_pipeline`, `eval_mode` flag.
+- Retrieval: number of chunks fetched, retained after dedup/threshold, reranker provider/scores when `VEKTRA_RERANK_ENABLED=true`.
+- Generation: model name (including streaming responses), prompt/completion token counts, `no_relevant_context` and `context_only` flags.
+
+Conversation question/answer text is captured **only** when `VEKTRA_EVAL_MODE=true` (staging-only batch evaluation use case, ARCH-050). Production traces are metadata-only.
+
+## Reporting
+
+The analytics router (prefix `/api/v1`) exposes three admin-scoped endpoints:
+
+- `GET /api/v1/traces` — paginated `QueryTrace` listing. Filters: `namespace`, `from`, `to`, `model`, `min_duration_ms`, `limit` (max 500), `offset`.
+- `GET /api/v1/traces/{response_id}` — single trace by `response_id` (`404 ERR-ANALYTICS-002` when missing).
+- `GET /api/v1/metrics` — aggregated metrics over an optional `(namespace, from, to)` window: `total_queries`, `avg_latency_ms`, `p95_latency_ms`, `avg_retrieval_score`, `queries_per_hour`, and a `model_distribution` map (LLM model → query count).
+
+Storage is optional and gated by `VEKTRA_ANALYTICS_STORE_TRACES`. Retention is independent (`VEKTRA_ANALYTICS_RETENTION_DAYS`) so traces can outlive the audit log or vice versa.
+
+See [architecture.md](../.s2s/architecture.md) for the component specification.
diff --git a/vektra-analytics/pyproject.toml b/vektra-analytics/pyproject.toml
index 3481f016..18afcfe1 100644
--- a/vektra-analytics/pyproject.toml
+++ b/vektra-analytics/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "vektra-analytics"
-version = "0.5.0-dev"
+version = "0.5.0"
description = "QueryTrace storage, metrics aggregation, and reporting API"
readme = "README.md"
requires-python = ">=3.12"
diff --git a/vektra-app/README.md b/vektra-app/README.md
index 06ebea2d..c2b18ce0 100644
--- a/vektra-app/README.md
+++ b/vektra-app/README.md
@@ -2,9 +2,11 @@
FastAPI application assembly and startup validation for the Vektra platform.
-This package wires all component modules (vektra-admin, vektra-core, vektra-ingest,
-vektra-index) into a single FastAPI application with the 8-step startup validation
-sequence (ARCH-057).
+This package wires all component modules (vektra-shared, vektra-core, vektra-ingest,
+vektra-index, vektra-analytics, vektra-learn, vektra-admin) into a single FastAPI
+application with the 11-step startup validation sequence (ARCH-057). It also mounts
+the chatbot widget bundle at `/static/learn/vektra-chat.js` and the admin static
+assets at `/admin/static`.
## Usage
diff --git a/vektra-app/pyproject.toml b/vektra-app/pyproject.toml
index a5c76358..b81f38e6 100644
--- a/vektra-app/pyproject.toml
+++ b/vektra-app/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "vektra-app"
-version = "0.5.0-dev"
+version = "0.5.0"
description = "FastAPI application assembly and startup validation (ARCH-015, ARCH-057)"
readme = "README.md"
requires-python = ">=3.12"
diff --git a/vektra-core/README.md b/vektra-core/README.md
index e0318dd0..8cb8f68e 100644
--- a/vektra-core/README.md
+++ b/vektra-core/README.md
@@ -4,4 +4,10 @@ RAG engine, LLM abstraction, conversation management, and API gateway.
This is the primary deployable component of the Vektra platform. It provides the FastAPI HTTP API, implements `QueryPipeline`, manages API key authentication, and coordinates with `vektra-ingest` and `vektra-index` for document ingestion and retrieval.
+## Source citations
+
+Every source in a query response carries a `document_name` field, joined from `source_documents.filename`, so consumers (the chatbot widget, analytics, manual inspection) display human-readable labels instead of chunk UUIDs. Soft-deleted documents (REQ-057) keep their citation with an `(archived)` suffix so traceability is preserved when an answer references content that has since been removed.
+
+The field propagates through both `SimpleQueryPipeline` and `AdvancedQueryPipeline`, in the JSON and SSE response paths.
+
See [architecture.md](../.s2s/architecture.md) for the component specification.
diff --git a/vektra-core/pyproject.toml b/vektra-core/pyproject.toml
index 500c77b1..55cc616f 100644
--- a/vektra-core/pyproject.toml
+++ b/vektra-core/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "vektra-core"
-version = "0.5.0-dev"
+version = "0.5.0"
description = "RAG engine, LLM abstraction, and conversation management"
readme = "README.md"
requires-python = ">=3.12"
diff --git a/vektra-index/README.md b/vektra-index/README.md
index bfc5a053..6231d0b7 100644
--- a/vektra-index/README.md
+++ b/vektra-index/README.md
@@ -1,7 +1,27 @@
# vektra-index
-Vector store abstraction and semantic search for the Vektra platform.
+Vector store abstraction, embedding generation, and semantic search for the Vektra platform.
-Implements the `VectorStoreProvider` protocol with pgvector as the Phase 1 backend and the `EmbeddingProvider` protocol using sentence-transformers.
+## Protocol implementations
+
+- **`VectorStoreProvider`** with two backends:
+ - **pgvector** (default, Phase 1): PostgreSQL extension. Single-deployable, no extra service.
+ - **Qdrant** (Phase 2, opt-in via `VEKTRA_VECTOR_STORE_PROVIDER=qdrant`): adds native dense + sparse + hybrid search modes via Qdrant's REST API. Includes the `qdrant` Docker Compose profile and the 11th startup validation step (collection check).
+- **`EmbeddingProvider`** with `sentence-transformers` (default model `paraphrase-multilingual-MiniLM-L12-v2`).
+- **`SparseEmbeddingProvider`** (Phase 2): `FastEmbedBM25Provider` via `fastembed` for BM25 sparse vectors. Activated via `VEKTRA_SPARSE_EMBEDDING_PROVIDER=fastembed-bm25`.
+
+## Search modes
+
+`SearchMode` enum (Phase 2):
+
+- `DENSE` — vector similarity only (Phase 1 behavior).
+- `SPARSE` — BM25-only (lexical match).
+- `HYBRID` — fusion of dense and sparse, native to Qdrant.
+
+The provider also exposes a `raw_filters` escape hatch for backend-specific metadata filtering, namespace isolation enforcement, and atomic index version switching for zero-downtime reindex.
+
+## Reindex
+
+Operator script `scripts/reindex.sh` triggers a full re-embedding into a new index version while serving from the active one. After completion, `VEKTRA_ACTIVE_INDEX_VERSION` is bumped atomically (provider-specific transaction).
See [architecture.md](../.s2s/architecture.md) for the component specification.
diff --git a/vektra-index/pyproject.toml b/vektra-index/pyproject.toml
index 2883ad1b..4e155f11 100644
--- a/vektra-index/pyproject.toml
+++ b/vektra-index/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "vektra-index"
-version = "0.5.0-dev"
+version = "0.5.0"
description = "Vector store abstraction and semantic search for the Vektra platform"
readme = "README.md"
requires-python = ">=3.12"
diff --git a/vektra-ingest/README.md b/vektra-ingest/README.md
index 61939008..813138ab 100644
--- a/vektra-ingest/README.md
+++ b/vektra-ingest/README.md
@@ -2,6 +2,33 @@
Document processing pipeline for the Vektra platform.
-Handles PDF, Word, and PowerPoint extraction via the `DocumentExtractor` protocol, text chunking via the `ChunkingStrategy` protocol, and background job execution via arq.
+Handles document extraction (`DocumentExtractor` protocol), chunking (`ChunkingStrategy` protocol), and background job execution via arq with PostgreSQL job persistence.
+
+## Extractors
+
+- **pdfplumber** (default): native text extraction from PDFs, DOCX, PPTX. No external runtime dependencies.
+- **Unstructured** (Phase 2, opt-in via the `INSTALL_UNSTRUCTURED=true` Docker build arg, not a runtime env var): adds OCR support for scanned PDFs (Tesseract + Poppler) and 10-class element classification (text, title, list, table, image, header, footer, figure caption, page break, formula). Heavier install footprint.
+
+Provider selected via `VEKTRA_DOCUMENT_EXTRACTOR=pdfplumber|unstructured`.
+
+## Chunking
+
+- **Fixed-size** (default): token-based chunks with configurable overlap. Predictable and fast.
+- **Dual strategy** (Phase 2): semantic splitting with table preservation, optional parent-child hierarchy. Activate via `VEKTRA_CHUNKING_STRATEGY=dual`.
+
+Sparse embeddings (Phase 2, BM25 via `fastembed`) are computed during ingestion when `VEKTRA_SPARSE_EMBEDDING_PROVIDER` is set, enabling hybrid search at query time.
+
+## Async ingest
+
+Files larger than 10 MB are processed asynchronously via arq. The ingestion endpoint returns a `job_id`; clients poll `GET /api/v1/ingest/jobs/{job_id}/status` until completion.
+
+The job row distinguishes:
+
+- **Status** — terminal-or-running state: `processing`, `indexed`, `failed`.
+- **Phase** — fine-grained progress while `status=processing`: `extracting`, `chunking`, `embedding`. Cleared once the job reaches a terminal status.
+
+Clients typically watch `status` to decide when to stop polling; `phase` and `percentage` drive progress UI.
+
+Batch operations (`POST /api/v1/ingest/batch`, batch deletion) and document version tracking are also implemented in Phase 2.
See [architecture.md](../.s2s/architecture.md) for the component specification.
diff --git a/vektra-ingest/pyproject.toml b/vektra-ingest/pyproject.toml
index 770e033c..44f61ea6 100644
--- a/vektra-ingest/pyproject.toml
+++ b/vektra-ingest/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "vektra-ingest"
-version = "0.5.0-dev"
+version = "0.5.0"
description = "Document processing pipeline for the Vektra platform"
readme = "README.md"
requires-python = ">=3.12"
diff --git a/vektra-learn/README.md b/vektra-learn/README.md
index 18ff2784..bf6ab315 100644
--- a/vektra-learn/README.md
+++ b/vektra-learn/README.md
@@ -4,7 +4,7 @@ E-learning vertical for the Vektra platform. Provides LMS-agnostic REST APIs for
## Components
-- **Python backend** (`src/vektra_learn/`): FastAPI router with enrollment, content ingestion, token, and query endpoints
+- **Python backend** (`src/vektra_learn/`): FastAPI router with enrollment, content ingestion, token, conversation, and query endpoints
- **Chatbot widget** (`widget/`): Vanilla JS chat interface built with esbuild
## Widget build
@@ -14,3 +14,78 @@ cd widget && npm install && npm run build
```
Output: `static/vektra-chat.js`
+
+## Widget integration
+
+The widget is loaded via a `
+```
+
+### Required attributes
+
+| Attribute | Description |
+|-----------|-------------|
+| `data-api-url` | Vektra base URL the widget calls (e.g. `https://vektra.example.com`). |
+| `data-course-id` | Stable course identifier; also used as the `sessionStorage` key for conversation persistence. |
+| `data-token` | JWT dashboard token issued by `POST /api/v1/learn/tokens` (server-side, by the LMS). |
+
+### Optional white-label attributes (FEAT-016)
+
+| Attribute | Default | Effect |
+|-----------|---------|--------|
+| `data-title` | "Course Assistant" / i18n | Chat panel header text and button aria-label. |
+| `data-primary-color` | `#2563eb` | Bound to CSS var `--vektra-primary`. Drives the floating button, accents, and links. Hover states use `filter: brightness()` so any custom color stays interactive. |
+| `data-icon` | speech-bubble emoji | Floating button icon. Accepts an emoji (rendered as text) or a URL (rendered as image). |
+| `data-welcome-message` | (none) | First assistant message shown when the widget opens. |
+| `data-powered-by` | `true` | When `false` (case-insensitive), hide the attribution footer. |
+| `data-powered-by-text` | "Powered by VektraLabs" | Override the footer text (plain text only). |
+| `data-powered-by-url` | `https://vektralabs.github.io` | Override the footer link target. |
+| `data-show-sources` | (server-resolved) | Client-side override for the citations block (FEAT-014). When absent, defer to the server-resolved value. When present, the trimmed lowercase value is compared to `"false"`: only that exact string hides the section; any other value (including `"true"`) forces it visible. Resolution chain: this attr > `namespaces.config.show_sources` > `VEKTRA_LEARN_SHOW_SOURCES` env > hardcoded `true`. |
+
+### Other attributes
+
+| Attribute | Default | Effect |
+|-----------|---------|--------|
+| `data-theme` | `light` | Color theme: `light` or `dark`. |
+| `data-language` | `en` | UI strings language: `en`, `it`. |
+| `data-token-refresh-url` | (none) | URL the widget POSTs to (with `credentials: same-origin`, no body) after a `401` response, to obtain a refreshed JWT (FEAT-009). The endpoint must return JSON `{"token": ""}`; any other shape, non-2xx status, or network error aborts the refresh and surfaces the original 401 to the user. |
+
+All `data-*` values are rendered via `textContent` (never `innerHTML`), and `data-primary-color` is validated against a safe whitelist, so XSS via attribute injection is neutralized.
+
+## Conversation persistence (FEAT-004)
+
+The widget keeps the active `conversation_id` in `sessionStorage` keyed by `course_id`, so reloading the page restores the chat history without leaking conversations across students on shared university computers (sessionStorage is tab-scoped, wiped on tab close — `localStorage` is intentionally not used).
+
+On widget init, if a stored conversation is present and not stale (24h cutoff), the widget calls `GET /api/v1/learn/conversations/{id}/turns` and replays the history before enabling input. The header "New chat" button resets the storage entry, the DOM, and the in-memory `conversation_id`. A `404` response from the turns endpoint (conversation deleted server-side) silently clears the entry; a `403` (different course/namespace) does the same.
+
+## Endpoints
+
+See [API reference > Learn](../docs/reference/api.md#learn) for full request/response shapes:
+
+| Method | Path | Auth | Purpose |
+|--------|------|------|---------|
+| `POST` | `/api/v1/learn/tokens` | API key (`admin`) | Issue a JWT dashboard token (called server-side by the LMS). |
+| `POST` | `/api/v1/learn/enrollments` | API key (`ingest`) | Register a student in a course (optional when `VEKTRA_LEARN_REQUIRE_ENROLLMENT=false`). |
+| `GET` | `/api/v1/learn/enrollments` | API key (`admin`) | List enrollments. |
+| `DELETE` | `/api/v1/learn/enrollments/{id}` | API key (`admin`) | Remove an enrollment. |
+| `POST` | `/api/v1/learn/content/ingest` | API key (`ingest`) | Trigger course-scoped ingestion. |
+| `POST` | `/api/v1/learn/query` | JWT | Course-scoped RAG query. Returns `show_sources` flag + `document_name` per source. |
+| `GET` | `/api/v1/learn/conversations/{id}/turns` | JWT | Replay decrypted conversation history (namespace-scoped; emits `learn_conversation_turns_read` audit row per NFR-007). |
diff --git a/vektra-learn/pyproject.toml b/vektra-learn/pyproject.toml
index b8fcb4d3..c355ff79 100644
--- a/vektra-learn/pyproject.toml
+++ b/vektra-learn/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "vektra-learn"
-version = "0.5.0-dev"
+version = "0.5.0"
description = "E-learning vertical: LMS-agnostic API and chatbot widget"
readme = "README.md"
requires-python = ">=3.12"
diff --git a/vektra-learn/widget/src/chat-ui.js b/vektra-learn/widget/src/chat-ui.js
index 7a2f9f08..d38a1f3b 100644
--- a/vektra-learn/widget/src/chat-ui.js
+++ b/vektra-learn/widget/src/chat-ui.js
@@ -192,8 +192,8 @@ export class ChatUI {
const footer = document.createElement("div");
footer.className = "vektra-chat-powered-by";
// If poweredByText is set, the whole footer is a single link with that
- // text. Otherwise the footer is " Vektra".
- // A blank or invalid poweredByUrl falls back to the Vektra default URL.
+ // text. Otherwise the footer is " VektraLabs".
+ // A blank or invalid poweredByUrl falls back to the VektraLabs default URL.
const url = _isSafeLinkUrl(this._poweredByUrl)
? this._poweredByUrl.trim()
: "https://vektralabs.github.io";
@@ -206,7 +206,7 @@ export class ChatUI {
link.textContent = this._poweredByText;
footer.appendChild(link);
} else {
- link.textContent = "Vektra";
+ link.textContent = "VektraLabs";
footer.textContent = `${this._lang.poweredBy} `;
footer.appendChild(link);
}
diff --git a/vektra-learn/widget/src/index.js b/vektra-learn/widget/src/index.js
index 038a924c..6f4ae561 100644
--- a/vektra-learn/widget/src/index.js
+++ b/vektra-learn/widget/src/index.js
@@ -30,7 +30,7 @@
* data-powered-by - "true" (default) shows the attribution footer,
* "false" hides it
* data-powered-by-text - overrides the footer text (plain text, no HTML).
- * Default: "Powered by Vektra" as a link.
+ * Default: "Powered by VektraLabs" as a link.
* data-powered-by-url - overrides the footer link target. Default:
* https://vektralabs.github.io
* data-show-sources - "true"/"false" client-side override for the
diff --git a/vektra-shared/README.md b/vektra-shared/README.md
index 6820cdce..fe5a454b 100644
--- a/vektra-shared/README.md
+++ b/vektra-shared/README.md
@@ -1,7 +1,25 @@
# vektra-shared
-Shared protocol interfaces and types used by all Vektra components.
+Shared protocol interfaces, configuration, and helpers used by all Vektra components.
-This package defines the 9 Protocol interfaces (LLMProvider, EmbeddingProvider, SparseEmbeddingProvider, VectorStoreProvider, DocumentExtractor, ChunkingStrategy, QueryPipeline, SafeguardHook, EventEmitter) and the shared data models that form the Vektra platform contract.
+This package is the platform contract: every other component depends on it, and no component depends on another (enforced by `import-linter`).
-See [architecture.md](../.s2s/architecture.md) for the full protocol specifications.
+## What lives here
+
+- **9 Protocol interfaces**: `LLMProvider`, `EmbeddingProvider`, `SparseEmbeddingProvider`, `VectorStoreProvider`, `DocumentExtractor`, `ChunkingStrategy`, `QueryPipeline`, `SafeguardHook`, `EventEmitter`. Each is a `typing.Protocol` so any conforming class is a valid implementation, no inheritance required.
+- **Shared data models** (`types.py`): `QueryRequest`, `QueryResponse`, `SourceRef`, `QueryTrace`, `SearchResult`, `Chunk`, `Conversation`, etc.
+- **Configuration** (`config.py`): `VektraSettings` (the single Pydantic source of truth, ARCH-060) plus sub-configs (`RewriteConfig`, `RerankConfig`, `WebhookConfig`).
+- **Auth primitives** (`auth.py`): `require_scope` FastAPI dependency, `ApiKeyInfo` model, scope-checking logic. `admin` is a project-wide superscope: a key with `admin` satisfies any `require_scope("X")` check.
+- **Errors** (`errors.py`): the REQ-010 error envelope, error code constants (`ERR_AUTH_*`, `ERR_INGEST_*`, `ERR_QUERY_*`, `ERR_ADMIN_*`, `ERR_LEARN_*`, ...), category-to-HTTP mapping, and `http_status_for()`.
+- **Audit** (`audit.py`): `log_event()` helper that writes a row into the audit log; called from every component on sensitive content access (NFR-007).
+- **Namespace config resolvers** (`namespace.py`) — async helpers that read `namespaces.config` JSONB once per request and fall back to a caller-supplied default. Both use raw SQL (ADR-0005, no ORM imports across packages) and return the default on any error (missing namespace, DB error, invalid value):
+ - `resolve_grounding_mode(namespace: str, session_factory: Any, default_mode: str = "strict") -> str` — returns `"strict"` or `"hybrid"`. Called by `vektra-core` and `vektra-learn` per query. The caller pre-resolves the env-var fallback (`VEKTRA_PROMPT_GROUNDING_MODE`) and passes it as `default_mode`.
+ - `resolve_show_sources(namespace: str, session_factory: Any, default_value: bool = True) -> bool` — FEAT-014. Called by `vektra-learn` per query; the caller pre-resolves the env-var fallback (`VEKTRA_LEARN_SHOW_SOURCES`) into `default_value`. `vektra-admin` does not use this helper directly: it computes the equivalent `resolved` view inline in `GET /api/v1/admin/namespaces/{id}/config` so the response can carry both stored and resolved values.
+- **`ProviderRegistry`** (`registry.py`): the runtime registry where `vektra-app` registers Protocol implementations and other components resolve them by name.
+- **Startup validation** (`startup.py`): two helpers consumed by `vektra-app`'s lifespan (steps 2 and 3 of the 11-step ARCH-057 sequence): `check_database_connectivity` and `check_database_schema`, plus the `StartupValidationError` exception. The remaining nine steps live inline in `vektra-app/main.py:lifespan` (config validation, pgvector extension, provider registration, embedding warmup, LLM connectivity, prompt template loading, analytics check, learn check, qdrant check); see `docs/architecture/index.md > Startup validation` for the full list.
+
+## Why it's a separate package
+
+ADR-0005 (module boundary enforcement). Every other component imports `vektra_shared` for types, config, and protocols, but **never** imports another component. This keeps the modular monolith honest and gives us a clean Phase 3 extraction path: components can move to separate repos individually because they share no code outside `vektra-shared`.
+
+See [.s2s/architecture.md](../.s2s/architecture.md) for the full protocol specifications.
diff --git a/vektra-shared/pyproject.toml b/vektra-shared/pyproject.toml
index 233c1246..9e8b7498 100644
--- a/vektra-shared/pyproject.toml
+++ b/vektra-shared/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "vektra-shared"
-version = "0.5.0-dev"
+version = "0.5.0"
description = "Shared protocols and types for the Vektra platform"
readme = "README.md"
requires-python = ">=3.12"