diff --git a/application/single_app/route_backend_conversation_export.py b/application/single_app/route_backend_conversation_export.py index 086019f1e..19b580563 100644 --- a/application/single_app/route_backend_conversation_export.py +++ b/application/single_app/route_backend_conversation_export.py @@ -9,7 +9,7 @@ from collections import Counter, defaultdict from datetime import datetime from html import escape as _escape_html -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple from bs4 import BeautifulSoup, NavigableString, Tag from config import * @@ -56,6 +56,9 @@ def api_export_conversations(): packaging (str): Output packaging — "single" or "zip". include_summary_intro (bool): Whether to generate a per-conversation intro. summary_model_deployment (str): Optional model deployment for summary generation. + summary_model_id (str): Optional multi-endpoint model id for summary generation. + summary_model_endpoint_id (str): Optional multi-endpoint endpoint id for summary generation. + summary_model_provider (str): Optional provider (aoai/aifoundry) for summary generation. """ user_id = get_current_user_id() if not user_id: @@ -70,6 +73,9 @@ def api_export_conversations(): packaging = str(data.get('packaging', 'single')).lower() include_summary_intro = bool(data.get('include_summary_intro', False)) summary_model_deployment = str(data.get('summary_model_deployment', '') or '').strip() + summary_model_id = str(data.get('summary_model_id', '') or '').strip() + summary_model_endpoint_id = str(data.get('summary_model_endpoint_id', '') or '').strip() + summary_model_provider = str(data.get('summary_model_provider', '') or '').strip() if not conversation_ids or not isinstance(conversation_ids, list): return jsonify({'error': 'At least one conversation_id is required'}), 400 @@ -115,7 +121,10 @@ def api_export_conversations(): user_id=user_id, settings=settings, include_summary_intro=include_summary_intro, - summary_model_deployment=summary_model_deployment + summary_model_deployment=summary_model_deployment, + summary_model_id=summary_model_id, + summary_model_endpoint_id=summary_model_endpoint_id, + summary_model_provider=summary_model_provider ) ) @@ -247,7 +256,10 @@ def _build_export_entry( user_id: str, settings: Dict[str, Any], include_summary_intro: bool = False, - summary_model_deployment: str = '' + summary_model_deployment: str = '', + summary_model_id: str = '', + summary_model_endpoint_id: str = '', + summary_model_provider: str = '', ) -> Dict[str, Any]: artifact_payload_map = build_message_artifact_payload_map(raw_messages) filtered_messages = _filter_messages_for_export(raw_messages) @@ -309,6 +321,9 @@ def _build_export_entry( settings=settings, enabled=include_summary_intro, summary_model_deployment=summary_model_deployment, + summary_model_id=summary_model_id, + summary_model_endpoint_id=summary_model_endpoint_id, + summary_model_provider=summary_model_provider, message_time_start=message_time_start, message_time_end=message_time_end ) @@ -660,7 +675,12 @@ def generate_conversation_summary( model_deployment: str, message_time_start: str = None, message_time_end: str = None, - conversation_id: str = None + conversation_id: str = None, + model_id: Optional[str] = None, + model_endpoint_id: Optional[str] = None, + model_provider: Optional[str] = None, + user_id: Optional[str] = None, + active_group_ids: Optional[List[str]] = None, ) -> Dict[str, Any]: """Generate a conversation summary using the LLM and optionally persist it. @@ -686,7 +706,25 @@ def generate_conversation_summary( transcript_text = _truncate_for_summary(transcript_text) - gpt_client, gpt_model = _initialize_gpt_client(settings, model_deployment) + # Prefer multi-endpoint model resolution so summaries use the same + # AOAI endpoint/deployment as chat for a given model selection. + gpt_client: AzureOpenAI + gpt_model: str + + multi_endpoint_client = _initialize_summary_multi_endpoint_client( + settings, + model_deployment=model_deployment, + model_id=model_id, + model_endpoint_id=model_endpoint_id, + model_provider=model_provider, + user_id=user_id, + active_group_ids=active_group_ids or [], + ) + + if multi_endpoint_client is not None: + gpt_client, gpt_model = multi_endpoint_client + else: + gpt_client, gpt_model = _initialize_gpt_client(settings, model_deployment) summary_prompt = ( "You are summarizing a conversation for an export document. " "Read the full conversation below and write a concise summary. " @@ -761,6 +799,9 @@ def _build_summary_intro( settings: Dict[str, Any], enabled: bool, summary_model_deployment: str, + summary_model_id: str = '', + summary_model_endpoint_id: str = '', + summary_model_provider: str = '', message_time_start: str = None, message_time_end: str = None ) -> Dict[str, Any]: @@ -809,7 +850,12 @@ def _build_summary_intro( model_deployment=summary_model_deployment, message_time_start=message_time_start, message_time_end=message_time_end, - conversation_id=conversation_id + conversation_id=conversation_id, + model_id=summary_model_id or None, + model_endpoint_id=summary_model_endpoint_id or None, + model_provider=summary_model_provider or None, + user_id=conversation.get('user_id'), + active_group_ids=[], ) summary_intro.update({ @@ -848,6 +894,55 @@ def _truncate_for_summary(transcript_text: str) -> str: ) +def _initialize_summary_multi_endpoint_client( + settings: Dict[str, Any], + model_deployment: str, + model_id: Optional[str] = None, + model_endpoint_id: Optional[str] = None, + model_provider: Optional[str] = None, + user_id: Optional[str] = None, + active_group_ids: Optional[List[str]] = None, +) -> Optional[Tuple[AzureOpenAI, str]]: + """Initialize a GPT client for summaries using the multi-endpoint model config. + + This mirrors the streaming model resolution path so that summaries use the + exact same AOAI endpoint/deployment as chat for a given model selection. + Returns (client, deployment) when a multi-endpoint config can be resolved, + otherwise None to allow fallback to the legacy single-endpoint settings. + """ + try: + from route_backend_chats import resolve_streaming_multi_endpoint_gpt_config + except Exception: + return None + + if not settings.get('enable_multi_model_endpoints', False): + return None + + request_payload = { + 'model_deployment': model_deployment or '', + 'model_id': model_id or '', + 'model_endpoint_id': model_endpoint_id or '', + 'model_provider': model_provider or '', + } + + try: + resolved = resolve_streaming_multi_endpoint_gpt_config( + settings, + request_payload, + user_id or '', + active_group_ids=active_group_ids or [], + allow_default_selection=False, + ) + except Exception: + return None + + if not resolved: + return None + + gpt_client, deployment, _provider, _endpoint, _auth_settings, _api_version = resolved + return gpt_client, deployment + + def _initialize_gpt_client(settings: Dict[str, Any], requested_model: str = ''): enable_gpt_apim = settings.get('enable_gpt_apim', False) diff --git a/application/single_app/route_backend_conversations.py b/application/single_app/route_backend_conversations.py index a99e7c54b..bd64bf357 100644 --- a/application/single_app/route_backend_conversations.py +++ b/application/single_app/route_backend_conversations.py @@ -1000,6 +1000,9 @@ def generate_conversation_summary_api(conversation_id): body = request.get_json(silent=True) or {} model_deployment = body.get('model_deployment', '') + model_id = body.get('model_id') or None + model_endpoint_id = body.get('model_endpoint_id') or None + model_provider = body.get('model_provider') or None # Query messages for this conversation try: @@ -1045,7 +1048,17 @@ def generate_conversation_summary_api(conversation_id): model_deployment=model_deployment, message_time_start=message_time_start, message_time_end=message_time_end, - conversation_id=conversation_id + conversation_id=conversation_id, + model_id=model_id, + model_endpoint_id=model_endpoint_id, + model_provider=model_provider, + user_id=user_id, + # For now, summaries use the same group context that + # was already captured in conversation metadata. The + # generate_conversation_summary helper accepts + # active_group_ids but does not require it, so we pass + # None here to avoid referencing undefined variables. + active_group_ids=None, ) return jsonify({'success': True, 'summary': summary_data}), 200 diff --git a/application/single_app/static/js/chat/chat-conversation-details.js b/application/single_app/static/js/chat/chat-conversation-details.js index 4bba53891..c3c5eac9f 100644 --- a/application/single_app/static/js/chat/chat-conversation-details.js +++ b/application/single_app/static/js/chat/chat-conversation-details.js @@ -673,19 +673,43 @@ function getAvailableModelOptions() { if (!globalSelect) { return ''; } - let options = ''; + + const tempSelect = document.createElement('select'); + for (const opt of globalSelect.options) { - options += ``; + const cloned = document.createElement('option'); + cloned.value = opt.value; + cloned.textContent = opt.text; + + if (opt.dataset?.deploymentName) { + cloned.dataset.deploymentName = opt.dataset.deploymentName; + } + if (opt.dataset?.modelId) { + cloned.dataset.modelId = opt.dataset.modelId; + } + if (opt.dataset?.endpointId) { + cloned.dataset.endpointId = opt.dataset.endpointId; + } + if (opt.dataset?.provider) { + cloned.dataset.provider = opt.dataset.provider; + } + if (opt.selected) { + cloned.selected = true; + } + + tempSelect.appendChild(cloned); } - return options || ''; + + const html = tempSelect.innerHTML; + return html || ''; } /** * Handle summary generation (generate or regenerate) * @param {string} conversationId - The conversation ID - * @param {string} modelDeployment - Selected model deployment + * @param {Object} modelPayload - Selected model metadata */ -async function handleGenerateSummary(conversationId, modelDeployment) { +async function handleGenerateSummary(conversationId, modelPayload) { const cardBody = document.getElementById('summary-card-body'); if (!cardBody) { return; @@ -704,7 +728,7 @@ async function handleGenerateSummary(conversationId, modelDeployment) { const response = await fetch(`/api/conversations/${conversationId}/summary`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ model_deployment: modelDeployment }) + body: JSON.stringify(modelPayload || {}) }); if (!response.ok) { @@ -766,8 +790,18 @@ document.addEventListener('click', function(e) { const cid = btn.getAttribute('data-conversation-id'); const modelSelect = document.getElementById('summary-model-select'); const selectedOption = modelSelect ? modelSelect.options[modelSelect.selectedIndex] : null; - const model = selectedOption?.dataset?.deploymentName || (modelSelect ? modelSelect.value : ''); - handleGenerateSummary(cid, model); + + const modelDeployment = selectedOption?.dataset?.deploymentName || (modelSelect ? modelSelect.value : ''); + const modelId = selectedOption?.dataset?.modelId || ''; + const modelEndpointId = selectedOption?.dataset?.endpointId || ''; + const modelProvider = selectedOption?.dataset?.provider || ''; + + handleGenerateSummary(cid, { + model_deployment: modelDeployment, + model_id: modelId, + model_endpoint_id: modelEndpointId, + model_provider: modelProvider + }); return; } @@ -779,8 +813,17 @@ document.addEventListener('click', function(e) { // Use the currently selected global model for regeneration const globalSelect = document.getElementById('model-select'); const selectedOption = globalSelect ? globalSelect.options[globalSelect.selectedIndex] : null; - const model = selectedOption?.dataset?.deploymentName || (globalSelect ? globalSelect.value : ''); - handleGenerateSummary(cid, model); + const modelDeployment = selectedOption?.dataset?.deploymentName || (globalSelect ? globalSelect.value : ''); + const modelId = selectedOption?.dataset?.modelId || ''; + const modelEndpointId = selectedOption?.dataset?.endpointId || ''; + const modelProvider = selectedOption?.dataset?.provider || ''; + + handleGenerateSummary(cid, { + model_deployment: modelDeployment, + model_id: modelId, + model_endpoint_id: modelEndpointId, + model_provider: modelProvider + }); return; } diff --git a/application/single_app/static/js/chat/chat-export.js b/application/single_app/static/js/chat/chat-export.js index f42e0123f..66989d861 100644 --- a/application/single_app/static/js/chat/chat-export.js +++ b/application/single_app/static/js/chat/chat-export.js @@ -17,6 +17,9 @@ let exportFormat = 'json'; let exportPackaging = 'single'; let includeSummaryIntro = false; let summaryModelDeployment = ''; +let summaryModelId = ''; +let summaryModelEndpointId = ''; +let summaryModelProvider = ''; let currentStep = 1; let totalSteps = 3; let skipSelectionStep = false; @@ -57,6 +60,10 @@ function openExportWizard(conversationIds, skipSelection) { exportPackaging = conversationIds.length > 1 ? 'zip' : 'single'; includeSummaryIntro = false; summaryModelDeployment = _getDefaultSummaryModel(); + const defaultModelMeta = _getDefaultSummaryModelMetadata(); + summaryModelId = defaultModelMeta.modelId; + summaryModelEndpointId = defaultModelMeta.endpointId; + summaryModelProvider = defaultModelMeta.provider; skipSelectionStep = !!skipSelection; // Determine step configuration @@ -345,9 +352,17 @@ function _renderSummaryStep(container) { if (summaryModelSelect && hasModelOptions) { summaryModelSelect.value = defaultSummaryModel || summaryModelSelect.value; - summaryModelDeployment = summaryModelSelect.options[summaryModelSelect.selectedIndex]?.dataset?.deploymentName || summaryModelSelect.value; + const selected = summaryModelSelect.options[summaryModelSelect.selectedIndex]; + summaryModelDeployment = selected?.dataset?.deploymentName || summaryModelSelect.value; + summaryModelId = selected?.dataset?.modelId || ''; + summaryModelEndpointId = selected?.dataset?.endpointId || ''; + summaryModelProvider = selected?.dataset?.provider || ''; summaryModelSelect.addEventListener('change', () => { - summaryModelDeployment = summaryModelSelect.options[summaryModelSelect.selectedIndex]?.dataset?.deploymentName || summaryModelSelect.value; + const opt = summaryModelSelect.options[summaryModelSelect.selectedIndex]; + summaryModelDeployment = opt?.dataset?.deploymentName || summaryModelSelect.value; + summaryModelId = opt?.dataset?.modelId || ''; + summaryModelEndpointId = opt?.dataset?.endpointId || ''; + summaryModelProvider = opt?.dataset?.provider || ''; }); } @@ -359,7 +374,11 @@ function _renderSummaryStep(container) { } if (includeSummaryIntro && summaryModelSelect && !summaryModelSelect.value) { summaryModelSelect.value = _getDefaultSummaryModel(); - summaryModelDeployment = summaryModelSelect.options[summaryModelSelect.selectedIndex]?.dataset?.deploymentName || summaryModelSelect.value; + const opt = summaryModelSelect.options[summaryModelSelect.selectedIndex]; + summaryModelDeployment = opt?.dataset?.deploymentName || summaryModelSelect.value; + summaryModelId = opt?.dataset?.modelId || ''; + summaryModelEndpointId = opt?.dataset?.endpointId || ''; + summaryModelProvider = opt?.dataset?.provider || ''; } }); } @@ -532,7 +551,10 @@ async function _executeExport() { format: exportFormat, packaging: exportPackaging, include_summary_intro: includeSummaryIntro, - summary_model_deployment: includeSummaryIntro ? summaryModelDeployment : null + summary_model_deployment: includeSummaryIntro ? summaryModelDeployment : null, + summary_model_id: includeSummaryIntro ? summaryModelId : null, + summary_model_endpoint_id: includeSummaryIntro ? summaryModelEndpointId : null, + summary_model_provider: includeSummaryIntro ? summaryModelProvider : null }) }); @@ -605,6 +627,20 @@ function _getDefaultSummaryModel() { return mainModelSelect.value || (mainModelSelect.options[0] ? mainModelSelect.options[0].value : ''); } +function _getDefaultSummaryModelMetadata() { + const mainModelSelect = getEl('model-select'); + if (!mainModelSelect || !mainModelSelect.options.length) { + return { modelId: '', endpointId: '', provider: '' }; + } + + const selected = mainModelSelect.options[mainModelSelect.selectedIndex] || mainModelSelect.options[0]; + return { + modelId: selected.dataset?.modelId || '', + endpointId: selected.dataset?.endpointId || '', + provider: selected.dataset?.provider || '' + }; +} + // --- Expose Globally --- window.chatExport = { openExportWizard diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index db17633db..5a6834fc3 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -11,6 +11,12 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Ver ## Bug Fixes +* **Conversation Summary and Export Multi-Endpoint Model Alignment** + * Fixed conversation summary generation so the on-demand summary card and conversation export intros now resolve GPT models through the same multi-endpoint configuration used by streaming chat. + * Summary and export flows now send the selected model's deployment, model id, endpoint id, and provider from the chat UI, ensuring 5.1 and other multi-endpoint models use the correct Azure OpenAI or Foundry endpoint instead of falling back to a legacy single-endpoint configuration that could return `DeploymentNotFound`. + * Added shared helpers so both summary and export paths prefer the multi-endpoint resolver and only fall back to the legacy single-endpoint settings when multi-endpoint is disabled, keeping model behavior consistent across chat, summaries, and exports. + * (Ref: `chat-conversation-details.js`, `chat-export.js`, `route_backend_conversations.py`, `route_backend_conversation_export.py`, multi-endpoint model resolution for summaries and exports) + * **Uploaded File Preview Body XSS Hardening** * Fixed the uploaded-file preview modal so stored file bodies no longer reach the preview pane through raw HTML sinks. * Plain-text previews now render as inert preformatted text, CSV-backed previews are built with DOM text nodes, and legacy HTML-backed table payloads now fall back to inert text instead of live markup.