fleet-memory/hindsight-control-plane/src/components/search-debug-view.tsx
Nicolò Boschi ea662d062e
feat: fact_types and mental model exclusion filters for reflect (#615)
* feat: add fact_types and mental model exclusion filters to reflect and mental models

Adds three new filtering options to both the reflect endpoint and mental model creation/refresh:

- `fact_types`: restrict which fact types (world, experience, observation) are retrieved.
  Disables irrelevant agent tools entirely (no wasted tokens).
- `exclude_mental_models`: skip the search_mental_models tool altogether.
- `exclude_mental_model_ids`: exclude specific mental models by ID (merged with the
  existing self-exclusion logic during mental model refresh).

For mental models, options are persisted in the existing `trigger` JSONB column so they
are automatically applied on every refresh. The `UpdateMentalModelRequest` already
proxies `trigger`, so no extra endpoint changes are needed.

Also fixes the test fixture (`pg0_db_url` in conftest.py) to correctly resolve pg0://
URLs and run migrations before tests, which was causing all DB-dependent tests to fail
with "relation public.banks does not exist" when HINDSIGHT_API_DATABASE_URL=pg0://uuuu.

* fix: guard against disabled-tool hallucination and regenerate clients

- Add enabled_tools guard in reflect agent: if an LLM calls a tool that
  was excluded (e.g. recall when fact_types=["observation"]), return an
  error result instead of executing it
- Regenerate OpenAPI spec and all SDK clients (Go, Python, TypeScript)
  to include new fact_types / exclude_mental_models fields

* fix: add missing ReflectRequest fields in Rust CLI struct initializers

* fix: filter hallucinated tool calls before trace to prevent disabled tools appearing in results

* chore: merge main, fix lint formatting and update skills openapi.json

* feat: expose fact_types, exclude_mental_models, exclude_mental_model_ids in control plane UI

* fix: add missing trigger fields to MentalModel type in control plane api.ts

* fix: add missing trigger fields to local MentalModel interface in mental-models-view

* feat: tabbed mental model dialogs (Basic / Options tabs)

* refactor: shared FactTypeFilter component, tabbed mental model dialogs use General tab, clean up labels

* feat: pill-style toggle buttons for fact type filter (blue/emerald/amber per type)

* fix: add spacing between Fact Types label and pills, rename to Exclude all mental models
2026-03-19 17:03:41 +01:00

1025 lines
49 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useState } from "react";
import { client } from "@/lib/api";
import { useBank } from "@/lib/bank-context";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { toast } from "sonner";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import { FactType, FactTypeFilter } from "@/components/fact-type-filter";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Search,
Clock,
Zap,
ChevronRight,
ChevronDown,
Database,
FileText,
Users,
ArrowDown,
Tag,
Calendar,
} from "lucide-react";
import JsonView from "react18-json-view";
import "react18-json-view/src/style.css";
import { MemoryDetailPanel } from "./memory-detail-panel";
type Budget = "low" | "mid" | "high";
type TagsMatch = "any" | "all" | "any_strict" | "all_strict";
type ViewMode = "results" | "trace" | "json";
export function SearchDebugView() {
const { currentBank } = useBank();
// Query state
const [query, setQuery] = useState("");
const [factTypes, setFactTypes] = useState<FactType[]>(["world"]);
const [budget, setBudget] = useState<Budget>("mid");
const [maxTokens, setMaxTokens] = useState(4096);
const [queryDate, setQueryDate] = useState("");
const [includeChunks, setIncludeChunks] = useState(false);
const [includeEntities, setIncludeEntities] = useState(false);
const [tags, setTags] = useState("");
const [tagsMatch, setTagsMatch] = useState<TagsMatch>("any");
// Results state
const [results, setResults] = useState<any[] | null>(null);
const [entities, setEntities] = useState<any[] | null>(null);
const [chunks, setChunks] = useState<any[] | null>(null);
const [observations, setObservations] = useState<any[] | null>(null);
const [trace, setTrace] = useState<any | null>(null);
const [loading, setLoading] = useState(false);
const [viewMode, setViewMode] = useState<ViewMode>("results");
const [selectedMemory, setSelectedMemory] = useState<any | null>(null);
const [expandedSteps, setExpandedSteps] = useState<Set<string>>(new Set());
const [expandedResults, setExpandedResults] = useState<Set<string>>(new Set());
const toggleStep = (step: string) => {
setExpandedSteps((prev) => {
const next = new Set(prev);
if (next.has(step)) {
next.delete(step);
} else {
next.add(step);
}
return next;
});
};
const toggleExpandResults = (key: string) => {
setExpandedResults((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
};
const INITIAL_RESULTS_COUNT = 5;
// Helper to find full memory data from results when clicking trace items
const selectMemoryFromTrace = (traceResult: any) => {
const nodeId = traceResult.id || traceResult.node_id;
// Try to find the full result with all metadata
const fullResult = results?.find((r: any) => r.id === nodeId || r.node_id === nodeId);
setSelectedMemory(fullResult || traceResult);
};
const runSearch = async () => {
if (!currentBank) {
toast.error("Validation error", {
description: "Please select a memory bank first",
});
return;
}
if (!query) {
return;
}
// Must select at least one type
if (factTypes.length === 0) {
toast.error("Validation error", {
description: "Please select at least one type (World, Experience, or Observations)",
});
return;
}
setLoading(true);
try {
// Parse tags from comma-separated string
const parsedTags = tags
.split(",")
.map((t) => t.trim())
.filter((t) => t.length > 0);
const requestBody: any = {
bank_id: currentBank,
query: query,
types: factTypes,
budget: budget,
max_tokens: maxTokens,
trace: true,
include: {
entities: includeEntities ? { max_tokens: 500 } : null,
chunks: includeChunks ? { max_tokens: 8192 } : null,
},
...(queryDate && { query_timestamp: queryDate }),
...(parsedTags.length > 0 && { tags: parsedTags, tags_match: tagsMatch }),
};
const data: any = await client.recall(requestBody);
setResults(data.results || []);
setEntities(data.entities || null);
setChunks(data.chunks || null);
setObservations(data.observations || null);
setTrace(data.trace || null);
setViewMode("results");
} catch (error) {
// Error toast is shown automatically by the API client interceptor
} finally {
setLoading(false);
}
};
if (!currentBank) {
return (
<Card className="border-dashed">
<CardContent className="flex flex-col items-center justify-center py-16">
<Database className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-xl font-semibold mb-2">No Bank Selected</h3>
<p className="text-muted-foreground">Select a memory bank to start recalling.</p>
</CardContent>
</Card>
);
}
return (
<div className="space-y-6">
{/* Search Input */}
<Card>
<CardContent className="pt-6">
<div className="flex gap-3">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="What would you like to recall?"
className="pl-10 h-12 text-lg"
onKeyDown={(e) => e.key === "Enter" && runSearch()}
/>
</div>
<Button onClick={runSearch} disabled={loading || !query} className="h-12 px-8">
{loading ? "Searching..." : "Recall"}
</Button>
</div>
{/* Filters */}
<div className="flex flex-wrap items-center gap-6 mt-4 pt-4 border-t">
<FactTypeFilter value={factTypes} onChange={setFactTypes} label="Types:" />
<div className="h-6 w-px bg-border" />
{/* Budget */}
<div className="flex items-center gap-2">
<Zap className="h-4 w-4 text-muted-foreground" />
<Select value={budget} onValueChange={(v) => setBudget(v as Budget)}>
<SelectTrigger className="w-24 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">Low</SelectItem>
<SelectItem value="mid">Mid</SelectItem>
<SelectItem value="high">High</SelectItem>
</SelectContent>
</Select>
</div>
{/* Max Tokens */}
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Tokens:</span>
<Input
type="number"
value={maxTokens}
onChange={(e) => setMaxTokens(parseInt(e.target.value))}
className="w-24 h-8"
/>
</div>
{/* Query Date */}
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-muted-foreground" />
<Input
type="datetime-local"
value={queryDate}
onChange={(e) => setQueryDate(e.target.value)}
className="h-8"
placeholder="Query date"
/>
</div>
<div className="h-6 w-px bg-border" />
{/* Include options */}
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={includeChunks}
onCheckedChange={(c) => setIncludeChunks(c as boolean)}
/>
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">Chunks</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={includeEntities}
onCheckedChange={(c) => setIncludeEntities(c as boolean)}
/>
<Users className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">Entities</span>
</label>
</div>
</div>
{/* Tags Filter */}
<div className="flex items-center gap-4 mt-4 pt-4 border-t">
<Tag className="h-4 w-4 text-muted-foreground" />
<div className="flex-1 max-w-md">
<Input
type="text"
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder="Filter by tags (comma-separated)"
className="h-8"
/>
</div>
<Select value={tagsMatch} onValueChange={(v) => setTagsMatch(v as TagsMatch)}>
<SelectTrigger className="w-40 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="any">Any (incl. untagged)</SelectItem>
<SelectItem value="all">All (incl. untagged)</SelectItem>
<SelectItem value="any_strict">Any (strict)</SelectItem>
<SelectItem value="all_strict">All (strict)</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
{/* Results */}
{loading && (
<Card>
<CardContent className="flex flex-col items-center justify-center py-16">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mb-4" />
<p className="text-muted-foreground">Searching memories...</p>
</CardContent>
</Card>
)}
{!loading && results && (
<div className="space-y-4">
{/* Summary Stats */}
{trace?.summary && (
<div className="flex items-center gap-6 text-sm">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Results:</span>
<span className="font-semibold">{results.length}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Duration:</span>
<span className="font-semibold">
{trace.summary.total_duration_seconds?.toFixed(2)}s
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Nodes visited:</span>
<span className="font-semibold">{trace.summary.total_nodes_visited}</span>
</div>
<div className="flex-1" />
{/* View Mode Tabs */}
<div className="flex gap-1 bg-muted p-1 rounded-lg">
{(["results", "trace", "json"] as ViewMode[]).map((mode) => (
<button
key={mode}
onClick={() => setViewMode(mode)}
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
viewMode === mode
? "bg-background shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
{mode === "results" ? "Results" : mode === "trace" ? "Trace" : "JSON"}
</button>
))}
</div>
</div>
)}
{/* Results View */}
{viewMode === "results" && (
<div className="space-y-4">
{/* Observations Section */}
{observations && observations.length > 0 && (
<Card className="border-orange-500/30 bg-orange-500/5">
<CardHeader className="py-3">
<CardTitle className="text-base flex items-center gap-2">
<Database className="h-4 w-4 text-orange-500" />
<span>Observations</span>
<span className="text-xs text-muted-foreground">({observations.length})</span>
</CardTitle>
</CardHeader>
<CardContent className="pt-0 space-y-2">
{observations.map((obs: any, idx: number) => (
<div
key={obs.id || idx}
className="p-3 bg-background rounded-lg border border-orange-500/20"
>
<p className="text-sm text-foreground">{obs.text}</p>
<div className="flex items-center gap-3 mt-2 text-xs text-muted-foreground">
<span className="px-2 py-0.5 rounded bg-orange-500/10 text-orange-600">
Observation
</span>
<span>Proof count: {obs.proof_count || 1}</span>
<span>Relevance: {(obs.relevance || 0).toFixed(3)}</span>
</div>
</div>
))}
</CardContent>
</Card>
)}
{/* Memories Section */}
<div className="space-y-3">
{results.length === 0 && (!observations || observations.length === 0) ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Search className="h-12 w-12 text-muted-foreground mb-4" />
<p className="text-muted-foreground">No memories found for this query.</p>
</CardContent>
</Card>
) : (
results.map((result: any, idx: number) => {
const visit = trace?.visits?.find((v: any) => v.node_id === result.id);
const score = visit ? visit.weights.final_weight : result.score || 0;
return (
<Card
key={idx}
className="cursor-pointer hover:border-primary/50 transition-colors"
onClick={() => setSelectedMemory(result)}
>
<CardContent className="py-4">
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
<span className="text-sm font-semibold text-primary">{idx + 1}</span>
</div>
<div className="flex-1 min-w-0">
<p className="text-foreground">{result.text}</p>
<div className="flex items-center gap-4 mt-2 text-xs text-muted-foreground">
<span className="px-2 py-0.5 rounded bg-muted capitalize">
{result.type || "world"}
</span>
{result.context && (
<span className="truncate max-w-xs">{result.context}</span>
)}
{result.occurred_start && (
<span>
{new Date(result.occurred_start).toLocaleDateString()}
</span>
)}
</div>
</div>
<div className="flex-shrink-0 text-right">
<div className="text-sm font-semibold">{score.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" />
</div>
</CardContent>
</Card>
);
})
)}
</div>
</div>
)}
{/* Trace View */}
{viewMode === "trace" && trace && (
<div className="space-y-4">
{/* Parallel Retrieval Methods - Grouped by Fact Type */}
{trace.retrieval_results &&
trace.retrieval_results.length > 0 &&
(() => {
// Group retrieval results by fact type
const factTypeGroups: Record<string, any[]> = {};
trace.retrieval_results.forEach((method: any) => {
const ft = method.fact_type || "all";
if (!factTypeGroups[ft]) factTypeGroups[ft] = [];
factTypeGroups[ft].push(method);
});
const factTypes = Object.keys(factTypeGroups);
return (
<div>
<div className="text-xs font-medium text-muted-foreground mb-3 flex items-center gap-2">
<div className="flex-1 h-px bg-border" />
<span>PARALLEL RETRIEVAL</span>
<div className="flex-1 h-px bg-border" />
</div>
{/* Fact type lanes */}
<div className="space-y-2">
{factTypes.map((factType, ftIdx) => {
const methods = factTypeGroups[factType];
const laneKey = `lane-${factType}`;
const isLaneExpanded = expandedSteps.has(laneKey);
const totalResults = methods.reduce(
(sum: number, m: any) => sum + (m.results?.length || 0),
0
);
const totalDuration = Math.max(
...methods.map((m: any) => m.duration_seconds || 0)
);
// Color coding for fact types
const ftColors: Record<
string,
{ bg: string; text: string; border: string }
> = {
world: {
bg: "bg-blue-500/10",
text: "text-blue-500",
border: "border-blue-500/30",
},
experience: {
bg: "bg-green-500/10",
text: "text-green-500",
border: "border-green-500/30",
},
opinion: {
bg: "bg-purple-500/10",
text: "text-purple-500",
border: "border-purple-500/30",
},
all: {
bg: "bg-gray-500/10",
text: "text-gray-500",
border: "border-gray-500/30",
},
};
const colors = ftColors[factType] || ftColors.all;
return (
<Card
key={laneKey}
className={`transition-colors ${isLaneExpanded ? "border-primary" : colors.border}`}
>
<CardContent className="py-3 px-4">
{/* Lane Header */}
<div
className="flex items-center gap-3 cursor-pointer"
onClick={() => toggleStep(laneKey)}
>
<div
className={`w-8 h-8 rounded-lg ${colors.bg} flex items-center justify-center`}
>
<span className={`text-sm font-bold ${colors.text} capitalize`}>
{factType.charAt(0).toUpperCase()}
</span>
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-semibold text-foreground capitalize">
{factType}
</span>
<span className="text-xs text-muted-foreground">
{methods.length} methods
</span>
</div>
{/* Method summary pills */}
<div className="flex gap-1.5 mt-1">
{methods.map((m: any, mIdx: number) => (
<span
key={mIdx}
className="text-[10px] px-2 py-0.5 rounded-full bg-muted text-muted-foreground capitalize"
>
{m.method_name}: {m.results?.length || 0}
</span>
))}
</div>
</div>
<div className="text-right">
<div className="text-2xl font-bold text-foreground">
{totalResults}
</div>
<div className="text-[10px] text-muted-foreground">
{totalDuration.toFixed(2)}s
</div>
</div>
{isLaneExpanded ? (
<ChevronDown className="h-5 w-5 text-muted-foreground" />
) : (
<ChevronRight className="h-5 w-5 text-muted-foreground" />
)}
</div>
{/* Expanded: Show methods grid */}
{isLaneExpanded && (
<div className="mt-4 pt-4 border-t border-border">
<div
className={`grid gap-3 ${
methods.length === 1
? "grid-cols-1"
: methods.length === 2
? "grid-cols-2"
: methods.length === 3
? "grid-cols-3"
: "grid-cols-4"
}`}
>
{methods.map((method: any, mIdx: number) => {
const methodKey = `${laneKey}-method-${mIdx}`;
const isMethodExpanded = expandedSteps.has(methodKey);
const methodResults = method.results || [];
return (
<div key={methodKey} className="flex flex-col">
<div
className={`p-3 rounded-lg cursor-pointer transition-colors ${
isMethodExpanded
? "bg-primary/10 border border-primary"
: "bg-muted/50 hover:bg-muted"
}`}
onClick={(e) => {
e.stopPropagation();
toggleStep(methodKey);
}}
>
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<span className="font-medium text-sm text-foreground capitalize">
{method.method_name}
</span>
{/* Show temporal range inline */}
{method.method_name === "temporal" &&
method.metadata?.constraint && (
<span className="flex items-center gap-1 text-[10px] text-muted-foreground">
<Calendar className="h-3 w-3" />
{method.metadata.constraint.start
? new Date(
method.metadata.constraint.start
).toLocaleDateString()
: "any"}
{" → "}
{method.metadata.constraint.end
? new Date(
method.metadata.constraint.end
).toLocaleDateString()
: "any"}
</span>
)}
</div>
{isMethodExpanded ? (
<ChevronDown className="h-3 w-3 text-muted-foreground" />
) : (
<ChevronRight className="h-3 w-3 text-muted-foreground" />
)}
</div>
<div className="flex items-end justify-between">
<div className="text-2xl font-bold text-foreground">
{methodResults.length}
</div>
<div className="text-[10px] text-muted-foreground">
{method.duration_seconds?.toFixed(2)}s
</div>
</div>
</div>
{/* Method Results */}
{isMethodExpanded &&
methodResults.length > 0 &&
(() => {
const resultsKey = `results-${methodKey}`;
const showAll = expandedResults.has(resultsKey);
const displayResults = showAll
? methodResults
: methodResults.slice(0, INITIAL_RESULTS_COUNT);
const hasMore =
methodResults.length > INITIAL_RESULTS_COUNT;
return (
<div className="mt-2 space-y-1.5 max-h-[300px] overflow-y-auto">
{displayResults.map((r: any, rIdx: number) => (
<div
key={rIdx}
className="p-2 bg-background rounded cursor-pointer hover:bg-muted/50 transition-colors border border-border"
onClick={(e) => {
e.stopPropagation();
selectMemoryFromTrace(r);
}}
>
<div className="flex items-start gap-2">
<span className="text-[10px] font-mono text-muted-foreground mt-0.5">
{rIdx + 1}
</span>
<div className="flex-1 min-w-0">
<p className="text-xs text-foreground line-clamp-2">
{r.text}
</p>
<div className="flex items-center gap-2 mt-1">
<span className="text-[10px] text-muted-foreground">
{(
r.score ||
r.similarity ||
0
).toFixed(4)}
</span>
</div>
</div>
</div>
</div>
))}
{hasMore && (
<button
className="w-full text-[10px] text-primary hover:text-primary/80 py-1.5 hover:bg-muted/50 rounded transition-colors"
onClick={(e) => {
e.stopPropagation();
toggleExpandResults(resultsKey);
}}
>
{showAll
? `Show less`
: `View all ${methodResults.length} results`}
</button>
)}
</div>
);
})()}
</div>
);
})}
</div>
</div>
)}
</CardContent>
</Card>
);
})}
</div>
{/* Parallel indicator - vertical lines showing all run together */}
<div className="flex justify-center py-2">
<div className="flex items-center gap-2">
{factTypes.map((ft, i) => {
const ftColors: Record<string, string> = {
world: "bg-blue-500",
experience: "bg-green-500",
opinion: "bg-purple-500",
all: "bg-gray-500",
};
return (
<div key={i} className="flex flex-col items-center">
<div
className={`w-1 h-4 ${ftColors[ft] || ftColors.all} rounded-full opacity-50`}
/>
</div>
);
})}
</div>
</div>
<div className="flex justify-center">
<ArrowDown className="h-5 w-5 text-muted-foreground/50" />
</div>
</div>
);
})()}
{/* Step 2: RRF Merge */}
{trace.rrf_merged &&
(() => {
const stepKey = "rrf-merge";
const isExpanded = expandedSteps.has(stepKey);
return (
<div>
<Card
className={`cursor-pointer transition-colors ${isExpanded ? "border-primary" : "hover:border-primary/50"}`}
onClick={() => toggleStep(stepKey)}
>
<CardContent className="py-4">
<div className="flex items-center gap-4">
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-purple-500/10 flex items-center justify-center">
<span className="text-sm font-bold text-purple-500"></span>
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-semibold text-foreground">RRF Fusion</span>
<span className="text-xs px-2 py-0.5 rounded bg-muted text-muted-foreground">
merge
</span>
</div>
<div className="text-sm text-muted-foreground mt-0.5">
Reciprocal Rank Fusion of all retrieval results
</div>
</div>
<div className="text-2xl font-bold text-foreground">
{trace.rrf_merged.length}
</div>
{isExpanded ? (
<ChevronDown className="h-5 w-5 text-muted-foreground" />
) : (
<ChevronRight className="h-5 w-5 text-muted-foreground" />
)}
</div>
</CardContent>
</Card>
{/* Expanded Results */}
{isExpanded &&
trace.rrf_merged.length > 0 &&
(() => {
const resultsKey = "results-rrf";
const showAll = expandedResults.has(resultsKey);
const displayResults = showAll
? trace.rrf_merged
: trace.rrf_merged.slice(0, INITIAL_RESULTS_COUNT);
const hasMore = trace.rrf_merged.length > INITIAL_RESULTS_COUNT;
return (
<div className="ml-6 mt-2 space-y-2 border-l-2 border-muted pl-4 max-h-[400px] overflow-y-auto">
{displayResults.map((r: any, rIdx: number) => (
<div
key={rIdx}
className="p-3 bg-muted/30 rounded-lg cursor-pointer hover:bg-muted/50 transition-colors"
onClick={(e) => {
e.stopPropagation();
selectMemoryFromTrace(r);
}}
>
<div className="flex items-start gap-3">
<span className="text-xs font-mono text-muted-foreground">
{rIdx + 1}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm text-foreground line-clamp-2">
{r.text}
</p>
<div className="text-xs text-muted-foreground mt-1">
RRF Score: {(r.rrf_score || r.score || 0).toFixed(4)}
</div>
</div>
</div>
</div>
))}
{hasMore && (
<button
className="w-full text-xs text-primary hover:text-primary/80 py-2 hover:bg-muted/50 rounded transition-colors"
onClick={(e) => {
e.stopPropagation();
toggleExpandResults(resultsKey);
}}
>
{showAll
? `Show less`
: `View all ${trace.rrf_merged.length} results`}
</button>
)}
</div>
);
})()}
{/* Arrow */}
<div className="flex justify-center py-2">
<ArrowDown className="h-4 w-4 text-muted-foreground/50" />
</div>
</div>
);
})()}
{/* Step 3: Combined Scoring */}
{trace.reranked &&
(() => {
const stepKey = "reranking";
const isExpanded = expandedSteps.has(stepKey);
return (
<div>
<Card
className={`cursor-pointer transition-colors ${isExpanded ? "border-primary" : "hover:border-primary/50"}`}
onClick={() => toggleStep(stepKey)}
>
<CardContent className="py-4">
<div className="flex items-center gap-4">
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-amber-500/10 flex items-center justify-center">
<span className="text-sm font-bold text-amber-500"></span>
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-semibold text-foreground">
Combined Scoring
</span>
<span className="text-xs px-2 py-0.5 rounded bg-muted text-muted-foreground">
rerank
</span>
</div>
<div className="text-sm text-muted-foreground mt-0.5">
<span className="font-mono text-xs">
ce × recency_boost(±10%) × temporal_boost(±10%)
</span>
</div>
</div>
<div className="text-2xl font-bold text-foreground">
{trace.reranked.length}
</div>
{isExpanded ? (
<ChevronDown className="h-5 w-5 text-muted-foreground" />
) : (
<ChevronRight className="h-5 w-5 text-muted-foreground" />
)}
</div>
</CardContent>
</Card>
{/* Expanded Results */}
{isExpanded &&
trace.reranked.length > 0 &&
(() => {
const resultsKey = "results-rerank";
const showAll = expandedResults.has(resultsKey);
const displayResults = showAll
? trace.reranked
: trace.reranked.slice(0, INITIAL_RESULTS_COUNT);
const hasMore = trace.reranked.length > INITIAL_RESULTS_COUNT;
return (
<div className="ml-6 mt-2 space-y-2 border-l-2 border-muted pl-4 max-h-[400px] overflow-y-auto">
{displayResults.map((r: any, rIdx: number) => {
const sc = r.score_components || {};
return (
<div
key={rIdx}
className="p-3 bg-muted/30 rounded-lg cursor-pointer hover:bg-muted/50 transition-colors"
onClick={(e) => {
e.stopPropagation();
selectMemoryFromTrace(r);
}}
>
<div className="flex items-start gap-3">
<span className="text-xs font-mono text-muted-foreground">
{rIdx + 1}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm text-foreground line-clamp-2">
{r.text}
</p>
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-2 text-[10px] text-muted-foreground font-mono">
<span className="font-semibold text-foreground">
= {(r.rerank_score || r.score || 0).toFixed(4)}
</span>
{sc.cross_encoder_score_normalized !== undefined && (
<span title="Cross-encoder score (primary relevance signal)">
CE: {sc.cross_encoder_score_normalized.toFixed(3)}
</span>
)}
{sc.temporal !== undefined && sc.temporal !== 0.5 && (
<span title="Temporal proximity boost (±10% — only active for temporal queries)">
Tmp: {sc.temporal.toFixed(3)}
</span>
)}
{sc.recency !== undefined && (
<span title="Recency boost (±10% — based on memory age)">
Rec: {sc.recency.toFixed(3)}
</span>
)}
</div>
</div>
</div>
</div>
);
})}
{hasMore && (
<button
className="w-full text-xs text-primary hover:text-primary/80 py-2 hover:bg-muted/50 rounded transition-colors"
onClick={(e) => {
e.stopPropagation();
toggleExpandResults(resultsKey);
}}
>
{showAll
? `Show less`
: `View all ${trace.reranked.length} results`}
</button>
)}
</div>
);
})()}
{/* Arrow */}
<div className="flex justify-center py-2">
<ArrowDown className="h-4 w-4 text-muted-foreground/50" />
</div>
</div>
);
})()}
{/* Final: Results */}
<Card className="border-primary bg-primary/5">
<CardContent className="py-4">
<div className="flex items-center gap-4">
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center">
<span className="text-sm font-bold text-primary"></span>
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-semibold text-foreground">Final Results</span>
<span className="text-xs px-2 py-0.5 rounded bg-primary/20 text-primary">
output
</span>
</div>
<div className="text-sm text-muted-foreground mt-0.5">
Top results after all processing steps
</div>
</div>
<div className="text-2xl font-bold text-primary">{results?.length || 0}</div>
</div>
</CardContent>
</Card>
</div>
)}
{/* JSON View */}
{viewMode === "json" && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Raw Response</CardTitle>
</CardHeader>
<CardContent>
<div className="bg-muted p-4 rounded-lg overflow-auto max-h-[600px]">
<JsonView
src={{
results,
...(entities && { entities }),
...(chunks && { chunks }),
...(observations && { observations }),
trace,
}}
collapsed={2}
theme="default"
/>
</div>
</CardContent>
</Card>
)}
</div>
)}
{/* Empty State */}
{!loading && !results && (
<Card className="border-dashed">
<CardContent className="flex flex-col items-center justify-center py-16">
<Search className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">Ready to Recall</h3>
<p className="text-muted-foreground text-center max-w-md">
Enter a query above to search through your memories. Use filters to narrow down by
fact type, budget, and more.
</p>
</CardContent>
</Card>
)}
{/* Memory Detail Panel */}
{selectedMemory && (
<div className="fixed right-0 top-0 h-screen w-[420px] bg-card border-l shadow-2xl z-50 overflow-y-auto">
<MemoryDetailPanel
memory={selectedMemory}
onClose={() => setSelectedMemory(null)}
inPanel
bankId={currentBank || undefined}
/>
</div>
)}
</div>
);
}