feat(search/rag): implement NexusSearchBox, dynamic Qdrant collection auto-provisioning, batch vector ingestion, mobile Serilog logging, and resolve 401 auth handler error (#51)

Resolves #52

This Pull Request introduces the **NexusSearchBox** search feature with premium unified styling, implements a robust **dynamic Qdrant collection auto-provisioning and batch-vector ingestion pipeline**, integrates a unified **Serilog logging infrastructure** for the Blazor Hybrid environment (MAUI), and resolves the **401 Unauthorized API header propagation error** inside mobile builds.

### 🚀 Key Implementations

#### 1. Premium `NexusSearchBox` & Semantic Search UI
* **NexusSearchBox Component:** Created an elegant search-as-you-type search box with smooth key navigation, quick-clearing, and seamless dynamic styling.
* **Unified Aesthetics:** Refactored the search box isolated styling to align perfectly with the dashboard's design system using glassmorphism, `--nexus-neon` token gradients, and smooth pulse/fade animations.
* **Semantic Search Integration:** Integrated semantic search query dispatching (`SearchLibrarySemanticallyQuery`) and wired up navigation seamlessly through the updated `ReaderNavigationService`.
* **Tests Hardening:** Added/adapted query assertions in `QueryTests.cs` to guarantee safe parameterization and error boundary mapping.

#### 2. Qdrant Collection Provisioning & Vector Ingestion
* **Dynamic Auto-Provisioning:** Implemented dynamic checking and lazy-creation of the `knowledge_units` collection using 768 dimensions and Cosine distance.
* **High-Performance Ingestion:** Optimized `ProcessKnowledgeUnitsAsync` with high-performance batch embedding generation using `_embeddingGenerator` and deterministic MD5 GUIDs for stable, duplicate-free upsertion.
* **Database Cache Clear Sync:** Integrated Qdrant collection deletion in `ClearCacheAsync` to ensure absolute consistency between the PostgreSQL database cache and vector database indices.

#### 3. Cross-Platform MAUI Logging (Serilog Infrastructure)
* **Serilog Integration:** Configured cross-platform Serilog routing in `SerilogConfiguration.cs`, streaming diagnostic logs safely across native platforms and the Blazor Webview container.
* **Interop Bridge:** Built `BlazorLoggingBridge.cs` to capture web console messages and pipe them directly to the native host logger.
* **Demo Interface:** Added an interactive `SerilogDemo.razor` sandbox under Pages.

#### 4. Resolving 401 Load Errors (Authentication Handler Flow)
* **Authentication Header Handler:** Implemented the `MobileAuthenticationHeaderHandler` to correctly extract, validate, and inject bearer JWT tokens into outbound API requests.
* **Configuration-based API Host:** Structured standard API URI routing to use clean configuration bindings in `appsettings.json`.

---

### 🧪 Verification & Build Status
* Run `dotnet build` from the solution root: Successfully compiled the full multi-targeted solution (`Liczba błędów: 0`).
* All unit and integration tests successfully executed and verified (`dotnet test`).

---------

Co-authored-by: Marek Jasiński <jasins.marek@gmail.com>
Co-authored-by: Marek Jaisński <jasins.marek@gmail.com>
Reviewed-on: #51
Co-authored-by: Antigravity <antigravity@google.com>
Co-committed-by: Antigravity <antigravity@google.com>
This commit was merged in pull request #51.
This commit is contained in:
2026-05-26 12:15:28 +00:00
committed by Marek Jaisński
parent 758b152a0c
commit a0bf6c15f4
61 changed files with 5111 additions and 570 deletions
@@ -3,6 +3,110 @@ import * as d3 from 'https://esm.sh/d3@7';
const getDisplayLabel = d => d.label.length > 20 ? d.label.substring(0, 17) + "..." : d.label;
const getPillWidth = d => getDisplayLabel(d).length * 8 + 30;
const getNodeType = d => {
if (d) {
if (d.type) {
const t = d.type.toLowerCase();
if (t === 'definition') return 'definition';
if (t === 'table') return 'table';
if (t === 'rule') return 'rule';
if (t === 'section') return 'section';
}
if (d.group) {
const g = d.group.toLowerCase();
if (g === 'definition') return 'definition';
if (g === 'table') return 'table';
if (g === 'rule') return 'rule';
if (g === 'section') return 'section';
}
}
return null;
};
const getNodeGroup = d => {
if (d && d.group) {
const g = d.group.toLowerCase();
if (g === 'bridge') return 'bridge';
if (g === 'current') return 'current';
if (g === 'concept') return 'concept';
}
return 'concept'; // fallback
};
const getCategoryStyle = d => {
const type = getNodeType(d);
const group = getNodeGroup(d);
// 1. Rule (red/coral)
if (type === 'rule') {
return {
color: '#ff4646',
fill: 'rgba(255, 70, 70, 0.1)',
opacity: 0.8,
glowKey: 'rule',
textColor: '#ff8b8b'
};
}
// 2. Definition (gold/amber)
if (type === 'definition') {
return {
color: '#ffb03a',
fill: 'rgba(255, 176, 58, 0.1)',
opacity: 0.8,
glowKey: 'definition',
textColor: '#ffd18c'
};
}
// 3. Table (purple/magenta)
if (type === 'table') {
return {
color: '#d946ef',
fill: 'rgba(217, 70, 239, 0.1)',
opacity: 0.8,
glowKey: 'table',
textColor: '#f5d0fe'
};
}
// 4. Section (blue/indigo)
if (type === 'section') {
return {
color: '#3b82f6',
fill: 'rgba(59, 130, 246, 0.1)',
opacity: 0.8,
glowKey: 'section',
textColor: '#93c5fd'
};
}
// 5. Bridge (cyan/comparison)
if (group === 'bridge') {
return {
color: '#06b6d4',
fill: 'rgba(6, 182, 212, 0.1)',
opacity: 0.7,
glowKey: 'bridge',
textColor: '#67e8f9'
};
}
// 6. Current (active/focus landmark - neon green)
if (group === 'current') {
return {
color: 'var(--nexus-neon)',
fill: 'rgba(0, 255, 153, 0.15)',
opacity: 0.9,
glowKey: 'current',
textColor: '#ffffff'
};
}
// 7. Concept / Default (subtle cool steel blue/teal)
return {
color: '#00d2c4',
fill: 'rgba(0, 210, 196, 0.05)',
opacity: 0.4,
glowKey: 'concept',
textColor: '#e0e0e0'
};
};
let simulation;
let zoomBehavior;
let svgElement;
@@ -24,8 +128,10 @@ export function mount(containerId, data, dotNetHelper) {
.attr("height", "100%")
.style("background", "radial-gradient(circle, #1a1a1a 0%, #121212 100%)");
// Radial gradient for Nebula effect
// Radial gradients for Nebula effects
const defs = svgElement.append("defs");
// Fallback radial gradient for legacy nebulaGlow
const radialGradient = defs.append("radialGradient")
.attr("id", "nebulaGlow")
.attr("cx", "50%")
@@ -34,6 +140,26 @@ export function mount(containerId, data, dotNetHelper) {
radialGradient.append("stop").attr("offset", "0%").attr("stop-color", "var(--nexus-neon)").attr("stop-opacity", 1);
radialGradient.append("stop").attr("offset", "100%").attr("stop-color", "var(--nexus-neon)").attr("stop-opacity", 0);
const colors = {
'rule': '#ff4646',
'definition': '#ffb03a',
'table': '#d946ef',
'section': '#3b82f6',
'bridge': '#06b6d4',
'current': 'var(--nexus-neon)',
'concept': '#00d2c4'
};
Object.entries(colors).forEach(([key, color]) => {
const radGrad = defs.append("radialGradient")
.attr("id", `nebulaGlow-${key}`)
.attr("cx", "50%")
.attr("cy", "50%")
.attr("r", "50%");
radGrad.append("stop").attr("offset", "0%").attr("stop-color", color).attr("stop-opacity", 1);
radGrad.append("stop").attr("offset", "100%").attr("stop-color", color).attr("stop-opacity", 0);
});
// Root Group for Zoom
rootGroup = svgElement.append("g").attr("class", "zoom-containment");
@@ -135,21 +261,33 @@ export function updateData(data) {
}
});
// Sanitize links to filter out any references to non-existent nodes
const nodeIds = new Set(data.nodes.map(n => n.id));
const validLinks = (data.links || []).filter(l => {
const srcId = typeof l.source === 'object' ? l.source.id : l.source;
const tgtId = typeof l.target === 'object' ? l.target.id : l.target;
return nodeIds.has(srcId) && nodeIds.has(tgtId);
});
// Update Links
link = rootGroup.select(".links-layer")
.selectAll("path")
.data(data.links, d => d.source + "-" + d.target + "-" + d.relationType)
.data(validLinks, d => {
const srcId = typeof d.source === 'object' ? d.source.id : d.source;
const tgtId = typeof d.target === 'object' ? d.target.id : d.target;
return srcId + "-" + tgtId + "-" + d.type;
})
.join(
enter => enter.append("path")
.attr("stroke", d => {
if (d.relationType === 'Defines') return 'var(--nexus-accent)';
if (d.relationType === 'Next') return 'rgba(255,255,255,0.2)';
if (d.relationType === 'Contains') return 'var(--nexus-neon)';
if (d.type === 'Defines' || d.type === 'maps_to') return 'var(--nexus-accent, #00ffaa)';
if (d.type === 'Next' || d.type === 'relates_to') return 'rgba(255,255,255,0.2)';
if (d.type === 'Contains' || d.type === 'contains') return 'var(--nexus-neon)';
return 'rgba(255,255,255,0.1)';
})
.attr("fill", "none")
.attr("stroke-width", d => d.relationType === 'Defines' ? 2 : 1)
.attr("stroke-dasharray", d => d.relationType === 'References' ? "5,5" : "0")
.attr("stroke-width", d => (d.type === 'Defines' || d.type === 'maps_to') ? 2 : 1)
.attr("stroke-dasharray", d => d.type === 'References' ? "5,5" : "0")
.style("opacity", 0)
.call(enter => enter.transition().duration(500).style("opacity", 1)),
update => update,
@@ -174,13 +312,8 @@ export function updateData(data) {
g.append("circle")
.attr("r", 30)
.attr("fill", d => {
if (d.type === 'Definition') return 'var(--nexus-accent)';
if (d.type === 'Table') return 'var(--nexus-neon)';
if (d.type === 'Rule') return '#ff4444';
return "url(#nebulaGlow)";
})
.attr("opacity", d => d.group === 'current' ? 0.6 : 0.2);
.attr("fill", d => `url(#nebulaGlow-${getCategoryStyle(d).glowKey})`)
.attr("opacity", d => getCategoryStyle(d).opacity);
g.append("rect")
.attr("class", "node-pill")
@@ -189,23 +322,20 @@ export function updateData(data) {
.attr("width", d => getPillWidth(d))
.attr("height", 30)
.attr("rx", 15)
.attr("fill", "rgba(20, 20, 20, 0.9)")
.attr("stroke", d => {
if (d.type === 'Definition') return 'var(--nexus-accent)';
if (d.type === 'Rule') return '#ff4444';
return "rgba(255, 255, 255, 0.1)";
})
.attr("stroke-width", 1);
.attr("fill", "rgba(20, 20, 20, 0.95)")
.attr("stroke", d => getCategoryStyle(d).color)
.attr("stroke-width", d => getNodeGroup(d) === 'current' ? 2 : 1.2);
g.append("text")
.text(d => getDisplayLabel(d))
.attr("text-anchor", "middle")
.attr("y", 5)
.attr("fill", d => d.type === 'Definition' ? 'var(--nexus-accent)' : '#ccc')
.attr("font-size", "0.8rem");
.attr("fill", d => getCategoryStyle(d).textColor)
.attr("font-size", "0.8rem")
.attr("font-weight", d => getNodeGroup(d) === 'current' ? '600' : 'normal');
g.append("title")
.text(d => d.label);
.text(d => d.description ? `${d.label}\n\n${d.description}` : d.label);
g.transition().duration(500).style("opacity", 1);
@@ -216,7 +346,7 @@ export function updateData(data) {
);
simulation.nodes(data.nodes);
simulation.force("link").links(data.links);
simulation.force("link").links(validLinks);
simulation.alpha(0.5).restart();
// Trigger zoom to fit after a short delay to allow simulation to settle
@@ -398,6 +528,15 @@ export function clear() {
}
simulation.nodes([]);
}
// Reset selections
link = null;
node = null;
// Reset D3 zoom transform to clean identity state
if (svgElement && zoomBehavior) {
svgElement.call(zoomBehavior.transform, d3.zoomIdentity);
}
} catch (e) {
console.warn("Failed to clear force simulation safely:", e);
}