patchvec 0.5.6__py3-none-any.whl

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.
pave/config.py ADDED
@@ -0,0 +1,240 @@
1
+ # (C) 2025 Rodrigo Rodrigues da Silva <rodrigopitanga@posteo.net>
2
+ # SPDX-License-Identifier: GPL-3.0-or-later
3
+
4
+ from __future__ import annotations
5
+ import os, re, yaml, threading, logging
6
+ from pathlib import Path
7
+ from typing import Any, Dict
8
+ from dotenv import load_dotenv
9
+
10
+ load_dotenv()
11
+
12
+ _ENV_PREFIX = "PATCHVEC_"
13
+
14
+ _DEFAULT_CONFIG_PATH = os.environ.get(_ENV_PREFIX + "CONFIG", "./config.yml")
15
+
16
+ _DEFAULTS = {
17
+ "data_dir": "./data",
18
+ "auth": {"mode": "none", "api_keys": {}, "tenants_file": None},
19
+ "vector_store": {"type": "default"},
20
+ }
21
+
22
+ _ENV_PATTERN = re.compile(r"\$\{([^}:|]+)(?:\|([^}]*))?\}")
23
+
24
+ # ---------------- utils ----------------
25
+ def _deep_merge(a: Dict[str, Any], b: Dict[str, Any]) -> Dict[str, Any]:
26
+ out = dict(a)
27
+ for k, v in (b or {}).items():
28
+ if isinstance(v, dict) and isinstance(out.get(k), dict):
29
+ out[k] = _deep_merge(out[k], v)
30
+ elif v is not None:
31
+ out[k] = v
32
+ return out
33
+
34
+ def _coerce(s: str) -> Any:
35
+ if isinstance(s, str):
36
+ low = s.lower()
37
+ if low in {"true", "false"}:
38
+ return low == "true"
39
+ try:
40
+ if s.isdigit():
41
+ return int(s)
42
+ return float(s)
43
+ except Exception:
44
+ return s
45
+ return s
46
+
47
+ def _subst_env(value: Any) -> Any:
48
+ if not isinstance(value, str):
49
+ return value
50
+ def repl(m: re.Match) -> str:
51
+ key = m.group(1)
52
+ default = m.group(2) if m.group(2) is not None else ""
53
+ return os.environ.get(key, default)
54
+ return _ENV_PATTERN.sub(repl, value)
55
+
56
+ def _resolve_env_in_obj(obj: Any) -> Any:
57
+ if isinstance(obj, dict):
58
+ return {k: _resolve_env_in_obj(v) for k, v in obj.items()}
59
+ if isinstance(obj, list):
60
+ return [_resolve_env_in_obj(v) for v in obj]
61
+ return _subst_env(obj)
62
+
63
+ def _env_to_dict(prefix: str = _ENV_PREFIX) -> Dict[str, Any]:
64
+ envmap: Dict[str, Any] = {}
65
+ for k, v in os.environ.items():
66
+ if not k.startswith(prefix):
67
+ continue
68
+ path = k[len(prefix):].lower().split("__")
69
+ cur = envmap
70
+ for part in path[:-1]:
71
+ cur = cur.setdefault(part, {})
72
+ cur[path[-1]] = _coerce(v)
73
+ return envmap
74
+
75
+ def _load_yaml(path: str | Path) -> Dict[str, Any]:
76
+ p = Path(path)
77
+ if p.is_file():
78
+ with p.open("r", encoding="utf-8") as f:
79
+ return yaml.safe_load(f) or {}
80
+ return {}
81
+
82
+ # --------------- singleton wrapper ----------------
83
+ class Config:
84
+ """
85
+ Single backing dict; thread-safe. Can be constructed from a file path
86
+ or from a pre-built dict. `get(path, default)` persists default.
87
+ """
88
+ def __init__(
89
+ self,
90
+ data: Dict[str, Any] | None = None,
91
+ path: str | Path | None = None):
92
+ self._lock = threading.RLock()
93
+ if data is None:
94
+ data = self._load_dict(path or _DEFAULT_CONFIG_PATH)
95
+ self._cfg: Dict[str, Any] = dict(data)
96
+ self._data = self._cfg # back-compat alias for old tests
97
+
98
+ # --- main loader, now a static/class member ---
99
+ @staticmethod
100
+ def _load_dict(path: str | Path) -> Dict[str, Any]:
101
+ file_cfg = _resolve_env_in_obj(_load_yaml(path))
102
+ tenants_file = file_cfg.get("auth", {}).get("tenants_file") \
103
+ if isinstance(file_cfg.get("auth"), dict) else None
104
+ if tenants_file:
105
+ tcfg = _resolve_env_in_obj(_load_yaml(tenants_file))
106
+ file_cfg = _deep_merge(file_cfg, tcfg)
107
+ env_cfg = _env_to_dict()
108
+ return _deep_merge(_deep_merge(_DEFAULTS, file_cfg), env_cfg)
109
+
110
+ # -------- path ops --------
111
+ def _get_from(self, store: Dict[str, Any], path: str):
112
+ cur: Any = store
113
+ for part in path.split("."):
114
+ if not isinstance(cur, dict) or part not in cur:
115
+ return None
116
+ cur = cur[part]
117
+ return cur
118
+
119
+ def _set_into(self, store: Dict[str, Any], path: str, value: Any) -> None:
120
+ cur = store
121
+ parts = path.split(".")
122
+ for p in parts[:-1]:
123
+ cur = cur.setdefault(p, {})
124
+ cur[parts[-1]] = value
125
+
126
+ # -------- public API --------
127
+ def get(self, path: str, default: Any = None) -> Any:
128
+ with self._lock:
129
+ val = self._get_from(self._cfg, path)
130
+ if val is not None:
131
+ return val
132
+ if default is not None:
133
+ # persist default on first read (previous behavior)
134
+ self._set_into(self._cfg, path, default)
135
+ return default
136
+ return None
137
+
138
+ def set(self, path: str, value: Any) -> None:
139
+ with self._lock:
140
+ self._set_into(self._cfg, path, value)
141
+
142
+ def as_dict(self) -> Dict[str, Any]:
143
+ with self._lock:
144
+ # shallow copy is enough for read-only views in tests/health
145
+ return _deep_merge({}, self._cfg)
146
+
147
+ def snapshot(self) -> Dict[str, Any]:
148
+ return self.as_dict()
149
+
150
+ # replace all config in place (keeps object identity for back-compat)
151
+ def replace(self, data: Dict[str, Any] | None = None,
152
+ path: str | Path | None = None) -> None:
153
+ fresh = Config(data=data, path=path)
154
+ with self._lock:
155
+ self._cfg.clear()
156
+ self._cfg.update(fresh._cfg)
157
+ self._data = self._cfg # keep the back-compat alias valid
158
+
159
+ # attribute sugar (cfg.instance_name, cfg.auth, ...)
160
+ def __getattr__(self, item):
161
+ with self._lock:
162
+ v = self._cfg.get(item)
163
+ if isinstance(v, dict):
164
+ # lightweight view (shares the same store via a child Config)
165
+ child = Config({})
166
+ # point child to the same backing dict (no copy)
167
+ child._cfg = v
168
+ return child
169
+ return v
170
+
171
+ # --- singleton access (API + CLI + tests share this) ---
172
+ _CFG_SINGLETON = Config()
173
+
174
+ def get_cfg() -> Config:
175
+ return _CFG_SINGLETON
176
+
177
+ def reload_cfg(path: str | None = None) -> Config:
178
+ # hard reload from disk/env; keep the same object for back-compat
179
+ _CFG_SINGLETON.replace(path=path)
180
+ return _CFG_SINGLETON
181
+
182
+ CFG = _CFG_SINGLETON
183
+
184
+ # --- singleton logger ---
185
+ def _init_logger() -> logging.Logger:
186
+ """
187
+ Initializes hierarchical logging levels:
188
+ - pave (base)
189
+ - watch namespaces (base -1 → more verbose)
190
+ - quiet namespaces (base +1 → less verbose)
191
+ - all others (base +2)
192
+ """
193
+ cfg = get_cfg()
194
+ base_level = getattr(logging, cfg.get("loglevel", "WARN").upper(), logging.INFO)
195
+
196
+ def shift(level, delta):
197
+ """Moves numeric loglevel by ±10 per step."""
198
+ return min(logging.CRITICAL, max(logging.DEBUG, level + 10 * delta))
199
+
200
+ root = logging.getLogger()
201
+ root.setLevel(shift(base_level, +2)) # default = quietest
202
+
203
+ handler = logging.StreamHandler()
204
+ handler.setFormatter(logging.Formatter(
205
+ "%(asctime)s [%(levelname)s] %(name)s: %(message)s",
206
+ "%H:%M:%S"
207
+ ))
208
+ root.handlers.clear()
209
+ root.addHandler(handler)
210
+
211
+ # main project
212
+ if cfg.get("dev",0):
213
+ logging.getLogger("pave").setLevel(logging.DEBUG)
214
+ else:
215
+ logging.getLogger("pave").setLevel(base_level)
216
+
217
+ #namespaces fixed to loglevel.DEBUG
218
+ debug = cfg.get("log.debug", [])
219
+ for ns in debug:
220
+ logging.getLogger(ns).setLevel(logging.DEBUG)
221
+
222
+ # namespaces that should be more verbose
223
+ watch = cfg.get("log.watch", ["txtai"])
224
+ for ns in watch:
225
+ logging.getLogger(ns).setLevel(shift(base_level, -1))
226
+
227
+ # namespaces that should be quieter
228
+ quiet = cfg.get("log.quiet", ["fastapi", "uvicorn", "sqlalchemy", "urllib"])
229
+ for ns in quiet:
230
+ logging.getLogger(ns).setLevel(shift(base_level, +1))
231
+
232
+ return logging.getLogger("pave")
233
+
234
+ _LOGGER_SINGLETON = _init_logger()
235
+
236
+ def get_logger() -> logging.Logger:
237
+ """Returns global PatchVec logger"""
238
+ return _LOGGER_SINGLETON
239
+
240
+ LOG = _LOGGER_SINGLETON
@@ -0,0 +1 @@
1
+ # pkg
pave/embedders/base.py ADDED
@@ -0,0 +1,12 @@
1
+ # (C) 2025 Rodrigo Rodrigues da Silva <rodrigopitanga@posteo.net>
2
+ # SPDX-License-Identifier: GPL-3.0-or-later
3
+ from __future__ import annotations
4
+ from abc import ABC, abstractmethod
5
+
6
+ class BaseEmbedder(ABC):
7
+ @abstractmethod
8
+ def encode(self, texts: list[str]) -> list[list[float]]: ...
9
+
10
+ @property
11
+ @abstractmethod
12
+ def dim(self) -> int | None: ...
@@ -0,0 +1,21 @@
1
+ # (C) 2025 Rodrigo Rodrigues da Silva <rodrigopitanga@posteo.net>
2
+ # SPDX-License-Identifier: GPL-3.0-or-later
3
+
4
+ from __future__ import annotations
5
+ from .base import BaseEmbedder
6
+ from ..config import CFG
7
+
8
+ def get_embedder(cfg: CFG = CFG) -> BaseEmbedder:
9
+ etype = (cfg.get("embedder.type", "default") or "default").lower()
10
+ match etype:
11
+ case "default" | "txtai": # vendor-neutral; bw compatible with 'txtai'
12
+ from .txtai_emb import TxtaiEmbedder
13
+ return TxtaiEmbedder()
14
+ case "sbert":
15
+ from .sbert_emb import SbertEmbedder
16
+ return SbertEmbedder()
17
+ case "openai":
18
+ from .openai_emb import OpenAIEmbedder
19
+ return OpenAIEmbedder()
20
+ case _:
21
+ raise RuntimeError(f"Unknown embedder.type: {etype}")
@@ -0,0 +1,30 @@
1
+ # (C) 2025 Rodrigo Rodrigues da Silva <rodrigopitanga@posteo.net>
2
+ # SPDX-License-Identifier: GPL-3.0-or-later
3
+ from __future__ import annotations
4
+ import os
5
+ from openai import OpenAI
6
+ from ..config import CFG
7
+
8
+ class OpenAIEmbedder:
9
+ def __init__(self):
10
+ self.model = CFG.get("embedder.model", "text-embedding-3-small")
11
+ self.batch_size = int(CFG.get("embedder.batch_size", 256))
12
+ self._dim = CFG.get("embedder.dim")
13
+ api_key = CFG.get("embedder.api_key") or os.environ.get("OPENAI_API_KEY")
14
+ if not api_key:
15
+ raise RuntimeError("OpenAI API key not configured")
16
+ self.client = OpenAI(api_key=api_key)
17
+
18
+ @property
19
+ def dim(self) -> int | None:
20
+ try:
21
+ return int(self._dim) if self._dim is not None else None
22
+ except Exception:
23
+ return None
24
+
25
+ def encode(self, texts: list[str]) -> list[list[float]]:
26
+ kwargs = {"model": self.model, "input": texts}
27
+ if self._dim is not None:
28
+ kwargs["dimensions"] = int(self._dim)
29
+ res = self.client.embeddings.create(**kwargs)
30
+ return [d.embedding for d in res.data]
@@ -0,0 +1,24 @@
1
+ # (C) 2025 Rodrigo Rodrigues da Silva <rodrigopitanga@posteo.net>
2
+ # SPDX-License-Identifier: GPL-3.0-or-later
3
+ from __future__ import annotations
4
+ from sentence_transformers import SentenceTransformer
5
+ from ..config import CFG
6
+
7
+ class SbertEmbedder:
8
+ def __init__(self):
9
+ model_name = CFG.get("embedder.model", "sentence-transformers/all-MiniLM-L6-v2")
10
+ device = CFG.get("embedder.device", "auto")
11
+ self.batch_size = int(CFG.get("embedder.batch_size", 64))
12
+ self.model = SentenceTransformer(model_name, device=device)
13
+ try:
14
+ self._dim = int(self.model.get_sentence_embedding_dimension())
15
+ except Exception:
16
+ self._dim = None
17
+
18
+ @property
19
+ def dim(self) -> int | None:
20
+ return self._dim
21
+
22
+ def encode(self, texts: list[str]) -> list[list[float]]:
23
+ vecs = self.model.encode(texts, batch_size=self.batch_size, show_progress_bar=False, convert_to_numpy=True)
24
+ return vecs.tolist()
@@ -0,0 +1,58 @@
1
+ # (C) 2025 Rodrigo Rodrigues da Silva <rodrigopitanga@posteo.net>
2
+ # SPDX-License-Identifier: GPL-3.0-or-later
3
+
4
+ from __future__ import annotations
5
+ from typing import Any, Dict, List
6
+ from txtai.embeddings import Embeddings
7
+ from ..config import CFG
8
+
9
+ class TxtaiEmbedder:
10
+ """
11
+ Generic txtai-powered embedder.
12
+ Uses txtai.Embeddings under the hood just for embedding (no indexing here).
13
+ You can switch models/backends via config.
14
+
15
+ Config (examples):
16
+ embedder:
17
+ type: txtai
18
+ txtai:
19
+ # simplest: sentence-transformers model path (default)
20
+ path: sentence-transformers/all-MiniLM-L6-v2
21
+
22
+ # or explicit method + path
23
+ # method: transformers # or 'sentence-transformers'
24
+ # path: sentence-transformers/paraphrase-MiniLM-L3-v2
25
+
26
+ # any other txtai Embeddings config fields can be passed through here.
27
+ """
28
+ def __init__(self):
29
+ # Back-compat: fall back to top-level embed_model if txtai section missing
30
+ section = CFG.get("embedder.txtai", {}) or {}
31
+ path = section.get("path") or CFG.get("embedder.model") or CFG.get("embed_model")
32
+
33
+ # Build the embeddings config for txtai
34
+ cfg: Dict[str, Any] = dict(section)
35
+ if "path" not in cfg and path:
36
+ cfg["path"] = path
37
+ # If method omitted, txtai will infer based on path; that’s fine.
38
+
39
+ # Ensure we’re only using txtai to embed (no index path persistence here)
40
+ # txtai will lazy-load the model as needed.
41
+ self._emb = Embeddings(cfg)
42
+
43
+ # Try to capture dim if available (not guaranteed)
44
+ try:
45
+ # Some txtai models expose : self._emb.model.get_sentence_embedding_dimension()
46
+ # We don’t rely on it; just best-effort.
47
+ dim = getattr(getattr(self._emb, "model", None), "get_sentence_embedding_dimension", None)
48
+ self._dim = int(dim()) if callable(dim) else None
49
+ except Exception:
50
+ self._dim = None
51
+
52
+ @property
53
+ def dim(self) -> int | None:
54
+ return self._dim
55
+
56
+ def encode(self, texts: List[str]) -> List[List[float]]:
57
+ # txtai expects list[str], returns list[list[float]]
58
+ return self._emb.embed(texts)
pave/main.py ADDED
@@ -0,0 +1,303 @@
1
+ # (C) 2025 Rodrigo Rodrigues da Silva <rodrigopitanga@posteo.net>
2
+ # SPDX-License-Identifier: GPL-3.0-or-later
3
+
4
+ from __future__ import annotations
5
+
6
+ import json, os, logging
7
+ import uvicorn
8
+
9
+ from fastapi import FastAPI, Header, Body, File, UploadFile, Form, Path, \
10
+ Query, Depends, Request, HTTPException
11
+ from fastapi.responses import JSONResponse, PlainTextResponse
12
+ from pydantic import BaseModel
13
+ from typing import Optional, Dict, Any, Annotated
14
+
15
+ from pave.config import get_cfg, get_logger
16
+ from pave.auth import AuthContext, auth_ctx, authorize_tenant, \
17
+ enforce_policy, resolve_bind
18
+ from pave.metrics import inc, set_error, snapshot, to_prometheus
19
+ from pave.stores.factory import get_store
20
+ from pave.stores.base import BaseStore
21
+ from pave.service import \
22
+ create_collection as svc_create_collection, \
23
+ delete_collection as svc_delete_collection, \
24
+ ingest_document as svc_ingest_document, \
25
+ do_search as svc_do_search
26
+
27
+
28
+ VERSION = "0.5.6"
29
+
30
+ class SearchBody(BaseModel):
31
+ q: str
32
+ k: int = 5
33
+ filters: Optional[Dict[str, Any]] = None
34
+
35
+ # Dependency injection builder
36
+ def build_app(cfg=get_cfg()) -> FastAPI:
37
+ app = FastAPI(
38
+ title=cfg.get("instance.name","Patchvec"),
39
+ description=cfg.get("instance.desc","Vector Search Microservice")
40
+ )
41
+ app.state.store = get_store(cfg)
42
+ app.state.cfg = cfg
43
+ app.state.version = VERSION
44
+
45
+ def current_store(request: Request) -> BaseStore:
46
+ return request.app.state.store
47
+
48
+
49
+ # -------------------- Health --------------------
50
+
51
+ def _readiness_check() -> Dict[str, Any]:
52
+ details: Dict[str, Any] = {
53
+ "data_dir": cfg.get("data_dir"),
54
+ "vector_store": cfg.get("vector_store.type"),
55
+ "writable": False,
56
+ "vector_backend_init": False,
57
+ }
58
+ try:
59
+ os.makedirs(cfg.data_dir, exist_ok=True)
60
+ testfile = os.path.join(cfg.data_dir, ".writetest")
61
+ with open(testfile, "w", encoding="utf-8") as f:
62
+ f.write("ok")
63
+ os.remove(testfile)
64
+ details["writable"] = True
65
+ except Exception as e:
66
+ details["writable"] = False
67
+ set_error(f"fs: {e}")
68
+ try:
69
+ request_store = app.state.store
70
+ request_store.load_or_init("_system", "health")
71
+ details["vector_backend_init"] = True
72
+ except Exception as e:
73
+ details["vector_backend_init"] = False
74
+ set_error(f"vec: {e}")
75
+ details["ok"] = bool(
76
+ details["writable"] and details["vector_backend_init"])
77
+ details["version"] = VERSION
78
+ return details
79
+
80
+ @app.get("/health")
81
+ def health():
82
+ inc("requests_total")
83
+ d = _readiness_check()
84
+ status = "ready" if d.get("ok") else "degraded"
85
+ return {"ok": d["ok"], "status": status, "version": VERSION}
86
+
87
+ @app.get("/health/live")
88
+ def health_live():
89
+ inc("requests_total")
90
+ return {"ok": True, "status": "live", "version": VERSION}
91
+
92
+ @app.get("/health/ready")
93
+ def health_ready():
94
+ inc("requests_total")
95
+ d = _readiness_check()
96
+ code = 200 if d.get("ok") else 503
97
+ return JSONResponse(d, status_code=code)
98
+
99
+ @app.get("/health/metrics")
100
+ def health_metrics():
101
+ inc("requests_total")
102
+ extra = {
103
+ "version": VERSION,
104
+ "vector_store": cfg.get("vector_store.type"),
105
+ "auth": cfg.get("auth.mode")
106
+ }
107
+ return snapshot(extra)
108
+
109
+ @app.get("/metrics")
110
+ def metrics_prom():
111
+ inc("requests_total")
112
+ txt = to_prometheus(build={
113
+ "version": VERSION,
114
+ "vector_store": cfg.get("vector_store.type"),
115
+ "auth":cfg.get("auth.mode")
116
+ })
117
+ return PlainTextResponse(txt, media_type="text/plain; version=0.0.4")
118
+
119
+
120
+ # ----------------- Core API ------------------
121
+
122
+ @app.post("/collections/{tenant}/{name}")
123
+ def create_collection(
124
+ tenant: str,
125
+ name: str,
126
+ ctx: AuthContext = Depends(authorize_tenant),
127
+ store: BaseStore = Depends(current_store),
128
+ ):
129
+ inc("requests_total")
130
+ return svc_create_collection(store, tenant, name)
131
+
132
+ @app.delete("/collections/{tenant}/{name}")
133
+ def delete_collection_route(
134
+ tenant: str,
135
+ name: str,
136
+ ctx: AuthContext = Depends(authorize_tenant),
137
+ store: BaseStore = Depends(current_store),
138
+ ):
139
+ inc("requests_total")
140
+ return svc_delete_collection(store, tenant, name)
141
+
142
+ @app.post("/collections/{tenant}/{collection}/documents")
143
+ async def upload_document(
144
+ tenant: str,
145
+ collection: str,
146
+ file: UploadFile = File(...),
147
+ docid: Optional[str] = Form(None),
148
+ metadata: Optional[str] = Form(None),
149
+ # CSV controls as optional query params
150
+ # (kept out of form to not clash with file upload)
151
+ csv_has_header: Optional[str] = Query(None, pattern="^(auto|yes|no)$"),
152
+ csv_meta_cols: Optional[str] = Query(None),
153
+ csv_include_cols: Optional[str] = Query(None),
154
+ ctx: AuthContext = Depends(authorize_tenant),
155
+ store: BaseStore = Depends(current_store),
156
+ ):
157
+ meta_obj = None
158
+ if metadata:
159
+ try:
160
+ import json
161
+ meta_obj = json.loads(metadata)
162
+ except Exception as e:
163
+ raise HTTPException(
164
+ status_code=400,
165
+ detail=f"invalid metadata json: {e}"
166
+ )
167
+
168
+ content = await file.read()
169
+
170
+ csv_opts = None
171
+ if csv_has_header or csv_meta_cols or csv_include_cols:
172
+ csv_opts = {
173
+ "has_header": csv_has_header or "auto",
174
+ "meta_cols": csv_meta_cols or "",
175
+ "include_cols": csv_include_cols or "",
176
+ }
177
+
178
+ try:
179
+ out = svc_ingest_document(
180
+ store, tenant, collection, file.filename, content,
181
+ docid, meta_obj, csv_options=csv_opts
182
+ )
183
+ return out
184
+ except ValueError as ve:
185
+ # e.g., names provided but no header
186
+ raise HTTPException(status_code=400, detail=str(ve))
187
+
188
+ # POST search (supports filters)
189
+ @app.post("/collections/{tenant}/{name}/search")
190
+ def search_route_post(
191
+ tenant: str,
192
+ name: str,
193
+ body: SearchBody,
194
+ ctx: AuthContext = Depends(authorize_tenant),
195
+ store: BaseStore = Depends(current_store),
196
+ ):
197
+ inc("requests_total")
198
+ include_common = bool(cfg.common_enabled)
199
+ result = svc_do_search(
200
+ store, tenant, name, body.q, body.k, filters=body.filters,
201
+ include_common=include_common, common_tenant=cfg.common_tenant,
202
+ common_collection=cfg.common_collection
203
+ )
204
+ return JSONResponse(result)
205
+
206
+ # GET search (no filters)
207
+ @app.get("/collections/{tenant}/{name}/search")
208
+ def search_route_get(
209
+ tenant: str,
210
+ name: str,
211
+ q: str = Query(...),
212
+ k: int = Query(5, ge=1),
213
+ ctx: AuthContext = Depends(authorize_tenant),
214
+ store: BaseStore = Depends(current_store),
215
+ ):
216
+ inc("requests_total")
217
+ include_common = bool(cfg.common_enabled)
218
+ result = svc_do_search(
219
+ store, tenant, name, q, k, filters=None,
220
+ include_common=include_common, common_tenant=cfg.common_tenant,
221
+ common_collection=cfg.common_collection
222
+ )
223
+ return JSONResponse(result)
224
+
225
+ # Common collection search
226
+ @app.post("/search")
227
+ def search_common_post(
228
+ body: SearchBody,
229
+ ctx: AuthContext = Depends(auth_ctx),
230
+ store: BaseStore = Depends(current_store),
231
+ ):
232
+ inc("requests_total")
233
+ if not cfg.common_enabled:
234
+ return JSONResponse({"matches": []})
235
+ result = svc_do_search(
236
+ store, cfg.common_tenant, cfg.common_collection, body.q, body.k,
237
+ filters=body.filters
238
+ )
239
+ return JSONResponse(result)
240
+
241
+ @app.get("/search")
242
+ def search_common_get(
243
+ q: str = Query(...),
244
+ k: int = Query(5, ge=1),
245
+ ctx: AuthContext = Depends(auth_ctx),
246
+ store: BaseStore = Depends(current_store),
247
+ ):
248
+ inc("requests_total")
249
+ if not cfg.common_enabled:
250
+ return JSONResponse({"matches": []})
251
+ result = svc_do_search(
252
+ store, cfg.common_tenant, cfg.common_collection, q, k, filters=None
253
+ )
254
+ return JSONResponse(result)
255
+
256
+ return app
257
+
258
+ def main_srv():
259
+ """
260
+ HTTP server entrypoint.
261
+ Precedence: CFG (reads env first) > defaults.
262
+ """
263
+ cfg = get_cfg()
264
+ log = get_logger()
265
+ log.info("Welcome to PatchVEC 🍰")
266
+ # Policy:
267
+ # - fail fast without auth in prod;
268
+ # - auth=none only in dev with loopback;
269
+ # - raises on invalid config.
270
+ enforce_policy(cfg)
271
+
272
+ # resolve bind host/port
273
+ host, port = resolve_bind(cfg)
274
+ cfg.set("server.host", host)
275
+ cfg.set("server.port", port)
276
+
277
+ # flags from CFG
278
+ reload = bool(cfg.get("server.reload", False))
279
+ workers = int(cfg.get("server.workers", 1))
280
+ log_level = str(cfg.get("server.log_level", "info"))
281
+
282
+ if cfg.get("dev",0):
283
+ log_level = "debug"
284
+ cfg.set("server.log_level", log_level)
285
+ log.setLevel(logging.DEBUG)
286
+
287
+ # run server
288
+ uvicorn.run("pave.main:app",
289
+ host=host,
290
+ port=port,
291
+ reload=reload,
292
+ workers=workers,
293
+ log_level=log_level)
294
+
295
+ # Default app instance for `uvicorn pave.main:app`
296
+ app = build_app()
297
+
298
+ # UI attach (minimal)
299
+ from pave.ui import attach_ui
300
+ attach_ui(app)
301
+
302
+ if __name__ == "__main__":
303
+ main_srv()