Skip to content

feat: unified smart search with exploration tracking and query program#285

Merged
aaronsb merged 23 commits intomainfrom
feature/unified-smart-search
Feb 3, 2026
Merged

feat: unified smart search with exploration tracking and query program#285
aaronsb merged 23 commits intomainfrom
feature/unified-smart-search

Conversation

@aaronsb
Copy link
Owner

@aaronsb aaronsb commented Feb 3, 2026

Summary

Replaces the three separate search tabs (concept, neighborhood, path) with a unified progressive interface. Introduces exploration tracking that records graph actions as ordered Cypher statements, enabling save/load/replay of graph explorations. Establishes a unified query program IR ({ op: '+' | '-', cypher: string }[]) that all query modes compile to.

Search UX

  • Single progressive search flow: select concept -> adjust depth -> optionally add destination
  • Parameter-presence model: mode is derived from which params are set, not stored
  • Additive graph loading: "Load" replaces, "Add to Graph" merges
  • Warmth physics slider for force graph tuning

Exploration Tracking (Phase 1)

  • Every graph action (explore, follow, add-adjacent, remove, load-path) records a step
  • Steps include semantic metadata and equivalent Cypher statements
  • +/- set algebra operators for additive/subtractive graph operations
  • Undo last step support

Saved Queries (Phase 2)

  • Save/load/delete explorations via query definitions API
  • Exploration type stores { op, cypher }[] statements
  • Replays Cypher sequentially with correct AGE ID mapping
  • Saved queries panel in icon rail sidebar

Unified Query Program

  • Cypher editor now participates in the exploration session
  • Multi-statement +/- script execution (was single statement only)
  • Fixed: Cypher editor bypassed rawGraphData pipeline, used wrong IDs
  • Export to Editor: push exploration session as editable Cypher text
  • Round-trip: smart search -> save -> load -> export to editor -> edit -> execute -> save

Canvas Context Menu

  • Travel submenu: find path between origin/destination, animate camera through nodes
  • Send to Polarity Explorer: sets origin/destination as poles
  • Send Path to Reports: creates traversal report with per-path tables

Bug Fixes

  • Cypher endpoint misidentified edges as nodes (AGE start_id/end_id check)
  • Query definition create response missing definition field
  • Stale URL params replayed on navigation (clear on mount without params)
  • getSubgraph missing relationship target hydration

Test plan

  • Smart search: select concept, adjust depth, load graph
  • Add destination: path mode activates, find paths, select, load
  • Add to Graph: merges new data without replacing
  • Remove from Graph: right-click node, remove, graph updates
  • Save exploration: build graph, save, verify in saved queries list
  • Load exploration: click saved query, graph rebuilds with correct edges
  • Cypher editor: write + MATCH ...; script, execute, graph renders
  • Cypher editor multi-statement: use +/- operators, verify set algebra
  • Export to Editor: build via smart search, click code icon, editor populated
  • Round-trip: save -> load -> export to editor -> edit -> execute -> save again
  • Clear graph: click eraser, graph clears, load saved query works
  • Travel: set origin+destination, right-click canvas, Travel > From Origin
  • Send to Polarity: right-click canvas, Send to Polarity Explorer
  • Send Path to Reports: right-click canvas, creates traversal report
  • Traversal report: CSV and Markdown export work

Extract five reusable components to web/src/components/shared/search/:
- ConceptSearchInput: search input + spinner + dropdown (was 4x duplicated)
- SelectedConceptChip: concept selection display with Change button (5x)
- SliderControl: labeled range slider for similarity/depth/hops (6x)
- LoadButtons: Clean Graph / Add to Existing button pair (3x)
- PathResults: path list, selection, and load buttons

SearchBar uses these in all three mode sections. No behavior change —
same three tabs, same state, same handlers. Reduces SearchBar from
1170 to 801 lines; extracted components are 260 lines total.
Replace mode-discriminated SearchParams (mode: concept|neighborhood|path)
with parameter-presence model where mode is derived:
- primaryConceptId only → explore (covers old concept + neighborhood)
- primaryConceptId + destinationConceptId → path
- depth controls neighborhood expansion (1=concept, >1=neighborhood)

