Skip to content
14 changes: 7 additions & 7 deletions .s2s/BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -1446,7 +1446,7 @@ For vertical deployments (e.g., a university running Vektra for their students),

### FEAT-014: Configurable source citation visibility

**Status**: draft | **Priority**: medium | **Created**: 2026-03-20
**Status**: completed | **Priority**: medium | **Created**: 2026-03-20 | **Completed**: 2026-04-22 | **PR**: pending
**Origin**: Moodle integration testing - source citations may not be appropriate for all courses

**Context**: The widget always displays source citations (document/chunk reference, relevance score, snippet) below each assistant response. Some instructors may prefer to hide them:
Expand All @@ -1467,12 +1467,12 @@ The API still returns sources in the response regardless of the flag (useful for

**Traceability**: ADR-0025, ARCH-063, FEAT-008

**Acceptance Criteria** (tentative):
- [ ] Global `show_sources` setting with default `true`
- [ ] Per-namespace override (metadata or JWT claim)
- [ ] Widget hides sources section when flag is `false`
- [ ] API response still includes sources regardless (no data loss)
- [ ] Moodle plugin exposes the setting in per-course block configuration
**Acceptance Criteria**:
- [x] Global `show_sources` setting with default `true` (`VEKTRA_LEARN_SHOW_SOURCES`)
- [x] Per-namespace override via `namespaces.config.show_sources` (writable through the existing `PATCH /api/v1/admin/namespaces/{id}/config` whitelist)
- [x] Widget hides sources section when flag is `false` (resolution chain: `data-show-sources` client override > server-resolved value > default `true`)
- [x] API response still includes sources regardless (no data loss)
- [ ] Moodle plugin exposes the setting in per-course block configuration — **deferred to the vektra-moodle sibling plan**

---

Expand Down
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

### Added

- **vektra-admin**: `PATCH /api/v1/admin/namespaces/{id}/config` endpoint for instructor configuration. Admin-scoped, whitelisted (v0.5.0 accepts `grounding_mode` only), partial updates, null removes a key. Backs the Moodle block form that lets teachers toggle strict/hybrid RAG per course without admin intervention.
- **vektra-admin**: `PATCH /api/v1/admin/namespaces/{id}/config` endpoint for instructor configuration. Admin-scoped, whitelisted (v0.5.0 accepts `grounding_mode` and `show_sources`), partial updates, null removes a key. Backs the Moodle block form that lets teachers toggle strict/hybrid RAG and source-citation visibility per course without admin intervention.
- **vektra-learn / widget**: configurable source citation visibility (FEAT-014). Resolution chain: `data-show-sources` attribute (client override) > `namespaces.config.show_sources` (per-course) > `VEKTRA_LEARN_SHOW_SOURCES` env var > default `true`. The learn query response (both JSON and the SSE `sources` event) now carries a `show_sources` flag the widget uses to decide whether to render the citations section. The API always returns the full sources list so analytics and QueryTrace keep complete data.
- **vektra-admin**: `GET /api/v1/admin/namespaces/{id}/config` symmetric to the PATCH. Returns `{namespace_id, config: <stored JSONB>, resolved: <effective after env defaults>}`. The `resolved` block is what the upstream plugin form (e.g. Moodle block edit) needs to render the "Use default" / "Override" toggle without re-implementing the fallback chain.
- **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).
Expand Down
35 changes: 33 additions & 2 deletions docs/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,36 @@ Returns HTTP 204 (no body).

## Namespaces

### GET /api/v1/admin/namespaces/{namespace_id}/config

Read a namespace's stored config and the effective values after env-default fallback. Symmetric to the PATCH endpoint below.

**Scopes**: `admin`

```bash
curl -s -H "Authorization: Bearer $VEKTRA_API_KEY" \
http://localhost:8000/api/v1/admin/namespaces/default/config | python3 -m json.tool
```

Response (HTTP 200):

```json
{
"namespace_id": "default",
"config": {"grounding_mode": "hybrid"},
"resolved": {
"grounding_mode": "hybrid",
"show_sources": true
}
}
```

