Skip to content

Commit a85ae05

Browse files
WallgauOlfa Maslahautofix-ci[bot]cursoragent
authored
feat: add preview-mode ingest backend with live index proof (OSS + SaaS) (#2051)
* feat: add preview-mode ingest backend with live index proof (OSS + SaaS) Introduce preview ingest plumbing so the frontend can show ingest progress and verify indexed chunks without persisting Docling layout JSON. - Add preview=true on upload router and connector sync (enabled in OSS and SaaS via is_ingest_preview_enabled; disabled on_prem) - Thread preview_mode through UploadTask, task service, and connector sync - Keep completed files in /tasks/enhanced for preview tasks (multi-file carousel) - Add GET /ingest/preview/{task_id}/index-proof to query OpenSearch live for chunk metadata (phase, text previews, embedding model/dimensions) - Track document_id on FileTask for index-proof lookups (set in Langflow and connector processors before ingest) - Wire IngestPreviewService as a stateless helper (no in-memory Docling cache) Intentionally excludes the spike's /docling endpoint and preview-only Docling options (embedded page images). Layout skeleton stays frontend-only; backend serves task phase + index proof only. Tests: API guards, index proof service, router preview threading, enhanced task serialization for preview vs normal tasks. * Address code-review findings on the preview ingest backend: - Pass the validated upload_task into IngestPreviewService instead of re-fetching from TaskService (closes preview_mode TOCTOU gap) - Add service-level preview_mode guard and map not_preview_task to 404 - Gate index-proof endpoint with require_permission(knowledge:upload) - Return per-request JSONResponse instances (no shared response object) - Sort chunks by page + numeric suffix instead of lexicographic _id - Use OpenSearch hits.total for chunk_count; expose chunks_returned and chunks_truncated when the 200-chunk cap applies - Return file_not_found (404) for unknown ?file= paths in multi-file tasks - Update Langflow processor comment for document_id threading - Expand unit tests: single-fetch regression, ordering, totals, file_not_found, document_id threading, and non-preview rejection * fix lint ruff * adding a feature of flag * address minor issues raised by Mike P * style: ruff autofix (auto) * ci: fail fast on runner/image architecture mismatch in integration suite * ci: free up runner disk space before building/loading images The move to ubuntu-latest runners (#2081) dropped available disk from the ~40GB self-hosted runners to ~14GB, so `docker load` of all four OpenRAG images ran out of space unpacking OpenSearch ("no space left on device"). Add a step to build-images, test-suite, and e2e-run that removes the large preinstalled toolchains these jobs never use (Android SDK, .NET, GHC, CodeQL), reclaiming ~25GB before any image build/load. Co-authored-by: Cursor <cursoragent@cursor.com> * ci: scope image cache keys by runner architecture The image cache key used only runner.os (always "Linux") plus a source hash, with no CPU arch. After the ubuntu-latest migration (#2081) the amd64 build-images job hit the cache from earlier arm64 self-hosted runs and restored/re-tagged stale arm64 images, shipping arm64 images to the amd64 test jobs (caught by the arch guard as a host/image mismatch). Add runner.arch to every image cache key so amd64 and arm64 caches never collide, forcing a native rebuild per architecture. Co-authored-by: Cursor <cursoragent@cursor.com> * ci: relocate Docker data-root to /mnt to fix disk exhaustion Trimming preinstalled toolchains alone did not free enough space on the small ubuntu-latest root filesystem (~30GB), so `docker load` of all four OpenRAG images still hit "no space left on device". Move Docker's data-root onto the runner's large ephemeral /mnt disk (~70GB) before building/loading images, and additionally clear swift/powershell caches and apt lists. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: forward explicit preview flag from v1 ingest endpoint The public /v1/documents ingest endpoint calls upload_ingest_router directly (not through FastAPI form parsing), so Form-defaulted params must be passed explicitly. The new preview param was omitted, leaking the Form("false") sentinel into `preview.lower()` and raising AttributeError -> HTTP 500 on every SDK ingest/delete/filter call (18 sdk-python + 6 sdk-typescript failures). Pass preview="false" (preview is frontend-only; v1 SDK does not expose it) and add a regression test asserting the flag is forwarded as a string. Co-authored-by: Cursor <cursoragent@cursor.com> * revert: drop CI runner/disk/cache workflow changes Revert the arch-mismatch guard, disk cleanup, arch-scoped image cache keys, and Docker data-root relocation added in this branch. The underlying CI infrastructure issues were fixed separately in another PR, so restore scripts/ci/run_integration_suite.sh and .github/workflows/test-ci.yml to their pre-change state. Keeps the v1 preview ingest code fix. Co-authored-by: Cursor <cursoragent@cursor.com> * resolve comment 2a --------- Co-authored-by: Olfa Maslah <olfamaslah@Olfas-MacBook-Pro.local> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 31fdf1d commit a85ae05

26 files changed

Lines changed: 1127 additions & 13 deletions

.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,12 @@ SESSION_SECRET=
411411
# - "on_prem": Private Cloud
412412
OPENRAG_RUN_MODE=oss
413413

414+
# Feature flag for the preview-mode ingest backend (default: false = disabled).
415+
# When true, the frontend can request preview ingests that show progress and a
416+
# live index proof without persisting Docling layout JSON. AND-ed with the run
417+
# mode: preview is only ever available in the "oss" and "saas" run modes above.
418+
# OPENRAG_INGEST_PREVIEW_ENABLED=false
419+
414420
# Permission/identity cache backend. Only "memory" is currently wired.
415421
# When CACHE_BACKEND=memory the RBAC permission cache and OAuth-subject
416422
# cache are per-process, so OpenRAG must run with UVICORN_WORKERS=1 (and

src/api/connectors.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
set_connector_access_bulk,
2929
)
3030
from session_manager import User
31+
from utils.ingest_preview_flag import is_ingest_preview_enabled
3132
from utils.logging_config import get_logger
3233
from utils.telemetry import Category, MessageId, TelemetryClient
3334

