08-portals-and-i18nwave: W5filled21 citations

DeepSearch — Semantic Patient Data Search

Audiences: doctor, developer, investor

DeepSearch — Semantic Patient Data Search

A separate Next.js 16 app on port 6220 — not a route inside ddx-web — that fronts the Qdrant vector store: clinicians ask a natural-language question, the app returns ranked chunks from OCR-ingested documents with patient and diagnosis context. Same Auth.js session, same BFF pattern, dedicated minimal shell.

Business Purpose

After a clinic has ingested years of scanned letters, lab reports, and imaging findings (through [[doxing]] OCR + chunking), the clinical question becomes "where did we see this before?". Keyword search rarely works on clinical prose ("hyperintensity in the left frontoparietal region" vs "white-matter lesion left front"). DeepSearch is the search-bar UX over the project's RAG pipeline: a vector query becomes ranked semantic chunks, scored, language-tagged, ICD-coded, attached to a patient name and source file — clinicians read three or four chunks and know whether to open the underlying document.

It is also a deliberately isolated app (port 6220, separate deploy) for two reasons. (1) Search is high-RPS and the team wanted independent scaling from the main HMS portal. (2) The reference architecture for a sandbox/embeddable Dudoxx surface — a Next.js shell that consumes the same Auth.js cookie and BFF gateway but ships independently.

Audiences

  • Investor: a worked example of the AI moat — once the OCR pipeline has ingested the practice's archive, semantic search is the daily "wow" demo for clinicians; "find every mention of methotrexate dosing questions in the last two years" returns instantly.
  • Doctor: the search bar for clinical recall. Quick actions ("Patient History", "Medication Review", "Treatment Guidelines", "Lab Results") seed common queries.
  • Developer/partner: shows how to build a second Dudoxx Next.js app that shares the same Auth.js cookie + BFF gateway pattern without living inside ddx-web. Useful template for the partner-portal idea and any future dedicated surface.
  • Internal (ops/support): port 6220, dudoxx-deepsearch.{trigram}.dudoxx.com vhost convention, depends on Qdrant + ddx-api OCR endpoints.

Architecture

Separate app, same identity

diagram
ddx-deepsearch/                        ← Next.js 16.2.1, port 6220
├── src/
│   ├── proxy.ts                        → minimal: locale + session check
│   ├── auth.ts                         → Auth.js v5 (shares ddx-web cookie)
│   ├── app/
│   │   ├── [locale]/
│   │   │   ├── auth/                    → login forms (when no session)
│   │   │   ├── (shell)/                 → DeepSearchShell route group
│   │   │   │   ├── layout.tsx            → mounts <DeepSearchShell>
│   │   │   │   ├── page.tsx              → root search UX
│   │   │   │   ├── dashboard/            → stats + onboarding
│   │   │   │   ├── patients/             → patient-scoped search
│   │   │   │   │   ├── page.tsx          → patient list
│   │   │   │   │   └── [id]/             → per-patient documents
│   │   │   │   ├── history/, saved/, help/, settings/
│   │   ├── api/
│   │   │   ├── auth/                    → Auth.js routes
│   │   │   └── v1/[...path]/             → BFF catch-all (browser → ddx-api)
│   ├── components/
│   │   ├── search/SearchClient.tsx       → main vector-search UX
│   │   ├── patients/                     → 13 patient/document widgets
│   │   ├── DeepSearchShell.tsx           → minimal header + side nav + footer
│   ├── lib/
│   │   ├── api/                          → chunk-service, gateway-headers
│   │   └── sse/                          → use-job-events + use-extraction-events

Minimal proxy.ts

ddx-deepsearch/src/proxy.ts:38-59 is half the size of ddx-web/src/proxy.ts: locale extraction, isAuthPath short-circuit, then hasSessionCookie — no trigram lookup, no PROTECTED_ROUTES list, no header injection. There is no per-tenant trigram resolution here because the Auth.js session already carries organizationId and the BFF builds the gateway headers from that.

typescript
if (!hasSessionCookie(request)) {
  const loginUrl = new URL(`/${locale}/auth/login`, request.url);
  loginUrl.searchParams.set('callbackUrl', pathWithoutLocale);
  return NextResponse.redirect(loginUrl);
}

BFF catch-all — app/api/v1/[...path]/route.ts

ddx-deepsearch/src/app/api/v1/[...path]/route.ts:26-131 is the same Trusted Gateway pattern as ddx-web, simplified:

  • buildGatewayHeaders(user, accessToken) from lib/api/gateway-headers.ts — same X-API-Key, X-Gateway-User-* header set as ddx-web.
  • BACKEND_URL resolves to ddx-api (port 6100).
  • Binary-safe body forwarding (request.arrayBuffer():59).
  • SSE pass-through (:81-91): preserves text/event-stream, sets Cache-Control: no-cache, no-transform, Connection: keep-alive, X-Accel-Buffering: no — the same convention as ddx-web.
  • Document preview iframe support (:99-108): for PDF/image/audio/ video content types, removes X-Frame-Options and sets Content-Security-Policy: frame-ancestors 'self' so the in-app DocumentPreviewPanel can embed.

