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.
Files changed (63) hide show
  1. agents/__init__.py +0 -0
  2. agents/extraction_agent/__init__.py +0 -0
  3. agents/extraction_agent/agent.py +170 -0
  4. agents/graph_agent/__init__.py +5 -0
  5. agents/graph_agent/agent.py +373 -0
  6. agents/graph_agent/tools.py +2106 -0
  7. agents/maintenance_agent/__init__.py +0 -0
  8. agents/maintenance_agent/agent.py +283 -0
  9. agents/orchestrator/__init__.py +0 -0
  10. agents/orchestrator/agent.py +217 -0
  11. agents/review_agent/__init__.py +0 -0
  12. agents/review_agent/agent.py +188 -0
  13. agents/review_agent/tools.py +472 -0
  14. api/__init__.py +0 -0
  15. api/main.py +136 -0
  16. api/routes/__init__.py +0 -0
  17. api/routes/agent.py +81 -0
  18. api/routes/entries.py +411 -0
  19. api/routes/graph.py +132 -0
  20. api/routes/mem.py +179 -0
  21. api/routes/remote.py +815 -0
  22. api/routes/remote_sync.py +230 -0
  23. api/routes/retrieve.py +88 -0
  24. core/__init__.py +0 -0
  25. core/app_state.py +9 -0
  26. core/events.py +84 -0
  27. core/extraction/__init__.py +0 -0
  28. core/extraction/wikilink_parser.py +48 -0
  29. core/graph/__init__.py +0 -0
  30. core/graph/graph.py +204 -0
  31. core/memory/__init__.py +0 -0
  32. core/memory/memgraph.py +458 -0
  33. core/resources/starter.db +0 -0
  34. core/retrieval/__init__.py +0 -0
  35. core/retrieval/embedder.py +122 -0
  36. core/retrieval/fusion.py +52 -0
  37. core/retrieval/progressive.py +399 -0
  38. core/retrieval/retrieval.py +346 -0
  39. core/retrieval/vector_store.py +91 -0
  40. core/schemas/__init__.py +0 -0
  41. core/schemas/edge.py +46 -0
  42. core/schemas/entry.py +388 -0
  43. core/storage/__init__.py +0 -0
  44. core/storage/database.py +104 -0
  45. core/storage/models.py +66 -0
  46. core/storage/repository.py +243 -0
  47. core/sync/__init__.py +20 -0
  48. core/sync/autolink.py +301 -0
  49. core/sync/db_merge.py +297 -0
  50. core/sync/db_watcher.py +84 -0
  51. core/sync/remote_sync.py +345 -0
  52. examples/__init__.py +0 -0
  53. examples/example_entries.py +206 -0
  54. examples/pymatgen_interface_examples.py +811 -0
  55. frontend/dist/assets/index-BLfo7ZZu.css +1 -0
  56. frontend/dist/assets/index-G-mYbZ9R.js +83 -0
  57. frontend/dist/assets/index-G-mYbZ9R.js.map +1 -0
  58. frontend/dist/index.html +92 -0
  59. know_do_graph-0.1.0.dist-info/METADATA +765 -0
  60. know_do_graph-0.1.0.dist-info/RECORD +63 -0
  61. know_do_graph-0.1.0.dist-info/WHEEL +4 -0
  62. know_do_graph-0.1.0.dist-info/entry_points.txt +2 -0
  63. 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("-")
File without changes
@@ -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
+ }