@@ -592,6 +593,8 @@ class ConnectorSyncBody(BaseModel):
592593
# rather than failing. Set by the provider upload UI after the user confirms
593594
# overwrite in the duplicate dialog.
594595
replace_duplicates: bool = False
596+
# When True (OSS/SaaS), run the ingest in preview mode (same as direct upload).
597+
preview: bool = False
595598
# When True (COS only), index chunks without an owner field so OpenSearch DLS
596599
# makes them visible to all users in the instance. Temporary CIO mechanism;
597600
# not a full ACL feature. Defaults to False (private).
@@ -925,6 +928,10 @@ async def connector_sync(
925928
selected_files = [f.get("id") for f in selected_files_raw if f.get("id")]
926929
file_infos = selected_files_raw
927930

931+
# Preview mode is opt-in from the connector upload UI (OSS/SaaS). It applies
932+
# to user-initiated ingests (explicit file selection), not automated re-sync.
933+
preview_mode = body.preview and is_ingest_preview_enabled()
934+
928935
try:
929936
await TelemetryClient.send_event(
930937
Category.CONNECTOR_OPERATIONS, MessageId.ORB_CONN_SYNC_START
@@ -1052,6 +1059,7 @@ async def connector_sync(
10521059
file_infos=file_infos,
10531060
ingest_settings=body.settings,
10541061
replace_duplicates=body.replace_duplicates,
1062+
preview_mode=preview_mode,
10551063
shared=body.shared,
10561064
)
10571065
elif body.sync_all or body.bucket_filter:
@@ -1156,6 +1164,7 @@ async def connector_sync(
11561164
new_ids,
11571165
jwt_token=jwt_token,
11581166
ingest_settings=body.settings,
1167+
preview_mode=preview_mode,
11591168
shared=body.shared,
11601169
)
11611170
)
@@ -1168,6 +1177,7 @@ async def connector_sync(
11681177
jwt_token=jwt_token,
11691178
ingest_settings=body.settings,
11701179
replace_duplicates=True,
1180+
preview_mode=preview_mode,
11711181
shared=body.shared,
11721182
)
11731183
)

