@cloudgrid-io/mcp 0.14.0 → 0.16.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.
Files changed (42) hide show
  1. package/package.json +1 -1
  2. package/src/corpus/capability-map.md +44 -15
  3. package/src/corpus/install.md +27 -0
  4. package/src/corpus/templates/semantic-search/AGENTS.md +216 -0
  5. package/src/corpus/templates/semantic-search/README.md +75 -0
  6. package/src/corpus/templates/semantic-search/cloudgrid.yaml +219 -0
  7. package/src/corpus/templates/semantic-search/index.md +108 -0
  8. package/src/corpus/templates/semantic-search/services/backend/requirements.txt +7 -0
  9. package/src/corpus/templates/semantic-search/services/backend/src/app/__init__.py +0 -0
  10. package/src/corpus/templates/semantic-search/services/backend/src/app/answer.py +90 -0
  11. package/src/corpus/templates/semantic-search/services/backend/src/app/auth.py +65 -0
  12. package/src/corpus/templates/semantic-search/services/backend/src/app/config.py +110 -0
  13. package/src/corpus/templates/semantic-search/services/backend/src/app/db.py +89 -0
  14. package/src/corpus/templates/semantic-search/services/backend/src/app/embeddings.py +44 -0
  15. package/src/corpus/templates/semantic-search/services/backend/src/app/indexing.py +223 -0
  16. package/src/corpus/templates/semantic-search/services/backend/src/app/main.py +259 -0
  17. package/src/corpus/templates/semantic-search/services/backend/src/app/search.py +222 -0
  18. package/src/corpus/templates/semantic-search/services/backend/src/app/source.py +259 -0
  19. package/src/corpus/templates/semantic-search/services/backend/src/main.py +12 -0
  20. package/src/corpus/templates/semantic-search/services/refresh/README.md +42 -0
  21. package/src/corpus/templates/semantic-search/services/refresh/requirements.txt +9 -0
  22. package/src/corpus/templates/semantic-search/services/refresh/src/app/__init__.py +0 -0
  23. package/src/corpus/templates/semantic-search/services/refresh/src/app/config.py +110 -0
  24. package/src/corpus/templates/semantic-search/services/refresh/src/app/db.py +89 -0
  25. package/src/corpus/templates/semantic-search/services/refresh/src/app/embeddings.py +44 -0
  26. package/src/corpus/templates/semantic-search/services/refresh/src/app/indexing.py +223 -0
  27. package/src/corpus/templates/semantic-search/services/refresh/src/app/source.py +259 -0
  28. package/src/corpus/templates/semantic-search/services/refresh/src/main.py +45 -0
  29. package/src/corpus/templates/semantic-search/services/web/index.html +12 -0
  30. package/src/corpus/templates/semantic-search/services/web/package-lock.json +1680 -0
  31. package/src/corpus/templates/semantic-search/services/web/package.json +19 -0
  32. package/src/corpus/templates/semantic-search/services/web/src/App.jsx +55 -0
  33. package/src/corpus/templates/semantic-search/services/web/src/api.js +125 -0
  34. package/src/corpus/templates/semantic-search/services/web/src/components/Dashboard.jsx +61 -0
  35. package/src/corpus/templates/semantic-search/services/web/src/components/ManagerLogin.jsx +39 -0
  36. package/src/corpus/templates/semantic-search/services/web/src/components/SearchView.jsx +166 -0
  37. package/src/corpus/templates/semantic-search/services/web/src/components/Settings.jsx +148 -0
  38. package/src/corpus/templates/semantic-search/services/web/src/main.jsx +10 -0
  39. package/src/corpus/templates/semantic-search/services/web/src/styles.css +78 -0
  40. package/src/corpus/templates/semantic-search/services/web/vite.config.js +10 -0
  41. package/src/corpus/workflows/semantic-search.md +142 -0
  42. package/src/tools.js +76 -14
