@cloudgrid-io/mcp 0.14.0 → 0.15.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 (32) hide show
  1. package/package.json +1 -1
  2. package/src/corpus/capability-map.md +14 -1
  3. package/src/corpus/templates/semantic-search/AGENTS.md +192 -0
  4. package/src/corpus/templates/semantic-search/README.md +74 -0
  5. package/src/corpus/templates/semantic-search/cloudgrid.yaml +217 -0
  6. package/src/corpus/templates/semantic-search/index.md +97 -0
  7. package/src/corpus/templates/semantic-search/services/backend/requirements.txt +7 -0
  8. package/src/corpus/templates/semantic-search/services/backend/src/app/__init__.py +0 -0
  9. package/src/corpus/templates/semantic-search/services/backend/src/app/answer.py +90 -0
  10. package/src/corpus/templates/semantic-search/services/backend/src/app/auth.py +65 -0
  11. package/src/corpus/templates/semantic-search/services/backend/src/app/config.py +110 -0
  12. package/src/corpus/templates/semantic-search/services/backend/src/app/db.py +89 -0
  13. package/src/corpus/templates/semantic-search/services/backend/src/app/embeddings.py +44 -0
  14. package/src/corpus/templates/semantic-search/services/backend/src/app/indexing.py +222 -0
  15. package/src/corpus/templates/semantic-search/services/backend/src/app/main.py +259 -0
  16. package/src/corpus/templates/semantic-search/services/backend/src/app/search.py +222 -0
  17. package/src/corpus/templates/semantic-search/services/backend/src/app/source.py +259 -0
  18. package/src/corpus/templates/semantic-search/services/backend/src/main.py +12 -0
  19. package/src/corpus/templates/semantic-search/services/web/index.html +12 -0
  20. package/src/corpus/templates/semantic-search/services/web/package-lock.json +1680 -0
  21. package/src/corpus/templates/semantic-search/services/web/package.json +19 -0
  22. package/src/corpus/templates/semantic-search/services/web/src/App.jsx +55 -0
  23. package/src/corpus/templates/semantic-search/services/web/src/api.js +125 -0
  24. package/src/corpus/templates/semantic-search/services/web/src/components/Dashboard.jsx +61 -0
  25. package/src/corpus/templates/semantic-search/services/web/src/components/ManagerLogin.jsx +39 -0
  26. package/src/corpus/templates/semantic-search/services/web/src/components/SearchView.jsx +166 -0
  27. package/src/corpus/templates/semantic-search/services/web/src/components/Settings.jsx +148 -0
  28. package/src/corpus/templates/semantic-search/services/web/src/main.jsx +10 -0
  29. package/src/corpus/templates/semantic-search/services/web/src/styles.css +78 -0
  30. package/src/corpus/templates/semantic-search/services/web/vite.config.js +10 -0
  31. package/src/corpus/workflows/semantic-search.md +133 -0
  32. package/src/tools.js +73 -12
