feat(knowledge): inline citations & sources + simplified embeddings config UX#468
feat(knowledge): inline citations & sources + simplified embeddings config UX#468dolev31 wants to merge 143 commits into
Conversation
Replaces the inline JSX block in KnowledgeConfig.tsx (lines 2678-2761,
93 lines) with a focused EnvPresetsPanel component (~165 lines) that
applies the 4-expert + product-lead synthesis from the redesign debate.
What changed (visible):
- "Quick setup from environment" -> "Detected in your environment"
- Subtitle prose deleted; replaced by a single Information tooltip
next to the section label
- Colored traffic-light dot -> 24x24 monogram (OA / OR / Wx / Az / Co)
on $layer-02
- Provider label + Information tooltip (model string moved here)
- Category Tag (Cloud / Enterprise) instead of model string in row
- "Apply" -> "Use" (verb-only; provider name is one column left)
- Disabled "Incomplete" button -> gray "Set up" Tag with Tooltip
listing the missing env vars
- Active preset -> blue "Active" Tag, no button (currentProvider +
currentModel prefix is the single source of truth)
- Bottom Link "Or run locally with Fastembed or Ollama - no keys
needed." focuses + scrolls to the Provider Select below
What changed (structural):
- EnvPresetsPanel.tsx extracted as a focused component with explicit
props {presets, currentProvider, currentModel, onApply,
onFocusProviderSelect}; KnowledgeConfig.tsx shrinks by 76 lines
- Carbon primitives: ContainedList + ContainedListItem + Tag +
Tooltip + Button kind="ghost" + Link + Information icon
- isPresetActive() handles the litellm shared-provider case by
comparing model prefix (so Watsonx / Azure / Cohere don't all
show Active simultaneously when one is selected)
- No new dependencies. No backend contract change.
What got ponytail-trimmed from the original 12-step plan:
- Storybook stories (no storybook in agentic_chat)
- RTL tests (no @testing-library; not adding a dep for one test)
- Snapshot test (same — no infra to lean on)
- Manual QA happens once #352 merges and this lands on main
Bundle rebuilt: main.93465aec5a842d6e178a.
NOT pushed yet — this branch is held locally until PR #352 merges,
then rebases to main and opens as a focused follow-up PR (per the
expert debate's shipping decision: clean review boundary beats
expanding #352's 32k-line review surface).
…e5-large, expose non-secret env values, +5 providers, alias support, stricter Active match
Backend (manage_routes.py):
- Watsonx default_model -> watsonx/intfloat/multilingual-e5-large
(multilingual coverage + stronger retrieval quality than ibm/slate-30m)
- Pipe-aliased required slots: "WATSONX_APIKEY|WATSONX_API_KEY" and
"WATSONX_URL|WATSONX_API_BASE" — accepts both spellings LiteLLM does.
Eliminates the watsonx special case in the loop.
- Reject angle-bracket placeholders (<your-key>) in env_set check.
- +5 providers: Gemini, Voyage AI, Mistral, Together AI, Jina AI.
Each ships hidden by the UI row filter until env vars are set.
- NEW response field env_values: dict[str, str] — non-secret detected
values (URL / project ID / region / etc.) for the UI to surface.
Credential material (KEY/APIKEY/TOKEN/SECRET/PASSWORD substring rule)
is filtered server-side and NEVER reaches the client.
Frontend (EnvPresetsPanel.tsx):
- EnvPreset interface extended with optional env_values.
- Per-row Information tooltip now shows Model + every detected
non-secret env value (envKeyLabel maps WATSONX_PROJECT_ID -> "Project
ID", WATSONX_URL -> "URL", AZURE_API_BASE -> "Endpoint", etc.).
Values truncated to 48 chars for compact display.
- Monograms for the new providers: Gm / Vy / Ms / Tg / Ja.
- isPresetActive tightened: non-litellm providers now require EXACT
model match (preset.default_model === currentModel). Previously a
user who picked "openai" via the Provider Select and typed a custom
model saw a misleading "Active" indicator. Litellm match (prefix)
is unchanged.
Provider-switching UX review (the scenarios I walked through):
1. Click "Use Watsonx" -> Watsonx row goes Active, OpenRouter row
becomes "Use" — coherent.
2. Click "Use OpenRouter" after #1 -> roles swap — coherent.
3. Provider Select "openai" with empty model -> NO preset shows
Active (exact match fails). Correct.
4. User customizes model on a preset provider -> Active deactivates,
"Use" reappears. Clicking "Use" overwrites custom model with
default (same as before — the panel's role is to bind to a preset).
Verified live against the user's actual .env:
- WATSONX_APIKEY (no underscore) detected via alias slot.
- env_values exposes WATSONX_URL + WATSONX_PROJECT_ID; APIKEY filtered.
- default_model is the new intfloat/multilingual-e5-large.
Bundle: main.020a715825345eb30db8.
Scope note: backend changes (default_model swap, env_values field, +5
providers) extend PR #352's surface area since the endpoint was
introduced there. They land on this stacked branch (#383) which
auto-retargets to main on #352 merge.
…on + canonical Watsonx var name Two changes to .env.example: 1. Fix the LLM section's Watsonx entry: WATSONX_API_KEY -> WATSONX_APIKEY (canonical name LiteLLM reads; the underscore variant is an alias but not the form documented in LiteLLM's IBM Watsonx provider docs). 2. Add an "Embedding Model — Knowledge retrieval" section before the trailing copy-instructions, listing every provider the Knowledge tab's "Detected in your environment" panel auto-discovers. Ten providers ordered alphabetically — Azure, Cohere, Gemini, Watsonx, Jina, Mistral, OpenAI, OpenRouter, Together AI, Voyage. Each block shows the canonical env var name(s) + a recommended default model. Watsonx no longer leads the embedding section — alphabetical order keeps it neutral so the file reads as "pick the provider you use" rather than "Watsonx is the recommended path." The new section sits below the optional features (Langfuse, OIDC, OpenLit, E2B, LLM timeout) and above the "Copy to .env" line, so the .env.example walks top-to-bottom from "required for LLM" through "optional add-ons" to "optional for embeddings" to "how to apply."
…ted; add "Modified" tag
The profile picker treated user edits to advanced settings as a reason
to DROP the named profile from the selected visual state. So:
pick Standard → edit chunk_size from 800 to 1000 →
Standard tile loses border + background highlight → user can't tell
which profile they're working from.
That misframes the relationship. The profile is a baseline; edits to
advanced settings are overrides on top of it, not a switch away from it.
Fix:
- ``isSelected`` is now ``isNamedProfile`` alone (matches the
``rag_profile`` field in config — the user's intent). Edits no
longer hide which profile is the baseline.
- New flag ``isModified = isNamedProfile && !vectorConfigMatches``
drives a small Carbon ``Tag type="cool-gray"`` reading "Modified"
inside the selected tile's name slot.
- In-tile hint when modified: "Your edits override profile defaults
— re-indexing will run on Publish." Surfaces the consequence at
the place the user made the change, not via a separate banner.
- The pre-existing "Requires re-indexing existing documents" hint on
NON-selected tiles is unchanged — that one informs the
would-be-switch decision before they click, not the
already-selected-with-edits state.
No behavior change to ``rag_profile`` semantics — it was already
preserved through ``onKnowledgeConfigChange`` (no auto-flip to
"custom"). The bug was purely visual selection logic.
Bundle: main.f58baaf8f09a27b92da3.
…d stale-bundle defense (1/4) Backend foundation for the config-apply-flow synthesis (issue #384 follow-up). Three small additions that make the existing draft+publish flow legible without changing its architecture: 1. **PATCH /api/manage/config/draft/knowledge response** now carries: - ``vector_config_hash``: server-computed hash of embedder + chunking + metric. The UI compares to the LIVE hash to render the "Draft saved · differs from Live" pill — server fact, not a client guess. - ``apply_generation``: monotonic counter (Slice B). Bumps only on supersede-relevant change. Distinguishes a successful apply from a no-op (chunking only) or a soft-failed preflight. - ``reindex_required``: boolean = (embedding OR chunking OR metric changed). Convenience so the UI doesn't AND the three flags itself. These were already computed server-side; we just stop swallowing them. 2. **``commit_knowledge_update``** re-exposes ``apply_generation`` and ``vector_config_hash`` in its result dict. (apply_generation was trimmed earlier as YAGNI per the Slice B ponytail pass; the UI now has a concrete consumer — restoring earns its keep.) 3. **``X-Cuga-Build-Id`` response header middleware** stamps every ``/api/manage/*`` and ``/api/knowledge/*`` response. Source order: env ``CUGA_BUILD_ID`` (container build) > ``git rev-parse --short=12 HEAD`` (local dev) > ``"dev"`` fallback. The webpack ``DefinePlugin`` injects the same value into ``CUGA_BUILD_ID`` on the client; ``apiFetch`` compares headers vs constant on every response and fires a one-time ``cuga:stale-bundle`` CustomEvent on mismatch (the app renders a forced-reload banner on this event in Phase 5). ``"dev" === any`` is an explicit non-mismatch — local-dev iterators shouldn't see the banner. Why this matters: the user just hit a real failure where they thought they'd switched to Watsonx but the live config still had bge-small. Three indistinguishable silent modes (no autosave / no Publish / stale bundle) were possible. Phases 2–5 (frontend) use these new server facts + the build-id event to make each state legible. Tests: 39/39 knowledge unit tests still pass. Part of the 14-change synthesis from the 6-expert config-apply-flow debate. Phases 3–5 (frontend state lift, Live pill, polish) follow in the next commits on this branch.
…CH lifecycle (2/4)
The literal bug the user just hit: clicking "Use Watsonx" showed
"Saved" after 1500ms regardless of whether the PATCH actually returned
or what status it carried. Three indistinguishable silent failure
modes (no autosave / no Publish / stale bundle) led to a real config
that thought it was Watsonx but actually still embedded with bge-small.
Fix per the 6-expert synthesis: lift the save status out of
KnowledgeConfig (where it was a setTimeout-driven lie) into ManagePage
(where the actual PATCH .then/.catch live), and pass it back down
as a controlled prop.
**ManagePage.tsx** — new ``draftSaveStatus`` state machine + wiring:
- { kind: "idle" } — no pending change
- { kind: "saving" } — set when the PATCH goes out (debounced 800ms)
- { kind: "saved", vectorConfigHash, applyGeneration, reindexRequired }
— on PATCH 2xx, carrying the SERVER-ECHOED
facts (hash + generation) for downstream
comparison with Live
- { kind: "failed", error } — on PATCH 422 / non-2xx / network error
The previously SILENT network-error catch at line ~1192 (literal
source of the user's confusion — "Network failure (real) — silent")
now flips the state to ``failed`` with a human-readable error.
**KnowledgeConfig.tsx** — accepts the new prop, removes the local
timer-driven ``saveState``:
- Old: ``setTimeout(() => setSaveState("saved"), 1500)`` — lied.
Deleted.
- New: ``saveState`` is derived from ``draftSaveStatus.kind``.
"Saved" pill auto-hides after 3s via ``recentlySaved`` flag so
the user doesn't see a permanent sticker.
- Added ``failed`` branch: red ghost "Couldn't save — Retry"
button with the server error as tooltip + onClick fires
``onRetryDraftSave``.
**Retry mechanism** — minimal: spread-copy ``knowledgeConfig`` to
re-trigger the autosave useEffect. No new state, no parallel pipeline.
What you'll see in the UI now:
- Click "Use Watsonx" → "Saving…" tag appears IMMEDIATELY (network
call in flight)
- PATCH returns 2xx → "Saved" appears, auto-hides in 3s
- PATCH returns non-2xx OR network drops → red "Couldn't save —
Retry" button persists until clicked or until next edit
- No more lying about saved state. Every visible state corresponds
to a real network event.
Bundle: main.b2e8c14f9ec1dfdab163.
Part of the 14-change synthesis from the 6-expert config-apply-flow
debate (issue #384 follow-up). Phase 1 (backend facts) shipped in
b5d22a4. Phases 4 (Live pill) + 5 (polish, including the misleading
"Reindex needed" banner deletion) follow.
…ftened reindex banner (3/4)
Phase 4 (Live pill) + the high-value Phase 5 polish items from the
6-expert config-apply-flow synthesis. Three changes:
**1. Live pill in the header — the truth anchor (Phase 4)**
New ``liveKnowledge`` state in ManagePage, sourced from
GET /api/manage/config (published=true) on mount and refreshed after
every successful Publish — never updated optimistically from draft
state. The pill renders in the existing manage-save-bar-status row
showing:
● Live: fastembed · BAAI/bge-small-en-v1.5 · v7
Dot color: green when ``draftSaveStatus.reindexRequired`` is false
(live matches the just-saved draft for vector-config), yellow when
the draft has changes the publish hasn't applied yet.
This is the single most-cited UX addition from the synthesis —
the user's failure mode ("I switched to Watsonx but logs show
bge-small") had no UI surface that answered "what is actually
running RIGHT NOW?" without log-reading. Now there is one, visible
on every Manage-page load.
**2. Stale-bundle banner — consumes the X-Cuga-Build-Id mismatch event (Phase 5)**
Listens for the ``cuga:stale-bundle`` CustomEvent that ``apiFetch``
dispatches when the server's X-Cuga-Build-Id header doesn't match
the webpack-injected client constant (both shipped in commit
b5d22a4). Renders a non-dismissable warning InlineNotification at
the top of the Manage page with a single Reload button.
We do NOT auto-reload — that would destroy in-progress edits. The
user chooses the moment.
This kills the third silent failure mode from the user's report:
"did I click the old JS that doesn't match the current API?"
**3. Softened "Re-index recommended" banner (Phase 5)**
The "most-cited source of confusion" from the synthesis: the
banner persisted alongside the new save-status pill, so users saw
both "Saved" AND "Re-index needed" simultaneously after an
auto-reindexed config change. We narrow the trigger condition
from:
(knowledgeReindexNeeded || knowledgeStale || knowledgeReindexDeferred)
to:
(knowledgeStale || knowledgeReindexDeferred)
Dropping the transient ``knowledgeReindexNeeded`` flag (snapshot
diff) — now redundant with the save-status pill that fires the
moment the user changes a vector-config field. The persistent
cases (knowledgeStale: vectors-under-old-config that survived a
failed prior reindex; knowledgeReindexDeferred: user explicitly
dismissed an earlier prompt) still surface the banner — the
safety net for genuinely stuck states stays.
New subtitle copy is more specific: "Some documents weren't
re-indexed after a prior config change. Run re-index to update
them." (Previously the generic "Settings changed. Existing
documents may use outdated embeddings." which sounded urgent
even when the system had already handled the change.)
Bundle: main.00fe751c7cd0c0cd311b.
Part of the 14-change synthesis from the 6-expert config-apply-flow
debate (issue #384 follow-up). Phases 1+2 backend foundation shipped
in b5d22a4; Phase 3 timer-kill shipped in c4deb33. Commit 4/4 to follow
covers the remaining smaller polish (Publish popover on non-trivial
reindex + preset-click debounce bypass).
…d PATCH-response fields Ladder pass on b5d22a4 + b5ec4b8: vector_config_hash + apply_generation in PATCH response → UI reads only reindexRequired (for Live pill dot color) → reindexRequired is derivable client-side from draft vs liveKnowledge → DELETE all three (server + client) apply_generation + vector_config_hash in engine.commit_knowledge_update return → no external consumer after the above → DELETE (restore the prior Slice B ponytail trim) X-Cuga-Build-Id middleware + webpack DefinePlugin + apiFetch listener + banner → cuga is a LOCAL single-user agent; server + bundle restart together → user's actual bug was the timer lie, not a stale bundle → YAGNI. DELETE across 4 files (~110 LOC) draftSaveStatus.saved payload (vectorConfigHash / applyGeneration / reindexRequired) → field-comparison can compute "diverged" locally from knowledgeConfig vs liveKnowledge → SIMPLIFY to { kind: "saved" } Live pill dot color → now compares embedding_provider + embedding_model directly → no server-side hash round-trip needed Net trim: 168 LOC across 6 files. The bug fix that earns its keep (killing the 1500ms timer + lifting saveStatus to PATCH lifecycle + Live pill in header + softened reindex banner) stays. The infra that was speculative scaffolding (build-id defense for a local agent; server-echoed hash for client-derivable comparison) is gone. What remains as the FULL fix for the user's reported bug: - "Saving…" / "Saved" / "Couldn't save — Retry" tag driven by the actual PATCH .then/.catch lifecycle - Live pill in header showing what's actually serving traffic - Yellow dot when draft.embedding_* differs from live.embedding_* - Softened "Re-index recommended" banner (kept for genuinely stuck states only) Bundle: main.32e5d102bb5fee28adee.
The 800ms debounce on the knowledge autosave coalesces rapid
keystrokes — but on an explicit "Use Watsonx" button click it just
adds visible lag before "Saving…" appears. Preset clicks are atomic
button presses with no coalescing benefit.
Mechanism — one ref, one signal:
- ``forceImmediateSaveRef`` lives in ManagePage alongside the
AbortController + other autosave refs.
- The EnvPresetsPanel ``onApply`` in KnowledgeConfig now calls
``onPresetApplied?.()`` BEFORE ``onKnowledgeConfigChange``, so
the ref bump lands before the autosave effect re-runs.
- The autosave effect reads + consumes the ref on entry:
``debounceMs = forceImmediateSaveRef.current ? 0 : 800``
then clears the ref. The next plain field edit goes back to
the keystroke-debounce path.
Plain text-input edits (adaptation_text, base_url, etc) still get
the 800ms coalesce — only explicit button clicks get the bypass.
Skipped the synthesis's "Publish confirm popover" item:
``handleSaveClick`` already opens setShowReindexConfirm when
``knowledgeReindexNeeded && knowledgeDocCount > 0``. Modal-vs-Popover
is a visual swap, not a missing feature — ponytail. Add when actual
UX testing flags the modal as too intrusive.
Bundle: main.fae14f56ad097a483d83.
Closes the last item from the 14-change config-flow synthesis.
…gging + console breadcrumb + 60s safety-net
User reported the autosave pill stuck on "Saving…" after switching to
LiteLLM + watsonx/intfloat/multilingual-e5-large. Server log line 1681
showed an empty-message exception:
ERROR | patch_draft_knowledge - Failed to patch draft knowledge:
Three issues attacked, none speculative — all in response to the
observed failure:
**1. Server: ``logger.exception`` instead of ``logger.error(f"...: {e}")``**
The old format swallowed everything when ``str(e)`` was empty
(some libs raise ``Exception()`` with no args). Now we get the
full traceback in the log, and the HTTPException ``detail`` falls
back to ``repr(e)`` so even an empty-message exception surfaces
its class name to the client.
**2. Client: console.error on non-2xx + read response body**
The autosave's else-branch (4xx/5xx without a 422 body) now logs
the status + first 200 chars of the response body to the browser
console, and includes the body in the failed-state error message.
When the user reports "stuck on Saving" we now have a breadcrumb
in dev tools to confirm the response did or didn't come back.
**3. Client: 60s safety-net timeout on "saving" state**
useEffect that fires when saveStatus transitions to "saving" and
schedules a fallback transition to "failed" if still saving after
60s. Covers the edge case where a response goes to an aborted
controller and no newer PATCH replaces the "saving" state — the
pill no longer sticks forever. 60s is generous for a local-backend
PATCH; beyond that means something's wrong worth surfacing.
Bundle: main.56c92335e9f35e92ebf4.
To reproduce the diagnosis: restart the backend so the new exception
logging takes effect, hard-refresh the browser to load the new bundle,
trigger the autosave failure again, share the full server log block
+ browser console output. The actual underlying exception class will
now be visible.
…auto-fire on PATCH)
User feedback: "a user might configure few things, and no need for automatic
reindexing every little change, we should show again the reindexing button
and clicking it will show the reindexing process".
Today's auto-reindex on PATCH fires the moment a single vector-config field
changes (embedder / chunking / metric). If a user opens Knowledge config to
tweak 3 things, they get 3 reindex passes — CPU burn + hyperactive UI. The
fix moves the trigger from "every PATCH that changed dim" to "the user
clicks Re-index when they're done editing".
Three coordinated changes:
**1. Backend (manage_routes.py) — gate auto-reindex off**
The dim_changed auto-reindex block in patch_draft_knowledge is deleted
(no more reindex during PATCH). Response still signals
``auto_reindex: {triggered: false, reason: "manual_required"}`` so the
UI knows where it stands. The migration logic (copy source files + drop
stale + reindex target + update knowledge_config_hash) is extracted
into ``_migrate_and_reindex_for_agent`` and exposed via a new endpoint.
**2. Backend (manage_routes.py) — new POST /api/manage/knowledge/reindex_for_config**
User-triggered. Computes the engine's current vector_config_hash,
finds source collections (old-hash dirs), copies files to the target
dir, drops + re-embeds with the active engine config, and updates
``app_state.knowledge_config_hash`` so searches/ingests go to the
migrated data. Returns the same ``{triggered, target, collections}``
shape the old auto-reindex returned — the frontend reindex-tile
arming code stays unchanged.
**3. Frontend (api.ts + ManagePage.tsx + KnowledgeConfig.tsx)**
- New API helper ``triggerKnowledgeReindexForConfig(agentId)`` calls
the new endpoint.
- ``onReindex`` routes to the new endpoint when
``knowledgeReindexNeeded`` (snapshot diff says vectors are stale
vs current config) and falls back to the plain ``/api/knowledge/reindex``
otherwise. The new-shape response is normalised so the same callsite
handles both.
- "Re-index recommended" banner trigger restored to include
``knowledgeReindexNeeded`` (which I'd previously narrowed away when
auto-reindex was making it redundant). The banner is now the user's
only signal that reindex is needed — restoring the trigger is the
visible half of this change.
- Subtitle copy updated: "Settings changed. Click Re-index to update
existing documents with the new configuration."
What the user sees now:
Open Knowledge config → pick Watsonx → "Saved" pill flashes →
"Re-index recommended" banner appears with [Re-index all documents]
button → user picks chunk_size → still just "Saved" + the banner
(no second reindex fires) → user finally clicks Re-index → progress
tile arms + workers run once with the final settings.
Tests: 38/38 knowledge unit tests pass. Bundle:
main.27c7b8c912fb8c43b279.
…he Re-index banner
User reported: pick Watsonx -> "Re-index recommended" shows -> revert
via Provider Select dropdown to the original provider -> banner STAYS
even though config is effectively back to where it started.
Root cause: the Provider Select onChange resets ``embedding_model``
to "" (alongside api_key/base_url/extra_params), but the saved
snapshot still holds the original model name like
"BAAI/bge-small-en-v1.5". The diff treats "" != "BAAI/..." as a
change and keeps the banner up — even though "" means "use provider
default" which IS the original.
Fix: normalise empty ``embedding_model`` as "no change" when the
provider matches the snapshot. Concretely:
providerChanged = current.provider !== saved.provider
modelChanged = providerChanged
? true
: current.model && current.model !== saved.model
banner_needed = providerChanged || modelChanged || chunking/metric changed
Result: picking Watsonx then reverting to fastembed via the dropdown
clears the model to "", but the provider matches saved -> modelChanged
is false -> banner hides. Honest match between UI state and engine
behaviour (engine picks the provider default for empty model).
Same trap doesn't apply to chunk_size / chunk_overlap (numeric, never
empty-string after edits).
…ralize the principle
User's principle: "it should work the same with every configuration that
effects the indexing — we should manage it efficiently". The previous
commit fixed embedding_model specifically; this generalises the pattern
into one place that any future index-affecting field can extend.
``isIndexConfigEquivalent(current, saved)`` lives next to
DEFAULT_KNOWLEDGE_CONFIG so the equivalence rule sits beside the field
shape it operates on. Returns true iff the two configs would produce
the same vector_config_hash on the server.
The rule, per field:
embedding_provider : strict equality
embedding_model : empty current on same provider == match saved
(Provider Select resets to "" on provider change;
engine resolves empty -> provider default at embed
time, so the snapshot's specific model and the
current empty are functionally the same)
chunk_size : strict equality (numeric, never empty after edits)
chunk_overlap : strict equality
metric_type : strict equality (Select with enum values)
If a future UI surface introduces another empty-string-on-reset for a
hash-relevant field, add the same empty-means-default branch in this
function. One file, one place.
The Re-index banner trigger collapses to:
changed = !isIndexConfigEquivalent(current, saved)
— no per-field branching in the useEffect, no risk of forgetting a
field. The principle is named, not implicit.
…eviewer pass
Three reviewers (2 UX experts + 1 Carbon designer) converged on a 6.3/10
"needs-refinement" verdict with a tight pre-client checklist. This commit
ships items 1–5 (the must-fixes that gate the demo); items 6–8 are
polish that can ship after.
**1. Lift EnvPresetsPanel out of Advanced (the one-chance moment)**
The env-presets panel was buried behind the off-by-default Advanced
toggle. For the enterprise client persona with a Watsonx key in .env,
this hid the single most reassuring moment ("we noticed your key"). Now
renders at the TOP of the knowledge-enabled section, above the
Retrieval Profile picker, gated on rows.length > 0. The Provider
Select inside Advanced is still the place for manual model entry;
``onFocusProviderSelect`` opens Advanced (if closed) and scrolls.
**2. Soften "Re-index recommended" banner**
Was kind="warning" + "danger--tertiary" button — alarming for a routine
config change. Now kind="info" + "tertiary" button, retitled from
"Re-index recommended" to "Update existing documents". The action is
routine; the framing now matches.
**3. Tooltip + cursor on Live yellow-dot state**
The Live pill's yellow dot (draft differs from Live) had no signal of
what it meant or what to do. Wrapped in Carbon ``Tooltip``: "Draft
differs from Live (now {provider} · {model}). Click Publish to apply."
Button-element trigger for keyboard reachability; cursor: help when
diverged.
**4. 60s safety-net → two-stage 25s/90s with in-flight abort**
The prior single-flip at 60s lied on slow corporate VPNs where 30–45s
saves are normal. New behaviour:
- 25s: pill softens to "Still saving — network is slow" (new
``saving-slow`` state in the DraftSaveStatus union). User
informed, not failed.
- 90s: aborts the in-flight controller AND flips to failed.
Abort prevents a stale-snapshot overwrite if the response
arrives later.
**5. Replace title-attr error with InlineNotification**
"Couldn't save — Retry" hid the server error in a native title
attribute (truncated to ~80 chars, invisible to most users). Now an
Inline ``InlineNotification kind="error"`` renders below the Retry
button with the FULL server error as subtitle. Close button on the
notification re-triggers the PATCH (same effect as clicking Retry).
What's deferred (polish items 6–8, ~50 min total):
- Second save pill at the Retrieval Profile section header
- Carbon vocabulary pass (Live pill as Tag, drop purple Enterprise tag)
- Delete the duplicate Re-index InlineNotification in ManagePage
Reviewer verdict was "ship after items 1–5". Now ready.
…g, drop duplicate banner, save pill at visible surface Three polish items from the pre-client review (items 6-8) + a small ponytail trim on the must-fix commit (15c5812). All optional-prop additions, fully backward-compatible for SDK consumers. **6. Save pill at the Retrieval Profile section header** The save pill previously lived inside the Advanced accordion next to Test connection — invisible during the most common edit path (clicking profile tiles). New instance renders inline next to the Retrieval Profile h4 so users see Saving/Saved/Couldn't-save feedback on the exact surface they're interacting with. ``recentlySaved`` keeps the green tag visible only 3s so it doesn't become a permanent sticker. **7. Carbon vocabulary pass** - Live pill: was a custom button-wrapped p+span with 30 lines of inline styles mimicking the plain text layout. Now a Carbon Tag (green in-sync / warm-gray diverged). Tooltip stays for the diverged case only — in-sync needs no explanation. Uses isIndexConfigEquivalent (the shared helper) so the Live-pill dot color and the Re-index banner trigger agree on what "diverged" means. - "Active" preset state: was blue → now green + Checkmark icon. Matches the "Saved" pill's green-with-Checkmark vocabulary — one visual signal for "this is the current state." - Enterprise category Tag: was purple, now outline (same as Cloud). Single muted category indicator instead of color-coded classification that fought the action-color vocabulary. **8. Delete duplicate Re-index InlineNotification on the agent card** ManagePage rendered "Re-index recommended" as kind="warning" right above the "Configure knowledge base" button — duplicated the in-modal notification the user sees when they actually open the config. Most- cited noise source in the pre-client review. Removed. The Live pill's yellow dot + tooltip already signal divergence from outside the modal; the actionable notification inside the modal is the right place for the Re-index button. **Ponytail cleanups on 15c5812**: - The Live pill's local ``diverged`` calc duplicated the rule that isIndexConfigEquivalent already encodes. Now uses the shared helper so the in-sync tooltip / yellow dot / banner trigger can't drift apart. - In-sync tooltip ("Live config matches your draft") deleted — the visible label already says that. Tooltip wraps the Tag only when diverged. - 30 lines of inline button styles to mimic plain text → Tooltip+button wrapper around a Carbon Tag. Lighter, idiomatic. **SDK backward compat verified**: - All new KnowledgePanelProps fields stay optional. Consumers that don't pass ``draftSaveStatus`` get ``saveState = "idle"`` → save-pill branches don't render → behavior matches pre-PR. - No new required props, no breaking type changes. - Carbon idiom switches affect only the existing KnowledgePanel consumer (ManagePage) — App.tsx in agentic_chat uses KnowledgeSidePanel (separate component), unaffected. Bundle: rebuilt. Reviewer's 8/8 checklist complete; #383 ready for client demo after the manual QA below.
Two user-feedback fixes:
**1. "Detected in your environment" lives in an Embedding model section**
The env-presets panel was floating above the Retrieval Profile picker
with no conceptual frame — visually surfaced but semantically loose.
Now wrapped in its own labeled section "Embedding model" placed
between Retrieval Profile and the Advanced toggle:
Knowledge Base toggle
Agent + Session scope tiles
Retrieval Profile (4 tiles) <- WHICH retrieval strategy
Embedding model (env-presets) <- WHICH embedder runs <-- new label
Advanced toggle <- manual override path
Heading + 1-line description tell the user what the section picks
("How your documents get embedded for retrieval") before the env-
presets panel renders. Renders only when ≥1 provider is detected
(otherwise a heading with no rows reads broken).
**2. Re-index copy: sharper, action-first**
The prior "Update existing documents" was reviewer-correct but user-
ambiguous — sounded optional when it's mandatory for the config
change to take effect on already-indexed documents. New copy leads
with the action:
Title: "Re-index to apply your changes"
Subtitle: "Existing documents still use the previous embedder. New
uploads will use the new configuration, but already-
indexed documents need a re-index to switch."
Button: "Re-index now" (kind="primary", was tertiary)
Primary-kind button because re-index IS the next step — making it
tertiary back when we softened the banner from kind="warning"
overcorrected. Tertiary kept the visual quiet but lost the call
to action. Primary restores it without the alarm-yellow surface.
…nfig change isn't blocked
User-reported edge case: after a successful re-index, the green "Re-
index complete" InlineNotification stays up until the user clicks X.
If they then change profile, the new "Re-index to apply your changes"
banner is suppressed because its trigger condition is
``!reindexProgress`` — and reindexProgress is still non-null while
the success banner shows.
Fix: auto-dismiss the success banner after 3.5s. The banner's X is
hidden on success too (closeButton only on failure, where the user
needs time to read the failed-count).
Failure path unchanged: ``failed > 0`` keeps the banner with X so the
user can dismiss after reading what failed.
Edge cases covered (per "all edge cases should be considered"):
- User changes profile mid-celebration → success auto-dismisses,
new "Re-index to apply" banner takes its place
- User clicks X during the 3.5s window → cleanup clears the timer
- User closes + reopens modal → reindexProgress is local state,
resets to null on remount, success can't re-fire
- User triggers another reindex during the window → new
reindexProgress with done=false; effect re-evaluates and skips
the dismiss timer
- Reindex completes with partial failures → banner stays until X
…n local config DB
Two protections for UI-entered secrets (API keys, tokens, passwords)
identified in the security audit after the UX review. Closes the
gaps an enterprise client would notice in a credential-storage demo.
**A. Redact secrets in GET /api/manage/config responses**
The prior GET response returned the full config including
``knowledge.embedding_api_key`` and ``llm.api_key`` in cleartext.
Anyone with HTTP access to the running server (default 127.0.0.1:7860,
no auth gate) could curl out keys typed via the UI.
New behaviour:
- ``_redact_secrets_in_config`` walks the response dict and replaces
every secret-named field with ``""``. Substring rule
(API_KEY / APIKEY / TOKEN / SECRET / PASSWORD) mirrors the env-
presets endpoint's ``_is_secret`` — both API surfaces agree on
what counts as secret.
- The engine doesn't depend on the GET API to read keys; it loads
from config_store on startup into in-memory state. So redaction
is purely a network-surface fix.
PATCH symmetry — preserve stored secrets when incoming is empty:
- The UI form re-saves with empty api_key after a redacted GET
populated the field. Naive merge would WIPE the stored value.
- The PATCH path now strips empty-string secret fields from the
incoming payload before merging — empty means "preserve stored"
not "clear stored". The user can still explicitly type a new
value to overwrite (their clear intent).
**B. chmod 600 on the local config SQLite DB**
``~/.cuga/cuga.db`` previously inherited the default umask (0o644,
world-readable on the host). It holds agent configs including UI-
entered embedding_api_key / llm.api_key in plaintext (no encryption
layer). Now pre-created (touch) with 0o600 before SQLite opens it,
so mode is set at creation not retroactively. Matches the
``.internal_token`` pattern already in use elsewhere.
Best-effort: catches OSError so Windows / network FS that don't
honor POSIX modes don't fail at startup. The OS layer enforces its
own ACLs there.
Smoke-tested:
- Redaction: ``embedding_api_key=sk-real`` → ``""`` in response;
non-secret fields (``embedding_base_url``) preserved.
- Preserve: empty incoming ``embedding_api_key`` + existing
``sk-stored-real`` → merged config keeps ``sk-stored-real``.
…from ALL source collections
**1. Env-presets panel relocated back inside the Embeddings accordion**
I'd misread the earlier "should be in the embeddings section" feedback
as "promote to its own visible section above Advanced." Now it lives
inside Advanced > Embeddings AccordionItem, where conceptually it
belongs (same scope as the manual Provider Select + API key + base
URL fields). Two modes of entry to ONE config: pick a detected preset
(env-presets) or fill the manual fields yourself.
The "Embedding model" h4 + 1-line description I'd added above Advanced
also went with this — the AccordionItem title "Embeddings" carries
the section name now.
**2. Migration copies from ALL non-target collections (bug fix)**
User-reported: had 5 documents listed in the Documents tab; clicked
Re-index; progress tile showed only 2 docs being indexed.
Root cause: ``_migrate_and_reindex_for_agent`` walked every
non-target collection dir but stopped after the FIRST successful
``copy_source_files``. Any docs in OTHER old-hash dirs (because the
user changed config more than once, or uploaded across two different
embedder states) were tagged ``orphan_stale`` and silently dropped.
Symptom on disk before this fix:
~/.cuga/knowledge/files/
kb_agent_cuga_default_<old_hash_A>/ ← 2 .pdf files (migrated)
kb_agent_cuga_default_<old_hash_B>/ ← 3 .pdf files (orphaned)
kb_agent_cuga_default_<new_hash_C>/ ← target, ends with only 2
Now the loop copies files from EVERY non-target collection into the
target dir BEFORE the single reindex pass. Filename collisions (same
PDF uploaded twice across configs) resolve to last-copy-wins via
shutil.copy2 — acceptable for the common case.
Also tightened: ``target_done`` now only flips true on a real
non-empty reindex result; ``no_documents`` (empty merged set) no
longer promotes ``knowledge_config_hash`` to point at a void.
|
Caution Failed to replace (edit) comment. This is likely due to insufficient permissions or the comment being deleted. Error details |
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/cuga/sdk.py (1)
2439-2466: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winFallback citation resolution bypasses the citations-disabled gate.
FinalAnswerNode.apply_citation_resolutiononly resolves via a live ledger wheneffective_citations_enabled(thread_id)is true, otherwise it strips markers (ledger=None). This fallback path (hit whenfinal_answeris empty, e.g. reasoning models) skips that check and always doesget_ledger(thread_id, create=False)— if a ledger already exists for the thread from an earlier turn, markers get resolved into display numbers even when citations were subsequently disabled for this thread/session, diverging from the primary path's documented and tested behavior.🐛 Proposed fix
from cuga.backend.knowledge.sources import ( + effective_citations_enabled, get_ledger, has_citation_markers, resolve_citations, ) if final_answer and has_citation_markers(final_answer): - ledger = get_ledger(thread_id, create=False) + ledger = ( + get_ledger(thread_id, create=False) + if thread_id and effective_citations_enabled(thread_id) + else None + ) final_answer, fallback_sources = resolve_citations(final_answer, ledger) else: fallback_sources = []🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/cuga/sdk.py` around lines 2439 - 2466, The fallback citation cleanup in src/cuga/sdk.py is bypassing the citations-disabled behavior used by FinalAnswerNode.apply_citation_resolution. Update the fallback path that extracts final_answer from the last AI chat message so it first checks effective_citations_enabled(thread_id) and only calls get_ledger(...)/resolve_citations when citations are enabled; otherwise strip citation markers or leave them unresolved to match the primary path’s behavior. Keep the fix localized around the fallback_sources/final_answer logic and reuse the same citation-resolution helpers already used by FinalAnswerNode.
🧹 Nitpick comments (10)
src/cuga/backend/knowledge/client.py (1)
240-260: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winExtract the repeated citation-annotation gate into a helper.
The
if citations_enabled_for(...): annotate_envelope_with_citations(...)block is duplicated verbatim between the multi-scope (_run_multi) and single-scope paths. Extracting a small helper (e.g._maybe_annotate_citations(envelope, results, config, thread_id, query)) would keep the gate logic in one place as citation rules evolve.♻️ Proposed refactor
+ def _maybe_annotate_citations(envelope, results): + if citations_enabled_for(self._engine._config, thread_id): + annotate_envelope_with_citations(envelope, results, thread_id=thread_id, query=query) + async def _run_multi(scope_requested: str, fallback_from: str | None) -> dict[str, Any]: ... envelope = build_retrieval_envelope(...) - if citations_enabled_for(self._engine._config, thread_id): - annotate_envelope_with_citations(envelope, results, thread_id=thread_id, query=query) + _maybe_annotate_citations(envelope, results) return envelope ... envelope = build_retrieval_envelope(...) - if citations_enabled_for(self._engine._config, thread_id): - annotate_envelope_with_citations(envelope, results, thread_id=thread_id, query=query) + _maybe_annotate_citations(envelope, results) return envelopeAlso applies to: 295-307
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/cuga/backend/knowledge/client.py` around lines 240 - 260, The citation-annotation gate is duplicated in the multi-scope and single-scope retrieval paths, so centralize it in a small helper to keep the rule in one place. Add a private helper near `_run_multi`/the single-scope equivalent, such as `_maybe_annotate_citations(envelope, results, thread_id, query)`, that checks `citations_enabled_for(self._engine._config, thread_id)` and calls `annotate_envelope_with_citations(...)`; then replace both inline `if` blocks with that helper.tests/unit/test_knowledge_reindex_busy_mapping.py (1)
20-37: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueMutable class-level defaults could leak state across instances.
_reindex_in_progress/_reindex_deferredare sharedset()objects at the class level; any instance mutation (.add()/.discard()) would be visible to every other instance/subclass unless overridden in__init__. Not exercised today, but worth an__init__assignment to avoid future cross-test leakage.♻️ Suggested fix
class _FakeEngineBase: - _reindex_in_progress: set = set() - _reindex_deferred: set = set() _files_dir = None + + def __init__(self): + self._reindex_in_progress: set = set() + self._reindex_deferred: set = set()🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/unit/test_knowledge_reindex_busy_mapping.py` around lines 20 - 37, The _FakeEngineBase test helper uses mutable class-level defaults for _reindex_in_progress and _reindex_deferred, which can leak state across instances and subclasses. Move these set initializations into an __init__ method on _FakeEngineBase so each instance gets its own fresh sets, and keep the existing _Config/vector_config_hash behavior unchanged for the reindex path being tested.Source: Linters/SAST tools
src/cuga/backend/knowledge/engine.py (1)
4193-4302: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value
zip()withoutstrict=Truein reindex worker.
zip(file_list, task_ids)(lines 4240, 4261) will silently truncate if the two lists ever diverge in length. Traced the construction:task_idsis appended 1:1 fromfile_listright before the worker is defined, and any exception during that loop aborts before the worker starts — so this can't currently misfire. Addingstrict=Trueis cheap defensive insurance against a future refactor breaking that invariant silently.♻️ Suggested tweak
- for fp, tid in zip(file_list, task_ids) + for fp, tid in zip(file_list, task_ids, strict=True)and similarly at the timeout-handling
zipon line 4261.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/cuga/backend/knowledge/engine.py` around lines 4193 - 4302, The reindex worker uses zip(file_list, task_ids) in two places, which can silently truncate if the lists ever diverge. Update the pairing logic in the reindex flow around _reindex_worker and the timeout cleanup to use a strict zip variant so mismatches fail fast instead of dropping entries, preserving the intended 1:1 mapping between file_list and task_ids.Source: Linters/SAST tools
tests/unit/conftest.py (1)
29-37: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueConsider logging the swallowed exception instead of silent pass.
Static analysis flags the bare
except Exception: passblocks. Since this fixture exists specifically to prevent state leaking across tests, silently swallowing an invalidate failure could hide the exact class of bug it's meant to guard against.♻️ Suggested tweak
try: facade.get_storage().invalidate_relational_stores() - except Exception: - pass + except Exception as e: + logger.debug(f"invalidate_relational_stores (setup) skipped: {e}") yield try: facade.get_storage().invalidate_relational_stores() - except Exception: - pass + except Exception as e: + logger.debug(f"invalidate_relational_stores (teardown) skipped: {e}")🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/unit/conftest.py` around lines 29 - 37, The fixture in conftest.py is swallowing failures from facade.get_storage().invalidate_relational_stores() with bare except blocks, which hides useful debugging context. Update the teardown/setup around the shared invalidate_relational_stores call to catch the exception and log it through the existing test logging mechanism (or re-raise after logging if appropriate), so failures are visible instead of being silently ignored.Source: Linters/SAST tools
tests/unit/test_knowledge_chunking_tokenizer_accessor.py (1)
154-163: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winReplace
assert Falsewithpytest.fail/raised assertion.Static analysis flags both: a blind
except Exception(BLE001) and twoassert Falsestatements (B011) that are stripped underpython -O, silently defeating the intended failure checks.♻️ Proposed fix
- try: - dataclasses.replace(tok, kind="hf") # via replace is fine - except Exception: - assert False, "replace must work on frozen dataclass" + dataclasses.replace(tok, kind="hf") # via replace is fine; let any error surface naturally # Direct mutation must fail. try: tok.kind = "hf" # type: ignore[misc] except dataclasses.FrozenInstanceError: return - assert False, "direct attribute mutation should have raised" + pytest.fail("direct attribute mutation should have raised")🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/unit/test_knowledge_chunking_tokenizer_accessor.py` around lines 154 - 163, The frozen-dataclass test in the token accessor check uses blind exception handling and assert False fallbacks; update the test body to use explicit failure signaling instead. In the test around dataclasses.replace(tok, kind="hf") and the direct tok.kind mutation, replace the broad except Exception and both assert False statements with pytest.fail or a raised assertion so failures remain visible under optimization and the intended checks are explicit.Source: Linters/SAST tools
src/cuga/backend/knowledge/sources.py (1)
352-362: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick winAdd
strict=Truetozip()to enforce the chunk-result correspondence invariant.The docstring states
envelope["results"][i]must correspond toresults[i], butzip(chunks, results)silently truncates on length mismatch. Ifenvelope.get("results")returnsNoneor a shorter list thanresults, some chunks would be left unstamped without any error.strict=Trueturns this silent failure into a loudValueError.♻️ Proposed fix
- for chunk, result in zip(chunks, results): + for chunk, result in zip(chunks, results, strict=True):🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/cuga/backend/knowledge/sources.py` around lines 352 - 362, Add strict=True to the zip in the citation stamping logic so the chunk/result pairing invariant is enforced. In the function that gets the ledger and iterates over chunks and results, change the `zip(chunks, results)` usage to a strict zip so a length mismatch raises immediately instead of silently truncating and leaving some chunks unstamped.src/cuga/backend/knowledge/routes.py (1)
760-771: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueMissing exception chaining (
raise ... from).Static analysis (Ruff B904) flags three
raise HTTPException(...)statements insideexceptblocks withoutfrom err/from None(Lines 760-771, 848-851, 862-863). This is a minor observability nit — without chaining, the original traceback context is dropped from logs, making it harder to debug the underlying cause (e.g. a malformed JSON body vs. a deserialization bug).♻️ Suggested fix (session settings excerpt)
try: body = await request.json() - except Exception: - raise HTTPException(status_code=400, detail="request body must be a JSON object") + except Exception as exc: + raise HTTPException(status_code=400, detail="request body must be a JSON object") from exc ... except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) + raise HTTPException(status_code=400, detail=str(exc)) from excAlso applies to: 848-863
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/cuga/backend/knowledge/routes.py` around lines 760 - 771, Add explicit exception chaining to the HTTPException raises inside the except blocks flagged by Ruff B904 in routes.py. Update the reindex/delete handler and the other HTTPException paths in the same module so the raised HTTPException preserves the caught exception context using the original exception variable (or suppresses it with from None where appropriate). Use the surrounding except blocks around ReindexInProgressError and the other affected route handlers to locate and fix all three raise sites consistently.Source: Linters/SAST tools
src/frontend_workspaces/frontend/src/carbon-chat/customSendMessage.ts (1)
487-575: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winWrap the
Answer/FinalAnswercase body in a block to clear the BiomenoSwitchDeclarationserrors.Biome flags the new
let/constdeclarations (answerSources,answerCompleteItem,answerGenericItems,sourcesItem,finalResponse) as errors because their scope leaks across the othercaseclauses. There is no runtime fallthrough today (the case ends inreturn), but these are lint errors that can fail CI. Wrapping the case body in{ }— as the siblingcustomLoadHistory.tsalready does — resolves them.♻️ Proposed block-scoping
case "Answer": - case "FinalAnswer": + case "FinalAnswer": { console.log("Received Answer event, finalizing message..."); let answerText = accumulatedText || ""; let answerSources: MessageSource[] = []; ... instance.messaging.addMessageChunk(finalResponse); console.log("Message finalized successfully"); return; // Exit after finalizing + }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/frontend_workspaces/frontend/src/carbon-chat/customSendMessage.ts` around lines 487 - 575, The Answer/FinalAnswer branch in customSendMessage’s switch needs block scoping to satisfy Biome’s noSwitchDeclarations rule. Wrap the entire case body in its own braces so the local declarations like answerSources, answerCompleteItem, answerGenericItems, sourcesItem, and finalResponse are scoped to that branch only, matching the pattern used in customLoadHistory.ts and preventing lint errors from leaking into other case clauses.Source: Linters/SAST tools
src/cuga/backend/server/conversation_history.py (1)
435-467: 🧹 Nitpick | 🔵 TrivialConsider scheduling
gc_ephemeral_stream_eventsperiodically, not just at startup.It currently only runs once during
lifespanstartup (main.py ~1042-1047). For long-running deployments, orphaned Try-It-Out rows will keep accumulating between restarts. Consider wiring it into the existing periodic background-task pattern (same style as the knowledge engine's background maintenance tasks).🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/cuga/backend/server/conversation_history.py` around lines 435 - 467, gc_ephemeral_stream_events is only invoked during startup, so orphaned Try-It-Out stream_events can accumulate in long-running deployments. Wire conversation_history.gc_ephemeral_stream_events into the existing periodic background-task mechanism used elsewhere (for example, the knowledge engine maintenance pattern) so it runs on a schedule, while keeping the existing startup call as-is if desired.src/cuga/backend/server/manage_routes/knowledge_routes.py (1)
95-108: 🩺 Stability & Availability | 🔵 Trivial | ⚖️ Poor tradeoff
asyncio.wait_forwon't stop the embed thread — a hung/unreachable endpoint leaks a shared-executor worker.On timeout,
wait_forcancels the future wrappingto_thread(_do_test), but the underlying thread keeps runningcreate_embeddings/embed_queryuntil the network call itself returns. Sinceto_threaduses the loop's defaultThreadPoolExecutor(shared with e.g.patch_draft_knowledge'sapply_knowledge_config), repeated Test-connection calls against a hanging host can pin all workers and stall unrelated request-thread offloads. Consider running_do_teston a dedicated single-worker executor for this endpoint, and/or documenting the leak so operators size the pool accordingly.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/cuga/backend/server/manage_routes/knowledge_routes.py` around lines 95 - 108, The timeout handling in the Test-connection flow does not actually stop the underlying embedding work, so a hung endpoint can keep a shared executor thread busy. Update the async test path in knowledge_routes’ endpoint around the _do_test / asyncio.wait_for / asyncio.to_thread call so it uses a dedicated single-worker executor (or equivalent isolated execution) instead of the loop default ThreadPoolExecutor, and keep the timeout response behavior intact. Reference the _do_test helper and the wait_for/to_thread block so the fix stays localized to this route and does not affect other offloaded work like apply_knowledge_config.Source: Linters/SAST tools
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@src/cuga/sdk.py`:
- Around line 2439-2466: The fallback citation cleanup in src/cuga/sdk.py is
bypassing the citations-disabled behavior used by
FinalAnswerNode.apply_citation_resolution. Update the fallback path that
extracts final_answer from the last AI chat message so it first checks
effective_citations_enabled(thread_id) and only calls
get_ledger(...)/resolve_citations when citations are enabled; otherwise strip
citation markers or leave them unresolved to match the primary path’s behavior.
Keep the fix localized around the fallback_sources/final_answer logic and reuse
the same citation-resolution helpers already used by FinalAnswerNode.
---
Nitpick comments:
In `@src/cuga/backend/knowledge/client.py`:
- Around line 240-260: The citation-annotation gate is duplicated in the
multi-scope and single-scope retrieval paths, so centralize it in a small helper
to keep the rule in one place. Add a private helper near `_run_multi`/the
single-scope equivalent, such as `_maybe_annotate_citations(envelope, results,
thread_id, query)`, that checks `citations_enabled_for(self._engine._config,
thread_id)` and calls `annotate_envelope_with_citations(...)`; then replace both
inline `if` blocks with that helper.
In `@src/cuga/backend/knowledge/engine.py`:
- Around line 4193-4302: The reindex worker uses zip(file_list, task_ids) in two
places, which can silently truncate if the lists ever diverge. Update the
pairing logic in the reindex flow around _reindex_worker and the timeout cleanup
to use a strict zip variant so mismatches fail fast instead of dropping entries,
preserving the intended 1:1 mapping between file_list and task_ids.
In `@src/cuga/backend/knowledge/routes.py`:
- Around line 760-771: Add explicit exception chaining to the HTTPException
raises inside the except blocks flagged by Ruff B904 in routes.py. Update the
reindex/delete handler and the other HTTPException paths in the same module so
the raised HTTPException preserves the caught exception context using the
original exception variable (or suppresses it with from None where appropriate).
Use the surrounding except blocks around ReindexInProgressError and the other
affected route handlers to locate and fix all three raise sites consistently.
In `@src/cuga/backend/knowledge/sources.py`:
- Around line 352-362: Add strict=True to the zip in the citation stamping logic
so the chunk/result pairing invariant is enforced. In the function that gets the
ledger and iterates over chunks and results, change the `zip(chunks, results)`
usage to a strict zip so a length mismatch raises immediately instead of
silently truncating and leaving some chunks unstamped.
In `@src/cuga/backend/server/conversation_history.py`:
- Around line 435-467: gc_ephemeral_stream_events is only invoked during
startup, so orphaned Try-It-Out stream_events can accumulate in long-running
deployments. Wire conversation_history.gc_ephemeral_stream_events into the
existing periodic background-task mechanism used elsewhere (for example, the
knowledge engine maintenance pattern) so it runs on a schedule, while keeping
the existing startup call as-is if desired.
In `@src/cuga/backend/server/manage_routes/knowledge_routes.py`:
- Around line 95-108: The timeout handling in the Test-connection flow does not
actually stop the underlying embedding work, so a hung endpoint can keep a
shared executor thread busy. Update the async test path in knowledge_routes’
endpoint around the _do_test / asyncio.wait_for / asyncio.to_thread call so it
uses a dedicated single-worker executor (or equivalent isolated execution)
instead of the loop default ThreadPoolExecutor, and keep the timeout response
behavior intact. Reference the _do_test helper and the wait_for/to_thread block
so the fix stays localized to this route and does not affect other offloaded
work like apply_knowledge_config.
In `@src/frontend_workspaces/frontend/src/carbon-chat/customSendMessage.ts`:
- Around line 487-575: The Answer/FinalAnswer branch in customSendMessage’s
switch needs block scoping to satisfy Biome’s noSwitchDeclarations rule. Wrap
the entire case body in its own braces so the local declarations like
answerSources, answerCompleteItem, answerGenericItems, sourcesItem, and
finalResponse are scoped to that branch only, matching the pattern used in
customLoadHistory.ts and preventing lint errors from leaking into other case
clauses.
In `@tests/unit/conftest.py`:
- Around line 29-37: The fixture in conftest.py is swallowing failures from
facade.get_storage().invalidate_relational_stores() with bare except blocks,
which hides useful debugging context. Update the teardown/setup around the
shared invalidate_relational_stores call to catch the exception and log it
through the existing test logging mechanism (or re-raise after logging if
appropriate), so failures are visible instead of being silently ignored.
In `@tests/unit/test_knowledge_chunking_tokenizer_accessor.py`:
- Around line 154-163: The frozen-dataclass test in the token accessor check
uses blind exception handling and assert False fallbacks; update the test body
to use explicit failure signaling instead. In the test around
dataclasses.replace(tok, kind="hf") and the direct tok.kind mutation, replace
the broad except Exception and both assert False statements with pytest.fail or
a raised assertion so failures remain visible under optimization and the
intended checks are explicit.
In `@tests/unit/test_knowledge_reindex_busy_mapping.py`:
- Around line 20-37: The _FakeEngineBase test helper uses mutable class-level
defaults for _reindex_in_progress and _reindex_deferred, which can leak state
across instances and subclasses. Move these set initializations into an __init__
method on _FakeEngineBase so each instance gets its own fresh sets, and keep the
existing _Config/vector_config_hash behavior unchanged for the reindex path
being tested.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: f3a68c7f-27f8-46d9-a55c-5875c929ce52
⛔ Files ignored due to path filters (4)
src/cuga/frontend/dist/index.htmlis excluded by!**/dist/**src/cuga/frontend/dist/main.5fd7de660b36475e3f99.jsis excluded by!**/dist/**src/cuga/frontend/dist/main.8f3239b467c01791bc29.jsis excluded by!**/dist/**src/cuga/frontend/dist/vendors.254bfd43ed6c401b5770.jsis excluded by!**/dist/**
📒 Files selected for processing (69)
docs/superpowers/plans/2026-07-06-knowledge-citations.mdscripts/build_frontend.shsrc/cuga/backend/cuga_graph/nodes/answer/final_answer.pysrc/cuga/backend/cuga_graph/nodes/answer/final_answer_agent/prompts/system.jinja2src/cuga/backend/cuga_graph/nodes/cuga_lite/adapter/prepare_node.pysrc/cuga/backend/cuga_graph/policy/enactment.pysrc/cuga/backend/cuga_graph/state/agent_state.pysrc/cuga/backend/cuga_graph/utils/agent_loop.pysrc/cuga/backend/knowledge/awareness.pysrc/cuga/backend/knowledge/client.pysrc/cuga/backend/knowledge/config.pysrc/cuga/backend/knowledge/engine.pysrc/cuga/backend/knowledge/envelope.pysrc/cuga/backend/knowledge/routes.pysrc/cuga/backend/knowledge/sources.pysrc/cuga/backend/server/conversation_history.pysrc/cuga/backend/server/main.pysrc/cuga/backend/server/manage_routes/__init__.pysrc/cuga/backend/server/manage_routes/config_routes.pysrc/cuga/backend/server/manage_routes/knowledge_reindex.pysrc/cuga/backend/server/manage_routes/knowledge_routes.pysrc/cuga/cli/main.pysrc/cuga/configurations/knowledge/knowledge_settings.tomlsrc/cuga/sdk.pysrc/frontend_workspaces/agentic_chat/package.jsonsrc/frontend_workspaces/agentic_chat/src/App.tsxsrc/frontend_workspaces/agentic_chat/src/CardManager.tsxsrc/frontend_workspaces/agentic_chat/src/KnowledgeConfig.tsxsrc/frontend_workspaces/agentic_chat/src/KnowledgeSidePanel.csssrc/frontend_workspaces/agentic_chat/src/KnowledgeSidePanel.tsxsrc/frontend_workspaces/agentic_chat/src/citations/MessageSources.tsxsrc/frontend_workspaces/agentic_chat/src/citations/SourcesPanel.csssrc/frontend_workspaces/agentic_chat/src/citations/SourcesPanel.tsxsrc/frontend_workspaces/agentic_chat/src/citations/citeElement.tssrc/frontend_workspaces/agentic_chat/src/citations/index.tssrc/frontend_workspaces/agentic_chat/src/citations/injectCitations.test.tssrc/frontend_workspaces/agentic_chat/src/citations/injectCitations.tssrc/frontend_workspaces/agentic_chat/src/citations/types.tssrc/frontend_workspaces/agentic_chat/vite.config.tssrc/frontend_workspaces/frontend/src/ChatLanding.tsxsrc/frontend_workspaces/frontend/src/ManagePage.tsxsrc/frontend_workspaces/frontend/src/api.tssrc/frontend_workspaces/frontend/src/carbon-chat/CarbonChat.tsxsrc/frontend_workspaces/frontend/src/carbon-chat/carbonChatHelpers.tssrc/frontend_workspaces/frontend/src/carbon-chat/customLoadHistory.tssrc/frontend_workspaces/frontend/src/carbon-chat/customSendMessage.tstests/integration/test_knowledge_citation_e2e.pytests/integration/test_knowledge_citation_e2e_live.pytests/unit/conftest.pytests/unit/test_citation_resolver.pytests/unit/test_citation_settings.pytests/unit/test_envelope_citations.pytests/unit/test_ephemeral_stream_events.pytests/unit/test_final_answer_citations.pytests/unit/test_knowledge_chunker_hf_tokenizer.pytests/unit/test_knowledge_chunking_tokenizer_accessor.pytests/unit/test_knowledge_cli_publish.pytests/unit/test_knowledge_reindex_busy_mapping.pytests/unit/test_knowledge_reindex_in_progress_guard.pytests/unit/test_knowledge_resplit_unit_mismatch.pytests/unit/test_knowledge_routes.pytests/unit/test_knowledge_text_splitter_tiktoken_canary.pytests/unit/test_knowledge_tokenizer_w5i1_synth_fixes.pytests/unit/test_manage_publish_sync.pytests/unit/test_sdk_citations.pytests/unit/test_server_citation_egress.pytests/unit/test_session_citation_override.pytests/unit/test_session_settings_routes.pytests/unit/test_source_ledger.py
- Per review on #410: scripts/build_frontend.sh duplicated the existing src/frontend_workspaces/frontend/build.sh (rm dist → prod build → copy into cuga/frontend/dist). Drop the duplicate and point the plan doc at build.sh.
- Resolves overlap with the merged reindex-UX PR (#410). Conflicts in knowledge_routes.py, KnowledgeConfig.tsx, ManagePage.tsx, and the reindex guard test all take the citations-branch version (newer adopt-only-when-empty logic + the embedder redesign that superseded the old provider UI); #410's non-conflicting reindex work is preserved. Bundle rebuilt from merged source.
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
docs/superpowers/plans/2026-07-06-knowledge-citations.md (2)
2203-2208: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winOpen the document window before awaiting the fetch.
Calling
window.openafterawait api.getKnowledgeDocumentFile(...)can be blocked by browser popup protection. Open a blank tab synchronously from the click handler, then assign its URL after the blob request succeeds.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/superpowers/plans/2026-07-06-knowledge-citations.md` around lines 2203 - 2208, Update the onOpenDocument handler to synchronously open a blank window before awaiting api.getKnowledgeDocumentFile, then assign the created object URL to that window after the fetch succeeds. Preserve the existing scope, filename, threadId arguments and successful return behavior.
2173-2183: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winResolve citation clicks against the owning message.
The listener currently falls back to the latest stored source set, so clicking a citation in an older message can open the wrong sources. Also,
event.targetis retargeted across shadow DOM; carry the message key inCITE_CLICK_EVENT.detailfrom<cuga-cite>and look up that exact entry. Add a two-message E2E case to verify this mapping.Also applies to: 2215-2220
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/superpowers/plans/2026-07-06-knowledge-citations.md` around lines 2173 - 2183, The CITE_CLICK_EVENT listener must resolve sources by the citation’s owning message instead of falling back to the latest entry. Pass the message key in the event detail from <cuga-cite>, use that key to look up lastSourcesByMessage.current, and preserve the citation number as activeN; add a two-message E2E case confirming each citation opens its own sources.
🧹 Nitpick comments (1)
docs/superpowers/plans/2026-07-06-knowledge-citations.md (1)
2425-2431: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winInclude integration tests in “full-suite verification.”
The listed commands run
tests/unitonly, despite the PR objectives explicitly requiring citation unit and integration coverage. Add the repository’s integration-test command or rename this step so it does not imply full-suite coverage.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/superpowers/plans/2026-07-06-knowledge-citations.md` around lines 2425 - 2431, Update “Step 2: Full-suite verification” to include the repository’s integration-test command alongside the existing unit-test command, ensuring citation integration coverage is exercised; alternatively, rename the step to accurately describe unit-only verification if no integration command is available.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@docs/superpowers/plans/2026-07-06-knowledge-citations.md`:
- Around line 2203-2208: Update the onOpenDocument handler to synchronously open
a blank window before awaiting api.getKnowledgeDocumentFile, then assign the
created object URL to that window after the fetch succeeds. Preserve the
existing scope, filename, threadId arguments and successful return behavior.
- Around line 2173-2183: The CITE_CLICK_EVENT listener must resolve sources by
the citation’s owning message instead of falling back to the latest entry. Pass
the message key in the event detail from <cuga-cite>, use that key to look up
lastSourcesByMessage.current, and preserve the citation number as activeN; add a
two-message E2E case confirming each citation opens its own sources.
---
Nitpick comments:
In `@docs/superpowers/plans/2026-07-06-knowledge-citations.md`:
- Around line 2425-2431: Update “Step 2: Full-suite verification” to include the
repository’s integration-test command alongside the existing unit-test command,
ensuring citation integration coverage is exercised; alternatively, rename the
step to accurately describe unit-only verification if no integration command is
available.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 23b23bc0-0bd2-4379-a17a-51b60471a680
⛔ Files ignored due to path filters (2)
src/cuga/frontend/dist/index.htmlis excluded by!**/dist/**src/cuga/frontend/dist/main.2ea10f484d495d5d953e.jsis excluded by!**/dist/**
📒 Files selected for processing (4)
docs/superpowers/plans/2026-07-06-knowledge-citations.mdsrc/cuga/backend/cuga_graph/nodes/cuga_lite/adapter/prepare_node.pysrc/cuga/backend/server/main.pysrc/frontend_workspaces/agentic_chat/src/KnowledgeConfig.tsx
🚧 Files skipped from review as they are similar to previous changes (3)
- src/cuga/backend/cuga_graph/nodes/cuga_lite/adapter/prepare_node.py
- src/cuga/backend/server/main.py
- src/frontend_workspaces/agentic_chat/src/KnowledgeConfig.tsx
|
@dolev31 can you check the rebase if needed? |
|
Thanks @sami-marreed — already rebased ✅ #468 now has #410's merged work integrated (merge Should be all set — would really appreciate your review whenever you get a chance 🙏 (and as flagged earlier, this one's an important customer-facing feature for us). Thanks! |
Resolves the conflicts blocking PR #468. ManagePage.tsx: adopted main's #465 refactor (save logic split into manage/hooks/*) as the base, then re-applied this branch's changes into their new homes rather than resurrecting the deleted monolithic save: - citations_enabled: kept in the knowledge interface + defaults - isDivergedFromLive(): kept as the single shared definition backing both the Live pill and the profile "Modified" tag - adopted-existing-collection fix moved to useKnowledgeDraftSave.ts — a draft autosave must not advance the reindex snapshot, or the "Re-index to apply your changes" banner flashes and vanishes - live embedding_model stored raw ("" = provider default) in usePublishConfig.ts; "(default)" is now display-only, so it can't corrupt the isIndexConfigEquivalent divergence check frontend/dist: rebuilt from the merged source (NODE_ENV=production) instead of hand-merging bundles. Both sides' vendors chunks were byte-identical under different content hashes; the rebuild yields one consistent main.*.js + index.html pair. Verified: tsc shows no new type errors vs either parent; 83 citation and knowledge unit tests pass.
reset_config_db() removed only cuga.db, leaving the -wal/-shm pair behind. WAL mode makes those three files one unit, so the orphaned sidecars still described pages the recreated database no longer had. The next connection read past EOF and raised SQLITE_IOERR_SHORT_READ, which Python surfaces as a bare "disk I/O error" — breaking `cuga start demo_knowledge` with no clue as to the cause. Any hard kill (crash, SIGKILL, Ctrl-C mid-write) strands the sidecars and poisons the following start. Remove all three files, and cover it with a regression test that fails against the old one-file delete.
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/frontend_workspaces/frontend/src/manage/hooks/useKnowledgeDraftSave.ts (1)
194-232: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winRetry budget is never reset for a genuinely new edit, causing premature failure.
knowledgeSaveRetryRef.currentis only reset to0on a successful save (Line 141) or via the parent's manual "Retry" click. If a save attempt exhaustsMAX_REINDEX_SAVE_RETRIES(20 × 3s ≈ 60s) against a still-runningreindex_in_progress, and the user then edits the config again while that same reindex is still in flight, the new debounce cycle re-enters this branch with the counter already at 20 — it immediately reports"Re-index still running — click Retry once it finishes."instead of giving the new edit its own retry window. The counter should be reset whenever the debounce cycle is triggered by an actual config change rather than by the retry nonce.🔧 Proposed fix: reset the retry budget when the attempted config actually changes
+ const lastAttemptedConfigRef = useRef<string>(""); + useEffect(() => { if (skipDraftSaveRef.current) return; if (knowledgeReindexing) { knowledgeAbortRef.current?.abort(); return; } knowledgeAbortRef.current?.abort(); const ac = new AbortController(); knowledgeAbortRef.current = ac; const debounceMs = forceImmediateSaveRef.current ? 0 : 800; forceImmediateSaveRef.current = false; + const serializedConfig = JSON.stringify(knowledgeConfig); + if (serializedConfig !== lastAttemptedConfigRef.current) { + knowledgeSaveRetryRef.current = 0; + } + lastAttemptedConfigRef.current = serializedConfig; + const t = setTimeout(async () => {🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/frontend_workspaces/frontend/src/manage/hooks/useKnowledgeDraftSave.ts` around lines 194 - 232, Reset knowledgeSaveRetryRef.current when a debounce cycle is triggered by a genuinely changed configuration, while preserving the existing counter for retry-nonce-driven reattempts. Update the effect or change-detection logic surrounding the save flow in useKnowledgeDraftSave so new edits receive a fresh MAX_REINDEX_SAVE_RETRIES budget without resetting it on scheduled retries.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@src/frontend_workspaces/frontend/src/manage/hooks/useKnowledgeDraftSave.ts`:
- Around line 194-232: Reset knowledgeSaveRetryRef.current when a debounce cycle
is triggered by a genuinely changed configuration, while preserving the existing
counter for retry-nonce-driven reattempts. Update the effect or change-detection
logic surrounding the save flow in useKnowledgeDraftSave so new edits receive a
fresh MAX_REINDEX_SAVE_RETRIES budget without resetting it on scheduled retries.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: ec4ed2b4-21c1-482c-8195-04049359ca4e
⛔ Files ignored due to path filters (2)
src/cuga/frontend/dist/index.htmlis excluded by!**/dist/**src/cuga/frontend/dist/main.aab7d1812e3bee202945.jsis excluded by!**/dist/**
📒 Files selected for processing (7)
src/cuga/backend/server/config_store.pysrc/cuga/sdk.pysrc/frontend_workspaces/frontend/src/ManagePage.tsxsrc/frontend_workspaces/frontend/src/api.tssrc/frontend_workspaces/frontend/src/manage/hooks/useKnowledgeDraftSave.tssrc/frontend_workspaces/frontend/src/manage/hooks/usePublishConfig.tstests/unit/test_config_db_reset_wal_sidecars.py
🚧 Files skipped from review as they are similar to previous changes (2)
- src/frontend_workspaces/frontend/src/api.ts
- src/cuga/sdk.py
offerakrabi
left a comment
There was a problem hiding this comment.
A personal note before the chewed up summary, this PR is too big, it is very hard to review (even for an LLM) and it is hard to track what issues/bugs/fixed are done in each file , ideally it should be split to at least 2 PRs (frontend and backend).
PR Review: #468 — feat(knowledge): inline citations & sources + simplified embeddings config UX
Summary
Verdict: merge with fixes.
This is a substantial, well-executed feature. The architecture is genuinely sound: the SourceLedger design — content-hash deduplication, thread-scoped registry, LRU eviction, and restart rehydration from persisted Answer events — is the right model for multi-hop, multi-turn citation stability and was clearly thought through carefully. The dual-seam stamping (in-process KnowledgeClient.search_envelope + HTTP POST /api/knowledge/search) is correctly placed and covers every execution path including MCP, E2B, and SDK. Citation resolution at FinalAnswerNode with bracket-family support (ASCII, fullwidth, lenticular, tortoise-shell) and a monitoring canary for unsupported styles is a smart hedge against model drift. The frontend <cuga-cite> component's use of the Popover API to escape CSS overflow clipping — with a correct CSS fallback for browsers without support — is technically sharp. Test coverage is extensive: 16 new test files, including deterministic integration tests against a real KnowledgeEngine/fastembed pipeline. The PR delivers exactly what #154 and #155 specified.
Strengths:
- Content-hash chunk identity (
sha1(scope|filename|page|sha1(text))) gives stablecite_idacross turns and across reindex without schema changes. resolve_citationsis correctness-first: code fence detection (fenced blocks, unterminated fences, double-backtick inline spans), explicit strip-mode for feature-off, and per-marker warnings only for real ledger misses.- The
_rehydrate_citation_ledgerhelper correctly gates on the session-awarecitations_enabled_for()predicate — not just the agent-level flag — so a per-session override can't cause counter collisions after a server restart. - Session settings endpoints (
GET/PATCH /api/knowledge/session/settings) are properly ownership-enforced and have full route-level tests. SourcesPanelquery-term highlighting is built with DOM text nodes (<mark>via React children), neverinnerHTML— hostile snippets are inert.- All new backend code runs
ruffclean and follows the establishedsources.pyno-engine-import discipline.
What to resolve before merge:
Major:
- All 13 new test files (9 unit + 4 integration) lack the required pytest type marker —
pytest -m unitsilently skips every new unit test and the fastembed integration tests run without a marker gate. See MAJ-1. apply_citation_resolutionclearsstate.sourceswhenfinal_answerhas no[sN]markers (already-resolved[1]...[n]text). The supervisor callback path (state.sender == CUGA_SUPERVISOR) can reach this with an already-resolvedlast_planner_answer, silently dropping correctly-resolved sources. See MAJ-2.- remove plan file (under
docs/superpowers/plans)
Minor:
4. SourcesPanel keys list items by s.n — per-message display numbers repeat (both message A and B have n=1). When the panel stays mounted and sources prop switches to a different message's set, React reuses key="1" DOM nodes, causing stale expanded/ref state. s.cite_id is the correct key. See MIN-1.
5. handleSessionCitationsChange in KnowledgeSidePanel only guards if (!threadId) before calling PATCH /api/knowledge/session/settings. If sessionScopeEnabled is false but threadId is non-empty, programmatic dispatch (automation/test tooling) bypasses the disabled checkbox and creates a phantom override entry for an inactive session. See MIN-2.
Nice to have:
6. customLoadHistory.ts unconditionally console.logs every event including full source snippets (filename, page, content) on reload. See NTH-1.
7. citeElement.ts Popover card position is computed once on mouseenter and not updated on scroll — card drifts off the chip if the user scrolls while hovering. See NTH-2.
Notes / out of scope:
- The three compiled dist bundles (
src/cuga/frontend/dist/*.js,index.html) are tracked in git and replaced by this PR. Not a finding introduced by this PR — this is pre-existing policy — but worth a decision: binary artifacts in git make diffs unreadable and cause guaranteed merge conflicts on concurrent frontend PRs. - 3 candidate findings were dropped after sub-agent verification: MIN-1 ("+N more" scroll target) was invalid — no upstream filtering exists; MIN-4 (TOCTOU in rehydration) was invalid —
get_ledger(create=True)holds its lock across both check and creation, making the race benign; NTH-1 (layering onapply_citation_resolution) was invalid — AGENTS.md's layering rule targets cross-node-layer deps, andknowledgeimports already exist in multiple other node files.
Issues
🟠 Major
[MAJ-1] All 13 new test files lack the required pytest type marker
tests/integration/test_knowledge_citation_e2e.py:119
What's wrong: AGENTS.md (project root) explicitly mandates that every test carry a registered pytest type marker (@pytest.mark.unit, @pytest.mark.e2e, @pytest.mark.slow, etc.) and states "Do not leave tests unmarked when a type marker applies." The 4 integration tests in test_knowledge_citation_e2e.py carry only @pytest.mark.asyncio — no type marker. All 9 new unit test files (test_source_ledger.py, test_citation_resolver.py, test_citation_settings.py, test_envelope_citations.py, test_final_answer_citations.py, test_session_citation_override.py, test_server_citation_egress.py, test_sdk_citations.py, test_session_settings_routes.py) carry no type marker at all. test_knowledge_citation_e2e_live.py correctly uses @pytest.mark.e2e — so the author knows the convention.
What it can cause: pytest -m unit skips all 9 new unit tests silently — a unit-only CI gate passes while the entire citation test net goes unexercised. pytest -m "not slow" picks up the fastembed-backed integration tests, which drive a real KnowledgeEngine, embed documents, and can take 30+ seconds — causing unexpected slowness in a supposedly-fast suite. The convention is explicit in AGENTS.md; callers who copy these files as templates will perpetuate the gap.
[MAJ-2] Supervisor callback path clears already-resolved state.sources via a second call to apply_citation_resolution
src/cuga/backend/cuga_graph/nodes/answer/final_answer.py:168-170
What's wrong: apply_citation_resolution is documented to be destructive when called on already-resolved text: "The second run sees no [sN] markers in the already-resolved text and clears state.sources." Lines 99-102 confirm this — if final_answer has no [sN] markers, state.sources = [] and return. In the supervisor callback path at lines 167-170: when state.final_answer is empty but state.last_planner_answer holds an already-resolved answer (e.g., CugaLite resolved [s1] → [1] and set sources before the supervisor transition), answer_to_forward = state.last_planner_answer is non-empty, the if branch executes at line 168, state.final_answer is set to that already-resolved text, then apply_citation_resolution is called — which sees no [sN] markers and wipes state.sources.
What it can cause: A CugaLite subgraph run that correctly resolved [s1] → [1] and set state.sources = [{n:1, cite_id:"s1", filename:"report.pdf", ...}] before the supervisor transition arrives at FinalAnswerNode with state.final_answer="" and state.last_planner_answer="The answer is 42 [1].". Lines 167–170 fire, apply_citation_resolution sees "The answer is 42 [1]." (no [sN] markers), sets state.sources = [], and the user receives an answer with [1] in the text but no clickable chips and no Sources panel. The test test_supervisor_empty_fallback_clears_stale_sources only verifies the desired clearing when both final_answer and last_planner_answer are empty; this path is untested.
🟡 Minor
[MIN-1] SourcesPanel keys list items by s.n — per-message display numbers repeat across messages, causing stale React DOM when the panel updates to a different message's sources
src/frontend_workspaces/agentic_chat/src/citations/SourcesPanel.tsx:82
What's wrong: {items.map((s) => (<div key={s.n} ...>))} uses s.n as the React reconciliation key. Display numbers are 1-based per message — message A and message B both have a source {n:1}. Both CarbonChat.tsx and App.tsx keep SourcesPanel mounted persistently (rendered conditionally via sourcesPanel && / sourcesView &&) and update the sources prop when a different message's chip is clicked. When the prop switches from A's sources to B's sources, React sees key="1" in both renders and reuses the existing DOM node instead of replacing it.
What it can cause: User clicks chip [1] in message A (filename: "report.pdf", snippet: "...Q4 revenue..."). Panel opens. User then clicks chip [1] in message B (filename: "handbook.docx", snippet: "...onboarding..."). React patches the existing DOM node keyed n=1: the activeRef does not re-fire (ref callback only fires on mount), scrollIntoView does not run, and the expanded state in SourcesPanel at line 47 — keyed by s.n — carries over. Users see stale expand state for "report.pdf" applied to "handbook.docx". Using s.cite_id (e.g., "s3" vs "s7") as the key is unique across all messages and fixes this.
[MIN-2] handleSessionCitationsChange in KnowledgeSidePanel calls the API even when the session scope is disabled
src/frontend_workspaces/agentic_chat/src/KnowledgeSidePanel.tsx:132-143
What's wrong: The handler guards only if (!threadId) return at line 133 before calling api.patchSessionKnowledgeSettings. The checkbox carries disabled={!conversationReady} where conversationReady = sessionScopeEnabled && Boolean(effectiveThreadId). In normal browser use a disabled checkbox cannot fire onChange, but the disabled attribute does not prevent programmatic dispatch (Cypress cy.get(…).click({force:true}), Playwright .dispatchEvent('change'), or direct element.dispatchEvent(new Event('change'))). When sessionScopeEnabled is false but threadId is non-empty, the !threadId guard passes, and the PATCH fires against a session that is not active.
What it can cause: An automated test or accessibility tool forces a click on the disabled citations toggle when session knowledge is turned off. PATCH /api/knowledge/session/settings fires with {citations_enabled: false} for that threadId. _apply_session_settings_patch creates a SessionKnowledgeState entry with overrides: {citations_enabled: false}. Later, when session knowledge is re-enabled for that agent, the stale override is loaded for the thread and citations silently start disabled, inconsistent with the agent-level default of true.
🔵 Nice to Have
[NTH-1] customLoadHistory.ts unconditionally logs every event including full citation source data to the browser console
src/frontend_workspaces/frontend/src/carbon-chat/customLoadHistory.ts:79,87,206
What's wrong: Lines 79, 87, and 206 call console.log(...) unconditionally, including logging each full event object on line 87. These log calls pre-date this PR, but the PR adds sources payloads (filename, page, snippet, score, query) to Answer events, so reloading a conversation now logs document content to the console.
What it can cause: In a production deployment any user who opens DevTools on history load can see the full source snippets and filenames of every cited document in that conversation. This may expose document content to users who should not have direct file access (they have search access through the agent, not raw read access). It also produces log noise that buries real errors.
[NTH-2] citeElement.ts Popover card position is computed once on hover enter — card drifts from the chip if the user scrolls while the card is visible
src/frontend_workspaces/agentic_chat/src/citations/citeElement.ts:101-105
What's wrong: place() computes left/top from this.getBoundingClientRect() once on mouseenter / focusin. The Popover API places the card in the browser top layer at position:fixed, relative to the viewport. If the user scrolls the page while the card is visible (e.g., in long answers with many paragraphs), the chip scrolls but the card stays fixed at its original coordinates.
What it can cause: On a long answer the user hovers chip [1] near the bottom, the card pops up above it. The user scrolls up slightly to read context. The chip moves up 50px but the card remains at the original top-layer coordinate, floating in empty space. Minor cosmetic issue; the card dismisses on mouseleave, which fires as soon as the pointer leaves the chip, so practical exposure is limited to the brief scroll-while-hovering window.
Test Suite Audit
114 tests across 14 new files. The suite is well-designed — 110 tests are fully justified. 4 should be cut, 2 collapsed into 1.
Cut these 4 tests
| File | Test | Why |
|---|---|---|
test_source_ledger.py |
test_get_unknown_returns_none |
Returns None on a cache miss — covered implicitly by every test that checks a non-existent ID |
test_source_ledger.py |
test_get_ledger_without_create_returns_none_on_miss |
Tests that create=False doesn't auto-create — covered implicitly by the thread isolation test |
test_sdk_citations.py |
test_enable_citations_param_stored |
Verifies agent._enable_citations = False after CugaAgent(enable_citations=False) — testing Python assignment, not behavior |
test_sdk_citations.py |
test_enable_citations_defaults_to_none |
Verifies omitting a param gives None — testing Python default argument semantics |
Reduce these 2 into 1
test_footer_single_entry_with_page and test_footer_single_entry_no_page in test_server_citation_egress.py both call _format_sources_footer with a one-element list, differing only in whether page is an int or None. Collapse into a single @pytest.mark.parametrize. Keep test_footer_page_zero_is_included standalone — page=0 is a real edge case (falsy in Python, must still render).
Everything else: keep
The remaining 110 tests all pin real, non-obvious behavioral invariants: the SourceLedger eviction policy, the regex edge cases in resolve_citations (CJK brackets, unterminated fences, double-backtick spans), the rehydration counter-bump regression, the HTTP ownership enforcement, and the end-to-end fastembed pipeline that caught the lenticular-bracket regression that unit tests with hand-seeded ledgers missed.
No tests were found that test the wrong thing (implementation details rather than behavior), and no assertion was found that would survive a real bug.
The audit also confirmed that test_session_citation_override.py (3 tests) should be kept despite appearing redundant with test_session_settings_routes.py — the helper-level tests verify the allowlist filtering and string coercion in isolation; the route tests exercise the full HTTP stack on top of it. Both layers are worth pinning.
The primary CI concern is not the count — it's the missing @pytest.mark type markers (MAJ-1). Without them, pytest -m unit silently skips all 9 new unit test files.
| @@ -0,0 +1,2484 @@ | |||
| # Knowledge Citations & Sources — Implementation Plan | |||
There was a problem hiding this comment.
this file should not be committed, please remove
…istory
Fixes citation mis-attribution: a cite_id from an earlier turn could resolve
in a later turn's answer, pointing at the wrong document (a scholarship
answer cited an AppWorld-benchmark chunk from a prior question). Root cause:
generated code stripped provenance from derived text, and the model then
attached a stale, still-resolvable id from the thread ledger.
Turn scoping (the fix for the symptom)
SourceLedger tracks which cite_ids were retrieved THIS turn; resolve_citations
strips any marker not in that set (distinct log lines: "not in ledger" for a
hallucinated id vs "not from this turn's retrieval" for a stale one). An
earlier-turn id no longer resolves unless it is re-retrieved this turn, so the
answer renders correct-and-uncited instead of correct-and-wrong.
Turn boundaries are reset at the two real entry points, both gated on a
genuinely NEW user turn:
- server event_stream via _rehydrate_citation_ledger(is_resume=...): a HITL
resume (tool approval / clarifying answer) re-enters the stream but is the
SAME turn — begin_turn must NOT run there, or the post-resume answer loses
the citations from the pre-interrupt search. (adversarial-review blocker)
- SDK invoke() via begin_ledger_turn(): the SDK bypassed the server hook
entirely, so cross-turn resolution still happened there. (review gap)
Durable event history (Fix 2, subsumes Fix 3)
event_stream reset stream_events_buffer=[] every turn and the save does
UPDATE ... SET events=?, so the persisted row was clobbered down to the last
turn only — breaking conversation reload (the frontend replays stream_events
first) and cross-turn citation rehydration. Seed the buffer from persisted
events so each turn APPENDS with a continuous sequence. This also delivers
integrity: rehydration now sees every turn's cited sources, so the id counter
stays above all issued ids and can never recycle one to different content.
Prompt contract updated to match: use only THIS turn's ids (the "earlier turns
remain citable" promise would now contradict the runtime).
Reuse (citing a past source without re-retrieving) is intentionally NOT enabled
here — it needs content verification or provenance binding; tracked as epics.
Verified: 643 citation/knowledge/stream/session tests pass, including new
regression tests for HITL-resume scope preservation, the SDK boundary, and
cumulative persistence.
offerakrabi flagged the 2484-line superpowers plan doc as not-for-commit. It was a working design note, not product docs.
|
Removed the committed plan doc in 963247c — thanks @offerakrabi. It was a working design note, not product docs; won't be re-added. |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
src/cuga/backend/server/main.py (1)
214-227: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winRedundant DB read of the same stream-events row on new/restarted threads.
_seed_stream_events_buffer(called at Line 1536) and_rehydrate_citation_ledger's internal fetch (Lines 271-272) both callget_conversation_db().get_stream_events(app_state.agent_id, thread_id, user_id)for the identical(agent_id, thread_id, user_id)within the same turn. The second fetch only happens when the in-memory ledger is absent (first turn of a thread, or any turn right after a server restart), but that's still a common hot path, and it duplicates a query whose result (stream_events_buffer) is already sitting in scope at the call site (Line 1537).Consider threading the already-fetched buffer into
_rehydrate_citation_ledgerinstead of re-querying:♻️ Reuse the seeded buffer instead of re-fetching
async def _rehydrate_citation_ledger( - app_state: "AppState", thread_id: str, user_id: str, is_resume: bool = False + app_state: "AppState", thread_id: str, user_id: str, is_resume: bool = False, + events_list: list | None = None, ) -> None: ... _ledger = _get_ledger(thread_id, create=False) if _ledger is None: - conversation_db = get_conversation_db() - stream_history = await conversation_db.get_stream_events(app_state.agent_id, thread_id, user_id) - events_list = stream_history.events if stream_history else [] + events_list = events_list or [] _ledger = _get_ledger(thread_id) # create for ev in events_list: - if ev.event_name != "Answer": + name = ev.get("event_name") if isinstance(ev, dict) else ev.event_name + if name != "Answer": continue try: - payload = json.loads(ev.event_data) + data = ev.get("event_data") if isinstance(ev, dict) else ev.event_data + payload = json.loads(data)And at the call site (Line 1598):
- await _rehydrate_citation_ledger(app_state, thread_id, user_id, is_resume=bool(resume)) + await _rehydrate_citation_ledger( + app_state, thread_id, user_id, is_resume=bool(resume), events_list=stream_events_buffer + )Also applies to: 269-276
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/cuga/backend/server/main.py` around lines 214 - 227, Update _rehydrate_citation_ledger to accept the already-seeded stream_events_buffer as an argument and use it when reconstructing the citation ledger, removing its duplicate get_stream_events database query. At the call site near the stream buffer initialization, pass the buffer returned by _seed_stream_events_buffer while preserving existing behavior when the buffer is empty or the in-memory ledger already exists.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/cuga/backend/server/main.py`:
- Around line 253-254: Update the ledger initialization flow so the resume path
skips only begin_turn(), not ledger rehydration. In the logic around
_get_ledger(thread_id, create=False), continue loading or restoring the existing
ledger when thread_id is present even when is_resume is true, while retaining
the early return for missing thread IDs and preserving normal begin_turn()
behavior for non-resume requests.
---
Nitpick comments:
In `@src/cuga/backend/server/main.py`:
- Around line 214-227: Update _rehydrate_citation_ledger to accept the
already-seeded stream_events_buffer as an argument and use it when
reconstructing the citation ledger, removing its duplicate get_stream_events
database query. At the call site near the stream buffer initialization, pass the
buffer returned by _seed_stream_events_buffer while preserving existing behavior
when the buffer is empty or the in-memory ledger already exists.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 04148729-3d1c-4bf2-b880-51186fdb94dc
📒 Files selected for processing (8)
src/cuga/backend/knowledge/awareness.pysrc/cuga/backend/knowledge/sources.pysrc/cuga/backend/server/main.pysrc/cuga/sdk.pytests/unit/test_citation_resolver.pytests/unit/test_citation_settings.pytests/unit/test_ephemeral_stream_events.pytests/unit/test_sdk_citations.py
🚧 Files skipped from review as they are similar to previous changes (5)
- src/cuga/backend/knowledge/awareness.py
- tests/unit/test_sdk_citations.py
- tests/unit/test_citation_settings.py
- src/cuga/sdk.py
- src/cuga/backend/knowledge/sources.py
…ion, review nits Resolves the actionable findings from offerakrabi's review + CodeRabbit. MAJ-1 (CI coverage): the citation / source-ledger / session-settings / durability-reset test files were never referenced by CI's explicit allowlist, so 13 test files (93 tests) ran only locally. Added them to tests.yml. MAJ-2 (correctness): apply_citation_resolution cleared state.sources whenever the text had no [sN] markers. The supervisor callback re-enters with an already-resolved last_planner_answer ([n] chips, no [sN]) and silently dropped its sources. Made it idempotent: keep sources when the current text still references them via resolved [n] markers; still clear a genuinely uncited answer (so stale prior-turn sources never leak). +2 regression tests. CodeRabbit (resume gate, my prior commit): _rehydrate_citation_ledger returned early on is_resume, so a restart DURING an interrupt never rehydrated the ledger. Now only begin_turn() is skipped on resume; rehydration still runs. MIN-1: SourcesPanel keyed list items by s.n (repeats across messages) — switch to the stable s.cite_id so React doesn't reuse DOM nodes / carry stale expanded state when the panel's sources prop changes. MIN-2: handleSessionCitationsChange guarded only !threadId; a programmatic dispatch could write a phantom session override while session scope was off. Guard on conversationReady (mirrors the toggle's own disabled condition). Frontend bundle rebuilt (production). Plan doc already removed (963247c).
|
Thanks @offerakrabi — genuinely thorough review, and the architecture notes are appreciated. I've pushed fixes for all the actionable findings (682c590):
NTH-1/NTH-2 (reload On splitting into frontend/backend PRs — I hear you that the size makes review hard, and I don't disagree in principle. My reasoning for keeping it together this once:
If you still want it split after this pass, I'll carve the frontend out — just confirm and I'll coordinate the stack. Re CI red: the failing checks are all @sami-marreed / @offerakrabi — ready for another look when you have a moment. |
Summary
Ships inline citations & sources for the knowledge engine end-to-end, plus a ground-up simplification of the knowledge / embeddings configuration UX.
Citations & sources — Closes #154, Closes #155
SourceLedgerstamps a stable, content-hashedcite_idon every retrieved chunk at both retrieval seams (client.search_envelope+routes.py::search, covering chat / sandbox / MCP / SDK). The id is carried on the wire and resolved byFinalAnswerNodeinto per-message[1]..[k]numbers with self-contained source snapshots that survive history replay. Marker parsing is hardened against model drift (ASCII + fullwidth/CJK bracket families) with a code-aware canary that logs unsupported bracket styles instead of silently dropping them.<cuga-cite>web component renders[n]markers as clickable chips with a hover preview (filename · page · snippet). The preview renders in the browser top layer (Popover API) so it isn't clipped inside markdown tables. A Sources panel/footer enumerates the cited documents; clicking a chip opens the source. Works in both the carbon-chat web UI and the extension CardManager surface.CugaAgent(enable_knowledge=True, enable_citations=True)→InvokeResult.sources([{n, cite_id, filename, page, section_path, scope, snippet, score, query}]); per-agent and per-session citation toggles.Knowledge / embeddings configuration UX
base_urlsending a key to the wrong server) all derive from a singleproviderSpecsource of truth.Verification
tscclean; citation unit + integration tests green (a deterministic realsearch → resolve → sourcesnet, plus an opt-in live-model smoke); served frontend bundle rebuilt.mainwith no conflicts.Follow-ups (tracked in #444)
Live-model citation e2e in CI, storage-at-scale validation (pgvector / Milvus), ingest streaming & performance, and the chunk_size-vs-embedder cap UX.
Part of the knowledge production-grade epic #444.
@sami-marreed — would you mind giving this a review when you get a chance? 🙏 It's a biggish one, so happy to walk you through any area (or split it) if that's easier. Thanks so much!
Summary by CodeRabbit
New Features
Bug Fixes
Citation correctness (added on this branch)
Fixes #505 — cite_ids from an earlier turn no longer resolve in a later answer (turn-scoping), and
stream_eventsis now cumulative (durable reload + citation rehydration). Reuse follow-ups: #506 (content verification) and #507 (provenance binding).