@@ -0,0 +1,223 @@
1
+ """Incremental indexing pipeline: discover -> extract -> chunk -> embed -> store.
2
+
3
+ Shared by the manager 'Refresh now' endpoint AND the scheduled `type: cron`
4
+ refresh service (services/refresh, which vendors this module). It is resilient:
5
+ if the source or
6
+ embeddings are not configured it records a clear, non-crashing report and
7
+ returns. Only new/changed files (by rev / content-hash) are reprocessed.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import datetime as dt
12
+ import os
13
+ import re
14
+
15
+ from . import config, db, embeddings, source
16
+
17
+ CHUNK_CHARS = 1200
18
+ CHUNK_OVERLAP = 150
19
+
20
+ # Generic date detection from filename or document head (best effort, nullable).
21
+ _DATE_RE = re.compile(r"(\d{4})[./-](\d{1,2})[./-](\d{1,2})") # 2026-07-05
22
+ _DATE_RE_ALT = re.compile(r"(\d{1,2})[./-](\d{1,2})[./-](\d{2,4})") # 05/07/2026
23
+
24
+
25
+ def _utcnow() -> dt.datetime:
26
+ return dt.datetime.now(dt.timezone.utc)
27
+
28
+
29
+ def _chunk_text(text: str) -> list[str]:
30
+ text = text.strip()
31
+ if not text:
32
+ return []
33
+ out: list[str] = []
34
+ start = 0
35
+ n = len(text)
36
+ while start < n:
37
+ end = min(start + CHUNK_CHARS, n)
38
+ out.append(text[start:end])
39
+ if end == n:
40
+ break
41
+ start = end - CHUNK_OVERLAP
42
+ return out
43
+
44
+
45
+ def _parse_metadata(name: str, text: str) -> dict:
46
+ """Best-effort title / date / collection extraction. All fields nullable.
47
+
48
+ Generalize this for your domain: `collection` is a free-form grouping tag
49
+ (folder, series, category), `date` is an ISO date if one can be found.
50
+ """
51
+ title = os.path.splitext(name)[0].strip()
52
+ # Collection tag = the leading non-numeric words of the title.
53
+ collection = re.sub(r"[\d].*$", "", title).strip() or None
54
+
55
+ date_iso = None
56
+ haystack = f"{name}\n{text[:2000]}"
57
+ m = _DATE_RE.search(haystack)
58
+ if m:
59
+ y, mo, d = m.groups()
60
+ try:
61
+ date_iso = dt.datetime(int(y), int(mo), int(d)).date().isoformat()
62
+ except ValueError:
63
+ date_iso = None
64
+ if date_iso is None:
65
+ m = _DATE_RE_ALT.search(haystack)
66
+ if m:
67
+ d, mo, y = m.groups()
68
+ if len(y) == 2:
69
+ y = "20" + y
70
+ try:
71
+ date_iso = dt.datetime(int(y), int(mo), int(d)).date().isoformat()
72
+ except ValueError:
73
+ date_iso = None
74
+
75
+ return {"title": title, "collection": collection, "date": date_iso}
76
+
77
+
78
+ def run_sync(triggered_by: str = "manual") -> dict:
79
+ """Run one incremental sync. Never raises on missing config — returns a report."""
80
+ started = _utcnow()
81
+ report: dict = {
82
+ "started_at": started,
83
+ "triggered_by": triggered_by,
84
+ "files": [],
85
+ "status": "ok",
86
+ "note": None,
87
+ }
88
+
89
+ # Mongo is required for indexing (and always injected). Fail loud only here.
90
+ db.ensure_indexes()
91
+
92
+ if not config.source_configured():
93
+ report["status"] = "skipped"
94
+ report["note"] = (
95
+ f"Source not configured (SOURCE_TYPE={config.source_type()!r} — "
96
+ "missing its secrets/config)."
97
+ )
98
+ _save_report(report)
99
+ return report
100
+
101
+ try:
102
+ src = source.get_source()
103
+ files = src.list_files()
104
+ except Exception as exc: # noqa: BLE001 - resilient by design
105
+ report["status"] = "error"
106
+ report["note"] = f"Source listing failed: {exc}"
107
+ _save_report(report)
108
+ return report
109
+
110
+ embeddings_ok = config.embeddings_configured()
111
+ if not embeddings_ok:
112
+ report["note"] = (
113
+ "Embeddings not configured — documents indexed for lexical/metadata "
114
+ "search only (no semantic vectors)."
115
+ )
116
+
117
+ for f in files:
118
+ outcome = _process_file(src, f, embeddings_ok)
119
+ report["files"].append(outcome)
120
+
121
+ report["finished_at"] = _utcnow()
122
+ _save_report(report)
123
+ return report
124
+
125
+
126
+ def _process_file(src: source.Source, f: source.SourceFile, embeddings_ok: bool) -> dict:
127
+ existing = db.documents().find_one({"source_filename": f.name})
128
+ if existing and existing.get("source_rev") == f.rev:
129
+ return {"file": f.name, "result": "unchanged"}
130
+
131
+ if f.is_audio and not f.is_text:
132
+ # Catalogue audio/media with a flag; do not search inside it.
133
+ doc = {
134
+ "title": os.path.splitext(f.name)[0],
135
+ "source_filename": f.name,
136
+ "source_rev": f.rev,
137
+ "file_path": f.path,
138
+ "collection": None,
139
+ "date": None,
140
+ "has_media": True,
141
+ "media_path": f.path,
142
+ "word_count": 0,
143
+ "indexed_at": _utcnow(),
144
+ }
145
+ doc_id = _upsert_document(doc)
146
+ db.chunks().delete_many({"document_id": doc_id})
147
+ return {"file": f.name, "result": "catalogued_media"}
148
+
149
+ if not f.is_text:
150
+ return {"file": f.name, "result": "skipped_unsupported"}
151
+
152
+ try:
153
+ raw = src.download(f.path)
154
+ text = source.extract_text(f.name, raw)
155
+ except Exception as exc: # noqa: BLE001
156
+ return {"file": f.name, "result": "error", "detail": str(exc)}
157
+
158
+ meta = _parse_metadata(f.name, text)
159
+ doc = {
160
+ **meta,
161
+ "source_filename": f.name,
162
+ "source_rev": f.rev,
163
+ "file_path": f.path,
164
+ "has_media": False,
165
+ "media_path": None,
166
+ "word_count": len(text.split()),
167
+ "indexed_at": _utcnow(),
168
+ }
169
+ doc_id = _upsert_document(doc)
170
+
171
+ # Rebuild chunks for this document.
172
+ db.chunks().delete_many({"document_id": doc_id})
173
+ parts = _chunk_text(text)
174
+ vectors: list[list[float] | None] = [None] * len(parts)
175
+ if embeddings_ok and parts:
176
+ try:
177
+ vectors = embeddings.embed(parts) # type: ignore[assignment]
178
+ except Exception as exc: # noqa: BLE001
179
+ return {
180
+ "file": f.name,
181
+ "result": "indexed_no_embeddings",
182
+ "detail": f"embedding failed: {exc}",
183
+ "chunks": len(parts),
184
+ }
185
+
186
+ docs = []
187
+ for i, content in enumerate(parts):
188
+ docs.append({
189
+ "document_id": doc_id,
190
+ "chunk_index": i,
191
+ "content": content,
192
+ "embedding": vectors[i] if i < len(vectors) else None,
193
+ "token_count": len(content.split()),
194
+ })
195
+ if docs:
196
+ db.chunks().insert_many(docs)
197
+
198
+ return {
199
+ "file": f.name,
200
+ "result": "indexed" if embeddings_ok else "indexed_no_embeddings",
201
+ "chunks": len(docs),
202
+ }
203
+
204
+
205
+ def _upsert_document(doc: dict):
206
+ db.documents().update_one(
207
+ {"source_filename": doc["source_filename"]},
208
+ {"$set": doc},
209
+ upsert=True,
210
+ )
211
+ return db.documents().find_one({"source_filename": doc["source_filename"]})["_id"]
212
+
213
+
214
+ def _save_report(report: dict) -> None:
215
+ try:
216
+ db.index_reports().insert_one(dict(report))
217
+ db.settings().update_one(
218
+ {"_id": "catalog"},
219
+ {"$set": {"last_refresh": report.get("finished_at") or report["started_at"]}},
220
+ upsert=True,
221
+ )
222
+ except Exception: # noqa: BLE001 - reporting must never crash the caller
223
+ pass
@@ -0,0 +1,259 @@
1
+ """FastAPI backend for the semantic-search document app.
2
+
3
+ Mounted at /backend by CloudGrid. Public search + answer endpoints, plus
4
+ manager-only admin (Settings, Usage Dashboard, Refresh now) enforced server-side.
5
+
6
+ Health is green as soon as Mongo connects — it does NOT require the document
7
+ source or the embeddings key. No secret is read at import time.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import datetime as dt
12
+
13
+ from fastapi import Depends, FastAPI
14
+ from fastapi.middleware.cors import CORSMiddleware
15
+ from pydantic import BaseModel
16
+
17
+ from . import answer, auth, config, db, indexing, search
18
+
19
+ app = FastAPI(title="Semantic Search API")
20
+
21
+ app.add_middleware(
22
+ CORSMiddleware,
23
+ allow_origins=["*"], # frontend is served same-origin; open for safety
24
+ allow_methods=["*"],
25
+ allow_headers=["*"],
26
+ )
27
+
28
+
29
+ @app.on_event("startup")
30
+ def _ensure_indexes_on_startup() -> None:
31
+ # Create the chunks $text index + documents indexes at boot so $text search
32
+ # returns [] on an empty catalog instead of erroring (IndexNotFound / 500).
33
+ # Never block startup if Mongo is briefly unavailable — an index run recreates
34
+ # them.
35
+ try:
36
+ db.ensure_indexes()
37
+ except Exception as e: # noqa: BLE001
38
+ print(f"startup: ensure_indexes skipped ({e})")
39
+
40
+
41
+ # ── Health ────────────────────────────────────────────────────────────────────
42
+
43
+ @app.get("/backend/health")
44
+ def health():
45
+ """Green when Mongo connects. Independent of the source / embeddings key."""
46
+ try:
47
+ db.ping()
48
+ return {"status": "ok"}
49
+ except Exception as exc: # noqa: BLE001
50
+ return {"status": "degraded", "detail": str(exc)}
51
+
52
+
53
+ @app.get("/backend/status")
54
+ def status():
55
+ """Public capability snapshot — powers the frontend's 'not indexed / missing
56
+ secret' states without exposing any secret value."""
57
+ try:
58
+ db.ping()
59
+ mongo_ok = True
60
+ except Exception: # noqa: BLE001
61
+ mongo_ok = False
62
+ settings_doc = db.settings().find_one({"_id": "catalog"}) if mongo_ok else None
63
+ features = db.settings().find_one({"_id": "features"}) if mongo_ok else None
64
+ return {
65
+ "mongo": mongo_ok,
66
+ "source_type": config.source_type(),
67
+ "source_configured": config.source_configured(),
68
+ "embeddings_configured": config.embeddings_configured(),
69
+ "manager_auth_configured": config.manager_auth_configured(),
70
+ "answer_mode_enabled": bool((features or {}).get("answer_mode_enabled", True)),
71
+ "indexed_documents": db.documents().count_documents({}) if mongo_ok else 0,
72
+ "last_refresh": (settings_doc or {}).get("last_refresh"),
73
+ }
74
+
75
+
76
+ # ── Search (public) ───────────────────────────────────────────────────────────
77
+
78
+ @app.get("/backend/search")
79
+ def do_search(q: str = "", mode: str = "hybrid", collection: str | None = None,
80
+ date_from: str | None = None, date_to: str | None = None,
81
+ limit: int = 20):
82
+ result = search.search(q, mode=mode, collection=collection,
83
+ date_from=date_from, date_to=date_to, limit=limit)
84
+ _log_event("search", {"q": q, "mode": mode,
85
+ "zero_result": len(result["results"]) == 0})
86
+ return result
87
+
88
+
89
+ @app.get("/backend/count")
90
+ def do_count(collection: str | None = None, date_from: str | None = None,
91
+ date_to: str | None = None):
92
+ """Counting answered directly from the DB (countDocuments)."""
93
+ return {"count": search.count_documents(collection, date_from, date_to)}
94
+
95
+
96
+ @app.get("/backend/collections")
97
+ def list_collections():
98
+ values = [c for c in db.documents().distinct("collection") if c]
99
+ return {"collections": sorted(values)}
100
+
101
+
102
+ class AnswerRequest(BaseModel):
103
+ question: str
104
+
105
+
106
+ @app.post("/backend/answer")
107
+ def do_answer(req: AnswerRequest):
108
+ _log_event("answer", {"q": req.question})
109
+ return answer.compose_answer(req.question)
110
+
111
+
112
+ @app.get("/backend/documents/{document_id}/download")
113
+ def download_document(document_id: str):
114
+ """Stream a document's text through the service — source paths never exposed."""
115
+ from bson import ObjectId
116
+ from fastapi import HTTPException
117
+ from fastapi.responses import PlainTextResponse
118
+
119
+ try:
120
+ doc = db.documents().find_one({"_id": ObjectId(document_id)})
121
+ except Exception: # noqa: BLE001
122
+ raise HTTPException(status_code=404, detail="Not found.")
123
+ if not doc:
124
+ raise HTTPException(status_code=404, detail="Not found.")
125
+ parts = db.chunks().find({"document_id": doc["_id"]}).sort("chunk_index", 1)
126
+ text = "".join(p["content"] for p in parts)
127
+ return PlainTextResponse(text, headers={
128
+ "Content-Disposition": f'attachment; filename="{doc.get("title","document")}.txt"'
129
+ })
130
+
131
+
132
+ # ── Manager auth ──────────────────────────────────────────────────────────────
133
+
134
+ class LoginRequest(BaseModel):
135
+ password: str
136
+
137
+
138
+ @app.post("/backend/admin/login")
139
+ def admin_login(req: LoginRequest):
140
+ token = auth.login(req.password)
141
+ return {"token": token}
142
+
143
+
144
+ # ── Manager: Settings ─────────────────────────────────────────────────────────
145
+
146
+ @app.get("/backend/admin/settings", dependencies=[Depends(auth.require_manager)])
147
+ def get_settings():
148
+ features = db.settings().find_one({"_id": "features"}) or {}
149
+ synonyms = db.settings().find_one({"_id": "synonyms"}) or {}
150
+ catalog = db.settings().find_one({"_id": "catalog"}) or {}
151
+ return {
152
+ "answer_mode_enabled": bool(features.get("answer_mode_enabled", True)),
153
+ "synonyms": synonyms.get("map", {}),
154
+ "last_refresh": catalog.get("last_refresh"),
155
+ }
156
+
157
+
158
+ class FeatureUpdate(BaseModel):
159
+ answer_mode_enabled: bool | None = None
160
+
161
+
162
+ @app.put("/backend/admin/settings", dependencies=[Depends(auth.require_manager)])
163
+ def update_settings(req: FeatureUpdate):
164
+ update = {k: v for k, v in req.dict().items() if v is not None}
165
+ if update:
166
+ db.settings().update_one({"_id": "features"}, {"$set": update}, upsert=True)
167
+ return {"ok": True}
168
+
169
+
170
+ class SynonymUpdate(BaseModel):
171
+ map: dict[str, list[str]]
172
+
173
+
174
+ @app.put("/backend/admin/synonyms", dependencies=[Depends(auth.require_manager)])
175
+ def update_synonyms(req: SynonymUpdate):
176
+ db.settings().update_one({"_id": "synonyms"}, {"$set": {"map": req.map}}, upsert=True)
177
+ return {"ok": True}
178
+
179
+
180
+ class PasswordChange(BaseModel):
181
+ current: str
182
+ new: str
183
+
184
+
185
+ @app.post("/backend/admin/password", dependencies=[Depends(auth.require_manager)])
186
+ def change_password(req: PasswordChange):
187
+ new_hash = auth.change_password(req.current, req.new)
188
+ # Return the hash for the manager to store as MANAGER_PASSWORD_HASH.
189
+ return {
190
+ "new_hash": new_hash,
191
+ "note": "Store this as the MANAGER_PASSWORD_HASH secret "
192
+ "(`grid secrets set MANAGER_PASSWORD_HASH=<hash>`) to persist it.",
193
+ }
194
+
195
+
196
+ @app.get("/backend/admin/index-report", dependencies=[Depends(auth.require_manager)])
197
+ def index_report():
198
+ reports = list(db.index_reports().find().sort("started_at", -1).limit(10))
199
+ for r in reports:
200
+ r["_id"] = str(r["_id"])
201
+ return {"reports": reports}
202
+
203
+
204
+ @app.post("/backend/admin/refresh", dependencies=[Depends(auth.require_manager)])
205
+ def refresh_now():
206
+ """Manager-only on-demand incremental sync.
207
+
208
+ Runs the same pipeline as the scheduled `type: cron` refresh service
209
+ (services/refresh); this is the on-demand path. See AGENTS.md §8.
210
+ """
211
+ report = indexing.run_sync(triggered_by="manager")
212
+ report.pop("_id", None)
213
+ return report
214
+
215
+
216
+ # ── Manager: Usage Dashboard ──────────────────────────────────────────────────
217
+
218
+ @app.get("/backend/admin/usage", dependencies=[Depends(auth.require_manager)])
219
+ def usage():
220
+ ev = db.events()
221
+ total_searches = ev.count_documents({"kind": "search"})
222
+ by_mode = list(ev.aggregate([
223
+ {"$match": {"kind": "search"}},
224
+ {"$group": {"_id": "$meta.mode", "count": {"$sum": 1}}},
225
+ ]))
226
+ top_queries = list(ev.aggregate([
227
+ {"$match": {"kind": "search", "meta.q": {"$ne": ""}}},
228
+ {"$group": {"_id": "$meta.q", "count": {"$sum": 1}}},
229
+ {"$sort": {"count": -1}}, {"$limit": 10},
230
+ ]))
231
+ zero_result = list(ev.aggregate([
232
+ {"$match": {"kind": "search", "meta.zero_result": True}},
233
+ {"$group": {"_id": "$meta.q", "count": {"$sum": 1}}},
234
+ {"$sort": {"count": -1}}, {"$limit": 10},
235
+ ]))
236
+ unique_users = len(ev.distinct("anon_id", {"kind": "search"}))
237
+ return {
238
+ "total_searches": total_searches,
239
+ "unique_users": unique_users,
240
+ "by_mode": {row["_id"] or "unknown": row["count"] for row in by_mode},
241
+ "top_queries": [{"query": r["_id"], "count": r["count"]} for r in top_queries],
242
+ "zero_result_queries": [{"query": r["_id"], "count": r["count"]} for r in zero_result],
243
+ "indexed_documents": db.documents().count_documents({}),
244
+ "last_refresh": (db.settings().find_one({"_id": "catalog"}) or {}).get("last_refresh"),
245
+ }
246
+
247
+
248
+ # ── internal ──────────────────────────────────────────────────────────────────
249
+
250
+ def _log_event(kind: str, meta: dict) -> None:
251
+ try:
252
+ db.events().insert_one({
253
+ "kind": kind,
254
+ "meta": meta,
255
+ "anon_id": meta.get("anon_id", "anon"),
256
+ "at": dt.datetime.now(dt.timezone.utc),
257
+ })
258
+ except Exception: # noqa: BLE001 - telemetry must never break a request
259
+ pass