Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@

## [Unreleased]

### Added

- **Optional-ingestion upload capability.** Honor
`DEPLOY_INGESTION_CONTAINER_APP` independently of the chat backend: `false`
hides standalone and Entra Copilot upload controls and rejects attachments
before ingestion URL or Dapr resolution. Missing preserves legacy upload
eligibility; `true` retains existing authentication and conversation checks;
malformed boolean values fail with a configuration error. Anonymous Copilot
uploads remain disabled. Text chat and retrieval are unchanged; no native
Foundry IQ conversation upload path is introduced.

## [v3.2.2] - 2026-10-14

### Fixed
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,16 @@ rejects first-turn uploads rather than fabricating an ID. A resumed Chainlit
thread restores context from its ordered messages and obtains a fresh managed
ID from the hosted runtime.

Uploads also require the independent `DEPLOY_INGESTION_CONTAINER_APP` capability
from the existing App Configuration provider. `false` hides upload controls in
standalone and Entra Copilot modes and rejects attachments before ingestion URL
or Dapr resolution with “Attachments are unavailable in this deployment.”
Missing preserves legacy eligibility; `true` retains existing authentication and
conversation checks. Invalid boolean values fail with a configuration error.
Text chat and retrieval are unchanged; this does not add native Foundry IQ
conversation uploads. Supported ingestion sources and deployment combinations
remain owned by the [platform documentation](https://azure.github.io/AI-Landing-Zones/agent-landing-zone/).

Live acceptance still requires Basic and isolated topology validation,
two-turn managed conversation continuity, and a two-user negative
document-authorization test. These deployment and identity checks cannot be
Expand Down
10 changes: 10 additions & 0 deletions docs/copilot-embedding.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,16 @@ Embedding is disabled by default. Enabling it without a valid
mode from missing Entra configuration and never downgrades an Entra failure to
anonymous access.

Entra Copilot upload controls also require the independent
`DEPLOY_INGESTION_CONTAINER_APP` capability read through the existing App
Configuration provider. A missing setting preserves legacy upload eligibility;
`true` keeps the existing Entra and conversation checks. `false` disables the
controls and server-side attachment processing, including ingestion URL/Dapr
fallbacks, with “Attachments are unavailable in this deployment.” Malformed
boolean values fail with an actionable configuration error. Anonymous Copilot
uploads remain disabled regardless of this flag. Text chat, authentication and
retrieval are unchanged; no native conversation upload path is introduced.

## Rendering and token flow

Chainlit 2.12.0 Copilot mode renders a floating widget in an open Shadow DOM. It
Expand Down
5 changes: 4 additions & 1 deletion src/gpt_rag_ui/auth/embed_security.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
reset_request_copilot_session,
)
from gpt_rag_ui.config.embed_config import EmbedSettings
from gpt_rag_ui.config.appconfig import load_ingestion_uploads_enabled
from gpt_rag_ui.config.dependencies import get_config
from gpt_rag_ui.telemetry.boundary_diagnostics import log_boundary_failure


