fix: control plane UI fixes for recall and data view (#693)

* fix: control plane UI fixes for recall and data view

- Sanitize NaN cross-encoder scores to 0.0 in reranking pipeline
  (Pydantic serializes NaN as JSON null, breaking UI score display)
- Add null-coalesce for score in search debug view to prevent crash
- Switch data view text filter from debounced onChange to Enter key
  (avoids slow ILIKE queries on every keystroke for large banks)
- Show loading spinner in search icon during filter requests
- Preserve search/tag filters when clicking "Load more"

* chore: sync generated files after rebase
This commit is contained in:
Nicolò Boschi 2026-03-25 18:42:57 +01:00 committed by GitHub
parent a94a90ea3f
commit 6bb83f4600
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 47 additions and 29 deletions

View file

@ -153,6 +153,8 @@ class CrossEncoderReranker:
# Normalize scores using sigmoid to [0, 1] range
# Cross-encoder returns logits which can be negative
import math
import numpy as np
def sigmoid(x):
@ -163,11 +165,20 @@ class CrossEncoderReranker:
# Create ScoredResult objects with cross-encoder scores
scored_results = []
for candidate, raw_score, norm_score in zip(candidates, scores, normalized_scores):
# Sanitize NaN scores (cross-encoder can return NaN for certain inputs).
# NaN propagates through all downstream scoring and Pydantic serializes
# NaN as JSON null, which breaks clients expecting numeric values.
raw = float(raw_score)
norm = float(norm_score)
if math.isnan(raw):
raw = 0.0
if math.isnan(norm):
norm = 0.0
scored_result = ScoredResult(
candidate=candidate,
cross_encoder_score=float(raw_score),
cross_encoder_score_normalized=float(norm_score),
weight=float(norm_score), # Initial weight is just cross-encoder score
cross_encoder_score=raw,
cross_encoder_score_normalized=norm,
weight=norm, # Initial weight is just cross-encoder score
)
scored_results.append(scored_result)

View file

@ -227,29 +227,15 @@ export function DataView({ factType }: DataViewProps) {
// Reset to first page when filters change
useEffect(() => {
setCurrentPage(1);
}, [searchQuery, tagFilters]);
}, [tagFilters]);
// Debounce ref for text search
const searchDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Trigger server-side reload when text filter changes (debounced 300ms)
useEffect(() => {
if (searchDebounceRef.current) {
clearTimeout(searchDebounceRef.current);
// Trigger text search on Enter key
const executeSearch = () => {
if (currentBank) {
setCurrentPage(1);
loadData(undefined, searchQuery || undefined, tagFilters.length > 0 ? tagFilters : undefined);
}
searchDebounceRef.current = setTimeout(() => {
if (currentBank) {
loadData(
undefined,
searchQuery || undefined,
tagFilters.length > 0 ? tagFilters : undefined
);
}
}, 300);
return () => {
if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current);
};
}, [searchQuery]);
};
// Trigger server-side reload immediately when tag filters change
useEffect(() => {
@ -292,12 +278,22 @@ export function DataView({ factType }: DataViewProps) {
<div className="flex items-center gap-2">
{/* Text search */}
<div className="relative max-w-xs flex-1">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
{loading ? (
<RefreshCw className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none animate-spin" />
) : (
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
)}
<Input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Filter by text or context..."
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
executeSearch();
}
}}
placeholder="Filter by text or context (press Enter)..."
className="pl-8 h-9"
/>
</div>
@ -356,7 +352,11 @@ export function DataView({ factType }: DataViewProps) {
onClick={() => {
const newLimit = Math.min(data.total_units, fetchLimit + 1000);
setFetchLimit(newLimit);
loadData(newLimit);
loadData(
newLimit,
searchQuery || undefined,
tagFilters.length > 0 ? tagFilters : undefined
);
}}
className="ml-2 text-primary hover:underline"
>

View file

@ -411,7 +411,7 @@ export function SearchDebugView() {
</div>
</div>
<div className="flex-shrink-0 text-right">
<div className="text-sm font-semibold">{score.toFixed(3)}</div>
<div className="text-sm font-semibold">{(score ?? 0).toFixed(3)}</div>
<div className="text-xs text-muted-foreground">score</div>
</div>
<ChevronRight className="h-5 w-5 text-muted-foreground flex-shrink-0" />

View file

@ -160,7 +160,7 @@ To switch between backends:
| Variable | Description | Default |
|----------|-------------|---------|
| `HINDSIGHT_API_LLM_PROVIDER` | Provider: `openai`, `openai-codex`, `claude-code`, `anthropic`, `gemini`, `groq`, `minimax`, `ollama`, `lmstudio`, `vertexai`, `bedrock`, `litellm` | `openai` |
| `HINDSIGHT_API_LLM_PROVIDER` | Provider: `openai`, `openai-codex`, `claude-code`, `anthropic`, `gemini`, `groq`, `minimax`, `ollama`, `lmstudio`, `vertexai`, `bedrock`, `litellm`, `none` | `openai` |
| `HINDSIGHT_API_LLM_API_KEY` | API key for LLM provider | - |
| `HINDSIGHT_API_LLM_MODEL` | Model name | `gpt-5-mini` |
| `HINDSIGHT_API_LLM_BASE_URL` | Custom LLM endpoint | Provider default |
@ -250,6 +250,13 @@ export HINDSIGHT_API_LLM_MODEL=azure/gpt-4o
export HINDSIGHT_API_LLM_PROVIDER=litellm
export HINDSIGHT_API_LLM_API_KEY=your-together-api-key
export HINDSIGHT_API_LLM_MODEL=together_ai/meta-llama/Llama-3-70b-chat-hf
# No LLM (chunk storage + semantic search only, no API key needed)
export HINDSIGHT_API_LLM_PROVIDER=none
# Retain automatically uses chunks mode (no fact extraction)
# Recall works normally (semantic search, BM25, graph retrieval)
# Reflect returns HTTP 400 (requires an LLM)
# Consolidation/observations are disabled
```
:::tip OpenAI Codex, Claude Code & Vertex AI Setup