Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
'use client'

import { useEffect, useMemo, useState } from 'react'
import { Chip } from '@sim/emcn'
import { Chip, ChipLink } from '@sim/emcn'
import { useQueryStates } from 'nuqs'
import { ActivityStatus } from '@/components/ui/activity-status'
import type { WorkspaceSearchFilters } from '@/lib/api/contracts/knowledge'
import { useSession } from '@/lib/auth/client'
import { type ResourceScope, resourceScopeKey } from '@/lib/core/resource-scope'
import { organizationRoutes } from '@/lib/navigation/paths'
import { SearchFilters } from '@/app/workspace/[workspaceId]/home/components/knowledge-search-results/search-filters'
import {
groupResultsByDocument,
Expand Down Expand Up @@ -150,6 +151,25 @@ function LiveSearchResults({
Search found no results.
</p>
)}
{!isFetching && data?.live && accounts.length === 0 && (
<div className='flex items-center gap-2 px-2 py-2'>
<p role='status' className='text-[var(--text-muted)] text-caption'>
{filters.source
? 'You haven’t connected this app.'
: 'Connect your apps to search them.'}
</p>
<ChipLink
variant='border'
href={
scope.kind === 'organization'
? organizationRoutes(scope.organizationId).integrations
: `/workspace/${scope.workspaceId}/integrations`
}
>
Connect apps
</ChipLink>
</div>
)}
{accounts.some((account) =>
['unavailable', 'timeout', 'rate_limited', 'reconnect'].includes(account.status)
) && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,19 @@ describe('Assistant retrieval tools', () => {
)
expect(JSON.stringify(result)).not.toContain(secret)
})
it('tells the model a search no account served searched nothing, not to narrow and retry', async () => {
mocks.search.mockResolvedValueOnce({
query: 'roadmap',
retrieval: { status: 'partial', timedOutLegs: [] },
results: [],
live: { backend: 'live', accounts: [], guidance: 'Nothing was searched: connect one.' },
})
const result = await searchWorkspaceServerTool.execute({ query: 'roadmap' }, context)
expect(result).toMatchObject({
success: true,
message: expect.stringMatching(/^Nothing was searched: (?!.*more specific query)/),
})
})
it('rejects untrusted contexts, incompatible sources and out-of-scope document reads', async () => {
expect(
await searchWorkspaceServerTool.execute(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,13 @@ export const searchWorkspaceServerTool: BaseServerTool = {
...liveInput,
workspaceId: scope.workspaceId,
})
/** Every searched account reports a status, so none means no account could serve it. */
const nothingSearched = data.live?.accounts.length === 0
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved
return {
success: true,
message: `${data.retrieval.status === 'partial' ? 'Search coverage is incomplete. Continue with a more specific query or source filter; these results cannot establish absence or completeness. ' : ''}Found ${data.results.length} live results. Read a documentId when its passage does not answer the question or more context is needed. ${CITATION_INSTRUCTION}`,
message: nothingSearched
? 'Nothing was searched: no connected account can serve this search, so it says nothing about what exists. Follow the first line of live.guidance instead of retrying the same search.'
: `${data.retrieval.status === 'partial' ? 'Search coverage is incomplete. Continue with a more specific query or source filter; these results cannot establish absence or completeness. ' : ''}Found ${data.results.length} live results. Read a documentId when its passage does not answer the question or more context is needed. ${CITATION_INSTRUCTION}`,
data: {
...data,
results: data.results.map((item) => ({
Expand Down
22 changes: 22 additions & 0 deletions apps/sim/lib/sim-search/live/application.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -666,6 +666,28 @@ describe('authorized live retrieval', () => {
expect(result.live?.accounts.map((status) => status.queryIndex)).toEqual([0, 1])
expect(result.live?.guidance).toBe('Live coverage: slack')
})
it('reports a search that reached no account as nothing searched, not as no results', async () => {
mocks.accounts.mockResolvedValue([])
const result = await searchLiveKnowledge.execute({ principal, input })
expect(mocks.search).not.toHaveBeenCalled()
expect(result.results).toEqual([])
expect(result.retrieval.status).toBe('partial')
expect(result.live?.accounts).toEqual([])
expect(result.live?.guidance).toMatch(
/^Nothing was searched: you have no connected sources here\. Connect your accounts at \S*\/workspace\/workspace\/integrations,/
)
})
it('names the filtered source when only other sources are connected', async () => {
const result = await searchLiveKnowledge.execute({
principal,
input: { ...input, filters: { source: 'slack' } },
})
expect(mocks.search).not.toHaveBeenCalled()
expect(result.retrieval.status).toBe('partial')
expect(result.live?.guidance).toMatch(
/^Nothing was searched: you have no connected slack account here\. .* or search without the source filter\./
)
})
describe('result previews', () => {
const excerpts = ['a', 'b', 'c', 'd'].map((id) => ({
...document,
Expand Down
39 changes: 33 additions & 6 deletions apps/sim/lib/sim-search/live/application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
mapWithConcurrency,
waitWithAbort,
} from '@/lib/core/utils/concurrency'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { MANAGED_MCP_CONNECTORS } from '@/lib/credential-groups/managed-mcp-connectors'
import { requireOrganizationSearchAvailable } from '@/lib/knowledge/access/availability'
import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/authorized-knowledge-use-case'
Expand All @@ -49,6 +50,7 @@ import {
import { RRF_K } from '@/lib/knowledge/search/recency'
import { matchPassage } from '@/lib/knowledge/search/snippet'
import { isKnowledgeSourceUrl } from '@/lib/knowledge/search/source-url'
import { organizationRoutes } from '@/lib/navigation/paths'
import { connectorDisplayName } from '@/lib/sim-search/connectors'
import {
type LiveAccountSession,
Expand Down Expand Up @@ -440,6 +442,24 @@ function compareAccounts(left: LiveAccount, right: LiveAccount): number {
)
}

/**
* Why a plain search that reached no account returned nothing, and where the caller connects one;
* an empty result would otherwise read as a search that found nothing.
*/
function nothingSearchedGuidance(
owner: ResourceOwner,
source: string | undefined,
hasOtherAccounts: boolean
): string {
const path = owner.organizationId
? organizationRoutes(owner.organizationId).integrations
: `/workspace/${owner.workspaceId}/integrations`
const connect = new URL(path, getBaseUrl()).href
return source && hasOtherAccounts
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved
? `Nothing was searched: you have no connected ${source} account here. Connect one at ${connect}, or search without the source filter.`
: `Nothing was searched: you have no connected sources here. Connect your accounts at ${connect}, then search again.`
}

const liveKnowledgeSearch = defineAuthorizedKnowledgeUseCase({
operation: knowledgeOperations.search,
resolveContext: ({ input }: { input: LiveSearchInput }) => resolveKnowledgeOwnerContext(input),
Expand Down Expand Up @@ -503,6 +523,7 @@ const liveKnowledgeSearch = defineAuthorizedKnowledgeUseCase({
nativesFor(account).length > 0
)
.sort(compareAccounts)
const nothingSearched = !queries && eligible.length === 0
const selected = eligible.slice(0, MAX_ACCOUNTS)
const openingAccount = createConcurrencyLimit(ACCOUNT_OPEN_CONCURRENCY)
annotateSearchDiagnostics({
Expand Down Expand Up @@ -909,9 +930,14 @@ const liveKnowledgeSearch = defineAuthorizedKnowledgeUseCase({
}
})
/** Live coverage guides the providers this call searched or tried to search. */
const guidance = liveSearchGuidance(
accounts.filter((account) => account.status !== 'reconnect').map(({ provider }) => provider)
)
const guidance = [
...(nothingSearched
? [nothingSearchedGuidance(input, filters?.source, allAccounts.length > 0)]
: []),
liveSearchGuidance(
accounts.filter((account) => account.status !== 'reconnect').map(({ provider }) => provider)
),
].join('\n')
for (const account of eligible.slice(MAX_ACCOUNTS)) {
for (const { queryIndex } of nativesFor(account))
accounts.push({
Expand All @@ -934,9 +960,10 @@ const liveKnowledgeSearch = defineAuthorizedKnowledgeUseCase({
message: 'No connection with this provider is configured and approved in this scope.',
})
}
const retrievalStatus = accounts.some((account) => account.status !== 'ok')
? 'partial'
: 'complete'
const retrievalStatus =
nothingSearched || accounts.some((account) => account.status !== 'ok')
? 'partial'
: 'complete'
annotateSearchDiagnostics({
resultCount: Math.min(ranked.length, input.topK),
retrievalStatus,
Expand Down
7 changes: 7 additions & 0 deletions apps/sim/lib/sim-search/live/atlassian.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,13 @@ describe('Jira any-term search', () => {
const page = await searchAtlassian(api, 'confluence', { ...query, textMatch: 'any' })
expect(page.documents.map((document) => document.id)).toEqual(['5'])
})

it('searches nothing for a Confluence query with no word left, which would list every page', async () => {
const api = client({})
const page = await searchAtlassian(api, 'confluence', { ...query, query: String.raw` :\ ` })
expect(page.documents).toEqual([])
expect(api.json).not.toHaveBeenCalled()
})
})

describe('Confluence plain search ranking', () => {
Expand Down
15 changes: 11 additions & 4 deletions apps/sim/lib/sim-search/live/atlassian.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,12 @@ export async function searchAtlassian(
provider: 'jira' | 'confluence',
input: NativeSearchInput
): Promise<NativePage> {
const text = nativeText(input)
const confluenceClause =
provider === 'confluence' && text && !input.native?.query
? confluenceTextClause(text, input.textMatch)
: ''
if (confluenceClause === undefined) return { documents: [] }
const allSites = (await sites(client)).filter(
(site) =>
!input.policy?.sites.length ||
Expand Down Expand Up @@ -166,7 +172,6 @@ export async function searchAtlassian(
.join(' AND ') || undefined
const direction = dateSortDirection(input.filters)
const order = direction ? `${field} ${direction.toUpperCase()}` : undefined
const text = nativeText(input)
// Jira's any-term JQL matches issues sharing two common words; like minimum-should-match,
// keep only issues holding at least half the terms.
const relevant = input.textMatch === 'any' && text ? holdsHalfTheTerms(text) : undefined
Expand Down Expand Up @@ -234,13 +239,15 @@ export async function searchAtlassian(
}
}
const exact = await searchPages(
text ? confluenceTextClause(text, input.textMatch) : '',
confluenceClause,
input.native?.cursor && single ? input.native.cursor : undefined
)
const relevance = confluenceTextClause(text, 'any')
// An all-terms match larger than a page comes back by raw term matches; rank it by relevance.
if (!text || input.native || order || input.textMatch === 'any' || !exact.next) return exact
if (!relevance || input.native || order || input.textMatch === 'any' || !exact.next)
return exact
try {
const ranked = await searchPages(confluenceTextClause(text, 'any'))
const ranked = await searchPages(relevance)
return { ...ranked, next: undefined, ranking: 'relevance' }
} catch {
// A cancelled search stops here; any other failure keeps the all-terms page.
Expand Down
1 change: 1 addition & 0 deletions apps/sim/lib/sim-search/live/service-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ export async function createLiveServiceSession(input: {
const query =
search.native?.query ??
(search.query ? confluenceTextClause(search.query, search.textMatch) : '')
if (query === undefined) return null
return {
...search,
native: { provider, ...search.native, query: scopeAtlassianQuery(query, scope) },
Expand Down
10 changes: 10 additions & 0 deletions apps/sim/lib/sim-search/live/text-match.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,16 @@ describe('text match clauses', () => {
)
})

it('sends a colon or backslash to Confluence as a word break, which it otherwise rejects', () => {
expect(confluenceTextClause('Security: Weekly Report', 'all')).toBe(
'text ~ "Security Weekly Report"'
)
expect(confluenceTextClause(String.raw`C:\builds\out`, 'any')).toBe(
'siteSearch ~ "C builds out"'
)
expect(confluenceTextClause(String.raw` :\ `, 'all')).toBeUndefined()
})

it('falls back to the all-terms clause when fewer than two terms are distinctive', () => {
expect(jiraTextClause('how does it work', 'any')).toBe('text ~ "how does it work"')
})
Expand Down
11 changes: 9 additions & 2 deletions apps/sim/lib/sim-search/live/text-match.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,9 +150,16 @@ export function jiraTextClause(text: string, match: TextMatch = 'all'): string {
* The text clause for a plain query in Confluence CQL. `any` uses `siteSearch`, Confluence's own
* relevance search, which ranks a page with the query's rarer words above the many pages sharing
* one common word, where a `text ~` OR query ranks them the other way round.
*
* Confluence answers HTTP 400 to the escaped colon or backslash this phrase would carry, and its
* analyzer splits words on both, so they go as spaces. `undefined` means nothing is left to match:
* Confluence would list every page for such a phrase, so callers search nothing instead.
*/
export function confluenceTextClause(text: string, match: TextMatch = 'all'): string {
return `${match === 'any' ? 'siteSearch' : 'text'} ~ ${escapeSearchPhrase(text)}`
export function confluenceTextClause(text: string, match: TextMatch = 'all'): string | undefined {
const searchable = text.replace(/[:\\]/g, ' ').trim()
return searchable
? `${match === 'any' ? 'siteSearch' : 'text'} ~ ${escapeSearchPhrase(searchable)}`
: undefined
}

/** How many distinct terms occur as whole words in the text. */
Expand Down
10 changes: 9 additions & 1 deletion apps/sim/scripts/test-organization-search-e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -445,7 +445,15 @@ try {
const body = toRecord(response.request().postDataJSON())
assert.equal(body.organizationId, organizationId)
assert.equal(body.query, 'Launch notes')
await expect(page.getByText('Search found no results.', { exact: true })).toBeVisible()
// The seeded member has connected no account, so nothing was searched.
await expect(
page.getByText('Connect your apps to search them.', { exact: true })
).toBeVisible()
await expect(page.getByText('Search found no results.', { exact: true })).toHaveCount(0)
await expect(page.getByRole('link', { name: 'Connect apps' })).toHaveAttribute(
'href',
`${root}/integrations`
)
await expect(page).toHaveURL(
(url) => url.pathname === `${root}/search` && url.searchParams.get('q') === 'Launch notes'
)
Expand Down
Loading