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
5 changes: 5 additions & 0 deletions src/types/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ export interface AtomReference {
label: string;
atom?: AtomData; // Full atom data if existing
confidence: number; // Search relevance score (0-1)

// Entity disambiguation
entityType?: 'person' | 'organization' | 'concept' | 'thing' | 'place' | 'event' | 'unknown';
disambiguation?: string; // Human-readable clarification
entityConfidence?: number; // 0-1, how confident this is the right entity
}

/**
Expand Down
117 changes: 117 additions & 0 deletions src/ui/components/atom-search-input.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,3 +195,120 @@ describe('AtomSearchInput - setValueFromReference', () => {
expect(true).toBe(true);
});
});

describe('AtomSearchInput - Entity Disambiguation', () => {
let parentEl: HTMLElement;
let mockIntuitionService: IntuitionService;
let onSelectSpy: ReturnType<typeof vi.fn>;
let component: AtomSearchInput;

beforeEach(() => {
parentEl = document.createElement('div');
onSelectSpy = vi.fn();

mockIntuitionService = {
semanticSearchAtoms: vi.fn(),
searchAtoms: vi.fn(),
} as unknown as IntuitionService;

vi.mocked(mockIntuitionService.semanticSearchAtoms).mockResolvedValue([]);
vi.mocked(mockIntuitionService.searchAtoms).mockResolvedValue([]);
});

it('should set entity hint and include in selection', async () => {
const mockAtom: Atom = {
id: 'test-id-1',
label: 'Apple Inc.',
vaultId: 'vault-1',
data: 'ipfs://test',
creator: { id: '0xtest', label: 'Test Creator' },
__typename: 'Atom',
};

vi.mocked(mockIntuitionService.searchAtoms).mockResolvedValue([mockAtom]);

component = new AtomSearchInput(parentEl, mockIntuitionService, onSelectSpy, {
placeholder: 'Test',
allowCreate: true,
});

// Set entity hint
component.setEntityHint({
type: 'organization',
disambiguation: 'Technology company',
confidence: 0.95,
});

await component.setValue('Apple Inc.');

// Should include entity metadata in selection
expect(onSelectSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: 'existing',
termId: 'test-id-1',
label: 'Apple Inc.',
entityType: 'organization',
disambiguation: 'Technology company',
entityConfidence: 0.95,
})
);
});

it('should include entity hint for new atoms', async () => {
component = new AtomSearchInput(parentEl, mockIntuitionService, onSelectSpy, {
placeholder: 'Test',
allowCreate: true,
});

component.setEntityHint({
type: 'person',
disambiguation: 'Physicist',
confidence: 0.92,
});

await component.setValue('Einstein');

expect(onSelectSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: 'new',
label: 'Einstein',
entityType: 'person',
disambiguation: 'Physicist',
entityConfidence: 0.92,
})
);
});

it('should work without entity hint', async () => {
const mockAtom: Atom = {
id: 'test-id-1',
label: 'Test',
vaultId: 'vault-1',
data: 'ipfs://test',
creator: { id: '0xtest', label: 'Test Creator' },
__typename: 'Atom',
};

vi.mocked(mockIntuitionService.searchAtoms).mockResolvedValue([mockAtom]);

component = new AtomSearchInput(parentEl, mockIntuitionService, onSelectSpy, {
placeholder: 'Test',
allowCreate: true,
});

// No entity hint set
await component.setValue('Test');

// Should still call onSelect but without entity metadata
const call = onSelectSpy.mock.calls[0][0];
expect(call).toMatchObject({
type: 'existing',
termId: 'test-id-1',
label: 'Test',
});
// Entity fields should be undefined
expect(call.entityType).toBeUndefined();
expect(call.disambiguation).toBeUndefined();
expect(call.entityConfidence).toBeUndefined();
});
});
97 changes: 91 additions & 6 deletions src/ui/components/atom-search-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
import { IntuitionService } from '../../services/intuition-service';
import { debounce, DebouncedFunction } from '../../utils/debounce';
import { mergeSearchResults } from '../../utils/search-helpers';
import { setImageSrc, validateSearchQuery } from '../../utils/helpers';
import { setImageSrc, validateSearchQuery, capitalizeFirst } from '../../utils/helpers';

export class AtomSearchInput {
private container: HTMLElement;
Expand All @@ -31,6 +31,13 @@ export class AtomSearchInput {
private searchAbortController: AbortController | null = null;
private currentSearchId = 0;

// Entity hint from LLM (if available)
private entityHint: {
type?: AtomReference['entityType'];
disambiguation?: string;
confidence?: number;
} | null = null;

constructor(
parent: HTMLElement,
intuitionService: IntuitionService,
Expand Down Expand Up @@ -228,6 +235,9 @@ export class AtomSearchInput {
type: 'new',
label: this.state.query,
confidence: 1,
entityType: this.entityHint?.type,
disambiguation: this.entityHint?.disambiguation,
entityConfidence: this.entityHint?.confidence,
});
} else if (this.state.results[this.state.selectedIndex]) {
const atom = this.state.results[this.state.selectedIndex];
Expand All @@ -237,6 +247,9 @@ export class AtomSearchInput {
label: atom.label,
atom,
confidence: 1,
entityType: this.entityHint?.type,
disambiguation: this.entityHint?.disambiguation,
entityConfidence: this.entityHint?.confidence,
});
}
}
Expand Down Expand Up @@ -271,31 +284,51 @@ export class AtomSearchInput {

// Results
this.state.results.forEach((atom, index) => {
const isLLMSuggestion = this.isLLMSuggestion(atom);

const item = this.dropdownEl.createDiv({
cls: `intuition-atom-suggestion ${
index === this.state.selectedIndex ? 'selected' : ''
}`,
} ${isLLMSuggestion ? 'llm-suggested' : ''}`,
});
item.setAttribute('role', 'option');
item.setAttribute(
'aria-selected',
(index === this.state.selectedIndex).toString()
);

// LLM suggestion badge
if (isLLMSuggestion) {
item.createSpan({
cls: 'llm-suggestion-badge',
text: 'πŸ€– AI suggests',
});
}

// Icon/Emoji/Image
this.renderAtomImage(item, atom, 'suggestion-icon');

// Label
item.createSpan({ cls: 'suggestion-label', text: atom.label });

// Entity type badge (from LLM hint or GraphQL)
const entityType = this.getEntityType(atom);
if (entityType) {
item.createSpan({
cls: 'entity-type-badge',
text: entityType,
});
}

// Type badge
item.createSpan({ cls: 'suggestion-type', text: atom.type });

// Description (if available from semantic search)
if (atom.description) {
// Description (if available from semantic search) or disambiguation
const description = atom.description || this.entityHint?.disambiguation;
if (description) {
item.createDiv({
cls: 'suggestion-description',
text: atom.description,
cls: description === this.entityHint?.disambiguation ? 'entity-disambiguation' : 'suggestion-description',
text: description,
});
}

Expand All @@ -313,6 +346,9 @@ export class AtomSearchInput {
label: atom.label,
atom,
confidence: 1,
entityType: this.entityHint?.type,
disambiguation: this.entityHint?.disambiguation,
entityConfidence: this.entityHint?.confidence,
});
});
});
Expand Down Expand Up @@ -455,6 +491,7 @@ export class AtomSearchInput {
selectedIndex: 0,
error: null,
};
this.entityHint = null; // Clear entity hint
this.previewEl.style.display = 'none';
this.inputEl.style.display = 'block';
this.inputEl.focus();
Expand Down Expand Up @@ -491,13 +528,19 @@ export class AtomSearchInput {
label: atom.label,
atom,
confidence: 1,
entityType: this.entityHint?.type,
disambiguation: this.entityHint?.disambiguation,
entityConfidence: this.entityHint?.confidence,
});
} else if (this.config.allowCreate) {
// No existing atom, create new one
this.selectAtom({
type: 'new',
label: label,
confidence: 1,
entityType: this.entityHint?.type,
disambiguation: this.entityHint?.disambiguation,
entityConfidence: this.entityHint?.confidence,
});
}
}
Expand All @@ -517,6 +560,48 @@ export class AtomSearchInput {
return this.inputEl.value;
}

/**
* Set entity type hint from LLM extraction
* This helps prioritize search results
*/
setEntityHint(hint: { type?: AtomReference['entityType']; disambiguation?: string; confidence?: number }): void {
this.entityHint = hint;
}

/**
* Check if atom matches LLM's suggested entity
*/
private isLLMSuggestion(atom: AtomData): boolean {
if (!this.entityHint) return false;

// Match by label similarity
const labelMatch = atom.label.toLowerCase() === this.inputEl.value.toLowerCase();

// If no entity type hint, match by label only
if (!this.entityHint.type) return labelMatch;

// If type hint exists, prefer exact type match but fallback to label match if atom has no type
const typeMatch = atom.type?.toLowerCase() === this.entityHint.type.toLowerCase();
return labelMatch && (typeMatch || !atom.type);
}

/**
* Get entity type display string
*/
private getEntityType(atom: AtomData): string | null {
// Prefer LLM hint if available
if (this.entityHint?.type) {
return capitalizeFirst(this.entityHint.type);
}

// Fallback to GraphQL type if available
if (atom.type) {
return capitalizeFirst(atom.type);
}

return null;
}

/**
* Clean up component resources
*/
Expand Down
Loading
Loading