@@ -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
+ This is the supported refresh path today. A scheduled `type: cron` refresh
209
+ service is a follow-up (blocked on platform issue #1585); see AGENTS.md.
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
@@ -0,0 +1,222 @@
1
+ """CloudGrid-native hybrid search over Mongo.
2
+
3
+ Modes blended in one query:
4
+ - lexical/exact : Mongo $text index over chunks.content (+ synonym expansion)
5
+ - semantic : in-app cosine similarity (NumPy) over chunk embedding arrays
6
+ - metadata : Mongo filters on documents (date, collection)
7
+
8
+ Results aggregate to the document level; each result carries its single best
9
+ matching passage with a highlight. Counting questions use countDocuments.
10
+
11
+ Everything degrades gracefully:
12
+ - no embeddings / no query vector -> semantic score = 0, lexical carries it
13
+ - no $text index yet (empty catalog) -> lexical contributes nothing, no 500
14
+ - empty catalog -> empty results + a clear reason
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import re
19
+
20
+ import numpy as np
21
+ from pymongo.errors import OperationFailure
22
+
23
+ from . import config, db, embeddings
24
+
25
+
26
+ def _synonyms() -> dict[str, list[str]]:
27
+ doc = db.settings().find_one({"_id": "synonyms"}) or {}
28
+ return doc.get("map", {})
29
+
30
+
31
+ def _expand_query(q: str) -> str:
32
+ syn = _synonyms()
33
+ extra: list[str] = []
34
+ for token in q.split():
35
+ for alt in syn.get(token, []):
36
+ extra.append(alt)
37
+ return q if not extra else q + " " + " ".join(extra)
38
+
39
+
40
+ def _metadata_filter(collection: str | None, date_from: str | None, date_to: str | None) -> dict:
41
+ f: dict = {}
42
+ if collection:
43
+ f["collection"] = collection
44
+ date_clause: dict = {}
45
+ if date_from:
46
+ date_clause["$gte"] = date_from
47
+ if date_to:
48
+ date_clause["$lte"] = date_to
49
+ if date_clause:
50
+ f["date"] = date_clause
51
+ return f
52
+
53
+
54
+ def _cosine(query_vec: np.ndarray, mat: np.ndarray) -> np.ndarray:
55
+ if mat.size == 0:
56
+ return np.array([])
57
+ qn = query_vec / (np.linalg.norm(query_vec) + 1e-9)
58
+ norms = np.linalg.norm(mat, axis=1) + 1e-9
59
+ return (mat @ qn) / norms
60
+
61
+
62
+ def _highlight(content: str, query: str) -> str:
63
+ terms = [t for t in re.split(r"\s+", query.strip()) if len(t) >= 2]
64
+ highlighted = content
65
+ for t in sorted(set(terms), key=len, reverse=True):
66
+ highlighted = re.sub(
67
+ f"({re.escape(t)})", r"«\1»", highlighted, flags=re.IGNORECASE
68
+ )
69
+ return highlighted
70
+
71
+
72
+ def count_documents(collection: str | None = None,
73
+ date_from: str | None = None,
74
+ date_to: str | None = None) -> int:
75
+ """Direct count from the DB (countDocuments), not estimated from results."""
76
+ return db.documents().count_documents(_metadata_filter(collection, date_from, date_to))
77
+
78
+
79
+ def search(query: str,
80
+ mode: str = "hybrid",
81
+ collection: str | None = None,
82
+ date_from: str | None = None,
83
+ date_to: str | None = None,
84
+ limit: int = 20) -> dict:
85
+ """Blended search. mode in {hybrid, lexical, semantic, metadata}."""
86
+ meta_filter = _metadata_filter(collection, date_from, date_to)
87
+
88
+ # Candidate documents after metadata pre-filter (shrinks the semantic set).
89
+ doc_ids = None
90
+ if meta_filter:
91
+ doc_ids = [d["_id"] for d in db.documents().find(meta_filter, {"_id": 1})]
92
+ if not doc_ids:
93
+ return {"results": [], "total_matching_documents": 0, "mode": mode,
94
+ "note": "No documents match the metadata filter."}
95
+
96
+ # Pure metadata browse (no text query).
97
+ if mode == "metadata" or not query.strip():
98
+ return _metadata_only(meta_filter, limit)
99
+
100
+ chunk_scope: dict = {}
101
+ if doc_ids is not None:
102
+ chunk_scope["document_id"] = {"$in": doc_ids}
103
+
104
+ # ── lexical score via $text ──────────────────────────────────────────────
105
+ lexical: dict = {} # chunk_id -> (score, content, document_id)
106
+ if mode in ("hybrid", "lexical"):
107
+ expanded = _expand_query(query)
108
+ try:
109
+ cursor = db.chunks().find(
110
+ {**chunk_scope, "$text": {"$search": expanded}},
111
+ {"content": 1, "document_id": 1, "score": {"$meta": "textScore"}},
112
+ ).sort([("score", {"$meta": "textScore"})]).limit(500)
113
+ for c in cursor:
114
+ lexical[c["_id"]] = (float(c.get("score", 0.0)), c["content"], c["document_id"])
115
+ except OperationFailure:
116
+ # No text index yet (empty catalog, not indexed) — lexical contributes
117
+ # nothing rather than 500ing. ensure_indexes() at startup normally
118
+ # prevents this; this is belt-and-suspenders.
119
+ pass
120
+
121
+ # ── semantic score via in-app cosine ─────────────────────────────────────
122
+ semantic: dict = {} # chunk_id -> (score, content, document_id)
123
+ semantic_note = None
124
+ if mode in ("hybrid", "semantic"):
125
+ if not config.embeddings_configured():
126
+ semantic_note = "Semantic search unavailable (embeddings not configured)."
127
+ else:
128
+ try:
129
+ qvec = np.array(embeddings.embed_one(query), dtype=np.float32)
130
+ candidate = db.chunks().find(
131
+ {**chunk_scope, "embedding": {"$ne": None}},
132
+ {"content": 1, "document_id": 1, "embedding": 1},
133
+ ).limit(50000)
134
+ ids, contents, dids, mat = [], [], [], []
135
+ for c in candidate:
136
+ ids.append(c["_id"])
137
+ contents.append(c["content"])
138
+ dids.append(c["document_id"])
139
+ mat.append(c["embedding"])
140
+ if mat:
141
+ sims = _cosine(qvec, np.array(mat, dtype=np.float32))
142
+ for cid, content, did, s in zip(ids, contents, dids, sims):
143
+ semantic[cid] = (float(s), content, did)
144
+ except Exception as exc: # noqa: BLE001
145
+ semantic_note = f"Semantic search degraded: {exc}"
146
+
147
+ # ── normalize + blend at the chunk level ─────────────────────────────────
148
+ def _normalize(d: dict) -> dict:
149
+ if not d:
150
+ return {}
151
+ vals = [v[0] for v in d.values()]
152
+ lo, hi = min(vals), max(vals)
153
+ span = (hi - lo) or 1.0
154
+ return {k: (v[0] - lo) / span for k, v in d.items()}
155
+
156
+ lex_n = _normalize(lexical)
157
+ sem_n = _normalize(semantic)
158
+ sw, kw = config.semantic_weight(), config.keyword_weight()
159
+ if mode == "lexical":
160
+ sw, kw = 0.0, 1.0
161
+ elif mode == "semantic":
162
+ sw, kw = 1.0, 0.0
163
+
164
+ blended: dict = {} # chunk_id -> (score, content, document_id)
165
+ for cid in set(lex_n) | set(sem_n):
166
+ content, did = (lexical.get(cid) or semantic.get(cid))[1:3]
167
+ score = sw * sem_n.get(cid, 0.0) + kw * lex_n.get(cid, 0.0)
168
+ blended[cid] = (score, content, did)
169
+
170
+ # ── aggregate to document level: keep best passage per document ──────────
171
+ best_per_doc: dict = {} # document_id -> (score, content, chunk_id)
172
+ for cid, (score, content, did) in blended.items():
173
+ cur = best_per_doc.get(did)
174
+ if cur is None or score > cur[0]:
175
+ best_per_doc[did] = (score, content, cid)
176
+
177
+ ranked = sorted(best_per_doc.items(), key=lambda kv: kv[1][0], reverse=True)[:limit]
178
+
179
+ results = []
180
+ for did, (score, content, cid) in ranked:
181
+ doc = db.documents().find_one({"_id": did})
182
+ if not doc:
183
+ continue
184
+ results.append({
185
+ "document_id": str(did),
186
+ "title": doc.get("title"),
187
+ "collection": doc.get("collection"),
188
+ "date": doc.get("date"),
189
+ "has_media": doc.get("has_media", False),
190
+ "score": round(score, 4),
191
+ "best_passage": _highlight(content, query),
192
+ "source_filename": doc.get("source_filename"),
193
+ })
194
+
195
+ return {
196
+ "results": results,
197
+ "total_matching_documents": len(best_per_doc),
198
+ "mode": mode,
199
+ "note": semantic_note,
200
+ }
201
+
202
+
203
+ def _metadata_only(meta_filter: dict, limit: int) -> dict:
204
+ cursor = db.documents().find(meta_filter).sort("date", -1).limit(limit)
205
+ results = []
206
+ for doc in cursor:
207
+ results.append({
208
+ "document_id": str(doc["_id"]),
209
+ "title": doc.get("title"),
210
+ "collection": doc.get("collection"),
211
+ "date": doc.get("date"),
212
+ "has_media": doc.get("has_media", False),
213
+ "score": None,
214
+ "best_passage": None,
215
+ "source_filename": doc.get("source_filename"),
216
+ })
217
+ return {
218
+ "results": results,
219
+ "total_matching_documents": db.documents().count_documents(meta_filter),
220
+ "mode": "metadata",
221
+ "note": None,
222
+ }