src/api/ingest_preview.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""Ephemeral ingest preview endpoints for preview-mode ingest."""
2+
3+
from typing import Annotated, Any
4+
5+
from fastapi import Depends
6+
from fastapi.responses import JSONResponse
7+
8+
from dependencies import (
9+
get_ingest_preview_service,
10+
get_session_manager,
11+
get_task_service,
12+
require_permission,
13+
)
14+
from session_manager import User
15+
from utils.ingest_preview_flag import is_ingest_preview_enabled
16+
17+
18+
def _preview_unavailable_response() -> JSONResponse:
19+
return JSONResponse(
20+
{"error": "Ingest preview is not available in this run mode"},
21+
status_code=404,
22+
)
23+
24+
25+
def _require_preview_task(task_service: Any, user: User, task_id: str):
26+
"""Return (upload_task, error_response). Exactly one of the tuple values is None."""
27+
if not is_ingest_preview_enabled():
28+
return None, _preview_unavailable_response()
29+
30+
upload_task = task_service.get_upload_task(user.user_id, task_id)
31+
if upload_task is None:
32+
return None, JSONResponse({"error": "Task not found"}, status_code=404)
33+
if not upload_task.preview_mode:
34+
return None, JSONResponse({"error": "Task is not a preview ingest"}, status_code=404)
35+
return upload_task, None
36+
37+
38+
async def get_index_proof(
39+
task_id: str,
40+
preview_service: Annotated[Any, Depends(get_ingest_preview_service)],
41+
task_service: Annotated[Any, Depends(get_task_service)],
42+
session_manager: Annotated[Any, Depends(get_session_manager)],
43+
user: Annotated[User, Depends(require_permission("knowledge:upload"))],
44+
file: str | None = None,
45+
):
46+
"""Return indexed chunk metadata proving embeddings landed in OpenSearch.
47+
48+
``file`` selects a specific file within a multi-file preview task.
49+
"""
50+
upload_task, error = _require_preview_task(task_service, user, task_id)
51+
if error is not None:
52+
return error
53+
54+
opensearch_client = session_manager.get_user_opensearch_client(user.user_id, user.jwt_token)
55+
proof = await preview_service.get_index_proof(
56+
upload_task=upload_task,
57+
task_id=task_id,
58+
opensearch_client=opensearch_client,
59+
file_path=file,
60+
)
61+
62+
if proof.get("error") == "not_preview_task":
63+
return JSONResponse({"error": "Task is not a preview ingest"}, status_code=404)
64+
if proof.get("error") == "file_not_found":
65+
return JSONResponse({"error": "File not found in preview task"}, status_code=404)
66+
67+
return JSONResponse({"task_id": task_id, **proof})

src/api/router.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
get_task_service,
1818
)
1919
from session_manager import User
20+
from utils.ingest_preview_flag import is_ingest_preview_enabled
2021
from utils.logging_config import get_logger
2122

2223
logger = get_logger(__name__)
@@ -29,6 +30,7 @@ async def upload_ingest_router(
2930
tweaks_json: str | None = Form(None, alias="tweaks"),
3031
replace_duplicates: str = Form("true"),
3132
create_filter: str = Form("false"),
33+
preview: str = Form("false"),
3234
document_service=Depends(get_document_service),
3335
langflow_file_service=Depends(get_langflow_file_service),
3436
session_manager=Depends(get_session_manager),
@@ -42,9 +44,14 @@ async def upload_ingest_router(
4244
- If DISABLE_INGEST_WITH_LANGFLOW is False (default): uses Langflow upload-ingest via task service
4345
"""
4446
disable_ingest_with_langflow = get_openrag_config().knowledge.disable_ingest_with_langflow
47+
requested_preview = preview.lower() == "true"
48+
preview_mode = requested_preview and is_ingest_preview_enabled()
49+
if requested_preview and not preview_mode:
50+
logger.debug("Ingest preview ignored — not available in this run mode")
4551
logger.debug(
4652
"Router upload_ingest endpoint called",
4753
disable_langflow_ingest=disable_ingest_with_langflow,
54+
preview_mode=preview_mode,
4855
)
4956

