jeles 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.
- jeles/__init__.py +77 -0
- jeles/corpus.py +359 -0
- jeles/corpus_server.py +123 -0
- jeles/persona/__init__.py +6 -0
- jeles/persona/compiler.py +164 -0
- jeles/persona/jeles_persona.json +132 -0
- jeles/reactions/__init__.py +20 -0
- jeles/reactions/conflict_scan.py +261 -0
- jeles/reactions/search_adapter.py +160 -0
- jeles/willow_mcp_client.py +216 -0
- jeles-0.1.0.dist-info/METADATA +158 -0
- jeles-0.1.0.dist-info/RECORD +16 -0
- jeles-0.1.0.dist-info/WHEEL +4 -0
- jeles-0.1.0.dist-info/entry_points.txt +2 -0
- jeles-0.1.0.dist-info/licenses/LICENSE +202 -0
- jeles-0.1.0.dist-info/licenses/NOTICE +2 -0
jeles/__init__.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Jeles — the verified-corpus organ and its canonical persona.
|
|
2
|
+
|
|
3
|
+
A small, dependency-light package extracted from the Ask Jeles app so the
|
|
4
|
+
verified-nugget corpus, its standalone MCP server, the best-effort fleet
|
|
5
|
+
gap-forwarder, and the canonical Jeles persona can be consumed by any host.
|
|
6
|
+
|
|
7
|
+
Public surface:
|
|
8
|
+
corpus — pure storage/ranking of verified nuggets + gaps
|
|
9
|
+
corpus_server — standalone FastMCP server over the corpus
|
|
10
|
+
willow_mcp_client — best-effort gap forwarding to willow-mcp
|
|
11
|
+
load_persona() — load the canonical Jeles persona JSON
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from functools import lru_cache
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
def __getattr__(name: str) -> Any:
|
|
22
|
+
"""Lazily resolve ``jeles.__version__`` from installed package metadata.
|
|
23
|
+
|
|
24
|
+
The version is no longer a literal here — it duplicated pyproject.toml's,
|
|
25
|
+
which is the pattern that had already drifted three releases apart in
|
|
26
|
+
kartikeya and one apart in willow-mcp's plugin manifest. Metadata is written
|
|
27
|
+
at build time from the git tag and cannot drift.
|
|
28
|
+
|
|
29
|
+
Resolved lazily (PEP 562) rather than at import, because
|
|
30
|
+
``importlib.metadata`` pulls in ``socket`` — which would break design
|
|
31
|
+
principle 2 and fail ``tests/test_import_purity.py``. Reading a version
|
|
32
|
+
should not be what puts a network module in ``sys.modules``.
|
|
33
|
+
"""
|
|
34
|
+
if name == "__version__":
|
|
35
|
+
from importlib.metadata import PackageNotFoundError
|
|
36
|
+
from importlib.metadata import version as _pkg_version
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
return _pkg_version("jeles")
|
|
40
|
+
except PackageNotFoundError: # source tree with no install
|
|
41
|
+
return "0.0.0+unknown"
|
|
42
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
43
|
+
|
|
44
|
+
_PERSONA_PATH = Path(__file__).resolve().parent / "persona" / "jeles_persona.json"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def persona_path() -> Path:
|
|
48
|
+
"""Absolute path to the canonical Jeles persona JSON shipped in this
|
|
49
|
+
package. This package is the persona's canonical home."""
|
|
50
|
+
return _PERSONA_PATH
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@lru_cache(maxsize=1)
|
|
54
|
+
def load_persona() -> dict[str, Any]:
|
|
55
|
+
"""Load and return the canonical Jeles persona as a dict.
|
|
56
|
+
|
|
57
|
+
Stdlib-only, no I/O beyond a single file read. Cached after the first
|
|
58
|
+
call. Mutating the returned dict mutates the cached copy — treat it as
|
|
59
|
+
read-only, or copy it if you need to edit.
|
|
60
|
+
"""
|
|
61
|
+
return json.loads(_PERSONA_PATH.read_text(encoding="utf-8"))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@lru_cache(maxsize=1)
|
|
65
|
+
def persona_prompt() -> str:
|
|
66
|
+
"""The canonical Jeles persona rendered to a system-prompt string.
|
|
67
|
+
|
|
68
|
+
The combine (#18): one JSON source of truth here, compiled deterministically
|
|
69
|
+
into the labeled prompt every host used to hand-carry as its own prose copy.
|
|
70
|
+
Consumers call this instead of pasting a persona string. Cached.
|
|
71
|
+
"""
|
|
72
|
+
from jeles.persona.compiler import compile_persona
|
|
73
|
+
|
|
74
|
+
return compile_persona(load_persona())
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
__all__ = ["load_persona", "persona_prompt", "persona_path", "__version__"]
|
jeles/corpus.py
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
"""Jeles' verified-nugget corpus — storage and ranked lookup.
|
|
2
|
+
|
|
3
|
+
A nugget is a human-verified question/answer pair with citations:
|
|
4
|
+
{question, answer, sources, verified_by, verified_at, tags}.
|
|
5
|
+
|
|
6
|
+
Storage reuses the same SQLite shape as willow-mcp's SOIL `Store` (a
|
|
7
|
+
`records` table under `<collection>/store.db`, keyed by WILLOW_STORE_ROOT),
|
|
8
|
+
so nuggets written here are already visible to a Willow-style soil scan
|
|
9
|
+
with no extra wiring, and the corpus stays readable by anything else that
|
|
10
|
+
understands a Willow-style SOIL collection.
|
|
11
|
+
|
|
12
|
+
This module has no MCP dependency and does no network I/O — see
|
|
13
|
+
corpus_server.py for the FastMCP wrapper that exposes it as a standalone,
|
|
14
|
+
MCP-agnostic server. It imports only the standard library, on purpose:
|
|
15
|
+
that is what keeps its tests fast and network-free, and lets it be reused
|
|
16
|
+
as a plain library by any host.
|
|
17
|
+
|
|
18
|
+
The default collection names (`ask_jeles_corpus` / `ask_jeles_corpus_gaps`)
|
|
19
|
+
are preserved from Ask Jeles for back-compat so existing stores keep
|
|
20
|
+
resolving; they can be overridden via env for a differently-scoped host.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import re
|
|
28
|
+
import sqlite3
|
|
29
|
+
import threading
|
|
30
|
+
import uuid
|
|
31
|
+
from datetime import datetime, timezone
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
NUGGETS_COLLECTION = os.environ.get("JELES_CORPUS_COLLECTION", "ask_jeles_corpus")
|
|
36
|
+
GAPS_COLLECTION = os.environ.get("JELES_CORPUS_GAPS_COLLECTION", "ask_jeles_corpus_gaps")
|
|
37
|
+
|
|
38
|
+
# A collection name becomes a path component (<root>/<collection>/store.db), so
|
|
39
|
+
# an unvalidated one (e.g. from an env var a launcher forwards) could traverse
|
|
40
|
+
# out of WILLOW_STORE_ROOT. Mirror willow-mcp's SOIL Store guard — the sibling
|
|
41
|
+
# this schema is copied from already validates; this copy hadn't.
|
|
42
|
+
_COLLECTION_RE = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _validate_collection(collection: str) -> None:
|
|
46
|
+
if not _COLLECTION_RE.match(collection or ""):
|
|
47
|
+
raise ValueError(f"invalid collection name (must match {_COLLECTION_RE.pattern}): {collection!r}")
|
|
48
|
+
|
|
49
|
+
# The `deviation`/`action` columns are willow-mcp's (SOIL Store, db.py). jeles
|
|
50
|
+
# never reads them, but a jeles-created collection missing them makes a
|
|
51
|
+
# willow-mcp writer pointed at the same store fail `no such column: deviation`
|
|
52
|
+
# (box audit A3 — the two independently-drifted "shared" schemas). Carry them so
|
|
53
|
+
# the store stays mutually usable; _migrate_records() backfills older stores.
|
|
54
|
+
_SCHEMA = """
|
|
55
|
+
CREATE TABLE IF NOT EXISTS records (
|
|
56
|
+
id TEXT PRIMARY KEY,
|
|
57
|
+
data TEXT NOT NULL,
|
|
58
|
+
created_at TEXT NOT NULL,
|
|
59
|
+
updated_at TEXT NOT NULL,
|
|
60
|
+
deviation REAL NOT NULL DEFAULT 0.0,
|
|
61
|
+
action TEXT NOT NULL DEFAULT 'work_quiet',
|
|
62
|
+
deleted INTEGER NOT NULL DEFAULT 0
|
|
63
|
+
);
|
|
64
|
+
CREATE INDEX IF NOT EXISTS idx_deleted ON records(deleted);
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _migrate_records(conn: sqlite3.Connection) -> None:
|
|
69
|
+
"""Add willow-mcp's SOIL columns to a pre-existing jeles store that predates
|
|
70
|
+
this change, so a shared collection stays compatible either direction."""
|
|
71
|
+
have = {row[1] for row in conn.execute("PRAGMA table_info(records)")}
|
|
72
|
+
if "deviation" not in have:
|
|
73
|
+
conn.execute("ALTER TABLE records ADD COLUMN deviation REAL NOT NULL DEFAULT 0.0")
|
|
74
|
+
if "action" not in have:
|
|
75
|
+
conn.execute("ALTER TABLE records ADD COLUMN action TEXT NOT NULL DEFAULT 'work_quiet'")
|
|
76
|
+
|
|
77
|
+
_lock = threading.RLock()
|
|
78
|
+
_conns: dict[str, sqlite3.Connection] = {}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _store_root() -> Path:
|
|
82
|
+
return Path(os.environ.get("WILLOW_STORE_ROOT", str(Path.home() / ".willow" / "store"))).expanduser()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _conn(collection: str) -> sqlite3.Connection:
|
|
86
|
+
_validate_collection(collection)
|
|
87
|
+
db_path = _store_root() / collection / "store.db"
|
|
88
|
+
key = str(db_path)
|
|
89
|
+
with _lock:
|
|
90
|
+
if key not in _conns:
|
|
91
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
conn = sqlite3.connect(str(db_path), check_same_thread=False)
|
|
93
|
+
conn.executescript(_SCHEMA)
|
|
94
|
+
_migrate_records(conn)
|
|
95
|
+
conn.commit()
|
|
96
|
+
_conns[key] = conn
|
|
97
|
+
return _conns[key]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _now() -> str:
|
|
101
|
+
return datetime.now(timezone.utc).isoformat()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# Strip C0 control chars (keep tab/newline) from stored text. A NUL that
|
|
105
|
+
# truncates downstream C-string tooling, or a BEL nobody can retype, has no
|
|
106
|
+
# place in a nugget or a logged question. (Mirrors the-squirrel's db.sanitize —
|
|
107
|
+
# the same input-hygiene rule at each app's single write boundary.)
|
|
108
|
+
_CONTROL_CHARS = re.compile(r"[\x00-\x08\x0b-\x1f\x7f]")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _clean(obj: Any) -> Any:
|
|
112
|
+
if isinstance(obj, str):
|
|
113
|
+
return _CONTROL_CHARS.sub("", obj)
|
|
114
|
+
if isinstance(obj, dict):
|
|
115
|
+
return {k: _clean(v) for k, v in obj.items()}
|
|
116
|
+
if isinstance(obj, (list, tuple)):
|
|
117
|
+
return [_clean(v) for v in obj] # tuples json-serialize as lists anyway
|
|
118
|
+
return obj
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _put(collection: str, record: dict[str, Any], record_id: str | None = None) -> str:
|
|
122
|
+
rid = record_id or uuid.uuid4().hex[:8]
|
|
123
|
+
now = _now()
|
|
124
|
+
record = _clean(record) # one chokepoint — covers nuggets and gaps alike
|
|
125
|
+
with _lock:
|
|
126
|
+
conn = _conn(collection)
|
|
127
|
+
existing = conn.execute("SELECT created_at FROM records WHERE id = ?", (rid,)).fetchone()
|
|
128
|
+
created = existing[0] if existing else now
|
|
129
|
+
conn.execute(
|
|
130
|
+
"INSERT OR REPLACE INTO records (id, data, created_at, updated_at, deleted) "
|
|
131
|
+
"VALUES (?, ?, ?, ?, 0)",
|
|
132
|
+
(rid, json.dumps(record), created, now),
|
|
133
|
+
)
|
|
134
|
+
conn.commit()
|
|
135
|
+
return rid
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _get(collection: str, record_id: str) -> dict[str, Any] | None:
|
|
139
|
+
with _lock:
|
|
140
|
+
row = _conn(collection).execute(
|
|
141
|
+
"SELECT data, created_at, updated_at FROM records WHERE id = ? AND deleted = 0",
|
|
142
|
+
(record_id,),
|
|
143
|
+
).fetchone()
|
|
144
|
+
if not row:
|
|
145
|
+
return None
|
|
146
|
+
record = json.loads(row[0])
|
|
147
|
+
record["_id"] = record_id
|
|
148
|
+
record["_created"] = row[1]
|
|
149
|
+
record["_updated"] = row[2]
|
|
150
|
+
return record
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _all(collection: str) -> list[dict[str, Any]]:
|
|
154
|
+
with _lock:
|
|
155
|
+
rows = _conn(collection).execute(
|
|
156
|
+
"SELECT id, data, created_at, updated_at FROM records "
|
|
157
|
+
"WHERE deleted = 0 ORDER BY updated_at DESC"
|
|
158
|
+
).fetchall()
|
|
159
|
+
out = []
|
|
160
|
+
for rid, data, created, updated in rows:
|
|
161
|
+
record = json.loads(data)
|
|
162
|
+
record["_id"] = rid
|
|
163
|
+
record["_created"] = created
|
|
164
|
+
record["_updated"] = updated
|
|
165
|
+
out.append(record)
|
|
166
|
+
return out
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
_STOP = {
|
|
170
|
+
"the", "and", "for", "with", "from", "that", "this", "these", "those",
|
|
171
|
+
"have", "has", "had", "was", "were", "are", "is", "been", "being",
|
|
172
|
+
"what", "who", "when", "where", "why", "how", "which", "would", "could",
|
|
173
|
+
"should", "does", "did", "about", "into", "your", "you", "tell", "show",
|
|
174
|
+
"find", "give", "please", "can", "will", "its", "it's",
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _tokens(text: str) -> list[str]:
|
|
179
|
+
return [
|
|
180
|
+
t for t in re.findall(r"[a-z0-9][a-z0-9_-]{2,}", (text or "").lower())
|
|
181
|
+
if t not in _STOP
|
|
182
|
+
]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# ── Nuggets ──────────────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def put_nugget(
|
|
189
|
+
question: str,
|
|
190
|
+
answer: str,
|
|
191
|
+
sources: list[str],
|
|
192
|
+
verified_by: str,
|
|
193
|
+
tags: list[str] | None = None,
|
|
194
|
+
nugget_id: str | None = None,
|
|
195
|
+
verified_at: str | None = None,
|
|
196
|
+
verification_kind: str = "human",
|
|
197
|
+
) -> dict[str, Any]:
|
|
198
|
+
"""Add or update a verified nugget. Returns {id, action} or {error}.
|
|
199
|
+
|
|
200
|
+
``verification_kind`` distinguishes a human check (``"human"``, the default)
|
|
201
|
+
from machine corroboration (``"machine"`` — e.g. the conflict-scan reaction's
|
|
202
|
+
two-independent-source finding). It is meant to be set by the *driver*, not
|
|
203
|
+
passed through from untrusted caller data, so a machine finding can't render
|
|
204
|
+
as human-verified on read (see :func:`to_search_hit`).
|
|
205
|
+
"""
|
|
206
|
+
question = (question or "").strip()
|
|
207
|
+
answer = (answer or "").strip()
|
|
208
|
+
verified_by = (verified_by or "").strip()
|
|
209
|
+
if not question or not answer or not verified_by:
|
|
210
|
+
return {"error": "question, answer, and verified_by are required"}
|
|
211
|
+
kind = "machine" if str(verification_kind).lower() == "machine" else "human"
|
|
212
|
+
record = {
|
|
213
|
+
"question": question,
|
|
214
|
+
"answer": answer,
|
|
215
|
+
"sources": [str(s) for s in (sources or [])],
|
|
216
|
+
"verified_by": verified_by,
|
|
217
|
+
"verified_at": verified_at or datetime.now(timezone.utc).date().isoformat(),
|
|
218
|
+
"tags": [str(t) for t in (tags or [])],
|
|
219
|
+
"status": "verified",
|
|
220
|
+
"verification_kind": kind,
|
|
221
|
+
}
|
|
222
|
+
action = "updated" if (nugget_id and _get(NUGGETS_COLLECTION, nugget_id)) else "created"
|
|
223
|
+
rid = _put(NUGGETS_COLLECTION, record, record_id=nugget_id)
|
|
224
|
+
return {"id": rid, "action": action}
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def get_nugget(nugget_id: str) -> dict[str, Any]:
|
|
228
|
+
return _get(NUGGETS_COLLECTION, nugget_id) or {"error": "not_found"}
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def list_nuggets(limit: int = 50) -> list[dict[str, Any]]:
|
|
232
|
+
return _all(NUGGETS_COLLECTION)[: max(0, limit)]
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _score(nugget: dict[str, Any], query_tokens: list[str]) -> float:
|
|
236
|
+
if not query_tokens:
|
|
237
|
+
return 0.0
|
|
238
|
+
question = (nugget.get("question") or "").lower()
|
|
239
|
+
answer = (nugget.get("answer") or "").lower()
|
|
240
|
+
tags = " ".join(nugget.get("tags") or []).lower()
|
|
241
|
+
q_tokens = set(_tokens(question))
|
|
242
|
+
matched = sum(1 for t in query_tokens if t in q_tokens)
|
|
243
|
+
score = matched / len(query_tokens)
|
|
244
|
+
if set(query_tokens) == q_tokens:
|
|
245
|
+
score += 1.0
|
|
246
|
+
elif matched == len(query_tokens):
|
|
247
|
+
score += 0.5
|
|
248
|
+
if any(t in answer for t in query_tokens):
|
|
249
|
+
score += 0.1
|
|
250
|
+
if any(t in tags for t in query_tokens):
|
|
251
|
+
score += 0.1
|
|
252
|
+
return score
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
MIN_ASK_SCORE = 0.5 # below this, a "match" is too weak to answer with — treat as a gap
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _ranked(query: str, limit: int) -> list[tuple[dict[str, Any], float]]:
|
|
259
|
+
tokens = _tokens(query)
|
|
260
|
+
if not tokens:
|
|
261
|
+
return []
|
|
262
|
+
scored = [(n, _score(n, tokens)) for n in _all(NUGGETS_COLLECTION)]
|
|
263
|
+
scored = [(n, s) for n, s in scored if s > 0]
|
|
264
|
+
scored.sort(key=lambda pair: pair[1], reverse=True)
|
|
265
|
+
return scored[: max(0, limit)]
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def search_nuggets(query: str, limit: int = 8) -> list[dict[str, Any]]:
|
|
269
|
+
"""Ranked nugget search. Pure lookup — never logs a gap on a miss.
|
|
270
|
+
|
|
271
|
+
Question-token overlap is weighted highest so a near-exact question
|
|
272
|
+
match outranks a nugget that merely mentions the query in its answer.
|
|
273
|
+
"""
|
|
274
|
+
return [n for n, _ in _ranked(query, limit)]
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def ask_corpus(question: str) -> dict[str, Any]:
|
|
278
|
+
"""The spec's interaction flow: exact match, else best partial match,
|
|
279
|
+
else 'I don't know yet' — which logs the gap for later triage.
|
|
280
|
+
|
|
281
|
+
Unlike search_nuggets(), this is the deliberate "ask the corpus"
|
|
282
|
+
entrypoint (used by corpus_server's corpus_ask tool and Jeles'
|
|
283
|
+
synthesize step), so a miss — or a match too weak to trust — is
|
|
284
|
+
assumed to be a real gap worth tracking, not background search noise.
|
|
285
|
+
"""
|
|
286
|
+
ranked = _ranked(question, 5)
|
|
287
|
+
if not ranked or ranked[0][1] < MIN_ASK_SCORE:
|
|
288
|
+
log_gap(question)
|
|
289
|
+
return {"found": False, "nugget": None, "candidates": [n for n, _ in ranked]}
|
|
290
|
+
tokens = set(_tokens(question))
|
|
291
|
+
top, _top_score = ranked[0]
|
|
292
|
+
top_tokens = set(_tokens(top.get("question") or ""))
|
|
293
|
+
exact = bool(tokens) and tokens == top_tokens
|
|
294
|
+
return {"found": True, "exact": exact, "nugget": top, "candidates": [n for n, _ in ranked[1:]]}
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def to_search_hit(nugget: dict[str, Any], idx: int = 0) -> dict[str, Any]:
|
|
298
|
+
"""Shape a nugget as a search hit compatible with a host search
|
|
299
|
+
pipeline's flatten_results()/rank_hit() (source_id="corpus")."""
|
|
300
|
+
sources = nugget.get("sources") or []
|
|
301
|
+
# A machine-corroborated nugget must not read as human-verified: surface the
|
|
302
|
+
# kind, and downgrade its confidence label so the two are distinguishable on
|
|
303
|
+
# read (absent kind => legacy human nugget).
|
|
304
|
+
kind = nugget.get("verification_kind") or "human"
|
|
305
|
+
return {
|
|
306
|
+
"title": nugget.get("question") or "Verified nugget",
|
|
307
|
+
"url": sources[0] if sources else "",
|
|
308
|
+
"snippet": nugget.get("answer") or "",
|
|
309
|
+
"source": f"Verified corpus — {nugget.get('verified_by') or 'unknown'}",
|
|
310
|
+
"date": nugget.get("verified_at") or "",
|
|
311
|
+
"source_id": "corpus",
|
|
312
|
+
"hostname": "corpus.local",
|
|
313
|
+
"confidence": "verified" if kind == "human" else "corroborated",
|
|
314
|
+
"verification_kind": kind,
|
|
315
|
+
"nugget_id": nugget.get("_id") or "",
|
|
316
|
+
"verified_by": nugget.get("verified_by") or "",
|
|
317
|
+
"verified_at": nugget.get("verified_at") or "",
|
|
318
|
+
"extra_sources": sources,
|
|
319
|
+
"tags": nugget.get("tags") or [],
|
|
320
|
+
"n": idx,
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
# ── Gaps ("I don't know yet") ───────────────────────────────────────────
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def log_gap(question: str) -> dict[str, Any]:
|
|
328
|
+
"""Log an unanswered question. Repeated asks bump asked_count instead of
|
|
329
|
+
creating duplicates, keyed by the question's normalized token set."""
|
|
330
|
+
question = (question or "").strip()
|
|
331
|
+
if not question:
|
|
332
|
+
return {"error": "question required"}
|
|
333
|
+
tokens = tuple(sorted(set(_tokens(question))))
|
|
334
|
+
# _tokens drops <3-char tokens, so "drug A" and "drug B" both reduce to
|
|
335
|
+
# {"drug"} and collide, silently overwriting the earlier gap. Keep the short
|
|
336
|
+
# meaning-bearing codes (single letters, "P0", "v2" — not stopwords) as an
|
|
337
|
+
# extra key segment; rephrasings that share the main token set still merge.
|
|
338
|
+
short = tuple(sorted({
|
|
339
|
+
t for t in re.findall(r"[a-z0-9]+", (question or "").lower())
|
|
340
|
+
if len(t) < 3 and t not in _STOP
|
|
341
|
+
}))
|
|
342
|
+
key = "|".join(tokens) + "##" + "|".join(short) if (tokens or short) else question.lower()
|
|
343
|
+
gap_id = uuid.uuid5(uuid.NAMESPACE_URL, key).hex[:12]
|
|
344
|
+
existing = _get(GAPS_COLLECTION, gap_id)
|
|
345
|
+
record = {
|
|
346
|
+
"question": question,
|
|
347
|
+
"status": "unverified",
|
|
348
|
+
"asked_count": (existing or {}).get("asked_count", 0) + 1,
|
|
349
|
+
"first_asked_at": (existing or {}).get("first_asked_at") or _now(),
|
|
350
|
+
"last_asked_at": _now(),
|
|
351
|
+
}
|
|
352
|
+
rid = _put(GAPS_COLLECTION, record, record_id=gap_id)
|
|
353
|
+
return {"id": rid, "asked_count": record["asked_count"]}
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def list_gaps(limit: int = 50) -> list[dict[str, Any]]:
|
|
357
|
+
gaps = _all(GAPS_COLLECTION)
|
|
358
|
+
gaps.sort(key=lambda g: g.get("asked_count", 0), reverse=True)
|
|
359
|
+
return gaps[: max(0, limit)]
|
jeles/corpus_server.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Standalone MCP server over Jeles' verified-nugget corpus.
|
|
2
|
+
|
|
3
|
+
Mirrors willow-mcp's shape — FastMCP, stdio by default, every tool takes an
|
|
4
|
+
`app_id` — but scoped to one small corpus so it can run independently of
|
|
5
|
+
any particular fleet and be MCP-agnostic: any stdio MCP client (Claude
|
|
6
|
+
Code, Claude Desktop, Cursor, willow-mcp itself, a bare script) can point
|
|
7
|
+
at it with `python -m jeles.corpus_server`.
|
|
8
|
+
|
|
9
|
+
Unlike willow-mcp, this server does not implement manifest-based ACL — the
|
|
10
|
+
corpus is already scoped to a single app's own data, so there is nothing
|
|
11
|
+
for a permission gate to isolate. `app_id` is accepted on every tool for
|
|
12
|
+
naming-convention parity and so a future gate can be added without
|
|
13
|
+
changing the tool signatures. This server does not depend on willow-mcp.
|
|
14
|
+
|
|
15
|
+
Tools:
|
|
16
|
+
corpus_ask — best-match nugget for a question, or {found: false}
|
|
17
|
+
(logs a gap on miss)
|
|
18
|
+
corpus_search — ranked nugget search (no gap logging)
|
|
19
|
+
corpus_get — fetch a single nugget by id
|
|
20
|
+
corpus_list — list nuggets, most recently updated first
|
|
21
|
+
corpus_put — add or update a verified nugget
|
|
22
|
+
corpus_gaps — list logged "I don't know yet" questions
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from typing import Optional
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
from mcp.server.fastmcp import FastMCP
|
|
31
|
+
except ImportError as exc: # pragma: no cover - exercised by install shape, not tests
|
|
32
|
+
# The MCP SDK is an optional extra: base `jeles` has zero runtime
|
|
33
|
+
# dependencies so a host can depend on it without inheriting a version
|
|
34
|
+
# constraint. Two different failures land here, and they need different
|
|
35
|
+
# answers, so say which one it is rather than leaking a bare traceback.
|
|
36
|
+
if exc.name == "mcp":
|
|
37
|
+
raise ImportError(
|
|
38
|
+
"jeles.corpus_server needs the MCP SDK, which base `jeles` does not "
|
|
39
|
+
'install. Add the extra: pip install "jeles[mcp]"\n'
|
|
40
|
+
"(the corpus, the persona, and the reactions all work without it)"
|
|
41
|
+
) from exc
|
|
42
|
+
raise ImportError(
|
|
43
|
+
"jeles.corpus_server requires MCP SDK 1.x — `mcp.server.fastmcp` was "
|
|
44
|
+
"removed in SDK 2.0 and this module has not been ported to "
|
|
45
|
+
"`mcp.server.mcpserver.MCPServer` yet. Install a 1.x SDK:\n"
|
|
46
|
+
' pip install "jeles[mcp]" # pins mcp>=1.6,<2\n'
|
|
47
|
+
"Note this means jeles[mcp] cannot currently be installed alongside "
|
|
48
|
+
"willow-mcp, which requires mcp>=2. Base `jeles` can."
|
|
49
|
+
) from exc
|
|
50
|
+
|
|
51
|
+
from jeles import corpus, willow_mcp_client
|
|
52
|
+
|
|
53
|
+
mcp = FastMCP(
|
|
54
|
+
"jeles-corpus",
|
|
55
|
+
instructions=(
|
|
56
|
+
"Jeles' verified-nugget corpus. Ask a question to get a cited, "
|
|
57
|
+
"human-verified answer if one exists, search the corpus directly, "
|
|
58
|
+
"or contribute a new verified nugget. Misses are logged as gaps "
|
|
59
|
+
"for someone to fill in later."
|
|
60
|
+
),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@mcp.tool()
|
|
65
|
+
def corpus_ask(app_id: str, question: str) -> dict:
|
|
66
|
+
"""Answer from the verified corpus if a nugget matches; returns
|
|
67
|
+
{found: false} and logs a gap otherwise. The gap also gets a
|
|
68
|
+
best-effort, non-blocking forward to willow-mcp's fleet-wide gap
|
|
69
|
+
backlog, so it isn't just a local secret."""
|
|
70
|
+
result = corpus.ask_corpus(question)
|
|
71
|
+
if not result.get("found"):
|
|
72
|
+
willow_mcp_client.forward_gap(question)
|
|
73
|
+
return result
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@mcp.tool()
|
|
77
|
+
def corpus_search(app_id: str, query: str, limit: int = 8) -> list:
|
|
78
|
+
"""Ranked nugget search across the corpus. Never logs a gap."""
|
|
79
|
+
return corpus.search_nuggets(query, limit=limit)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@mcp.tool()
|
|
83
|
+
def corpus_get(app_id: str, nugget_id: str) -> dict:
|
|
84
|
+
"""Fetch a single nugget by id."""
|
|
85
|
+
return corpus.get_nugget(nugget_id)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@mcp.tool()
|
|
89
|
+
def corpus_list(app_id: str, limit: int = 50) -> list:
|
|
90
|
+
"""List nuggets, most recently updated first."""
|
|
91
|
+
return corpus.list_nuggets(limit=limit)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@mcp.tool()
|
|
95
|
+
def corpus_put(
|
|
96
|
+
app_id: str,
|
|
97
|
+
question: str,
|
|
98
|
+
answer: str,
|
|
99
|
+
sources: list[str],
|
|
100
|
+
verified_by: str,
|
|
101
|
+
tags: Optional[list[str]] = None,
|
|
102
|
+
nugget_id: Optional[str] = None,
|
|
103
|
+
) -> dict:
|
|
104
|
+
"""Add or update a verified nugget. Requires question, answer, at least
|
|
105
|
+
one source, and who verified it. Returns {id, action}."""
|
|
106
|
+
return corpus.put_nugget(
|
|
107
|
+
question, answer, sources, verified_by, tags=tags, nugget_id=nugget_id
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@mcp.tool()
|
|
112
|
+
def corpus_gaps(app_id: str, limit: int = 50) -> list:
|
|
113
|
+
"""List logged 'I don't know yet' questions, most-asked first — the
|
|
114
|
+
corpus's growth queue."""
|
|
115
|
+
return corpus.list_gaps(limit=limit)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def main() -> None:
|
|
119
|
+
mcp.run()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
if __name__ == "__main__":
|
|
123
|
+
main()
|