@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,90 @@
|
|
|
1
|
+
"""Grounded answer mode.
|
|
2
|
+
|
|
3
|
+
Composes an answer from the indexed documents ONLY, with citations back to the
|
|
4
|
+
documents used. No outside knowledge. Toggleable from Settings. If the answer LLM
|
|
5
|
+
is not configured, it falls back to an extractive answer (the top passages
|
|
6
|
+
themselves), still grounded and cited — never crashes.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from . import config, search
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _answer_enabled_from_db() -> bool:
|
|
16
|
+
from . import db
|
|
17
|
+
|
|
18
|
+
doc = db.settings().find_one({"_id": "features"}) or {}
|
|
19
|
+
return bool(doc.get("answer_mode_enabled", True))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def compose_answer(question: str) -> dict:
|
|
23
|
+
"""Return a grounded answer + the documents cited. Extractive fallback if no LLM."""
|
|
24
|
+
if not _answer_enabled_from_db():
|
|
25
|
+
return {"answer": None, "citations": [], "note": "Answer mode is disabled."}
|
|
26
|
+
|
|
27
|
+
hits = search.search(question, mode="hybrid", limit=5)
|
|
28
|
+
passages = hits["results"]
|
|
29
|
+
if not passages:
|
|
30
|
+
return {
|
|
31
|
+
"answer": None,
|
|
32
|
+
"citations": [],
|
|
33
|
+
"note": "No indexed material matches this question.",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
citations = [
|
|
37
|
+
{"document_id": p["document_id"], "title": p["title"], "collection": p["collection"]}
|
|
38
|
+
for p in passages
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
# Grounded generation only if the LLM key is present; otherwise return an
|
|
42
|
+
# extractive answer built from the retrieved passages.
|
|
43
|
+
key = config.embeddings_api_key()
|
|
44
|
+
if not key:
|
|
45
|
+
extractive = "\n\n".join(
|
|
46
|
+
f"- {p['title']}: {p['best_passage']}" for p in passages[:3]
|
|
47
|
+
)
|
|
48
|
+
return {
|
|
49
|
+
"answer": extractive,
|
|
50
|
+
"citations": citations,
|
|
51
|
+
"mode": "extractive",
|
|
52
|
+
"note": "Grounded generation unavailable (no API key) — showing "
|
|
53
|
+
"the most relevant source passages instead.",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
context = "\n\n".join(
|
|
57
|
+
f"[{i+1}] {p['title']}:\n{p['best_passage']}" for i, p in enumerate(passages)
|
|
58
|
+
)
|
|
59
|
+
system = (
|
|
60
|
+
"You answer strictly and only from the provided document passages. "
|
|
61
|
+
"Cite sources as [n]. If the passages do not contain the answer, say so. "
|
|
62
|
+
"Never use outside knowledge."
|
|
63
|
+
)
|
|
64
|
+
try:
|
|
65
|
+
resp = httpx.post(
|
|
66
|
+
f"{config.embeddings_base_url()}/chat/completions",
|
|
67
|
+
headers={"Authorization": f"Bearer {key}"},
|
|
68
|
+
json={
|
|
69
|
+
"model": config.answer_model(),
|
|
70
|
+
"messages": [
|
|
71
|
+
{"role": "system", "content": system},
|
|
72
|
+
{"role": "user", "content": f"Question: {question}\n\nSources:\n{context}"},
|
|
73
|
+
],
|
|
74
|
+
"temperature": 0.2,
|
|
75
|
+
},
|
|
76
|
+
timeout=60.0,
|
|
77
|
+
)
|
|
78
|
+
resp.raise_for_status()
|
|
79
|
+
text = resp.json()["choices"][0]["message"]["content"]
|
|
80
|
+
return {"answer": text, "citations": citations, "mode": "generated", "note": None}
|
|
81
|
+
except Exception as exc: # noqa: BLE001 - degrade to extractive, never crash
|
|
82
|
+
extractive = "\n\n".join(
|
|
83
|
+
f"- {p['title']}: {p['best_passage']}" for p in passages[:3]
|
|
84
|
+
)
|
|
85
|
+
return {
|
|
86
|
+
"answer": extractive,
|
|
87
|
+
"citations": citations,
|
|
88
|
+
"mode": "extractive",
|
|
89
|
+
"note": f"Generation failed ({exc}); showing source passages.",
|
|
90
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Server-side manager session auth.
|
|
2
|
+
|
|
3
|
+
Admin functions are enforced on the server, not just hidden in the UI. Sessions
|
|
4
|
+
are opaque tokens stored in Mongo. The password is compared against
|
|
5
|
+
MANAGER_PASSWORD_HASH (sha256 hex). If no hash is configured, admin login is
|
|
6
|
+
refused (never open by default) — but the app still starts and search stays
|
|
7
|
+
public.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import datetime as dt
|
|
12
|
+
import hashlib
|
|
13
|
+
import secrets
|
|
14
|
+
|
|
15
|
+
from fastapi import Header, HTTPException
|
|
16
|
+
|
|
17
|
+
from . import config, db
|
|
18
|
+
|
|
19
|
+
SESSION_TTL_HOURS = 12
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _hash(password: str) -> str:
|
|
23
|
+
return hashlib.sha256(password.encode("utf-8")).hexdigest()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def login(password: str) -> str:
|
|
27
|
+
configured = config.manager_password_hash()
|
|
28
|
+
if not configured:
|
|
29
|
+
raise HTTPException(
|
|
30
|
+
status_code=503,
|
|
31
|
+
detail="Manager auth is not configured (MANAGER_PASSWORD_HASH unset).",
|
|
32
|
+
)
|
|
33
|
+
if not secrets.compare_digest(_hash(password), configured):
|
|
34
|
+
raise HTTPException(status_code=401, detail="Invalid password.")
|
|
35
|
+
token = secrets.token_urlsafe(32)
|
|
36
|
+
db.get_db().sessions.insert_one({
|
|
37
|
+
"_id": token,
|
|
38
|
+
"created_at": dt.datetime.now(dt.timezone.utc),
|
|
39
|
+
"expires_at": dt.datetime.now(dt.timezone.utc) + dt.timedelta(hours=SESSION_TTL_HOURS),
|
|
40
|
+
})
|
|
41
|
+
return token
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def require_manager(authorization: str | None = Header(default=None)) -> str:
|
|
45
|
+
"""FastAPI dependency: validate the Bearer session token."""
|
|
46
|
+
if not authorization or not authorization.lower().startswith("bearer "):
|
|
47
|
+
raise HTTPException(status_code=401, detail="Manager session required.")
|
|
48
|
+
token = authorization.split(" ", 1)[1].strip()
|
|
49
|
+
session = db.get_db().sessions.find_one({"_id": token})
|
|
50
|
+
if not session:
|
|
51
|
+
raise HTTPException(status_code=401, detail="Invalid session.")
|
|
52
|
+
if session["expires_at"] < dt.datetime.now(dt.timezone.utc):
|
|
53
|
+
db.get_db().sessions.delete_one({"_id": token})
|
|
54
|
+
raise HTTPException(status_code=401, detail="Session expired.")
|
|
55
|
+
return token
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def change_password(current: str, new: str) -> str:
|
|
59
|
+
"""Self-contained password change. Returns the NEW hash for the manager to
|
|
60
|
+
store as the MANAGER_PASSWORD_HASH secret (we never persist the plaintext or
|
|
61
|
+
the secret ourselves)."""
|
|
62
|
+
configured = config.manager_password_hash()
|
|
63
|
+
if configured and not secrets.compare_digest(_hash(current), configured):
|
|
64
|
+
raise HTTPException(status_code=401, detail="Current password is incorrect.")
|
|
65
|
+
return _hash(new)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Central configuration and secret gating.
|
|
2
|
+
|
|
3
|
+
CRITICAL CloudGrid rule: NEVER read a secret at module import time. Every value
|
|
4
|
+
here is resolved lazily inside a function, so the process starts and passes
|
|
5
|
+
`GET /backend/health` even when NO secret is set. Search and indexing degrade
|
|
6
|
+
gracefully (empty results + a clear "not indexed / missing secret" note) rather
|
|
7
|
+
than crashing.
|
|
8
|
+
|
|
9
|
+
The Mongo connection is the ONLY hard dependency for health, and it is injected
|
|
10
|
+
automatically by CloudGrid (`needs: database`) as DATABASE_MONGODB_URL.
|
|
11
|
+
|
|
12
|
+
Two pluggable config points:
|
|
13
|
+
- SOURCE_TYPE selects the document-source adapter (dropbox | local | url).
|
|
14
|
+
- EMBEDDINGS_* selects the embeddings provider (OpenAI-compatible by default).
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ── Mongo (injected by the grid — the only health-critical config) ──────────
|
|
22
|
+
|
|
23
|
+
def mongo_url() -> str | None:
|
|
24
|
+
"""Injected by CloudGrid `needs: database`. Never set by hand."""
|
|
25
|
+
return os.environ.get("DATABASE_MONGODB_URL") or os.environ.get("MONGODB_URL")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ── Embeddings (one config point, gated) ─────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
def embeddings_api_key() -> str | None:
|
|
31
|
+
return os.environ.get("EMBEDDINGS_API_KEY")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def embeddings_model() -> str:
|
|
35
|
+
# Changing the model changes vector dimensions and requires a full reindex.
|
|
36
|
+
return os.environ.get("EMBEDDINGS_MODEL", "text-embedding-3-small")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def embeddings_base_url() -> str:
|
|
40
|
+
# Any OpenAI-compatible endpoint. Default is OpenAI.
|
|
41
|
+
return os.environ.get("EMBEDDINGS_BASE_URL", "https://api.openai.com/v1")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def embeddings_configured() -> bool:
|
|
45
|
+
return bool(embeddings_api_key())
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def answer_model() -> str:
|
|
49
|
+
return os.environ.get("ANSWER_MODEL", "gpt-4o-mini")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ── Source adapter (one config point, gated) ─────────────────────────────────
|
|
53
|
+
|
|
54
|
+
def source_type() -> str:
|
|
55
|
+
"""dropbox | local | url. Selects the document-source adapter."""
|
|
56
|
+
return os.environ.get("SOURCE_TYPE", "dropbox")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def dropbox_config() -> dict[str, str | None]:
|
|
60
|
+
return {
|
|
61
|
+
"app_key": os.environ.get("DROPBOX_APP_KEY"),
|
|
62
|
+
"app_secret": os.environ.get("DROPBOX_APP_SECRET"),
|
|
63
|
+
"refresh_token": os.environ.get("DROPBOX_REFRESH_TOKEN"),
|
|
64
|
+
"folder_path": os.environ.get("DROPBOX_FOLDER_PATH", ""),
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def local_source_path() -> str:
|
|
69
|
+
"""Directory the local adapter reads. Point at a `needs: disk` mount
|
|
70
|
+
(DISK_PATH) or a bundled folder."""
|
|
71
|
+
return os.environ.get("LOCAL_SOURCE_PATH", os.environ.get("DISK_PATH", ""))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def url_source_manifest() -> str | None:
|
|
75
|
+
"""A newline- or comma-separated list of document URLs the url adapter
|
|
76
|
+
fetches. Set as a secret or plain env var."""
|
|
77
|
+
return os.environ.get("URL_SOURCE_MANIFEST")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def source_configured() -> bool:
|
|
81
|
+
st = source_type()
|
|
82
|
+
if st == "dropbox":
|
|
83
|
+
c = dropbox_config()
|
|
84
|
+
return bool(c["app_key"] and c["app_secret"] and c["refresh_token"])
|
|
85
|
+
if st == "local":
|
|
86
|
+
return bool(local_source_path())
|
|
87
|
+
if st == "url":
|
|
88
|
+
return bool(url_source_manifest())
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ── Manager auth (gated) ──────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
def manager_password_hash() -> str | None:
|
|
95
|
+
"""A sha256 hex digest of the manager password. Compared server-side."""
|
|
96
|
+
return os.environ.get("MANAGER_PASSWORD_HASH")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def manager_auth_configured() -> bool:
|
|
100
|
+
return bool(manager_password_hash())
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ── Search blend weights ─────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
def semantic_weight() -> float:
|
|
106
|
+
return float(os.environ.get("SEMANTIC_WEIGHT", "0.55"))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def keyword_weight() -> float:
|
|
110
|
+
return float(os.environ.get("KEYWORD_WEIGHT", "0.45"))
|
|
@@ -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,222 @@
|
|
|
1
|
+
"""Incremental indexing pipeline: discover -> extract -> chunk -> embed -> store.
|
|
2
|
+
|
|
3
|
+
Shared by the manager 'Refresh now' endpoint (and, once platform issue #1585
|
|
4
|
+
lands, a `type: cron` refresh service). It is resilient: if the source or
|
|
5
|
+
embeddings are not configured it records a clear, non-crashing report and
|
|
6
|
+
returns. Only new/changed files (by rev / content-hash) are reprocessed.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import datetime as dt
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
from . import config, db, embeddings, source
|
|
15
|
+
|
|
16
|
+
CHUNK_CHARS = 1200
|
|
17
|
+
CHUNK_OVERLAP = 150
|
|
18
|
+
|
|
19
|
+
# Generic date detection from filename or document head (best effort, nullable).
|
|
20
|
+
_DATE_RE = re.compile(r"(\d{4})[./-](\d{1,2})[./-](\d{1,2})") # 2026-07-05
|
|
21
|
+
_DATE_RE_ALT = re.compile(r"(\d{1,2})[./-](\d{1,2})[./-](\d{2,4})") # 05/07/2026
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _utcnow() -> dt.datetime:
|
|
25
|
+
return dt.datetime.now(dt.timezone.utc)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _chunk_text(text: str) -> list[str]:
|
|
29
|
+
text = text.strip()
|
|
30
|
+
if not text:
|
|
31
|
+
return []
|
|
32
|
+
out: list[str] = []
|
|
33
|
+
start = 0
|
|
34
|
+
n = len(text)
|
|
35
|
+
while start < n:
|
|
36
|
+
end = min(start + CHUNK_CHARS, n)
|
|
37
|
+
out.append(text[start:end])
|
|
38
|
+
if end == n:
|
|
39
|
+
break
|
|
40
|
+
start = end - CHUNK_OVERLAP
|
|
41
|
+
return out
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _parse_metadata(name: str, text: str) -> dict:
|
|
45
|
+
"""Best-effort title / date / collection extraction. All fields nullable.
|
|
46
|
+
|
|
47
|
+
Generalize this for your domain: `collection` is a free-form grouping tag
|
|
48
|
+
(folder, series, category), `date` is an ISO date if one can be found.
|
|
49
|
+
"""
|
|
50
|
+
title = os.path.splitext(name)[0].strip()
|
|
51
|
+
# Collection tag = the leading non-numeric words of the title.
|
|
52
|
+
collection = re.sub(r"[\d].*$", "", title).strip() or None
|
|
53
|
+
|
|
54
|
+
date_iso = None
|
|
55
|
+
haystack = f"{name}\n{text[:2000]}"
|
|
56
|
+
m = _DATE_RE.search(haystack)
|
|
57
|
+
if m:
|
|
58
|
+
y, mo, d = m.groups()
|
|
59
|
+
try:
|
|
60
|
+
date_iso = dt.datetime(int(y), int(mo), int(d)).date().isoformat()
|
|
61
|
+
except ValueError:
|
|
62
|
+
date_iso = None
|
|
63
|
+
if date_iso is None:
|
|
64
|
+
m = _DATE_RE_ALT.search(haystack)
|
|
65
|
+
if m:
|
|
66
|
+
d, mo, y = m.groups()
|
|
67
|
+
if len(y) == 2:
|
|
68
|
+
y = "20" + y
|
|
69
|
+
try:
|
|
70
|
+
date_iso = dt.datetime(int(y), int(mo), int(d)).date().isoformat()
|
|
71
|
+
except ValueError:
|
|
72
|
+
date_iso = None
|
|
73
|
+
|
|
74
|
+
return {"title": title, "collection": collection, "date": date_iso}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def run_sync(triggered_by: str = "manual") -> dict:
|
|
78
|
+
"""Run one incremental sync. Never raises on missing config — returns a report."""
|
|
79
|
+
started = _utcnow()
|
|
80
|
+
report: dict = {
|
|
81
|
+
"started_at": started,
|
|
82
|
+
"triggered_by": triggered_by,
|
|
83
|
+
"files": [],
|
|
84
|
+
"status": "ok",
|
|
85
|
+
"note": None,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
# Mongo is required for indexing (and always injected). Fail loud only here.
|
|
89
|
+
db.ensure_indexes()
|
|
90
|
+
|
|
91
|
+
if not config.source_configured():
|
|
92
|
+
report["status"] = "skipped"
|
|
93
|
+
report["note"] = (
|
|
94
|
+
f"Source not configured (SOURCE_TYPE={config.source_type()!r} — "
|
|
95
|
+
"missing its secrets/config)."
|
|
96
|
+
)
|
|
97
|
+
_save_report(report)
|
|
98
|
+
return report
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
src = source.get_source()
|
|
102
|
+
files = src.list_files()
|
|
103
|
+
except Exception as exc: # noqa: BLE001 - resilient by design
|
|
104
|
+
report["status"] = "error"
|
|
105
|
+
report["note"] = f"Source listing failed: {exc}"
|
|
106
|
+
_save_report(report)
|
|
107
|
+
return report
|
|
108
|
+
|
|
109
|
+
embeddings_ok = config.embeddings_configured()
|
|
110
|
+
if not embeddings_ok:
|
|
111
|
+
report["note"] = (
|
|
112
|
+
"Embeddings not configured — documents indexed for lexical/metadata "
|
|
113
|
+
"search only (no semantic vectors)."
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
for f in files:
|
|
117
|
+
outcome = _process_file(src, f, embeddings_ok)
|
|
118
|
+
report["files"].append(outcome)
|
|
119
|
+
|
|
120
|
+
report["finished_at"] = _utcnow()
|
|
121
|
+
_save_report(report)
|
|
122
|
+
return report
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _process_file(src: source.Source, f: source.SourceFile, embeddings_ok: bool) -> dict:
|
|
126
|
+
existing = db.documents().find_one({"source_filename": f.name})
|
|
127
|
+
if existing and existing.get("source_rev") == f.rev:
|
|
128
|
+
return {"file": f.name, "result": "unchanged"}
|
|
129
|
+
|
|
130
|
+
if f.is_audio and not f.is_text:
|
|
131
|
+
# Catalogue audio/media with a flag; do not search inside it.
|
|
132
|
+
doc = {
|
|
133
|
+
"title": os.path.splitext(f.name)[0],
|
|
134
|
+
"source_filename": f.name,
|
|
135
|
+
"source_rev": f.rev,
|
|
136
|
+
"file_path": f.path,
|
|
137
|
+
"collection": None,
|
|
138
|
+
"date": None,
|
|
139
|
+
"has_media": True,
|
|
140
|
+
"media_path": f.path,
|
|
141
|
+
"word_count": 0,
|
|
142
|
+
"indexed_at": _utcnow(),
|
|
143
|
+
}
|
|
144
|
+
doc_id = _upsert_document(doc)
|
|
145
|
+
db.chunks().delete_many({"document_id": doc_id})
|
|
146
|
+
return {"file": f.name, "result": "catalogued_media"}
|
|
147
|
+
|
|
148
|
+
if not f.is_text:
|
|
149
|
+
return {"file": f.name, "result": "skipped_unsupported"}
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
raw = src.download(f.path)
|
|
153
|
+
text = source.extract_text(f.name, raw)
|
|
154
|
+
except Exception as exc: # noqa: BLE001
|
|
155
|
+
return {"file": f.name, "result": "error", "detail": str(exc)}
|
|
156
|
+
|
|
157
|
+
meta = _parse_metadata(f.name, text)
|
|
158
|
+
doc = {
|
|
159
|
+
**meta,
|
|
160
|
+
"source_filename": f.name,
|
|
161
|
+
"source_rev": f.rev,
|
|
162
|
+
"file_path": f.path,
|
|
163
|
+
"has_media": False,
|
|
164
|
+
"media_path": None,
|
|
165
|
+
"word_count": len(text.split()),
|
|
166
|
+
"indexed_at": _utcnow(),
|
|
167
|
+
}
|
|
168
|
+
doc_id = _upsert_document(doc)
|
|
169
|
+
|
|
170
|
+
# Rebuild chunks for this document.
|
|
171
|
+
db.chunks().delete_many({"document_id": doc_id})
|
|
172
|
+
parts = _chunk_text(text)
|
|
173
|
+
vectors: list[list[float] | None] = [None] * len(parts)
|
|
174
|
+
if embeddings_ok and parts:
|
|
175
|
+
try:
|
|
176
|
+
vectors = embeddings.embed(parts) # type: ignore[assignment]
|
|
177
|
+
except Exception as exc: # noqa: BLE001
|
|
178
|
+
return {
|
|
179
|
+
"file": f.name,
|
|
180
|
+
"result": "indexed_no_embeddings",
|
|
181
|
+
"detail": f"embedding failed: {exc}",
|
|
182
|
+
"chunks": len(parts),
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
docs = []
|
|
186
|
+
for i, content in enumerate(parts):
|
|
187
|
+
docs.append({
|
|
188
|
+
"document_id": doc_id,
|
|
189
|
+
"chunk_index": i,
|
|
190
|
+
"content": content,
|
|
191
|
+
"embedding": vectors[i] if i < len(vectors) else None,
|
|
192
|
+
"token_count": len(content.split()),
|
|
193
|
+
})
|
|
194
|
+
if docs:
|
|
195
|
+
db.chunks().insert_many(docs)
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
"file": f.name,
|
|
199
|
+
"result": "indexed" if embeddings_ok else "indexed_no_embeddings",
|
|
200
|
+
"chunks": len(docs),
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _upsert_document(doc: dict):
|
|
205
|
+
db.documents().update_one(
|
|
206
|
+
{"source_filename": doc["source_filename"]},
|
|
207
|
+
{"$set": doc},
|
|
208
|
+
upsert=True,
|
|
209
|
+
)
|
|
210
|
+
return db.documents().find_one({"source_filename": doc["source_filename"]})["_id"]
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _save_report(report: dict) -> None:
|
|
214
|
+
try:
|
|
215
|
+
db.index_reports().insert_one(dict(report))
|
|
216
|
+
db.settings().update_one(
|
|
217
|
+
{"_id": "catalog"},
|
|
218
|
+
{"$set": {"last_refresh": report.get("finished_at") or report["started_at"]}},
|
|
219
|
+
upsert=True,
|
|
220
|
+
)
|
|
221
|
+
except Exception: # noqa: BLE001 - reporting must never crash the caller
|
|
222
|
+
pass
|