Skip to content

feat: filter RAG sources by LLM citations to reduce false positives - #246

Merged
paultranvan merged 2 commits into
devfrom
filter-sources
Feb 24, 2026
Merged

feat: filter RAG sources by LLM citations to reduce false positives#246
paultranvan merged 2 commits into
devfrom
filter-sources

Conversation

@paultranvan

@paultranvan paultranvan commented Feb 12, 2026

Copy link
Copy Markdown
Collaborator

Number sources in context ([Source 1], [Source 2], ...) and instruct the LLM to append a [Sources: N, ...] tag listing which it actually used. The server strips this tag from the response and filters the source metadata accordingly, for both streaming and non-streaming modes.

See before/after example (do not mind the old info in the example)
Before
image

After
image

Summary by CodeRabbit

  • New Features

    • Source citation filtering for RAG: extracts end-of-output source lists, strips them from user-facing content, and returns filtered sources in response metadata for both streaming and non-streaming flows.
    • Streaming support extended to include source filtering and SSE-friendly behavior.
  • Bug Fixes

    • Defensive handling to avoid errors when source data is absent from model output.
  • Tests

    • Comprehensive tests covering extraction, stripping, filtering, streaming and non-streaming behaviors.
  • Documentation

    • Updated guidance on source citation filtering and prompt expectations.

@coderabbitai

coderabbitai Bot commented Feb 12, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@paultranvan has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 8 minutes and 38 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

📥 Commits

Reviewing files that changed from the base of the PR and between b09e9fa and a89d1cc.

📒 Files selected for processing (12)
  • CLAUDE.md
  • openrag/app_front.py
  • openrag/components/pipeline.py
  • openrag/components/test_source_filtering.py
  • openrag/components/utils.py
  • openrag/routers/openai.py
  • prompts/example1/spoken_style_answer_tmpl.txt
  • prompts/example1/sys_prompt_tmpl.txt
  • tests/api_tests/api_run/mock_vllm.py
  • tests/api_tests/conftest.py
  • tests/api_tests/test_indexer.py
  • tests/api_tests/test_openai_compat.py
📝 Walkthrough

Walkthrough

This PR implements end-of-response source citation extraction and filtering for the RAG pipeline, refactors context formatting to return included document indices, adds streaming-safe source filtering utilities, integrates them into streaming and non-streaming routes, and adds unit/integration tests plus mock streaming support.

Changes

Cohort / File(s) Summary
Core Utilities & Source Filtering
openrag/components/utils.py
Refactored format_context to return (text, included_indices) and added number_sources flag. Added extract_and_strip_sources_block(), filter_sources_by_citations(), and stream_with_source_filtering() for SSE buffering and final filtered-sources emission.
Pipeline Integration
openrag/components/pipeline.py
Updated _prepare_for_chat_completion and _prepare_for_completions to use new format_context return (payload, docs filtered by indices). Appended prompt instruction in completions to require a trailing [Sources: ...] line.
API Routing & Integration
openrag/routers/openai.py
Replaced inline metadata handling with extract_and_strip_sources_block + filter_sources_by_citations for non-streaming flows, and wired stream_with_source_filtering() for streaming chat completions.
Streaming Safety
openrag/app_front.py
Made streaming chunk handling defensive: only assign sources when chunk.extra exists and contains a "sources" key.
Unit Tests - Source Filtering
openrag/components/test_source_filtering.py
New comprehensive tests for extracting/stripping [Sources: ...] blocks and for filtering sources by citation sets (various edge cases, ordering, and dict-based sources).
Integration Tests - Source Behavior
tests/api_tests/test_openai_compat.py
Added TestSourceFiltering with tests for non-streaming and streaming responses: content stripping, presence of filtered sources in extra, streaming role delta and finish chunk behavior.
Test Infrastructure
tests/api_tests/conftest.py
Increased httpx timeouts, added TASK_TIMEOUT, wait_for_task(), and wait_for_indexing() helpers; indexed fixture now waits for per-file indexing completion.
Test Refactor
tests/api_tests/test_indexer.py
Removed local TASK_TIMEOUT/wait helpers; now import from conftest.
Mock LLM Streaming
tests/api_tests/api_run/mock_vllm.py
Added SSE streaming support (stream_chat_completion), made ChatCompletionRequest accept extra fields (ConfigDict(extra="allow")), and updated mock response generation to append sources when context contains numbered sources.
Prompt Templates
prompts/example1/sys_prompt_tmpl.txt, prompts/example1/spoken_style_answer_tmpl.txt
Updated prompts to require a trailing [Sources: N, ...] line as the very last line and to avoid inline citations.
Documentation
CLAUDE.md
Added "Source Citation Filtering" section describing the RAG pipeline self-reporting flow and streaming buffering behavior.

