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.
Files changed (83) hide show
  1. ragfence/__init__.py +3 -0
  2. ragfence/adapters/__init__.py +6 -0
  3. ragfence/adapters/errors.py +38 -0
  4. ragfence/adapters/factory.py +16 -0
  5. ragfence/adapters/generic_http.py +466 -0
  6. ragfence/adapters/mapping.py +67 -0
  7. ragfence/adapters/models.py +38 -0
  8. ragfence/adapters/reference.py +154 -0
  9. ragfence/adapters/wire.py +57 -0
  10. ragfence/attacks/__init__.py +17 -0
  11. ragfence/attacks/generators.py +301 -0
  12. ragfence/attacks/runner.py +131 -0
  13. ragfence/attacks/scenarios.py +83 -0
  14. ragfence/auth/__init__.py +33 -0
  15. ragfence/auth/builder.py +32 -0
  16. ragfence/auth/db.py +86 -0
  17. ragfence/auth/errors.py +18 -0
  18. ragfence/auth/identity.py +31 -0
  19. ragfence/auth/providers.py +78 -0
  20. ragfence/auth/registry.py +28 -0
  21. ragfence/cli/__init__.py +1 -0
  22. ragfence/cli/config.py +93 -0
  23. ragfence/cli/errors.py +9 -0
  24. ragfence/cli/main.py +227 -0
  25. ragfence/cli/reporting.py +89 -0
  26. ragfence/cli/seams.py +211 -0
  27. ragfence/core/__init__.py +1 -0
  28. ragfence/core/enums.py +48 -0
  29. ragfence/core/models.py +177 -0
  30. ragfence/core/protocols.py +49 -0
  31. ragfence/datasets/__init__.py +28 -0
  32. ragfence/datasets/acme.py +373 -0
  33. ragfence/datasets/checksums.py +27 -0
  34. ragfence/datasets/constants.py +90 -0
  35. ragfence/datasets/loader.py +233 -0
  36. ragfence/db/__init__.py +1 -0
  37. ragfence/db/base.py +12 -0
  38. ragfence/db/models.py +509 -0
  39. ragfence/db/repositories.py +152 -0
  40. ragfence/db/vector_store.py +82 -0
  41. ragfence/evaluation/__init__.py +1 -0
  42. ragfence/evaluation/contracts.py +58 -0
  43. ragfence/evaluation/engine.py +377 -0
  44. ragfence/evaluation/findings.py +154 -0
  45. ragfence/evaluation/gate.py +117 -0
  46. ragfence/evaluation/matrix_runner.py +315 -0
  47. ragfence/evaluation/metrics.py +152 -0
  48. ragfence/evaluation/redaction.py +105 -0
  49. ragfence/evaluation/report.py +166 -0
  50. ragfence/evaluation/repositories.py +353 -0
  51. ragfence/evaluation/scoring.py +83 -0
  52. ragfence/observability/__init__.py +1 -0
  53. ragfence/policies/__init__.py +1 -0
  54. ragfence/policies/decision.py +52 -0
  55. ragfence/policies/guard.py +80 -0
  56. ragfence/providers/__init__.py +21 -0
  57. ragfence/providers/errors.py +24 -0
  58. ragfence/providers/fakes.py +51 -0
  59. ragfence/providers/llm.py +466 -0
  60. ragfence/providers/tokens.py +23 -0
  61. ragfence/providers/transport.py +85 -0
  62. ragfence/reference/alembic/env.py +72 -0
  63. ragfence/reference/alembic/script.py.mako +27 -0
  64. ragfence/reference/alembic/versions/0001_base.py +28 -0
  65. ragfence/reference/alembic/versions/0002_enums.py +44 -0
  66. ragfence/reference/alembic/versions/0003_tables.py +206 -0
  67. ragfence/reference/alembic/versions/0004_indexes.py +93 -0
  68. ragfence/reference/alembic.ini +44 -0
  69. ragfence/reference/docker-compose.yml +22 -0
  70. ragfence/release_readiness/__init__.py +1 -0
  71. ragfence/release_readiness/audit.py +113 -0
  72. ragfence/release_readiness/benchmark.py +97 -0
  73. ragfence/release_readiness/checklist.py +79 -0
  74. ragfence/retrieval/__init__.py +32 -0
  75. ragfence/retrieval/citations.py +54 -0
  76. ragfence/retrieval/result.py +39 -0
  77. ragfence/retrieval/service.py +144 -0
  78. ragfence/retrieval/trace.py +24 -0
  79. ragfence-0.1.0.dist-info/METADATA +162 -0
  80. ragfence-0.1.0.dist-info/RECORD +83 -0
  81. ragfence-0.1.0.dist-info/WHEEL +4 -0
  82. ragfence-0.1.0.dist-info/entry_points.txt +2 -0
  83. ragfence-0.1.0.dist-info/licenses/LICENSE +201 -0
ragfence/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """RAGFence — security testing and authorization-aware retrieval for RAG systems."""
2
+
3
+ __version__ = "0.1.0"
@@ -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