Skip to content

Commit fff2aa6

Browse files
committed
feat(search): show the matching passage and the author on each result
A result's snippet is the window around the first query term, skipping the header block an email carries, so the row shows why the document matched; its meta line names the source, the person behind the document from its author-like tag, and the date. The agent's citations carry the author too.
1 parent dde4920 commit fff2aa6

10 files changed

Lines changed: 206 additions & 15 deletions

File tree

apps/sim/app/api/knowledge/search/route.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
import { internalKnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies'
88
import { knowledgeOperations } from '@/lib/knowledge/application/operations'
99
import { searchKnowledge } from '@/lib/knowledge/application/search'
10+
import { sourceAuthor } from '@/lib/knowledge/search/author'
1011

1112
export const POST = defineInternalJsonRoute({
1213
contract: searchWorkspaceKnowledgeContract,
@@ -37,6 +38,7 @@ export const POST = defineInternalJsonRoute({
3738
sourceUrl: result.sourceUrl,
3839
connectorType: result.connectorType,
3940
sourceModifiedAt: result.sourceModifiedAt?.toISOString() ?? null,
41+
author: sourceAuthor(result.metadata),
4042
content: result.content,
4143
chunkIndex: result.chunkIndex,
4244
similarity: result.similarity,

apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { useMemo, useState } from 'react'
44
import { Button, Chip } from '@sim/emcn'
55
import type { WorkspaceKnowledgeSearchResult } from '@/lib/api/contracts/knowledge'
6+
import { matchSnippet } from '@/lib/knowledge/search/snippet'
67
import { connectorDisplayName } from '@/lib/sim-search/connectors'
78
import { SourceCard } from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-card'
89
import type { SourceTagData } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
@@ -18,8 +19,6 @@ const EMPTY_MEMBER_CONNECTORS: WorkspaceMemberConnector[] = []
1819

1920
/** A search spans at most this many knowledge bases. */
2021
const MAX_SEARCHED_KNOWLEDGE_BASES = 20
21-
/** Characters of the matching chunk shown under a result. */
22-
const SNIPPET_LENGTH = 280
2322
/** Filters appear only once a list is long and mixed enough for them to help. */
2423
const FILTERS_MIN_RESULTS = 10
2524
const DAY_MS = 24 * 60 * 60 * 1000
@@ -31,11 +30,6 @@ const UPDATED_WINDOWS = [
3130
] as const
3231
type UpdatedWindow = (typeof UPDATED_WINDOWS)[number]['id']
3332

34-
function toSnippet(content: string): string {
35-
const flat = content.replace(/\s+/g, ' ').trim()
36-
return flat.length > SNIPPET_LENGTH ? `${flat.slice(0, SNIPPET_LENGTH).trimEnd()}…` : flat
37-
}
38-
3933
/**
4034
* One card per document, keeping the best-ranked chunk of each: the list is
4135
* already in rank order, so the first chunk seen for a document is its best.
@@ -77,7 +71,7 @@ export function indexingSourceNames(
7771
* source app, or the knowledge base for an upload. A document without a
7872
* source URL cannot be opened.
7973
*/
80-
function toSource(result: WorkspaceKnowledgeSearchResult): SourceTagData | null {
74+
function toSource(result: WorkspaceKnowledgeSearchResult, query: string): SourceTagData | null {
8175
if (!result.sourceUrl) return null
8276
return {
8377
url: result.sourceUrl,
@@ -86,7 +80,8 @@ function toSource(result: WorkspaceKnowledgeSearchResult): SourceTagData | null
8680
? connectorDisplayName(result.connectorType)
8781
: result.knowledgeBaseName || undefined,
8882
connectorType: result.connectorType ?? undefined,
89-
snippet: toSnippet(result.content),
83+
snippet: matchSnippet(result.content, query),
84+
author: result.author ?? undefined,
9085
updatedAt: result.sourceModifiedAt ?? undefined,
9186
}
9287
}
@@ -251,7 +246,7 @@ export function KnowledgeSearchResults({
251246
) : (
252247
<div className='flex flex-col gap-0.5' onKeyDown={handleResultsKeyDown}>
253248
{visible.map((result) => {
254-
const source = toSource(result)
249+
const source = toSource(result, query)
255250
return source ? (
256251
<SourceCard
257252
key={result.documentId}
@@ -270,7 +265,7 @@ export function KnowledgeSearchResults({
270265
{result.knowledgeBaseName}
271266
</p>
272267
<p className='line-clamp-2 text-[var(--text-body)] text-small leading-snug'>
273-
{toSnippet(result.content)}
268+
{matchSnippet(result.content, query)}
274269
</p>
275270
</div>
276271
)

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-card/source-card.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -129,9 +129,11 @@ export function SourceCard({ source, query, onSummarize }: SourceCardProps) {
129129
? BRAND_ICON_BY_BASE_TYPE.get(source.connectorType)
130130
: undefined
131131
const updatedAt = parseUpdatedAt(source.updatedAt)
132-
const meta = [sourceLabel(source), updatedAt ? `Updated ${formatDate(updatedAt)}` : null].filter(
133-
(part): part is string => Boolean(part)
134-
)
132+
const meta = [
133+
sourceLabel(source),
134+
source.author?.trim() || null,
135+
updatedAt ? formatDate(updatedAt) : null,
136+
].filter((part): part is string => Boolean(part))
135137

136138
return (
137139
<div className='group/source not-prose flex items-start gap-3 rounded-md px-2 py-2 transition-colors focus-within:bg-[var(--surface-5)] hover-hover:bg-[var(--surface-5)]'>

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,8 @@ export interface SourceTagData {
347347
snippet?: string
348348
/** When the source last changed the document, as an ISO timestamp. */
349349
updatedAt?: string
350+
/** The person behind the document, as the source names them. */
351+
author?: string
350352
}
351353

352354
export type ContentSegment =
@@ -583,6 +585,7 @@ function isSourceTagData(value: unknown): value is SourceTagData {
583585
if (value.connectorType !== undefined && typeof value.connectorType !== 'string') return false
584586
if (value.snippet !== undefined && typeof value.snippet !== 'string') return false
585587
if (value.updatedAt !== undefined && typeof value.updatedAt !== 'string') return false
588+
if (value.author !== undefined && typeof value.author !== 'string') return false
586589
return true
587590
}
588591

apps/sim/lib/api/contracts/knowledge/search.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,8 @@ export const workspaceKnowledgeSearchResultSchema = z.object({
164164
sourceUrl: z.string().nullable(),
165165
connectorType: z.string().nullable(),
166166
sourceModifiedAt: z.string().nullable(),
167+
/** The person behind the document, from its author-like tag; null when the source names none. */
168+
author: z.string().nullable(),
167169
content: z.string(),
168170
chunkIndex: z.number(),
169171
similarity: z.number(),

apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import {
5252
KNOWLEDGE_TAG_DISPLAY_NAME_MAX_LENGTH,
5353
MAX_KNOWLEDGE_BATCH_ITEMS,
5454
} from '@/lib/knowledge/constants'
55+
import { sourceAuthor } from '@/lib/knowledge/search/author'
5556
import { captureServerEvent } from '@/lib/posthog/server'
5657
import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection'
5758

@@ -65,7 +66,7 @@ const DEFAULT_QUERY_TOP_K = 10
6566
* a source URL is quoted by name instead.
6667
*/
6768
const KNOWLEDGE_CITATION_INSTRUCTION =
68-
'Cite each result you use inline, right after the sentence it supports, as <source>{"url":<sourceUrl>,"title":<documentName>,"siteName":<knowledgeBaseName>,"connectorType":<connectorType>,"snippet":<the sentence or two of content you relied on>,"updatedAt":<sourceModifiedAt>}</source>; omit the tag for a result whose sourceUrl is null and name the document instead.'
69+
'Cite each result you use inline, right after the sentence it supports, as <source>{"url":<sourceUrl>,"title":<documentName>,"siteName":<knowledgeBaseName>,"connectorType":<connectorType>,"snippet":<the sentence or two of content you relied on>,"updatedAt":<sourceModifiedAt>,"author":<author>}</source>; omit the tag for a result whose sourceUrl is null and name the document instead.'
6970

7071
/**
7172
* Resolves an environment-variable reference passed as a connector API key.
@@ -468,6 +469,7 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg
468469
documentName: result.documentName,
469470
sourceUrl: result.sourceUrl,
470471
sourceModifiedAt: result.sourceModifiedAt?.toISOString() ?? null,
472+
author: sourceAuthor(result.metadata),
471473
connectorType: result.connectorType,
472474
content: result.content,
473475
chunkIndex: result.chunkIndex,
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { sourceAuthor } from '@/lib/knowledge/search/author'
6+
7+
describe('sourceAuthor', () => {
8+
it('prefers the sender of an email and drops the address', () => {
9+
expect(sourceAuthor({ From: '"Ada Lovelace" <ada@example.com>', Owner: 'Someone' })).toBe(
10+
'Ada Lovelace'
11+
)
12+
})
13+
14+
it('falls through the author-like names in order', () => {
15+
expect(sourceAuthor({ Assignee: 'Grace', Owner: 'Alan' })).toBe('Alan')
16+
expect(sourceAuthor({ Reporter: 'Grace' })).toBe('Grace')
17+
})
18+
19+
it('returns null when nothing names a person', () => {
20+
expect(sourceAuthor({ From: '<only@example.com>', Status: 'Open' })).toBeNull()
21+
expect(sourceAuthor({})).toBeNull()
22+
})
23+
})
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/**
2+
* The tag names connectors give the person behind a document, in the order
3+
* they are tried. Connectors were never asked to agree on a name, so the
4+
* result's author is derived here rather than in each of them.
5+
*/
6+
const AUTHOR_TAG_NAMES = [
7+
'From',
8+
'Author',
9+
'Sender',
10+
'Owner',
11+
'Organizer',
12+
'Creator',
13+
'Reporter',
14+
'Assignee',
15+
] as const
16+
17+
/**
18+
* The person a search result shows beside its source: the first author-like
19+
* tag the document carries, reduced to a display name when the connector
20+
* stored an address form such as `Name <name@example.com>`.
21+
*/
22+
export function sourceAuthor(metadata: Record<string, unknown>): string | null {
23+
for (const name of AUTHOR_TAG_NAMES) {
24+
const value = metadata[name]
25+
if (typeof value !== 'string') continue
26+
const display = value
27+
.replace(/<[^>]*>/g, '')
28+
.trim()
29+
.replace(/^"|"$/g, '')
30+
.trim()
31+
if (display) return display
32+
}
33+
return null
34+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import {
6+
matchSnippet,
7+
SNIPPET_LENGTH,
8+
snippetTerms,
9+
stripLeadingHeaders,
10+
} from '@/lib/knowledge/search/snippet'
11+
12+
const EMAIL = [
13+
'Subject: Invoice #1010 is overdue',
14+
'From: Support <support@example.com>',
15+
'To: Someone <someone@example.com>',
16+
'Messages: 1',
17+
'',
18+
`${'Thanks for your patience. '.repeat(12)}The Volvo order shipped on Monday and the tracking number follows. ${'More text here. '.repeat(20)}`,
19+
].join('\n')
20+
21+
describe('stripLeadingHeaders', () => {
22+
it('drops the header block a connector writes above an email body', () => {
23+
expect(stripLeadingHeaders(EMAIL).startsWith('\nThanks for your patience.')).toBe(true)
24+
})
25+
26+
it('leaves a document that does not start with headers alone', () => {
27+
expect(stripLeadingHeaders('Plain prose: with a colon inside.')).toBe(
28+
'Plain prose: with a colon inside.'
29+
)
30+
})
31+
})
32+
33+
describe('snippetTerms', () => {
34+
it('keeps distinct terms of three or more characters, longest first', () => {
35+
expect(snippetTerms('the Volvo invoice is volvo')).toEqual(['invoice', 'Volvo', 'volvo', 'the'])
36+
expect(snippetTerms(undefined)).toEqual([])
37+
})
38+
})
39+
40+
describe('matchSnippet', () => {
41+
it('returns a short document whole, without its headers', () => {
42+
expect(matchSnippet('Subject: Hi\nFrom: A\n\nShort body.', 'body')).toBe('Short body.')
43+
})
44+
45+
it('windows around the first query term with ellipses on both sides', () => {
46+
const snippet = matchSnippet(EMAIL, 'volvo')
47+
expect(snippet.startsWith('…')).toBe(true)
48+
expect(snippet.endsWith('…')).toBe(true)
49+
expect(snippet).toContain('The Volvo order shipped')
50+
expect(snippet).not.toContain('Subject:')
51+
expect(snippet.length).toBeLessThanOrEqual(SNIPPET_LENGTH + 2)
52+
})
53+
54+
it('falls back to the opening when no term appears in the chunk', () => {
55+
const snippet = matchSnippet(EMAIL, 'unrelated')
56+
expect(snippet.startsWith('Thanks for your patience.')).toBe(true)
57+
expect(snippet.endsWith('…')).toBe(true)
58+
})
59+
})
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/** Characters of a document shown under a search result. */
2+
export const SNIPPET_LENGTH = 280
3+
/** Characters kept before the first match, so the hit sits in context rather than at the edge. */
4+
const LEAD_LENGTH = 90
5+
/** Query terms shorter than this are too common to anchor a snippet on. */
6+
const MIN_TERM_LENGTH = 3
7+
/** `Key: value` lines a connector writes above an email or ticket body. */
8+
const HEADER_LINE = /^[A-Z][A-Za-z-]{1,15}: .*$/
9+
10+
function escapeRegExp(value: string): string {
11+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
12+
}
13+
14+
/**
15+
* The document text without the header block some connectors prefix (the
16+
* `Subject:` / `From:` / `To:` lines of an email): the title already says
17+
* what the subject is, and a snippet spent on the header never shows why the
18+
* document matched.
19+
*/
20+
export function stripLeadingHeaders(content: string): string {
21+
const lines = content.split('\n')
22+
let index = 0
23+
while (index < lines.length && HEADER_LINE.test(lines[index].trim())) index += 1
24+
if (index === 0) return content
25+
return lines.slice(index).join('\n')
26+
}
27+
28+
/** The query's terms worth anchoring on, longest first so the most specific one wins. */
29+
export function snippetTerms(query: string | undefined): string[] {
30+
return [
31+
...new Set(
32+
(query ?? '')
33+
.split(/\s+/)
34+
.map((term) => term.trim())
35+
.filter((term) => term.length >= MIN_TERM_LENGTH)
36+
),
37+
].sort((a, b) => b.length - a.length)
38+
}
39+
40+
/**
41+
* The passage of a document a search result shows: a window around the first
42+
* query term found, the way a search page shows why a document matched, and
43+
* the document's opening when no term appears in this chunk. Whitespace is
44+
* collapsed and the window is cut on word boundaries with ellipses where the
45+
* text continues.
46+
*/
47+
export function matchSnippet(content: string, query?: string): string {
48+
const flat = stripLeadingHeaders(content).replace(/\s+/g, ' ').trim()
49+
if (flat.length <= SNIPPET_LENGTH) return flat
50+
51+
let start = 0
52+
for (const term of snippetTerms(query)) {
53+
const match = new RegExp(`\\b${escapeRegExp(term)}\\b`, 'i').exec(flat)
54+
if (!match) continue
55+
start = Math.max(0, match.index - LEAD_LENGTH)
56+
break
57+
}
58+
if (start > 0) {
59+
const boundary = flat.indexOf(' ', start)
60+
if (boundary !== -1 && boundary - start < LEAD_LENGTH) start = boundary + 1
61+
}
62+
if (flat.length - start <= SNIPPET_LENGTH) {
63+
return `${start > 0 ? '…' : ''}${flat.slice(start)}`
64+
}
65+
let end = start + SNIPPET_LENGTH
66+
const lastSpace = flat.lastIndexOf(' ', end)
67+
if (lastSpace > start + SNIPPET_LENGTH / 2) end = lastSpace
68+
return `${start > 0 ? '…' : ''}${flat.slice(start, end).trimEnd()}…`
69+
}

0 commit comments

Comments
 (0)