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.
Files changed (47) hide show
  1. knowledge_fabric/__init__.py +4 -0
  2. knowledge_fabric/chunking/__init__.py +9 -0
  3. knowledge_fabric/chunking/service.py +304 -0
  4. knowledge_fabric/config.py +174 -0
  5. knowledge_fabric/db/__init__.py +17 -0
  6. knowledge_fabric/db/audit.py +59 -0
  7. knowledge_fabric/db/connection.py +85 -0
  8. knowledge_fabric/db/dimension_guard.py +109 -0
  9. knowledge_fabric/db/repository.py +448 -0
  10. knowledge_fabric/embeddings/__init__.py +30 -0
  11. knowledge_fabric/embeddings/providers.py +499 -0
  12. knowledge_fabric/evaluation/__init__.py +40 -0
  13. knowledge_fabric/evaluation/queries.yaml +146 -0
  14. knowledge_fabric/evaluation/runner.py +746 -0
  15. knowledge_fabric/evidence/__init__.py +28 -0
  16. knowledge_fabric/evidence/models.py +227 -0
  17. knowledge_fabric/evidence/provenance.py +78 -0
  18. knowledge_fabric/fusion/__init__.py +8 -0
  19. knowledge_fabric/fusion/rrf.py +58 -0
  20. knowledge_fabric/ingestion/__init__.py +16 -0
  21. knowledge_fabric/ingestion/cli.py +204 -0
  22. knowledge_fabric/ingestion/models.py +109 -0
  23. knowledge_fabric/ingestion/service.py +120 -0
  24. knowledge_fabric/ingestion/sync_cli.py +143 -0
  25. knowledge_fabric/ingestion/tika_client.py +44 -0
  26. knowledge_fabric/mcp/__init__.py +9 -0
  27. knowledge_fabric/mcp/__main__.py +10 -0
  28. knowledge_fabric/mcp/server.py +206 -0
  29. knowledge_fabric/mcp/tools.py +184 -0
  30. knowledge_fabric/reranking/__init__.py +182 -0
  31. knowledge_fabric/retrieval/__init__.py +15 -0
  32. knowledge_fabric/retrieval/models.py +62 -0
  33. knowledge_fabric/retrieval/pipeline.py +349 -0
  34. knowledge_fabric/retrieval/postgres.py +267 -0
  35. knowledge_fabric/retrieval/qdrant.py +163 -0
  36. knowledge_fabric/retrieval/store.py +120 -0
  37. knowledge_fabric/ui/__init__.py +8 -0
  38. knowledge_fabric/ui/server.py +255 -0
  39. knowledge_fabric/ui/static/index.html +829 -0
  40. knowledge_fabric/ui/static/logo.svg +111 -0
  41. knowledge_fabric-0.1.1.dist-info/METADATA +431 -0
  42. knowledge_fabric-0.1.1.dist-info/RECORD +47 -0
  43. knowledge_fabric-0.1.1.dist-info/WHEEL +5 -0
  44. knowledge_fabric-0.1.1.dist-info/entry_points.txt +6 -0
  45. knowledge_fabric-0.1.1.dist-info/licenses/LICENSE +201 -0
  46. knowledge_fabric-0.1.1.dist-info/licenses/NOTICE +4 -0
  47. knowledge_fabric-0.1.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,4 @@
1
+ # Copyright 2026 Invarcore Organization
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Knowledge Fabric package."""
@@ -0,0 +1,9 @@
1
+ # Copyright 2026 Invarcore Organization
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Chunking strategies."""
5
+
6
+ from knowledge_fabric.chunking.service import ChunkingConfig, DocumentChunkingService
7
+
8
+ __all__ = ["ChunkingConfig", "DocumentChunkingService"]
9
+
@@ -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
+