@cloudgrid-io/mcp 0.15.0 → 0.16.1

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.
@@ -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())
@@ -4,8 +4,8 @@ when: "search over my documents / PDFs / notes / knowledge base, semantic search
4
4
  needs: database
5
5
  deploy: runtime
6
6
  editions: local
7
- capabilities_note: "Document search — needs a database (Mongo) for the documents + chunks collections. Two services: a React (Vite) static frontend at / and a FastAPI backend at /backend. Hybrid search = Mongo $text (lexical) + in-app NumPy cosine over chunk embedding arrays (semantic) + metadata filters, blended to the document level. Runtime app, async build, local edition only. Declare needs:{database:true}. NO needs:vector — pgvector runtime role-grant is blocked (#1545), so embeddings live in the Mongo chunks collection and are cosine-ranked in-app. NO active cron — a Python type:cron refresh is blocked (#1585); ship the manager 'Refresh now' endpoint. Health is green with no secrets; startup ensure_indexes() so $text never 500s on an empty catalog. Pluggable source (dropbox|local|url) + embeddings (OpenAI-compatible) each behind one config point, all secrets from env."
8
- summary: "Build a document-search app on the grid — index a folder of PDFs/DOCX/TXT/MD, then search by keyword + meaning + metadata, with a grounded answer mode and manager admin. React static frontend at / + FastAPI backend at /backend, backed by grid-shared Mongo. Edition-gate first, scaffold, put the code under services/web/ + services/backend/, declare needs:{database:true} (NO vector #1545, NO active cron #1585), set the embeddings + source + manager secrets, deploy async, poll to a live URL, then Refresh now to index."
7
+ capabilities_note: "Document search — needs a database (Mongo) for the documents + chunks collections. Two services: a React (Vite) static frontend at / and a FastAPI backend at /backend. Hybrid search = Mongo $text (lexical) + in-app NumPy cosine over chunk embedding arrays (semantic) + metadata filters, blended to the document level. Runtime app, async build, local edition only. Declare needs:{database:true}. NO needs:vector — pgvector runtime role-grant is blocked (#1545), so embeddings live in the Mongo chunks collection and are cosine-ranked in-app. Two refresh paths: the manager 'Refresh now' endpoint AND a scheduled Python type:cron job (services/refresh, daily 03:00 UTC) that vendors the backend app/ modules and reuses indexing.run_sync — both degrade gracefully. Health is green with no secrets; startup ensure_indexes() so $text never 500s on an empty catalog. Pluggable source (dropbox|local|url) + embeddings (OpenAI-compatible) each behind one config point, all secrets from env."
8
+ summary: "Build a document-search app on the grid — index a folder of PDFs/DOCX/TXT/MD, then search by keyword + meaning + metadata, with a grounded answer mode and manager admin. React static frontend at / + FastAPI backend at /backend, backed by grid-shared Mongo. Edition-gate first, scaffold, put the code under services/web/ + services/backend/, declare needs:{database:true} (NO vector #1545), wire the web+backend+refresh services, set the embeddings + source + manager secrets, deploy async, poll to a live URL, then Refresh now (or wait for the daily cron) to index."
9
9
  recipe: "edition-gate -> auth+grid -> gridctl_init -> fetch template + read AGENTS.md -> put code under services/web + services/backend, set cloudgrid.yaml active fields -> grid secrets set (embeddings + source + MANAGER_PASSWORD_HASH) -> optional grid dev -> grid plug (async, poll) -> Refresh now to index -> return live URL"
10
10
  ---
11
11
 
@@ -53,7 +53,7 @@ writing anything — it defines the file tree, the `documents` + `chunks`
53
53
  collections + fields, the Mongo injection, the pluggable source adapter, the
54
54
  embeddings config point, the secrets, the deploy flow, and the four baked-in
55
55
  learnings you must NOT regress (health-without-secrets, startup index, `$text`
56
- guard, no vector / no cron).
56
+ guard, no `needs: vector`).
57
57
 
58
58
  ## 5. Put the code under services/ and fill cloudgrid.yaml
59
59
 
@@ -74,6 +74,11 @@ services:
74
74
  backend:
75
75
  type: python
76
76
  path: /backend
77
+ refresh: # scheduled reindex — Python cron, batch job
78
+ type: cron
79
+ schedule: "0 3 * * *" # daily 03:00
80
+ timezone: UTC
81
+ run: job
77
82
  needs:
78
83
  database: true
79
84
  ```
@@ -85,9 +90,12 @@ injects `DATABASE_MONGODB_URL` (+ legacy `MONGODB_URL`), read lazily in
85
90
 
86
91
  **NO `needs: vector`** — the pgvector runtime role-grant is blocked on platform
87
92
  issue **#1545**, so this template stores embeddings in the Mongo `chunks`
88
- collection (a float array) and cosine-ranks in-app. **NO active cron** — a Python
89
- `type: cron` refresh is blocked on **#1585**; the cron service stays commented and
90
- the manager "Refresh now" endpoint does the indexing.
93
+ collection (a float array) and cosine-ranks in-app. **The refresh cron IS active**
94
+ — a Python `type: cron`, `run: job` service (`services/refresh/`) reindexes daily
95
+ at 03:00 UTC, and the manager "Refresh now" endpoint indexes on demand. Both call
96
+ the same `indexing.run_sync`. Because each service builds in an isolated container,
97
+ the cron **vendors** the backend `app/` modules it needs into
98
+ `services/refresh/src/app/` (keep the copies in sync — see AGENTS.md §8).
91
99
 
92
100
  ## 6. Configure the source + embeddings (one config point each)
93
101
 
@@ -124,10 +132,11 @@ response is `status: "building"`, NOT a live URL yet.
124
132
  ## 9. Index + iterate
125
133
 
126
134
  Sign in as manager and hit **"Refresh now"** (Settings) to index the source into
127
- Mongo, then search. Give the user the live app URL — the deliverable. To iterate,
128
- re-plug the SAME entity so it updates the same URL.
135
+ Mongo immediately, then search — or let the daily `refresh` cron do it on schedule.
136
+ Give the user the live app URL — the deliverable. To iterate, re-plug the SAME
137
+ entity so it updates the same URL.
129
138
 
130
139
  Keep it honest: this ships real code you adapt, the build is async and
131
140
  local-edition only, Mongo is injected by `needs`, embeddings live in Mongo (no
132
- pgvector until #1545), and refresh is a manual endpoint until the cron unblocks
133
- (#1585).
141
+ pgvector until #1545), and refresh runs both on demand (endpoint) and on a schedule
142
+ (the `refresh` cron).