Add deriveMode() helper function. Collapse ExplorerView from 3 React
Query hooks to 2 (explore + path). Update URL sync with short params
(c, to, d, h, s) and backward-compatible legacy URL parsing.

SearchBar still uses three tabs but maps to the new param shape.
Remove SmartSearchSubMode pill selector and per-mode state. Single search
flow progressively reveals controls as parameters are populated: search →
concept chip + depth slider + load buttons → optional destination →
path controls. Mode is derived from parameter presence, matching the
store's deriveMode() pattern.
New usePathEnrichment hook uses React Query useQueries to fetch subgraph
neighborhoods around each path node in parallel. Performance guards cap
enrichment depth at 2 and skip enrichment for paths with >50 nodes.

In path mode, the depth slider becomes "Context" (range 0-2, default 0).
Setting context > 0 expands neighborhoods around each path node,
providing richer graph context for the discovered connections.
Add min-w-0 and overflow-hidden through the flex chain so controls
shrink gracefully instead of clipping off-screen. Concept labels
in SelectedConceptChip now truncate with ellipsis.
… slider

Move GraphSettingsPanel and Settings3DPanel from floating PanelStack
overlays into the sidebar Settings tab. Add embedded prop to both panels
to strip floating-card chrome when rendered inside the sidebar.

Remove the duplicate similarity threshold slider from the sidebar —
SearchBar owns it as part of the search workflow. The graph canvas now
only shows Legend and StatsPanel overlays.
The overflow-hidden added for responsive layout was preventing the
absolutely-positioned SearchResultsDropdown from escaping its container
to overlay the graph canvas. min-w-0 alone handles flex shrinking.
Add scrollbar styling using CSS custom properties so scrollbar thumb
and track colors adapt to light, dark, and twilight modes. Uses both
standard scrollbar-color property and WebKit pseudo-elements.
The URL init effect was re-triggering on every URL change (caused by
store-to-URL sync), overwriting loadMode back to 'clean'. Fixed by
running URL initialization only once per mount using a ref guard.

Also give both load buttons equal visual weight (both secondary) and
highlight the last-clicked button with primary color for feedback.
- Fix add-to-existing-graph: use ref-based loadMode tracking so data
  effect reads intended mode when data arrives, not stale closure value.
  Initialize lastProcessedData ref to current query data on mount so
  cached React Query results don't re-process on view switches.
- Fix isolated nodes filtered out: filterByEdgeCategory now preserves
  nodes with no connections (from additive loading) instead of dropping
  them when edge category filters are active.
- Simplify search UX: remove SelectedConceptChip toggle for both primary
  and destination searches. Input stays visible with concept label after
  selection; typing clears selection and restarts search.
- Context menu: rename "Add X to Graph" to "Add Adjacent Nodes", route
  both follow and add-adjacent through rawGraphData store (persists
  across view switches).
- Add warmth physics control (0.1-1.0) for D3 force simulation initial
  energy. Lower values = gentler settle on load/merge.
Track graph exploration as ordered +/- Cypher statements that can be
saved, exported, and replayed. Each action (explore, follow, add-adjacent,
load-path) records a step with its equivalent openCypher statement.

- Add ExplorationStep/Session types and actions to graphStore
- Wrap store with Zustand persist middleware (localStorage)
- Add subtractRawGraphData for future subtractive operations
- Create cypherGenerator.ts (stepToCypher, generateCypher, parse)
- Instrument SearchBar, context menu, and ExplorerView action points
Right-clicking a node now shows "Remove from Graph" which removes the
node and its connections via subtractRawGraphData, recording a step
with op: '-' for the exploration session's Cypher trail.
D3Node now extends APIGraphNode and D3Link extends APIGraphLink.
The transform spreads all API fields through instead of stripping
them, so the full payload (search_terms, grounding_display,
confidence_level, etc.) is available on graph nodes without
re-fetching from the API.
The /query/related BFS can miss neighbors (stale accelerator, edge
type gaps). Now getSubgraph discovers relationship targets from
concept details and fetches any missing ones, so the subgraph is
complete even when traversal is incomplete.

