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.
Files changed (149) hide show
  1. vikingrag/__init__.py +7 -0
  2. vikingrag/api/__init__.py +1 -0
  3. vikingrag/api/app.py +180 -0
  4. vikingrag/api/auth.py +82 -0
  5. vikingrag/api/dependencies.py +54 -0
  6. vikingrag/api/errors.py +98 -0
  7. vikingrag/api/routers/__init__.py +13 -0
  8. vikingrag/api/routers/answers.py +132 -0
  9. vikingrag/api/routers/documents.py +157 -0
  10. vikingrag/api/routers/health.py +83 -0
  11. vikingrag/api/routers/retrieval.py +201 -0
  12. vikingrag/api/routers/search.py +121 -0
  13. vikingrag/api/schemas/answers.py +92 -0
  14. vikingrag/api/schemas/documents.py +146 -0
  15. vikingrag/api/schemas/retrieval.py +298 -0
  16. vikingrag/api/schemas/search.py +175 -0
  17. vikingrag/application/__init__.py +1 -0
  18. vikingrag/application/agent/__init__.py +26 -0
  19. vikingrag/application/agent/finalize.py +188 -0
  20. vikingrag/application/agent/loop.py +338 -0
  21. vikingrag/application/agent/tool_executor.py +279 -0
  22. vikingrag/application/answer/__init__.py +5 -0
  23. vikingrag/application/answer/generate.py +225 -0
  24. vikingrag/application/assessment.py +632 -0
  25. vikingrag/application/budget.py +262 -0
  26. vikingrag/application/cursors.py +33 -0
  27. vikingrag/application/documents.py +93 -0
  28. vikingrag/application/evidence.py +102 -0
  29. vikingrag/application/evidence_bundle.py +223 -0
  30. vikingrag/application/evidence_collector.py +323 -0
  31. vikingrag/application/experience/__init__.py +18 -0
  32. vikingrag/application/experience/activate.py +59 -0
  33. vikingrag/application/experience/builder.py +196 -0
  34. vikingrag/application/experience/enqueue.py +107 -0
  35. vikingrag/application/experience/expand.py +147 -0
  36. vikingrag/application/experience/invalidate.py +47 -0
  37. vikingrag/application/experience/memory_store.py +86 -0
  38. vikingrag/application/experience/support_select.py +210 -0
  39. vikingrag/application/experience/trace_sets.py +70 -0
  40. vikingrag/application/grep_primitive.py +367 -0
  41. vikingrag/application/indexing.py +529 -0
  42. vikingrag/application/list_primitive.py +128 -0
  43. vikingrag/application/navigation.py +83 -0
  44. vikingrag/application/orchestration/__init__.py +13 -0
  45. vikingrag/application/orchestration/candidate.py +53 -0
  46. vikingrag/application/orchestration/query.py +316 -0
  47. vikingrag/application/read_primitive.py +212 -0
  48. vikingrag/application/search.py +267 -0
  49. vikingrag/application/search_plus.py +251 -0
  50. vikingrag/client.py +115 -0
  51. vikingrag/domain/__init__.py +58 -0
  52. vikingrag/domain/errors.py +124 -0
  53. vikingrag/domain/models/__init__.py +114 -0
  54. vikingrag/domain/models/answer.py +64 -0
  55. vikingrag/domain/models/assessment.py +68 -0
  56. vikingrag/domain/models/document.py +45 -0
  57. vikingrag/domain/models/evidence.py +101 -0
  58. vikingrag/domain/models/experience.py +245 -0
  59. vikingrag/domain/models/node.py +52 -0
  60. vikingrag/domain/models/primitives.py +147 -0
  61. vikingrag/domain/models/representation.py +188 -0
  62. vikingrag/domain/models/retrieval.py +85 -0
  63. vikingrag/domain/summary.py +23 -0
  64. vikingrag/domain/uri/__init__.py +11 -0
  65. vikingrag/domain/uri/object_uri.py +141 -0
  66. vikingrag/domain/uri/viking_uri.py +121 -0
  67. vikingrag/evaluation/__init__.py +16 -0
  68. vikingrag/evaluation/adapters/__init__.py +40 -0
  69. vikingrag/evaluation/adapters/financebench.py +20 -0
  70. vikingrag/evaluation/adapters/hotpotqa.py +20 -0
  71. vikingrag/evaluation/adapters/legalbench_cuad.py +20 -0
  72. vikingrag/evaluation/adapters/qasper.py +20 -0
  73. vikingrag/evaluation/adapters/syllabusqa.py +23 -0
  74. vikingrag/evaluation/adapters/versionqa.py +20 -0
  75. vikingrag/evaluation/base.py +146 -0
  76. vikingrag/evaluation/cli.py +156 -0
  77. vikingrag/evaluation/smoke.py +134 -0
  78. vikingrag/evaluation/warmup.py +56 -0
  79. vikingrag/infrastructure/__init__.py +1 -0
  80. vikingrag/infrastructure/cache/__init__.py +3 -0
  81. vikingrag/infrastructure/cache/redis.py +32 -0
  82. vikingrag/infrastructure/database/__init__.py +15 -0
  83. vikingrag/infrastructure/database/engine.py +56 -0
  84. vikingrag/infrastructure/database/models.py +328 -0
  85. vikingrag/infrastructure/database/repositories/__init__.py +31 -0
  86. vikingrag/infrastructure/database/repositories/base.py +80 -0
  87. vikingrag/infrastructure/database/repositories/document.py +115 -0
  88. vikingrag/infrastructure/database/repositories/embedding.py +253 -0
  89. vikingrag/infrastructure/database/repositories/experience.py +440 -0
  90. vikingrag/infrastructure/database/repositories/node.py +260 -0
  91. vikingrag/infrastructure/database/repositories/representation.py +113 -0
  92. vikingrag/infrastructure/database/repositories/vector.py +28 -0
  93. vikingrag/infrastructure/object_store/__init__.py +6 -0
  94. vikingrag/infrastructure/object_store/base.py +16 -0
  95. vikingrag/infrastructure/object_store/factory.py +27 -0
  96. vikingrag/infrastructure/object_store/local.py +55 -0
  97. vikingrag/infrastructure/object_store/s3.py +197 -0
  98. vikingrag/ingestion/__init__.py +1 -0
  99. vikingrag/ingestion/chunking.py +213 -0
  100. vikingrag/ingestion/hashing.py +13 -0
  101. vikingrag/ingestion/hierarchy.py +133 -0
  102. vikingrag/ingestion/parsers/__init__.py +4 -0
  103. vikingrag/ingestion/parsers/base.py +17 -0
  104. vikingrag/ingestion/parsers/docx.py +103 -0
  105. vikingrag/ingestion/parsers/markdown.py +108 -0
  106. vikingrag/ingestion/parsers/pdf.py +113 -0
  107. vikingrag/ingestion/parsers/registry.py +34 -0
  108. vikingrag/ingestion/parsers/text.py +95 -0
  109. vikingrag/ingestion/pipeline.py +291 -0
  110. vikingrag/ingestion/tokenization.py +62 -0
  111. vikingrag/ingestion/types.py +95 -0
  112. vikingrag/migrate.py +79 -0
  113. vikingrag/migrations/README +12 -0
  114. vikingrag/migrations/env.py +70 -0
  115. vikingrag/migrations/script.py.mako +28 -0
  116. vikingrag/migrations/versions/20260326_0001_enable_pgvector_and_documents.py +61 -0
  117. vikingrag/migrations/versions/20261007_0002_document_nodes_hierarchy.py +117 -0
  118. vikingrag/migrations/versions/20261007_0003_node_representations_embeddings.py +151 -0
  119. vikingrag/migrations/versions/20261007_0004_experience_edges.py +266 -0
  120. vikingrag/observability/__init__.py +16 -0
  121. vikingrag/observability/context.py +32 -0
  122. vikingrag/observability/logging.py +78 -0
  123. vikingrag/providers/__init__.py +1 -0
  124. vikingrag/providers/embeddings/__init__.py +3 -0
  125. vikingrag/providers/embeddings/base.py +41 -0
  126. vikingrag/providers/embeddings/deterministic.py +95 -0
  127. vikingrag/providers/embeddings/openai_compatible.py +152 -0
  128. vikingrag/providers/factory.py +106 -0
  129. vikingrag/providers/llm/__init__.py +33 -0
  130. vikingrag/providers/llm/anthropic.py +295 -0
  131. vikingrag/providers/llm/base.py +130 -0
  132. vikingrag/providers/llm/fake.py +261 -0
  133. vikingrag/providers/llm/openai_compatible.py +270 -0
  134. vikingrag/providers/llm/tools.py +170 -0
  135. vikingrag/providers/presets.py +152 -0
  136. vikingrag/providers/reranking/__init__.py +3 -0
  137. vikingrag/providers/reranking/base.py +24 -0
  138. vikingrag/providers/reranking/noop.py +20 -0
  139. vikingrag/settings/__init__.py +3 -0
  140. vikingrag/settings/config.py +402 -0
  141. vikingrag/workers/__init__.py +5 -0
  142. vikingrag/workers/__main__.py +70 -0
  143. vikingrag/workers/edge_builder.py +115 -0
  144. vikingrag-0.4.0.dist-info/METADATA +526 -0
  145. vikingrag-0.4.0.dist-info/RECORD +149 -0
  146. vikingrag-0.4.0.dist-info/WHEEL +4 -0
  147. vikingrag-0.4.0.dist-info/entry_points.txt +4 -0
  148. vikingrag-0.4.0.dist-info/licenses/LICENSE +201 -0
  149. vikingrag-0.4.0.dist-info/licenses/NOTICE +30 -0
vikingrag/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """VikingRAG: production hierarchical retrieval platform."""
2
+
3
+ from vikingrag.client import VikingRAGClient
4
+
5
+ __version__ = "0.4.0"
6
+
7
+ __all__ = ["VikingRAGClient", "__version__"]
@@ -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)]
@@ -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)