Sequence Diagram

sequenceDiagram
    participant Client
    participant API as API Router
    participant Pipeline
    participant Utils
    participant LLM
    participant Filter as Source Filter

    Client->>API: Chat completion request
    API->>Pipeline: prepare_for_chat_completion(...)
    Pipeline->>Utils: format_context(docs, number_sources=True)
    Utils-->>Pipeline: (formatted_context, included_indices)
    Pipeline-->>API: (payload, filtered_docs)

    API->>LLM: Send payload + prompt

    alt Streaming
        LLM-->>API: SSE chunks (includes final [Sources: ...])
        API->>Filter: stream_with_source_filtering(llm_stream, all_sources_json)
        Filter->>Filter: buffer chunks, detect sources tag
        Filter->>Utils: extract_and_strip_sources_block(buffered_text)
        Utils-->>Filter: (clean_text, citations)
        Filter->>Utils: filter_sources_by_citations(all_sources, citations)
        Utils-->>Filter: filtered_sources
        Filter-->>API: cleaned SSE chunks + final extra with filtered_sources
        API-->>Client: Streamed cleaned response
    else Non-Streaming
        LLM-->>API: Full response with [Sources: ...]
        API->>Utils: extract_and_strip_sources_block(content)
        Utils-->>API: (clean_content, citations)
        API->>Utils: filter_sources_by_citations(all_sources, citations)
        Utils-->>API: filtered_sources
        API-->>Client: Response with cleaned content and extra filtered_sources
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • Ahmath-Gadji
  • dodekapod

Poem

🐰
Sources hop in, then neatly hide,
Stripped from lines, then filtered wide.
Streams kept tidy, indices true,
A rabbit cheers: "I cite for you!" ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: filter RAG sources by LLM citations to reduce false positives' clearly and specifically summarizes the main change: implementing source filtering based on LLM citations to improve result quality.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch filter-sources

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@paultranvan
paultranvan changed the base branch from main to dev February 12, 2026 17:35
@paultranvan
paultranvan force-pushed the filter-sources branch 2 times, most recently from 51816a3 to d94f0ae Compare February 16, 2026 15:10
@paultranvan
paultranvan marked this pull request as ready for review February 16, 2026 15:18
@coderabbitai coderabbitai Bot added the feat Add a new feature label Feb 16, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (4)
openrag/components/pipeline.py (1)

179-191: Source citation instruction duplicated inline for completions path.

The source instruction on Line 187 duplicates what the prompt templates already specify for the chat path. This is acceptable since completions don't use system prompt templates, but consider extracting this instruction string to a shared constant in components/prompts to keep the expected format in sync.

openrag/routers/openai.py (1)

191-192: all_sources_json is computed but effectively unused.

all_sources_json is passed to stream_with_source_filtering but the parameter is annotated noqa: ARG001 ("no longer sent to clients"). Consider removing both the variable here and the unused parameter from stream_with_source_filtering to avoid confusion.

tests/api_tests/conftest.py (1)

160-170: wait_for_indexing silently falls back to a 5-second sleep and returns None.

When neither task_id nor task_status_url is present, the function silently sleeps and returns None instead of a status dict. This may mask unexpected API response formats. Consider logging a warning or raising, so test authors are alerted when the response structure changes unexpectedly.

Also, the existing indexed_folder_partition fixture (lines 188–206) duplicates the same poll-and-wait logic manually — it could be simplified by calling wait_for_indexing instead.

Proposed improvement for the silent fallback
     else:
+        import warnings
+        warnings.warn("No task_id or task_status_url in response; falling back to fixed sleep", stacklevel=2)
         time.sleep(5)
