knowledge-fabric 0.1.1__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.
- knowledge_fabric/__init__.py +4 -0
- knowledge_fabric/chunking/__init__.py +9 -0
- knowledge_fabric/chunking/service.py +304 -0
- knowledge_fabric/config.py +174 -0
- knowledge_fabric/db/__init__.py +17 -0
- knowledge_fabric/db/audit.py +59 -0
- knowledge_fabric/db/connection.py +85 -0
- knowledge_fabric/db/dimension_guard.py +109 -0
- knowledge_fabric/db/repository.py +448 -0
- knowledge_fabric/embeddings/__init__.py +30 -0
- knowledge_fabric/embeddings/providers.py +499 -0
- knowledge_fabric/evaluation/__init__.py +40 -0
- knowledge_fabric/evaluation/queries.yaml +146 -0
- knowledge_fabric/evaluation/runner.py +746 -0
- knowledge_fabric/evidence/__init__.py +28 -0
- knowledge_fabric/evidence/models.py +227 -0
- knowledge_fabric/evidence/provenance.py +78 -0
- knowledge_fabric/fusion/__init__.py +8 -0
- knowledge_fabric/fusion/rrf.py +58 -0
- knowledge_fabric/ingestion/__init__.py +16 -0
- knowledge_fabric/ingestion/cli.py +204 -0
- knowledge_fabric/ingestion/models.py +109 -0
- knowledge_fabric/ingestion/service.py +120 -0
- knowledge_fabric/ingestion/sync_cli.py +143 -0
- knowledge_fabric/ingestion/tika_client.py +44 -0
- knowledge_fabric/mcp/__init__.py +9 -0
- knowledge_fabric/mcp/__main__.py +10 -0
- knowledge_fabric/mcp/server.py +206 -0
- knowledge_fabric/mcp/tools.py +184 -0
- knowledge_fabric/reranking/__init__.py +182 -0
- knowledge_fabric/retrieval/__init__.py +15 -0
- knowledge_fabric/retrieval/models.py +62 -0
- knowledge_fabric/retrieval/pipeline.py +349 -0
- knowledge_fabric/retrieval/postgres.py +267 -0
- knowledge_fabric/retrieval/qdrant.py +163 -0
- knowledge_fabric/retrieval/store.py +120 -0
- knowledge_fabric/ui/__init__.py +8 -0
- knowledge_fabric/ui/server.py +255 -0
- knowledge_fabric/ui/static/index.html +829 -0
- knowledge_fabric/ui/static/logo.svg +111 -0
- knowledge_fabric-0.1.1.dist-info/METADATA +431 -0
- knowledge_fabric-0.1.1.dist-info/RECORD +47 -0
- knowledge_fabric-0.1.1.dist-info/WHEEL +5 -0
- knowledge_fabric-0.1.1.dist-info/entry_points.txt +6 -0
- knowledge_fabric-0.1.1.dist-info/licenses/LICENSE +201 -0
- knowledge_fabric-0.1.1.dist-info/licenses/NOTICE +4 -0
- knowledge_fabric-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
# Copyright 2026 Invarcore Organization
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Structure-aware chunking service for Markdown, HTML, PDF/pages, and fallback text."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
from knowledge_fabric.ingestion.models import Chunk, Document, SourceFormat
|
|
12
|
+
|
|
13
|
+
_HEADING_PATTERN = re.compile(r"^(#{1,6})\s+(.+?)\s*$", re.MULTILINE)
|
|
14
|
+
_CODE_BLOCK_PATTERN = re.compile(r"(```[\s\S]*?```)")
|
|
15
|
+
_TABLE_PATTERN = re.compile(r"((?:^\|.*?\|\s*$\n?)+)", re.MULTILINE)
|
|
16
|
+
_PAGE_MARKER_PATTERN = re.compile(r"(?:\f|---\s*Page\s+(\d+)\s*---)", re.IGNORECASE)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(slots=True)
|
|
20
|
+
class ChunkingConfig:
|
|
21
|
+
"""Configuration parameters for document chunking."""
|
|
22
|
+
|
|
23
|
+
max_chars: int = 1200
|
|
24
|
+
overlap_chars: int = 150
|
|
25
|
+
preserve_code_blocks: bool = True
|
|
26
|
+
preserve_tables: bool = True
|
|
27
|
+
min_chunk_chars: int = 30
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class DocumentChunkingService:
|
|
31
|
+
"""Split ingested documents into structure-preserving chunks."""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
max_chars: int = 1200,
|
|
36
|
+
overlap_chars: int = 150,
|
|
37
|
+
config: ChunkingConfig | None = None,
|
|
38
|
+
) -> None:
|
|
39
|
+
if config is not None:
|
|
40
|
+
self._config = config
|
|
41
|
+
else:
|
|
42
|
+
if max_chars <= 0:
|
|
43
|
+
raise ValueError("max_chars must be greater than 0")
|
|
44
|
+
if overlap_chars < 0:
|
|
45
|
+
raise ValueError("overlap_chars must be >= 0")
|
|
46
|
+
if overlap_chars >= max_chars:
|
|
47
|
+
raise ValueError("overlap_chars must be smaller than max_chars")
|
|
48
|
+
self._config = ChunkingConfig(max_chars=max_chars, overlap_chars=overlap_chars)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def max_chars(self) -> int:
|
|
52
|
+
return self._config.max_chars
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def overlap_chars(self) -> int:
|
|
56
|
+
return self._config.overlap_chars
|
|
57
|
+
|
|
58
|
+
def chunk_document(self, document: Document) -> list[Chunk]:
|
|
59
|
+
"""Chunk document content with structure-aware logic according to format."""
|
|
60
|
+
if document.source_format is SourceFormat.MARKDOWN:
|
|
61
|
+
chunks = self._chunk_markdown_structure(document)
|
|
62
|
+
if chunks:
|
|
63
|
+
return chunks
|
|
64
|
+
|
|
65
|
+
# Page-aware chunking for PDFs and presentations
|
|
66
|
+
if document.source_format in (SourceFormat.PDF, SourceFormat.PPTX):
|
|
67
|
+
page_chunks = self._chunk_page_aware(document)
|
|
68
|
+
if page_chunks:
|
|
69
|
+
return page_chunks
|
|
70
|
+
|
|
71
|
+
return self._chunk_fallback(document)
|
|
72
|
+
|
|
73
|
+
def _chunk_markdown_structure(self, document: Document) -> list[Chunk]:
|
|
74
|
+
text = document.content_text.strip()
|
|
75
|
+
matches = list(_HEADING_PATTERN.finditer(text))
|
|
76
|
+
if not matches:
|
|
77
|
+
return []
|
|
78
|
+
|
|
79
|
+
chunks: list[Chunk] = []
|
|
80
|
+
chunk_index = 0
|
|
81
|
+
heading_stack: list[tuple[int, str]] = []
|
|
82
|
+
|
|
83
|
+
for pos, match in enumerate(matches):
|
|
84
|
+
level = len(match.group(1))
|
|
85
|
+
heading_title = match.group(2).strip()
|
|
86
|
+
|
|
87
|
+
# Update heading hierarchy stack
|
|
88
|
+
while heading_stack and heading_stack[-1][0] >= level:
|
|
89
|
+
heading_stack.pop()
|
|
90
|
+
heading_stack.append((level, heading_title))
|
|
91
|
+
heading_path = " > ".join(title for _, title in heading_stack)
|
|
92
|
+
|
|
93
|
+
start = match.start()
|
|
94
|
+
end = matches[pos + 1].start() if pos + 1 < len(matches) else len(text)
|
|
95
|
+
section_text = text[start:end].strip()
|
|
96
|
+
|
|
97
|
+
if len(section_text) <= self._config.max_chars:
|
|
98
|
+
offset_start = text.find(section_text, start)
|
|
99
|
+
offset_end = offset_start + len(section_text) if offset_start >= 0 else end
|
|
100
|
+
chunk = Chunk(
|
|
101
|
+
document_uri=document.source_uri,
|
|
102
|
+
chunk_index=chunk_index,
|
|
103
|
+
chunk_text=section_text,
|
|
104
|
+
heading_path=heading_path,
|
|
105
|
+
heading_level=level,
|
|
106
|
+
start_offset=max(0, offset_start),
|
|
107
|
+
end_offset=offset_end,
|
|
108
|
+
metadata={
|
|
109
|
+
"heading": heading_title,
|
|
110
|
+
"heading_level": level,
|
|
111
|
+
"heading_path": heading_path,
|
|
112
|
+
"start_offset": max(0, offset_start),
|
|
113
|
+
"end_offset": offset_end,
|
|
114
|
+
},
|
|
115
|
+
)
|
|
116
|
+
chunks.append(chunk)
|
|
117
|
+
chunk_index += 1
|
|
118
|
+
continue
|
|
119
|
+
|
|
120
|
+
# Split large section into atomic blocks (headings, code blocks, tables, paragraphs)
|
|
121
|
+
blocks = self._split_section_into_blocks(section_text)
|
|
122
|
+
accumulated_text = ""
|
|
123
|
+
accumulated_start = start
|
|
124
|
+
|
|
125
|
+
for block in blocks:
|
|
126
|
+
if not accumulated_text:
|
|
127
|
+
accumulated_text = block
|
|
128
|
+
elif len(accumulated_text) + 1 + len(block) <= self._config.max_chars:
|
|
129
|
+
accumulated_text = f"{accumulated_text}\n\n{block}"
|
|
130
|
+
else:
|
|
131
|
+
# Emit current accumulated chunk
|
|
132
|
+
offset_start = text.find(accumulated_text, accumulated_start)
|
|
133
|
+
offset_end = offset_start + len(accumulated_text) if offset_start >= 0 else accumulated_start + len(accumulated_text)
|
|
134
|
+
chunks.append(
|
|
135
|
+
Chunk(
|
|
136
|
+
document_uri=document.source_uri,
|
|
137
|
+
chunk_index=chunk_index,
|
|
138
|
+
chunk_text=accumulated_text,
|
|
139
|
+
heading_path=heading_path,
|
|
140
|
+
heading_level=level,
|
|
141
|
+
start_offset=max(0, offset_start),
|
|
142
|
+
end_offset=offset_end,
|
|
143
|
+
metadata={
|
|
144
|
+
"heading": heading_title,
|
|
145
|
+
"heading_level": level,
|
|
146
|
+
"heading_path": heading_path,
|
|
147
|
+
"split_from_heading_section": True,
|
|
148
|
+
"start_offset": max(0, offset_start),
|
|
149
|
+
"end_offset": offset_end,
|
|
150
|
+
},
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
chunk_index += 1
|
|
154
|
+
accumulated_start = max(0, offset_end - self._config.overlap_chars)
|
|
155
|
+
accumulated_text = block
|
|
156
|
+
|
|
157
|
+
if accumulated_text:
|
|
158
|
+
offset_start = text.find(accumulated_text, accumulated_start)
|
|
159
|
+
offset_end = offset_start + len(accumulated_text) if offset_start >= 0 else accumulated_start + len(accumulated_text)
|
|
160
|
+
chunks.append(
|
|
161
|
+
Chunk(
|
|
162
|
+
document_uri=document.source_uri,
|
|
163
|
+
chunk_index=chunk_index,
|
|
164
|
+
chunk_text=accumulated_text,
|
|
165
|
+
heading_path=heading_path,
|
|
166
|
+
heading_level=level,
|
|
167
|
+
start_offset=max(0, offset_start),
|
|
168
|
+
end_offset=offset_end,
|
|
169
|
+
metadata={
|
|
170
|
+
"heading": heading_title,
|
|
171
|
+
"heading_level": level,
|
|
172
|
+
"heading_path": heading_path,
|
|
173
|
+
"split_from_heading_section": True,
|
|
174
|
+
"start_offset": max(0, offset_start),
|
|
175
|
+
"end_offset": offset_end,
|
|
176
|
+
},
|
|
177
|
+
)
|
|
178
|
+
)
|
|
179
|
+
chunk_index += 1
|
|
180
|
+
|
|
181
|
+
return chunks
|
|
182
|
+
|
|
183
|
+
def _split_section_into_blocks(self, section_text: str) -> list[str]:
|
|
184
|
+
"""Decompose a markdown section into discrete structural blocks (paragraphs, tables, code blocks)."""
|
|
185
|
+
# Split on double newline while keeping code blocks intact
|
|
186
|
+
raw_paragraphs = [p.strip() for p in re.split(r"\n\s*\n", section_text) if p.strip()]
|
|
187
|
+
blocks: list[str] = []
|
|
188
|
+
inside_code_fence = False
|
|
189
|
+
current_fence_block: list[str] = []
|
|
190
|
+
|
|
191
|
+
for p in raw_paragraphs:
|
|
192
|
+
fence_count = p.count("```")
|
|
193
|
+
if fence_count % 2 != 0:
|
|
194
|
+
inside_code_fence = not inside_code_fence
|
|
195
|
+
|
|
196
|
+
if inside_code_fence or fence_count > 0:
|
|
197
|
+
current_fence_block.append(p)
|
|
198
|
+
if not inside_code_fence:
|
|
199
|
+
blocks.append("\n\n".join(current_fence_block))
|
|
200
|
+
current_fence_block = []
|
|
201
|
+
else:
|
|
202
|
+
if current_fence_block:
|
|
203
|
+
current_fence_block.append(p)
|
|
204
|
+
blocks.append("\n\n".join(current_fence_block))
|
|
205
|
+
current_fence_block = []
|
|
206
|
+
else:
|
|
207
|
+
blocks.append(p)
|
|
208
|
+
|
|
209
|
+
if current_fence_block:
|
|
210
|
+
blocks.append("\n\n".join(current_fence_block))
|
|
211
|
+
|
|
212
|
+
# Further split any single block that exceeds max_chars
|
|
213
|
+
refined: list[str] = []
|
|
214
|
+
for b in blocks:
|
|
215
|
+
if len(b) <= self._config.max_chars:
|
|
216
|
+
refined.append(b)
|
|
217
|
+
else:
|
|
218
|
+
refined.extend(self._split_with_overlap(b))
|
|
219
|
+
return refined
|
|
220
|
+
|
|
221
|
+
def _chunk_page_aware(self, document: Document) -> list[Chunk]:
|
|
222
|
+
"""Detect form-feed or slide delimiters and emit page-attributed chunks."""
|
|
223
|
+
text = document.content_text.strip()
|
|
224
|
+
pages = _PAGE_MARKER_PATTERN.split(text)
|
|
225
|
+
if len(pages) <= 1:
|
|
226
|
+
return []
|
|
227
|
+
|
|
228
|
+
chunks: list[Chunk] = []
|
|
229
|
+
chunk_index = 0
|
|
230
|
+
page_num = 1
|
|
231
|
+
|
|
232
|
+
for part in pages:
|
|
233
|
+
if not part:
|
|
234
|
+
continue
|
|
235
|
+
if part.isdigit():
|
|
236
|
+
page_num = int(part)
|
|
237
|
+
continue
|
|
238
|
+
|
|
239
|
+
page_text = part.strip()
|
|
240
|
+
if not page_text:
|
|
241
|
+
continue
|
|
242
|
+
|
|
243
|
+
splits = self._split_with_overlap(page_text)
|
|
244
|
+
for split in splits:
|
|
245
|
+
chunks.append(
|
|
246
|
+
Chunk(
|
|
247
|
+
document_uri=document.source_uri,
|
|
248
|
+
chunk_index=chunk_index,
|
|
249
|
+
chunk_text=split,
|
|
250
|
+
metadata={
|
|
251
|
+
"page_number": page_num,
|
|
252
|
+
"strategy": "page_aware",
|
|
253
|
+
},
|
|
254
|
+
)
|
|
255
|
+
)
|
|
256
|
+
chunk_index += 1
|
|
257
|
+
page_num += 1
|
|
258
|
+
|
|
259
|
+
return chunks
|
|
260
|
+
|
|
261
|
+
def _chunk_fallback(self, document: Document) -> list[Chunk]:
|
|
262
|
+
"""Fallback character window chunking for unstructured plain text."""
|
|
263
|
+
text = document.content_text.strip()
|
|
264
|
+
splits = self._split_with_overlap(text)
|
|
265
|
+
chunks: list[Chunk] = []
|
|
266
|
+
current_pos = 0
|
|
267
|
+
|
|
268
|
+
for index, chunk_text in enumerate(splits):
|
|
269
|
+
start = text.find(chunk_text, current_pos)
|
|
270
|
+
end = start + len(chunk_text) if start >= 0 else current_pos + len(chunk_text)
|
|
271
|
+
current_pos = max(0, end - self._config.overlap_chars)
|
|
272
|
+
chunks.append(
|
|
273
|
+
Chunk(
|
|
274
|
+
document_uri=document.source_uri,
|
|
275
|
+
chunk_index=index,
|
|
276
|
+
chunk_text=chunk_text,
|
|
277
|
+
start_offset=max(0, start),
|
|
278
|
+
end_offset=end,
|
|
279
|
+
metadata={
|
|
280
|
+
"strategy": "fallback_window",
|
|
281
|
+
"start_offset": max(0, start),
|
|
282
|
+
"end_offset": end,
|
|
283
|
+
},
|
|
284
|
+
)
|
|
285
|
+
)
|
|
286
|
+
return chunks
|
|
287
|
+
|
|
288
|
+
def _split_with_overlap(self, text: str) -> list[str]:
|
|
289
|
+
if not text:
|
|
290
|
+
return []
|
|
291
|
+
if len(text) <= self._config.max_chars:
|
|
292
|
+
return [text]
|
|
293
|
+
|
|
294
|
+
chunks: list[str] = []
|
|
295
|
+
start = 0
|
|
296
|
+
while start < len(text):
|
|
297
|
+
end = min(start + self._config.max_chars, len(text))
|
|
298
|
+
chunk = text[start:end].strip()
|
|
299
|
+
if chunk:
|
|
300
|
+
chunks.append(chunk)
|
|
301
|
+
if end == len(text):
|
|
302
|
+
break
|
|
303
|
+
start = max(0, end - self._config.overlap_chars)
|
|
304
|
+
return chunks
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# Copyright 2026 Invarcore Organization
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Configuration loader for Knowledge Fabric runtime components."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
from urllib.parse import urlparse
|
|
13
|
+
|
|
14
|
+
import yaml
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(slots=True)
|
|
19
|
+
class DatabaseSettings:
|
|
20
|
+
host: str
|
|
21
|
+
port: int
|
|
22
|
+
name: str
|
|
23
|
+
user: str
|
|
24
|
+
password: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(slots=True)
|
|
28
|
+
class TikaSettings:
|
|
29
|
+
endpoint: str
|
|
30
|
+
request_timeout_seconds: int
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(slots=True)
|
|
34
|
+
class EmbeddingSettings:
|
|
35
|
+
provider: str
|
|
36
|
+
dimension: int
|
|
37
|
+
batch_size: int
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(slots=True)
|
|
41
|
+
class RetrievalSettings:
|
|
42
|
+
default_top_k: int
|
|
43
|
+
lexical_weight: float
|
|
44
|
+
vector_weight: float
|
|
45
|
+
rrf_k: int
|
|
46
|
+
backend: str = "postgres"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(slots=True)
|
|
50
|
+
class RerankingSettings:
|
|
51
|
+
provider: str = "none"
|
|
52
|
+
top_n: int = 5
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(slots=True)
|
|
56
|
+
class AppSettings:
|
|
57
|
+
name: str
|
|
58
|
+
environment: str
|
|
59
|
+
log_level: str
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(slots=True)
|
|
63
|
+
class Settings:
|
|
64
|
+
app: AppSettings
|
|
65
|
+
database: DatabaseSettings
|
|
66
|
+
tika: TikaSettings
|
|
67
|
+
embeddings: EmbeddingSettings
|
|
68
|
+
retrieval: RetrievalSettings
|
|
69
|
+
reranking: RerankingSettings
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def load_settings(path: str | Path = "config/settings.yaml") -> Settings:
|
|
73
|
+
config_file = Path(path)
|
|
74
|
+
if config_file.exists():
|
|
75
|
+
raw = yaml.safe_load(config_file.read_text(encoding="utf-8")) or {}
|
|
76
|
+
else:
|
|
77
|
+
raw = {}
|
|
78
|
+
|
|
79
|
+
app_raw = raw.get("app") or {}
|
|
80
|
+
db_raw = raw.get("database") or {}
|
|
81
|
+
tika_raw = raw.get("tika") or {}
|
|
82
|
+
emb_raw = raw.get("embeddings") or {}
|
|
83
|
+
retrieval_raw = raw.get("retrieval") or {}
|
|
84
|
+
reranking_raw = raw.get("reranking") or {}
|
|
85
|
+
|
|
86
|
+
# Database env overrides
|
|
87
|
+
db_url = os.environ.get("DATABASE_URL")
|
|
88
|
+
if db_url:
|
|
89
|
+
parsed = urlparse(db_url)
|
|
90
|
+
if parsed.hostname:
|
|
91
|
+
db_raw["host"] = parsed.hostname
|
|
92
|
+
if parsed.port:
|
|
93
|
+
db_raw["port"] = parsed.port
|
|
94
|
+
if parsed.path and parsed.path.lstrip("/"):
|
|
95
|
+
db_raw["name"] = parsed.path.lstrip("/")
|
|
96
|
+
if parsed.username:
|
|
97
|
+
db_raw["user"] = parsed.username
|
|
98
|
+
if parsed.password is not None:
|
|
99
|
+
db_raw["password"] = parsed.password
|
|
100
|
+
|
|
101
|
+
if os.environ.get("KF_DB_HOST") or os.environ.get("POSTGRES_HOST"):
|
|
102
|
+
db_raw["host"] = os.environ.get("KF_DB_HOST") or os.environ.get("POSTGRES_HOST")
|
|
103
|
+
if os.environ.get("KF_DB_PORT") or os.environ.get("POSTGRES_PORT"):
|
|
104
|
+
db_raw["port"] = int(os.environ.get("KF_DB_PORT") or os.environ.get("POSTGRES_PORT") or 5432)
|
|
105
|
+
if os.environ.get("KF_DB_NAME") or os.environ.get("POSTGRES_DB"):
|
|
106
|
+
db_raw["name"] = os.environ.get("KF_DB_NAME") or os.environ.get("POSTGRES_DB")
|
|
107
|
+
if os.environ.get("KF_DB_USER"):
|
|
108
|
+
db_raw["user"] = os.environ.get("KF_DB_USER")
|
|
109
|
+
elif os.environ.get("POSTGRES_USER"):
|
|
110
|
+
db_raw["user"] = os.environ.get("POSTGRES_USER")
|
|
111
|
+
if "KF_DB_PASSWORD" in os.environ:
|
|
112
|
+
db_raw["password"] = os.environ.get("KF_DB_PASSWORD")
|
|
113
|
+
elif "POSTGRES_PASSWORD" in os.environ:
|
|
114
|
+
db_raw["password"] = os.environ.get("POSTGRES_PASSWORD", "")
|
|
115
|
+
|
|
116
|
+
# Tika env overrides
|
|
117
|
+
if os.environ.get("TIKA_ENDPOINT"):
|
|
118
|
+
tika_raw["endpoint"] = os.environ["TIKA_ENDPOINT"]
|
|
119
|
+
|
|
120
|
+
# Embedding env overrides
|
|
121
|
+
if os.environ.get("EMBEDDING_PROVIDER"):
|
|
122
|
+
emb_raw["provider"] = os.environ["EMBEDDING_PROVIDER"]
|
|
123
|
+
if os.environ.get("EMBEDDING_DIMENSION"):
|
|
124
|
+
emb_raw["dimension"] = int(os.environ["EMBEDDING_DIMENSION"])
|
|
125
|
+
|
|
126
|
+
# Retrieval env overrides
|
|
127
|
+
if os.environ.get("RETRIEVAL_STORE_BACKEND") or os.environ.get("RETRIEVAL_BACKEND"):
|
|
128
|
+
retrieval_raw["backend"] = os.environ.get("RETRIEVAL_STORE_BACKEND") or os.environ.get("RETRIEVAL_BACKEND")
|
|
129
|
+
|
|
130
|
+
# Reranking env overrides
|
|
131
|
+
if os.environ.get("RERANKER") or os.environ.get("RERANKING_PROVIDER"):
|
|
132
|
+
reranking_raw["provider"] = os.environ.get("RERANKER") or os.environ.get("RERANKING_PROVIDER")
|
|
133
|
+
|
|
134
|
+
return Settings(
|
|
135
|
+
app=AppSettings(
|
|
136
|
+
name=str(app_raw.get("name", "knowledge-fabric")),
|
|
137
|
+
environment=str(app_raw.get("environment", "local")),
|
|
138
|
+
log_level=str(app_raw.get("log_level", "INFO")),
|
|
139
|
+
),
|
|
140
|
+
database=DatabaseSettings(
|
|
141
|
+
host=str(db_raw.get("host", "localhost")),
|
|
142
|
+
port=int(db_raw.get("port", 5432)),
|
|
143
|
+
name=str(db_raw.get("name", "knowledge_fabric")),
|
|
144
|
+
user=str(db_raw.get("user", "kf_app")),
|
|
145
|
+
password=str(db_raw.get("password", "kf_app_password")),
|
|
146
|
+
),
|
|
147
|
+
tika=TikaSettings(
|
|
148
|
+
endpoint=str(tika_raw.get("endpoint", "http://localhost:9998")),
|
|
149
|
+
request_timeout_seconds=int(tika_raw.get("request_timeout_seconds", 30)),
|
|
150
|
+
),
|
|
151
|
+
embeddings=EmbeddingSettings(
|
|
152
|
+
provider=str(emb_raw.get("provider", "mock")),
|
|
153
|
+
dimension=int(emb_raw.get("dimension", 1536)),
|
|
154
|
+
batch_size=int(emb_raw.get("batch_size", 64)),
|
|
155
|
+
),
|
|
156
|
+
retrieval=RetrievalSettings(
|
|
157
|
+
default_top_k=int(retrieval_raw.get("default_top_k", 10)),
|
|
158
|
+
lexical_weight=float(retrieval_raw.get("lexical_weight", 0.5)),
|
|
159
|
+
vector_weight=float(retrieval_raw.get("vector_weight", 0.5)),
|
|
160
|
+
rrf_k=int(retrieval_raw.get("rrf_k", 60)),
|
|
161
|
+
backend=str(retrieval_raw.get("backend", "postgres")),
|
|
162
|
+
),
|
|
163
|
+
reranking=RerankingSettings(
|
|
164
|
+
provider=str(reranking_raw.get("provider", "none")),
|
|
165
|
+
top_n=int(reranking_raw.get("top_n", 5)),
|
|
166
|
+
),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _as_dict(raw: dict[str, Any], key: str) -> dict[str, Any]:
|
|
171
|
+
value = raw.get(key)
|
|
172
|
+
if not isinstance(value, dict):
|
|
173
|
+
raise ValueError(f"Expected mapping for '{key}'")
|
|
174
|
+
return value
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Copyright 2026 Invarcore Organization
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Database access and schema bootstrap."""
|
|
5
|
+
|
|
6
|
+
from knowledge_fabric.db.audit import AuditLogger
|
|
7
|
+
from knowledge_fabric.db.connection import create_postgres_connection_factory
|
|
8
|
+
from knowledge_fabric.db.dimension_guard import DimensionMismatchError, check_embedding_dimension
|
|
9
|
+
from knowledge_fabric.db.repository import KnowledgeRepository
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"AuditLogger",
|
|
13
|
+
"DimensionMismatchError",
|
|
14
|
+
"KnowledgeRepository",
|
|
15
|
+
"check_embedding_dimension",
|
|
16
|
+
"create_postgres_connection_factory",
|
|
17
|
+
]
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Copyright 2026 Invarcore Organization
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Audit and retrieval run logging."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
ConnectionFactory = Callable[[], Any]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AuditLogger:
|
|
15
|
+
"""Writes retrieval telemetry into `retrieval_runs` and `audit_events`."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, connection_factory: ConnectionFactory) -> None:
|
|
18
|
+
self._connection_factory = connection_factory
|
|
19
|
+
|
|
20
|
+
def log_retrieval(
|
|
21
|
+
self,
|
|
22
|
+
*,
|
|
23
|
+
query_text: str,
|
|
24
|
+
top_k: int,
|
|
25
|
+
retrieval_mode: str,
|
|
26
|
+
result_count: int,
|
|
27
|
+
latency_ms: int,
|
|
28
|
+
trace_id: str | None = None,
|
|
29
|
+
tenant_id: str | None = None,
|
|
30
|
+
details: dict[str, object] | None = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
effective_tenant = tenant_id or "default"
|
|
33
|
+
details_payload = details or {}
|
|
34
|
+
connection = self._connection_factory()
|
|
35
|
+
with connection.cursor() as cursor:
|
|
36
|
+
cursor.execute("SELECT set_config('app.tenant_id', %s, true)", [effective_tenant])
|
|
37
|
+
cursor.execute(
|
|
38
|
+
"""
|
|
39
|
+
INSERT INTO retrieval_runs (tenant_id, query_text, top_k, retrieval_mode, filters, latency_ms, result_count)
|
|
40
|
+
VALUES (%s, %s, %s, %s, %s::jsonb, %s, %s)
|
|
41
|
+
""",
|
|
42
|
+
[effective_tenant, query_text, top_k, retrieval_mode, "{}", latency_ms, result_count],
|
|
43
|
+
)
|
|
44
|
+
cursor.execute(
|
|
45
|
+
"""
|
|
46
|
+
INSERT INTO audit_events (tenant_id, event_type, actor, trace_id, event_payload)
|
|
47
|
+
VALUES (%s, %s, %s, %s, %s::jsonb)
|
|
48
|
+
""",
|
|
49
|
+
[effective_tenant, "retrieval.executed", "system", trace_id, _json_dump(details_payload)],
|
|
50
|
+
)
|
|
51
|
+
if hasattr(connection, "commit"):
|
|
52
|
+
connection.commit()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _json_dump(payload: dict[str, object]) -> str:
|
|
56
|
+
# Late import keeps baseline runtime dependency minimal unless logging is used.
|
|
57
|
+
import json
|
|
58
|
+
|
|
59
|
+
return json.dumps(payload, separators=(",", ":"), sort_keys=True)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Copyright 2026 Invarcore Organization
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Database connection helpers."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
import os
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from knowledge_fabric.config import DatabaseSettings
|
|
13
|
+
|
|
14
|
+
ConnectionFactory = Callable[[], Any]
|
|
15
|
+
|
|
16
|
+
# Prohibited superuser usernames that bypass PostgreSQL Row-Level Security (RLS)
|
|
17
|
+
PROHIBITED_SUPERUSER_ROLES = frozenset({"postgres", "root", "superuser", "admin"})
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def create_postgres_connection_factory(
|
|
21
|
+
settings: DatabaseSettings,
|
|
22
|
+
*,
|
|
23
|
+
enforce_non_superuser: bool | None = None,
|
|
24
|
+
) -> ConnectionFactory:
|
|
25
|
+
"""Create a lazy psycopg connection factory from database settings.
|
|
26
|
+
|
|
27
|
+
Restricts PostgreSQL connection roles to least-privilege tenant roles rather
|
|
28
|
+
than superuser to prevent Row-Level Security (RLS) bypass (KF-01).
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def _connect() -> Any:
|
|
32
|
+
import psycopg
|
|
33
|
+
|
|
34
|
+
# Determine non-superuser enforcement
|
|
35
|
+
should_enforce = enforce_non_superuser
|
|
36
|
+
if should_enforce is None:
|
|
37
|
+
should_enforce = os.environ.get("KNOWLEDGE_ENFORCE_NON_SUPERUSER", "true").lower() not in (
|
|
38
|
+
"false",
|
|
39
|
+
"0",
|
|
40
|
+
"off",
|
|
41
|
+
"no",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
if should_enforce and settings.user.lower() in PROHIBITED_SUPERUSER_ROLES:
|
|
45
|
+
raise PermissionError(
|
|
46
|
+
f"PostgreSQL connection role '{settings.user}' is a prohibited superuser role. "
|
|
47
|
+
"Knowledge Fabric requires a dedicated least-privilege tenant role (e.g., 'kf_app') "
|
|
48
|
+
"to ensure Row-Level Security (RLS) policies are enforced (KF-01). "
|
|
49
|
+
"Set KNOWLEDGE_ENFORCE_NON_SUPERUSER=false to override for migrations/admin tooling."
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
connect_timeout = int(os.environ.get("KNOWLEDGE_DB_CONNECT_TIMEOUT", "2"))
|
|
53
|
+
conn = psycopg.connect(
|
|
54
|
+
host=settings.host,
|
|
55
|
+
port=settings.port,
|
|
56
|
+
dbname=settings.name,
|
|
57
|
+
user=settings.user,
|
|
58
|
+
password=settings.password,
|
|
59
|
+
connect_timeout=connect_timeout,
|
|
60
|
+
autocommit=False,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
if should_enforce:
|
|
64
|
+
try:
|
|
65
|
+
with conn.cursor() as cur:
|
|
66
|
+
cur.execute("SHOW is_superuser")
|
|
67
|
+
row = cur.fetchone()
|
|
68
|
+
if row and str(row[0]).lower() in ("on", "true", "1", "yes"):
|
|
69
|
+
conn.close()
|
|
70
|
+
raise PermissionError(
|
|
71
|
+
f"PostgreSQL connection role '{settings.user}' has superuser privileges (is_superuser=on). "
|
|
72
|
+
"Knowledge Fabric requires a dedicated least-privilege tenant role (e.g., 'kf_app') "
|
|
73
|
+
"to ensure Row-Level Security (RLS) policies are enforced (KF-01). "
|
|
74
|
+
"Set KNOWLEDGE_ENFORCE_NON_SUPERUSER=false to override for migrations/admin tooling."
|
|
75
|
+
)
|
|
76
|
+
except PermissionError:
|
|
77
|
+
raise
|
|
78
|
+
except Exception:
|
|
79
|
+
# If SHOW is_superuser is unsupported or fails for an unexpected non-permission reason, do not block
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
return conn
|
|
83
|
+
|
|
84
|
+
return _connect
|
|
85
|
+
|