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,100 @@
|
|
|
1
|
+
"""Fact-extraction prompts.
|
|
2
|
+
|
|
3
|
+
The system prompt (``additive_extraction_prompt.txt``) and the user-prompt
|
|
4
|
+
builder below are taken from Mem0 (https://github.com/mem0ai/mem0, Apache
|
|
5
|
+
License 2.0; ``mem0/configs/prompts.py``, mem0ai 2.1.0), copied unchanged so
|
|
6
|
+
Cortex's fact-memory engine starts from identical extraction behavior. See the
|
|
7
|
+
repository ``NOTICE`` file. Changes: none to the prompt text; the builder is
|
|
8
|
+
re-typed with type hints and its dates default to UTC today.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from importlib import resources
|
|
16
|
+
from typing import Any, Dict, List, Optional, Union
|
|
17
|
+
|
|
18
|
+
PAST_MESSAGE_TRUNCATION_LIMIT = 300
|
|
19
|
+
|
|
20
|
+
_prompt_cache: Optional[str] = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def extraction_system_prompt() -> str:
|
|
24
|
+
"""The additive (ADD-only) extraction system prompt, from package data."""
|
|
25
|
+
global _prompt_cache
|
|
26
|
+
if _prompt_cache is None:
|
|
27
|
+
_prompt_cache = (
|
|
28
|
+
resources.files(__package__)
|
|
29
|
+
.joinpath("additive_extraction_prompt.txt")
|
|
30
|
+
.read_text(encoding="utf-8")
|
|
31
|
+
)
|
|
32
|
+
return _prompt_cache
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _truncate(text: str, limit: int = PAST_MESSAGE_TRUNCATION_LIMIT) -> str:
|
|
36
|
+
return text if len(text) <= limit else text[:limit] + "..."
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _format_summary(summary: Union[None, str, Dict[str, Any]]) -> str:
|
|
40
|
+
if isinstance(summary, dict):
|
|
41
|
+
return summary.get("summary", "")
|
|
42
|
+
return summary or ""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _format_conversation_history(messages: Optional[List[Dict[str, Any]]]) -> str:
|
|
46
|
+
if not messages:
|
|
47
|
+
return ""
|
|
48
|
+
out = ""
|
|
49
|
+
for msg in messages:
|
|
50
|
+
role = msg.get("role", "")
|
|
51
|
+
content = msg.get("message") or msg.get("content", "")
|
|
52
|
+
if role and content:
|
|
53
|
+
out += f"{role}: {_truncate(content)}\n"
|
|
54
|
+
return out
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _serialize_memories(memories: Optional[List[Dict[str, Any]]]) -> str:
|
|
58
|
+
return json.dumps(memories or [], ensure_ascii=False)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _format_new_messages(new_messages: Union[str, List[Dict[str, Any]], None]) -> str:
|
|
62
|
+
if isinstance(new_messages, str):
|
|
63
|
+
return new_messages
|
|
64
|
+
return json.dumps(new_messages or [], ensure_ascii=False)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _resolve_dates(current_date: Optional[str] = None, observation_date: Optional[str] = None):
|
|
68
|
+
if current_date is None:
|
|
69
|
+
current_date = datetime.now(timezone.utc).date().isoformat()
|
|
70
|
+
if observation_date is None:
|
|
71
|
+
observation_date = current_date
|
|
72
|
+
return current_date, observation_date
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def build_extraction_prompt(
|
|
76
|
+
*,
|
|
77
|
+
summary: Union[None, str, Dict[str, Any]] = None,
|
|
78
|
+
recently_extracted_memories: Optional[List[Dict[str, Any]]] = None,
|
|
79
|
+
existing_memories: Optional[List[Dict[str, Any]]] = None,
|
|
80
|
+
new_messages: Union[str, List[Dict[str, Any]], None] = None,
|
|
81
|
+
last_k_messages: Optional[List[Dict[str, Any]]] = None,
|
|
82
|
+
current_date: Optional[str] = None,
|
|
83
|
+
timestamp: Optional[str] = None,
|
|
84
|
+
custom_instructions: Optional[str] = None,
|
|
85
|
+
) -> str:
|
|
86
|
+
"""Build the user-side prompt (pairs with :func:`extraction_system_prompt`)."""
|
|
87
|
+
current_date, observation_date = _resolve_dates(current_date, timestamp)
|
|
88
|
+
sections = [
|
|
89
|
+
f"## Summary\n{_format_summary(summary)}",
|
|
90
|
+
f"## Last k Messages\n{_format_conversation_history(last_k_messages)}",
|
|
91
|
+
f"## Recently Extracted Memories\n{_serialize_memories(recently_extracted_memories)}",
|
|
92
|
+
f"## Existing Memories\n{_serialize_memories(existing_memories)}",
|
|
93
|
+
f"## New Messages\n{_format_new_messages(new_messages)}",
|
|
94
|
+
f"## Observation Date\n{observation_date}",
|
|
95
|
+
f"## Current Date\n{current_date}",
|
|
96
|
+
]
|
|
97
|
+
if custom_instructions:
|
|
98
|
+
sections.append(f"## Custom Instructions\n{custom_instructions}")
|
|
99
|
+
sections.append("# Output:")
|
|
100
|
+
return "\n\n".join(sections)
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Search scoring for fact memory.
|
|
2
|
+
|
|
3
|
+
Adapted from Mem0 (Apache License 2.0; mem0ai 2.1.0, ``mem0/utils/scoring.py``
|
|
4
|
+
and ``Memory._compute_entity_boosts``). See the repository NOTICE file.
|
|
5
|
+
|
|
6
|
+
Changes: Mem0 only computes BM25 when the vector store implements keyword
|
|
7
|
+
search, and its Chroma connector does not, so in a Chroma deployment the BM25
|
|
8
|
+
branch never runs. The default here is identical (no keyword term). Task 0079
|
|
9
|
+
adds it as an opt-in: a dependency-free BM25 (no lemmatizer) whose score is
|
|
10
|
+
normalised to [0, 1] by the IDF mass of the query terms the store knows, then
|
|
11
|
+
fused as Mem0 does: ``combined = (semantic + keyword + entity) / max_possible``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import math
|
|
17
|
+
import re
|
|
18
|
+
from typing import Any, Dict, List, Optional, Sequence
|
|
19
|
+
|
|
20
|
+
ENTITY_BOOST_WEIGHT = 0.5
|
|
21
|
+
ENTITY_MATCH_MIN_SIMILARITY = 0.5 # entity-store hits below this are ignored
|
|
22
|
+
MAX_QUERY_ENTITIES = 8
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def distance_to_score(distance: float) -> float:
|
|
26
|
+
"""Chroma L2 distance -> similarity in (0, 1], higher is closer (as Mem0)."""
|
|
27
|
+
return 1.0 / (1.0 + distance)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def entity_boost(similarity: float, num_linked: int) -> float:
|
|
31
|
+
"""Boost for the memories linked to one matched entity.
|
|
32
|
+
|
|
33
|
+
Scaled down for entities linked to very many memories (they carry less
|
|
34
|
+
information): ``sim * 0.5 / (1 + 0.001 * (n - 1)^2)``.
|
|
35
|
+
"""
|
|
36
|
+
n = max(num_linked, 1)
|
|
37
|
+
weight = 1.0 / (1.0 + 0.001 * ((n - 1) ** 2))
|
|
38
|
+
return similarity * ENTITY_BOOST_WEIGHT * weight
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
BM25_K1 = 1.2
|
|
42
|
+
BM25_B = 0.75
|
|
43
|
+
KEYWORD_MAX_EXTRA_CANDIDATES = 50 # keyword hits the semantic over-fetch missed
|
|
44
|
+
|
|
45
|
+
_STOPWORDS = frozenset(
|
|
46
|
+
"a an and are as at be been but by can could did do does for from had has have he her his how i "
|
|
47
|
+
"if in is it its me my of on or our she so than that the their them then there these they this "
|
|
48
|
+
"those to was we were what when where which who whom why will with would you your".split()
|
|
49
|
+
)
|
|
50
|
+
_TOKEN = re.compile(r"[a-z0-9]+(?:'[a-z]+)?")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def tokenize(text: str) -> List[str]:
|
|
54
|
+
"""Lower-cased content words: possessive/plural endings folded, stopwords dropped.
|
|
55
|
+
|
|
56
|
+
Deliberately tiny (no stemmer/lemmatizer dependency); applied identically to
|
|
57
|
+
facts and queries, so consistency is what matters.
|
|
58
|
+
"""
|
|
59
|
+
out: List[str] = []
|
|
60
|
+
for tok in _TOKEN.findall(text.lower()):
|
|
61
|
+
if tok.endswith("'s"):
|
|
62
|
+
tok = tok[:-2]
|
|
63
|
+
tok = tok.replace("'", "")
|
|
64
|
+
if tok in _STOPWORDS:
|
|
65
|
+
continue
|
|
66
|
+
if len(tok) > 4 and tok.endswith("ies"):
|
|
67
|
+
tok = tok[:-3] + "y"
|
|
68
|
+
elif len(tok) > 3 and tok.endswith("s") and not tok.endswith("ss"):
|
|
69
|
+
tok = tok[:-1]
|
|
70
|
+
if tok and tok not in _STOPWORDS:
|
|
71
|
+
out.append(tok)
|
|
72
|
+
return out
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def keyword_scores(query: str, documents: Dict[str, str]) -> Dict[str, float]:
|
|
76
|
+
"""BM25 of ``query`` over ``documents`` (id -> text), normalised to (0, 1].
|
|
77
|
+
|
|
78
|
+
Raw BM25 is divided by the summed IDF of the query terms that occur in at
|
|
79
|
+
least one document, so a fact containing every (known) query term scores
|
|
80
|
+
~1 and one containing only a very common term scores near 0 — comparable
|
|
81
|
+
with the semantic and entity terms whatever the store size. Query terms no
|
|
82
|
+
fact contains do not count against the others. Only facts with a nonzero
|
|
83
|
+
score are returned.
|
|
84
|
+
"""
|
|
85
|
+
q_terms = list(dict.fromkeys(tokenize(query)))
|
|
86
|
+
if not q_terms or not documents:
|
|
87
|
+
return {}
|
|
88
|
+
tokens = {i: tokenize(t) for i, t in documents.items()}
|
|
89
|
+
n = len(tokens)
|
|
90
|
+
avg_len = (sum(len(t) for t in tokens.values()) / n) or 1.0
|
|
91
|
+
counts = {i: {} for i in tokens}
|
|
92
|
+
df = {t: 0 for t in q_terms}
|
|
93
|
+
wanted = set(q_terms)
|
|
94
|
+
for i, toks in tokens.items():
|
|
95
|
+
c = counts[i]
|
|
96
|
+
for t in toks:
|
|
97
|
+
if t in wanted:
|
|
98
|
+
c[t] = c.get(t, 0) + 1
|
|
99
|
+
for t in c:
|
|
100
|
+
df[t] += 1
|
|
101
|
+
idf = {t: math.log(1.0 + (n - d + 0.5) / (d + 0.5)) for t, d in df.items() if d}
|
|
102
|
+
total_idf = sum(idf.values())
|
|
103
|
+
if total_idf <= 0:
|
|
104
|
+
return {}
|
|
105
|
+
out: Dict[str, float] = {}
|
|
106
|
+
for i, c in counts.items():
|
|
107
|
+
if not c:
|
|
108
|
+
continue
|
|
109
|
+
norm = BM25_K1 * (1.0 - BM25_B + BM25_B * len(tokens[i]) / avg_len)
|
|
110
|
+
raw = sum(idf[t] * c[t] * (BM25_K1 + 1.0) / (c[t] + norm) for t in c)
|
|
111
|
+
score = min(raw / total_idf, 1.0) # tf=1 at average length gives idf, so full coverage ~ 1
|
|
112
|
+
if score > 0:
|
|
113
|
+
out[i] = score
|
|
114
|
+
return out
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def score_and_rank(
|
|
118
|
+
semantic_results: List[Dict[str, Any]],
|
|
119
|
+
entity_boosts: Dict[str, float],
|
|
120
|
+
threshold: float,
|
|
121
|
+
top_k: int,
|
|
122
|
+
keyword: Optional[Dict[str, float]] = None,
|
|
123
|
+
keyword_weight: float = 1.0,
|
|
124
|
+
) -> List[Dict[str, Any]]:
|
|
125
|
+
"""``combined = (semantic + w*keyword + entity_boost) / max_possible``, top-k descending.
|
|
126
|
+
|
|
127
|
+
The threshold gates the *semantic* score before combining. ``max_possible``
|
|
128
|
+
is 1.0, +``keyword_weight`` when any keyword score is active, +0.5 when any
|
|
129
|
+
entity boost is. With no ``keyword`` this is exactly Mem0-with-Chroma.
|
|
130
|
+
"""
|
|
131
|
+
keyword = keyword or {}
|
|
132
|
+
max_possible = 1.0 + (ENTITY_BOOST_WEIGHT if entity_boosts else 0.0)
|
|
133
|
+
if keyword:
|
|
134
|
+
max_possible += keyword_weight
|
|
135
|
+
scored: List[Dict[str, Any]] = []
|
|
136
|
+
for result in semantic_results:
|
|
137
|
+
mem_id = result.get("id")
|
|
138
|
+
if mem_id is None:
|
|
139
|
+
continue
|
|
140
|
+
semantic = result.get("score") or 0.0
|
|
141
|
+
if semantic < threshold:
|
|
142
|
+
continue
|
|
143
|
+
boost = entity_boosts.get(str(mem_id), 0.0)
|
|
144
|
+
kw = keyword_weight * keyword.get(str(mem_id), 0.0)
|
|
145
|
+
combined = min((semantic + kw + boost) / max_possible, 1.0)
|
|
146
|
+
scored.append({**result, "id": str(mem_id), "score": combined})
|
|
147
|
+
scored.sort(key=lambda r: r["score"], reverse=True)
|
|
148
|
+
return scored[:top_k]
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Ingestion: chunk raw text into small pages, extract entities, embed, insert.
|
|
2
|
+
|
|
3
|
+
Pages are deliberately small — roughly one fact/statement each — because
|
|
4
|
+
link-expansion works best on focused pages. Chunking: split on line breaks,
|
|
5
|
+
then greedily pack sentences so no page exceeds ``MAX_PAGE_CHARS``. Entity
|
|
6
|
+
extraction is delegated to an :class:`~cortexlayer._engine.nlp.NLP` object.
|
|
7
|
+
|
|
8
|
+
Ingestion never runs the linking pass — linking stays a batch operation.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from typing import List, Optional
|
|
15
|
+
|
|
16
|
+
from chromadb.api.models.Collection import Collection
|
|
17
|
+
|
|
18
|
+
from . import storage
|
|
19
|
+
from .nlp import NLP
|
|
20
|
+
|
|
21
|
+
MAX_PAGE_CHARS = 500
|
|
22
|
+
|
|
23
|
+
_DATE_PREFIX_RE = re.compile(r"^\[.+?\]")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def apply_timestamp(text: str, timestamp: str) -> str:
|
|
27
|
+
"""Prefix ``[timestamp]`` onto undated lines only.
|
|
28
|
+
|
|
29
|
+
Lines already starting with a ``[date]``-style bracket prefix keep their
|
|
30
|
+
original date instead of getting a second one stacked on top.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def prefix_line(line: str) -> str:
|
|
34
|
+
if not line.strip():
|
|
35
|
+
return line
|
|
36
|
+
if _DATE_PREFIX_RE.match(line.lstrip()):
|
|
37
|
+
return line
|
|
38
|
+
return f"[{timestamp}] {line}"
|
|
39
|
+
|
|
40
|
+
return "\n".join(prefix_line(line) for line in text.splitlines())
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def chunk_text(text: str, nlp: NLP, max_chars: int = MAX_PAGE_CHARS) -> List[str]:
|
|
44
|
+
"""Split raw text into page-sized chunks."""
|
|
45
|
+
pages: List[str] = []
|
|
46
|
+
for block in (b.strip() for b in text.splitlines()):
|
|
47
|
+
if not block:
|
|
48
|
+
continue
|
|
49
|
+
if len(block) <= max_chars:
|
|
50
|
+
pages.append(block)
|
|
51
|
+
continue
|
|
52
|
+
# Long turn: split into sentences and greedily repack.
|
|
53
|
+
current: List[str] = []
|
|
54
|
+
current_len = 0
|
|
55
|
+
for s in nlp.sentences(block):
|
|
56
|
+
if current and current_len + 1 + len(s) > max_chars:
|
|
57
|
+
pages.append(" ".join(current))
|
|
58
|
+
current, current_len = [], 0
|
|
59
|
+
current.append(s)
|
|
60
|
+
current_len += (1 if current_len else 0) + len(s)
|
|
61
|
+
if current:
|
|
62
|
+
pages.append(" ".join(current))
|
|
63
|
+
return pages
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def add_text(
|
|
67
|
+
collection: Collection,
|
|
68
|
+
text: str,
|
|
69
|
+
nlp: NLP,
|
|
70
|
+
timestamp: Optional[str] = None,
|
|
71
|
+
) -> List[str]:
|
|
72
|
+
"""Chunk → entities → insert. Returns the new page ids.
|
|
73
|
+
|
|
74
|
+
``timestamp`` (e.g. ``"8 May, 2023"``) is prefixed to undated lines and
|
|
75
|
+
stored as each page's ``created_at``.
|
|
76
|
+
"""
|
|
77
|
+
if timestamp:
|
|
78
|
+
text = apply_timestamp(text, timestamp)
|
|
79
|
+
return [
|
|
80
|
+
storage.insert_page(
|
|
81
|
+
collection, chunk, nlp.entities(chunk), created_at=timestamp or None
|
|
82
|
+
)
|
|
83
|
+
for chunk in chunk_text(text, nlp)
|
|
84
|
+
]
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Linking pass: connect pages that share entities.
|
|
2
|
+
|
|
3
|
+
Deterministic entity-overlap linking, no LLM judgment. Runs as a batch over the
|
|
4
|
+
full collection — after bulk ingestion or on-demand via `memory_relink` — never
|
|
5
|
+
per-insert. Re-running recomputes links from scratch, so it is idempotent.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from chromadb.api.models.Collection import Collection
|
|
11
|
+
|
|
12
|
+
from . import storage
|
|
13
|
+
|
|
14
|
+
PAGE_BATCH = 500
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _normalize(entity: str) -> str:
|
|
18
|
+
return entity.strip().lower()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def run_linking_pass(
|
|
22
|
+
collection: Collection,
|
|
23
|
+
common_entity_fraction: float = 0.3,
|
|
24
|
+
common_entity_min_pages: int = 5,
|
|
25
|
+
) -> dict:
|
|
26
|
+
"""Populate every page's `links` with IDs of pages sharing ≥1 entity.
|
|
27
|
+
|
|
28
|
+
Matching is exact on normalized (case-insensitive) entity strings.
|
|
29
|
+
Entities appearing in more than max(common_entity_min_pages,
|
|
30
|
+
common_entity_fraction * N) pages are ignored for linking — in dialogue,
|
|
31
|
+
speaker names show up nearly everywhere and would otherwise link each page
|
|
32
|
+
to nearly every other page (found in benchmark diagnosis).
|
|
33
|
+
Ignored entities stay stored on the page; they just don't create links.
|
|
34
|
+
Returns summary stats: {"pages": n, "links_written": m}.
|
|
35
|
+
"""
|
|
36
|
+
pages: list[dict] = []
|
|
37
|
+
offset = 0
|
|
38
|
+
while True:
|
|
39
|
+
batch = storage.list_pages(collection, limit=PAGE_BATCH, offset=offset)
|
|
40
|
+
if not batch:
|
|
41
|
+
break
|
|
42
|
+
pages.extend(batch)
|
|
43
|
+
offset += len(batch)
|
|
44
|
+
|
|
45
|
+
# Inverted index: normalized entity -> page IDs containing it.
|
|
46
|
+
index: dict[str, list[str]] = {}
|
|
47
|
+
for page in pages:
|
|
48
|
+
for entity in page["entities"]:
|
|
49
|
+
index.setdefault(_normalize(entity), []).append(page["id"])
|
|
50
|
+
|
|
51
|
+
threshold = max(common_entity_min_pages, common_entity_fraction * len(pages))
|
|
52
|
+
linkable = {entity for entity, ids in index.items() if len(ids) <= threshold}
|
|
53
|
+
|
|
54
|
+
links_written = 0
|
|
55
|
+
for page in pages:
|
|
56
|
+
neighbors: list[str] = []
|
|
57
|
+
for entity in page["entities"]:
|
|
58
|
+
if _normalize(entity) not in linkable:
|
|
59
|
+
continue
|
|
60
|
+
for other_id in index.get(_normalize(entity), []):
|
|
61
|
+
if other_id != page["id"] and other_id not in neighbors:
|
|
62
|
+
neighbors.append(other_id)
|
|
63
|
+
neighbors.sort()
|
|
64
|
+
if neighbors != sorted(page["links"]):
|
|
65
|
+
storage.update_links(collection, page["id"], neighbors)
|
|
66
|
+
links_written += 1
|
|
67
|
+
|
|
68
|
+
return {"pages": len(pages), "links_written": links_written}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Language processing behind ingestion: entity extraction + sentence splitting.
|
|
2
|
+
|
|
3
|
+
Two implementations behind one tiny interface (``entities`` / ``sentences``):
|
|
4
|
+
|
|
5
|
+
- :class:`SpacyNLP` — spaCy NER plus a proper-noun pass (the quality path; this
|
|
6
|
+
is exactly what the Cortex server uses).
|
|
7
|
+
- :class:`RegexNLP` — dependency-free capitalised-phrase heuristics. Good
|
|
8
|
+
enough to try the library without downloading a model, but it finds fewer
|
|
9
|
+
entities, so the linking pass links fewer pages. ``"auto"`` falls back to it
|
|
10
|
+
(with a one-time warning) only when spaCy or its model is missing.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
import warnings
|
|
17
|
+
from typing import Any, List, Protocol
|
|
18
|
+
|
|
19
|
+
from ..errors import LocalDependencyError, MissingModelError
|
|
20
|
+
|
|
21
|
+
SPACY_MODEL = "en_core_web_sm"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class NLP(Protocol):
|
|
25
|
+
def entities(self, text: str) -> List[str]: ...
|
|
26
|
+
def sentences(self, text: str) -> List[str]: ...
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _ordered_unique_add(seen: set, out: List[str], name: str) -> None:
|
|
30
|
+
name = name.strip()
|
|
31
|
+
if name and name not in seen:
|
|
32
|
+
seen.add(name)
|
|
33
|
+
out.append(name)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SpacyNLP:
|
|
37
|
+
"""spaCy-backed extraction. Loading is lazy and raises clear errors."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, model: str = SPACY_MODEL) -> None:
|
|
40
|
+
self.model = model
|
|
41
|
+
self._nlp: Any = None
|
|
42
|
+
|
|
43
|
+
def _load(self) -> Any:
|
|
44
|
+
if self._nlp is None:
|
|
45
|
+
try:
|
|
46
|
+
import spacy
|
|
47
|
+
except ImportError as e: # pragma: no cover — exercised via monkeypatch
|
|
48
|
+
raise LocalDependencyError(
|
|
49
|
+
"spaCy is not installed. Run: pip install \"cortexlayer[local]\" "
|
|
50
|
+
"(or use Memory(entity_extractor=\"regex\"))."
|
|
51
|
+
) from e
|
|
52
|
+
try:
|
|
53
|
+
self._nlp = spacy.load(self.model)
|
|
54
|
+
except OSError as e:
|
|
55
|
+
raise MissingModelError(
|
|
56
|
+
f"spaCy model '{self.model}' is not installed. Run: "
|
|
57
|
+
f"python -m spacy download {self.model} "
|
|
58
|
+
"(or use Memory(entity_extractor=\"regex\") to run without one)."
|
|
59
|
+
) from e
|
|
60
|
+
return self._nlp
|
|
61
|
+
|
|
62
|
+
def entities(self, text: str) -> List[str]:
|
|
63
|
+
"""Ordered-unique entities/key terms.
|
|
64
|
+
|
|
65
|
+
spaCy NER first, plus a proper-noun fallback: the small model reliably
|
|
66
|
+
tags people/places but often misses titles and works of art (e.g.
|
|
67
|
+
"Inception"), which POS tagging still catches as PROPN. Both feed the
|
|
68
|
+
linking pass, where a missed shared term means a missed link.
|
|
69
|
+
"""
|
|
70
|
+
doc = self._load()(text)
|
|
71
|
+
seen: set = set()
|
|
72
|
+
entities: List[str] = []
|
|
73
|
+
for ent in doc.ents:
|
|
74
|
+
_ordered_unique_add(seen, entities, ent.text)
|
|
75
|
+
lowered = [e.lower() for e in entities]
|
|
76
|
+
for token in doc:
|
|
77
|
+
if token.pos_ != "PROPN":
|
|
78
|
+
continue
|
|
79
|
+
word = token.text.strip()
|
|
80
|
+
if not word:
|
|
81
|
+
continue
|
|
82
|
+
# Skip tokens already covered by a collected entity ("Nolan" in
|
|
83
|
+
# "Christopher Nolan"), keep genuinely new key terms ("Inception").
|
|
84
|
+
if any(word.lower() in e or e in word.lower() for e in lowered):
|
|
85
|
+
continue
|
|
86
|
+
_ordered_unique_add(seen, entities, word)
|
|
87
|
+
lowered.append(word.lower())
|
|
88
|
+
return entities
|
|
89
|
+
|
|
90
|
+
def sentences(self, text: str) -> List[str]:
|
|
91
|
+
return [s.text.strip() for s in self._load()(text).sents if s.text.strip()]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
_CAP_RUN = re.compile(r"[A-Z][\w'’-]*(?:[ \t]+[A-Z][\w'’-]*)*")
|
|
95
|
+
_SENTENCE_END = re.compile(r"(?<=[.!?])[\"')\]]*\s+(?=[\"'(\[]*[A-Z0-9])")
|
|
96
|
+
_STOP = frozenset(
|
|
97
|
+
"I A An The This That These Those There Then Here He She It We They You Me "
|
|
98
|
+
"My Our His Her Their Its Your Mine Ours And But Or Nor So Yet If In On At "
|
|
99
|
+
"For To Of With By From As Is Are Was Were Be Been Am Do Does Did Have Has "
|
|
100
|
+
"Had Will Would Can Could Should May Might Must What When Where Who Whom "
|
|
101
|
+
"Which Why How Yes No Not Also Just Well Oh Hi Hello Thanks Thank Please "
|
|
102
|
+
"Okay Ok Maybe Sure Some Any All Each Every Both Either Neither One".split()
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class RegexNLP:
|
|
107
|
+
"""Dependency-free heuristics: capitalised runs minus function words."""
|
|
108
|
+
|
|
109
|
+
def entities(self, text: str) -> List[str]:
|
|
110
|
+
seen: set = set()
|
|
111
|
+
out: List[str] = []
|
|
112
|
+
for match in _CAP_RUN.finditer(text):
|
|
113
|
+
words = match.group(0).split()
|
|
114
|
+
while words and words[0] in _STOP: # "The Godfather" -> "Godfather"
|
|
115
|
+
words.pop(0)
|
|
116
|
+
if words:
|
|
117
|
+
_ordered_unique_add(seen, out, " ".join(words))
|
|
118
|
+
return out
|
|
119
|
+
|
|
120
|
+
def sentences(self, text: str) -> List[str]:
|
|
121
|
+
return [s.strip() for s in _SENTENCE_END.split(text) if s.strip()]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
_warned_fallback = False
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def resolve_nlp(spec: Any = "auto", *, spacy_model: str = SPACY_MODEL) -> NLP:
|
|
128
|
+
"""``"auto"`` | ``"spacy"`` | ``"regex"`` | any object with ``entities`` and
|
|
129
|
+
``sentences`` methods.
|
|
130
|
+
|
|
131
|
+
``"auto"`` prefers spaCy and falls back to the regex heuristics with a
|
|
132
|
+
one-time warning if spaCy or its model is unavailable — so a fresh
|
|
133
|
+
``pip install "cortexlayer[local]"`` works even before the model download.
|
|
134
|
+
"""
|
|
135
|
+
global _warned_fallback
|
|
136
|
+
if hasattr(spec, "entities") and hasattr(spec, "sentences"):
|
|
137
|
+
return spec
|
|
138
|
+
if spec == "regex":
|
|
139
|
+
return RegexNLP()
|
|
140
|
+
if spec == "spacy":
|
|
141
|
+
nlp = SpacyNLP(spacy_model)
|
|
142
|
+
nlp._load() # fail now, with the helpful message
|
|
143
|
+
return nlp
|
|
144
|
+
if spec == "auto":
|
|
145
|
+
nlp = SpacyNLP(spacy_model)
|
|
146
|
+
try:
|
|
147
|
+
nlp._load()
|
|
148
|
+
return nlp
|
|
149
|
+
except LocalDependencyError as e:
|
|
150
|
+
if not _warned_fallback:
|
|
151
|
+
_warned_fallback = True
|
|
152
|
+
warnings.warn(
|
|
153
|
+
f"cortexlayer: {e} Falling back to the simpler regex entity "
|
|
154
|
+
"extractor — fewer entities means fewer links between pages.",
|
|
155
|
+
RuntimeWarning,
|
|
156
|
+
stacklevel=3,
|
|
157
|
+
)
|
|
158
|
+
return RegexNLP()
|
|
159
|
+
raise ValueError(
|
|
160
|
+
"entity_extractor must be 'auto', 'spacy', 'regex', or an object with "
|
|
161
|
+
f"entities() and sentences() methods, got {spec!r}"
|
|
162
|
+
)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Retrieval pipeline: vector search + link-expansion.
|
|
2
|
+
|
|
3
|
+
Searches the whole collection (no vault/cluster routing — deliberately excluded).
|
|
4
|
+
Links only *add* candidates, never restrict. MVP expansion heuristic: pull the top 1
|
|
5
|
+
linked page per seed, no relevance judgment.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from chromadb.api.models.Collection import Collection
|
|
11
|
+
|
|
12
|
+
from . import storage
|
|
13
|
+
|
|
14
|
+
DEFAULT_K = 4
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def retrieve(collection: Collection, query: str, k: int = DEFAULT_K) -> list[dict]:
|
|
18
|
+
"""Return combined seed + link-expanded passages.
|
|
19
|
+
|
|
20
|
+
Each passage is ``{page_id, text, score, via, [linked_from]}``: seeds are
|
|
21
|
+
``via="direct"`` (score = Chroma distance, lower = closer), expanded pages
|
|
22
|
+
are ``via="link"`` with the seed id in ``linked_from``. Seeds come first,
|
|
23
|
+
then expanded pages, deduplicated by page ID (a page that is both seed and
|
|
24
|
+
expansion keeps ``direct``).
|
|
25
|
+
"""
|
|
26
|
+
return expand_links(collection, storage.query(collection, query, n_results=k))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def expand_links(collection: Collection, seeds: list[dict]) -> list[dict]:
|
|
30
|
+
"""Seeds (storage-shaped page dicts with ``score``) -> passages, adding the
|
|
31
|
+
top linked page of each seed. Split from :func:`retrieve` so other seed
|
|
32
|
+
sources (fact memory's entity-boosted search) reuse the same expansion."""
|
|
33
|
+
seen: set[str] = set()
|
|
34
|
+
passages: list[dict] = []
|
|
35
|
+
|
|
36
|
+
def _add(page_id: str, text: str, score: float, via: str,
|
|
37
|
+
linked_from: str | None = None) -> None:
|
|
38
|
+
if page_id not in seen:
|
|
39
|
+
seen.add(page_id)
|
|
40
|
+
passage: dict = {"page_id": page_id, "text": text,
|
|
41
|
+
"score": score, "via": via}
|
|
42
|
+
if linked_from is not None:
|
|
43
|
+
passage["linked_from"] = linked_from
|
|
44
|
+
passages.append(passage)
|
|
45
|
+
|
|
46
|
+
for seed in seeds:
|
|
47
|
+
_add(seed["id"], seed["text"], seed.get("score", 0.0), "direct")
|
|
48
|
+
for seed in seeds:
|
|
49
|
+
# MVP heuristic: top 1 linked page per seed (links are stored sorted).
|
|
50
|
+
if seed["links"]:
|
|
51
|
+
target = storage.get_page(collection, seed["links"][0])
|
|
52
|
+
if target is not None:
|
|
53
|
+
_add(target["id"], target["text"], target.get("score", 0.0),
|
|
54
|
+
"link", linked_from=seed["id"])
|
|
55
|
+
return passages
|