rfnry-memory 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.
- rfnry_memory/__init__.py +153 -0
- rfnry_memory/chunking/__init__.py +0 -0
- rfnry_memory/chunking/chunker.py +39 -0
- rfnry_memory/chunking/splitter.py +93 -0
- rfnry_memory/chunking/structure.py +110 -0
- rfnry_memory/cli.py +178 -0
- rfnry_memory/errors.py +22 -0
- rfnry_memory/filters.py +104 -0
- rfnry_memory/fusion/__init__.py +13 -0
- rfnry_memory/fusion/provenance.py +14 -0
- rfnry_memory/fusion/relative_score.py +38 -0
- rfnry_memory/fusion/rrf.py +35 -0
- rfnry_memory/graph/__init__.py +0 -0
- rfnry_memory/graph/extraction.py +81 -0
- rfnry_memory/graph/identity.py +187 -0
- rfnry_memory/graph/placement.py +65 -0
- rfnry_memory/index/__init__.py +13 -0
- rfnry_memory/index/hnsw.py +39 -0
- rfnry_memory/index/ivfflat.py +33 -0
- rfnry_memory/judge.py +61 -0
- rfnry_memory/memory.py +1954 -0
- rfnry_memory/models.py +634 -0
- rfnry_memory/postgres/__init__.py +11 -0
- rfnry_memory/postgres/base.py +28 -0
- rfnry_memory/postgres/corpus.py +116 -0
- rfnry_memory/postgres/graph.py +422 -0
- rfnry_memory/postgres/language.py +52 -0
- rfnry_memory/postgres/locks.py +14 -0
- rfnry_memory/postgres/migrations.py +354 -0
- rfnry_memory/postgres/records.py +110 -0
- rfnry_memory/postgres/schema.py +93 -0
- rfnry_memory/postgres/search.py +286 -0
- rfnry_memory/postgres/sources.py +374 -0
- rfnry_memory/postgres/sql.py +133 -0
- rfnry_memory/postgres/stories.py +279 -0
- rfnry_memory/postgres/vectors.py +324 -0
- rfnry_memory/py.typed +0 -0
- rfnry_memory/ranking/__init__.py +13 -0
- rfnry_memory/ranking/bm25.py +46 -0
- rfnry_memory/ranking/cover_density.py +36 -0
- rfnry_memory/rows.py +110 -0
- rfnry_memory/structured.py +57 -0
- rfnry_memory/tools.py +897 -0
- rfnry_memory-0.1.0.dist-info/METADATA +29 -0
- rfnry_memory-0.1.0.dist-info/RECORD +48 -0
- rfnry_memory-0.1.0.dist-info/WHEEL +4 -0
- rfnry_memory-0.1.0.dist-info/entry_points.txt +2 -0
- rfnry_memory-0.1.0.dist-info/licenses/LICENSE +21 -0
rfnry_memory/__init__.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
from rfnry_memory.errors import (
|
|
2
|
+
ConfigurationError,
|
|
3
|
+
InputError,
|
|
4
|
+
MemoryException,
|
|
5
|
+
ModelError,
|
|
6
|
+
NotFoundError,
|
|
7
|
+
StorageError,
|
|
8
|
+
)
|
|
9
|
+
from rfnry_memory.fusion import RRF, Fusion, RelativeScore
|
|
10
|
+
from rfnry_memory.index import HNSW, IVFFlat, VectorIndex
|
|
11
|
+
from rfnry_memory.memory import Memory
|
|
12
|
+
from rfnry_memory.models import (
|
|
13
|
+
Answer,
|
|
14
|
+
AnswerLike,
|
|
15
|
+
Audio,
|
|
16
|
+
Check,
|
|
17
|
+
CheckAnswer,
|
|
18
|
+
Choice,
|
|
19
|
+
ChoiceAnswer,
|
|
20
|
+
Chunk,
|
|
21
|
+
Chunking,
|
|
22
|
+
Content,
|
|
23
|
+
CorpusDescription,
|
|
24
|
+
CorpusFingerprint,
|
|
25
|
+
Counts,
|
|
26
|
+
Decided,
|
|
27
|
+
DecidedLike,
|
|
28
|
+
DecisionModel,
|
|
29
|
+
Document,
|
|
30
|
+
DocumentEmbeddingModel,
|
|
31
|
+
EmbeddingModel,
|
|
32
|
+
Entity,
|
|
33
|
+
EntityIn,
|
|
34
|
+
GenerationModel,
|
|
35
|
+
Hit,
|
|
36
|
+
Hits,
|
|
37
|
+
Image,
|
|
38
|
+
Ingested,
|
|
39
|
+
Judgement,
|
|
40
|
+
JudgementName,
|
|
41
|
+
Kind,
|
|
42
|
+
Language,
|
|
43
|
+
Match,
|
|
44
|
+
Message,
|
|
45
|
+
Neighborhood,
|
|
46
|
+
Part,
|
|
47
|
+
PillarReport,
|
|
48
|
+
Question,
|
|
49
|
+
QuestionLike,
|
|
50
|
+
Ref,
|
|
51
|
+
Relation,
|
|
52
|
+
RelationIn,
|
|
53
|
+
Remembered,
|
|
54
|
+
Reply,
|
|
55
|
+
ReplyLike,
|
|
56
|
+
Score,
|
|
57
|
+
ScoreAnswer,
|
|
58
|
+
Section,
|
|
59
|
+
Source,
|
|
60
|
+
SourceIn,
|
|
61
|
+
State,
|
|
62
|
+
Story,
|
|
63
|
+
Synced,
|
|
64
|
+
Text,
|
|
65
|
+
ToolCall,
|
|
66
|
+
ToolSpec,
|
|
67
|
+
Usage,
|
|
68
|
+
confidence,
|
|
69
|
+
)
|
|
70
|
+
from rfnry_memory.postgres import PostgresStorage
|
|
71
|
+
from rfnry_memory.ranking import BM25, CoverDensity, TextRanking
|
|
72
|
+
from rfnry_memory.tools import MemoryTools, Tool, Tools
|
|
73
|
+
|
|
74
|
+
__version__ = "0.1.0"
|
|
75
|
+
|
|
76
|
+
__all__ = [
|
|
77
|
+
"Answer",
|
|
78
|
+
"AnswerLike",
|
|
79
|
+
"Check",
|
|
80
|
+
"CheckAnswer",
|
|
81
|
+
"Choice",
|
|
82
|
+
"ChoiceAnswer",
|
|
83
|
+
"Chunk",
|
|
84
|
+
"Chunking",
|
|
85
|
+
"ConfigurationError",
|
|
86
|
+
"CorpusDescription",
|
|
87
|
+
"CorpusFingerprint",
|
|
88
|
+
"Counts",
|
|
89
|
+
"Decided",
|
|
90
|
+
"DecidedLike",
|
|
91
|
+
"DecisionModel",
|
|
92
|
+
"DocumentEmbeddingModel",
|
|
93
|
+
"EmbeddingModel",
|
|
94
|
+
"Entity",
|
|
95
|
+
"EntityIn",
|
|
96
|
+
"GenerationModel",
|
|
97
|
+
"HNSW",
|
|
98
|
+
"BM25",
|
|
99
|
+
"CoverDensity",
|
|
100
|
+
"TextRanking",
|
|
101
|
+
"RRF",
|
|
102
|
+
"RelativeScore",
|
|
103
|
+
"Fusion",
|
|
104
|
+
"Hit",
|
|
105
|
+
"Hits",
|
|
106
|
+
"IVFFlat",
|
|
107
|
+
"Ingested",
|
|
108
|
+
"InputError",
|
|
109
|
+
"Judgement",
|
|
110
|
+
"JudgementName",
|
|
111
|
+
"Kind",
|
|
112
|
+
"Match",
|
|
113
|
+
"Language",
|
|
114
|
+
"Memory",
|
|
115
|
+
"MemoryException",
|
|
116
|
+
"MemoryTools",
|
|
117
|
+
"Message",
|
|
118
|
+
"ModelError",
|
|
119
|
+
"Neighborhood",
|
|
120
|
+
"NotFoundError",
|
|
121
|
+
"PillarReport",
|
|
122
|
+
"PostgresStorage",
|
|
123
|
+
"Question",
|
|
124
|
+
"QuestionLike",
|
|
125
|
+
"Ref",
|
|
126
|
+
"Relation",
|
|
127
|
+
"RelationIn",
|
|
128
|
+
"Remembered",
|
|
129
|
+
"Reply",
|
|
130
|
+
"ReplyLike",
|
|
131
|
+
"Score",
|
|
132
|
+
"ScoreAnswer",
|
|
133
|
+
"Section",
|
|
134
|
+
"Source",
|
|
135
|
+
"SourceIn",
|
|
136
|
+
"State",
|
|
137
|
+
"StorageError",
|
|
138
|
+
"Story",
|
|
139
|
+
"Synced",
|
|
140
|
+
"Tool",
|
|
141
|
+
"ToolCall",
|
|
142
|
+
"ToolSpec",
|
|
143
|
+
"Tools",
|
|
144
|
+
"Usage",
|
|
145
|
+
"VectorIndex",
|
|
146
|
+
"confidence",
|
|
147
|
+
"Text",
|
|
148
|
+
"Image",
|
|
149
|
+
"Audio",
|
|
150
|
+
"Document",
|
|
151
|
+
"Part",
|
|
152
|
+
"Content",
|
|
153
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
from rfnry_memory.chunking.splitter import RecursiveTextSplitter
|
|
6
|
+
from rfnry_memory.chunking.structure import (
|
|
7
|
+
build_heading_spans,
|
|
8
|
+
find_atomic_regions,
|
|
9
|
+
section_path_at,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Piece(BaseModel):
|
|
14
|
+
title: str | None
|
|
15
|
+
text: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def chunk_text(text: str, *, chunk_chars: int, overlap_chars: int) -> list[Piece]:
|
|
19
|
+
text = text.strip()
|
|
20
|
+
if not text:
|
|
21
|
+
return []
|
|
22
|
+
regions = find_atomic_regions(text)
|
|
23
|
+
spans = build_heading_spans(text, regions)
|
|
24
|
+
splitter = RecursiveTextSplitter(chunk_size=chunk_chars, chunk_overlap=overlap_chars)
|
|
25
|
+
pieces: list[Piece] = []
|
|
26
|
+
|
|
27
|
+
def free(start: int, end: int) -> None:
|
|
28
|
+
for piece, offset in splitter.split_text_with_offsets(text[start:end]):
|
|
29
|
+
pieces.append(Piece(title=section_path_at(start + offset, spans), text=piece))
|
|
30
|
+
|
|
31
|
+
cursor = 0
|
|
32
|
+
for region in regions:
|
|
33
|
+
free(cursor, region.start)
|
|
34
|
+
pieces.append(
|
|
35
|
+
Piece(title=section_path_at(region.start, spans), text=region.content.strip())
|
|
36
|
+
)
|
|
37
|
+
cursor = region.end
|
|
38
|
+
free(cursor, len(text))
|
|
39
|
+
return [piece for piece in pieces if piece.text]
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
SEPARATORS = ["\n\n", "\n", ". ", " ", ""]
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _split(text: str, separator: str) -> list[tuple[int, int]]:
|
|
7
|
+
if not separator:
|
|
8
|
+
return [(i, i + 1) for i in range(len(text))]
|
|
9
|
+
spans: list[tuple[int, int]] = []
|
|
10
|
+
prev = 0
|
|
11
|
+
for m in re.finditer(re.escape(separator), text):
|
|
12
|
+
spans.append((prev, m.start()))
|
|
13
|
+
prev = m.end()
|
|
14
|
+
spans.append((prev, len(text)))
|
|
15
|
+
return [(s, e) for s, e in spans if e > s]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RecursiveTextSplitter:
|
|
19
|
+
def __init__(self, chunk_size: int, chunk_overlap: int) -> None:
|
|
20
|
+
self._chunk_size = chunk_size
|
|
21
|
+
self._chunk_overlap = chunk_overlap
|
|
22
|
+
|
|
23
|
+
def split_text_with_offsets(self, text: str) -> list[tuple[str, int]]:
|
|
24
|
+
return self._split(text, SEPARATORS, 0)
|
|
25
|
+
|
|
26
|
+
def _emit(self, text: str, start: int, end: int, base: int) -> list[tuple[str, int]]:
|
|
27
|
+
chunk = text[start:end]
|
|
28
|
+
s = start + len(chunk) - len(chunk.lstrip())
|
|
29
|
+
e = end - (len(chunk) - len(chunk.rstrip()))
|
|
30
|
+
if e <= s:
|
|
31
|
+
return []
|
|
32
|
+
return [(text[s:e], base + s)]
|
|
33
|
+
|
|
34
|
+
def _merge(self, spans: list[tuple[int, int]], separator: str) -> list[tuple[int, int]]:
|
|
35
|
+
joiner = len(separator)
|
|
36
|
+
chunks: list[tuple[int, int]] = []
|
|
37
|
+
current: list[tuple[int, int]] = []
|
|
38
|
+
total = 0
|
|
39
|
+
for span in spans:
|
|
40
|
+
size = span[1] - span[0]
|
|
41
|
+
if total + size + (joiner if current else 0) > self._chunk_size and current:
|
|
42
|
+
chunks.append((current[0][0], current[-1][1]))
|
|
43
|
+
while total > self._chunk_overlap or (
|
|
44
|
+
total + size + (joiner if current else 0) > self._chunk_size and total > 0
|
|
45
|
+
):
|
|
46
|
+
total -= current[0][1] - current[0][0] + (joiner if len(current) > 1 else 0)
|
|
47
|
+
current = current[1:]
|
|
48
|
+
current.append(span)
|
|
49
|
+
total += size + (joiner if len(current) > 1 else 0)
|
|
50
|
+
if current:
|
|
51
|
+
chunks.append((current[0][0], current[-1][1]))
|
|
52
|
+
return chunks
|
|
53
|
+
|
|
54
|
+
def _split(self, text: str, separators: list[str], base: int) -> list[tuple[str, int]]:
|
|
55
|
+
if not text:
|
|
56
|
+
return []
|
|
57
|
+
if len(text) <= self._chunk_size:
|
|
58
|
+
return self._emit(text, 0, len(text), base)
|
|
59
|
+
separator = separators[-1]
|
|
60
|
+
remaining: list[str] = []
|
|
61
|
+
for i, sep in enumerate(separators):
|
|
62
|
+
if not sep:
|
|
63
|
+
separator = sep
|
|
64
|
+
break
|
|
65
|
+
if sep in text:
|
|
66
|
+
separator = sep
|
|
67
|
+
remaining = separators[i + 1 :]
|
|
68
|
+
break
|
|
69
|
+
found: list[tuple[str, int]] = []
|
|
70
|
+
good: list[tuple[int, int]] = []
|
|
71
|
+
for span in _split(text, separator):
|
|
72
|
+
piece = text[span[0] : span[1]]
|
|
73
|
+
if len(piece) < self._chunk_size:
|
|
74
|
+
good.append(span)
|
|
75
|
+
continue
|
|
76
|
+
for s, e in self._merge(good, separator):
|
|
77
|
+
found.extend(self._emit(text, s, e, base))
|
|
78
|
+
good = []
|
|
79
|
+
if remaining:
|
|
80
|
+
found.extend(self._split(piece, remaining, base + span[0]))
|
|
81
|
+
elif len(piece) > self._chunk_size:
|
|
82
|
+
step = max(1, self._chunk_size - self._chunk_overlap)
|
|
83
|
+
for start in range(0, len(piece), step):
|
|
84
|
+
found.extend(
|
|
85
|
+
self._emit(
|
|
86
|
+
piece, start, min(start + self._chunk_size, len(piece)), base + span[0]
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
else:
|
|
90
|
+
found.extend(self._emit(text, span[0], span[1], base))
|
|
91
|
+
for s, e in self._merge(good, separator):
|
|
92
|
+
found.extend(self._emit(text, s, e, base))
|
|
93
|
+
return found
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
_FENCE_OPEN_RE = re.compile(r"```[a-zA-Z0-9_+-]*")
|
|
7
|
+
_TABLE_RE = re.compile(
|
|
8
|
+
r"(?:^\|[^\n]+\|\n)"
|
|
9
|
+
r"(?:^\|[\s\-:|]+\|\n)"
|
|
10
|
+
r"(?:^\|[^\n]+\|\n?)+",
|
|
11
|
+
re.MULTILINE,
|
|
12
|
+
)
|
|
13
|
+
_HEADING_RE = re.compile(r"^(#{1,6})\s+(.+?)$", re.MULTILINE)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class AtomicRegion:
|
|
18
|
+
start: int
|
|
19
|
+
end: int
|
|
20
|
+
content: str
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class HeadingSpan:
|
|
25
|
+
start: int
|
|
26
|
+
end: int
|
|
27
|
+
path: tuple[str, ...]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _iter_code_fences(text: str) -> list[AtomicRegion]:
|
|
31
|
+
lines = text.splitlines(keepends=True)
|
|
32
|
+
starts: list[int] = []
|
|
33
|
+
pos = 0
|
|
34
|
+
for line in lines:
|
|
35
|
+
starts.append(pos)
|
|
36
|
+
pos += len(line)
|
|
37
|
+
|
|
38
|
+
regions: list[AtomicRegion] = []
|
|
39
|
+
i = 0
|
|
40
|
+
n = len(lines)
|
|
41
|
+
while i < n:
|
|
42
|
+
if _FENCE_OPEN_RE.fullmatch(lines[i].rstrip("\r\n")):
|
|
43
|
+
j = i + 1
|
|
44
|
+
while j < n and not lines[j].startswith("```"):
|
|
45
|
+
j += 1
|
|
46
|
+
if j < n:
|
|
47
|
+
start = starts[i]
|
|
48
|
+
end = starts[j] + 3
|
|
49
|
+
regions.append(AtomicRegion(start, end, text[start:end]))
|
|
50
|
+
i = j + 1
|
|
51
|
+
continue
|
|
52
|
+
i += 1
|
|
53
|
+
return regions
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def find_atomic_regions(text: str) -> list[AtomicRegion]:
|
|
57
|
+
regions: list[AtomicRegion] = []
|
|
58
|
+
regions.extend(_iter_code_fences(text))
|
|
59
|
+
for m in _TABLE_RE.finditer(text):
|
|
60
|
+
regions.append(AtomicRegion(m.start(), m.end(), m.group(0)))
|
|
61
|
+
regions.sort(key=lambda r: (r.start, -r.end))
|
|
62
|
+
merged: list[AtomicRegion] = []
|
|
63
|
+
for region in regions:
|
|
64
|
+
if merged and region.end <= merged[-1].end:
|
|
65
|
+
continue
|
|
66
|
+
if merged and region.start < merged[-1].end:
|
|
67
|
+
prev = merged[-1]
|
|
68
|
+
merged[-1] = AtomicRegion(prev.start, region.end, text[prev.start : region.end])
|
|
69
|
+
continue
|
|
70
|
+
merged.append(region)
|
|
71
|
+
return merged
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def build_heading_spans(
|
|
75
|
+
text: str,
|
|
76
|
+
exclude_regions: list[AtomicRegion] | None = None,
|
|
77
|
+
) -> list[HeadingSpan]:
|
|
78
|
+
headings: list[tuple[int, int, str]] = []
|
|
79
|
+
exclude = exclude_regions or []
|
|
80
|
+
for m in _HEADING_RE.finditer(text):
|
|
81
|
+
offset = m.start()
|
|
82
|
+
if any(r.start <= offset < r.end for r in exclude):
|
|
83
|
+
continue
|
|
84
|
+
title = m.group(2).strip()
|
|
85
|
+
title = title.rstrip("#").rstrip() or title
|
|
86
|
+
headings.append((offset, len(m.group(1)), title))
|
|
87
|
+
if not headings:
|
|
88
|
+
return []
|
|
89
|
+
spans: list[HeadingSpan] = []
|
|
90
|
+
stack: list[tuple[int, str]] = []
|
|
91
|
+
for i, (offset, level, title) in enumerate(headings):
|
|
92
|
+
while stack and stack[-1][0] >= level:
|
|
93
|
+
stack.pop()
|
|
94
|
+
stack.append((level, title))
|
|
95
|
+
end = headings[i + 1][0] if i + 1 < len(headings) else len(text)
|
|
96
|
+
spans.append(
|
|
97
|
+
HeadingSpan(
|
|
98
|
+
start=offset,
|
|
99
|
+
end=end,
|
|
100
|
+
path=tuple(t for _, t in stack),
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
return spans
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def section_path_at(offset: int, spans: list[HeadingSpan]) -> str | None:
|
|
107
|
+
for s in spans:
|
|
108
|
+
if s.start <= offset < s.end:
|
|
109
|
+
return " > ".join(s.path)
|
|
110
|
+
return None
|
rfnry_memory/cli.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import sys
|
|
6
|
+
from functools import partial
|
|
7
|
+
from importlib.util import find_spec
|
|
8
|
+
from typing import Any
|
|
9
|
+
from urllib.parse import urlsplit
|
|
10
|
+
|
|
11
|
+
from rfnry_memory.errors import MemoryException
|
|
12
|
+
|
|
13
|
+
_WARNING = "take a snapshot of the database and stop app instances running the old version first"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _parser() -> argparse.ArgumentParser:
|
|
17
|
+
parser = argparse.ArgumentParser(
|
|
18
|
+
prog="rfnry-memory", description="prepare and upgrade the database rfnry-memory runs on"
|
|
19
|
+
)
|
|
20
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
21
|
+
|
|
22
|
+
def command(name: str, summary: str) -> argparse.ArgumentParser:
|
|
23
|
+
sub = commands.add_parser(name, help=summary, description=summary)
|
|
24
|
+
sub.add_argument("--url", help="postgres connection string; without it, the PG* variables")
|
|
25
|
+
sub.add_argument("--schema", default="memory", help="the postgres schema (default: memory)")
|
|
26
|
+
return sub
|
|
27
|
+
|
|
28
|
+
create = command("create", "create the schema with this version's tables")
|
|
29
|
+
vectors = create.add_mutually_exclusive_group(required=True)
|
|
30
|
+
vectors.add_argument("--dimension", type=int, help="the embedding dimension")
|
|
31
|
+
vectors.add_argument("--no-vector", action="store_true", help="create no vector columns")
|
|
32
|
+
create.add_argument("--no-graph", action="store_true", help="do not install pg_trgm")
|
|
33
|
+
create.add_argument("--language", help="the default text search configuration of new corpora")
|
|
34
|
+
create.add_argument("--sql", action="store_true", help="print the statements and run nothing")
|
|
35
|
+
command("check", "say whether the schema fits this version")
|
|
36
|
+
upgrade = command("upgrade", "carry the schema to this version")
|
|
37
|
+
upgrade.add_argument("--sql", action="store_true", help="print the statements and run nothing")
|
|
38
|
+
upgrade.add_argument("--yes", action="store_true", help="run without asking")
|
|
39
|
+
change = command("set", "change a setting of the schema")
|
|
40
|
+
setting = change.add_mutually_exclusive_group(required=True)
|
|
41
|
+
setting.add_argument("--dimension", type=int, help="the embedding dimension")
|
|
42
|
+
setting.add_argument("--graph", action="store_true", help="install pg_trgm")
|
|
43
|
+
setting.add_argument("--language", help="the default text search configuration of new corpora")
|
|
44
|
+
change.add_argument("--sql", action="store_true", help="print the statements and run nothing")
|
|
45
|
+
change.add_argument("--yes", action="store_true", help="run without asking")
|
|
46
|
+
return parser
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _scrubbed(text: str, url: str | None) -> str:
|
|
50
|
+
if not url:
|
|
51
|
+
return text
|
|
52
|
+
password = urlsplit(url).password
|
|
53
|
+
text = text.replace(url, "<url>")
|
|
54
|
+
return text.replace(f":{password}@", ":***@") if password else text
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _quoted(name: str) -> str:
|
|
58
|
+
return '"' + name.replace('"', '""') + '"'
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def _connect(args: argparse.Namespace) -> tuple[Any, str]:
|
|
62
|
+
import asyncpg
|
|
63
|
+
|
|
64
|
+
conn = await asyncpg.connect(args.url)
|
|
65
|
+
held = await conn.fetch(
|
|
66
|
+
"SELECT DISTINCT quote_ident(n.nspname) AS name FROM pg_extension e "
|
|
67
|
+
"JOIN pg_namespace n ON n.oid = e.extnamespace WHERE e.extname IN ('vector', 'pg_trgm') "
|
|
68
|
+
"AND n.nspname NOT IN ($1, 'public') ORDER BY 1",
|
|
69
|
+
args.schema,
|
|
70
|
+
)
|
|
71
|
+
path = [_quoted(args.schema), "public", *(r["name"] for r in held)]
|
|
72
|
+
setting = f"SET search_path TO {', '.join(path)}"
|
|
73
|
+
await conn.execute(setting)
|
|
74
|
+
return conn, setting
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _confirmed(args: argparse.Namespace) -> bool:
|
|
78
|
+
if args.yes:
|
|
79
|
+
return True
|
|
80
|
+
print(_WARNING)
|
|
81
|
+
try:
|
|
82
|
+
answer = input("run these steps? [y/N] ")
|
|
83
|
+
except (EOFError, KeyboardInterrupt):
|
|
84
|
+
print()
|
|
85
|
+
return False
|
|
86
|
+
return answer.strip().lower() in ("y", "yes")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
async def _run(args: argparse.Namespace) -> int:
|
|
90
|
+
from rfnry_memory.postgres import migrations
|
|
91
|
+
from rfnry_memory.postgres.schema import _problems
|
|
92
|
+
|
|
93
|
+
conn, setting = await _connect(args)
|
|
94
|
+
try:
|
|
95
|
+
exists = await conn.fetchval(
|
|
96
|
+
"SELECT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = $1)", args.schema
|
|
97
|
+
)
|
|
98
|
+
if not exists and args.command != "create":
|
|
99
|
+
print(
|
|
100
|
+
f"cli: there is no schema {args.schema} — run rfnry-memory create --schema {args.schema}",
|
|
101
|
+
file=sys.stderr,
|
|
102
|
+
)
|
|
103
|
+
return 1
|
|
104
|
+
if args.command == "check":
|
|
105
|
+
problems, held = await _problems(conn, dimension=None, graph=False)
|
|
106
|
+
if problems:
|
|
107
|
+
for problem in problems:
|
|
108
|
+
print(f"cli: {problem}", file=sys.stderr)
|
|
109
|
+
return 1
|
|
110
|
+
vectors = "no vectors" if held is None else f"vectors of {held} dimensions"
|
|
111
|
+
print(f"schema {args.schema} fits rfnry-memory: version {migrations.LATEST}, {vectors}")
|
|
112
|
+
return 0
|
|
113
|
+
before: list[str] = []
|
|
114
|
+
planner: migrations.Planner
|
|
115
|
+
if args.command == "create":
|
|
116
|
+
before = [f"CREATE SCHEMA IF NOT EXISTS {_quoted(args.schema)}"]
|
|
117
|
+
planner = partial(
|
|
118
|
+
migrations.plan_create,
|
|
119
|
+
dimension=None if args.no_vector else args.dimension,
|
|
120
|
+
graph=not args.no_graph,
|
|
121
|
+
language=args.language,
|
|
122
|
+
)
|
|
123
|
+
elif args.command == "upgrade":
|
|
124
|
+
planner = migrations.plan_upgrade
|
|
125
|
+
else:
|
|
126
|
+
planner = partial(
|
|
127
|
+
migrations.plan_set,
|
|
128
|
+
dimension=args.dimension,
|
|
129
|
+
graph=args.graph,
|
|
130
|
+
language=args.language,
|
|
131
|
+
)
|
|
132
|
+
shown = await migrations.apply(conn, planner, run=False, before=before)
|
|
133
|
+
if args.sql:
|
|
134
|
+
print(f"{setting};")
|
|
135
|
+
for statement in shown.statements:
|
|
136
|
+
print(f"{statement};")
|
|
137
|
+
return 0
|
|
138
|
+
if not shown.steps:
|
|
139
|
+
print(f"schema {args.schema}: nothing to do")
|
|
140
|
+
return 0
|
|
141
|
+
for step in shown.steps:
|
|
142
|
+
print(f" {step}")
|
|
143
|
+
if args.command != "create" and not _confirmed(args):
|
|
144
|
+
print("nothing was run")
|
|
145
|
+
return 0
|
|
146
|
+
await migrations.apply(conn, planner, expected=shown, before=before)
|
|
147
|
+
print(f"schema {args.schema} is at version {migrations.LATEST}")
|
|
148
|
+
return 0
|
|
149
|
+
finally:
|
|
150
|
+
await conn.close()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def main(argv: list[str] | None = None) -> int:
|
|
154
|
+
args = _parser().parse_args(argv)
|
|
155
|
+
if args.schema in ("public", "information_schema") or args.schema.startswith("pg_"):
|
|
156
|
+
print(
|
|
157
|
+
f"cli: memory is not created in {args.schema!r} — name a schema of its own",
|
|
158
|
+
file=sys.stderr,
|
|
159
|
+
)
|
|
160
|
+
return 1
|
|
161
|
+
if args.command in ("create", "set") and args.dimension is not None and args.dimension < 1:
|
|
162
|
+
print("cli: a dimension is a positive int", file=sys.stderr)
|
|
163
|
+
return 1
|
|
164
|
+
if find_spec("asyncpg") is None:
|
|
165
|
+
print("cli: install rfnry-memory[postgres] to use this command", file=sys.stderr)
|
|
166
|
+
return 1
|
|
167
|
+
try:
|
|
168
|
+
return asyncio.run(_run(args))
|
|
169
|
+
except MemoryException as exc:
|
|
170
|
+
print(_scrubbed(str(exc), args.url), file=sys.stderr)
|
|
171
|
+
return 1
|
|
172
|
+
except Exception as exc:
|
|
173
|
+
print(_scrubbed(f"cli: {type(exc).__name__}: {exc}", args.url), file=sys.stderr)
|
|
174
|
+
return 1
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
if __name__ == "__main__":
|
|
178
|
+
sys.exit(main())
|
rfnry_memory/errors.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
class MemoryException(Exception):
|
|
2
|
+
pass
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ConfigurationError(MemoryException):
|
|
6
|
+
pass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class InputError(MemoryException):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ModelError(MemoryException):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class StorageError(MemoryException):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class NotFoundError(MemoryException):
|
|
22
|
+
pass
|
rfnry_memory/filters.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from rfnry_memory.errors import InputError
|
|
7
|
+
|
|
8
|
+
RESERVED = frozenset({"corpus_id"})
|
|
9
|
+
FACET_PREFIX = "metadata."
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _valid_list(values: list[Any]) -> bool:
|
|
13
|
+
if all(isinstance(v, str) for v in values):
|
|
14
|
+
return True
|
|
15
|
+
return all(isinstance(v, int) and not isinstance(v, bool) for v in values)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def validate_filters(filters: dict[str, Any] | None) -> None:
|
|
19
|
+
if not filters:
|
|
20
|
+
return
|
|
21
|
+
for key, condition in filters.items():
|
|
22
|
+
if not isinstance(key, str) or not key.strip():
|
|
23
|
+
raise InputError("filters: filter keys must be non-empty strings")
|
|
24
|
+
if key in RESERVED:
|
|
25
|
+
raise InputError(
|
|
26
|
+
f"filters: filter key {key!r} is reserved — pass it as a call argument"
|
|
27
|
+
)
|
|
28
|
+
if not key.startswith(FACET_PREFIX) or len(key) == len(FACET_PREFIX):
|
|
29
|
+
raise InputError(
|
|
30
|
+
f"filters: filter key {key!r} must address a facet as '{FACET_PREFIX}<key>'"
|
|
31
|
+
)
|
|
32
|
+
if isinstance(condition, list):
|
|
33
|
+
if not condition or not _valid_list(condition):
|
|
34
|
+
raise InputError(
|
|
35
|
+
f"filters: filter {key!r} must be a non-empty list of str or of int"
|
|
36
|
+
)
|
|
37
|
+
continue
|
|
38
|
+
if isinstance(condition, str | int):
|
|
39
|
+
continue
|
|
40
|
+
raise InputError(
|
|
41
|
+
f"filters: filter {key!r} must be str, int, bool or a list of str or of int"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _lookup(metadata: dict[str, Any], path: str) -> tuple[Any, bool]:
|
|
46
|
+
current: Any = metadata
|
|
47
|
+
for part in path.split("."):
|
|
48
|
+
if not isinstance(current, dict) or part not in current:
|
|
49
|
+
return None, False
|
|
50
|
+
current = current[part]
|
|
51
|
+
return current, True
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _same(a: Any, b: Any) -> bool:
|
|
55
|
+
if isinstance(a, bool) != isinstance(b, bool):
|
|
56
|
+
return False
|
|
57
|
+
return bool(a == b)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _condition_matches(value: Any, condition: Any) -> bool:
|
|
61
|
+
values = value if isinstance(value, list) else [value]
|
|
62
|
+
conditions = condition if isinstance(condition, list) else [condition]
|
|
63
|
+
return any(_same(v, c) for v in values for c in conditions)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def within(value: Any, bound: Any) -> bool:
|
|
67
|
+
values = value if isinstance(value, list) else [value]
|
|
68
|
+
bounds = bound if isinstance(bound, list) else [bound]
|
|
69
|
+
return bool(values) and all(any(_same(v, b) for b in bounds) for v in values)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def matches_filters(metadata: dict[str, Any], filters: dict[str, Any] | None) -> bool:
|
|
73
|
+
if not filters:
|
|
74
|
+
return True
|
|
75
|
+
for key, condition in filters.items():
|
|
76
|
+
value, found = _lookup(metadata, key[len(FACET_PREFIX) :])
|
|
77
|
+
if not found or not _condition_matches(value, condition):
|
|
78
|
+
return False
|
|
79
|
+
return True
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def stamp(metadata: dict[str, Any] | None, filters: dict[str, Any]) -> dict[str, Any]:
|
|
83
|
+
stamped = deepcopy(metadata or {})
|
|
84
|
+
for key, value in filters.items():
|
|
85
|
+
*parents, leaf = key[len(FACET_PREFIX) :].split(".")
|
|
86
|
+
node: Any = stamped
|
|
87
|
+
for part in parents:
|
|
88
|
+
node = node.setdefault(part, {})
|
|
89
|
+
if not isinstance(node, dict):
|
|
90
|
+
raise InputError(
|
|
91
|
+
f"filters: metadata must agree with the bound filter {key!r} = {value!r}"
|
|
92
|
+
)
|
|
93
|
+
if leaf in node:
|
|
94
|
+
if not within(node[leaf], value):
|
|
95
|
+
raise InputError(
|
|
96
|
+
f"filters: metadata must agree with the bound filter {key!r} = {value!r}"
|
|
97
|
+
)
|
|
98
|
+
continue
|
|
99
|
+
if isinstance(value, list) and len(value) > 1:
|
|
100
|
+
raise InputError(
|
|
101
|
+
f"filters: metadata must name one of the values bound to {key!r}: {value!r}"
|
|
102
|
+
)
|
|
103
|
+
node[leaf] = value[0] if isinstance(value, list) else value
|
|
104
|
+
return stamped
|