- `config` mirrors the raw JSONB stored in `namespaces.config` (`{}` when no key has ever been set).
- `resolved` is the value queries actually observe at runtime, after the namespace > env > hardcoded-default chain. Always includes every key in the whitelist. Use this for "Use default" / "Override" form rendering in upstream plugins (e.g. the Moodle block edit form).

Errors:
- `404 ERR-ADMIN-005` if the namespace does not exist.

### PATCH /api/v1/admin/namespaces/{namespace_id}/config

Partially update a namespace's behavioral config (JSONB). Requires `admin` scope.
Expand All @@ -163,19 +193,20 @@ 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. |

Behavior:
- **Partial update**: keys not present in the body are preserved.
- **Unknown keys rejected** with `400 ERR-ADMIN-006` (not silently ignored — surfaces typos early).
- **Invalid values rejected** with `400 ERR-ADMIN-007`.
- **Invalid values rejected** with `400 ERR-ADMIN-007` (includes type mismatches, e.g. a JSON string passed for a boolean key).
- **Missing namespace** returns `404 ERR-ADMIN-005`.

Response (HTTP 200):

```json
{
"namespace_id": "default",
"config": {"grounding_mode": "hybrid"}
"config": {"grounding_mode": "hybrid", "show_sources": false}
}
```

Expand Down
139 changes: 137 additions & 2 deletions vektra-admin/src/vektra_admin/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,27 @@
# Tight whitelist: unknown keys are rejected (not silently ignored) so that
# typos from upstream clients (e.g. Moodle plugin) surface immediately. Each
# key is a public contract — extend cautiously.
ALLOWED_CONFIG_KEYS: set[str] = {"grounding_mode"}
#
# Validation model: every allowed key MUST be declared in exactly one of the
# two maps below.
# - ALLOWED_CONFIG_VALUES[key]: set of allowed values (enum-style key)
# - ALLOWED_CONFIG_TYPES[key]: required runtime type (type-validated key)
# ALLOWED_CONFIG_KEYS is derived (not hand-maintained) so a key cannot reach
# the whitelist without a corresponding validation rule. The disjoint check
# below fails at import if the same key is wired into both maps.
ALLOWED_CONFIG_VALUES: dict[str, set[str]] = {
"grounding_mode": {"strict", "hybrid"},
}
ALLOWED_CONFIG_TYPES: dict[str, type] = {
"show_sources": bool,
}
Comment thread
fvadicamo marked this conversation as resolved.
assert ALLOWED_CONFIG_VALUES.keys().isdisjoint(ALLOWED_CONFIG_TYPES.keys()), (
"Each config key must appear in exactly one of "
"ALLOWED_CONFIG_VALUES / ALLOWED_CONFIG_TYPES, never both."
)
ALLOWED_CONFIG_KEYS: set[str] = (
ALLOWED_CONFIG_VALUES.keys() | ALLOWED_CONFIG_TYPES.keys()
)

log = structlog.get_logger(__name__)

Expand Down Expand Up @@ -534,6 +551,111 @@ class NamespaceConfigResponse(BaseModel):
config: dict[str, Any]


class NamespaceConfigDetailResponse(BaseModel):
"""Full namespace config plus the effective values after env-default fallback.

*config* mirrors what is physically stored in ``namespaces.config``.
*resolved* is what queries actually see at runtime, after the resolvers
in :mod:`vektra_shared.namespace` apply the env-var fallback chain
(namespace JSONB > env var > hardcoded default). Consumers like the
Moodle block edit form use *resolved* to render the "Use default" /
"Override" toggle without having to re-implement the chain client-side.
"""

namespace_id: str
config: dict[str, Any]
resolved: dict[str, Any]


