@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
|
+
"""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,12 @@
|
|
|
1
|
+
"""CloudGrid python-service entry point (services/backend/src/main.py).
|
|
2
|
+
|
|
3
|
+
Launches the FastAPI app (app/main.py) with uvicorn, binding to the platform's
|
|
4
|
+
injected PORT (default 8080). No secret is read here or at import time.
|
|
5
|
+
"""
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
import uvicorn
|
|
9
|
+
|
|
10
|
+
if __name__ == "__main__":
|
|
11
|
+
port = int(os.environ.get("PORT", "8080"))
|
|
12
|
+
uvicorn.run("app.main:app", host="0.0.0.0", port=port, workers=1)
|
|
@@ -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>
|