arkaos 2.90.0 → 2.91.0
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.
- package/VERSION +1 -1
- package/core/knowledge/__pycache__/vector_store.cpython-313.pyc +0 -0
- package/core/knowledge/vector_store.py +93 -30
- package/core/personas/__pycache__/obsidian_store.cpython-313.pyc +0 -0
- package/core/personas/obsidian_store.py +276 -0
- package/dashboard/app/pages/knowledge.vue +19 -5
- package/dashboard/app/types/index.d.ts +4 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/__pycache__/dashboard-api.cpython-313.pyc +0 -0
- package/scripts/dashboard-api.py +76 -6
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2.
|
|
1
|
+
2.91.0
|
|
Binary file
|
|
@@ -13,24 +13,81 @@ from typing import Any, Optional
|
|
|
13
13
|
from core.knowledge.embedder import embed, embed_batch, EMBEDDING_DIMS
|
|
14
14
|
|
|
15
15
|
|
|
16
|
+
_VEC_UNAVAILABLE_REASON: str = "" # set by _load_vec on failure
|
|
17
|
+
|
|
18
|
+
|
|
16
19
|
def _load_vec(db: sqlite3.Connection) -> bool:
|
|
17
|
-
"""Try to load sqlite-vec extension.
|
|
20
|
+
"""Try to load sqlite-vec extension.
|
|
21
|
+
|
|
22
|
+
PR73 v2.91.0 — failure reason is now captured in a module-level
|
|
23
|
+
string so the dashboard / /api/knowledge/stats can surface why
|
|
24
|
+
vector search is unavailable instead of just "Unavailable".
|
|
25
|
+
Common reasons:
|
|
26
|
+
- sqlite-vec not installed (pip install sqlite-vec)
|
|
27
|
+
- Python sqlite3 built without enable_load_extension (rare on
|
|
28
|
+
homebrew Python; common on system Python on some distros)
|
|
29
|
+
"""
|
|
30
|
+
global _VEC_UNAVAILABLE_REASON
|
|
18
31
|
try:
|
|
19
32
|
db.enable_load_extension(True)
|
|
20
|
-
|
|
33
|
+
except AttributeError:
|
|
34
|
+
_VEC_UNAVAILABLE_REASON = (
|
|
35
|
+
"Python sqlite3 was built without extension loading support. "
|
|
36
|
+
"On macOS, reinstall via Homebrew Python: brew install python."
|
|
37
|
+
)
|
|
38
|
+
return False
|
|
39
|
+
except sqlite3.OperationalError as exc:
|
|
40
|
+
_VEC_UNAVAILABLE_REASON = f"sqlite3 refused extension loading: {exc}"
|
|
41
|
+
return False
|
|
42
|
+
try:
|
|
43
|
+
import sqlite_vec # noqa: PLC0415
|
|
44
|
+
except ImportError:
|
|
45
|
+
_VEC_UNAVAILABLE_REASON = (
|
|
46
|
+
"sqlite-vec package missing. Install with: "
|
|
47
|
+
"pip install sqlite-vec"
|
|
48
|
+
)
|
|
49
|
+
return False
|
|
50
|
+
try:
|
|
21
51
|
sqlite_vec.load(db)
|
|
52
|
+
_VEC_UNAVAILABLE_REASON = ""
|
|
22
53
|
return True
|
|
23
|
-
except
|
|
54
|
+
except Exception as exc: # noqa: BLE001
|
|
55
|
+
_VEC_UNAVAILABLE_REASON = f"sqlite_vec.load failed: {exc}"
|
|
24
56
|
return False
|
|
25
57
|
|
|
26
58
|
|
|
59
|
+
def vec_unavailable_reason() -> str:
|
|
60
|
+
"""Public accessor for the last vec-load failure reason."""
|
|
61
|
+
return _VEC_UNAVAILABLE_REASON
|
|
62
|
+
|
|
63
|
+
|
|
27
64
|
class VectorStore:
|
|
28
|
-
"""SQLite-vec backed vector store for knowledge retrieval.
|
|
65
|
+
"""SQLite-vec backed vector store for knowledge retrieval.
|
|
66
|
+
|
|
67
|
+
PR73 v2.91.0 — connection is opened with ``check_same_thread=False``
|
|
68
|
+
so background ingest workers (knowledge_ingest, knowledge_ingest_bulk
|
|
69
|
+
in scripts/dashboard-api.py) can reuse a long-lived FastAPI-scoped
|
|
70
|
+
store instance without hitting ``sqlite3.ProgrammingError: SQLite
|
|
71
|
+
objects created in a thread can only be used in that same thread``.
|
|
72
|
+
Concurrent writes are serialised via ``_write_lock``; SQLite's WAL
|
|
73
|
+
journal_mode lets readers continue while a writer holds the lock.
|
|
74
|
+
"""
|
|
29
75
|
|
|
30
76
|
def __init__(self, db_path: str | Path = ":memory:") -> None:
|
|
77
|
+
import threading
|
|
31
78
|
self._db_path = str(db_path)
|
|
32
|
-
|
|
79
|
+
# check_same_thread=False — see class docstring.
|
|
80
|
+
self._db = sqlite3.connect(self._db_path, check_same_thread=False)
|
|
33
81
|
self._db.row_factory = sqlite3.Row
|
|
82
|
+
# WAL gives concurrent readers + a single writer at the engine
|
|
83
|
+
# level. The Python lock below serialises our application-level
|
|
84
|
+
# writes per VectorStore instance.
|
|
85
|
+
try:
|
|
86
|
+
self._db.execute("PRAGMA journal_mode=WAL")
|
|
87
|
+
except sqlite3.OperationalError:
|
|
88
|
+
# in-memory DBs don't support WAL — harmless.
|
|
89
|
+
pass
|
|
90
|
+
self._write_lock = threading.Lock()
|
|
34
91
|
self._vec_available = _load_vec(self._db)
|
|
35
92
|
self._init_schema()
|
|
36
93
|
|
|
@@ -102,26 +159,31 @@ class VectorStore:
|
|
|
102
159
|
meta_json = json.dumps(metadata or {})
|
|
103
160
|
count = 0
|
|
104
161
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
(cursor.lastrowid, emb_blob),
|
|
162
|
+
# PR73 v2.91.0 — serialise writes from background ingest threads.
|
|
163
|
+
# check_same_thread=False on the connection lets the threads use
|
|
164
|
+
# `self._db`; the lock ensures only one writer at a time so
|
|
165
|
+
# cursor lastrowid stays consistent.
|
|
166
|
+
with self._write_lock:
|
|
167
|
+
for i, text in enumerate(texts):
|
|
168
|
+
heading = headings[i] if headings and i < len(headings) else ""
|
|
169
|
+
emb_blob = None
|
|
170
|
+
|
|
171
|
+
if embeddings and i < len(embeddings):
|
|
172
|
+
emb_blob = _vec_to_blob(embeddings[i])
|
|
173
|
+
|
|
174
|
+
cursor = self._db.execute(
|
|
175
|
+
"INSERT INTO chunks (text, heading, source, file_hash, metadata, embedding) VALUES (?, ?, ?, ?, ?, ?)",
|
|
176
|
+
(text, heading, source, file_hash, meta_json, emb_blob),
|
|
121
177
|
)
|
|
122
|
-
count += 1
|
|
123
178
|
|
|
124
|
-
|
|
179
|
+
if self._vec_available and emb_blob:
|
|
180
|
+
self._db.execute(
|
|
181
|
+
"INSERT INTO vec_chunks (rowid, embedding) VALUES (?, ?)",
|
|
182
|
+
(cursor.lastrowid, emb_blob),
|
|
183
|
+
)
|
|
184
|
+
count += 1
|
|
185
|
+
|
|
186
|
+
self._db.commit()
|
|
125
187
|
return count
|
|
126
188
|
|
|
127
189
|
def search(self, query: str, top_k: int = 5) -> list[dict]:
|
|
@@ -201,13 +263,14 @@ class VectorStore:
|
|
|
201
263
|
|
|
202
264
|
def remove_file(self, source: str) -> int:
|
|
203
265
|
"""Remove all chunks from a source file."""
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
266
|
+
with self._write_lock:
|
|
267
|
+
if self._vec_available:
|
|
268
|
+
rows = self._db.execute("SELECT id FROM chunks WHERE source = ?", (source,)).fetchall()
|
|
269
|
+
for r in rows:
|
|
270
|
+
self._db.execute("DELETE FROM vec_chunks WHERE rowid = ?", (r["id"],))
|
|
271
|
+
deleted = self._db.execute("DELETE FROM chunks WHERE source = ?", (source,)).rowcount
|
|
272
|
+
self._db.commit()
|
|
273
|
+
return deleted
|
|
211
274
|
|
|
212
275
|
def get_stats(self) -> dict:
|
|
213
276
|
"""Get store statistics."""
|
|
Binary file
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""Obsidian-backed persona store (PR73 v2.91.0).
|
|
2
|
+
|
|
3
|
+
Reads personas from ``<vaultPath>/Personas/*.md`` and writes new
|
|
4
|
+
personas back to the same folder. The vault path comes from
|
|
5
|
+
``~/.arkaos/profile.json::vaultPath``; when that's empty / missing,
|
|
6
|
+
the store gracefully degrades to a no-op (the JSON store keeps
|
|
7
|
+
working).
|
|
8
|
+
|
|
9
|
+
Per the operator's instruction: "personas podiamos ir buscar a lista
|
|
10
|
+
aqui o que esta no obsidian acho que é o mais sensato e quando criamos
|
|
11
|
+
guardamos no obsidian tambem". This module implements both directions.
|
|
12
|
+
|
|
13
|
+
Schema (frontmatter):
|
|
14
|
+
type: persona
|
|
15
|
+
name: <full name>
|
|
16
|
+
source: <one-line summary>
|
|
17
|
+
title: <role label>
|
|
18
|
+
tagline: <essence>
|
|
19
|
+
mbti: <4-letter>
|
|
20
|
+
disc: { primary, secondary }
|
|
21
|
+
enneagram: { type, wing }
|
|
22
|
+
big_five: { openness, ... }
|
|
23
|
+
mental_models: [list]
|
|
24
|
+
expertise: [list] # legacy alias for expertise_domains
|
|
25
|
+
expertise_domains: [list]
|
|
26
|
+
frameworks: [list]
|
|
27
|
+
key_quotes: [list]
|
|
28
|
+
communication: { tone, vocabulary_level, preferred_format, avoid }
|
|
29
|
+
|
|
30
|
+
Unknown frontmatter keys are ignored. Body markdown is preserved
|
|
31
|
+
when reading but not re-injected into the Persona model (the model
|
|
32
|
+
doesn't carry free-form notes).
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import re
|
|
38
|
+
import uuid
|
|
39
|
+
from datetime import datetime, timezone
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
from typing import Any, Iterable
|
|
42
|
+
|
|
43
|
+
from core.personas.schema import (
|
|
44
|
+
Persona,
|
|
45
|
+
PersonaBigFive,
|
|
46
|
+
PersonaCommunication,
|
|
47
|
+
PersonaDISC,
|
|
48
|
+
PersonaEnneagram,
|
|
49
|
+
)
|
|
50
|
+
from core.profile import ProfileManager
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
_FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n?(.*)\Z", re.DOTALL)
|
|
54
|
+
_PERSONAS_SUBDIR = "Personas"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
_UNSET = object()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ObsidianPersonaStore:
|
|
61
|
+
"""Read / write personas as Markdown files in an Obsidian vault."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, vault_path: Path | None | object = _UNSET) -> None:
|
|
64
|
+
# Distinguish "not passed" (auto-resolve from profile) from
|
|
65
|
+
# "passed as None" (explicitly no vault — tests use this).
|
|
66
|
+
if vault_path is _UNSET:
|
|
67
|
+
self._vault_path = self._resolve_vault_path()
|
|
68
|
+
else:
|
|
69
|
+
self._vault_path = vault_path # type: ignore[assignment]
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def available(self) -> bool:
|
|
73
|
+
return bool(self._vault_path and self.personas_dir.exists())
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def personas_dir(self) -> Path:
|
|
77
|
+
return (self._vault_path or Path()) / _PERSONAS_SUBDIR
|
|
78
|
+
|
|
79
|
+
@staticmethod
|
|
80
|
+
def _resolve_vault_path() -> Path | None:
|
|
81
|
+
try:
|
|
82
|
+
profile = ProfileManager().read()
|
|
83
|
+
except Exception: # noqa: BLE001
|
|
84
|
+
return None
|
|
85
|
+
if not profile.vaultPath:
|
|
86
|
+
return None
|
|
87
|
+
path = Path(profile.vaultPath).expanduser()
|
|
88
|
+
return path if path.exists() else None
|
|
89
|
+
|
|
90
|
+
# ─── Read ────────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
def list_all(self) -> list[Persona]:
|
|
93
|
+
"""Return every parseable persona in <vault>/Personas/."""
|
|
94
|
+
if not self.available:
|
|
95
|
+
return []
|
|
96
|
+
out: list[Persona] = []
|
|
97
|
+
for md_path in sorted(self.personas_dir.glob("*.md")):
|
|
98
|
+
persona = self._read_file(md_path)
|
|
99
|
+
if persona is not None:
|
|
100
|
+
out.append(persona)
|
|
101
|
+
return out
|
|
102
|
+
|
|
103
|
+
def _read_file(self, md_path: Path) -> Persona | None:
|
|
104
|
+
try:
|
|
105
|
+
text = md_path.read_text(encoding="utf-8", errors="replace")
|
|
106
|
+
except OSError:
|
|
107
|
+
return None
|
|
108
|
+
fm = self._parse_frontmatter(text)
|
|
109
|
+
if not fm:
|
|
110
|
+
return None
|
|
111
|
+
# MOC files etc. can also live in this folder; skip anything not
|
|
112
|
+
# explicitly typed as persona.
|
|
113
|
+
if str(fm.get("type", "")).lower() != "persona":
|
|
114
|
+
return None
|
|
115
|
+
return self._frontmatter_to_persona(fm, md_path)
|
|
116
|
+
|
|
117
|
+
@staticmethod
|
|
118
|
+
def _parse_frontmatter(text: str) -> dict[str, Any] | None:
|
|
119
|
+
match = _FRONTMATTER_RE.match(text)
|
|
120
|
+
if not match:
|
|
121
|
+
return None
|
|
122
|
+
try:
|
|
123
|
+
import yaml # noqa: PLC0415
|
|
124
|
+
except ImportError:
|
|
125
|
+
return None
|
|
126
|
+
try:
|
|
127
|
+
data = yaml.safe_load(match.group(1)) or {}
|
|
128
|
+
except Exception: # noqa: BLE001 — yaml parsers raise many shapes
|
|
129
|
+
return None
|
|
130
|
+
return data if isinstance(data, dict) else None
|
|
131
|
+
|
|
132
|
+
@staticmethod
|
|
133
|
+
def _frontmatter_to_persona(fm: dict, md_path: Path) -> Persona:
|
|
134
|
+
name = str(fm.get("name") or md_path.stem)
|
|
135
|
+
disc_fm = fm.get("disc") if isinstance(fm.get("disc"), dict) else {}
|
|
136
|
+
enneagram_fm = fm.get("enneagram") if isinstance(fm.get("enneagram"), dict) else {}
|
|
137
|
+
big_five_fm = fm.get("big_five") if isinstance(fm.get("big_five"), dict) else {}
|
|
138
|
+
comm_fm = fm.get("communication") if isinstance(fm.get("communication"), dict) else {}
|
|
139
|
+
expertise = _as_str_list(fm.get("expertise_domains") or fm.get("expertise"))
|
|
140
|
+
|
|
141
|
+
return Persona(
|
|
142
|
+
id=ObsidianPersonaStore._slug_from_name(name),
|
|
143
|
+
name=name,
|
|
144
|
+
title=str(fm.get("title") or ""),
|
|
145
|
+
tagline=str(fm.get("tagline") or ""),
|
|
146
|
+
source=str(fm.get("source") or name),
|
|
147
|
+
disc=PersonaDISC(**_filter_known(PersonaDISC, disc_fm)),
|
|
148
|
+
enneagram=PersonaEnneagram(**_filter_known(PersonaEnneagram, enneagram_fm)),
|
|
149
|
+
big_five=PersonaBigFive(**_filter_known(PersonaBigFive, big_five_fm)),
|
|
150
|
+
mbti=str(fm.get("mbti") or "INTJ"),
|
|
151
|
+
mental_models=_as_str_list(fm.get("mental_models")),
|
|
152
|
+
expertise_domains=expertise,
|
|
153
|
+
frameworks=_as_str_list(fm.get("frameworks")),
|
|
154
|
+
key_quotes=_as_str_list(fm.get("key_quotes")),
|
|
155
|
+
communication=PersonaCommunication(
|
|
156
|
+
**_filter_known(PersonaCommunication, comm_fm),
|
|
157
|
+
),
|
|
158
|
+
created_at=str(fm.get("created_at") or ""),
|
|
159
|
+
updated_at=str(fm.get("updated_at") or ""),
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
@staticmethod
|
|
163
|
+
def _slug_from_name(name: str) -> str:
|
|
164
|
+
return (
|
|
165
|
+
name.lower()
|
|
166
|
+
.replace(" ", "-")
|
|
167
|
+
.replace(".", "")
|
|
168
|
+
.replace("'", "")
|
|
169
|
+
) or str(uuid.uuid4())
|
|
170
|
+
|
|
171
|
+
# ─── Write ───────────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
def write(self, persona: Persona) -> Path | None:
|
|
174
|
+
"""Persist a persona as <vault>/Personas/<name>.md.
|
|
175
|
+
|
|
176
|
+
Returns the path written, or None when the vault isn't
|
|
177
|
+
configured. Existing files are overwritten — the operator
|
|
178
|
+
is the source of truth.
|
|
179
|
+
"""
|
|
180
|
+
if not self._vault_path:
|
|
181
|
+
return None
|
|
182
|
+
self.personas_dir.mkdir(parents=True, exist_ok=True)
|
|
183
|
+
target = self.personas_dir / f"{persona.name}.md"
|
|
184
|
+
body = self._render(persona)
|
|
185
|
+
tmp = target.with_suffix(target.suffix + ".tmp")
|
|
186
|
+
try:
|
|
187
|
+
tmp.write_text(body, encoding="utf-8")
|
|
188
|
+
tmp.replace(target)
|
|
189
|
+
except OSError:
|
|
190
|
+
try:
|
|
191
|
+
tmp.unlink()
|
|
192
|
+
except FileNotFoundError:
|
|
193
|
+
pass
|
|
194
|
+
return None
|
|
195
|
+
return target
|
|
196
|
+
|
|
197
|
+
@staticmethod
|
|
198
|
+
def _render(persona: Persona) -> str:
|
|
199
|
+
try:
|
|
200
|
+
import yaml # noqa: PLC0415
|
|
201
|
+
except ImportError:
|
|
202
|
+
yaml = None # type: ignore[assignment]
|
|
203
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
204
|
+
frontmatter = {
|
|
205
|
+
"type": "persona",
|
|
206
|
+
"name": persona.name,
|
|
207
|
+
"source": persona.source or persona.name,
|
|
208
|
+
"title": persona.title,
|
|
209
|
+
"tagline": persona.tagline,
|
|
210
|
+
"mbti": persona.mbti,
|
|
211
|
+
"disc": {
|
|
212
|
+
"primary": persona.disc.primary,
|
|
213
|
+
"secondary": persona.disc.secondary,
|
|
214
|
+
},
|
|
215
|
+
"enneagram": {
|
|
216
|
+
"type": persona.enneagram.type,
|
|
217
|
+
"wing": persona.enneagram.wing,
|
|
218
|
+
},
|
|
219
|
+
"big_five": {
|
|
220
|
+
"openness": persona.big_five.openness,
|
|
221
|
+
"conscientiousness": persona.big_five.conscientiousness,
|
|
222
|
+
"extraversion": persona.big_five.extraversion,
|
|
223
|
+
"agreeableness": persona.big_five.agreeableness,
|
|
224
|
+
"neuroticism": persona.big_five.neuroticism,
|
|
225
|
+
},
|
|
226
|
+
"mental_models": list(persona.mental_models),
|
|
227
|
+
"expertise_domains": list(persona.expertise_domains),
|
|
228
|
+
"frameworks": list(persona.frameworks),
|
|
229
|
+
"key_quotes": list(persona.key_quotes),
|
|
230
|
+
"communication": {
|
|
231
|
+
"tone": persona.communication.tone,
|
|
232
|
+
"vocabulary_level": persona.communication.vocabulary_level,
|
|
233
|
+
"preferred_format": persona.communication.preferred_format,
|
|
234
|
+
"avoid": list(persona.communication.avoid),
|
|
235
|
+
},
|
|
236
|
+
"created_at": persona.created_at or now,
|
|
237
|
+
"updated_at": now,
|
|
238
|
+
}
|
|
239
|
+
if yaml is not None:
|
|
240
|
+
fm_block = yaml.safe_dump(
|
|
241
|
+
frontmatter,
|
|
242
|
+
sort_keys=False,
|
|
243
|
+
allow_unicode=True,
|
|
244
|
+
default_flow_style=False,
|
|
245
|
+
)
|
|
246
|
+
else:
|
|
247
|
+
fm_block = "\n".join(f"{k}: {v}" for k, v in frontmatter.items()) + "\n"
|
|
248
|
+
body = "# " + persona.name + "\n\n"
|
|
249
|
+
if persona.tagline:
|
|
250
|
+
body += f"> {persona.tagline}\n\n"
|
|
251
|
+
body += "## Source\n\n" + (persona.source or persona.name) + "\n\n"
|
|
252
|
+
if persona.mental_models:
|
|
253
|
+
body += "## Mental Models\n\n"
|
|
254
|
+
body += "\n".join(f"- {m}" for m in persona.mental_models) + "\n\n"
|
|
255
|
+
if persona.key_quotes:
|
|
256
|
+
body += "## Key Quotes\n\n"
|
|
257
|
+
body += "\n".join(f"> {q}" for q in persona.key_quotes) + "\n\n"
|
|
258
|
+
return f"---\n{fm_block}---\n\n{body}"
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# ─── Helpers ────────────────────────────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _as_str_list(value: Any) -> list[str]:
|
|
265
|
+
if not isinstance(value, list):
|
|
266
|
+
return []
|
|
267
|
+
return [str(item) for item in value if isinstance(item, (str, int, float))]
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _filter_known(model: type, data: dict) -> dict:
|
|
271
|
+
"""Drop keys the Pydantic model doesn't declare so unknown
|
|
272
|
+
frontmatter keys (e.g. older schema variants) don't crash."""
|
|
273
|
+
if not isinstance(data, dict):
|
|
274
|
+
return {}
|
|
275
|
+
known: Iterable[str] = model.model_fields.keys() # type: ignore[attr-defined]
|
|
276
|
+
return {k: v for k, v in data.items() if k in known}
|
|
@@ -325,6 +325,13 @@ function formatScore(score: number): string {
|
|
|
325
325
|
return `${(score * 100).toFixed(0)}%`
|
|
326
326
|
}
|
|
327
327
|
|
|
328
|
+
// PR73 v2.91.0 — `vec_available` is the canonical PR47-era flag from
|
|
329
|
+
// the new VectorStore; `vss_available` is the legacy field name from
|
|
330
|
+
// earlier sqlite-vss builds. Treat either as "active".
|
|
331
|
+
const vectorSearchActive = computed(() =>
|
|
332
|
+
Boolean(stats.value?.vec_available || stats.value?.vss_available),
|
|
333
|
+
)
|
|
334
|
+
|
|
328
335
|
// PR71 v2.88.0 — delete all chunks from a given source.
|
|
329
336
|
|
|
330
337
|
const deletingSource = ref<string | null>(null)
|
|
@@ -419,9 +426,9 @@ function escapeRegex(value: string): string {
|
|
|
419
426
|
</template>
|
|
420
427
|
<template #trailing>
|
|
421
428
|
<UBadge
|
|
422
|
-
v-if="stats?.vss_available !== undefined"
|
|
423
|
-
:label="
|
|
424
|
-
:color="
|
|
429
|
+
v-if="stats?.vec_available !== undefined || stats?.vss_available !== undefined"
|
|
430
|
+
:label="vectorSearchActive ? 'Vector Active' : 'Vector Off'"
|
|
431
|
+
:color="vectorSearchActive ? 'success' : 'warning'"
|
|
425
432
|
variant="subtle"
|
|
426
433
|
/>
|
|
427
434
|
</template>
|
|
@@ -768,12 +775,19 @@ function escapeRegex(value: string): string {
|
|
|
768
775
|
</div>
|
|
769
776
|
<div class="rounded-lg border border-default p-4 text-center">
|
|
770
777
|
<UBadge
|
|
771
|
-
:label="
|
|
772
|
-
:color="
|
|
778
|
+
:label="vectorSearchActive ? 'Active' : 'Unavailable'"
|
|
779
|
+
:color="vectorSearchActive ? 'success' : 'warning'"
|
|
773
780
|
variant="subtle"
|
|
774
781
|
size="sm"
|
|
775
782
|
/>
|
|
776
783
|
<p class="text-xs text-muted mt-1">Vector Search</p>
|
|
784
|
+
<p
|
|
785
|
+
v-if="!vectorSearchActive && stats?.vec_unavailable_reason"
|
|
786
|
+
class="text-xs text-yellow-400 mt-2 text-left"
|
|
787
|
+
:title="stats.vec_unavailable_reason"
|
|
788
|
+
>
|
|
789
|
+
{{ stats.vec_unavailable_reason }}
|
|
790
|
+
</p>
|
|
777
791
|
</div>
|
|
778
792
|
</div>
|
|
779
793
|
|
|
@@ -128,6 +128,10 @@ export interface KnowledgeStats {
|
|
|
128
128
|
total_chunks: number
|
|
129
129
|
total_files: number
|
|
130
130
|
vss_available?: boolean
|
|
131
|
+
// PR73 v2.91.0 — vec_available + reason surfaced by /api/knowledge/stats
|
|
132
|
+
vec_available?: boolean
|
|
133
|
+
vec_unavailable_reason?: string
|
|
134
|
+
indexed?: boolean
|
|
131
135
|
areas?: {
|
|
132
136
|
name: string
|
|
133
137
|
chunks: number
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
Binary file
|
package/scripts/dashboard-api.py
CHANGED
|
@@ -595,11 +595,29 @@ def task_detail(task_id: str):
|
|
|
595
595
|
|
|
596
596
|
@app.get("/api/knowledge/stats")
|
|
597
597
|
def knowledge_stats():
|
|
598
|
+
"""PR73 v2.91.0 — vec_unavailable_reason surfaced so the dashboard
|
|
599
|
+
can show *why* vector search is offline instead of just a generic
|
|
600
|
+
"Unavailable" badge."""
|
|
598
601
|
store = _get_vector_store()
|
|
599
602
|
if not store:
|
|
600
|
-
return {
|
|
603
|
+
return {
|
|
604
|
+
"total_chunks": 0,
|
|
605
|
+
"total_files": 0,
|
|
606
|
+
"vss_available": False,
|
|
607
|
+
"vec_available": False,
|
|
608
|
+
"vec_unavailable_reason": "Vector store could not be opened.",
|
|
609
|
+
"indexed": False,
|
|
610
|
+
}
|
|
601
611
|
stats = store.get_stats()
|
|
602
612
|
stats["indexed"] = stats["total_chunks"] > 0
|
|
613
|
+
if not stats.get("vec_available", False):
|
|
614
|
+
try:
|
|
615
|
+
from core.knowledge.vector_store import vec_unavailable_reason
|
|
616
|
+
stats["vec_unavailable_reason"] = vec_unavailable_reason()
|
|
617
|
+
except Exception:
|
|
618
|
+
stats["vec_unavailable_reason"] = "unknown"
|
|
619
|
+
else:
|
|
620
|
+
stats["vec_unavailable_reason"] = ""
|
|
603
621
|
return stats
|
|
604
622
|
|
|
605
623
|
|
|
@@ -722,11 +740,47 @@ def _get_persona_manager():
|
|
|
722
740
|
|
|
723
741
|
@app.get("/api/personas")
|
|
724
742
|
def personas_list():
|
|
743
|
+
"""PR73 v2.91.0 — merges JSON-store personas with the Obsidian
|
|
744
|
+
vault's ``<vaultPath>/Personas/*.md`` files. Obsidian is the
|
|
745
|
+
source of truth: when the same persona exists in both places
|
|
746
|
+
(matched by name/slug), the vault entry wins.
|
|
747
|
+
"""
|
|
748
|
+
by_id: dict[str, dict] = {}
|
|
749
|
+
|
|
750
|
+
# JSON store first (in-memory copy is fast, vault read may be slow)
|
|
725
751
|
mgr = _get_persona_manager()
|
|
726
|
-
if
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
752
|
+
if mgr:
|
|
753
|
+
for p in mgr.list_all():
|
|
754
|
+
payload = p.model_dump()
|
|
755
|
+
by_id[payload["id"]] = payload
|
|
756
|
+
|
|
757
|
+
# Obsidian vault — overwrites duplicates so the vault wins.
|
|
758
|
+
try:
|
|
759
|
+
from core.personas.obsidian_store import ObsidianPersonaStore
|
|
760
|
+
ob_store = ObsidianPersonaStore()
|
|
761
|
+
if ob_store.available:
|
|
762
|
+
for p in ob_store.list_all():
|
|
763
|
+
payload = p.model_dump()
|
|
764
|
+
payload["_source_store"] = "obsidian"
|
|
765
|
+
by_id[payload["id"]] = payload
|
|
766
|
+
except Exception:
|
|
767
|
+
pass
|
|
768
|
+
|
|
769
|
+
personas = list(by_id.values())
|
|
770
|
+
personas.sort(key=lambda p: p.get("name", "").lower())
|
|
771
|
+
return {
|
|
772
|
+
"personas": personas,
|
|
773
|
+
"total": len(personas),
|
|
774
|
+
"obsidian_available": _obsidian_store_available(),
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def _obsidian_store_available() -> bool:
|
|
779
|
+
try:
|
|
780
|
+
from core.personas.obsidian_store import ObsidianPersonaStore
|
|
781
|
+
return ObsidianPersonaStore().available
|
|
782
|
+
except Exception:
|
|
783
|
+
return False
|
|
730
784
|
|
|
731
785
|
|
|
732
786
|
@app.get("/api/personas/{persona_id}")
|
|
@@ -772,7 +826,23 @@ def persona_create(body: dict):
|
|
|
772
826
|
)
|
|
773
827
|
|
|
774
828
|
mgr.create(persona)
|
|
775
|
-
|
|
829
|
+
|
|
830
|
+
# PR73 v2.91.0 — also write to the Obsidian vault so the persona
|
|
831
|
+
# survives outside the JSON store and is browsable in Obsidian.
|
|
832
|
+
# Best-effort: vault unavailable / write failure does not abort
|
|
833
|
+
# the JSON-side success.
|
|
834
|
+
obsidian_path: str | None = None
|
|
835
|
+
try:
|
|
836
|
+
from core.personas.obsidian_store import ObsidianPersonaStore
|
|
837
|
+
ob_store = ObsidianPersonaStore()
|
|
838
|
+
if ob_store.available or ob_store._vault_path is not None:
|
|
839
|
+
written = ob_store.write(persona)
|
|
840
|
+
if written is not None:
|
|
841
|
+
obsidian_path = str(written)
|
|
842
|
+
except Exception:
|
|
843
|
+
pass
|
|
844
|
+
|
|
845
|
+
return {"id": persona.id, "created": True, "obsidian_path": obsidian_path}
|
|
776
846
|
|
|
777
847
|
|
|
778
848
|
@app.post("/api/personas/{persona_id}/clone")
|