Search UX — SearchClient.tsx

ddx-deepsearch/src/components/search/SearchClient.tsx:11-120 is the flagship client component:

  • 500ms debounced input → searchChunks(query, 'ddx-ocr-chunks', { limit: 20, scoreThreshold: 0.3 }).
  • Four quick actions seed the query (QUICK_ACTIONS:8: patientHistory, medicationReview, treatmentGuidelines, labResults).
  • Loading / error / empty / results states all on a single component (:84-118).

Results are DocumentChunk records (chunk-service.ts:14-31): qdrant_id, chunk_index, text, contextual_prefix, score, source_file, task_id, optional session_id, document_type, patient_name, diagnoses (array), icd_codes (array), language, collection.

chunk-service.ts — client-side wrapper

ddx-deepsearch/src/lib/api/chunk-service.ts:1-31 is 'use client' and imports apiClient (./client). It hits ddx-api's OCR chunk endpoints via the BFF — browser → /api/v1/qdrant/search/... → ddx-deepsearch catch-all → ddx-api → Qdrant.

Patient-scoped variants

ddx-deepsearch/src/components/patients/ has 13 components: PatientsListClient, PatientCard, PatientBanner, PatientDocumentsClient, DocumentCard, DocumentDetailClient, DocumentPreviewPanel, ChunksTabContent, DocumentActions, DocumentFilters, JobProgressBar, MediaPlayer, PatientAvatar. The (shell)/patients/[id]/ route shows a per-patient document explorer with chunk filtering, preview, and a job-progress bar for re-OCR.

Real-time updates — SSE

ddx-deepsearch/src/lib/sse/:

  • use-job-events.ts — subscribe to ingestion job progress (re-OCR, re-index).
  • use-extraction-events.ts — subscribe to per-document extraction events (chunk insertion, embedding completion).

Both use EventSource('/api/v1/sse/...') — the BFF preserves text/event-stream end-to-end.

Tech Stack & Choices

LayerChoiceWhy
FrameworkNext.js 16.2.1 + React 19.2.4 + Tailwind 4.2.2Same major versions as ddx-web; allows shared snippets, deps, and CI.
App separationStandalone ddx-deepsearch/ repo dir, port 6220Independent deploy + scaling; search is high-RPS and benefits from separate horizontal scale.
AuthAuth.js v5 same cookie familyOne login, two apps. Cookie naming via getDdxAuthSessionCookieCandidates() keeps both in sync.
BFFCatch-all app/api/v1/[...path]/route.tsSame Trusted Gateway pattern; ddx-deepsearch never exposes ddx-api directly.
Vector storeQdrant via ddx-api qdrant moduleBrowser never touches Qdrant; everything proxies.
Search collectionddx-ocr-chunks default (SearchClient.tsx:9)Single named collection; chunks are language- and ICD-tagged at ingestion.
Score threshold0.3 default (SearchClient.tsx:28)Conservative — high-precision; users can request more recall via filters.
Localesen/fr/de via next-intl 4.8.3Mirrors ddx-web; all UI strings translated.
Real-timeSSE via EventSourceSame SSE contract as ddx-web; preserved end-to-end through both BFF gateways.
StateTanStack Query 5.95.2, nuqs 2.4.1Search/filter URL state via nuqs; pagination/caching via Query.

Rejected: building DeepSearch as a route inside ddx-web (would contaminate the main app's bundle with heavy patient-document viewers and a vector-search dependency tree); shipping a direct browser → Qdrant client (would expose the Qdrant API key and force Qdrant CORS); using a separate auth pool (would double-prompt users).

Data Flow

(1) Search a chunk

  1. Doctor logs in to ddx-web (acm.dudoxx.com); Auth.js sets the __Secure-...-ddx-auth-session-token cookie scoped to .dudoxx.com.
  2. Doctor navigates to acm-deepsearch.dudoxx.com (or the wired ddx-web shortcut); the cookie is sent.
  3. ddx-deepsearch/src/proxy.ts:50-56 sees a session cookie and proceeds.
  4. The shell renders; doctor types a query in SearchClient.
  5. After 500ms debounce, searchChunks('white matter lesion', 'ddx-ocr-chunks', { limit: 20, scoreThreshold: 0.3 }).
  6. Browser POSTs to /api/v1/qdrant/search/... (same-origin).
  7. ddx-deepsearch/src/app/api/v1/[...path]/route.ts builds the X-Gateway-User-* header set and forwards to ddx-api.
  8. ddx-api QdrantController runs the vector search against ddx-ocr-chunks, joins patient + ICD + language metadata, returns ranked chunks.
  9. SearchClient renders SearchResultCard rows with score, snippet, patient name, ICD codes.

(2) Open a chunk's source document

  1. Click → navigate to /<locale>/patients/<id> (in-app route).
  2. (shell)/patients/[id]/page.tsx server-fetches the patient + ranked documents via the BFF.
  3. DocumentPreviewPanel requests the binary preview via /api/v1/documents/<id>/preview — BFF removes X-Frame-Options (route.ts:99-108) and the panel embeds the PDF/image inline.

(3) Re-ingest job stream

  1. Triggering a re-OCR opens an SSE channel /api/v1/jobs/<jobId>/events.
  2. use-job-events subscribes; chunks insert into Qdrant as they process; the JobProgressBar advances.
  3. After completion, the search results live-update.

Implicated Code

  • ddx-deepsearch/src/proxy.ts:38-59 — minimal locale + session middleware.
  • ddx-deepsearch/src/proxy.ts:21AUTH_PATHS exception list.
  • ddx-deepsearch/src/proxy.ts:27-36hasSessionCookie via getDdxAuthSessionCookieCandidates().
  • ddx-deepsearch/src/auth.ts — Auth.js v5 setup (shares the ddx-web cookie family).
  • ddx-deepsearch/src/app/[locale]/(shell)/layout.tsx:1-17 — shell layout: read session, mount <DeepSearchShell>.
  • ddx-deepsearch/src/app/[locale]/(shell)/dashboard/page.tsx:7-72 — greeting + stats + getStarted + quickActions; quick-action links to /, /history, /saved, /help.
  • ddx-deepsearch/src/components/search/SearchClient.tsx:11-120 — flagship search UX; QUICK_ACTIONS at :8, debounce at :41, result rendering at :96-110.
  • ddx-deepsearch/src/lib/api/chunk-service.ts:14-44DocumentChunk, ChunksResponse, SemanticSearchResult interfaces.
  • ddx-deepsearch/src/app/api/v1/[...path]/route.ts:26-131 — BFF catch-all; SSE handling at :81-91; iframe header normalisation at :99-108.
  • ddx-deepsearch/src/components/patients/ — 13 patient + document components.
  • ddx-deepsearch/src/lib/sse/use-job-events.ts — ingestion job progress stream.
  • ddx-deepsearch/src/lib/sse/use-extraction-events.ts — per-document extraction events.
  • ddx-deepsearch/package.json:5-9 — port 6220, version 0.7.7, next 16.2.1.
  • ddx-api/src/platform/qdrant/ — backend vector-search endpoints (consumer of [[rag-system-indexation]] artefacts).

Operational Notes

  • Port 6220 is canonicaldudoxx-{trigram}-deepsearch.dudoxx.com vhost convention; PM2 entry must use next start --port 6220. Conflict with another local Next.js will silently fail to bind.
  • Cookie must match ddx-webgetDdxAuthSessionCookieCandidates() is the single source of truth. Auth.js cookie name drift (e.g. moving from authjs to next-auth) must propagate to both apps simultaneously or the doctor sees a login screen in DeepSearch only.
  • No X-Clinic-ID injection in proxy — DeepSearch does not do trigram lookup. Tenant routing happens server-side in the BFF (gateway-headers.ts:resolveClinicId(user)); never read the Host header for tenant logic here.
  • Default collection: ddx-ocr-chunksSearchClient.tsx:9 hard codes this. If you split collections per language/clinic in [[rag-system-indexation]], surface a UI selector before renaming the constant — silent partial-collection results are a worse UX than a visible dropdown.
  • scoreThreshold: 0.3SearchClient.tsx:28. Tuning hits two signals: too-high → empty results on legitimate queries (false negatives); too-low → noise. Adjust against a labeled test set.
  • iframe-preview content-typesroute.ts:99-108 only strips X-Frame-Options for application/pdf, image/*, audio/*, video/*. New media types must be added explicitly; do not blanket- strip the header.
  • No Qdrant in the browserchunk-service.ts imports apiClient, not a Qdrant client. Even if Qdrant adds a JS SDK, do not import it into a 'use client' file — it would leak the API key and break the Trusted Gateway invariant.
  • Build vs runtimepnpm dev runs on port 6220; production pnpm start reads the build/ directory. The ddx-web build pitfall (distDir env-aware) does NOT apply here — DeepSearch uses the default .next directory.
    DeepSearch — Semantic Patient Data Search — Dudoxx Docs | Dudoxx