-
Notifications
You must be signed in to change notification settings - Fork 12
Add a general search-entries host tool over the entry endpoint #6068
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| import { IconSearchThick } from '@cardstack/boxel-ui/icons'; | ||
| import { | ||
| CardDef, | ||
| Component, | ||
| FieldDef, | ||
| StringField, | ||
| contains, | ||
| containsMany, | ||
| field, | ||
| } from '../card-api'; | ||
| import BooleanField from '../boolean'; | ||
| import CodeRefField from '../code-ref'; | ||
| import NumberField from '../number'; | ||
| import { QueryField } from './search-card-result'; | ||
|
|
||
| export class SearchEntriesInput extends CardDef { | ||
| static displayName = 'Search Entries'; | ||
| static icon = IconSearchThick; | ||
| @field query = contains(QueryField); | ||
| // Realm URLs to search; empty means every realm the user can read. | ||
| @field realms = containsMany(StringField); | ||
| // 'cards' | 'files' | 'all'; the tool validates and defaults to 'all'. | ||
| @field scope = contains(StringField); | ||
| // Maximum rows returned; the tool defaults to 5 and clamps to 10. | ||
| @field limit = contains(NumberField); | ||
| } | ||
|
|
||
| export class SearchEntrySummaryField extends FieldDef { | ||
| static displayName = 'Search Entry Summary'; | ||
| // The entry's URL — a card id or a file URL. | ||
| @field url = contains(StringField); | ||
| // 'card' | 'file', from the entry's item resource type. | ||
| @field kind = contains(StringField); | ||
| // Populated for Spec rows. | ||
| @field ref = contains(CodeRefField); | ||
| @field specType = contains(StringField); | ||
| @field cardTitle = contains(StringField); | ||
| @field cardDescription = contains(StringField); | ||
| // The file name — the display handle for file rows, which carry no | ||
| // cardTitle. | ||
| @field name = contains(StringField); | ||
| // Present only when the search sorted by full-text relevance. | ||
| @field matchRelevance = contains(NumberField); | ||
| // Full, untruncated readMe when the row carries one (e.g. a Spec). | ||
| @field readMe = contains(StringField); | ||
| } | ||
|
|
||
| export class SearchEntriesResult extends CardDef { | ||
| static displayName = 'Search Entries Result'; | ||
| static icon = IconSearchThick; | ||
| @field results = containsMany(SearchEntrySummaryField); | ||
| // Total matches across the searched realms; `results` is one page of them. | ||
| @field total = contains(NumberField); | ||
| // True when a searched realm failed to answer: `results`/`total` then cover | ||
| // only the realms that responded. | ||
| @field incomplete = contains(BooleanField); | ||
| @field cardDescription = contains(StringField); | ||
|
|
||
| static embedded = class Embedded extends Component<typeof this> { | ||
| <template> | ||
| <div data-test-search-entries-result> | ||
| <p>{{@model.results.length}} | ||
| of | ||
| {{@model.total}} | ||
| results{{if @model.incomplete ' (incomplete: a realm failed)' ''}}</p> | ||
| <ol> | ||
| {{#each @model.results as |result|}} | ||
| <li data-test-search-entry={{result.url}}> | ||
| {{if result.cardTitle result.cardTitle result.name}} | ||
| ({{result.url}}) | ||
| </li> | ||
| {{/each}} | ||
| </ol> | ||
| </div> | ||
| </template> | ||
| }; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,220 @@ | ||
| import { service } from '@ember/service'; | ||
|
|
||
| import { | ||
| MATCH_RELEVANCE_SORT_KEY, | ||
| assertQuery, | ||
| collectPositiveMatchTerms, | ||
| excludeCardInstanceFileRows, | ||
| isFileMetaResource, | ||
| resourceIdentity, | ||
| searchEntryWireQueryFromQuery, | ||
| type CardResource, | ||
| type EntryCollectionDocument, | ||
| type FileMetaResource, | ||
| type Query, | ||
| type Saved, | ||
| type SearchEntryScope, | ||
| } from '@cardstack/runtime-common'; | ||
|
|
||
| import HostBaseTool from '../lib/host-base-tool'; | ||
| import { hasNarrowingPositiveTypeRef } from '../utils/search/query-builder'; | ||
|
|
||
| import type StoreService from '../services/store'; | ||
| import type * as BaseToolModule from '@cardstack/base/command'; | ||
|
|
||
| const DEFAULT_LIMIT = 5; | ||
| const MAX_LIMIT = 10; | ||
|
|
||
| const SCOPES: SearchEntryScope[] = ['cards', 'files', 'all']; | ||
|
|
||
| // The fixed projection each row carries. Everything a discovery pass needs to | ||
| // judge a candidate rides the row itself (including the full readMe), so no | ||
| // per-hit follow-up read is required; the fieldset stays internal so a caller | ||
| // can never request full serializations that blow up the result size. | ||
| const PROJECTION_FIELDS = [ | ||
| 'item.cardTitle', | ||
| 'item.cardDescription', | ||
| 'item.specType', | ||
| 'item.readMe', | ||
| 'item.ref', | ||
| // `name` is the file-meta display handle — without it a file row's summary | ||
| // carries nothing but its URL. Card rows have no `name` attribute, so the | ||
| // sparse fieldset simply omits it there. | ||
| 'item.name', | ||
| ]; | ||
|
|
||
| interface EntrySummary { | ||
| url: string; | ||
| kind: 'card' | 'file'; | ||
| ref?: unknown; | ||
| specType?: string; | ||
| cardTitle?: string; | ||
| cardDescription?: string; | ||
| name?: string; | ||
| readMe?: string; | ||
| matchRelevance?: number; | ||
| } | ||
|
|
||
| function resolveScope(scope: string | undefined): SearchEntryScope { | ||
| if (scope == null || scope === '') { | ||
| return 'all'; | ||
| } | ||
| if (!SCOPES.includes(scope as SearchEntryScope)) { | ||
| throw new Error( | ||
| `Invalid scope "${scope}": must be one of 'cards', 'files', 'all'`, | ||
| ); | ||
| } | ||
| return scope as SearchEntryScope; | ||
| } | ||
|
|
||
| // Prepares a caller's card-rooted query for the entry endpoint: under the | ||
| // mixed 'all' scope a card matches both its instance row and its dual-indexed | ||
| // `.json` file row, so the filter gains the card-instance-file exclusion — | ||
| // unless it already carries a kind-narrowing positive type ref, which matches | ||
| // only one of the two. A filter with a positive full-text `matches` term and | ||
| // no explicit sort gains the relevance sort (the server rejects that sort | ||
| // without such a term, so it is never added unconditionally). | ||
| // `addedRelevanceSort` reports the sort addition: the tool re-sorts merged | ||
| // rows client-side only for an ordering it imposed itself, never over one the | ||
| // caller chose (a caller's own sort may legally include `_matchRelevance`). | ||
| export function composeSearchEntriesQuery( | ||
| query: Query, | ||
| scope: SearchEntryScope, | ||
| ): { query: Query; addedRelevanceSort: boolean } { | ||
| let filter = query.filter; | ||
| if (scope === 'all' && !hasNarrowingPositiveTypeRef(filter)) { | ||
| filter = filter | ||
| ? { every: [filter, excludeCardInstanceFileRows()] } | ||
| : excludeCardInstanceFileRows(); | ||
| } | ||
| let sort = query.sort; | ||
| let addedRelevanceSort = false; | ||
| if (!sort && collectPositiveMatchTerms(query.filter).length > 0) { | ||
| sort = [{ by: MATCH_RELEVANCE_SORT_KEY, direction: 'desc' }]; | ||
| addedRelevanceSort = true; | ||
| } | ||
| return { | ||
| query: { | ||
| ...query, | ||
| ...(filter ? { filter } : {}), | ||
| ...(sort ? { sort } : {}), | ||
| }, | ||
| addedRelevanceSort, | ||
| }; | ||
| } | ||
|
|
||
| function summarizeEntries(doc: EntryCollectionDocument): EntrySummary[] { | ||
| let itemsByIdentity = new Map< | ||
| string, | ||
| CardResource<Saved> | FileMetaResource | ||
| >(); | ||
| for (let resource of doc.included ?? []) { | ||
| if (resource.type === 'card' || resource.type === 'file-meta') { | ||
| itemsByIdentity.set( | ||
| resourceIdentity(resource.type, resource.id), | ||
| resource, | ||
| ); | ||
| } | ||
| } | ||
| let summaries: EntrySummary[] = []; | ||
| for (let entry of doc.data) { | ||
| let itemRef = entry.relationships?.item?.data; | ||
| if (!entry.id || !itemRef) { | ||
| continue; | ||
| } | ||
| let item = itemsByIdentity.get(resourceIdentity(itemRef.type, itemRef.id)); | ||
| if (!item) { | ||
| continue; | ||
| } | ||
| let attributes = (item.attributes ?? {}) as Record<string, unknown>; | ||
| summaries.push({ | ||
| url: entry.id, | ||
| kind: isFileMetaResource(item) ? 'file' : 'card', | ||
| ref: attributes.ref, | ||
| specType: attributes.specType as string | undefined, | ||
| cardTitle: attributes.cardTitle as string | undefined, | ||
| cardDescription: attributes.cardDescription as string | undefined, | ||
| name: attributes.name as string | undefined, | ||
| readMe: attributes.readMe as string | undefined, | ||
| matchRelevance: entry.meta?._matchRelevance, | ||
| }); | ||
| } | ||
| return summaries; | ||
| } | ||
|
|
||
| export default class SearchEntriesTool extends HostBaseTool< | ||
| typeof BaseToolModule.SearchEntriesInput, | ||
| typeof BaseToolModule.SearchEntriesResult | ||
| > { | ||
| @service declare private store: StoreService; | ||
|
|
||
| static actionVerb = 'Search'; | ||
|
|
||
| description = | ||
| 'Search across realms for existing cards, specs, listings, themes, and files — ' + | ||
| 'the primary tool for discovery: always check what already exists before creating ' + | ||
| 'anything new. Takes a card query (`filter` supporting `type`/`on`/`eq`/`contains`/' + | ||
| '`range`/`any`/`every`/`not` and full-text `matches`, plus optional `sort`), optional ' + | ||
| '`realms` (realm URLs; defaults to every realm you can read), optional `scope` ' + | ||
| "('cards' | 'files' | 'all', default 'all'), and optional `limit` (default " + | ||
| `${DEFAULT_LIMIT}, max ${MAX_LIMIT}). Returns lightweight entry summaries — url, ` + | ||
| 'ref, specType, title, description, file name, full readMe, and full-text match ' + | ||
| 'relevance — not live card instances. A result with `incomplete: true` is ' + | ||
| 'partial: at least one searched realm failed to answer, so matches may be ' + | ||
| 'missing and `total` undercounts. When you need instances to attach, open, or ' + | ||
| 'patch, use the card-instance search tools instead.'; | ||
|
|
||
| requireInputFields = ['query']; | ||
|
|
||
| async getInputType() { | ||
| let commandModule = await this.loadToolModule(); | ||
| return commandModule.SearchEntriesInput; | ||
| } | ||
|
|
||
| protected async run( | ||
| input: BaseToolModule.SearchEntriesInput, | ||
| ): Promise<BaseToolModule.SearchEntriesResult> { | ||
| assertQuery(input.query); | ||
| let scope = resolveScope(input.scope); | ||
| let limit = Math.min( | ||
| Math.max(Math.floor(input.limit ?? DEFAULT_LIMIT), 1), | ||
| MAX_LIMIT, | ||
| ); | ||
|
|
||
| let { query, addedRelevanceSort } = composeSearchEntriesQuery( | ||
| input.query, | ||
| scope, | ||
| ); | ||
| let wireQuery = searchEntryWireQueryFromQuery(query, { | ||
| fields: PROJECTION_FIELDS, | ||
| scope, | ||
| }); | ||
| wireQuery.page = { ...wireQuery.page, size: limit }; | ||
|
|
||
| let realms = input.realms?.length ? [...input.realms] : undefined; | ||
| let doc = await this.store.searchEntries(wireQuery, realms); | ||
|
|
||
| let rows = summarizeEntries(doc); | ||
| if (addedRelevanceSort) { | ||
| // The federated merge concatenates per-realm results without re-ranking | ||
| // across realms; relevance rides each entry so the merged page can be. | ||
| // Only the tool's own default ordering is re-imposed here — a caller's | ||
| // explicit sort (which may itself include `_matchRelevance`) stands. | ||
|
Comment on lines
+198
to
+202
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||
| rows = [...rows].sort( | ||
| (a, b) => (b.matchRelevance ?? -1) - (a.matchRelevance ?? -1), | ||
| ); | ||
| } | ||
|
|
||
| let commandModule = await this.loadToolModule(); | ||
| let { SearchEntriesResult, SearchEntrySummaryField } = commandModule; | ||
| return new SearchEntriesResult({ | ||
| results: rows.map((row) => new SearchEntrySummaryField(row)), | ||
| total: doc.meta.page.total, | ||
| // A realm that fails (or never resolves) during the federated fan-out is | ||
| // reported through this flag rather than a thrown error — the realms | ||
| // that answered still return; the flag keeps their partiality visible. | ||
| incomplete: doc.meta.incomplete === true, | ||
| cardDescription: `Query: ${JSON.stringify(input.query.filter ?? {})}`, | ||
| }); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When more than one realm is searched,
page.sizeis applied independently by each realm andcombineSearchEntryResultsconcatenates those pages, so the defaultlimit: 5can return five rows per readable realm rather than five rows total. Because the default targets every readable realm and each result can include a fullreadMe, this defeats the tool's output-size bound; trim the merged, re-ranked rows tolimitbefore constructing the result.Useful? React with 👍 / 👎.