diff --git a/src/api/schemas.py b/src/api/schemas.py index 7dd77ea..d987200 100644 --- a/src/api/schemas.py +++ b/src/api/schemas.py @@ -247,7 +247,7 @@ class HistoryEntry(BaseModel): } -SourceSystemValue = Literal["GITHUB", "JIRA", "CONFLUENCE", "UPLOAD"] +SourceSystemValue = Literal["GITHUB", "BITBUCKET", "JIRA", "CONFLUENCE", "UPLOAD"] class ProjectScopedRequest(BaseModel): diff --git a/src/rag/types.py b/src/rag/types.py index ecd2eac..b6e6488 100644 --- a/src/rag/types.py +++ b/src/rag/types.py @@ -4,7 +4,7 @@ from ingestion.source_role import SourceRole ChunkKind = Literal["text", "code", "pdf", "image"] -SourceSystem = Literal["GITHUB", "JIRA", "CONFLUENCE", "UPLOAD"] +SourceSystem = Literal["GITHUB", "BITBUCKET", "JIRA", "CONFLUENCE", "UPLOAD"] def is_chunk_kind(value: str) -> TypeGuard[ChunkKind]: @@ -12,7 +12,7 @@ def is_chunk_kind(value: str) -> TypeGuard[ChunkKind]: def is_source_system(value: str) -> TypeGuard[SourceSystem]: - return value in ("GITHUB", "JIRA", "CONFLUENCE", "UPLOAD") + return value in ("GITHUB", "BITBUCKET", "JIRA", "CONFLUENCE", "UPLOAD") @dataclass(frozen=True) diff --git a/tests/api/test_chat.py b/tests/api/test_chat.py index cc029f5..193a007 100644 --- a/tests/api/test_chat.py +++ b/tests/api/test_chat.py @@ -152,6 +152,66 @@ def stream(self, messages: list[Message]) -> Iterator[LLMStreamEvent]: ] +@pytest.mark.parametrize("value", ["BITBUCKET", "bitbucket"]) +def test_chat_accepts_a_bitbucket_source_filter( + client: tuple[TestClient, StubLLMClient, StubVectorStore], value: str +) -> None: + http_client, _, store = client + embedding = [1.0] + [0.0] * 767 + + class AnsweringLLM(StubLLMClient): + def stream(self, messages: list[Message]) -> Iterator[LLMStreamEvent]: + yield TextDelta("From Bitbucket.") + + app.dependency_overrides[get_llm] = lambda: AnsweringLLM(embedding=embedding) + store.add( + [ + Chunk( + id="chunk-1", + artifact_id="doc-1", + filename="widgets.md", + text="The widgets service retries three times.", + embedding=embedding, + project_ids=(_PROJECT,), + source_system="BITBUCKET", + ) + ] + ) + + response = http_client.post( + "/api/v1/chat", + json={ + "prompt": "How does widgets retry?", + "projectId": _PROJECT, + "filters": {"source_systems": [value]}, + }, + ) + + # Before BITBUCKET was a known source system this was rejected with a 422. + assert response.status_code == 200 + events = parse_sse_events(response.text) + assert [event["content"] for event in events if event["type"] == "token"] == [ + "From Bitbucket." + ] + + +def test_chat_still_rejects_an_unknown_source_filter( + client: tuple[TestClient, StubLLMClient, StubVectorStore], +) -> None: + http_client, _, _ = client + + response = http_client.post( + "/api/v1/chat", + json={ + "prompt": "Anything?", + "projectId": _PROJECT, + "filters": {"source_systems": ["GITLAB"]}, + }, + ) + + assert response.status_code == 422 + + def test_chat_emits_citation_when_chunks_exist( client: tuple[TestClient, StubLLMClient, StubVectorStore], ) -> None: diff --git a/tests/rag/test_filters.py b/tests/rag/test_filters.py index b345e11..8b5639b 100644 --- a/tests/rag/test_filters.py +++ b/tests/rag/test_filters.py @@ -4,9 +4,10 @@ decode_project_ids, encode_project_ids, matches_retrieval_filters, + normalize_source_system, where_filter_for_chroma, ) -from rag.types import Chunk, RetrievalFilters +from rag.types import Chunk, RetrievalFilters, is_source_system def test_source_system_filter_matches_allowed_system() -> None: @@ -54,6 +55,39 @@ def test_confluence_source_system_survives_ingest_and_filtering() -> None: ) +def test_bitbucket_source_system_survives_ingest_and_filtering() -> None: + # Without BITBUCKET in the known systems the ingest silently tagged the chunk + # with no source system, so a Bitbucket filter could never find it. + chunk = to_chunk( + ParsedChunk( + content="Why the widgets service retries", + kind="text", + metadata={"filename": "widgets.md"}, + ), + artifact_id="bitbucket:acme/widgets:FILE:widgets.md", + embedding=[1.0, 0.0], + artifact_type="FILE", + source_system="BITBUCKET", + ) + + assert chunk.source_system == "BITBUCKET" + assert matches_retrieval_filters( + chunk, + RetrievalFilters(source_systems=["BITBUCKET"]), + ) + assert not matches_retrieval_filters( + chunk, + RetrievalFilters(source_systems=["GITHUB"]), + ) + + +def test_source_system_is_recognised_case_insensitively() -> None: + assert normalize_source_system("bitbucket") == "BITBUCKET" + assert is_source_system("BITBUCKET") + assert normalize_source_system("gitlab") is None + assert normalize_source_system(None) is None + + def test_source_timestamp_preferred_over_indexed_at() -> None: chunk = to_chunk( ParsedChunk(