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.
@@ -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
@@ -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
+ ```
@@ -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,3 @@
1
+ """Single source of truth for the package version (read by hatch at build time)."""
2
+
3
+ __version__ = "0.0.1"
@@ -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,3 @@
1
+ from smartrag.cli.main import main
2
+
3
+ main()
@@ -0,0 +1,5 @@
1
+ """The query-plane Web API (endpoints 1–6 and ops)."""
2
+
3
+ from smartrag.api.app import create_app
4
+
5
+ __all__ = ["create_app"]
@@ -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