cortexlayer 0.1.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,796 @@
1
+ # Copyright mem0 contributors. Licensed under the Apache License, Version 2.0.
2
+ #
3
+ # Adapted for cortexlayer from mem0ai 2.1.0 (https://github.com/mem0ai/mem0),
4
+ # ``mem0/utils/entity_extraction.py``. See the repository NOTICE file.
5
+ #
6
+ # Changes from the original: the two public entry points (``extract_entities``,
7
+ # ``extract_entities_batch``) take an optional spaCy pipeline and otherwise load
8
+ # ``en_core_web_sm`` through ``_default_nlp`` instead of importing
9
+ # ``mem0.utils.spacy_models`` (which silently downloads the model at runtime).
10
+ # The extraction heuristics themselves are unchanged.
11
+
12
+ """
13
+ Entity extraction from text using spaCy NLP.
14
+
15
+ Extracts three types of entities from a spaCy-processed document:
16
+ - **Proper nouns**: Capitalized multi-word sequences (person names, places, brands)
17
+ - **Quoted text**: Text in single or double quotes (titles, specific terms)
18
+ - **Noun compounds**: Multi-word noun phrases with specific modifiers (e.g., "machine learning")
19
+
20
+ Public API:
21
+ ``extract_entities(text)`` accepts a string and owns spaCy model loading.
22
+ ``extract_entities_batch(texts)`` uses ``nlp.pipe`` for batched extraction.
23
+
24
+ Returns:
25
+ List of ``(entity_type, entity_text)`` tuples where entity_type is one of
26
+ PROPER, QUOTED, TOPIC, or IDENTIFIER. Returns ``[]`` if spaCy is unavailable.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from dataclasses import dataclass
32
+ import re
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class _EntityCandidate:
37
+ entity_type: str
38
+ text: str
39
+ source: str
40
+ start: int
41
+ end: int
42
+ confidence: float
43
+ priority: int
44
+
45
+
46
+ # Words that are too generic to be useful as entity heads
47
+ _GENERIC_HEADS = {
48
+ "thing",
49
+ "stuff",
50
+ "way",
51
+ "time",
52
+ "experience",
53
+ "situation",
54
+ "case",
55
+ "fact",
56
+ "matter",
57
+ "issue",
58
+ "idea",
59
+ "thought",
60
+ "feeling",
61
+ "place",
62
+ "area",
63
+ "part",
64
+ "kind",
65
+ "type",
66
+ "sort",
67
+ "lot",
68
+ "bit",
69
+ "day",
70
+ "year",
71
+ "week",
72
+ "month",
73
+ "moment",
74
+ "instance",
75
+ "example",
76
+ "technique",
77
+ "method",
78
+ "approach",
79
+ "process",
80
+ "step",
81
+ "tool",
82
+ "result",
83
+ "outcome",
84
+ "goal",
85
+ "task",
86
+ "item",
87
+ "topic",
88
+ "scale",
89
+ "size",
90
+ "level",
91
+ "degree",
92
+ "amount",
93
+ "number",
94
+ "style",
95
+ "look",
96
+ "color",
97
+ "colour",
98
+ "shape",
99
+ "form",
100
+ "piece",
101
+ "section",
102
+ "side",
103
+ "end",
104
+ "edge",
105
+ "surface",
106
+ "point",
107
+ }
108
+
109
+ # Entity labels emitted by spaCy that are usually safe to treat as named
110
+ # entities. Numeric and temporal labels are intentionally excluded.
111
+ _ACCEPTED_NER_LABELS = {
112
+ "PERSON",
113
+ "ORG",
114
+ "GPE",
115
+ "LOC",
116
+ "FAC",
117
+ "PRODUCT",
118
+ "WORK_OF_ART",
119
+ "EVENT",
120
+ "NORP",
121
+ "LAW",
122
+ "LANGUAGE",
123
+ }
124
+
125
+ _REJECTED_NER_LABELS = {
126
+ "DATE",
127
+ "TIME",
128
+ "CARDINAL",
129
+ "ORDINAL",
130
+ "QUANTITY",
131
+ "MONEY",
132
+ "PERCENT",
133
+ }
134
+
135
+ # Generic role words and title-cased English words that should not become
136
+ # single-token named entities just because spaCy tagged them as PROPN.
137
+ _GENERIC_SINGLE_ENTITY_TERMS = {
138
+ "user",
139
+ "assistant",
140
+ "agent",
141
+ "customer",
142
+ "client",
143
+ "person",
144
+ "people",
145
+ "human",
146
+ "memory",
147
+ "message",
148
+ "conversation",
149
+ "chat",
150
+ "session",
151
+ "system",
152
+ "top",
153
+ }
154
+
155
+ # Modifiers that describe circumstance, not content
156
+ _CIRCUMSTANTIAL_MODS = {
157
+ "solo",
158
+ "individual",
159
+ "team",
160
+ "group",
161
+ "joint",
162
+ "collaborative",
163
+ "first",
164
+ "last",
165
+ "next",
166
+ "previous",
167
+ "final",
168
+ "initial",
169
+ "main",
170
+ "side",
171
+ "top",
172
+ }
173
+
174
+ # Adjectives too vague to make a compound entity specific
175
+ _NON_SPECIFIC_ADJ = {
176
+ "many",
177
+ "few",
178
+ "several",
179
+ "some",
180
+ "any",
181
+ "all",
182
+ "most",
183
+ "more",
184
+ "less",
185
+ "much",
186
+ "little",
187
+ "enough",
188
+ "various",
189
+ "numerous",
190
+ "multiple",
191
+ "countless",
192
+ "great",
193
+ "good",
194
+ "bad",
195
+ "nice",
196
+ "terrible",
197
+ "awful",
198
+ "awesome",
199
+ "amazing",
200
+ "wonderful",
201
+ "horrible",
202
+ "excellent",
203
+ "poor",
204
+ "best",
205
+ "worst",
206
+ "fine",
207
+ "okay",
208
+ "new",
209
+ "old",
210
+ "recent",
211
+ "past",
212
+ "future",
213
+ "current",
214
+ "previous",
215
+ "next",
216
+ "last",
217
+ "first",
218
+ "latest",
219
+ "early",
220
+ "late",
221
+ "former",
222
+ "modern",
223
+ "ancient",
224
+ "big",
225
+ "small",
226
+ "large",
227
+ "tiny",
228
+ "huge",
229
+ "enormous",
230
+ "long",
231
+ "short",
232
+ "tall",
233
+ "high",
234
+ "low",
235
+ "wide",
236
+ "narrow",
237
+ "thick",
238
+ "thin",
239
+ "deep",
240
+ "shallow",
241
+ "similar",
242
+ "different",
243
+ "same",
244
+ "other",
245
+ "another",
246
+ "such",
247
+ "certain",
248
+ "important",
249
+ "main",
250
+ "major",
251
+ "minor",
252
+ "key",
253
+ "primary",
254
+ "real",
255
+ "actual",
256
+ "true",
257
+ "whole",
258
+ "entire",
259
+ "full",
260
+ "complete",
261
+ "total",
262
+ "basic",
263
+ "simple",
264
+ "interesting",
265
+ "boring",
266
+ "exciting",
267
+ "special",
268
+ "particular",
269
+ "general",
270
+ "common",
271
+ "unique",
272
+ "rare",
273
+ "typical",
274
+ "usual",
275
+ "normal",
276
+ "regular",
277
+ "possible",
278
+ "likely",
279
+ "potential",
280
+ "available",
281
+ "necessary",
282
+ "only",
283
+ "solo",
284
+ "individual",
285
+ "team",
286
+ "group",
287
+ "joint",
288
+ "collaborative",
289
+ "final",
290
+ "initial",
291
+ "side",
292
+ }
293
+
294
+ # Generic tail words to strip from compound entities
295
+ _GENERIC_ENDINGS = {
296
+ "work",
297
+ "works",
298
+ "job",
299
+ "jobs",
300
+ "task",
301
+ "tasks",
302
+ "stuff",
303
+ "things",
304
+ "thing",
305
+ "info",
306
+ "information",
307
+ "details",
308
+ "data",
309
+ "content",
310
+ "material",
311
+ "materials",
312
+ "activities",
313
+ "activity",
314
+ "efforts",
315
+ "effort",
316
+ "options",
317
+ "option",
318
+ "choices",
319
+ "choice",
320
+ "results",
321
+ "result",
322
+ "output",
323
+ "outputs",
324
+ "products",
325
+ "product",
326
+ "items",
327
+ "item",
328
+ }
329
+
330
+ # Capitalized single words that are too generic to be proper nouns
331
+ _GENERIC_CAPS = {
332
+ "works",
333
+ "items",
334
+ "things",
335
+ "stuff",
336
+ "resources",
337
+ "options",
338
+ "tips",
339
+ "ideas",
340
+ "steps",
341
+ "ways",
342
+ "methods",
343
+ "tools",
344
+ "features",
345
+ "benefits",
346
+ "examples",
347
+ "details",
348
+ "notes",
349
+ "instructions",
350
+ "guidelines",
351
+ "recommendations",
352
+ "suggestions",
353
+ "overview",
354
+ "summary",
355
+ "conclusion",
356
+ "introduction",
357
+ "pros",
358
+ "cons",
359
+ "advantages",
360
+ "disadvantages",
361
+ }
362
+
363
+ # Markdown/formatting markers to skip during extraction
364
+ _FORMATTING_MARKERS = {"*", "-", "+", "\u2022", "\u2013", "\u2014", "#", "##", "###", "**", "__"}
365
+
366
+
367
+ def _is_sentence_start(tokens: list, idx: int) -> bool:
368
+ """Check if a token is at the start of a sentence or after formatting."""
369
+ if idx == 0:
370
+ return True
371
+ tok = tokens[idx]
372
+ if tok.is_sent_start:
373
+ return True
374
+ prev = tokens[idx - 1].text
375
+ return prev in ".!?:" or prev in _FORMATTING_MARKERS or "\n" in prev
376
+
377
+
378
+ def _strip_generic_ending(toks: list) -> list:
379
+ """Remove generic trailing words from compound token sequences."""
380
+ if len(toks) <= 1:
381
+ return toks
382
+ last = toks[-1].lemma_.lower() if hasattr(toks[-1], "lemma_") else toks[-1].lower()
383
+ return toks[:-1] if last in _GENERIC_ENDINGS and len(toks) > 2 else toks
384
+
385
+
386
+ def _lemmatize_compound(toks: list) -> str:
387
+ """Join compound tokens, lemmatizing nouns."""
388
+ return " ".join(t.lemma_ if t.pos_ == "NOUN" else t.text for t in toks)
389
+
390
+
391
+ def _has_artifacts(txt: str) -> bool:
392
+ """Check for formatting artifacts that indicate non-entity text."""
393
+ return any(
394
+ [
395
+ "**" in txt or "__" in txt or ":*" in txt,
396
+ re.search(r"\s\*\s|\s\*$|^\*\s", txt),
397
+ " " in txt or "\n" in txt or "\t" in txt,
398
+ len(txt) > 100,
399
+ txt.startswith(("\u2022", "-", "+", "\u2013", "\u2014")),
400
+ ]
401
+ )
402
+
403
+
404
+ def _clean_text(txt: str) -> str:
405
+ txt = re.sub(r"^\*+\s*|\s*\*+$", "", txt.strip())
406
+ txt = re.sub(r"\s*:+$", "", txt)
407
+ txt = re.sub(r"^\d+\s*\.\s*", "", txt)
408
+ return " ".join(txt.split())
409
+
410
+
411
+ def _norm_text(txt: str) -> str:
412
+ return " ".join(txt.lower().split())
413
+
414
+
415
+ def _looks_like_technical_identifier(text: str) -> bool:
416
+ return bool(re.fullmatch(r"[A-Za-z_][\w-]*(?:\.[A-Za-z_][\w-]*)+", text))
417
+
418
+
419
+ def _has_internal_cap_or_digit(text: str) -> bool:
420
+ return any(ch.isdigit() for ch in text) or any(ch.isupper() for ch in text[1:])
421
+
422
+
423
+ def _looks_like_metric_count_token(tok) -> bool:
424
+ return tok.pos_ == "NUM" and bool(re.fullmatch(r"\d[\d,]*(?:\.\d+)?", tok.text))
425
+
426
+
427
+ def _is_metric_list_context(tokens: list, idx: int) -> bool:
428
+ prev_text = tokens[idx - 1].text if idx > 0 else ""
429
+ next_text = tokens[idx + 1].text if idx + 1 < len(tokens) else ""
430
+ return prev_text in {":", ",", ";"} or next_text in {",", ";"}
431
+
432
+
433
+ def _strip_trailing_metric_counts(span_tokens: list, all_tokens: list) -> list:
434
+ while len(span_tokens) > 1 and _looks_like_metric_count_token(span_tokens[-1]):
435
+ tok = span_tokens[-1]
436
+ if "," not in tok.text and not _is_metric_list_context(all_tokens, tok.i):
437
+ break
438
+ span_tokens = span_tokens[:-1]
439
+ return span_tokens
440
+
441
+
442
+ def _is_list_item_name_token(tokens: list, idx: int) -> bool:
443
+ tok = tokens[idx]
444
+ if not tok.text or tok.text in _FORMATTING_MARKERS or not tok.text[0].isupper():
445
+ return False
446
+ if not any(ch.isalpha() for ch in tok.text) or _is_bad_single_name_token(tok):
447
+ return False
448
+ next_tok = tokens[idx + 1] if idx + 1 < len(tokens) else None
449
+ if not next_tok or not _looks_like_metric_count_token(next_tok):
450
+ return False
451
+ return _is_metric_list_context(tokens, idx) or _is_metric_list_context(tokens, idx + 1)
452
+
453
+
454
+ def _is_name_like_token(tok, tokens: list | None = None, idx: int | None = None) -> bool:
455
+ if not tok.text or tok.text in _FORMATTING_MARKERS:
456
+ return False
457
+ if not tok.text[0].isupper():
458
+ return False
459
+ if not any(ch.isalpha() for ch in tok.text):
460
+ return False
461
+ if _is_bad_single_name_token(tok):
462
+ return False
463
+ if tok.pos_ == "PROPN" or tok.tag_ in {"NNP", "NNPS"}:
464
+ return True
465
+ if tokens is not None and idx is not None and _is_list_item_name_token(tokens, idx):
466
+ return True
467
+ if _has_internal_cap_or_digit(tok.text):
468
+ return True
469
+ return (
470
+ tokens is not None
471
+ and idx is not None
472
+ and tok.pos_ == "NOUN"
473
+ and tok.dep_ not in {"compound", "amod"}
474
+ and not _is_sentence_start(tokens, idx)
475
+ )
476
+
477
+
478
+ def _is_bad_single_name_token(tok) -> bool:
479
+ lower = tok.text.lower()
480
+ return lower in _GENERIC_SINGLE_ENTITY_TERMS or lower in _GENERIC_CAPS or tok.is_stop
481
+
482
+
483
+ def _add_candidate(
484
+ candidates: list[_EntityCandidate],
485
+ entity_type: str,
486
+ text: str,
487
+ source: str,
488
+ start: int,
489
+ end: int,
490
+ confidence: float,
491
+ priority: int,
492
+ ) -> None:
493
+ cleaned = _clean_text(text)
494
+ if not cleaned or len(cleaned) <= 2 or _has_artifacts(cleaned):
495
+ return
496
+ candidates.append(
497
+ _EntityCandidate(
498
+ entity_type=entity_type,
499
+ text=cleaned,
500
+ source=source,
501
+ start=start,
502
+ end=end,
503
+ confidence=confidence,
504
+ priority=priority,
505
+ )
506
+ )
507
+
508
+
509
+ def _add_ner_candidates(doc, candidates: list[_EntityCandidate]) -> None:
510
+ tokens = list(doc)
511
+ for ent in doc.ents:
512
+ if ent.label_ in _REJECTED_NER_LABELS or ent.label_ not in _ACCEPTED_NER_LABELS:
513
+ continue
514
+ ent_tokens = _strip_trailing_metric_counts(list(ent), tokens)
515
+ if not ent_tokens:
516
+ continue
517
+ if any(tok.pos_ == "CCONJ" and tok.text.lower() == "and" for tok in ent_tokens):
518
+ continue
519
+ if len(ent_tokens) == 1 and _is_bad_single_name_token(ent_tokens[0]):
520
+ continue
521
+ if (
522
+ len(ent_tokens) == 1
523
+ and ent_tokens[0].dep_ in {"compound", "amod"}
524
+ and ent_tokens[0].head.pos_ in {"NOUN", "PROPN"}
525
+ ):
526
+ continue
527
+ _add_candidate(
528
+ candidates,
529
+ "PROPER",
530
+ "".join(tok.text_with_ws for tok in ent_tokens).strip(),
531
+ "spacy_ner",
532
+ ent_tokens[0].i,
533
+ ent_tokens[-1].i + 1,
534
+ 0.95,
535
+ 0,
536
+ )
537
+
538
+
539
+ def _add_technical_identifier_candidates(tokens: list, candidates: list[_EntityCandidate]) -> None:
540
+ for tok in tokens:
541
+ if _looks_like_technical_identifier(tok.text):
542
+ _add_candidate(
543
+ candidates,
544
+ "IDENTIFIER",
545
+ tok.text,
546
+ "technical_identifier",
547
+ tok.i,
548
+ tok.i + 1,
549
+ 0.9,
550
+ 1,
551
+ )
552
+
553
+
554
+ def _add_proper_name_candidates(tokens: list, candidates: list[_EntityCandidate]) -> None:
555
+ allowed_inner_connectors = {"of", "the", "for", "at", "in"}
556
+ i = 0
557
+ while i < len(tokens):
558
+ tok = tokens[i]
559
+ if not _is_name_like_token(tok, tokens, i):
560
+ i += 1
561
+ continue
562
+
563
+ span_tokens = [tok]
564
+ j = i + 1
565
+ while j < len(tokens):
566
+ current = tokens[j]
567
+ if _is_name_like_token(current, tokens, j):
568
+ span_tokens.append(current)
569
+ j += 1
570
+ continue
571
+ if (
572
+ current.text.lower() in allowed_inner_connectors
573
+ and j + 1 < len(tokens)
574
+ and _is_name_like_token(tokens[j + 1], tokens, j + 1)
575
+ ):
576
+ span_tokens.extend([current, tokens[j + 1]])
577
+ j += 2
578
+ continue
579
+ break
580
+
581
+ name_tokens = [
582
+ t
583
+ for t in span_tokens
584
+ if _is_name_like_token(t, tokens, t.i) or (0 <= t.i < len(tokens) and _is_list_item_name_token(tokens, t.i))
585
+ ]
586
+ if len(name_tokens) > 1 or not _is_bad_single_name_token(name_tokens[0]):
587
+ text = "".join(t.text_with_ws for t in span_tokens).strip()
588
+ _add_candidate(candidates, "PROPER", text, "proper_name_span", i, j, 0.8, 2)
589
+ i = max(j, i + 1)
590
+
591
+
592
+ def _add_quoted_candidates(text: str, candidates: list[_EntityCandidate]) -> None:
593
+ for m in re.finditer(r'"([^"]+)"', text):
594
+ if len(m.group(1).strip()) > 2:
595
+ _add_candidate(candidates, "QUOTED", m.group(1).strip(), "quoted", -1, -1, 0.75, 3)
596
+ for m in re.finditer(r"(?:^|[\s\(\[{,;])'([^']+)'(?=[\s\.,;:!?\)\]]|$)", text):
597
+ if len(m.group(1).strip()) > 2:
598
+ _add_candidate(candidates, "QUOTED", m.group(1).strip(), "quoted", -1, -1, 0.75, 3)
599
+
600
+
601
+ def _add_topic_phrase_candidates(doc, candidates: list[_EntityCandidate]) -> None:
602
+ for chunk in doc.noun_chunks:
603
+ chunk_tokens = list(chunk)
604
+ split_indices: list[int] = []
605
+ poss_splits: list[int] = []
606
+ for idx, tok in enumerate(chunk_tokens):
607
+ if tok.dep_ == "case" and tok.text in {"'s", "\u2019s", "'"}:
608
+ split_indices.append(idx)
609
+ poss_splits.append(idx)
610
+ elif tok.pos_ == "PUNCT" and tok.text in {"'", '"', "\u2018", "\u2019", "\u201c", "\u201d"}:
611
+ split_indices.append(idx)
612
+
613
+ if split_indices:
614
+ groups: list[list] = []
615
+ prev = 0
616
+ for split_idx in split_indices:
617
+ if split_idx > prev:
618
+ groups.append(chunk_tokens[prev:split_idx])
619
+ if split_idx in poss_splits:
620
+ next_split = next((s for s in split_indices if s > split_idx), None)
621
+ owned = chunk_tokens[split_idx + 1 : next_split if next_split else len(chunk_tokens)]
622
+ if owned:
623
+ first_content = next((t for t in owned if t.pos_ not in {"PUNCT", "PART"}), None)
624
+ if not (first_content and first_content.text and first_content.text[0].isupper()):
625
+ prev = next_split if next_split else len(chunk_tokens)
626
+ continue
627
+ prev = split_idx + 1
628
+ if prev < len(chunk_tokens):
629
+ groups.append(chunk_tokens[prev:])
630
+ else:
631
+ groups = [chunk_tokens]
632
+
633
+ for group in groups:
634
+ if not group:
635
+ continue
636
+ head = next((t for t in reversed(group) if t.pos_ in {"NOUN", "PROPN"}), None)
637
+ if not head:
638
+ continue
639
+ head_generic = head.lemma_.lower() in _GENERIC_HEADS
640
+ content = [
641
+ t
642
+ for t in group
643
+ if t.pos_ not in {"DET", "PRON", "PUNCT", "PART", "ADP", "SCONJ", "NUM"}
644
+ and (t.pos_ == "ADJ" or not t.is_stop)
645
+ ]
646
+ if not content:
647
+ continue
648
+
649
+ compound_toks = [t for t in content if t.dep_ == "compound"]
650
+ adj_toks = [t for t in content if t.pos_ == "ADJ" or t.dep_ == "amod"]
651
+ has_spec_adj = any(t.lemma_.lower() not in _NON_SPECIFIC_ADJ for t in adj_toks)
652
+ if head_generic and not has_spec_adj and not compound_toks:
653
+ continue
654
+
655
+ if compound_toks:
656
+ is_circ = any(t.lemma_.lower() in _CIRCUMSTANTIAL_MODS for t in compound_toks)
657
+ if is_circ:
658
+ val = head.text
659
+ if len(val) > 2:
660
+ _add_candidate(
661
+ candidates,
662
+ "TOPIC",
663
+ val,
664
+ "topic_phrase",
665
+ head.i,
666
+ head.i + 1,
667
+ 0.45,
668
+ 4,
669
+ )
670
+ else:
671
+ filtered = _strip_generic_ending(
672
+ [t for t in content if not (t.pos_ == "ADJ" and t.lemma_.lower() in _NON_SPECIFIC_ADJ)]
673
+ )
674
+ if filtered:
675
+ phrase = " ".join(t.text for t in filtered)
676
+ if len(phrase) > 3 and " " in phrase:
677
+ _add_candidate(
678
+ candidates,
679
+ "TOPIC",
680
+ phrase,
681
+ "topic_phrase",
682
+ filtered[0].i,
683
+ filtered[-1].i + 1,
684
+ 0.45,
685
+ 4,
686
+ )
687
+ elif len(content) > 1 and has_spec_adj:
688
+ filtered = _strip_generic_ending(
689
+ [
690
+ t
691
+ for t in content
692
+ if not ((t.pos_ == "ADJ" or t.dep_ == "amod") and t.lemma_.lower() in _NON_SPECIFIC_ADJ)
693
+ ]
694
+ )
695
+ if filtered:
696
+ phrase = " ".join(t.text for t in filtered)
697
+ if len(phrase) > 3 and " " in phrase:
698
+ _add_candidate(
699
+ candidates,
700
+ "TOPIC",
701
+ phrase,
702
+ "topic_phrase",
703
+ filtered[0].i,
704
+ filtered[-1].i + 1,
705
+ 0.45,
706
+ 4,
707
+ )
708
+
709
+
710
+ def _spans_overlap(a: _EntityCandidate, b: _EntityCandidate) -> bool:
711
+ if a.start < 0 or b.start < 0:
712
+ return False
713
+ return a.start < b.end and b.start < a.end
714
+
715
+
716
+ def _resolve_candidates(candidates: list[_EntityCandidate]) -> list[tuple[str, str]]:
717
+ deduped_by_text: dict[str, _EntityCandidate] = {}
718
+ for candidate in candidates:
719
+ key = _norm_text(candidate.text)
720
+ current = deduped_by_text.get(key)
721
+ if current is None or (candidate.priority, -candidate.confidence) < (current.priority, -current.confidence):
722
+ deduped_by_text[key] = candidate
723
+
724
+ ordered = sorted(
725
+ deduped_by_text.values(),
726
+ key=lambda c: (c.priority, -c.confidence, -(c.end - c.start), c.start),
727
+ )
728
+ accepted: list[_EntityCandidate] = []
729
+ for candidate in ordered:
730
+ if any(
731
+ _spans_overlap(candidate, existing)
732
+ and not (candidate.entity_type == "TOPIC" and " " in candidate.text and existing.entity_type == "PROPER")
733
+ for existing in accepted
734
+ ):
735
+ continue
736
+ accepted.append(candidate)
737
+
738
+ accepted.sort(key=lambda c: (c.start if c.start >= 0 else 10**9, c.end, c.priority))
739
+ return [(candidate.entity_type, candidate.text) for candidate in accepted]
740
+
741
+
742
+ def _extract_entities_from_doc(doc) -> list[tuple[str, str]]:
743
+ """Extract typed entity candidates from a spaCy Doc.
744
+
745
+ Args:
746
+ doc: A spaCy ``Doc`` object (from ``nlp(text)``).
747
+
748
+ Returns:
749
+ Deduplicated list of ``(entity_type, entity_text)`` tuples.
750
+ Entity types include PROPER, QUOTED, TOPIC, and IDENTIFIER.
751
+ """
752
+ tokens = list(doc)
753
+ candidates: list[_EntityCandidate] = []
754
+ _add_ner_candidates(doc, candidates)
755
+ _add_technical_identifier_candidates(tokens, candidates)
756
+ _add_proper_name_candidates(tokens, candidates)
757
+ _add_quoted_candidates(doc.text, candidates)
758
+ _add_topic_phrase_candidates(doc, candidates)
759
+ return _resolve_candidates(candidates)
760
+
761
+
762
+ _default_nlp_cache = {"nlp": None, "tried": False}
763
+
764
+
765
+ def _default_nlp():
766
+ """Load ``en_core_web_sm`` once; ``None`` if spaCy or the model is missing
767
+ (extraction then returns no entities, exactly as mem0 does)."""
768
+ if not _default_nlp_cache["tried"]:
769
+ _default_nlp_cache["tried"] = True
770
+ try:
771
+ import spacy
772
+
773
+ _default_nlp_cache["nlp"] = spacy.load("en_core_web_sm")
774
+ except Exception: # noqa: BLE001 — optional dependency
775
+ _default_nlp_cache["nlp"] = None
776
+ return _default_nlp_cache["nlp"]
777
+
778
+
779
+ def extract_entities(text: str, nlp=None) -> list[tuple[str, str]]:
780
+ """Extract typed entity candidates from text (``nlp``: a loaded spaCy pipeline)."""
781
+ nlp = nlp if nlp is not None else _default_nlp()
782
+ if nlp is None:
783
+ return []
784
+ return _extract_entities_from_doc(nlp(text))
785
+
786
+
787
+ def extract_entities_batch(
788
+ texts: list[str], batch_size: int = 32, nlp=None
789
+ ) -> list[list[tuple[str, str]]]:
790
+ """Extract typed entity candidates from multiple texts."""
791
+ if not texts:
792
+ return []
793
+ nlp = nlp if nlp is not None else _default_nlp()
794
+ if nlp is None:
795
+ return [[] for _ in texts]
796
+ return [_extract_entities_from_doc(doc) for doc in nlp.pipe(texts, batch_size=batch_size)]