tests/api_tests/test_openai_compat.py (1)

125-149: SSE parsing is duplicated across streaming tests — consider a small helper.

The pattern of streaming, filtering data: lines, checking [DONE], and parsing JSON chunks is repeated verbatim in test_streaming_content_clean, test_streaming_has_role_delta, and test_streaming_has_finish_reason. A small helper (e.g. iter_sse_chunks(response)) that yields parsed chunk dicts would reduce the boilerplate and make the tests easier to maintain.

Example helper
def iter_sse_chunks(response):
    """Yield parsed SSE chunk dicts from a streaming httpx response."""
    for line in response.iter_lines():
        if not line.startswith("data:"):
            continue
        if "[DONE]" in line:
            return
        yield json.loads(line[len("data: "):])

@Ahmath-Gadji Ahmath-Gadji left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. This PR highlights a broader issue: we need better filtering before sending chunks to the LLM. Unused chunks consume context space, reducing available generation tokens. Filtering them would free up room for more relevant chunks and improve their ranking.

for n_tokens, doc in docs_with_tokens:

for i, doc in enumerate(docs):
prefix = f"[Source {len(reduced_docs) + 1}]\n" if number_sources else ""

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a small thing but instead of len(reduced_docs) + 1 the counter variable i could be used directly:

- prefix = f"[Source {len(reduced_docs) + 1}]\n" if number_sources else ""
+ prefix = f"[Source {i + 1}]\n" if number_sources else ""

Comment thread openrag/components/utils.py Outdated
last_chunk_template = data
content_buffer += content

if len(content_buffer) > buffer_size:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My main concern here is that we are not sending chunks anymore but an accumulated value to_emit = content_buffer[:-buffer_size] in the place the current streamed chunk content data["choices"][0]["delta"]["content"] and metadata of that chunks doesn't go along witht the content which is problematic.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've proposed another version in this PR #257 that leaves the original chunks intact.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I considered it was ok to have potentially incorrect metadata chunk (or at least, shifted), but your fix avoids that, so it's nice 👍
And good use of deque!

@paultranvan
paultranvan force-pushed the filter-sources branch 2 times, most recently from 660fadd to b09e9fa Compare February 24, 2026 12:53

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
tests/api_tests/api_run/mock_vllm.py (1)

1-16: ⚠️ Potential issue | 🟡 Minor

Fix Ruff formatting to unblock CI.

The pipeline reports that this file would be reformatted by ruff format. Run ruff format tests/api_tests/api_run/mock_vllm.py to fix.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/api_tests/api_run/mock_vllm.py` around lines 1 - 16, Run ruff format on
this module (or apply equivalent changes) to match the project's formatting
rules: reflow the module docstring, sort and group imports, ensure single blank
lines between import blocks and top-level code, fix spacing around commas and
parentheses in the import lines (hashlib, json, time, uuid, typing import), and
ensure a trailing newline; the primary identifiers to check are the module-level
imports and the FastAPI app instantiation (app = FastAPI()) so CI no longer
reports the file as reformattable by ruff.
🧹 Nitpick comments (3)
tests/api_tests/api_run/mock_vllm.py (1)

128-154: Mock always cites [Sources: 1] — sufficient for testing but worth noting.

The mock appends \n[Sources: 1] whenever the system prompt contains [Source 1]. This exercises the extraction/filtering path but won't test multi-source citation filtering or the "no match → fallback to all" path. Consider parameterizing or adding a second mock variant if broader coverage is needed later.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/api_tests/api_run/mock_vllm.py` around lines 128 - 154, The mock in
this file unconditionally appends a single-source citation whenever the system
prompt contains “[Source 1]” (see variables system_msg and has_numbered_sources
and the final response mutation), which limits test coverage for multi-source
and no-match fallback paths; update the mock to allow configurable citation
behavior (e.g., accept an argument or environment flag like cited_sources or
citation_mode) and implement logic in the mock function to: 1) append multiple
citations when cited_sources > 1, 2) return no citations when citation_mode ==
"none", and 3) simulate the fallback-to-all behavior when citation_mode ==
"fallback"; use the same response construction flow (content handling,
content_lower checks) and only alter the final citation-appending block so tests
can toggle between single-source, multi-source, and no-match scenarios.
tests/api_tests/conftest.py (1)