Expand Down Expand Up @@ -654,6 +656,7 @@ def _inject_chainlit_cookie(
def _copilot_project_settings_send(send, auth_mode: str):
"""Expose only the capabilities supported by this Copilot auth mode."""

ingestion_uploads_enabled = load_ingestion_uploads_enabled(get_config())
pending_start = None

async def send_response(message):
Expand All @@ -678,7 +681,7 @@ async def send_response(message):
.get("spontaneous_file_upload", {})
)
if isinstance(upload, dict):
upload["enabled"] = auth_mode == "entra"
upload["enabled"] = ingestion_uploads_enabled and auth_mode == "entra"
body = json.dumps(
payload,
separators=(",", ":"),
Expand Down
33 changes: 25 additions & 8 deletions src/gpt_rag_ui/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from fastapi import FastAPI, Response
from fastapi.responses import JSONResponse, StreamingResponse

from gpt_rag_ui.config.appconfig import AppConfigClient
from gpt_rag_ui.config.appconfig import AppConfigClient, load_ingestion_uploads_enabled
from gpt_rag_ui.telemetry.boundary_diagnostics import log_boundary_failure
from gpt_rag_ui.clients.blob import BlobClient
from gpt_rag_ui.config.dependencies import get_config
Expand Down Expand Up @@ -154,21 +154,27 @@ def _format_release_value(value: str | None, missing_message: str) -> str:
return missing_message


def _want_chainlit_spontaneous_file_upload(auth_state: AuthState) -> bool:
"""True whenever the effective allow_anonymous is false.
def _want_chainlit_spontaneous_file_upload(
auth_state: AuthState, *, ingestion_uploads_enabled: bool = True,
) -> bool:
"""Enable uploads only with ingestion capability and authenticated callers.

Per-conversation uploads require an authenticated caller (each file is bound to the
user's conversation), so we enable the Chainlit paperclip whenever auth is in force,
user's conversation), so the Chainlit paperclip also requires auth in force,
regardless of whether ALLOW_ANONYMOUS came from env, App Config, or the default.
"""

return not auth_state.allow_anonymous
return ingestion_uploads_enabled and not auth_state.allow_anonymous


def _sync_chainlit_spontaneous_file_upload(auth_state: AuthState) -> None:
def _sync_chainlit_spontaneous_file_upload(
auth_state: AuthState, *, ingestion_uploads_enabled: bool = True,
) -> None:
"""Write [features.spontaneous_file_upload].enabled before `import chainlit` (Chainlit reads TOML on import)."""

want_enabled = _want_chainlit_spontaneous_file_upload(auth_state)
want_enabled = _want_chainlit_spontaneous_file_upload(
auth_state, ingestion_uploads_enabled=ingestion_uploads_enabled,
)
path = get_asset_root() / ".chainlit" / "config.toml"
if not path.is_file():
logger.warning("Chainlit config not found at %s; skipping spontaneous file upload sync", path)
Expand Down Expand Up @@ -728,10 +734,13 @@ def _create_chainlit_app(
"""

embed_settings = embed_settings or EmbedSettings()
ingestion_uploads_enabled = load_ingestion_uploads_enabled(config)
_configure_embed_environment(embed_settings)
_configure_auth_environment(config, auth_state)
effective_auth = auth_state or _evaluate_auth_state(config, embed_settings)
_sync_chainlit_spontaneous_file_upload(effective_auth)
_sync_chainlit_spontaneous_file_upload(
effective_auth, ingestion_uploads_enabled=ingestion_uploads_enabled,
)

download_tokens = None
if embed_settings.enabled:
Expand All @@ -757,6 +766,14 @@ def _create_chainlit_app(
# origins can be applied in memory without modifying the deployment files.
from chainlit.config import config as chainlit_config

# Also enforce the capability if TOML could not be written or Chainlit
# was imported earlier in this process.
if chainlit_config.features.spontaneous_file_upload is not None:
chainlit_config.features.spontaneous_file_upload.enabled = (
_want_chainlit_spontaneous_file_upload(
effective_auth, ingestion_uploads_enabled=ingestion_uploads_enabled,
)
)
configure_chainlit_allowed_origins(embed_settings, chainlit_config)
import chainlit.server as chainlit_server

Expand Down
8 changes: 8 additions & 0 deletions src/gpt_rag_ui/clients/ingestion_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@

import httpx

from gpt_rag_ui.config.appconfig import (
ATTACHMENTS_UNAVAILABLE_MESSAGE,
load_ingestion_uploads_enabled,
)
from gpt_rag_ui.config.dependencies import get_config
from gpt_rag_ui.clients.orchestrator_client import (
_format_outgoing_request_debug,
_get_config_value,
Expand Down Expand Up @@ -189,6 +194,9 @@ async def _build_ingest_documents_payload(conversation_id: str, question_id: str


async def ingest_files_session(conversation_id: str, question_id: str, auth_info: dict, files: list[dict]) -> bool:
if not load_ingestion_uploads_enabled(get_config()):
raise RuntimeError(ATTACHMENTS_UNAVAILABLE_MESSAGE)

url, target_context = _build_ingestion_url()
headers = {"Content-Type": "application/json"}

Expand Down
25 changes: 24 additions & 1 deletion src/gpt_rag_ui/config/appconfig.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import os
import logging

from typing import Dict, Any
from typing import Dict, Any, Protocol
from azure.identity import ChainedTokenCredential, ManagedIdentityCredential, AzureCliCredential
from azure.identity.aio import ChainedTokenCredential as AsyncChainedTokenCredential, ManagedIdentityCredential as AsyncManagedIdentityCredential, AzureCliCredential as AsyncAzureCliCredential
from azure.appconfiguration import AzureAppConfigurationClient
Expand All @@ -20,6 +20,29 @@
APP_LABEL = 'agent-app-ui'
LEGACY_APP_LABEL = 'gpt-rag-ui'

ATTACHMENTS_UNAVAILABLE_MESSAGE = "Attachments are unavailable in this deployment."


class IngestionUploadConfig(Protocol):
def get(self, key: str, default: str, type: type) -> str: ...


def load_ingestion_uploads_enabled(config: IngestionUploadConfig) -> bool:
"""Read the independent ingestion capability without changing provider precedence.

Missing preserves legacy upload eligibility; a present malformed value is
an operator error, never an implicit opt-in.
"""
value = config.get("DEPLOY_INGESTION_CONTAINER_APP", "true", str).strip().lower()
if value in {"true", "1", "yes", "y", "on"}:
return True
if value in {"false", "0", "no", "n", "off"}:
return False
raise ConfigurationError(
"DEPLOY_INGESTION_CONTAINER_APP must be a boolean (true or false). "
"Set false for deployments without the ingestion service."
)


def candidate_keys(key: str) -> list[str]:
"""Return the lookup keys for ``key``; only the exact key name is read."""
Expand Down
17 changes: 15 additions & 2 deletions src/gpt_rag_ui/services/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@
replace_source_reference_links,
)
from gpt_rag_ui.config.dependencies import get_config
from gpt_rag_ui.config.appconfig import (
ATTACHMENTS_UNAVAILABLE_MESSAGE,
load_ingestion_uploads_enabled,
)
from gpt_rag_ui.clients.blob import BlobClient
from gpt_rag_ui.services.conversation_security import get_owned_conversation
from gpt_rag_ui.clients.ingestion_client import ingest_files_session
Expand Down Expand Up @@ -526,12 +530,18 @@ def _trim_for_log(value: str, limit: int = 400) -> str:
rejected: list[str] = []
total_declared_bytes = 0
file_reply_parts: list[str] = []
ingestion_uploads_enabled = True

if message.elements:
ingestion_uploads_enabled = load_ingestion_uploads_enabled(config)
for element in message.elements:
if not isinstance(element, cl.File):
continue

if not ingestion_uploads_enabled:
rejected.append(ATTACHMENTS_UNAVAILABLE_MESSAGE)
continue

mime = (getattr(element, "mime", "") or "").lower()
name = getattr(element, "name", "upload")
path = getattr(element, "path", None)
Expand Down Expand Up @@ -661,7 +671,10 @@ def _trim_for_log(value: str, limit: int = 400) -> str:
return

user_ask = (message.content or "").strip()
if CHAT_BACKEND == "hosted_agent" and not user_ask:
if not user_ask and (
CHAT_BACKEND == "hosted_agent"
or (rejected and not ingestion_uploads_enabled)
):
final_text = "".join(file_reply_parts).strip()
if not final_text:
final_text = "Enter a question to start or continue the hosted conversation."
Expand All @@ -670,7 +683,7 @@ def _trim_for_log(value: str, limit: int = 400) -> str:
response_msg.content = final_text
await response_msg.update()
logger.info(
"Skipping hosted agent (empty ask): conversation=%s question_id=%s",
"Skipping chat backend (empty ask): conversation=%s question_id=%s",
hosted_conversation_id or "new",
message.id,
)
Expand Down
Loading
Loading