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.
- cortexlayer/__init__.py +61 -0
- cortexlayer/_engine/__init__.py +0 -0
- cortexlayer/_engine/compression.py +87 -0
- cortexlayer/_engine/facts/__init__.py +1 -0
- cortexlayer/_engine/facts/additive_extraction_prompt.txt +476 -0
- cortexlayer/_engine/facts/backends.py +196 -0
- cortexlayer/_engine/facts/engine.py +556 -0
- cortexlayer/_engine/facts/entities.py +796 -0
- cortexlayer/_engine/facts/prompts.py +100 -0
- cortexlayer/_engine/facts/scoring.py +148 -0
- cortexlayer/_engine/ingestion.py +84 -0
- cortexlayer/_engine/linking.py +68 -0
- cortexlayer/_engine/nlp.py +162 -0
- cortexlayer/_engine/retrieval.py +55 -0
- cortexlayer/_engine/shaping.py +96 -0
- cortexlayer/_engine/storage.py +295 -0
- cortexlayer/_validate.py +23 -0
- cortexlayer/_version.py +1 -0
- cortexlayer/client.py +526 -0
- cortexlayer/errors.py +108 -0
- cortexlayer/memory.py +372 -0
- cortexlayer/py.typed +0 -0
- cortexlayer/types.py +199 -0
- cortexlayer-0.1.0.dist-info/METADATA +266 -0
- cortexlayer-0.1.0.dist-info/RECORD +29 -0
- cortexlayer-0.1.0.dist-info/WHEEL +5 -0
- cortexlayer-0.1.0.dist-info/licenses/LICENSE +202 -0
- cortexlayer-0.1.0.dist-info/licenses/NOTICE +40 -0
- cortexlayer-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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)]
|