-
Notifications
You must be signed in to change notification settings - Fork 0
AI-assisted knowledge graph auto-linking #291
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
89dd1c7
1cba403
7ab8345
eab8399
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,160 @@ | ||
| import React, { useState } from 'react'; | ||
| import { X, CheckCircle2, Link2, PlusCircle } from 'lucide-react'; | ||
| import { EntityExtractionResult } from '../../lib/ai/entity-extractor'; | ||
| import { useRepository } from '../../db/useRepository'; | ||
| import { applyEntitiesToGraph } from '../../lib/ai/graph-linker'; | ||
| import { logger } from '../../lib/logger'; | ||
|
|
||
| interface EntityReviewDialogProps { | ||
| result: EntityExtractionResult; | ||
| sourceNoteId?: string; | ||
| onClose: () => void; | ||
| onComplete: () => void; | ||
| } | ||
|
|
||
| const EntityReviewDialog: React.FC<EntityReviewDialogProps> = ({ | ||
| result, | ||
| sourceNoteId, | ||
| onClose, | ||
| onComplete, | ||
| }) => { | ||
| const repository = useRepository(); | ||
| const [selectedEntities, setSelectedEntities] = useState<string[]>( | ||
| result.entities.map(e => e.name) | ||
| ); | ||
| const [selectedRelationships, setSelectedRelationships] = useState<string[]>( | ||
| result.relationships.map(r => `${r.from}->${r.to}`) | ||
| ); | ||
| const [isApplying, setIsApplying] = useState(false); | ||
|
|
||
| const toggleEntity = (name: string) => { | ||
| setSelectedEntities(prev => | ||
| prev.includes(name) ? prev.filter(n => n !== name) : [...prev, name] | ||
| ); | ||
| }; | ||
|
|
||
| const toggleRelationship = (from: string, to: string) => { | ||
| const key = `${from}->${to}`; | ||
| setSelectedRelationships(prev => | ||
| prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key] | ||
| ); | ||
| }; | ||
|
|
||
| const handleApply = async () => { | ||
| setIsApplying(true); | ||
| try { | ||
| await applyEntitiesToGraph( | ||
| result, | ||
| repository, | ||
| selectedEntities, | ||
| selectedRelationships, | ||
| sourceNoteId | ||
| ); | ||
| onComplete(); | ||
| onClose(); | ||
| } catch (err) { | ||
| logger.error('Failed to apply entities to graph', err); | ||
| } finally { | ||
| setIsApplying(false); | ||
| } | ||
| }; | ||
|
|
||
| return ( | ||
| <div className="modal-overlay" onClick={(e) => e.target === e.currentTarget && onClose()}> | ||
|
Check warning on line 63 in src/features/ai/EntityReviewDialog.tsx
|
||
| <div className="modal-content" style={{ maxWidth: '600px', maxHeight: '90vh', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}> | ||
| <div className="inspector-header"> | ||
| <h3><PlusCircle size={18} /> Review Extracted Entities</h3> | ||
| <button className="close-button" onClick={onClose} aria-label="Close"> | ||
| <X size={18} /> | ||
| </button> | ||
| </div> | ||
|
|
||
| <div className="modal-body" style={{ overflowY: 'auto', padding: '16px', flex: 1 }}> | ||
| <p style={{ fontSize: '13px', color: 'var(--text-muted)', marginBottom: '16px' }}> | ||
| AI analyzed your note and found the following entities and relationships. | ||
| Select the ones you want to add to your knowledge graph. | ||
| </p> | ||
|
|
||
| <section style={{ marginBottom: '24px' }}> | ||
| <h4 style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px', fontSize: '14px' }}> | ||
| <CheckCircle2 size={16} /> Entities ({result.entities.length}) | ||
| </h4> | ||
| <div className="entity-list" style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}> | ||
| {result.entities.map((entity, idx) => ( | ||
| <label key={idx} style={{ | ||
|
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.
|
||
| display: 'flex', | ||
| alignItems: 'flex-start', | ||
| gap: '10px', | ||
| padding: '10px', | ||
| border: '1px solid var(--border-default)', | ||
| borderRadius: '6px', | ||
| cursor: 'pointer', | ||
| background: selectedEntities.includes(entity.name) ? 'var(--bg-surface-active)' : 'transparent' | ||
| }}> | ||
| <input | ||
| type="checkbox" | ||
| checked={selectedEntities.includes(entity.name)} | ||
| onChange={() => toggleEntity(entity.name)} | ||
|
Check warning on line 97 in src/features/ai/EntityReviewDialog.tsx
|
||
| style={{ marginTop: '3px' }} | ||
| /> | ||
| <div> | ||
| <div style={{ fontWeight: 600, fontSize: '14px' }}>{entity.name}</div> | ||
| <div style={{ fontSize: '11px', color: 'var(--interactive-primary)', textTransform: 'uppercase', marginBottom: '4px' }}>{entity.type}</div> | ||
| <div style={{ fontSize: '12px', color: 'var(--text-secondary)' }}>{entity.description}</div> | ||
| </div> | ||
| </label> | ||
| ))} | ||
| </div> | ||
| </section> | ||
|
|
||
| <section> | ||
| <h4 style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px', fontSize: '14px' }}> | ||
| <Link2 size={16} /> Relationships ({result.relationships.length}) | ||
| </h4> | ||
| <div className="relationship-list" style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}> | ||
| {result.relationships.map((rel, idx) => { | ||
| const key = `${rel.from}->${rel.to}`; | ||
| return ( | ||
| <label key={idx} style={{ | ||
|
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.
|
||
| display: 'flex', | ||
| alignItems: 'center', | ||
| gap: '10px', | ||
| padding: '10px', | ||
| border: '1px solid var(--border-default)', | ||
| borderRadius: '6px', | ||
| cursor: 'pointer', | ||
| background: selectedRelationships.includes(key) ? 'var(--bg-surface-active)' : 'transparent' | ||
| }}> | ||
| <input | ||
| type="checkbox" | ||
| checked={selectedRelationships.includes(key)} | ||
| onChange={() => toggleRelationship(rel.from, rel.to)} | ||
|
Check warning on line 131 in src/features/ai/EntityReviewDialog.tsx
|
||
| /> | ||
| <div style={{ fontSize: '13px' }}> | ||
| <span style={{ fontWeight: 600 }}>{rel.from}</span> | ||
| <span style={{ margin: '0 8px', color: 'var(--text-muted)' }}>— {rel.label} →</span> | ||
| <span style={{ fontWeight: 600 }}>{rel.to}</span> | ||
| </div> | ||
| </label> | ||
| ); | ||
| })} | ||
| </div> | ||
| </section> | ||
| </div> | ||
|
|
||
| <div className="modal-actions" style={{ padding: '16px', borderTop: '1px solid var(--border-default)' }}> | ||
| <button onClick={onClose} disabled={isApplying}>Cancel</button> | ||
| <button | ||
| onClick={handleApply} | ||
| className="primary" | ||
| disabled={isApplying || (selectedEntities.length === 0 && selectedRelationships.length === 0)} | ||
| > | ||
| {isApplying ? 'Adding...' : `Add Selected to Graph (${selectedEntities.length + selectedRelationships.length})`} | ||
| </button> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| ); | ||
| }; | ||
|
|
||
| export default EntityReviewDialog; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,8 +9,11 @@ | |
| import { jobCoordinator } from '../../lib/jobs'; | ||
| import { upsertToSearchIndex } from '../../lib/search'; | ||
| import { perf } from '../../lib/perf'; | ||
| import { CheckCircle, AtSign, Link2, ChevronDown, ChevronRight, Pencil } from 'lucide-react'; | ||
| import { CheckCircle, AtSign, Link2, ChevronDown, ChevronRight, Pencil, Sparkles, X } from 'lucide-react'; | ||
| import { Entity } from '../../lib/validation'; | ||
| import { extractEntities, EntityExtractionResult } from '../../lib/ai/entity-extractor'; | ||
| import { loadConfig, createProvider } from '../../lib/llm/config'; | ||
| import EntityReviewDialog from '../ai/EntityReviewDialog'; | ||
|
|
||
| const ENTITY_TYPES = [ | ||
| { value: 'note', label: 'Note' }, | ||
|
|
@@ -34,6 +37,12 @@ | |
| const [showAdvanced, setShowAdvanced] = useState(false); | ||
| const [status, setStatus] = useState<{ type: 'success' | 'error', message: string } | null>(null); | ||
| const [isLoadingEntity, setIsLoadingEntity] = useState(false); | ||
| const [isExtracting, setIsExtracting] = useState(false); | ||
| const [extractionResult, setExtractionResult] = useState<EntityExtractionResult | null>(null); | ||
| const [showExtractionReview, setShowExtractionReview] = useState(false); | ||
| const [extractionSourceId, setExtractionSourceId] = useState<string | undefined>(undefined); | ||
|
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.
|
||
| const [showExtractionNotice, setShowExtractionNotice] = useState(false); | ||
|
|
||
| const editor = useEditor({ | ||
| extensions: [ | ||
| StarterKit, | ||
|
|
@@ -94,6 +103,31 @@ | |
| setSourceUrl(e.target.value); | ||
| }, []); | ||
|
|
||
| const handleExtractEntities = useCallback(async (entityId?: string, forceContent?: string) => { | ||
|
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.
|
||
| if (!editor || isExtracting) return; | ||
|
|
||
| const content = forceContent || editor.getHTML(); | ||
| if (!content.trim() || content === '<p></p>') return; | ||
|
|
||
| setIsExtracting(true); | ||
| try { | ||
| const config = await loadConfig(); | ||
| const provider = createProvider(config); | ||
| const providerConfig = config.providers[config.activeProvider]; | ||
| const model = providerConfig.defaultModel || 'google/gemini-2.0-flash-lite-preview-02-05:free'; | ||
|
|
||
| const result = await extractEntities(content, provider, model); | ||
| setExtractionResult(result); | ||
| setExtractionSourceId(entityId || editingEntityId || undefined); | ||
| setShowExtractionNotice(true); | ||
| } catch (err) { | ||
| logger.error('Failed to extract entities', err); | ||
| setStatus({ type: 'error', message: 'Failed to extract entities with AI' }); | ||
| } finally { | ||
| setIsExtracting(false); | ||
| } | ||
| }, [editor, isExtracting, editingEntityId]); | ||
|
|
||
| const handleSave = useCallback(async () => { | ||
| if (!title.trim() || !editor) return; | ||
|
|
||
|
|
@@ -190,6 +224,14 @@ | |
| jobCoordinator.enqueue('reindex-document', entity.id, { entityId: entity.id }); | ||
|
|
||
| setStatus({ type: 'success', message: `Saved successfully! (${claims.length} claims, ${mentions.length} links)${sourceUrl.trim() ? ' — fetching source...' : ''}` }); | ||
|
|
||
| // Auto-trigger extraction after 3s debounce | ||
| // Capture content before clearing editor | ||
| const savedContent = content; | ||
| setTimeout(() => { | ||
| void handleExtractEntities(entity.id, savedContent); | ||
|
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.
|
||
| }, 3000); | ||
|
|
||
| setTitle(''); | ||
| setSourceUrl(''); | ||
| editor.commands.setContent('<p></p>'); | ||
|
|
@@ -272,6 +314,20 @@ | |
| > | ||
| <CheckCircle size={16} aria-hidden="true" /> Claim | ||
| </button> | ||
| <button | ||
| onClick={() => void handleExtractEntities()} | ||
|
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.
|
||
| disabled={isExtracting} | ||
| title="Extract entities with AI" | ||
| aria-label="Extract entities with AI" | ||
| style={{ color: 'var(--interactive-primary)' }} | ||
| > | ||
| {isExtracting ? ( | ||
| <span className="animate-spin" style={{ display: 'inline-block' }}>⌛</span> | ||
| ) : ( | ||
| <Sparkles size={16} aria-hidden="true" /> | ||
| )} | ||
| AI Extract | ||
| </button> | ||
| <div className="toolbar-spacer" /> | ||
| <button type="button" onClick={() => void handleSave()} className="primary">{editingEntityId ? 'Update Entity' : 'Save to DB'}</button> | ||
| {editingEntityId && ( | ||
|
|
@@ -282,6 +338,56 @@ | |
| </div> | ||
| <EditorContent editor={editor} className="tiptap-content" /> | ||
|
|
||
| {showExtractionNotice && extractionResult && ( | ||
| <div style={{ | ||
| marginTop: '16px', | ||
| padding: '12px 16px', | ||
| background: 'var(--interactive-primary-subtle)', | ||
| borderRadius: '8px', | ||
| border: '1px solid var(--interactive-primary)', | ||
| display: 'flex', | ||
| alignItems: 'center', | ||
| justifyContent: 'space-between', | ||
| gap: '12px' | ||
| }}> | ||
| <div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '13px' }}> | ||
| <Sparkles size={16} style={{ color: 'var(--interactive-primary)' }} /> | ||
| <span> | ||
| AI found <strong>{extractionResult.entities.length} entities</strong> and <strong>{extractionResult.relationships.length} relationships</strong> in this note. | ||
| </span> | ||
| </div> | ||
| <div style={{ display: 'flex', gap: '8px' }}> | ||
| <button | ||
| onClick={() => setShowExtractionReview(true)} | ||
| className="primary" | ||
| style={{ padding: '4px 12px', fontSize: '12px', minHeight: '32px' }} | ||
| > | ||
| Review | ||
| </button> | ||
| <button | ||
| onClick={() => setShowExtractionNotice(false)} | ||
| style={{ padding: '4px 8px', fontSize: '12px', minHeight: '32px', background: 'transparent', border: 'none' }} | ||
| aria-label="Dismiss" | ||
| > | ||
| <X size={16} /> | ||
| </button> | ||
| </div> | ||
| </div> | ||
| )} | ||
|
|
||
| {showExtractionReview && extractionResult && ( | ||
| <EntityReviewDialog | ||
| result={extractionResult} | ||
| sourceNoteId={extractionSourceId} | ||
| onClose={() => setShowExtractionReview(false)} | ||
| onComplete={() => { | ||
| setShowExtractionNotice(false); | ||
| setExtractionResult(null); | ||
| onEditComplete?.(); | ||
| }} | ||
| /> | ||
| )} | ||
|
|
||
| <button | ||
| onClick={() => setShowAdvanced(!showAdvanced)} | ||
| className="advanced-toggle" | ||
|
|
||
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.
Nesting JSX elements too deeply can confuse developers reading the code. To make maintenance and refactoring easier, DeepSource recommends limiting the maximum JSX tree depth to 4.