Initial curated RAGcore source from c16667f0c9397e2bfc8dd0255b114fba23c8d05e
This commit is contained in:
commit
7ca726caf2
659 files changed
+98171
No files matched your search
@@ -0,0 +1,482 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
from dataclasses import replace
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ragcore.api.v1.answers.dependencies import get_answer_application, get_answer_principal
|
||||
from ragcore.api.v1.answers.service import (
|
||||
AnswerEventType,
|
||||
AnswerQuery,
|
||||
AnswerService,
|
||||
)
|
||||
from ragcore.api.v1.context.service import ContextExecutionRequest, ContextExecutionResult
|
||||
from ragcore.api.v1.search.service import SearchQuery
|
||||
from ragcore.application.retrieval.authorization import RetrievalAuthorizationService
|
||||
from ragcore.domain.authorization import Principal, PrincipalType
|
||||
from ragcore.domain.citations import Citation
|
||||
from ragcore.domain.control import Application, Role, Workspace
|
||||
from ragcore.main import create_app
|
||||
from ragcore.retrieval.context import ContextCandidate
|
||||
|
||||
WORKSPACE_ID = UUID("00000000-0000-0000-0000-000000000001")
|
||||
SPACE_ID = UUID("00000000-0000-0000-0000-000000000002")
|
||||
|
||||
EVIDENCE_EXCERPT = (
|
||||
"The quarterly maintenance window for the payment gateway runs every "
|
||||
"second Sunday from midnight to four in the morning Central European time."
|
||||
)
|
||||
|
||||
|
||||
class FixtureContextExecutor:
|
||||
def __init__(
|
||||
self,
|
||||
candidate: ContextCandidate | None,
|
||||
*,
|
||||
degraded: bool = False,
|
||||
degradation_codes: tuple[str, ...] = (),
|
||||
) -> None:
|
||||
self.candidate = candidate
|
||||
self.degraded = degraded
|
||||
self.degradation_codes = degradation_codes
|
||||
self.calls: list[ContextExecutionRequest] = []
|
||||
|
||||
async def execute(self, request: ContextExecutionRequest) -> ContextExecutionResult:
|
||||
self.calls.append(request)
|
||||
if self.candidate is None:
|
||||
return ContextExecutionResult(
|
||||
degraded=self.degraded, degradation_codes=self.degradation_codes
|
||||
)
|
||||
citation = self.candidate.citation
|
||||
refreshed = Citation.create(
|
||||
workspace_id=citation.workspace_id,
|
||||
space_id=citation.space_id,
|
||||
document_id=citation.document_id,
|
||||
document_version_id=citation.document_version_id,
|
||||
chunk_id=citation.chunk_id,
|
||||
parent_chunk_id=citation.parent_chunk_id,
|
||||
location=citation.location,
|
||||
excerpt=citation.excerpt,
|
||||
source_uri=citation.source_uri,
|
||||
acquired_at=citation.acquired_at,
|
||||
retrieval_run_id=request.retrieval_run_id,
|
||||
index_alias=citation.index_alias,
|
||||
profile_version=citation.profile_version,
|
||||
)
|
||||
return ContextExecutionResult(
|
||||
candidates=(replace(self.candidate, citation=refreshed),),
|
||||
degraded=self.degraded,
|
||||
degradation_codes=self.degradation_codes,
|
||||
)
|
||||
|
||||
|
||||
_CITATION_ID = re.compile(r"citation_ids=([0-9a-fA-F-]{36})")
|
||||
|
||||
|
||||
class FixtureGenerator:
|
||||
"""``None`` in ``responses`` means: build a valid answer citing whichever
|
||||
citation id the prompt's evidence fence actually carries -- the real id
|
||||
depends on the request's own (unpredictable) retrieval_run_id, so it
|
||||
cannot be precomputed by the test itself.
|
||||
"""
|
||||
|
||||
def __init__(self, responses: list[str | None] | None = None) -> None:
|
||||
self.responses: list[str | None] = list(responses if responses is not None else [None])
|
||||
self.calls: list[tuple[str, ...]] = []
|
||||
|
||||
async def generate(self, *, prompt: str, generation_profile: str, spaces) -> str:
|
||||
self.calls.append(tuple(space.slug for space in spaces))
|
||||
if not self.responses:
|
||||
raise AssertionError("generator was called more times than expected")
|
||||
response = self.responses.pop(0)
|
||||
if response is not None:
|
||||
return response
|
||||
match = _CITATION_ID.search(prompt)
|
||||
assert match is not None, "prompt did not contain a citation id fence"
|
||||
return json.dumps(
|
||||
{
|
||||
"status": "answerable",
|
||||
"answer": (
|
||||
"The quarterly maintenance window for the payment gateway runs "
|
||||
"every second Sunday from midnight to four in the morning."
|
||||
),
|
||||
"citations": [match.group(1)],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class HangingGenerator:
|
||||
def __init__(self) -> None:
|
||||
self.started = asyncio.Event()
|
||||
self.cancelled = False
|
||||
|
||||
async def generate(self, *, prompt: str, generation_profile: str, spaces) -> str:
|
||||
self.started.set()
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
except asyncio.CancelledError:
|
||||
self.cancelled = True
|
||||
raise
|
||||
return "unreachable"
|
||||
|
||||
|
||||
class FixtureInputs:
|
||||
def __init__(self, workspace: Workspace, principal: Principal, space_id: UUID) -> None:
|
||||
self.workspace = workspace
|
||||
self.principal = principal
|
||||
self.space_id = space_id
|
||||
|
||||
async def load(self, principal: Principal):
|
||||
space = self.workspace.create_knowledge_space(
|
||||
id=self.space_id, name="Answers space", slug="answers-space"
|
||||
)
|
||||
return type(
|
||||
"Inputs",
|
||||
(),
|
||||
{
|
||||
"spaces": (space,),
|
||||
"grants": (),
|
||||
"user_grants": (
|
||||
self.workspace.grant_user(
|
||||
id=uuid4(),
|
||||
user_id=principal.id,
|
||||
knowledge_space=space,
|
||||
role=Role.EDITOR,
|
||||
),
|
||||
),
|
||||
"application": Application(
|
||||
id=uuid4(),
|
||||
workspace_id=self.workspace.id,
|
||||
name="Answers application",
|
||||
default_knowledge_space_ids=frozenset({self.space_id}),
|
||||
),
|
||||
},
|
||||
)()
|
||||
|
||||
|
||||
def _candidate(excerpt: str = EVIDENCE_EXCERPT) -> ContextCandidate:
|
||||
citation = Citation.create(
|
||||
workspace_id=WORKSPACE_ID,
|
||||
space_id=SPACE_ID,
|
||||
document_id=uuid4(),
|
||||
document_version_id=uuid4(),
|
||||
chunk_id=uuid4(),
|
||||
parent_chunk_id=None,
|
||||
location={"page_start": 1},
|
||||
excerpt=excerpt,
|
||||
source_uri="https://example.test/doc.md",
|
||||
acquired_at=datetime(2026, 8, 1, 12, tzinfo=UTC),
|
||||
retrieval_run_id=uuid4(),
|
||||
index_alias="rag_dense_hybrid_active",
|
||||
profile_version="hybrid-v1",
|
||||
)
|
||||
return ContextCandidate(citation, excerpt, 1, 1.0)
|
||||
|
||||
|
||||
def _fixture(
|
||||
*,
|
||||
candidate: ContextCandidate | None,
|
||||
generator: object,
|
||||
degraded: bool = False,
|
||||
degradation_codes: tuple[str, ...] = (),
|
||||
) -> tuple[object, Principal, UUID, FixtureContextExecutor]:
|
||||
workspace = Workspace(WORKSPACE_ID, "Answers workspace", "answers-workspace")
|
||||
space_id = SPACE_ID
|
||||
principal = Principal(uuid4(), workspace.id, PrincipalType.HUMAN, frozenset({Role.EDITOR}))
|
||||
executor = FixtureContextExecutor(
|
||||
candidate, degraded=degraded, degradation_codes=degradation_codes
|
||||
)
|
||||
service = AnswerService(
|
||||
authorization=RetrievalAuthorizationService(),
|
||||
inputs=FixtureInputs(workspace, principal, space_id),
|
||||
context_executor=executor,
|
||||
generator=generator,
|
||||
)
|
||||
api = create_app()
|
||||
api.dependency_overrides[get_answer_principal] = lambda: principal
|
||||
api.dependency_overrides[get_answer_application] = lambda: service
|
||||
return api, principal, space_id, executor
|
||||
|
||||
|
||||
def test_openapi_and_runtime_expose_answer_operation() -> None:
|
||||
candidate = _candidate()
|
||||
api, _, _, _ = _fixture(candidate=candidate, generator=FixtureGenerator())
|
||||
document = yaml.safe_load(
|
||||
(Path(__file__).parents[4] / "docs" / "contracts" / "openapi.yaml").read_text()
|
||||
)
|
||||
assert set(api.openapi()["paths"]["/v1/answers"]) == {"post"}
|
||||
assert "post" in document["paths"]["/v1/answers"]
|
||||
|
||||
|
||||
def test_grounded_answer_returns_citations_and_claims() -> None:
|
||||
candidate = _candidate()
|
||||
generator = FixtureGenerator()
|
||||
api, _, space_id, executor = _fixture(candidate=candidate, generator=generator)
|
||||
|
||||
response = TestClient(api).post(
|
||||
"/v1/answers",
|
||||
json={"query": "when is the maintenance window?", "requested_space_ids": [str(space_id)]},
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.json()
|
||||
body = response.json()
|
||||
# A single contributing document assembles as "partially_answerable"
|
||||
# (ContextAssembler's own document-diversity heuristic); generation is
|
||||
# still expected to proceed for any status other than not_answerable.
|
||||
assert body["answerability"] == "partially_answerable"
|
||||
assert "maintenance window" in body["answer"]
|
||||
assert body["citations"]
|
||||
assert body["claims"]
|
||||
assert all(claim["citation_ids"] for claim in body["claims"])
|
||||
assert executor.calls[0].scope.effective_space_ids == frozenset({space_id})
|
||||
|
||||
|
||||
def test_reranker_failure_degrades_but_still_returns_a_grounded_answer() -> None:
|
||||
"""M8-01: a reranker that 404s or times out must not turn found evidence
|
||||
into a not_answerable, 0-citation response -- it degrades to the fused
|
||||
ranking, and the caller can see that happened via degraded=true."""
|
||||
|
||||
candidate = _candidate()
|
||||
generator = FixtureGenerator()
|
||||
api, _, space_id, _ = _fixture(
|
||||
candidate=candidate,
|
||||
generator=generator,
|
||||
degraded=True,
|
||||
degradation_codes=("rerank_rejected",),
|
||||
)
|
||||
|
||||
response = TestClient(api).post(
|
||||
"/v1/answers",
|
||||
json={"query": "when is the maintenance window?", "requested_space_ids": [str(space_id)]},
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.json()
|
||||
body = response.json()
|
||||
assert body["answerability"] != "not_answerable"
|
||||
assert body["citations"]
|
||||
assert body["degraded"] is True
|
||||
assert "rerank_rejected" in body["degradation_reasons"]
|
||||
|
||||
|
||||
def test_search_authorization_is_reused_and_narrows_to_effective_spaces() -> None:
|
||||
candidate = _candidate()
|
||||
generator = FixtureGenerator()
|
||||
api, _, _, executor = _fixture(candidate=candidate, generator=generator)
|
||||
|
||||
forbidden_space = uuid4()
|
||||
response = TestClient(api).post(
|
||||
"/v1/answers",
|
||||
json={"query": "anything", "requested_space_ids": [str(forbidden_space)]},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["answerability"] == "not_answerable"
|
||||
assert body["answer"] == ""
|
||||
assert executor.calls == []
|
||||
assert generator.calls == []
|
||||
|
||||
|
||||
def test_not_answerable_context_never_calls_the_generator() -> None:
|
||||
generator = FixtureGenerator()
|
||||
api, _, space_id, executor = _fixture(candidate=None, generator=generator)
|
||||
|
||||
response = TestClient(api).post(
|
||||
"/v1/answers",
|
||||
json={"query": "anything", "requested_space_ids": [str(space_id)]},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["answerability"] == "not_answerable"
|
||||
assert body["answer"] == ""
|
||||
assert body["claims"] == []
|
||||
assert len(executor.calls) == 1
|
||||
assert generator.calls == []
|
||||
|
||||
|
||||
def test_invalid_generated_output_is_rejected_not_silently_accepted() -> None:
|
||||
candidate = _candidate()
|
||||
generator = FixtureGenerator(["not json at all", "still not json"])
|
||||
api, _, space_id, _ = _fixture(candidate=candidate, generator=generator)
|
||||
|
||||
response = TestClient(api).post(
|
||||
"/v1/answers",
|
||||
json={
|
||||
"query": "when is the maintenance window?",
|
||||
"requested_space_ids": [str(space_id)],
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
# ClaimCitationValidator's own retry loop exhausted both attempts and
|
||||
# raises its stable "exhausted" code, not a silently-degraded answer.
|
||||
assert response.json()["code"] == "VALIDATION_RETRIES_EXHAUSTED"
|
||||
assert len(generator.calls) == 2
|
||||
|
||||
|
||||
def test_debug_output_requires_admin_capability() -> None:
|
||||
candidate = _candidate()
|
||||
api, _, space_id, _ = _fixture(candidate=candidate, generator=FixtureGenerator())
|
||||
|
||||
response = TestClient(api).post(
|
||||
"/v1/answers",
|
||||
json={
|
||||
"query": "anything",
|
||||
"requested_space_ids": [str(space_id)],
|
||||
"include_debug": True,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json()["code"] == "DEBUG_NOT_ALLOWED"
|
||||
|
||||
|
||||
def test_missing_answer_service_is_503_problem_details() -> None:
|
||||
candidate = _candidate()
|
||||
api, _, _, _ = _fixture(candidate=candidate, generator=FixtureGenerator())
|
||||
# create_app() always wires a real answer_application now; popping the
|
||||
# dependency override alone would just fall through to that real service
|
||||
# instead of exercising get_answer_application's own missing-state
|
||||
# fallback, so the state attribute itself has to be genuinely absent.
|
||||
api.dependency_overrides.pop(get_answer_application)
|
||||
del api.state.answer_application
|
||||
|
||||
response = TestClient(api).post("/v1/answers", json={"query": "anything"})
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.json()["code"] == "ANSWERS_UNAVAILABLE"
|
||||
|
||||
|
||||
def test_missing_query_is_a_validation_error() -> None:
|
||||
candidate = _candidate()
|
||||
api, _, _, _ = _fixture(candidate=candidate, generator=FixtureGenerator())
|
||||
|
||||
response = TestClient(api).post("/v1/answers", json={})
|
||||
|
||||
assert response.status_code == 422
|
||||
assert response.json()["code"] == "VALIDATION_ERROR"
|
||||
|
||||
|
||||
def test_sse_stream_emits_metadata_before_done_in_order() -> None:
|
||||
candidate = _candidate()
|
||||
generator = FixtureGenerator()
|
||||
api, _, space_id, _ = _fixture(candidate=candidate, generator=generator)
|
||||
|
||||
with TestClient(api).stream(
|
||||
"POST",
|
||||
"/v1/answers",
|
||||
json={
|
||||
"query": "when is the maintenance window?",
|
||||
"requested_space_ids": [str(space_id)],
|
||||
"stream": True,
|
||||
},
|
||||
) as response:
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"].startswith("text/event-stream")
|
||||
raw = response.read().decode()
|
||||
|
||||
frames = [frame for frame in raw.strip().split("\n\n") if frame]
|
||||
assert len(frames) == 2
|
||||
|
||||
def _parse(frame: str) -> tuple[str, dict]:
|
||||
lines = frame.splitlines()
|
||||
event_line = next(line for line in lines if line.startswith("event: "))
|
||||
data_line = next(line for line in lines if line.startswith("data: "))
|
||||
return event_line[len("event: ") :], json.loads(data_line[len("data: ") :])
|
||||
|
||||
first_type, first_data = _parse(frames[0])
|
||||
second_type, second_data = _parse(frames[1])
|
||||
|
||||
assert first_type == AnswerEventType.RETRIEVAL_METADATA.value
|
||||
assert first_data["answerability"] == "partially_answerable"
|
||||
assert "effective_space_ids" in first_data
|
||||
|
||||
assert second_type == AnswerEventType.DONE.value
|
||||
assert second_data["answerability"] == "partially_answerable"
|
||||
assert second_data["citations"]
|
||||
assert "maintenance window" in second_data["answer"]
|
||||
|
||||
|
||||
def test_sse_stream_reports_a_terminal_error_event_on_invalid_output() -> None:
|
||||
candidate = _candidate()
|
||||
generator = FixtureGenerator(["not json", "still not json"])
|
||||
api, _, space_id, _ = _fixture(candidate=candidate, generator=generator)
|
||||
|
||||
with TestClient(api).stream(
|
||||
"POST",
|
||||
"/v1/answers",
|
||||
json={
|
||||
"query": "when is the maintenance window?",
|
||||
"requested_space_ids": [str(space_id)],
|
||||
"stream": True,
|
||||
},
|
||||
) as response:
|
||||
assert response.status_code == 200
|
||||
raw = response.read().decode()
|
||||
|
||||
frames = [frame for frame in raw.strip().split("\n\n") if frame]
|
||||
assert len(frames) == 2
|
||||
assert "event: retrieval_metadata" in frames[0]
|
||||
assert "event: error" in frames[1]
|
||||
assert '"validation_retries_exhausted"' in frames[1]
|
||||
|
||||
|
||||
async def test_disconnect_cancels_provider_work() -> None:
|
||||
candidate = _candidate()
|
||||
generator = HangingGenerator()
|
||||
workspace = Workspace(WORKSPACE_ID, "Answers workspace", "answers-workspace")
|
||||
space_id = SPACE_ID
|
||||
principal = Principal(uuid4(), workspace.id, PrincipalType.HUMAN, frozenset({Role.EDITOR}))
|
||||
executor = FixtureContextExecutor(candidate)
|
||||
service = AnswerService(
|
||||
authorization=RetrievalAuthorizationService(),
|
||||
inputs=FixtureInputs(workspace, principal, space_id),
|
||||
context_executor=executor,
|
||||
generator=generator,
|
||||
)
|
||||
query = AnswerQuery(
|
||||
query=SearchQuery(
|
||||
query="when is the maintenance window?", requested_space_ids=(space_id,)
|
||||
)
|
||||
)
|
||||
authorized = await service.authorize(principal, query)
|
||||
stream = service.answer_stream(principal, query, authorized)
|
||||
|
||||
first_event = await stream.__anext__()
|
||||
assert first_event.type is AnswerEventType.RETRIEVAL_METADATA
|
||||
|
||||
with pytest.raises(TimeoutError):
|
||||
await asyncio.wait_for(stream.__anext__(), timeout=0.2)
|
||||
|
||||
assert generator.cancelled is True
|
||||
|
||||
|
||||
def test_answer_response_schema_matches_runtime_openapi() -> None:
|
||||
candidate = _candidate()
|
||||
api, _, _, _ = _fixture(candidate=candidate, generator=FixtureGenerator())
|
||||
static = yaml.safe_load(
|
||||
(Path(__file__).parents[4] / "docs" / "contracts" / "openapi.yaml").read_text()
|
||||
)["components"]["schemas"]
|
||||
runtime = api.openapi()["components"]["schemas"]
|
||||
|
||||
assert static["AnswerClaim"]["required"] == runtime["AnswerClaim"]["required"]
|
||||
assert static["AnswerResponse"]["required"] == runtime["AnswerResponse"]["required"]
|
||||
|
||||
def _resolved_required(schemas: dict, ref: dict) -> set[str]:
|
||||
name = ref["$ref"].rsplit("/", 1)[-1]
|
||||
return set(schemas[name].get("required", ()))
|
||||
|
||||
static_citation_ref = static["AnswerResponse"]["properties"]["citations"]["items"]
|
||||
runtime_citation_ref = runtime["AnswerResponse"]["properties"]["citations"]["items"]
|
||||
assert _resolved_required(static, static_citation_ref) == _resolved_required(
|
||||
runtime, runtime_citation_ref
|
||||
)
|
||||
Reference in new issue
Block a user