@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.
- package/package.json +1 -1
- package/src/corpus/capability-map.md +44 -15
- package/src/corpus/install.md +27 -0
- package/src/corpus/templates/semantic-search/AGENTS.md +216 -0
- package/src/corpus/templates/semantic-search/README.md +75 -0
- package/src/corpus/templates/semantic-search/cloudgrid.yaml +219 -0
- package/src/corpus/templates/semantic-search/index.md +108 -0
- package/src/corpus/templates/semantic-search/services/backend/requirements.txt +7 -0
- package/src/corpus/templates/semantic-search/services/backend/src/app/__init__.py +0 -0
- package/src/corpus/templates/semantic-search/services/backend/src/app/answer.py +90 -0
- package/src/corpus/templates/semantic-search/services/backend/src/app/auth.py +65 -0
- package/src/corpus/templates/semantic-search/services/backend/src/app/config.py +110 -0
- package/src/corpus/templates/semantic-search/services/backend/src/app/db.py +89 -0
- package/src/corpus/templates/semantic-search/services/backend/src/app/embeddings.py +44 -0
- package/src/corpus/templates/semantic-search/services/backend/src/app/indexing.py +223 -0
- package/src/corpus/templates/semantic-search/services/backend/src/app/main.py +259 -0
- package/src/corpus/templates/semantic-search/services/backend/src/app/search.py +222 -0
- package/src/corpus/templates/semantic-search/services/backend/src/app/source.py +259 -0
- package/src/corpus/templates/semantic-search/services/backend/src/main.py +12 -0
- package/src/corpus/templates/semantic-search/services/refresh/README.md +42 -0
- package/src/corpus/templates/semantic-search/services/refresh/requirements.txt +9 -0
- package/src/corpus/templates/semantic-search/services/refresh/src/app/__init__.py +0 -0
- package/src/corpus/templates/semantic-search/services/refresh/src/app/config.py +110 -0
- package/src/corpus/templates/semantic-search/services/refresh/src/app/db.py +89 -0
- package/src/corpus/templates/semantic-search/services/refresh/src/app/embeddings.py +44 -0
- package/src/corpus/templates/semantic-search/services/refresh/src/app/indexing.py +223 -0
- package/src/corpus/templates/semantic-search/services/refresh/src/app/source.py +259 -0
- package/src/corpus/templates/semantic-search/services/refresh/src/main.py +45 -0
- package/src/corpus/templates/semantic-search/services/web/index.html +12 -0
- package/src/corpus/templates/semantic-search/services/web/package-lock.json +1680 -0
- package/src/corpus/templates/semantic-search/services/web/package.json +19 -0
- package/src/corpus/templates/semantic-search/services/web/src/App.jsx +55 -0
- package/src/corpus/templates/semantic-search/services/web/src/api.js +125 -0
- package/src/corpus/templates/semantic-search/services/web/src/components/Dashboard.jsx +61 -0
- package/src/corpus/templates/semantic-search/services/web/src/components/ManagerLogin.jsx +39 -0
- package/src/corpus/templates/semantic-search/services/web/src/components/SearchView.jsx +166 -0
- package/src/corpus/templates/semantic-search/services/web/src/components/Settings.jsx +148 -0
- package/src/corpus/templates/semantic-search/services/web/src/main.jsx +10 -0
- package/src/corpus/templates/semantic-search/services/web/src/styles.css +78 -0
- package/src/corpus/templates/semantic-search/services/web/vite.config.js +10 -0
- package/src/corpus/workflows/semantic-search.md +142 -0
- 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
|