@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.
- package/package.json +1 -1
- package/src/corpus/capability-map.md +14 -1
- package/src/corpus/templates/semantic-search/AGENTS.md +192 -0
- package/src/corpus/templates/semantic-search/README.md +74 -0
- package/src/corpus/templates/semantic-search/cloudgrid.yaml +217 -0
- package/src/corpus/templates/semantic-search/index.md +97 -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 +222 -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/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 +133 -0
- 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
|
+
}
|