@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,89 @@
|
|
|
1
|
+
"""Lazy MongoDB access.
|
|
2
|
+
|
|
3
|
+
The client and connection string are resolved LAZILY (inside get_client), never
|
|
4
|
+
at module top level — so importing this module never touches the network and
|
|
5
|
+
never requires the env var to exist. CloudGrid injects DATABASE_MONGODB_URL at
|
|
6
|
+
runtime from `needs: database`.
|
|
7
|
+
|
|
8
|
+
Collections:
|
|
9
|
+
documents — one row per source document (title, collection tag, date, ...).
|
|
10
|
+
chunks — one embeddable passage per document (content + embedding array).
|
|
11
|
+
settings — feature flags, synonyms, catalog metadata (last_refresh).
|
|
12
|
+
usage_events, index_reports, sessions — telemetry / admin.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
from pymongo import ASCENDING, MongoClient, TEXT
|
|
19
|
+
from pymongo.database import Database
|
|
20
|
+
|
|
21
|
+
from . import config
|
|
22
|
+
|
|
23
|
+
_client: Optional[MongoClient] = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_client() -> MongoClient:
|
|
27
|
+
global _client
|
|
28
|
+
if _client is None:
|
|
29
|
+
uri = config.mongo_url()
|
|
30
|
+
if not uri:
|
|
31
|
+
raise RuntimeError(
|
|
32
|
+
"DATABASE_MONGODB_URL is not set. CloudGrid injects it "
|
|
33
|
+
"automatically from `needs: database` — run with `grid dev` "
|
|
34
|
+
"locally or deploy with `grid plug`. Do not set it by hand."
|
|
35
|
+
)
|
|
36
|
+
# Short server-selection timeout so health checks fail fast, not hang.
|
|
37
|
+
_client = MongoClient(uri, serverSelectionTimeoutMS=3000)
|
|
38
|
+
return _client
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_db() -> Database:
|
|
42
|
+
# Default DB name comes from the connection-string path segment the grid injects.
|
|
43
|
+
return get_client().get_default_database()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def ping() -> bool:
|
|
47
|
+
"""True if Mongo is reachable. Used by /backend/health."""
|
|
48
|
+
get_client().admin.command("ping")
|
|
49
|
+
return True
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def ensure_indexes() -> None:
|
|
53
|
+
"""Create the indexes the search relies on. Safe to call repeatedly.
|
|
54
|
+
|
|
55
|
+
Called at FastAPI startup so the chunks $text index exists before any query
|
|
56
|
+
— without it, `$text` raises IndexNotFound (a 500) on an empty catalog.
|
|
57
|
+
"""
|
|
58
|
+
db = get_db()
|
|
59
|
+
# Text index on chunk content for lexical / $text search.
|
|
60
|
+
db.chunks.create_index([("content", TEXT)], name="chunks_content_text")
|
|
61
|
+
db.chunks.create_index([("document_id", ASCENDING)], name="chunks_document_id")
|
|
62
|
+
# Metadata filter indexes on documents.
|
|
63
|
+
db.documents.create_index([("date", ASCENDING)], name="documents_date")
|
|
64
|
+
db.documents.create_index([("collection", ASCENDING)], name="documents_collection")
|
|
65
|
+
db.documents.create_index(
|
|
66
|
+
[("source_filename", ASCENDING)], name="documents_source_filename", unique=True
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ── Collection accessors ─────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
def documents():
|
|
73
|
+
return get_db().documents
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def chunks():
|
|
77
|
+
return get_db().chunks
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def settings():
|
|
81
|
+
return get_db().settings
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def events():
|
|
85
|
+
return get_db().usage_events
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def index_reports():
|
|
89
|
+
return get_db().index_reports
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Single config point for embeddings (OpenAI-compatible).
|
|
2
|
+
|
|
3
|
+
Isolated behind one module so the provider can be swapped by env var
|
|
4
|
+
(EMBEDDINGS_BASE_URL / EMBEDDINGS_MODEL / EMBEDDINGS_API_KEY). NEVER reads the
|
|
5
|
+
API key at import time — only inside embed(). If the key is missing, embed()
|
|
6
|
+
raises EmbeddingsUnavailable, which callers translate into a graceful degraded
|
|
7
|
+
state (lexical + metadata search still work).
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from . import config
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class EmbeddingsUnavailable(RuntimeError):
|
|
17
|
+
"""Raised when embeddings are requested but not configured/reachable."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def embed(texts: list[str]) -> list[list[float]]:
|
|
21
|
+
"""Return one embedding vector per input text.
|
|
22
|
+
|
|
23
|
+
Raises EmbeddingsUnavailable if EMBEDDINGS_API_KEY is not set.
|
|
24
|
+
"""
|
|
25
|
+
if not texts:
|
|
26
|
+
return []
|
|
27
|
+
key = config.embeddings_api_key()
|
|
28
|
+
if not key:
|
|
29
|
+
raise EmbeddingsUnavailable(
|
|
30
|
+
"EMBEDDINGS_API_KEY is not set — semantic embedding is unavailable."
|
|
31
|
+
)
|
|
32
|
+
resp = httpx.post(
|
|
33
|
+
f"{config.embeddings_base_url()}/embeddings",
|
|
34
|
+
headers={"Authorization": f"Bearer {key}"},
|
|
35
|
+
json={"model": config.embeddings_model(), "input": texts},
|
|
36
|
+
timeout=60.0,
|
|
37
|
+
)
|
|
38
|
+
resp.raise_for_status()
|
|
39
|
+
data = resp.json()["data"]
|
|
40
|
+
return [row["embedding"] for row in data]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def embed_one(text: str) -> list[float]:
|
|
44
|
+
return embed([text])[0]
|
|
@@ -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
|
+
"""Pluggable document-source adapter, selected by SOURCE_TYPE.
|
|
2
|
+
|
|
3
|
+
The source is where the raw documents *live*, not the query database. Three
|
|
4
|
+
adapters ship here:
|
|
5
|
+
|
|
6
|
+
- dropbox : read-only Dropbox via the offline refresh-token flow.
|
|
7
|
+
- local : a directory on the container filesystem (e.g. a `needs: disk`
|
|
8
|
+
mount at DISK_PATH, or a bundled folder via LOCAL_SOURCE_PATH).
|
|
9
|
+
- url : a manifest of document URLs (URL_SOURCE_MANIFEST) fetched over HTTP.
|
|
10
|
+
|
|
11
|
+
Add your own by subclassing Source and returning it from get_source(). Never read
|
|
12
|
+
source secrets at import time; a missing config surfaces as SourceUnavailable,
|
|
13
|
+
handled gracefully by the indexing pipeline (it records a report and exits 0).
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import io
|
|
18
|
+
import os
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
|
|
23
|
+
from . import config
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SourceUnavailable(RuntimeError):
|
|
27
|
+
"""Raised when the configured source is not usable (missing secrets, etc)."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# File classes we handle.
|
|
31
|
+
TEXT_EXTS = {".txt", ".md"}
|
|
32
|
+
PDF_EXTS = {".pdf"}
|
|
33
|
+
DOCX_EXTS = {".docx", ".doc"}
|
|
34
|
+
AUDIO_EXTS = {".mp3", ".m4a", ".wav", ".aac", ".ogg"}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class SourceFile:
|
|
39
|
+
path: str # source-internal path/URL (never exposed to clients)
|
|
40
|
+
name: str # filename only
|
|
41
|
+
rev: str # content revision / hash for incremental sync
|
|
42
|
+
size: int
|
|
43
|
+
is_audio: bool
|
|
44
|
+
is_text: bool # text-extractable (txt/md/pdf/docx)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _classify(name: str) -> tuple[bool, bool]:
|
|
48
|
+
ext = os.path.splitext(name)[1].lower()
|
|
49
|
+
return (ext in AUDIO_EXTS, ext in (TEXT_EXTS | PDF_EXTS | DOCX_EXTS))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Source:
|
|
53
|
+
def list_files(self) -> list[SourceFile]:
|
|
54
|
+
raise NotImplementedError
|
|
55
|
+
|
|
56
|
+
def download(self, path: str) -> bytes:
|
|
57
|
+
raise NotImplementedError
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ── Dropbox adapter ───────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
class DropboxSource(Source):
|
|
63
|
+
"""Read-only Dropbox via the offline refresh-token flow."""
|
|
64
|
+
|
|
65
|
+
def __init__(self) -> None:
|
|
66
|
+
c = config.dropbox_config()
|
|
67
|
+
if not (c["app_key"] and c["app_secret"] and c["refresh_token"]):
|
|
68
|
+
raise SourceUnavailable(
|
|
69
|
+
"Dropbox is not configured — set DROPBOX_APP_KEY, "
|
|
70
|
+
"DROPBOX_APP_SECRET and DROPBOX_REFRESH_TOKEN."
|
|
71
|
+
)
|
|
72
|
+
self._app_key = c["app_key"]
|
|
73
|
+
self._app_secret = c["app_secret"]
|
|
74
|
+
self._refresh_token = c["refresh_token"]
|
|
75
|
+
self._folder = (c["folder_path"] or "").rstrip("/")
|
|
76
|
+
self._token: str | None = None
|
|
77
|
+
|
|
78
|
+
def _access_token(self) -> str:
|
|
79
|
+
if self._token:
|
|
80
|
+
return self._token
|
|
81
|
+
resp = httpx.post(
|
|
82
|
+
"https://api.dropbox.com/oauth2/token",
|
|
83
|
+
data={
|
|
84
|
+
"grant_type": "refresh_token",
|
|
85
|
+
"refresh_token": self._refresh_token,
|
|
86
|
+
},
|
|
87
|
+
auth=(self._app_key, self._app_secret),
|
|
88
|
+
timeout=30.0,
|
|
89
|
+
)
|
|
90
|
+
resp.raise_for_status()
|
|
91
|
+
self._token = resp.json()["access_token"]
|
|
92
|
+
return self._token
|
|
93
|
+
|
|
94
|
+
def _headers(self) -> dict[str, str]:
|
|
95
|
+
return {"Authorization": f"Bearer {self._access_token()}"}
|
|
96
|
+
|
|
97
|
+
def list_files(self) -> list[SourceFile]:
|
|
98
|
+
out: list[SourceFile] = []
|
|
99
|
+
url = "https://api.dropboxapi.com/2/files/list_folder"
|
|
100
|
+
body: dict = {"path": self._folder, "recursive": True}
|
|
101
|
+
while True:
|
|
102
|
+
resp = httpx.post(url, headers=self._headers(), json=body, timeout=60.0)
|
|
103
|
+
resp.raise_for_status()
|
|
104
|
+
payload = resp.json()
|
|
105
|
+
for entry in payload.get("entries", []):
|
|
106
|
+
if entry.get(".tag") != "file":
|
|
107
|
+
continue
|
|
108
|
+
out.append(self._to_source_file(entry))
|
|
109
|
+
if payload.get("has_more"):
|
|
110
|
+
url = "https://api.dropboxapi.com/2/files/list_folder/continue"
|
|
111
|
+
body = {"cursor": payload["cursor"]}
|
|
112
|
+
else:
|
|
113
|
+
break
|
|
114
|
+
return out
|
|
115
|
+
|
|
116
|
+
@staticmethod
|
|
117
|
+
def _to_source_file(entry: dict) -> SourceFile:
|
|
118
|
+
name = entry["name"]
|
|
119
|
+
is_audio, is_text = _classify(name)
|
|
120
|
+
return SourceFile(
|
|
121
|
+
path=entry["path_lower"],
|
|
122
|
+
name=name,
|
|
123
|
+
rev=entry.get("content_hash") or entry.get("rev", ""),
|
|
124
|
+
size=int(entry.get("size", 0)),
|
|
125
|
+
is_audio=is_audio,
|
|
126
|
+
is_text=is_text,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def download(self, path: str) -> bytes:
|
|
130
|
+
import json
|
|
131
|
+
|
|
132
|
+
resp = httpx.post(
|
|
133
|
+
"https://content.dropboxapi.com/2/files/download",
|
|
134
|
+
headers={
|
|
135
|
+
**self._headers(),
|
|
136
|
+
"Dropbox-API-Arg": json.dumps({"path": path}),
|
|
137
|
+
},
|
|
138
|
+
timeout=120.0,
|
|
139
|
+
)
|
|
140
|
+
resp.raise_for_status()
|
|
141
|
+
return resp.content
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ── Local-filesystem adapter ──────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
class LocalSource(Source):
|
|
147
|
+
"""Read documents from a directory on the container filesystem.
|
|
148
|
+
|
|
149
|
+
Point LOCAL_SOURCE_PATH (or a `needs: disk` mount at DISK_PATH) at a folder
|
|
150
|
+
of .txt/.md/.pdf/.docx files. `rev` is the file mtime+size, so changed files
|
|
151
|
+
re-index on the next sync.
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
def __init__(self) -> None:
|
|
155
|
+
root = config.local_source_path()
|
|
156
|
+
if not root:
|
|
157
|
+
raise SourceUnavailable(
|
|
158
|
+
"Local source is not configured — set LOCAL_SOURCE_PATH "
|
|
159
|
+
"(or mount a disk and it will default to DISK_PATH)."
|
|
160
|
+
)
|
|
161
|
+
if not os.path.isdir(root):
|
|
162
|
+
raise SourceUnavailable(f"LOCAL_SOURCE_PATH does not exist: {root!r}")
|
|
163
|
+
self._root = root
|
|
164
|
+
|
|
165
|
+
def list_files(self) -> list[SourceFile]:
|
|
166
|
+
out: list[SourceFile] = []
|
|
167
|
+
for dirpath, _dirs, files in os.walk(self._root):
|
|
168
|
+
for name in files:
|
|
169
|
+
full = os.path.join(dirpath, name)
|
|
170
|
+
is_audio, is_text = _classify(name)
|
|
171
|
+
try:
|
|
172
|
+
st = os.stat(full)
|
|
173
|
+
except OSError:
|
|
174
|
+
continue
|
|
175
|
+
out.append(SourceFile(
|
|
176
|
+
path=full,
|
|
177
|
+
name=name,
|
|
178
|
+
rev=f"{int(st.st_mtime)}-{st.st_size}",
|
|
179
|
+
size=st.st_size,
|
|
180
|
+
is_audio=is_audio,
|
|
181
|
+
is_text=is_text,
|
|
182
|
+
))
|
|
183
|
+
return out
|
|
184
|
+
|
|
185
|
+
def download(self, path: str) -> bytes:
|
|
186
|
+
with open(path, "rb") as fh:
|
|
187
|
+
return fh.read()
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ── URL-manifest adapter ──────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
class UrlSource(Source):
|
|
193
|
+
"""Fetch documents from a manifest of URLs.
|
|
194
|
+
|
|
195
|
+
URL_SOURCE_MANIFEST is a newline- or comma-separated list of document URLs.
|
|
196
|
+
The filename is the URL's last path segment; `rev` is the URL itself
|
|
197
|
+
(override by pointing the manifest at content-hashed URLs).
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
def __init__(self) -> None:
|
|
201
|
+
manifest = config.url_source_manifest()
|
|
202
|
+
if not manifest:
|
|
203
|
+
raise SourceUnavailable(
|
|
204
|
+
"URL source is not configured — set URL_SOURCE_MANIFEST to a "
|
|
205
|
+
"newline- or comma-separated list of document URLs."
|
|
206
|
+
)
|
|
207
|
+
raw = manifest.replace(",", "\n").splitlines()
|
|
208
|
+
self._urls = [u.strip() for u in raw if u.strip()]
|
|
209
|
+
|
|
210
|
+
def list_files(self) -> list[SourceFile]:
|
|
211
|
+
out: list[SourceFile] = []
|
|
212
|
+
for u in self._urls:
|
|
213
|
+
name = u.rstrip("/").rsplit("/", 1)[-1] or "document"
|
|
214
|
+
is_audio, is_text = _classify(name)
|
|
215
|
+
out.append(SourceFile(
|
|
216
|
+
path=u,
|
|
217
|
+
name=name,
|
|
218
|
+
rev=u,
|
|
219
|
+
size=0,
|
|
220
|
+
is_audio=is_audio,
|
|
221
|
+
is_text=is_text,
|
|
222
|
+
))
|
|
223
|
+
return out
|
|
224
|
+
|
|
225
|
+
def download(self, path: str) -> bytes:
|
|
226
|
+
resp = httpx.get(path, timeout=120.0, follow_redirects=True)
|
|
227
|
+
resp.raise_for_status()
|
|
228
|
+
return resp.content
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def get_source() -> Source:
|
|
232
|
+
"""Build the configured source adapter. Raises SourceUnavailable if unusable."""
|
|
233
|
+
st = config.source_type()
|
|
234
|
+
if st == "dropbox":
|
|
235
|
+
return DropboxSource()
|
|
236
|
+
if st == "local":
|
|
237
|
+
return LocalSource()
|
|
238
|
+
if st == "url":
|
|
239
|
+
return UrlSource()
|
|
240
|
+
raise SourceUnavailable(f"Unknown SOURCE_TYPE: {st!r}")
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# ── Text extraction (used by the pipeline) ───────────────────────────────────
|
|
244
|
+
|
|
245
|
+
def extract_text(name: str, raw: bytes) -> str:
|
|
246
|
+
ext = os.path.splitext(name)[1].lower()
|
|
247
|
+
if ext in TEXT_EXTS:
|
|
248
|
+
return raw.decode("utf-8", errors="replace")
|
|
249
|
+
if ext in PDF_EXTS:
|
|
250
|
+
from pypdf import PdfReader
|
|
251
|
+
|
|
252
|
+
reader = PdfReader(io.BytesIO(raw))
|
|
253
|
+
return "\n".join((page.extract_text() or "") for page in reader.pages)
|
|
254
|
+
if ext in DOCX_EXTS:
|
|
255
|
+
from docx import Document
|
|
256
|
+
|
|
257
|
+
doc = Document(io.BytesIO(raw))
|
|
258
|
+
return "\n".join(p.text for p in doc.paragraphs)
|
|
259
|
+
return ""
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""CloudGrid cron-job entry point (services/refresh/src/main.py).
|
|
2
|
+
|
|
3
|
+
A `type: cron` service with `run: job` runs this file to completion once per
|
|
4
|
+
schedule, then exits — there is NO HTTP server here. It reuses the SAME
|
|
5
|
+
incremental indexing pipeline as the manager 'Refresh now' endpoint
|
|
6
|
+
(`app.indexing.run_sync`) and connects DIRECTLY to the grid-injected
|
|
7
|
+
`DATABASE_MONGODB_URL` (from the app-level `needs: { database: true }`), so it
|
|
8
|
+
does not depend on the backend service being reachable over the network.
|
|
9
|
+
|
|
10
|
+
The backend's `app/` package is VENDORED into this service's own
|
|
11
|
+
`services/refresh/src/app/` (config, db, source, embeddings, indexing). Each
|
|
12
|
+
CloudGrid service builds from its own folder in an isolated container, so a
|
|
13
|
+
cross-folder import of `services/backend/src/app` would not resolve here — the
|
|
14
|
+
copy is the CloudGrid-native way to share the logic. See services/refresh/README.md
|
|
15
|
+
for the drift note (keep the copies in sync with the backend originals).
|
|
16
|
+
|
|
17
|
+
Graceful by design: if the document source or the embeddings key is not
|
|
18
|
+
configured, `run_sync` records a clear report and returns WITHOUT raising, so the
|
|
19
|
+
scheduled run is a clean no-op (source_configured: false / 0 documents) rather
|
|
20
|
+
than a crash. No secret is read at import time.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import sys
|
|
25
|
+
|
|
26
|
+
from app import indexing
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def main() -> int:
|
|
30
|
+
report = indexing.run_sync(triggered_by="cron")
|
|
31
|
+
status = report.get("status", "unknown")
|
|
32
|
+
note = report.get("note")
|
|
33
|
+
files = report.get("files", [])
|
|
34
|
+
# A single, greppable summary line for `grid logs` / the manager index-report.
|
|
35
|
+
print(
|
|
36
|
+
f"cron refresh: status={status} files={len(files)}"
|
|
37
|
+
+ (f" note={note!r}" if note else "")
|
|
38
|
+
)
|
|
39
|
+
# A skipped/degraded run is a SUCCESS (graceful no-op), not a failure — only a
|
|
40
|
+
# genuine pipeline error should surface a non-zero exit for the scheduler.
|
|
41
|
+
return 1 if status == "error" else 0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
if __name__ == "__main__":
|
|
45
|
+
sys.exit(main())
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>Document Search</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="/src/main.jsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|