tribalmemory 0.1.1__py3-none-any.whl → 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,627 @@
1
+ """Graph-enriched memory storage for entity and relationship tracking.
2
+
3
+ Provides a lightweight graph layer alongside vector search to enable:
4
+ - Entity-centric queries ("tell me everything about auth-service")
5
+ - Relationship traversal ("what does auth-service connect to?")
6
+ - Multi-hop reasoning ("what framework does the service that handles auth use?")
7
+
8
+ Uses SQLite for local-first, zero-cloud constraint.
9
+ """
10
+
11
+ import re
12
+ import sqlite3
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+
16
+ # Constants
17
+ MIN_ENTITY_NAME_LENGTH = 3
18
+ MAX_HOP_ITERATIONS = 100 # Safety limit for graph traversal
19
+
20
+
21
+ @dataclass
22
+ class Entity:
23
+ """An extracted entity from memory text."""
24
+
25
+ name: str
26
+ entity_type: str # service, technology, data, concept, person, etc.
27
+ metadata: dict = field(default_factory=dict)
28
+
29
+
30
+ @dataclass
31
+ class Relationship:
32
+ """A relationship between two entities."""
33
+
34
+ source: str # Entity name
35
+ target: str # Entity name
36
+ relation_type: str # uses, stores, connects_to, depends_on, etc.
37
+ metadata: dict = field(default_factory=dict)
38
+
39
+
40
+ class EntityExtractor:
41
+ """Extract entities and relationships from text.
42
+
43
+ Uses pattern-based extraction for common software architecture terms.
44
+ Can be upgraded to spaCy NER or LLM extraction later.
45
+
46
+ Attributes:
47
+ SERVICE_PATTERN: Regex for service-like names (kebab-case with suffix or 8+ chars).
48
+ TECHNOLOGIES: Set of known technology names for exact matching.
49
+ RELATIONSHIP_PATTERNS: List of (pattern, relation_type) tuples for extraction.
50
+ """
51
+
52
+ # Patterns for common entity types
53
+ # Matches: kebab-case identifiers that look like service/component names
54
+ # - Must have at least one hyphen (kebab-case)
55
+ # - Either ends with known suffix OR has 3+ segments OR is 8+ chars
56
+ # - Excludes common false positives via MIN_ENTITY_NAME_LENGTH
57
+ SERVICE_PATTERN = re.compile(
58
+ r'\b('
59
+ r'[a-z][a-z0-9]*-(?:[a-z0-9]+-)*(?:service|api|worker|db|cache|server|client|gateway|proxy|database)' # Known suffix
60
+ r'|'
61
+ r'[a-z][a-z0-9]*(?:-[a-z0-9]+){2,}' # 3+ segments
62
+ r'|'
63
+ r'[a-z][a-z0-9]*-[a-z0-9]{4,}' # 2 segments, second is 4+ chars
64
+ r')\b',
65
+ re.IGNORECASE
66
+ )
67
+
68
+ # Known technology names (case-insensitive matching)
69
+ TECHNOLOGIES = {
70
+ 'postgresql', 'postgres', 'mysql', 'mongodb', 'redis', 'memcached',
71
+ 'elasticsearch', 'kafka', 'rabbitmq', 'nginx', 'docker', 'kubernetes',
72
+ 'aws', 'gcp', 'azure', 'terraform', 'ansible', 'jenkins', 'github',
73
+ 'python', 'javascript', 'typescript', 'rust', 'go', 'java', 'node',
74
+ 'react', 'vue', 'angular', 'django', 'flask', 'fastapi', 'express',
75
+ 'graphql', 'rest', 'grpc', 'websocket', 'http', 'https',
76
+ 'sqlite', 'lancedb', 'chromadb', 'pinecone', 'weaviate',
77
+ 'openai', 'anthropic', 'ollama', 'huggingface',
78
+ 'pgbouncer', 'haproxy', 'traefik', 'envoy',
79
+ }
80
+
81
+ # Relationship patterns: (pattern, relation_type)
82
+ RELATIONSHIP_PATTERNS = [
83
+ (re.compile(r'(\S+)\s+uses\s+(\S+)', re.IGNORECASE), 'uses'),
84
+ (re.compile(r'(\S+)\s+connects?\s+to\s+(\S+)', re.IGNORECASE), 'connects_to'),
85
+ (re.compile(r'(\S+)\s+stores?\s+(?:data\s+)?in\s+(\S+)', re.IGNORECASE), 'stores_in'),
86
+ (re.compile(r'(\S+)\s+depends?\s+on\s+(\S+)', re.IGNORECASE), 'depends_on'),
87
+ (re.compile(r'(\S+)\s+talks?\s+to\s+(\S+)', re.IGNORECASE), 'connects_to'),
88
+ (re.compile(r'(\S+)\s+calls?\s+(\S+)', re.IGNORECASE), 'calls'),
89
+ (re.compile(r'(\S+)\s+handles?\s+(\S+)', re.IGNORECASE), 'handles'),
90
+ (re.compile(r'(\S+)\s+for\s+(?:the\s+)?(\S+)', re.IGNORECASE), 'serves'),
91
+ ]
92
+
93
+ def extract(self, text: str) -> list[Entity]:
94
+ """Extract entities from text.
95
+
96
+ Args:
97
+ text: Input text to extract entities from.
98
+
99
+ Returns:
100
+ List of extracted Entity objects.
101
+ """
102
+ if not text or not text.strip():
103
+ return []
104
+
105
+ entities = []
106
+ seen_names: set[str] = set()
107
+
108
+ # Extract service-like names (kebab-case identifiers)
109
+ for match in self.SERVICE_PATTERN.finditer(text):
110
+ name = match.group(1)
111
+ if name and name.lower() not in seen_names and len(name) >= MIN_ENTITY_NAME_LENGTH:
112
+ seen_names.add(name.lower())
113
+ entities.append(Entity(
114
+ name=name,
115
+ entity_type=self._infer_service_type(name)
116
+ ))
117
+
118
+ # Extract known technology names
119
+ words = re.findall(r'\b\w+\b', text)
120
+ for word in words:
121
+ word_lower = word.lower()
122
+ if word_lower in self.TECHNOLOGIES and word_lower not in seen_names:
123
+ seen_names.add(word_lower)
124
+ entities.append(Entity(
125
+ name=word, # Preserve original case
126
+ entity_type='technology'
127
+ ))
128
+
129
+ return entities
130
+
131
+ def extract_with_relationships(
132
+ self, text: str
133
+ ) -> tuple[list[Entity], list[Relationship]]:
134
+ """Extract both entities and relationships from text."""
135
+ entities = self.extract(text)
136
+ entity_names = {e.name.lower() for e in entities}
137
+ relationships = []
138
+
139
+ for pattern, rel_type in self.RELATIONSHIP_PATTERNS:
140
+ for match in pattern.finditer(text):
141
+ source = match.group(1).strip('.,;:')
142
+ target = match.group(2).strip('.,;:')
143
+
144
+ # Only create relationship if both entities were extracted
145
+ # or if they look like valid entity names
146
+ source_valid = (
147
+ source.lower() in entity_names or
148
+ self._looks_like_entity(source)
149
+ )
150
+ target_valid = (
151
+ target.lower() in entity_names or
152
+ self._looks_like_entity(target)
153
+ )
154
+
155
+ if source_valid and target_valid:
156
+ relationships.append(Relationship(
157
+ source=source,
158
+ target=target,
159
+ relation_type=rel_type
160
+ ))
161
+
162
+ # Add entities if not already present
163
+ if source.lower() not in entity_names:
164
+ entity_names.add(source.lower())
165
+ entities.append(Entity(
166
+ name=source,
167
+ entity_type=self._infer_type(source)
168
+ ))
169
+ if target.lower() not in entity_names:
170
+ entity_names.add(target.lower())
171
+ entities.append(Entity(
172
+ name=target,
173
+ entity_type=self._infer_type(target)
174
+ ))
175
+
176
+ return entities, relationships
177
+
178
+ def _infer_service_type(self, name: str) -> str:
179
+ """Infer entity type from service-like name.
180
+
181
+ Args:
182
+ name: Service name to analyze.
183
+
184
+ Returns:
185
+ Entity type string (e.g., 'service', 'database', 'worker').
186
+ """
187
+ name_lower = name.lower()
188
+ if '-db' in name_lower or '-database' in name_lower:
189
+ return 'database'
190
+ if '-api' in name_lower or '-service' in name_lower:
191
+ return 'service'
192
+ if '-worker' in name_lower or '-job' in name_lower:
193
+ return 'worker'
194
+ if '-cache' in name_lower:
195
+ return 'cache'
196
+ if '-gateway' in name_lower or '-proxy' in name_lower:
197
+ return 'gateway'
198
+ if '-server' in name_lower:
199
+ return 'server'
200
+ if '-client' in name_lower:
201
+ return 'client'
202
+ return 'service'
203
+
204
+ def _infer_type(self, name: str) -> str:
205
+ """Infer entity type from name.
206
+
207
+ Args:
208
+ name: Entity name to analyze.
209
+
210
+ Returns:
211
+ Entity type string.
212
+ """
213
+ if name.lower() in self.TECHNOLOGIES:
214
+ return 'technology'
215
+ if self.SERVICE_PATTERN.match(name):
216
+ return self._infer_service_type(name)
217
+ return 'concept'
218
+
219
+ def _looks_like_entity(self, name: str) -> bool:
220
+ """Check if a string looks like a valid entity name.
221
+
222
+ Args:
223
+ name: String to check.
224
+
225
+ Returns:
226
+ True if the string looks like an entity name.
227
+ """
228
+ if not name or len(name) < MIN_ENTITY_NAME_LENGTH:
229
+ return False
230
+ if name.lower() in self.TECHNOLOGIES:
231
+ return True
232
+ if self.SERVICE_PATTERN.match(name):
233
+ return True
234
+ # Capitalized words (proper nouns)
235
+ if name[0].isupper() and name.isalnum():
236
+ return True
237
+ return False
238
+
239
+
240
+ class GraphStore:
241
+ """SQLite-backed graph storage for entities and relationships.
242
+
243
+ Schema:
244
+ - entities: (id, name, entity_type, metadata_json)
245
+ - entity_memories: (entity_id, memory_id) - many-to-many
246
+ - relationships: (id, source_entity_id, target_entity_id, relation_type, metadata_json)
247
+ - relationship_memories: (relationship_id, memory_id) - many-to-many
248
+
249
+ Note on connection management:
250
+ Each operation creates a fresh connection. For high-throughput scenarios,
251
+ consider using connection pooling. SQLite's file locking handles concurrency.
252
+ """
253
+
254
+ # Known technology names for type inference
255
+ KNOWN_TECHNOLOGIES = EntityExtractor.TECHNOLOGIES
256
+
257
+ def __init__(self, db_path: str | Path):
258
+ """Initialize graph store with SQLite database.
259
+
260
+ Args:
261
+ db_path: Path to the SQLite database file.
262
+ """
263
+ self.db_path = Path(db_path)
264
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
265
+ self._init_schema()
266
+
267
+ def _get_connection(self) -> sqlite3.Connection:
268
+ """Get a database connection.
269
+
270
+ Returns:
271
+ SQLite connection with Row factory.
272
+ """
273
+ conn = sqlite3.connect(self.db_path)
274
+ conn.row_factory = sqlite3.Row
275
+ return conn
276
+
277
+ def _infer_entity_type(self, name: str) -> str:
278
+ """Infer entity type from name when creating from relationships.
279
+
280
+ Args:
281
+ name: Entity name to analyze.
282
+
283
+ Returns:
284
+ Inferred entity type string.
285
+ """
286
+ if name.lower() in self.KNOWN_TECHNOLOGIES:
287
+ return 'technology'
288
+ # Check for service-like patterns
289
+ name_lower = name.lower()
290
+ if '-db' in name_lower or '-database' in name_lower:
291
+ return 'database'
292
+ if '-api' in name_lower or '-service' in name_lower:
293
+ return 'service'
294
+ if '-worker' in name_lower or '-job' in name_lower:
295
+ return 'worker'
296
+ if '-cache' in name_lower:
297
+ return 'cache'
298
+ if '-gateway' in name_lower or '-proxy' in name_lower:
299
+ return 'gateway'
300
+ if '-' in name: # Generic kebab-case, probably a service
301
+ return 'service'
302
+ return 'concept'
303
+
304
+ def _init_schema(self) -> None:
305
+ """Initialize database schema."""
306
+ with self._get_connection() as conn:
307
+ conn.executescript("""
308
+ CREATE TABLE IF NOT EXISTS entities (
309
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
310
+ name TEXT NOT NULL,
311
+ entity_type TEXT NOT NULL,
312
+ metadata_json TEXT DEFAULT '{}',
313
+ UNIQUE(name)
314
+ );
315
+
316
+ CREATE TABLE IF NOT EXISTS entity_memories (
317
+ entity_id INTEGER NOT NULL,
318
+ memory_id TEXT NOT NULL,
319
+ FOREIGN KEY (entity_id) REFERENCES entities(id) ON DELETE CASCADE,
320
+ UNIQUE(entity_id, memory_id)
321
+ );
322
+
323
+ CREATE TABLE IF NOT EXISTS relationships (
324
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
325
+ source_entity_id INTEGER NOT NULL,
326
+ target_entity_id INTEGER NOT NULL,
327
+ relation_type TEXT NOT NULL,
328
+ metadata_json TEXT DEFAULT '{}',
329
+ FOREIGN KEY (source_entity_id) REFERENCES entities(id) ON DELETE CASCADE,
330
+ FOREIGN KEY (target_entity_id) REFERENCES entities(id) ON DELETE CASCADE,
331
+ UNIQUE(source_entity_id, target_entity_id, relation_type)
332
+ );
333
+
334
+ CREATE TABLE IF NOT EXISTS relationship_memories (
335
+ relationship_id INTEGER NOT NULL,
336
+ memory_id TEXT NOT NULL,
337
+ FOREIGN KEY (relationship_id) REFERENCES relationships(id) ON DELETE CASCADE,
338
+ UNIQUE(relationship_id, memory_id)
339
+ );
340
+
341
+ CREATE INDEX IF NOT EXISTS idx_entity_name ON entities(name);
342
+ CREATE INDEX IF NOT EXISTS idx_entity_memories_memory ON entity_memories(memory_id);
343
+ CREATE INDEX IF NOT EXISTS idx_rel_source ON relationships(source_entity_id);
344
+ CREATE INDEX IF NOT EXISTS idx_rel_target ON relationships(target_entity_id);
345
+ """)
346
+
347
+ def add_entity(self, entity: Entity, memory_id: str) -> int:
348
+ """Add an entity and associate it with a memory.
349
+
350
+ Returns the entity ID.
351
+ """
352
+ with self._get_connection() as conn:
353
+ # Upsert entity
354
+ cursor = conn.execute(
355
+ """
356
+ INSERT INTO entities (name, entity_type, metadata_json)
357
+ VALUES (?, ?, ?)
358
+ ON CONFLICT(name) DO UPDATE SET
359
+ entity_type = COALESCE(excluded.entity_type, entities.entity_type)
360
+ RETURNING id
361
+ """,
362
+ (entity.name, entity.entity_type, '{}')
363
+ )
364
+ entity_id = cursor.fetchone()[0]
365
+
366
+ # Associate with memory
367
+ conn.execute(
368
+ """
369
+ INSERT OR IGNORE INTO entity_memories (entity_id, memory_id)
370
+ VALUES (?, ?)
371
+ """,
372
+ (entity_id, memory_id)
373
+ )
374
+
375
+ return entity_id
376
+
377
+ def add_relationship(self, relationship: Relationship, memory_id: str) -> int:
378
+ """Add a relationship and associate it with a memory.
379
+
380
+ Args:
381
+ relationship: The relationship to store.
382
+ memory_id: ID of the memory this relationship was extracted from.
383
+
384
+ Returns:
385
+ The relationship ID.
386
+ """
387
+ with self._get_connection() as conn:
388
+ # Get or create source entity (infer type from name)
389
+ source_row = conn.execute(
390
+ "SELECT id FROM entities WHERE name = ?",
391
+ (relationship.source,)
392
+ ).fetchone()
393
+ if not source_row:
394
+ source_type = self._infer_entity_type(relationship.source)
395
+ cursor = conn.execute(
396
+ "INSERT INTO entities (name, entity_type) VALUES (?, ?) RETURNING id",
397
+ (relationship.source, source_type)
398
+ )
399
+ source_id = cursor.fetchone()[0]
400
+ else:
401
+ source_id = source_row[0]
402
+
403
+ # Get or create target entity (infer type from name)
404
+ target_row = conn.execute(
405
+ "SELECT id FROM entities WHERE name = ?",
406
+ (relationship.target,)
407
+ ).fetchone()
408
+ if not target_row:
409
+ target_type = self._infer_entity_type(relationship.target)
410
+ cursor = conn.execute(
411
+ "INSERT INTO entities (name, entity_type) VALUES (?, ?) RETURNING id",
412
+ (relationship.target, target_type)
413
+ )
414
+ target_id = cursor.fetchone()[0]
415
+ else:
416
+ target_id = target_row[0]
417
+
418
+ # Upsert relationship
419
+ cursor = conn.execute(
420
+ """
421
+ INSERT INTO relationships (source_entity_id, target_entity_id, relation_type)
422
+ VALUES (?, ?, ?)
423
+ ON CONFLICT(source_entity_id, target_entity_id, relation_type) DO NOTHING
424
+ RETURNING id
425
+ """,
426
+ (source_id, target_id, relationship.relation_type)
427
+ )
428
+ row = cursor.fetchone()
429
+ if row:
430
+ rel_id = row[0]
431
+ else:
432
+ # Relationship already exists, get its ID
433
+ rel_id = conn.execute(
434
+ """
435
+ SELECT id FROM relationships
436
+ WHERE source_entity_id = ? AND target_entity_id = ? AND relation_type = ?
437
+ """,
438
+ (source_id, target_id, relationship.relation_type)
439
+ ).fetchone()[0]
440
+
441
+ # Associate with memory
442
+ conn.execute(
443
+ """
444
+ INSERT OR IGNORE INTO relationship_memories (relationship_id, memory_id)
445
+ VALUES (?, ?)
446
+ """,
447
+ (rel_id, memory_id)
448
+ )
449
+
450
+ return rel_id
451
+
452
+ def get_entities_for_memory(self, memory_id: str) -> list[Entity]:
453
+ """Get all entities associated with a memory."""
454
+ with self._get_connection() as conn:
455
+ rows = conn.execute(
456
+ """
457
+ SELECT e.name, e.entity_type, e.metadata_json
458
+ FROM entities e
459
+ JOIN entity_memories em ON e.id = em.entity_id
460
+ WHERE em.memory_id = ?
461
+ """,
462
+ (memory_id,)
463
+ ).fetchall()
464
+
465
+ return [
466
+ Entity(name=row['name'], entity_type=row['entity_type'])
467
+ for row in rows
468
+ ]
469
+
470
+ def get_relationships_for_entity(self, entity_name: str) -> list[Relationship]:
471
+ """Get all relationships where entity is the source."""
472
+ with self._get_connection() as conn:
473
+ rows = conn.execute(
474
+ """
475
+ SELECT e_source.name as source, e_target.name as target, r.relation_type
476
+ FROM relationships r
477
+ JOIN entities e_source ON r.source_entity_id = e_source.id
478
+ JOIN entities e_target ON r.target_entity_id = e_target.id
479
+ WHERE e_source.name = ?
480
+ """,
481
+ (entity_name,)
482
+ ).fetchall()
483
+
484
+ return [
485
+ Relationship(
486
+ source=row['source'],
487
+ target=row['target'],
488
+ relation_type=row['relation_type']
489
+ )
490
+ for row in rows
491
+ ]
492
+
493
+ def get_memories_for_entity(self, entity_name: str) -> list[str]:
494
+ """Get all memory IDs associated with an entity."""
495
+ with self._get_connection() as conn:
496
+ rows = conn.execute(
497
+ """
498
+ SELECT DISTINCT em.memory_id
499
+ FROM entity_memories em
500
+ JOIN entities e ON em.entity_id = e.id
501
+ WHERE e.name = ?
502
+ """,
503
+ (entity_name,)
504
+ ).fetchall()
505
+
506
+ return [row['memory_id'] for row in rows]
507
+
508
+ def find_connected(
509
+ self,
510
+ entity_name: str,
511
+ hops: int = 1,
512
+ include_source: bool = False
513
+ ) -> list[Entity]:
514
+ """Find entities connected to the given entity within N hops.
515
+
516
+ Args:
517
+ entity_name: Starting entity name.
518
+ hops: Maximum number of relationship hops (1 = direct connections).
519
+ Capped at MAX_HOP_ITERATIONS for safety.
520
+ include_source: Whether to include the source entity in results.
521
+
522
+ Returns:
523
+ List of connected entities.
524
+ """
525
+ # Safety: cap hops to prevent runaway traversal
526
+ safe_hops = min(hops, MAX_HOP_ITERATIONS)
527
+
528
+ with self._get_connection() as conn:
529
+ # Start with source entity
530
+ source = conn.execute(
531
+ "SELECT id, name, entity_type FROM entities WHERE name = ?",
532
+ (entity_name,)
533
+ ).fetchone()
534
+
535
+ if not source:
536
+ return []
537
+
538
+ visited: set[int] = {source['id']}
539
+ current_frontier: set[int] = {source['id']}
540
+ result_ids: set[int] = set()
541
+
542
+ for _ in range(safe_hops):
543
+ if not current_frontier:
544
+ break
545
+
546
+ # Find all entities connected to current frontier
547
+ # SECURITY NOTE: placeholders is safe because it's computed from
548
+ # len(current_frontier) (an integer), not user input. The actual
549
+ # values are passed as parameters, not interpolated.
550
+ placeholders = ','.join('?' * len(current_frontier))
551
+ rows = conn.execute(
552
+ f"""
553
+ SELECT DISTINCT e.id, e.name, e.entity_type
554
+ FROM entities e
555
+ JOIN relationships r ON (
556
+ (r.source_entity_id IN ({placeholders}) AND r.target_entity_id = e.id)
557
+ OR
558
+ (r.target_entity_id IN ({placeholders}) AND r.source_entity_id = e.id)
559
+ )
560
+ """,
561
+ list(current_frontier) + list(current_frontier)
562
+ ).fetchall()
563
+
564
+ next_frontier: set[int] = set()
565
+ for row in rows:
566
+ if row['id'] not in visited:
567
+ visited.add(row['id'])
568
+ next_frontier.add(row['id'])
569
+ result_ids.add(row['id'])
570
+
571
+ current_frontier = next_frontier
572
+
573
+ # Fetch full entity info for results
574
+ if not result_ids:
575
+ return []
576
+
577
+ placeholders = ','.join('?' * len(result_ids))
578
+ rows = conn.execute(
579
+ f"SELECT name, entity_type FROM entities WHERE id IN ({placeholders})",
580
+ list(result_ids)
581
+ ).fetchall()
582
+
583
+ result = [
584
+ Entity(name=row['name'], entity_type=row['entity_type'])
585
+ for row in rows
586
+ ]
587
+
588
+ if include_source:
589
+ result.insert(0, Entity(
590
+ name=source['name'],
591
+ entity_type=source['entity_type']
592
+ ))
593
+
594
+ return result
595
+
596
+ def delete_memory(self, memory_id: str) -> None:
597
+ """Delete all entity and relationship associations for a memory.
598
+
599
+ Note: Entities themselves are preserved (they may be referenced by other memories).
600
+ Only the associations are removed.
601
+ """
602
+ with self._get_connection() as conn:
603
+ # Delete relationship associations
604
+ conn.execute(
605
+ "DELETE FROM relationship_memories WHERE memory_id = ?",
606
+ (memory_id,)
607
+ )
608
+
609
+ # Delete entity associations
610
+ conn.execute(
611
+ "DELETE FROM entity_memories WHERE memory_id = ?",
612
+ (memory_id,)
613
+ )
614
+
615
+ # Clean up orphaned relationships (no memory references)
616
+ conn.execute("""
617
+ DELETE FROM relationships
618
+ WHERE id NOT IN (SELECT relationship_id FROM relationship_memories)
619
+ """)
620
+
621
+ # Optionally clean up orphaned entities (no memory or relationship references)
622
+ conn.execute("""
623
+ DELETE FROM entities
624
+ WHERE id NOT IN (SELECT entity_id FROM entity_memories)
625
+ AND id NOT IN (SELECT source_entity_id FROM relationships)
626
+ AND id NOT IN (SELECT target_entity_id FROM relationships)
627
+ """)