5057
if disable_ingest_with_langflow:
@@ -53,6 +60,7 @@ async def upload_ingest_router(
5360
upload_files=file,
5461
replace_duplicates=replace_duplicates.lower() == "true",
5562
create_filter=create_filter.lower() == "true",
63+
preview_mode=preview_mode,
5664
session_manager=session_manager,
5765
task_service=task_service,
5866
user=user,
@@ -67,6 +75,7 @@ async def upload_ingest_router(
6775
tweaks_json=tweaks_json,
6876
replace_duplicates=replace_duplicates.lower() == "true",
6977
create_filter=create_filter.lower() == "true",
78+
preview_mode=preview_mode,
7079
langflow_file_service=langflow_file_service,
7180
session_manager=session_manager,
7281
task_service=task_service,
@@ -78,6 +87,7 @@ async def _traditional_upload_ingest_task(
7887
upload_files: list[UploadFile],
7988
replace_duplicates: bool,
8089
create_filter: bool,
90+
preview_mode: bool,
8191
session_manager,
8292
task_service,
8393
user: User,
@@ -142,6 +152,7 @@ async def _traditional_upload_ingest_task(
142152
original_filenames=file_path_to_original_filename,
143153
replace_duplicates=replace_duplicates,
144154
settings=settings,
155+
preview_mode=preview_mode,
145156
)
146157

147158
return JSONResponse(
@@ -151,6 +162,7 @@ async def _traditional_upload_ingest_task(
151162
"file_count": len(upload_files),
152163
"create_filter": create_filter,
153164
"filename": original_filenames[0] if len(original_filenames) == 1 else None,
165+
"preview_mode": preview_mode,
154166
},
155167
status_code=202,
156168
)
@@ -180,6 +192,7 @@ async def _langflow_upload_ingest_task(
180192
tweaks_json,
181193
replace_duplicates: bool,
182194
create_filter: bool,
195+
preview_mode: bool,
183196
langflow_file_service,
184197
session_manager,
185198
task_service,
@@ -251,6 +264,7 @@ async def _langflow_upload_ingest_task(
251264
tweaks=tweaks,
252265
settings=settings,
253266
replace_duplicates=replace_duplicates,
267+
preview_mode=preview_mode,
254268
)
255269

256270
return JSONResponse(
@@ -260,6 +274,7 @@ async def _langflow_upload_ingest_task(
260274
"file_count": len(upload_files),
261275
"create_filter": create_filter,
262276
"filename": original_filenames[0] if len(original_filenames) == 1 else None,
277+
"preview_mode": preview_mode,
263278
},
264279
status_code=202,
265280
)

src/api/v1/documents.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,10 @@ async def ingest_endpoint(
6969
tweaks_json=tweaks,
7070
replace_duplicates=replace_duplicates,
7171
create_filter=create_filter,
72+
# Preview ingest is a frontend-only feature; the v1 SDK does not expose
73+
# it. Pass an explicit value so the Form("false") default sentinel is not
74+
# forwarded when this function is called directly (not via form parsing).
75+
preview="false",
7276
document_service=document_service,
7377
langflow_file_service=langflow_file_service,
7478
session_manager=session_manager,

src/app/container.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from services.document_service import DocumentService
2626
from services.flows_service import FlowsService
2727
from services.group_acl_service import GroupACLService
28+
from services.ingest_preview_service import IngestPreviewService
2829
from services.knowledge_filter_service import KnowledgeFilterService
2930
from services.langflow_file_service import LangflowFileService
3031
from services.langflow_ingest_token_service import LangflowIngestTokenService
@@ -82,6 +83,7 @@ async def initialize_services():
8283
models_service = ModelsService()
8384
document_index_writer = DocumentIndexWriter()
8485
langflow_ingest_token_service = LangflowIngestTokenService()
86+
ingest_preview_service = IngestPreviewService()
8587
document_service = DocumentService(
8688
session_manager=session_manager,
8789
models_service=models_service,
@@ -232,6 +234,7 @@ def _lazy_session_factory():
232234
"langflow_mcp_service": langflow_mcp_service,
233235
"docling_service": clients.docling_service,
234236
"docling_polling_service": docling_polling_service,
237+
"ingest_preview_service": ingest_preview_service,
235238
"rbac_service": rbac_service,
236239
"workspace_config_service": workspace_config_service,
237240
}

src/app/routes/internal.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
documents,
1717
files,
1818
flows,
19+
ingest_preview,
1920
knowledge_filter,
2021
langflow_files,
2122
langflow_ingest,
@@ -74,6 +75,14 @@ def register_internal_routes(app: FastAPI):
7475
app.add_api_route("/upload_options", upload.upload_options, methods=["GET"], tags=["internal"])
7576
app.add_api_route("/upload_bucket", upload.upload_bucket, methods=["POST"], tags=["internal"])
7677

78+
# Ingest preview endpoint (index proof for preview-mode ingests)
79+
app.add_api_route(
80+
"/ingest/preview/{task_id}/index-proof",
81+
ingest_preview.get_index_proof,
82+
methods=["GET"],
83+
tags=["internal"],
84+
)
85+
7786
# Task endpoints
7887
# Literal sub-paths must be registered before the parameterised /{task_id}
7988
# so Starlette does not absorb "enhanced" as a task_id value.

src/config/settings.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,18 @@ def is_azure_blob_enabled() -> bool:
290290
return raw in ("true", "1", "yes", "on")
291291

292292

293+
def is_ingest_preview_flag_enabled() -> bool:
294+
"""Raw opt-in flag for the preview-mode ingest backend.
295+
296+
Read per-call (like the other feature-flag accessors in this module) so
297+
runtime/test overrides of ``OPENRAG_INGEST_PREVIEW_ENABLED`` take effect
298+
without a restart. This is only the flag itself; run-mode gating is applied
299+
by ``utils.ingest_preview_flag.is_ingest_preview_enabled()``.
300+
"""
301+
raw = os.getenv("OPENRAG_INGEST_PREVIEW_ENABLED", "false").strip().lower()
302+
return raw in ("true", "1", "yes", "on")
303+
304+
293305
def is_cloud_context() -> bool:
294306
"""True when connector policy and SaaS settings guards should apply."""
295307
from utils.run_mode_utils import is_run_mode_saas

src/connectors/service.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -471,7 +471,10 @@ async def sync_connector_files(
471471

472472
# Create custom task using TaskService
473473
task_id = await self.task_service.create_custom_task(
474-
user_id, file_ids, processor, original_filenames=original_filenames
474+
user_id,
475+
file_ids,
476+
processor,
477+
original_filenames=original_filenames,
475478
)
476479

477480
return task_id
@@ -485,6 +488,7 @@ async def sync_specific_files(
485488
file_infos: list[dict[str, Any]] = None,
486489
ingest_settings: dict[str, Any] | None = None,
487490
replace_duplicates: bool = False,
491+
preview_mode: bool = False,
488492
shared: bool = False,
489493
) -> str:
490494
"""
@@ -662,7 +666,11 @@ async def sync_specific_files(
662666
}
663667

664668
task_id = await self.task_service.create_custom_task(
665-
user_id, expanded_file_ids, processor, original_filenames=original_filenames
669+
user_id,
670+
expanded_file_ids,
671+
processor,
672+
original_filenames=original_filenames,
673+
preview_mode=preview_mode,
666674
)
667675

668676
return task_id

src/dependencies.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,10 @@ def get_langflow_file_service(services: dict = Depends(get_services)):
156156
return services["langflow_file_service"]
157157

158158

159+
def get_ingest_preview_service(services: dict = Depends(get_services)):
160+
return services["ingest_preview_service"]
161+
162+
159163
def get_document_index_writer(services: dict = Depends(get_services)):
160164
return services["document_index_writer"]
161165

0 commit comments

Comments
 (0)