Also fixes duplicate edges: normalizes dedup key to treat A→B and
B→A with same type as one edge.
- FolderOpen icon in sidebar nav rail (consistent with report explorer)
- Save button appears when exploration has steps, stores as
  QueryDefinition with definition_type 'exploration'
- Load replays +/- Cypher statements sequentially via executeCypherQuery
- Query list shows step count and date for exploration queries
- Delete uses existing queryDefinitionStore infrastructure
Migration 050 adds 'exploration' to the query_definitions CHECK
constraint and Pydantic model, enabling graph explorations to be
saved as ordered +/- Cypher statement sequences.
API's QueryDefinitionCreateResponse omits `definition` and `metadata`
fields, so saved queries showed "0 steps" until the next full fetch.
…ry replay

Server: individual edges returned from RETURN c, r, n have both `id` and
`start_id`/`end_id`. The parser checked `id` first, so edges were silently
captured as nodes and `result.relationships` was empty.

Client: replay mapped AGE internal vertex IDs as concept_ids. Added
internalToConceptId translation map (matching BlockBuilder's pattern).
Context menu gains three new actions when origin+destination are set:
- Travel submenu (From Origin / From Destination) finds path, merges
  nodes into graph, and animates camera through each node sequentially
- Send to Polarity Explorer sets origin/destination as poles
- Send Path to Reports creates a traversal report with per-path tables

Traversal report type added to reportStore with rendering, CSV export,
and Markdown export in ReportWorkspace.
…arch

All three query modes now compile to the same IR: { op, cypher }[].
Smart search generates it from UI actions, Cypher editor lets users
write +/- prefixed statements directly, and saved queries store it.

Fixes in the Cypher editor:
- Routes through rawGraphData pipeline (was bypassing with setGraphData)
- Correct AGE internal ID → concept_id mapping via shared mapper
- Multi-statement execution with +/- set algebra operators
- Records exploration steps so Save/Export work after execution

New capabilities:
- Export to Editor button pushes exploration session as Cypher text
- Loading saved queries reconstructs the exploration session
- Clear Graph eraser icon in the icon rail (clears graph + search state)
- Navigating to explorer without URL params no longer replays stale queries
@aaronsb
Copy link
Owner Author

aaronsb commented Feb 3, 2026

Code Review: Unified Smart Search with Exploration Tracking

Scope: 30 files, +2535/-1162 lines. Large feature branch with new exploration session system, unified search model, Cypher generator/parser, and significant context menu expansion.

Overall assessment: The architecture is sound. The parameter-presence model (deriveMode) is a clean improvement over the explicit mode enum, and the exploration session IR (intermediate representation) of { op, cypher }[] that all query modes compile to is a good unifying abstraction. The Cypher generator/parser roundtrip is well-structured. There are a few concrete issues to address.


Bug: Exploration step always records + regardless of loadMode

Location: web/src/components/shared/SearchBar.tsx:162

useGraphStore.getState().addExplorationStep({
  action: 'explore',
  op: loadMode === 'add' ? '+' : '+',  // <-- always '+'
  cypher: stepToCypher(stepParams),
  ...
});

The ternary loadMode === 'add' ? '+' : '+' evaluates to '+' in both branches. This appears to be an oversight -- a clean load that replaces the graph should arguably still be '+' (it is additive in the query program sense), but the expression reads as an accidental copy-paste. If both branches are intentionally '+', the ternary should be removed and replaced with the literal '+' to avoid confusing future readers. If clean loads were intended to get a different operator, the second branch needs the correct value.


Significant duplication: Path node extraction logic appears 3 times

Locations:

  • web/src/hooks/useGraphData.ts:195-241 (in useFindConnection)
  • web/src/components/shared/SearchBar.tsx:235-268 (in handleLoadPath)
  • web/src/explorers/common/useGraphContextMenu.ts:196-226 (in handleTravelPath)

All three implement the same algorithm: iterate path nodes, filter empty-ID nodes (Source/Ontology vertices from AGE traversal), collect relationship types between consecutive concept nodes, and build a { nodes, links } payload. The code is nearly identical across all three call sites.

This is a strong extraction candidate. A shared utility like extractConceptPathFromRawPath(path) => { nodes, links } would eliminate ~30 lines of duplication per call site and ensure the filtering logic stays consistent if the AGE response format evolves. The cypherResultMapper.ts module would be a natural home for this.


Type safety: Pervasive any on concept/path types

Several interfaces and handler signatures use any where narrower types exist or could be defined:

  • web/src/components/shared/search/ConceptSearchInput.tsx:11-13 -- results: any[], onSelect: (concept: any) => void
  • web/src/components/shared/search/PathResults.tsx:5-8 -- pathResults: any, selectedPath: any, onSelectPath: (path: any) => void
  • web/src/components/shared/SearchBar.tsx:58 -- selectedPrimary: useState<any>(null)
  • web/src/components/shared/SearchBar.tsx:68-69 -- pathResults: useState<any>(null), selectedPath: useState<any>(null)
  • web/src/store/graphStore.ts:150 -- rawGraphData: { nodes: any[]; links: any[] } | null

The API response types already exist in web/src/types/graph.ts (APIGraphNode, APIGraphLink). For the search results, the search API returns a known shape. For path results, the findConnection response has a definable structure. Narrowing these would catch ID mapping bugs at compile time rather than runtime -- especially relevant given the AGE internal ID vs concept_id theme in this PR.

Not a blocker for this PR, but worth noting: the rawGraphData store field using any[] for nodes and links means the entire pipeline downstream loses type information. The APIGraphNode[] / APIGraphLink[] types would be a drop-in improvement.


Race condition risk: Exploration step recorded before API call succeeds

Locations:

  • web/src/explorers/common/useGraphContextMenu.ts:82-102 (handleFollowConcept)
  • web/src/explorers/common/useGraphContextMenu.ts:107-131 (handleAddToGraph)

Both handlers call store.addExplorationStep(...) before the apiClient.getSubgraph() call. If the API call fails (which is caught and shows an alert), the exploration session now contains a step that never produced graph data. On replay, this step would attempt to execute its Cypher equivalent and may or may not succeed depending on why the original call failed.

Consider moving addExplorationStep into the success path (after the API response), or adding a rollback mechanism (e.g., removing the last step on catch). The same pattern appears in handleTravelPath at line 157, where the step is recorded before the mergeRawGraphData call.

For comparison, SearchBar.tsx:handleLoadExplore (line 160) does the same pre-recording, but since it delegates to setSearchParams -> React Query (which has its own retry/error handling), the failure mode is different.


Missing graphData: null on subtract operations

Location: web/src/store/graphStore.ts:324-357 (subtractRawGraphData)

When setRawGraphData is called, the caller typically also calls setGraphData(null) to force re-transformation. But subtractRawGraphData only updates rawGraphData without clearing graphData. The downstream useEffect in ExplorerView that watches rawGraphData and runs explorerPlugin.dataTransformer handles re-transformation, so this likely works in practice. However, there is a brief window where graphData contains nodes that no longer exist in rawGraphData. If any component reads graphData synchronously between the store update and the next React render cycle, it could reference stale nodes.

This is a low-severity issue since React's batched updates usually prevent intermediate reads, but it would be more defensive to clear graphData inside subtractRawGraphData the same way clearExploration does.


ExplorerView: useEffect dependencies are incomplete

Location: web/src/views/ExplorerView.tsx (around the data-loading effect)

useEffect(() => {
    const newData = mode === 'path' ? pathData : exploreData;
    if (!newData || newData === lastProcessedData.current) return;
    lastProcessedData.current = newData;
    ...
    if (loadModeRef.current === 'add') {
      mergeRawGraphData(graphPayload);
    } else {
      setGraphData(null);
      setRawGraphData(graphPayload);
    }
  }, [exploreData, pathData, mode]);

The effect uses mergeRawGraphData, setGraphData, and setRawGraphData from the store but they are not in the dependency array. Zustand store functions are stable references (they don't change between renders), so this will not cause bugs in practice. But the ESLint react-hooks/exhaustive-deps rule would flag it. Worth noting but not a functional issue.


handleRemoveFromGraph action label is misleading

Location: web/src/explorers/common/useGraphContextMenu.ts:135-151

store.addExplorationStep({
  action: 'add-adjacent',  // <-- this is a removal, not an add
  op: '-',
  cypher: stepToCypher({ action: 'add-adjacent', ... }),
  ...
});

The action is 'add-adjacent' but this is semantically a removal. The op: '-' correctly marks it as subtractive, and the Cypher it generates via stepToCypher will be the neighborhood MATCH (which is correct for replay -- subtracting a neighborhood means "remove these nodes"). However, in the generated Cypher script comments, this will appear as:

-- Step N: add-adjacent "NodeLabel"
- MATCH (c:Concept)-[r]-(n:Concept) WHERE c.label = 'NodeLabel' RETURN c, r, n;

The comment says "add-adjacent" for a subtraction step, which could be confusing when reading exported scripts. Consider adding a 'remove' action type, or at minimum adjusting the label generation in generateCypher to account for the op.


What looks good

  • deriveMode pattern: Eliminating the explicit mode enum in favor of parameter-presence is a clean design. DerivedMode being a pure function of SearchParams means no possibility of mode/params getting out of sync.

  • cypherGenerator.ts / cypherResultMapper.ts: Well-structured modules with clear doc comments. The escapeCypher function handles the right escape cases. The parseCypherStatements parser correctly handles continuation lines, blank-line flushing, and default-to-additive for unprefixed statements.

  • AGE ID mapping in cypherResultMapper.ts: The internalToConceptId map correctly remaps relationship endpoints from AGE internal vertex IDs to concept_id property values. This is the right place to centralize that mapping.

  • subtractRawGraphData: The cascading link removal (removing links that reference removed nodes via remainingNodeIds set) is correct and handles both from_id/to_id and D3-mutated source.id/target.id forms.

  • usePathEnrichment guard rails: Capping enrichment depth at 2 and skipping if >50 nodes prevents accidental explosion. The useQueries pattern for parallel fetching is appropriate here.

  • Migration 050: Properly idempotent with the schema_migrations check and ON CONFLICT DO NOTHING.

  • Settings panel relocation to sidebar: Moving graph settings from floating overlays inside the explorer to the IconRailPanel sidebar is a good UX improvement. The embedded prop on GraphSettingsPanel and Settings3DPanel is a clean way to support both contexts.


Summary

Category Count Severity
Bugs / logic errors 1 (always-+ ternary) Low -- cosmetic/confusing but not functionally broken
Race conditions 1 (step before API) Medium -- causes phantom steps on failure
Duplication 1 (path extraction x3) Medium -- maintenance risk
Type safety 1 (pervasive any) Low -- existing pattern, not new debt
Misleading semantics 1 (remove uses add-adjacent action) Low -- affects script readability
Stale state window 1 (subtract without clearing graphData) Low

None of these are merge-blockers. The most actionable items are the path extraction duplication (extract to shared utility) and the step-before-API-call pattern (move recording to success path).


AI-assisted review via Claude

- Remove dead ternary that always resolved to '+' (SearchBar.tsx)
- Move addExplorationStep after API success to prevent phantom steps
  on failure (useGraphContextMenu.ts)
- Use 'cypher' action type for remove operations so exported Cypher
  comments correctly reflect subtraction (useGraphContextMenu.ts)
- Extract duplicated AGE path extraction logic (SearchBar + context
  menu) into shared extractGraphFromPath() in cypherResultMapper.ts
- Add RawGraphData, PathResult, PathResultNode types to cypherResultMapper
- Type graphStore rawGraphData interface with RawGraphData instead of
  { nodes: any[]; links: any[] }
- Replace ~28 any annotations across store, context menu, SearchBar,
  and ExplorerView with RawGraphNode, RawGraphLink, and proper types
- Change catch (error: any) to catch (error: unknown) with narrowing
- Simplify store merge/subtract to use concept_id directly (canonical
  field) instead of defensive n.id || n.concept_id fallbacks
@aaronsb aaronsb merged commit 260a853 into main Feb 3, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant