-
+ {/*
*/}
diff --git a/src/app/api/assistant/route.ts b/src/app/api/assistant/route.ts
new file mode 100644
index 0000000..b8a04da
--- /dev/null
+++ b/src/app/api/assistant/route.ts
@@ -0,0 +1,103 @@
+import type { NextRequest } from 'next/server';
+import { SummarizerClient } from '@clinia/tritonclient';
+
+export const dynamic = 'force-dynamic';
+
+const INFERENCE_URL = process.env.INFERENCE_URL ?? 'http://127.0.0.1:8001';
+const MODEL_NAME =
+ process.env.INFERENCE_MODEL_NAME ?? 'summarizer_medical_journals_qa';
+const MODEL_VERSION = process.env.INFERENCE_MODEL_VERSION ?? '120240905190000';
+const client = new SummarizerClient(INFERENCE_URL);
+
+type InferParameter = {
+ parameterChoice: {
+ value: boolean;
+ };
+};
+const colors = [
+ '\x1b[31m', // Red
+ '\x1b[32m', // Green
+ '\x1b[33m', // Yellow
+ '\x1b[34m', // Blue
+ '\x1b[35m', // Magenta
+ '\x1b[36m', // Cyan
+ '\x1b[91m', // Bright Red
+ '\x1b[92m', // Bright Green
+ '\x1b[93m', // Bright Yellow
+ '\x1b[94m', // Bright Blue
+];
+const resetColor = '\x1b[0m'; // Reset color
+
+function getRandomColor(): string {
+ const randomIndex = Math.floor(Math.random() * colors.length);
+ return colors[randomIndex];
+}
+
+function shrinkText(text: string, maxLength: number): string {
+ return text.length > maxLength ? text.substring(0, maxLength) + '...' : text;
+}
+
+export async function POST(request: NextRequest) {
+ const { query, articles, mode } = (await request.json()) as {
+ query: string;
+ articles: string[];
+ mode?: string;
+ };
+ const maxChars = 25;
+ const start = Date.now();
+ const color = getRandomColor();
+ console.log(
+ `Received request with query: ${color}${shrinkText(query, maxChars)}${resetColor}`
+ );
+ const logEnd = () => {
+ console.log(
+ `Inference took ${'\x1b[36m'}${Date.now() - start}ms${resetColor} for query ${color}${shrinkText(query, maxChars)}${resetColor}`
+ );
+ };
+
+ let responseStream = new TransformStream();
+ const writer = responseStream.writable.getWriter();
+
+ const answerStream = client.streamAnswer(
+ MODEL_NAME,
+ MODEL_VERSION,
+ query,
+ articles,
+ mode ?? 'answer'
+ );
+
+ (async () => {
+ for await (const chunk of answerStream) {
+ // Assuming the text is in a property called 'text' in the chunk
+ const isFinished: InferParameter | undefined = chunk.inferResponse
+ ?.parameters['triton_final_response'] as InferParameter | undefined;
+ if (isFinished?.parameterChoice.value) {
+ logEnd();
+ await writer.close();
+ return;
+ }
+
+ const bytes = chunk.inferResponse?.rawOutputContents?.[0];
+ if (bytes === undefined || bytes.length <= 4) {
+ await writer.close();
+ return;
+ }
+
+ // Uncomment for debugging
+ // const text = new TextDecoder().decode(bytes?.slice(4));
+ // console.log(`Got chunk = ${text}`);
+ writer.write(bytes?.slice(4));
+ }
+
+ logEnd();
+ await writer.close();
+ })();
+
+ return new Response(responseStream.readable, {
+ headers: {
+ 'Content-Type': 'text/event-stream',
+ Connection: 'keep-alive',
+ 'Cache-Control': 'no-cache, no-transform',
+ },
+ });
+}
diff --git a/src/components/article-drawer.tsx b/src/components/article-drawer.tsx
index ee7b522..e5ee3f2 100644
--- a/src/components/article-drawer.tsx
+++ b/src/components/article-drawer.tsx
@@ -1,15 +1,38 @@
'use client';
+import { HitsHighlight } from '@/lib/client';
+import { getHighlightText } from '@/lib/client/util';
import { X } from 'lucide-react';
-import Markdown from 'react-markdown';
-import Link from 'next/link';
+import { useMemo } from 'react';
import { Button } from '@clinia-ui/react';
-import { PassageHighlight } from './highlight';
+import { HtmlDisplay } from './html-display';
import { useSearchLayout } from './search-layout';
export const ArticleDrawer = () => {
const searchLayout = useSearchLayout();
+ const hitsToDisplay = useMemo((): string[] => {
+ const allhighlights = Object.values(
+ searchLayout.hit?.highlighting ?? {}
+ ).flat();
+ if (allhighlights.length === 0) {
+ return [];
+ }
+
+ const hitsHighlights = allhighlights.filter(
+ (highlight): highlight is HitsHighlight =>
+ 'type' in highlight && highlight.type === 'hits'
+ );
+ if (hitsHighlights.length === 0) {
+ return allhighlights.map(getHighlightText);
+ }
+
+ // We display the hits by score
+ return hitsHighlights
+ .sort((a, b) => b.score - a.score)
+ .map(getHighlightText);
+ }, [searchLayout.hit?.highlighting]);
+
if (!searchLayout.hit) {
return null;
}
@@ -33,37 +56,16 @@ export const ArticleDrawer = () => {
- {searchLayout.hit.resource.title}
+ {searchLayout.hit.resource.data.title}
- {searchLayout.hit.highlight.map((highlight, idx) => (
-
(
-
- ),
- h2: (props) => (
-
- ),
- h3: (props) => (
-
- ),
- }}
+
+ {hitsToDisplay.map((highlight, idx) => (
+
- {highlight.match}
-
- //
+ html={highlight}
+ />
))}
-
-
-
- View full article
-
-
-
diff --git a/src/components/article-hit.tsx b/src/components/article-hit.tsx
index 6981dd9..e6dee3d 100644
--- a/src/components/article-hit.tsx
+++ b/src/components/article-hit.tsx
@@ -1,11 +1,35 @@
'use client';
-import { Article, Hit } from '@/lib/client';
-import { PassageHighlight } from './highlight';
+import { Article, Hit, HitsHighlight } from '@/lib/client';
+import { useMemo } from 'react';
+import { getHighlightText } from '../lib/client/util';
+import { HtmlDisplay } from './html-display';
import { useSearchLayout } from './search-layout';
export const ArticleHit = ({ hit }: { hit: Hit
}) => {
- const passageHighlight = hit.highlight.find((h) => h.type === 'passage');
+ // TODO: should take the highlighing from 'abstract.passages.vector' | 'content.text.passages.vector'
+ // We will take all the highlights from all the keys `abstract.passages.vector`, `content.text.passages.vector`, etc.
+ // Then we will display only the hit with the highest score as the single paragraph below.
+ const highestHitsHighlight = useMemo(() => {
+ const allHighlights = Object.values(hit.highlighting ?? {}).flat();
+ if (allHighlights.length === 0) {
+ return undefined;
+ }
+ const hitsHighlights = allHighlights.filter(
+ (highlight): highlight is HitsHighlight =>
+ 'type' in highlight && highlight.type === 'hits'
+ );
+ if (hitsHighlights.length === 0) {
+ // We fallback to displaying the first text highlight
+ return getHighlightText(allHighlights[0]);
+ }
+
+ return getHighlightText(
+ hitsHighlights
+ // We sort the hits by score and take the highest one
+ .sort((a, b) => b.score - a.score)[0]
+ );
+ }, [hit]);
const searchLayout = useSearchLayout();
return (
@@ -16,9 +40,12 @@ export const ArticleHit = ({ hit }: { hit: Hit }) => {
}}
>
- {hit.resource.title}
+ {hit.resource.data.title}
- {passageHighlight && }
+
+ {highestHitsHighlight && }
+
+ {/* {passageHighlight && } */}
);
};
diff --git a/src/components/assistant.tsx b/src/components/assistant.tsx
index c25977d..78939f7 100644
--- a/src/components/assistant.tsx
+++ b/src/components/assistant.tsx
@@ -2,22 +2,20 @@
import { Sparkles } from 'lucide-react';
import { twMerge } from 'tailwind-merge';
-import { useEffect, useState } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
import Markdown from 'react-markdown';
+import { V1Hit } from '@clinia/client-common';
+import { useHits, useQuery } from '@clinia/search-sdk-react';
import styles from './assistant.module.css';
-import { useEventSource, useEventSourceListener } from './use-event-source';
-import { useMeta } from './use-meta';
+import { useStreamRequest } from './use-stream-request';
export type AssistantProps = {
className?: string;
};
export const Assistant = ({ className }: AssistantProps) => {
- const meta = useMeta();
-
- if (meta.queryIntent !== 'QUESTION' || !meta.queryId) {
- return null;
- }
+ const hits = useHits();
+ const [query] = useQuery();
return (
@@ -26,7 +24,7 @@ export const Assistant = ({ className }: AssistantProps) => {
Assistant
@@ -34,29 +32,53 @@ export const Assistant = ({ className }: AssistantProps) => {
};
type AssistantListenerProps = {
- queryId: string;
+ query: string;
+ hits: V1Hit[];
};
-const AssistantListener = ({ queryId }: AssistantListenerProps) => {
+const AssistantListener = ({ hits, query }: AssistantListenerProps) => {
const [summary, setSummary] = useState('');
+ const queryRef = useRef(query);
- // Reset summary every time the query ID change
- useEffect(() => setSummary(''), [queryId]);
+ useEffect(() => {
+ // We store the query in a ref so that we only refetch the assistant when new articles are coming.
+ // This avoids doing a double-query in between the request-response from the query API.
+ queryRef.current = query;
+ setSummary('');
+ }, [query]);
- const [eventSource, eventSourceStatus] = useEventSource(
- `/api/query/${queryId}/answer`,
- true
- );
- useEventSourceListener(
- eventSource,
- ['message'],
- (evt) => {
- setSummary((s) => s + evt.data);
- },
- [setSummary]
+ const { refetch, status } = useStreamRequest(
+ useCallback(
+ (chunk: string) => {
+ setSummary((s) => s + chunk);
+ },
+ [setSummary]
+ )
);
+ // Reset summary every time the query changes
+ useEffect(() => {
+ if (hits.length === 0) return;
+ const passages = hits.flatMap((h) =>
+ (h.highlighting?.['abstract.passages'] ?? []).slice(0, 1).map((x) =>
+ JSON.stringify({
+ id: h.resource.id,
+ text: '',
+ title: h.resource.data.title,
+ passages: [x.highlight],
+ })
+ )
+ );
+ refetch(`/api/assistant`, {
+ method: 'POST',
+ body: JSON.stringify({
+ query: queryRef.current,
+ articles: passages.slice(0, 3),
+ }),
+ });
+ }, [hits, refetch]);
+
const classnames = [];
- if (eventSourceStatus === 'open' || eventSourceStatus === 'init') {
+ if (status === 'loading' || status === 'idle') {
classnames.push(styles.type);
}
@@ -75,5 +97,4 @@ const AssistantListener = ({ queryId }: AssistantListenerProps) => {
{summary}
);
- // return {summary}
;
};
diff --git a/src/components/combobox.tsx b/src/components/combobox.tsx
index 9d730b3..d8b2476 100644
--- a/src/components/combobox.tsx
+++ b/src/components/combobox.tsx
@@ -30,25 +30,25 @@ export const ComboBox = ({
const router = useRouter();
const pathname = usePathname();
- const groups = useMemo(
- () => [
- {
- heading: t('searchbox.groups.ask.heading'),
- icon: ,
- items: [
- 'How long to prepare for ACLR?',
- 'Recovery time for ACLR',
- 'How to treat a torn meniscus?',
- ],
- },
- {
- heading: t('searchbox.groups.search.heading'),
- icon: ,
- items: ['ACL recovery'],
- },
- ],
- [t]
- );
+ // const groups = useMemo(
+ // () => [
+ // {
+ // heading: t('searchbox.groups.ask.heading'),
+ // icon: ,
+ // items: [
+ // 'How long to prepare for ACLR?',
+ // 'Recovery time for ACLR',
+ // 'How to treat a torn meniscus?',
+ // ],
+ // },
+ // {
+ // heading: t('searchbox.groups.search.heading'),
+ // icon: ,
+ // items: ['ACL recovery'],
+ // },
+ // ],
+ // [t]
+ // );
const ref = useClickAway(() => setOpen(false));
@@ -71,7 +71,7 @@ export const ComboBox = ({
}}
/>
-
+ {/*
{open && (
<>
No results found.
@@ -98,7 +98,7 @@ export const ComboBox = ({
))}
>
)}
-
+ */}
);
};
diff --git a/src/components/highlight.tsx b/src/components/highlight.tsx
index eed69cf..2241855 100644
--- a/src/components/highlight.tsx
+++ b/src/components/highlight.tsx
@@ -1,40 +1,40 @@
-import { Highlight } from '@/lib/client';
+// import { Highlight } from '@/lib/client';
-export const PassageHighlight = ({ highlight }: { highlight: Highlight }) => {
- if (highlight.type !== 'passage') {
- return null;
- }
+// export const PassageHighlight = ({ highlight }: { highlight: Highlight }) => {
+// if (highlight.type !== 'passage') {
+// return null;
+// }
- const sentenceHighlight = highlight.highlight;
- if (sentenceHighlight?.type === 'sentence') {
- return (
-
- );
- }
+// const sentenceHighlight = highlight.highlight;
+// if (sentenceHighlight?.type === 'sentence') {
+// return (
+//
+// );
+// }
- return {highlight.match}
;
-};
+// return {highlight.match}
;
+// };
-export const SentenceHighlight = ({
- highlight,
- passage,
-}: {
- highlight: Highlight;
- passage: Highlight;
-}) => {
- // We get the start offset of the sentence with respect to the passage
- const startOffset = highlight.startOffset - passage.startOffset;
- const start = passage.match.slice(passage.startOffset, startOffset);
+// export const SentenceHighlight = ({
+// highlight,
+// passage,
+// }: {
+// highlight: Highlight;
+// passage: Highlight;
+// }) => {
+// // We get the start offset of the sentence with respect to the passage
+// const startOffset = highlight.startOffset - passage.startOffset;
+// const start = passage.match.slice(passage.startOffset, startOffset);
- // We get the end offset of the sentence with respect to the passage
- const endOffset = highlight.endOffset - passage.startOffset;
- const end = passage.match.slice(endOffset);
+// // We get the end offset of the sentence with respect to the passage
+// const endOffset = highlight.endOffset - passage.startOffset;
+// const end = passage.match.slice(endOffset);
- return (
-
- {start}
- {highlight.match}
- {end}
-
- );
-};
+// return (
+//
+// {start}
+// {highlight.match}
+// {end}
+//
+// );
+// };
diff --git a/src/components/html-display.tsx b/src/components/html-display.tsx
new file mode 100644
index 0000000..bd9724b
--- /dev/null
+++ b/src/components/html-display.tsx
@@ -0,0 +1,20 @@
+'use client';
+
+import sanitize from 'sanitize-html';
+import { useMemo } from 'react';
+
+type HtmlDisplayProps = {
+ className?: string;
+ html: string;
+};
+
+export const HtmlDisplay = ({ className, html }: HtmlDisplayProps) => {
+ const sanitizedHtml = useMemo(() => sanitize(html), [html]);
+
+ return (
+
+ );
+};
diff --git a/src/components/questions.tsx b/src/components/questions.tsx
index 45e5c94..9a3fe37 100644
--- a/src/components/questions.tsx
+++ b/src/components/questions.tsx
@@ -1,9 +1,7 @@
'use client';
import { twMerge } from 'tailwind-merge';
-import { useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
-import { useMeta } from './use-meta';
type QuestionsProps = {
className?: string;
@@ -34,15 +32,15 @@ export const Questions = ({ title, questions, className }: QuestionsProps) => {
);
};
-export const QuestionsResult = () => {
- const meta = useMeta();
- const t = useTranslations();
+// export const QuestionsResult = () => {
+// const meta = useMeta();
+// const t = useTranslations();
- if (!meta?.questions || meta.questions.length === 0) {
- return null;
- }
+// if (!meta?.questions || meta.questions.length === 0) {
+// return null;
+// }
- return (
-
- );
-};
+// return (
+//
+// );
+// };
diff --git a/src/components/search-provider.tsx b/src/components/search-provider.tsx
index aeef2f8..ad70b5b 100644
--- a/src/components/search-provider.tsx
+++ b/src/components/search-provider.tsx
@@ -1,8 +1,8 @@
'use client';
import { SearchRequest, SearchResponse } from '@/lib/client';
-import { client } from '@/lib/info-poc-client';
import { PropsWithChildren, use, useCallback, useEffect, useMemo } from 'react';
+import client from '@clinia/client-datapartition';
import {
SearchParameters,
type SearchSDKOptions,
@@ -17,19 +17,57 @@ type SearchProviderProps = PropsWithChildren<{
};
}>;
+const datapartitionClient = client(
+ 'clinia',
+ {
+ mode: 'BearerToken',
+ bearerToken: '',
+ },
+ {
+ hosts: [
+ {
+ url: 'localhost:3100/api',
+ protocol: 'http',
+ accept: 'readWrite',
+ },
+ ],
+ }
+);
+
export const SearchProvider = ({ children, state }: SearchProviderProps) => {
const search: SearchSDKOptions['search'] = async (_collection, params) => {
- const resp = await client.search({ query: params.query ?? '' });
- return {
- hits: resp.hits,
- meta: {
- numPages: 1,
- page: 1,
- perPage: 10,
- total: resp.hits.length,
- ...resp.meta,
+ const resp = await datapartitionClient.searchClient.query<
+ Record
+ >({
+ partitionKey: 'clinia',
+ collectionKey: 'articles',
+ v1SearchParameters: {
+ page: 0,
+ perPage: 5,
+ query: {
+ or: [
+ {
+ match: {
+ 'abstract.passages': {
+ value: params.query ?? '',
+ type: 'word',
+ },
+ },
+ },
+ {
+ knn: {
+ 'abstract.passages.vector': {
+ value: params.query ?? '',
+ },
+ },
+ },
+ ],
+ },
+ highlighting: ['abstract.passages'],
},
- };
+ });
+ // const resp = await client.search({ query: params.query ?? '' });
+ return resp;
};
const searchForFacets: SearchSDKOptions['searchForFacets'] =
diff --git a/src/components/use-event-source.ts b/src/components/use-event-source.ts
index 4bca698..afb5d64 100644
--- a/src/components/use-event-source.ts
+++ b/src/components/use-event-source.ts
@@ -11,7 +11,7 @@ export type EventSourceStatus = 'init' | 'open' | 'closed' | 'error';
export type EventSourceEvent = Event & { data: string };
export function useEventSource(
- url: string,
+ url?: string,
withCredentials?: boolean,
ESClass: EventSourceConstructor = EventSource
) {
diff --git a/src/components/use-stream-request.ts b/src/components/use-stream-request.ts
new file mode 100644
index 0000000..85826e6
--- /dev/null
+++ b/src/components/use-stream-request.ts
@@ -0,0 +1,56 @@
+import { useCallback, useRef, useState } from 'react';
+
+type StreamRequestStatus = 'idle' | 'loading' | 'error' | 'success';
+
+export function useStreamRequest(onData: (data: string) => void) {
+ const [status, setStatus] = useState('idle');
+ const controllerRef = useRef(null);
+
+ const refetch = useCallback(
+ async (url: string, request: RequestInit) => {
+ setStatus('loading');
+ if (controllerRef.current) {
+ console.warn('Aborting previous request');
+ controllerRef.current.abort();
+ }
+ const controller = new AbortController();
+ controllerRef.current = controller;
+
+ try {
+ const response = await fetch(url, {
+ signal: controller.signal,
+ headers: {
+ Accept: 'text/event-stream',
+ },
+ ...request,
+ });
+
+ if (!response.body) {
+ throw new Error('ReadableStream not supported in this environment.');
+ }
+
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+
+ let done = false;
+ while (!done) {
+ const { value, done: readerDone } = await reader.read();
+ done = readerDone;
+ if (value) {
+ const chunk = decoder.decode(value, { stream: true });
+ onData(chunk);
+ }
+ }
+
+ setStatus('success');
+ } catch (error) {
+ if ((error as Error)?.name !== 'AbortError') {
+ setStatus('error');
+ }
+ }
+ },
+ [onData]
+ );
+
+ return { refetch, status };
+}
diff --git a/src/lib/client/index.ts b/src/lib/client/index.ts
index 541a3ad..d860d06 100644
--- a/src/lib/client/index.ts
+++ b/src/lib/client/index.ts
@@ -1,2 +1,2 @@
export * from './client';
-export * from './types';
\ No newline at end of file
+export * from './types';
diff --git a/src/lib/client/types.ts b/src/lib/client/types.ts
index f991794..81ede1c 100644
--- a/src/lib/client/types.ts
+++ b/src/lib/client/types.ts
@@ -10,36 +10,46 @@ export type SearchResponse = {
hits: Hit[];
meta: {
queryId: string;
- queryIntent: 'QUESTION';
- questions: string[];
};
};
export type Hit = {
resource: T;
- highlight: Highlight[];
- enrichers: Enrichers;
+ highlighting?: Record;
};
export type Resource = {
id: string;
- [key: string]: any;
};
export type Article = Resource & {
- title: string;
- text: string;
+ data: {
+ title: string;
+ abstract: string;
+ content: [
+ {
+ title: string;
+ text: string;
+ },
+ ];
+ };
};
-export type Highlight = {
- match: string;
- startOffset: number;
- endOffset: number;
- type: 'passage' | 'sentence';
- highlight?: Highlight;
- score: number;
-};
+// Display a dumb fallback. We would ideally show `data` but if it's not respecting that shape let's fallback to `highlight`.
+export type Highlight =
+ | {
+ highlight: string;
+ }
+ | {
+ type: 'text';
+ highlight: string;
+ }
+ | HitsHighlight;
-export type Enrichers = {
- questions: string[];
+export type HitsHighlight = {
+ type: 'hits';
+ score: number;
+ data: string;
+ // content.0.passages.0
+ path: string;
};
diff --git a/src/lib/client/util.ts b/src/lib/client/util.ts
new file mode 100644
index 0000000..c7d3328
--- /dev/null
+++ b/src/lib/client/util.ts
@@ -0,0 +1,13 @@
+import type { Highlight } from './types';
+
+export const getHighlightText = (highlight: Highlight): string => {
+ if ('highlight' in highlight) {
+ return highlight.highlight;
+ }
+
+ if (highlight.type === 'hits' || 'data' in highlight) {
+ return highlight.data;
+ }
+
+ return '';
+};
diff --git a/src/pages/api/[...path].ts b/src/pages/api/[...path].ts
index 659a6f6..bf52060 100644
--- a/src/pages/api/[...path].ts
+++ b/src/pages/api/[...path].ts
@@ -23,7 +23,7 @@ async function proxy(req: NextApiRequest, res: NextApiResponse) {
req,
res,
{
- target: process.env.API_URL ?? 'http://localhost:7999',
+ target: process.env.API_URL ?? 'http://localhost:3000', // Atlas
},
(err: Error | null | undefined) => {
if (err) {