ragfence 0.1.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.
- ragfence/__init__.py +3 -0
- ragfence/adapters/__init__.py +6 -0
- ragfence/adapters/errors.py +38 -0
- ragfence/adapters/factory.py +16 -0
- ragfence/adapters/generic_http.py +466 -0
- ragfence/adapters/mapping.py +67 -0
- ragfence/adapters/models.py +38 -0
- ragfence/adapters/reference.py +154 -0
- ragfence/adapters/wire.py +57 -0
- ragfence/attacks/__init__.py +17 -0
- ragfence/attacks/generators.py +301 -0
- ragfence/attacks/runner.py +131 -0
- ragfence/attacks/scenarios.py +83 -0
- ragfence/auth/__init__.py +33 -0
- ragfence/auth/builder.py +32 -0
- ragfence/auth/db.py +86 -0
- ragfence/auth/errors.py +18 -0
- ragfence/auth/identity.py +31 -0
- ragfence/auth/providers.py +78 -0
- ragfence/auth/registry.py +28 -0
- ragfence/cli/__init__.py +1 -0
- ragfence/cli/config.py +93 -0
- ragfence/cli/errors.py +9 -0
- ragfence/cli/main.py +227 -0
- ragfence/cli/reporting.py +89 -0
- ragfence/cli/seams.py +211 -0
- ragfence/core/__init__.py +1 -0
- ragfence/core/enums.py +48 -0
- ragfence/core/models.py +177 -0
- ragfence/core/protocols.py +49 -0
- ragfence/datasets/__init__.py +28 -0
- ragfence/datasets/acme.py +373 -0
- ragfence/datasets/checksums.py +27 -0
- ragfence/datasets/constants.py +90 -0
- ragfence/datasets/loader.py +233 -0
- ragfence/db/__init__.py +1 -0
- ragfence/db/base.py +12 -0
- ragfence/db/models.py +509 -0
- ragfence/db/repositories.py +152 -0
- ragfence/db/vector_store.py +82 -0
- ragfence/evaluation/__init__.py +1 -0
- ragfence/evaluation/contracts.py +58 -0
- ragfence/evaluation/engine.py +377 -0
- ragfence/evaluation/findings.py +154 -0
- ragfence/evaluation/gate.py +117 -0
- ragfence/evaluation/matrix_runner.py +315 -0
- ragfence/evaluation/metrics.py +152 -0
- ragfence/evaluation/redaction.py +105 -0
- ragfence/evaluation/report.py +166 -0
- ragfence/evaluation/repositories.py +353 -0
- ragfence/evaluation/scoring.py +83 -0
- ragfence/observability/__init__.py +1 -0
- ragfence/policies/__init__.py +1 -0
- ragfence/policies/decision.py +52 -0
- ragfence/policies/guard.py +80 -0
- ragfence/providers/__init__.py +21 -0
- ragfence/providers/errors.py +24 -0
- ragfence/providers/fakes.py +51 -0
- ragfence/providers/llm.py +466 -0
- ragfence/providers/tokens.py +23 -0
- ragfence/providers/transport.py +85 -0
- ragfence/reference/alembic/env.py +72 -0
- ragfence/reference/alembic/script.py.mako +27 -0
- ragfence/reference/alembic/versions/0001_base.py +28 -0
- ragfence/reference/alembic/versions/0002_enums.py +44 -0
- ragfence/reference/alembic/versions/0003_tables.py +206 -0
- ragfence/reference/alembic/versions/0004_indexes.py +93 -0
- ragfence/reference/alembic.ini +44 -0
- ragfence/reference/docker-compose.yml +22 -0
- ragfence/release_readiness/__init__.py +1 -0
- ragfence/release_readiness/audit.py +113 -0
- ragfence/release_readiness/benchmark.py +97 -0
- ragfence/release_readiness/checklist.py +79 -0
- ragfence/retrieval/__init__.py +32 -0
- ragfence/retrieval/citations.py +54 -0
- ragfence/retrieval/result.py +39 -0
- ragfence/retrieval/service.py +144 -0
- ragfence/retrieval/trace.py +24 -0
- ragfence-0.1.0.dist-info/METADATA +162 -0
- ragfence-0.1.0.dist-info/RECORD +83 -0
- ragfence-0.1.0.dist-info/WHEEL +4 -0
- ragfence-0.1.0.dist-info/entry_points.txt +2 -0
- ragfence-0.1.0.dist-info/licenses/LICENSE +201 -0
ragfence/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""RAGAdapter contract and implementations."""
|
|
2
|
+
|
|
3
|
+
from .factory import create_adapter
|
|
4
|
+
from .generic_http import GenericHTTPAdapter, GenericHTTPConfig, GenericHTTPTransport
|
|
5
|
+
|
|
6
|
+
__all__ = ["GenericHTTPAdapter", "GenericHTTPConfig", "GenericHTTPTransport", "create_adapter"]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Typed errors for the generic HTTP adapter boundary."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class GenericHTTPError(RuntimeError):
|
|
7
|
+
"""Base class for safe, operation-scoped generic HTTP failures."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, operation: str, message: str) -> None:
|
|
10
|
+
self.operation = operation
|
|
11
|
+
super().__init__(f"{operation}: {message}")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class GenericHTTPParseError(GenericHTTPError):
|
|
15
|
+
"""Raised when a target response cannot be mapped to a wire contract."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AdapterError(GenericHTTPError):
|
|
19
|
+
"""Compatibility name for adapter-level failures."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AdapterParseError(GenericHTTPParseError, AdapterError):
|
|
23
|
+
"""The target response was not valid for the operation contract."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AdapterTransportError(AdapterError):
|
|
27
|
+
"""The target returned an unsuccessful HTTP status."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, operation: str, status_code: int) -> None:
|
|
30
|
+
self.status_code = status_code
|
|
31
|
+
super().__init__(operation, f"target returned HTTP {status_code}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class UnsupportedOperationError(AdapterError):
|
|
35
|
+
"""The configured target does not support an optional operation."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, operation: str) -> None:
|
|
38
|
+
super().__init__(operation, "operation is not configured")
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Typed construction of configured RAGFence adapters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ragfence.cli.errors import RuntimeCommandError
|
|
6
|
+
|
|
7
|
+
from .generic_http import GenericHTTPAdapter, GenericHTTPConfig
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def create_adapter(name: str, config: GenericHTTPConfig | None) -> GenericHTTPAdapter:
|
|
11
|
+
"""Construct a supported external adapter from its validated config."""
|
|
12
|
+
if name != "generic_http":
|
|
13
|
+
raise RuntimeCommandError(f"unsupported adapter: {name}")
|
|
14
|
+
if config is None:
|
|
15
|
+
raise RuntimeCommandError("missing adapters.generic_http configuration")
|
|
16
|
+
return GenericHTTPAdapter(config)
|
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
"""Configuration, transport, and adapter for generic HTTP targets.
|
|
2
|
+
|
|
3
|
+
The adapter delegates I/O to :class:`GenericHTTPTransport` and keeps response
|
|
4
|
+
decoding at the strict mapping seam.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import math
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import time
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from typing import Annotated, Literal, Protocol, cast
|
|
16
|
+
from urllib.error import HTTPError, URLError
|
|
17
|
+
from urllib.parse import urlsplit
|
|
18
|
+
from urllib.request import Request, urlopen
|
|
19
|
+
from uuid import UUID
|
|
20
|
+
|
|
21
|
+
from pydantic import (
|
|
22
|
+
BaseModel,
|
|
23
|
+
ConfigDict,
|
|
24
|
+
Field,
|
|
25
|
+
StrictInt,
|
|
26
|
+
StrictStr,
|
|
27
|
+
field_validator,
|
|
28
|
+
model_validator,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
from ragfence.core.enums import Classification
|
|
32
|
+
from ragfence.core.models import AuthorizationContext, RetrievalRequest, RetrievedChunk
|
|
33
|
+
from ragfence.evaluation.redaction import redact_secrets
|
|
34
|
+
|
|
35
|
+
from .errors import AdapterError, AdapterTransportError
|
|
36
|
+
from .mapping import map_answer_response, map_retrieve_response, require_answer_path
|
|
37
|
+
|
|
38
|
+
_MAX_HEALTH_RETRIES = 10
|
|
39
|
+
_MAX_BACKOFF_SECONDS = 60.0
|
|
40
|
+
|
|
41
|
+
ALLOWED_IDENTITY_PLACEHOLDERS: frozenset[str] = frozenset(
|
|
42
|
+
{"{actor_user_id}", "{actor_tenant_id}", "{actor_department_id}"}
|
|
43
|
+
)
|
|
44
|
+
_PLACEHOLDER_RE = re.compile(r"\{([^}]*)\}")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _relative_path(value: str) -> str:
|
|
48
|
+
if not value or value.startswith(("/", "\\")) or "\\" in value:
|
|
49
|
+
raise ValueError("endpoint paths must be non-empty relative paths")
|
|
50
|
+
parsed = urlsplit(value)
|
|
51
|
+
if parsed.scheme or parsed.netloc or parsed.query or parsed.fragment:
|
|
52
|
+
raise ValueError("endpoint paths must be relative paths without query or fragment")
|
|
53
|
+
if any(ord(char) < 32 for char in value):
|
|
54
|
+
raise ValueError("endpoint paths must not contain control characters")
|
|
55
|
+
return value
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _validate_identity_templates(templates: dict[str, str]) -> dict[str, str]:
|
|
59
|
+
"""Reject templates containing placeholders outside the allowlist (spec R4.2)."""
|
|
60
|
+
for header_name, template in templates.items():
|
|
61
|
+
for match in _PLACEHOLDER_RE.finditer(template):
|
|
62
|
+
placeholder = f"{{{match.group(1)}}}"
|
|
63
|
+
if placeholder not in ALLOWED_IDENTITY_PLACEHOLDERS:
|
|
64
|
+
raise ValueError(
|
|
65
|
+
f"unknown placeholder {placeholder} in identity header {header_name!r}; "
|
|
66
|
+
f"allowed: {sorted(ALLOWED_IDENTITY_PLACEHOLDERS)}"
|
|
67
|
+
)
|
|
68
|
+
return templates
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class HeadersIdentity(BaseModel):
|
|
72
|
+
"""Identity represented via per-actor headers (spec R4.1)."""
|
|
73
|
+
|
|
74
|
+
model_config = ConfigDict(extra="forbid")
|
|
75
|
+
|
|
76
|
+
kind: Literal["headers"] = "headers"
|
|
77
|
+
headers: dict[str, str]
|
|
78
|
+
|
|
79
|
+
@field_validator("headers")
|
|
80
|
+
@classmethod
|
|
81
|
+
def validate_headers(cls, value: dict[str, str]) -> dict[str, str]:
|
|
82
|
+
if not value:
|
|
83
|
+
raise ValueError("headers identity strategy requires at least one header")
|
|
84
|
+
for header_name in value:
|
|
85
|
+
if not header_name or any(ord(char) < 33 or ord(char) > 126 for char in header_name):
|
|
86
|
+
raise ValueError(f"identity header name {header_name!r} must be visible ASCII")
|
|
87
|
+
return _validate_identity_templates(value)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class BearerIdentity(BaseModel):
|
|
91
|
+
"""Identity represented via a bearer token resolved from an env var (spec R4.2)."""
|
|
92
|
+
|
|
93
|
+
model_config = ConfigDict(extra="forbid")
|
|
94
|
+
|
|
95
|
+
kind: Literal["bearer"] = "bearer"
|
|
96
|
+
header_name: StrictStr = "Authorization"
|
|
97
|
+
token_env: StrictStr
|
|
98
|
+
|
|
99
|
+
@field_validator("header_name")
|
|
100
|
+
@classmethod
|
|
101
|
+
def validate_header_name(cls, value: str) -> str:
|
|
102
|
+
if not value or any(ord(char) < 33 or ord(char) > 126 for char in value):
|
|
103
|
+
raise ValueError("bearer header_name must be visible ASCII")
|
|
104
|
+
return value
|
|
105
|
+
|
|
106
|
+
@field_validator("token_env")
|
|
107
|
+
@classmethod
|
|
108
|
+
def validate_token_env(cls, value: str) -> str:
|
|
109
|
+
if not value:
|
|
110
|
+
raise ValueError("token_env must be a non-empty environment variable name")
|
|
111
|
+
return value
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
IdentityStrategy = Annotated[HeadersIdentity | BearerIdentity, Field(discriminator="kind")]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def resolve_identity_headers(
|
|
118
|
+
strategy: IdentityStrategy | None,
|
|
119
|
+
authorization: AuthorizationContext,
|
|
120
|
+
) -> tuple[dict[str, str], bool]:
|
|
121
|
+
"""Resolve the identity strategy against an authorization context.
|
|
122
|
+
|
|
123
|
+
Returns ``(headers, represented)``. When ``strategy`` is None, returns
|
|
124
|
+
``({}, False)``. For bearer, the token is resolved from the environment;
|
|
125
|
+
if the env var is unset, ``represented`` is False and no header is emitted.
|
|
126
|
+
"""
|
|
127
|
+
if strategy is None:
|
|
128
|
+
return {}, False
|
|
129
|
+
if isinstance(strategy, HeadersIdentity):
|
|
130
|
+
resolved = {}
|
|
131
|
+
for header_name, template in strategy.headers.items():
|
|
132
|
+
resolved[header_name] = _interpolate(template, authorization)
|
|
133
|
+
return resolved, True
|
|
134
|
+
token = os.environ.get(strategy.token_env)
|
|
135
|
+
if not token:
|
|
136
|
+
return {}, False
|
|
137
|
+
return {strategy.header_name: f"Bearer {token}"}, True
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _interpolate(template: str, authorization: AuthorizationContext) -> str:
|
|
141
|
+
"""Interpolate only the allowlisted placeholders from the authorization context."""
|
|
142
|
+
|
|
143
|
+
def _replace(match: re.Match[str]) -> str:
|
|
144
|
+
placeholder = f"{{{match.group(1)}}}"
|
|
145
|
+
if placeholder == "{actor_user_id}":
|
|
146
|
+
return str(authorization.user_id)
|
|
147
|
+
if placeholder == "{actor_tenant_id}":
|
|
148
|
+
return str(authorization.tenant_id)
|
|
149
|
+
if placeholder == "{actor_department_id}":
|
|
150
|
+
return str(authorization.department_id) if authorization.department_id else ""
|
|
151
|
+
return match.group(0)
|
|
152
|
+
|
|
153
|
+
return _PLACEHOLDER_RE.sub(_replace, template)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _sentinel_auth() -> AuthorizationContext:
|
|
157
|
+
"""Minimal authorization context for probing identity resolution without a real actor."""
|
|
158
|
+
return AuthorizationContext(
|
|
159
|
+
tenant_id=UUID(int=0),
|
|
160
|
+
user_id=UUID(int=0),
|
|
161
|
+
department_id=None,
|
|
162
|
+
classification=Classification.INTERNAL,
|
|
163
|
+
allowed_user_ids=set(),
|
|
164
|
+
allowed_group_ids=set(),
|
|
165
|
+
is_authenticated=True,
|
|
166
|
+
source="synthetic",
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class GenericHTTPConfig(BaseModel):
|
|
171
|
+
"""Validated settings loaded from ``[adapters.generic_http]``."""
|
|
172
|
+
|
|
173
|
+
model_config = ConfigDict(extra="forbid")
|
|
174
|
+
|
|
175
|
+
base_url: StrictStr
|
|
176
|
+
retrieve_path: StrictStr
|
|
177
|
+
answer_path: StrictStr | None = None
|
|
178
|
+
health_path: StrictStr = "health"
|
|
179
|
+
timeout_seconds: float = 10.0
|
|
180
|
+
health_retries: StrictInt = 3
|
|
181
|
+
health_backoff_seconds: float = 0.25
|
|
182
|
+
auth_header: StrictStr | None = None
|
|
183
|
+
auth_token: StrictStr | None = None
|
|
184
|
+
identity: IdentityStrategy | None = None
|
|
185
|
+
|
|
186
|
+
@field_validator("base_url")
|
|
187
|
+
@classmethod
|
|
188
|
+
def validate_base_url(cls, value: str) -> str:
|
|
189
|
+
parsed = urlsplit(value)
|
|
190
|
+
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
191
|
+
raise ValueError("base_url must be an HTTP(S) URL")
|
|
192
|
+
if parsed.username or parsed.password or parsed.query or parsed.fragment:
|
|
193
|
+
raise ValueError("base_url must not contain credentials, query, or fragment")
|
|
194
|
+
if any(ord(char) < 32 for char in value):
|
|
195
|
+
raise ValueError("base_url must not contain control characters")
|
|
196
|
+
return value.rstrip("/")
|
|
197
|
+
|
|
198
|
+
_validate_retrieve_path = field_validator("retrieve_path")(_relative_path)
|
|
199
|
+
_validate_optional_paths = field_validator("answer_path", "health_path")(
|
|
200
|
+
lambda value: None if value is None else _relative_path(value)
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
@field_validator("timeout_seconds")
|
|
204
|
+
@classmethod
|
|
205
|
+
def validate_timeout(cls, value: float) -> float:
|
|
206
|
+
if not math.isfinite(value) or value <= 0:
|
|
207
|
+
raise ValueError("timeout_seconds must be finite and positive")
|
|
208
|
+
return value
|
|
209
|
+
|
|
210
|
+
@field_validator("health_retries")
|
|
211
|
+
@classmethod
|
|
212
|
+
def validate_retries(cls, value: int) -> int:
|
|
213
|
+
if not 0 <= value <= _MAX_HEALTH_RETRIES:
|
|
214
|
+
raise ValueError(f"health_retries must be between 0 and {_MAX_HEALTH_RETRIES}")
|
|
215
|
+
return value
|
|
216
|
+
|
|
217
|
+
@field_validator("health_backoff_seconds")
|
|
218
|
+
@classmethod
|
|
219
|
+
def validate_backoff(cls, value: float) -> float:
|
|
220
|
+
if not math.isfinite(value) or not 0 <= value <= _MAX_BACKOFF_SECONDS:
|
|
221
|
+
raise ValueError(
|
|
222
|
+
f"health_backoff_seconds must be finite and between 0 and {_MAX_BACKOFF_SECONDS}"
|
|
223
|
+
)
|
|
224
|
+
return value
|
|
225
|
+
|
|
226
|
+
@field_validator("auth_header")
|
|
227
|
+
@classmethod
|
|
228
|
+
def validate_auth_header(cls, value: str | None) -> str | None:
|
|
229
|
+
if value is not None and (
|
|
230
|
+
not value or any(ord(char) < 33 or ord(char) > 126 for char in value)
|
|
231
|
+
):
|
|
232
|
+
raise ValueError("auth_header must be a visible, non-empty HTTP header name")
|
|
233
|
+
return value
|
|
234
|
+
|
|
235
|
+
@field_validator("auth_token")
|
|
236
|
+
@classmethod
|
|
237
|
+
def validate_auth_token(cls, value: str | None) -> str | None:
|
|
238
|
+
if value is not None and (not value or any(char in value for char in "\r\n")):
|
|
239
|
+
raise ValueError("auth_token must be a non-empty static token without line breaks")
|
|
240
|
+
return value
|
|
241
|
+
|
|
242
|
+
@model_validator(mode="after")
|
|
243
|
+
def validate_auth_pair(self) -> GenericHTTPConfig:
|
|
244
|
+
if self.auth_token is not None and self.auth_header is None:
|
|
245
|
+
self.auth_header = "Authorization"
|
|
246
|
+
if self.auth_header is not None and self.auth_token is None:
|
|
247
|
+
raise ValueError("auth_header requires auth_token")
|
|
248
|
+
return self
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class _HTTPResponse(Protocol):
|
|
252
|
+
status: int
|
|
253
|
+
|
|
254
|
+
def __enter__(self) -> _HTTPResponse: ...
|
|
255
|
+
def __exit__(self, *args: object) -> None: ...
|
|
256
|
+
def read(self) -> bytes: ...
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
_Opener = Callable[[Request, float], _HTTPResponse]
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _default_opener(request: Request, timeout: float) -> _HTTPResponse:
|
|
263
|
+
return cast(_HTTPResponse, urlopen(request, timeout=timeout))
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
class GenericHTTPTransport:
|
|
267
|
+
"""Small synchronous JSON transport used by the generic HTTP adapter.
|
|
268
|
+
|
|
269
|
+
Only health checks are retried. POST operations deliberately perform one
|
|
270
|
+
request so a transient response cannot duplicate a target-side operation.
|
|
271
|
+
"""
|
|
272
|
+
|
|
273
|
+
def __init__(
|
|
274
|
+
self,
|
|
275
|
+
config: GenericHTTPConfig,
|
|
276
|
+
*,
|
|
277
|
+
opener: _Opener | None = None,
|
|
278
|
+
sleeper: Callable[[float], None] = time.sleep,
|
|
279
|
+
) -> None:
|
|
280
|
+
self.config = config
|
|
281
|
+
self._opener = opener or _default_opener
|
|
282
|
+
self._sleeper = sleeper
|
|
283
|
+
|
|
284
|
+
def post_json(
|
|
285
|
+
self, path: str, payload: object, *, extra_headers: dict[str, str] | None = None
|
|
286
|
+
) -> object:
|
|
287
|
+
"""POST one JSON payload and decode the JSON response."""
|
|
288
|
+
|
|
289
|
+
return self._request("POST", path, payload, decode_json=True, extra_headers=extra_headers)
|
|
290
|
+
|
|
291
|
+
def get_json(self, path: str, *, extra_headers: dict[str, str] | None = None) -> object:
|
|
292
|
+
"""GET one endpoint and decode its JSON response."""
|
|
293
|
+
|
|
294
|
+
return self._request("GET", path, None, decode_json=True, extra_headers=extra_headers)
|
|
295
|
+
|
|
296
|
+
def health(self) -> bool:
|
|
297
|
+
"""Check health, retrying only transient failures within the budget."""
|
|
298
|
+
|
|
299
|
+
attempts = self.config.health_retries + 1
|
|
300
|
+
for attempt in range(attempts):
|
|
301
|
+
try:
|
|
302
|
+
self._request("GET", self.config.health_path, None, decode_json=False)
|
|
303
|
+
return True
|
|
304
|
+
except AdapterTransportError as exc:
|
|
305
|
+
retryable = exc.status_code >= 500
|
|
306
|
+
except AdapterError:
|
|
307
|
+
retryable = True
|
|
308
|
+
|
|
309
|
+
if not retryable or attempt == attempts - 1:
|
|
310
|
+
return False
|
|
311
|
+
delay = min(
|
|
312
|
+
self.config.health_backoff_seconds * (2**attempt),
|
|
313
|
+
60.0,
|
|
314
|
+
)
|
|
315
|
+
self._sleeper(delay)
|
|
316
|
+
return False
|
|
317
|
+
|
|
318
|
+
def _request(
|
|
319
|
+
self,
|
|
320
|
+
method: str,
|
|
321
|
+
path: str,
|
|
322
|
+
payload: object | None,
|
|
323
|
+
*,
|
|
324
|
+
decode_json: bool,
|
|
325
|
+
extra_headers: dict[str, str] | None = None,
|
|
326
|
+
) -> object:
|
|
327
|
+
url = self._endpoint_url(path)
|
|
328
|
+
body = None if payload is None else json.dumps(payload, separators=(",", ":")).encode()
|
|
329
|
+
headers = {"Accept": "application/json"}
|
|
330
|
+
if body is not None:
|
|
331
|
+
headers["Content-Type"] = "application/json"
|
|
332
|
+
if self.config.auth_token is not None and self.config.auth_header is not None:
|
|
333
|
+
headers[self.config.auth_header] = f"Bearer {self.config.auth_token}"
|
|
334
|
+
if extra_headers:
|
|
335
|
+
headers.update(extra_headers)
|
|
336
|
+
request = Request(url, data=body, headers=headers, method=method)
|
|
337
|
+
try:
|
|
338
|
+
with self._opener(request, self.config.timeout_seconds) as response:
|
|
339
|
+
status = response.status
|
|
340
|
+
raw = response.read()
|
|
341
|
+
except HTTPError as exc:
|
|
342
|
+
raise AdapterTransportError(method.lower(), exc.code) from None
|
|
343
|
+
except (TimeoutError, OSError, URLError) as exc:
|
|
344
|
+
raise AdapterError(method.lower(), "request failed") from exc
|
|
345
|
+
|
|
346
|
+
if not 200 <= status < 300:
|
|
347
|
+
raise AdapterTransportError(method.lower(), status)
|
|
348
|
+
if not decode_json:
|
|
349
|
+
return None
|
|
350
|
+
try:
|
|
351
|
+
return json.loads(raw)
|
|
352
|
+
except (TypeError, ValueError, UnicodeError):
|
|
353
|
+
raise AdapterError(method.lower(), "response body is not JSON") from None
|
|
354
|
+
|
|
355
|
+
def _endpoint_url(self, path: str) -> str:
|
|
356
|
+
"""Join only a validated relative endpoint to the normalized base URL."""
|
|
357
|
+
|
|
358
|
+
return f"{self.config.base_url}/{_relative_path(path)}"
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
class _AdapterTransport(Protocol):
|
|
362
|
+
def post_json(
|
|
363
|
+
self, path: str, payload: object, *, extra_headers: dict[str, str] | None = None
|
|
364
|
+
) -> object: ...
|
|
365
|
+
|
|
366
|
+
def health(self) -> bool: ...
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
class _DecodedResponse:
|
|
370
|
+
"""Minimal response view consumed by the shared strict mapping helpers."""
|
|
371
|
+
|
|
372
|
+
status_code = 200
|
|
373
|
+
|
|
374
|
+
def __init__(self, payload: object) -> None:
|
|
375
|
+
self._payload = payload
|
|
376
|
+
|
|
377
|
+
def json(self) -> object:
|
|
378
|
+
return self._payload
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
class GenericHTTPAdapter:
|
|
382
|
+
"""RAGAdapter implementation for a configured JSON HTTP target."""
|
|
383
|
+
|
|
384
|
+
name = "generic_http"
|
|
385
|
+
|
|
386
|
+
def __init__(
|
|
387
|
+
self,
|
|
388
|
+
config: GenericHTTPConfig,
|
|
389
|
+
*,
|
|
390
|
+
transport: _AdapterTransport | None = None,
|
|
391
|
+
) -> None:
|
|
392
|
+
self.config = config
|
|
393
|
+
self.transport = transport or GenericHTTPTransport(config)
|
|
394
|
+
self.last_identity_represented: bool = False
|
|
395
|
+
self._resolved_secrets: list[str] = []
|
|
396
|
+
|
|
397
|
+
def check_identity_represented(self) -> bool:
|
|
398
|
+
"""Check if identity is represented for the current config (spec R4.2).
|
|
399
|
+
|
|
400
|
+
Returns False when no identity strategy is configured. For bearer,
|
|
401
|
+
returns False when the token env var is unset.
|
|
402
|
+
"""
|
|
403
|
+
_, represented = resolve_identity_headers(self.config.identity, _sentinel_auth())
|
|
404
|
+
return represented
|
|
405
|
+
|
|
406
|
+
def _sanitize(self, text: str) -> str:
|
|
407
|
+
"""Redact any resolved secrets from text before it leaves the adapter."""
|
|
408
|
+
return redact_secrets(text, self._resolved_secrets)
|
|
409
|
+
|
|
410
|
+
def retrieve(self, request: RetrievalRequest) -> list[RetrievedChunk]:
|
|
411
|
+
identity_headers, identity_represented = resolve_identity_headers(
|
|
412
|
+
self.config.identity, request.authorization
|
|
413
|
+
)
|
|
414
|
+
self.last_identity_represented = identity_represented
|
|
415
|
+
self._resolved_secrets = []
|
|
416
|
+
if isinstance(self.config.identity, BearerIdentity) and identity_represented:
|
|
417
|
+
token = os.environ.get(self.config.identity.token_env, "")
|
|
418
|
+
if token:
|
|
419
|
+
self._resolved_secrets.append(token)
|
|
420
|
+
try:
|
|
421
|
+
payload = self.transport.post_json(
|
|
422
|
+
self.config.retrieve_path,
|
|
423
|
+
{"question": request.query, "top_k": request.top_k},
|
|
424
|
+
extra_headers=identity_headers or None,
|
|
425
|
+
)
|
|
426
|
+
return map_retrieve_response(_DecodedResponse(payload))
|
|
427
|
+
except AdapterTransportError as exc:
|
|
428
|
+
raise AdapterTransportError(exc.operation, exc.status_code) from exc.__cause__
|
|
429
|
+
except AdapterError as exc:
|
|
430
|
+
sanitized_msg = self._sanitize(str(exc).split(": ", 1)[-1])
|
|
431
|
+
raise AdapterError(exc.operation, sanitized_msg) from exc.__cause__
|
|
432
|
+
|
|
433
|
+
def answer(
|
|
434
|
+
self,
|
|
435
|
+
request: RetrievalRequest,
|
|
436
|
+
chunks: list[RetrievedChunk],
|
|
437
|
+
) -> str:
|
|
438
|
+
path = require_answer_path(self.config.answer_path)
|
|
439
|
+
identity_headers, identity_represented = resolve_identity_headers(
|
|
440
|
+
self.config.identity, request.authorization
|
|
441
|
+
)
|
|
442
|
+
self.last_identity_represented = identity_represented
|
|
443
|
+
self._resolved_secrets = []
|
|
444
|
+
if isinstance(self.config.identity, BearerIdentity) and identity_represented:
|
|
445
|
+
token = os.environ.get(self.config.identity.token_env, "")
|
|
446
|
+
if token:
|
|
447
|
+
self._resolved_secrets.append(token)
|
|
448
|
+
try:
|
|
449
|
+
payload = self.transport.post_json(
|
|
450
|
+
path,
|
|
451
|
+
{
|
|
452
|
+
"question": request.query,
|
|
453
|
+
"context": [chunk.model_dump(mode="json") for chunk in chunks],
|
|
454
|
+
},
|
|
455
|
+
extra_headers=identity_headers or None,
|
|
456
|
+
)
|
|
457
|
+
answer_text = map_answer_response(_DecodedResponse(payload))
|
|
458
|
+
return self._sanitize(answer_text)
|
|
459
|
+
except AdapterTransportError as exc:
|
|
460
|
+
raise AdapterTransportError(exc.operation, exc.status_code) from exc.__cause__
|
|
461
|
+
except AdapterError as exc:
|
|
462
|
+
sanitized_msg = self._sanitize(str(exc).split(": ", 1)[-1])
|
|
463
|
+
raise AdapterError(exc.operation, sanitized_msg) from exc.__cause__
|
|
464
|
+
|
|
465
|
+
def is_healthy(self) -> bool:
|
|
466
|
+
return self.transport.health()
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Strict response decoding and conversion to canonical domain models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Protocol
|
|
6
|
+
|
|
7
|
+
from pydantic import ValidationError
|
|
8
|
+
|
|
9
|
+
from ragfence.adapters.errors import (
|
|
10
|
+
AdapterParseError,
|
|
11
|
+
AdapterTransportError,
|
|
12
|
+
GenericHTTPParseError,
|
|
13
|
+
UnsupportedOperationError,
|
|
14
|
+
)
|
|
15
|
+
from ragfence.adapters.wire import GenericHTTPAnswerResponse, GenericHTTPRetrieveResponse
|
|
16
|
+
from ragfence.core.models import RetrievedChunk
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _Response(Protocol):
|
|
20
|
+
status_code: int
|
|
21
|
+
|
|
22
|
+
def json(self) -> object: ...
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _payload(response: _Response, operation: str) -> object:
|
|
26
|
+
if not 200 <= response.status_code < 300:
|
|
27
|
+
raise AdapterTransportError(operation, response.status_code)
|
|
28
|
+
try:
|
|
29
|
+
return response.json()
|
|
30
|
+
except (TypeError, ValueError, UnicodeError):
|
|
31
|
+
raise AdapterParseError(operation, "response body is not JSON") from None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def map_retrieve_response(response: _Response) -> list[RetrievedChunk]:
|
|
35
|
+
"""Validate a retrieve response fully, then map every chunk atomically."""
|
|
36
|
+
|
|
37
|
+
payload = _payload(response, "retrieve")
|
|
38
|
+
try:
|
|
39
|
+
parsed = GenericHTTPRetrieveResponse.model_validate(payload)
|
|
40
|
+
return parsed.to_chunks()
|
|
41
|
+
except (ValidationError, GenericHTTPParseError):
|
|
42
|
+
raise AdapterParseError(
|
|
43
|
+
"retrieve", "response does not match the retrieve contract"
|
|
44
|
+
) from None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def map_answer_response(response: _Response) -> str:
|
|
48
|
+
"""Validate an answer response and return only its canonical string."""
|
|
49
|
+
|
|
50
|
+
payload = _payload(response, "answer")
|
|
51
|
+
try:
|
|
52
|
+
return GenericHTTPAnswerResponse.model_validate(payload).to_answer()
|
|
53
|
+
except ValidationError:
|
|
54
|
+
raise AdapterParseError("answer", "response does not match the answer contract") from None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def require_answer_path(answer_path: str | None) -> str:
|
|
58
|
+
"""Return a configured answer path or fail explicitly for retrieval-only mode."""
|
|
59
|
+
|
|
60
|
+
if answer_path is None:
|
|
61
|
+
raise UnsupportedOperationError("answer")
|
|
62
|
+
return answer_path
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# Verb-named aliases make the mapping seam convenient for transport adapters.
|
|
66
|
+
parse_retrieve_response = map_retrieve_response
|
|
67
|
+
parse_answer_response = map_answer_response
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Strict wire contracts for generic HTTP target responses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
from uuid import UUID
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, StrictFloat, StrictInt, StrictStr
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RetrievedChunkResponse(BaseModel):
|
|
12
|
+
"""One target chunk; target-only fields are intentionally discarded."""
|
|
13
|
+
|
|
14
|
+
model_config = ConfigDict(extra="ignore")
|
|
15
|
+
|
|
16
|
+
chunk_id: UUID
|
|
17
|
+
document_id: UUID
|
|
18
|
+
document_title: StrictStr
|
|
19
|
+
chunk_index: StrictInt
|
|
20
|
+
content: StrictStr
|
|
21
|
+
score: StrictFloat
|
|
22
|
+
metadata: dict[str, Any]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RetrieveResponse(BaseModel):
|
|
26
|
+
"""Strict retrieve response envelope."""
|
|
27
|
+
|
|
28
|
+
model_config = ConfigDict(extra="ignore")
|
|
29
|
+
|
|
30
|
+
chunks: list[RetrievedChunkResponse]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AnswerResponse(BaseModel):
|
|
34
|
+
"""Strict answer response envelope; an explicitly empty answer is valid."""
|
|
35
|
+
|
|
36
|
+
model_config = ConfigDict(extra="ignore")
|
|
37
|
+
|
|
38
|
+
answer: StrictStr
|