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.
- patchvec-0.5.6.dist-info/METADATA +115 -0
- patchvec-0.5.6.dist-info/RECORD +28 -0
- patchvec-0.5.6.dist-info/WHEEL +5 -0
- patchvec-0.5.6.dist-info/entry_points.txt +3 -0
- patchvec-0.5.6.dist-info/licenses/LICENSE +9 -0
- patchvec-0.5.6.dist-info/top_level.txt +1 -0
- pave/__init__.py +6 -0
- pave/assets/patchvec_icon_192.png +0 -0
- pave/assets/ui.html +125 -0
- pave/auth.py +108 -0
- pave/cli.py +97 -0
- pave/config.py +240 -0
- pave/embedders/__init__.py +1 -0
- pave/embedders/base.py +12 -0
- pave/embedders/factory.py +21 -0
- pave/embedders/openai_emb.py +30 -0
- pave/embedders/sbert_emb.py +24 -0
- pave/embedders/txtai_emb.py +58 -0
- pave/main.py +303 -0
- pave/metrics.py +52 -0
- pave/preprocess.py +151 -0
- pave/service.py +92 -0
- pave/stores/__init__.py +1 -0
- pave/stores/base.py +33 -0
- pave/stores/factory.py +18 -0
- pave/stores/qdrant_store.py +26 -0
- pave/stores/txtai_store.py +445 -0
- pave/ui.py +175 -0
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()
|