Skip to content

Commit 3b135fb

Browse files
authored
Merge pull request #868 from Anxhela21/anx/streaming_query
LCORE-873: Support Solr Vector I/O Provider in LCORE
2 parents aab14c5 + 929f0a4 commit 3b135fb

19 files changed

Lines changed: 541 additions & 72 deletions

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ test-integration: ## Run integration tests tests
3333
COVERAGE_FILE="${ARTIFACT_DIR}/.coverage.integration" uv run python -m pytest tests/integration --cov=src --cov-report term-missing --cov-report "json:${ARTIFACT_DIR}/coverage_integration.json" --junit-xml="${ARTIFACT_DIR}/junit_integration.xml" --cov-fail-under=10
3434

3535
test-e2e: ## Run end to end tests for the service
36-
script -q -e -c "uv run behave --color --format pretty --tags=-skip -D dump_errors=true @tests/e2e/test_list.txt"
36+
uv run behave --color --format pretty --tags=-skip -D dump_errors=true @tests/e2e/test_list.txt
3737

3838
test-e2e-local: ## Run end to end tests for the service
3939
uv run behave --color --format pretty --tags=-skip -D dump_errors=true @tests/e2e/test_list.txt

lightspeed-stack.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,8 @@ conversation_cache:
3030

3131
authentication:
3232
module: "noop"
33+
34+
35+
solr:
36+
enabled: true
37+
offline: true

run.yaml

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ apis:
1515
benchmarks: []
1616
datasets: []
1717
image_name: starter
18-
# external_providers_dir: /opt/app-root/src/.llama/providers.d
18+
external_providers_dir: ${env.EXTERNAL_PROVIDERS_DIR}
1919

2020
providers:
2121
inference:
@@ -24,7 +24,9 @@ providers:
2424
config:
2525
api_key: ${env.OPENAI_API_KEY}
2626
allowed_models: ["${env.E2E_OPENAI_MODEL:=gpt-4o-mini}"]
27-
- config: {}
27+
- config:
28+
allowed_models:
29+
- ${env.EMBEDDING_MODEL_DIR}
2830
provider_id: sentence-transformers
2931
provider_type: inline::sentence-transformers
3032
files:
@@ -56,6 +58,18 @@ providers:
5658
provider_id: rag-runtime
5759
provider_type: inline::rag-runtime
5860
vector_io:
61+
- provider_id: solr-vector
62+
provider_type: remote::solr_vector_io
63+
config:
64+
solr_url: http://localhost:8983/solr
65+
collection_name: portal-rag
66+
vector_field: chunk_vector
67+
content_field: chunk
68+
embedding_dimension: 384
69+
embedding_model: ${env.EMBEDDING_MODEL_DIR}
70+
persistence:
71+
namespace: portal-rag
72+
backend: kv_default
5973
- config: # Define the storage backend for RAG
6074
persistence:
6175
namespace: vector_io::faiss
@@ -127,7 +141,13 @@ storage:
127141
namespace: prompts
128142
backend: kv_default
129143
registered_resources:
130-
models: []
144+
models:
145+
- model_id: granite-embedding-30m
146+
model_type: embedding
147+
provider_id: sentence-transformers
148+
provider_model_id: ${env.EMBEDDING_MODEL_DIR}
149+
metadata:
150+
embedding_dimension: 384
131151
shields:
132152
- shield_id: llama-guard
133153
provider_id: llama-guard

src/app/endpoints/a2a.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,7 @@ async def _process_task_streaming( # pylint: disable=too-many-locals
311311
generate_topic_summary=True,
312312
media_type=None,
313313
vector_store_ids=vector_store_ids,
314+
solr=None,
314315
)
315316

316317
# Get LLM client and select model

src/app/endpoints/query.py

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22

33
"""Handler for REST API call to provide answer to query using Response API."""
44

5+
import datetime
56
import logging
6-
from datetime import UTC, datetime
77
from typing import Annotated, Any, cast
88