def _resolved_from_stored(
stored: dict[str, Any],
defaults: dict[str, Any],
) -> dict[str, Any]:
"""Apply the namespace > env > hardcoded-default chain to a loaded JSONB.

Operates on an already-loaded config dict so the GET endpoint can avoid
opening extra DB sessions (the standalone resolvers in
:mod:`vektra_shared.namespace` remain for callers that hold only a
namespace id).

Validation is derived from the module-level ``ALLOWED_CONFIG_VALUES`` /
``ALLOWED_CONFIG_TYPES`` maps and the result is built by iterating
``ALLOWED_CONFIG_KEYS``, so adding a new whitelist key automatically
flows through here without further edits — the only requirement is that
*defaults* provides an entry for the new key (assertion below).
"""
assert ALLOWED_CONFIG_KEYS <= defaults.keys(), (
"defaults must provide a fallback for every key in "
"ALLOWED_CONFIG_KEYS; missing: "
f"{sorted(ALLOWED_CONFIG_KEYS - defaults.keys())}"
)
resolved: dict[str, Any] = {}
for key in ALLOWED_CONFIG_KEYS:
raw = stored.get(key)
allowed_values = ALLOWED_CONFIG_VALUES.get(key)
allowed_type = ALLOWED_CONFIG_TYPES.get(key)
if allowed_values is not None and raw in allowed_values:
resolved[key] = raw
elif allowed_type is not None and isinstance(raw, allowed_type):
resolved[key] = raw
else:
resolved[key] = defaults[key]
return resolved


@router.get(
"/api/v1/admin/namespaces/{namespace_id}/config",
response_model=NamespaceConfigDetailResponse,
)
async def get_namespace_config(
namespace_id: str,
request: Request,
session: AsyncSession = Depends(get_session),
key_info: ApiKeyInfo = Depends(require_scope("admin")),
) -> NamespaceConfigDetailResponse:
"""Return the stored namespace config and the effective resolved values.

Behavior:
- 404 with ``ERR-ADMIN-005`` if the namespace does not exist.
- ``config`` is the raw JSONB (``{}`` when no key has ever been set).
- ``resolved`` is the value that queries observe, computed via the
same resolvers used by the runtime path. Always includes every
key in ``ALLOWED_CONFIG_KEYS``.
"""
from vektra_admin.models import NamespaceOrm # late import

result = await session.execute(
select(NamespaceOrm).where(NamespaceOrm.id == namespace_id)
)
ns = result.scalar_one_or_none()
if ns is None:
err = ErrorResponse(
category=ErrorCategory.PERMANENT,
code="ERR-ADMIN-005",
message=f"Namespace '{namespace_id}' not found.",
remediation="Verify the namespace id or create it via the admin UI.",
)
raise HTTPException(status_code=404, detail=err.to_envelope())

stored = dict(ns.ns_config or {})
resolved = _resolved_from_stored(
stored,
defaults={
"grounding_mode": getattr(
request.app.state, "grounding_mode_default", "strict"
),
"show_sources": getattr(
request.app.state, "learn_show_sources_default", True
),
},
)
return NamespaceConfigDetailResponse(
namespace_id=namespace_id,
config=stored,
resolved=resolved,
)


@router.patch(
"/api/v1/admin/namespaces/{namespace_id}/config",
response_model=NamespaceConfigResponse,
Expand Down Expand Up @@ -572,11 +694,12 @@ async def patch_namespace_config(
)
raise HTTPException(status_code=400, detail=err.to_envelope())

# --- Validate each value: must be in per-key enum or null (ERR-ADMIN-007) ---
# --- Validate each value: enum or runtime type per key, or null (ERR-ADMIN-007) ---
for key, value in body.items():
if value is None:
continue # null = remove the key
allowed_values = ALLOWED_CONFIG_VALUES.get(key)
allowed_type = ALLOWED_CONFIG_TYPES.get(key)
if allowed_values is not None and value not in allowed_values:
err = ErrorResponse(
category=ErrorCategory.PERMANENT,
Expand All @@ -587,6 +710,18 @@ async def patch_namespace_config(
),
)
raise HTTPException(status_code=400, detail=err.to_envelope())
if allowed_type is not None and not isinstance(value, allowed_type):
# isinstance(True, bool) is True and isinstance(1, bool) is False,
# so a JSON integer cannot impersonate a bool here.
err = ErrorResponse(
category=ErrorCategory.PERMANENT,
code="ERR-ADMIN-007",
message=f"Invalid value for '{key}': {value!r}.",
remediation=(
f"Value must be of type {allowed_type.__name__}, or null to unset."
),
)
raise HTTPException(status_code=400, detail=err.to_envelope())

# --- Load namespace (ERR-ADMIN-005 if missing) ---
result = await session.execute(
Expand Down
Loading
Loading