smartrag 0.0.1__tar.gz
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.
- smartrag-0.0.1/.gitignore +25 -0
- smartrag-0.0.1/PKG-INFO +86 -0
- smartrag-0.0.1/README.md +56 -0
- smartrag-0.0.1/pyproject.toml +68 -0
- smartrag-0.0.1/src/smartrag/__about__.py +3 -0
- smartrag-0.0.1/src/smartrag/__init__.py +10 -0
- smartrag-0.0.1/src/smartrag/__main__.py +3 -0
- smartrag-0.0.1/src/smartrag/api/__init__.py +5 -0
- smartrag-0.0.1/src/smartrag/api/app.py +42 -0
- smartrag-0.0.1/src/smartrag/api/auth.py +115 -0
- smartrag-0.0.1/src/smartrag/api/errors.py +57 -0
- smartrag-0.0.1/src/smartrag/api/ops.py +39 -0
- smartrag-0.0.1/src/smartrag/api/query.py +154 -0
- smartrag-0.0.1/src/smartrag/cli/__init__.py +0 -0
- smartrag-0.0.1/src/smartrag/cli/main.py +78 -0
- smartrag-0.0.1/src/smartrag/engine/__init__.py +7 -0
- smartrag-0.0.1/src/smartrag/engine/base.py +78 -0
- smartrag-0.0.1/src/smartrag/engine/demo.py +69 -0
- smartrag-0.0.1/src/smartrag/engine/memory.py +424 -0
- smartrag-0.0.1/src/smartrag/errors.py +70 -0
- smartrag-0.0.1/src/smartrag/models.py +229 -0
- smartrag-0.0.1/src/smartrag/paging.py +51 -0
- smartrag-0.0.1/src/smartrag/settings.py +25 -0
- smartrag-0.0.1/tests/__init__.py +0 -0
- smartrag-0.0.1/tests/conftest.py +44 -0
- smartrag-0.0.1/tests/test_auth.py +59 -0
- smartrag-0.0.1/tests/test_read.py +99 -0
- smartrag-0.0.1/tests/test_search.py +91 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.eggs/
|
|
6
|
+
build/
|
|
7
|
+
dist/
|
|
8
|
+
*.egg
|
|
9
|
+
|
|
10
|
+
# Virtual envs / tooling
|
|
11
|
+
.venv/
|
|
12
|
+
venv/
|
|
13
|
+
.env
|
|
14
|
+
.uv/
|
|
15
|
+
.ruff_cache/
|
|
16
|
+
.mypy_cache/
|
|
17
|
+
.pytest_cache/
|
|
18
|
+
|
|
19
|
+
# Local secrets (API key stores written by the CLI)
|
|
20
|
+
api_keys.json
|
|
21
|
+
|
|
22
|
+
# Editors / OS
|
|
23
|
+
.vscode/
|
|
24
|
+
.idea/
|
|
25
|
+
.DS_Store
|
smartrag-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: smartrag
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: A knowledge source any agent can use: search returns evidence with citations, the agent writes the answer.
|
|
5
|
+
Project-URL: Homepage, https://github.com/gotoai/smartrag
|
|
6
|
+
Project-URL: Repository, https://github.com/gotoai/smartrag
|
|
7
|
+
Project-URL: Issues, https://github.com/gotoai/smartrag/issues
|
|
8
|
+
Author: GOTOAI
|
|
9
|
+
Keywords: agents,embeddings,mcp,rag,retrieval,search
|
|
10
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Requires-Dist: fastapi>=0.110
|
|
19
|
+
Requires-Dist: pydantic-settings>=2
|
|
20
|
+
Requires-Dist: pydantic>=2
|
|
21
|
+
Requires-Dist: typer>=0.12
|
|
22
|
+
Requires-Dist: uvicorn>=0.29
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: httpx2>=2; extra == 'dev'
|
|
25
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
28
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# Smart RAG
|
|
32
|
+
|
|
33
|
+
A knowledge source any agent can use. **Search returns evidence** (snippets, stable ids,
|
|
34
|
+
citations); the agent, or a person at the search box, writes the answer.
|
|
35
|
+
|
|
36
|
+
Status: **skeleton** (0.0.1). The query-plane Web API is complete as a contract and runs on an
|
|
37
|
+
in-memory demo engine. The real index (PostgreSQL + pgvector + PGroonga, embeddings from
|
|
38
|
+
OumiGo's `/v1/embeddings`), ingestion and the admin CLI are still to come.
|
|
39
|
+
|
|
40
|
+
## Run
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
uv venv --python 3.12 && uv pip install -e '.[dev]'
|
|
44
|
+
.venv/bin/smartrag serve --demo --no-auth # http://127.0.0.1:8710/docs
|
|
45
|
+
curl -s 'http://127.0.0.1:8710/v1/search?q=有給休暇の繰り越し' | python3 -m json.tool
|
|
46
|
+
.venv/bin/pytest
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`--no-auth` is refused unless the server binds a loopback address. Otherwise clients send
|
|
50
|
+
`Authorization: Bearer <key>`, checked against the hashed key file `SMARTRAG_API_KEYS_FILE`:
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
[{"key_id": "ui-search", "sha256": "<sha256 of the key>", "role": "reader", "delegate": true}]
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Web API (query plane)
|
|
57
|
+
|
|
58
|
+
| Method & path | Purpose |
|
|
59
|
+
|---|---|
|
|
60
|
+
| `GET /v1/sources` | What is indexed, filterable fields, freshness (no URIs, no credentials) |
|
|
61
|
+
| `POST /v1/search` | Natural-language search: snippets, ids, citations; filters, cursors, facets |
|
|
62
|
+
| `GET /v1/search?q=` | The same for the search box: one result per document, bookmarkable |
|
|
63
|
+
| `GET /v1/chunks/{chunk_id}` | Read with `scope=chunk\|neighbors\|section\|document`, paged by `max_chars` |
|
|
64
|
+
| `GET /v1/documents/{doc_id}` | Metadata and outline; text in pages with `include=text` |
|
|
65
|
+
| `POST /v1/feedback` | `click` / `relevant` / `irrelevant` for a `query_id` |
|
|
66
|
+
| `GET /healthz`, `/readyz`, `/version` | Ops (no auth) |
|
|
67
|
+
|
|
68
|
+
- Errors always look like `{"error": {"code", "message", "details"}}`.
|
|
69
|
+
- A chunk from an older document version returns `410 chunk_superseded`, with the current version.
|
|
70
|
+
- `X-SmartRAG-On-Behalf-Of: <user>` names the end user (keys with `delegate` only).
|
|
71
|
+
|
|
72
|
+
Admin operations (sources, sync, jobs, index, query logs, API keys, eval) are **not** part of
|
|
73
|
+
the Web API by design: they run through the `smartrag` CLI on the operator host, and the
|
|
74
|
+
query server holds no source credentials.
|
|
75
|
+
|
|
76
|
+
## Layout
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
src/smartrag/
|
|
80
|
+
models.py request/response schemas (the contract)
|
|
81
|
+
errors.py errors with stable codes
|
|
82
|
+
paging.py opaque cursors bound to their request
|
|
83
|
+
engine/ RetrievalEngine protocol; in-memory engine + demo corpus
|
|
84
|
+
api/ FastAPI app: query routes, ops routes, auth, error shape
|
|
85
|
+
cli/ `smartrag serve` (admin commands to come)
|
|
86
|
+
```
|
smartrag-0.0.1/README.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Smart RAG
|
|
2
|
+
|
|
3
|
+
A knowledge source any agent can use. **Search returns evidence** (snippets, stable ids,
|
|
4
|
+
citations); the agent, or a person at the search box, writes the answer.
|
|
5
|
+
|
|
6
|
+
Status: **skeleton** (0.0.1). The query-plane Web API is complete as a contract and runs on an
|
|
7
|
+
in-memory demo engine. The real index (PostgreSQL + pgvector + PGroonga, embeddings from
|
|
8
|
+
OumiGo's `/v1/embeddings`), ingestion and the admin CLI are still to come.
|
|
9
|
+
|
|
10
|
+
## Run
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
uv venv --python 3.12 && uv pip install -e '.[dev]'
|
|
14
|
+
.venv/bin/smartrag serve --demo --no-auth # http://127.0.0.1:8710/docs
|
|
15
|
+
curl -s 'http://127.0.0.1:8710/v1/search?q=有給休暇の繰り越し' | python3 -m json.tool
|
|
16
|
+
.venv/bin/pytest
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`--no-auth` is refused unless the server binds a loopback address. Otherwise clients send
|
|
20
|
+
`Authorization: Bearer <key>`, checked against the hashed key file `SMARTRAG_API_KEYS_FILE`:
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
[{"key_id": "ui-search", "sha256": "<sha256 of the key>", "role": "reader", "delegate": true}]
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Web API (query plane)
|
|
27
|
+
|
|
28
|
+
| Method & path | Purpose |
|
|
29
|
+
|---|---|
|
|
30
|
+
| `GET /v1/sources` | What is indexed, filterable fields, freshness (no URIs, no credentials) |
|
|
31
|
+
| `POST /v1/search` | Natural-language search: snippets, ids, citations; filters, cursors, facets |
|
|
32
|
+
| `GET /v1/search?q=` | The same for the search box: one result per document, bookmarkable |
|
|
33
|
+
| `GET /v1/chunks/{chunk_id}` | Read with `scope=chunk\|neighbors\|section\|document`, paged by `max_chars` |
|
|
34
|
+
| `GET /v1/documents/{doc_id}` | Metadata and outline; text in pages with `include=text` |
|
|
35
|
+
| `POST /v1/feedback` | `click` / `relevant` / `irrelevant` for a `query_id` |
|
|
36
|
+
| `GET /healthz`, `/readyz`, `/version` | Ops (no auth) |
|
|
37
|
+
|
|
38
|
+
- Errors always look like `{"error": {"code", "message", "details"}}`.
|
|
39
|
+
- A chunk from an older document version returns `410 chunk_superseded`, with the current version.
|
|
40
|
+
- `X-SmartRAG-On-Behalf-Of: <user>` names the end user (keys with `delegate` only).
|
|
41
|
+
|
|
42
|
+
Admin operations (sources, sync, jobs, index, query logs, API keys, eval) are **not** part of
|
|
43
|
+
the Web API by design: they run through the `smartrag` CLI on the operator host, and the
|
|
44
|
+
query server holds no source credentials.
|
|
45
|
+
|
|
46
|
+
## Layout
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
src/smartrag/
|
|
50
|
+
models.py request/response schemas (the contract)
|
|
51
|
+
errors.py errors with stable codes
|
|
52
|
+
paging.py opaque cursors bound to their request
|
|
53
|
+
engine/ RetrievalEngine protocol; in-memory engine + demo corpus
|
|
54
|
+
api/ FastAPI app: query routes, ops routes, auth, error shape
|
|
55
|
+
cli/ `smartrag serve` (admin commands to come)
|
|
56
|
+
```
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "smartrag"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "A knowledge source any agent can use: search returns evidence with citations, the agent writes the answer."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
authors = [{ name = "GOTOAI" }]
|
|
12
|
+
keywords = ["rag", "retrieval", "search", "agents", "mcp", "embeddings"]
|
|
13
|
+
# TODO: choose the licence (open framework vs commercial implementation) and add
|
|
14
|
+
# `license = "..."` + `license-files = ["LICENSE"]`.
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 2 - Pre-Alpha",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
]
|
|
24
|
+
dependencies = [
|
|
25
|
+
"pydantic>=2",
|
|
26
|
+
"pydantic-settings>=2",
|
|
27
|
+
"fastapi>=0.110", # the query-plane Web API
|
|
28
|
+
"uvicorn>=0.29",
|
|
29
|
+
"typer>=0.12", # single CLI: serve now; admin commands (source, jobs, apikey, ...) later
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://github.com/gotoai/smartrag"
|
|
34
|
+
Repository = "https://github.com/gotoai/smartrag"
|
|
35
|
+
Issues = "https://github.com/gotoai/smartrag/issues"
|
|
36
|
+
|
|
37
|
+
[project.optional-dependencies]
|
|
38
|
+
dev = [
|
|
39
|
+
"pytest>=8",
|
|
40
|
+
"pytest-asyncio>=0.23",
|
|
41
|
+
"httpx2>=2", # FastAPI's TestClient (Starlette deprecated plain httpx for it)
|
|
42
|
+
"ruff>=0.5",
|
|
43
|
+
"mypy>=1.10",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
[project.scripts]
|
|
47
|
+
smartrag = "smartrag.cli.main:main"
|
|
48
|
+
|
|
49
|
+
[tool.hatch.version]
|
|
50
|
+
path = "src/smartrag/__about__.py"
|
|
51
|
+
|
|
52
|
+
[tool.hatch.build.targets.wheel]
|
|
53
|
+
packages = ["src/smartrag"]
|
|
54
|
+
|
|
55
|
+
[tool.ruff]
|
|
56
|
+
line-length = 100
|
|
57
|
+
src = ["src", "tests"]
|
|
58
|
+
|
|
59
|
+
[tool.ruff.lint.flake8-bugbear]
|
|
60
|
+
# FastAPI / typer declare parameters through calls in defaults; that is their idiom.
|
|
61
|
+
extend-immutable-calls = [
|
|
62
|
+
"fastapi.Depends", "fastapi.Query", "fastapi.Path", "fastapi.Header", "typer.Option",
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
[tool.pytest.ini_options]
|
|
66
|
+
testpaths = ["tests"]
|
|
67
|
+
asyncio_mode = "auto"
|
|
68
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Smart RAG — a knowledge source any agent can use.
|
|
2
|
+
|
|
3
|
+
Search returns evidence (snippets, stable ids, citations); the agent, or a person at the
|
|
4
|
+
search box, writes the answer. See `smartrag.api` for the query-plane Web API and
|
|
5
|
+
`smartrag.engine` for the retrieval-engine seam behind it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from smartrag.__about__ import __version__
|
|
9
|
+
|
|
10
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Build the query-plane FastAPI app around a `RetrievalEngine`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fastapi import FastAPI
|
|
6
|
+
|
|
7
|
+
from smartrag.__about__ import __version__
|
|
8
|
+
from smartrag.api import ops, query
|
|
9
|
+
from smartrag.api.auth import FileKeyStore, KeyStore
|
|
10
|
+
from smartrag.api.errors import install_error_handlers
|
|
11
|
+
from smartrag.engine.base import RetrievalEngine
|
|
12
|
+
from smartrag.settings import Settings
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def create_app(
|
|
16
|
+
engine: RetrievalEngine,
|
|
17
|
+
settings: Settings | None = None,
|
|
18
|
+
key_store: KeyStore | None = None,
|
|
19
|
+
) -> FastAPI:
|
|
20
|
+
"""The Smart RAG Web API (query plane only).
|
|
21
|
+
|
|
22
|
+
Auth: `key_store` if given, else the hashed key file `settings.api_keys_file`. With
|
|
23
|
+
neither, the app refuses to start unless `settings.auth_disabled` is set — an
|
|
24
|
+
unauthenticated server is never the silent default.
|
|
25
|
+
"""
|
|
26
|
+
settings = settings or Settings()
|
|
27
|
+
if key_store is None and not settings.auth_disabled:
|
|
28
|
+
if settings.api_keys_file is None:
|
|
29
|
+
raise ValueError("no API keys configured: set SMARTRAG_API_KEYS_FILE "
|
|
30
|
+
"(or auth_disabled for local development)")
|
|
31
|
+
key_store = FileKeyStore(settings.api_keys_file)
|
|
32
|
+
|
|
33
|
+
app = FastAPI(title="Smart RAG", version=__version__,
|
|
34
|
+
description="Search returns evidence with citations; the agent writes "
|
|
35
|
+
"the answer.")
|
|
36
|
+
app.state.engine = engine
|
|
37
|
+
app.state.settings = settings
|
|
38
|
+
app.state.key_store = None if settings.auth_disabled else key_store
|
|
39
|
+
install_error_handlers(app)
|
|
40
|
+
app.include_router(query.router)
|
|
41
|
+
app.include_router(ops.router)
|
|
42
|
+
return app
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""API-key authentication and the end-user identity slot.
|
|
2
|
+
|
|
3
|
+
Clients send `Authorization: Bearer <api key>`. Keys are stored hashed (SHA-256) in a
|
|
4
|
+
JSON file that the CLI writes, so the query server never holds a plaintext key:
|
|
5
|
+
|
|
6
|
+
[{"key_id": "ui-search", "sha256": "<hex>", "role": "reader", "delegate": true}]
|
|
7
|
+
|
|
8
|
+
The file is re-read when it changes, so a key issued or revoked by the CLI takes effect
|
|
9
|
+
without restarting the server.
|
|
10
|
+
|
|
11
|
+
`X-SmartRAG-On-Behalf-Of: <user id>` names the end user an agent or UI acts for
|
|
12
|
+
(CommonRAG P2). Only keys with `delegate: true` may send it; v1 logs it and passes it to
|
|
13
|
+
the engine, and document permissions will use it later.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import hashlib
|
|
19
|
+
import hmac
|
|
20
|
+
import json
|
|
21
|
+
import logging
|
|
22
|
+
import threading
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Literal, Protocol
|
|
26
|
+
|
|
27
|
+
from fastapi import Header, Request
|
|
28
|
+
|
|
29
|
+
from smartrag.engine.base import RequestContext
|
|
30
|
+
from smartrag.errors import Forbidden, Unauthorized
|
|
31
|
+
|
|
32
|
+
log = logging.getLogger("smartrag.api.auth")
|
|
33
|
+
|
|
34
|
+
ON_BEHALF_OF_HEADER = "X-SmartRAG-On-Behalf-Of"
|
|
35
|
+
|
|
36
|
+
Role = Literal["reader", "admin"]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class Principal:
|
|
41
|
+
key_id: str
|
|
42
|
+
role: Role
|
|
43
|
+
delegate: bool = False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
ANONYMOUS = Principal(key_id="anonymous", role="reader", delegate=True)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def hash_key(key: str) -> str:
|
|
50
|
+
return hashlib.sha256(key.encode()).hexdigest()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class KeyStore(Protocol):
|
|
54
|
+
def authenticate(self, key: str) -> Principal | None: ...
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class FileKeyStore:
|
|
58
|
+
"""Hashed keys from a JSON file, reloaded when the file's mtime changes."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, path: Path) -> None:
|
|
61
|
+
self.path = Path(path)
|
|
62
|
+
self._lock = threading.Lock()
|
|
63
|
+
self._mtime: float | None = None
|
|
64
|
+
self._entries: list[tuple[str, Principal]] = []
|
|
65
|
+
self._reload()
|
|
66
|
+
|
|
67
|
+
def _reload(self) -> None:
|
|
68
|
+
mtime = self.path.stat().st_mtime
|
|
69
|
+
if mtime == self._mtime:
|
|
70
|
+
return
|
|
71
|
+
try:
|
|
72
|
+
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
|
73
|
+
entries = [(str(e["sha256"]).lower(),
|
|
74
|
+
Principal(key_id=str(e["key_id"]), role=e.get("role", "reader"),
|
|
75
|
+
delegate=bool(e.get("delegate", False))))
|
|
76
|
+
for e in raw]
|
|
77
|
+
except (OSError, ValueError, KeyError, TypeError) as exc:
|
|
78
|
+
raise ValueError(f"invalid API key file {self.path}: {exc}") from exc
|
|
79
|
+
self._entries, self._mtime = entries, mtime
|
|
80
|
+
log.info("loaded %d API keys from %s", len(entries), self.path)
|
|
81
|
+
|
|
82
|
+
def authenticate(self, key: str) -> Principal | None:
|
|
83
|
+
with self._lock:
|
|
84
|
+
try:
|
|
85
|
+
self._reload()
|
|
86
|
+
except (OSError, ValueError):
|
|
87
|
+
log.exception("cannot reload %s; keeping the previous keys", self.path)
|
|
88
|
+
digest = hash_key(key)
|
|
89
|
+
match = None
|
|
90
|
+
for stored, principal in self._entries: # compare all: no early exit on timing
|
|
91
|
+
if hmac.compare_digest(stored, digest):
|
|
92
|
+
match = principal
|
|
93
|
+
return match
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
async def request_context(
|
|
97
|
+
request: Request,
|
|
98
|
+
authorization: str | None = Header(default=None),
|
|
99
|
+
on_behalf_of: str | None = Header(default=None, alias=ON_BEHALF_OF_HEADER),
|
|
100
|
+
) -> RequestContext:
|
|
101
|
+
"""FastAPI dependency: authenticate the caller and build the engine's context."""
|
|
102
|
+
store: KeyStore | None = request.app.state.key_store
|
|
103
|
+
if store is None: # auth disabled (development)
|
|
104
|
+
principal = ANONYMOUS
|
|
105
|
+
else:
|
|
106
|
+
scheme, _, key = (authorization or "").partition(" ")
|
|
107
|
+
if scheme.lower() != "bearer" or not key.strip():
|
|
108
|
+
raise Unauthorized("an API key is required: Authorization: Bearer <key>")
|
|
109
|
+
found = store.authenticate(key.strip())
|
|
110
|
+
if found is None:
|
|
111
|
+
raise Unauthorized("the API key is not valid")
|
|
112
|
+
principal = found
|
|
113
|
+
if on_behalf_of is not None and not principal.delegate:
|
|
114
|
+
raise Forbidden(f"this API key may not act on behalf of users ({ON_BEHALF_OF_HEADER})")
|
|
115
|
+
return RequestContext(key_id=principal.key_id, on_behalf_of=on_behalf_of or None)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""One error shape for every failure: `{"error": {"code", "message", "details"}}`.
|
|
2
|
+
|
|
3
|
+
Validation errors are `400 invalid_request` (not FastAPI's default 422), unknown paths
|
|
4
|
+
`404 not_found`, and unexpected exceptions `500 internal_error` with a generic message
|
|
5
|
+
(the traceback goes to the log, never to the client).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from fastapi import FastAPI, Request
|
|
14
|
+
from fastapi.exceptions import RequestValidationError
|
|
15
|
+
from fastapi.responses import JSONResponse
|
|
16
|
+
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
17
|
+
|
|
18
|
+
from smartrag.errors import EmbedderUnavailable, SmartRAGError, Unauthorized
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger("smartrag.api")
|
|
21
|
+
|
|
22
|
+
_HTTP_CODES = {404: "not_found", 405: "method_not_allowed"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def error_response(status: int, code: str, message: str,
|
|
26
|
+
details: dict[str, Any] | None = None,
|
|
27
|
+
headers: dict[str, str] | None = None) -> JSONResponse:
|
|
28
|
+
body = {"error": {"code": code, "message": message, "details": details or {}}}
|
|
29
|
+
return JSONResponse(body, status_code=status, headers=headers)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def install_error_handlers(app: FastAPI) -> None:
|
|
33
|
+
@app.exception_handler(SmartRAGError)
|
|
34
|
+
async def _smartrag_error(_: Request, exc: SmartRAGError) -> JSONResponse:
|
|
35
|
+
headers = None
|
|
36
|
+
if isinstance(exc, Unauthorized):
|
|
37
|
+
headers = {"WWW-Authenticate": "Bearer"}
|
|
38
|
+
elif isinstance(exc, EmbedderUnavailable) and exc.retry_after:
|
|
39
|
+
headers = {"Retry-After": str(exc.retry_after)}
|
|
40
|
+
return error_response(exc.status, exc.code, exc.message, exc.details, headers)
|
|
41
|
+
|
|
42
|
+
@app.exception_handler(RequestValidationError)
|
|
43
|
+
async def _validation_error(_: Request, exc: RequestValidationError) -> JSONResponse:
|
|
44
|
+
errors = [{"loc": list(e.get("loc", ())), "msg": e.get("msg", ""),
|
|
45
|
+
"type": e.get("type", "")} for e in exc.errors()]
|
|
46
|
+
return error_response(400, "invalid_request", "the request is not valid",
|
|
47
|
+
{"errors": errors})
|
|
48
|
+
|
|
49
|
+
@app.exception_handler(StarletteHTTPException)
|
|
50
|
+
async def _http_error(_: Request, exc: StarletteHTTPException) -> JSONResponse:
|
|
51
|
+
code = _HTTP_CODES.get(exc.status_code, "http_error")
|
|
52
|
+
return error_response(exc.status_code, code, str(exc.detail))
|
|
53
|
+
|
|
54
|
+
@app.exception_handler(Exception)
|
|
55
|
+
async def _unexpected(request: Request, exc: Exception) -> JSONResponse:
|
|
56
|
+
log.exception("unhandled error on %s %s", request.method, request.url.path)
|
|
57
|
+
return error_response(500, "internal_error", "an internal error occurred")
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Ops endpoints (13), unauthenticated so probes and load balancers can call them.
|
|
2
|
+
|
|
3
|
+
GET /healthz liveness: the process answers
|
|
4
|
+
GET /readyz readiness: the index and the embedder can serve (503 otherwise)
|
|
5
|
+
GET /version package and API version
|
|
6
|
+
|
|
7
|
+
They reveal no data: `/readyz` reports check names and short details only.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from fastapi import APIRouter, Request
|
|
13
|
+
from fastapi.responses import JSONResponse
|
|
14
|
+
|
|
15
|
+
from smartrag.__about__ import __version__
|
|
16
|
+
from smartrag.models import ReadinessResponse, VersionResponse
|
|
17
|
+
|
|
18
|
+
router = APIRouter()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@router.get("/healthz")
|
|
22
|
+
async def healthz() -> dict[str, str]:
|
|
23
|
+
"""liveness"""
|
|
24
|
+
return {"status": "ok"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@router.get("/readyz", response_model=ReadinessResponse,
|
|
28
|
+
responses={503: {"model": ReadinessResponse}})
|
|
29
|
+
async def readyz(request: Request) -> JSONResponse:
|
|
30
|
+
"""readiness of the index and the embedder"""
|
|
31
|
+
readiness: ReadinessResponse = await request.app.state.engine.readiness()
|
|
32
|
+
return JSONResponse(readiness.model_dump(mode="json"),
|
|
33
|
+
status_code=200 if readiness.ready else 503)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@router.get("/version", response_model=VersionResponse)
|
|
37
|
+
async def version() -> VersionResponse:
|
|
38
|
+
"""package and API version"""
|
|
39
|
+
return VersionResponse(version=__version__)
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Query-plane endpoints 1–6: the only operations agents, the UI and MCP get.
|
|
2
|
+
|
|
3
|
+
GET /v1/sources describe_sources
|
|
4
|
+
POST /v1/search search (canonical form)
|
|
5
|
+
GET /v1/search?q= search for the search box (bookmarkable URLs)
|
|
6
|
+
GET /v1/chunks/{chunk_id} read: chunk -> neighbours -> section -> document
|
|
7
|
+
GET /v1/documents/{doc_id} metadata, outline, text in pages
|
|
8
|
+
POST /v1/feedback click / relevant / irrelevant
|
|
9
|
+
|
|
10
|
+
Admin operations (sources, sync, jobs, index, query logs, API keys) are not here by
|
|
11
|
+
design: they run through the CLI on the operator host.
|
|
12
|
+
|
|
13
|
+
Every search is logged with its `query_id` (the server-side query log, CommonRAG P3),
|
|
14
|
+
so a wrong answer can be traced to retrieval or to the agent.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import logging
|
|
21
|
+
import time
|
|
22
|
+
import uuid
|
|
23
|
+
from typing import Literal
|
|
24
|
+
|
|
25
|
+
from fastapi import APIRouter, Depends, Path, Query, Request
|
|
26
|
+
|
|
27
|
+
from smartrag.api.auth import request_context
|
|
28
|
+
from smartrag.engine.base import RequestContext, RetrievalEngine
|
|
29
|
+
from smartrag.models import (
|
|
30
|
+
DocumentResponse,
|
|
31
|
+
FeedbackRequest,
|
|
32
|
+
FeedbackResponse,
|
|
33
|
+
ReadResponse,
|
|
34
|
+
ReadScope,
|
|
35
|
+
SearchFilters,
|
|
36
|
+
SearchRequest,
|
|
37
|
+
SearchResponse,
|
|
38
|
+
SourcesResponse,
|
|
39
|
+
)
|
|
40
|
+
from smartrag.paging import encode_cursor, search_binding
|
|
41
|
+
|
|
42
|
+
log = logging.getLogger("smartrag.query")
|
|
43
|
+
|
|
44
|
+
router = APIRouter(prefix="/v1", dependencies=[Depends(request_context)])
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _engine(request: Request) -> RetrievalEngine:
|
|
48
|
+
return request.app.state.engine
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _document_link(request: Request, doc_id: str, page: int | None) -> str:
|
|
52
|
+
base = request.app.state.settings.public_base_url or str(request.base_url)
|
|
53
|
+
link = f"{base.rstrip('/')}/v1/documents/{doc_id}"
|
|
54
|
+
return f"{link}?page={page}" if page is not None else link
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@router.get("/sources", response_model=SourcesResponse, response_model_exclude_none=True)
|
|
58
|
+
async def describe_sources(
|
|
59
|
+
request: Request, ctx: RequestContext = Depends(request_context)
|
|
60
|
+
) -> SourcesResponse:
|
|
61
|
+
"""what is indexed, which fields can be filtered, and how fresh it is"""
|
|
62
|
+
return await _engine(request).describe_sources(ctx)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@router.post("/search", response_model=SearchResponse, response_model_exclude_none=True)
|
|
66
|
+
async def search(
|
|
67
|
+
body: SearchRequest, request: Request, ctx: RequestContext = Depends(request_context)
|
|
68
|
+
) -> SearchResponse:
|
|
69
|
+
"""search with a natural-language query; returns evidence with citations"""
|
|
70
|
+
return await _search(body, request, ctx)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@router.get("/search", response_model=SearchResponse, response_model_exclude_none=True)
|
|
74
|
+
async def search_get(
|
|
75
|
+
request: Request,
|
|
76
|
+
q: str = Query(min_length=1, max_length=2000),
|
|
77
|
+
source: list[str] | None = Query(default=None),
|
|
78
|
+
type: list[str] | None = Query(default=None), # the URL's own name
|
|
79
|
+
limit: int = Query(default=10, ge=1, le=50),
|
|
80
|
+
page: int = Query(default=1, ge=1, le=100),
|
|
81
|
+
ctx: RequestContext = Depends(request_context),
|
|
82
|
+
) -> SearchResponse:
|
|
83
|
+
"""search for the search box: one result per document, highlights on"""
|
|
84
|
+
filters = SearchFilters(source_id=source, file_type=type) if (source or type) else None
|
|
85
|
+
body = SearchRequest(query=q, filters=filters, limit=limit, group_by="document",
|
|
86
|
+
include=["highlights"])
|
|
87
|
+
if page > 1:
|
|
88
|
+
body.cursor = encode_cursor((page - 1) * limit, search_binding(body))
|
|
89
|
+
return await _search(body, request, ctx)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
async def _search(body: SearchRequest, request: Request, ctx: RequestContext) -> SearchResponse:
|
|
93
|
+
query_id = f"q_{uuid.uuid4().hex}"
|
|
94
|
+
started = time.perf_counter()
|
|
95
|
+
response = await _engine(request).search(body, ctx, query_id)
|
|
96
|
+
response.took_ms = round((time.perf_counter() - started) * 1000)
|
|
97
|
+
for result in response.results:
|
|
98
|
+
result.citation.link = _document_link(request, result.doc_id, result.citation.page)
|
|
99
|
+
log.info("search %s", json.dumps({
|
|
100
|
+
"query_id": query_id,
|
|
101
|
+
"key_id": ctx.key_id,
|
|
102
|
+
"on_behalf_of": ctx.on_behalf_of,
|
|
103
|
+
"query": body.query,
|
|
104
|
+
"filters": body.filters.model_dump(mode="json", exclude_none=True)
|
|
105
|
+
if body.filters else None,
|
|
106
|
+
"mode": body.mode,
|
|
107
|
+
"group_by": body.group_by,
|
|
108
|
+
"results": [r.chunk_id for r in response.results],
|
|
109
|
+
"took_ms": response.took_ms,
|
|
110
|
+
}, ensure_ascii=False))
|
|
111
|
+
return response
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@router.get("/chunks/{chunk_id}", response_model=ReadResponse, response_model_exclude_none=True)
|
|
115
|
+
async def read_chunk(
|
|
116
|
+
request: Request,
|
|
117
|
+
chunk_id: str = Path(max_length=200),
|
|
118
|
+
scope: ReadScope = "chunk",
|
|
119
|
+
window: int = Query(default=1, ge=0, le=5),
|
|
120
|
+
max_chars: int = Query(default=8000, ge=500, le=50000),
|
|
121
|
+
cursor: str | None = None,
|
|
122
|
+
ctx: RequestContext = Depends(request_context),
|
|
123
|
+
) -> ReadResponse:
|
|
124
|
+
"""read a chunk, widened to its neighbours, section or whole document"""
|
|
125
|
+
response = await _engine(request).read_chunk(chunk_id, scope, window, max_chars, cursor, ctx)
|
|
126
|
+
response.citation.link = _document_link(request, response.doc_id, response.citation.page)
|
|
127
|
+
return response
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@router.get("/documents/{doc_id}", response_model=DocumentResponse,
|
|
131
|
+
response_model_exclude_none=True)
|
|
132
|
+
async def get_document(
|
|
133
|
+
request: Request,
|
|
134
|
+
doc_id: str = Path(max_length=100),
|
|
135
|
+
include: list[Literal["text"]] = Query(default=[]),
|
|
136
|
+
section: str | None = Query(default=None, max_length=50),
|
|
137
|
+
max_chars: int = Query(default=8000, ge=500, le=50000),
|
|
138
|
+
cursor: str | None = None,
|
|
139
|
+
ctx: RequestContext = Depends(request_context),
|
|
140
|
+
) -> DocumentResponse:
|
|
141
|
+
"""document metadata and outline; text in pages with include=text"""
|
|
142
|
+
response = await _engine(request).get_document(
|
|
143
|
+
doc_id, "text" in include, section, max_chars, cursor, ctx)
|
|
144
|
+
response.link = _document_link(request, response.doc_id, None)
|
|
145
|
+
return response
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@router.post("/feedback", status_code=202, response_model=FeedbackResponse)
|
|
149
|
+
async def feedback(
|
|
150
|
+
body: FeedbackRequest, request: Request, ctx: RequestContext = Depends(request_context)
|
|
151
|
+
) -> FeedbackResponse:
|
|
152
|
+
"""record whether a result was clicked, relevant or irrelevant"""
|
|
153
|
+
await _engine(request).record_feedback(body, ctx)
|
|
154
|
+
return FeedbackResponse()
|
|
File without changes
|