vikingrag 0.4.0__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.
- vikingrag/__init__.py +7 -0
- vikingrag/api/__init__.py +1 -0
- vikingrag/api/app.py +180 -0
- vikingrag/api/auth.py +82 -0
- vikingrag/api/dependencies.py +54 -0
- vikingrag/api/errors.py +98 -0
- vikingrag/api/routers/__init__.py +13 -0
- vikingrag/api/routers/answers.py +132 -0
- vikingrag/api/routers/documents.py +157 -0
- vikingrag/api/routers/health.py +83 -0
- vikingrag/api/routers/retrieval.py +201 -0
- vikingrag/api/routers/search.py +121 -0
- vikingrag/api/schemas/answers.py +92 -0
- vikingrag/api/schemas/documents.py +146 -0
- vikingrag/api/schemas/retrieval.py +298 -0
- vikingrag/api/schemas/search.py +175 -0
- vikingrag/application/__init__.py +1 -0
- vikingrag/application/agent/__init__.py +26 -0
- vikingrag/application/agent/finalize.py +188 -0
- vikingrag/application/agent/loop.py +338 -0
- vikingrag/application/agent/tool_executor.py +279 -0
- vikingrag/application/answer/__init__.py +5 -0
- vikingrag/application/answer/generate.py +225 -0
- vikingrag/application/assessment.py +632 -0
- vikingrag/application/budget.py +262 -0
- vikingrag/application/cursors.py +33 -0
- vikingrag/application/documents.py +93 -0
- vikingrag/application/evidence.py +102 -0
- vikingrag/application/evidence_bundle.py +223 -0
- vikingrag/application/evidence_collector.py +323 -0
- vikingrag/application/experience/__init__.py +18 -0
- vikingrag/application/experience/activate.py +59 -0
- vikingrag/application/experience/builder.py +196 -0
- vikingrag/application/experience/enqueue.py +107 -0
- vikingrag/application/experience/expand.py +147 -0
- vikingrag/application/experience/invalidate.py +47 -0
- vikingrag/application/experience/memory_store.py +86 -0
- vikingrag/application/experience/support_select.py +210 -0
- vikingrag/application/experience/trace_sets.py +70 -0
- vikingrag/application/grep_primitive.py +367 -0
- vikingrag/application/indexing.py +529 -0
- vikingrag/application/list_primitive.py +128 -0
- vikingrag/application/navigation.py +83 -0
- vikingrag/application/orchestration/__init__.py +13 -0
- vikingrag/application/orchestration/candidate.py +53 -0
- vikingrag/application/orchestration/query.py +316 -0
- vikingrag/application/read_primitive.py +212 -0
- vikingrag/application/search.py +267 -0
- vikingrag/application/search_plus.py +251 -0
- vikingrag/client.py +115 -0
- vikingrag/domain/__init__.py +58 -0
- vikingrag/domain/errors.py +124 -0
- vikingrag/domain/models/__init__.py +114 -0
- vikingrag/domain/models/answer.py +64 -0
- vikingrag/domain/models/assessment.py +68 -0
- vikingrag/domain/models/document.py +45 -0
- vikingrag/domain/models/evidence.py +101 -0
- vikingrag/domain/models/experience.py +245 -0
- vikingrag/domain/models/node.py +52 -0
- vikingrag/domain/models/primitives.py +147 -0
- vikingrag/domain/models/representation.py +188 -0
- vikingrag/domain/models/retrieval.py +85 -0
- vikingrag/domain/summary.py +23 -0
- vikingrag/domain/uri/__init__.py +11 -0
- vikingrag/domain/uri/object_uri.py +141 -0
- vikingrag/domain/uri/viking_uri.py +121 -0
- vikingrag/evaluation/__init__.py +16 -0
- vikingrag/evaluation/adapters/__init__.py +40 -0
- vikingrag/evaluation/adapters/financebench.py +20 -0
- vikingrag/evaluation/adapters/hotpotqa.py +20 -0
- vikingrag/evaluation/adapters/legalbench_cuad.py +20 -0
- vikingrag/evaluation/adapters/qasper.py +20 -0
- vikingrag/evaluation/adapters/syllabusqa.py +23 -0
- vikingrag/evaluation/adapters/versionqa.py +20 -0
- vikingrag/evaluation/base.py +146 -0
- vikingrag/evaluation/cli.py +156 -0
- vikingrag/evaluation/smoke.py +134 -0
- vikingrag/evaluation/warmup.py +56 -0
- vikingrag/infrastructure/__init__.py +1 -0
- vikingrag/infrastructure/cache/__init__.py +3 -0
- vikingrag/infrastructure/cache/redis.py +32 -0
- vikingrag/infrastructure/database/__init__.py +15 -0
- vikingrag/infrastructure/database/engine.py +56 -0
- vikingrag/infrastructure/database/models.py +328 -0
- vikingrag/infrastructure/database/repositories/__init__.py +31 -0
- vikingrag/infrastructure/database/repositories/base.py +80 -0
- vikingrag/infrastructure/database/repositories/document.py +115 -0
- vikingrag/infrastructure/database/repositories/embedding.py +253 -0
- vikingrag/infrastructure/database/repositories/experience.py +440 -0
- vikingrag/infrastructure/database/repositories/node.py +260 -0
- vikingrag/infrastructure/database/repositories/representation.py +113 -0
- vikingrag/infrastructure/database/repositories/vector.py +28 -0
- vikingrag/infrastructure/object_store/__init__.py +6 -0
- vikingrag/infrastructure/object_store/base.py +16 -0
- vikingrag/infrastructure/object_store/factory.py +27 -0
- vikingrag/infrastructure/object_store/local.py +55 -0
- vikingrag/infrastructure/object_store/s3.py +197 -0
- vikingrag/ingestion/__init__.py +1 -0
- vikingrag/ingestion/chunking.py +213 -0
- vikingrag/ingestion/hashing.py +13 -0
- vikingrag/ingestion/hierarchy.py +133 -0
- vikingrag/ingestion/parsers/__init__.py +4 -0
- vikingrag/ingestion/parsers/base.py +17 -0
- vikingrag/ingestion/parsers/docx.py +103 -0
- vikingrag/ingestion/parsers/markdown.py +108 -0
- vikingrag/ingestion/parsers/pdf.py +113 -0
- vikingrag/ingestion/parsers/registry.py +34 -0
- vikingrag/ingestion/parsers/text.py +95 -0
- vikingrag/ingestion/pipeline.py +291 -0
- vikingrag/ingestion/tokenization.py +62 -0
- vikingrag/ingestion/types.py +95 -0
- vikingrag/migrate.py +79 -0
- vikingrag/migrations/README +12 -0
- vikingrag/migrations/env.py +70 -0
- vikingrag/migrations/script.py.mako +28 -0
- vikingrag/migrations/versions/20260326_0001_enable_pgvector_and_documents.py +61 -0
- vikingrag/migrations/versions/20261007_0002_document_nodes_hierarchy.py +117 -0
- vikingrag/migrations/versions/20261007_0003_node_representations_embeddings.py +151 -0
- vikingrag/migrations/versions/20261007_0004_experience_edges.py +266 -0
- vikingrag/observability/__init__.py +16 -0
- vikingrag/observability/context.py +32 -0
- vikingrag/observability/logging.py +78 -0
- vikingrag/providers/__init__.py +1 -0
- vikingrag/providers/embeddings/__init__.py +3 -0
- vikingrag/providers/embeddings/base.py +41 -0
- vikingrag/providers/embeddings/deterministic.py +95 -0
- vikingrag/providers/embeddings/openai_compatible.py +152 -0
- vikingrag/providers/factory.py +106 -0
- vikingrag/providers/llm/__init__.py +33 -0
- vikingrag/providers/llm/anthropic.py +295 -0
- vikingrag/providers/llm/base.py +130 -0
- vikingrag/providers/llm/fake.py +261 -0
- vikingrag/providers/llm/openai_compatible.py +270 -0
- vikingrag/providers/llm/tools.py +170 -0
- vikingrag/providers/presets.py +152 -0
- vikingrag/providers/reranking/__init__.py +3 -0
- vikingrag/providers/reranking/base.py +24 -0
- vikingrag/providers/reranking/noop.py +20 -0
- vikingrag/settings/__init__.py +3 -0
- vikingrag/settings/config.py +402 -0
- vikingrag/workers/__init__.py +5 -0
- vikingrag/workers/__main__.py +70 -0
- vikingrag/workers/edge_builder.py +115 -0
- vikingrag-0.4.0.dist-info/METADATA +526 -0
- vikingrag-0.4.0.dist-info/RECORD +149 -0
- vikingrag-0.4.0.dist-info/WHEEL +4 -0
- vikingrag-0.4.0.dist-info/entry_points.txt +4 -0
- vikingrag-0.4.0.dist-info/licenses/LICENSE +201 -0
- vikingrag-0.4.0.dist-info/licenses/NOTICE +30 -0
vikingrag/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""HTTP API surface."""
|
vikingrag/api/app.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""FastAPI application factory and lifespan."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import AsyncIterator
|
|
7
|
+
from contextlib import asynccontextmanager
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from fastapi import FastAPI, Request, Response
|
|
11
|
+
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
|
12
|
+
|
|
13
|
+
from vikingrag import __version__
|
|
14
|
+
from vikingrag.api.errors import register_exception_handlers
|
|
15
|
+
from vikingrag.api.routers import (
|
|
16
|
+
answers_router,
|
|
17
|
+
documents_router,
|
|
18
|
+
health_router,
|
|
19
|
+
retrieval_router,
|
|
20
|
+
search_router,
|
|
21
|
+
)
|
|
22
|
+
from vikingrag.application.experience.support_select import build_support_selector
|
|
23
|
+
from vikingrag.domain.errors import NotImplementedCapabilityError
|
|
24
|
+
from vikingrag.infrastructure.cache.redis import create_redis_client
|
|
25
|
+
from vikingrag.infrastructure.database.engine import create_database
|
|
26
|
+
from vikingrag.infrastructure.object_store.factory import build_object_store
|
|
27
|
+
from vikingrag.observability.context import bind_request_context, clear_request_context
|
|
28
|
+
from vikingrag.observability.logging import configure_logging, get_logger
|
|
29
|
+
from vikingrag.providers.factory import build_embedding_provider, build_llm_provider
|
|
30
|
+
from vikingrag.settings.config import Settings, get_settings
|
|
31
|
+
from vikingrag.workers.edge_builder import EdgeBuilderWorker
|
|
32
|
+
|
|
33
|
+
logger = get_logger(__name__)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class CorrelationMiddleware(BaseHTTPMiddleware):
|
|
37
|
+
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
|
38
|
+
request_id = request.headers.get("x-request-id")
|
|
39
|
+
trace_id = request.headers.get("x-trace-id")
|
|
40
|
+
rid, tid = bind_request_context(request_id=request_id, trace_id=trace_id)
|
|
41
|
+
try:
|
|
42
|
+
response = await call_next(request)
|
|
43
|
+
response.headers["x-request-id"] = rid
|
|
44
|
+
response.headers["x-trace-id"] = tid
|
|
45
|
+
return response
|
|
46
|
+
finally:
|
|
47
|
+
clear_request_context()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _provider_configured(name: str) -> bool:
|
|
51
|
+
return name.lower().strip() not in {"unimplemented", "", "none"}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def _aclose_maybe(obj: Any) -> None:
|
|
55
|
+
if obj is None:
|
|
56
|
+
return
|
|
57
|
+
close = getattr(obj, "aclose", None)
|
|
58
|
+
if close is not None:
|
|
59
|
+
await close()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@asynccontextmanager
|
|
63
|
+
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
64
|
+
settings: Settings = app.state.settings
|
|
65
|
+
configure_logging(
|
|
66
|
+
level=settings.app.log_level,
|
|
67
|
+
json_logs=settings.app.env != "development",
|
|
68
|
+
)
|
|
69
|
+
logger.info("starting_application", version=__version__, env=settings.app.env)
|
|
70
|
+
|
|
71
|
+
database = create_database(settings.database)
|
|
72
|
+
redis = create_redis_client(settings.redis)
|
|
73
|
+
object_store = build_object_store(settings.object_store)
|
|
74
|
+
|
|
75
|
+
embedding_provider = None
|
|
76
|
+
llm_provider = None
|
|
77
|
+
if _provider_configured(settings.embedding.provider):
|
|
78
|
+
try:
|
|
79
|
+
embedding_provider = build_embedding_provider(settings)
|
|
80
|
+
except NotImplementedCapabilityError:
|
|
81
|
+
logger.warning(
|
|
82
|
+
"embedding_provider_unconfigured",
|
|
83
|
+
provider=settings.embedding.provider,
|
|
84
|
+
)
|
|
85
|
+
if _provider_configured(settings.llm.provider):
|
|
86
|
+
try:
|
|
87
|
+
llm_provider = build_llm_provider(settings)
|
|
88
|
+
except NotImplementedCapabilityError:
|
|
89
|
+
logger.warning("llm_provider_unconfigured", provider=settings.llm.provider)
|
|
90
|
+
|
|
91
|
+
app.state.database = database
|
|
92
|
+
app.state.redis = redis
|
|
93
|
+
app.state.object_store = object_store
|
|
94
|
+
app.state.embedding_provider = embedding_provider
|
|
95
|
+
app.state.llm_provider = llm_provider
|
|
96
|
+
|
|
97
|
+
edge_worker: EdgeBuilderWorker | None = None
|
|
98
|
+
edge_task: asyncio.Task[None] | None = None
|
|
99
|
+
if settings.workers.edge_builder_enabled:
|
|
100
|
+
selector = build_support_selector(
|
|
101
|
+
mode=settings.retrieval.support_selector,
|
|
102
|
+
llm=llm_provider,
|
|
103
|
+
model=settings.llm.model,
|
|
104
|
+
max_support=settings.retrieval.experience_max_support,
|
|
105
|
+
)
|
|
106
|
+
edge_worker = EdgeBuilderWorker(
|
|
107
|
+
database=database,
|
|
108
|
+
poll_interval_seconds=settings.workers.edge_builder_poll_seconds,
|
|
109
|
+
batch_size=settings.workers.edge_builder_batch_size,
|
|
110
|
+
max_support=settings.retrieval.experience_max_support,
|
|
111
|
+
support_selector=selector,
|
|
112
|
+
)
|
|
113
|
+
edge_task = asyncio.create_task(edge_worker.run_forever(), name="edge_builder")
|
|
114
|
+
app.state.edge_builder_worker = edge_worker
|
|
115
|
+
logger.info(
|
|
116
|
+
"edge_builder_worker_attached",
|
|
117
|
+
poll_seconds=settings.workers.edge_builder_poll_seconds,
|
|
118
|
+
)
|
|
119
|
+
else:
|
|
120
|
+
app.state.edge_builder_worker = None
|
|
121
|
+
logger.info("edge_builder_worker_disabled")
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
yield
|
|
125
|
+
finally:
|
|
126
|
+
logger.info("shutting_down_application")
|
|
127
|
+
if edge_worker is not None:
|
|
128
|
+
edge_worker.request_stop()
|
|
129
|
+
if edge_task is not None:
|
|
130
|
+
try:
|
|
131
|
+
await asyncio.wait_for(edge_task, timeout=10.0)
|
|
132
|
+
except (TimeoutError, asyncio.CancelledError):
|
|
133
|
+
edge_task.cancel()
|
|
134
|
+
await _aclose_maybe(embedding_provider)
|
|
135
|
+
await _aclose_maybe(llm_provider)
|
|
136
|
+
await _aclose_maybe(object_store)
|
|
137
|
+
await redis.close()
|
|
138
|
+
await database.dispose()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def create_app(settings: Settings | None = None) -> FastAPI:
|
|
142
|
+
cfg = settings or get_settings()
|
|
143
|
+
app = FastAPI(
|
|
144
|
+
title="VikingRAG",
|
|
145
|
+
description=(
|
|
146
|
+
"Production-oriented hierarchical retrieval platform inspired by VikingRAG. "
|
|
147
|
+
"Hierarchical ingestion, semantic Search, List/Grep/Read, and evidence sufficiency."
|
|
148
|
+
),
|
|
149
|
+
version=__version__,
|
|
150
|
+
lifespan=lifespan,
|
|
151
|
+
docs_url="/docs",
|
|
152
|
+
redoc_url="/redoc",
|
|
153
|
+
openapi_url="/openapi.json",
|
|
154
|
+
)
|
|
155
|
+
app.state.settings = cfg
|
|
156
|
+
app.add_middleware(CorrelationMiddleware)
|
|
157
|
+
register_exception_handlers(app)
|
|
158
|
+
app.include_router(health_router)
|
|
159
|
+
app.include_router(documents_router)
|
|
160
|
+
app.include_router(search_router)
|
|
161
|
+
app.include_router(retrieval_router)
|
|
162
|
+
app.include_router(answers_router)
|
|
163
|
+
return app
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def run() -> None:
|
|
167
|
+
import uvicorn
|
|
168
|
+
|
|
169
|
+
settings = get_settings()
|
|
170
|
+
uvicorn.run(
|
|
171
|
+
"vikingrag.api.app:create_app",
|
|
172
|
+
factory=True,
|
|
173
|
+
host=settings.app.host,
|
|
174
|
+
port=settings.app.port,
|
|
175
|
+
reload=settings.app.debug,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# ASGI entry used by Docker / uvicorn without factory flag when needed
|
|
180
|
+
app: Any = None
|
vikingrag/api/auth.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Single-tenant API-key auth and server-derived document ACL."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from uuid import UUID
|
|
7
|
+
|
|
8
|
+
from fastapi import Request
|
|
9
|
+
from fastapi.security.utils import get_authorization_scheme_param
|
|
10
|
+
|
|
11
|
+
from vikingrag.domain.errors import AuthenticationError, ScopeDeniedError
|
|
12
|
+
from vikingrag.domain.models.document import DocumentId
|
|
13
|
+
from vikingrag.settings.config import (
|
|
14
|
+
Settings,
|
|
15
|
+
derive_permitted_document_ids,
|
|
16
|
+
parse_allowed_document_ids,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"AuthContext",
|
|
21
|
+
"derive_permitted_document_ids",
|
|
22
|
+
"parse_allowed_document_ids",
|
|
23
|
+
"require_auth",
|
|
24
|
+
"resolve_auth_context",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True, slots=True)
|
|
29
|
+
class AuthContext:
|
|
30
|
+
"""Resolved auth for a request.
|
|
31
|
+
|
|
32
|
+
``permitted_document_ids``:
|
|
33
|
+
- ``None`` — unrestricted
|
|
34
|
+
- ``frozenset()`` — allow-nothing
|
|
35
|
+
- nonempty frozenset — allowlist
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
authenticated: bool
|
|
39
|
+
permitted_document_ids: frozenset[DocumentId] | None
|
|
40
|
+
|
|
41
|
+
def ensure_document_allowed(self, document_id: UUID | DocumentId) -> None:
|
|
42
|
+
did = DocumentId(document_id) if isinstance(document_id, UUID) else document_id
|
|
43
|
+
if self.permitted_document_ids is None:
|
|
44
|
+
return
|
|
45
|
+
if did not in self.permitted_document_ids:
|
|
46
|
+
raise ScopeDeniedError(f"Document {did} is outside permitted scope")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _extract_api_key(request: Request) -> str | None:
|
|
50
|
+
header = request.headers.get("x-api-key")
|
|
51
|
+
if header:
|
|
52
|
+
return header.strip() or None
|
|
53
|
+
authorization = request.headers.get("authorization")
|
|
54
|
+
if not authorization:
|
|
55
|
+
return None
|
|
56
|
+
scheme, param = get_authorization_scheme_param(authorization)
|
|
57
|
+
if scheme.lower() != "bearer":
|
|
58
|
+
return None
|
|
59
|
+
return param.strip() or None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def resolve_auth_context(settings: Settings, request: Request) -> AuthContext:
|
|
63
|
+
auth = settings.auth
|
|
64
|
+
permitted = derive_permitted_document_ids(auth)
|
|
65
|
+
if not auth.enabled:
|
|
66
|
+
return AuthContext(authenticated=False, permitted_document_ids=None)
|
|
67
|
+
|
|
68
|
+
expected = auth.api_key
|
|
69
|
+
if not expected:
|
|
70
|
+
raise AuthenticationError("Auth is enabled but VIKINGRAG_AUTH_API_KEY is not configured")
|
|
71
|
+
|
|
72
|
+
provided = _extract_api_key(request)
|
|
73
|
+
if provided is None or provided != expected:
|
|
74
|
+
raise AuthenticationError("Invalid or missing API key")
|
|
75
|
+
|
|
76
|
+
return AuthContext(authenticated=True, permitted_document_ids=permitted)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
async def require_auth(request: Request) -> AuthContext:
|
|
80
|
+
"""FastAPI dependency: enforce API key when auth is enabled."""
|
|
81
|
+
settings: Settings = request.app.state.settings
|
|
82
|
+
return resolve_auth_context(settings, request)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""FastAPI dependency injection - resources live on app.state."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated, Any, cast
|
|
6
|
+
|
|
7
|
+
from fastapi import Depends, Request
|
|
8
|
+
|
|
9
|
+
from vikingrag.api.auth import AuthContext, require_auth
|
|
10
|
+
from vikingrag.infrastructure.cache.redis import RedisClient
|
|
11
|
+
from vikingrag.infrastructure.database.engine import Database
|
|
12
|
+
from vikingrag.infrastructure.object_store.base import ObjectStore
|
|
13
|
+
from vikingrag.providers.embeddings.base import EmbeddingProvider
|
|
14
|
+
from vikingrag.providers.factory import build_embedding_provider, build_llm_provider
|
|
15
|
+
from vikingrag.settings.config import Settings
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_settings_dep(request: Request) -> Settings:
|
|
19
|
+
return cast(Settings, request.app.state.settings)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_database(request: Request) -> Database:
|
|
23
|
+
return cast(Database, request.app.state.database)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_redis(request: Request) -> RedisClient:
|
|
27
|
+
return cast(RedisClient, request.app.state.redis)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_object_store(request: Request) -> ObjectStore:
|
|
31
|
+
return cast(ObjectStore, request.app.state.object_store)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_embedding_provider(request: Request) -> EmbeddingProvider:
|
|
35
|
+
owned = getattr(request.app.state, "embedding_provider", None)
|
|
36
|
+
if owned is not None:
|
|
37
|
+
return cast(EmbeddingProvider, owned)
|
|
38
|
+
return build_embedding_provider(cast(Settings, request.app.state.settings))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_llm_provider(request: Request) -> Any:
|
|
42
|
+
owned = getattr(request.app.state, "llm_provider", None)
|
|
43
|
+
if owned is not None:
|
|
44
|
+
return owned
|
|
45
|
+
return build_llm_provider(cast(Settings, request.app.state.settings))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
SettingsDep = Annotated[Settings, Depends(get_settings_dep)]
|
|
49
|
+
DatabaseDep = Annotated[Database, Depends(get_database)]
|
|
50
|
+
RedisDep = Annotated[RedisClient, Depends(get_redis)]
|
|
51
|
+
ObjectStoreDep = Annotated[ObjectStore, Depends(get_object_store)]
|
|
52
|
+
AuthDep = Annotated[AuthContext, Depends(require_auth)]
|
|
53
|
+
EmbeddingProviderDep = Annotated[EmbeddingProvider, Depends(get_embedding_provider)]
|
|
54
|
+
LLMProviderDep = Annotated[Any, Depends(get_llm_provider)]
|
vikingrag/api/errors.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Map domain errors to HTTP responses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fastapi import FastAPI, Request
|
|
6
|
+
from fastapi.responses import JSONResponse
|
|
7
|
+
|
|
8
|
+
from vikingrag.domain.errors import (
|
|
9
|
+
AuthenticationError,
|
|
10
|
+
BudgetExhaustedError,
|
|
11
|
+
ConflictError,
|
|
12
|
+
DocumentAlreadyExists,
|
|
13
|
+
DocumentNotFound,
|
|
14
|
+
DocumentParseError,
|
|
15
|
+
DomainError,
|
|
16
|
+
HierarchyConstructionError,
|
|
17
|
+
InvalidVikingURI,
|
|
18
|
+
NodeNotFound,
|
|
19
|
+
NotFoundError,
|
|
20
|
+
ScopeDeniedError,
|
|
21
|
+
StaleSourceError,
|
|
22
|
+
UnsupportedDocumentType,
|
|
23
|
+
ValidationDomainError,
|
|
24
|
+
)
|
|
25
|
+
from vikingrag.observability.logging import get_logger
|
|
26
|
+
|
|
27
|
+
logger = get_logger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _payload(exc: DomainError) -> dict[str, str]:
|
|
31
|
+
return {"error": exc.code, "message": exc.message}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def register_exception_handlers(app: FastAPI) -> None:
|
|
35
|
+
@app.exception_handler(AuthenticationError)
|
|
36
|
+
async def authentication(_request: Request, exc: AuthenticationError) -> JSONResponse:
|
|
37
|
+
return JSONResponse(
|
|
38
|
+
status_code=401,
|
|
39
|
+
content=_payload(exc),
|
|
40
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
@app.exception_handler(UnsupportedDocumentType)
|
|
44
|
+
async def unsupported_type(_request: Request, exc: UnsupportedDocumentType) -> JSONResponse:
|
|
45
|
+
return JSONResponse(status_code=415, content=_payload(exc))
|
|
46
|
+
|
|
47
|
+
@app.exception_handler(DocumentParseError)
|
|
48
|
+
async def parse_error(_request: Request, exc: DocumentParseError) -> JSONResponse:
|
|
49
|
+
return JSONResponse(status_code=422, content=_payload(exc))
|
|
50
|
+
|
|
51
|
+
@app.exception_handler(HierarchyConstructionError)
|
|
52
|
+
async def hierarchy_error(_request: Request, exc: HierarchyConstructionError) -> JSONResponse:
|
|
53
|
+
return JSONResponse(status_code=422, content=_payload(exc))
|
|
54
|
+
|
|
55
|
+
@app.exception_handler(InvalidVikingURI)
|
|
56
|
+
async def invalid_uri(_request: Request, exc: InvalidVikingURI) -> JSONResponse:
|
|
57
|
+
return JSONResponse(status_code=400, content=_payload(exc))
|
|
58
|
+
|
|
59
|
+
@app.exception_handler(DocumentAlreadyExists)
|
|
60
|
+
async def already_exists(_request: Request, exc: DocumentAlreadyExists) -> JSONResponse:
|
|
61
|
+
return JSONResponse(status_code=409, content=_payload(exc))
|
|
62
|
+
|
|
63
|
+
@app.exception_handler(DocumentNotFound)
|
|
64
|
+
async def document_not_found(_request: Request, exc: DocumentNotFound) -> JSONResponse:
|
|
65
|
+
return JSONResponse(status_code=404, content=_payload(exc))
|
|
66
|
+
|
|
67
|
+
@app.exception_handler(NodeNotFound)
|
|
68
|
+
async def node_not_found(_request: Request, exc: NodeNotFound) -> JSONResponse:
|
|
69
|
+
return JSONResponse(status_code=404, content=_payload(exc))
|
|
70
|
+
|
|
71
|
+
@app.exception_handler(NotFoundError)
|
|
72
|
+
async def not_found(_request: Request, exc: NotFoundError) -> JSONResponse:
|
|
73
|
+
return JSONResponse(status_code=404, content=_payload(exc))
|
|
74
|
+
|
|
75
|
+
@app.exception_handler(ConflictError)
|
|
76
|
+
async def conflict(_request: Request, exc: ConflictError) -> JSONResponse:
|
|
77
|
+
return JSONResponse(status_code=409, content=_payload(exc))
|
|
78
|
+
|
|
79
|
+
@app.exception_handler(ValidationDomainError)
|
|
80
|
+
async def validation(_request: Request, exc: ValidationDomainError) -> JSONResponse:
|
|
81
|
+
return JSONResponse(status_code=422, content=_payload(exc))
|
|
82
|
+
|
|
83
|
+
@app.exception_handler(ScopeDeniedError)
|
|
84
|
+
async def scope_denied(_request: Request, exc: ScopeDeniedError) -> JSONResponse:
|
|
85
|
+
return JSONResponse(status_code=403, content=_payload(exc))
|
|
86
|
+
|
|
87
|
+
@app.exception_handler(StaleSourceError)
|
|
88
|
+
async def stale_source(_request: Request, exc: StaleSourceError) -> JSONResponse:
|
|
89
|
+
return JSONResponse(status_code=409, content=_payload(exc))
|
|
90
|
+
|
|
91
|
+
@app.exception_handler(BudgetExhaustedError)
|
|
92
|
+
async def budget_exhausted(_request: Request, exc: BudgetExhaustedError) -> JSONResponse:
|
|
93
|
+
return JSONResponse(status_code=429, content=_payload(exc))
|
|
94
|
+
|
|
95
|
+
@app.exception_handler(DomainError)
|
|
96
|
+
async def domain(_request: Request, exc: DomainError) -> JSONResponse:
|
|
97
|
+
logger.warning("domain_error", code=exc.code, message=exc.message)
|
|
98
|
+
return JSONResponse(status_code=400, content=_payload(exc))
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from vikingrag.api.routers.answers import router as answers_router
|
|
2
|
+
from vikingrag.api.routers.documents import router as documents_router
|
|
3
|
+
from vikingrag.api.routers.health import router as health_router
|
|
4
|
+
from vikingrag.api.routers.retrieval import router as retrieval_router
|
|
5
|
+
from vikingrag.api.routers.search import router as search_router
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"answers_router",
|
|
9
|
+
"documents_router",
|
|
10
|
+
"health_router",
|
|
11
|
+
"retrieval_router",
|
|
12
|
+
"search_router",
|
|
13
|
+
]
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""HTTP endpoints for answers and query execution modes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib import import_module
|
|
6
|
+
from typing import Annotated, Any
|
|
7
|
+
from uuid import uuid4
|
|
8
|
+
|
|
9
|
+
from fastapi import APIRouter, Depends, Request
|
|
10
|
+
|
|
11
|
+
from vikingrag.api.auth import AuthContext, require_auth
|
|
12
|
+
from vikingrag.api.schemas.answers import (
|
|
13
|
+
AnswerRequestBody,
|
|
14
|
+
AnswerResponseBody,
|
|
15
|
+
QueryRequestBody,
|
|
16
|
+
)
|
|
17
|
+
from vikingrag.application.answer.generate import AnswerGenerator
|
|
18
|
+
from vikingrag.application.grep_primitive import GrepService
|
|
19
|
+
from vikingrag.application.list_primitive import ListService
|
|
20
|
+
from vikingrag.application.read_primitive import ReadService
|
|
21
|
+
from vikingrag.application.search import SemanticSearchService
|
|
22
|
+
from vikingrag.domain.models.answer import AnswerRequest, ExecutionMode
|
|
23
|
+
from vikingrag.domain.models.document import DocumentId
|
|
24
|
+
from vikingrag.providers.factory import (
|
|
25
|
+
build_embedding_provider,
|
|
26
|
+
build_llm_provider,
|
|
27
|
+
build_reranker,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
router = APIRouter(
|
|
31
|
+
prefix="/v1",
|
|
32
|
+
tags=["answers"],
|
|
33
|
+
dependencies=[Depends(require_auth)],
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _embedding_provider(request: Request) -> Any:
|
|
38
|
+
owned = getattr(request.app.state, "embedding_provider", None)
|
|
39
|
+
if owned is not None:
|
|
40
|
+
return owned
|
|
41
|
+
return build_embedding_provider(request.app.state.settings)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _llm_provider(request: Request) -> Any:
|
|
45
|
+
owned = getattr(request.app.state, "llm_provider", None)
|
|
46
|
+
if owned is not None:
|
|
47
|
+
return owned
|
|
48
|
+
return build_llm_provider(request.app.state.settings)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _search_service(request: Request) -> SemanticSearchService:
|
|
52
|
+
settings = request.app.state.settings
|
|
53
|
+
reranker = build_reranker(settings) if settings.retrieval.rerank_enabled else None
|
|
54
|
+
return SemanticSearchService(
|
|
55
|
+
database=request.app.state.database,
|
|
56
|
+
embedding_provider=_embedding_provider(request),
|
|
57
|
+
retrieval_settings=settings.retrieval,
|
|
58
|
+
embedding_settings=settings.embedding,
|
|
59
|
+
reranker=reranker,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _maybe_search_plus(request: Request) -> Any | None:
|
|
64
|
+
"""Prefer Search+ hook when application/search_plus exists."""
|
|
65
|
+
try:
|
|
66
|
+
mod = import_module("vikingrag.application.search_plus")
|
|
67
|
+
except ModuleNotFoundError:
|
|
68
|
+
return None
|
|
69
|
+
factory = getattr(mod, "build_search_plus", None) or getattr(mod, "SearchPlusService", None)
|
|
70
|
+
if factory is None:
|
|
71
|
+
return None
|
|
72
|
+
try:
|
|
73
|
+
if callable(factory) and getattr(factory, "__name__", "") == "build_search_plus":
|
|
74
|
+
return factory(request.app.state.settings, request.app.state.database)
|
|
75
|
+
return factory(
|
|
76
|
+
database=request.app.state.database,
|
|
77
|
+
settings=request.app.state.settings,
|
|
78
|
+
)
|
|
79
|
+
except (TypeError, Exception):
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _answer_generator(request: Request) -> AnswerGenerator:
|
|
84
|
+
settings = request.app.state.settings
|
|
85
|
+
return AnswerGenerator(
|
|
86
|
+
llm=_llm_provider(request),
|
|
87
|
+
search=_search_service(request),
|
|
88
|
+
list_service=ListService(database=request.app.state.database),
|
|
89
|
+
grep_service=GrepService(database=request.app.state.database),
|
|
90
|
+
read_service=ReadService(database=request.app.state.database),
|
|
91
|
+
settings=settings,
|
|
92
|
+
search_plus=_maybe_search_plus(request),
|
|
93
|
+
database=request.app.state.database,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _to_domain(body: AnswerRequestBody) -> AnswerRequest:
|
|
98
|
+
return AnswerRequest(
|
|
99
|
+
question=body.question,
|
|
100
|
+
document_ids=tuple(DocumentId(x) for x in body.document_ids),
|
|
101
|
+
execution_mode=ExecutionMode(body.execution_mode),
|
|
102
|
+
instructions=body.instructions,
|
|
103
|
+
max_rounds=body.max_rounds,
|
|
104
|
+
query_id=uuid4(),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@router.post("/answers", response_model=AnswerResponseBody)
|
|
109
|
+
async def create_answer(
|
|
110
|
+
request: Request,
|
|
111
|
+
body: AnswerRequestBody,
|
|
112
|
+
auth: Annotated[AuthContext, Depends(require_auth)],
|
|
113
|
+
) -> AnswerResponseBody:
|
|
114
|
+
result = await _answer_generator(request).generate(
|
|
115
|
+
_to_domain(body),
|
|
116
|
+
permitted_document_ids=auth.permitted_document_ids,
|
|
117
|
+
)
|
|
118
|
+
return AnswerResponseBody.from_domain(result)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@router.post("/query", response_model=AnswerResponseBody)
|
|
122
|
+
async def query(
|
|
123
|
+
request: Request,
|
|
124
|
+
body: QueryRequestBody,
|
|
125
|
+
auth: Annotated[AuthContext, Depends(require_auth)],
|
|
126
|
+
) -> AnswerResponseBody:
|
|
127
|
+
"""Execute vikingrag | vikingrag_e | vikingrag_e_plus."""
|
|
128
|
+
result = await _answer_generator(request).generate(
|
|
129
|
+
_to_domain(body),
|
|
130
|
+
permitted_document_ids=auth.permitted_document_ids,
|
|
131
|
+
)
|
|
132
|
+
return AnswerResponseBody.from_domain(result)
|