99
from fastapi import APIRouter, Depends, HTTPException, Request
@@ -25,13 +25,15 @@
2525
from configuration import configuration
2626
from models.config import Action
2727
from models.requests import QueryRequest
28+
2829
from models.responses import (
2930
ForbiddenResponse,
3031
InternalServerErrorResponse,
3132
NotFoundResponse,
3233
PromptTooLongResponse,
3334
QueryResponse,
3435
QuotaExceededResponse,
36+
ReferencedDocument,
3537
ServiceUnavailableResponse,
3638
UnauthorizedResponse,
3739
UnprocessableEntityResponse,
@@ -63,7 +65,11 @@
6365
run_shield_moderation,
6466
)
6567
from utils.suid import normalize_conversation_id
66-
from utils.types import ResponsesApiParams, TurnSummary
68+
from utils.types import (
69+
ResponsesApiParams,
70+
TurnSummary,
71+
)
72+
from utils.vector_search import perform_vector_search, format_rag_context_for_injection
6773

6874
logger = logging.getLogger("app.endpoints.handlers")
6975
router = APIRouter(tags=["query"])
@@ -77,9 +83,9 @@
7783
examples=["endpoint", "conversation read", "model override"]
7884
),
7985
404: NotFoundResponse.openapi_response(
80-
examples=["model", "conversation", "provider"]
86+
examples=["conversation", "model", "provider"]
8187
),
82-
413: PromptTooLongResponse.openapi_response(),
88+
# 413: PromptTooLongResponse.openapi_response(),
8389
422: UnprocessableEntityResponse.openapi_response(),
8490
429: QuotaExceededResponse.openapi_response(),
8591
500: InternalServerErrorResponse.openapi_response(examples=["configuration"]),
@@ -117,7 +123,7 @@ async def query_endpoint_handler(
117123
"""
118124
check_configuration_loaded(configuration)
119125

120-
started_at = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
126+
started_at = datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
121127
user_id, _, _skip_userid_check, token = auth
122128
# Check token availability
123129
check_tokens_available(configuration.quota_limiters, user_id)
@@ -145,6 +151,19 @@ async def query_endpoint_handler(
145151

146152
client = AsyncLlamaStackClientHolder().get_client()
147153

154+
doc_ids_from_chunks: list[ReferencedDocument] = []
155+
pre_rag_chunks: list[Any] = [] # use your RAGChunk type (or the upstream one)
156+
157+
_, _, doc_ids_from_chunks, pre_rag_chunks = await perform_vector_search(
158+
client, query_request, configuration
159+
)
160+
161+
rag_context = format_rag_context_for_injection(pre_rag_chunks)
162+
if rag_context:
163+
# safest: mutate a local copy so we don't surprise other logic
164+
query_request = query_request.model_copy(deep=True) # pydantic v2
165+
query_request.query = query_request.query + rag_context
166+
148167
# Prepare API request parameters
149168
responses_params = await prepare_responses_params(
150169
client,
@@ -168,6 +187,14 @@ async def query_endpoint_handler(
168187
# Retrieve response using Responses API
169188
turn_summary = await retrieve_response(client, responses_params)
170189

190+
if pre_rag_chunks:
191+
turn_summary.rag_chunks = pre_rag_chunks + (turn_summary.rag_chunks or [])
192+
193+
if doc_ids_from_chunks:
194+
turn_summary.referenced_documents = parse_referenced_docs(
195+
doc_ids_from_chunks + (turn_summary.referenced_documents or [])
196+
)
197+
171198
# Get topic summary for new conversation
172199
if not user_conversation and query_request.generate_topic_summary:
173200
logger.debug("Generating topic summary for new conversation")
@@ -190,7 +217,9 @@ async def query_endpoint_handler(
190217
quota_limiters=configuration.quota_limiters, user_id=user_id
191218
)
192219

193-
completed_at = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
220+
completed_at = datetime.datetime.now(datetime.timezone.utc).strftime(
221+
"%Y-%m-%dT%H:%M:%SZ"
222+
)
194223
conversation_id = normalize_conversation_id(responses_params.conversation)
195224

196225
logger.info("Storing query results")
@@ -222,6 +251,21 @@ async def query_endpoint_handler(
222251
)
223252

224253

254+
def parse_referenced_docs(
255+
docs: list[ReferencedDocument],
256+
) -> list[ReferencedDocument]:
257+
"""Remove duplicate referenced documents based on URL and title."""
258+
seen: set[tuple[str | None, str | None]] = set()
259+
out: list[ReferencedDocument] = []
260+
for d in docs:
261+
key = (str(d.doc_url) if d.doc_url else None, d.doc_title)
262+
if key in seen:
263+
continue
264+
seen.add(key)
265+
out.append(d)
266+
return out
267+
268+
225269
async def retrieve_response( # pylint: disable=too-many-locals
226270
client: AsyncLlamaStackClient,
227271
responses_params: ResponsesApiParams,

src/app/endpoints/streaming_query.py

Lines changed: 47 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
"""Streaming query handler using Responses API."""
22

3+
import datetime
34
import json
45
import logging
5-
from datetime import UTC, datetime
66
from typing import Annotated, Any, AsyncIterator, Optional, cast
77

88
from fastapi import APIRouter, Depends, HTTPException, Request
@@ -53,7 +53,7 @@
5353
UnauthorizedResponse,
5454
UnprocessableEntityResponse,
5555
)
56-
from utils.types import ReferencedDocument
56+
from utils.types import RAGChunk, ReferencedDocument
5757
from utils.endpoints import (
5858
check_configuration_loaded,
5959
validate_and_retrieve_conversation,
@@ -85,6 +85,7 @@
8585
from utils.suid import normalize_conversation_id
8686
from utils.token_counter import TokenCounter
8787
from utils.types import ResponsesApiParams, TurnSummary
88+
from utils.vector_search import format_rag_context_for_injection, perform_vector_search
8889

8990
logger = logging.getLogger(__name__)
9091
router = APIRouter(tags=["streaming_query"])
@@ -100,7 +101,7 @@
100101
404: NotFoundResponse.openapi_response(
101102
examples=["conversation", "model", "provider"]
102103
),
103-
413: PromptTooLongResponse.openapi_response(),
104+
# 413: PromptTooLongResponse.openapi_response(),
104105
422: UnprocessableEntityResponse.openapi_response(),
105106
429: QuotaExceededResponse.openapi_response(),
106107
500: InternalServerErrorResponse.openapi_response(examples=["configuration"]),
@@ -144,7 +145,7 @@ async def streaming_query_endpoint_handler( # pylint: disable=too-many-locals
144145
check_configuration_loaded(configuration)
145146

146147
user_id, _user_name, _skip_userid_check, token = auth
147-
started_at = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
148+
started_at = datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
148149

149150
# Check token availability
150151
check_tokens_available(configuration.quota_limiters, user_id)
@@ -172,6 +173,17 @@ async def streaming_query_endpoint_handler( # pylint: disable=too-many-locals
172173

173174
client = AsyncLlamaStackClientHolder().get_client()
174175

176+
pre_rag_chunks: list[RAGChunk] = []
177+
doc_ids_from_chunks: list[ReferencedDocument] = []
178+
179+
_, _, doc_ids_from_chunks, pre_rag_chunks = await perform_vector_search(
180+
client, query_request, configuration
181+
)
182+
rag_context = format_rag_context_for_injection(pre_rag_chunks)
183+
if rag_context:
184+
query_request = query_request.model_copy(deep=True)
185+
query_request.query = query_request.query + rag_context
186+
175187
# Prepare API request parameters
176188
responses_params = await prepare_responses_params(
177189
client=client,
@@ -212,6 +224,7 @@ async def streaming_query_endpoint_handler( # pylint: disable=too-many-locals
212224
generator, turn_summary = await retrieve_response_generator(
213225
responses_params=responses_params,
214226
context=context,
227+
doc_ids_from_chunks=doc_ids_from_chunks,
215228
)
216229

217230
response_media_type = (
@@ -234,6 +247,7 @@ async def streaming_query_endpoint_handler( # pylint: disable=too-many-locals
234247
async def retrieve_response_generator(
235248
responses_params: ResponsesApiParams,
236249
context: ResponseGeneratorContext,
250+
doc_ids_from_chunks: list[ReferencedDocument],
237251
) -> tuple[AsyncIterator[str], TurnSummary]:
238252
"""
239253
Retrieve the appropriate response generator.
@@ -273,6 +287,8 @@ async def retrieve_response_generator(
273287
response = await context.client.responses.create(
274288
**responses_params.model_dump()
275289
)
290+
# Store pre-RAG documents for later merging
291+
turn_summary.pre_rag_documents = doc_ids_from_chunks
276292
return response_generator(response, context, turn_summary), turn_summary
277293

278294
# Handle know LLS client errors only at stream creation time and shield execution
@@ -373,7 +389,7 @@ async def generate_response(
373389
turn_summary.referenced_documents,
374390
context.query_request.media_type or MEDIA_TYPE_JSON,
375391
)
376-
completed_at = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
392+
completed_at = datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
377393

378394
# Store query results (transcript, conversation details, cache)
379395
logger.info("Storing query results")
@@ -571,9 +587,21 @@ async def response_generator( # pylint: disable=too-many-branches,too-many-stat
571587
turn_summary.token_usage = extract_token_usage(
572588
latest_response_object, context.model_id
573589
)
574-
turn_summary.referenced_documents = parse_referenced_documents(
575-
latest_response_object
576-
)
590+
tool_based_documents = parse_referenced_documents(latest_response_object)
591+
592+
# Merge pre-RAG documents with tool-based documents (similar to query.py)
593+
if turn_summary.pre_rag_documents:
594+
all_documents = turn_summary.pre_rag_documents + tool_based_documents
595+
seen = set()
596+
deduplicated_documents = []
597+
for doc in all_documents:
598+
key = (doc.doc_url, doc.doc_title)
599+
if key not in seen:
600+
seen.add(key)
601+
deduplicated_documents.append(doc)
602+
turn_summary.referenced_documents = deduplicated_documents
603+
else:
604+
turn_summary.referenced_documents = tool_based_documents
577605

578606

579607
def stream_http_error_event(
@@ -608,7 +636,7 @@ def stream_http_error_event(
608636

609637
def format_stream_data(d: dict) -> str:
610638
"""
611-
Format a dictionary as a Server-Sent Events (SSE) data string.
639+
Create a response generator function for Responses API streaming.
612640
613641
Parameters:
614642
d (dict): The data to be formatted as an SSE event.
@@ -694,22 +722,24 @@ def stream_event(data: dict, event_type: str, media_type: str) -> str:
694722
"""Build an item to yield based on media type.
695723
696724
Args:
697-
data: The data to yield.
698-
event_type: The type of event (e.g. token, tool request, tool execution).
699-
media_type: Media type of the response (e.g. text or JSON).
725+
data: Dictionary containing the event data
726+
event_type: Type of event (token, tool call, etc.)
727+
media_type: The media type for the response format
700728
701729
Returns:
702-
str: The formatted string or JSON to yield.
730+
SSE-formatted string representing the event
703731
"""
704732
if media_type == MEDIA_TYPE_TEXT:
705733
if event_type == LLM_TOKEN_EVENT:
706-
return data["token"]
734+
return data.get("token", "")
707735
if event_type == LLM_TOOL_CALL_EVENT:
708-
return f"\nTool call: {json.dumps(data)}\n"
736+
return f"[Tool Call: {data.get('function_name', 'unknown')}]\n"
709737
if event_type == LLM_TOOL_RESULT_EVENT:
710-
return f"\nTool result: {json.dumps(data)}\n"
711-
logger.error("Unknown event type: %s", event_type)
738+
return "[Tool Result]\n"
739+
if event_type == LLM_TURN_COMPLETE_EVENT:
740+
return ""
712741
return ""
742+
713743
return format_stream_data(
714744
{
715745
"event": event_type,

src/app/main.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,18 @@
77
from fastapi import FastAPI, HTTPException, Request, Response
88
from fastapi.middleware.cors import CORSMiddleware
99
from fastapi.responses import JSONResponse
10-
from starlette.routing import Mount, Route, WebSocketRoute
1110
from llama_stack_client import APIConnectionError
11+
from starlette.routing import Mount, Route, WebSocketRoute
1212

13-
from authorization.azure_token_manager import AzureEntraIDManager
1413
import metrics
1514
import version
15+
from a2a_storage import A2AStorageFactory
1616
from app import routers
1717
from app.database import create_tables, initialize_database
18+
from authorization.azure_token_manager import AzureEntraIDManager
1819
from client import AsyncLlamaStackClientHolder
1920
from configuration import configuration
2021
from log import get_logger
21-
from a2a_storage import A2AStorageFactory
2222
from models.responses import InternalServerErrorResponse
2323
from utils.common import register_mcp_servers_async
2424
from utils.llama_stack_version import check_llama_stack_version

0 commit comments

Comments
 (0)