177-213: indexed_folder_partition doesn't use the new wait_for_task / wait_for_indexing helpers.

The fixture still has its own inline polling loop (lines 193-211) with different timeout logic (30 iterations × 2s = 60s) vs. TASK_TIMEOUT (180s), and checks for both upper/lowercase state strings. Consider refactoring to use wait_for_indexing for consistency and reduced duplication.

Proposed refactor
         data = response.json()
 
-        # Wait for each file to be indexed
-        if "task_status_url" in data:
-            task_url = data["task_status_url"]
-            task_path = "/" + "/".join(task_url.split("/")[3:])
-        elif "task_id" in data:
-            task_path = f"/indexer/task/{data['task_id']}"
-        else:
-            time.sleep(3)
-            continue
-
-        for _ in range(30):
-            task_response = api_client.get(task_path)
-            task_data = task_response.json()
-            state = task_data.get("task_state", "")
-            if state in ["SUCCESS", "COMPLETED", "success", "completed"]:
-                break
-            elif state in ["FAILED", "failed", "FAILURE", "failure"]:
-                pytest.skip(f"Indexing failed for {filename}: {task_data}")
-            time.sleep(2)
+        try:
+            wait_for_indexing(api_client, data)
+        except AssertionError:
+            pytest.skip(f"Indexing failed for {filename}")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/api_tests/conftest.py` around lines 177 - 213, The
indexed_folder_partition fixture contains an inline polling loop to wait for
indexing that duplicates logic and timeout values; replace the per-file polling
(the block that inspects "task_status_url" / "task_id" and loops up to 30×2s
checking task_state) with the existing helper wait_for_indexing (or
wait_for_task) to centralize timeout and status handling. Specifically, in
indexed_folder_partition use the response to derive task_path as currently done,
then call wait_for_indexing(api_client, task_path) (or wait_for_task) instead of
the manual for-loop and state casing checks so the fixture uses TASK_TIMEOUT and
consistent success/failure logic. Ensure you preserve the existing behavior of
skipping the test on failure by letting the helper raise or return failure in
the same way the callers expect.
openrag/components/pipeline.py (1)

183-188: Inline source instruction for completions — consider extracting to a shared constant.

The source citation instruction on line 187 duplicates the intent of lines 10-12 in spoken_style_answer_tmpl.txt. If the citation format changes (e.g., different tag syntax), this inline string would need a separate update.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openrag/components/pipeline.py` around lines 183 - 188, The inline citation
instruction string appended into the local variable prompt (the multiline
f-string that includes "At the very end of your response, on a new line, list
which source numbers you used: [Sources: 1, 3]") should be extracted into a
shared constant (e.g., SOURCE_CITATION_INSTRUCTION) and referenced from the
prompt construction; update the existing spoken_style_answer_tmpl.txt usage to
import or load that same constant so both the pipeline.py prompt builder and the
spoken_style template share a single source of truth, and replace the duplicated
literal in pipeline.py (where prompt is created) with that constant.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@openrag/components/utils.py`:
- Around line 165-195: The async loop over llm_stream can exit without hitting
the "data: [DONE]" branch and drop buffered content in chunk_buffer; wrap the
iteration in try/finally (or add a sentinel after the loop) to detect premature
termination and flush chunk_buffer the same way the "[DONE]" branch does: build
buffered_text from chunk_buffer, call
extract_and_strip_sources_block(buffered_text) to get clean_text and citations,
trim each chunk's original content to the surviving slice, set chunk["extra"] =
"{}", yield those chunks, and if last_chunk_template exists produce the
finish_chunk with filtered = filter_sources_by_citations(sources, citations) and
include last_finish_reason and the extra sources, then yield "data: [DONE]\n\n".

In `@tests/api_tests/conftest.py`:
- Around line 165-175: The wait_for_indexing function currently falls through
and implicitly returns None when response_data lacks "task_id" and
"task_status_url", which hides failures; update wait_for_indexing to log a
warning (use the module logger or testing logger) indicating missing task info
(include response_data for context) and make the return explicit (return None or
a clearly documented sentinel) so callers can detect the condition; reference
wait_for_indexing and wait_for_task when making the change so callers still use
wait_for_task when a task_id is available.

---

Outside diff comments:
In `@tests/api_tests/api_run/mock_vllm.py`:
- Around line 1-16: Run ruff format on this module (or apply equivalent changes)
to match the project's formatting rules: reflow the module docstring, sort and
group imports, ensure single blank lines between import blocks and top-level
code, fix spacing around commas and parentheses in the import lines (hashlib,
json, time, uuid, typing import), and ensure a trailing newline; the primary
identifiers to check are the module-level imports and the FastAPI app
instantiation (app = FastAPI()) so CI no longer reports the file as
reformattable by ruff.

---

Nitpick comments:
In `@openrag/components/pipeline.py`:
- Around line 183-188: The inline citation instruction string appended into the
local variable prompt (the multiline f-string that includes "At the very end of
your response, on a new line, list which source numbers you used: [Sources: 1,
3]") should be extracted into a shared constant (e.g.,
SOURCE_CITATION_INSTRUCTION) and referenced from the prompt construction; update
the existing spoken_style_answer_tmpl.txt usage to import or load that same
constant so both the pipeline.py prompt builder and the spoken_style template
share a single source of truth, and replace the duplicated literal in
pipeline.py (where prompt is created) with that constant.

In `@tests/api_tests/api_run/mock_vllm.py`:
- Around line 128-154: The mock in this file unconditionally appends a
single-source citation whenever the system prompt contains “[Source 1]” (see
variables system_msg and has_numbered_sources and the final response mutation),
which limits test coverage for multi-source and no-match fallback paths; update
the mock to allow configurable citation behavior (e.g., accept an argument or
environment flag like cited_sources or citation_mode) and implement logic in the
mock function to: 1) append multiple citations when cited_sources > 1, 2) return
no citations when citation_mode == "none", and 3) simulate the fallback-to-all
behavior when citation_mode == "fallback"; use the same response construction
flow (content handling, content_lower checks) and only alter the final
citation-appending block so tests can toggle between single-source,
multi-source, and no-match scenarios.

In `@tests/api_tests/conftest.py`:
- Around line 177-213: The indexed_folder_partition fixture contains an inline
polling loop to wait for indexing that duplicates logic and timeout values;
replace the per-file polling (the block that inspects "task_status_url" /
"task_id" and loops up to 30×2s checking task_state) with the existing helper
wait_for_indexing (or wait_for_task) to centralize timeout and status handling.
Specifically, in indexed_folder_partition use the response to derive task_path
as currently done, then call wait_for_indexing(api_client, task_path) (or
wait_for_task) instead of the manual for-loop and state casing checks so the
fixture uses TASK_TIMEOUT and consistent success/failure logic. Ensure you
preserve the existing behavior of skipping the test on failure by letting the
helper raise or return failure in the same way the callers expect.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d94f0ae and 660fadd.

📒 Files selected for processing (12)
  • CLAUDE.md
  • openrag/app_front.py
  • openrag/components/pipeline.py
  • openrag/components/test_source_filtering.py
  • openrag/components/utils.py
  • openrag/routers/openai.py
  • prompts/example1/spoken_style_answer_tmpl.txt
  • prompts/example1/sys_prompt_tmpl.txt
  • tests/api_tests/api_run/mock_vllm.py
  • tests/api_tests/conftest.py
  • tests/api_tests/test_indexer.py
  • tests/api_tests/test_openai_compat.py
🚧 Files skipped from review as they are similar to previous changes (5)
  • prompts/example1/sys_prompt_tmpl.txt
  • openrag/components/test_source_filtering.py
  • openrag/app_front.py
  • tests/api_tests/test_indexer.py
  • tests/api_tests/test_openai_compat.py

Comment on lines +165 to +195
async for line in llm_stream:
if not line.startswith("data:"):
continue

if line.strip() == "data: [DONE]":
buffered_text = "".join(
(c.get("choices", [{}])[0].get("delta", {}).get("content", "") or "") for c in chunk_buffer
)
clean_text, citations = extract_and_strip_sources_block(buffered_text)

remaining = clean_text
for chunk in chunk_buffer:
original_content = chunk.get("choices", [{}])[0].get("delta", {}).get("content", "") or ""
if not remaining:
break
surviving = remaining[: len(original_content)]
remaining = remaining[len(original_content) :]
if surviving != original_content:
chunk["choices"][0]["delta"]["content"] = surviving
chunk["extra"] = "{}"
yield f"data: {json.dumps(chunk)}\n\n"

if last_chunk_template:
filtered = filter_sources_by_citations(sources, citations)
finish_chunk = copy.deepcopy(last_chunk_template)
finish_chunk["choices"][0]["delta"] = {}
finish_chunk["choices"][0]["finish_reason"] = last_finish_reason or "stop"
finish_chunk["extra"] = json.dumps({"sources": filtered})
yield f"data: {json.dumps(finish_chunk)}\n\n"

yield "data: [DONE]\n\n"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Buffered content is silently lost if the stream ends without data: [DONE].

If the upstream LLM stream is interrupted (connection drop, timeout, exception), the async for loop exits without hitting the [DONE] branch. Up to buffer_size (100) characters of legitimate content in chunk_buffer will never be yielded to the client. The client sees a truncated response with no indication that content was withheld.

Consider adding a try/finally or sentinel to flush the buffer on unexpected stream termination:

Sketch of a safer flush
+    try:
         async for line in llm_stream:
             ...  # existing logic
+    finally:
+        # Flush remaining buffer on unexpected exit (no [DONE] received)
+        if chunk_buffer:
+            for chunk in chunk_buffer:
+                chunk["extra"] = "{}"
+                yield f"data: {json.dumps(chunk)}\n\n"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openrag/components/utils.py` around lines 165 - 195, The async loop over
llm_stream can exit without hitting the "data: [DONE]" branch and drop buffered
content in chunk_buffer; wrap the iteration in try/finally (or add a sentinel
after the loop) to detect premature termination and flush chunk_buffer the same
way the "[DONE]" branch does: build buffered_text from chunk_buffer, call
extract_and_strip_sources_block(buffered_text) to get clean_text and citations,
trim each chunk's original content to the surviving slice, set chunk["extra"] =
"{}", yield those chunks, and if last_chunk_template exists produce the
finish_chunk with filtered = filter_sources_by_citations(sources, citations) and
include last_finish_reason and the extra sources, then yield "data: [DONE]\n\n".

Comment on lines +165 to +175
def wait_for_indexing(api_client, response_data: dict, timeout: int = TASK_TIMEOUT):
"""Wait for file indexing task to complete, extracting task_id from response."""
if "task_id" in response_data:
return wait_for_task(api_client, response_data["task_id"], timeout)
elif "task_status_url" in response_data:
# Extract task_id from URL like http://host/indexer/task/{task_id}
task_id = response_data["task_status_url"].split("/")[-1]
return wait_for_task(api_client, task_id, timeout)
else:
time.sleep(5)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

wait_for_indexing silently returns None when response lacks task info.

When neither task_id nor task_status_url is present, the function sleeps 5 seconds and implicitly returns None. Callers have no way to distinguish "completed successfully" from "couldn't track the task." Consider logging a warning or returning a sentinel.

Proposed fix
     else:
+        # No task tracking info available — best-effort wait
         time.sleep(5)
+        return None

The explicit return None doesn't change behavior but documents the intent. A logger.warning(...) here would help diagnose test failures.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def wait_for_indexing(api_client, response_data: dict, timeout: int = TASK_TIMEOUT):
"""Wait for file indexing task to complete, extracting task_id from response."""
if "task_id" in response_data:
return wait_for_task(api_client, response_data["task_id"], timeout)
elif "task_status_url" in response_data:
# Extract task_id from URL like http://host/indexer/task/{task_id}
task_id = response_data["task_status_url"].split("/")[-1]
return wait_for_task(api_client, task_id, timeout)
else:
time.sleep(5)
def wait_for_indexing(api_client, response_data: dict, timeout: int = TASK_TIMEOUT):
"""Wait for file indexing task to complete, extracting task_id from response."""
if "task_id" in response_data:
return wait_for_task(api_client, response_data["task_id"], timeout)
elif "task_status_url" in response_data:
# Extract task_id from URL like http://host/indexer/task/{task_id}
task_id = response_data["task_status_url"].split("/")[-1]
return wait_for_task(api_client, task_id, timeout)
else:
# No task tracking info available — best-effort wait
time.sleep(5)
return None
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/api_tests/conftest.py` around lines 165 - 175, The wait_for_indexing
function currently falls through and implicitly returns None when response_data
lacks "task_id" and "task_status_url", which hides failures; update
wait_for_indexing to log a warning (use the module logger or testing logger)
indicating missing task info (include response_data for context) and make the
return explicit (return None or a clearly documented sentinel) so callers can
detect the condition; reference wait_for_indexing and wait_for_task when making
the change so callers still use wait_for_task when a task_id is available.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (2)
tests/api_tests/conftest.py (1)

165-175: ⚠️ Potential issue | 🟡 Minor

Make the “no task info” path explicit and observable.

This still falls through and returns None silently; the prior review already flagged adding an explicit return + warning. Please make the return explicit and log the condition so failures are diagnosable.
As per coding guidelines, use get_logger() from openrag.utils.logger with .bind() for context.

🛈 Proposed fix
+from openrag.utils.logger import get_logger
+
+logger = get_logger(__name__)
+
 def wait_for_indexing(api_client, response_data: dict, timeout: int = TASK_TIMEOUT):
     """Wait for file indexing task to complete, extracting task_id from response."""
     if "task_id" in response_data:
         return wait_for_task(api_client, response_data["task_id"], timeout)
     elif "task_status_url" in response_data:
         # Extract task_id from URL like http://host/indexer/task/{task_id}
         task_id = response_data["task_status_url"].split("/")[-1]
         return wait_for_task(api_client, task_id, timeout)
     else:
-        time.sleep(5)
+        logger.bind(response_data=response_data).warning("No task tracking info in indexing response")
+        time.sleep(5)
+        return None
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/api_tests/conftest.py` around lines 165 - 175, The "no task info"
branch in wait_for_indexing currently falls through and returns None silently;
update wait_for_indexing to create a logger via
get_logger().bind(context={'function':'wait_for_indexing'}) and when neither
"task_id" nor "task_status_url" are present, log a warning with the
response_data (e.g., logger.warning("No task info in response",
response=response_data)) and then explicitly return None so the absence of task
info is observable and diagnosable.
openrag/components/utils.py (1)

165-195: ⚠️ Potential issue | 🟡 Minor

Stream may drop buffered content if [DONE] never arrives.
If the upstream stream terminates early, the buffered tail is never flushed to the client. Consider a try/finally flush path.

Possible fix sketch
 async def stream_with_source_filtering(...):
     ...
-    async for line in llm_stream:
-        ...
+    try:
+        async for line in llm_stream:
+            ...
+    finally:
+        if chunk_buffer:
+            buffered_text = "".join(
+                (c.get("choices", [{}])[0].get("delta", {}).get("content", "") or "")
+                for c in chunk_buffer
+            )
+            clean_text, citations = extract_and_strip_sources_block(buffered_text)
+            remaining = clean_text
+            for chunk in chunk_buffer:
+                original_content = chunk.get("choices", [{}])[0].get("delta", {}).get("content", "") or ""
+                if not remaining:
+                    break
+                surviving = remaining[: len(original_content)]
+                remaining = remaining[len(original_content) :]
+                if surviving != original_content:
+                    chunk["choices"][0]["delta"]["content"] = surviving
+                chunk["extra"] = "{}"
+                yield f"data: {json.dumps(chunk)}\n\n"
+            if last_chunk_template:
+                filtered = filter_sources_by_citations(sources, citations)
+                finish_chunk = copy.deepcopy(last_chunk_template)
+                finish_chunk["choices"][0]["delta"] = {}
+                finish_chunk["choices"][0]["finish_reason"] = last_finish_reason or "stop"
+                finish_chunk["extra"] = json.dumps({"sources": filtered})
+                yield f"data: {json.dumps(finish_chunk)}\n\n"
+            yield "data: [DONE]\n\n"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openrag/components/utils.py` around lines 165 - 195, The loop over llm_stream
currently only flushes buffered chunk_buffer when a "data: [DONE]" arrives, so
if the upstream stream closes early you must flush the tail in a
finally/after-loop path; implement a post-loop flush that mirrors the "data:
[DONE]" handling: build buffered_text from chunk_buffer, call
extract_and_strip_sources_block(buffered_text) to get clean_text and citations,
reconcile surviving content with each chunk in chunk_buffer (updating
chunk["choices"][0]["delta"]["content"] and chunk["extra"]) and yield them, then
if last_chunk_template create finish_chunk (set choices[0].delta = {},
choices[0].finish_reason = last_finish_reason or "stop", extra =
json.dumps({"sources": filter_sources_by_citations(sources, citations)})) and
yield it, and finally yield "data: [DONE]\n\n"; ensure this runs even if the
async iterator ends without a DONE by using try/finally or handling
StopAsyncIteration.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tests/api_tests/conftest.py`:
- Around line 130-163: The wait_for_task helper currently only treats
"COMPLETED" as success and can miss "SUCCESS" or "success" states; update the
wait_for_task function to normalize the returned task_state (e.g., state =
status.get("task_state", "").upper()) and consider both "COMPLETED" and
"SUCCESS" as terminal success values before returning the status, while keeping
the existing "FAILED" handling and timeout behavior.

---

Duplicate comments:
In `@openrag/components/utils.py`:
- Around line 165-195: The loop over llm_stream currently only flushes buffered
chunk_buffer when a "data: [DONE]" arrives, so if the upstream stream closes
early you must flush the tail in a finally/after-loop path; implement a
post-loop flush that mirrors the "data: [DONE]" handling: build buffered_text
from chunk_buffer, call extract_and_strip_sources_block(buffered_text) to get
clean_text and citations, reconcile surviving content with each chunk in
chunk_buffer (updating chunk["choices"][0]["delta"]["content"] and
chunk["extra"]) and yield them, then if last_chunk_template create finish_chunk
(set choices[0].delta = {}, choices[0].finish_reason = last_finish_reason or
"stop", extra = json.dumps({"sources": filter_sources_by_citations(sources,
citations)})) and yield it, and finally yield "data: [DONE]\n\n"; ensure this
runs even if the async iterator ends without a DONE by using try/finally or
handling StopAsyncIteration.

In `@tests/api_tests/conftest.py`:
- Around line 165-175: The "no task info" branch in wait_for_indexing currently
falls through and returns None silently; update wait_for_indexing to create a
logger via get_logger().bind(context={'function':'wait_for_indexing'}) and when
neither "task_id" nor "task_status_url" are present, log a warning with the
response_data (e.g., logger.warning("No task info in response",
response=response_data)) and then explicitly return None so the absence of task
info is observable and diagnosable.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 660fadd and b09e9fa.

📒 Files selected for processing (12)
  • CLAUDE.md
  • openrag/app_front.py
  • openrag/components/pipeline.py
  • openrag/components/test_source_filtering.py
  • openrag/components/utils.py
  • openrag/routers/openai.py
  • prompts/example1/spoken_style_answer_tmpl.txt
  • prompts/example1/sys_prompt_tmpl.txt
  • tests/api_tests/api_run/mock_vllm.py
  • tests/api_tests/conftest.py
  • tests/api_tests/test_indexer.py
  • tests/api_tests/test_openai_compat.py
🚧 Files skipped from review as they are similar to previous changes (5)
  • prompts/example1/spoken_style_answer_tmpl.txt
  • openrag/components/test_source_filtering.py
  • openrag/components/pipeline.py
  • tests/api_tests/api_run/mock_vllm.py
  • tests/api_tests/test_openai_compat.py

Comment thread tests/api_tests/conftest.py
paultranvan and others added 2 commits February 24, 2026 14:10
Number sources in context ([Source 1], [Source 2], ...) and instruct the
LLM to append a [Sources: N, ...] tag listing which it actually used.
The server strips this tag from the response and filters the source
metadata accordingly, for both streaming and non-streaming modes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@paultranvan
paultranvan merged commit ade7637 into dev Feb 24, 2026
4 checks passed
@paultranvan
paultranvan deleted the filter-sources branch February 24, 2026 13:31
@coderabbitai coderabbitai Bot mentioned this pull request Feb 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feat Add a new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants