know-do-graph 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.
- agents/__init__.py +0 -0
- agents/extraction_agent/__init__.py +0 -0
- agents/extraction_agent/agent.py +170 -0
- agents/graph_agent/__init__.py +5 -0
- agents/graph_agent/agent.py +373 -0
- agents/graph_agent/tools.py +2106 -0
- agents/maintenance_agent/__init__.py +0 -0
- agents/maintenance_agent/agent.py +283 -0
- agents/orchestrator/__init__.py +0 -0
- agents/orchestrator/agent.py +217 -0
- agents/review_agent/__init__.py +0 -0
- agents/review_agent/agent.py +188 -0
- agents/review_agent/tools.py +472 -0
- api/__init__.py +0 -0
- api/main.py +136 -0
- api/routes/__init__.py +0 -0
- api/routes/agent.py +81 -0
- api/routes/entries.py +411 -0
- api/routes/graph.py +132 -0
- api/routes/mem.py +179 -0
- api/routes/remote.py +815 -0
- api/routes/remote_sync.py +230 -0
- api/routes/retrieve.py +88 -0
- core/__init__.py +0 -0
- core/app_state.py +9 -0
- core/events.py +84 -0
- core/extraction/__init__.py +0 -0
- core/extraction/wikilink_parser.py +48 -0
- core/graph/__init__.py +0 -0
- core/graph/graph.py +204 -0
- core/memory/__init__.py +0 -0
- core/memory/memgraph.py +458 -0
- core/resources/starter.db +0 -0
- core/retrieval/__init__.py +0 -0
- core/retrieval/embedder.py +122 -0
- core/retrieval/fusion.py +52 -0
- core/retrieval/progressive.py +399 -0
- core/retrieval/retrieval.py +346 -0
- core/retrieval/vector_store.py +91 -0
- core/schemas/__init__.py +0 -0
- core/schemas/edge.py +46 -0
- core/schemas/entry.py +388 -0
- core/storage/__init__.py +0 -0
- core/storage/database.py +104 -0
- core/storage/models.py +66 -0
- core/storage/repository.py +243 -0
- core/sync/__init__.py +20 -0
- core/sync/autolink.py +301 -0
- core/sync/db_merge.py +297 -0
- core/sync/db_watcher.py +84 -0
- core/sync/remote_sync.py +345 -0
- examples/__init__.py +0 -0
- examples/example_entries.py +206 -0
- examples/pymatgen_interface_examples.py +811 -0
- frontend/dist/assets/index-BLfo7ZZu.css +1 -0
- frontend/dist/assets/index-G-mYbZ9R.js +83 -0
- frontend/dist/assets/index-G-mYbZ9R.js.map +1 -0
- frontend/dist/index.html +92 -0
- know_do_graph-0.1.0.dist-info/METADATA +765 -0
- know_do_graph-0.1.0.dist-info/RECORD +63 -0
- know_do_graph-0.1.0.dist-info/WHEEL +4 -0
- know_do_graph-0.1.0.dist-info/entry_points.txt +2 -0
- main.py +944 -0
core/schemas/entry.py
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import uuid
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Literal, Optional
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field, field_validator
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RemoteSource(BaseModel):
|
|
13
|
+
"""Link to an upstream file (e.g. a SKILL.md in another repo) that should be
|
|
14
|
+
mirrored periodically into this node's ``content``.
|
|
15
|
+
|
|
16
|
+
The identity of the node (id, slug, title, wikilinks-pointing-at-it) is
|
|
17
|
+
intentionally NOT touched by the sync — only the body is refreshed.
|
|
18
|
+
"""
|
|
19
|
+
kind: Literal["github", "http"] = "github"
|
|
20
|
+
# Display URL (e.g. https://github.com/owner/repo or https://github.com/owner/repo/blob/ref/path)
|
|
21
|
+
url: str
|
|
22
|
+
# For kind="github": owner/repo and path-in-repo.
|
|
23
|
+
owner: Optional[str] = None
|
|
24
|
+
repo: Optional[str] = None
|
|
25
|
+
ref: str = "main" # branch / tag / commit
|
|
26
|
+
path: Optional[str] = None # e.g. "skills/extractor/SKILL.md"
|
|
27
|
+
# Cache / change-detection
|
|
28
|
+
content_hash: Optional[str] = None # sha256 of last-fetched body
|
|
29
|
+
etag: Optional[str] = None # GitHub blob sha or HTTP ETag
|
|
30
|
+
fetched_at: Optional[datetime] = None
|
|
31
|
+
status: Literal["ok", "stale", "error", "never"] = "never"
|
|
32
|
+
last_error: Optional[str] = None
|
|
33
|
+
# Auto-sync controls
|
|
34
|
+
auto_sync: bool = True
|
|
35
|
+
sync_interval_seconds: int = 3600
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class EntryType(str, Enum):
|
|
39
|
+
capability = "capability"
|
|
40
|
+
procedure = "procedure"
|
|
41
|
+
workflow = "workflow"
|
|
42
|
+
tool = "tool"
|
|
43
|
+
repository = "repository"
|
|
44
|
+
environment = "environment"
|
|
45
|
+
dependency = "dependency"
|
|
46
|
+
data = "data"
|
|
47
|
+
analytical = "analytical"
|
|
48
|
+
memory = "memory"
|
|
49
|
+
# Hierarchical-memory layers (see SkillLevel).
|
|
50
|
+
heuristic = "heuristic" # L3: operational experience / empirical guidance
|
|
51
|
+
constraint = "constraint" # L4: known failure modes / limitations
|
|
52
|
+
generic = "generic"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class SkillLevel(str, Enum):
|
|
56
|
+
"""Progressive-disclosure layer of a node in the hierarchical memory.
|
|
57
|
+
|
|
58
|
+
L1 — Capability : reusable high-level ability (planner-friendly)
|
|
59
|
+
L2 — Procedure : executable workflow / task decomposition
|
|
60
|
+
L3 — Heuristic : empirical guidance, conditional advice
|
|
61
|
+
L4 — Constraint : failure modes, instability regions, do-not-use cases
|
|
62
|
+
|
|
63
|
+
Stored on ``EntryMetadata.skill_level``. ``None`` means *unclassified*
|
|
64
|
+
(legacy / generic content). The level is **orthogonal** to ``entry_type``
|
|
65
|
+
so that, e.g., a ``procedure`` and a ``workflow`` can both be tagged L2,
|
|
66
|
+
and an L3 ``heuristic`` can be attached to either.
|
|
67
|
+
"""
|
|
68
|
+
L1 = "L1"
|
|
69
|
+
L2 = "L2"
|
|
70
|
+
L3 = "L3"
|
|
71
|
+
L4 = "L4"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# Default mapping from entry_type → skill level, used by the backfill script
|
|
75
|
+
# and by progressive retrieval when ``skill_level`` is not set explicitly.
|
|
76
|
+
DEFAULT_LEVEL_FOR_TYPE: dict[str, SkillLevel] = {
|
|
77
|
+
"capability": SkillLevel.L1,
|
|
78
|
+
"workflow": SkillLevel.L1,
|
|
79
|
+
"procedure": SkillLevel.L2,
|
|
80
|
+
"heuristic": SkillLevel.L3,
|
|
81
|
+
"constraint": SkillLevel.L4,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def implied_level(entry_type: "EntryType | str | None", explicit: "SkillLevel | None") -> "SkillLevel | None":
|
|
86
|
+
"""Return the effective skill level for an entry.
|
|
87
|
+
|
|
88
|
+
Prefers an explicit metadata tag; otherwise falls back to the default
|
|
89
|
+
mapping for the entry type.
|
|
90
|
+
"""
|
|
91
|
+
if explicit is not None:
|
|
92
|
+
return explicit
|
|
93
|
+
et = entry_type.value if hasattr(entry_type, "value") else (entry_type or "")
|
|
94
|
+
return DEFAULT_LEVEL_FOR_TYPE.get(str(et))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class RefinementStatus(str, Enum):
|
|
98
|
+
raw = "raw"
|
|
99
|
+
linked = "linked"
|
|
100
|
+
refined = "refined"
|
|
101
|
+
validated = "validated"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class VerificationStatus(str, Enum):
|
|
105
|
+
"""Lifecycle of correctness checking for a node.
|
|
106
|
+
|
|
107
|
+
unverified — created by an agent, never validated.
|
|
108
|
+
self_tested — author/agent reports it works (low confidence).
|
|
109
|
+
peer_reviewed — another agent or human reviewed it.
|
|
110
|
+
community_tested — multiple independent successes recorded.
|
|
111
|
+
bugged — known broken; needs fix.
|
|
112
|
+
deprecated — superseded; do not use.
|
|
113
|
+
"""
|
|
114
|
+
unverified = "unverified"
|
|
115
|
+
self_tested = "self_tested"
|
|
116
|
+
peer_reviewed = "peer_reviewed"
|
|
117
|
+
community_tested = "community_tested"
|
|
118
|
+
bugged = "bugged"
|
|
119
|
+
deprecated = "deprecated"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class EntryMetadata(BaseModel):
|
|
123
|
+
timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
|
124
|
+
source_provenance: Optional[str] = None
|
|
125
|
+
extraction_method: Optional[str] = None
|
|
126
|
+
refinement_status: RefinementStatus = RefinementStatus.raw
|
|
127
|
+
usage_count: int = 0
|
|
128
|
+
trust_score: Optional[float] = None
|
|
129
|
+
verification_status: VerificationStatus = VerificationStatus.unverified
|
|
130
|
+
# Set by create_entry when a more-generic existing node is found.
|
|
131
|
+
# MaintenanceAgent uses this to pick candidates for abstraction.
|
|
132
|
+
needs_generalization: bool = False
|
|
133
|
+
# Append-only log of external/internal feedback events.
|
|
134
|
+
# Each item: {timestamp, agent_id, verdict, note, evidence}
|
|
135
|
+
feedback_log: list[dict] = Field(default_factory=list)
|
|
136
|
+
|
|
137
|
+
# Hierarchical-memory tagging (progressive disclosure).
|
|
138
|
+
# Explicit override for the L1/L2/L3/L4 level. When None, callers should
|
|
139
|
+
# use ``implied_level(entry_type, skill_level)`` to derive it from the
|
|
140
|
+
# entry_type via DEFAULT_LEVEL_FOR_TYPE.
|
|
141
|
+
skill_level: Optional[SkillLevel] = None
|
|
142
|
+
# Free-form metadata for L3 heuristics / L4 constraints:
|
|
143
|
+
# {domain: str, confidence: float, papers: [str], notes: str, ...}
|
|
144
|
+
applicability: dict = Field(default_factory=dict)
|
|
145
|
+
# Quick-access list of slugs/ids of L4 constraint nodes attached to this
|
|
146
|
+
# capability/procedure. Kept denormalised so planners can skim risks
|
|
147
|
+
# without traversing edges. Authoritative source is still ``constraint_on``
|
|
148
|
+
# edges in the graph.
|
|
149
|
+
failure_modes: list[str] = Field(default_factory=list)
|
|
150
|
+
|
|
151
|
+
@field_validator("verification_status", mode="before")
|
|
152
|
+
@classmethod
|
|
153
|
+
def _default_verification(cls, v):
|
|
154
|
+
# Tolerate legacy rows where verification_status was stored as null.
|
|
155
|
+
return VerificationStatus.unverified if v is None else v
|
|
156
|
+
# Script-specific metadata
|
|
157
|
+
script_language: Optional[str] = None # e.g. "python", "bash", "julia"
|
|
158
|
+
script_requirements: list[str] = Field(default_factory=list) # pip/conda packages
|
|
159
|
+
script_filename: Optional[str] = None # suggested filename for download
|
|
160
|
+
related_environments: list[str] = Field(default_factory=list)
|
|
161
|
+
runtime_requirements: list[str] = Field(default_factory=list)
|
|
162
|
+
external_refs: list[str] = Field(default_factory=list)
|
|
163
|
+
# Review-agent tracking
|
|
164
|
+
review_count: int = 0
|
|
165
|
+
modify_count: int = 0
|
|
166
|
+
last_reviewed_at: Optional[datetime] = None
|
|
167
|
+
# Remote-source mirror (for nodes that wrap an upstream SKILL.md / script).
|
|
168
|
+
remote_source: Optional[RemoteSource] = None
|
|
169
|
+
custom: dict = Field(default_factory=dict)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
_WIKILINK_RE = re.compile(r"\[\[([^\]|]+?)(?:\|[^\]]+)?\]\]")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _extract_wikilinks(content: str) -> list[str]:
|
|
176
|
+
return _WIKILINK_RE.findall(content)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class ScriptAttachment(BaseModel):
|
|
180
|
+
"""An executable script stored directly on a node, separate from human-readable content.
|
|
181
|
+
|
|
182
|
+
Kept for backward compatibility — internally these are mirrored into the
|
|
183
|
+
generalised :class:`NodeAsset` list with ``folder="scripts"``.
|
|
184
|
+
"""
|
|
185
|
+
filename: str
|
|
186
|
+
language: str = "python"
|
|
187
|
+
content: str
|
|
188
|
+
requirements: list[str] = Field(default_factory=list)
|
|
189
|
+
description: str = ""
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# ── Asset folders (free-form, but these are recognised by the UI) ────────────
|
|
193
|
+
ASSET_FOLDER_SCRIPTS = "scripts"
|
|
194
|
+
ASSET_FOLDER_REFERENCES = "references"
|
|
195
|
+
ASSET_FOLDER_DOCS = "docs"
|
|
196
|
+
ASSET_FOLDER_EXAMPLES = "examples"
|
|
197
|
+
ASSET_FOLDER_DATA = "data"
|
|
198
|
+
ASSET_FOLDER_NOTES = "notes"
|
|
199
|
+
|
|
200
|
+
KNOWN_ASSET_FOLDERS = (
|
|
201
|
+
ASSET_FOLDER_SCRIPTS,
|
|
202
|
+
ASSET_FOLDER_REFERENCES,
|
|
203
|
+
ASSET_FOLDER_DOCS,
|
|
204
|
+
ASSET_FOLDER_EXAMPLES,
|
|
205
|
+
ASSET_FOLDER_DATA,
|
|
206
|
+
ASSET_FOLDER_NOTES,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _normalise_folder(folder: str) -> str:
|
|
211
|
+
f = (folder or "").strip().strip("/").lower()
|
|
212
|
+
f = re.sub(r"[^a-z0-9_\-]+", "-", f)
|
|
213
|
+
return f or ASSET_FOLDER_NOTES
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _normalise_filename(filename: str) -> str:
|
|
217
|
+
"""Strip path traversal and leading slashes; keep simple sub-paths."""
|
|
218
|
+
name = (filename or "").strip().lstrip("/\\")
|
|
219
|
+
# Reject path-traversal segments outright
|
|
220
|
+
parts = [p for p in re.split(r"[\\/]+", name) if p and p != "."]
|
|
221
|
+
if any(p == ".." for p in parts):
|
|
222
|
+
raise ValueError(f"Invalid asset filename (contains '..'): {filename!r}")
|
|
223
|
+
if not parts:
|
|
224
|
+
raise ValueError("Asset filename must not be empty")
|
|
225
|
+
return "/".join(parts)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class NodeAsset(BaseModel):
|
|
229
|
+
"""A file-like attachment on an entry.
|
|
230
|
+
|
|
231
|
+
Each node behaves like a small folder of typed assets. Folders are free-form
|
|
232
|
+
strings (e.g. ``scripts``, ``references``, ``docs``, ``examples``, ``data``,
|
|
233
|
+
``notes``); see :data:`KNOWN_ASSET_FOLDERS` for the conventional set.
|
|
234
|
+
|
|
235
|
+
External agents can address assets as ``[entry-slug]/[folder]/[filename]``
|
|
236
|
+
via ``GET /entries/{id}/assets/{folder}/{filename}``.
|
|
237
|
+
"""
|
|
238
|
+
folder: str = ASSET_FOLDER_NOTES
|
|
239
|
+
filename: str
|
|
240
|
+
kind: str = "file" # "file" | "link" | "text"
|
|
241
|
+
content: str = "" # body for file/text; URL for link
|
|
242
|
+
language: Optional[str] = None
|
|
243
|
+
mime_type: Optional[str] = None
|
|
244
|
+
description: str = ""
|
|
245
|
+
requirements: list[str] = Field(default_factory=list)
|
|
246
|
+
metadata: dict = Field(default_factory=dict)
|
|
247
|
+
|
|
248
|
+
@field_validator("folder", mode="before")
|
|
249
|
+
@classmethod
|
|
250
|
+
def _v_folder(cls, v):
|
|
251
|
+
return _normalise_folder(v if isinstance(v, str) else "")
|
|
252
|
+
|
|
253
|
+
@field_validator("filename", mode="before")
|
|
254
|
+
@classmethod
|
|
255
|
+
def _v_filename(cls, v):
|
|
256
|
+
return _normalise_filename(v if isinstance(v, str) else "")
|
|
257
|
+
|
|
258
|
+
@field_validator("kind", mode="before")
|
|
259
|
+
@classmethod
|
|
260
|
+
def _v_kind(cls, v):
|
|
261
|
+
v = (v or "file").lower()
|
|
262
|
+
if v not in ("file", "link", "text"):
|
|
263
|
+
v = "file"
|
|
264
|
+
return v
|
|
265
|
+
|
|
266
|
+
@property
|
|
267
|
+
def path(self) -> str:
|
|
268
|
+
"""Folder/filename path used in asset URLs."""
|
|
269
|
+
return f"{self.folder}/{self.filename}"
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class Entry(BaseModel):
|
|
273
|
+
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
|
274
|
+
title: str
|
|
275
|
+
slug: str = ""
|
|
276
|
+
entry_type: EntryType = EntryType.generic
|
|
277
|
+
content: str = ""
|
|
278
|
+
tags: list[str] = Field(default_factory=list)
|
|
279
|
+
aliases: list[str] = Field(default_factory=list)
|
|
280
|
+
metadata: EntryMetadata = Field(default_factory=EntryMetadata)
|
|
281
|
+
internal_refs: list[str] = Field(default_factory=list)
|
|
282
|
+
scripts: list[ScriptAttachment] = Field(default_factory=list)
|
|
283
|
+
assets: list[NodeAsset] = Field(default_factory=list)
|
|
284
|
+
|
|
285
|
+
def model_post_init(self, __context: object) -> None:
|
|
286
|
+
if not self.slug:
|
|
287
|
+
self.slug = _slug_from_title(self.title)
|
|
288
|
+
extracted = _extract_wikilinks(self.content)
|
|
289
|
+
combined = list(dict.fromkeys(self.internal_refs + extracted))
|
|
290
|
+
self.internal_refs = combined
|
|
291
|
+
# Bidirectional sync between legacy `scripts` and generalised `assets`.
|
|
292
|
+
self._sync_scripts_and_assets()
|
|
293
|
+
|
|
294
|
+
def refresh_refs(self) -> None:
|
|
295
|
+
self.internal_refs = list(dict.fromkeys(_extract_wikilinks(self.content)))
|
|
296
|
+
|
|
297
|
+
# ------------------------------------------------------------------
|
|
298
|
+
# Assets / scripts sync helpers
|
|
299
|
+
# ------------------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
def _sync_scripts_and_assets(self) -> None:
|
|
302
|
+
"""Keep ``scripts`` and ``assets[folder='scripts']`` in sync.
|
|
303
|
+
|
|
304
|
+
``assets`` is the canonical store. ``scripts`` is a derived legacy view.
|
|
305
|
+
|
|
306
|
+
- **Legacy load** (``assets`` empty, ``scripts`` non-empty): migrate the
|
|
307
|
+
scripts into ``assets`` so future reads see the unified model.
|
|
308
|
+
- Otherwise: any ``scripts=`` passed at construction is **ignored**
|
|
309
|
+
(``assets`` is authoritative).
|
|
310
|
+
- ``scripts`` is then rebuilt strictly from ``assets`` so deletions on
|
|
311
|
+
``assets`` propagate to the legacy view.
|
|
312
|
+
"""
|
|
313
|
+
if not self.assets and self.scripts:
|
|
314
|
+
for s in self.scripts:
|
|
315
|
+
self.assets.append(NodeAsset(
|
|
316
|
+
folder=ASSET_FOLDER_SCRIPTS,
|
|
317
|
+
filename=s.filename,
|
|
318
|
+
kind="file",
|
|
319
|
+
content=s.content,
|
|
320
|
+
language=s.language,
|
|
321
|
+
requirements=list(s.requirements),
|
|
322
|
+
description=s.description,
|
|
323
|
+
))
|
|
324
|
+
# Derived view — always rebuilt from canonical assets.
|
|
325
|
+
self.scripts = [
|
|
326
|
+
ScriptAttachment(
|
|
327
|
+
filename=a.filename,
|
|
328
|
+
language=a.language or "python",
|
|
329
|
+
content=a.content,
|
|
330
|
+
requirements=list(a.requirements),
|
|
331
|
+
description=a.description,
|
|
332
|
+
)
|
|
333
|
+
for a in self.assets
|
|
334
|
+
if a.folder == ASSET_FOLDER_SCRIPTS and a.kind != "link"
|
|
335
|
+
]
|
|
336
|
+
|
|
337
|
+
def find_asset(self, folder: str, filename: str) -> Optional[NodeAsset]:
|
|
338
|
+
folder = _normalise_folder(folder)
|
|
339
|
+
filename = _normalise_filename(filename)
|
|
340
|
+
for a in self.assets:
|
|
341
|
+
if a.folder == folder and a.filename == filename:
|
|
342
|
+
return a
|
|
343
|
+
return None
|
|
344
|
+
|
|
345
|
+
def assets_by_folder(self) -> dict[str, list[NodeAsset]]:
|
|
346
|
+
out: dict[str, list[NodeAsset]] = {}
|
|
347
|
+
for a in self.assets:
|
|
348
|
+
out.setdefault(a.folder, []).append(a)
|
|
349
|
+
return out
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
# Scientific symbols that should become descriptive words, not their Latin
|
|
353
|
+
# transliterations. Å (U+00C5/U+00E5) decomposes to 'a' via NFKD, but in
|
|
354
|
+
# materials-science titles it is the Ångström unit symbol.
|
|
355
|
+
_CHAR_SUBS: dict[str, str] = {
|
|
356
|
+
"Å": "angstrom",
|
|
357
|
+
"å": "angstrom",
|
|
358
|
+
"µ": "micro",
|
|
359
|
+
"μ": "micro",
|
|
360
|
+
"°": "deg",
|
|
361
|
+
"±": "plus-minus",
|
|
362
|
+
"×": "x",
|
|
363
|
+
"·": "-",
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _slug_from_title(title: str) -> str:
|
|
368
|
+
import unicodedata
|
|
369
|
+
# Pre-substitute scientific symbols that have misleading NFKD decompositions.
|
|
370
|
+
for sym, replacement in _CHAR_SUBS.items():
|
|
371
|
+
title = title.replace(sym, f" {replacement} ")
|
|
372
|
+
# Transliterate to ASCII: NFKD decomposes accented letters (é→e);
|
|
373
|
+
# characters with no Latin decomposition (δ, Δ) are replaced char-by-char
|
|
374
|
+
# using the last word of their Unicode name (e.g. "GREEK SMALL LETTER DELTA" → "delta").
|
|
375
|
+
parts: list[str] = []
|
|
376
|
+
for ch in unicodedata.normalize("NFKD", title):
|
|
377
|
+
if ch.isascii():
|
|
378
|
+
parts.append(ch)
|
|
379
|
+
elif unicodedata.combining(ch):
|
|
380
|
+
pass # drop combining diacritics
|
|
381
|
+
else:
|
|
382
|
+
name = unicodedata.name(ch, "").lower()
|
|
383
|
+
parts.append(name.split()[-1] if name else "")
|
|
384
|
+
slug = "".join(parts).lower().strip()
|
|
385
|
+
slug = re.sub(r"[^\w\s-]", "", slug)
|
|
386
|
+
slug = re.sub(r"[\s_]+", "-", slug)
|
|
387
|
+
slug = re.sub(r"-+", "-", slug)
|
|
388
|
+
return slug.strip("-")
|
core/storage/__init__.py
ADDED
|
File without changes
|
core/storage/database.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import shutil
|
|
3
|
+
from importlib import resources
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Generator
|
|
6
|
+
|
|
7
|
+
from sqlalchemy import create_engine, event, text
|
|
8
|
+
from sqlalchemy.orm import Session, sessionmaker
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _database_path() -> Path:
|
|
12
|
+
configured_path = os.environ.get("KDG_DB_PATH")
|
|
13
|
+
if configured_path:
|
|
14
|
+
return Path(configured_path).expanduser().resolve()
|
|
15
|
+
return (Path.cwd() / "data" / "know_do_graph.db").resolve()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
DB_PATH = _database_path()
|
|
19
|
+
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
20
|
+
|
|
21
|
+
engine = create_engine(
|
|
22
|
+
f"sqlite:///{DB_PATH}",
|
|
23
|
+
connect_args={"check_same_thread": False},
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Try to load sqlite-vec on every new SQLite connection so vector search works.
|
|
27
|
+
# Optional dep: if sqlite-vec isn't installed, retrieval falls back to keyword-only.
|
|
28
|
+
try:
|
|
29
|
+
import sqlite_vec # type: ignore
|
|
30
|
+
|
|
31
|
+
@event.listens_for(engine, "connect")
|
|
32
|
+
def _load_sqlite_vec(dbapi_conn, _connection_record) -> None:
|
|
33
|
+
try:
|
|
34
|
+
dbapi_conn.enable_load_extension(True)
|
|
35
|
+
sqlite_vec.load(dbapi_conn)
|
|
36
|
+
dbapi_conn.enable_load_extension(False)
|
|
37
|
+
except Exception:
|
|
38
|
+
pass # extension loading not supported on this build; vector search disabled
|
|
39
|
+
except ImportError:
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def install_starter_database(*, force: bool = False) -> Path:
|
|
46
|
+
"""Copy the packaged starter DB to the configured working DB path."""
|
|
47
|
+
if DB_PATH.exists() and not force:
|
|
48
|
+
raise FileExistsError(DB_PATH)
|
|
49
|
+
|
|
50
|
+
packaged_starter = resources.files("core").joinpath("resources/starter.db")
|
|
51
|
+
source_checkout_starter = Path(__file__).resolve().parents[2] / "assets" / "starter.db"
|
|
52
|
+
|
|
53
|
+
if packaged_starter.is_file():
|
|
54
|
+
starter = packaged_starter
|
|
55
|
+
elif source_checkout_starter.is_file():
|
|
56
|
+
starter = source_checkout_starter
|
|
57
|
+
else:
|
|
58
|
+
raise FileNotFoundError("The starter database is not included in this installation.")
|
|
59
|
+
|
|
60
|
+
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
engine.dispose()
|
|
62
|
+
with resources.as_file(starter) as starter_path:
|
|
63
|
+
if starter_path.resolve() != DB_PATH:
|
|
64
|
+
shutil.copy2(starter_path, DB_PATH)
|
|
65
|
+
return DB_PATH
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def get_db() -> Generator[Session, None, None]:
|
|
69
|
+
db = SessionLocal()
|
|
70
|
+
try:
|
|
71
|
+
yield db
|
|
72
|
+
finally:
|
|
73
|
+
db.close()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def init_db() -> None:
|
|
77
|
+
from core.storage.models import Base
|
|
78
|
+
|
|
79
|
+
Base.metadata.create_all(bind=engine)
|
|
80
|
+
|
|
81
|
+
# Migrate: add new columns idempotently (SQLite only supports ADD COLUMN)
|
|
82
|
+
with engine.connect() as conn:
|
|
83
|
+
for col, default in [
|
|
84
|
+
("aliases", "'[]'"),
|
|
85
|
+
("scripts_json", "'[]'"),
|
|
86
|
+
("assets_json", "'[]'"),
|
|
87
|
+
("embedding_hash", "NULL"),
|
|
88
|
+
]:
|
|
89
|
+
try:
|
|
90
|
+
conn.execute(text(f"ALTER TABLE entries ADD COLUMN {col} TEXT DEFAULT {default}"))
|
|
91
|
+
conn.commit()
|
|
92
|
+
except Exception:
|
|
93
|
+
pass # column already exists
|
|
94
|
+
|
|
95
|
+
# Create the sqlite-vec virtual table for entry embeddings (if extension loaded).
|
|
96
|
+
# 384 dims matches sentence-transformers/all-MiniLM-L6-v2 (the default).
|
|
97
|
+
try:
|
|
98
|
+
conn.execute(text(
|
|
99
|
+
"CREATE VIRTUAL TABLE IF NOT EXISTS entry_embeddings USING vec0("
|
|
100
|
+
"entry_id TEXT PRIMARY KEY, embedding FLOAT[384])"
|
|
101
|
+
))
|
|
102
|
+
conn.commit()
|
|
103
|
+
except Exception:
|
|
104
|
+
pass # sqlite-vec not loaded; hybrid retrieval will fall back to keyword
|
core/storage/models.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
|
|
4
|
+
from sqlalchemy import Column, DateTime, Float, String, Text
|
|
5
|
+
from sqlalchemy.orm import DeclarativeBase
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Base(DeclarativeBase):
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class EntryModel(Base):
|
|
13
|
+
__tablename__ = "entries"
|
|
14
|
+
|
|
15
|
+
id = Column(String, primary_key=True)
|
|
16
|
+
title = Column(String, nullable=False, index=True)
|
|
17
|
+
slug = Column(String, nullable=False, index=True, unique=True)
|
|
18
|
+
entry_type = Column(String, nullable=False, default="generic")
|
|
19
|
+
content = Column(Text, default="")
|
|
20
|
+
tags = Column(Text, default="[]") # JSON list
|
|
21
|
+
aliases = Column(Text, default="[]") # JSON list
|
|
22
|
+
metadata_json = Column(Text, default="{}") # JSON object
|
|
23
|
+
internal_refs = Column(Text, default="[]") # JSON list
|
|
24
|
+
scripts_json = Column(Text, default="[]") # JSON list of ScriptAttachment dicts (legacy)
|
|
25
|
+
assets_json = Column(Text, default="[]") # JSON list of NodeAsset dicts (folder-organised)
|
|
26
|
+
embedding_hash = Column(String, nullable=True) # sha1 of last-embedded text; null = needs embedding
|
|
27
|
+
created_at = Column(DateTime, default=datetime.utcnow)
|
|
28
|
+
updated_at = Column(DateTime, default=datetime.utcnow)
|
|
29
|
+
|
|
30
|
+
def to_dict(self) -> dict:
|
|
31
|
+
return {
|
|
32
|
+
"id": self.id,
|
|
33
|
+
"title": self.title,
|
|
34
|
+
"slug": self.slug,
|
|
35
|
+
"entry_type": self.entry_type,
|
|
36
|
+
"content": self.content,
|
|
37
|
+
"tags": json.loads(self.tags or "[]"),
|
|
38
|
+
"aliases": json.loads(self.aliases or "[]"),
|
|
39
|
+
"metadata": json.loads(self.metadata_json or "{}"),
|
|
40
|
+
"internal_refs": json.loads(self.internal_refs or "[]"),
|
|
41
|
+
"scripts": json.loads(self.scripts_json or "[]"),
|
|
42
|
+
"assets": json.loads(self.assets_json or "[]"),
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class EdgeModel(Base):
|
|
47
|
+
__tablename__ = "edges"
|
|
48
|
+
|
|
49
|
+
id = Column(String, primary_key=True)
|
|
50
|
+
source_id = Column(String, nullable=False, index=True)
|
|
51
|
+
target_id = Column(String, nullable=False, index=True)
|
|
52
|
+
relation = Column(String, nullable=False, default="wikilink")
|
|
53
|
+
weight = Column(Float, default=1.0)
|
|
54
|
+
metadata_json = Column(Text, default="{}")
|
|
55
|
+
created_at = Column(DateTime, default=datetime.utcnow)
|
|
56
|
+
|
|
57
|
+
def to_dict(self) -> dict:
|
|
58
|
+
return {
|
|
59
|
+
"id": self.id,
|
|
60
|
+
"source_id": self.source_id,
|
|
61
|
+
"target_id": self.target_id,
|
|
62
|
+
"relation": self.relation,
|
|
63
|
+
"weight": self.weight,
|
|
64
|
+
"metadata": json.loads(self.metadata_json or "{}"),
|
|
65
|
+
"created_at": self.created_at.isoformat() if self.created_at else None,
|
|
66
|
+
}
|