Skip to content

Commit

Permalink
feat: article hit with highlight
Browse files Browse the repository at this point in the history
  • Loading branch information
etiennecl committed Mar 11, 2024
1 parent 1e330e0 commit 9d292c9
Show file tree
Hide file tree
Showing 9 changed files with 200 additions and 83 deletions.
5 changes: 5 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@
"title": "Try asking..."
}
},
"search": {
"followUp": {
"title": "Follow up..."
}
},
"searchbox": {
"placeholder": "Search for words and phrases, or ask a question...",
"groups": {
Expand Down
19 changes: 5 additions & 14 deletions src/app/[locale]/search/page.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { Hits } from '@/components/hits';
import { QuestionsResult } from '@/components/questions';
import { SearchProvider } from '@/components/search-provider';
import { Searchbox } from '@/components/searchbox';
import { SearchRequest } from '@/lib/client';
import { getInfoPocServerClient } from '@/lib/info-poc-client';
import { SearchParameters } from '@clinia/search-sdk-core';

const parseSearchRequest = (searchParams: {
[key: string]: string | string[] | undefined;
Expand All @@ -27,19 +26,11 @@ export default async function Search({
}) {
const req = parseSearchRequest(searchParams);

const client = getInfoPocServerClient();

const resp = await client.search(req);

return (
<SearchProvider
state={{
searchRequest: req,
searchResponse: resp,
}}
>
<div className="grid justify-center">
<Searchbox className="w-[570px]" />
<SearchProvider>
<div className="grid justify-center gap-8">
<Searchbox className="w-[570px]" initialQuery={req.query} />
<QuestionsResult />
<Hits />
</div>
</SearchProvider>
Expand Down
89 changes: 84 additions & 5 deletions src/components/hits.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,97 @@
'use client';

import { Article, Hit } from '@/lib/client';
import { Article, Highlight, Hit } from '@/lib/client';
import { useHits } from '@clinia/search-sdk-react';

export const Hits = () => {
const hits = useHits() as Hit<Article>[];

if (hits.length === 0) {
return null;
}

return (
<div>
<div className="grid w-[674px] grid-cols-1 divide-y rounded-lg border">
{hits.map((hit) => (
<div key={hit.resource.id}>
<h2>{hit.resource.title}</h2>
</div>
<ArticleHit hit={hit} key={hit.resource.id} />
))}
</div>
);
};

const ArticleHit = ({ hit }: { hit: Hit<Article> }) => {
const passageHighlight = hit.highlight.find((h) => h.type === 'passage');

return (
<article className="p-4">
<h1 className="mb-2 text-lg font-medium text-foreground">
{hit.resource.title}
</h1>
{passageHighlight && (
<PassageHighlight
highlight={passageHighlight}
text={hit.resource.text}
/>
)}
</article>
);
};

const PassageHighlight = ({
highlight,
text,
}: {
highlight: Highlight;
text: string;
}) => {
if (highlight.type !== 'passage') {
return null;
}

const sentenceHighlight = highlight.highlight;
if (sentenceHighlight?.type === 'sentence') {
return (
<SentenceHighlight
highlight={sentenceHighlight}
passage={highlight}
text={text}
/>
);
}

return <p className="text-sm text-muted-foreground">{highlight.match}</p>;
};

const SentenceHighlight = ({
highlight,
text,
passage,
}: {
highlight: Highlight;
passage: Highlight;
text: string;
}) => {
// We get the start offset of the sentence with respect to the passage
const startOffset = highlight.startOffset - passage.startOffset;

console.log({
passageStartOffset: passage.startOffset,
sentenceStartOffset: highlight.startOffset,
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);

return (
<p className="text-sm text-muted-foreground">
{start}
<mark className="bg-primary/20">{highlight.match}</mark>
{end}
</p>
);
};
15 changes: 15 additions & 0 deletions src/components/questions.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
'use client';

import { useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { Button } from '@clinia-ui/react';
import { useMeta } from './use-meta';

type QuestionsProps = {
title: string;
Expand All @@ -28,3 +30,16 @@ export const Questions = ({ title, questions }: QuestionsProps) => {
</div>
);
};

export const QuestionsResult = () => {
const meta = useMeta();
const t = useTranslations();

if (!meta) {
return null;
}

return (
<Questions title={t('search.followUp.title')} questions={meta.questions} />
);
};
33 changes: 11 additions & 22 deletions src/components/search-provider.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import { SearchRequest, SearchResponse } from '@/lib/client';
import { client } from '@/lib/info-poc-client';
import { PropsWithChildren, use, useCallback, useEffect, useMemo } from 'react';
import {
SearchParameters,
Expand All @@ -18,13 +19,16 @@ type SearchProviderProps = PropsWithChildren<{

export const SearchProvider = ({ children, state }: SearchProviderProps) => {
const search: SearchSDKOptions['search'] = async (_collection, params) => {
const response = await fetch(`/api/search?q=${params.query}`);

if (!response.ok) {
throw new Error('Failed to fetch search results');
}

return await response.json();
const resp = await client.search({ query: params.query ?? '' });
return {
hits: resp.hits,
meta: {
numPages: 1,
page: 1,
perPage: 10,
total: resp.hits.length,
},
};
};

const searchForFacets: SearchSDKOptions['searchForFacets'] =
Expand Down Expand Up @@ -72,23 +76,8 @@ export const SearchProvider = ({ children, state }: SearchProviderProps) => {
}}
>
<Collection partition="main" collection="articles">
<CollectionObserver />
{children}
</Collection>
</SearchSDKProvider>
);
};

/**
* This component is used to observe the collection state and trigger side effects
*
**/
const CollectionObserver = () => {
const col = useCollection();

useEffect(() => {
col.search();
}, [col]);

return null;
};
33 changes: 28 additions & 5 deletions src/components/searchbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
import { useClickAway } from '@uidotdev/usehooks';
import { Search, Sparkles } from 'lucide-react';
import { twMerge } from 'tailwind-merge';
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useRouter } from 'next/router';
import { usePathname, useRouter } from 'next/navigation';
import { useQuery } from '@clinia/search-sdk-react';
import {
Command,
CommandEmpty,
Expand All @@ -15,13 +16,21 @@ import {
CommandList,
} from '@clinia-ui/react';

type SearchBoxProps = React.HTMLAttributes<HTMLDivElement>;
type SearchBoxProps = React.HTMLAttributes<HTMLDivElement> & {
initialQuery?: string;
};

export const Searchbox = ({ className, ...props }: SearchBoxProps) => {
export const Searchbox = ({
className,
initialQuery,
...props
}: SearchBoxProps) => {
const [query, setQuery] = useQuery();
const t = useTranslations();
const [value, setValue] = useState('');
const [value, setValue] = useState(initialQuery ?? '');
const [open, setOpen] = useState(false);
const router = useRouter();
const pathname = usePathname();

const groups = useMemo(
() => [
Expand All @@ -46,9 +55,23 @@ export const Searchbox = ({ className, ...props }: SearchBoxProps) => {
const ref = useClickAway<HTMLDivElement>(() => setOpen(false));

const handleSearch = (v: string) => {
if (pathname === '/search') {
setQuery(v);
setValue(v);
return;
}

// Else we push the new search query to the router
router.push(`/search?q=${v}`);
};

useEffect(() => {
if (pathname === '/search') {
setQuery(initialQuery);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- We only want to run this once
}, []);

return (
<Command className={twMerge('border', className)} loop ref={ref} {...props}>
<CommandInput
Expand Down
21 changes: 21 additions & 0 deletions src/components/use-meta.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { SearchResponse } from '@/lib/client';
import { useObserver } from '@clinia/search-sdk-react';

export const useMeta = () => {
const meta = useObserver({
key: 'meta',
onSearchStateChange: (state) => {
if (!state.result)
return {
queryId: '',
queryIntent: '',
query: '',
questions: [],
};

return state.result.meta as unknown as SearchResponse['meta'];
},
});

return meta;
};
60 changes: 29 additions & 31 deletions src/lib/client/types.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,45 @@
export type InformationPocClient = {
search: <T = Resource>(params: SearchRequest) => Promise<SearchResponse<T>>;
search: <T = Resource>(params: SearchRequest) => Promise<SearchResponse<T>>;
};


export type SearchRequest = {
query: string;
}
query: string;
};

export type SearchResponse<T = Resource> = {
hits: Hit<T>[];
meta: {
queryId: string
queryIntent: 'QUESTION'
questions: string[]
}
}
hits: Hit<T>[];
meta: {
queryId: string;
queryIntent: 'QUESTION';
questions: string[];
};
};

export type Hit<T = Resource> = {
resource: T;
highlight: Highlight[];
enrichers: Enrichers;
}

resource: T;
highlight: Highlight[];
enrichers: Enrichers;
};

export type Resource = {
id: string;
[key: string]: any;
}
id: string;
[key: string]: any;
};

export type Article = Resource & {
title: string;
text: string;
}
title: string;
text: string;
};

export type Highlight = {
match: string;
startOffset: number;
endOffset: number;
type: 'passage' | 'sentence';
highlight?: Highlight;
score: number;
}
match: string;
startOffset: number;
endOffset: number;
type: 'passage' | 'sentence';
highlight?: Highlight;
score: number;
};

export type Enrichers = {
questions: string[];
}
questions: string[];
};
Loading

0 comments on commit 9d292c9

Please sign in to comment.