From 1bf0567fc20f9cb9032af6cbfa1fac3d6989a702 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Thu, 17 Sep 2026 16:30:51 -0400 Subject: [PATCH] Stabilize Analyze planning recovery, downloads and responsive chat Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- application/single_app/app.py | 1 + application/single_app/config.py | 2 +- .../functions_orchestration_planner.py | 26 +- .../single_app/route_enhanced_citations.py | 68 ++- .../static/js/chat/chat-enhanced-citations.js | 4 +- .../static/js/chat/chat-messages.js | 32 +- .../v2_ui/src/components/chat/Composer.tsx | 7 +- .../components/chat/ConversationDrawer.tsx | 6 +- .../components/chat/GeneratedArtifactCard.tsx | 30 +- .../v2_ui/src/components/layout/AppShell.tsx | 45 +- .../v2_ui/src/components/layout/Sidebar.tsx | 55 ++- application/v2_ui/src/lib/endpoints.ts | 28 +- .../v2_ui/src/lib/orchestrationController.ts | 80 +++- application/v2_ui/src/pages/ChatPage.tsx | 2 +- application/v2_ui/src/stores/chatStore.ts | 21 +- docs/explanation/features/ANALYZE_RESULTS.md | 15 + .../fixes/ANALYZE_STABILIZATION_FIX.md | 149 ++++++ docs/guides/analyze-results.md | 24 +- .../review-and-edit-orchestration-plans.md | 21 +- docs/reference/chat-controls.md | 14 +- .../test_analyze_three_document_smoke.py | 149 ++++++ .../test_chat_artifact_download_bytes.py | 257 +++++++++++ ...chestration_planner_failure_diagnostics.py | 150 +++++++ .../test_v2_artifact_download_cors.py | 60 +++ .../fixtures/orchestration/harness_entry.tsx | 8 + ui_tests/test_chat_saved_analysis.py | 101 ++++- ui_tests/test_v2_chat_context_selection.py | 15 +- ui_tests/test_v2_orchestration_elicitation.py | 3 +- ui_tests/test_v2_orchestration_plan_editor.py | 13 +- ...st_v2_orchestration_plan_editor_backend.py | 10 +- .../test_v2_orchestration_planning_retry.py | 423 ++++++++++++++++++ 31 files changed, 1707 insertions(+), 112 deletions(-) create mode 100644 docs/explanation/fixes/ANALYZE_STABILIZATION_FIX.md create mode 100644 functional_tests/test_analyze_three_document_smoke.py create mode 100644 functional_tests/test_chat_artifact_download_bytes.py create mode 100644 functional_tests/test_orchestration_planner_failure_diagnostics.py create mode 100644 functional_tests/test_v2_artifact_download_cors.py create mode 100644 ui_tests/test_v2_orchestration_planning_retry.py diff --git a/application/single_app/app.py b/application/single_app/app.py index afd17ffba..9a047084c 100644 --- a/application/single_app/app.py +++ b/application/single_app/app.py @@ -1110,6 +1110,7 @@ def add_security_headers(response): if request_origin and request_origin in V2_UI_ALLOWED_ORIGINS: response.headers['Access-Control-Allow-Origin'] = request_origin response.headers['Access-Control-Allow-Credentials'] = 'true' + response.headers['Access-Control-Expose-Headers'] = 'Content-Disposition' response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Accept' response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, PATCH, DELETE, OPTIONS' response.headers['Access-Control-Max-Age'] = '600' diff --git a/application/single_app/config.py b/application/single_app/config.py index 4cf1558e2..7bd234aaa 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -97,7 +97,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.112" +VERSION = "0.261.113" IS_DEVELOPMENT = is_development_env_enabled() SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax') diff --git a/application/single_app/functions_orchestration_planner.py b/application/single_app/functions_orchestration_planner.py index bad0cac27..89c211fa2 100644 --- a/application/single_app/functions_orchestration_planner.py +++ b/application/single_app/functions_orchestration_planner.py @@ -22,7 +22,7 @@ tries several strategies before giving up. A failed model call or invalid plan is an error, not evidence that the task can be answered without gathering information. -Version: 0.261.104 +Version: 0.261.113 """ import json @@ -768,10 +768,15 @@ def plan_request( ] actions = context.get('actions') or [] - def _failure(reason): + def _failure(reason, error=None, *, stage=None): log_event( '[ORCHESTRATION_PLANNER] The request could not be planned.', - level=logging.WARNING, extra={'reason': reason}, + level=logging.WARNING, extra={ + 'reason': reason, 'stage': stage, + 'conversation_id': conversation_id, 'turn_id': turn_id, 'revision': revision, + 'error_type': type(error).__name__ if error is not None else None, + 'response_failure': error.reason if isinstance(error, PlannerResponseError) else None, + }, ) raise PlannerError( 'The requested change could not be planned. Your previous plan is unchanged.' @@ -805,8 +810,8 @@ def _failure(reason): client, deployment = planner_model.as_planner_client(), planner_model.deployment else: client, deployment = resolve_planner_client(settings) - except (PlannerError, APIError, AzureError, ValueError): - return _failure('model_configuration_failed') + except (PlannerError, APIError, AzureError, ValueError) as exc: + return _failure('model_configuration_failed', exc, stage='model_binding') try: reply, usage = _call_planner( @@ -815,8 +820,8 @@ def _failure(reason): ), require_complete_response=True, ) - except (PlannerError, APIError, AzureError): - return _failure('model_request_failed') + except (PlannerError, APIError, AzureError) as exc: + return _failure('model_request_failed', exc, stage='model_request') parsed = extract_planner_json(reply) if not parsed: @@ -927,9 +932,12 @@ def _failure(reason): agent_names=agent_names, actions=actions, ) + except PlanValidationError as exc: + return _failure('invalid_plan_or_missing_requirement', exc, stage='plan_normalization') + try: validate_plan_requirements(plan, seeds, allow_changes=edit_context is not None) - except PlanValidationError: - return _failure('invalid_plan_or_missing_requirement') + except PlanValidationError as exc: + return _failure('invalid_plan_or_missing_requirement', exc, stage='selected_requirements') if plan.get('validation', {}).get('errors'): return _failure('invalid_plan_work') diff --git a/application/single_app/route_enhanced_citations.py b/application/single_app/route_enhanced_citations.py index fe01b6abd..db3ab47bd 100644 --- a/application/single_app/route_enhanced_citations.py +++ b/application/single_app/route_enhanced_citations.py @@ -3,6 +3,7 @@ from flask import jsonify, request, Response from datetime import datetime, timedelta, timezone +import hashlib import logging import os import tempfile @@ -14,6 +15,7 @@ import pandas import fitz from azure.cosmos.exceptions import CosmosResourceNotFoundError +from azure.core.exceptions import AzureError, ResourceNotFoundError from werkzeug.utils import secure_filename from functions_authentication import login_required, user_required, get_current_user_id, get_current_user_info @@ -29,6 +31,7 @@ from functions_saved_analysis import authorize_analysis_artifact from functions_simplechat_operations import ( assert_generated_chat_artifact_is_published_for_user, + download_blob_content, ) from swagger_wrapper import swagger_route, get_auth_security from config import CLIENTS, storage_account_user_documents_container_name, storage_account_group_documents_container_name, storage_account_public_documents_container_name, storage_account_personal_chat_container_name, IMAGE_EXTENSIONS, VIDEO_EXTENSIONS, AUDIO_EXTENSIONS, TABULAR_EXTENSIONS, VISIO_EXTENSIONS, cosmos_messages_container, cosmos_conversations_container @@ -194,6 +197,33 @@ def _build_content_disposition(disposition, file_name, fallback='download'): return f'{normalized_disposition}; filename="{ascii_file_name}"; filename*=UTF-8\'\'{encoded_file_name}' +def _serve_chat_artifact_download(user_id, conversation_id, message_id): + """Read an authorized chat artifact, not a workspace document's representation.""" + artifact = _get_authorized_chat_artifact_message(user_id, conversation_id, message_id) + content = download_blob_content(artifact['blob_container'], artifact['blob_path']) + current = _get_authorized_chat_artifact_message(user_id, conversation_id, message_id) + identity_fields = ('id', 'conversation_id', 'blob_container', 'blob_path', 'filename', '_etag') + digest = (artifact.get('metadata') or {}).get('generated_artifact_content_sha256') + current_digest = (current.get('metadata') or {}).get('generated_artifact_content_sha256') + if any(artifact.get(field) != current.get(field) for field in identity_fields) or digest != current_digest: + raise LookupError('The artifact changed during download.') + if digest and hashlib.sha256(content).hexdigest() != digest: + raise LookupError('The artifact content changed.') + + file_name = _resolve_generated_artifact_file_name(current) + content_type = { + '.csv': 'text/csv; charset=utf-8', + '.md': 'text/markdown; charset=utf-8', + '.json': 'application/json', + }.get(os.path.splitext(file_name)[1].lower()) or mimetypes.guess_type(file_name)[0] or 'application/octet-stream' + return Response(content, content_type=content_type, headers={ + 'Content-Length': str(len(content)), + 'Content-Disposition': _build_content_disposition('attachment', file_name), + 'Cache-Control': 'private, no-store', + 'X-Content-Type-Options': 'nosniff', + }) + + def _log_enhanced_citations_debug(message, **details): """Write debug-gated enhanced citations diagnostics.""" log_event( @@ -613,25 +643,27 @@ def download_chat_artifact(): return jsonify({"error": "User not authenticated"}), 401 try: - message_item = _get_authorized_chat_artifact_message(user_id, conversation_id, message_id) - return serve_enhanced_citation_content( - { - 'file_name': _resolve_generated_artifact_file_name(message_item), - 'blob_container': message_item.get('blob_container'), - 'blob_path': message_item.get('blob_path'), - }, - force_download=True, + return _serve_chat_artifact_download(user_id, conversation_id, message_id) + except PermissionError: + return jsonify({"error": "You no longer have access to this artifact or its sources."}), 403 + except (LookupError, ResourceNotFoundError): + return jsonify({"error": "The artifact content is unavailable. Refresh the conversation and try again."}), 404 + except ValueError: + return jsonify({"error": "Invalid artifact download request."}), 400 + except (AzureError, RuntimeError) as exc: + log_event( + "[ENHANCED_CITATIONS] Chat artifact storage is unavailable.", + extra={"error_type": type(exc).__name__, "conversation_id": conversation_id, "message_id": message_id}, + level=logging.ERROR, ) - except PermissionError as exc: - debug_print(f"Forbidden chat artifact download attempt: {exc}") - return jsonify({"error": "Forbidden"}), 403 - except LookupError as exc: - return jsonify({"error": str(exc)}), 404 - except ValueError as exc: - return jsonify({"error": str(exc)}), 400 - except Exception as e: - debug_print(f"Error serving chat artifact download: {e}") - return jsonify({"error": "An internal error has occurred"}), 500 + return jsonify({"error": "Artifact storage is temporarily unavailable. Please retry the download."}), 503 + except Exception as exc: + log_event( + "[ENHANCED_CITATIONS] Chat artifact download failed.", + extra={"error_type": type(exc).__name__, "conversation_id": conversation_id, "message_id": message_id}, + level=logging.ERROR, + ) + return jsonify({"error": "The artifact could not be downloaded. Please retry."}), 500 @bp.route("/api/chat_artifacts/promote", methods=["POST"]) @swagger_route(security=get_auth_security()) diff --git a/application/single_app/static/js/chat/chat-enhanced-citations.js b/application/single_app/static/js/chat/chat-enhanced-citations.js index 6f02bb071..beab46407 100644 --- a/application/single_app/static/js/chat/chat-enhanced-citations.js +++ b/application/single_app/static/js/chat/chat-enhanced-citations.js @@ -518,7 +518,7 @@ export function showAudioModal(docId, timestamp, fileName) { modalInstance.show(); } -function triggerBlobDownload(blob, filename) { +export function triggerBlobDownload(blob, filename) { const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; @@ -529,7 +529,7 @@ function triggerBlobDownload(blob, filename) { window.setTimeout(() => URL.revokeObjectURL(url), 0); } -function getDownloadFilename(response, fallbackFilename) { +export function getDownloadFilename(response, fallbackFilename) { const contentDisposition = response.headers.get('Content-Disposition') || ''; const utf8Match = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i); if (utf8Match && utf8Match[1]) { diff --git a/application/single_app/static/js/chat/chat-messages.js b/application/single_app/static/js/chat/chat-messages.js index 57ac345e6..6b8592602 100644 --- a/application/single_app/static/js/chat/chat-messages.js +++ b/application/single_app/static/js/chat/chat-messages.js @@ -17,6 +17,7 @@ import { updateSidebarConversationTitle } from "./chat-sidebar-conversations.js" import { getActiveConversationContext, getActiveConversationScope } from "./chat-conversation-scope.js"; import { escapeHtml, isColorLight, addTargetBlankToExternalLinks, sanitizeHttpUrl } from "./chat-utils.js"; import { showToast } from "./chat-toast.js"; +import { getDownloadFilename, triggerBlobDownload } from "./chat-enhanced-citations.js"; import { buildGeneratedFileApprovalBlock, generatedFileApprovalBlocksDownload, @@ -4441,7 +4442,7 @@ function renderReplyQuoteHtml(fullMessageObject = null) { return ''; } - function triggerGeneratedTabularOutputDownload(outputMetadata) { + async function triggerGeneratedTabularOutputDownload(outputMetadata, downloadButton) { const downloadHref = buildGeneratedArtifactDownloadUrl(outputMetadata); if (!downloadHref) { @@ -4449,13 +4450,26 @@ function renderReplyQuoteHtml(fullMessageObject = null) { return; } - const downloadLink = document.createElement('a'); - downloadLink.href = downloadHref; - downloadLink.rel = 'noopener'; - downloadLink.className = 'd-none'; - document.body.appendChild(downloadLink); - downloadLink.click(); - downloadLink.remove(); + if (downloadButton.disabled) { + return; + } + const originalText = downloadButton.textContent; + downloadButton.disabled = true; + downloadButton.textContent = 'Downloading...'; + try { + const response = await fetch(downloadHref, { credentials: 'same-origin', cache: 'no-store' }); + const disposition = response.headers.get('Content-Disposition') || ''; + if (!response.ok || response.redirected || !/^\s*attachment(?:;|$)/i.test(disposition)) { + throw new Error('The artifact could not be downloaded.'); + } + const blob = await response.blob(); + triggerBlobDownload(blob, getDownloadFilename(response, outputMetadata.file_name || 'generated-artifact')); + } catch { + showToast('The artifact could not be downloaded. Refresh the conversation and try again.', 'danger'); + } finally { + downloadButton.disabled = false; + downloadButton.textContent = originalText; + } } async function viewGeneratedMarkdownArtifact(outputMetadata, viewButton) { @@ -5579,7 +5593,7 @@ function renderReplyQuoteHtml(fullMessageObject = null) { memberCount: 1, formats: [outputFormat], }); - triggerGeneratedTabularOutputDownload(outputMetadata); + void triggerGeneratedTabularOutputDownload(outputMetadata, downloadButton); }); actions.appendChild(downloadButton); diff --git a/application/v2_ui/src/components/chat/Composer.tsx b/application/v2_ui/src/components/chat/Composer.tsx index 73ec89cb3..4c17683bd 100644 --- a/application/v2_ui/src/components/chat/Composer.tsx +++ b/application/v2_ui/src/components/chat/Composer.tsx @@ -881,7 +881,8 @@ export function Composer({ initialAgentSelection }: { initialAgentSelection?: st const seeds: Record = { web_search_enabled: options.webSearch, required_capabilities: [ - ...(options.documentSearch || contextItems.length > 0 ? ['document_search'] : []), + // Pinned sources constrain inputs, not the operation (search, Analyze, or Compare). + ...(options.documentSearch ? ['document_search'] : []), ...(options.webSearch ? ['web_search'] : []), ...(options.deepResearch ? ['deep_research'] : []), ...(options.urlAccess && promptUrls(message).length > 0 ? ['url_fetch'] : []), @@ -991,10 +992,6 @@ export function Composer({ initialAgentSelection }: { initialAgentSelection?: st return; } handoffApplied.current = true; - setOptions((current) => ({ - ...current, - documentSearch: items.length > 0 || current.documentSearch, - })); setDraft((current) => ({ ...current, contextItems: items.reduce( diff --git a/application/v2_ui/src/components/chat/ConversationDrawer.tsx b/application/v2_ui/src/components/chat/ConversationDrawer.tsx index 2706b76db..b29385743 100644 --- a/application/v2_ui/src/components/chat/ConversationDrawer.tsx +++ b/application/v2_ui/src/components/chat/ConversationDrawer.tsx @@ -261,13 +261,13 @@ export function ConversationDrawer() { return (