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,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