[A-Za-z_][A-Za-z0-9_.:-]*)(?:\s[^<>]*)?>')
+DOCX_OUTPUT_REQUEST_PATTERNS = (
+ re.compile(
+ r'\b(?:build|create|download|export|generate|make|prepare|save|turn|convert)\b'
+ r'.{0,120}\b(?:a\s+)?(?:word|docx)(?:\s+(?:document|file|output|report))?\b'
+ ),
+ re.compile(r'\b(?:word|docx)\s+(?:document|file|output|report|version)\b'),
+ re.compile(r'\b(?:get|give)\s+(?:me\s+)?(?:a|the|one)\s+(?:word|docx)\b'),
+ re.compile(r'\b(?:need|want)\s+(?:(?:a|the|one)\s+)?(?:word|docx)\b'),
+ re.compile(r'\b(?:in|as)\s+(?:a\s+)?(?:word|docx)(?:\s+(?:document|file|report))?\b'),
+)
+PDF_OUTPUT_REQUEST_PATTERNS = (
+ re.compile(
+ r'\b(?:build|create|download|export|generate|make|prepare|save|turn|convert)\b'
+ r'.{0,120}\b(?:a\s+)?pdf(?:\s+(?:document|file|output|report))?\b'
+ ),
+ re.compile(r'\bpdf\s+(?:document|file|output|report|version)\b'),
+ re.compile(r'\b(?:get|give)\s+(?:me\s+)?(?:a|the|one)\s+pdf\b'),
+ re.compile(r'\b(?:need|want)\s+(?:(?:a|the|one)\s+)?pdf\b'),
+ re.compile(r'\b(?:in|as)\s+(?:a\s+)?pdf(?:\s+(?:document|file|report))?\b'),
+)
+PDF_EXPORT_CSS = """
+body { font-family: sans-serif; font-size: 10pt; color: #172033; }
+h1 { font-size: 20pt; color: #173b5f; margin-bottom: 10pt; }
+h2 { font-size: 14pt; color: #173b5f; margin-top: 16pt; }
+p { line-height: 1.35; margin-bottom: 8pt; }
+table { border-collapse: collapse; width: 100%; margin-top: 8pt; }
+th { background-color: #e8eef5; font-weight: bold; }
+th, td { border: 0.6pt solid #aab7c4; padding: 4pt; vertical-align: top; }
+"""
+
+
+def get_requested_generated_file_format(user_question: str) -> Optional[str]:
+ """Return the requested generated file format, if any."""
+ if assistant_table_export_requested(user_question):
+ return GENERATED_FILE_FORMAT_CSV
+
+ normalized_question = re.sub(r'\s+', ' ', str(user_question or '').strip().casefold())
+ if not normalized_question:
+ return None
+ if any(pattern.search(normalized_question) for pattern in DOCX_OUTPUT_REQUEST_PATTERNS):
+ return GENERATED_FILE_FORMAT_DOCX
+ if any(pattern.search(normalized_question) for pattern in PDF_OUTPUT_REQUEST_PATTERNS):
+ return GENERATED_FILE_FORMAT_PDF
+ return None
+
+
+def generated_file_export_requested(user_question: str) -> bool:
+ """Return whether the user asked for a supported generated file artifact."""
+ return get_requested_generated_file_format(user_question) is not None
+
+
+def build_generated_file_output_guidance(user_question: str) -> str:
+ """Return shared model guidance for a requested generated output format."""
+ output_format = get_requested_generated_file_format(user_question)
+ if output_format == GENERATED_FILE_FORMAT_CSV:
+ return build_csv_output_clarification_guidance(user_question)
+ if output_format in {GENERATED_FILE_FORMAT_DOCX, GENERATED_FILE_FORMAT_PDF}:
+ return (
+ f'The user requested a downloadable {output_format.upper()} artifact. Provide a clear final '
+ 'response grounded in the available evidence. Structured function results from this turn may '
+ 'be included as labeled tables in the generated file; do not invent rows or claim an attachment '
+ 'exists before the file-output finalizer publishes it.'
+ )
+ return ''
+
+
+def get_generated_file_export_content(assistant_result: Any) -> str:
+ """Return the structured document-action reply when it supersedes a concise artifact reply."""
+ if not isinstance(assistant_result, dict):
+ return str(assistant_result or '')
+
+ analysis_result = assistant_result.get('analysis_result')
+ if isinstance(analysis_result, dict):
+ analysis_reply = str(analysis_result.get('analysis_reply') or '').strip()
+ if analysis_reply:
+ return analysis_reply
+
+ return str(assistant_result.get('reply') or '')
+
+
+def build_generated_file_export(
+ user_question: str,
+ assistant_content: str,
+ function_results: Optional[List[Dict[str, Any]]] = None,
+) -> Optional[Dict[str, Any]]:
+ """Build a generated file payload from final assistant content and function-result evidence."""
+ output_format = get_requested_generated_file_format(user_question)
+ if output_format not in GENERATED_FILE_FORMATS:
+ return None
+
+ assistant_text = str(assistant_content or '').strip()
+ assistant_rows = extract_assistant_table_entries(assistant_text)
+ function_rows = extract_authorized_function_result_rows(function_results)
+
+ if output_format == GENERATED_FILE_FORMAT_CSV:
+ rows = assistant_rows or function_rows
+ if not rows:
+ return None
+ row_source = 'assistant response' if assistant_rows else 'structured function result'
+ return _build_generated_file_payload(
+ output_format=output_format,
+ file_content=build_assistant_table_csv(rows),
+ rows=rows,
+ row_source=row_source,
+ assistant_content=assistant_text,
+ )
+
+ if not assistant_text and not assistant_rows and not function_rows:
+ return None
+ rows = function_rows or assistant_rows
+ row_source = 'structured function result' if function_rows else 'assistant response'
+ title = _build_generated_file_title(output_format)
+ if output_format == GENERATED_FILE_FORMAT_DOCX:
+ file_content = _render_docx_file_export(title, assistant_text, rows, row_source)
+ else:
+ file_content = _render_pdf_file_export(title, assistant_text, rows, row_source)
+ return _build_generated_file_payload(
+ output_format=output_format,
+ file_content=file_content,
+ rows=rows,
+ row_source=row_source,
+ assistant_content=assistant_text,
+ title=title,
+ )
+
+
+def extract_authorized_function_result_rows(function_results: Optional[List[Dict[str, Any]]]) -> List[Dict[str, Any]]:
+ """Return structured rows from successful current-turn non-tabular function results."""
+ function_row_groups: List[Tuple[str, List[Dict[str, Any]]]] = []
+ for function_result in function_results or []:
+ if not isinstance(function_result, dict):
+ continue
+ if function_result.get('success') is False or _is_tabular_function_result(function_result):
+ continue
+
+ structured_rows = _extract_function_result_rows(
+ _parse_function_result_payload(function_result.get('function_result')),
+ )
+ if not structured_rows:
+ continue
+ function_row_groups.append((
+ _get_function_result_label(function_result),
+ structured_rows,
+ ))
+
+ if not function_row_groups:
+ return []
+ if len(function_row_groups) == 1:
+ return function_row_groups[0][1]
+
+ source_column = _get_function_result_source_column(function_row_groups)
+ combined_rows = []
+ for function_label, rows in function_row_groups:
+ for row in rows:
+ normalized_row = dict(row)
+ normalized_row[source_column] = function_label
+ combined_rows.append(normalized_row)
+ return combined_rows
+
+
+def has_generated_file_output(existing_outputs: Optional[List[Dict[str, Any]]], output_format: str) -> bool:
+ """Return whether an existing generated artifact already covers an output format."""
+ normalized_output_format = str(output_format or '').strip().lower()
+ if not normalized_output_format:
+ return False
+ for output in existing_outputs or []:
+ if not isinstance(output, dict):
+ continue
+ existing_output_format = str(output.get('output_format') or '').strip().lower()
+ existing_file_name = str(output.get('file_name') or '').strip().lower()
+ if existing_output_format == normalized_output_format or existing_file_name.endswith(f'.{normalized_output_format}'):
+ return True
+ return False
+
+
+def build_generated_file_artifact_metadata(
+ export_payload: Dict[str, Any],
+ upload_result: Dict[str, Any],
+ conversation_id: str,
+) -> Optional[Dict[str, Any]]:
+ """Build public artifact metadata after an authorized generated-file upload."""
+ uploaded_message = upload_result.get('message') if isinstance(upload_result, dict) else {}
+ uploaded_message = uploaded_message if isinstance(uploaded_message, dict) else {}
+ artifact_message_id = str(uploaded_message.get('id') or '').strip()
+ if not artifact_message_id:
+ return None
+
+ generated_file_name = str(export_payload.get('file_name') or '').strip()
+ artifact_metadata = {
+ 'capability': str(export_payload.get('capability') or 'file_export').strip().lower() or 'file_export',
+ 'artifact_message_id': artifact_message_id,
+ 'conversation_id': str(conversation_id or '').strip(),
+ 'storage_scope': 'chat',
+ 'file_name': uploaded_message.get('file_name') or generated_file_name,
+ 'output_format': str(export_payload.get('output_format') or '').strip().lower(),
+ 'summary': str(export_payload.get('summary') or '').strip(),
+ }
+ row_count = export_payload.get('row_count')
+ if isinstance(row_count, int) and row_count > 0:
+ artifact_metadata['row_count'] = row_count
+ preview_rows = export_payload.get('preview_rows')
+ if isinstance(preview_rows, list) and preview_rows:
+ artifact_metadata['preview_rows'] = preview_rows
+ preview_lines = export_payload.get('preview_lines')
+ if isinstance(preview_lines, list) and preview_lines:
+ artifact_metadata['preview_lines'] = preview_lines
+ row_source = str(export_payload.get('row_source') or '').strip()
+ if row_source:
+ artifact_metadata['row_source'] = row_source
+ return artifact_metadata
+
+
+def _build_generated_file_payload(
+ output_format: str,
+ file_content: Any,
+ rows: Sequence[Dict[str, Any]],
+ row_source: str,
+ assistant_content: str,
+ title: str = '',
+) -> Dict[str, Any]:
+ normalized_output_format = str(output_format or '').strip().lower()
+ row_count = len(rows or [])
+ normalized_title = str(title or _build_generated_file_title(normalized_output_format)).strip()
+ return {
+ 'capability': 'file_export',
+ 'file_name': _build_generated_file_name(normalized_output_format),
+ 'file_content': file_content,
+ 'output_format': normalized_output_format,
+ 'row_count': row_count,
+ 'preview_rows': list(rows or [])[:GENERATED_FILE_PREVIEW_ROWS],
+ 'preview_lines': _build_preview_lines(assistant_content),
+ 'row_source': row_source,
+ '_structured_rows': list(rows or []),
+ 'summary': _build_generated_file_summary(
+ normalized_output_format,
+ row_count,
+ row_source,
+ normalized_title,
+ ),
+ }
+
+
+def _build_generated_file_name(output_format: str) -> str:
+ timestamp_suffix = datetime.utcnow().strftime('%Y%m%d_%H%M%S')
+ return f'generated_output_{timestamp_suffix}.{output_format}'
+
+
+def _build_generated_file_title(output_format: str) -> str:
+ return f'Generated {str(output_format or "file").upper()} export'
+
+
+def _build_generated_file_summary(
+ output_format: str,
+ row_count: int,
+ row_source: str,
+ title: str,
+) -> str:
+ row_detail = f' with {row_count} structured row(s)' if row_count else ''
+ return f'Prepared {title}{row_detail} from the {row_source}.'
+
+
+def _build_preview_lines(assistant_content: str) -> List[str]:
+ normalized_lines = [
+ line.strip()
+ for line in str(assistant_content or '').splitlines()
+ if line.strip()
+ ]
+ return normalized_lines[:3]
+
+
+def _parse_function_result_payload(value: Any) -> Any:
+ if not isinstance(value, str):
+ return value
+
+ normalized_value = value.strip()
+ if not normalized_value or normalized_value[0] not in '[{':
+ return value
+ try:
+ return json.loads(normalized_value)
+ except (TypeError, ValueError, json.JSONDecodeError):
+ return value
+
+
+def _extract_function_result_rows(
+ payload: Any,
+ depth: int = 0,
+ is_data_row: bool = False,
+) -> List[Dict[str, Any]]:
+ if depth > 4:
+ return []
+ if isinstance(payload, list):
+ rows = []
+ for item in payload:
+ if isinstance(item, dict):
+ normalized_row = _normalize_function_result_row(item, is_data_row=True)
+ if normalized_row:
+ rows.append(normalized_row)
+ elif item not in (None, ''):
+ rows.append({'value': _sanitize_function_result_value(item)})
+ return rows
+ if not isinstance(payload, dict):
+ return []
+
+ normalized_keys = {
+ _normalize_function_result_key(key): key
+ for key in payload
+ }
+ for row_key in FUNCTION_RESULT_ROW_KEYS:
+ matching_key = normalized_keys.get(_normalize_function_result_key(row_key))
+ if matching_key is None:
+ continue
+ rows = _extract_function_result_rows(
+ payload.get(matching_key),
+ depth + 1,
+ is_data_row=True,
+ )
+ if rows:
+ return rows
+
+ normalized_row = _normalize_function_result_row(payload, is_data_row=is_data_row)
+ return [normalized_row] if normalized_row else []
+
+
+def _normalize_function_result_row(row: Dict[str, Any], is_data_row: bool) -> Dict[str, Any]:
+ normalized_row = {}
+ for raw_key, raw_value in row.items():
+ key = str(raw_key or '').strip()
+ normalized_key = _normalize_function_result_key(key)
+ if not key or _is_sensitive_function_result_key(key):
+ continue
+ if not is_data_row and normalized_key in FUNCTION_RESULT_CONTROL_KEYS:
+ continue
+
+ value = _sanitize_function_result_value(raw_value)
+ if value in (None, '', [], {}) or value == '***REDACTED***':
+ continue
+ normalized_row[key] = value
+ return normalized_row
+
+
+def _sanitize_function_result_value(value: Any, depth: int = 0) -> Any:
+ if depth > 4:
+ return '[truncated]'
+ if isinstance(value, dict):
+ return {
+ str(key): _sanitize_function_result_value(item, depth + 1)
+ for key, item in value.items()
+ if not _is_sensitive_function_result_key(key)
+ }
+ if isinstance(value, (list, tuple, set)):
+ return [
+ _sanitize_function_result_value(item, depth + 1)
+ for item in value
+ ]
+ return value
+
+
+def _normalize_function_result_key(key: Any) -> str:
+ return re.sub(r'[^a-z0-9]', '', str(key or '').casefold())
+
+
+def _is_sensitive_function_result_key(key: Any) -> bool:
+ normalized_key = _normalize_function_result_key(key)
+ if not normalized_key:
+ return False
+ return any(fragment in normalized_key for fragment in FUNCTION_RESULT_SENSITIVE_KEY_FRAGMENTS)
+
+
+def _is_tabular_function_result(function_result: Dict[str, Any]) -> bool:
+ plugin_name = str(function_result.get('plugin_name') or '').strip().casefold()
+ return plugin_name in TABULAR_FUNCTION_RESULT_PLUGIN_NAMES
+
+
+def _get_function_result_label(function_result: Dict[str, Any]) -> str:
+ return (
+ str(function_result.get('function_name') or '').strip()
+ or str(function_result.get('plugin_name') or '').strip()
+ or 'function result'
+ )
+
+
+def _get_function_result_source_column(
+ function_row_groups: Sequence[Tuple[str, Sequence[Dict[str, Any]]]],
+) -> str:
+ existing_columns = {
+ str(column_name).casefold()
+ for _, rows in function_row_groups
+ for row in rows
+ for column_name in row
+ }
+ source_column = 'Source action'
+ suffix = 2
+ while source_column.casefold() in existing_columns:
+ source_column = f'Source action {suffix}'
+ suffix += 1
+ return source_column
+
+
+def _render_docx_file_export(
+ title: str,
+ assistant_content: str,
+ rows: Sequence[Dict[str, Any]],
+ row_source: str,
+) -> bytes:
+ from docx import Document as DocxDocument
+
+ document = DocxDocument()
+ document.add_heading(title, level=1)
+ _append_docx_text(document, assistant_content)
+ if rows:
+ document.add_heading(_build_structured_rows_heading(row_source), level=2)
+ _append_docx_table(document, rows)
+
+ output_buffer = io.BytesIO()
+ document.save(output_buffer)
+ return output_buffer.getvalue()
+
+
+def _append_docx_text(document: Any, assistant_content: str) -> None:
+ normalized_content = str(assistant_content or '').strip()
+ if not normalized_content:
+ return
+ for paragraph_text in re.split(r'\n\s*\n', normalized_content):
+ cleaned_paragraph = paragraph_text.strip()
+ if cleaned_paragraph:
+ document.add_paragraph(cleaned_paragraph)
+
+
+def _append_docx_table(document: Any, rows: Sequence[Dict[str, Any]]) -> None:
+ columns = _collect_structured_row_columns(rows)
+ if not columns:
+ return
+ table = document.add_table(rows=1, cols=len(columns))
+ table.style = 'Table Grid'
+ for index, column_name in enumerate(columns):
+ table.rows[0].cells[index].text = str(column_name)
+ for row in rows:
+ cells = table.add_row().cells
+ for index, column_name in enumerate(columns):
+ cells[index].text = _format_structured_cell(row.get(column_name))
+
+
+def _render_pdf_file_export(
+ title: str,
+ assistant_content: str,
+ rows: Sequence[Dict[str, Any]],
+ row_source: str,
+) -> bytes:
+ import fitz
+
+ html_parts = [f'{html.escape(title)}
']
+ normalized_content = str(assistant_content or '').strip()
+ if normalized_content:
+ html_parts.append('Response
')
+ for paragraph_text in re.split(r'\n\s*\n', normalized_content):
+ cleaned_paragraph = paragraph_text.strip()
+ if cleaned_paragraph:
+ html_parts.append(f'{html.escape(cleaned_paragraph).replace(chr(10), "
")}
')
+ if rows:
+ html_parts.append(f'{html.escape(_build_structured_rows_heading(row_source))}
')
+ html_parts.append(_build_structured_rows_html(rows))
+
+ media_box = fitz.paper_rect('letter')
+ content_box = media_box + (36, 36, -36, -36)
+ story = fitz.Story(html='\n'.join(html_parts), user_css=PDF_EXPORT_CSS)
+ temporary_path = None
+ try:
+ with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as temporary_file:
+ temporary_path = temporary_file.name
+
+ writer = fitz.DocumentWriter(temporary_path)
+ has_more = True
+ while has_more:
+ device = writer.begin_page(media_box)
+ has_more, _ = story.place(content_box)
+ story.draw(device)
+ writer.end_page()
+ writer.close()
+ with open(temporary_path, 'rb') as generated_file:
+ return generated_file.read()
+ finally:
+ if temporary_path:
+ try:
+ os.unlink(temporary_path)
+ except OSError:
+ pass
+
+
+def _build_structured_rows_html(rows: Sequence[Dict[str, Any]]) -> str:
+ columns = _collect_structured_row_columns(rows)
+ if not columns:
+ return 'No structured rows were available.
'
+ table_parts = ['']
+ table_parts.extend(f'| {html.escape(str(column_name))} | ' for column_name in columns)
+ table_parts.append('
')
+ for row in rows:
+ table_parts.append('')
+ for column_name in columns:
+ table_parts.append(f'{html.escape(_format_structured_cell(row.get(column_name))).replace(chr(10), " ")} | ')
+ table_parts.append('
')
+ table_parts.append('
')
+ return ''.join(table_parts)
+
+
+def _collect_structured_row_columns(rows: Sequence[Dict[str, Any]]) -> List[str]:
+ columns = []
+ seen_columns = set()
+ for row in rows or []:
+ if not isinstance(row, dict):
+ continue
+ for raw_column_name in row:
+ column_name = str(raw_column_name or '').strip()
+ if not column_name or column_name.casefold() in seen_columns:
+ continue
+ seen_columns.add(column_name.casefold())
+ columns.append(column_name)
+ return columns
+
+
+def _format_structured_cell(value: Any) -> str:
+ if value is None:
+ return ''
+ if isinstance(value, (dict, list, tuple, set)):
+ return json.dumps(value, default=str, ensure_ascii=False)
+ return str(value)
+
+
+def _build_structured_rows_heading(row_source: str) -> str:
+ if row_source == 'structured function result':
+ return 'Structured function results'
+ return 'Structured response rows'
def normalize_generated_output_format(output_format, default='json'):
diff --git a/application/single_app/functions_group.py b/application/single_app/functions_group.py
index bab68f7f6..81d4ff556 100644
--- a/application/single_app/functions_group.py
+++ b/application/single_app/functions_group.py
@@ -105,7 +105,7 @@ def search_all_groups(search_query, limit=10):
parameters=params,
enable_cross_partition_query=True
))
- return results[:max(1, min(int(limit or 10), 25))]
+ return results[:max(1, min(int(limit or 10), 50))]
def get_user_groups(user_id):
"""
diff --git a/application/single_app/functions_mixed_source_orchestration.py b/application/single_app/functions_mixed_source_orchestration.py
new file mode 100644
index 000000000..162a2ee4d
--- /dev/null
+++ b/application/single_app/functions_mixed_source_orchestration.py
@@ -0,0 +1,1838 @@
+# functions_mixed_source_orchestration.py
+"""Authorization-safe source manifest and evidence contracts for mixed sources."""
+
+import json
+import logging
+import math
+import os
+import time
+import uuid
+
+
+def log_event(*args, **kwargs):
+ """Lazily resolve telemetry logging to avoid module-level import cycles."""
+ try:
+ from functions_appinsights import log_event as _log_event_impl
+ except ImportError:
+ from single_app.functions_appinsights import log_event as _log_event_impl
+ return _log_event_impl(*args, **kwargs)
+
+
+SOURCE_KIND_TABULAR = "tabular"
+SOURCE_KIND_NARRATIVE = "narrative"
+SOURCE_KIND_UNSUPPORTED = "unsupported"
+SOURCE_KIND_UNRESOLVED = "unresolved"
+SOURCE_KINDS = frozenset({
+ SOURCE_KIND_TABULAR,
+ SOURCE_KIND_NARRATIVE,
+ SOURCE_KIND_UNSUPPORTED,
+ SOURCE_KIND_UNRESOLVED,
+})
+
+SOURCE_SCOPE_PERSONAL = "personal"
+SOURCE_SCOPE_GROUP = "group"
+SOURCE_SCOPE_PUBLIC = "public"
+SOURCE_SCOPE_CHAT = "chat"
+SOURCE_SCOPES = frozenset({
+ SOURCE_SCOPE_PERSONAL,
+ SOURCE_SCOPE_GROUP,
+ SOURCE_SCOPE_PUBLIC,
+ SOURCE_SCOPE_CHAT,
+})
+
+AUTHORIZATION_STATUS_AUTHORIZED = "authorized"
+AUTHORIZATION_STATUS_UNRESOLVED = "unresolved"
+SOURCE_MANIFEST_MAX_SOURCES = 100
+
+SELECTION_MODE_SELECTED = "selected"
+SELECTION_MODE_ALL = "all"
+SELECTION_MODE_HISTORY = "history"
+SELECTION_MODE_RELEVANCE = "relevance"
+SELECTION_MODES = frozenset({
+ SELECTION_MODE_SELECTED,
+ SELECTION_MODE_ALL,
+ SELECTION_MODE_HISTORY,
+ SELECTION_MODE_RELEVANCE,
+})
+
+TABULAR_SOURCE_EXTENSIONS = frozenset({".csv", ".xls", ".xlsx", ".xlsm"})
+NARRATIVE_SOURCE_EXTENSIONS = frozenset({
+ ".txt", ".doc", ".docm", ".docx", ".html", ".htm", ".md", ".markdown",
+ ".json", ".xml", ".yaml", ".yml", ".log", ".pdf", ".ppt", ".pptx",
+ ".msg", ".vsdx", ".jpg", ".jpeg", ".png", ".bmp", ".tiff", ".tif",
+ ".heif", ".heic", ".3ga", ".aac", ".ac3", ".aif", ".aifc", ".aiff",
+ ".amr", ".ape", ".au", ".caf", ".dts", ".f4a", ".flac", ".m4a",
+ ".m4b", ".m4r", ".mka", ".mp2", ".mp3", ".mpa", ".oga", ".ogg",
+ ".opus", ".spx", ".wav", ".weba", ".wma", ".wv", ".mp4", ".mov",
+ ".avi", ".mkv", ".flv", ".mxf", ".gxf", ".ts", ".ps", ".3gp",
+ ".3gpp", ".mpg", ".wmv", ".asf", ".m4v", ".isma", ".ismv",
+ ".dvr-ms", ".webm", ".mpeg",
+})
+
+EVIDENCE_ENGINE_TABULAR_TOOLS = "tabular_tools"
+EVIDENCE_ENGINE_DOCUMENT_ANALYSIS = "document_analysis"
+EVIDENCE_ENGINE_HYBRID_SEARCH = "hybrid_search"
+EVIDENCE_ENGINES = frozenset({
+ EVIDENCE_ENGINE_TABULAR_TOOLS,
+ EVIDENCE_ENGINE_DOCUMENT_ANALYSIS,
+ EVIDENCE_ENGINE_HYBRID_SEARCH,
+})
+
+EVIDENCE_STATUS_COMPLETED = "completed"
+EVIDENCE_STATUS_PARTIAL = "partial"
+EVIDENCE_STATUS_FAILED = "failed"
+EVIDENCE_STATUS_SKIPPED = "skipped"
+EVIDENCE_STATUSES = frozenset({
+ EVIDENCE_STATUS_COMPLETED,
+ EVIDENCE_STATUS_PARTIAL,
+ EVIDENCE_STATUS_FAILED,
+ EVIDENCE_STATUS_SKIPPED,
+})
+
+EVIDENCE_ENVELOPE_MAX_BYTES = 65536
+EVIDENCE_SUMMARY_MAX_BYTES = 4096
+EVIDENCE_ERROR_MAX_BYTES = 1024
+EVIDENCE_LIST_MAX_ITEMS = 10
+EVIDENCE_ITEM_MAX_BYTES = 1536
+EVIDENCE_COVERAGE_MAX_BYTES = 4096
+EVIDENCE_JSON_MAX_DEPTH = 4
+EVIDENCE_JSON_MAX_COLLECTION_ITEMS = 20
+EVIDENCE_JSON_MAX_STRING_BYTES = 1024
+MIXED_SOURCE_HANDOFF_MAX_BYTES = 49152
+MIXED_SOURCE_HANDOFF_MAX_ENVELOPES = 20
+MIXED_SOURCE_MODES = frozenset({"chat", "search", "analyze", "compare"})
+MIXED_SOURCE_TERMINAL_REASON_MAX_BYTES = 128
+MIXED_SOURCE_TELEMETRY_EVENTS = frozenset({
+ "authorization_failure",
+ "background_export",
+ "cancellation",
+ "continuity",
+ "native_execution",
+ "reduction",
+ "terminal_coverage",
+})
+MIXED_SOURCE_TELEMETRY_METRICS = frozenset({
+ "artifact_count",
+ "authorization_failure_count",
+ "background_export_count",
+ "cancellation_count",
+ "citation_count",
+ "completed_source_count",
+ "duplicate_evidence_count",
+ "engine_call_count",
+ "evidence_omitted_count",
+ "failed_source_count",
+ "history_rerun_count",
+ "history_reuse_count",
+ "history_source_count",
+ "latency_ms",
+ "missing_coverage_violation_count",
+ "model_request_count",
+ "narrative_source_count",
+ "partial_failure_count",
+ "partial_source_count",
+ "prompt_tokens",
+ "request_count",
+ "skipped_source_count",
+ "successful_source_count",
+ "tabular_source_count",
+ "token_request_count",
+ "total_source_count",
+ "total_tokens",
+ "unexpected_evidence_count",
+ "unsupported_source_count",
+ "unresolved_source_count",
+})
+MIXED_SOURCE_TELEMETRY_DIMENSIONS = frozenset({
+ "cancellation_phase",
+ "continuity_decision",
+ "outcome_status",
+ "selection_mode",
+})
+
+
+class MixedSourceCancellationError(RuntimeError):
+ """Stop mixed-source work without converting cancellation into source failure."""
+
+ def __init__(self, phase="unknown"):
+ self.phase = str(phase or "unknown").strip().lower() or "unknown"
+ super().__init__(f"Mixed-source execution canceled during {self.phase}.")
+
+
+class MixedSourceFinalizationError(RuntimeError):
+ """Prevent publication when a fresh manifest no longer matches execution evidence."""
+
+ def __init__(self, reason):
+ self.reason = str(reason or "finalization_failed").strip().lower()
+ super().__init__("Mixed-source evidence changed or became unavailable before publication.")
+
+
+def normalize_mixed_source_correlation_id(request_correlation_id=None):
+ """Return an internal UUID correlation value without trusting caller-shaped text."""
+ try:
+ return str(uuid.UUID(str(request_correlation_id or "").strip()))
+ except (TypeError, ValueError, AttributeError):
+ return str(uuid.uuid4())
+
+
+def raise_if_mixed_source_cancelled(
+ cancel_requested,
+ phase,
+ request_correlation_id=None,
+):
+ """Raise the shared cancellation signal when an optional predicate is set."""
+ if cancel_requested is None:
+ return
+ if not callable(cancel_requested):
+ raise TypeError("cancel_requested must be callable")
+ if not cancel_requested():
+ return
+
+ normalized_phase = str(phase or "unknown").strip().lower() or "unknown"
+ log_event(
+ "[MixedSourceLifecycle] Execution canceled.",
+ extra={
+ "request_correlation_id": normalize_mixed_source_correlation_id(
+ request_correlation_id
+ ),
+ "cancellation_phase": normalized_phase,
+ },
+ level=logging.INFO,
+ )
+ raise MixedSourceCancellationError(normalized_phase)
+
+
+def emit_mixed_source_telemetry(
+ settings,
+ event_name,
+ mode,
+ request_correlation_id=None,
+ metrics=None,
+ dimensions=None,
+):
+ """Emit only allowlisted aggregate lifecycle telemetry when explicitly enabled."""
+ if not bool((settings or {}).get("enable_mixed_source_development_telemetry", False)):
+ return False
+
+ normalized_event_name = str(event_name or "").strip().lower()
+ normalized_mode = str(mode or "").strip().lower()
+ if normalized_event_name not in MIXED_SOURCE_TELEMETRY_EVENTS:
+ raise ValueError(f"Unsupported mixed-source telemetry event: {normalized_event_name}")
+ if normalized_mode not in MIXED_SOURCE_MODES:
+ raise ValueError(f"Unsupported mixed-source telemetry mode: {normalized_mode}")
+
+ metrics = metrics if isinstance(metrics, dict) else {}
+ dimensions = dimensions if isinstance(dimensions, dict) else {}
+ unknown_metrics = set(metrics) - MIXED_SOURCE_TELEMETRY_METRICS
+ unknown_dimensions = set(dimensions) - MIXED_SOURCE_TELEMETRY_DIMENSIONS
+ if unknown_metrics or unknown_dimensions:
+ raise ValueError("Mixed-source telemetry contains non-allowlisted fields")
+
+ extra = {
+ "request_correlation_id": normalize_mixed_source_correlation_id(
+ request_correlation_id
+ ),
+ "event_name": normalized_event_name,
+ "mode": normalized_mode,
+ }
+ for field_name, raw_value in metrics.items():
+ if isinstance(raw_value, bool):
+ metric_value = int(raw_value)
+ elif isinstance(raw_value, (int, float)) and math.isfinite(raw_value):
+ metric_value = max(0, raw_value)
+ else:
+ raise ValueError("Mixed-source telemetry metrics must be finite numbers")
+ extra[field_name] = metric_value
+ for field_name, raw_value in dimensions.items():
+ extra[field_name] = _truncate_utf8(
+ str(raw_value or "").strip().lower(),
+ 64,
+ )
+
+ log_event(
+ "[MixedSourceTelemetry] Aggregate lifecycle metrics.",
+ extra=extra,
+ level=logging.INFO,
+ )
+ return True
+
+
+def emit_mixed_source_coverage_telemetry(
+ settings,
+ mode,
+ coverage,
+ request_correlation_id=None,
+):
+ """Emit terminal status and source-kind counts derived from the bounded ledger."""
+ coverage = coverage if isinstance(coverage, dict) else {}
+ terminal_ledger = [
+ entry
+ for entry in list(coverage.get("terminal_ledger") or [])
+ if isinstance(entry, dict)
+ ]
+ source_kind_counts = {source_kind: 0 for source_kind in SOURCE_KINDS}
+ for entry in terminal_ledger:
+ source_kind = str(entry.get("source_kind") or "").strip().lower()
+ if source_kind in source_kind_counts:
+ source_kind_counts[source_kind] += 1
+
+ outcome_status = (
+ EVIDENCE_STATUS_PARTIAL
+ if coverage.get("partial_coverage")
+ else EVIDENCE_STATUS_COMPLETED
+ )
+ if coverage.get("successful_source_count", 0) == 0 and coverage.get(
+ "requested_source_count",
+ 0,
+ ):
+ outcome_status = EVIDENCE_STATUS_FAILED
+ return emit_mixed_source_telemetry(
+ settings,
+ "terminal_coverage",
+ mode,
+ request_correlation_id=request_correlation_id,
+ metrics={
+ "total_source_count": coverage.get("requested_source_count", 0),
+ "completed_source_count": coverage.get("completed_source_count", 0),
+ "partial_source_count": coverage.get("partial_source_count", 0),
+ "failed_source_count": coverage.get("failed_source_count", 0),
+ "skipped_source_count": coverage.get("skipped_source_count", 0),
+ "successful_source_count": coverage.get("successful_source_count", 0),
+ "tabular_source_count": source_kind_counts[SOURCE_KIND_TABULAR],
+ "narrative_source_count": source_kind_counts[SOURCE_KIND_NARRATIVE],
+ "unsupported_source_count": source_kind_counts[SOURCE_KIND_UNSUPPORTED],
+ "unresolved_source_count": source_kind_counts[SOURCE_KIND_UNRESOLVED],
+ "missing_coverage_violation_count": coverage.get(
+ "missing_coverage_violation_count",
+ 0,
+ ),
+ "duplicate_evidence_count": coverage.get("duplicate_evidence_count", 0),
+ "unexpected_evidence_count": coverage.get("unexpected_evidence_count", 0),
+ "evidence_omitted_count": coverage.get("evidence_omitted_count", 0),
+ "partial_failure_count": int(bool(coverage.get("partial_coverage"))),
+ },
+ dimensions={
+ "selection_mode": coverage.get("selection_mode") or "selected",
+ "outcome_status": outcome_status,
+ },
+ )
+
+
+def normalize_selection_mode(selection_mode, default=SELECTION_MODE_SELECTED):
+ """Return a supported selection mode or raise for an invalid explicit value."""
+ normalized_default = str(default or "").strip().lower()
+ if normalized_default not in SELECTION_MODES:
+ raise ValueError("Invalid default selection_mode")
+
+ normalized_mode = str(selection_mode or "").strip().lower()
+ if not normalized_mode:
+ return normalized_default
+ if normalized_mode not in SELECTION_MODES:
+ raise ValueError(
+ f"selection_mode must be one of: {', '.join(sorted(SELECTION_MODES))}"
+ )
+ return normalized_mode
+
+
+def normalize_document_context_request(
+ selection_mode=None,
+ selected_document_ids=None,
+ document_context_requested=None,
+ hybrid_search=False,
+):
+ """Validate the Phase 2 request contract and derive effective context intent."""
+ normalized_document_ids = []
+ seen_document_ids = set()
+ for document_id in _normalize_identifier_list(selected_document_ids):
+ if document_id == "all" or document_id in seen_document_ids:
+ continue
+ seen_document_ids.add(document_id)
+ normalized_document_ids.append(document_id)
+
+ normalized_hybrid_search = _normalize_boolean(
+ hybrid_search,
+ field_name="hybrid_search",
+ )
+ normalized_context_requested = _normalize_optional_boolean(
+ document_context_requested,
+ field_name="document_context_requested",
+ )
+ has_explicit_selection_mode = str(selection_mode or "").strip() != ""
+
+ if normalized_document_ids:
+ normalized_selection_mode = normalize_selection_mode(
+ selection_mode,
+ default=SELECTION_MODE_SELECTED,
+ )
+ if normalized_selection_mode != SELECTION_MODE_SELECTED:
+ raise ValueError(
+ "selection_mode must be selected when selected_document_ids are provided"
+ )
+ normalized_context_requested = True
+ elif has_explicit_selection_mode:
+ normalized_selection_mode = normalize_selection_mode(selection_mode)
+ if normalized_selection_mode == SELECTION_MODE_SELECTED:
+ raise ValueError(
+ "selection_mode selected requires at least one selected_document_id"
+ )
+ if (
+ normalized_selection_mode in {SELECTION_MODE_ALL, SELECTION_MODE_RELEVANCE}
+ and normalized_context_requested is None
+ ):
+ normalized_context_requested = True
+ elif normalized_context_requested is True or normalized_hybrid_search:
+ normalized_selection_mode = SELECTION_MODE_RELEVANCE
+ normalized_context_requested = True
+ else:
+ normalized_selection_mode = None
+ normalized_context_requested = False
+
+ return {
+ "selection_mode": normalized_selection_mode,
+ "selected_document_ids": normalized_document_ids,
+ "document_context_requested": bool(normalized_context_requested),
+ "hybrid_search": normalized_hybrid_search,
+ "explicit_selection": bool(normalized_document_ids),
+ }
+
+
+def should_run_tabular_evidence(user_question, has_narrative_sources=False):
+ """Return whether a mixed-source question needs tabular data or schema evidence."""
+ normalized_question = " ".join(str(user_question or "").strip().lower().split())
+ if not normalized_question:
+ return True
+
+ tabular_markers = (
+ "calculate", "calculation", "count", "average", "mean", "median",
+ "minimum", "maximum", "total", "sum", "percentage", "percent",
+ "rows", "columns", "spreadsheet", "workbook", "worksheet", "sheet",
+ "csv", "xlsx", "xls", "table", "tabular", "data set", "dataset",
+ "trend", "group by", "how many", "highest", "lowest",
+ )
+ collective_markers = (
+ "both files", "both documents", "all files", "all documents",
+ "all selected", "each file", "each document", "each source",
+ "across the files", "across the documents", "across the sources",
+ "mixed sources",
+ )
+ narrative_markers = (
+ "pdf", "docx", "word document", "presentation", "powerpoint",
+ "paragraph", "section", "policy", "procedure", "contract",
+ "agreement", "memo", "letter", "narrative", "prose", "report",
+ )
+
+ if any(marker in normalized_question for marker in tabular_markers):
+ return True
+ if any(marker in normalized_question for marker in collective_markers):
+ return True
+ if has_narrative_sources and any(
+ marker in normalized_question for marker in narrative_markers
+ ):
+ return False
+ if normalized_question in {"summarize", "summary", "summarize the selected sources"}:
+ return True
+ if has_narrative_sources:
+ return False
+ return True
+
+
+def build_tabular_file_contexts_from_manifest(tabular_sources):
+ """Build canonical per-file contexts for the existing tabular runner."""
+ contexts = []
+ seen_source_identities = set()
+ for raw_source in list(tabular_sources or []):
+ source = raw_source if isinstance(raw_source, dict) else {}
+ if (
+ source.get("authorization_status") != AUTHORIZATION_STATUS_AUTHORIZED
+ or source.get("source_kind") != SOURCE_KIND_TABULAR
+ ):
+ continue
+
+ document_id = str(source.get("document_id") or "").strip()
+ file_name = str(source.get("file_name") or "").strip()
+ scope = str(source.get("scope") or "").strip().lower()
+ if not document_id or not file_name or scope not in SOURCE_SCOPES:
+ continue
+ source_hint = "workspace" if scope == SOURCE_SCOPE_PERSONAL else scope
+ source_identity = (
+ document_id,
+ source_hint,
+ str(source.get("scope_id") or "").strip(),
+ )
+ if source_identity in seen_source_identities:
+ continue
+ seen_source_identities.add(source_identity)
+ contexts.append({
+ "document_id": document_id,
+ "file_name": file_name,
+ "source_hint": source_hint,
+ "group_id": source.get("group_id"),
+ "public_workspace_id": source.get("public_workspace_id"),
+ "conversation_id": source.get("conversation_id"),
+ "storage_locator": dict(source.get("storage_locator") or {}),
+ })
+ return contexts
+
+
+def classify_source_kind(file_name, document_item=None):
+ """Classify a resolved source by native capability without reading its content."""
+ normalized_file_name = str(file_name or "").strip()
+ extension = os.path.splitext(normalized_file_name)[1].lower()
+ if extension in TABULAR_SOURCE_EXTENSIONS:
+ return SOURCE_KIND_TABULAR
+ if extension in NARRATIVE_SOURCE_EXTENSIONS:
+ return SOURCE_KIND_NARRATIVE
+
+ document_item = document_item if isinstance(document_item, dict) else {}
+ if any(
+ document_item.get(field_name)
+ for field_name in (
+ "num_file_chunks",
+ "comparison_text",
+ "extracted_text",
+ "vision_analysis",
+ )
+ ):
+ return SOURCE_KIND_NARRATIVE
+ return SOURCE_KIND_UNSUPPORTED
+
+
+def _normalize_document_id(requested_source):
+ if isinstance(requested_source, dict):
+ requested_source = requested_source.get("document_id") or requested_source.get("id")
+ return str(requested_source or "").strip()
+
+
+def _normalize_identifier_list(values):
+ if values is None:
+ return []
+ if isinstance(values, (str, int)):
+ values = [values]
+ return [
+ normalized_value
+ for normalized_value in (
+ str(value or "").strip()
+ for value in list(values)
+ )
+ if normalized_value
+ ]
+
+
+def _normalize_boolean(value, field_name):
+ if isinstance(value, bool):
+ return value
+ if value in (None, ""):
+ return False
+ if isinstance(value, int) and value in (0, 1):
+ return bool(value)
+ if isinstance(value, str):
+ normalized_value = value.strip().lower()
+ if normalized_value in {"true", "1"}:
+ return True
+ if normalized_value in {"false", "0"}:
+ return False
+ raise ValueError(f"{field_name} must be a boolean")
+
+
+def _normalize_optional_boolean(value, field_name):
+ if value is None or value == "":
+ return None
+ return _normalize_boolean(value, field_name=field_name)
+
+
+def _safe_file_name(file_name):
+ return str(file_name or "").replace("\\", "/").split("/")[-1].strip()
+
+
+def _unresolved_manifest_entry(document_id):
+ return {
+ "document_id": document_id,
+ "display_name": None,
+ "file_name": None,
+ "extension": None,
+ "source_kind": SOURCE_KIND_UNRESOLVED,
+ "scope": None,
+ "scope_id": None,
+ "group_id": None,
+ "public_workspace_id": None,
+ "conversation_id": None,
+ "source_version": None,
+ "storage_locator": None,
+ "authorization_status": AUTHORIZATION_STATUS_UNRESOLVED,
+ }
+
+
+def _build_authorized_manifest_entry(document_id, user_id, document_context):
+ if not isinstance(document_context, dict):
+ return _unresolved_manifest_entry(document_id)
+
+ document_item = document_context.get("document")
+ if not isinstance(document_item, dict):
+ return _unresolved_manifest_entry(document_id)
+
+ resolved_document_id = str(document_item.get("id") or "").strip()
+ if resolved_document_id != document_id:
+ return _unresolved_manifest_entry(document_id)
+
+ scope = str(document_context.get("scope") or "").strip().lower()
+ if scope not in SOURCE_SCOPES:
+ return _unresolved_manifest_entry(document_id)
+
+ group_id = None
+ public_workspace_id = None
+ conversation_id = str(
+ document_context.get("conversation_id")
+ or document_item.get("conversation_id")
+ or ""
+ ).strip() or None
+
+ if scope == SOURCE_SCOPE_PERSONAL:
+ scope_id = str(document_item.get("user_id") or user_id or "").strip()
+ if scope_id != str(user_id or "").strip() and not any(
+ str(shared_entry or "").strip() == f"{user_id},approved"
+ for shared_entry in document_item.get("shared_user_ids", []) or []
+ ):
+ return _unresolved_manifest_entry(document_id)
+ elif scope == SOURCE_SCOPE_GROUP:
+ group_id = str(document_context.get("group_id") or "").strip() or None
+ scope_id = group_id
+ document_group_id = str(document_item.get("group_id") or "").strip()
+ if document_group_id != group_id and not any(
+ str(shared_entry or "").strip() == f"{group_id},approved"
+ for shared_entry in document_item.get("shared_group_ids", []) or []
+ ):
+ return _unresolved_manifest_entry(document_id)
+ elif scope == SOURCE_SCOPE_PUBLIC:
+ public_workspace_id = str(
+ document_context.get("public_workspace_id") or ""
+ ).strip() or None
+ scope_id = public_workspace_id
+ else:
+ scope_id = conversation_id
+
+ if not scope_id:
+ return _unresolved_manifest_entry(document_id)
+
+ file_name = _safe_file_name(
+ document_item.get("file_name")
+ or document_item.get("filename")
+ or document_item.get("title")
+ )
+ display_name = str(document_item.get("title") or file_name or document_id).strip()
+ extension = os.path.splitext(file_name)[1].lower() or None
+ source_version = document_item.get("version")
+ if source_version is None:
+ source_version = document_item.get("source_version")
+ if source_version is not None and not isinstance(source_version, (str, int, float)):
+ source_version = str(source_version)
+
+ storage_locator = None
+ if scope != SOURCE_SCOPE_CHAT:
+ explicit_blob_container = document_item.get("blob_container")
+ explicit_blob_path = document_item.get("blob_path") or document_item.get("archived_blob_path")
+ try:
+ from functions_documents import get_document_blob_storage_info
+
+ blob_container, blob_path = get_document_blob_storage_info(
+ document_item,
+ user_id=(
+ document_item.get("user_id")
+ if scope == SOURCE_SCOPE_PERSONAL
+ else None
+ ),
+ group_id=(
+ document_item.get("group_id")
+ if scope == SOURCE_SCOPE_GROUP
+ else None
+ ),
+ public_workspace_id=(
+ document_item.get("public_workspace_id")
+ if scope == SOURCE_SCOPE_PUBLIC
+ else None
+ ),
+ )
+ if blob_container and blob_path:
+ storage_locator = {
+ "container": str(blob_container),
+ "blob_path": str(blob_path),
+ }
+ except Exception:
+ storage_locator = None
+ if storage_locator is None and explicit_blob_container and explicit_blob_path:
+ storage_locator = {
+ "container": str(explicit_blob_container),
+ "blob_path": str(explicit_blob_path),
+ }
+
+ return {
+ "document_id": document_id,
+ "display_name": display_name,
+ "file_name": file_name or None,
+ "extension": extension,
+ "source_kind": classify_source_kind(file_name, document_item=document_item),
+ "scope": scope,
+ "scope_id": scope_id,
+ "group_id": group_id,
+ "public_workspace_id": public_workspace_id,
+ "conversation_id": conversation_id,
+ "source_version": source_version,
+ "storage_locator": storage_locator,
+ "authorization_status": AUTHORIZATION_STATUS_AUTHORIZED,
+ }
+
+
+def _default_document_context_batch_resolver(**resolver_arguments):
+ # Imported lazily so this contract module remains usable by startup code and isolated tests.
+ from functions_search_service import resolve_document_contexts
+
+ resolver_arguments["include_content"] = False
+ return resolve_document_contexts(**resolver_arguments)
+
+
+def resolve_authorized_source_manifest(
+ requested_sources,
+ user_id,
+ selection_mode=SELECTION_MODE_SELECTED,
+ conversation_id=None,
+ active_group_ids=None,
+ active_public_workspace_ids=None,
+ doc_scope="all",
+ context_resolver=None,
+ cancel_requested=None,
+ request_correlation_id=None,
+):
+ """Resolve each unique requested ID once into an ordered, authorized manifest."""
+ request_correlation_id = normalize_mixed_source_correlation_id(
+ request_correlation_id
+ )
+ raise_if_mixed_source_cancelled(
+ cancel_requested,
+ "manifest",
+ request_correlation_id=request_correlation_id,
+ )
+ normalized_selection_mode = normalize_selection_mode(selection_mode)
+ normalized_user_id = str(user_id or "").strip()
+ if not normalized_user_id:
+ raise ValueError("user_id is required")
+
+ if isinstance(requested_sources, (str, int, dict)):
+ requested_source_list = [requested_sources]
+ else:
+ requested_source_list = list(requested_sources or [])
+ if len(requested_source_list) > SOURCE_MANIFEST_MAX_SOURCES:
+ log_event(
+ "[MixedSourceManifest] Rejected over-limit source manifest request.",
+ extra={
+ "selection_mode": normalized_selection_mode,
+ "requested_source_count": len(requested_source_list),
+ "source_limit": SOURCE_MANIFEST_MAX_SOURCES,
+ },
+ level=logging.WARNING,
+ )
+ raise ValueError(
+ f"A source manifest supports at most {SOURCE_MANIFEST_MAX_SOURCES} requested sources"
+ )
+
+ unique_document_ids = []
+ seen_document_ids = set()
+ duplicate_ids_removed = 0
+ for requested_source in requested_source_list:
+ document_id = _normalize_document_id(requested_source)
+ if not document_id:
+ continue
+ if document_id in seen_document_ids:
+ duplicate_ids_removed += 1
+ continue
+ seen_document_ids.add(document_id)
+ unique_document_ids.append(document_id)
+
+ if context_resolver is not None and not callable(context_resolver):
+ raise TypeError("context_resolver must be callable")
+
+ started_at = time.perf_counter()
+ manifest = []
+ resolution_error_count = 0
+ normalized_active_group_ids = _normalize_identifier_list(active_group_ids)
+ normalized_public_workspace_ids = _normalize_identifier_list(
+ active_public_workspace_ids
+ )
+ normalized_conversation_id = str(conversation_id or "").strip() or None
+ normalized_doc_scope = str(doc_scope or "all").strip().lower()
+ if normalized_doc_scope not in {"all", "personal", "group", "public"}:
+ raise ValueError("doc_scope must be all, personal, group, or public")
+
+ resolved_contexts = None
+ if context_resolver is None:
+ try:
+ raise_if_mixed_source_cancelled(
+ cancel_requested,
+ "manifest",
+ request_correlation_id=request_correlation_id,
+ )
+ resolved_contexts = _default_document_context_batch_resolver(
+ document_ids=unique_document_ids,
+ user_id=normalized_user_id,
+ doc_scope=normalized_doc_scope,
+ active_group_ids=normalized_active_group_ids,
+ active_public_workspace_id=normalized_public_workspace_ids,
+ conversation_id=normalized_conversation_id,
+ )
+ raise_if_mixed_source_cancelled(
+ cancel_requested,
+ "manifest",
+ request_correlation_id=request_correlation_id,
+ )
+ except MixedSourceCancellationError:
+ raise
+ except Exception:
+ resolved_contexts = [None] * len(unique_document_ids)
+ resolution_error_count = len(unique_document_ids)
+ if (
+ not isinstance(resolved_contexts, list)
+ or len(resolved_contexts) != len(unique_document_ids)
+ ):
+ resolved_contexts = [None] * len(unique_document_ids)
+ resolution_error_count = len(unique_document_ids)
+
+ for document_index, document_id in enumerate(unique_document_ids):
+ raise_if_mixed_source_cancelled(
+ cancel_requested,
+ "manifest",
+ request_correlation_id=request_correlation_id,
+ )
+ document_context = None
+ if resolved_contexts is not None:
+ document_context = resolved_contexts[document_index]
+ else:
+ try:
+ document_context = context_resolver(
+ document_id=document_id,
+ user_id=normalized_user_id,
+ doc_scope=normalized_doc_scope,
+ active_group_ids=normalized_active_group_ids,
+ active_public_workspace_id=normalized_public_workspace_ids,
+ conversation_id=normalized_conversation_id,
+ )
+ raise_if_mixed_source_cancelled(
+ cancel_requested,
+ "manifest",
+ request_correlation_id=request_correlation_id,
+ )
+ except MixedSourceCancellationError:
+ raise
+ except Exception:
+ resolution_error_count += 1
+ if (
+ isinstance(document_context, dict)
+ and normalized_doc_scope != "all"
+ and str(document_context.get("scope") or "").strip().lower()
+ != normalized_doc_scope
+ ):
+ document_context = None
+ manifest.append(
+ _build_authorized_manifest_entry(
+ document_id,
+ normalized_user_id,
+ document_context,
+ )
+ )
+
+ source_kind_counts = {source_kind: 0 for source_kind in SOURCE_KINDS}
+ scope_distribution = {scope: 0 for scope in SOURCE_SCOPES}
+ for entry in manifest:
+ source_kind_counts[entry["source_kind"]] += 1
+ if entry["scope"] in scope_distribution:
+ scope_distribution[entry["scope"]] += 1
+
+ duration_ms = round((time.perf_counter() - started_at) * 1000, 3)
+ resolved_source_count = len(manifest) - source_kind_counts[SOURCE_KIND_UNRESOLVED]
+ log_event(
+ "[MixedSourceManifest] Resolved authorized source manifest.",
+ extra={
+ "selection_mode": normalized_selection_mode,
+ "requested_source_count": len(requested_source_list),
+ "unique_source_count": len(unique_document_ids),
+ "resolved_source_count": resolved_source_count,
+ "tabular_source_count": source_kind_counts[SOURCE_KIND_TABULAR],
+ "narrative_source_count": source_kind_counts[SOURCE_KIND_NARRATIVE],
+ "unsupported_source_count": source_kind_counts[SOURCE_KIND_UNSUPPORTED],
+ "unresolved_or_unauthorized_count": source_kind_counts[SOURCE_KIND_UNRESOLVED],
+ "duplicate_ids_removed": duplicate_ids_removed,
+ "resolution_error_count": resolution_error_count,
+ "scope_distribution": scope_distribution,
+ "manifest_resolution_duration_ms": duration_ms,
+ "request_correlation_id": request_correlation_id,
+ },
+ level=logging.INFO,
+ )
+ return manifest
+
+
+def partition_source_manifest(manifest):
+ """Partition a manifest by capability while preserving order within each cohort."""
+ partitions = {
+ "tabular_sources": [],
+ "narrative_sources": [],
+ "unsupported_sources": [],
+ "unresolved_sources": [],
+ }
+ partition_key_by_source_kind = {
+ SOURCE_KIND_TABULAR: "tabular_sources",
+ SOURCE_KIND_NARRATIVE: "narrative_sources",
+ SOURCE_KIND_UNSUPPORTED: "unsupported_sources",
+ SOURCE_KIND_UNRESOLVED: "unresolved_sources",
+ }
+
+ for raw_entry in list(manifest or []):
+ entry = raw_entry if isinstance(raw_entry, dict) else {}
+ document_id = _normalize_document_id(entry)
+ if entry.get("authorization_status") != AUTHORIZATION_STATUS_AUTHORIZED:
+ partitions["unresolved_sources"].append(
+ _unresolved_manifest_entry(document_id)
+ )
+ continue
+
+ partition_key = partition_key_by_source_kind.get(
+ entry.get("source_kind"),
+ "unsupported_sources",
+ )
+ partitions[partition_key].append(entry)
+
+ return partitions
+
+
+def _truncate_utf8(value, max_bytes):
+ normalized_value = str(value or "")
+ encoded_value = normalized_value.encode("utf-8")
+ if len(encoded_value) <= max_bytes:
+ return normalized_value
+ if max_bytes <= 3:
+ return encoded_value[:max_bytes].decode("utf-8", errors="ignore")
+ return (
+ encoded_value[:max_bytes - 3].decode("utf-8", errors="ignore").rstrip()
+ + "..."
+ )
+
+
+def _make_json_safe(value, depth=0):
+ if value is None or isinstance(value, (bool, int)):
+ return value, False
+ if isinstance(value, float):
+ return (value, False) if math.isfinite(value) else (None, True)
+ if isinstance(value, str):
+ bounded_value = _truncate_utf8(value, EVIDENCE_JSON_MAX_STRING_BYTES)
+ return bounded_value, bounded_value != value
+ if depth >= EVIDENCE_JSON_MAX_DEPTH:
+ return _truncate_utf8(str(value), EVIDENCE_JSON_MAX_STRING_BYTES), True
+ if isinstance(value, dict):
+ source_items = list(value.items())
+ bounded_value = {}
+ was_truncated = len(source_items) > EVIDENCE_JSON_MAX_COLLECTION_ITEMS
+ for key, item_value in source_items[:EVIDENCE_JSON_MAX_COLLECTION_ITEMS]:
+ normalized_key = _truncate_utf8(key, 128)
+ bounded_item, item_was_truncated = _make_json_safe(
+ item_value,
+ depth + 1,
+ )
+ if (
+ not isinstance(key, str)
+ or normalized_key != key
+ or normalized_key in bounded_value
+ ):
+ was_truncated = True
+ bounded_value[normalized_key] = bounded_item
+ was_truncated = was_truncated or item_was_truncated
+ return bounded_value, was_truncated
+ if isinstance(value, (list, tuple, set)):
+ source_items = list(value)
+ bounded_value = []
+ was_truncated = len(source_items) > EVIDENCE_JSON_MAX_COLLECTION_ITEMS
+ for item in source_items[:EVIDENCE_JSON_MAX_COLLECTION_ITEMS]:
+ bounded_item, item_was_truncated = _make_json_safe(item, depth + 1)
+ bounded_value.append(bounded_item)
+ was_truncated = was_truncated or item_was_truncated
+ return bounded_value, was_truncated
+ return _truncate_utf8(str(value), EVIDENCE_JSON_MAX_STRING_BYTES), True
+
+
+def _json_size_bytes(value):
+ return len(
+ json.dumps(
+ value,
+ allow_nan=False,
+ ensure_ascii=False,
+ separators=(",", ":"),
+ ).encode("utf-8")
+ )
+
+
+def _bound_json_value(value, max_bytes):
+ safe_value, safe_value_was_truncated = _make_json_safe(value)
+ if _json_size_bytes(safe_value) <= max_bytes:
+ return safe_value, safe_value_was_truncated
+
+ serialized_preview = json.dumps(
+ safe_value,
+ allow_nan=False,
+ ensure_ascii=False,
+ separators=(",", ":"),
+ )
+ preview_max_bytes = max(16, max_bytes // 3)
+ while preview_max_bytes > 0:
+ bounded_value = {
+ "truncated": True,
+ "preview": _truncate_utf8(serialized_preview, preview_max_bytes),
+ }
+ if _json_size_bytes(bounded_value) <= max_bytes:
+ return bounded_value, True
+ preview_max_bytes //= 2
+ return {"truncated": True}, True
+
+
+def _bound_json_list(values):
+ if values is None:
+ return [], False
+ if not isinstance(values, (list, tuple)):
+ raise ValueError("Evidence collection values must be lists")
+
+ source_values = list(values)
+ bounded_values = []
+ was_truncated = len(source_values) > EVIDENCE_LIST_MAX_ITEMS
+ for value in source_values[:EVIDENCE_LIST_MAX_ITEMS]:
+ bounded_value, value_was_truncated = _bound_json_value(
+ value,
+ EVIDENCE_ITEM_MAX_BYTES,
+ )
+ bounded_values.append(bounded_value)
+ was_truncated = was_truncated or value_was_truncated
+ return bounded_values, was_truncated
+
+
+def deduplicate_mixed_source_references(references, reference_type="citation"):
+ """Deduplicate structured citations or artifacts while preserving first payloads."""
+ normalized_reference_type = str(reference_type or "citation").strip().lower()
+ if normalized_reference_type not in {"citation", "artifact"}:
+ raise ValueError("reference_type must be citation or artifact")
+
+ deduplicated = []
+ seen_keys = set()
+ for reference in list(references or []):
+ if not isinstance(reference, dict):
+ dedupe_key = ("scalar", str(reference))
+ elif normalized_reference_type == "artifact":
+ identity_value = (
+ reference.get("artifact_message_id")
+ or reference.get("document_id")
+ or reference.get("export_run_id")
+ )
+ dedupe_key = (
+ ("artifact_id", str(identity_value).strip())
+ if identity_value
+ else (
+ "artifact_location",
+ str(reference.get("file_name") or "").strip(),
+ str(reference.get("output_format") or "").strip().lower(),
+ str(reference.get("capability") or "").strip().lower(),
+ )
+ )
+ else:
+ identity_value = (
+ reference.get("artifact_id")
+ or reference.get("citation_id")
+ or reference.get("chunk_id")
+ )
+ plugin_name = str(reference.get("plugin_name") or "").strip()
+ function_name = str(reference.get("function_name") or "").strip()
+ tool_name = str(reference.get("tool_name") or "").strip()
+ if identity_value:
+ dedupe_key = ("citation_id", str(identity_value).strip())
+ elif plugin_name or function_name or tool_name:
+ tool_arguments = (
+ reference.get("function_arguments")
+ if reference.get("function_arguments") is not None
+ else reference.get("parameters")
+ )
+ dedupe_key = (
+ "tool_citation",
+ plugin_name,
+ function_name,
+ tool_name,
+ json.dumps(
+ tool_arguments,
+ allow_nan=False,
+ default=str,
+ ensure_ascii=False,
+ separators=(",", ":"),
+ sort_keys=True,
+ ),
+ )
+ else:
+ dedupe_key = (
+ "citation_location",
+ str(reference.get("document_id") or "").strip(),
+ str(reference.get("file_name") or "").strip(),
+ str(reference.get("page_number") or "").strip(),
+ str(reference.get("chunk_sequence") or "").strip(),
+ str(reference.get("sheet_name") or "").strip(),
+ )
+ if dedupe_key in seen_keys:
+ continue
+ seen_keys.add(dedupe_key)
+ deduplicated.append(reference)
+ return deduplicated
+
+
+def _build_truncated_coverage(coverage, coverage_was_truncated=False):
+ normalized_coverage = dict(coverage or {})
+ normalized_coverage["evidence_envelope_truncated"] = True
+ if coverage_was_truncated:
+ normalized_coverage["coverage_truncated"] = True
+
+ bounded_coverage, additional_truncation = _bound_json_value(
+ normalized_coverage,
+ EVIDENCE_COVERAGE_MAX_BYTES,
+ )
+ if additional_truncation:
+ return {
+ "evidence_envelope_truncated": True,
+ "coverage_truncated": True,
+ }
+ return bounded_coverage
+
+
+def build_evidence_envelope(
+ document_id,
+ source_kind,
+ engine,
+ status,
+ summary="",
+ evidence=None,
+ citations=None,
+ generated_artifacts=None,
+ coverage=None,
+ error=None,
+):
+ """Build a bounded, JSON-safe evidence envelope for later synthesis phases."""
+ normalized_document_id = str(document_id or "").strip()
+ if not normalized_document_id:
+ raise ValueError("document_id is required")
+
+ normalized_source_kind = str(source_kind or "").strip().lower()
+ if normalized_source_kind not in {SOURCE_KIND_TABULAR, SOURCE_KIND_NARRATIVE}:
+ raise ValueError("Evidence source_kind must be tabular or narrative")
+
+ normalized_engine = str(engine or "").strip().lower()
+ if normalized_engine not in EVIDENCE_ENGINES:
+ raise ValueError(f"Unsupported evidence engine: {normalized_engine}")
+
+ normalized_status = str(status or "").strip().lower()
+ if normalized_status not in EVIDENCE_STATUSES:
+ raise ValueError(f"Unsupported evidence status: {normalized_status}")
+
+ if coverage is not None and not isinstance(coverage, dict):
+ raise ValueError("coverage must be a dictionary")
+
+ bounded_evidence, evidence_was_truncated = _bound_json_list(evidence)
+ bounded_citations, citations_were_truncated = _bound_json_list(
+ deduplicate_mixed_source_references(citations, reference_type="citation")
+ )
+ bounded_artifacts, artifacts_were_truncated = _bound_json_list(
+ deduplicate_mixed_source_references(generated_artifacts, reference_type="artifact")
+ )
+ normalized_summary = _truncate_utf8(summary, EVIDENCE_SUMMARY_MAX_BYTES)
+ normalized_error = (
+ _truncate_utf8(error, EVIDENCE_ERROR_MAX_BYTES)
+ if error is not None
+ else None
+ )
+ bounds_applied = bool(
+ evidence_was_truncated
+ or citations_were_truncated
+ or artifacts_were_truncated
+ or len(str(summary or "").encode("utf-8")) > EVIDENCE_SUMMARY_MAX_BYTES
+ or (
+ error is not None
+ and len(str(error).encode("utf-8")) > EVIDENCE_ERROR_MAX_BYTES
+ )
+ )
+ normalized_coverage = dict(coverage or {})
+ if bounds_applied:
+ normalized_coverage["evidence_envelope_truncated"] = True
+ bounded_coverage, coverage_was_truncated = _bound_json_value(
+ normalized_coverage,
+ EVIDENCE_COVERAGE_MAX_BYTES,
+ )
+ if coverage_was_truncated:
+ bounded_coverage = _build_truncated_coverage(
+ {},
+ coverage_was_truncated=True,
+ )
+
+ envelope = {
+ "document_id": normalized_document_id,
+ "source_kind": normalized_source_kind,
+ "engine": normalized_engine,
+ "status": normalized_status,
+ "summary": normalized_summary,
+ "evidence": bounded_evidence,
+ "citations": bounded_citations,
+ "generated_artifacts": bounded_artifacts,
+ "coverage": bounded_coverage,
+ "error": normalized_error,
+ }
+
+ while _json_size_bytes(envelope) > EVIDENCE_ENVELOPE_MAX_BYTES:
+ candidate_field = max(
+ ("evidence", "citations", "generated_artifacts"),
+ key=lambda field_name: len(envelope[field_name]),
+ )
+ if envelope[candidate_field]:
+ envelope[candidate_field].pop()
+ envelope["coverage"] = _build_truncated_coverage(
+ envelope["coverage"],
+ )
+ continue
+ envelope["summary"] = _truncate_utf8(
+ envelope["summary"],
+ max(128, len(envelope["summary"].encode("utf-8")) // 2),
+ )
+ if len(envelope["summary"].encode("utf-8")) <= 128:
+ raise ValueError("Unable to bound evidence envelope")
+
+ return envelope
+
+
+def serialize_evidence_envelope(envelope):
+ """Validate and serialize a bounded evidence envelope."""
+ if not isinstance(envelope, dict):
+ raise ValueError("Evidence envelope must be a dictionary")
+
+ required_fields = {
+ "document_id",
+ "source_kind",
+ "engine",
+ "status",
+ "summary",
+ "evidence",
+ "citations",
+ "generated_artifacts",
+ "coverage",
+ "error",
+ }
+ if set(envelope) != required_fields:
+ raise ValueError("Evidence envelope fields do not match the contract")
+
+ bounded_envelope = build_evidence_envelope(
+ document_id=envelope["document_id"],
+ source_kind=envelope["source_kind"],
+ engine=envelope["engine"],
+ status=envelope["status"],
+ summary=envelope["summary"],
+ evidence=envelope["evidence"],
+ citations=envelope["citations"],
+ generated_artifacts=envelope["generated_artifacts"],
+ coverage=envelope["coverage"],
+ error=envelope["error"],
+ )
+
+ serialized_envelope = json.dumps(
+ bounded_envelope,
+ allow_nan=False,
+ ensure_ascii=False,
+ separators=(",", ":"),
+ sort_keys=True,
+ )
+ if len(serialized_envelope.encode("utf-8")) > EVIDENCE_ENVELOPE_MAX_BYTES:
+ raise ValueError("Evidence envelope exceeds its serialized size bound")
+ return serialized_envelope
+
+
+def build_narrative_evidence_envelopes(
+ narrative_sources,
+ search_results,
+ selection_mode,
+):
+ """Normalize bounded narrative search results into one envelope per source."""
+ normalized_selection_mode = normalize_selection_mode(
+ selection_mode,
+ default=SELECTION_MODE_RELEVANCE,
+ )
+ results_by_document_id = {}
+ for raw_result in list(search_results or []):
+ result = raw_result if isinstance(raw_result, dict) else {}
+ document_id = str(result.get("document_id") or "").strip()
+ if not document_id:
+ continue
+ results_by_document_id.setdefault(document_id, []).append(result)
+
+ envelopes = []
+ for source in list(narrative_sources or []):
+ source = source if isinstance(source, dict) else {}
+ document_id = str(source.get("document_id") or "").strip()
+ if not document_id:
+ continue
+ source_results = results_by_document_id.get(document_id, [])
+ evidence = []
+ citations = []
+ for result in source_results:
+ evidence.append({
+ "chunk_text": result.get("chunk_text"),
+ "page_number": result.get("page_number"),
+ "chunk_sequence": result.get("chunk_sequence"),
+ "score": result.get("score"),
+ })
+ citations.append({
+ "citation_id": result.get("id") or result.get("chunk_id"),
+ "page_number": result.get("page_number"),
+ "chunk_sequence": result.get("chunk_sequence"),
+ })
+
+ result_count = len(source_results)
+ envelopes.append(build_evidence_envelope(
+ document_id=document_id,
+ source_kind=SOURCE_KIND_NARRATIVE,
+ engine=EVIDENCE_ENGINE_HYBRID_SEARCH,
+ status=(
+ EVIDENCE_STATUS_COMPLETED
+ if result_count
+ else EVIDENCE_STATUS_PARTIAL
+ ),
+ summary=(
+ f"Retrieved {result_count} bounded narrative excerpt(s)."
+ if result_count
+ else "No relevant narrative excerpts were returned."
+ ),
+ evidence=evidence,
+ citations=citations,
+ coverage={
+ "selection_mode": normalized_selection_mode,
+ "terminal": True,
+ "result_count": result_count,
+ },
+ error=(
+ None
+ if result_count
+ else "Narrative retrieval returned no relevant excerpts."
+ ),
+ ))
+ return envelopes
+
+
+def build_failed_narrative_evidence_envelopes(
+ narrative_sources,
+ selection_mode,
+ reason="narrative_retrieval_failed",
+):
+ """Build one scrubbed terminal failure for every narrative source in a failed cohort."""
+ normalized_selection_mode = normalize_selection_mode(
+ selection_mode,
+ default=SELECTION_MODE_RELEVANCE,
+ )
+ normalized_reason = _truncate_utf8(
+ str(reason or "narrative_retrieval_failed").strip().lower(),
+ MIXED_SOURCE_TERMINAL_REASON_MAX_BYTES,
+ )
+ envelopes = []
+ for source in list(narrative_sources or []):
+ source = source if isinstance(source, dict) else {}
+ document_id = str(source.get("document_id") or "").strip()
+ if not document_id:
+ continue
+ envelopes.append(build_evidence_envelope(
+ document_id=document_id,
+ source_kind=SOURCE_KIND_NARRATIVE,
+ engine=EVIDENCE_ENGINE_HYBRID_SEARCH,
+ status=EVIDENCE_STATUS_FAILED,
+ summary="Narrative evidence could not be retrieved for this source.",
+ coverage={
+ "selection_mode": normalized_selection_mode,
+ "terminal": True,
+ "reason": normalized_reason,
+ },
+ error="Narrative retrieval could not be completed.",
+ ))
+ return envelopes
+
+
+def execute_tabular_evidence_sources(
+ tabular_sources,
+ execute_source,
+ selection_mode,
+ execute=True,
+ cancel_requested=None,
+ request_correlation_id=None,
+):
+ """Execute the existing tabular runner once per source and require terminal coverage."""
+ normalized_selection_mode = normalize_selection_mode(
+ selection_mode,
+ default=SELECTION_MODE_RELEVANCE,
+ )
+ request_correlation_id = normalize_mixed_source_correlation_id(
+ request_correlation_id
+ )
+ if execute and not callable(execute_source):
+ raise TypeError("execute_source must be callable")
+
+ envelopes = []
+ completed_count = 0
+ failed_count = 0
+ skipped_count = 0
+ for raw_source in list(tabular_sources or []):
+ raise_if_mixed_source_cancelled(
+ cancel_requested,
+ "tabular",
+ request_correlation_id=request_correlation_id,
+ )
+ source = raw_source if isinstance(raw_source, dict) else {}
+ document_id = str(source.get("document_id") or "").strip()
+ if not document_id:
+ continue
+
+ if not execute:
+ skipped_count += 1
+ envelopes.append(build_evidence_envelope(
+ document_id=document_id,
+ source_kind=SOURCE_KIND_TABULAR,
+ engine=EVIDENCE_ENGINE_TABULAR_TOOLS,
+ status=EVIDENCE_STATUS_SKIPPED,
+ summary="Tabular processing was not needed for this narrative-only request.",
+ coverage={
+ "selection_mode": normalized_selection_mode,
+ "terminal": True,
+ "reason": "narrative_only_request",
+ },
+ ))
+ continue
+
+ try:
+ raw_result = execute_source(source)
+ raise_if_mixed_source_cancelled(
+ cancel_requested,
+ "tabular",
+ request_correlation_id=request_correlation_id,
+ )
+ result = raw_result if isinstance(raw_result, dict) else {}
+ summary = str(result.get("summary") or "").strip()
+ if not summary:
+ raise ValueError("Tabular execution returned no bounded summary")
+ completed_count += 1
+ envelopes.append(build_evidence_envelope(
+ document_id=document_id,
+ source_kind=SOURCE_KIND_TABULAR,
+ engine=EVIDENCE_ENGINE_TABULAR_TOOLS,
+ status=EVIDENCE_STATUS_COMPLETED,
+ summary=summary,
+ evidence=result.get("evidence"),
+ citations=result.get("citations"),
+ generated_artifacts=result.get("generated_artifacts"),
+ coverage={
+ "selection_mode": normalized_selection_mode,
+ "terminal": True,
+ **dict(result.get("coverage") or {}),
+ },
+ ))
+ except MixedSourceCancellationError:
+ raise
+ except Exception:
+ failed_count += 1
+ envelopes.append(build_evidence_envelope(
+ document_id=document_id,
+ source_kind=SOURCE_KIND_TABULAR,
+ engine=EVIDENCE_ENGINE_TABULAR_TOOLS,
+ status=EVIDENCE_STATUS_FAILED,
+ summary="Tabular evidence could not be completed for this source.",
+ coverage={
+ "selection_mode": normalized_selection_mode,
+ "terminal": True,
+ },
+ error="Tabular evidence could not be completed.",
+ ))
+
+ log_event(
+ "[MixedSourceChatSearch] Tabular source execution reached terminal coverage.",
+ extra={
+ "selection_mode": normalized_selection_mode,
+ "tabular_candidate_count": len(list(tabular_sources or [])),
+ "tabular_completed_count": completed_count,
+ "tabular_failed_count": failed_count,
+ "tabular_skipped_count": skipped_count,
+ "request_correlation_id": request_correlation_id,
+ },
+ level=logging.INFO,
+ )
+ return envelopes
+
+
+def _get_terminal_reason(status, source, envelope=None):
+ """Return one bounded, non-sensitive reason for a terminal non-success state."""
+ envelope = envelope if isinstance(envelope, dict) else {}
+ coverage = envelope.get("coverage") if isinstance(envelope.get("coverage"), dict) else {}
+ explicit_reason = str(coverage.get("reason") or "").strip().lower()
+ if explicit_reason:
+ return _truncate_utf8(explicit_reason, MIXED_SOURCE_TERMINAL_REASON_MAX_BYTES)
+ if source.get("authorization_status") != AUTHORIZATION_STATUS_AUTHORIZED:
+ return "source_unavailable"
+ if source.get("source_kind") == SOURCE_KIND_UNSUPPORTED:
+ return "unsupported_source"
+ if status == EVIDENCE_STATUS_SKIPPED:
+ return "bounded_policy_skip"
+ if status == EVIDENCE_STATUS_PARTIAL:
+ return "incomplete_native_coverage"
+ if status == EVIDENCE_STATUS_FAILED:
+ return "native_execution_failed"
+ return None
+
+
+def build_terminal_coverage_ledger(
+ manifest,
+ evidence_envelopes,
+ max_handoff_envelopes=MIXED_SOURCE_HANDOFF_MAX_ENVELOPES,
+):
+ """Align exactly one terminal state and bounded evidence item to each manifest source."""
+ manifest_entries = [entry for entry in list(manifest or []) if isinstance(entry, dict)]
+ raw_envelopes = [
+ envelope
+ for envelope in list(evidence_envelopes or [])
+ if isinstance(envelope, dict)
+ ]
+ manifest_document_ids = {
+ str(entry.get("document_id") or "").strip()
+ for entry in manifest_entries
+ if str(entry.get("document_id") or "").strip()
+ }
+ envelopes_by_document_id = {}
+ unexpected_evidence_count = 0
+ for envelope in raw_envelopes:
+ document_id = str(envelope.get("document_id") or "").strip()
+ if not document_id or document_id not in manifest_document_ids:
+ unexpected_evidence_count += 1
+ continue
+ envelopes_by_document_id.setdefault(document_id, []).append(envelope)
+
+ ledger_entries = []
+ aligned_envelopes = []
+ status_counts = {status: 0 for status in EVIDENCE_STATUSES}
+ missing_coverage_violation_count = 0
+ duplicate_evidence_count = 0
+ evidence_omitted_count = 0
+
+ for request_order, source in enumerate(manifest_entries):
+ document_id = str(source.get("document_id") or "").strip()
+ source_kind = str(source.get("source_kind") or SOURCE_KIND_UNRESOLVED).strip().lower()
+ source_envelopes = envelopes_by_document_id.get(document_id, [])
+ envelope = source_envelopes[0] if len(source_envelopes) == 1 else None
+ reason = None
+
+ if len(source_envelopes) > 1:
+ status = EVIDENCE_STATUS_FAILED
+ reason = "duplicate_terminal_evidence"
+ duplicate_evidence_count += len(source_envelopes) - 1
+ missing_coverage_violation_count += 1
+ elif source.get("authorization_status") != AUTHORIZATION_STATUS_AUTHORIZED:
+ status = EVIDENCE_STATUS_FAILED
+ reason = "source_unavailable"
+ elif source_kind == SOURCE_KIND_UNSUPPORTED:
+ status = EVIDENCE_STATUS_SKIPPED
+ reason = "unsupported_source"
+ elif envelope is None:
+ status = EVIDENCE_STATUS_FAILED
+ reason = "missing_terminal_evidence"
+ missing_coverage_violation_count += 1
+ elif str(envelope.get("source_kind") or "").strip().lower() != source_kind:
+ status = EVIDENCE_STATUS_FAILED
+ reason = "evidence_identity_mismatch"
+ missing_coverage_violation_count += 1
+ envelope = None
+ else:
+ status = str(envelope.get("status") or "").strip().lower()
+ if status not in EVIDENCE_STATUSES:
+ status = EVIDENCE_STATUS_FAILED
+ reason = "invalid_terminal_status"
+ missing_coverage_violation_count += 1
+ envelope = None
+ else:
+ reason = _get_terminal_reason(status, source, envelope=envelope)
+
+ status_counts[status] += 1
+ handoff_included = False
+ if envelope is not None:
+ if len(aligned_envelopes) < max(0, int(max_handoff_envelopes)):
+ aligned_envelopes.append(envelope)
+ handoff_included = True
+ else:
+ evidence_omitted_count += 1
+
+ source_role = str(
+ source.get("comparison_role")
+ or source.get("source_role")
+ or source.get("role")
+ or "selected"
+ ).strip().lower() or "selected"
+ ledger_entry = {
+ "document_id": document_id,
+ "scope": source.get("scope"),
+ "scope_id": source.get("scope_id"),
+ "source_version": source.get("source_version"),
+ "source_kind": source_kind,
+ "role": source_role,
+ "request_order": request_order,
+ "status": status,
+ "reason": reason,
+ "handoff_included": handoff_included,
+ }
+ ledger_entries.append(ledger_entry)
+
+ partial_coverage = bool(
+ status_counts[EVIDENCE_STATUS_PARTIAL]
+ or status_counts[EVIDENCE_STATUS_FAILED]
+ or status_counts[EVIDENCE_STATUS_SKIPPED]
+ or missing_coverage_violation_count
+ or duplicate_evidence_count
+ or unexpected_evidence_count
+ or evidence_omitted_count
+ )
+ return {
+ "entries": ledger_entries,
+ "evidence_envelopes": aligned_envelopes,
+ "requested_source_count": len(manifest_entries),
+ "completed_source_count": status_counts[EVIDENCE_STATUS_COMPLETED],
+ "partial_source_count": status_counts[EVIDENCE_STATUS_PARTIAL],
+ "failed_source_count": status_counts[EVIDENCE_STATUS_FAILED],
+ "skipped_source_count": status_counts[EVIDENCE_STATUS_SKIPPED],
+ "successful_source_count": (
+ status_counts[EVIDENCE_STATUS_COMPLETED]
+ + status_counts[EVIDENCE_STATUS_PARTIAL]
+ ),
+ "missing_coverage_violation_count": missing_coverage_violation_count,
+ "duplicate_evidence_count": duplicate_evidence_count,
+ "unexpected_evidence_count": unexpected_evidence_count,
+ "evidence_omitted_count": evidence_omitted_count,
+ "partial_coverage": partial_coverage,
+ }
+
+
+def evaluate_mixed_source_mode_outcome(mode, coverage_ledger):
+ """Apply the Phase 6 terminal failure policy to one aggregate-only ledger."""
+ normalized_mode = str(mode or "").strip().lower()
+ if normalized_mode not in MIXED_SOURCE_MODES:
+ raise ValueError(f"Unsupported mixed-source mode: {normalized_mode}")
+
+ coverage_ledger = coverage_ledger if isinstance(coverage_ledger, dict) else {}
+ entries = [
+ entry
+ for entry in list(coverage_ledger.get("entries") or [])
+ if isinstance(entry, dict)
+ ]
+ successful_statuses = {EVIDENCE_STATUS_COMPLETED, EVIDENCE_STATUS_PARTIAL}
+ successful_source_count = sum(
+ str(entry.get("status") or "").strip().lower() in successful_statuses
+ for entry in entries
+ )
+ partial_coverage = bool(coverage_ledger.get("partial_coverage"))
+ should_reduce = successful_source_count > 0
+ reason = None
+
+ if normalized_mode == "compare":
+ source_entry = next(
+ (
+ entry
+ for entry in entries
+ if str(entry.get("role") or "").strip().lower() in {"left", "source"}
+ ),
+ entries[0] if entries else None,
+ )
+ source_succeeded = bool(
+ source_entry
+ and str(source_entry.get("status") or "").strip().lower() in successful_statuses
+ )
+ target_entries = [entry for entry in entries if entry is not source_entry]
+ successful_target_count = sum(
+ str(entry.get("status") or "").strip().lower() in successful_statuses
+ for entry in target_entries
+ )
+ should_reduce = source_succeeded and successful_target_count > 0
+ if not source_succeeded:
+ reason = "source_preparation_failed"
+ elif not successful_target_count:
+ reason = "no_target_prepared"
+ partial_coverage = partial_coverage or successful_target_count < len(target_entries)
+
+ if not should_reduce:
+ status = EVIDENCE_STATUS_FAILED
+ reason = reason or "no_successful_source"
+ elif partial_coverage:
+ status = EVIDENCE_STATUS_PARTIAL
+ else:
+ status = EVIDENCE_STATUS_COMPLETED
+
+ return {
+ "mode": normalized_mode,
+ "status": status,
+ "should_reduce": should_reduce,
+ "successful_source_count": successful_source_count,
+ "partial_coverage": partial_coverage,
+ "reason": reason,
+ }
+
+
+def compare_reauthorized_source_manifests(execution_manifest, fresh_manifest):
+ """Return aggregate canonical-identity and version differences for authorized sources."""
+ fresh_by_document_id = {
+ str(source.get("document_id") or "").strip(): source
+ for source in list(fresh_manifest or [])
+ if isinstance(source, dict) and str(source.get("document_id") or "").strip()
+ }
+ authorization_failure_count = 0
+ source_version_changed_count = 0
+ for source in list(execution_manifest or []):
+ if (
+ not isinstance(source, dict)
+ or source.get("authorization_status") != AUTHORIZATION_STATUS_AUTHORIZED
+ ):
+ continue
+ document_id = str(source.get("document_id") or "").strip()
+ fresh_source = fresh_by_document_id.get(document_id) or {}
+ same_canonical_identity = (
+ fresh_source.get("authorization_status") == AUTHORIZATION_STATUS_AUTHORIZED
+ and str(fresh_source.get("scope") or "").strip().lower()
+ == str(source.get("scope") or "").strip().lower()
+ and str(fresh_source.get("scope_id") or "").strip()
+ == str(source.get("scope_id") or "").strip()
+ )
+ if not same_canonical_identity:
+ authorization_failure_count += 1
+ continue
+ prior_version = source.get("source_version")
+ fresh_version = fresh_source.get("source_version")
+ if prior_version != fresh_version:
+ source_version_changed_count += 1
+ return {
+ "authorization_failure_count": authorization_failure_count,
+ "source_version_changed_count": source_version_changed_count,
+ }
+
+
+def build_mixed_source_evidence_handoff(
+ manifest,
+ evidence_envelopes,
+ selection_mode,
+ mode=None,
+ telemetry_settings=None,
+ request_correlation_id=None,
+):
+ """Build one bounded synthesis handoff from Phase 1 evidence envelopes."""
+ normalized_selection_mode = normalize_selection_mode(
+ selection_mode,
+ default=SELECTION_MODE_RELEVANCE,
+ )
+ manifest_entries = [entry for entry in list(manifest or []) if isinstance(entry, dict)]
+ ledger = build_terminal_coverage_ledger(manifest_entries, evidence_envelopes)
+ envelopes = list(ledger["evidence_envelopes"])
+ source_coverage = []
+ for source_index, (entry, ledger_entry) in enumerate(
+ zip(manifest_entries, ledger["entries"]),
+ start=1,
+ ):
+ if entry.get("authorization_status") != AUTHORIZATION_STATUS_AUTHORIZED:
+ source_label = f"Unavailable selected source {source_index}"
+ elif entry.get("source_kind") == SOURCE_KIND_UNSUPPORTED:
+ source_label = str(entry.get("display_name") or f"Unsupported source {source_index}")
+ else:
+ source_label = str(entry.get("display_name") or f"Source {source_index}")
+ source_coverage.append({
+ "source": _truncate_utf8(source_label, 128),
+ "source_kind": ledger_entry.get("source_kind"),
+ "status": ledger_entry.get("status"),
+ "reason": ledger_entry.get("reason"),
+ })
+
+ coverage = {
+ "selection_mode": normalized_selection_mode,
+ **{
+ key: value
+ for key, value in ledger.items()
+ if key not in {"entries", "evidence_envelopes"}
+ },
+ "sources": source_coverage,
+ "terminal_ledger": ledger["entries"],
+ }
+ prompt_terminal_ledger = []
+ for entry, ledger_entry in zip(manifest_entries, ledger["entries"]):
+ prompt_entry = dict(ledger_entry)
+ if entry.get("authorization_status") != AUTHORIZATION_STATUS_AUTHORIZED:
+ prompt_entry.update({
+ "document_id": None,
+ "scope": None,
+ "scope_id": None,
+ "source_version": None,
+ })
+ prompt_terminal_ledger.append(prompt_entry)
+ prompt_coverage = dict(coverage)
+ prompt_coverage["terminal_ledger"] = prompt_terminal_ledger
+ payload = {
+ "coverage": prompt_coverage,
+ "evidence_envelopes": envelopes,
+ }
+ serialized_payload = json.dumps(
+ payload,
+ allow_nan=False,
+ ensure_ascii=False,
+ separators=(",", ":"),
+ )
+ if len(serialized_payload.encode("utf-8")) > MIXED_SOURCE_HANDOFF_MAX_BYTES:
+ payload["evidence_envelopes"] = [
+ {
+ "document_id": envelope.get("document_id"),
+ "source_kind": envelope.get("source_kind"),
+ "engine": envelope.get("engine"),
+ "status": envelope.get("status"),
+ "summary": _truncate_utf8(envelope.get("summary"), 512),
+ "coverage": {
+ "selection_mode": (envelope.get("coverage") or {}).get("selection_mode"),
+ "terminal": bool((envelope.get("coverage") or {}).get("terminal")),
+ "result_count": (envelope.get("coverage") or {}).get("result_count"),
+ "tool_call_count": (envelope.get("coverage") or {}).get("tool_call_count"),
+ },
+ }
+ for envelope in envelopes
+ ]
+ payload["coverage"]["handoff_compacted"] = True
+ payload["coverage"]["partial_coverage"] = True
+ payload["coverage"]["evidence_compacted_count"] = len(envelopes)
+ coverage["handoff_compacted"] = True
+ coverage["partial_coverage"] = True
+ coverage["evidence_compacted_count"] = len(envelopes)
+ serialized_payload = json.dumps(
+ payload,
+ allow_nan=False,
+ ensure_ascii=False,
+ separators=(",", ":"),
+ )
+ if len(serialized_payload.encode("utf-8")) > MIXED_SOURCE_HANDOFF_MAX_BYTES:
+ raise ValueError("Mixed-source evidence handoff exceeds its size bound")
+
+ partial_coverage_instruction = (
+ "State clearly that source coverage was partial and identify unavailable authorized source labels."
+ if coverage["partial_coverage"]
+ else "Do not claim that selected sources were omitted."
+ )
+ if mode:
+ emit_mixed_source_coverage_telemetry(
+ telemetry_settings,
+ mode,
+ coverage,
+ request_correlation_id=request_correlation_id,
+ )
+ return {
+ "role": "system",
+ "content": (
+ "Use the mixed-source evidence handoff below with the other bounded narrative excerpts "
+ "and computed tabular results. Synthesize one answer. Preserve narrative source citations "
+ "and tabular tool citations; do not convert computed table facts into unsupported narrative claims. "
+ "When selection_mode is selected, current selected-source evidence supersedes prior document "
+ "grounding; do not use prior source claims to fill missing current coverage. "
+ f"{partial_coverage_instruction}\n\n{serialized_payload}"
+ ),
+ "mixed_source_coverage": coverage,
+ "evidence_envelopes": list(payload["evidence_envelopes"]),
+ }
\ No newline at end of file
diff --git a/application/single_app/functions_search_service.py b/application/single_app/functions_search_service.py
index 58b9793b0..2dc18b832 100644
--- a/application/single_app/functions_search_service.py
+++ b/application/single_app/functions_search_service.py
@@ -12,7 +12,12 @@
from azure.cosmos.exceptions import CosmosResourceNotFoundError
from openai import AzureOpenAI
-from config import CLIENTS, cognitive_services_scope, cosmos_messages_container
+from config import (
+ CLIENTS,
+ cognitive_services_scope,
+ cosmos_conversations_container,
+ cosmos_messages_container,
+)
from functions_appinsights import log_event
from functions_debug import debug_print
from functions_documents import get_document_record, get_ordered_document_chunks
@@ -40,6 +45,9 @@
SUMMARY_MAX_WINDOW_SIZE = 50
CHAT_UPLOAD_CHUNK_WORD_SIZE = 400
CHAT_UPLOAD_CHUNK_WORD_OVERLAP = 40
+MIXED_SOURCE_TABULAR_CANDIDATE_TOP_N = 50
+MIXED_SOURCE_TABULAR_CANDIDATE_LIMIT = 100
+MIXED_SOURCE_TABULAR_EXTENSIONS = frozenset({".csv", ".xls", ".xlsx", ".xlsm"})
def _coerce_positive_int(value, default_value, min_value=1, max_value=None):
@@ -238,34 +246,101 @@ def _build_chat_upload_chunks(text_content, max_chunks=None):
return chunks
-def _resolve_chat_upload_context(document_id, conversation_id=None):
+def _authorize_chat_upload_conversation(user_id, conversation_id):
+ normalized_user_id = str(user_id or "").strip()
+ normalized_conversation_id = str(conversation_id or "").strip()
+ if not normalized_user_id or not normalized_conversation_id:
+ return False
+
+ try:
+ conversation_item = cosmos_conversations_container.read_item(
+ item=normalized_conversation_id,
+ partition_key=normalized_conversation_id,
+ )
+ except CosmosResourceNotFoundError:
+ return False
+ except Exception as exc:
+ log_event(
+ "[SearchService] Failed to authorize chat upload conversation.",
+ extra={"exception_type": type(exc).__name__},
+ level=logging.WARNING,
+ exceptionTraceback=True,
+ debug_only=True,
+ )
+ return False
+
+ return str(conversation_item.get("user_id") or "").strip() == normalized_user_id
+
+
+def _resolve_chat_upload_context(
+ document_id,
+ user_id=None,
+ conversation_id=None,
+ include_content=True,
+ authorization_prechecked=False,
+):
normalized_conversation_id = str(conversation_id or "").strip()
normalized_document_id = str(document_id or "").strip()
if not normalized_conversation_id or not normalized_document_id:
return None
+ if (
+ not authorization_prechecked
+ and not _authorize_chat_upload_conversation(user_id, normalized_conversation_id)
+ ):
+ return None
try:
- message_item = cosmos_messages_container.read_item(
- item=normalized_document_id,
- partition_key=normalized_conversation_id,
- )
+ if include_content:
+ message_item = cosmos_messages_container.read_item(
+ item=normalized_document_id,
+ partition_key=normalized_conversation_id,
+ )
+ else:
+ metadata_items = list(cosmos_messages_container.query_items(
+ query="""
+ SELECT TOP 1
+ c.id,
+ c.role,
+ c.filename,
+ c.title,
+ c.version,
+ c.metadata.is_user_upload AS is_user_upload,
+ c.metadata.is_generated_chat_artifact AS is_generated_chat_artifact,
+ c.metadata.generated_artifact_capability AS generated_artifact_capability,
+ c.metadata.generated_artifact_output_format AS generated_artifact_output_format
+ FROM c
+ WHERE c.id = @document_id
+ """,
+ parameters=[
+ {"name": "@document_id", "value": normalized_document_id},
+ ],
+ partition_key=normalized_conversation_id,
+ ))
+ if not metadata_items:
+ return None
+ message_item = metadata_items[0]
except CosmosResourceNotFoundError:
return None
except Exception as exc:
- debug_print(
- "[SearchService] Failed to resolve chat upload context | "
- f"document_id={normalized_document_id} | conversation_id={normalized_conversation_id} | error={exc}"
+ log_event(
+ "[SearchService] Failed to resolve authorized chat upload context.",
+ extra={"exception_type": type(exc).__name__},
+ level=logging.WARNING,
+ exceptionTraceback=True,
+ debug_only=True,
)
return None
role_name = str(message_item.get("role") or "").strip().lower()
metadata = message_item.get("metadata", {}) or {}
- is_uploaded_image = role_name == "image" and bool((message_item.get("metadata") or {}).get("is_user_upload"))
+ is_uploaded_image = role_name == "image" and bool(
+ metadata.get("is_user_upload") or message_item.get("is_user_upload")
+ )
if role_name not in {"file", "image"} or (role_name == "image" and not is_uploaded_image):
return None
- comparison_text = _coerce_chat_upload_text(message_item)
- if not comparison_text:
+ comparison_text = _coerce_chat_upload_text(message_item) if include_content else ""
+ if include_content and not comparison_text:
return None
message_title = str(message_item.get("filename") or message_item.get("title") or normalized_document_id).strip() or normalized_document_id
@@ -275,11 +350,24 @@ def _resolve_chat_upload_context(document_id, conversation_id=None):
"title": message_title,
"conversation_id": normalized_conversation_id,
"source_type": "chat_upload",
- "source_subtype": "generated_chat_artifact" if metadata.get("is_generated_chat_artifact") else "chat_upload",
- "artifact_capability": str(metadata.get("generated_artifact_capability") or "").strip().lower() or None,
- "artifact_output_format": str(metadata.get("generated_artifact_output_format") or "").strip().lower() or None,
- "comparison_text": comparison_text,
+ "source_subtype": "generated_chat_artifact" if (
+ metadata.get("is_generated_chat_artifact")
+ or message_item.get("is_generated_chat_artifact")
+ ) else "chat_upload",
+ "artifact_capability": str(
+ metadata.get("generated_artifact_capability")
+ or message_item.get("generated_artifact_capability")
+ or ""
+ ).strip().lower() or None,
+ "artifact_output_format": str(
+ metadata.get("generated_artifact_output_format")
+ or message_item.get("generated_artifact_output_format")
+ or ""
+ ).strip().lower() or None,
+ "version": message_item.get("version"),
}
+ if include_content:
+ resolved_document["comparison_text"] = comparison_text
return {
"scope": "chat",
"group_id": None,
@@ -289,6 +377,59 @@ def _resolve_chat_upload_context(document_id, conversation_id=None):
}
+def _resolve_personal_document_context(document_id, user_id):
+ personal_document = get_document_record(
+ user_id=user_id,
+ document_id=document_id,
+ )
+ if not personal_document:
+ return None
+ return {
+ "scope": "personal",
+ "group_id": None,
+ "public_workspace_id": None,
+ "document": personal_document,
+ }
+
+
+def _resolve_group_document_context(document_id, user_id, authorized_group_ids):
+ for group_id in authorized_group_ids or []:
+ group_document = get_document_record(
+ user_id=user_id,
+ document_id=document_id,
+ group_id=group_id,
+ )
+ if group_document:
+ return {
+ "scope": "group",
+ "group_id": group_id,
+ "public_workspace_id": None,
+ "document": group_document,
+ }
+ return None
+
+
+def _resolve_public_document_context(
+ document_id,
+ user_id,
+ authorized_public_workspace_ids,
+):
+ for public_workspace_id in authorized_public_workspace_ids or []:
+ public_document = get_document_record(
+ user_id=user_id,
+ document_id=document_id,
+ public_workspace_id=public_workspace_id,
+ )
+ if public_document:
+ return {
+ "scope": "public",
+ "group_id": None,
+ "public_workspace_id": public_workspace_id,
+ "document": public_document,
+ }
+ return None
+
+
def resolve_document_context(
document_id,
user_id,
@@ -296,59 +437,45 @@ def resolve_document_context(
active_group_ids=None,
active_public_workspace_id=None,
conversation_id=None,
+ include_content=True,
):
normalized_scope = normalize_search_scope(doc_scope)
if normalized_scope in ("all", "personal"):
- personal_document = get_document_record(user_id=user_id, document_id=document_id)
- if personal_document:
- return {
- "scope": "personal",
- "group_id": None,
- "public_workspace_id": None,
- "document": personal_document,
- }
+ personal_context = _resolve_personal_document_context(document_id, user_id)
+ if personal_context:
+ return personal_context
if normalized_scope in ("all", "group"):
- for group_id in _resolve_active_group_ids(
+ group_context = _resolve_group_document_context(
+ document_id,
user_id,
- active_group_ids=active_group_ids,
- fallback_to_memberships=True,
- ):
- group_document = get_document_record(
- user_id=user_id,
- document_id=document_id,
- group_id=group_id,
- )
- if group_document:
- return {
- "scope": "group",
- "group_id": group_id,
- "public_workspace_id": None,
- "document": group_document,
- }
+ _resolve_active_group_ids(
+ user_id,
+ active_group_ids=active_group_ids,
+ fallback_to_memberships=True,
+ ),
+ )
+ if group_context:
+ return group_context
if normalized_scope in ("all", "public"):
- for public_workspace_id in _resolve_public_workspace_ids(
+ public_context = _resolve_public_document_context(
+ document_id,
user_id,
- active_public_workspace_id=active_public_workspace_id,
- ):
- public_document = get_document_record(
- user_id=user_id,
- document_id=document_id,
- public_workspace_id=public_workspace_id,
- )
- if public_document:
- return {
- "scope": "public",
- "group_id": None,
- "public_workspace_id": public_workspace_id,
- "document": public_document,
- }
+ _resolve_public_workspace_ids(
+ user_id,
+ active_public_workspace_id=active_public_workspace_id,
+ ),
+ )
+ if public_context:
+ return public_context
chat_upload_context = _resolve_chat_upload_context(
document_id=document_id,
+ user_id=user_id,
conversation_id=conversation_id,
+ include_content=include_content,
)
if chat_upload_context:
return chat_upload_context
@@ -356,6 +483,68 @@ def resolve_document_context(
return None
+def resolve_document_contexts(
+ document_ids,
+ user_id,
+ doc_scope="all",
+ active_group_ids=None,
+ active_public_workspace_id=None,
+ conversation_id=None,
+ include_content=True,
+):
+ """Resolve ordered document contexts using one current authorization snapshot."""
+ normalized_scope = normalize_search_scope(doc_scope)
+ normalized_document_ids = normalize_search_id_list(document_ids)
+ authorized_group_ids = []
+ if normalized_scope in ("all", "group"):
+ authorized_group_ids = _resolve_active_group_ids(
+ user_id,
+ active_group_ids=active_group_ids,
+ fallback_to_memberships=True,
+ )
+ authorized_public_workspace_ids = []
+ if normalized_scope in ("all", "public"):
+ authorized_public_workspace_ids = _resolve_public_workspace_ids(
+ user_id,
+ active_public_workspace_id=active_public_workspace_id,
+ )
+
+ normalized_conversation_id = str(conversation_id or "").strip()
+ chat_conversation_authorized = bool(
+ normalized_conversation_id
+ and _authorize_chat_upload_conversation(user_id, normalized_conversation_id)
+ )
+
+ resolved_contexts = []
+ for document_id in normalized_document_ids:
+ document_context = None
+ if normalized_scope in ("all", "personal"):
+ document_context = _resolve_personal_document_context(document_id, user_id)
+ if not document_context and normalized_scope in ("all", "group"):
+ document_context = _resolve_group_document_context(
+ document_id,
+ user_id,
+ authorized_group_ids,
+ )
+ if not document_context and normalized_scope in ("all", "public"):
+ document_context = _resolve_public_document_context(
+ document_id,
+ user_id,
+ authorized_public_workspace_ids,
+ )
+ if not document_context and chat_conversation_authorized:
+ document_context = _resolve_chat_upload_context(
+ document_id=document_id,
+ user_id=user_id,
+ conversation_id=normalized_conversation_id,
+ include_content=include_content,
+ authorization_prechecked=True,
+ )
+ resolved_contexts.append(document_context)
+
+ return resolved_contexts
+
+
def build_search_request(
query,
user_id,
@@ -367,6 +556,7 @@ def build_search_request(
active_group_ids=None,
active_public_workspace_id=None,
enable_file_sharing=True,
+ include_all_public_workspaces=False,
):
normalized_query = str(query or "").strip()
if not normalized_query:
@@ -406,7 +596,11 @@ def build_search_request(
active_public_workspace_id=active_public_workspace_id,
)
if resolved_public_workspace_ids and normalized_scope in ("all", "public"):
- search_request["active_public_workspace_id"] = resolved_public_workspace_ids[0]
+ search_request["active_public_workspace_id"] = (
+ resolved_public_workspace_ids
+ if include_all_public_workspaces
+ else resolved_public_workspace_ids[0]
+ )
return search_request
@@ -422,6 +616,7 @@ def search_documents(
active_group_ids=None,
active_public_workspace_id=None,
enable_file_sharing=True,
+ include_all_public_workspaces=False,
):
search_request = build_search_request(
query=query,
@@ -434,6 +629,7 @@ def search_documents(
active_group_ids=active_group_ids,
active_public_workspace_id=active_public_workspace_id,
enable_file_sharing=enable_file_sharing,
+ include_all_public_workspaces=include_all_public_workspaces,
)
results = hybrid_search(**search_request) or []
unique_document_ids = {
@@ -456,6 +652,70 @@ def search_documents(
}
+def search_relevant_tabular_candidates(
+ query,
+ user_id,
+ doc_scope="all",
+ document_ids=None,
+ tags_filter=None,
+ active_group_ids=None,
+ active_public_workspace_id=None,
+ max_candidates=MIXED_SOURCE_TABULAR_CANDIDATE_LIMIT,
+):
+ """Find a bounded set of authorized table candidates from indexed schema chunks."""
+ normalized_limit = _coerce_positive_int(
+ max_candidates,
+ MIXED_SOURCE_TABULAR_CANDIDATE_LIMIT,
+ min_value=1,
+ max_value=MIXED_SOURCE_TABULAR_CANDIDATE_LIMIT,
+ )
+ candidate_query = (
+ f"{str(query or '').strip()}\n"
+ "Relevant spreadsheet, workbook, worksheet, CSV, table schema, columns, and data fields."
+ ).strip()
+ search_result = search_documents(
+ query=candidate_query,
+ user_id=user_id,
+ top_n=MIXED_SOURCE_TABULAR_CANDIDATE_TOP_N,
+ doc_scope=doc_scope,
+ document_ids=document_ids,
+ tags_filter=tags_filter,
+ active_group_ids=active_group_ids,
+ active_public_workspace_id=active_public_workspace_id,
+ include_all_public_workspaces=True,
+ )
+
+ candidate_document_ids = []
+ seen_document_ids = set()
+ for result in search_result.get("results") or []:
+ file_name = str(result.get("file_name") or "").strip()
+ if os.path.splitext(file_name)[1].lower() not in MIXED_SOURCE_TABULAR_EXTENSIONS:
+ continue
+ document_id = str(result.get("document_id") or "").strip()
+ if not document_id or document_id in seen_document_ids:
+ continue
+ seen_document_ids.add(document_id)
+ candidate_document_ids.append(document_id)
+ if len(candidate_document_ids) >= normalized_limit:
+ break
+
+ log_event(
+ "[MixedSourceChatSearch] Completed bounded authorized tabular candidate search.",
+ extra={
+ "candidate_search_result_count": search_result.get("result_count", 0),
+ "tabular_candidate_count": len(candidate_document_ids),
+ "candidate_limit": normalized_limit,
+ },
+ level=logging.INFO,
+ )
+ return {
+ "document_ids": candidate_document_ids,
+ "candidate_count": len(candidate_document_ids),
+ "search_result_count": search_result.get("result_count", 0),
+ "query": search_result.get("query"),
+ }
+
+
def _derive_window_size(chunks, window_unit, window_size=None, window_percent=None):
if not chunks:
return 0
diff --git a/application/single_app/functions_settings.py b/application/single_app/functions_settings.py
index c225b6434..85c517f28 100644
--- a/application/single_app/functions_settings.py
+++ b/application/single_app/functions_settings.py
@@ -329,6 +329,50 @@ def is_tabular_processing_enabled(settings):
return bool((settings or {}).get('enable_enhanced_citations', False))
+def is_mixed_source_manifest_enabled(settings):
+ """Return whether Phase 1 mixed-source manifest diagnostics are enabled."""
+ return bool((settings or {}).get('enable_mixed_source_manifest', False))
+
+
+def is_mixed_source_development_telemetry_enabled(settings):
+ """Return whether aggregate-only mixed-source development telemetry is enabled."""
+ return bool((settings or {}).get('enable_mixed_source_development_telemetry', False))
+
+
+def is_mixed_source_chat_search_enabled(settings):
+ """Return whether Phase 2 mixed-source Chat and Search behavior is enabled."""
+ return bool((settings or {}).get('enable_mixed_source_chat_search', False))
+
+
+def is_mixed_source_conversation_continuity_enabled(settings):
+ """Return whether Phase 5 reauthorized source-continuity metadata is enabled."""
+ return bool(
+ (settings or {}).get('enable_mixed_source_chat_search', False)
+ and (settings or {}).get('enable_mixed_source_conversation_continuity', False)
+ )
+
+
+def is_cross_format_compare_enabled(settings):
+ """Return whether Phase 4 native mixed-source Compare behavior is enabled."""
+ return bool((settings or {}).get('enable_cross_format_compare', False))
+
+
+def is_cross_format_compare_one_to_many_enabled(settings):
+ """Return whether the separately staged one-to-many mixed-target rollout is enabled."""
+ return bool(
+ (settings or {}).get('enable_cross_format_compare', False)
+ and (settings or {}).get('enable_cross_format_compare_one_to_many', False)
+ )
+
+
+def is_mixed_source_relevance_candidates_enabled(settings):
+ """Return whether Phase 2 relevance-mode table candidates are enabled."""
+ return bool(
+ (settings or {}).get('enable_mixed_source_chat_search', False)
+ and (settings or {}).get('enable_mixed_source_relevance_candidates', False)
+ )
+
+
CHAT_FILE_UPLOAD_APP_ROLE = "ChatFileUploadUser"
WORKFLOW_USER_APP_ROLE = "WorkflowUser"
DOCUMENT_INTELLIGENCE_PDF_IMAGE_EXTRACTION_MODES = {"read", "layout", "auto"}
@@ -878,6 +922,13 @@ def get_settings(use_cosmos=False, include_source=False):
'enable_fact_memory_plugin': True,
'enable_tabular_processing_plugin': False,
'enable_multi_agent_orchestration': False,
+ 'enable_mixed_source_development_telemetry': False,
+ 'enable_mixed_source_manifest': False,
+ 'enable_mixed_source_chat_search': False,
+ 'enable_mixed_source_relevance_candidates': False,
+ 'enable_mixed_source_conversation_continuity': False,
+ 'enable_cross_format_compare': False,
+ 'enable_cross_format_compare_one_to_many': False,
'max_rounds_per_agent': 1,
'workflow_max_auto_invoke_attempts': 60,
'enable_semantic_kernel': False,
diff --git a/application/single_app/functions_simplechat_operations.py b/application/single_app/functions_simplechat_operations.py
index 9bce1a37b..d132bd003 100644
--- a/application/single_app/functions_simplechat_operations.py
+++ b/application/single_app/functions_simplechat_operations.py
@@ -1223,6 +1223,70 @@ def upload_generated_analysis_artifact_for_current_user(
)
+def delete_generated_chat_artifact_for_current_user(
+ conversation_id: str,
+ artifact_message_id: str,
+) -> bool:
+ """Delete one generated artifact after reauthorizing its conversation and stored identity."""
+ current_user_info = _require_current_user_info()
+ current_user_id = str(current_user_info.get("userId") or "").strip()
+ return delete_generated_chat_artifact_for_user(
+ current_user_id,
+ conversation_id,
+ artifact_message_id,
+ )
+
+
+def delete_generated_chat_artifact_for_user(
+ current_user_id: str,
+ conversation_id: str,
+ artifact_message_id: str,
+) -> bool:
+ """Delete one generated artifact for a known user after object-level authorization."""
+ current_user_id = str(current_user_id or "").strip()
+ normalized_conversation_id = str(conversation_id or "").strip()
+ normalized_message_id = str(artifact_message_id or "").strip()
+ if not current_user_id or not normalized_conversation_id or not normalized_message_id:
+ return False
+
+ try:
+ conversation_item = cosmos_conversations_container.read_item(
+ item=normalized_conversation_id,
+ partition_key=normalized_conversation_id,
+ )
+ message_item = cosmos_messages_container.read_item(
+ item=normalized_message_id,
+ partition_key=normalized_conversation_id,
+ )
+ except CosmosResourceNotFoundError:
+ return False
+
+ if str(conversation_item.get("user_id") or "").strip() != current_user_id:
+ raise PermissionError("Forbidden")
+ message_metadata = message_item.get("metadata") if isinstance(message_item.get("metadata"), dict) else {}
+ if (
+ str(message_item.get("conversation_id") or "").strip() != normalized_conversation_id
+ or message_item.get("role") != "file"
+ or not message_metadata.get("is_generated_chat_artifact")
+ ):
+ raise PermissionError("Forbidden")
+
+ delete_blob_backed_chat_message_files([message_item])
+ cosmos_messages_container.delete_item(
+ item=normalized_message_id,
+ partition_key=normalized_conversation_id,
+ )
+ log_event(
+ "[SimpleChat] Generated chat artifact rolled back after cancellation",
+ {
+ "artifact_count": 1,
+ "rollback_reason": "cancellation",
+ },
+ debug_only=True,
+ )
+ return True
+
+
def upload_generated_analysis_artifact_for_user(
current_user_id: str,
conversation_id: str,
@@ -1280,49 +1344,111 @@ def upload_generated_analysis_artifact_for_user(
)
+def upload_generated_analysis_artifact_stream_for_user(
+ current_user_id: str,
+ conversation_id: str,
+ file_name: str,
+ file_stream: Any,
+ file_size: int,
+ capability: str = "analysis",
+ output_format: str = "",
+ summary: str = "",
+ artifact_idempotency_key: str = "",
+) -> Dict[str, Any]:
+ """Upload a bounded-memory generated artifact stream for an authorized user."""
+ normalized_user_id = str(current_user_id or "").strip()
+ normalized_conversation_id = str(conversation_id or "").strip()
+ normalized_file_name = _normalize_generated_document_file_name(file_name)
+ normalized_capability = str(capability or "analysis").strip().lower() or "analysis"
+ normalized_output_format = str(output_format or "").strip().lower() or os.path.splitext(normalized_file_name)[1].lower().lstrip(".")
+ normalized_summary = str(summary or "").strip()
+
+ if not normalized_user_id:
+ raise ValueError("current_user_id is required")
+ if not normalized_conversation_id:
+ raise ValueError("conversation_id is required")
+ if not hasattr(file_stream, "read") or not hasattr(file_stream, "seek"):
+ raise ValueError("file_stream must be seekable and readable")
+ if not allowed_file(normalized_file_name):
+ raise ValueError("Generated file type is not supported")
+
+ normalized_file_size = max(0, int(file_size or 0))
+ if normalized_file_size <= 0:
+ raise ValueError("Generated artifact is empty")
+
+ settings = get_settings()
+ max_artifact_size_mb = settings.get("max_generated_chat_artifact_size_mb", 500)
+ try:
+ max_artifact_size_mb = max(1, int(max_artifact_size_mb))
+ except (TypeError, ValueError):
+ max_artifact_size_mb = 500
+
+ max_artifact_size_bytes = max_artifact_size_mb * 1024 * 1024
+ if normalized_file_size > max_artifact_size_bytes:
+ raise ValueError(
+ f"Generated artifact exceeds the {max_artifact_size_mb} MB size limit"
+ )
+
+ file_stream.seek(0)
+ return _upload_generated_chat_artifact_for_current_user(
+ current_user_id=normalized_user_id,
+ conversation_id=normalized_conversation_id,
+ normalized_file_name=normalized_file_name,
+ file_content_bytes=file_stream,
+ artifact_metadata={
+ "capability": normalized_capability,
+ "output_format": normalized_output_format,
+ "summary": normalized_summary,
+ },
+ artifact_idempotency_key=artifact_idempotency_key,
+ )
+
+
def delete_blob_backed_chat_message_files(
messages: Iterable[Dict[str, Any]],
raise_on_error: bool = False,
) -> int:
"""Delete blob-backed chat files referenced by the provided message documents."""
- blob_targets = []
- seen_targets = set()
+ blob_service_client = CLIENTS.get("storage_account_office_docs_client")
+ if not blob_service_client:
+ if raise_on_error:
+ raise RuntimeError("Blob storage client is unavailable for chat file cleanup")
+ return 0
+
+ deleted_count = 0
+ deleted_targets = set()
+
for message in messages or []:
if not isinstance(message, dict):
continue
+
if str(message.get("file_content_source") or "").strip().lower() != "blob":
continue
blob_container = str(message.get("blob_container") or "").strip()
blob_path = str(message.get("blob_path") or "").strip()
- target = (blob_container, blob_path)
- if not blob_container or not blob_path or target in seen_targets:
+ if not blob_container or not blob_path:
continue
- seen_targets.add(target)
- blob_targets.append(target)
-
- blob_service_client = CLIENTS.get("storage_account_office_docs_client")
- if not blob_service_client:
- if raise_on_error and blob_targets:
- raise RuntimeError("Blob storage client is unavailable for chat file cleanup")
- return 0
+ target = (blob_container, blob_path)
+ if target in deleted_targets:
+ continue
- deleted_count = 0
- cleanup_errors = []
- for blob_container, blob_path in blob_targets:
try:
blob_client = blob_service_client.get_blob_client(
container=blob_container,
blob=blob_path,
)
if not blob_client.exists():
+ deleted_targets.add(target)
continue
blob_client.delete_blob()
+ deleted_targets.add(target)
deleted_count += 1
except Exception as exc:
- cleanup_errors.append((blob_container, blob_path, exc))
+ if raise_on_error:
+ raise
log_event(
"[SimpleChat] Failed to delete blob-backed chat file",
{
@@ -1333,11 +1459,6 @@ def delete_blob_backed_chat_message_files(
debug_only=True,
)
- if cleanup_errors and raise_on_error:
- raise RuntimeError(
- f"Failed to delete {len(cleanup_errors)} blob-backed chat file(s)"
- ) from cleanup_errors[0][2]
-
return deleted_count
@@ -1842,7 +1963,7 @@ def search_directory_users(query: str, limit: int = 10) -> List[Dict[str, str]]:
f"or startswith(mail, '{escaped_query}') "
f"or startswith(userPrincipalName, '{escaped_query}')"
),
- "$top": max(1, min(int(limit or 10), 25)),
+ "$top": max(1, min(int(limit or 10), 50)),
"$select": "id,displayName,mail,userPrincipalName",
},
)
@@ -2366,6 +2487,7 @@ def _upload_generated_chat_artifact_for_current_user(
normalized_file_name: str,
file_content_bytes: bytes,
artifact_metadata: Optional[Dict[str, Any]] = None,
+ artifact_idempotency_key: str = "",
) -> Dict[str, Any]:
try:
conversation_item = cosmos_conversations_container.read_item(
@@ -2382,7 +2504,15 @@ def _upload_generated_chat_artifact_for_current_user(
if not blob_service_client:
raise RuntimeError("Blob storage client not available")
- artifact_message_id = f"{conversation_id}_generated_file_{uuid.uuid4().hex}"
+ normalized_idempotency_key = str(artifact_idempotency_key or "").strip()
+ if normalized_idempotency_key:
+ artifact_suffix = uuid.uuid5(
+ uuid.NAMESPACE_URL,
+ f"simplechat-generated-artifact:{conversation_id}:{normalized_idempotency_key}",
+ ).hex
+ else:
+ artifact_suffix = uuid.uuid4().hex
+ artifact_message_id = f"{conversation_id}_generated_file_{artifact_suffix}"
blob_path = (
f"{current_user_id}/{conversation_id}/generated/"
f"{artifact_message_id}/{normalized_file_name}"
@@ -2391,6 +2521,33 @@ def _upload_generated_chat_artifact_for_current_user(
container=storage_account_personal_chat_container_name,
blob=blob_path,
)
+ if normalized_idempotency_key:
+ try:
+ existing_message = cosmos_messages_container.read_item(
+ item=artifact_message_id,
+ partition_key=conversation_id,
+ )
+ except CosmosResourceNotFoundError:
+ existing_message = None
+ if (
+ isinstance(existing_message, dict)
+ and existing_message.get("role") == "file"
+ and existing_message.get("filename") == normalized_file_name
+ and existing_message.get("blob_path") == blob_path
+ and blob_client.exists()
+ ):
+ existing_metadata = existing_message.get("metadata") or {}
+ return {
+ "message": {
+ "id": artifact_message_id,
+ "file_name": normalized_file_name,
+ "blob_container": storage_account_personal_chat_container_name,
+ "blob_path": blob_path,
+ "capability": existing_metadata.get("generated_artifact_capability") or "analysis",
+ "output_format": existing_metadata.get("generated_artifact_output_format") or "",
+ },
+ "conversation_id": conversation_id,
+ }
blob_client.upload_blob(
file_content_bytes,
overwrite=True,
@@ -2398,6 +2555,7 @@ def _upload_generated_chat_artifact_for_current_user(
"conversation_id": conversation_id,
"user_id": current_user_id,
"generated_artifact": "true",
+ "idempotent_artifact": str(bool(normalized_idempotency_key)).lower(),
},
)
@@ -2427,6 +2585,7 @@ def _upload_generated_chat_artifact_for_current_user(
"generated_artifact_capability": artifact_capability,
"generated_artifact_output_format": artifact_output_format,
"generated_artifact_summary": artifact_summary,
+ "generated_artifact_idempotency_key": normalized_idempotency_key or None,
"thread_info": {
"thread_id": current_thread_id,
"previous_thread_id": previous_thread_id,
diff --git a/application/single_app/functions_tabular_analysis.py b/application/single_app/functions_tabular_analysis.py
index ae6421c4e..f18cfac58 100644
--- a/application/single_app/functions_tabular_analysis.py
+++ b/application/single_app/functions_tabular_analysis.py
@@ -2,35 +2,39 @@
"""
Reusable tabular analysis helpers for chat and workflow execution.
-This module is the non-route import surface for tabular analysis behavior that
-is still implemented in route_backend_chats.py. Keeping workflow code pointed at
-this module lets the implementation move out of the chat route incrementally
-without changing workflow callers again.
+This module owns extracted tabular coordination helpers and provides the
+non-route import surface for behavior still implemented in
+route_backend_chats.py. Keeping workflow code pointed here lets the remaining
+implementation move incrementally without changing workflow callers again.
"""
def _load_chat_helper(helper_name):
# Import lazily because route_backend_chats imports functions_workflow_runner during app startup.
from route_backend_chats import (
+ _execute_mixed_source_tabular_evidence,
augment_tabular_invocations_with_related_document_evidence,
build_tabular_computed_results_system_message,
build_tabular_related_document_evidence_summary,
- get_new_plugin_invocations,
maybe_create_tabular_generated_output,
run_tabular_analysis_with_thought_tracking,
)
helpers = {
+ 'execute_mixed_source_tabular_evidence': _execute_mixed_source_tabular_evidence,
'augment_tabular_invocations_with_related_document_evidence': augment_tabular_invocations_with_related_document_evidence,
'build_tabular_computed_results_system_message': build_tabular_computed_results_system_message,
'build_tabular_related_document_evidence_summary': build_tabular_related_document_evidence_summary,
- 'get_new_plugin_invocations': get_new_plugin_invocations,
'maybe_create_tabular_generated_output': maybe_create_tabular_generated_output,
'run_tabular_analysis_with_thought_tracking': run_tabular_analysis_with_thought_tracking,
}
return helpers[helper_name]
+def execute_mixed_source_tabular_evidence(*args, **kwargs):
+ return _load_chat_helper('execute_mixed_source_tabular_evidence')(*args, **kwargs)
+
+
def augment_tabular_invocations_with_related_document_evidence(*args, **kwargs):
return _load_chat_helper('augment_tabular_invocations_with_related_document_evidence')(*args, **kwargs)
@@ -43,8 +47,18 @@ def build_tabular_related_document_evidence_summary(*args, **kwargs):
return _load_chat_helper('build_tabular_related_document_evidence_summary')(*args, **kwargs)
-def get_new_plugin_invocations(*args, **kwargs):
- return _load_chat_helper('get_new_plugin_invocations')(*args, **kwargs)
+def get_new_plugin_invocations(invocations, baseline_count):
+ """Return only the plugin invocations created after the baseline count."""
+ if not invocations:
+ return []
+
+ if baseline_count <= 0:
+ return list(invocations)
+
+ if baseline_count >= len(invocations):
+ return []
+
+ return list(invocations[baseline_count:])
async def maybe_create_tabular_generated_output(*args, **kwargs):
diff --git a/application/single_app/functions_tabular_csv_query.py b/application/single_app/functions_tabular_csv_query.py
new file mode 100644
index 000000000..a8915fb4d
--- /dev/null
+++ b/application/single_app/functions_tabular_csv_query.py
@@ -0,0 +1,204 @@
+# functions_tabular_csv_query.py
+"""Shared bounded CSV query evaluation for foreground tools and durable exports."""
+
+import ast
+
+import pandas
+
+
+TABULAR_ROW_LOCAL_QUERY_AST_NODES = (
+ ast.Expression,
+ ast.BoolOp,
+ ast.BinOp,
+ ast.UnaryOp,
+ ast.Compare,
+ ast.Name,
+ ast.Load,
+ ast.Constant,
+ ast.List,
+ ast.Tuple,
+ ast.Set,
+ ast.And,
+ ast.Or,
+ ast.Not,
+ ast.Eq,
+ ast.NotEq,
+ ast.Lt,
+ ast.LtE,
+ ast.Gt,
+ ast.GtE,
+ ast.In,
+ ast.NotIn,
+ ast.Is,
+ ast.IsNot,
+ ast.Add,
+ ast.Sub,
+ ast.Mult,
+ ast.Div,
+ ast.FloorDiv,
+ ast.Mod,
+ ast.Pow,
+ ast.BitAnd,
+ ast.BitOr,
+ ast.BitXor,
+ ast.USub,
+ ast.UAdd,
+ ast.Invert,
+)
+
+
+def _replace_backtick_column_references(expression):
+ """Replace pandas backtick column labels with parseable placeholder names."""
+ output = []
+ placeholder_index = 0
+ character_index = 0
+ active_quote = None
+ escaped = False
+ while character_index < len(expression):
+ character = expression[character_index]
+ if escaped:
+ output.append(character)
+ escaped = False
+ character_index += 1
+ continue
+ if character == '\\' and active_quote:
+ output.append(character)
+ escaped = True
+ character_index += 1
+ continue
+ if active_quote:
+ output.append(character)
+ if character == active_quote:
+ active_quote = None
+ character_index += 1
+ continue
+ if character in {'\'', '"'}:
+ active_quote = character
+ output.append(character)
+ character_index += 1
+ continue
+ if character != '`':
+ output.append(character)
+ character_index += 1
+ continue
+
+ closing_index = expression.find('`', character_index + 1)
+ if closing_index < 0 or not expression[character_index + 1:closing_index].strip():
+ raise ValueError('Source query contains an invalid backtick column reference')
+ output.append(f'__simplechat_column_{placeholder_index}')
+ placeholder_index += 1
+ character_index = closing_index + 1
+
+ return ''.join(output)
+
+
+def validate_tabular_csv_query_expression(query_expression):
+ """Return a row-local expression or reject operations that change across chunks."""
+ normalized_expression = str(query_expression or '').strip()
+ if not normalized_expression:
+ raise ValueError('Source query expression is required')
+ if '@' in normalized_expression:
+ raise ValueError(
+ 'Source query variables cannot be replayed in bounded chunks'
+ )
+
+ parseable_expression = _replace_backtick_column_references(normalized_expression)
+ try:
+ parsed_expression = ast.parse(parseable_expression, mode='eval')
+ except SyntaxError as exc:
+ raise ValueError('Source query is not a valid row-local expression') from exc
+
+ unsupported_nodes = [
+ node.__class__.__name__
+ for node in ast.walk(parsed_expression)
+ if not isinstance(node, TABULAR_ROW_LOCAL_QUERY_AST_NODES)
+ ]
+ if unsupported_nodes:
+ raise ValueError(
+ 'Source query uses an operation that cannot be replayed equivalently in bounded chunks: '
+ f'{unsupported_nodes[0]}'
+ )
+ for node in ast.walk(parsed_expression):
+ if isinstance(node, ast.Name) and node.id.startswith('__') and not node.id.startswith('__simplechat_column_'):
+ raise ValueError('Source query contains an unsupported private identifier')
+ return normalized_expression
+
+
+def detect_tabular_csv_numeric_columns(csv_stream, source_chunk_rows, tabular_plugin):
+ """Find columns that pandas can convert to numeric across every bounded chunk."""
+ numeric_columns = None
+ csv_stream.seek(0)
+ for source_chunk in pandas.read_csv(
+ csv_stream,
+ keep_default_na=False,
+ dtype=str,
+ chunksize=max(1, int(source_chunk_rows or 1)),
+ ):
+ source_chunk = tabular_plugin._normalize_dataframe_columns(source_chunk)
+ if numeric_columns is None:
+ numeric_columns = set(source_chunk.columns)
+ for column_name in list(numeric_columns):
+ try:
+ pandas.to_numeric(source_chunk[column_name])
+ except (TypeError, ValueError):
+ numeric_columns.discard(column_name)
+ csv_stream.seek(0)
+ return numeric_columns or set()
+
+
+def iter_tabular_csv_query_rows(
+ csv_stream,
+ query_expression,
+ return_columns,
+ source_chunk_rows,
+ tabular_plugin,
+ start_source_row=0,
+ replay_stats=None,
+):
+ """Yield physical source row numbers and query-matched records in bounded chunks."""
+ source_chunk_rows = max(1, int(source_chunk_rows or 1))
+ start_source_row = max(0, int(start_source_row or 0))
+ normalized_query_expression = validate_tabular_csv_query_expression(query_expression)
+ numeric_columns = detect_tabular_csv_numeric_columns(
+ csv_stream,
+ source_chunk_rows,
+ tabular_plugin,
+ )
+ parsed_return_columns = tabular_plugin._parse_optional_column_list_argument(return_columns)
+
+ csv_stream.seek(0)
+ read_options = {
+ 'keep_default_na': False,
+ 'dtype': str,
+ 'chunksize': source_chunk_rows,
+ }
+ if start_source_row:
+ read_options['skiprows'] = lambda row_index: 0 < row_index <= start_source_row
+
+ source_row_offset = start_source_row
+ for source_chunk in pandas.read_csv(csv_stream, **read_options):
+ source_chunk = tabular_plugin._normalize_dataframe_columns(source_chunk)
+ source_chunk.index = range(source_row_offset, source_row_offset + len(source_chunk))
+ source_row_offset += len(source_chunk)
+ for column_name in numeric_columns:
+ if column_name in source_chunk.columns:
+ source_chunk[column_name] = pandas.to_numeric(source_chunk[column_name])
+
+ filtered_chunk, used_reviewer_style_fallback = tabular_plugin._apply_query_expression_with_fallback(
+ source_chunk,
+ query_expression=normalized_query_expression,
+ normalize_match=False,
+ )
+ if isinstance(replay_stats, dict) and used_reviewer_style_fallback:
+ replay_stats['used_reviewer_style_fallback'] = True
+ selected_columns = [
+ column_name
+ for column_name in (parsed_return_columns or list(filtered_chunk.columns))
+ if column_name in filtered_chunk.columns
+ ]
+ output_records = tabular_plugin._build_row_output_records(
+ filtered_chunk,
+ selected_columns,
+ )
+ for source_row_index, output_record in zip(filtered_chunk.index, output_records):
+ yield int(source_row_index) + 1, output_record
diff --git a/application/single_app/functions_tabular_generated_exports.py b/application/single_app/functions_tabular_generated_exports.py
index 91700a347..62c53875a 100644
--- a/application/single_app/functions_tabular_generated_exports.py
+++ b/application/single_app/functions_tabular_generated_exports.py
@@ -2,40 +2,57 @@
"""Durable background runs for large tabular generated exports."""
import asyncio
+from collections import Counter
import csv
import io
import json
import logging
+import math
import os
import re
import socket
+import tempfile
import time
import uuid
from datetime import datetime, timedelta, timezone
from azure.core import MatchConditions
+from azure.core.exceptions import ResourceExistsError
from azure.cosmos.exceptions import CosmosResourceNotFoundError
from flask import current_app, has_app_context
from semantic_kernel.connectors.ai.open_ai.prompt_execution_settings.azure_chat_prompt_execution_settings import AzureChatPromptExecutionSettings
from semantic_kernel.contents.chat_history import ChatHistory as SKChatHistory
+from semantic_kernel_plugins.tabular_processing_plugin import TabularProcessingPlugin
from config import (
CLIENTS,
+ cosmos_conversations_container,
cosmos_tabular_export_runs_container,
+ storage_account_group_documents_container_name,
storage_account_personal_chat_container_name,
+ storage_account_public_documents_container_name,
+ storage_account_user_documents_container_name,
)
from functions_appinsights import log_event
+from functions_assistant_table_exports import build_safe_csv_headers, neutralize_csv_spreadsheet_formula
+from functions_tabular_csv_query import (
+ iter_tabular_csv_query_rows,
+ validate_tabular_csv_query_expression,
+)
+from functions_group import assert_group_role
from functions_generated_file_exports import (
normalize_generated_output_format,
serialize_generated_json,
serialize_generated_xml,
)
from functions_model_endpoint_runtime import build_semantic_kernel_chat_service_for_model
+from functions_public_workspaces import get_user_visible_public_workspace_ids_from_settings
from functions_settings import get_settings
-from functions_simplechat_operations import upload_generated_analysis_artifact_for_user
+from functions_simplechat_operations import upload_generated_analysis_artifact_stream_for_user
TABULAR_EXPORT_RUN_TYPE = 'tabular_generated_output_run'
+TABULAR_EXPORT_CONTRACT_VERSION = 2
TABULAR_EXPORT_STATUS_QUEUED = 'queued'
TABULAR_EXPORT_STATUS_RUNNING = 'running'
TABULAR_EXPORT_STATUS_COMPLETED = 'completed'
@@ -54,8 +71,16 @@
TABULAR_EXPORT_DEFAULT_STALE_SECONDS = 420
TABULAR_EXPORT_DEFAULT_SCAN_LIMIT = 5
TABULAR_EXPORT_DEFAULT_MAX_TRANSIENT_FAILURES = 20
-TABULAR_EXPORT_DEFAULT_BATCH_CONCURRENCY = 2
+TABULAR_EXPORT_DEFAULT_BATCH_CONCURRENCY = 3
TABULAR_EXPORT_MAX_BATCH_CONCURRENCY = 5
+TABULAR_EXPORT_DEFAULT_BATCH_TIMEOUT_SECONDS = 300
+TABULAR_EXPORT_FINAL_SPOOL_MAX_MEMORY_BYTES = 1024 * 1024
+TABULAR_EXPORT_DEFAULT_SOURCE_CHUNK_ROWS = 1000
+TABULAR_EXPORT_DEFAULT_SOURCE_BATCH_ROWS = 50
+TABULAR_EXPORT_DEFAULT_SOURCE_BATCH_CHARS = 60000
+TABULAR_EXPORT_SUMMARY_MAX_FIELDS = 25
+TABULAR_EXPORT_SUMMARY_MAX_VALUES_PER_FIELD = 5
+TABULAR_EXPORT_SUMMARY_AGGREGATE_MAX_VALUES = 25
TABULAR_EXPORT_PROGRESS_LOG_INTERVAL_SECONDS = 30
TABULAR_EXPORT_SCHEDULER_STATUSES = (
TABULAR_EXPORT_STATUS_QUEUED,
@@ -92,6 +117,19 @@
'worker exiting',
'worker restart',
)
+TABULAR_EXPORT_INPUT_ROW_NUMBER_FIELD = '__simplechat_source_row_number'
+TABULAR_EXPORT_INPUT_ROW_IDENTITY_FIELD = '__simplechat_source_row_identity'
+TABULAR_EXPORT_INPUT_ROW_TOKEN_FIELD = '__simplechat_source_row_token'
+TABULAR_EXPORT_OUTPUT_ROW_NUMBER_FIELD = 'source_row_number'
+TABULAR_EXPORT_OUTPUT_ROW_IDENTITY_FIELD = 'source_row_identity'
+
+
+class TabularExportCanceledError(RuntimeError):
+ """Raised when a durable export is canceled between checkpoints."""
+
+
+class TabularExportLeaseLostError(RuntimeError):
+ """Raised when a stale worker no longer owns the durable run claim."""
def _now_utc():
@@ -201,13 +239,269 @@ def _serialize_generated_output_value(value):
if value is None:
return ''
if isinstance(value, (dict, list)):
- return json.dumps(value, default=str, ensure_ascii=False)
+ return neutralize_csv_spreadsheet_formula(json.dumps(value, default=str, ensure_ascii=False))
if hasattr(value, 'isoformat') and not isinstance(value, str):
try:
- return value.isoformat()
+ return neutralize_csv_spreadsheet_formula(value.isoformat())
except TypeError:
pass
- return str(value)
+ return neutralize_csv_spreadsheet_formula(value)
+
+
+def _normalize_source_identity_label(value):
+ return re.sub(r'[^a-z0-9]+', '', str(value or '').strip().casefold())
+
+
+def _select_source_row_identity(row, source_row_number):
+ if not isinstance(row, dict):
+ return str(source_row_number)
+
+ identity_priorities = (
+ 'sourceidentity',
+ 'sourceid',
+ 'caseid',
+ 'recordid',
+ 'rowid',
+ 'commentid',
+ 'submissionid',
+ 'id',
+ )
+ normalized_values = {}
+ for field_name, field_value in row.items():
+ if field_name in {
+ TABULAR_EXPORT_INPUT_ROW_NUMBER_FIELD,
+ TABULAR_EXPORT_INPUT_ROW_IDENTITY_FIELD,
+ TABULAR_EXPORT_INPUT_ROW_TOKEN_FIELD,
+ }:
+ continue
+ normalized_label = _normalize_source_identity_label(field_name)
+ if normalized_label and field_value not in (None, '', [], {}):
+ normalized_values.setdefault(normalized_label, field_value)
+
+ for identity_label in identity_priorities:
+ identity_value = normalized_values.get(identity_label)
+ if identity_value not in (None, '', [], {}):
+ return str(identity_value)
+
+ for identity_label, identity_value in normalized_values.items():
+ if identity_label.endswith('id') and not isinstance(identity_value, (dict, list, tuple, set)):
+ return str(identity_value)
+
+ return str(source_row_number)
+
+
+def _prepare_tabular_source_rows(rows, start_row=0, token_namespace=''):
+ try:
+ normalized_start_row = max(0, int(start_row or 0))
+ except (TypeError, ValueError):
+ normalized_start_row = 0
+
+ prepared_rows = []
+ for row_offset, row in enumerate(rows or []):
+ source_row_number = normalized_start_row + row_offset + 1
+ prepared_row = dict(row) if isinstance(row, dict) else {'value': row}
+ prepared_row[TABULAR_EXPORT_INPUT_ROW_NUMBER_FIELD] = source_row_number
+ prepared_row[TABULAR_EXPORT_INPUT_ROW_IDENTITY_FIELD] = _select_source_row_identity(
+ prepared_row,
+ source_row_number,
+ )
+ token_seed = (
+ f'simplechat-tabular-row:{token_namespace}:{source_row_number}:'
+ f'{prepared_row[TABULAR_EXPORT_INPUT_ROW_IDENTITY_FIELD]}'
+ )
+ prepared_row[TABULAR_EXPORT_INPUT_ROW_TOKEN_FIELD] = uuid.uuid5(
+ uuid.NAMESPACE_URL,
+ token_seed,
+ ).hex
+ prepared_rows.append(prepared_row)
+ return prepared_rows
+
+
+def _normalize_generated_batch_entries(
+ source_rows,
+ generated_entries,
+ expected_output_schema=None,
+ require_source_token=True,
+):
+ source_rows = list(source_rows or [])
+ generated_entries = list(generated_entries or [])
+ if len(source_rows) != len(generated_entries):
+ raise ValueError(
+ f'Generated row count {len(generated_entries)} does not match source row count {len(source_rows)}'
+ )
+
+ normalized_entries = []
+ for row_index, (source_row, generated_entry) in enumerate(zip(source_rows, generated_entries), start=1):
+ if not isinstance(source_row, dict):
+ raise ValueError(f'Source row {row_index} is not an object')
+ if not isinstance(generated_entry, dict):
+ raise ValueError(f'Generated row {row_index} is not an object')
+
+ source_row_number = source_row.get(TABULAR_EXPORT_INPUT_ROW_NUMBER_FIELD)
+ source_row_identity = source_row.get(TABULAR_EXPORT_INPUT_ROW_IDENTITY_FIELD)
+ source_row_token = str(source_row.get(TABULAR_EXPORT_INPUT_ROW_TOKEN_FIELD) or '').strip()
+ if source_row_number in (None, '') or source_row_identity in (None, ''):
+ raise ValueError(f'Source identity is missing for generated row {row_index}')
+ generated_row_token = str(
+ generated_entry.get(TABULAR_EXPORT_INPUT_ROW_TOKEN_FIELD) or ''
+ ).strip()
+ if require_source_token and (
+ not source_row_token
+ or generated_row_token != source_row_token
+ ):
+ raise ValueError(
+ f'Generated source row token mismatch at row {row_index}'
+ )
+
+ normalized_entry = {
+ TABULAR_EXPORT_OUTPUT_ROW_NUMBER_FIELD: source_row_number,
+ TABULAR_EXPORT_OUTPUT_ROW_IDENTITY_FIELD: str(source_row_identity),
+ }
+ normalized_entry.update({
+ str(field_name): field_value
+ for field_name, field_value in generated_entry.items()
+ if str(field_name) not in {
+ TABULAR_EXPORT_INPUT_ROW_NUMBER_FIELD,
+ TABULAR_EXPORT_INPUT_ROW_IDENTITY_FIELD,
+ TABULAR_EXPORT_INPUT_ROW_TOKEN_FIELD,
+ TABULAR_EXPORT_OUTPUT_ROW_NUMBER_FIELD,
+ TABULAR_EXPORT_OUTPUT_ROW_IDENTITY_FIELD,
+ }
+ })
+ normalized_entries.append(normalized_entry)
+
+ output_schema = list(expected_output_schema or [])
+ if not output_schema and normalized_entries:
+ output_schema = list(normalized_entries[0])
+
+ expected_fields = set(output_schema)
+ for row_index, normalized_entry in enumerate(normalized_entries, start=1):
+ actual_fields = set(normalized_entry)
+ if actual_fields != expected_fields:
+ missing_fields = sorted(expected_fields - actual_fields)
+ unexpected_fields = sorted(actual_fields - expected_fields)
+ raise ValueError(
+ f'Generated output schema mismatch at row {row_index}; '
+ f'missing={missing_fields}; unexpected={unexpected_fields}'
+ )
+
+ ordered_entries = [
+ {field_name: entry.get(field_name) for field_name in output_schema}
+ for entry in normalized_entries
+ ]
+ return ordered_entries, output_schema
+
+
+def _build_generated_batch_summary(entries):
+ entries = [entry for entry in (entries or []) if isinstance(entry, dict)]
+ summary = {
+ 'row_count': len(entries),
+ 'source_row_start': None,
+ 'source_row_end': None,
+ 'fields': {},
+ }
+ if not entries:
+ return summary
+
+ summary['source_row_start'] = _safe_int(entries[0].get(TABULAR_EXPORT_OUTPUT_ROW_NUMBER_FIELD)) or None
+ summary['source_row_end'] = _safe_int(entries[-1].get(TABULAR_EXPORT_OUTPUT_ROW_NUMBER_FIELD)) or None
+ field_names = [
+ field_name
+ for field_name in entries[0]
+ if field_name not in {
+ TABULAR_EXPORT_OUTPUT_ROW_NUMBER_FIELD,
+ TABULAR_EXPORT_OUTPUT_ROW_IDENTITY_FIELD,
+ }
+ ][:TABULAR_EXPORT_SUMMARY_MAX_FIELDS]
+ for field_name in field_names:
+ populated_count = 0
+ value_counts = Counter()
+ for entry in entries:
+ field_value = entry.get(field_name)
+ if field_value in (None, '', [], {}):
+ continue
+ populated_count += 1
+ if isinstance(field_value, (str, int, float, bool)):
+ normalized_value = str(field_value).strip()
+ if normalized_value and len(normalized_value) <= 100:
+ value_counts[normalized_value] += 1
+
+ summary['fields'][field_name] = {
+ 'populated_count': populated_count,
+ 'empty_count': len(entries) - populated_count,
+ 'top_values': [
+ {'value': value, 'count': count}
+ for value, count in value_counts.most_common(TABULAR_EXPORT_SUMMARY_MAX_VALUES_PER_FIELD)
+ ],
+ }
+ return summary
+
+
+def _build_compact_post_run_summary(run):
+ batch_count = _safe_int(run.get('batch_count'))
+ row_count = _safe_int(run.get('row_count'))
+ aggregate_fields = {}
+ summarized_batch_count = 0
+ for batch_number in range(1, batch_count + 1):
+ summary_blob_path = _output_summary_blob_path(
+ run.get('user_id'),
+ run.get('conversation_id'),
+ run.get('id'),
+ batch_number,
+ )
+ if not _blob_exists(summary_blob_path):
+ continue
+ batch_summary = _download_json_blob(summary_blob_path)
+ if not isinstance(batch_summary, dict):
+ continue
+ summarized_batch_count += 1
+ for field_name, field_summary in list((batch_summary.get('fields') or {}).items())[
+ :TABULAR_EXPORT_SUMMARY_MAX_FIELDS
+ ]:
+ aggregate_field = aggregate_fields.setdefault(field_name, {
+ 'populated_count': 0,
+ 'empty_count': 0,
+ 'value_counts': Counter(),
+ })
+ aggregate_field['populated_count'] += _safe_int(field_summary.get('populated_count'))
+ aggregate_field['empty_count'] += _safe_int(field_summary.get('empty_count'))
+ for value_summary in field_summary.get('top_values') or []:
+ value = str(value_summary.get('value') or '').strip()
+ if value:
+ aggregate_field['value_counts'][value] += _safe_int(value_summary.get('count'))
+ if len(aggregate_field['value_counts']) > TABULAR_EXPORT_SUMMARY_AGGREGATE_MAX_VALUES * 2:
+ aggregate_field['value_counts'] = Counter(dict(
+ aggregate_field['value_counts'].most_common(TABULAR_EXPORT_SUMMARY_AGGREGATE_MAX_VALUES)
+ ))
+
+ summary_parts = [
+ f'Processed {row_count:,} ordered row(s) across {batch_count:,} checkpointed batch(es).'
+ ]
+ if aggregate_fields:
+ field_names = list(aggregate_fields)[:10]
+ summary_parts.append(f"Output fields: {', '.join(field_names)}.")
+ completeness_parts = []
+ for field_name in field_names[:5]:
+ populated_count = aggregate_fields[field_name]['populated_count']
+ completeness_percent = round((populated_count / row_count) * 100) if row_count else 0
+ completeness_parts.append(f'{field_name} {completeness_percent}% populated')
+ if completeness_parts:
+ summary_parts.append(f"Completeness: {', '.join(completeness_parts)}.")
+
+ common_value_parts = []
+ for field_name in field_names[:5]:
+ top_values = aggregate_fields[field_name]['value_counts'].most_common(3)
+ if 1 < len(top_values) <= 3:
+ rendered_values = ', '.join(f'{value} ({count:,})' for value, count in top_values)
+ common_value_parts.append(f'{field_name}: {rendered_values}')
+ if common_value_parts:
+ summary_parts.append(f"Common values: {'; '.join(common_value_parts)}.")
+
+ if summarized_batch_count != batch_count:
+ summary_parts.append(
+ f'Batch summaries available for {summarized_batch_count:,} of {batch_count:,} batch(es).'
+ )
+ return ' '.join(summary_parts)[:2000]
def _build_generated_output_csv(entries):
@@ -226,14 +520,15 @@ def _build_generated_output_csv(entries):
if not ordered_columns:
ordered_columns = ['value']
+ safe_ordered_columns = build_safe_csv_headers(ordered_columns)
output_buffer = io.StringIO()
- writer = csv.DictWriter(output_buffer, fieldnames=ordered_columns)
+ writer = csv.DictWriter(output_buffer, fieldnames=safe_ordered_columns)
writer.writeheader()
for entry in entries or []:
serialized_row = {}
if isinstance(entry, dict):
- for field_name in ordered_columns:
- serialized_row[field_name] = _serialize_generated_output_value(entry.get(field_name))
+ for field_name, safe_field_name in zip(ordered_columns, safe_ordered_columns):
+ serialized_row[safe_field_name] = _serialize_generated_output_value(entry.get(field_name))
writer.writerow(serialized_row)
return output_buffer.getvalue()
@@ -257,14 +552,18 @@ def _output_blob_path(user_id, conversation_id, run_id, batch_number):
return f"{user_id}/{conversation_id}/generated/tabular_runs/{run_id}/output/batch_{batch_number:06d}.json"
-def _upload_json_blob(blob_path, payload, metadata=None):
+def _output_summary_blob_path(user_id, conversation_id, run_id, batch_number):
+ return f"{user_id}/{conversation_id}/generated/tabular_runs/{run_id}/summary/batch_{batch_number:06d}.json"
+
+
+def _upload_json_blob(blob_path, payload, metadata=None, overwrite=True):
blob_client = _get_blob_service_client().get_blob_client(
container=storage_account_personal_chat_container_name,
blob=blob_path,
)
blob_client.upload_blob(
json.dumps(payload, default=str, ensure_ascii=False).encode('utf-8'),
- overwrite=True,
+ overwrite=overwrite,
metadata={str(key): str(value) for key, value in (metadata or {}).items()},
)
@@ -288,6 +587,109 @@ def _blob_exists(blob_path):
return bool(blob_client.exists())
+def _delete_blob_if_exists(blob_path):
+ blob_client = _get_blob_service_client().get_blob_client(
+ container=storage_account_personal_chat_container_name,
+ blob=blob_path,
+ )
+ if blob_client.exists():
+ blob_client.delete_blob()
+
+
+def _authorize_tabular_export_run_execution(run):
+ user_id = str((run or {}).get('user_id') or '').strip()
+ conversation_id = str((run or {}).get('conversation_id') or '').strip()
+ if not user_id or not conversation_id:
+ raise PermissionError('Export run identity is incomplete')
+
+ try:
+ conversation = cosmos_conversations_container.read_item(
+ item=conversation_id,
+ partition_key=conversation_id,
+ )
+ except CosmosResourceNotFoundError as exc:
+ raise PermissionError('Export conversation no longer exists') from exc
+ if str(conversation.get('user_id') or '').strip() != user_id:
+ raise PermissionError('Export conversation ownership changed')
+
+ source_authorization = (
+ (run or {}).get('source_descriptor')
+ or (run or {}).get('source_authorization')
+ or {}
+ )
+ if not source_authorization:
+ return conversation
+
+ source = str(source_authorization.get('source') or '').strip().lower()
+ container_name = str(source_authorization.get('container') or '').strip()
+ blob_path = str(source_authorization.get('blob_path') or '').strip()
+ scope_id = str(source_authorization.get('scope_id') or '').strip()
+ if not source:
+ raise PermissionError('Export source authorization is incomplete')
+
+ if source == 'chat':
+ expected_prefix = f'{user_id}/{conversation_id}/'
+ authorized = not container_name and not blob_path or (
+ container_name == storage_account_personal_chat_container_name
+ and blob_path.startswith(expected_prefix)
+ )
+ elif source == 'workspace':
+ expected_prefix = f'{user_id}/'
+ authorized = not container_name and not blob_path or (
+ container_name == storage_account_user_documents_container_name
+ and blob_path.startswith(expected_prefix)
+ )
+ elif source == 'group':
+ if not scope_id:
+ raise PermissionError('Export group scope is missing')
+ assert_group_role(
+ user_id,
+ scope_id,
+ allowed_roles=('Owner', 'Admin', 'DocumentManager', 'User'),
+ )
+ authorized = not container_name and not blob_path or (
+ container_name == storage_account_group_documents_container_name
+ and blob_path.startswith(f'{scope_id}/')
+ )
+ elif source == 'public':
+ if not scope_id:
+ raise PermissionError('Export public workspace scope is missing')
+ visible_workspace_ids = set(get_user_visible_public_workspace_ids_from_settings(user_id) or [])
+ authorized = scope_id in visible_workspace_ids and (
+ not container_name and not blob_path
+ or (
+ container_name == storage_account_public_documents_container_name
+ and blob_path.startswith(f'{scope_id}/')
+ )
+ )
+ else:
+ raise PermissionError('Export source type is not supported')
+
+ if not authorized:
+ raise PermissionError('Export source is no longer authorized')
+ return conversation
+
+
+def _get_versioned_source_blob_client(source_descriptor):
+ container_name = str((source_descriptor or {}).get('container') or '').strip()
+ blob_path = str((source_descriptor or {}).get('blob_path') or '').strip()
+ expected_etag = str((source_descriptor or {}).get('blob_etag') or '').strip()
+ if not container_name or not blob_path or not expected_etag:
+ raise ValueError('Source query descriptor is incomplete')
+
+ blob_client = _get_blob_service_client().get_blob_client(
+ container=container_name,
+ blob=blob_path,
+ )
+ blob_properties = blob_client.get_blob_properties()
+ current_etag = str(getattr(blob_properties, 'etag', '') or '').strip()
+ if isinstance(blob_properties, dict):
+ current_etag = current_etag or str(blob_properties.get('etag') or '').strip()
+ if current_etag != expected_etag:
+ raise ValueError('Source CSV changed after the export was queued')
+ return blob_client
+
+
def _clean_generated_json_code_fence(response_content):
cleaned = str(response_content or '').strip()
if not cleaned:
@@ -341,17 +743,286 @@ def _dump_generated_output_json(value):
return json.dumps(value, default=str, ensure_ascii=False, separators=(',', ':'))
-def _build_batch_prompt(user_question, batch_rows, batch_index, total_batches, source_file_name, selected_sheet=''):
+def _checkpoint_source_input_batch(run, batch_rows, source_scan_row_count):
+ _raise_if_tabular_export_canceled(run)
+ staged_row_count = _safe_int(run.get('source_staged_rows'))
+ batch_number = _safe_int(run.get('source_staged_batches')) + 1
+ prepared_rows = _prepare_tabular_source_rows(
+ batch_rows,
+ start_row=staged_row_count,
+ token_namespace=run.get('id'),
+ )
+ input_blob_path = _input_blob_path(
+ run.get('user_id'),
+ run.get('conversation_id'),
+ run.get('id'),
+ batch_number,
+ )
+ _upload_json_blob(
+ input_blob_path,
+ prepared_rows,
+ metadata={
+ 'run_id': run.get('id'),
+ 'conversation_id': run.get('conversation_id'),
+ 'generated_output_input': 'true',
+ 'batch_number': batch_number,
+ 'source_query_checkpoint': 'true',
+ },
+ )
+ _raise_if_tabular_export_canceled(run)
+
+ now = _now_iso()
+ run.update({
+ 'source_staged_rows': staged_row_count + len(prepared_rows),
+ 'source_staged_batches': batch_number,
+ 'source_scan_row_count': _safe_int(source_scan_row_count),
+ 'updated_at': now,
+ 'last_heartbeat_at': now,
+ 'last_message': (
+ f'Staged source query batch {batch_number} '
+ f'with {staged_row_count + len(prepared_rows)} row(s) ready'
+ ),
+ })
+ return _replace_claimed_run(run)
+
+
+def _stage_tabular_generated_output_source(run, settings):
+ source_descriptor = run.get('source_descriptor') or {}
+ if source_descriptor.get('kind') != 'query_tabular_data':
+ raise ValueError('Unsupported generated export source descriptor')
+ if not str(source_descriptor.get('blob_path') or '').lower().endswith('.csv'):
+ raise ValueError('Source-backed generated exports require a CSV source')
+
+ expected_row_count = _safe_int(source_descriptor.get('expected_row_count'))
+ if expected_row_count <= 0:
+ raise ValueError('Source query descriptor has no expected rows')
+ source_chunk_rows = _settings_int(
+ settings,
+ 'tabular_generated_output_source_chunk_rows',
+ TABULAR_EXPORT_DEFAULT_SOURCE_CHUNK_ROWS,
+ minimum=100,
+ maximum=10000,
+ )
+ max_batch_rows = _safe_int(
+ source_descriptor.get('batch_max_rows'),
+ default=TABULAR_EXPORT_DEFAULT_SOURCE_BATCH_ROWS,
+ minimum=1,
+ maximum=100,
+ )
+ max_batch_chars = _safe_int(
+ source_descriptor.get('batch_max_chars'),
+ default=TABULAR_EXPORT_DEFAULT_SOURCE_BATCH_CHARS,
+ minimum=6000,
+ maximum=120000,
+ )
+ source_blob_client = _get_versioned_source_blob_client(source_descriptor)
+ resume_source_row = _safe_int(run.get('source_scan_row_count'))
+ pending_rows = []
+ pending_chars = 0
+ last_source_row_number = resume_source_row
+
+ with tempfile.SpooledTemporaryFile(
+ max_size=TABULAR_EXPORT_FINAL_SPOOL_MAX_MEMORY_BYTES,
+ mode='w+b',
+ ) as source_stream:
+ source_blob_client.download_blob(
+ etag=source_descriptor.get('blob_etag'),
+ match_condition=MatchConditions.IfNotModified,
+ ).readinto(source_stream)
+ source_stream.seek(0)
+
+ tabular_plugin = TabularProcessingPlugin()
+ for source_row_number, source_row in iter_tabular_csv_query_rows(
+ csv_stream=source_stream,
+ query_expression=source_descriptor.get('query_expression'),
+ return_columns=source_descriptor.get('return_columns'),
+ source_chunk_rows=source_chunk_rows,
+ tabular_plugin=tabular_plugin,
+ start_source_row=resume_source_row,
+ ):
+ source_row_text = _dump_generated_output_json(source_row)
+ if pending_rows and (
+ len(pending_rows) >= max_batch_rows
+ or pending_chars + len(source_row_text) > max_batch_chars
+ ):
+ run = _checkpoint_source_input_batch(
+ run,
+ pending_rows,
+ source_scan_row_count=source_row_number - 1,
+ )
+ pending_rows = []
+ pending_chars = 0
+
+ pending_rows.append(source_row)
+ pending_chars += len(source_row_text)
+ last_source_row_number = source_row_number
+ if len(pending_rows) >= max_batch_rows:
+ run = _checkpoint_source_input_batch(
+ run,
+ pending_rows,
+ source_scan_row_count=source_row_number,
+ )
+ pending_rows = []
+ pending_chars = 0
+
+ if pending_rows:
+ run = _checkpoint_source_input_batch(
+ run,
+ pending_rows,
+ source_scan_row_count=last_source_row_number,
+ )
+
+ staged_row_count = _safe_int(run.get('source_staged_rows'))
+ staged_batch_count = _safe_int(run.get('source_staged_batches'))
+ if staged_row_count != expected_row_count:
+ raise ValueError(
+ f'Source query returned {staged_row_count} row(s); expected {expected_row_count}'
+ )
+ if staged_batch_count <= 0:
+ raise ValueError('Source query produced no input checkpoints')
+
+ now = _now_iso()
+ run.update({
+ 'source_staging_complete': True,
+ 'row_count': staged_row_count,
+ 'batch_count': staged_batch_count,
+ 'updated_at': now,
+ 'last_heartbeat_at': now,
+ 'last_message': (
+ f'Source query staging complete: {staged_row_count} row(s) '
+ f'across {staged_batch_count} batch(es)'
+ ),
+ })
+ return _replace_claimed_run(run)
+
+
+def _migrate_legacy_tabular_export_run(run):
+ if _safe_int(run.get('contract_version')) >= TABULAR_EXPORT_CONTRACT_VERSION:
+ return run
+
+ batch_count = _safe_int(run.get('batch_count'))
+ expected_row_count = _safe_int(run.get('row_count'))
+ if batch_count <= 0 or expected_row_count <= 0:
+ raise ValueError('Legacy export run has invalid input counts')
+
+ aggregate_input_batches = None
+ aggregate_input_blob_path = str(run.get('input_blob_path') or '').strip()
+ if aggregate_input_blob_path:
+ aggregate_input_batches = _download_json_blob(aggregate_input_blob_path)
+ if not isinstance(aggregate_input_batches, list):
+ raise ValueError('Legacy input batches blob was not a JSON array')
+
+ migrated_row_count = 0
+ for batch_number in range(1, batch_count + 1):
+ _raise_if_tabular_export_canceled(run)
+ if aggregate_input_batches is not None:
+ try:
+ batch_rows = aggregate_input_batches[batch_number - 1]
+ except IndexError as exc:
+ raise ValueError(f'Legacy input batch {batch_number}/{batch_count} is missing') from exc
+ else:
+ batch_rows = _download_json_blob(_input_blob_path(
+ run.get('user_id'),
+ run.get('conversation_id'),
+ run.get('id'),
+ batch_number,
+ ))
+ if not isinstance(batch_rows, list):
+ raise ValueError(f'Legacy input batch {batch_number}/{batch_count} was not a JSON array')
+
+ prepared_rows = _prepare_tabular_source_rows(
+ batch_rows,
+ start_row=migrated_row_count,
+ token_namespace=run.get('id'),
+ )
+ _upload_json_blob(
+ _input_blob_path(
+ run.get('user_id'),
+ run.get('conversation_id'),
+ run.get('id'),
+ batch_number,
+ ),
+ prepared_rows,
+ metadata={
+ 'run_id': run.get('id'),
+ 'conversation_id': run.get('conversation_id'),
+ 'generated_output_input': 'true',
+ 'batch_number': batch_number,
+ 'contract_version': TABULAR_EXPORT_CONTRACT_VERSION,
+ },
+ )
+ _delete_blob_if_exists(_output_blob_path(
+ run.get('user_id'),
+ run.get('conversation_id'),
+ run.get('id'),
+ batch_number,
+ ))
+ _delete_blob_if_exists(_output_summary_blob_path(
+ run.get('user_id'),
+ run.get('conversation_id'),
+ run.get('id'),
+ batch_number,
+ ))
+ migrated_row_count += len(prepared_rows)
+
+ if migrated_row_count != expected_row_count:
+ raise ValueError(
+ f'Legacy input migration found {migrated_row_count} row(s); expected {expected_row_count}'
+ )
+
+ now = _now_iso()
+ run.update({
+ 'contract_version': TABULAR_EXPORT_CONTRACT_VERSION,
+ 'input_blob_path': None,
+ 'completed_batches': 0,
+ 'processed_rows': 0,
+ 'output_schema': None,
+ 'regenerate_legacy_output_checkpoints': False,
+ 'updated_at': now,
+ 'last_heartbeat_at': now,
+ 'last_message': 'Legacy export inputs migrated; output checkpoints will be regenerated',
+ })
+ _raise_if_tabular_export_canceled(run)
+ return _replace_claimed_run(run)
+
+
+def _build_batch_prompt(
+ user_question,
+ batch_rows,
+ batch_index,
+ total_batches,
+ source_file_name,
+ selected_sheet='',
+ output_schema=None,
+):
source_file_name = str(source_file_name or 'unknown file').strip() or 'unknown file'
selected_sheet = str(selected_sheet or '').strip()
batch_rows_json = _dump_generated_output_json(batch_rows)
selected_sheet_line = f"Worksheet: {selected_sheet}\n" if selected_sheet else ''
+ model_output_schema = [
+ field_name
+ for field_name in (output_schema or [])
+ if field_name not in {
+ TABULAR_EXPORT_OUTPUT_ROW_NUMBER_FIELD,
+ TABULAR_EXPORT_OUTPUT_ROW_IDENTITY_FIELD,
+ }
+ ]
+ output_schema_line = (
+ f'Use exactly these output fields for every object, in this order: '
+ f'{json.dumps(model_output_schema, ensure_ascii=False)}.\n'
+ if model_output_schema
+ else ''
+ )
return (
'Transform the tabular input rows below into structured output for the user.\n\n'
f'User instructions:\n{user_question}\n\n'
'Return ONLY a valid JSON array.\n'
f'Return exactly {len(batch_rows)} JSON object(s), one per input row, in the same order.\n'
+ f'{output_schema_line}'
+ f'Copy {TABULAR_EXPORT_INPUT_ROW_TOKEN_FIELD} exactly from each input row into its matching output object. '
+ f'The {TABULAR_EXPORT_INPUT_ROW_NUMBER_FIELD} and {TABULAR_EXPORT_INPUT_ROW_IDENTITY_FIELD} fields are internal; '
+ 'do not include those two fields in generated objects.\n'
'Do not drop, merge, summarize, or cap rows.\n'
'Input rows may include normalized helper fields such as comment_id, body_text, source_file, attachment_present, attachment_names, and attachment_text. Use those normalized fields when they are present.\n'
'Input rows may include a referenced_documents array containing row-linked evidence from explicitly referenced non-tabular documents. Use that evidence as part of the source row context when it is relevant to the requested output.\n'
@@ -385,6 +1056,8 @@ async def _generate_batch_entries(
selected_sheet,
retry_attempts,
run_id,
+ expected_output_schema=None,
+ batch_timeout_seconds=TABULAR_EXPORT_DEFAULT_BATCH_TIMEOUT_SECONDS,
):
batch_number = batch_index + 1
batch_prompt = _build_batch_prompt(
@@ -394,11 +1067,20 @@ async def _generate_batch_entries(
total_batches,
source_file_name,
selected_sheet=selected_sheet,
+ output_schema=expected_output_schema,
)
parsed_entries = None
raw_response_content = ''
mismatch_count = 0
+ last_validation_error = None
+ timeout_seconds = max(
+ _safe_float(
+ batch_timeout_seconds,
+ default=TABULAR_EXPORT_DEFAULT_BATCH_TIMEOUT_SECONDS,
+ ),
+ 0.001,
+ )
for attempt_number in range(1, retry_attempts + 1):
chat_history = SKChatHistory()
chat_history.add_system_message(
@@ -414,12 +1096,29 @@ async def _generate_batch_entries(
chat_history.add_user_message(batch_prompt)
execution_settings = AzureChatPromptExecutionSettings(service_id='tabular-generated-output-background')
- result = await chat_service.get_chat_message_contents(chat_history, execution_settings)
+ try:
+ result = await asyncio.wait_for(
+ chat_service.get_chat_message_contents(chat_history, execution_settings),
+ timeout=timeout_seconds,
+ )
+ except asyncio.TimeoutError as exc:
+ raise TimeoutError(
+ f'Background structured export batch {batch_number}/{total_batches} '
+ f'timed out after {timeout_seconds:g} seconds.'
+ ) from exc
raw_response_content = result[0].content if result and result[0].content else ''
parsed_entries = _parse_generated_json_entries(raw_response_content) if raw_response_content else None
parsed_entry_count = len(parsed_entries) if parsed_entries is not None else 0
if parsed_entries is not None and parsed_entry_count == len(batch_rows):
- return parsed_entries, mismatch_count
+ try:
+ normalized_entries, output_schema = _normalize_generated_batch_entries(
+ batch_rows,
+ parsed_entries,
+ expected_output_schema=expected_output_schema,
+ )
+ return normalized_entries, mismatch_count, output_schema
+ except ValueError as exc:
+ last_validation_error = str(exc)
mismatch_count += 1
log_event(
@@ -431,15 +1130,19 @@ async def _generate_batch_entries(
'attempt_number': attempt_number,
'expected_row_count': len(batch_rows),
'parsed_row_count': parsed_entry_count,
+ 'validation_error': last_validation_error,
'response_char_count': len(raw_response_content),
'response_preview': _truncate_response_preview(raw_response_content),
},
debug_only=True,
)
+ failure_detail = last_validation_error or (
+ f'returned {len(parsed_entries) if parsed_entries is not None else 0} object(s) '
+ f'for {len(batch_rows)} input row(s)'
+ )
raise ValueError(
- f'Background structured export batch {batch_number}/{total_batches} returned '
- f'{len(parsed_entries) if parsed_entries is not None else 0} object(s) for {len(batch_rows)} input row(s).'
+ f'Background structured export batch {batch_number}/{total_batches} failed validation: {failure_detail}.'
)
@@ -453,10 +1156,12 @@ async def _generate_batch_entries_for_window(
selected_sheet,
retry_attempts,
run_id,
+ expected_output_schema,
+ batch_timeout_seconds,
):
async with semaphore:
batch_started_at = time.monotonic()
- batch_entries, mismatch_count = await _generate_batch_entries(
+ batch_entries, mismatch_count, output_schema = await _generate_batch_entries(
chat_service,
user_question,
batch_request['rows'],
@@ -466,13 +1171,17 @@ async def _generate_batch_entries_for_window(
selected_sheet,
retry_attempts,
run_id,
+ expected_output_schema=expected_output_schema,
+ batch_timeout_seconds=batch_timeout_seconds,
)
return {
'batch_number': batch_request['batch_number'],
'batch_entries': batch_entries,
+ 'batch_summary': _build_generated_batch_summary(batch_entries),
'batch_row_count': len(batch_entries),
'elapsed_seconds': time.monotonic() - batch_started_at,
'mismatch_count': mismatch_count,
+ 'output_schema': output_schema,
}
@@ -486,6 +1195,8 @@ async def _generate_batch_window_entries(
retry_attempts,
run_id,
batch_concurrency,
+ expected_output_schema=None,
+ batch_timeout_seconds=TABULAR_EXPORT_DEFAULT_BATCH_TIMEOUT_SECONDS,
):
semaphore = asyncio.Semaphore(max(1, batch_concurrency))
tasks = [
@@ -499,6 +1210,8 @@ async def _generate_batch_window_entries(
selected_sheet,
retry_attempts,
run_id,
+ expected_output_schema,
+ batch_timeout_seconds,
)
for batch_request in batch_requests
]
@@ -535,6 +1248,46 @@ def should_queue_tabular_generated_output_background(row_count, batch_count, set
return _safe_int(row_count) > inline_max_rows or _safe_int(batch_count) > inline_max_batches
+def build_tabular_generated_output_row_batches(rows, settings=None):
+ """Split structured rows using the shared generated-export size budget."""
+ settings = settings or {}
+ max_batch_rows = _settings_int(
+ settings,
+ 'tabular_generated_output_max_batch_rows',
+ TABULAR_EXPORT_DEFAULT_SOURCE_BATCH_ROWS,
+ minimum=1,
+ maximum=100,
+ )
+ max_batch_chars = _settings_int(
+ settings,
+ 'tabular_generated_output_max_batch_chars',
+ TABULAR_EXPORT_DEFAULT_SOURCE_BATCH_CHARS,
+ minimum=6000,
+ maximum=120000,
+ )
+ batches = []
+ current_batch = []
+ current_batch_chars = 0
+
+ for row in rows or []:
+ row_text = json.dumps(row, default=str, ensure_ascii=False, separators=(',', ':'))
+ if current_batch and (
+ len(current_batch) >= max_batch_rows
+ or current_batch_chars + len(row_text) > max_batch_chars
+ ):
+ batches.append(current_batch)
+ current_batch = []
+ current_batch_chars = 0
+
+ current_batch.append(row)
+ current_batch_chars += len(row_text)
+
+ if current_batch:
+ batches.append(current_batch)
+
+ return batches
+
+
def _parse_iso_datetime(value):
normalized_value = str(value or '').strip()
if not normalized_value:
@@ -633,23 +1386,32 @@ def _scheduler_candidate_sort_key(run):
)
-def _query_scheduler_candidates_by_status(status, scan_limit):
+def _query_scheduler_candidates_by_status(status, scan_limit, settings):
per_status_limit = _safe_int(scan_limit, default=TABULAR_EXPORT_DEFAULT_SCAN_LIMIT, minimum=1, maximum=10)
query = (
- f"SELECT TOP {per_status_limit} "
+ "SELECT "
"c.id, c.user_id, c.status, c.created_at, c.updated_at, c.last_heartbeat_at, "
"c.next_attempt_at, c.last_error, c.transient_failure_count "
- "FROM c WHERE c.type = @type AND c.status = @status"
+ "FROM c WHERE c.type = @type AND c.status = @status "
+ "ORDER BY c.updated_at ASC"
)
try:
- return list(cosmos_tabular_export_runs_container.query_items(
+ query_results = cosmos_tabular_export_runs_container.query_items(
query=query,
parameters=[
{'name': '@type', 'value': TABULAR_EXPORT_RUN_TYPE},
{'name': '@status', 'value': status},
],
enable_cross_partition_query=True,
- ))
+ )
+ eligible_candidates = []
+ for run in query_results:
+ if not _scheduler_candidate_reason(run, settings):
+ continue
+ eligible_candidates.append(run)
+ if len(eligible_candidates) >= per_status_limit:
+ break
+ return eligible_candidates
except Exception as exc:
log_event(
'[Tabular Generated Output] Scheduler candidate query failed',
@@ -684,6 +1446,14 @@ def _can_resume_run(run, settings=None):
return False
+def _can_cancel_run(run):
+ status = str((run or {}).get('status') or '').strip().lower()
+ return not run.get('publishing_started_at') and status not in {
+ TABULAR_EXPORT_STATUS_COMPLETED,
+ TABULAR_EXPORT_STATUS_CANCELED,
+ }
+
+
def _build_checkpoint_summary(completed_batches, batch_count, processed_rows, row_count):
checkpoint_parts = []
if batch_count:
@@ -732,6 +1502,16 @@ def _build_run_status_detail(run, settings, retryable_failure, can_resume):
'retry_delay_seconds': None,
}
if status == TABULAR_EXPORT_STATUS_RUNNING:
+ if run.get('publishing_started_at'):
+ return {
+ 'status_label': 'Finalizing',
+ 'status_tone': 'info',
+ 'status_detail': 'Export validation passed and the final artifact is being published.',
+ 'is_stale': False,
+ 'waiting_for_retry': False,
+ 'retry_due': False,
+ 'retry_delay_seconds': None,
+ }
return {
'status_label': 'Running',
'status_tone': 'info',
@@ -833,6 +1613,8 @@ def _build_run_public_status(run, settings=None):
'storage_scope': 'chat',
'source_file_name': run.get('source_file_name'),
'selected_sheet': run.get('selected_sheet'),
+ 'summary': run.get('post_run_summary'),
+ 'suppress_assistant_table_export': True,
}
return {
@@ -870,11 +1652,13 @@ def _build_run_public_status(run, settings=None):
'manual_resume_count': _safe_int(run.get('manual_resume_count')),
'next_attempt_at': run.get('next_attempt_at'),
'can_resume': can_resume,
+ 'can_cancel': _can_cancel_run(run),
'retryable_failure': retryable_failure,
'artifact_message_id': final_artifact.get('artifact_message_id'),
'file_name': final_artifact.get('file_name') or run.get('generated_file_name'),
'generated_artifact': generated_artifact,
'capability': 'tabular',
+ 'suppress_assistant_table_export': True,
'background_export': not (
str(run.get('status') or '').strip().lower() == TABULAR_EXPORT_STATUS_COMPLETED
and generated_artifact
@@ -889,6 +1673,7 @@ def build_background_tabular_generated_output_metadata(run):
'export_run_id': public_status.get('run_id'),
'background_export': True,
'capability': 'tabular',
+ 'suppress_assistant_table_export': True,
'summary': (
f"Queued structured {str(public_status.get('output_format') or 'json').upper()} export "
f"for {public_status.get('row_count', 0)} row(s) across {public_status.get('batch_count', 0)} batch(es)."
@@ -927,6 +1712,18 @@ def resume_tabular_generated_output_run(user_id, run_id):
except CosmosResourceNotFoundError:
return None
+ try:
+ _authorize_tabular_export_run_execution(run)
+ except (LookupError, PermissionError, ValueError):
+ return {
+ 'success': False,
+ 'resumed': False,
+ 'submitted': False,
+ 'authorization_failed': True,
+ 'message': 'Background export access is no longer authorized.',
+ 'run': _build_run_public_status(run, settings=settings),
+ }
+
status = str(run.get('status') or '').strip().lower()
if status == TABULAR_EXPORT_STATUS_COMPLETED:
return {
@@ -944,6 +1741,14 @@ def resume_tabular_generated_output_run(user_id, run_id):
'message': 'Canceled background exports cannot be continued.',
'run': _build_run_public_status(run, settings=settings),
}
+ if not _can_cancel_run(run):
+ return {
+ 'success': False,
+ 'resumed': False,
+ 'submitted': False,
+ 'message': 'Background export is already publishing and cannot be resumed.',
+ 'run': _build_run_public_status(run, settings=settings),
+ }
if status == TABULAR_EXPORT_STATUS_RUNNING and not _is_stale_running_run(run, settings):
return {
'success': True,
@@ -975,10 +1780,21 @@ def resume_tabular_generated_output_run(user_id, run_id):
'manual_resume_count': _safe_int(run.get('manual_resume_count')) + 1,
'last_manual_resume_at': now,
})
- run = _upsert_run(run)
+ try:
+ run = _replace_run(run)
+ except Exception as exc:
+ if getattr(exc, 'status_code', None) not in (409, 412):
+ raise
+ current_run = _read_run(normalized_user_id, normalized_run_id)
+ return {
+ 'success': False,
+ 'resumed': False,
+ 'submitted': False,
+ 'message': 'Background export state changed. Refresh its status before continuing.',
+ 'run': _build_run_public_status(current_run, settings=settings),
+ }
submitted = submit_tabular_generated_output_run(normalized_run_id, normalized_user_id)
run['submitted_to_executor'] = submitted
- run = _upsert_run(run)
log_event(
'[Tabular Generated Output] Background export manually resumed',
{
@@ -1003,6 +1819,86 @@ def resume_tabular_generated_output_run(user_id, run_id):
}
+def cancel_tabular_generated_output_run(user_id, run_id):
+ """Cancel a queued, running, retryable, or failed generated-output run."""
+ normalized_user_id = str(user_id or '').strip()
+ normalized_run_id = str(run_id or '').strip()
+ if not normalized_user_id or not normalized_run_id:
+ return None
+
+ settings = get_settings()
+ try:
+ run = _read_run(normalized_user_id, normalized_run_id)
+ except CosmosResourceNotFoundError:
+ return None
+
+ status = str(run.get('status') or '').strip().lower()
+ if status == TABULAR_EXPORT_STATUS_COMPLETED:
+ return {
+ 'success': False,
+ 'canceled': False,
+ 'message': 'Completed background exports cannot be canceled.',
+ 'run': _build_run_public_status(run, settings=settings),
+ }
+ if status == TABULAR_EXPORT_STATUS_CANCELED:
+ return {
+ 'success': True,
+ 'canceled': True,
+ 'message': 'Background export is already canceled.',
+ 'run': _build_run_public_status(run, settings=settings),
+ }
+ if not _can_cancel_run(run):
+ return {
+ 'success': False,
+ 'canceled': False,
+ 'message': 'Background export is already publishing and can no longer be canceled.',
+ 'run': _build_run_public_status(run, settings=settings),
+ }
+
+ now = _now_iso()
+ run.update({
+ 'status': TABULAR_EXPORT_STATUS_CANCELED,
+ 'updated_at': now,
+ 'completed_at': now,
+ 'last_heartbeat_at': now,
+ 'lease_holder_id': None,
+ 'lease_expires_at': None,
+ 'next_attempt_at': None,
+ 'last_message': 'Background structured export canceled by the user',
+ 'last_error': None,
+ 'canceled_at': now,
+ })
+ try:
+ run = _replace_run(run)
+ except Exception as exc:
+ if getattr(exc, 'status_code', None) not in (409, 412):
+ raise
+ current_run = _read_run(normalized_user_id, normalized_run_id)
+ return {
+ 'success': False,
+ 'canceled': False,
+ 'message': 'Background export state changed. Refresh its status before canceling.',
+ 'run': _build_run_public_status(current_run, settings=settings),
+ }
+ log_event(
+ '[Tabular Generated Output] Background export canceled',
+ {
+ 'run_id': normalized_run_id,
+ 'conversation_id': run.get('conversation_id'),
+ 'user_id': normalized_user_id,
+ 'completed_batches': run.get('completed_batches'),
+ 'processed_rows': run.get('processed_rows'),
+ },
+ level=logging.INFO,
+ )
+ return {
+ 'success': True,
+ 'canceled': True,
+ 'message': 'Background export canceled.',
+ 'run': _build_run_public_status(run, settings=settings),
+ }
+
+
def _read_run(user_id, run_id):
return cosmos_tabular_export_runs_container.read_item(
item=run_id,
@@ -1010,6 +1906,37 @@ def _read_run(user_id, run_id):
)
+def _raise_if_tabular_export_canceled(run):
+ current_run = _read_run(run.get('user_id'), run.get('id'))
+ current_status = str(current_run.get('status') or '').strip().lower()
+ if current_status == TABULAR_EXPORT_STATUS_CANCELED:
+ run.clear()
+ run.update(current_run)
+ raise TabularExportCanceledError('Background structured export was canceled')
+
+ claim_matches = (
+ current_status == TABULAR_EXPORT_STATUS_RUNNING
+ and str(current_run.get('lease_holder_id') or '') == str(run.get('lease_holder_id') or '')
+ and _safe_int(current_run.get('lease_generation')) == _safe_int(run.get('lease_generation'))
+ )
+ if not claim_matches:
+ raise TabularExportLeaseLostError('Background structured export worker lost its claim')
+
+ run['_etag'] = current_run.get('_etag')
+ return current_run
+
+
+def _replace_claimed_run(run):
+ try:
+ return _replace_run(run)
+ except Exception as exc:
+ if getattr(exc, 'status_code', None) in (409, 412):
+ raise TabularExportLeaseLostError(
+ 'Background structured export worker lost its claim'
+ ) from exc
+ raise
+
+
def _replace_run(run):
return cosmos_tabular_export_runs_container.replace_item(
item=run.get('id'),
@@ -1019,10 +1946,6 @@ def _replace_run(run):
)
-def _upsert_run(run):
- return cosmos_tabular_export_runs_container.upsert_item(run)
-
-
def _lease_holder_id():
return f"{socket.gethostname()}:{os.getpid()}:{uuid.uuid4().hex}"
@@ -1085,6 +2008,7 @@ def _try_claim_run(user_id, run_id, settings):
'completed_at': None,
'last_heartbeat_at': now.isoformat(),
'lease_holder_id': _lease_holder_id(),
+ 'lease_generation': _safe_int(run.get('lease_generation')) + 1,
'lease_expires_at': (now + timedelta(seconds=lease_seconds)).isoformat(),
'next_attempt_at': None,
'last_message': 'Background structured export is running',
@@ -1112,7 +2036,10 @@ def _mark_run_failed(run, error_message):
'last_error': str(error_message or 'Unknown error')[:1000],
'last_message': 'Background structured export failed',
})
- _upsert_run(run)
+ try:
+ run = _replace_claimed_run(run)
+ except TabularExportLeaseLostError:
+ return _read_run(run.get('user_id'), run.get('id'))
log_event(
'[Tabular Generated Output] Background export run failed',
{
@@ -1161,7 +2088,10 @@ def _mark_run_retryable(run, error_message, settings):
'transient_failure_count': transient_failure_count,
'next_attempt_at': next_attempt_at,
})
- _upsert_run(run)
+ try:
+ run = _replace_claimed_run(run)
+ except TabularExportLeaseLostError:
+ return _read_run(run.get('user_id'), run.get('id'))
log_event(
'[Tabular Generated Output] Background export run requeued after transient failure',
{
@@ -1236,7 +2166,7 @@ def _update_run_progress(run, completed_batches, processed_rows, batch_rows, bat
})
run['recent_batches'] = recent_batches
- return _upsert_run(run)
+ return _replace_claimed_run(run)
def _log_progress_if_due(run, last_logged_at):
@@ -1268,50 +2198,124 @@ def _log_progress_if_due(run, last_logged_at):
return now_monotonic
-def _assemble_output_entries(run):
- output_entries = []
+def _write_ordered_output_stream(run, output_stream):
user_id = run.get('user_id')
conversation_id = run.get('conversation_id')
run_id = run.get('id')
batch_count = _safe_int(run.get('batch_count'))
+ expected_row_count = _safe_int(run.get('row_count'))
+ output_format = str(run.get('output_format') or 'json').strip().lower() or 'json'
+ output_schema = list(run.get('output_schema') or [])
+ if not output_schema:
+ raise ValueError('Generated output schema is missing')
+ if TABULAR_EXPORT_OUTPUT_ROW_NUMBER_FIELD not in output_schema:
+ raise ValueError('Generated output schema is missing source row order')
+
+ csv_writer = None
+ safe_output_schema = None
+ if output_format == 'csv':
+ safe_output_schema = build_safe_csv_headers(output_schema)
+ csv_writer = csv.DictWriter(output_stream, fieldnames=safe_output_schema, lineterminator='\n')
+ csv_writer.writeheader()
+ else:
+ output_stream.write('[\n')
+
+ written_row_count = 0
+ expected_source_row_number = 1
for batch_number in range(1, batch_count + 1):
batch_blob_path = _output_blob_path(user_id, conversation_id, run_id, batch_number)
batch_entries = _download_json_blob(batch_blob_path)
- if isinstance(batch_entries, list):
- output_entries.extend(batch_entries)
- return output_entries
+ if not isinstance(batch_entries, list):
+ raise ValueError(f'Output checkpoint {batch_number}/{batch_count} was not a JSON array')
+ for batch_row_index, entry in enumerate(batch_entries, start=1):
+ if not isinstance(entry, dict):
+ raise ValueError(
+ f'Output checkpoint {batch_number}/{batch_count} row {batch_row_index} was not an object'
+ )
+ if set(entry) != set(output_schema):
+ raise ValueError(
+ f'Output checkpoint {batch_number}/{batch_count} row {batch_row_index} has schema drift'
+ )
-def _complete_run(run):
- output_entries = _assemble_output_entries(run)
- output_format = normalize_generated_output_format(run.get('output_format'))
- if output_format == 'csv':
- serialized_output = _build_generated_output_csv(output_entries)
- elif output_format == 'xml':
- serialized_output = serialize_generated_xml(
- output_entries,
- root_name='GeneratedRows',
- item_name='Row',
+ source_row_number = _safe_int(entry.get(TABULAR_EXPORT_OUTPUT_ROW_NUMBER_FIELD))
+ if source_row_number != expected_source_row_number:
+ raise ValueError(
+ f'Source row order gap or overlap: expected {expected_source_row_number}, '
+ f'found {source_row_number}'
+ )
+ ordered_entry = {
+ field_name: entry.get(field_name)
+ for field_name in output_schema
+ }
+ if csv_writer:
+ csv_writer.writerow({
+ safe_field_name: _serialize_generated_output_value(ordered_entry.get(field_name))
+ for field_name, safe_field_name in zip(output_schema, safe_output_schema)
+ })
+ else:
+ if written_row_count:
+ output_stream.write(',\n')
+ output_stream.write(json.dumps(ordered_entry, default=str, ensure_ascii=False))
+
+ written_row_count += 1
+ expected_source_row_number += 1
+
+ if output_format != 'csv':
+ output_stream.write('\n]\n')
+ if written_row_count != expected_row_count:
+ raise ValueError(
+ f'Final output row count {written_row_count} does not match expected count {expected_row_count}'
)
- else:
- serialized_output = serialize_generated_json(output_entries)
+ return written_row_count
+
+def _complete_run(run):
+ output_format = normalize_generated_output_format(run.get('output_format'))
generated_file_name = run.get('generated_file_name') or _build_generated_file_name(
run.get('source_file_name'),
output_format,
)
- upload_result = upload_generated_analysis_artifact_for_user(
- current_user_id=run.get('user_id'),
- conversation_id=run.get('conversation_id'),
- file_name=generated_file_name,
- file_content=serialized_output,
- capability='tabular',
- output_format=output_format,
- summary=(
- f"Saved {len(output_entries)} row(s) to {generated_file_name} "
- 'from a durable background tabular export.'
- ),
- )
+ with tempfile.SpooledTemporaryFile(
+ max_size=TABULAR_EXPORT_FINAL_SPOOL_MAX_MEMORY_BYTES,
+ mode='w+b',
+ ) as binary_output_stream:
+ text_output_stream = io.TextIOWrapper(
+ binary_output_stream,
+ encoding='utf-8',
+ newline='',
+ write_through=True,
+ )
+ try:
+ output_entry_count = _write_ordered_output_stream(run, text_output_stream)
+ post_run_summary = _build_compact_post_run_summary(run)
+ _authorize_tabular_export_run_execution(run)
+ _raise_if_tabular_export_canceled(run)
+ if not run.get('publishing_started_at'):
+ run.update({
+ 'publishing_started_at': _now_iso(),
+ 'last_message': 'Final validation passed; publishing the generated artifact',
+ })
+ run = _replace_claimed_run(run)
+ _authorize_tabular_export_run_execution(run)
+ text_output_stream.flush()
+ output_size = binary_output_stream.tell()
+ binary_output_stream.seek(0)
+ upload_result = upload_generated_analysis_artifact_stream_for_user(
+ current_user_id=run.get('user_id'),
+ conversation_id=run.get('conversation_id'),
+ file_name=generated_file_name,
+ file_stream=binary_output_stream,
+ file_size=output_size,
+ capability='tabular',
+ output_format=output_format,
+ summary=post_run_summary,
+ artifact_idempotency_key=f"tabular-generated-output:{run.get('id')}",
+ )
+ _raise_if_tabular_export_canceled(run)
+ finally:
+ text_output_stream.detach()
+
uploaded_message = upload_result.get('message') or {}
now = _now_iso()
run.update({
@@ -1319,9 +2323,10 @@ def _complete_run(run):
'updated_at': now,
'completed_at': now,
'last_heartbeat_at': now,
- 'processed_rows': len(output_entries),
+ 'processed_rows': output_entry_count,
'completed_batches': _safe_int(run.get('batch_count')),
'last_message': 'Background structured export completed',
+ 'post_run_summary': post_run_summary,
'generated_file_name': uploaded_message.get('file_name') or generated_file_name,
'final_artifact': {
'artifact_message_id': uploaded_message.get('id'),
@@ -1333,7 +2338,7 @@ def _complete_run(run):
},
'estimated_remaining_seconds': 0,
})
- _upsert_run(run)
+ run = _replace_claimed_run(run)
log_event(
'[Tabular Generated Output] Background export completed',
{
@@ -1342,7 +2347,7 @@ def _complete_run(run):
'user_id': run.get('user_id'),
'source_file_name': run.get('source_file_name'),
'output_format': output_format,
- 'row_count': len(output_entries),
+ 'row_count': output_entry_count,
'batch_count': run.get('batch_count'),
'artifact_message_id': uploaded_message.get('id'),
'generated_file_name': uploaded_message.get('file_name') or generated_file_name,
@@ -1383,8 +2388,37 @@ def _build_batch_window(run, input_batches, user_id, run_id, window_start, windo
batch_number,
)
- if _blob_exists(output_blob_path):
+ if _blob_exists(output_blob_path) and not run.get('regenerate_legacy_output_checkpoints'):
batch_entries = _download_json_blob(output_blob_path)
+ expected_output_schema = set(run.get('output_schema') or [])
+ if not isinstance(batch_entries, list) or not batch_entries:
+ raise ValueError(f'Output checkpoint {batch_number}/{batch_count} is empty or malformed')
+ for checkpoint_row_index, checkpoint_entry in enumerate(batch_entries, start=1):
+ if not isinstance(checkpoint_entry, dict):
+ raise ValueError(
+ f'Output checkpoint {batch_number}/{batch_count} row {checkpoint_row_index} is not an object'
+ )
+ if set(checkpoint_entry) != expected_output_schema:
+ raise ValueError(
+ f'Output checkpoint {batch_number}/{batch_count} row {checkpoint_row_index} has schema drift'
+ )
+ summary_blob_path = _output_summary_blob_path(
+ user_id,
+ run.get('conversation_id'),
+ run_id,
+ batch_number,
+ )
+ if isinstance(batch_entries, list) and not _blob_exists(summary_blob_path):
+ _upload_json_blob(
+ summary_blob_path,
+ _build_generated_batch_summary(batch_entries),
+ metadata={
+ 'run_id': run_id,
+ 'conversation_id': run.get('conversation_id'),
+ 'batch_number': batch_number,
+ 'generated_output_summary': 'true',
+ },
+ )
batch_results[batch_number] = {
'batch_number': batch_number,
'batch_row_count': len(batch_entries) if isinstance(batch_entries, list) else 0,
@@ -1415,8 +2449,29 @@ def _build_batch_window(run, input_batches, user_id, run_id, window_start, windo
def _checkpoint_generated_batch_results(run, generated_results):
+ _raise_if_tabular_export_canceled(run)
batch_results = {}
- for generated_result in generated_results:
+ expected_output_schema = list(run.get('output_schema') or [])
+ ordered_results = sorted(generated_results, key=lambda result: result['batch_number'])
+ for generated_result in ordered_results:
+ generated_output_schema = list(generated_result.get('output_schema') or [])
+ if not expected_output_schema:
+ expected_output_schema = generated_output_schema
+ if generated_output_schema != expected_output_schema:
+ raise ValueError(
+ f"Generated output schema drifted in batch {generated_result['batch_number']}"
+ )
+
+ if not expected_output_schema:
+ raise ValueError('Generated output schema could not be established')
+ if list(run.get('output_schema') or []) != expected_output_schema:
+ run['output_schema'] = expected_output_schema
+ persisted_run = _replace_claimed_run(run)
+ run.clear()
+ run.update(persisted_run)
+
+ for generated_result in ordered_results:
+ _raise_if_tabular_export_canceled(run)
batch_number = generated_result['batch_number']
output_blob_path = _output_blob_path(
run.get('user_id'),
@@ -1424,14 +2479,49 @@ def _checkpoint_generated_batch_results(run, generated_results):
run.get('id'),
batch_number,
)
+ try:
+ _upload_json_blob(
+ output_blob_path,
+ generated_result['batch_entries'],
+ metadata={
+ 'run_id': run.get('id'),
+ 'conversation_id': run.get('conversation_id'),
+ 'batch_number': batch_number,
+ 'generated_output': 'true',
+ 'lease_generation': run.get('lease_generation'),
+ },
+ overwrite=bool(run.get('regenerate_legacy_output_checkpoints')),
+ )
+ except ResourceExistsError:
+ checkpoint_entries = _download_json_blob(output_blob_path)
+ if (
+ not isinstance(checkpoint_entries, list)
+ or len(checkpoint_entries) != generated_result['batch_row_count']
+ or any(
+ not isinstance(entry, dict) or set(entry) != set(expected_output_schema)
+ for entry in checkpoint_entries
+ )
+ ):
+ raise ValueError(
+ f'Concurrent output checkpoint {batch_number} failed schema or row-count validation'
+ )
+ generated_result['batch_entries'] = checkpoint_entries
+ generated_result['batch_summary'] = _build_generated_batch_summary(checkpoint_entries)
_upload_json_blob(
- output_blob_path,
- generated_result['batch_entries'],
+ _output_summary_blob_path(
+ run.get('user_id'),
+ run.get('conversation_id'),
+ run.get('id'),
+ batch_number,
+ ),
+ generated_result.get('batch_summary') or _build_generated_batch_summary(
+ generated_result['batch_entries']
+ ),
metadata={
'run_id': run.get('id'),
'conversation_id': run.get('conversation_id'),
'batch_number': batch_number,
- 'generated_output': 'true',
+ 'generated_output_summary': 'true',
},
)
batch_results[batch_number] = {
@@ -1444,6 +2534,31 @@ def _checkpoint_generated_batch_results(run, generated_results):
return batch_results
+def _build_passthrough_batch_results(run, batch_requests):
+ """Create checkpoint entries directly when rows are already final export output."""
+ expected_output_schema = list(run.get('output_schema') or [])
+ generated_results = []
+ for batch_request in batch_requests:
+ batch_started_at = time.monotonic()
+ batch_entries, output_schema = _normalize_generated_batch_entries(
+ batch_request['rows'],
+ batch_request['rows'],
+ expected_output_schema=expected_output_schema,
+ )
+ if not expected_output_schema:
+ expected_output_schema = output_schema
+ generated_results.append({
+ 'batch_number': batch_request['batch_number'],
+ 'batch_entries': batch_entries,
+ 'batch_summary': _build_generated_batch_summary(batch_entries),
+ 'batch_row_count': len(batch_entries),
+ 'elapsed_seconds': time.monotonic() - batch_started_at,
+ 'mismatch_count': 0,
+ 'output_schema': output_schema,
+ })
+ return generated_results
+
+
def _advance_run_progress_for_window(run, batch_results, completed_batches, processed_rows, window_start, window_end):
for batch_number in range(window_start, window_end + 1):
batch_result = batch_results.get(batch_number)
@@ -1478,6 +2593,11 @@ def process_tabular_generated_output_run(run_id, user_id):
return None
try:
+ _authorize_tabular_export_run_execution(run)
+ run = _migrate_legacy_tabular_export_run(run)
+ if run.get('source_descriptor') and not run.get('source_staging_complete'):
+ run = _stage_tabular_generated_output_source(run, settings)
+
retry_attempts = _settings_int(
settings,
'tabular_generated_output_batch_retry_attempts',
@@ -1492,11 +2612,29 @@ def process_tabular_generated_output_run(run_id, user_id):
minimum=1,
maximum=TABULAR_EXPORT_MAX_BATCH_CONCURRENCY,
)
- chat_service = _build_chat_service(
- run.get('gpt_model'),
+ stale_seconds = _settings_int(
settings,
- model_context=run.get('model_context'),
+ 'tabular_generated_output_stale_seconds',
+ TABULAR_EXPORT_DEFAULT_STALE_SECONDS,
+ minimum=60,
+ )
+ batch_timeout_seconds = min(
+ _settings_int(
+ settings,
+ 'tabular_generated_output_batch_timeout_seconds',
+ TABULAR_EXPORT_DEFAULT_BATCH_TIMEOUT_SECONDS,
+ minimum=30,
+ maximum=900,
+ ),
+ max(30, stale_seconds - 30),
)
+ chat_service = None
+ if not run.get('passthrough_input_rows'):
+ chat_service = _build_chat_service(
+ run.get('gpt_model'),
+ settings,
+ model_context=run.get('model_context'),
+ )
completed_batches = _safe_int(run.get('completed_batches'))
processed_rows = _safe_int(run.get('processed_rows'))
batch_count = _safe_int(run.get('batch_count'))
@@ -1520,13 +2658,17 @@ def process_tabular_generated_output_run(run_id, user_id):
'batch_count': batch_count,
'resume_completed_batches': completed_batches,
'batch_concurrency': batch_concurrency,
+ 'batch_timeout_seconds': batch_timeout_seconds,
},
level=logging.INFO,
)
while completed_batches < batch_count:
+ _raise_if_tabular_export_canceled(run)
window_start = completed_batches + 1
window_end = min(batch_count, window_start + batch_concurrency - 1)
+ if not run.get('output_schema'):
+ window_end = window_start
batch_results, batch_requests = _build_batch_window(
run,
input_batches,
@@ -1549,23 +2691,30 @@ def process_tabular_generated_output_run(run_id, user_id):
'window_end': window_end,
'batch_count': batch_count,
'batch_concurrency': batch_concurrency,
+ 'batch_timeout_seconds': batch_timeout_seconds,
'generation_request_count': len(batch_requests),
},
debug_only=True,
)
- generated_results, generation_error = asyncio.run(
- _generate_batch_window_entries(
- chat_service,
- run.get('user_question'),
- batch_requests,
- batch_count,
- run.get('source_file_name'),
- run.get('selected_sheet'),
- retry_attempts,
- normalized_run_id,
- batch_concurrency,
+ if run.get('passthrough_input_rows'):
+ generated_results = _build_passthrough_batch_results(run, batch_requests)
+ else:
+ generated_results, generation_error = asyncio.run(
+ _generate_batch_window_entries(
+ chat_service,
+ run.get('user_question'),
+ batch_requests,
+ batch_count,
+ run.get('source_file_name'),
+ run.get('selected_sheet'),
+ retry_attempts,
+ normalized_run_id,
+ batch_concurrency,
+ expected_output_schema=run.get('output_schema'),
+ batch_timeout_seconds=batch_timeout_seconds,
+ )
)
- )
+ _raise_if_tabular_export_canceled(run)
batch_results.update(_checkpoint_generated_batch_results(run, generated_results))
previous_completed_batches = completed_batches
@@ -1583,7 +2732,12 @@ def process_tabular_generated_output_run(run_id, user_id):
if completed_batches == previous_completed_batches:
raise RuntimeError(f'No progress was made for batch window {window_start}-{window_end}')
+ _raise_if_tabular_export_canceled(run)
return _complete_run(run)
+ except TabularExportCanceledError:
+ return _read_run(normalized_user_id, normalized_run_id)
+ except TabularExportLeaseLostError:
+ return _read_run(normalized_user_id, normalized_run_id)
except Exception as exc:
if _is_retryable_export_error(exc):
return _mark_run_retryable(run, exc, settings)
@@ -1620,6 +2774,8 @@ def queue_tabular_generated_output_run(
gpt_model,
settings=None,
model_context=None,
+ source_descriptor=None,
+ passthrough_input_rows=False,
):
"""Stage batch input blobs, create a run record, and submit background processing."""
normalized_user_id = str(user_id or '').strip()
@@ -1633,34 +2789,80 @@ def queue_tabular_generated_output_run(
selected_sheet = str(source_candidate.get('selected_sheet') or '').strip()
normalized_output_format = str(output_format or 'json').strip().lower() or 'json'
generated_file_name = _build_generated_file_name(source_file_name, normalized_output_format)
- row_batches = list(row_batches or [])
+ settings = settings or {}
+ source_descriptor = dict(source_descriptor or {})
+ source_authorization = dict(source_candidate.get('source_authorization') or {})
staged_row_count = 0
staged_char_count = 0
- normalized_row_batches = []
-
- for index, batch_rows in enumerate(row_batches, start=1):
- if not isinstance(batch_rows, list):
- batch_rows = list(batch_rows or [])
- normalized_row_batches.append(batch_rows)
- staged_row_count += len(batch_rows)
- staged_char_count += len(json.dumps(batch_rows, default=str, ensure_ascii=False))
+ staged_batch_count = 0
+
+ if source_descriptor:
+ staged_row_count = _safe_int(source_descriptor.get('expected_row_count'))
+ if staged_row_count <= 0:
+ raise ValueError('Source query descriptor must include the expected row count')
+ source_descriptor['batch_max_rows'] = _safe_int(
+ source_descriptor.get('batch_max_rows'),
+ default=_settings_int(
+ settings,
+ 'tabular_generated_output_max_batch_rows',
+ TABULAR_EXPORT_DEFAULT_SOURCE_BATCH_ROWS,
+ minimum=1,
+ maximum=100,
+ ),
+ minimum=1,
+ maximum=100,
+ )
+ source_descriptor['batch_max_chars'] = _safe_int(
+ source_descriptor.get('batch_max_chars'),
+ default=_settings_int(
+ settings,
+ 'tabular_generated_output_max_batch_chars',
+ TABULAR_EXPORT_DEFAULT_SOURCE_BATCH_CHARS,
+ minimum=6000,
+ maximum=120000,
+ ),
+ minimum=6000,
+ maximum=120000,
+ )
+ staged_batch_count = max(
+ 1,
+ math.ceil(staged_row_count / source_descriptor['batch_max_rows']),
+ )
+ source_authorization = {
+ field_name: source_descriptor.get(field_name)
+ for field_name in ('source', 'scope_id', 'container', 'blob_path')
+ }
+ else:
+ for index, batch_rows in enumerate(row_batches or [], start=1):
+ if not isinstance(batch_rows, list):
+ batch_rows = list(batch_rows or [])
+ prepared_batch_rows = _prepare_tabular_source_rows(
+ batch_rows,
+ start_row=staged_row_count,
+ token_namespace=run_id,
+ )
+ _upload_json_blob(
+ _input_blob_path(normalized_user_id, normalized_conversation_id, run_id, index),
+ prepared_batch_rows,
+ metadata={
+ 'run_id': run_id,
+ 'conversation_id': normalized_conversation_id,
+ 'generated_output_input': 'true',
+ 'batch_number': index,
+ },
+ )
+ staged_row_count += len(prepared_batch_rows)
+ staged_char_count += len(json.dumps(prepared_batch_rows, default=str, ensure_ascii=False))
+ staged_batch_count = index
- input_blob_path = _input_batches_blob_path(normalized_user_id, normalized_conversation_id, run_id)
- _upload_json_blob(
- input_blob_path,
- normalized_row_batches,
- metadata={
- 'run_id': run_id,
- 'conversation_id': normalized_conversation_id,
- 'generated_output_input': 'true',
- 'batch_count': len(normalized_row_batches),
- },
- )
+ if not staged_batch_count or not staged_row_count:
+ raise ValueError('At least one source row is required for a background tabular export')
now = _now_iso()
run = {
'id': run_id,
'type': TABULAR_EXPORT_RUN_TYPE,
+ 'contract_version': TABULAR_EXPORT_CONTRACT_VERSION,
'user_id': normalized_user_id,
'conversation_id': normalized_conversation_id,
'status': TABULAR_EXPORT_STATUS_QUEUED,
@@ -1675,13 +2877,21 @@ def queue_tabular_generated_output_run(
'output_format': normalized_output_format,
'gpt_model': str(gpt_model or '').strip(),
'model_context': model_context if isinstance(model_context, dict) else {},
+ 'passthrough_input_rows': bool(passthrough_input_rows),
'generated_file_name': generated_file_name,
'row_count': staged_row_count,
- 'batch_count': len(row_batches),
+ 'batch_count': staged_batch_count,
'completed_batches': 0,
'processed_rows': 0,
+ 'output_schema': None,
+ 'source_descriptor': source_descriptor or None,
+ 'source_authorization': source_authorization or None,
+ 'source_staging_complete': not bool(source_descriptor),
+ 'source_staged_rows': 0 if source_descriptor else staged_row_count,
+ 'source_staged_batches': 0 if source_descriptor else staged_batch_count,
+ 'source_scan_row_count': 0,
'input_blob_container': storage_account_personal_chat_container_name,
- 'input_blob_path': input_blob_path,
+ 'input_blob_path': None,
'input_blob_prefix': f'{normalized_user_id}/{normalized_conversation_id}/generated/tabular_runs/{run_id}/input/',
'output_blob_container': storage_account_personal_chat_container_name,
'output_blob_prefix': f'{normalized_user_id}/{normalized_conversation_id}/generated/tabular_runs/{run_id}/output/',
@@ -1708,8 +2918,9 @@ def queue_tabular_generated_output_run(
'selected_sheet': selected_sheet,
'output_format': normalized_output_format,
'row_count': staged_row_count,
- 'batch_count': len(row_batches),
+ 'batch_count': staged_batch_count,
'staged_input_char_count': staged_char_count,
+ 'source_backed': bool(source_descriptor),
'submitted_to_executor': submitted,
},
level=logging.INFO,
@@ -1736,7 +2947,7 @@ def check_due_tabular_generated_output_runs_once(limit=None):
scanned_candidates = []
status_counts = {}
for status in TABULAR_EXPORT_SCHEDULER_STATUSES:
- status_candidates = _query_scheduler_candidates_by_status(status, scan_limit)
+ status_candidates = _query_scheduler_candidates_by_status(status, scan_limit, settings)
status_counts[status] = len(status_candidates)
scanned_candidates.extend(status_candidates)
@@ -1765,15 +2976,19 @@ def check_due_tabular_generated_output_runs_once(limit=None):
for candidate in candidates:
run = candidate.get('run') or {}
status = str(run.get('status') or '').strip().lower()
- processed_run = process_tabular_generated_output_run(run.get('id'), run.get('user_id'))
- if processed_run:
- processed.append(processed_run.get('id'))
+ submitted = submit_tabular_generated_output_run(run.get('id'), run.get('user_id'))
+ if submitted:
+ processed.append(run.get('id'))
else:
- skipped.append({
- 'run_id': run.get('id'),
- 'status': status,
- 'reason': f"{candidate.get('reason')}; claim or processing did not start",
- })
+ processed_run = process_tabular_generated_output_run(run.get('id'), run.get('user_id'))
+ if processed_run:
+ processed.append(processed_run.get('id'))
+ else:
+ skipped.append({
+ 'run_id': run.get('id'),
+ 'status': status,
+ 'reason': f"{candidate.get('reason')}; claim or processing did not start",
+ })
if scanned_candidates or candidates:
log_event(
diff --git a/application/single_app/functions_workflow_runner.py b/application/single_app/functions_workflow_runner.py
index 627dcf411..4dc04679e 100644
--- a/application/single_app/functions_workflow_runner.py
+++ b/application/single_app/functions_workflow_runner.py
@@ -10,6 +10,7 @@
import logging
import os
import re
+import time
import uuid
from contextlib import contextmanager
from datetime import datetime, timezone
@@ -44,6 +45,7 @@
cosmos_messages_container,
cosmos_public_documents_container,
cosmos_user_documents_container,
+ storage_account_personal_chat_container_name,
)
from functions_conversation_context import (
build_conversation_context_data_message,
@@ -53,6 +55,19 @@
)
from functions_activity_logging import log_conversation_creation, log_token_usage, log_workflow_run
from functions_appinsights import log_event
+from functions_assistant_table_exports import (
+ build_safe_csv_headers,
+ has_generated_tabular_csv_output,
+ neutralize_csv_spreadsheet_formula,
+)
+from functions_generated_file_exports import (
+ build_generated_file_artifact_metadata,
+ build_generated_file_export,
+ build_generated_file_output_guidance,
+ get_generated_file_export_content,
+ get_requested_generated_file_format,
+ has_generated_file_output,
+)
from functions_chart_operations import append_proactive_chart_guidance
from functions_collaboration import (
create_collaboration_message_notifications,
@@ -66,6 +81,7 @@
from functions_document_actions import (
DOCUMENT_ACTION_ANALYSIS_MODE_PER_DOCUMENT,
DOCUMENT_ACTION_CONTEXT_WORKFLOW,
+ DOCUMENT_ACTION_TARGET_MODE_ALL,
DOCUMENT_ACTION_TARGET_MODE_RECENT,
DOCUMENT_ACTION_TYPE_COMPARISON,
DOCUMENT_ACTION_TYPE_ANALYZE,
@@ -80,7 +96,13 @@
normalize_document_action_analysis_mode,
)
from functions_documents import select_current_documents, sort_documents
-from functions_document_comparison import run_document_comparison
+from functions_document_access_index import (
+ DOCUMENT_ACCESS_SCOPE_GROUP,
+ DOCUMENT_ACCESS_SCOPE_PERSONAL,
+ DOCUMENT_ACCESS_SCOPE_PUBLIC,
+ enumerate_bounded_document_access_index_ids,
+)
+from functions_document_comparison import run_document_comparison, run_evidence_document_comparison
from functions_debug import debug_print
from functions_document_analysis import run_document_analysis
from functions_file_sync import get_authorized_sync_source, queue_file_sync_source_run
@@ -99,6 +121,27 @@
build_agent_citation_artifact_documents,
make_json_serializable,
)
+from functions_mixed_source_orchestration import (
+ EVIDENCE_ENGINE_DOCUMENT_ANALYSIS,
+ EVIDENCE_ENGINE_TABULAR_TOOLS,
+ EVIDENCE_STATUS_COMPLETED,
+ EVIDENCE_STATUS_FAILED,
+ MixedSourceCancellationError,
+ MixedSourceFinalizationError,
+ SELECTION_MODE_SELECTED,
+ build_evidence_envelope,
+ build_failed_narrative_evidence_envelopes,
+ build_mixed_source_evidence_handoff,
+ compare_reauthorized_source_manifests,
+ evaluate_mixed_source_mode_outcome,
+ build_narrative_evidence_envelopes,
+ deduplicate_mixed_source_references,
+ emit_mixed_source_telemetry,
+ partition_source_manifest,
+ normalize_mixed_source_correlation_id,
+ resolve_authorized_source_manifest,
+ raise_if_mixed_source_cancelled,
+)
from model_endpoint_clients import (
MODEL_ENDPOINT_PROTOCOL_AZURE_OPENAI,
)
@@ -116,10 +159,35 @@
save_personal_workflow_run_item,
)
from functions_public_workspaces import get_user_visible_public_workspace_ids_from_settings
-from functions_search_service import resolve_document_context, search_documents
+from functions_search_service import (
+ resolve_document_context,
+ search_documents,
+ search_relevant_tabular_candidates,
+)
from functions_search import normalize_search_id_list, normalize_search_scope, normalize_search_top_n
-from functions_simplechat_operations import upload_generated_analysis_artifact_for_current_user
-from functions_settings import get_settings, get_user_settings, is_tabular_processing_enabled, normalize_model_endpoints, resolve_model_endpoint_foundry_scope
+from functions_simplechat_operations import (
+ delete_generated_chat_artifact_for_current_user,
+ delete_generated_chat_artifact_for_user,
+ upload_generated_analysis_artifact_for_current_user,
+ upload_generated_analysis_artifact_for_user,
+)
+from functions_settings import (
+ get_settings,
+ get_user_settings,
+ is_mixed_source_chat_search_enabled,
+ is_mixed_source_manifest_enabled,
+ is_cross_format_compare_enabled,
+ is_cross_format_compare_one_to_many_enabled,
+ is_tabular_processing_enabled,
+ normalize_model_endpoints,
+ resolve_model_endpoint_foundry_scope,
+)
+from functions_tabular_generated_exports import (
+ build_background_tabular_generated_output_metadata,
+ build_tabular_generated_output_row_batches,
+ queue_tabular_generated_output_run,
+ should_queue_tabular_generated_output_background,
+)
from functions_source_review import (
URL_ACCESS_CONTEXT_WORKFLOW,
compact_source_review_result_for_metadata,
@@ -127,6 +195,7 @@
validate_url_access_request,
)
from functions_thoughts import ThoughtTracker
+from functions_tabular_generated_exports import cancel_tabular_generated_output_run
from semantic_kernel_loader import (
get_max_auto_invoke_attempts,
load_core_plugins_only,
@@ -297,6 +366,11 @@ def _mark_unfinished_workflow_run_items_cancelled(workflow, run_id):
_save_workflow_run_item_record(workflow, cancelled_item)
+def create_workflow_run_id():
+ """Create a server-side identifier for a new workflow run."""
+ return str(uuid.uuid4())
+
+
def _utc_now():
return datetime.now(timezone.utc)
@@ -305,21 +379,29 @@ def _utc_now_iso():
return _utc_now().isoformat()
-def create_workflow_run_id():
- """Create a server-side identifier for a new workflow run."""
- return str(uuid.uuid4())
-
-
def _strip_markdown_code_fence(text):
normalized_text = str(text or '').strip()
if not normalized_text.startswith('```'):
return normalized_text
- code_fence_match = re.fullmatch(r'```(?:[a-zA-Z0-9_-]+)?\s*(.*?)\s*```', normalized_text, re.DOTALL)
- if not code_fence_match:
+ if not normalized_text.endswith('```'):
return normalized_text
- return str(code_fence_match.group(1) or '').strip()
+ fenced_body = normalized_text[3:-3]
+ label_end = 0
+ while label_end < len(fenced_body) and (
+ ('a' <= fenced_body[label_end] <= 'z')
+ or ('A' <= fenced_body[label_end] <= 'Z')
+ or ('0' <= fenced_body[label_end] <= '9')
+ or fenced_body[label_end] in ('_', '-')
+ ):
+ label_end += 1
+
+ body_start = label_end
+ while body_start < len(fenced_body) and fenced_body[body_start].isspace():
+ body_start += 1
+
+ return fenced_body[body_start:].strip()
def _parse_json_artifact_payload(text):
@@ -995,13 +1077,13 @@ def _serialize_document_analysis_csv_value(value):
if value is None:
return ''
if isinstance(value, (dict, list)):
- return json.dumps(value, ensure_ascii=False, default=str)
+ return neutralize_csv_spreadsheet_formula(json.dumps(value, ensure_ascii=False, default=str))
if hasattr(value, 'isoformat') and not isinstance(value, str):
try:
- return value.isoformat()
+ return neutralize_csv_spreadsheet_formula(value.isoformat())
except TypeError:
pass
- return str(value)
+ return neutralize_csv_spreadsheet_formula(value)
def _add_document_analysis_source_context(row, source_context):
@@ -1098,13 +1180,14 @@ def add_column(column_name):
for column_name in row.keys():
add_column(column_name)
+ safe_ordered_columns = build_safe_csv_headers(ordered_columns)
output_buffer = io.StringIO()
- writer = csv.DictWriter(output_buffer, fieldnames=ordered_columns, lineterminator='\n')
+ writer = csv.DictWriter(output_buffer, fieldnames=safe_ordered_columns, lineterminator='\n')
writer.writeheader()
for row in rows:
writer.writerow({
- column_name: _serialize_document_analysis_csv_value(row.get(column_name))
- for column_name in ordered_columns
+ safe_column_name: _serialize_document_analysis_csv_value(row.get(column_name))
+ for column_name, safe_column_name in zip(ordered_columns, safe_ordered_columns)
})
return output_buffer.getvalue()
@@ -1205,6 +1288,8 @@ def _upload_document_analysis_generated_artifact(
preview_rows=None,
preview_items=None,
preview_lines=None,
+ cancel_requested=None,
+ request_correlation_id=None,
):
try:
upload_result = upload_generated_analysis_artifact_for_current_user(
@@ -1215,6 +1300,20 @@ def _upload_document_analysis_generated_artifact(
output_format=output_format,
summary=summary,
)
+ try:
+ raise_if_mixed_source_cancelled(
+ cancel_requested,
+ 'artifact_publication',
+ request_correlation_id=request_correlation_id,
+ )
+ except MixedSourceCancellationError:
+ delete_generated_chat_artifact_for_current_user(
+ normalized_conversation_id,
+ (upload_result.get('message') or {}).get('id'),
+ )
+ raise
+ except MixedSourceCancellationError:
+ raise
except Exception as exc:
debug_print(
'[WorkflowDocumentAnalysis] Generated artifact upload skipped | '
@@ -1293,7 +1392,14 @@ def _maybe_create_document_analysis_generated_artifacts(
analysis_prompt,
conversation_id='',
primary_generated_outputs=None,
+ cancel_requested=None,
+ request_correlation_id=None,
):
+ raise_if_mixed_source_cancelled(
+ cancel_requested,
+ 'artifact_publication',
+ request_correlation_id=request_correlation_id,
+ )
normalized_conversation_id = str(conversation_id or '').strip()
if not normalized_conversation_id or not has_request_context():
return {'artifacts': [], 'assistant_reply': None}
@@ -1322,86 +1428,101 @@ def _maybe_create_document_analysis_generated_artifacts(
if create_lossless_artifacts:
artifacts = []
structured_rows = _build_document_analysis_structured_rows(analysis_result)
-
- if artifact_intent.get('csv_artifact_recommended') and structured_rows:
- csv_output = _build_document_analysis_rows_csv(structured_rows)
- csv_file_name = _build_document_analysis_artifact_file_name(analysis_result, 'csv')
- csv_summary = (
- f'Saved {len(structured_rows)} extracted analysis row(s) for {document_count} '
- 'source document(s) as a downloadable CSV artifact.'
- )
- csv_artifact = _upload_document_analysis_generated_artifact(
- normalized_conversation_id,
- csv_file_name,
- csv_output,
- 'csv',
- csv_summary,
- preview_rows=structured_rows[:DOCUMENT_ANALYSIS_ARTIFACT_PREVIEW_ROW_COUNT],
- )
- if csv_artifact:
- artifacts.append(csv_artifact)
-
- markdown_output = _build_document_analysis_markdown_artifact(analysis_result)
- should_create_markdown_artifact = bool(
- (
- artifact_intent.get('markdown_analysis_artifact_recommended')
- or (json_payload is not None and not json_artifact_requested)
- )
- and markdown_output
- and (
- not primary_tabular_outputs
- or _prompt_explicitly_requests_markdown_artifact(analysis_prompt)
- )
- )
- if should_create_markdown_artifact:
- markdown_file_name = _build_document_analysis_artifact_file_name(analysis_result, 'md')
- markdown_summary = (
- f'Saved the final analysis plus retained raw analysis notes for {document_count} '
- 'source document(s) as a downloadable Markdown artifact.'
- )
- markdown_artifact = _upload_document_analysis_generated_artifact(
- normalized_conversation_id,
- markdown_file_name,
- markdown_output,
- 'md',
- markdown_summary,
- preview_lines=_build_document_analysis_preview_lines(analysis_reply),
- )
- if markdown_artifact:
- artifacts.append(markdown_artifact)
-
- if xml_payload and xml_artifact_requested and not primary_tabular_outputs:
- xml_file_name = _build_document_analysis_artifact_file_name(analysis_result, 'xml')
- xml_summary = _build_document_analysis_artifact_summary(document_count, 'xml')
- xml_artifact = _upload_document_analysis_generated_artifact(
- normalized_conversation_id,
- xml_file_name,
- xml_payload,
- 'xml',
- xml_summary,
- preview_lines=_build_document_analysis_preview_lines(xml_payload),
- )
- if xml_artifact:
- artifacts.append(xml_artifact)
-
- if json_payload is not None and json_artifact_requested and not primary_tabular_outputs:
- json_file_name = _build_document_analysis_artifact_file_name(analysis_result, 'json')
- json_summary = _build_document_analysis_artifact_summary(document_count, 'json')
- json_preview_items = []
- if isinstance(json_payload, list):
- json_preview_items = json_payload[:DOCUMENT_ANALYSIS_ARTIFACT_PREVIEW_ITEM_COUNT]
- elif isinstance(json_payload, dict):
- json_preview_items = [json_payload]
- json_artifact = _upload_document_analysis_generated_artifact(
- normalized_conversation_id,
- json_file_name,
- serialize_generated_json(json_payload),
- 'json',
- json_summary,
- preview_items=json_preview_items,
+ try:
+ if artifact_intent.get('csv_artifact_recommended') and structured_rows:
+ csv_output = _build_document_analysis_rows_csv(structured_rows)
+ csv_file_name = _build_document_analysis_artifact_file_name(analysis_result, 'csv')
+ csv_summary = (
+ f'Saved {len(structured_rows)} extracted analysis row(s) for {document_count} '
+ 'source document(s) as a downloadable CSV artifact.'
+ )
+ csv_artifact = _upload_document_analysis_generated_artifact(
+ normalized_conversation_id,
+ csv_file_name,
+ csv_output,
+ 'csv',
+ csv_summary,
+ preview_rows=structured_rows[:DOCUMENT_ANALYSIS_ARTIFACT_PREVIEW_ROW_COUNT],
+ cancel_requested=cancel_requested,
+ request_correlation_id=request_correlation_id,
+ )
+ if csv_artifact:
+ artifacts.append(csv_artifact)
+
+ markdown_output = _build_document_analysis_markdown_artifact(analysis_result)
+ should_create_markdown_artifact = bool(
+ (
+ artifact_intent.get('markdown_analysis_artifact_recommended')
+ or (json_payload is not None and not json_artifact_requested)
+ )
+ and markdown_output
+ and (
+ not primary_tabular_outputs
+ or _prompt_explicitly_requests_markdown_artifact(analysis_prompt)
+ )
)
- if json_artifact:
- artifacts.append(json_artifact)
+ if should_create_markdown_artifact:
+ markdown_file_name = _build_document_analysis_artifact_file_name(analysis_result, 'md')
+ markdown_summary = (
+ f'Saved the final analysis plus retained raw analysis notes for {document_count} '
+ 'source document(s) as a downloadable Markdown artifact.'
+ )
+ markdown_artifact = _upload_document_analysis_generated_artifact(
+ normalized_conversation_id,
+ markdown_file_name,
+ markdown_output,
+ 'md',
+ markdown_summary,
+ preview_lines=_build_document_analysis_preview_lines(analysis_reply),
+ cancel_requested=cancel_requested,
+ request_correlation_id=request_correlation_id,
+ )
+ if markdown_artifact:
+ artifacts.append(markdown_artifact)
+
+ if xml_payload and xml_artifact_requested and not primary_tabular_outputs:
+ xml_file_name = _build_document_analysis_artifact_file_name(analysis_result, 'xml')
+ xml_summary = _build_document_analysis_artifact_summary(document_count, 'xml')
+ xml_artifact = _upload_document_analysis_generated_artifact(
+ normalized_conversation_id,
+ xml_file_name,
+ xml_payload,
+ 'xml',
+ xml_summary,
+ preview_lines=_build_document_analysis_preview_lines(xml_payload),
+ cancel_requested=cancel_requested,
+ request_correlation_id=request_correlation_id,
+ )
+ if xml_artifact:
+ artifacts.append(xml_artifact)
+
+ if json_payload is not None and json_artifact_requested and not primary_tabular_outputs:
+ json_file_name = _build_document_analysis_artifact_file_name(analysis_result, 'json')
+ json_summary = _build_document_analysis_artifact_summary(document_count, 'json')
+ json_preview_items = []
+ if isinstance(json_payload, list):
+ json_preview_items = json_payload[:DOCUMENT_ANALYSIS_ARTIFACT_PREVIEW_ITEM_COUNT]
+ elif isinstance(json_payload, dict):
+ json_preview_items = [json_payload]
+ json_artifact = _upload_document_analysis_generated_artifact(
+ normalized_conversation_id,
+ json_file_name,
+ serialize_generated_json(json_payload),
+ 'json',
+ json_summary,
+ preview_items=json_preview_items,
+ cancel_requested=cancel_requested,
+ request_correlation_id=request_correlation_id,
+ )
+ if json_artifact:
+ artifacts.append(json_artifact)
+ except MixedSourceCancellationError:
+ for artifact in reversed(artifacts):
+ delete_generated_chat_artifact_for_current_user(
+ normalized_conversation_id,
+ artifact.get('artifact_message_id'),
+ )
+ raise
if artifacts or primary_tabular_outputs:
assistant_reply = _build_document_analysis_multi_artifact_reply(
@@ -1478,6 +1599,13 @@ def _maybe_create_document_analysis_generated_artifacts(
summary,
preview_items=preview_items,
preview_lines=preview_lines,
+ cancel_requested=cancel_requested,
+ request_correlation_id=request_correlation_id,
+ )
+ raise_if_mixed_source_cancelled(
+ cancel_requested,
+ 'artifact_publication',
+ request_correlation_id=request_correlation_id,
)
if not artifact_payload:
return {'artifacts': [], 'assistant_reply': None}
@@ -1520,7 +1648,18 @@ def _build_comparison_artifact_reply(left_document_name, right_count, output_for
)
-def _maybe_create_comparison_generated_artifacts(comparison_result, comparison_prompt, conversation_id=''):
+def _maybe_create_comparison_generated_artifacts(
+ comparison_result,
+ comparison_prompt,
+ conversation_id='',
+ cancel_requested=None,
+ request_correlation_id=None,
+):
+ raise_if_mixed_source_cancelled(
+ cancel_requested,
+ 'artifact_publication',
+ request_correlation_id=request_correlation_id,
+ )
normalized_conversation_id = str(conversation_id or '').strip()
if not normalized_conversation_id or not has_request_context():
return {'artifacts': [], 'assistant_reply': None}
@@ -1566,6 +1705,11 @@ def _maybe_create_comparison_generated_artifacts(comparison_result, comparison_p
summary = _build_comparison_artifact_summary(left_document_name, len(right_documents), output_format)
try:
+ raise_if_mixed_source_cancelled(
+ cancel_requested,
+ 'artifact_publication',
+ request_correlation_id=request_correlation_id,
+ )
upload_result = upload_generated_analysis_artifact_for_current_user(
conversation_id=normalized_conversation_id,
file_name=file_name,
@@ -1574,6 +1718,20 @@ def _maybe_create_comparison_generated_artifacts(comparison_result, comparison_p
output_format=output_format,
summary=summary,
)
+ try:
+ raise_if_mixed_source_cancelled(
+ cancel_requested,
+ 'artifact_publication',
+ request_correlation_id=request_correlation_id,
+ )
+ except MixedSourceCancellationError:
+ delete_generated_chat_artifact_for_current_user(
+ normalized_conversation_id,
+ (upload_result.get('message') or {}).get('id'),
+ )
+ raise
+ except MixedSourceCancellationError:
+ raise
except Exception as exc:
debug_print(
'[WorkflowDocumentComparison] Generated artifact upload skipped | '
@@ -1678,6 +1836,191 @@ def _finalize_token_usage(aggregate):
return token_usage
+def _accumulate_token_usage_summary(aggregate, token_usage):
+ """Merge an already-normalized token summary into the existing aggregate."""
+ if not isinstance(aggregate, dict) or not isinstance(token_usage, dict):
+ return
+ for key in ('prompt_tokens', 'completion_tokens', 'total_tokens'):
+ value = _coerce_token_count(token_usage.get(key))
+ if value is not None:
+ aggregate[key] = int(aggregate.get(key, 0) or 0) + value
+ request_count = _coerce_token_count(token_usage.get('request_count'))
+ if request_count is None and any(
+ token_usage.get(key) not in (None, 0, '')
+ for key in ('prompt_tokens', 'completion_tokens', 'total_tokens')
+ ):
+ request_count = 1
+ if request_count:
+ aggregate['request_count'] = int(aggregate.get('request_count', 0) or 0) + request_count
+
+
+def _emit_mixed_source_token_telemetry(
+ settings,
+ mode,
+ token_usage,
+ request_correlation_id=None,
+):
+ """Link existing aggregate token accounting to mixed-source request telemetry."""
+ token_usage = token_usage if isinstance(token_usage, dict) else {}
+ return emit_mixed_source_telemetry(
+ settings,
+ 'native_execution',
+ mode,
+ request_correlation_id=request_correlation_id,
+ metrics={
+ 'prompt_tokens': token_usage.get('prompt_tokens', 0),
+ 'completion_tokens': token_usage.get('completion_tokens', 0),
+ 'total_tokens': token_usage.get('total_tokens', 0),
+ 'token_request_count': token_usage.get('request_count', 0),
+ 'request_count': token_usage.get('request_count', 0),
+ },
+ )
+
+
+def _rollback_mixed_source_generated_outputs(
+ user_id,
+ conversation_id,
+ generated_outputs,
+ reason='finalization',
+):
+ """Cancel queued exports and delete exact generated artifacts by stored identity."""
+ normalized_user_id = str(user_id or '').strip()
+ normalized_conversation_id = str(conversation_id or '').strip()
+ if not normalized_user_id or not normalized_conversation_id:
+ return {'canceled_export_count': 0, 'deleted_artifact_count': 0}
+
+ export_run_ids = []
+ artifact_message_ids = []
+ for output in list(generated_outputs or []):
+ if not isinstance(output, dict):
+ continue
+ export_run_id = str(output.get('export_run_id') or '').strip()
+ artifact_message_id = str(output.get('artifact_message_id') or '').strip()
+ if export_run_id and export_run_id not in export_run_ids:
+ export_run_ids.append(export_run_id)
+ if artifact_message_id and artifact_message_id not in artifact_message_ids:
+ artifact_message_ids.append(artifact_message_id)
+
+ canceled_export_count = 0
+ deleted_artifact_count = 0
+ rollback_failure_count = 0
+ for export_run_id in export_run_ids:
+ try:
+ cancel_result = cancel_tabular_generated_output_run(
+ normalized_user_id,
+ export_run_id,
+ )
+ if isinstance(cancel_result, dict) and cancel_result.get('canceled'):
+ canceled_export_count += 1
+ except Exception:
+ rollback_failure_count += 1
+ for artifact_message_id in artifact_message_ids:
+ try:
+ if delete_generated_chat_artifact_for_user(
+ normalized_user_id,
+ normalized_conversation_id,
+ artifact_message_id,
+ ):
+ deleted_artifact_count += 1
+ except Exception:
+ rollback_failure_count += 1
+
+ log_event(
+ '[MixedSourceLifecycle] Generated output rollback completed.',
+ extra={
+ 'canceled_export_count': canceled_export_count,
+ 'deleted_artifact_count': deleted_artifact_count,
+ 'rollback_failure_count': rollback_failure_count,
+ 'rollback_reason': str(reason or 'finalization')[:64],
+ },
+ level=logging.INFO if not rollback_failure_count else logging.WARNING,
+ )
+ return {
+ 'canceled_export_count': canceled_export_count,
+ 'deleted_artifact_count': deleted_artifact_count,
+ 'rollback_failure_count': rollback_failure_count,
+ }
+
+
+def _reauthorize_mixed_source_workflow_result(
+ workflow,
+ action_config,
+ result,
+ settings,
+ conversation_id,
+ cancel_requested=None,
+ request_correlation_id=None,
+ additional_generated_outputs=None,
+):
+ """Reauthorize a mixed result and roll back outputs before propagating failure."""
+ result = result if isinstance(result, dict) else {}
+ execution_manifest = result.get('mixed_source_manifest')
+ if not isinstance(execution_manifest, list):
+ return
+
+ user_id = str((workflow or {}).get('user_id') or '').strip()
+ requested_ids = [
+ str(source.get('document_id') or '').strip()
+ for source in execution_manifest
+ if isinstance(source, dict) and str(source.get('document_id') or '').strip()
+ ]
+ selection_mode = str(
+ (action_config or {}).get('target_mode') or SELECTION_MODE_SELECTED
+ ).strip().lower()
+ if selection_mode not in {'selected', 'all', 'history', 'relevance'}:
+ selection_mode = SELECTION_MODE_SELECTED
+
+ generated_outputs = list(result.get('generated_tabular_outputs') or [])
+ generated_outputs.extend(list(additional_generated_outputs or []))
+ try:
+ raise_if_mixed_source_cancelled(
+ cancel_requested,
+ 'finalization',
+ request_correlation_id=request_correlation_id,
+ )
+ fresh_manifest = resolve_authorized_source_manifest(
+ requested_ids,
+ user_id=user_id,
+ selection_mode=selection_mode,
+ conversation_id=conversation_id,
+ active_group_ids=(action_config or {}).get('active_group_ids'),
+ active_public_workspace_ids=(action_config or {}).get('active_public_workspace_id'),
+ doc_scope=(action_config or {}).get('doc_scope', 'all'),
+ cancel_requested=cancel_requested,
+ request_correlation_id=request_correlation_id,
+ )
+ finalization_result = compare_reauthorized_source_manifests(
+ execution_manifest,
+ fresh_manifest,
+ )
+ if finalization_result['authorization_failure_count']:
+ emit_mixed_source_telemetry(
+ settings,
+ 'authorization_failure',
+ (
+ 'compare'
+ if (action_config or {}).get('type') == DOCUMENT_ACTION_TYPE_COMPARISON
+ else 'analyze'
+ ),
+ request_correlation_id=request_correlation_id,
+ metrics={
+ 'authorization_failure_count': finalization_result['authorization_failure_count'],
+ },
+ dimensions={'outcome_status': 'failed'},
+ )
+ raise MixedSourceFinalizationError('authorization_lost')
+ if finalization_result['source_version_changed_count']:
+ raise MixedSourceFinalizationError('source_version_changed')
+ except (MixedSourceCancellationError, MixedSourceFinalizationError):
+ _rollback_mixed_source_generated_outputs(
+ user_id,
+ conversation_id,
+ generated_outputs,
+ reason='finalization',
+ )
+ raise
+
+
def _strip_agent_citation_artifact_refs(agent_citations):
compact_citations = []
for citation in agent_citations or []:
@@ -1793,7 +2136,7 @@ def _normalize_tabular_source_hint(scope):
return 'workspace'
-def _resolve_tabular_document_action_documents(action_config, user_id, conversation_id=''):
+def _get_document_action_source_ids(action_config):
action_config = action_config if isinstance(action_config, dict) else {}
action_type = str(action_config.get('type') or '').strip().lower()
@@ -1819,6 +2162,16 @@ def _resolve_tabular_document_action_documents(action_config, user_id, conversat
document_ids.append(document_id)
role_by_document_id[document_id] = 'right'
+ return document_ids, role_by_document_id
+
+
+def _resolve_tabular_document_action_documents(
+ action_config,
+ user_id,
+ conversation_id='',
+):
+ action_config = action_config if isinstance(action_config, dict) else {}
+ document_ids, role_by_document_id = _get_document_action_source_ids(action_config)
if not document_ids:
return []
@@ -2007,6 +2360,501 @@ def _build_tabular_comparison_action_prompt(comparison_prompt, left_document, ri
)
+def _build_mixed_source_analyze_reduction_prompt(analysis_prompt, handoff):
+ """Build the one bounded collective reduction prompt for combined Analyze."""
+ return (
+ 'Answer the exact Analyze request using only the bounded evidence handoff below. '
+ 'Treat computed tabular facts as tool-backed calculations and narrative facts as document excerpts. '
+ 'Identify cross-source relationships only where the evidence supports them. Keep facts source-separated when needed. '
+ 'Explicitly state missing, failed, unsupported, unresolved, or unprocessed evidence and never claim coverage for it.\n\n'
+ f'Analyze request:\n{str(analysis_prompt or "").strip()}\n\n'
+ f'Bounded evidence handoff:\n{json.dumps(handoff, ensure_ascii=False, separators=(",", ":"))}'
+ )
+
+
+def _build_mixed_source_analysis_coverage(handoff):
+ """Expose terminal coverage for every manifest entry with engine/status totals."""
+ coverage = dict((handoff or {}).get('mixed_source_coverage') or {})
+ engine_status_totals = {}
+ for envelope in list((handoff or {}).get('evidence_envelopes') or []):
+ engine = str(envelope.get('engine') or 'unknown')
+ status = str(envelope.get('status') or 'failed')
+ engine_status_totals.setdefault(engine, {})[status] = (
+ engine_status_totals.setdefault(engine, {}).get(status, 0) + 1
+ )
+ coverage['engine_status_totals'] = engine_status_totals
+ coverage['document_count'] = coverage.get('requested_source_count', 0)
+ coverage['progress_meta'] = {
+ 'phase': 'complete',
+ 'phase_label': 'Complete' if not coverage.get('partial_coverage') else 'Partial',
+ 'phase_detail': 'Mixed-source Analyze completed with terminal source coverage',
+ 'status': 'partial' if coverage.get('partial_coverage') else 'completed',
+ 'percent_override': 100,
+ }
+ return coverage
+
+
+def _execute_mixed_source_analyze_workflow(
+ workflow,
+ analysis_config,
+ settings,
+ invoke_prompt,
+ conversation_id='',
+ activity_callback=None,
+ thought_tracker=None,
+ live_thought_callback=None,
+ max_documents=None,
+ cancel_requested=None,
+ request_correlation_id=None,
+ token_usage_callback=None,
+):
+ """Run combined Analyze cohorts natively, then reduce bounded evidence once."""
+ started_at = time.perf_counter()
+ request_correlation_id = normalize_mixed_source_correlation_id(
+ request_correlation_id
+ )
+ user_id = str(workflow.get('user_id') or '').strip()
+ requested_ids, _ = _get_document_action_source_ids(analysis_config)
+ requested_selection_mode = str(
+ analysis_config.get('selection_mode')
+ or analysis_config.get('target_mode')
+ or SELECTION_MODE_SELECTED
+ ).strip().lower()
+ if requested_selection_mode == 'all':
+ if not bool(settings.get('enable_mixed_source_analyze_all', False)):
+ raise ValueError(
+ 'Analyze All Documents is temporarily unavailable while its rollout flag is disabled.'
+ )
+ if max_documents is None:
+ raise ValueError('Analyze All Documents requires a configured document limit.')
+ all_targets = _resolve_analyze_all_document_ids(
+ workflow,
+ analysis_config,
+ settings,
+ int(max_documents),
+ )
+ analysis_config = {**analysis_config, **all_targets}
+ requested_ids = list(all_targets['document_ids'])
+ if max_documents is not None and len(requested_ids) > int(max_documents):
+ raise ValueError(
+ f'Analyze supports up to {int(max_documents)} authorized documents at a time.'
+ )
+ if callable(activity_callback):
+ activity_callback({'type': 'mixed_source_progress', 'phase': 'resolving_sources', 'label': 'Resolving sources'})
+ manifest = resolve_authorized_source_manifest(
+ requested_ids,
+ user_id=user_id,
+ selection_mode=requested_selection_mode,
+ conversation_id=conversation_id,
+ active_group_ids=analysis_config.get('active_group_ids'),
+ active_public_workspace_ids=analysis_config.get('active_public_workspace_id'),
+ doc_scope=analysis_config.get('doc_scope', 'all'),
+ cancel_requested=cancel_requested,
+ request_correlation_id=request_correlation_id,
+ )
+ partitions = partition_source_manifest(manifest)
+ evidence_envelopes = []
+ generated_tabular_outputs = []
+ tabular_agent_citations = []
+
+ narrative_sources = partitions['narrative_sources']
+ if narrative_sources:
+ if callable(activity_callback):
+ activity_callback({'type': 'mixed_source_progress', 'phase': 'analyzing_narrative', 'label': 'Analyzing narrative documents'})
+ try:
+ narrative_result = run_document_analysis(
+ user_id=user_id,
+ analysis_prompt=workflow.get('task_prompt', ''),
+ document_ids=[source.get('document_id') for source in narrative_sources],
+ invoke_prompt=invoke_prompt,
+ doc_scope=analysis_config.get('doc_scope'),
+ active_group_ids=analysis_config.get('active_group_ids'),
+ active_public_workspace_id=analysis_config.get('active_public_workspace_id'),
+ conversation_id=conversation_id,
+ window_unit=analysis_config.get('window_unit'),
+ window_size=analysis_config.get('window_size'),
+ window_percent=analysis_config.get('window_percent'),
+ max_retries_per_window=analysis_config.get('max_retries_per_window'),
+ activity_callback=activity_callback,
+ max_documents=max_documents,
+ include_coverage_summary=False,
+ cancel_requested=cancel_requested,
+ request_correlation_id=request_correlation_id,
+ )
+ documents_by_id = {
+ str(document.get('document_id') or ''): document
+ for document in list((narrative_result.get('coverage') or {}).get('documents') or [])
+ }
+ narrative_items_by_id = {
+ str(item.get('document_id') or ''): item
+ for item in list(narrative_result.get('document_analysis_items') or [])
+ if str(item.get('document_id') or '').strip()
+ }
+ for source in narrative_sources:
+ document_id = source.get('document_id')
+ document_coverage = documents_by_id.get(str(document_id), {})
+ narrative_item = narrative_items_by_id.get(str(document_id), {})
+ failed_windows = int(document_coverage.get('failed_windows') or 0)
+ total_windows = int(document_coverage.get('total_windows') or 0)
+ processed_windows = int(document_coverage.get('processed_windows') or 0)
+ status = (
+ EVIDENCE_STATUS_COMPLETED
+ if total_windows and processed_windows == total_windows and not failed_windows
+ else ('partial' if processed_windows else EVIDENCE_STATUS_FAILED)
+ )
+ evidence_envelopes.append(build_evidence_envelope(
+ document_id=document_id,
+ source_kind='narrative',
+ engine=EVIDENCE_ENGINE_DOCUMENT_ANALYSIS,
+ status=status,
+ summary=str(narrative_item.get('text') or ''),
+ citations=[],
+ generated_artifacts=[],
+ coverage={'terminal': True, 'processed_windows': processed_windows, 'total_windows': total_windows, 'failed_windows': failed_windows},
+ error='Narrative analysis could not be completed.' if status == EVIDENCE_STATUS_FAILED else None,
+ ))
+ except MixedSourceCancellationError:
+ raise
+ except Exception:
+ for source in narrative_sources:
+ evidence_envelopes.append(build_evidence_envelope(
+ document_id=source.get('document_id'), source_kind='narrative',
+ engine=EVIDENCE_ENGINE_DOCUMENT_ANALYSIS, status=EVIDENCE_STATUS_FAILED,
+ summary='Narrative evidence could not be completed for this source.',
+ coverage={'terminal': True}, error='Narrative analysis could not be completed.',
+ ))
+
+ tabular_sources = partitions['tabular_sources']
+ if tabular_sources:
+ if callable(activity_callback):
+ activity_callback({'type': 'mixed_source_progress', 'phase': 'analyzing_tabular', 'label': 'Analyzing tabular documents'})
+ for source in tabular_sources:
+ tabular_config = dict(analysis_config)
+ tabular_config['document_ids'] = [source.get('document_id')]
+ tabular_payload = _maybe_execute_tabular_document_action(
+ DOCUMENT_ACTION_TYPE_ANALYZE, workflow, tabular_config, settings,
+ conversation_id=conversation_id, invoke_prompt=invoke_prompt,
+ thought_tracker=thought_tracker, live_thought_callback=live_thought_callback,
+ cancel_requested=cancel_requested,
+ request_correlation_id=request_correlation_id,
+ token_usage_callback=token_usage_callback,
+ )
+ if not tabular_payload:
+ evidence_envelopes.append(build_evidence_envelope(
+ document_id=source.get('document_id'), source_kind='tabular',
+ engine=EVIDENCE_ENGINE_TABULAR_TOOLS, status=EVIDENCE_STATUS_FAILED,
+ summary='Tabular evidence could not be completed for this source.',
+ coverage={'terminal': True}, error='Tabular analysis could not be completed.',
+ ))
+ continue
+ tabular_result = tabular_payload.get('result') or {}
+ evidence_envelopes.append(build_evidence_envelope(
+ document_id=source.get('document_id'), source_kind='tabular',
+ engine=EVIDENCE_ENGINE_TABULAR_TOOLS, status=EVIDENCE_STATUS_COMPLETED,
+ summary=str(tabular_result.get('analysis_reply') or tabular_result.get('reply') or ''),
+ citations=list(tabular_payload.get('agent_citations') or []),
+ generated_artifacts=list(tabular_payload.get('generated_tabular_outputs') or []),
+ coverage={'terminal': True, 'tool_call_count': 1},
+ ))
+ generated_tabular_outputs.extend(tabular_payload.get('generated_tabular_outputs') or [])
+ tabular_agent_citations.extend(tabular_payload.get('agent_citations') or [])
+
+ handoff = build_mixed_source_evidence_handoff(
+ manifest,
+ evidence_envelopes,
+ requested_selection_mode,
+ mode='analyze',
+ telemetry_settings=settings,
+ request_correlation_id=request_correlation_id,
+ )
+ mode_outcome = evaluate_mixed_source_mode_outcome(
+ 'analyze',
+ {
+ 'entries': (handoff.get('mixed_source_coverage') or {}).get('terminal_ledger') or [],
+ 'partial_coverage': bool((handoff.get('mixed_source_coverage') or {}).get('partial_coverage')),
+ },
+ )
+ if not mode_outcome['should_reduce']:
+ raise RuntimeError(
+ 'Mixed-source Analyze could not prepare evidence from any selected source.'
+ )
+ raise_if_mixed_source_cancelled(
+ cancel_requested,
+ 'reduction',
+ request_correlation_id=request_correlation_id,
+ )
+ if callable(activity_callback):
+ activity_callback({'type': 'mixed_source_progress', 'phase': 'combining_findings', 'label': 'Combining findings'})
+ collective_reply = str(invoke_prompt(
+ _build_mixed_source_analyze_reduction_prompt(workflow.get('task_prompt', ''), handoff),
+ stage='mixed_source_reduction', metadata={'requested_source_count': len(manifest)},
+ ) or '').strip()
+ raise_if_mixed_source_cancelled(
+ cancel_requested,
+ 'reduction',
+ request_correlation_id=request_correlation_id,
+ )
+ if not collective_reply:
+ collective_reply = 'The selected sources could not be combined into a final analysis.'
+ emit_mixed_source_telemetry(
+ settings,
+ 'reduction',
+ 'analyze',
+ request_correlation_id=request_correlation_id,
+ metrics={
+ 'engine_call_count': len(evidence_envelopes),
+ 'model_request_count': 1,
+ 'latency_ms': round((time.perf_counter() - started_at) * 1000, 3),
+ },
+ dimensions={
+ 'selection_mode': requested_selection_mode,
+ 'outcome_status': mode_outcome['status'],
+ },
+ )
+ coverage = _build_mixed_source_analysis_coverage(handoff)
+ if callable(activity_callback):
+ activity_callback({
+ 'type': 'mixed_source_progress',
+ 'phase': 'complete',
+ 'label': coverage['progress_meta']['phase_label'],
+ 'status': coverage['progress_meta']['status'],
+ })
+ return {
+ 'reply': collective_reply,
+ 'analysis_reply': collective_reply,
+ 'coverage': coverage,
+ 'documents': list(coverage.get('sources') or []),
+ 'document_ids': [source.get('document_id') for source in manifest],
+ 'mixed_source_manifest': manifest,
+ 'mixed_source_evidence': handoff.get('evidence_envelopes') or [],
+ 'generated_tabular_outputs': generated_tabular_outputs,
+ 'agent_citations': tabular_agent_citations,
+ }
+
+def _resolve_cross_format_comparison_manifest(
+ comparison_config,
+ user_id,
+ conversation_id,
+ cancel_requested=None,
+ request_correlation_id=None,
+):
+ """Resolve the Source and ordered Targets once for the mixed Compare decision."""
+ requested_ids, role_by_document_id = _get_document_action_source_ids(comparison_config)
+ manifest = resolve_authorized_source_manifest(
+ requested_ids,
+ user_id=user_id,
+ selection_mode=SELECTION_MODE_SELECTED,
+ conversation_id=conversation_id,
+ active_group_ids=comparison_config.get('active_group_ids'),
+ active_public_workspace_ids=comparison_config.get('active_public_workspace_id'),
+ doc_scope=comparison_config.get('doc_scope', 'all'),
+ cancel_requested=cancel_requested,
+ request_correlation_id=request_correlation_id,
+ )
+ for source in manifest:
+ source['comparison_role'] = role_by_document_id.get(source.get('document_id'), 'target')
+ return manifest
+
+
+def _raise_legacy_cross_format_compare_limitation(comparison_config, user_id, conversation_id=''):
+ """Fail closed when rollback would otherwise route a table through chunk analysis."""
+ manifest = _resolve_cross_format_comparison_manifest(comparison_config, user_id, conversation_id)
+ partitions = partition_source_manifest(manifest)
+ if partitions['narrative_sources'] and partitions['tabular_sources']:
+ raise ValueError(
+ 'Mixed narrative and tabular Compare is temporarily unavailable while cross-format Compare is disabled.'
+ )
+
+
+def _execute_cross_format_comparison_workflow(
+ workflow,
+ comparison_config,
+ settings,
+ invoke_prompt,
+ conversation_id='',
+ activity_callback=None,
+ thought_tracker=None,
+ live_thought_callback=None,
+ cancel_requested=None,
+ request_correlation_id=None,
+ token_usage_callback=None,
+):
+ """Prepare native envelopes for a mixed Source/Target Compare, then reuse pairwise reduction."""
+ started_at = time.perf_counter()
+ request_correlation_id = normalize_mixed_source_correlation_id(
+ request_correlation_id
+ )
+ user_id = str(workflow.get('user_id') or '').strip()
+ raise_if_mixed_source_cancelled(
+ cancel_requested,
+ 'manifest',
+ request_correlation_id=request_correlation_id,
+ )
+ manifest = _resolve_cross_format_comparison_manifest(
+ comparison_config,
+ user_id,
+ conversation_id,
+ cancel_requested=cancel_requested,
+ request_correlation_id=request_correlation_id,
+ )
+ partitions = partition_source_manifest(manifest)
+ if not (partitions['narrative_sources'] and partitions['tabular_sources']):
+ return None
+ target_sources = [source for source in manifest if source.get('comparison_role') == 'right']
+ if len(target_sources) > 1 and not is_cross_format_compare_one_to_many_enabled(settings):
+ raise ValueError('Cross-format Compare currently supports one Target while one-to-many rollout is disabled.')
+ if callable(activity_callback):
+ activity_callback({'type': 'mixed_source_progress', 'phase': 'resolving_sources', 'label': 'Resolving Source and Targets'})
+
+ evidence_by_id = {}
+ generated_tabular_outputs = []
+ tabular_agent_citations = []
+ for source in partitions['narrative_sources']:
+ if callable(activity_callback):
+ activity_callback({'type': 'mixed_source_progress', 'phase': 'analyzing_narrative', 'label': 'Analyzing narrative source'})
+ try:
+ narrative_result = run_document_analysis(
+ user_id=user_id,
+ analysis_prompt=workflow.get('task_prompt', ''),
+ document_ids=[source.get('document_id')],
+ invoke_prompt=invoke_prompt,
+ doc_scope=comparison_config.get('doc_scope'),
+ active_group_ids=comparison_config.get('active_group_ids'),
+ active_public_workspace_id=comparison_config.get('active_public_workspace_id'),
+ conversation_id=conversation_id,
+ window_unit=comparison_config.get('window_unit'),
+ window_size=comparison_config.get('window_size'),
+ window_percent=comparison_config.get('window_percent'),
+ max_retries_per_window=comparison_config.get('max_retries_per_window'),
+ activity_callback=activity_callback,
+ max_documents=1,
+ include_coverage_summary=False,
+ cancel_requested=cancel_requested,
+ request_correlation_id=request_correlation_id,
+ )
+ document_coverage = list((narrative_result.get('coverage') or {}).get('documents') or [{}])[0]
+ status = EVIDENCE_STATUS_COMPLETED if not document_coverage.get('failed_windows') else 'partial'
+ evidence_by_id[source.get('document_id')] = build_evidence_envelope(
+ document_id=source.get('document_id'), source_kind='narrative',
+ engine=EVIDENCE_ENGINE_DOCUMENT_ANALYSIS, status=status,
+ summary=str(narrative_result.get('analysis_reply') or narrative_result.get('reply') or ''),
+ coverage={'terminal': True, 'source_version': source.get('source_version'), **document_coverage},
+ )
+ except MixedSourceCancellationError:
+ raise
+ except Exception:
+ evidence_by_id[source.get('document_id')] = build_evidence_envelope(
+ document_id=source.get('document_id'), source_kind='narrative',
+ engine=EVIDENCE_ENGINE_DOCUMENT_ANALYSIS, status=EVIDENCE_STATUS_FAILED,
+ summary='Narrative evidence could not be completed for this source.',
+ coverage={'terminal': True, 'source_version': source.get('source_version')},
+ error='Narrative analysis could not be completed.',
+ )
+
+ for source in partitions['tabular_sources']:
+ if callable(activity_callback):
+ activity_callback({'type': 'mixed_source_progress', 'phase': 'analyzing_tabular', 'label': 'Analyzing tabular source'})
+ tabular_config = dict(comparison_config)
+ tabular_payload = _maybe_execute_tabular_document_action(
+ DOCUMENT_ACTION_TYPE_ANALYZE, workflow, {
+ **tabular_config,
+ 'type': DOCUMENT_ACTION_TYPE_ANALYZE,
+ 'document_ids': [source.get('document_id')],
+ },
+ settings, conversation_id=conversation_id, invoke_prompt=invoke_prompt,
+ thought_tracker=thought_tracker, live_thought_callback=live_thought_callback,
+ cancel_requested=cancel_requested,
+ request_correlation_id=request_correlation_id,
+ token_usage_callback=token_usage_callback,
+ )
+ tabular_result = (tabular_payload or {}).get('result') or {}
+ if tabular_result.get('analysis_reply'):
+ evidence_by_id[source.get('document_id')] = build_evidence_envelope(
+ document_id=source.get('document_id'), source_kind='tabular',
+ engine=EVIDENCE_ENGINE_TABULAR_TOOLS, status=EVIDENCE_STATUS_COMPLETED,
+ summary=str(tabular_result.get('analysis_reply') or ''),
+ citations=list((tabular_payload or {}).get('agent_citations') or []),
+ generated_artifacts=list((tabular_payload or {}).get('generated_tabular_outputs') or []),
+ coverage={'terminal': True, 'source_version': source.get('source_version'), 'tool_call_count': 1},
+ )
+ generated_tabular_outputs.extend((tabular_payload or {}).get('generated_tabular_outputs') or [])
+ tabular_agent_citations.extend((tabular_payload or {}).get('agent_citations') or [])
+ else:
+ evidence_by_id[source.get('document_id')] = build_evidence_envelope(
+ document_id=source.get('document_id'), source_kind='tabular',
+ engine=EVIDENCE_ENGINE_TABULAR_TOOLS, status=EVIDENCE_STATUS_FAILED,
+ summary='Tabular evidence could not be completed for this source.',
+ coverage={'terminal': True, 'source_version': source.get('source_version')},
+ error='Tabular analysis could not be completed.',
+ )
+
+ handoff = build_mixed_source_evidence_handoff(
+ manifest,
+ list(evidence_by_id.values()),
+ SELECTION_MODE_SELECTED,
+ mode='compare',
+ telemetry_settings=settings,
+ request_correlation_id=request_correlation_id,
+ )
+ mode_outcome = evaluate_mixed_source_mode_outcome(
+ 'compare',
+ {
+ 'entries': (handoff.get('mixed_source_coverage') or {}).get('terminal_ledger') or [],
+ 'partial_coverage': bool((handoff.get('mixed_source_coverage') or {}).get('partial_coverage')),
+ },
+ )
+ if not mode_outcome['should_reduce']:
+ if mode_outcome.get('reason') == 'source_preparation_failed':
+ raise RuntimeError('Cross-format Compare Source could not be prepared.')
+ raise RuntimeError('Cross-format Compare could not prepare any Target evidence.')
+ envelopes = {envelope.get('document_id'): envelope for envelope in handoff.get('evidence_envelopes') or []}
+ def source_payload(source):
+ envelope = dict(envelopes.get(source.get('document_id')) or {})
+ envelope['document_name'] = source.get('display_name') or ('Source' if source.get('comparison_role') == 'left' else 'Target')
+ role_label = 'Source' if source.get('comparison_role') == 'left' else 'Target'
+ evidence_type = 'computed tabular facts' if envelope.get('source_kind') == 'tabular' else 'narrative document analysis'
+ envelope['summary'] = (
+ f'Role: {role_label}. Evidence type: {evidence_type}.\n'
+ f"{str(envelope.get('summary') or '').strip()}"
+ )
+ return envelope
+ left_source = next((source for source in manifest if source.get('comparison_role') == 'left'), {})
+ comparison_result = run_evidence_document_comparison(
+ workflow.get('task_prompt', ''), source_payload(left_source),
+ [source_payload(source) for source in target_sources], invoke_prompt, activity_callback=activity_callback,
+ cancel_requested=cancel_requested,
+ request_correlation_id=request_correlation_id,
+ )
+ pairwise_coverage = dict(comparison_result.get('coverage') or {})
+ mixed_coverage = _build_mixed_source_analysis_coverage(handoff)
+ mixed_coverage['failed_targets'] = list(pairwise_coverage.get('failed_targets') or [])
+ mixed_coverage['partial_coverage'] = bool(
+ mixed_coverage.get('partial_coverage')
+ or pairwise_coverage.get('partial_coverage')
+ )
+ comparison_result['coverage'] = mixed_coverage
+ comparison_result['mixed_source_manifest'] = manifest
+ comparison_result['mixed_source_evidence'] = handoff.get('evidence_envelopes') or []
+ comparison_result['generated_tabular_outputs'] = generated_tabular_outputs
+ comparison_result['agent_citations'] = tabular_agent_citations
+ emit_mixed_source_telemetry(
+ settings,
+ 'reduction',
+ 'compare',
+ request_correlation_id=request_correlation_id,
+ metrics={
+ 'engine_call_count': len(evidence_by_id),
+ 'model_request_count': len(comparison_result.get('comparison_items') or []),
+ 'latency_ms': round((time.perf_counter() - started_at) * 1000, 3),
+ },
+ dimensions={
+ 'selection_mode': SELECTION_MODE_SELECTED,
+ 'outcome_status': mode_outcome['status'],
+ },
+ )
+ return comparison_result
+
+
def _build_workflow_generation_prompt(task_prompt):
return append_proactive_chart_guidance(task_prompt)
@@ -2044,6 +2892,9 @@ def _build_workflow_chat_messages(
source_review_content = _get_workflow_url_access_system_content(url_access_context)
if source_review_content:
messages.append({'role': 'system', 'content': source_review_content})
+ generated_file_output_guidance = build_generated_file_output_guidance(prompt_text)
+ if generated_file_output_guidance:
+ messages.append({'role': 'system', 'content': generated_file_output_guidance})
normalized_context_system = str(conversation_context_system or '').strip()
if normalized_context_system:
messages.append({'role': 'system', 'content': normalized_context_system})
@@ -2063,8 +2914,13 @@ def _build_workflow_agent_messages(
):
user_content = _build_workflow_generation_prompt(prompt_text) if apply_generation_guidance else str(prompt_text or '').strip()
source_review_content = _get_workflow_url_access_system_content(url_access_context)
- if source_review_content:
- user_content = f'{source_review_content}\n\n[Workflow Task]\n{user_content}'
+ generated_file_output_guidance = build_generated_file_output_guidance(prompt_text)
+ content_sections = [
+ content
+ for content in (generated_file_output_guidance, source_review_content)
+ if content
+ ]
+ content_sections.append(f'[Workflow Task]\n{user_content}')
messages = []
normalized_context_system = str(conversation_context_system or '').strip()
if normalized_context_system:
@@ -2072,7 +2928,7 @@ def _build_workflow_agent_messages(
normalized_context_data = str(conversation_context_data or '').strip()
if normalized_context_data:
messages.append(ChatMessageContent(role='user', content=normalized_context_data))
- messages.append(ChatMessageContent(role='user', content=user_content))
+ messages.append(ChatMessageContent(role='user', content='\n\n'.join(content_sections)))
return messages
@@ -2316,16 +3172,51 @@ def _maybe_execute_tabular_document_action(
invoke_prompt=None,
thought_tracker=None,
live_thought_callback=None,
+ cancel_requested=None,
+ request_correlation_id=None,
+ token_usage_callback=None,
):
+ raise_if_mixed_source_cancelled(
+ cancel_requested,
+ 'tabular',
+ request_correlation_id=request_correlation_id,
+ )
if action_type not in {DOCUMENT_ACTION_TYPE_ANALYZE, DOCUMENT_ACTION_TYPE_COMPARISON}:
return None
- if not callable(invoke_prompt) or not is_tabular_processing_enabled(settings):
- return None
user_id = str(workflow.get('user_id') or '').strip()
if not user_id:
return None
+ if is_mixed_source_manifest_enabled(settings):
+ requested_source_ids, _ = _get_document_action_source_ids(action_config)
+ if requested_source_ids:
+ try:
+ resolve_authorized_source_manifest(
+ requested_source_ids,
+ user_id=user_id,
+ selection_mode='selected',
+ conversation_id=conversation_id,
+ active_group_ids=action_config.get('active_group_ids'),
+ active_public_workspace_ids=action_config.get('active_public_workspace_id'),
+ cancel_requested=cancel_requested,
+ request_correlation_id=request_correlation_id,
+ )
+ except MixedSourceCancellationError:
+ raise
+ except Exception:
+ log_event(
+ '[MixedSourceManifest] Workflow shadow resolution failed.',
+ extra={
+ 'requested_source_count': len(requested_source_ids),
+ 'selection_mode': 'selected',
+ },
+ level=logging.WARNING,
+ )
+
+ if not callable(invoke_prompt) or not is_tabular_processing_enabled(settings):
+ return None
+
tabular_documents = _resolve_tabular_document_action_documents(
action_config,
user_id,
@@ -2362,6 +3253,11 @@ def _maybe_execute_tabular_document_action(
try:
for tabular_document in tabular_documents:
+ raise_if_mixed_source_cancelled(
+ cancel_requested,
+ 'tabular',
+ request_correlation_id=request_correlation_id,
+ )
document_baseline_invocation_count = 0
if conversation_id:
document_baseline_invocation_count = len(
@@ -2392,8 +3288,14 @@ def _maybe_execute_tabular_document_action(
}],
thought_tracker=thought_tracker,
live_thought_callback=live_thought_callback,
+ token_usage_callback=token_usage_callback,
)
)
+ raise_if_mixed_source_cancelled(
+ cancel_requested,
+ 'tabular',
+ request_correlation_id=request_correlation_id,
+ )
if not str(tabular_analysis or '').strip():
raise ValueError(
f"Tabular analysis returned no computed results for {tabular_document.get('document_name') or tabular_document.get('file_name') or tabular_document.get('document_id')}."
@@ -2431,10 +3333,20 @@ def _maybe_execute_tabular_document_action(
conversation_id=conversation_id,
thought_callback=tabular_post_processing_thought_callback,
user_id=user_id,
+ cancel_requested=cancel_requested,
+ request_correlation_id=request_correlation_id,
+ token_usage_callback=token_usage_callback,
)
)
+ raise_if_mixed_source_cancelled(
+ cancel_requested,
+ 'export',
+ request_correlation_id=request_correlation_id,
+ )
if generated_tabular_output:
generated_tabular_outputs.append(generated_tabular_output)
+ except MixedSourceCancellationError:
+ raise
except Exception as exc:
log_event(
f'[WorkflowDocumentAction] Tabular document-action helper skipped: {exc}',
@@ -2458,6 +3370,11 @@ def _maybe_execute_tabular_document_action(
tabular_agent_citations = _build_agent_citations_from_plugin_invocations(tabular_invocations)
if action_type == DOCUMENT_ACTION_TYPE_ANALYZE:
+ raise_if_mixed_source_cancelled(
+ cancel_requested,
+ 'reduction',
+ request_correlation_id=request_correlation_id,
+ )
analysis_result = {
'reply': '',
'analysis_reply': str(invoke_prompt(
@@ -2476,6 +3393,11 @@ def _maybe_execute_tabular_document_action(
'window_size': None,
'window_percent': None,
}
+ raise_if_mixed_source_cancelled(
+ cancel_requested,
+ 'reduction',
+ request_correlation_id=request_correlation_id,
+ )
if not analysis_result['analysis_reply']:
raise RuntimeError('Tabular analysis synthesis returned an empty response.')
analysis_result['reply'] = analysis_result['analysis_reply']
@@ -2488,6 +3410,11 @@ def _maybe_execute_tabular_document_action(
left_document = tabular_documents[0] if tabular_documents else {}
right_documents = tabular_documents[1:]
+ raise_if_mixed_source_cancelled(
+ cancel_requested,
+ 'comparison_reduction',
+ request_correlation_id=request_correlation_id,
+ )
comparison_result = {
'reply': '',
'analysis_reply': str(invoke_prompt(
@@ -2518,6 +3445,11 @@ def _maybe_execute_tabular_document_action(
],
'comparison_items': [],
}
+ raise_if_mixed_source_cancelled(
+ cancel_requested,
+ 'comparison_reduction',
+ request_correlation_id=request_correlation_id,
+ )
if not comparison_result['analysis_reply']:
raise RuntimeError('Tabular comparison synthesis returned an empty response.')
comparison_result['reply'] = comparison_result['analysis_reply']
@@ -3751,18 +4683,163 @@ def _add_workflow_activity_thought(
)
-def _create_assistant_message(conversation, workflow, result, trigger_source, run_id, user_message_doc, assistant_message_id=None):
- assistant_message_id = assistant_message_id or str(uuid.uuid4())
- timestamp = _utc_now_iso()
- user_thread_info = (user_message_doc.get('metadata') or {}).get('thread_info') or {}
- document_action = _get_document_action_config(workflow)
- workspace_type = _get_workflow_scope(workflow)
- group_id = _get_workflow_group_id(workflow)
- raw_agent_citations = list(result.get('agent_citations') or [])
- web_search_citations = list(result.get('web_search_citations') or [])
- source_review_metadata = result.get('source_review') if isinstance(result.get('source_review'), dict) else {}
- url_access_metadata = result.get('url_access') if isinstance(result.get('url_access'), dict) else {}
- prepared_agent_citations = _persist_agent_citation_artifacts(
+def _maybe_create_workflow_generated_file_output(
+ workflow,
+ conversation_id,
+ user_question,
+ assistant_content,
+ function_results=None,
+ existing_outputs=None,
+):
+ """Persist a requested workflow CSV, DOCX, or PDF artifact."""
+ output_format = get_requested_generated_file_format(user_question)
+ if not output_format:
+ return None
+ if output_format == 'csv' and has_generated_tabular_csv_output(existing_outputs):
+ return None
+ if has_generated_file_output(existing_outputs, output_format):
+ return None
+
+ export_payload = build_generated_file_export(
+ user_question,
+ assistant_content,
+ function_results=function_results,
+ )
+ if not export_payload:
+ return None
+
+ normalized_workflow = workflow if isinstance(workflow, dict) else {}
+ user_id = str(normalized_workflow.get('user_id') or '').strip()
+ normalized_conversation_id = str(conversation_id or '').strip()
+ if not user_id or not normalized_conversation_id:
+ return None
+
+ generated_file_name = str(export_payload.get('file_name') or '').strip()
+ row_count = int(export_payload.get('row_count') or 0)
+ settings = get_settings()
+ structured_rows = export_payload.get('_structured_rows') or []
+ row_batches = []
+ if output_format == 'csv':
+ row_batches = build_tabular_generated_output_row_batches(structured_rows, settings=settings)
+ if not generated_file_name:
+ return None
+
+ if output_format == 'csv' and should_queue_tabular_generated_output_background(
+ row_count,
+ len(row_batches),
+ settings,
+ ):
+ try:
+ background_run = queue_tabular_generated_output_run(
+ user_id=user_id,
+ conversation_id=normalized_conversation_id,
+ user_question=user_question,
+ source_candidate={
+ 'filename': generated_file_name,
+ 'selected_sheet': '',
+ 'source_authorization': {
+ 'source': 'chat',
+ },
+ },
+ output_format=output_format,
+ row_batches=row_batches,
+ gpt_model='',
+ settings=settings,
+ passthrough_input_rows=True,
+ )
+ return build_background_tabular_generated_output_metadata(background_run)
+ except Exception as exc:
+ log_event(
+ '[Workflow Generated File Export] Failed to queue large CSV export',
+ {
+ 'workflow_id': normalized_workflow.get('id'),
+ 'conversation_id': normalized_conversation_id,
+ 'row_count': row_count,
+ 'output_format': output_format,
+ 'error': str(exc),
+ },
+ level=logging.ERROR,
+ exceptionTraceback=True,
+ )
+ return None
+
+ try:
+ upload_result = upload_generated_analysis_artifact_for_user(
+ current_user_id=user_id,
+ conversation_id=normalized_conversation_id,
+ file_name=generated_file_name,
+ file_content=export_payload.get('file_content'),
+ capability=export_payload.get('capability') or 'file_export',
+ output_format=output_format,
+ summary=export_payload.get('summary'),
+ )
+ except Exception as exc:
+ log_event(
+ '[Workflow Generated File Export] Failed to save generated file artifact',
+ {
+ 'workflow_id': normalized_workflow.get('id'),
+ 'conversation_id': normalized_conversation_id,
+ 'row_count': row_count,
+ 'output_format': output_format,
+ 'error': str(exc),
+ },
+ debug_only=True,
+ )
+ return None
+
+ artifact_metadata = build_generated_file_artifact_metadata(
+ export_payload,
+ upload_result,
+ normalized_conversation_id,
+ )
+ if not artifact_metadata:
+ return None
+
+ log_event(
+ '[Workflow Generated File Export] Saved generated file artifact',
+ {
+ 'workflow_id': normalized_workflow.get('id'),
+ 'conversation_id': normalized_conversation_id,
+ 'artifact_message_id': artifact_metadata.get('artifact_message_id'),
+ 'row_count': row_count,
+ 'output_format': output_format,
+ },
+ debug_only=True,
+ )
+ return artifact_metadata
+
+
+def _maybe_create_workflow_assistant_table_generated_output(*args, **kwargs):
+ """Backward-compatible wrapper for the generic workflow file-output finalizer."""
+ return _maybe_create_workflow_generated_file_output(*args, **kwargs)
+
+
+def _create_assistant_message(conversation, workflow, result, trigger_source, run_id, user_message_doc, assistant_message_id=None):
+ assistant_message_id = assistant_message_id or str(uuid.uuid4())
+ timestamp = _utc_now_iso()
+ user_thread_info = (user_message_doc.get('metadata') or {}).get('thread_info') or {}
+ document_action = _get_document_action_config(workflow)
+ workspace_type = _get_workflow_scope(workflow)
+ group_id = _get_workflow_group_id(workflow)
+ generated_analysis_artifacts = list(result.get('generated_analysis_artifacts') or [])
+ generated_tabular_outputs = list(result.get('generated_tabular_outputs') or [])
+ raw_agent_citations = list(result.get('agent_citations') or [])
+ generated_file_output = _maybe_create_workflow_generated_file_output(
+ workflow=workflow,
+ conversation_id=conversation.get('id'),
+ user_question=workflow.get('task_prompt', ''),
+ assistant_content=get_generated_file_export_content(result),
+ function_results=raw_agent_citations,
+ existing_outputs=generated_analysis_artifacts + generated_tabular_outputs,
+ )
+ if generated_file_output:
+ generated_analysis_artifacts.append(generated_file_output)
+ if generated_file_output.get('output_format') == 'csv':
+ generated_tabular_outputs.append(generated_file_output)
+ web_search_citations = list(result.get('web_search_citations') or [])
+ source_review_metadata = result.get('source_review') if isinstance(result.get('source_review'), dict) else {}
+ url_access_metadata = result.get('url_access') if isinstance(result.get('url_access'), dict) else {}
+ prepared_agent_citations = _persist_agent_citation_artifacts(
conversation_id=conversation.get('id'),
assistant_message_id=assistant_message_id,
agent_citations=raw_agent_citations,
@@ -3791,6 +4868,8 @@ def _create_assistant_message(conversation, workflow, result, trigger_source, ru
'workspace_type': workspace_type,
'group_id': group_id or None,
'token_usage': result.get('token_usage'),
+ 'generated_analysis_artifacts': generated_analysis_artifacts,
+ 'generated_tabular_outputs': generated_tabular_outputs,
'source_review': source_review_metadata,
'workflow': {
'workflow_id': workflow.get('id'),
@@ -3803,6 +4882,7 @@ def _create_assistant_message(conversation, workflow, result, trigger_source, ru
'model_binding_summary': workflow.get('model_binding_summary') or {},
'document_action': document_action,
'document_search': result.get('document_search') or {},
+ 'mixed_source_coverage': result.get('mixed_source_coverage') or {},
'analyze': workflow.get('analyze') or {},
'analysis_coverage': result.get('analysis_coverage') or {},
},
@@ -4162,6 +5242,80 @@ def _get_recent_workflow_document_limit(action_type, settings):
return _get_workflow_search_max_documents(settings)
+def _resolve_analyze_all_document_ids(workflow, action_config, settings, max_documents):
+ """Enumerate a bounded catalog before the manifest performs object authorization."""
+ user_id = str(workflow.get('user_id') or '').strip()
+ workflow_group_id = _get_workflow_group_id(workflow)
+ doc_scope = normalize_search_scope(action_config.get('doc_scope'))
+ active_group_ids = normalize_search_id_list(action_config.get('active_group_ids'))
+ if workflow_group_id:
+ assert_group_role(
+ user_id,
+ workflow_group_id,
+ allowed_roles=("Owner", "Admin", "DocumentManager", "User"),
+ )
+ active_group_ids = [workflow_group_id]
+ doc_scope = 'group'
+ else:
+ active_group_ids = _resolve_recent_authorized_group_ids(user_id, active_group_ids)
+
+ active_public_workspace_ids = normalize_search_id_list(
+ action_config.get('active_public_workspace_id')
+ )
+ if not workflow_group_id:
+ active_public_workspace_ids = _resolve_recent_authorized_public_workspace_ids(
+ user_id,
+ active_public_workspace_ids,
+ )
+
+ catalog_requests = []
+ if doc_scope in {'personal', 'all'} and not workflow_group_id:
+ catalog_requests.append((DOCUMENT_ACCESS_SCOPE_PERSONAL, {'user_id': user_id}))
+ if doc_scope in {'group', 'all'} and active_group_ids:
+ catalog_requests.append((DOCUMENT_ACCESS_SCOPE_GROUP, {'group_ids': active_group_ids}))
+ if doc_scope in {'public', 'all'} and active_public_workspace_ids and not workflow_group_id:
+ catalog_requests.append((
+ DOCUMENT_ACCESS_SCOPE_PUBLIC,
+ {'public_workspace_ids': active_public_workspace_ids},
+ ))
+ if not catalog_requests:
+ raise ValueError('Analyze All Documents has no authorized workspace scopes to enumerate.')
+
+ document_ids = []
+ for source_scope, scope_arguments in catalog_requests:
+ catalog_result = enumerate_bounded_document_access_index_ids(
+ source_scope,
+ max_documents,
+ settings=settings,
+ **scope_arguments,
+ )
+ if not catalog_result.get('success'):
+ if catalog_result.get('status') == 'document_limit_exceeded':
+ raise ValueError(
+ f'Analyze All Documents exceeds the configured {max_documents}-document limit.'
+ )
+ raise RuntimeError(
+ 'Analyze All Documents is temporarily unavailable until the authorized document catalog is ready.'
+ )
+ for document_id in catalog_result.get('document_ids') or []:
+ if document_id not in document_ids:
+ document_ids.append(document_id)
+ if len(document_ids) > max_documents:
+ raise ValueError(
+ f'Analyze All Documents exceeds the configured {max_documents}-document limit.'
+ )
+
+ if not document_ids:
+ raise ValueError('Analyze All Documents found no authorized documents in the selected scopes.')
+ return {
+ 'document_ids': document_ids,
+ 'doc_scope': doc_scope,
+ 'active_group_ids': active_group_ids,
+ 'active_public_workspace_id': active_public_workspace_ids,
+ 'target_mode': DOCUMENT_ACTION_TARGET_MODE_ALL,
+ }
+
+
def _collect_recent_workflow_documents(workflow, action_config, settings, max_documents):
user_id = str(workflow.get('user_id') or '').strip()
workflow_group_id = _get_workflow_group_id(workflow)
@@ -4299,20 +5453,90 @@ def _format_workflow_search_results(results):
def _build_workflow_search_prompt(task_prompt, search_context):
task_prompt = str(task_prompt or '').strip()
retrieved_content = str((search_context or {}).get('retrieved_content') or '').strip()
- if not retrieved_content:
+ evidence_messages = [
+ str((message or {}).get('content') or '').strip()
+ for message in list((search_context or {}).get('evidence_messages') or [])
+ if str((message or {}).get('content') or '').strip()
+ ]
+ if not retrieved_content and not evidence_messages:
return task_prompt
- return (
+ prompt_sections = [
'[Workflow document search context]\n'
- 'Use the retrieved document excerpts below as grounding for the workflow task. '
- 'When the excerpts are insufficient, say what is missing instead of guessing.\n\n'
- f'{retrieved_content}\n\n'
- '[Workflow task]\n'
- f'{task_prompt}'
- ).strip()
+ 'Use the bounded native-engine evidence below as grounding for the workflow task. '
+ 'When the evidence is insufficient or source coverage is partial, say what is missing instead of guessing.'
+ ]
+ if retrieved_content:
+ prompt_sections.append(
+ f'[Narrative excerpts]\n{retrieved_content}'
+ )
+ if evidence_messages:
+ prompt_sections.append(
+ '[Computed and coverage evidence]\n'
+ + '\n\n'.join(evidence_messages)
+ )
+ prompt_sections.append(f'[Workflow task]\n{task_prompt}')
+ return '\n\n'.join(prompt_sections).strip()
-def _prepare_workflow_search_context(workflow, action_config, settings, thought_tracker=None, run_id=None):
+@contextmanager
+def _workflow_mixed_source_execution_context(user_id, conversation_id, manifest):
+ """Bind tabular tools to scope IDs from a freshly authorized manifest."""
+ group_ids = []
+ public_workspace_ids = []
+ for source in list(manifest or []):
+ if source.get('authorization_status') != 'authorized':
+ continue
+ group_id = str(source.get('group_id') or '').strip()
+ public_workspace_id = str(source.get('public_workspace_id') or '').strip()
+ if group_id and group_id not in group_ids:
+ group_ids.append(group_id)
+ if public_workspace_id and public_workspace_id not in public_workspace_ids:
+ public_workspace_ids.append(public_workspace_id)
+
+ with _ensure_execution_context(user_id):
+ previous_conversation_id = getattr(g, 'conversation_id', None) if hasattr(g, 'conversation_id') else None
+ previous_authorized_chat_context = getattr(g, 'authorized_chat_context', None) if hasattr(g, 'authorized_chat_context') else None
+ g.conversation_id = conversation_id
+ g.authorized_chat_context = {
+ 'user_id': user_id,
+ 'conversation_id': conversation_id,
+ 'active_group_ids': group_ids,
+ 'active_group_id': group_ids[0] if len(group_ids) == 1 else None,
+ 'active_public_workspace_ids': public_workspace_ids,
+ 'active_public_workspace_id': (
+ public_workspace_ids[0]
+ if len(public_workspace_ids) == 1
+ else None
+ ),
+ 'fact_memory_scope_id': group_ids[0] if len(group_ids) == 1 else user_id,
+ 'fact_memory_scope_type': 'group' if len(group_ids) == 1 else 'user',
+ }
+ try:
+ yield
+ finally:
+ if previous_conversation_id is None and hasattr(g, 'conversation_id'):
+ delattr(g, 'conversation_id')
+ else:
+ g.conversation_id = previous_conversation_id
+ if previous_authorized_chat_context is None and hasattr(g, 'authorized_chat_context'):
+ delattr(g, 'authorized_chat_context')
+ else:
+ g.authorized_chat_context = previous_authorized_chat_context
+
+
+def _prepare_workflow_search_context(
+ workflow,
+ action_config,
+ settings,
+ conversation_id='',
+ thought_tracker=None,
+ run_id=None,
+ request_correlation_id=None,
+):
+ request_correlation_id = normalize_mixed_source_correlation_id(
+ request_correlation_id
+ )
if not _is_document_search_workflow(action_config):
return {'workflow': workflow, 'citations': [], 'result_count': 0, 'document_count': 0, 'query': None}
@@ -4325,20 +5549,186 @@ def _prepare_workflow_search_context(workflow, action_config, settings, thought_
if not query:
return {'workflow': workflow, 'citations': [], 'result_count': 0, 'document_count': 0, 'query': None}
- search_top_n = normalize_search_top_n(max(12, len(document_ids) * 3 if document_ids else 12))
- search_result = search_documents(
- query=query,
- user_id=str(workflow.get('user_id') or '').strip(),
- top_n=search_top_n,
- doc_scope=resolved_action.get('doc_scope') or 'all',
- document_ids=document_ids,
- active_group_ids=resolved_action.get('active_group_ids'),
- active_public_workspace_id=resolved_action.get('active_public_workspace_id'),
+ user_id = str(workflow.get('user_id') or '').strip()
+ manifest_doc_scope = resolved_action.get('doc_scope') or 'all'
+ manifest_group_ids = list(resolved_action.get('active_group_ids') or [])
+ manifest_public_workspace_ids = list(
+ resolved_action.get('active_public_workspace_id') or []
)
+ workflow_group_id = _get_workflow_group_id(workflow)
+ if workflow_group_id:
+ assert_group_role(
+ user_id,
+ workflow_group_id,
+ allowed_roles=("Owner", "Admin", "DocumentManager", "User"),
+ )
+ manifest_doc_scope = 'group'
+ manifest_group_ids = [workflow_group_id]
+ manifest_public_workspace_ids = []
+
+ scoped_action = dict(resolved_action)
+ scoped_action['doc_scope'] = manifest_doc_scope
+ scoped_action['active_group_ids'] = manifest_group_ids
+ scoped_action['active_public_workspace_id'] = manifest_public_workspace_ids
+
+ if not is_mixed_source_chat_search_enabled(settings):
+ search_top_n = normalize_search_top_n(max(50, len(document_ids) * 3 if document_ids else 50))
+ search_result = search_documents(
+ query=query,
+ user_id=user_id,
+ top_n=search_top_n,
+ doc_scope=manifest_doc_scope,
+ document_ids=document_ids,
+ active_group_ids=manifest_group_ids,
+ active_public_workspace_id=manifest_public_workspace_ids,
+ )
+ retrieved_content, citations = _format_workflow_search_results(search_result.get('results') or [])
+ prepared_workflow = _apply_runtime_document_action_config(workflow, scoped_action)
+ prepared_workflow['task_prompt'] = _build_workflow_search_prompt(workflow.get('task_prompt', ''), {
+ 'retrieved_content': retrieved_content,
+ })
+
+ if thought_tracker and run_id:
+ _add_workflow_activity_thought(
+ thought_tracker,
+ prepared_workflow,
+ run_id,
+ step_type='document',
+ content='Searched selected workflow documents',
+ detail=(
+ f"results={search_result.get('result_count', 0)} | "
+ f"documents={search_result.get('document_count', 0)}"
+ ),
+ activity_key=f'search:{run_id}:documents',
+ kind='document_search',
+ title='Document search',
+ status='completed',
+ )
+
+ return {
+ 'workflow': prepared_workflow,
+ 'citations': citations,
+ 'agent_citations': [],
+ 'generated_tabular_outputs': [],
+ 'coverage': {},
+ 'result_count': search_result.get('result_count', 0),
+ 'document_count': search_result.get('document_count', 0),
+ 'query': search_result.get('query'),
+ }
+
+ manifest = resolve_authorized_source_manifest(
+ document_ids,
+ user_id=user_id,
+ selection_mode='selected',
+ conversation_id=conversation_id,
+ active_group_ids=manifest_group_ids,
+ active_public_workspace_ids=manifest_public_workspace_ids,
+ doc_scope=manifest_doc_scope,
+ request_correlation_id=request_correlation_id,
+ )
+ partitions = partition_source_manifest(manifest)
+ narrative_sources = list(partitions.get('narrative_sources') or [])
+ tabular_sources = list(partitions.get('tabular_sources') or [])
+ narrative_document_ids = [
+ str(source.get('document_id') or '').strip()
+ for source in narrative_sources
+ if str(source.get('document_id') or '').strip()
+ ]
+ authorized_document_ids = narrative_document_ids + [
+ str(source.get('document_id') or '').strip()
+ for source in tabular_sources
+ if str(source.get('document_id') or '').strip()
+ ]
+
+ manifest_group_ids = []
+ manifest_public_workspace_ids = []
+ for source in narrative_sources + tabular_sources:
+ group_id = str(source.get('group_id') or '').strip()
+ public_workspace_id = str(source.get('public_workspace_id') or '').strip()
+ if group_id and group_id not in manifest_group_ids:
+ manifest_group_ids.append(group_id)
+ if public_workspace_id and public_workspace_id not in manifest_public_workspace_ids:
+ manifest_public_workspace_ids.append(public_workspace_id)
+
+ search_top_n = normalize_search_top_n(
+ max(50, len(narrative_document_ids) * 3 if narrative_document_ids else 50)
+ )
+ search_result = {
+ 'results': [],
+ 'result_count': 0,
+ 'document_count': 0,
+ 'query': query,
+ }
+ narrative_retrieval_failed = False
+ if narrative_document_ids:
+ try:
+ search_result = search_documents(
+ query=query,
+ user_id=user_id,
+ top_n=search_top_n,
+ doc_scope=manifest_doc_scope,
+ document_ids=narrative_document_ids,
+ active_group_ids=manifest_group_ids,
+ active_public_workspace_id=manifest_public_workspace_ids,
+ include_all_public_workspaces=True,
+ )
+ except Exception:
+ if not tabular_sources:
+ raise
+ narrative_retrieval_failed = True
retrieved_content, citations = _format_workflow_search_results(search_result.get('results') or [])
- prepared_workflow = _apply_runtime_document_action_config(workflow, resolved_action)
+ evidence_envelopes = (
+ build_failed_narrative_evidence_envelopes(
+ narrative_sources,
+ 'selected',
+ )
+ if narrative_retrieval_failed
+ else build_narrative_evidence_envelopes(
+ narrative_sources,
+ search_result.get('results') or [],
+ 'selected',
+ )
+ )
+
+ from functions_tabular_analysis import execute_mixed_source_tabular_evidence
+
+ native_token_usage_aggregate = _create_token_usage_aggregate()
+ with _workflow_mixed_source_execution_context(user_id, conversation_id, manifest):
+ tabular_result = execute_mixed_source_tabular_evidence(
+ tabular_sources=tabular_sources,
+ selection_mode='selected',
+ has_narrative_sources=bool(narrative_sources),
+ user_question=query,
+ user_id=user_id,
+ conversation_id=conversation_id,
+ gpt_model=_resolve_tabular_document_action_model_name(workflow, settings),
+ settings=settings,
+ thought_tracker=thought_tracker,
+ request_correlation_id=request_correlation_id,
+ )
+ _accumulate_token_usage_summary(
+ native_token_usage_aggregate,
+ tabular_result.get('token_usage'),
+ )
+ evidence_envelopes.extend(tabular_result.get('evidence_envelopes') or [])
+ mixed_source_handoff = build_mixed_source_evidence_handoff(
+ manifest,
+ evidence_envelopes,
+ 'selected',
+ mode='search',
+ telemetry_settings=settings,
+ request_correlation_id=request_correlation_id,
+ )
+
+ prepared_action = dict(resolved_action)
+ prepared_action['document_ids'] = authorized_document_ids
+ prepared_action['doc_scope'] = manifest_doc_scope
+ prepared_action['active_group_ids'] = manifest_group_ids
+ prepared_action['active_public_workspace_id'] = manifest_public_workspace_ids
+ prepared_workflow = _apply_runtime_document_action_config(workflow, prepared_action)
prepared_workflow['task_prompt'] = _build_workflow_search_prompt(workflow.get('task_prompt', ''), {
- 'retrieved_content': retrieved_content,
+ 'retrieved_content': '',
+ 'evidence_messages': [mixed_source_handoff],
})
if thought_tracker and run_id:
@@ -4350,7 +5740,8 @@ def _prepare_workflow_search_context(workflow, action_config, settings, thought_
content='Searched selected workflow documents',
detail=(
f"results={search_result.get('result_count', 0)} | "
- f"documents={search_result.get('document_count', 0)}"
+ f"narrative_documents={len(narrative_sources)} | "
+ f"tabular_documents={len(tabular_sources)}"
),
activity_key=f'search:{run_id}:documents',
kind='document_search',
@@ -4361,12 +5752,63 @@ def _prepare_workflow_search_context(workflow, action_config, settings, thought_
return {
'workflow': prepared_workflow,
'citations': citations,
+ 'agent_citations': list(tabular_result.get('agent_citations') or []),
+ 'generated_tabular_outputs': list(tabular_result.get('generated_outputs') or []),
+ 'coverage': mixed_source_handoff.get('mixed_source_coverage') or {},
+ 'token_usage': _finalize_token_usage(native_token_usage_aggregate),
'result_count': search_result.get('result_count', 0),
- 'document_count': search_result.get('document_count', 0),
+ 'document_count': len(authorized_document_ids),
'query': search_result.get('query'),
}
+def _attach_workflow_search_context(execution_result, workflow_search_context):
+ """Merge mixed Search evidence into either a workflow model or agent result."""
+ execution_result = execution_result if isinstance(execution_result, dict) else {}
+ search_context = workflow_search_context if isinstance(workflow_search_context, dict) else {}
+ if not search_context:
+ return execution_result
+
+ existing_agent_citations = deduplicate_mixed_source_references(
+ list(execution_result.get('agent_citations') or [])
+ + list(search_context.get('agent_citations') or []),
+ reference_type='citation',
+ )
+ existing_outputs = deduplicate_mixed_source_references(
+ list(execution_result.get('generated_tabular_outputs') or [])
+ + list(search_context.get('generated_tabular_outputs') or []),
+ reference_type='artifact',
+ )
+ hybrid_citations = deduplicate_mixed_source_references(
+ list(search_context.get('citations') or []),
+ reference_type='citation',
+ )
+ coverage = search_context.get('coverage') if isinstance(search_context.get('coverage'), dict) else {}
+ merged_token_usage = _merge_token_usage_summaries([
+ execution_result,
+ {'token_usage': search_context.get('token_usage')},
+ ])
+ execution_result.update({
+ 'hybrid_citations': hybrid_citations,
+ 'agent_citations': existing_agent_citations,
+ 'generated_tabular_outputs': existing_outputs,
+ 'mixed_source_coverage': coverage,
+ 'token_usage': merged_token_usage or execution_result.get('token_usage'),
+ 'augmented': bool(
+ hybrid_citations
+ or existing_agent_citations
+ or coverage.get('requested_source_count')
+ ),
+ 'document_search': {
+ 'query': search_context.get('query'),
+ 'result_count': search_context.get('result_count', 0),
+ 'document_count': search_context.get('document_count', 0),
+ 'partial_coverage': bool(coverage.get('partial_coverage')),
+ },
+ })
+ return execution_result
+
+
def _apply_runtime_document_action_config(workflow, action_config):
prepared_workflow = dict(workflow or {})
prepared_workflow['document_action'] = dict(action_config or {})
@@ -4434,22 +5876,17 @@ def _execute_workflow_file_sync(workflow, run_id, trigger_source):
seen_document_ids = set()
for source_config in config.get('sources') or []:
- _raise_if_workflow_run_cancelled(workflow, run_id)
source = get_authorized_sync_source(
source_config.get('scope_type'),
source_config.get('source_id'),
user_id,
scope_id=source_config.get('scope_id'),
)
- run = _execute_cancelable_workflow_step(
- workflow,
- run_id,
- lambda: queue_file_sync_source_run(
- source,
- triggered_by=user_id,
- trigger='workflow',
- run_inline=wait_mode == 'complete',
- ),
+ run = queue_file_sync_source_run(
+ source,
+ triggered_by=user_id,
+ trigger='workflow',
+ run_inline=wait_mode == 'complete',
)
run_summary = _summarize_file_sync_run(run)
run_summary['workflow_run_id'] = run_id
@@ -4531,7 +5968,6 @@ def _apply_file_sync_context_to_workflow(workflow, file_sync_result):
file_sync_context = _format_workflow_file_sync_context(file_sync_result)
if file_sync_context:
prepared_workflow['task_prompt'] = f"{workflow.get('task_prompt', '')}\n\n{file_sync_context}".strip()
- prepared_workflow['file_sync_prompt_context'] = file_sync_context
config = _get_workflow_file_sync_config(workflow)
changed_document_ids = list(file_sync_result.get('changed_document_ids') or [])
@@ -4592,11 +6028,6 @@ def _save_document_run_item(workflow, run_id, document_id, status, *, file_sync_
if not user_id or not run_id or not document_id:
return None
- cancellation_requested = _is_workflow_run_cancellation_requested(workflow, run_id)
- if cancellation_requested:
- status = 'cancelled'
- error = error or WORKFLOW_RUN_CANCELLED_MESSAGE
-
now_iso = _utc_now_iso()
file_sync_document = _file_sync_document_details(file_sync_result or {}, document_id)
item = {
@@ -4627,7 +6058,7 @@ def _save_document_run_item(workflow, run_id, document_id, status, *, file_sync_
item['created_at'] = now_iso
if status == 'running':
item['started_at'] = now_iso
- if status in {'succeeded', 'failed', 'skipped', 'cancelled'}:
+ if status in {'succeeded', 'failed', 'skipped'}:
item['completed_at'] = now_iso
return _save_workflow_run_item_record(workflow, item)
@@ -4942,7 +6373,6 @@ def _combine_per_document_analysis_results(document_results):
provider = ''
agent_name = ''
agent_display_name = ''
- conversation_context_json = ''
for index, item in enumerate(document_results or [], start=1):
result = item.get('result') if isinstance(item.get('result'), dict) else {}
@@ -4978,11 +6408,6 @@ def _combine_per_document_analysis_results(document_results):
provider = provider or result.get('provider') or ''
agent_name = agent_name or result.get('agent_name') or ''
agent_display_name = agent_display_name or result.get('agent_display_name') or ''
- conversation_context_json = (
- conversation_context_json
- or result.get('conversation_context_json')
- or ''
- )
combined_coverage['documents'] = combined_documents
combined_coverage['document_count'] = len(combined_documents) or len(document_results or [])
@@ -5015,12 +6440,10 @@ def _combine_per_document_analysis_results(document_results):
'agent_citations': agent_citations,
'generated_tabular_outputs': generated_tabular_outputs,
'alert_targets': _select_preferred_workflow_alert_targets(alert_targets),
- 'conversation_context_json': conversation_context_json,
}
def _execute_raw_model_workflow(workflow, settings, run_id=None, thought_tracker=None, url_access_context=None):
- _raise_if_workflow_run_cancelled(workflow, run_id)
if thought_tracker and run_id:
_add_workflow_activity_thought(
thought_tracker,
@@ -5037,16 +6460,12 @@ def _execute_raw_model_workflow(workflow, settings, run_id=None, thought_tracker
client, deployment_name, provider = _resolve_model_workflow_client(workflow, settings)
- completion = _execute_cancelable_workflow_step(
- workflow,
- run_id,
- lambda: client.chat.completions.create(
- model=deployment_name,
- messages=_build_workflow_chat_messages(
- workflow.get('task_prompt', ''),
- url_access_context=url_access_context,
- apply_generation_guidance=True,
- ),
+ completion = client.chat.completions.create(
+ model=deployment_name,
+ messages=_build_workflow_chat_messages(
+ workflow.get('task_prompt', ''),
+ url_access_context=url_access_context,
+ apply_generation_guidance=True,
),
)
reply = ''
@@ -5203,11 +6622,19 @@ def _execute_model_workflow_with_core_capabilities(
)
kernel.add_service(chat_service)
+ _resolve_workflow_conversation_context(
+ workflow,
+ model_name=deployment_name,
+ model_provider=provider,
+ model_endpoint_id=workflow.get('model_endpoint_id'),
+ )
chat_history = ChatHistory()
for message in _build_workflow_chat_messages(
workflow.get('task_prompt', ''),
url_access_context=url_access_context,
apply_generation_guidance=True,
+ conversation_context_system=workflow.get('conversation_context_system_message'),
+ conversation_context_data=workflow.get('conversation_context_data_message'),
):
chat_history.add_message(message)
@@ -5295,8 +6722,14 @@ def _execute_document_analysis_workflow(
external_activity_callback=None,
action_config=None,
url_access_context=None,
+ cancel_requested=None,
+ request_correlation_id=None,
):
- _raise_if_workflow_run_cancelled(workflow, run_id)
+ raise_if_mixed_source_cancelled(
+ cancel_requested,
+ 'manifest',
+ request_correlation_id=request_correlation_id,
+ )
analysis_config = action_config if isinstance(action_config, dict) else _get_document_action_config(workflow)
if analysis_config.get('type') != DOCUMENT_ACTION_TYPE_ANALYZE:
raise ValueError('Document analysis is not enabled for this workflow.')
@@ -5341,7 +6774,6 @@ def _execute_document_analysis_workflow(
per_document_results = []
for index, document_id in enumerate(analysis_document_ids, start=1):
- _raise_if_workflow_run_cancelled(workflow, run_id)
per_document_workflow = _build_per_document_workflow(
workflow,
analysis_config,
@@ -5350,26 +6782,20 @@ def _execute_document_analysis_workflow(
len(analysis_document_ids),
)
per_document_action = per_document_workflow.get('document_action') or {}
- per_document_result = _execute_document_analysis_workflow(
- per_document_workflow,
- settings,
- conversation_id=conversation_id,
- run_id=run_id,
- thought_tracker=thought_tracker,
- external_activity_callback=external_activity_callback,
- action_config=per_document_action,
- url_access_context=url_access_context,
- )
- resolved_context_json = per_document_workflow.get(
- '_resolved_conversation_context_json'
- )
- if resolved_context_json:
- per_document_result['conversation_context_json'] = (
- resolved_context_json
- )
per_document_results.append({
'document_id': document_id,
- 'result': per_document_result,
+ 'result': _execute_document_analysis_workflow(
+ per_document_workflow,
+ settings,
+ conversation_id=conversation_id,
+ run_id=run_id,
+ thought_tracker=thought_tracker,
+ external_activity_callback=external_activity_callback,
+ action_config=per_document_action,
+ url_access_context=url_access_context,
+ cancel_requested=cancel_requested,
+ request_correlation_id=request_correlation_id,
+ ),
})
if thought_tracker and run_id:
@@ -5437,13 +6863,6 @@ def _execute_document_analysis_workflow(
loaded_agent = agent_objs.get(requested_name)
if loaded_agent is None:
loaded_agent = next(iter(agent_objs.values()))
- _resolve_workflow_conversation_context(
- workflow,
- model_name=workflow.get('legacy_model_deployment'),
- model_provider=(workflow.get('model_binding_summary') or {}).get('provider'),
- model_endpoint_id=workflow.get('model_endpoint_id'),
- selected_agent=loaded_agent,
- )
if thought_tracker and run_id and conversation_id:
callback_key = register_plugin_invocation_thought_callback(
@@ -5455,62 +6874,84 @@ def _execute_document_analysis_workflow(
)
def invoke_prompt(prompt_text, stage='window_analysis', metadata=None):
- result = _execute_cancelable_workflow_step(
- workflow,
- run_id,
- lambda: asyncio.run(loaded_agent.invoke(_build_workflow_agent_messages(
- prompt_text,
- url_access_context=url_access_context,
- conversation_context_system=workflow.get('conversation_context_system_message'),
- conversation_context_data=workflow.get('conversation_context_data_message'),
- ))),
- )
+ result = asyncio.run(loaded_agent.invoke(_build_workflow_agent_messages(
+ prompt_text,
+ url_access_context=url_access_context,
+ )))
_accumulate_token_usage(token_usage_aggregate, result)
return str(result)
- tabular_action_payload = _maybe_execute_tabular_document_action(
- DOCUMENT_ACTION_TYPE_ANALYZE,
+ def record_native_token_usage(token_usage):
+ _accumulate_token_usage_summary(token_usage_aggregate, token_usage)
+
+ analysis_result = _execute_mixed_source_analyze_workflow(
+ workflow, analysis_config, settings, invoke_prompt,
+ conversation_id=conversation_id, activity_callback=activity_callback,
+ thought_tracker=thought_tracker, live_thought_callback=external_activity_callback,
+ max_documents=workflow_analysis_max_documents,
+ cancel_requested=cancel_requested,
+ request_correlation_id=request_correlation_id,
+ token_usage_callback=record_native_token_usage,
+ )
+ primary_generated_outputs = list(
+ analysis_result.get('generated_tabular_outputs')
+ or []
+ )
+ _reauthorize_mixed_source_workflow_result(
workflow,
analysis_config,
+ analysis_result,
settings,
- conversation_id=conversation_id,
- invoke_prompt=invoke_prompt,
- thought_tracker=thought_tracker,
- live_thought_callback=external_activity_callback,
+ conversation_id,
+ cancel_requested=cancel_requested,
+ request_correlation_id=request_correlation_id,
+ additional_generated_outputs=primary_generated_outputs,
)
- if tabular_action_payload:
- analysis_result = tabular_action_payload.get('result') or {}
- else:
- analysis_result = run_document_analysis(
- user_id=user_id,
- analysis_prompt=workflow.get('task_prompt', ''),
- document_ids=analysis_config.get('document_ids'),
- invoke_prompt=invoke_prompt,
- doc_scope=analysis_config.get('doc_scope'),
- active_group_ids=analysis_config.get('active_group_ids'),
- active_public_workspace_id=analysis_config.get('active_public_workspace_id'),
- window_unit=analysis_config.get('window_unit'),
- window_size=analysis_config.get('window_size'),
- window_percent=analysis_config.get('window_percent'),
- max_retries_per_window=analysis_config.get('max_retries_per_window'),
- activity_callback=activity_callback,
- max_documents=workflow_analysis_max_documents,
- )
- document_analysis_artifact_payload = _execute_cancelable_workflow_step(
- workflow,
- run_id,
- lambda: _maybe_create_document_analysis_generated_artifacts(
+ try:
+ document_analysis_artifact_payload = _maybe_create_document_analysis_generated_artifacts(
analysis_result,
workflow.get('task_prompt', ''),
conversation_id=conversation_id,
- primary_generated_outputs=list((tabular_action_payload or {}).get('generated_tabular_outputs') or []),
+ primary_generated_outputs=primary_generated_outputs,
+ cancel_requested=cancel_requested,
+ request_correlation_id=request_correlation_id,
+ )
+ except MixedSourceCancellationError:
+ _rollback_mixed_source_generated_outputs(
+ user_id,
+ conversation_id,
+ primary_generated_outputs,
+ reason='cancellation',
+ )
+ raise
+ _reauthorize_mixed_source_workflow_result(
+ workflow,
+ analysis_config,
+ analysis_result,
+ settings,
+ conversation_id,
+ cancel_requested=cancel_requested,
+ request_correlation_id=request_correlation_id,
+ additional_generated_outputs=(
+ primary_generated_outputs
+ + list(document_analysis_artifact_payload.get('artifacts') or [])
),
)
agent_citations = _build_agent_citations_from_invocations(user_id, conversation_id)
if not agent_citations:
- agent_citations = list((tabular_action_payload or {}).get('agent_citations') or [])
+ agent_citations = list(
+ analysis_result.get('agent_citations')
+ or []
+ )
alert_targets = _collect_agent_alert_targets(user_id, conversation_id)
token_usage = _finalize_token_usage(token_usage_aggregate)
+ if analysis_result.get('mixed_source_manifest'):
+ _emit_mixed_source_token_telemetry(
+ settings,
+ 'analyze',
+ token_usage,
+ request_correlation_id=request_correlation_id,
+ )
return {
'reply': (
@@ -5526,7 +6967,10 @@ def invoke_prompt(prompt_text, stage='window_analysis', metadata=None):
'agent_name': getattr(loaded_agent, 'name', None) or requested_name,
'agent_display_name': getattr(loaded_agent, 'display_name', None) or selected_agent.get('display_name') or requested_name,
'agent_citations': agent_citations,
- 'generated_tabular_outputs': list((tabular_action_payload or {}).get('generated_tabular_outputs') or []),
+ 'generated_tabular_outputs': list(
+ analysis_result.get('generated_tabular_outputs')
+ or []
+ ),
'alert_targets': alert_targets,
}
finally:
@@ -5573,25 +7017,13 @@ def invoke_prompt(prompt_text, stage='window_analysis', metadata=None):
g.authorized_chat_context = previous_authorized_chat_context
client, deployment_name, provider = _resolve_model_workflow_client(workflow, settings)
- _resolve_workflow_conversation_context(
- workflow,
- model_name=deployment_name,
- model_provider=provider,
- model_endpoint_id=workflow.get('model_endpoint_id'),
- )
def invoke_model_prompt(prompt_text, stage='window_analysis', metadata=None):
- completion = _execute_cancelable_workflow_step(
- workflow,
- run_id,
- lambda: client.chat.completions.create(
- model=deployment_name,
- messages=_build_workflow_chat_messages(
- prompt_text,
- url_access_context=url_access_context,
- conversation_context_system=workflow.get('conversation_context_system_message'),
- conversation_context_data=workflow.get('conversation_context_data_message'),
- ),
+ completion = client.chat.completions.create(
+ model=deployment_name,
+ messages=_build_workflow_chat_messages(
+ prompt_text,
+ url_access_context=url_access_context,
),
)
_accumulate_token_usage(token_usage_aggregate, completion)
@@ -5599,45 +7031,70 @@ def invoke_model_prompt(prompt_text, stage='window_analysis', metadata=None):
return ''
return _extract_message_text(completion.choices[0].message.content)
- tabular_action_payload = _maybe_execute_tabular_document_action(
- DOCUMENT_ACTION_TYPE_ANALYZE,
+ def record_native_token_usage(token_usage):
+ _accumulate_token_usage_summary(token_usage_aggregate, token_usage)
+
+ analysis_result = _execute_mixed_source_analyze_workflow(
+ workflow, analysis_config, settings, invoke_model_prompt,
+ conversation_id=conversation_id, activity_callback=activity_callback,
+ thought_tracker=thought_tracker, live_thought_callback=external_activity_callback,
+ max_documents=workflow_analysis_max_documents,
+ cancel_requested=cancel_requested,
+ request_correlation_id=request_correlation_id,
+ token_usage_callback=record_native_token_usage,
+ )
+ primary_generated_outputs = list(
+ analysis_result.get('generated_tabular_outputs')
+ or []
+ )
+ _reauthorize_mixed_source_workflow_result(
workflow,
analysis_config,
+ analysis_result,
settings,
- conversation_id=conversation_id,
- invoke_prompt=invoke_model_prompt,
- thought_tracker=thought_tracker,
- live_thought_callback=external_activity_callback,
+ conversation_id,
+ cancel_requested=cancel_requested,
+ request_correlation_id=request_correlation_id,
+ additional_generated_outputs=primary_generated_outputs,
)
- if tabular_action_payload:
- analysis_result = tabular_action_payload.get('result') or {}
- else:
- analysis_result = run_document_analysis(
- user_id=user_id,
- analysis_prompt=workflow.get('task_prompt', ''),
- document_ids=analysis_config.get('document_ids'),
- invoke_prompt=invoke_model_prompt,
- doc_scope=analysis_config.get('doc_scope'),
- active_group_ids=analysis_config.get('active_group_ids'),
- active_public_workspace_id=analysis_config.get('active_public_workspace_id'),
- window_unit=analysis_config.get('window_unit'),
- window_size=analysis_config.get('window_size'),
- window_percent=analysis_config.get('window_percent'),
- max_retries_per_window=analysis_config.get('max_retries_per_window'),
- activity_callback=activity_callback,
- max_documents=workflow_analysis_max_documents,
- )
- document_analysis_artifact_payload = _execute_cancelable_workflow_step(
- workflow,
- run_id,
- lambda: _maybe_create_document_analysis_generated_artifacts(
+ try:
+ document_analysis_artifact_payload = _maybe_create_document_analysis_generated_artifacts(
analysis_result,
workflow.get('task_prompt', ''),
conversation_id=conversation_id,
- primary_generated_outputs=list((tabular_action_payload or {}).get('generated_tabular_outputs') or []),
+ primary_generated_outputs=primary_generated_outputs,
+ cancel_requested=cancel_requested,
+ request_correlation_id=request_correlation_id,
+ )
+ except MixedSourceCancellationError:
+ _rollback_mixed_source_generated_outputs(
+ user_id,
+ conversation_id,
+ primary_generated_outputs,
+ reason='cancellation',
+ )
+ raise
+ _reauthorize_mixed_source_workflow_result(
+ workflow,
+ analysis_config,
+ analysis_result,
+ settings,
+ conversation_id,
+ cancel_requested=cancel_requested,
+ request_correlation_id=request_correlation_id,
+ additional_generated_outputs=(
+ primary_generated_outputs
+ + list(document_analysis_artifact_payload.get('artifacts') or [])
),
)
token_usage = _finalize_token_usage(token_usage_aggregate)
+ if analysis_result.get('mixed_source_manifest'):
+ _emit_mixed_source_token_telemetry(
+ settings,
+ 'analyze',
+ token_usage,
+ request_correlation_id=request_correlation_id,
+ )
debug_print(
'[WorkflowDocumentAnalysis] Completed workflow action | '
f"workflow_id={workflow.get('id')} | "
@@ -5659,8 +7116,14 @@ def invoke_model_prompt(prompt_text, stage='window_analysis', metadata=None):
'model_deployment_name': deployment_name,
'token_usage': token_usage,
'provider': provider,
- 'agent_citations': list((tabular_action_payload or {}).get('agent_citations') or []),
- 'generated_tabular_outputs': list((tabular_action_payload or {}).get('generated_tabular_outputs') or []),
+ 'agent_citations': list(
+ analysis_result.get('agent_citations')
+ or []
+ ),
+ 'generated_tabular_outputs': list(
+ analysis_result.get('generated_tabular_outputs')
+ or []
+ ),
}
@@ -5673,8 +7136,14 @@ def _execute_document_comparison_workflow(
external_activity_callback=None,
action_config=None,
url_access_context=None,
+ cancel_requested=None,
+ request_correlation_id=None,
):
- _raise_if_workflow_run_cancelled(workflow, run_id)
+ raise_if_mixed_source_cancelled(
+ cancel_requested,
+ 'manifest',
+ request_correlation_id=request_correlation_id,
+ )
comparison_config = action_config if isinstance(action_config, dict) else _get_document_action_config(workflow)
if comparison_config.get('type') != DOCUMENT_ACTION_TYPE_COMPARISON:
raise ValueError('Document comparison is not enabled for this workflow.')
@@ -5743,13 +7212,6 @@ def _execute_document_comparison_workflow(
loaded_agent = agent_objs.get(requested_name)
if loaded_agent is None:
loaded_agent = next(iter(agent_objs.values()))
- _resolve_workflow_conversation_context(
- workflow,
- model_name=workflow.get('legacy_model_deployment'),
- model_provider=(workflow.get('model_binding_summary') or {}).get('provider'),
- model_endpoint_id=workflow.get('model_endpoint_id'),
- selected_agent=loaded_agent,
- )
if thought_tracker and run_id and conversation_id:
callback_key = register_plugin_invocation_thought_callback(
@@ -5761,30 +7223,40 @@ def _execute_document_comparison_workflow(
)
def invoke_prompt(prompt_text, stage='window_analysis', metadata=None):
- result = _execute_cancelable_workflow_step(
- workflow,
- run_id,
- lambda: asyncio.run(loaded_agent.invoke(_build_workflow_agent_messages(
- prompt_text,
- url_access_context=url_access_context,
- conversation_context_system=workflow.get('conversation_context_system_message'),
- conversation_context_data=workflow.get('conversation_context_data_message'),
- ))),
- )
+ result = asyncio.run(loaded_agent.invoke(_build_workflow_agent_messages(
+ prompt_text,
+ url_access_context=url_access_context,
+ )))
_accumulate_token_usage(token_usage_aggregate, result)
return str(result)
- tabular_action_payload = _maybe_execute_tabular_document_action(
- DOCUMENT_ACTION_TYPE_COMPARISON,
- workflow,
- comparison_config,
- settings,
- conversation_id=conversation_id,
- invoke_prompt=invoke_prompt,
- thought_tracker=thought_tracker,
- live_thought_callback=external_activity_callback,
+ def record_native_token_usage(token_usage):
+ _accumulate_token_usage_summary(token_usage_aggregate, token_usage)
+
+ mixed_comparison_enabled = is_cross_format_compare_enabled(settings)
+ mixed_comparison_result = (
+ _execute_cross_format_comparison_workflow(
+ workflow, comparison_config, settings, invoke_prompt,
+ conversation_id=conversation_id, activity_callback=activity_callback,
+ thought_tracker=thought_tracker, live_thought_callback=external_activity_callback,
+ cancel_requested=cancel_requested,
+ request_correlation_id=request_correlation_id,
+ token_usage_callback=record_native_token_usage,
+ ) if mixed_comparison_enabled else None
+ )
+ if not mixed_comparison_enabled:
+ _raise_legacy_cross_format_compare_limitation(comparison_config, user_id, conversation_id)
+ tabular_action_payload = None if mixed_comparison_result else _maybe_execute_tabular_document_action(
+ DOCUMENT_ACTION_TYPE_COMPARISON, workflow, comparison_config, settings,
+ conversation_id=conversation_id, invoke_prompt=invoke_prompt,
+ thought_tracker=thought_tracker, live_thought_callback=external_activity_callback,
+ cancel_requested=cancel_requested,
+ request_correlation_id=request_correlation_id,
+ token_usage_callback=record_native_token_usage,
)
- if tabular_action_payload:
+ if mixed_comparison_result:
+ comparison_result = mixed_comparison_result
+ elif tabular_action_payload:
comparison_result = tabular_action_payload.get('result') or {}
else:
comparison_result = run_document_comparison(
@@ -5794,21 +7266,69 @@ def invoke_prompt(prompt_text, stage='window_analysis', metadata=None):
invoke_prompt=invoke_prompt,
activity_callback=activity_callback,
conversation_id=conversation_id,
+ cancel_requested=cancel_requested,
+ request_correlation_id=request_correlation_id,
)
- comparison_artifact_payload = _execute_cancelable_workflow_step(
+ agent_citations = _build_agent_citations_from_invocations(user_id, conversation_id)
+ agent_citations = deduplicate_mixed_source_references(
+ list(agent_citations or [])
+ + list((tabular_action_payload or {}).get('agent_citations') or [])
+ + list(comparison_result.get('agent_citations') or []),
+ reference_type='citation',
+ )
+ generated_tabular_outputs = deduplicate_mixed_source_references(
+ list((tabular_action_payload or {}).get('generated_tabular_outputs') or [])
+ + list(comparison_result.get('generated_tabular_outputs') or []),
+ reference_type='artifact',
+ )
+ _reauthorize_mixed_source_workflow_result(
workflow,
- run_id,
- lambda: _maybe_create_comparison_generated_artifacts(
+ comparison_config,
+ comparison_result,
+ settings,
+ conversation_id,
+ cancel_requested=cancel_requested,
+ request_correlation_id=request_correlation_id,
+ additional_generated_outputs=generated_tabular_outputs,
+ )
+ try:
+ comparison_artifact_payload = _maybe_create_comparison_generated_artifacts(
comparison_result,
workflow.get('task_prompt', ''),
conversation_id=conversation_id,
+ cancel_requested=cancel_requested,
+ request_correlation_id=request_correlation_id,
+ )
+ except MixedSourceCancellationError:
+ _rollback_mixed_source_generated_outputs(
+ user_id,
+ conversation_id,
+ generated_tabular_outputs,
+ reason='cancellation',
+ )
+ raise
+ _reauthorize_mixed_source_workflow_result(
+ workflow,
+ comparison_config,
+ comparison_result,
+ settings,
+ conversation_id,
+ cancel_requested=cancel_requested,
+ request_correlation_id=request_correlation_id,
+ additional_generated_outputs=(
+ generated_tabular_outputs
+ + list(comparison_artifact_payload.get('artifacts') or [])
),
)
- agent_citations = _build_agent_citations_from_invocations(user_id, conversation_id)
- if not agent_citations:
- agent_citations = list((tabular_action_payload or {}).get('agent_citations') or [])
alert_targets = _collect_agent_alert_targets(user_id, conversation_id)
token_usage = _finalize_token_usage(token_usage_aggregate)
+ if comparison_result.get('mixed_source_manifest'):
+ _emit_mixed_source_token_telemetry(
+ settings,
+ 'compare',
+ token_usage,
+ request_correlation_id=request_correlation_id,
+ )
return {
'reply': (
@@ -5824,7 +7344,7 @@ def invoke_prompt(prompt_text, stage='window_analysis', metadata=None):
'agent_name': getattr(loaded_agent, 'name', None) or requested_name,
'agent_display_name': getattr(loaded_agent, 'display_name', None) or selected_agent.get('display_name') or requested_name,
'agent_citations': agent_citations,
- 'generated_tabular_outputs': list((tabular_action_payload or {}).get('generated_tabular_outputs') or []),
+ 'generated_tabular_outputs': generated_tabular_outputs,
'alert_targets': alert_targets,
}
finally:
@@ -5871,25 +7391,13 @@ def invoke_prompt(prompt_text, stage='window_analysis', metadata=None):
g.authorized_chat_context = previous_authorized_chat_context
client, deployment_name, provider = _resolve_model_workflow_client(workflow, settings)
- _resolve_workflow_conversation_context(
- workflow,
- model_name=deployment_name,
- model_provider=provider,
- model_endpoint_id=workflow.get('model_endpoint_id'),
- )
def invoke_model_prompt(prompt_text, stage='window_analysis', metadata=None):
- completion = _execute_cancelable_workflow_step(
- workflow,
- run_id,
- lambda: client.chat.completions.create(
- model=deployment_name,
- messages=_build_workflow_chat_messages(
- prompt_text,
- url_access_context=url_access_context,
- conversation_context_system=workflow.get('conversation_context_system_message'),
- conversation_context_data=workflow.get('conversation_context_data_message'),
- ),
+ completion = client.chat.completions.create(
+ model=deployment_name,
+ messages=_build_workflow_chat_messages(
+ prompt_text,
+ url_access_context=url_access_context,
),
)
_accumulate_token_usage(token_usage_aggregate, completion)
@@ -5897,17 +7405,33 @@ def invoke_model_prompt(prompt_text, stage='window_analysis', metadata=None):
return ''
return _extract_message_text(completion.choices[0].message.content)
- tabular_action_payload = _maybe_execute_tabular_document_action(
- DOCUMENT_ACTION_TYPE_COMPARISON,
- workflow,
- comparison_config,
- settings,
- conversation_id=conversation_id,
- invoke_prompt=invoke_model_prompt,
- thought_tracker=thought_tracker,
- live_thought_callback=external_activity_callback,
+ def record_native_token_usage(token_usage):
+ _accumulate_token_usage_summary(token_usage_aggregate, token_usage)
+
+ mixed_comparison_enabled = is_cross_format_compare_enabled(settings)
+ mixed_comparison_result = (
+ _execute_cross_format_comparison_workflow(
+ workflow, comparison_config, settings, invoke_model_prompt,
+ conversation_id=conversation_id, activity_callback=activity_callback,
+ thought_tracker=thought_tracker, live_thought_callback=external_activity_callback,
+ cancel_requested=cancel_requested,
+ request_correlation_id=request_correlation_id,
+ token_usage_callback=record_native_token_usage,
+ ) if mixed_comparison_enabled else None
+ )
+ if not mixed_comparison_enabled:
+ _raise_legacy_cross_format_compare_limitation(comparison_config, user_id, conversation_id)
+ tabular_action_payload = None if mixed_comparison_result else _maybe_execute_tabular_document_action(
+ DOCUMENT_ACTION_TYPE_COMPARISON, workflow, comparison_config, settings,
+ conversation_id=conversation_id, invoke_prompt=invoke_model_prompt,
+ thought_tracker=thought_tracker, live_thought_callback=external_activity_callback,
+ cancel_requested=cancel_requested,
+ request_correlation_id=request_correlation_id,
+ token_usage_callback=record_native_token_usage,
)
- if tabular_action_payload:
+ if mixed_comparison_result:
+ comparison_result = mixed_comparison_result
+ elif tabular_action_payload:
comparison_result = tabular_action_payload.get('result') or {}
else:
comparison_result = run_document_comparison(
@@ -5917,17 +7441,66 @@ def invoke_model_prompt(prompt_text, stage='window_analysis', metadata=None):
invoke_prompt=invoke_model_prompt,
activity_callback=activity_callback,
conversation_id=conversation_id,
+ cancel_requested=cancel_requested,
+ request_correlation_id=request_correlation_id,
)
- comparison_artifact_payload = _execute_cancelable_workflow_step(
+ agent_citations = deduplicate_mixed_source_references(
+ list((tabular_action_payload or {}).get('agent_citations') or [])
+ + list(comparison_result.get('agent_citations') or []),
+ reference_type='citation',
+ )
+ generated_tabular_outputs = deduplicate_mixed_source_references(
+ list((tabular_action_payload or {}).get('generated_tabular_outputs') or [])
+ + list(comparison_result.get('generated_tabular_outputs') or []),
+ reference_type='artifact',
+ )
+ _reauthorize_mixed_source_workflow_result(
workflow,
- run_id,
- lambda: _maybe_create_comparison_generated_artifacts(
+ comparison_config,
+ comparison_result,
+ settings,
+ conversation_id,
+ cancel_requested=cancel_requested,
+ request_correlation_id=request_correlation_id,
+ additional_generated_outputs=generated_tabular_outputs,
+ )
+ try:
+ comparison_artifact_payload = _maybe_create_comparison_generated_artifacts(
comparison_result,
workflow.get('task_prompt', ''),
conversation_id=conversation_id,
+ cancel_requested=cancel_requested,
+ request_correlation_id=request_correlation_id,
+ )
+ except MixedSourceCancellationError:
+ _rollback_mixed_source_generated_outputs(
+ user_id,
+ conversation_id,
+ generated_tabular_outputs,
+ reason='cancellation',
+ )
+ raise
+ _reauthorize_mixed_source_workflow_result(
+ workflow,
+ comparison_config,
+ comparison_result,
+ settings,
+ conversation_id,
+ cancel_requested=cancel_requested,
+ request_correlation_id=request_correlation_id,
+ additional_generated_outputs=(
+ generated_tabular_outputs
+ + list(comparison_artifact_payload.get('artifacts') or [])
),
)
token_usage = _finalize_token_usage(token_usage_aggregate)
+ if comparison_result.get('mixed_source_manifest'):
+ _emit_mixed_source_token_telemetry(
+ settings,
+ 'compare',
+ token_usage,
+ request_correlation_id=request_correlation_id,
+ )
debug_print(
'[WorkflowDocumentComparison] Completed workflow action | '
f"workflow_id={workflow.get('id')} | "
@@ -5949,8 +7522,8 @@ def invoke_model_prompt(prompt_text, stage='window_analysis', metadata=None):
'model_deployment_name': deployment_name,
'token_usage': token_usage,
'provider': provider,
- 'agent_citations': list((tabular_action_payload or {}).get('agent_citations') or []),
- 'generated_tabular_outputs': list((tabular_action_payload or {}).get('generated_tabular_outputs') or []),
+ 'agent_citations': agent_citations,
+ 'generated_tabular_outputs': generated_tabular_outputs,
}
@@ -5962,8 +7535,17 @@ def _execute_document_action_workflow(
thought_tracker=None,
external_activity_callback=None,
url_access_context=None,
+ cancel_requested=None,
+ request_correlation_id=None,
):
- _raise_if_workflow_run_cancelled(workflow, run_id)
+ request_correlation_id = normalize_mixed_source_correlation_id(
+ request_correlation_id
+ )
+ raise_if_mixed_source_cancelled(
+ cancel_requested,
+ 'manifest',
+ request_correlation_id=request_correlation_id,
+ )
action_config = _get_document_action_config(workflow)
action_config = _resolve_recent_document_action_targets(workflow, action_config, settings)
workflow = _apply_runtime_document_action_config(workflow, action_config)
@@ -5988,6 +7570,8 @@ def _execute_document_action_workflow(
external_activity_callback=external_activity_callback,
action_config=action_config,
url_access_context=url_access_context,
+ cancel_requested=cancel_requested,
+ request_correlation_id=request_correlation_id,
)
elif action_type == DOCUMENT_ACTION_TYPE_COMPARISON:
result = _execute_document_comparison_workflow(
@@ -5999,6 +7583,8 @@ def _execute_document_action_workflow(
external_activity_callback=external_activity_callback,
action_config=action_config,
url_access_context=url_access_context,
+ cancel_requested=cancel_requested,
+ request_correlation_id=request_correlation_id,
)
else:
raise ValueError('No document action is enabled for this workflow.')
@@ -6013,6 +7599,11 @@ def _execute_document_action_workflow(
)
raise
+ raise_if_mixed_source_cancelled(
+ cancel_requested,
+ 'finalization',
+ request_correlation_id=request_correlation_id,
+ )
debug_print(
'[WorkflowDocumentAction] Action completed | '
f"workflow_id={workflow.get('id')} | "
@@ -6023,10 +7614,6 @@ def _execute_document_action_workflow(
f"processed_windows={(result.get('analysis_coverage') or {}).get('processed_windows', 0)} | "
f"failed_windows={(result.get('analysis_coverage') or {}).get('failed_windows', 0)}"
)
- resolved_context_json = workflow.get('_resolved_conversation_context_json')
- if resolved_context_json:
- result['conversation_context_json'] = resolved_context_json
- _raise_if_workflow_run_cancelled(workflow, run_id)
return result
@@ -6110,6 +7697,14 @@ def _execute_agent_workflow(workflow, settings, conversation_id='', run_id=None,
if loaded_agent is None:
loaded_agent = next(iter(agent_objs.values()))
+ _resolve_workflow_conversation_context(
+ workflow,
+ model_name=getattr(loaded_agent, 'deployment_name', None) or requested_name,
+ model_provider=(workflow.get('model_binding_summary') or {}).get('provider'),
+ model_endpoint_id=workflow.get('model_endpoint_id'),
+ selected_agent=loaded_agent,
+ )
+
result = _execute_cancelable_workflow_step(
workflow,
run_id,
@@ -6117,6 +7712,8 @@ def _execute_agent_workflow(workflow, settings, conversation_id='', run_id=None,
workflow.get('task_prompt', ''),
url_access_context=url_access_context,
apply_generation_guidance=True,
+ conversation_context_system=workflow.get('conversation_context_system_message'),
+ conversation_context_data=workflow.get('conversation_context_data_message'),
))),
)
reply = str(result)
@@ -6440,6 +8037,7 @@ def _execute_workflow_dispatch(
execution_workflow,
document_action,
settings,
+ conversation_id=conversation_id,
thought_tracker=thought_tracker,
run_id=run_id,
),
@@ -6473,6 +8071,8 @@ def _execute_workflow_dispatch(
thought_tracker=thought_tracker,
external_activity_callback=run_item_callback,
url_access_context=url_access_context,
+ cancel_requested=lambda: _is_workflow_run_cancellation_requested(execution_workflow, run_id),
+ request_correlation_id=run_id,
),
)
elif execution_workflow.get('runner_type') == 'agent':
@@ -6488,6 +8088,7 @@ def _execute_workflow_dispatch(
url_access_context=url_access_context,
),
)
+ execution_result = _attach_workflow_search_context(execution_result, workflow_search_context)
else:
execution_result = _execute_cancelable_workflow_step(
execution_workflow,
@@ -6501,17 +8102,8 @@ def _execute_workflow_dispatch(
url_access_context=url_access_context,
),
)
+ execution_result = _attach_workflow_search_context(execution_result, workflow_search_context)
- if workflow_search_context:
- execution_result.update({
- 'hybrid_citations': workflow_search_context.get('citations') or [],
- 'augmented': bool(workflow_search_context.get('citations')),
- 'document_search': {
- 'query': workflow_search_context.get('query'),
- 'result_count': workflow_search_context.get('result_count', 0),
- 'document_count': workflow_search_context.get('document_count', 0),
- },
- })
return execution_result
@@ -6593,7 +8185,13 @@ def _execute_workflow_task_sequence(
previous_reply = ''
task_results = []
+ def raise_if_cancelled():
+ cancel_check = globals().get('_raise_if_workflow_run_cancelled')
+ if callable(cancel_check):
+ cancel_check(workflow, run_id)
+
for task_index, raw_task in enumerate(tasks):
+ raise_if_cancelled()
task = dict(raw_task or {})
task['order'] = task_index + 1
task_id = str(task.get('id') or f'task-{task_index + 1}').strip()
@@ -6634,6 +8232,7 @@ def _execute_workflow_task_sequence(
task_error = ''
attempt_count = 0
for attempt_index in range(retry_count + 1):
+ raise_if_cancelled()
attempt_count = attempt_index + 1
try:
attempt_workflow, runner_audit = _resolve_workflow_task_runner(
@@ -6907,7 +8506,6 @@ def run_personal_workflow(workflow, trigger_source='manual', user_roles=None, ac
'error': '',
})
_save_workflow_run_record(workflow, run_record)
- _raise_if_workflow_run_cancelled(workflow, run_id)
log_workflow_run(
user_id=user_id,
workflow_id=workflow_id,
@@ -6920,7 +8518,6 @@ def run_personal_workflow(workflow, trigger_source='manual', user_roles=None, ac
workspace_type=workspace_type,
group_id=group_id or None,
)
- _raise_if_workflow_run_cancelled(workflow, run_id)
return {
'success': True,
'run': run_record,
@@ -6996,9 +8593,9 @@ def run_personal_workflow(workflow, trigger_source='manual', user_roles=None, ac
execution_workflow,
settings,
conversation_id,
- run_id,
- thought_tracker,
- url_access_context,
+ run_id=run_id,
+ thought_tracker=thought_tracker,
+ url_access_context=url_access_context,
file_sync_result=file_sync_result,
actor_user_id=actor_user_id or user_id,
)
diff --git a/application/single_app/route_backend_chats.py b/application/single_app/route_backend_chats.py
index a4adb5374..f03824f3a 100644
--- a/application/single_app/route_backend_chats.py
+++ b/application/single_app/route_backend_chats.py
@@ -12,7 +12,11 @@
from semantic_kernel_plugins.plugin_invocation_thoughts import (
register_plugin_invocation_thought_callback,
)
-from semantic_kernel_plugins.plugin_invocation_logger import get_plugin_logger, sanitize_plugin_invocation_value
+from semantic_kernel_plugins.plugin_invocation_logger import (
+ PluginInvocationResult,
+ get_plugin_logger,
+ sanitize_plugin_invocation_value,
+)
from semantic_kernel_plugins.chart_plugin import ChartPlugin
from foundry_agent_runtime import FoundryAgentInvocationError, FoundryAgentUserAuthenticationRequired, execute_foundry_agent, resolve_authority
from model_endpoint_clients import (
@@ -33,6 +37,26 @@
build_model_endpoint_sync_chat_client,
build_semantic_kernel_chat_service_for_model,
)
+from functions_mixed_source_orchestration import (
+ MixedSourceCancellationError,
+ MixedSourceFinalizationError,
+ build_failed_narrative_evidence_envelopes,
+ build_mixed_source_evidence_handoff,
+ build_narrative_evidence_envelopes,
+ build_tabular_file_contexts_from_manifest,
+ compare_reauthorized_source_manifests,
+ emit_mixed_source_telemetry,
+ execute_tabular_evidence_sources,
+ normalize_mixed_source_correlation_id,
+ normalize_document_context_request,
+ partition_source_manifest,
+ resolve_authorized_source_manifest,
+ raise_if_mixed_source_cancelled,
+ should_run_tabular_evidence,
+)
+from functions_tabular_analysis import (
+ get_new_plugin_invocations as _shared_get_new_plugin_invocations,
+)
import builtins
import asyncio, types
import ast
@@ -55,6 +79,7 @@
from flask import Response, copy_current_request_context, g, has_request_context, stream_with_context
from functions_authentication import *
from functions_search import *
+from functions_search_service import search_relevant_tabular_candidates
from functions_service_health import (
SEMANTIC_SEARCH_QUOTA_WARNING_TYPE,
SemanticSearchQuotaExceededError,
@@ -96,7 +121,23 @@
from functions_content import generate_embedding, generate_embeddings_batch
from functions_assistant_table_exports import (
TABLE_EXPORT_REQUEST_MARKERS,
- build_assistant_table_csv_export,
+ assistant_table_export_requested,
+ build_safe_csv_headers,
+ has_generated_tabular_csv_output,
+ neutralize_csv_spreadsheet_formula,
+)
+from functions_generated_file_exports import (
+ build_generated_file_artifact_metadata,
+ build_generated_file_export,
+ build_generated_file_output_guidance,
+ get_generated_file_export_content,
+ get_requested_generated_file_format,
+ has_generated_file_output,
+ normalize_json_artifact_payload,
+ normalize_generated_output_format,
+ normalize_xml_artifact_payload,
+ serialize_generated_json,
+ serialize_generated_xml,
)
from functions_chart_operations import (
CORE_CHART_PLUGIN_NAME,
@@ -129,13 +170,6 @@
)
from functions_appinsights import log_event
from functions_debug import debug_print
-from functions_generated_file_exports import (
- normalize_json_artifact_payload,
- normalize_generated_output_format,
- normalize_xml_artifact_payload,
- serialize_generated_json,
- serialize_generated_xml,
-)
from functions_governance import ensure_governance_access
from functions_notifications import create_chat_response_notification
from functions_activity_logging import log_agent_run, log_chat_activity, log_conversation_creation, log_token_usage
@@ -167,14 +201,20 @@
normalize_document_action_config,
)
from functions_thoughts import ThoughtTracker
+from functions_tabular_csv_query import validate_tabular_csv_query_expression
from functions_workflow_runner import _execute_document_action_workflow
from functions_simplechat_operations import (
+ delete_generated_chat_artifact_for_current_user,
derive_conversation_title_from_message,
upload_chat_image_bytes_for_user,
upload_generated_analysis_artifact_for_current_user,
)
from functions_tabular_generated_exports import (
+ _normalize_generated_batch_entries,
+ _prepare_tabular_source_rows,
build_background_tabular_generated_output_metadata,
+ build_tabular_generated_output_row_batches,
+ cancel_tabular_generated_output_run,
get_tabular_generated_output_run_status,
queue_tabular_generated_output_run,
resume_tabular_generated_output_run,
@@ -188,8 +228,9 @@
DOCUMENT_ACTION_TYPE_ANALYZE: ASSIGNED_KNOWLEDGE_USER_ACTION_ANALYZE,
DOCUMENT_ACTION_TYPE_COMPARISON: ASSIGNED_KNOWLEDGE_USER_ACTION_COMPARE,
}
-ASSIGNED_KNOWLEDGE_CONTEXT_TOP_N = 12
+ASSIGNED_KNOWLEDGE_CONTEXT_TOP_N = 50
ASSIGNED_KNOWLEDGE_CONTEXT_EXCERPT_MAX_CHARS = 1800
+MIXED_SOURCE_CHAT_RELEVANCE_SOURCE_LIMIT = 48
FOUNDRY_SELECTED_AGENT_TYPES = {'aifoundry', 'new_foundry', 'foundry_workflow'}
FOUNDRY_AGENT_PLUGIN_NAMES = {
'aifoundry': 'azure_ai_foundry',
@@ -221,6 +262,73 @@ def _get_foundry_agent_label(agent_type):
)
+FOUNDRY_CITATION_DISPLAY_FIELDS = (
+ 'title',
+ 'name',
+ 'file_name',
+ 'filename',
+ 'document_name',
+ 'source_name',
+)
+FOUNDRY_CITATION_URL_FIELDS = ('url', 'uri')
+FOUNDRY_CITATION_NESTED_FIELDS = ('metadata', 'source', 'file')
+
+
+def _normalize_foundry_citation_display_text(value):
+ if not isinstance(value, str):
+ return ''
+ cleaned_value = ' '.join(value.replace('<', '').replace('>', '').split())
+ if len(cleaned_value) > 120:
+ return f"{cleaned_value[:117].rstrip()}..."
+ return cleaned_value
+
+
+def _get_foundry_citation_url_label(value):
+ if not isinstance(value, str):
+ return ''
+ parsed_url = urlparse(value.strip())
+ if parsed_url.scheme not in ('http', 'https') or not parsed_url.hostname:
+ return ''
+ return _normalize_foundry_citation_display_text(parsed_url.hostname)
+
+
+def _iter_foundry_citation_sources(citation):
+ if not isinstance(citation, dict):
+ return []
+ sources = [citation]
+ for field_name in FOUNDRY_CITATION_NESTED_FIELDS:
+ nested_source = citation.get(field_name)
+ if isinstance(nested_source, dict):
+ sources.append(nested_source)
+ return sources
+
+
+def _get_foundry_citation_display_label(citation):
+ for source in _iter_foundry_citation_sources(citation):
+ for field_name in FOUNDRY_CITATION_DISPLAY_FIELDS:
+ display_label = _normalize_foundry_citation_display_text(source.get(field_name))
+ if display_label:
+ return display_label
+ for source in _iter_foundry_citation_sources(citation):
+ for field_name in FOUNDRY_CITATION_URL_FIELDS:
+ url_label = _get_foundry_citation_url_label(source.get(field_name))
+ if url_label:
+ return url_label
+ if isinstance(citation, dict):
+ citation_type = _normalize_foundry_citation_display_text(citation.get('citation_type'))
+ if citation_type:
+ return citation_type.replace('_', ' ')
+ return ''
+
+
+def _build_foundry_citation_thought_content(agent_type, citation):
+ foundry_label = _get_foundry_agent_label(agent_type)
+ citation_label = _get_foundry_citation_display_label(citation)
+ if citation_label:
+ return f"Agent retrieved citation from {foundry_label}: {citation_label}"
+ return f"Agent retrieved citation from {foundry_label}"
+
+
def _build_foundry_runtime_metadata(agent):
metadata = getattr(agent, 'last_run_metadata', None)
return metadata if isinstance(metadata, dict) else {}
@@ -377,6 +485,37 @@ def _safe_metadata_int(value):
return 0
+def _merge_chat_token_usage(*token_summaries):
+ """Merge observed token summaries without estimating missing provider usage."""
+ merged = {
+ 'prompt_tokens': 0,
+ 'completion_tokens': 0,
+ 'total_tokens': 0,
+ 'request_count': 0,
+ }
+ has_usage = False
+ for token_summary in token_summaries:
+ if not isinstance(token_summary, dict):
+ continue
+ summary_has_usage = False
+ for key in ('prompt_tokens', 'completion_tokens', 'total_tokens'):
+ try:
+ value = max(0, int(token_summary.get(key) or 0))
+ except (TypeError, ValueError):
+ value = 0
+ merged[key] += value
+ summary_has_usage = summary_has_usage or bool(value)
+ try:
+ request_count = max(0, int(token_summary.get('request_count') or 0))
+ except (TypeError, ValueError):
+ request_count = 0
+ if not request_count and summary_has_usage:
+ request_count = 1
+ merged['request_count'] += request_count
+ has_usage = has_usage or summary_has_usage or bool(request_count)
+ return merged if has_usage else None
+
+
def _normalize_capability_action(document_action_type):
normalized_action_type = str(document_action_type or DOCUMENT_ACTION_TYPE_NONE).strip().lower()
if normalized_action_type == DOCUMENT_ACTION_TYPE_ANALYZE:
@@ -386,6 +525,505 @@ def _normalize_capability_action(document_action_type):
return ASSIGNED_KNOWLEDGE_USER_ACTION_SEARCH
+def _maybe_resolve_chat_source_manifest(
+ settings,
+ user_id,
+ conversation_id,
+ selected_document_ids,
+ scope_context,
+):
+ if not is_mixed_source_manifest_enabled(settings):
+ return []
+
+ requested_source_ids = _normalize_conversation_task_document_ids(
+ selected_document_ids
+ )
+ if not requested_source_ids:
+ return []
+
+ scope_context = scope_context if isinstance(scope_context, dict) else {}
+ try:
+ return resolve_authorized_source_manifest(
+ requested_source_ids,
+ user_id=user_id,
+ selection_mode='selected',
+ conversation_id=conversation_id,
+ active_group_ids=scope_context.get('active_group_ids'),
+ active_public_workspace_ids=scope_context.get('active_public_workspace_ids'),
+ )
+ except Exception:
+ log_event(
+ '[MixedSourceManifest] Chat shadow resolution failed.',
+ extra={
+ 'requested_source_count': len(requested_source_ids),
+ 'selection_mode': 'selected',
+ },
+ level=logging.WARNING,
+ )
+ return []
+
+
+def _normalize_chat_document_context_contract(
+ settings,
+ data,
+ selected_document_ids,
+ hybrid_search_enabled,
+):
+ """Normalize Phase 2 context intent while preserving flag-off compatibility."""
+ normalized_document_ids = _normalize_conversation_task_document_ids(
+ selected_document_ids
+ )
+ if not is_mixed_source_chat_search_enabled(settings):
+ return {
+ 'selection_mode': 'selected' if normalized_document_ids else 'relevance',
+ 'selected_document_ids': normalized_document_ids,
+ 'document_context_requested': bool(hybrid_search_enabled),
+ 'hybrid_search': bool(hybrid_search_enabled),
+ 'explicit_selection': False,
+ }
+
+ return normalize_document_context_request(
+ selection_mode=data.get('selection_mode'),
+ selected_document_ids=normalized_document_ids,
+ document_context_requested=data.get('document_context_requested'),
+ hybrid_search=hybrid_search_enabled,
+ )
+
+
+def _resolve_chat_mixed_source_manifest(
+ settings,
+ user_id,
+ conversation_id,
+ document_ids,
+ selection_mode,
+ active_group_ids=None,
+ active_public_workspace_ids=None,
+ cancel_requested=None,
+ request_correlation_id=None,
+):
+ """Resolve a fresh Phase 1 manifest for Phase 2 Chat evidence preparation."""
+ if not is_mixed_source_chat_search_enabled(settings):
+ return []
+ normalized_document_ids = _normalize_conversation_task_document_ids(document_ids)
+ if not normalized_document_ids:
+ return []
+ return resolve_authorized_source_manifest(
+ normalized_document_ids,
+ user_id=user_id,
+ selection_mode=selection_mode,
+ conversation_id=conversation_id,
+ active_group_ids=active_group_ids,
+ active_public_workspace_ids=active_public_workspace_ids,
+ cancel_requested=cancel_requested,
+ request_correlation_id=request_correlation_id,
+ )
+
+
+def _get_manifest_partition_document_ids(manifest_partitions, partition_name):
+ partitions = manifest_partitions if isinstance(manifest_partitions, dict) else {}
+ return [
+ str(source.get('document_id') or '').strip()
+ for source in partitions.get(partition_name) or []
+ if str(source.get('document_id') or '').strip()
+ ]
+
+
+def _resolve_chat_mixed_source_partition(
+ settings,
+ user_id,
+ conversation_id,
+ document_ids,
+ selection_mode,
+ active_group_ids=None,
+ active_public_workspace_ids=None,
+ cancel_requested=None,
+ request_correlation_id=None,
+):
+ """Resolve and partition one current authorization-safe source manifest."""
+ manifest = _resolve_chat_mixed_source_manifest(
+ settings,
+ user_id,
+ conversation_id,
+ document_ids,
+ selection_mode,
+ active_group_ids=active_group_ids,
+ active_public_workspace_ids=active_public_workspace_ids,
+ cancel_requested=cancel_requested,
+ request_correlation_id=request_correlation_id,
+ )
+ partitions = partition_source_manifest(manifest)
+ return {
+ 'manifest': manifest,
+ 'partitions': partitions,
+ 'narrative_document_ids': _get_manifest_partition_document_ids(
+ partitions,
+ 'narrative_sources',
+ ),
+ 'tabular_sources': list(partitions.get('tabular_sources') or []),
+ }
+
+
+def _build_mixed_source_continuity_refs(manifest, evidence_envelopes, selection_origin):
+ """Persist only compact source identity and terminal state for later reauthorization."""
+ evidence_by_document_id = {
+ str(envelope.get('document_id') or '').strip(): envelope
+ for envelope in list(evidence_envelopes or [])
+ if isinstance(envelope, dict) and str(envelope.get('document_id') or '').strip()
+ }
+ continuity_refs = []
+ for requested_order, source in enumerate(list(manifest or [])[:100]):
+ if not isinstance(source, dict) or source.get('authorization_status') != 'authorized':
+ continue
+ document_id = str(source.get('document_id') or '').strip()
+ scope = str(source.get('scope') or '').strip().lower()
+ scope_id = str(source.get('scope_id') or '').strip()
+ if not document_id or not scope or not scope_id:
+ continue
+ envelope = evidence_by_document_id.get(document_id) or {}
+ coverage = envelope.get('coverage') if isinstance(envelope.get('coverage'), dict) else {}
+ continuity_ref = {
+ 'document_id': document_id,
+ 'scope': scope,
+ 'scope_id': scope_id,
+ 'source_role': str(source.get('source_role') or 'selected'),
+ 'requested_order': requested_order,
+ 'source_kind': source.get('source_kind'),
+ 'engine': envelope.get('engine'),
+ 'source_version': source.get('source_version'),
+ 'status': envelope.get('status') or 'unavailable',
+ 'coverage': {
+ 'partial_coverage': bool(coverage.get('partial_coverage')),
+ 'evidence_envelope_truncated': bool(coverage.get('evidence_envelope_truncated')),
+ 'failed': str(envelope.get('status') or '').strip().lower() in {'failed', 'unavailable'},
+ },
+ 'selection_origin': selection_origin,
+ 'action_mode': 'chat',
+ 'citation_count': len(envelope.get('citations') or []),
+ 'artifact_count': len(envelope.get('generated_artifacts') or []),
+ }
+ if scope == 'group':
+ continuity_ref['group_id'] = scope_id
+ elif scope == 'public':
+ continuity_ref['public_workspace_id'] = scope_id
+ else:
+ continuity_ref['user_id'] = scope_id
+ continuity_refs.append(continuity_ref)
+ return continuity_refs
+
+
+def _reauthorize_document_action_finalization(
+ normalized_action,
+ execution_result,
+ user_id,
+ conversation_id,
+ cancel_requested=None,
+ request_correlation_id=None,
+ settings=None,
+):
+ """Reauthorize every action source and exact version before publishing output."""
+ raise_if_mixed_source_cancelled(
+ cancel_requested,
+ 'finalization',
+ request_correlation_id=request_correlation_id,
+ )
+ normalized_action = normalized_action if isinstance(normalized_action, dict) else {}
+ analysis_result = (
+ execution_result.get('analysis_result')
+ if isinstance(execution_result, dict)
+ and isinstance(execution_result.get('analysis_result'), dict)
+ else {}
+ )
+ execution_manifest = analysis_result.get('mixed_source_manifest')
+ requested_ids = [
+ str(document_id or '').strip()
+ for document_id in list(normalized_action.get('document_ids') or [])
+ if str(document_id or '').strip()
+ ]
+ if not requested_ids and isinstance(execution_manifest, list):
+ requested_ids = [
+ str(source.get('document_id') or '').strip()
+ for source in execution_manifest
+ if isinstance(source, dict) and str(source.get('document_id') or '').strip()
+ ]
+ if not requested_ids:
+ return
+
+ finalization_selection_mode = str(
+ normalized_action.get('target_mode') or 'selected'
+ ).strip().lower()
+ if finalization_selection_mode not in {'selected', 'all', 'history', 'relevance'}:
+ finalization_selection_mode = 'selected'
+ fresh_manifest = resolve_authorized_source_manifest(
+ requested_ids,
+ user_id=user_id,
+ selection_mode=finalization_selection_mode,
+ conversation_id=conversation_id,
+ active_group_ids=normalized_action.get('active_group_ids'),
+ active_public_workspace_ids=normalized_action.get('active_public_workspace_id'),
+ doc_scope=normalized_action.get('doc_scope', 'all'),
+ cancel_requested=cancel_requested,
+ request_correlation_id=request_correlation_id,
+ )
+ if isinstance(execution_manifest, list):
+ _validate_reauthorized_manifest_finalization(
+ execution_manifest,
+ fresh_manifest,
+ settings=settings,
+ mode=(
+ 'compare'
+ if normalized_action.get('type') == DOCUMENT_ACTION_TYPE_COMPARISON
+ else 'analyze'
+ ),
+ request_correlation_id=request_correlation_id,
+ )
+ elif len(fresh_manifest) != len(requested_ids) or any(
+ source.get('authorization_status') != 'authorized'
+ for source in fresh_manifest
+ ):
+ raise PermissionError('One or more selected sources are no longer available.')
+
+
+def _validate_reauthorized_manifest_finalization(
+ execution_manifest,
+ fresh_manifest,
+ settings=None,
+ mode='chat',
+ request_correlation_id=None,
+):
+ """Require every previously authorized source to retain identity and version."""
+ finalization_result = compare_reauthorized_source_manifests(
+ execution_manifest,
+ fresh_manifest,
+ )
+ authorization_failure_count = finalization_result['authorization_failure_count']
+ source_version_changed_count = finalization_result['source_version_changed_count']
+
+ if authorization_failure_count:
+ emit_mixed_source_telemetry(
+ settings,
+ 'authorization_failure',
+ mode,
+ request_correlation_id=request_correlation_id,
+ metrics={
+ 'authorization_failure_count': authorization_failure_count,
+ },
+ dimensions={'outcome_status': 'failed'},
+ )
+ raise MixedSourceFinalizationError('authorization_lost')
+ if source_version_changed_count:
+ log_event(
+ '[MixedSourceLifecycle] Finalization source version changed.',
+ extra={
+ 'request_correlation_id': request_correlation_id,
+ 'source_version_changed_count': source_version_changed_count,
+ },
+ level=logging.WARNING,
+ )
+ raise MixedSourceFinalizationError('source_version_changed')
+
+
+def _build_reauthorized_continuity_decision(prior_refs, manifest, explicit_selection):
+ """Describe a fresh manifest decision without treating persisted refs as authority."""
+ if explicit_selection:
+ return {
+ 'selection_origin': 'selected',
+ 'prior_source_count': 0,
+ 'reauthorized_source_count': 0,
+ 'unavailable_source_count': 0,
+ 'source_version_changed_count': 0,
+ 'requires_native_execution': False,
+ }
+
+ prior_by_document_id = {
+ str(ref.get('document_id') or '').strip(): ref
+ for ref in list(prior_refs or [])
+ if isinstance(ref, dict) and str(ref.get('document_id') or '').strip()
+ }
+ unavailable_source_count = 0
+ source_version_changed_count = 0
+ incomplete_prior_source_count = 0
+ reauthorized_source_count = 0
+ for source in list(manifest or []):
+ document_id = str((source or {}).get('document_id') or '').strip()
+ prior_ref = prior_by_document_id.get(document_id)
+ if not prior_ref:
+ continue
+ if source.get('authorization_status') != 'authorized':
+ unavailable_source_count += 1
+ continue
+ reauthorized_source_count += 1
+ prior_status = str(prior_ref.get('status') or '').strip().lower()
+ prior_coverage = (
+ prior_ref.get('coverage')
+ if isinstance(prior_ref.get('coverage'), dict)
+ else {}
+ )
+ if (
+ prior_status not in {'completed'}
+ or prior_coverage.get('partial_coverage')
+ or prior_coverage.get('failed')
+ or prior_coverage.get('evidence_envelope_truncated')
+ ):
+ incomplete_prior_source_count += 1
+ prior_version = prior_ref.get('source_version')
+ current_version = source.get('source_version')
+ if prior_version is not None and current_version is not None and str(prior_version) != str(current_version):
+ source_version_changed_count += 1
+
+ return {
+ 'selection_origin': 'history',
+ 'prior_source_count': len(prior_by_document_id),
+ 'reauthorized_source_count': reauthorized_source_count,
+ 'unavailable_source_count': unavailable_source_count,
+ 'source_version_changed_count': source_version_changed_count,
+ 'incomplete_prior_source_count': incomplete_prior_source_count,
+ 'requires_native_execution': bool(
+ unavailable_source_count
+ or source_version_changed_count
+ or incomplete_prior_source_count
+ ),
+ }
+
+
+def _resolve_reauthorized_continuity_decision(
+ settings,
+ user_id,
+ conversation_id,
+ prior_refs,
+ request_correlation_id=None,
+):
+ """Resolve persisted continuity hints through a fresh authorized manifest."""
+ if not is_mixed_source_conversation_continuity_enabled(settings):
+ return None
+ requested_document_ids = [
+ str(ref.get('document_id') or '').strip()
+ for ref in list(prior_refs or [])
+ if isinstance(ref, dict) and str(ref.get('document_id') or '').strip()
+ ]
+ if not requested_document_ids:
+ return None
+
+ search_parameters = build_prior_grounded_document_search_parameters(prior_refs)
+ search_parameters = revalidate_prior_grounded_document_search_parameters(
+ user_id,
+ search_parameters,
+ )
+ manifest = _resolve_chat_mixed_source_manifest(
+ settings,
+ user_id,
+ conversation_id,
+ requested_document_ids,
+ 'history',
+ active_group_ids=search_parameters.get('active_group_ids'),
+ active_public_workspace_ids=search_parameters.get('active_public_workspace_ids'),
+ request_correlation_id=request_correlation_id,
+ )
+ return _build_reauthorized_continuity_decision(
+ prior_refs,
+ manifest,
+ explicit_selection=False,
+ )
+
+
+def _can_reuse_prior_grounded_history(history_assessment, continuity_decision):
+ """Return whether existing history is complete enough to avoid fresh native evidence."""
+ if (
+ isinstance(continuity_decision, dict)
+ and continuity_decision.get('requires_native_execution')
+ ):
+ return False
+ return bool(
+ isinstance(history_assessment, dict)
+ and history_assessment.get('can_answer_from_history')
+ )
+
+
+def _resolve_chat_mixed_source_relevance_context(
+ *,
+ settings,
+ user_id,
+ conversation_id,
+ query,
+ search_results,
+ document_scope,
+ candidate_document_ids=None,
+ tags_filter=None,
+ active_group_ids=None,
+ active_public_workspace_ids=None,
+ cancel_requested=None,
+ request_correlation_id=None,
+):
+ """Add bounded schema candidates and reauthorize all relevance-derived sources."""
+ if not is_mixed_source_chat_search_enabled(settings):
+ return {
+ 'manifest': [],
+ 'partitions': {},
+ 'narrative_document_ids': [],
+ 'tabular_sources': [],
+ 'search_results': list(search_results or []),
+ 'tabular_candidate_count': 0,
+ }
+
+ relevance_candidates_enabled = is_mixed_source_relevance_candidates_enabled(settings)
+ candidate_result = {
+ 'document_ids': [],
+ 'candidate_count': 0,
+ }
+ if relevance_candidates_enabled:
+ candidate_result = search_relevant_tabular_candidates(
+ query=query,
+ user_id=user_id,
+ doc_scope=document_scope,
+ document_ids=candidate_document_ids,
+ tags_filter=tags_filter,
+ active_group_ids=active_group_ids,
+ active_public_workspace_id=active_public_workspace_ids,
+ )
+ relevance_document_ids = []
+ seen_document_ids = set()
+ for result in list(search_results or []):
+ document_id = str((result or {}).get('document_id') or '').strip()
+ if document_id and document_id not in seen_document_ids:
+ seen_document_ids.add(document_id)
+ relevance_document_ids.append(document_id)
+ if len(relevance_document_ids) >= (
+ MIXED_SOURCE_CHAT_RELEVANCE_SOURCE_LIMIT - 6
+ ):
+ break
+ for document_id in candidate_result.get('document_ids') or []:
+ normalized_document_id = str(document_id or '').strip()
+ if normalized_document_id and normalized_document_id not in seen_document_ids:
+ seen_document_ids.add(normalized_document_id)
+ relevance_document_ids.append(normalized_document_id)
+ if len(relevance_document_ids) >= MIXED_SOURCE_CHAT_RELEVANCE_SOURCE_LIMIT:
+ break
+
+ resolved_context = _resolve_chat_mixed_source_partition(
+ settings,
+ user_id,
+ conversation_id,
+ relevance_document_ids,
+ 'relevance',
+ active_group_ids=active_group_ids,
+ active_public_workspace_ids=active_public_workspace_ids,
+ cancel_requested=cancel_requested,
+ request_correlation_id=request_correlation_id,
+ )
+ narrative_document_id_set = set(
+ resolved_context.get('narrative_document_ids') or []
+ )
+ resolved_context['search_results'] = [
+ result
+ for result in list(search_results or [])
+ if str((result or {}).get('document_id') or '').strip()
+ in narrative_document_id_set
+ ]
+ resolved_context['tabular_candidate_count'] = int(
+ candidate_result.get('candidate_count') or 0
+ )
+ return resolved_context
+
+
def _source_review_metadata_used(source_review_result):
if not isinstance(source_review_result, dict):
return False
@@ -1211,6 +1849,124 @@ def _strip_agent_citation_artifact_refs(agent_citations):
return compact_citations
+def _rollback_agent_citation_artifacts(conversation_id, compact_citations):
+ """Remove current-message citation artifacts and chunks after aborted publication."""
+ normalized_conversation_id = str(conversation_id or '').strip()
+ artifact_ids = []
+ for citation in list(compact_citations or []):
+ if not isinstance(citation, dict):
+ continue
+ artifact_id = str(citation.get('artifact_id') or '').strip()
+ if artifact_id and artifact_id not in artifact_ids:
+ artifact_ids.append(artifact_id)
+
+ deleted_artifact_count = 0
+ rollback_failure_count = 0
+ for artifact_id in artifact_ids:
+ try:
+ chunk_documents = list(cosmos_messages_container.query_items(
+ query='SELECT c.id FROM c WHERE c.parent_message_id = @parent_message_id',
+ parameters=[{'name': '@parent_message_id', 'value': artifact_id}],
+ partition_key=normalized_conversation_id,
+ ))
+ for chunk_document in chunk_documents:
+ chunk_id = str((chunk_document or {}).get('id') or '').strip()
+ if chunk_id:
+ cosmos_messages_container.delete_item(
+ item=chunk_id,
+ partition_key=normalized_conversation_id,
+ )
+ cosmos_messages_container.delete_item(
+ item=artifact_id,
+ partition_key=normalized_conversation_id,
+ )
+ deleted_artifact_count += 1
+ except Exception:
+ rollback_failure_count += 1
+
+ if artifact_ids:
+ log_event(
+ '[MixedSourceLifecycle] Citation artifact rollback completed.',
+ extra={
+ 'citation_artifact_count': len(artifact_ids),
+ 'deleted_artifact_count': deleted_artifact_count,
+ 'rollback_failure_count': rollback_failure_count,
+ },
+ level=logging.INFO if not rollback_failure_count else logging.WARNING,
+ )
+ return {
+ 'deleted_artifact_count': deleted_artifact_count,
+ 'rollback_failure_count': rollback_failure_count,
+ }
+
+
+def _rollback_mixed_source_chat_publication(
+ user_id,
+ conversation_id,
+ generated_outputs=None,
+ compact_citations=None,
+):
+ """Cancel queued exports and remove artifacts created before mixed publication stopped."""
+ normalized_user_id = str(user_id or '').strip()
+ normalized_conversation_id = str(conversation_id or '').strip()
+ export_run_ids = []
+ artifact_message_ids = []
+ for output in list(generated_outputs or []):
+ if not isinstance(output, dict):
+ continue
+ export_run_id = str(output.get('export_run_id') or '').strip()
+ artifact_message_id = str(output.get('artifact_message_id') or '').strip()
+ if export_run_id and export_run_id not in export_run_ids:
+ export_run_ids.append(export_run_id)
+ if artifact_message_id and artifact_message_id not in artifact_message_ids:
+ artifact_message_ids.append(artifact_message_id)
+
+ canceled_export_count = 0
+ deleted_generated_artifact_count = 0
+ rollback_failure_count = 0
+ for export_run_id in export_run_ids:
+ try:
+ cancel_result = cancel_tabular_generated_output_run(
+ normalized_user_id,
+ export_run_id,
+ )
+ if isinstance(cancel_result, dict) and cancel_result.get('canceled'):
+ canceled_export_count += 1
+ except Exception:
+ rollback_failure_count += 1
+ for artifact_message_id in artifact_message_ids:
+ try:
+ if delete_generated_chat_artifact_for_current_user(
+ normalized_conversation_id,
+ artifact_message_id,
+ ):
+ deleted_generated_artifact_count += 1
+ except Exception:
+ rollback_failure_count += 1
+
+ citation_rollback = _rollback_agent_citation_artifacts(
+ normalized_conversation_id,
+ compact_citations,
+ )
+ rollback_failure_count += citation_rollback.get('rollback_failure_count', 0)
+ log_event(
+ '[MixedSourceLifecycle] Chat publication rollback completed.',
+ extra={
+ 'canceled_export_count': canceled_export_count,
+ 'deleted_generated_artifact_count': deleted_generated_artifact_count,
+ 'deleted_citation_artifact_count': citation_rollback.get('deleted_artifact_count', 0),
+ 'rollback_failure_count': rollback_failure_count,
+ },
+ level=logging.INFO if not rollback_failure_count else logging.WARNING,
+ )
+ return {
+ 'canceled_export_count': canceled_export_count,
+ 'deleted_generated_artifact_count': deleted_generated_artifact_count,
+ 'deleted_citation_artifact_count': citation_rollback.get('deleted_artifact_count', 0),
+ 'rollback_failure_count': rollback_failure_count,
+ }
+
+
FACT_MEMORY_TYPE_FACT = 'fact'
FACT_MEMORY_TYPE_INSTRUCTION = 'instruction'
FACT_MEMORY_TYPE_LEGACY_DESCRIBER = 'describer'
@@ -1328,7 +2084,13 @@ def _normalize_generated_analysis_artifact_metadata(raw_artifact, default_capabi
artifact_message_id = str(raw_artifact.get('artifact_message_id') or '').strip()
document_id = str(raw_artifact.get('document_id') or '').strip()
export_run_id = str(raw_artifact.get('export_run_id') or raw_artifact.get('run_id') or '').strip()
- if not artifact_message_id and not document_id and not export_run_id:
+ terminal_status = str(raw_artifact.get('status') or '').strip().lower()
+ suppress_assistant_table_export = bool(raw_artifact.get('suppress_assistant_table_export'))
+ is_terminal_export_status = (
+ terminal_status in {'failed', 'canceled'}
+ and suppress_assistant_table_export
+ )
+ if not artifact_message_id and not document_id and not export_run_id and not is_terminal_export_status:
return None
normalized_artifact = dict(raw_artifact)
@@ -1343,6 +2105,9 @@ def _normalize_generated_analysis_artifact_metadata(raw_artifact, default_capabi
if export_run_id:
normalized_artifact['export_run_id'] = export_run_id
normalized_artifact['background_export'] = bool(raw_artifact.get('background_export', True))
+ elif is_terminal_export_status:
+ normalized_artifact['background_export'] = True
+ normalized_artifact['suppress_assistant_table_export'] = True
normalized_output_format = str(raw_artifact.get('output_format') or '').strip().lower()
if normalized_output_format:
@@ -1457,84 +2222,173 @@ def _maybe_create_deep_research_ledger_artifact(settings, conversation_id, ledge
def _has_generated_tabular_csv_output(generated_outputs):
- for generated_output in generated_outputs or []:
- if not isinstance(generated_output, dict):
- continue
-
- capability = str(generated_output.get('capability') or '').strip().lower()
- output_format = str(generated_output.get('output_format') or '').strip().lower()
- file_name = str(generated_output.get('file_name') or '').strip().lower()
- if output_format == 'csv' or file_name.endswith('.csv'):
- if not capability or capability == 'tabular':
- return True
-
- return False
+ return has_generated_tabular_csv_output(generated_outputs)
-def maybe_create_assistant_table_generated_output(
+def maybe_create_generated_file_output(
user_question,
assistant_content,
conversation_id,
+ function_results=None,
existing_outputs=None,
+ cancel_requested=None,
+ request_correlation_id=None,
):
- """Save a CSV artifact when a table-request answer contains a parseable table."""
- if _has_generated_tabular_csv_output(existing_outputs):
+ """Save a requested CSV, DOCX, or PDF artifact from response and action evidence."""
+ raise_if_mixed_source_cancelled(
+ cancel_requested,
+ 'artifact_publication',
+ request_correlation_id=request_correlation_id,
+ )
+ output_format = get_requested_generated_file_format(user_question)
+ if not output_format:
+ return None
+ if output_format == 'csv' and _has_generated_tabular_csv_output(existing_outputs):
+ return None
+ if has_generated_file_output(existing_outputs, output_format):
return None
- export_payload = build_assistant_table_csv_export(user_question, assistant_content)
+ export_payload = build_generated_file_export(
+ user_question,
+ assistant_content,
+ function_results=function_results,
+ )
if not export_payload:
return None
- generated_file_name = export_payload.get('file_name')
+ generated_file_name = str(export_payload.get('file_name') or '').strip()
+ if not generated_file_name:
+ return None
row_count = _safe_int(export_payload.get('row_count'))
+ settings = get_settings()
+ structured_rows = export_payload.get('_structured_rows') or []
+ row_batches = []
+ if output_format == 'csv':
+ row_batches = _build_tabular_generated_output_row_batches(
+ structured_rows,
+ settings=settings,
+ )
+ if output_format == 'csv' and should_queue_tabular_generated_output_background(
+ row_count,
+ len(row_batches),
+ settings,
+ ):
+ try:
+ background_run = queue_tabular_generated_output_run(
+ user_id=get_current_user_id(),
+ conversation_id=conversation_id,
+ user_question=user_question,
+ source_candidate={
+ 'filename': generated_file_name,
+ 'selected_sheet': '',
+ 'source_authorization': {
+ 'source': 'chat',
+ },
+ },
+ output_format=output_format,
+ row_batches=row_batches,
+ gpt_model='',
+ settings=settings,
+ passthrough_input_rows=True,
+ )
+ background_metadata = build_background_tabular_generated_output_metadata(background_run)
+ try:
+ raise_if_mixed_source_cancelled(
+ cancel_requested,
+ 'export',
+ request_correlation_id=request_correlation_id,
+ )
+ except MixedSourceCancellationError:
+ cancel_tabular_generated_output_run(
+ get_current_user_id(),
+ background_metadata.get('export_run_id'),
+ )
+ raise
+ return background_metadata
+ except MixedSourceCancellationError:
+ raise
+ except Exception as exc:
+ log_event(
+ '[Generated File Export] Failed to queue large CSV export',
+ {
+ 'conversation_id': conversation_id,
+ 'generated_file_name': generated_file_name,
+ 'row_count': row_count,
+ 'output_format': output_format,
+ 'error': str(exc),
+ },
+ level=logging.ERROR,
+ exceptionTraceback=True,
+ )
+ return None
+
try:
+ raise_if_mixed_source_cancelled(
+ cancel_requested,
+ 'artifact_publication',
+ request_correlation_id=request_correlation_id,
+ )
upload_result = upload_generated_analysis_artifact_for_current_user(
conversation_id=conversation_id,
file_name=generated_file_name,
file_content=export_payload.get('file_content'),
- capability='tabular',
- output_format='csv',
+ capability=export_payload.get('capability') or 'file_export',
+ output_format=output_format,
summary=export_payload.get('summary'),
)
+ try:
+ raise_if_mixed_source_cancelled(
+ cancel_requested,
+ 'artifact_publication',
+ request_correlation_id=request_correlation_id,
+ )
+ except MixedSourceCancellationError:
+ delete_generated_chat_artifact_for_current_user(
+ conversation_id,
+ (upload_result.get('message') or {}).get('id'),
+ )
+ raise
+ except MixedSourceCancellationError:
+ raise
except Exception as exc:
log_event(
- '[Assistant Table Export] Failed to save assistant table CSV artifact',
+ '[Generated File Export] Failed to save generated file artifact',
{
'conversation_id': conversation_id,
'generated_file_name': generated_file_name,
'row_count': row_count,
+ 'output_format': output_format,
'error': str(exc),
},
debug_only=True,
)
return None
- artifact_message_id = upload_result.get('message', {}).get('id')
- if not artifact_message_id:
+ artifact_metadata = build_generated_file_artifact_metadata(
+ export_payload,
+ upload_result,
+ conversation_id,
+ )
+ if not artifact_metadata:
return None
- uploaded_file_name = upload_result.get('message', {}).get('file_name') or generated_file_name
log_event(
- '[Assistant Table Export] Saved assistant table CSV artifact',
+ '[Generated File Export] Saved generated file artifact',
{
'conversation_id': conversation_id,
- 'artifact_message_id': artifact_message_id,
- 'generated_file_name': uploaded_file_name,
+ 'artifact_message_id': artifact_metadata.get('artifact_message_id'),
+ 'generated_file_name': artifact_metadata.get('file_name'),
'row_count': row_count,
+ 'output_format': output_format,
},
debug_only=True,
)
- return {
- 'capability': 'tabular',
- 'artifact_message_id': artifact_message_id,
- 'conversation_id': conversation_id,
- 'storage_scope': 'chat',
- 'file_name': uploaded_file_name,
- 'output_format': 'csv',
- 'row_count': row_count,
- 'preview_rows': export_payload.get('preview_rows') or [],
- 'summary': export_payload.get('summary'),
- }
+ return artifact_metadata
+
+
+def maybe_create_assistant_table_generated_output(*args, **kwargs):
+ """Backward-compatible wrapper for the generic generated-file finalizer."""
+ return maybe_create_generated_file_output(*args, **kwargs)
def _has_generated_file_output(existing_outputs, output_format):
@@ -1593,7 +2447,7 @@ def _build_assistant_file_preview_lines(file_content, max_lines=5, max_line_leng
if not normalized_line:
continue
if len(normalized_line) > max_line_length:
- normalized_line = f'{normalized_line[:max_line_length - 1]}…'
+ normalized_line = f'{normalized_line[:max_line_length - 3]}...'
preview_lines.append(normalized_line)
if len(preview_lines) >= max_lines:
break
@@ -2860,6 +3714,8 @@ def persist_agent_citation_artifacts(
agent_citations,
created_timestamp,
user_info=None,
+ cancel_requested=None,
+ request_correlation_id=None,
):
"""Persist raw agent citation payloads outside the primary assistant message doc."""
if not agent_citations:
@@ -2873,10 +3729,43 @@ def persist_agent_citation_artifacts(
user_info=user_info,
)
+ persisted_artifact_ids = []
try:
+ raise_if_mixed_source_cancelled(
+ cancel_requested,
+ 'artifact_publication',
+ request_correlation_id=request_correlation_id,
+ )
for artifact_doc in artifact_docs:
+ raise_if_mixed_source_cancelled(
+ cancel_requested,
+ 'artifact_publication',
+ request_correlation_id=request_correlation_id,
+ )
cosmos_messages_container.upsert_item(artifact_doc)
+ artifact_id = str(artifact_doc.get('id') or '').strip()
+ if artifact_id:
+ persisted_artifact_ids.append(artifact_id)
+ raise_if_mixed_source_cancelled(
+ cancel_requested,
+ 'artifact_publication',
+ request_correlation_id=request_correlation_id,
+ )
return compact_citations
+ except MixedSourceCancellationError:
+ for artifact_id in reversed(persisted_artifact_ids):
+ try:
+ cosmos_messages_container.delete_item(
+ item=artifact_id,
+ partition_key=conversation_id,
+ )
+ except Exception:
+ log_event(
+ '[Agent Citations] Citation rollback after cancellation failed.',
+ extra={'citation_artifact_rollback_failure_count': 1},
+ level=logging.WARNING,
+ )
+ raise
except Exception as exc:
log_event(
f"[Agent Citations] Failed to persist assistant artifacts: {exc}",
@@ -3763,7 +4652,7 @@ def _resolve_tabular_related_document_evidence(document_match, user_question, us
search_payload = search_documents(
query=search_query,
user_id=user_id,
- top_n=2,
+ top_n=10,
doc_scope=doc_scope,
document_ids=[document_id],
active_group_ids=active_group_ids,
@@ -3954,10 +4843,15 @@ def augment_tabular_invocations_with_related_document_evidence(invocations, user
updated_result_payload['data'] = updated_rows
updated_result_payload['referenced_document_row_count'] = augmented_rows_for_invocation
updated_result_payload['referenced_document_match_count'] = augmented_documents_for_invocation
- if isinstance(getattr(invocation, 'result', None), dict):
+ existing_result = getattr(invocation, 'result', None)
+ internal_metadata = getattr(existing_result, 'internal_metadata', {}) or {}
+ if isinstance(existing_result, dict):
invocation.result = updated_result_payload
else:
- invocation.result = json.dumps(updated_result_payload, indent=2, default=str, ensure_ascii=False)
+ invocation.result = PluginInvocationResult(
+ json.dumps(updated_result_payload, indent=2, default=str, ensure_ascii=False),
+ internal_metadata=internal_metadata,
+ )
return {
'augmented_row_count': augmented_row_count,
@@ -4154,7 +5048,7 @@ def question_requests_tabular_generated_output(user_question):
'each object',
'each row',
)
- if requested_format == 'csv' and any(marker in normalized_question for marker in TABLE_EXPORT_REQUEST_MARKERS):
+ if requested_format == 'csv' and assistant_table_export_requested(user_question):
return True
return any(marker in normalized_question for marker in exhaustive_markers)
@@ -4223,13 +5117,13 @@ def _serialize_tabular_generated_output_value(value):
if value is None:
return ''
if isinstance(value, (dict, list)):
- return json.dumps(value, default=str, ensure_ascii=False)
+ return neutralize_csv_spreadsheet_formula(json.dumps(value, default=str, ensure_ascii=False))
if hasattr(value, 'isoformat') and not isinstance(value, str):
try:
- return value.isoformat()
+ return neutralize_csv_spreadsheet_formula(value.isoformat())
except TypeError:
pass
- return str(value)
+ return neutralize_csv_spreadsheet_formula(value)
def _build_tabular_generated_output_csv(entries):
@@ -4248,14 +5142,15 @@ def _build_tabular_generated_output_csv(entries):
if not ordered_columns:
ordered_columns = ['value']
+ safe_ordered_columns = build_safe_csv_headers(ordered_columns)
output_buffer = io.StringIO()
- writer = csv.DictWriter(output_buffer, fieldnames=ordered_columns)
+ writer = csv.DictWriter(output_buffer, fieldnames=safe_ordered_columns)
writer.writeheader()
for entry in entries or []:
serialized_row = {}
if isinstance(entry, dict):
- for field_name in ordered_columns:
- serialized_row[field_name] = _serialize_tabular_generated_output_value(entry.get(field_name))
+ for field_name, safe_field_name in zip(ordered_columns, safe_ordered_columns):
+ serialized_row[safe_field_name] = _serialize_tabular_generated_output_value(entry.get(field_name))
writer.writerow(serialized_row)
return output_buffer.getvalue()
@@ -4557,36 +5452,13 @@ def _build_tabular_generated_output_input_row(row, source_file_name=None):
def _build_tabular_generated_output_file_name(source_file_name, output_format):
timestamp_suffix = datetime.utcnow().strftime('%Y%m%d_%H%M%S')
- normalized_base_name = _sanitize_tabular_generated_output_base_name(source_file_name)
- normalized_extension = normalize_generated_output_format(output_format)
- return f"{normalized_base_name}_generated_{timestamp_suffix}.{normalized_extension}"
-
-
-def _build_tabular_generated_output_row_batches(rows, settings=None):
- budget = _get_tabular_generated_output_batch_budget(settings)
- max_batch_rows = budget['max_rows']
- max_batch_chars = budget['max_chars']
- batches = []
- current_batch = []
- current_batch_chars = 0
-
- for row in rows or []:
- row_text = _dump_tabular_generated_output_json(row)
- if current_batch and (
- len(current_batch) >= max_batch_rows
- or current_batch_chars + len(row_text) > max_batch_chars
- ):
- batches.append(current_batch)
- current_batch = []
- current_batch_chars = 0
-
- current_batch.append(row)
- current_batch_chars += len(row_text)
+ normalized_base_name = _sanitize_tabular_generated_output_base_name(source_file_name)
+ normalized_extension = normalize_generated_output_format(output_format)
+ return f"{normalized_base_name}_generated_{timestamp_suffix}.{normalized_extension}"
- if current_batch:
- batches.append(current_batch)
- return batches
+def _build_tabular_generated_output_row_batches(rows, settings=None):
+ return build_tabular_generated_output_row_batches(rows, settings=settings)
def _build_tabular_generated_output_candidate_diagnostic(invocation):
@@ -4629,6 +5501,9 @@ def _build_tabular_generated_output_candidate_diagnostic(invocation):
'full_result_available': full_result_available,
'function_rank': function_rank,
'max_rows': result_payload.get('max_rows') if isinstance(result_payload, dict) else None,
+ 'start_row': _safe_int(result_payload.get('start_row')) if isinstance(result_payload, dict) else 0,
+ 'has_more': bool(result_payload.get('has_more')) if isinstance(result_payload, dict) else False,
+ 'next_start_row': result_payload.get('next_start_row') if isinstance(result_payload, dict) else None,
'filter_applied': result_payload.get('filter_applied') if isinstance(result_payload, dict) else None,
'normalized_match': result_payload.get('normalized_match') if isinstance(result_payload, dict) else None,
'skip_reason': skip_reason,
@@ -4643,50 +5518,327 @@ def _build_tabular_generated_output_candidate_diagnostics(invocations):
]
+def _build_tabular_generated_output_source_signature(invocation, result_payload):
+ invocation_parameters = getattr(invocation, 'parameters', {}) or {}
+ invocation_internal_metadata = getattr(
+ getattr(invocation, 'result', None),
+ 'internal_metadata',
+ {},
+ ) or {}
+ source_descriptor = invocation_internal_metadata.get('tabular_generated_export_source') or {}
+ source_authorization = invocation_internal_metadata.get('tabular_source_authorization') or {}
+ source_identity = {
+ 'source': source_descriptor.get('source') or source_authorization.get('source'),
+ 'scope_id': source_descriptor.get('scope_id') or source_authorization.get('scope_id'),
+ 'container': source_descriptor.get('container') or source_authorization.get('container'),
+ 'blob_path': source_descriptor.get('blob_path') or source_authorization.get('blob_path'),
+ 'blob_etag': source_descriptor.get('blob_etag') or source_authorization.get('blob_etag'),
+ }
+ signature_parameters = {
+ str(parameter_name): parameter_value
+ for parameter_name, parameter_value in invocation_parameters.items()
+ if str(parameter_name) not in {
+ 'user_id',
+ 'conversation_id',
+ 'start_row',
+ 'max_rows',
+ }
+ }
+ signature_payload = {
+ 'plugin_name': str(getattr(invocation, 'plugin_name', '') or '').strip(),
+ 'function_name': str(getattr(invocation, 'function_name', '') or '').strip(),
+ 'filename': result_payload.get('filename'),
+ 'selected_sheet': result_payload.get('selected_sheet'),
+ 'parameters': signature_parameters,
+ 'source_identity': source_identity,
+ }
+ return json.dumps(signature_payload, sort_keys=True, default=str, separators=(',', ':'))
+
+
+def _coalesce_tabular_generated_output_pages(pages, total_matches):
+ normalized_total_matches = _safe_int(total_matches)
+ ordered_pages = sorted(pages or [], key=lambda page: page.get('start_row', 0))
+ merged_rows = []
+ next_expected_row = 0
+
+ for page in ordered_pages:
+ start_row = _safe_int(page.get('start_row'))
+ page_rows = page.get('rows') if isinstance(page.get('rows'), list) else []
+ returned_rows = _safe_int(page.get('returned_rows'))
+ if returned_rows != len(page_rows):
+ return {
+ 'rows': merged_rows,
+ 'row_count': len(merged_rows),
+ 'full_result_available': False,
+ 'validation_error': (
+ f'Page at row {start_row} declared {returned_rows} row(s) but contained {len(page_rows)}'
+ ),
+ }
+ if start_row < next_expected_row:
+ return {
+ 'rows': merged_rows,
+ 'row_count': len(merged_rows),
+ 'full_result_available': False,
+ 'validation_error': (
+ f'Page overlap at row {start_row}; next expected row was {next_expected_row}'
+ ),
+ }
+ if start_row > next_expected_row:
+ return {
+ 'rows': merged_rows,
+ 'row_count': len(merged_rows),
+ 'full_result_available': False,
+ 'validation_error': (
+ f'Page gap from row {next_expected_row} through {start_row - 1}'
+ ),
+ }
+
+ merged_rows.extend(page_rows)
+ next_expected_row += len(page_rows)
+
+ full_result_available = normalized_total_matches > 0 and next_expected_row == normalized_total_matches
+ validation_error = None
+ if next_expected_row < normalized_total_matches:
+ validation_error = (
+ f'Page gap from row {next_expected_row} through {normalized_total_matches - 1}'
+ )
+ elif normalized_total_matches and next_expected_row > normalized_total_matches:
+ validation_error = (
+ f'Page coverage returned {next_expected_row} row(s) for {normalized_total_matches} total match(es)'
+ )
+
+ return {
+ 'rows': merged_rows,
+ 'row_count': len(merged_rows),
+ 'full_result_available': full_result_available,
+ 'validation_error': validation_error,
+ }
+
+
def _build_tabular_generated_output_source_candidate(invocations):
- best_candidate = None
- best_score = None
+ candidate_groups = {}
+ logical_source_identities = {}
for invocation in invocations or []:
diagnostic = _build_tabular_generated_output_candidate_diagnostic(invocation)
if diagnostic.get('skip_reason'):
continue
+ result_payload = get_tabular_invocation_result_payload(invocation)
+ invocation_internal_metadata = getattr(
+ getattr(invocation, 'result', None),
+ 'internal_metadata',
+ {},
+ ) or {}
+ source_signature = _build_tabular_generated_output_source_signature(invocation, result_payload)
+ logical_signature = json.dumps({
+ 'plugin_name': str(getattr(invocation, 'plugin_name', '') or '').strip(),
+ 'function_name': str(getattr(invocation, 'function_name', '') or '').strip(),
+ 'filename': result_payload.get('filename'),
+ 'selected_sheet': result_payload.get('selected_sheet'),
+ 'parameters': {
+ str(parameter_name): parameter_value
+ for parameter_name, parameter_value in (getattr(invocation, 'parameters', {}) or {}).items()
+ if str(parameter_name) not in {
+ 'user_id',
+ 'conversation_id',
+ 'start_row',
+ 'max_rows',
+ }
+ },
+ }, sort_keys=True, default=str, separators=(',', ':'))
+ logical_source_identities.setdefault(logical_signature, set()).add(source_signature)
+ candidate_group = candidate_groups.setdefault(source_signature, {
+ 'function_name': diagnostic.get('function_name'),
+ 'filename': result_payload.get('filename'),
+ 'selected_sheet': result_payload.get('selected_sheet'),
+ 'source_parameters': dict(getattr(invocation, 'parameters', {}) or {}),
+ 'source_descriptor': invocation_internal_metadata.get('tabular_generated_export_source'),
+ 'source_authorization': invocation_internal_metadata.get('tabular_source_authorization'),
+ 'function_rank': diagnostic.get('function_rank') or 0,
+ 'total_matches': diagnostic.get('total_matches') or 0,
+ 'pages': [],
+ 'diagnostics': [],
+ 'logical_signature': logical_signature,
+ })
+ candidate_group['diagnostics'].append(diagnostic)
+ candidate_group['pages'].append({
+ 'start_row': diagnostic.get('start_row') or 0,
+ 'returned_rows': diagnostic.get('returned_rows') or diagnostic.get('data_row_count') or 0,
+ 'rows': result_payload.get('data'),
+ })
+ if diagnostic.get('total_matches') != candidate_group['total_matches']:
+ candidate_group['total_count_mismatch'] = True
+
+ best_candidate = None
+ best_score = None
+ for candidate_group in candidate_groups.values():
+ coalesced_result = _coalesce_tabular_generated_output_pages(
+ candidate_group.get('pages'),
+ candidate_group.get('total_matches'),
+ )
+ if candidate_group.get('total_count_mismatch'):
+ coalesced_result['full_result_available'] = False
+ coalesced_result['validation_error'] = 'Compatible pages reported inconsistent total row counts'
+ if len(logical_source_identities.get(candidate_group.get('logical_signature'), set())) > 1:
+ coalesced_result['full_result_available'] = False
+ coalesced_result['validation_error'] = (
+ 'Compatible pages resolved to different source blobs or versions'
+ )
+
score = (
- 1 if diagnostic.get('full_result_available') else 0,
- diagnostic.get('returned_rows') or diagnostic.get('data_row_count') or 0,
- diagnostic.get('function_rank') or 0,
+ 1 if coalesced_result.get('full_result_available') else 0,
+ coalesced_result.get('row_count') or 0,
+ candidate_group.get('function_rank') or 0,
)
if best_score is not None and score <= best_score:
continue
- result_payload = get_tabular_invocation_result_payload(invocation)
best_candidate = {
- 'function_name': diagnostic.get('function_name'),
- 'filename': result_payload.get('filename'),
- 'selected_sheet': result_payload.get('selected_sheet'),
- 'rows': result_payload.get('data'),
- 'row_count': diagnostic.get('returned_rows') or diagnostic.get('data_row_count'),
- 'total_matches': diagnostic.get('total_matches'),
- 'full_result_available': diagnostic.get('full_result_available'),
- 'diagnostics': diagnostic,
+ 'function_name': candidate_group.get('function_name'),
+ 'filename': candidate_group.get('filename'),
+ 'selected_sheet': candidate_group.get('selected_sheet'),
+ 'source_parameters': candidate_group.get('source_parameters'),
+ 'source_descriptor': candidate_group.get('source_descriptor'),
+ 'source_authorization': candidate_group.get('source_authorization'),
+ 'rows': coalesced_result.get('rows'),
+ 'row_count': coalesced_result.get('row_count'),
+ 'total_matches': candidate_group.get('total_matches'),
+ 'full_result_available': coalesced_result.get('full_result_available'),
+ 'validation_error': coalesced_result.get('validation_error'),
+ 'page_count': len(candidate_group.get('pages') or []),
+ 'diagnostics': candidate_group.get('diagnostics'),
}
best_score = score
return best_candidate
-def _build_tabular_generated_output_batch_prompt(user_question, batch_rows, batch_index, total_batches, source_candidate):
+def _build_tabular_generated_output_query_descriptor(
+ source_candidate,
+ user_id,
+ conversation_id,
+ settings,
+):
+ if not isinstance(source_candidate, dict):
+ return None
+ if source_candidate.get('function_name') != 'query_tabular_data':
+ return None
+
+ source_parameters = source_candidate.get('source_parameters') or {}
+ query_expression = str(source_parameters.get('query_expression') or '').strip()
+ source_descriptor = source_candidate.get('source_descriptor') or {}
+ if (
+ not query_expression
+ or source_descriptor.get('kind') != 'query_tabular_data'
+ or str(source_descriptor.get('filename') or '') != str(source_candidate.get('filename') or '')
+ or str(source_descriptor.get('query_expression') or '') != query_expression
+ ):
+ return None
+ validate_tabular_csv_query_expression(query_expression)
+
+ batch_budget = _get_tabular_generated_output_batch_budget(settings)
+ descriptor = dict(source_descriptor)
+ descriptor['expected_row_count'] = _safe_int(source_candidate.get('total_matches'))
+ descriptor['batch_max_rows'] = batch_budget['max_rows']
+ descriptor['batch_max_chars'] = batch_budget['max_chars']
+ return descriptor
+
+
+def _build_tabular_generated_output_source_authorization(source_candidate):
+ exact_source_authorization = (source_candidate or {}).get('source_authorization') or {}
+ if (
+ exact_source_authorization.get('source')
+ and exact_source_authorization.get('container')
+ and exact_source_authorization.get('blob_path')
+ ):
+ return dict(exact_source_authorization)
+
+ source_parameters = (source_candidate or {}).get('source_parameters') or {}
+ source = str(source_parameters.get('source') or 'chat').strip().lower()
+ if source not in {'chat', 'workspace', 'group', 'public'}:
+ source = 'chat'
+
+ authorized_context = dict(getattr(g, 'authorized_chat_context', {}) or {})
+ scope_id = None
+ if source == 'group':
+ scope_id = str(
+ source_parameters.get('group_id')
+ or authorized_context.get('active_group_id')
+ or ''
+ ).strip() or None
+ elif source == 'public':
+ scope_id = str(
+ source_parameters.get('public_workspace_id')
+ or authorized_context.get('active_public_workspace_id')
+ or ''
+ ).strip() or None
+ return {
+ 'source': source,
+ 'scope_id': scope_id,
+ }
+
+
+def _build_failed_tabular_generated_output_metadata(source_candidate, output_format, reason):
+ normalized_output_format = str(output_format or 'json').strip().lower() or 'json'
+ row_count = _safe_int((source_candidate or {}).get('total_matches'))
+ failure_reason = str(reason or 'The exhaustive export could not be prepared.').strip()
+ return {
+ 'capability': 'tabular',
+ 'background_export': True,
+ 'status': 'failed',
+ 'status_label': 'Failed',
+ 'status_tone': 'danger',
+ 'status_detail': failure_reason,
+ 'retryable_failure': False,
+ 'can_resume': False,
+ 'can_cancel': False,
+ 'suppress_assistant_table_export': True,
+ 'file_name': _build_tabular_generated_output_file_name(
+ (source_candidate or {}).get('filename'),
+ normalized_output_format,
+ ),
+ 'output_format': normalized_output_format,
+ 'row_count': row_count,
+ 'processed_rows': 0,
+ 'source_file_name': (source_candidate or {}).get('filename'),
+ 'selected_sheet': (source_candidate or {}).get('selected_sheet'),
+ 'summary': failure_reason,
+ }
+
+
+def _build_tabular_generated_output_batch_prompt(
+ user_question,
+ batch_rows,
+ batch_index,
+ total_batches,
+ source_candidate,
+ output_schema=None,
+):
source_file_name = str(source_candidate.get('filename') or 'unknown file').strip() or 'unknown file'
selected_sheet = str(source_candidate.get('selected_sheet') or '').strip()
batch_rows_json = _dump_tabular_generated_output_json(batch_rows)
selected_sheet_line = f"Worksheet: {selected_sheet}\n" if selected_sheet else ''
+ model_output_schema = [
+ field_name
+ for field_name in (output_schema or [])
+ if field_name not in {'source_row_number', 'source_row_identity'}
+ ]
+ output_schema_line = (
+ f'Use exactly these output fields for every object, in this order: '
+ f'{json.dumps(model_output_schema, ensure_ascii=False)}.\n'
+ if model_output_schema
+ else ''
+ )
return (
'Transform the tabular input rows below into structured output for the user.\n\n'
f'User instructions:\n{user_question}\n\n'
'Return ONLY a valid JSON array.\n'
f'Return exactly {len(batch_rows)} JSON object(s), one per input row, in the same order.\n'
+ f'{output_schema_line}'
+ 'Copy __simplechat_source_row_token exactly from each input row into its matching output object. '
+ 'Do not include the other fields beginning with __simplechat_source_ in generated objects.\n'
'Do not drop, merge, summarize, or cap rows.\n'
'Input rows may include normalized helper fields such as comment_id, body_text, source_file, attachment_present, attachment_names, and attachment_text. Use those normalized fields when they are present.\n'
'Input rows may include a referenced_documents array containing row-linked evidence from explicitly referenced non-tabular documents. Use that evidence as part of the source row context when it is relevant to the requested output.\n'
@@ -4704,6 +5856,25 @@ def _build_tabular_generated_output_system_message(output_metadata):
output_format = str(output_metadata.get('output_format') or 'json').upper()
file_name = str(output_metadata.get('file_name') or 'generated output').strip() or 'generated output'
row_count = _safe_int(output_metadata.get('row_count'))
+ output_status = str(output_metadata.get('status') or '').strip().lower()
+
+ if output_status == 'failed':
+ failure_detail = str(
+ output_metadata.get('status_detail')
+ or output_metadata.get('summary')
+ or 'The exhaustive export failed validation.'
+ ).strip()
+ return (
+ f'The requested exhaustive {output_format} export for {row_count} row(s) failed. '
+ f'{failure_detail} Do not claim that a full or partial export is attached, and do not recreate '
+ 'the assistant summary table as a CSV. Briefly report the failure and preserve any other requested analysis.'
+ )
+
+ if output_status == 'canceled':
+ return (
+ f'The requested exhaustive {output_format} export for {row_count} row(s) was canceled. '
+ 'Do not claim that the full export is attached, and do not recreate a partial assistant-table CSV.'
+ )
if output_metadata.get('background_export'):
run_id = str(output_metadata.get('export_run_id') or output_metadata.get('run_id') or '').strip()
@@ -4762,16 +5933,18 @@ async def _generate_tabular_structured_output_entries(
user_id=None,
conversation_id=None,
model_context=None,
+ token_usage_callback=None,
):
from semantic_kernel.contents.chat_history import ChatHistory as SKChatHistory
- rows = [
+ normalized_rows = [
_build_tabular_generated_output_input_row(
row,
source_file_name=source_candidate.get('filename'),
)
for row in (source_candidate.get('rows') or [])
]
+ rows = _prepare_tabular_source_rows(normalized_rows)
if not rows:
return None
@@ -4863,6 +6036,7 @@ async def _generate_tabular_structured_output_entries(
)
merged_entries = []
+ output_schema = None
for batch_index, batch_rows in enumerate(row_batches):
batch_number = batch_index + 1
log_event(
@@ -4897,6 +6071,7 @@ async def _generate_tabular_structured_output_entries(
batch_index,
total_batches,
source_candidate,
+ output_schema=output_schema,
)
parsed_entries = None
@@ -4931,10 +6106,27 @@ async def _generate_tabular_structured_output_entries(
execution_settings = AzureChatPromptExecutionSettings(service_id='tabular-generated-output')
result = await chat_service.get_chat_message_contents(chat_history, execution_settings)
+ if result:
+ _publish_tabular_response_token_usage(
+ result[0],
+ token_usage_callback,
+ )
raw_response_content = result[0].content if result and result[0].content else ''
if result and result[0].content:
parsed_entries = _parse_tabular_generated_json_entries(raw_response_content)
parsed_entry_count = len(parsed_entries) if parsed_entries is not None else 0
+ validation_error = None
+ if parsed_entries is not None and parsed_entry_count == len(batch_rows):
+ try:
+ parsed_entries, candidate_output_schema = _normalize_generated_batch_entries(
+ batch_rows,
+ parsed_entries,
+ expected_output_schema=output_schema,
+ )
+ output_schema = candidate_output_schema
+ except ValueError as exc:
+ validation_error = str(exc)
+ parsed_entries = None
if parsed_entries is None or parsed_entry_count != len(batch_rows):
log_event(
'[Tabular Generated Output] Structured export batch attempt mismatch',
@@ -4946,6 +6138,7 @@ async def _generate_tabular_structured_output_entries(
'attempt_number': attempt_number,
'expected_row_count': len(batch_rows),
'parsed_row_count': parsed_entry_count,
+ 'validation_error': validation_error,
'response_char_count': len(raw_response_content),
'response_preview': _truncate_tabular_generated_output_response_preview(raw_response_content),
},
@@ -4997,8 +6190,16 @@ async def maybe_create_tabular_generated_output(
thought_callback=None,
user_id=None,
model_context=None,
+ cancel_requested=None,
+ request_correlation_id=None,
+ token_usage_callback=None,
):
"""Build, upload, and describe a generated tabular JSON/CSV export when requested."""
+ raise_if_mixed_source_cancelled(
+ cancel_requested,
+ 'export',
+ request_correlation_id=request_correlation_id,
+ )
if not question_requests_tabular_generated_output(user_question):
return None
@@ -5028,6 +6229,208 @@ async def maybe_create_tabular_generated_output(
debug_only=True,
)
return None
+
+ source_candidate['source_authorization'] = _build_tabular_generated_output_source_authorization(
+ source_candidate
+ )
+ source_descriptor = None
+ source_descriptor_error = None
+ if (
+ user_id
+ and conversation_id
+ and question_requests_tabular_structured_object_output(user_question)
+ ):
+ try:
+ source_descriptor = _build_tabular_generated_output_query_descriptor(
+ source_candidate,
+ user_id,
+ conversation_id,
+ settings,
+ )
+ except Exception as exc:
+ source_descriptor_error = str(exc)
+ log_event(
+ '[Tabular Generated Output] Could not build durable source query descriptor',
+ {
+ 'conversation_id': conversation_id,
+ 'source_file_name': source_candidate.get('filename'),
+ 'function_name': source_candidate.get('function_name'),
+ 'error': str(exc),
+ },
+ level=logging.WARNING,
+ )
+ if source_descriptor:
+ source_candidate['source_authorization'] = {
+ field_name: source_descriptor.get(field_name)
+ for field_name in ('source', 'scope_id', 'container', 'blob_path')
+ }
+
+ expected_row_count = _safe_int(source_candidate.get('total_matches'))
+ source_batch_rows = _safe_int((source_descriptor or {}).get('batch_max_rows')) or 1
+ estimated_batch_count = max(
+ 1,
+ (expected_row_count + source_batch_rows - 1) // source_batch_rows,
+ )
+ materialized_rows = None
+ materialized_batches = None
+ if (
+ source_candidate.get('full_result_available')
+ and _safe_int(source_candidate.get('page_count')) > 1
+ ):
+ materialized_rows = [
+ _build_tabular_generated_output_input_row(
+ row,
+ source_file_name=source_candidate.get('filename'),
+ )
+ for row in (source_candidate.get('rows') or [])
+ ]
+ materialized_batches = _build_tabular_generated_output_row_batches(
+ materialized_rows,
+ settings=settings,
+ )
+ threshold_batch_count = len(materialized_batches) if materialized_batches else estimated_batch_count
+ exceeds_background_threshold = should_queue_tabular_generated_output_background(
+ expected_row_count,
+ threshold_batch_count,
+ settings,
+ )
+ should_queue_materialized_pages = bool(
+ user_id
+ and conversation_id
+ and question_requests_tabular_structured_object_output(user_question)
+ and source_candidate.get('full_result_available')
+ and _safe_int(source_candidate.get('page_count')) > 1
+ and not exceeds_background_threshold
+ )
+ if should_queue_materialized_pages:
+ raise_if_mixed_source_cancelled(
+ cancel_requested,
+ 'export',
+ request_correlation_id=request_correlation_id,
+ )
+ background_run = queue_tabular_generated_output_run(
+ user_id=user_id,
+ conversation_id=conversation_id,
+ user_question=user_question,
+ source_candidate=source_candidate,
+ output_format=output_format,
+ row_batches=materialized_batches,
+ gpt_model=gpt_model,
+ settings=settings,
+ model_context=model_context,
+ )
+ background_metadata = build_background_tabular_generated_output_metadata(background_run)
+ try:
+ raise_if_mixed_source_cancelled(
+ cancel_requested,
+ 'export',
+ request_correlation_id=request_correlation_id,
+ )
+ except MixedSourceCancellationError:
+ cancel_tabular_generated_output_run(
+ user_id,
+ background_metadata.get('export_run_id'),
+ )
+ raise
+ await emit_tabular_post_processing_thought(
+ thought_callback,
+ f"Queued exhaustive {str(output_format or 'json').upper()} export from validated tabular pages",
+ detail=(
+ f"run_id={background_metadata.get('export_run_id')}; "
+ f"rows={expected_row_count}; batches={len(materialized_batches)}; checkpointed=true"
+ ),
+ activity=build_tabular_post_processing_activity_payload(
+ 'tabular.generated_output',
+ f"Exhaustive {str(output_format or 'json').upper()} export queued",
+ 'running',
+ phase='queued',
+ output_format=output_format,
+ file_name=source_candidate.get('filename'),
+ batch_index=0,
+ batch_count=len(materialized_batches),
+ ),
+ )
+ return background_metadata
+
+ should_queue_source_backed_run = bool(
+ source_descriptor
+ and expected_row_count > 0
+ and (
+ not source_candidate.get('full_result_available')
+ or exceeds_background_threshold
+ )
+ )
+ if should_queue_source_backed_run:
+ try:
+ raise_if_mixed_source_cancelled(
+ cancel_requested,
+ 'export',
+ request_correlation_id=request_correlation_id,
+ )
+ background_run = queue_tabular_generated_output_run(
+ user_id=user_id,
+ conversation_id=conversation_id,
+ user_question=user_question,
+ source_candidate=source_candidate,
+ output_format=output_format,
+ row_batches=None,
+ gpt_model=gpt_model,
+ settings=settings,
+ model_context=model_context,
+ source_descriptor=source_descriptor,
+ )
+ except MixedSourceCancellationError:
+ raise
+ except Exception as exc:
+ log_event(
+ '[Tabular Generated Output] Durable source-backed export queueing failed',
+ {
+ 'conversation_id': conversation_id,
+ 'source_file_name': source_candidate.get('filename'),
+ 'row_count': expected_row_count,
+ 'error': str(exc),
+ },
+ level=logging.ERROR,
+ exceptionTraceback=True,
+ )
+ return _build_failed_tabular_generated_output_metadata(
+ source_candidate,
+ output_format,
+ 'The exhaustive export could not be queued. No partial CSV was created.',
+ )
+ background_metadata = build_background_tabular_generated_output_metadata(background_run)
+ try:
+ raise_if_mixed_source_cancelled(
+ cancel_requested,
+ 'export',
+ request_correlation_id=request_correlation_id,
+ )
+ except MixedSourceCancellationError:
+ cancel_tabular_generated_output_run(
+ user_id,
+ background_metadata.get('export_run_id'),
+ )
+ raise
+ await emit_tabular_post_processing_thought(
+ thought_callback,
+ f"Queued exhaustive {str(output_format or 'json').upper()} export from the authorized source query",
+ detail=(
+ f"run_id={background_metadata.get('export_run_id')}; "
+ f"rows={expected_row_count}; batches~={estimated_batch_count}; checkpointed=true"
+ ),
+ activity=build_tabular_post_processing_activity_payload(
+ 'tabular.generated_output',
+ f"Exhaustive {str(output_format or 'json').upper()} export queued",
+ 'running',
+ phase='queued',
+ output_format=output_format,
+ file_name=source_candidate.get('filename'),
+ batch_index=0,
+ batch_count=estimated_batch_count,
+ ),
+ )
+ return background_metadata
+
if not source_candidate.get('full_result_available'):
log_event(
'[Tabular Generated Output] Selected source candidate is incomplete; skipping export',
@@ -5035,10 +6438,22 @@ async def maybe_create_tabular_generated_output(
'conversation_id': conversation_id,
'output_format': output_format,
'selected_candidate': source_candidate.get('diagnostics'),
+ 'validation_error': source_candidate.get('validation_error'),
+ 'source_replay_available': bool(source_descriptor),
},
debug_only=True,
)
- return None
+ failure_detail = source_descriptor_error or source_candidate.get('validation_error')
+ return _build_failed_tabular_generated_output_metadata(
+ source_candidate,
+ output_format,
+ (
+ f'The exhaustive export source could not be validated: {failure_detail}. '
+ 'No partial CSV was created.'
+ if failure_detail
+ else 'The exhaustive export source was incomplete. No partial CSV was created.'
+ ),
+ )
log_event(
'[Tabular Generated Output] Selected source candidate',
{
@@ -5053,6 +6468,11 @@ async def maybe_create_tabular_generated_output(
return None
if question_requests_tabular_structured_object_output(user_question):
+ raise_if_mixed_source_cancelled(
+ cancel_requested,
+ 'export',
+ request_correlation_id=request_correlation_id,
+ )
output_entries = await _generate_tabular_structured_output_entries(
user_question,
source_candidate,
@@ -5064,8 +6484,17 @@ async def maybe_create_tabular_generated_output(
conversation_id=conversation_id,
model_context=model_context,
)
+ raise_if_mixed_source_cancelled(
+ cancel_requested,
+ 'export',
+ request_correlation_id=request_correlation_id,
+ )
if output_entries is None:
- return None
+ return _build_failed_tabular_generated_output_metadata(
+ source_candidate,
+ output_format,
+ 'The exhaustive export failed output validation. No partial CSV was created.',
+ )
if isinstance(output_entries, dict) and output_entries.get('background_export'):
return output_entries
else:
@@ -5122,6 +6551,18 @@ async def maybe_create_tabular_generated_output(
'in this chat as a downloadable export.'
),
)
+ try:
+ raise_if_mixed_source_cancelled(
+ cancel_requested,
+ 'artifact_publication',
+ request_correlation_id=request_correlation_id,
+ )
+ except MixedSourceCancellationError:
+ delete_generated_chat_artifact_for_current_user(
+ conversation_id,
+ (upload_result.get('message') or {}).get('id'),
+ )
+ raise
preview_rows = output_entries[:TABULAR_GENERATED_OUTPUT_PREVIEW_ROWS]
uploaded_file_name = upload_result.get('message', {}).get('file_name') or generated_file_name
@@ -5151,6 +6592,7 @@ async def maybe_create_tabular_generated_output(
)
return {
'capability': 'tabular',
+ 'suppress_assistant_table_export': True,
'artifact_message_id': upload_result.get('message', {}).get('id'),
'conversation_id': conversation_id,
'storage_scope': 'chat',
@@ -6020,17 +7462,8 @@ def get_session(self, user_id, conversation_id, active_only=False):
def get_new_plugin_invocations(invocations, baseline_count):
- """Return only the plugin invocations created after the baseline count."""
- if not invocations:
- return []
-
- if baseline_count <= 0:
- return list(invocations)
-
- if baseline_count >= len(invocations):
- return []
-
- return list(invocations[baseline_count:])
+ """Compatibility shim for existing chat-route imports and call sites."""
+ return _shared_get_new_plugin_invocations(invocations, baseline_count)
def split_tabular_plugin_invocations(invocations):
@@ -7364,6 +8797,47 @@ def normalize_tabular_row_text(value):
return re.sub(r'\s+', ' ', str(value).casefold()).strip()
+def tabular_text_contains_url_like_value(value):
+ """Return True when text contains a URL, SharePoint host, or site path."""
+ rendered_value = normalize_tabular_row_text(value)
+ if not rendered_value:
+ return False
+
+ def is_sharepoint_hostname(hostname):
+ normalized_hostname = str(hostname or '').strip().rstrip('.')
+ return normalized_hostname == 'sharepoint.com' or normalized_hostname.endswith('.sharepoint.com')
+
+ def is_sharepoint_lookalike_hostname(hostname):
+ normalized_hostname = str(hostname or '').strip().rstrip('.')
+ if not normalized_hostname or is_sharepoint_hostname(normalized_hostname):
+ return False
+ return any(
+ label == 'sharepoint' or label.endswith('sharepoint')
+ for label in normalized_hostname.split('.')
+ )
+
+ saw_full_url = False
+ for candidate in re.findall(r'https?://[^\s\)\]\}\>"\']+', rendered_value):
+ parsed_candidate = urlparse(candidate.rstrip('.,;:'))
+ if parsed_candidate.scheme in ('http', 'https') and parsed_candidate.netloc:
+ saw_full_url = True
+ candidate_host = str(parsed_candidate.hostname or '').rstrip('.')
+ if is_sharepoint_lookalike_hostname(candidate_host):
+ continue
+ return True
+
+ for candidate in re.findall(r'\b[a-z0-9][a-z0-9.-]*\.[a-z0-9.-]+\b', rendered_value):
+ parsed_candidate = urlparse(f'https://{candidate}')
+ candidate_host = str(parsed_candidate.hostname or '').rstrip('.')
+ if is_sharepoint_hostname(candidate_host):
+ return True
+
+ if saw_full_url:
+ return False
+
+ return '/sites/' in rendered_value
+
+
def parse_tabular_column_candidates(raw_columns):
"""Normalize column arguments from string or list form into a stable list."""
if isinstance(raw_columns, list):
@@ -7392,16 +8866,7 @@ def parse_tabular_column_candidates(raw_columns):
def tabular_value_looks_url_like(value):
"""Return True when a scalar cell value looks like a URL or site path."""
- rendered_value = normalize_tabular_row_text(value)
- if not rendered_value:
- return False
-
- return (
- 'http://' in rendered_value
- or 'https://' in rendered_value
- or 'sharepoint.com' in rendered_value
- or '/sites/' in rendered_value
- )
+ return tabular_text_contains_url_like_value(value)
def tabular_result_payload_contains_url_like_content(result_payload):
@@ -7422,15 +8887,7 @@ def tabular_result_payload_contains_url_like_content(result_payload):
candidate_values.extend(raw_row.values())
for candidate_value in candidate_values:
- rendered_candidate = str(candidate_value or '').strip().lower()
- if not rendered_candidate:
- continue
- if (
- 'http://' in rendered_candidate
- or 'https://' in rendered_candidate
- or 'sharepoint.com' in rendered_candidate
- or '/sites/' in rendered_candidate
- ):
+ if tabular_text_contains_url_like_value(candidate_value):
return True
return False
@@ -7852,10 +9309,46 @@ def derive_tabular_follow_up_calls_from_invocations(user_question, invocations):
existing_signatures.add(extraction_signature)
has_url_extraction_tool = True
- if len(follow_up_calls) >= 2:
- break
+ if len(follow_up_calls) >= 2:
+ break
+
+ return follow_up_calls[:2]
+
+
+def _extract_tabular_response_token_usage(response):
+ """Return observed token usage from a Semantic Kernel response metadata payload."""
+ metadata = getattr(response, 'metadata', None)
+ metadata = metadata if isinstance(metadata, dict) else {}
+ usage = metadata.get('usage') or metadata.get('token_usage')
+ usage = usage if isinstance(usage, dict) else {}
+
+ def as_nonnegative_int(value):
+ try:
+ return max(0, int(value or 0))
+ except (TypeError, ValueError):
+ return 0
+
+ prompt_tokens = as_nonnegative_int(usage.get('prompt_tokens'))
+ completion_tokens = as_nonnegative_int(usage.get('completion_tokens'))
+ total_tokens = as_nonnegative_int(usage.get('total_tokens'))
+ if not total_tokens and (prompt_tokens or completion_tokens):
+ total_tokens = prompt_tokens + completion_tokens
+ if not any((prompt_tokens, completion_tokens, total_tokens)):
+ return None
+ return {
+ 'prompt_tokens': prompt_tokens,
+ 'completion_tokens': completion_tokens,
+ 'total_tokens': total_tokens,
+ 'request_count': 1,
+ }
+
- return follow_up_calls[:2]
+def _publish_tabular_response_token_usage(response, token_usage_callback=None):
+ """Publish observed native model usage without changing existing return contracts."""
+ token_usage = _extract_tabular_response_token_usage(response)
+ if token_usage and callable(token_usage_callback):
+ token_usage_callback(token_usage)
+ return token_usage
async def maybe_recover_tabular_analysis_with_llm_reviewer(chat_service, kernel,
@@ -7874,7 +9367,8 @@ async def maybe_recover_tabular_analysis_with_llm_reviewer(chat_service, kernel,
discovery_feedback_messages=None,
fallback_source_hint='workspace',
fallback_group_id=None,
- fallback_public_workspace_id=None):
+ fallback_public_workspace_id=None,
+ token_usage_callback=None):
"""Use an LLM reviewer to choose analytical tool calls when the main SK loop stalls."""
reviewer_allowed_function_names = [
function_name for function_name in (allowed_function_names or [])
@@ -7960,6 +9454,10 @@ async def maybe_recover_tabular_analysis_with_llm_reviewer(chat_service, kernel,
reviewer_text = ''
if reviewer_result and reviewer_result[0].content:
+ _publish_tabular_response_token_usage(
+ reviewer_result[0],
+ token_usage_callback,
+ )
reviewer_text = reviewer_result[0].content.strip()
reviewer_calls = parse_tabular_reviewer_plan(reviewer_text)
@@ -8583,7 +10081,9 @@ async def emit_tabular_post_processing_thought(thought_callback, content, detail
callback_result = thought_callback(thought_payload)
if inspect.isawaitable(callback_result):
- await callback_result
+ return await callback_result
+
+ return callback_result
async def emit_tabular_analysis_lifecycle_thought(
@@ -8598,7 +10098,7 @@ async def emit_tabular_analysis_lifecycle_thought(
attempt_count=None,
):
"""Emit a long-running lifecycle thought for tabular analysis progress."""
- await emit_tabular_post_processing_thought(
+ return await emit_tabular_post_processing_thought(
thought_callback,
content,
detail=detail,
@@ -9178,7 +10678,8 @@ async def run_tabular_sk_analysis(user_question, tabular_filenames, user_id,
execution_mode='analysis',
tabular_file_contexts=None,
thought_callback=None,
- model_context=None):
+ model_context=None,
+ token_usage_callback=None):
"""Run lightweight SK with tabular analysis and attachment follow-up support.
Creates a temporary Kernel with TabularProcessingPlugin plus document-search
@@ -9261,6 +10762,17 @@ async def run_tabular_sk_analysis(user_question, tabular_filenames, user_id,
file_source_hint = file_context.get('source_hint', source_hint)
file_group_id = file_context.get('group_id')
file_public_workspace_id = file_context.get('public_workspace_id')
+ storage_locator = file_context.get('storage_locator')
+ if isinstance(storage_locator, dict):
+ locator_container = str(storage_locator.get('container') or '').strip()
+ locator_blob_path = str(storage_locator.get('blob_path') or '').strip()
+ if locator_container and locator_blob_path:
+ tabular_plugin.remember_resolved_blob_location(
+ file_source_hint,
+ fname,
+ locator_container,
+ locator_blob_path,
+ )
schema_source_context = {'source': file_source_hint}
if file_group_id:
schema_source_context['group_id'] = file_group_id
@@ -9858,6 +11370,11 @@ def build_system_prompt(force_tool_use=False, tool_error_messages=None,
result = await chat_service.get_chat_message_contents(
chat_history, execution_settings, kernel=kernel
)
+ if result:
+ _publish_tabular_response_token_usage(
+ result[0],
+ token_usage_callback,
+ )
except Exception as exc:
synthesis_exception = exc
log_event(
@@ -10202,6 +11719,7 @@ def build_system_prompt(force_tool_use=False, tool_error_messages=None,
fallback_source_hint=source_hint,
fallback_group_id=group_id,
fallback_public_workspace_id=public_workspace_id,
+ token_usage_callback=token_usage_callback,
)
if reviewer_recovery and reviewer_recovery.get('fallback'):
return reviewer_recovery['fallback']
@@ -10213,12 +11731,8 @@ def build_system_prompt(force_tool_use=False, tool_error_messages=None,
log_event(f"[Tabular SK Analysis] Error: {e}", level=logging.WARNING, exceptionTraceback=True)
return None
-def collect_tabular_sk_citations(user_id, conversation_id):
- """Collect plugin invocations from the tabular SK analysis and convert to citation format."""
- from semantic_kernel_plugins.plugin_invocation_logger import get_plugin_logger
-
- plugin_logger = get_plugin_logger()
- plugin_invocations = plugin_logger.get_invocations_for_conversation(user_id, conversation_id)
+def _build_tabular_sk_citations_from_invocations(plugin_invocations):
+ """Convert selected tabular invocations to the existing tool-citation shape."""
plugin_invocations = filter_tabular_citation_invocations(plugin_invocations)
if not plugin_invocations:
@@ -10263,10 +11777,281 @@ def collect_tabular_sk_citations(user_id, conversation_id):
}
citations.append(citation)
- log_event(f"[Tabular SK Citations] Collected {len(citations)} tool execution citations", level=logging.INFO)
return citations
+def collect_tabular_sk_citations(user_id, conversation_id):
+ """Collect plugin invocations from the tabular SK analysis and convert to citation format."""
+ plugin_logger = get_plugin_logger()
+ plugin_invocations = plugin_logger.get_invocations_for_conversation(user_id, conversation_id)
+ citations = _build_tabular_sk_citations_from_invocations(plugin_invocations)
+ log_event(
+ f"[Tabular SK Citations] Collected {len(citations)} tool execution citations",
+ level=logging.INFO,
+ )
+ return citations
+
+
+def _execute_mixed_source_tabular_evidence(
+ *,
+ tabular_sources,
+ selection_mode,
+ has_narrative_sources,
+ user_question,
+ user_id,
+ conversation_id,
+ gpt_model,
+ settings,
+ thought_tracker=None,
+ live_thought_callback=None,
+ model_context=None,
+ cancel_requested=None,
+ request_correlation_id=None,
+):
+ """Run the existing tabular engine once per manifest source with terminal coverage."""
+ source_contexts = build_tabular_file_contexts_from_manifest(tabular_sources)
+ if has_request_context():
+ authorized_context = dict(getattr(g, 'authorized_chat_context', {}) or {})
+ authorized_blob_locations = []
+ for file_context in source_contexts:
+ storage_locator = file_context.get('storage_locator')
+ if not isinstance(storage_locator, dict):
+ continue
+ locator_container = str(storage_locator.get('container') or '').strip()
+ locator_blob_path = str(storage_locator.get('blob_path') or '').strip()
+ if locator_container and locator_blob_path:
+ authorized_blob_locations.append([locator_container, locator_blob_path])
+ authorized_context['authorized_blob_locations'] = authorized_blob_locations
+ g.authorized_chat_context = authorized_context
+ context_by_document_id = {
+ context['document_id']: context
+ for context in source_contexts
+ }
+ system_messages = []
+ agent_citations = []
+ generated_outputs = []
+ all_invocations = []
+ token_usage = {
+ 'prompt_tokens': 0,
+ 'completion_tokens': 0,
+ 'total_tokens': 0,
+ 'request_count': 0,
+ }
+
+ def record_token_usage(usage):
+ if not isinstance(usage, dict):
+ return
+ for key in ('prompt_tokens', 'completion_tokens', 'total_tokens', 'request_count'):
+ try:
+ token_usage[key] += max(0, int(usage.get(key) or 0))
+ except (TypeError, ValueError):
+ continue
+ if not is_tabular_processing_enabled(settings):
+ def fail_unavailable_tabular_source(source):
+ del source
+ raise RuntimeError('Tabular processing is unavailable')
+
+ return {
+ 'evidence_envelopes': execute_tabular_evidence_sources(
+ tabular_sources,
+ fail_unavailable_tabular_source,
+ selection_mode,
+ cancel_requested=cancel_requested,
+ request_correlation_id=request_correlation_id,
+ ),
+ 'system_messages': [],
+ 'agent_citations': [],
+ 'generated_outputs': [],
+ 'invocations': [],
+ 'executed': False,
+ 'token_usage': None,
+ }
+
+ execute_tabular = should_run_tabular_evidence(
+ user_question,
+ has_narrative_sources=has_narrative_sources,
+ )
+ plugin_logger = get_plugin_logger()
+
+ def publish_post_processing_thought(thought_payload):
+ payload = thought_payload if isinstance(thought_payload, dict) else {}
+ if thought_tracker is not None:
+ thought_tracker.add_thought(
+ payload.get('step_type', 'tabular_analysis'),
+ payload.get('content', ''),
+ detail=payload.get('detail'),
+ activity=payload.get('activity'),
+ )
+ if callable(live_thought_callback):
+ live_payload = dict(payload)
+ if thought_tracker is not None:
+ live_payload['message_id'] = getattr(thought_tracker, 'message_id', None)
+ live_payload['step_index'] = thought_tracker.current_index - 1
+ live_thought_callback(live_payload)
+
+ def execute_source(source):
+ document_id = str(source.get('document_id') or '').strip()
+ file_context = context_by_document_id.get(document_id)
+ if not file_context:
+ raise ValueError('Authorized tabular source context is unavailable')
+
+ baseline_invocation_count = len(
+ plugin_logger.get_invocations_for_conversation(
+ user_id,
+ conversation_id,
+ limit=1000,
+ )
+ )
+ execution_mode = get_tabular_execution_mode(user_question)
+ tabular_analysis, streamed_tool_thoughts = asyncio.run(
+ run_tabular_analysis_with_thought_tracking(
+ user_question=user_question,
+ tabular_filenames={file_context['file_name']},
+ tabular_file_contexts=[file_context],
+ user_id=user_id,
+ conversation_id=conversation_id,
+ gpt_model=gpt_model,
+ settings=settings,
+ source_hint=file_context.get('source_hint', 'workspace'),
+ group_id=file_context.get('group_id'),
+ public_workspace_id=file_context.get('public_workspace_id'),
+ execution_mode=execution_mode,
+ thought_tracker=thought_tracker,
+ live_thought_callback=live_thought_callback,
+ model_context=model_context,
+ token_usage_callback=record_token_usage,
+ )
+ )
+ if not str(tabular_analysis or '').strip():
+ raise ValueError('Tabular execution returned no computed results')
+
+ invocations_after = plugin_logger.get_invocations_for_conversation(
+ user_id,
+ conversation_id,
+ limit=1000,
+ )
+ source_invocations = get_new_plugin_invocations(
+ invocations_after,
+ baseline_invocation_count,
+ )
+ if execution_mode == 'schema_summary':
+ successful_source_invocations = [
+ invocation
+ for invocation in source_invocations
+ if getattr(invocation, 'function_name', '') == 'describe_tabular_file'
+ and not get_tabular_invocation_error_message(invocation)
+ ]
+ else:
+ successful_source_invocations, _ = split_tabular_analysis_invocations(
+ source_invocations
+ )
+ if not successful_source_invocations:
+ raise ValueError('Tabular execution returned no successful native tool calls')
+ all_invocations.extend(source_invocations)
+ related_document_summary = ''
+ related_document_stats = augment_tabular_invocations_with_related_document_evidence(
+ source_invocations,
+ user_question,
+ user_id,
+ conversation_id=conversation_id,
+ )
+ if related_document_stats.get('augmented_row_count'):
+ related_document_summary = build_tabular_related_document_evidence_summary(
+ source_invocations,
+ )
+
+ if thought_tracker is not None and not streamed_tool_thoughts:
+ for thought_content, thought_detail in get_tabular_tool_thought_payloads(
+ source_invocations
+ ):
+ thought_tracker.add_thought(
+ 'tabular_analysis',
+ thought_content,
+ thought_detail,
+ )
+ if thought_tracker is not None:
+ for thought_content, thought_detail in get_tabular_status_thought_payloads(
+ source_invocations,
+ analysis_succeeded=True,
+ ):
+ thought_tracker.add_thought(
+ 'tabular_analysis',
+ thought_content,
+ thought_detail,
+ )
+
+ generated_output = asyncio.run(maybe_create_tabular_generated_output(
+ user_question=user_question,
+ invocations=source_invocations,
+ gpt_model=gpt_model,
+ settings=settings,
+ conversation_id=conversation_id,
+ thought_callback=publish_post_processing_thought,
+ user_id=user_id,
+ model_context=model_context,
+ cancel_requested=cancel_requested,
+ request_correlation_id=request_correlation_id,
+ token_usage_callback=record_token_usage,
+ ))
+ if generated_output:
+ generated_outputs.append(generated_output)
+
+ source_citations = _build_tabular_sk_citations_from_invocations(
+ source_invocations
+ )
+ agent_citations.extend(source_citations)
+ chart_citations = build_tabular_inline_chart_citations(
+ user_question,
+ source_invocations,
+ )
+ agent_citations.extend(chart_citations)
+ system_messages.append({
+ 'role': 'system',
+ 'content': build_tabular_computed_results_system_message(
+ 'an authorized selected tabular source',
+ str(tabular_analysis).strip(),
+ related_document_evidence_summary=related_document_summary,
+ ),
+ })
+ if generated_output:
+ system_messages.append({
+ 'role': 'system',
+ 'content': _build_tabular_generated_output_system_message(generated_output),
+ })
+
+ return {
+ 'summary': str(tabular_analysis).strip(),
+ 'evidence': [
+ get_tabular_invocation_compact_payload(invocation, max_rows=5)
+ for invocation in source_invocations
+ ],
+ 'citations': source_citations,
+ 'generated_artifacts': [generated_output] if generated_output else [],
+ 'coverage': {
+ 'tool_call_count': len(source_invocations),
+ 'execution_mode': execution_mode,
+ },
+ }
+
+ evidence_envelopes = execute_tabular_evidence_sources(
+ tabular_sources,
+ execute_source,
+ selection_mode,
+ execute=execute_tabular,
+ cancel_requested=cancel_requested,
+ request_correlation_id=request_correlation_id,
+ )
+ return {
+ 'evidence_envelopes': evidence_envelopes,
+ 'system_messages': system_messages,
+ 'agent_citations': agent_citations,
+ 'generated_outputs': generated_outputs,
+ 'invocations': all_invocations,
+ 'executed': execute_tabular,
+ 'token_usage': token_usage if token_usage['request_count'] else None,
+ }
+
+
def is_tabular_filename(filename):
"""Return True when the filename has a supported tabular extension."""
if not filename or not isinstance(filename, str):
@@ -10753,7 +12538,8 @@ async def run_tabular_analysis_with_multi_file_support(user_question, tabular_fi
execution_mode='analysis',
tabular_file_contexts=None,
thought_callback=None,
- model_context=None):
+ model_context=None,
+ token_usage_callback=None):
"""Run deterministic multi-file helpers first, then fall back to the SK planner."""
analysis_file_contexts = normalize_tabular_file_contexts_for_analysis(
tabular_filenames=tabular_filenames,
@@ -10800,6 +12586,7 @@ async def run_tabular_analysis_with_multi_file_support(user_question, tabular_fi
execution_mode=execution_mode,
thought_callback=thought_callback,
model_context=model_context,
+ token_usage_callback=token_usage_callback,
)
@@ -10811,7 +12598,8 @@ async def run_tabular_analysis_with_thought_tracking(user_question, tabular_file
tabular_file_contexts=None,
thought_tracker=None,
live_thought_callback=None,
- model_context=None):
+ model_context=None,
+ token_usage_callback=None):
"""Run tabular analysis while streaming/persisting live tool thoughts when available."""
plugin_logger = get_plugin_logger()
callback_key = None
@@ -10870,6 +12658,7 @@ def record_and_publish_tabular_progress_thought(thought_payload):
execution_mode=execution_mode,
thought_callback=tabular_progress_callback,
model_context=model_context,
+ token_usage_callback=token_usage_callback,
)
if callable(tabular_progress_callback):
@@ -11256,6 +13045,37 @@ def restore_agent_stream_retry_state(agent, retry_state):
def register_route_backend_chats(bp):
+ CLIENT_SAFE_INTERNAL_ERROR_MESSAGE = 'Something went wrong while processing the request. Please try again.'
+ CLIENT_SAFE_STREAM_ERROR_MESSAGE = 'Something went wrong while streaming the response. Please try again.'
+
+ def build_stream_error_event(message=CLIENT_SAFE_STREAM_ERROR_MESSAGE, **extra):
+ payload = {'error': message}
+ for key, value in extra.items():
+ if value is not None:
+ payload[key] = value
+ return f"data: {json.dumps(make_json_serializable(payload))}\n\n"
+
+ def get_safe_stream_error_message(payload, status_code, fallback_message):
+ if status_code >= 500 and not (
+ payload.get('service_health_warning') or payload.get('warning_type')
+ ):
+ return fallback_message
+ return payload.get('error') or fallback_message
+
+ def build_json_error_response(message=CLIENT_SAFE_INTERNAL_ERROR_MESSAGE, status_code=500, **extra):
+ payload = {'error': message}
+ for key, value in extra.items():
+ if value is not None:
+ payload[key] = value
+ return jsonify(make_json_serializable(payload)), status_code
+
+ def is_content_safety_error(error_message):
+ normalized_message = str(error_message or '').lower()
+ return 'safety system' in normalized_message or 'moderation_blocked' in normalized_message
+
+ def is_provider_bad_request_error(error_message, exc):
+ return '400' in str(error_message or '') and 'BadRequestError' in str(type(exc))
+
def build_background_stream_response(event_generator_factory, stream_session=None):
"""Run SSE generation in background execution so it survives disconnects."""
stream_bridge = BackgroundStreamBridge(stream_session=stream_session)
@@ -11297,7 +13117,7 @@ def stream_worker():
level=logging.ERROR,
exceptionTraceback=True,
)
- error_event = f"data: {json.dumps({'error': f'Internal server error: {str(e)}'})}\n\n"
+ error_event = build_stream_error_event()
publish_background_event(error_event)
finally:
if stream_session:
@@ -11987,8 +13807,17 @@ def _load_or_create_analyze_conversation(user_id, conversation_id=None):
invalidate_conversation_cache_for_item(conversation_item, reason="conversation_created")
return conversation_item
- def execute_document_action_chat_request(data=None, publish_background_event=None, forced_action_type=None):
+ def execute_document_action_chat_request(
+ data=None,
+ publish_background_event=None,
+ forced_action_type=None,
+ cancel_requested=None,
+ request_correlation_id=None,
+ ):
settings = get_settings()
+ request_correlation_id = normalize_mixed_source_correlation_id(
+ request_correlation_id
+ )
data = data if isinstance(data, dict) else (request.get_json() or {})
user_id = get_current_user_id()
if not user_id:
@@ -12049,7 +13878,8 @@ def execute_document_action_chat_request(data=None, publish_background_event=Non
try:
conversation_item = _load_or_create_analyze_conversation(user_id, conversation_id=conversation_id)
except PermissionError as exc:
- return {'error': str(exc)}, 403
+ debug_print(f'[ChatDocumentAction] Analyze conversation access denied: {exc}')
+ return {'error': 'You do not have access to this conversation.'}, 403
conversation_id = conversation_item.get('id')
g.conversation_id = conversation_id
@@ -12116,7 +13946,7 @@ def execute_document_action_chat_request(data=None, publish_background_event=Non
f'conversation_id={conversation_id or "new"} | '
f'error={exc}'
)
- return {'error': str(exc)}, 400
+ return {'error': 'Document action request is invalid. Please review the selected documents and try again.'}, 400
if normalized_action.get('type') == DOCUMENT_ACTION_TYPE_NONE:
return {'error': 'Select a document action before sending this request.'}, 400
@@ -12175,7 +14005,8 @@ def execute_document_action_chat_request(data=None, publish_background_event=Non
try:
conversation_item = _load_or_create_analyze_conversation(user_id, conversation_id=conversation_id)
except PermissionError as exc:
- return {'error': str(exc)}, 403
+ debug_print(f'[ChatDocumentAction] Conversation access denied: {exc}')
+ return {'error': 'You do not have access to this conversation.'}, 403
conversation_id = conversation_item.get('id')
g.conversation_id = conversation_id
@@ -12379,7 +14210,22 @@ def execute_document_action_chat_request(data=None, publish_background_event=Non
run_id=assistant_message_id,
thought_tracker=thought_tracker,
external_activity_callback=stream_activity_callback,
+ cancel_requested=cancel_requested,
+ request_correlation_id=request_correlation_id,
)
+ except MixedSourceCancellationError as exc:
+ if thought_tracker.enabled:
+ thought_tracker.add_thought(
+ 'cancellation',
+ 'Document action canceled before final output publication',
+ detail=f'phase={exc.phase}',
+ )
+ return {
+ 'canceled': True,
+ 'conversation_id': conversation_id,
+ 'user_message_id': user_message_id,
+ 'request_correlation_id': request_correlation_id,
+ }, 409
except Exception as exc:
debug_print(
'[ChatDocumentAction] Execution failed | '
@@ -12399,7 +14245,49 @@ def execute_document_action_chat_request(data=None, publish_background_event=Non
level=logging.ERROR,
exceptionTraceback=True,
)
- return {'error': str(exc), 'conversation_id': conversation_id, 'user_message_id': user_message_id}, 500
+ return {
+ 'error': 'Document action failed. Please try again.',
+ 'conversation_id': conversation_id,
+ 'user_message_id': user_message_id,
+ }, 500
+
+ try:
+ _reauthorize_document_action_finalization(
+ normalized_action,
+ execution_result,
+ user_id,
+ conversation_id,
+ cancel_requested=cancel_requested,
+ request_correlation_id=request_correlation_id,
+ settings=settings,
+ )
+ except MixedSourceCancellationError as exc:
+ if thought_tracker.enabled:
+ thought_tracker.add_thought(
+ 'cancellation',
+ 'Document action canceled before final output publication',
+ detail=f'phase={exc.phase}',
+ )
+ return {
+ 'canceled': True,
+ 'conversation_id': conversation_id,
+ 'user_message_id': user_message_id,
+ 'request_correlation_id': request_correlation_id,
+ }, 409
+ except PermissionError as exc:
+ debug_print(f'[ChatDocumentAction] Finalization authorization failed: {exc}')
+ return {
+ 'error': 'One or more selected sources are no longer available.',
+ 'conversation_id': conversation_id,
+ 'user_message_id': user_message_id,
+ }, 403
+ except RuntimeError as exc:
+ debug_print(f'[ChatDocumentAction] Finalization state changed: {exc}')
+ return {
+ 'error': 'Document action finalization could not complete. Please try again.',
+ 'conversation_id': conversation_id,
+ 'user_message_id': user_message_id,
+ }, 409
assistant_timestamp = datetime.utcnow().isoformat()
hybrid_citations_list = _build_document_action_hybrid_citations(execution_result)
@@ -12415,34 +14303,92 @@ def execute_document_action_chat_request(data=None, publish_background_event=Non
document_action_agent_citations,
document_action_context_json,
)
- prepared_agent_citations = persist_agent_citation_artifacts(
- conversation_id=conversation_id,
- assistant_message_id=assistant_message_id,
- agent_citations=document_action_agent_citations,
- created_timestamp=assistant_timestamp,
- user_info=response_message_context.get('user_info'),
- )
+ prepared_agent_citations = []
document_generated_analysis_artifacts = list(execution_result.get('generated_analysis_artifacts') or [])
document_generated_tabular_outputs = list(execution_result.get('generated_tabular_outputs') or [])
- document_action_reply_content = execution_result.get('reply', '')
- assistant_table_generated_output = maybe_create_assistant_table_generated_output(
- user_question=user_message,
- assistant_content=document_action_reply_content,
- conversation_id=conversation_id,
- existing_outputs=document_generated_analysis_artifacts + document_generated_tabular_outputs,
- )
- if assistant_table_generated_output:
- document_generated_analysis_artifacts.append(assistant_table_generated_output)
- document_generated_tabular_outputs.append(assistant_table_generated_output)
- assistant_file_generated_output = maybe_create_assistant_file_generated_output(
- user_question=user_message,
- assistant_content=document_action_reply_content,
- conversation_id=conversation_id,
- existing_outputs=document_generated_analysis_artifacts + document_generated_tabular_outputs,
- )
- if assistant_file_generated_output:
- document_generated_analysis_artifacts.append(assistant_file_generated_output)
- document_action_reply_content = _build_assistant_file_output_handoff(assistant_file_generated_output)
+ document_action_reply_content = get_generated_file_export_content(execution_result)
+ try:
+ raise_if_mixed_source_cancelled(
+ cancel_requested,
+ 'artifact_publication',
+ request_correlation_id=request_correlation_id,
+ )
+ prepared_agent_citations = persist_agent_citation_artifacts(
+ conversation_id=conversation_id,
+ assistant_message_id=assistant_message_id,
+ agent_citations=document_action_agent_citations,
+ created_timestamp=assistant_timestamp,
+ user_info=response_message_context.get('user_info'),
+ cancel_requested=cancel_requested,
+ request_correlation_id=request_correlation_id,
+ )
+ raise_if_mixed_source_cancelled(
+ cancel_requested,
+ 'artifact_publication',
+ request_correlation_id=request_correlation_id,
+ )
+ generated_file_output = maybe_create_generated_file_output(
+ user_question=user_message,
+ assistant_content=document_action_reply_content,
+ conversation_id=conversation_id,
+ function_results=execution_result.get('agent_citations') or [],
+ existing_outputs=document_generated_analysis_artifacts + document_generated_tabular_outputs,
+ cancel_requested=cancel_requested,
+ request_correlation_id=request_correlation_id,
+ )
+ if generated_file_output:
+ document_generated_analysis_artifacts.append(generated_file_output)
+ if generated_file_output.get('output_format') == 'csv':
+ document_generated_tabular_outputs.append(generated_file_output)
+ assistant_file_generated_output = maybe_create_assistant_file_generated_output(
+ user_question=user_message,
+ assistant_content=document_action_reply_content,
+ conversation_id=conversation_id,
+ existing_outputs=document_generated_analysis_artifacts + document_generated_tabular_outputs,
+ )
+ if assistant_file_generated_output:
+ document_generated_analysis_artifacts.append(assistant_file_generated_output)
+ document_action_reply_content = _build_assistant_file_output_handoff(assistant_file_generated_output)
+ _reauthorize_document_action_finalization(
+ normalized_action,
+ execution_result,
+ user_id,
+ conversation_id,
+ cancel_requested=cancel_requested,
+ request_correlation_id=request_correlation_id,
+ settings=settings,
+ )
+ except MixedSourceCancellationError as exc:
+ _rollback_mixed_source_chat_publication(
+ user_id,
+ conversation_id,
+ document_generated_analysis_artifacts + document_generated_tabular_outputs,
+ compact_citations=prepared_agent_citations,
+ )
+ if thought_tracker.enabled:
+ thought_tracker.add_thought(
+ 'cancellation',
+ 'Document action canceled before final output publication',
+ detail=f'phase={exc.phase}',
+ )
+ return {
+ 'canceled': True,
+ 'conversation_id': conversation_id,
+ 'user_message_id': user_message_id,
+ 'request_correlation_id': request_correlation_id,
+ }, 409
+ except MixedSourceFinalizationError:
+ _rollback_mixed_source_chat_publication(
+ user_id,
+ conversation_id,
+ document_generated_analysis_artifacts + document_generated_tabular_outputs,
+ compact_citations=prepared_agent_citations,
+ )
+ return {
+ 'error': 'Selected source state changed before final output could be published.',
+ 'conversation_id': conversation_id,
+ 'user_message_id': user_message_id,
+ }, 409
generated_analysis_metadata = _build_generated_analysis_metadata(
generated_analysis_artifacts=document_generated_analysis_artifacts,
generated_tabular_outputs=document_generated_tabular_outputs,
@@ -12495,6 +14441,42 @@ def execute_document_action_chat_request(data=None, publish_background_event=Non
},
})
cosmos_messages_container.upsert_item(assistant_doc)
+ try:
+ raise_if_mixed_source_cancelled(
+ cancel_requested,
+ 'finalization',
+ request_correlation_id=request_correlation_id,
+ )
+ except MixedSourceCancellationError as exc:
+ try:
+ cosmos_messages_container.delete_item(
+ item=assistant_message_id,
+ partition_key=conversation_id,
+ )
+ except Exception:
+ log_event(
+ '[MixedSourceLifecycle] Assistant rollback after cancellation failed.',
+ extra={'assistant_message_rollback_failure_count': 1},
+ level=logging.WARNING,
+ )
+ _rollback_mixed_source_chat_publication(
+ user_id,
+ conversation_id,
+ document_generated_analysis_artifacts + document_generated_tabular_outputs,
+ compact_citations=prepared_agent_citations,
+ )
+ if thought_tracker.enabled:
+ thought_tracker.add_thought(
+ 'cancellation',
+ 'Document action canceled before final output publication',
+ detail=f'phase={exc.phase}',
+ )
+ return {
+ 'canceled': True,
+ 'conversation_id': conversation_id,
+ 'user_message_id': user_message_id,
+ 'request_correlation_id': request_correlation_id,
+ }, 409
token_usage = execution_result.get('token_usage') if isinstance(execution_result.get('token_usage'), dict) else None
if token_usage and token_usage.get('total_tokens'):
@@ -12601,11 +14583,18 @@ def execute_document_action_chat_request(data=None, publish_background_event=Non
'metadata': assistant_doc.get('metadata', {}),
}), 200
- def execute_analyze_chat_request(data=None, publish_background_event=None):
+ def execute_analyze_chat_request(
+ data=None,
+ publish_background_event=None,
+ cancel_requested=None,
+ request_correlation_id=None,
+ ):
return execute_document_action_chat_request(
data=data,
publish_background_event=publish_background_event,
forced_action_type=DOCUMENT_ACTION_TYPE_ANALYZE,
+ cancel_requested=cancel_requested,
+ request_correlation_id=request_correlation_id,
)
@bp.route('/api/chat/document-action', methods=['POST'])
@@ -12634,6 +14623,7 @@ def chat_document_action_stream_api():
data['conversation_id'] = conversation_id
g.conversation_id = conversation_id
stream_session = CHAT_STREAM_REGISTRY.start_session(user_id, conversation_id)
+ request_correlation_id = normalize_mixed_source_correlation_id()
def generate_document_action_response(publish_background_event=None):
try:
@@ -12647,7 +14637,17 @@ def generate_document_action_response(publish_background_event=None):
payload, status_code = execute_document_action_chat_request(
data=data,
publish_background_event=publish_background_event,
+ cancel_requested=stream_session.is_cancel_requested,
+ request_correlation_id=request_correlation_id,
)
+ if payload.get('canceled'):
+ yield _build_stream_cancel_event(
+ payload.get('conversation_id') or conversation_id,
+ user_message_id=payload.get('user_message_id'),
+ reason=stream_session.get_cancel_reason(),
+ message_persisted=False,
+ )
+ return
if stream_session and stream_session.is_cancel_requested():
yield _build_stream_cancel_event(
payload.get('conversation_id') or conversation_id,
@@ -12659,13 +14659,29 @@ def generate_document_action_response(publish_background_event=None):
)
return
if status_code >= 400:
- error_message = payload.get('error') or f'Document action failed ({status_code})'
- yield f"data: {json.dumps({'error': error_message, 'conversation_id': payload.get('conversation_id')})}\n\n"
+ error_message = get_safe_stream_error_message(
+ payload,
+ status_code,
+ f'Document action failed ({status_code})',
+ )
+ yield build_stream_error_event(
+ error_message,
+ conversation_id=payload.get('conversation_id'),
+ )
return
yield f"data: {json.dumps(normalize_terminal_chat_payload(payload))}\n\n"
except Exception as document_action_error:
- yield f"data: {json.dumps({'error': str(document_action_error), 'conversation_id': conversation_id})}\n\n"
+ log_event(
+ f'[DocumentActionStream] Streaming response failed: {document_action_error}',
+ extra={'conversation_id': conversation_id},
+ level=logging.ERROR,
+ exceptionTraceback=True,
+ )
+ yield build_stream_error_event(
+ 'Document action failed. Please try again.',
+ conversation_id=conversation_id,
+ )
return build_background_stream_response(generate_document_action_response, stream_session=stream_session)
@@ -12695,6 +14711,7 @@ def chat_analyze_stream_api():
data['conversation_id'] = conversation_id
g.conversation_id = conversation_id
stream_session = CHAT_STREAM_REGISTRY.start_session(user_id, conversation_id)
+ request_correlation_id = normalize_mixed_source_correlation_id()
def generate_analyze_response(publish_background_event=None):
try:
@@ -12708,7 +14725,17 @@ def generate_analyze_response(publish_background_event=None):
payload, status_code = execute_analyze_chat_request(
data=data,
publish_background_event=publish_background_event,
+ cancel_requested=stream_session.is_cancel_requested,
+ request_correlation_id=request_correlation_id,
)
+ if payload.get('canceled'):
+ yield _build_stream_cancel_event(
+ payload.get('conversation_id') or conversation_id,
+ user_message_id=payload.get('user_message_id'),
+ reason=stream_session.get_cancel_reason(),
+ message_persisted=False,
+ )
+ return
if stream_session and stream_session.is_cancel_requested():
yield _build_stream_cancel_event(
payload.get('conversation_id') or conversation_id,
@@ -12720,13 +14747,29 @@ def generate_analyze_response(publish_background_event=None):
)
return
if status_code >= 400:
- error_message = payload.get('error') or f'Document analysis failed ({status_code})'
- yield f"data: {json.dumps({'error': error_message, 'conversation_id': payload.get('conversation_id')})}\n\n"
+ error_message = get_safe_stream_error_message(
+ payload,
+ status_code,
+ f'Document analysis failed ({status_code})',
+ )
+ yield build_stream_error_event(
+ error_message,
+ conversation_id=payload.get('conversation_id'),
+ )
return
yield f"data: {json.dumps(normalize_terminal_chat_payload(payload))}\n\n"
except Exception as analysis_error:
- yield f"data: {json.dumps({'error': str(analysis_error), 'conversation_id': conversation_id})}\n\n"
+ log_event(
+ f'[DocumentAnalysisStream] Streaming response failed: {analysis_error}',
+ extra={'conversation_id': conversation_id},
+ level=logging.ERROR,
+ exceptionTraceback=True,
+ )
+ yield build_stream_error_event(
+ 'Document analysis failed. Please try again.',
+ conversation_id=conversation_id,
+ )
return build_background_stream_response(generate_analyze_response, stream_session=stream_session)
@@ -12815,17 +14858,32 @@ def generate_image_from_proposal():
except CosmosResourceNotFoundError:
return jsonify({'error': 'Conversation or source message not found'}), 404
except PermissionError as exc:
- return jsonify({'error': str(exc)}), 403
+ log_event(
+ f'[ImageGeneration] Proposal approval authorization failed: {exc}',
+ extra={'conversation_id': data.get('conversation_id') if isinstance(data, dict) else None},
+ level=logging.WARNING,
+ exceptionTraceback=True,
+ )
+ return jsonify({'error': 'You do not have access to this conversation'}), 403
except ValueError as exc:
- return jsonify({'error': str(exc)}), 400
+ log_event(
+ f'[ImageGeneration] Proposal approval validation failed: {exc}',
+ extra={'conversation_id': data.get('conversation_id') if isinstance(data, dict) else None},
+ level=logging.WARNING,
+ exceptionTraceback=True,
+ )
+ return jsonify({'error': 'Image generation request is invalid. Please review the prompt and try again.'}), 400
except Exception as exc:
error_message = str(exc)
status_code = 500
- if 'safety system' in error_message.lower() or 'moderation_blocked' in error_message:
+ if is_content_safety_error(error_message):
error_message = 'Image generation was blocked by content safety policies. Please edit the prompt and try again.'
status_code = 400
- elif '400' in error_message and 'BadRequestError' in str(type(exc)):
+ elif is_provider_bad_request_error(error_message, exc):
+ error_message = 'Image generation request was invalid. Please edit the prompt and try again.'
status_code = 400
+ else:
+ error_message = 'Image generation failed due to a technical error. Please try again.'
log_event(
f'[ImageGeneration] Proposal approval failed: {exc}',
@@ -12980,7 +15038,14 @@ def result_requires_message_reload(result: Any) -> bool:
generated_tabular_outputs_list = []
generated_analysis_artifacts_list = []
system_messages_for_augmentation = [] # Collect system messages from search
+ generated_file_output_guidance = build_generated_file_output_guidance(user_message)
+ if generated_file_output_guidance:
+ system_messages_for_augmentation.append({
+ 'role': 'system',
+ 'content': generated_file_output_guidance,
+ })
search_results = []
+ mixed_source_narrative_retrieval_failed = False
selected_agent = None # Initialize selected_agent early to prevent NameError
# --- Configuration ---
# History / Summarization Settings
@@ -13008,6 +15073,39 @@ def result_requires_message_reload(result: Any) -> bool:
deep_research_enabled = deep_research_enabled.lower() == 'true'
if isinstance(image_gen_enabled, str):
image_gen_enabled = image_gen_enabled.lower() == 'true'
+ try:
+ document_context_contract = _normalize_chat_document_context_contract(
+ settings,
+ data,
+ selected_document_ids,
+ hybrid_search_enabled,
+ )
+ except ValueError as contract_error:
+ debug_print(f'[ChatAPI] Invalid document context request: {contract_error}')
+ return jsonify({'error': 'Document context request is invalid. Please review the selected sources and try again.'}), 400
+ selected_document_ids = list(
+ document_context_contract.get('selected_document_ids') or []
+ )
+ requested_selected_document_ids = list(selected_document_ids)
+ selected_document_id = (
+ selected_document_ids[0]
+ if len(selected_document_ids) == 1
+ else None
+ )
+ selection_mode = document_context_contract.get('selection_mode')
+ document_context_requested = bool(
+ document_context_contract.get('document_context_requested')
+ )
+ request_has_explicit_document_selection = bool(
+ document_context_contract.get('explicit_selection')
+ )
+ request_document_context_enabled = bool(
+ hybrid_search_enabled
+ or (
+ is_mixed_source_chat_search_enabled(settings)
+ and document_context_requested
+ )
+ )
user_workspace_context_requested = data.get('user_workspace_context_enabled')
if isinstance(user_workspace_context_requested, str):
user_workspace_context_requested = user_workspace_context_requested.lower() == 'true'
@@ -13057,6 +15155,7 @@ def result_requires_message_reload(result: Any) -> bool:
history_grounded_search_used = False
history_only_answerability = None
prior_grounded_document_refs = []
+ continuity_decision = None
effective_document_scope = document_scope
effective_selected_document_ids = list(selected_document_ids or [])
effective_selected_document_id = selected_document_id
@@ -13075,7 +15174,7 @@ def result_requires_message_reload(result: Any) -> bool:
assigned_knowledge_deep_research_urls = []
if assigned_knowledge_filters:
assigned_knowledge_user_context_active = (
- user_workspace_context_requested
+ (user_workspace_context_requested or document_context_requested)
and _assigned_knowledge_allows_user_workspace_context(assigned_knowledge_filters)
and _assigned_knowledge_allows_document_action(
assigned_knowledge_filters,
@@ -13135,6 +15234,15 @@ def result_requires_message_reload(result: Any) -> bool:
g.assigned_knowledge_context = assigned_knowledge_filters
g.assigned_knowledge_user_context_active = assigned_knowledge_user_context_active
+ mixed_source_explicit_selection = bool(
+ is_mixed_source_chat_search_enabled(settings)
+ and request_has_explicit_document_selection
+ and (
+ not assigned_knowledge_filters
+ or assigned_knowledge_user_context_active
+ )
+ )
+
explicit_external_retrieval_requested = _is_explicit_external_retrieval_requested(
web_search_enabled=web_search_enabled,
url_access_enabled=url_access_enabled,
@@ -13287,8 +15395,13 @@ def result_requires_message_reload(result: Any) -> bool:
except Exception as e:
debug_print(f"Error initializing GPT client/model: {e}")
- # Handle error appropriately - maybe return 500 or default behavior
- return jsonify({'error': f'Failed to initialize AI model: {str(e)}'}), 500
+ log_event(
+ f'[ChatAPI] Failed to initialize AI model: {e}',
+ extra={'user_id': user_id, 'conversation_id': conversation_id},
+ level=logging.ERROR,
+ exceptionTraceback=True,
+ )
+ return build_json_error_response('Failed to initialize AI model')
# region 1 - Load or Create Conversation
# ---------------------------------------------------------------------
# 1) Load or create conversation
@@ -13304,16 +15417,25 @@ def result_requires_message_reload(result: Any) -> bool:
return jsonify({'error': 'Forbidden'}), 403
except Exception as e:
debug_print(f"Error reading conversation {conversation_id}: {e}")
- return jsonify({'error': f'Error reading conversation: {str(e)}'}), 500
+ log_event(
+ f'[ChatAPI] Failed to read conversation: {e}',
+ extra={'user_id': user_id, 'conversation_id': conversation_id},
+ level=logging.ERROR,
+ exceptionTraceback=True,
+ )
+ return build_json_error_response('Failed to read conversation')
_set_authorized_chat_request_context(user_id, conversation_id, scope_context)
auto_linked_chat_upload_document_ids = []
- auto_merge_chat_upload_workspace_context = _should_auto_merge_chat_upload_workspace_context(
- explicit_external_retrieval_requested,
- hybrid_search_enabled,
- assigned_knowledge_filters=assigned_knowledge_filters,
- assigned_knowledge_user_context_active=assigned_knowledge_user_context_active,
+ auto_merge_chat_upload_workspace_context = (
+ not mixed_source_explicit_selection
+ and _should_auto_merge_chat_upload_workspace_context(
+ explicit_external_retrieval_requested,
+ hybrid_search_enabled,
+ assigned_knowledge_filters=assigned_knowledge_filters,
+ assigned_knowledge_user_context_active=assigned_knowledge_user_context_active,
+ )
)
if auto_merge_chat_upload_workspace_context:
chat_upload_context = _resolve_chat_upload_workspace_context(
@@ -13372,6 +15494,82 @@ def result_requires_message_reload(result: Any) -> bool:
selected_document_id = effective_selected_document_id
document_scope = effective_document_scope
+ mixed_source_manifest = []
+ mixed_source_partitions = {}
+ mixed_source_narrative_document_ids = []
+ mixed_source_tabular_sources = []
+ mixed_source_evidence_envelopes = []
+ mixed_source_native_token_usage = None
+ mixed_source_request_correlation_id = normalize_mixed_source_correlation_id()
+ if mixed_source_explicit_selection:
+ try:
+ mixed_source_manifest = _resolve_chat_mixed_source_manifest(
+ settings,
+ user_id,
+ conversation_id,
+ effective_selected_document_ids,
+ 'selected',
+ active_group_ids=effective_active_group_ids,
+ active_public_workspace_ids=effective_active_public_workspace_ids,
+ request_correlation_id=mixed_source_request_correlation_id,
+ )
+ except ValueError as manifest_error:
+ debug_print(f'[ChatAPI] Mixed-source manifest validation failed: {manifest_error}')
+ return jsonify({'error': 'Selected document context is unavailable. Please refresh and try again.'}), 400
+ mixed_source_partitions = partition_source_manifest(
+ mixed_source_manifest
+ )
+ mixed_source_narrative_document_ids = _get_manifest_partition_document_ids(
+ mixed_source_partitions,
+ 'narrative_sources',
+ )
+ mixed_source_tabular_sources = list(
+ mixed_source_partitions.get('tabular_sources') or []
+ )
+ authorized_selected_document_ids = [
+ str(source.get('document_id') or '').strip()
+ for source in (
+ list(mixed_source_partitions.get('narrative_sources') or [])
+ + mixed_source_tabular_sources
+ )
+ if str(source.get('document_id') or '').strip()
+ ]
+ effective_selected_document_ids = authorized_selected_document_ids
+ effective_selected_document_id = (
+ authorized_selected_document_ids[0]
+ if len(authorized_selected_document_ids) == 1
+ else None
+ )
+ log_event(
+ '[MixedSourceChatSearch] Activated explicit selected-source context.',
+ extra={
+ 'selection_mode': 'selected',
+ 'requested_source_count': len(mixed_source_manifest),
+ 'authorized_source_count': len(authorized_selected_document_ids),
+ 'narrative_source_count': len(mixed_source_narrative_document_ids),
+ 'tabular_source_count': len(mixed_source_tabular_sources),
+ 'omitted_source_count': len(
+ mixed_source_partitions.get('unresolved_sources') or []
+ ),
+ },
+ level=logging.INFO,
+ )
+ else:
+ _maybe_resolve_chat_source_manifest(
+ settings,
+ user_id,
+ conversation_id,
+ effective_selected_document_ids,
+ scope_context,
+ )
+ request_document_context_enabled = bool(
+ hybrid_search_enabled
+ or (
+ is_mixed_source_chat_search_enabled(settings)
+ and document_context_requested
+ )
+ )
+
# Clear plugin invocations at start of message processing to ensure
# each message only shows citations for tools executed during that specific interaction
plugin_logger = get_plugin_logger()
@@ -13469,13 +15667,13 @@ def result_requires_message_reload(result: Any) -> bool:
# Button states and selections
user_metadata['button_states'] = {
'image_generation': image_gen_enabled,
- 'document_search': hybrid_search_enabled,
+ 'document_search': request_document_context_enabled,
'web_search': bool(web_search_enabled),
'url_access': bool(url_access_enabled),
'deep_research': bool(deep_research_enabled)
}
user_metadata['capability_usage'] = _build_capability_usage_metadata(
- workspace_search_enabled=hybrid_search_enabled,
+ workspace_search_enabled=request_document_context_enabled,
document_action_type=DOCUMENT_ACTION_TYPE_NONE,
document_scope=effective_document_scope,
selected_document_ids=effective_selected_document_ids,
@@ -13488,12 +15686,18 @@ def result_requires_message_reload(result: Any) -> bool:
)
# Document search scope and selections
- if hybrid_search_enabled:
+ if request_document_context_enabled:
user_metadata['workspace_search'] = {
'search_enabled': True,
+ 'selection_mode': selection_mode,
+ 'document_context_requested': document_context_requested,
+ 'hybrid_search_preference': bool(hybrid_search_enabled),
'document_scope': effective_document_scope,
'selected_document_id': effective_selected_document_id,
'selected_document_ids': effective_selected_document_ids,
+ 'requested_document_ids': requested_selected_document_ids,
+ 'active_group_ids': effective_active_group_ids,
+ 'active_public_workspace_ids': effective_active_public_workspace_ids,
'tags': tags_filter,
'classification': classifications_to_send
}
@@ -13683,7 +15887,7 @@ def result_requires_message_reload(result: Any) -> bool:
conversation_id=conversation_id,
message_type='user_message',
message_length=len(user_message) if user_message else 0,
- has_document_search=hybrid_search_enabled,
+ has_document_search=request_document_context_enabled,
has_image_generation=image_gen_enabled,
document_scope=document_scope,
chat_context=actual_chat_type,
@@ -13819,9 +16023,20 @@ def result_requires_message_reload(result: Any) -> bool:
except Exception as ex:
debug_print(f"[Content Safety] Unexpected error: {ex}")
- if not original_hybrid_search_enabled and not explicit_external_retrieval_requested:
+ if (
+ not original_hybrid_search_enabled
+ and not explicit_external_retrieval_requested
+ and not mixed_source_explicit_selection
+ ):
prior_grounded_document_refs = _normalize_prior_grounded_document_refs(conversation_item)
if prior_grounded_document_refs:
+ continuity_decision = _resolve_reauthorized_continuity_decision(
+ settings,
+ user_id,
+ conversation_id,
+ prior_grounded_document_refs,
+ request_correlation_id=mixed_source_request_correlation_id,
+ )
thought_tracker.add_thought(
'history_context',
'Checking whether prior conversation context already answers the question',
@@ -13860,7 +16075,10 @@ def result_requires_message_reload(result: Any) -> bool:
f"[History Fallback] History-only sufficiency assessment failed: {assessment_error}"
)
- if history_only_answerability and history_only_answerability.get('can_answer_from_history'):
+ if _can_reuse_prior_grounded_history(
+ history_only_answerability,
+ continuity_decision,
+ ):
thought_tracker.add_thought(
'history_context',
'Prior conversation context appears sufficient without new document retrieval',
@@ -13933,13 +16151,71 @@ def result_requires_message_reload(result: Any) -> bool:
'history_context',
'No prior grounded documents were available; using conversation history only'
)
+ if (
+ is_mixed_source_chat_search_enabled(settings)
+ and history_grounded_search_used
+ ):
+ history_context = _resolve_chat_mixed_source_partition(
+ settings,
+ user_id,
+ conversation_id,
+ effective_selected_document_ids,
+ 'history',
+ active_group_ids=effective_active_group_ids,
+ active_public_workspace_ids=effective_active_public_workspace_ids,
+ request_correlation_id=mixed_source_request_correlation_id,
+ )
+ mixed_source_manifest = history_context.get('manifest') or []
+ mixed_source_partitions = history_context.get('partitions') or {}
+ mixed_source_narrative_document_ids = list(
+ history_context.get('narrative_document_ids') or []
+ )
+ mixed_source_tabular_sources = list(
+ history_context.get('tabular_sources') or []
+ )
+ if is_mixed_source_conversation_continuity_enabled(settings):
+ continuity_decision = _build_reauthorized_continuity_decision(
+ prior_grounded_document_refs,
+ mixed_source_manifest,
+ explicit_selection=False,
+ )
+ effective_selected_document_ids = (
+ mixed_source_narrative_document_ids
+ + _get_manifest_partition_document_ids(
+ mixed_source_partitions,
+ 'tabular_sources',
+ )
+ )
+ effective_selected_document_id = (
+ effective_selected_document_ids[0]
+ if len(effective_selected_document_ids) == 1
+ else None
+ )
# region 4 - Augmentation
# ---------------------------------------------------------------------
# 4) Augmentation (Search, etc.) - Run *before* final history prep
# ---------------------------------------------------------------------
# Hybrid Search
- if hybrid_search_enabled or history_grounded_search_used:
+ mixed_source_document_context_active = bool(
+ (hybrid_search_enabled or history_grounded_search_used)
+ if not is_mixed_source_chat_search_enabled(settings)
+ else (
+ document_context_requested
+ or hybrid_search_enabled
+ or history_grounded_search_used
+ )
+ )
+ combined_documents = []
+ mixed_source_narrative_search_active = bool(
+ mixed_source_document_context_active
+ and (
+ not is_mixed_source_chat_search_enabled(settings)
+ or not mixed_source_manifest
+ or mixed_source_narrative_document_ids
+ )
+ )
+ if mixed_source_narrative_search_active:
# Optional: Summarize recent history *for search* (uses its own limit)
if hybrid_search_enabled and enable_summarize_content_history_for_search:
@@ -14054,8 +16330,14 @@ def result_requires_message_reload(result: Any) -> bool:
):
search_args["active_public_workspace_id"] = effective_active_public_workspace_id
- if effective_selected_document_ids:
- search_args["document_ids"] = effective_selected_document_ids
+ search_document_ids = (
+ mixed_source_narrative_document_ids
+ if is_mixed_source_chat_search_enabled(settings)
+ and mixed_source_manifest
+ else effective_selected_document_ids
+ )
+ if search_document_ids:
+ search_args["document_ids"] = search_document_ids
elif effective_selected_document_id:
search_args["document_id"] = effective_selected_document_id
if auto_linked_chat_upload_document_ids:
@@ -14090,21 +16372,70 @@ def result_requires_message_reload(result: Any) -> bool:
else:
# Public scope now automatically searches all visible public workspaces
search_results = hybrid_search(**search_args) # Assuming hybrid_search handles None document_id
+
+ if (
+ is_mixed_source_chat_search_enabled(settings)
+ and not mixed_source_explicit_selection
+ and not history_grounded_search_used
+ ):
+ relevance_context = _resolve_chat_mixed_source_relevance_context(
+ settings=settings,
+ user_id=user_id,
+ conversation_id=conversation_id,
+ query=search_query,
+ search_results=search_results,
+ document_scope=effective_document_scope,
+ candidate_document_ids=(
+ assigned_knowledge_filters.get('document_ids')
+ if assigned_knowledge_filters
+ and assigned_knowledge_filters.get('has_workspace_knowledge')
+ else None
+ ),
+ tags_filter=tags_filter,
+ active_group_ids=effective_active_group_ids,
+ active_public_workspace_ids=effective_active_public_workspace_ids,
+ request_correlation_id=mixed_source_request_correlation_id,
+ )
+ mixed_source_manifest = relevance_context.get('manifest') or []
+ mixed_source_partitions = relevance_context.get('partitions') or {}
+ mixed_source_narrative_document_ids = list(
+ relevance_context.get('narrative_document_ids') or []
+ )
+ mixed_source_tabular_sources = list(
+ relevance_context.get('tabular_sources') or []
+ )
+ search_results = list(
+ relevance_context.get('search_results') or []
+ )
except SemanticSearchQuotaExceededError as e:
debug_print(f"Semantic search quota exceeded during hybrid search: {e}")
- return jsonify({
- 'error': e.user_message,
- 'warning_type': SEMANTIC_SEARCH_QUOTA_WARNING_TYPE,
- 'service_health_warning': True,
- }), 503
+ if (
+ is_mixed_source_chat_search_enabled(settings)
+ and mixed_source_manifest
+ and mixed_source_tabular_sources
+ ):
+ mixed_source_narrative_retrieval_failed = True
+ search_results = []
+ else:
+ return jsonify({
+ 'error': e.user_message,
+ 'warning_type': SEMANTIC_SEARCH_QUOTA_WARNING_TYPE,
+ 'service_health_warning': True,
+ }), 503
except Exception as e:
debug_print(f"Error during hybrid search: {e}")
- # Only treat as error if the exception is from embedding failure
- return jsonify({
- 'error': 'There was an issue with the embedding process. Please check with an admin on embedding configuration.'
- }), 500
+ if (
+ is_mixed_source_chat_search_enabled(settings)
+ and mixed_source_manifest
+ and mixed_source_tabular_sources
+ ):
+ mixed_source_narrative_retrieval_failed = True
+ search_results = []
+ else:
+ return jsonify({
+ 'error': 'There was an issue with the embedding process. Please check with an admin on embedding configuration.'
+ }), 500
- combined_documents = []
if search_results:
unique_doc_names = set(doc.get('file_name', 'Unknown') for doc in search_results)
thought_tracker.add_thought('search', f"Found {len(search_results)} results from {len(unique_doc_names)} documents")
@@ -14165,11 +16496,12 @@ def result_requires_message_reload(result: Any) -> bool:
# Construct system prompt for search results
system_prompt_search = build_search_augmentation_system_prompt(retrieved_content)
# Add this to a temporary list, don't save to DB yet
- system_messages_for_augmentation.append({
- 'role': 'system',
- 'content': system_prompt_search,
- 'documents': combined_documents # Keep track of docs used
- })
+ if not is_mixed_source_chat_search_enabled(settings):
+ system_messages_for_augmentation.append({
+ 'role': 'system',
+ 'content': system_prompt_search,
+ 'documents': combined_documents # Keep track of docs used
+ })
# Loop through each source document/chunk used for this message
for source_doc in combined_documents:
@@ -14385,7 +16717,13 @@ def result_requires_message_reload(result: Any) -> bool:
# Update message-level chat_type based on actual document usage for this message
# This must happen after document search is completed so search_results is populated
message_chat_type = None
- if (hybrid_search_enabled or history_grounded_search_used) and search_results and len(search_results) > 0:
+ mixed_source_has_authorized_evidence_sources = bool(
+ mixed_source_narrative_document_ids
+ or mixed_source_tabular_sources
+ )
+ if mixed_source_document_context_active and (
+ search_results or mixed_source_has_authorized_evidence_sources
+ ):
# Documents were actually used for this message
if effective_document_scope == 'group':
message_chat_type = 'group'
@@ -14443,8 +16781,10 @@ def result_requires_message_reload(result: Any) -> bool:
source_review_used = _source_review_metadata_used(source_review_result)
user_metadata['capability_usage'] = _build_capability_usage_metadata(
- workspace_search_enabled=hybrid_search_enabled or history_grounded_search_used,
- workspace_search_used=bool(search_results),
+ workspace_search_enabled=mixed_source_document_context_active,
+ workspace_search_used=bool(
+ search_results or mixed_source_has_authorized_evidence_sources
+ ),
workspace_search_result_count=len(search_results or []),
document_action_type=DOCUMENT_ACTION_TYPE_NONE,
document_scope=effective_document_scope,
@@ -14630,7 +16970,6 @@ def result_requires_message_reload(result: Any) -> bool:
except Exception as e:
debug_print(f"Image generation error: {str(e)}")
debug_print(f"Error type: {type(e)}")
- import traceback
debug_print(f"Traceback: {traceback.format_exc()}")
# Handle different types of errors appropriately
@@ -14638,14 +16977,21 @@ def result_requires_message_reload(result: Any) -> bool:
status_code = 500
# Check if this is a content moderation error
- if "safety system" in error_message.lower() or "moderation_blocked" in error_message:
+ if is_content_safety_error(error_message):
user_friendly_message = "Image generation was blocked by content safety policies. Please try a different prompt that doesn't involve potentially harmful content."
status_code = 400 # Bad request rather than server error
- elif "400" in error_message and "BadRequestError" in str(type(e)):
- user_friendly_message = f"Image generation request was invalid: {error_message}"
+ elif is_provider_bad_request_error(error_message, e):
+ user_friendly_message = "Image generation request was invalid. Please edit the prompt and try again."
status_code = 400
else:
- user_friendly_message = f"Image generation failed due to a technical error: {error_message}"
+ user_friendly_message = "Image generation failed due to a technical error. Please try again."
+
+ log_event(
+ f'[ImageGeneration] Chat image generation failed: {e}',
+ extra={'conversation_id': conversation_id, 'user_id': user_id},
+ level=logging.ERROR,
+ exceptionTraceback=True,
+ )
return jsonify({
'error': user_friendly_message
@@ -14653,7 +16999,11 @@ def result_requires_message_reload(result: Any) -> bool:
workspace_tabular_file_contexts = []
workspace_tabular_files = set()
- if (hybrid_search_enabled or history_grounded_search_used) and is_tabular_processing_enabled(settings):
+ if (
+ not is_mixed_source_chat_search_enabled(settings)
+ and (hybrid_search_enabled or history_grounded_search_used)
+ and is_tabular_processing_enabled(settings)
+ ):
workspace_tabular_file_contexts = collect_workspace_tabular_file_contexts(
combined_documents=combined_documents,
selected_document_ids=effective_selected_document_ids,
@@ -14677,7 +17027,138 @@ def record_tabular_post_processing_thought(thought_payload):
activity=thought_payload.get('activity'),
)
- if (hybrid_search_enabled or history_grounded_search_used) and workspace_tabular_files and is_tabular_processing_enabled(settings):
+ if (
+ is_mixed_source_chat_search_enabled(settings)
+ and mixed_source_document_context_active
+ and mixed_source_manifest
+ ):
+ effective_mixed_source_selection_mode = (
+ 'selected'
+ if mixed_source_explicit_selection
+ else 'history'
+ if history_grounded_search_used
+ else 'relevance'
+ )
+ mixed_source_evidence_envelopes.extend(
+ build_failed_narrative_evidence_envelopes(
+ mixed_source_partitions.get('narrative_sources') or [],
+ effective_mixed_source_selection_mode,
+ )
+ if mixed_source_narrative_retrieval_failed
+ else build_narrative_evidence_envelopes(
+ mixed_source_partitions.get('narrative_sources') or [],
+ search_results,
+ effective_mixed_source_selection_mode,
+ )
+ )
+ mixed_source_tabular_result = _execute_mixed_source_tabular_evidence(
+ tabular_sources=mixed_source_tabular_sources,
+ selection_mode=effective_mixed_source_selection_mode,
+ has_narrative_sources=bool(mixed_source_narrative_document_ids),
+ user_question=user_message,
+ user_id=user_id,
+ conversation_id=conversation_id,
+ gpt_model=gpt_model,
+ settings=settings,
+ thought_tracker=thought_tracker,
+ model_context=tabular_model_context,
+ request_correlation_id=mixed_source_request_correlation_id,
+ )
+ mixed_source_evidence_envelopes.extend(
+ mixed_source_tabular_result.get('evidence_envelopes') or []
+ )
+ mixed_source_native_token_usage = mixed_source_tabular_result.get('token_usage')
+ agent_citations_list.extend(
+ mixed_source_tabular_result.get('agent_citations') or []
+ )
+ generated_tabular_outputs_list.extend(
+ mixed_source_tabular_result.get('generated_outputs') or []
+ )
+ generated_analysis_artifacts_list.extend(
+ mixed_source_tabular_result.get('generated_outputs') or []
+ )
+ mixed_source_handoff = build_mixed_source_evidence_handoff(
+ mixed_source_manifest,
+ mixed_source_evidence_envelopes,
+ effective_mixed_source_selection_mode,
+ mode='chat',
+ telemetry_settings=settings,
+ request_correlation_id=mixed_source_request_correlation_id,
+ )
+ system_messages_for_augmentation.append(mixed_source_handoff)
+ mixed_source_coverage = mixed_source_handoff.get(
+ 'mixed_source_coverage',
+ {},
+ )
+ user_metadata['mixed_source_coverage'] = mixed_source_coverage
+ if continuity_decision:
+ user_metadata['source_continuity'] = continuity_decision
+ user_message_doc['metadata'] = user_metadata
+ cosmos_messages_container.upsert_item(user_message_doc)
+ if continuity_decision:
+ emit_mixed_source_telemetry(
+ settings,
+ 'continuity',
+ 'chat',
+ request_correlation_id=mixed_source_request_correlation_id,
+ metrics={
+ 'history_source_count': continuity_decision.get('prior_source_count', 0),
+ 'history_rerun_count': int(bool(continuity_decision.get('requires_native_execution'))),
+ 'history_reuse_count': int(not continuity_decision.get('requires_native_execution')),
+ },
+ dimensions={
+ 'selection_mode': effective_mixed_source_selection_mode,
+ 'continuity_decision': (
+ 'rerun'
+ if continuity_decision.get('requires_native_execution')
+ else 'reuse'
+ ),
+ },
+ )
+ emit_mixed_source_telemetry(
+ settings,
+ 'background_export',
+ 'chat',
+ request_correlation_id=mixed_source_request_correlation_id,
+ metrics={
+ 'background_export_count': sum(
+ bool(output.get('background_export'))
+ for output in generated_tabular_outputs_list
+ if isinstance(output, dict)
+ ),
+ 'artifact_count': len(generated_analysis_artifacts_list),
+ 'citation_count': len(agent_citations_list) + len(hybrid_citations_list),
+ },
+ dimensions={
+ 'selection_mode': effective_mixed_source_selection_mode,
+ },
+ )
+ log_event(
+ '[MixedSourceChatSearch] Prepared bounded mixed-source synthesis evidence.',
+ extra={
+ 'selection_mode': effective_mixed_source_selection_mode,
+ 'narrative_result_count': len(search_results or []),
+ 'tabular_candidate_count': len(mixed_source_tabular_sources),
+ 'tabular_completed_count': sum(
+ 1
+ for envelope in mixed_source_evidence_envelopes
+ if envelope.get('source_kind') == 'tabular'
+ and envelope.get('status') == 'completed'
+ ),
+ 'mixed_synthesis_count': 1,
+ 'partial_coverage': bool(
+ mixed_source_coverage.get('partial_coverage')
+ ),
+ },
+ level=logging.INFO,
+ )
+
+ if (
+ not is_mixed_source_chat_search_enabled(settings)
+ and (hybrid_search_enabled or history_grounded_search_used)
+ and workspace_tabular_files
+ and is_tabular_processing_enabled(settings)
+ ):
tabular_source_hint = determine_tabular_source_hint(
effective_document_scope,
active_group_id=effective_active_group_id,
@@ -14940,7 +17421,10 @@ def record_tabular_post_processing_thought(thought_payload):
gpt_model=gpt_model,
user_message_id=user_message_id,
fallback_user_message=user_message,
- include_assistant_citation_context=not explicit_external_retrieval_requested,
+ include_assistant_citation_context=(
+ not explicit_external_retrieval_requested
+ and not mixed_source_explicit_selection
+ ),
)
summary_of_older = history_segments['summary_of_older']
chat_tabular_files = history_segments['chat_tabular_files']
@@ -15017,7 +17501,14 @@ def record_tabular_post_processing_thought(thought_payload):
final_api_source_refs.extend(history_debug_info.get('history_message_source_refs', []))
# --- Mini SK analysis for tabular files uploaded directly to chat ---
- if chat_tabular_files and is_tabular_processing_enabled(settings):
+ if (
+ chat_tabular_files
+ and is_tabular_processing_enabled(settings)
+ and not (
+ is_mixed_source_chat_search_enabled(settings)
+ and (mixed_source_explicit_selection or mixed_source_tabular_sources)
+ )
+ ):
chat_tabular_filenames_str = ", ".join(chat_tabular_files)
chat_tabular_execution_mode = get_tabular_execution_mode(user_message)
log_event(
@@ -15146,7 +17637,13 @@ def record_tabular_post_processing_thought(thought_payload):
except Exception as e:
debug_print(f"Error preparing conversation history: {e}")
- return jsonify({'error': f'Error preparing conversation history: {str(e)}'}), 500
+ log_event(
+ f'[ChatAPI] Failed to prepare conversation history: {e}',
+ extra={'conversation_id': conversation_id, 'user_id': user_id},
+ level=logging.ERROR,
+ exceptionTraceback=True,
+ )
+ return build_json_error_response('Failed to prepare conversation history')
# region 6 - Final GPT Call
# ---------------------------------------------------------------------
@@ -15180,6 +17677,10 @@ def record_tabular_post_processing_thought(thought_payload):
original_hybrid_search_enabled,
prior_grounded_document_refs,
explicit_external_retrieval_requested,
+ current_document_context_requested=(
+ is_mixed_source_chat_search_enabled(settings)
+ and document_context_requested
+ ),
):
history_grounding_message = build_history_grounding_system_message()
insert_idx = 0
@@ -15288,6 +17789,7 @@ async def run_sk_call(callable_obj, *args, **kwargs):
)
async for r in result:
return r
+ return None
else:
return result
except asyncio.CancelledError:
@@ -15629,10 +18131,12 @@ def invoke_foundry_agent():
'user_id': user_id,
'message_id': user_message_id,
'chat_type': chat_type,
- 'document_scope': document_scope,
+ 'document_scope': effective_document_scope,
'group_id': active_group_id if chat_type == 'group' else None,
'hybrid_search_enabled': hybrid_search_enabled,
- 'selected_document_id': selected_document_id,
+ 'selection_mode': selection_mode,
+ 'document_context_requested': mixed_source_document_context_active,
+ 'selected_document_id': effective_selected_document_id,
'selected_document_ids': effective_selected_document_ids,
'active_group_ids': effective_active_group_ids,
'active_public_workspace_ids': effective_active_public_workspace_ids,
@@ -15668,7 +18172,7 @@ def foundry_agent_success(result):
for citation in foundry_citations:
thought_tracker.add_thought(
'agent_tool_call',
- f"Agent retrieved citation from {_get_foundry_agent_label(selected_agent_type)}"
+ _build_foundry_citation_thought_content(selected_agent_type, citation)
)
for citation in foundry_citations:
serializable = make_json_serializable(citation)
@@ -15967,7 +18471,7 @@ def gpt_error(e):
if "context length" in str(e).lower():
return ("Sorry, the conversation history is too long even after summarization. Please start a new conversation or try a shorter message.", gpt_model, None, None, None)
else:
- return (f"Sorry, I encountered an error generating the response. Details: {str(e)}", gpt_model, None, None, None)
+ return ("Sorry, I encountered an error generating the response. Please try again.", gpt_model, None, None, None)
fallback_steps.append({
'name': 'gpt',
'func': invoke_gpt_fallback,
@@ -15983,7 +18487,6 @@ def gpt_error(e):
else:
ai_message, final_model_used, chat_mode, kernel_fallback_notice = fallback_result
token_usage_data = None
-
ai_message = _append_inline_chart_blocks_to_message(ai_message, agent_citations_list)
# Emit responded thought for non-agent paths (agent paths emit their own inside callbacks)
@@ -16030,6 +18533,25 @@ def gpt_error(e):
exceptionTraceback=True
)
+ token_usage_data = _merge_chat_token_usage(
+ mixed_source_native_token_usage,
+ token_usage_data,
+ )
+ if mixed_source_manifest:
+ emit_mixed_source_telemetry(
+ settings,
+ 'native_execution',
+ 'chat',
+ request_correlation_id=mixed_source_request_correlation_id,
+ metrics={
+ 'prompt_tokens': (token_usage_data or {}).get('prompt_tokens', 0),
+ 'completion_tokens': (token_usage_data or {}).get('completion_tokens', 0),
+ 'total_tokens': (token_usage_data or {}).get('total_tokens', 0),
+ 'token_request_count': (token_usage_data or {}).get('request_count', 0),
+ 'request_count': (token_usage_data or {}).get('request_count', 0),
+ },
+ )
+
# region 7 - Save GPT Response
# ---------------------------------------------------------------------
# 7) Save GPT response (or error message)
@@ -16075,6 +18597,24 @@ def gpt_error(e):
# Assistant message should be part of the same thread as the user message
# Only system/augmentation messages create new threads within a conversation
+ if mixed_source_manifest:
+ fresh_finalization_manifest = resolve_authorized_source_manifest(
+ [source.get('document_id') for source in mixed_source_manifest],
+ user_id=user_id,
+ selection_mode=effective_mixed_source_selection_mode,
+ conversation_id=conversation_id,
+ active_group_ids=effective_active_group_ids,
+ active_public_workspace_ids=effective_active_public_workspace_ids,
+ doc_scope=effective_document_scope,
+ request_correlation_id=mixed_source_request_correlation_id,
+ )
+ _validate_reauthorized_manifest_finalization(
+ mixed_source_manifest,
+ fresh_finalization_manifest,
+ settings=settings,
+ mode='chat',
+ request_correlation_id=mixed_source_request_correlation_id,
+ )
assistant_timestamp = datetime.utcnow().isoformat()
prepared_agent_citations = persist_agent_citation_artifacts(
conversation_id=conversation_id,
@@ -16083,15 +18623,17 @@ def gpt_error(e):
created_timestamp=assistant_timestamp,
user_info=user_info_for_assistant,
)
- assistant_table_generated_output = maybe_create_assistant_table_generated_output(
+ generated_file_output = maybe_create_generated_file_output(
user_question=user_message,
assistant_content=ai_message,
conversation_id=conversation_id,
+ function_results=agent_citations_list,
existing_outputs=generated_analysis_artifacts_list + generated_tabular_outputs_list,
)
- if assistant_table_generated_output:
- generated_analysis_artifacts_list.append(assistant_table_generated_output)
- generated_tabular_outputs_list.append(assistant_table_generated_output)
+ if generated_file_output:
+ generated_analysis_artifacts_list.append(generated_file_output)
+ if generated_file_output.get('output_format') == 'csv':
+ generated_tabular_outputs_list.append(generated_file_output)
assistant_file_generated_output = maybe_create_assistant_file_generated_output(
user_question=user_message,
assistant_content=ai_message,
@@ -16107,8 +18649,10 @@ def gpt_error(e):
)
source_review_used = _source_review_metadata_used(source_review_result)
assistant_capability_usage = _build_capability_usage_metadata(
- workspace_search_enabled=hybrid_search_enabled or history_grounded_search_used,
- workspace_search_used=bool(search_results),
+ workspace_search_enabled=mixed_source_document_context_active,
+ workspace_search_used=bool(
+ search_results or mixed_source_has_authorized_evidence_sources
+ ),
workspace_search_result_count=len(hybrid_citations_list or []),
document_action_type=DOCUMENT_ACTION_TYPE_NONE,
document_scope=effective_document_scope,
@@ -16255,6 +18799,16 @@ def gpt_error(e):
selected_agent_name = None
if selected_agent:
selected_agent_name = getattr(selected_agent, 'name', None)
+ source_continuity_refs = None
+ if (
+ is_mixed_source_conversation_continuity_enabled(settings)
+ and mixed_source_manifest
+ ):
+ source_continuity_refs = _build_mixed_source_continuity_refs(
+ mixed_source_manifest,
+ mixed_source_evidence_envelopes,
+ effective_mixed_source_selection_mode,
+ )
# Collect metadata for this conversation interaction
conversation_item = collect_conversation_metadata(
@@ -16266,7 +18820,7 @@ def gpt_error(e):
document_scope=effective_document_scope,
selected_document_id=effective_selected_document_id,
model_deployment=actual_model_used,
- hybrid_search_enabled=hybrid_search_enabled or history_grounded_search_used,
+ hybrid_search_enabled=mixed_source_document_context_active,
image_gen_enabled=image_gen_enabled,
selected_documents=combined_documents if 'combined_documents' in locals() else None,
selected_agent=selected_agent_name,
@@ -16274,7 +18828,8 @@ def gpt_error(e):
search_results=search_results if 'search_results' in locals() else None,
conversation_item=conversation_item,
active_public_workspace_id=effective_active_public_workspace_id,
- active_public_workspace_ids=effective_active_public_workspace_ids
+ active_public_workspace_ids=effective_active_public_workspace_ids,
+ source_continuity_refs=source_continuity_refs,
)
except Exception as e:
debug_print(f"Error collecting conversation metadata: {e}")
@@ -16287,10 +18842,6 @@ def gpt_error(e):
# ---------------------------------------------------------------------
# 8) Return final success (even if AI generated an error message)
# ---------------------------------------------------------------------
- # Persist per-user kernel state if needed
- enable_redis_for_kernel = False
- if enable_semantic_kernel and per_user_semantic_kernel and redis_client and enable_redis_for_kernel:
- save_user_kernel(user_id, g.kernel, g.kernel_agents, redis_client)
return jsonify(make_json_serializable({
'reply': ai_message, # Send the AI's response (or the error message) back
'conversation_id': conversation_id,
@@ -16322,7 +18873,6 @@ def gpt_error(e):
})), 200
except Exception as e:
- import traceback
error_traceback = traceback.format_exc()
debug_print(f"[CHAT API ERROR] Unhandled exception in chat_api: {str(e)}")
debug_print(f"[CHAT API ERROR] Full traceback:\n{error_traceback}")
@@ -16334,12 +18884,10 @@ def gpt_error(e):
"user_id": user_id if 'user_id' in locals() else None,
"conversation_id": conversation_id if 'conversation_id' in locals() else None
},
- level=logging.ERROR
+ level=logging.ERROR,
+ exceptionTraceback=True,
)
- return jsonify({
- 'error': f'Internal server error: {str(e)}',
- 'details': error_traceback if current_app.debug else None
- }), 500
+ return build_json_error_response()
@bp.route('/api/chat/stream', methods=['POST'])
@swagger_route(security=get_auth_security())
@@ -16351,7 +18899,6 @@ def chat_stream_api():
Streams tokens as they are generated from Azure OpenAI.
"""
from flask import Response, stream_with_context
- import json
from queue import Queue, Empty
# IMPORTANT: Parse JSON and get user_id BEFORE entering the generator
@@ -16365,7 +18912,12 @@ def chat_stream_api():
settings = get_settings()
request_start_time = time.time()
except Exception as e:
- return jsonify({'error': f'Failed to parse request: {str(e)}'}), 400
+ log_event(
+ f'[Streaming] Failed to parse stream request: {e}',
+ level=logging.WARNING,
+ exceptionTraceback=True,
+ )
+ return jsonify({'error': 'Invalid request payload'}), 400
retry_user_message_id = data.get('retry_user_message_id') or data.get('edited_user_message_id')
retry_thread_id = data.get('retry_thread_id')
@@ -16495,8 +19047,12 @@ def generate_compatibility_response():
payload = {}
if status_code >= 400:
- error_message = payload.get('error') or f'Compatibility chat request failed ({status_code})'
- yield f"data: {json.dumps({'error': error_message})}\n\n"
+ error_message = get_safe_stream_error_message(
+ payload,
+ status_code,
+ f'Compatibility chat request failed ({status_code})',
+ )
+ yield build_stream_error_event(error_message)
return
if payload.get('image_url'):
@@ -16509,7 +19065,13 @@ def generate_compatibility_response():
yield f"data: {json.dumps(normalize_legacy_chat_payload(payload))}\n\n"
except Exception as compatibility_error:
- yield f"data: {json.dumps({'error': str(compatibility_error)})}\n\n"
+ log_event(
+ f'[Streaming] Compatibility response failed: {compatibility_error}',
+ extra={'conversation_id': finalized_conversation_id, 'user_id': user_id},
+ level=logging.ERROR,
+ exceptionTraceback=True,
+ )
+ yield build_stream_error_event()
if compatibility_mode:
debug_print("[Streaming] Routing request through compatibility bridge")
@@ -16679,7 +19241,14 @@ def stream_cancel_requested():
generated_tabular_outputs_list = []
generated_analysis_artifacts_list = []
system_messages_for_augmentation = []
+ generated_file_output_guidance = build_generated_file_output_guidance(user_message)
+ if generated_file_output_guidance:
+ system_messages_for_augmentation.append({
+ 'role': 'system',
+ 'content': generated_file_output_guidance,
+ })
search_results = []
+ mixed_source_narrative_retrieval_failed = False
selected_agent = None
# Configuration
@@ -16703,6 +19272,42 @@ def stream_cancel_requested():
source_review_enabled = source_review_enabled.lower() == 'true'
if isinstance(deep_research_enabled, str):
deep_research_enabled = deep_research_enabled.lower() == 'true'
+ try:
+ document_context_contract = _normalize_chat_document_context_contract(
+ settings,
+ data,
+ selected_document_ids,
+ hybrid_search_enabled,
+ )
+ except ValueError as contract_error:
+ debug_print(f'[Streaming] Invalid document context request: {contract_error}')
+ yield build_stream_error_event(
+ 'Document context request is invalid. Please review the selected sources and try again.'
+ )
+ return
+ selected_document_ids = list(
+ document_context_contract.get('selected_document_ids') or []
+ )
+ requested_selected_document_ids = list(selected_document_ids)
+ selected_document_id = (
+ selected_document_ids[0]
+ if len(selected_document_ids) == 1
+ else None
+ )
+ selection_mode = document_context_contract.get('selection_mode')
+ document_context_requested = bool(
+ document_context_contract.get('document_context_requested')
+ )
+ request_has_explicit_document_selection = bool(
+ document_context_contract.get('explicit_selection')
+ )
+ request_document_context_enabled = bool(
+ hybrid_search_enabled
+ or (
+ is_mixed_source_chat_search_enabled(settings)
+ and document_context_requested
+ )
+ )
user_workspace_context_requested = data.get('user_workspace_context_enabled')
if isinstance(user_workspace_context_requested, str):
user_workspace_context_requested = user_workspace_context_requested.lower() == 'true'
@@ -16749,6 +19354,7 @@ def stream_cancel_requested():
history_grounded_search_used = False
history_only_answerability = None
prior_grounded_document_refs = []
+ continuity_decision = None
effective_document_scope = document_scope
effective_selected_document_ids = list(selected_document_ids or [])
effective_selected_document_id = selected_document_id
@@ -16767,7 +19373,7 @@ def stream_cancel_requested():
assigned_knowledge_deep_research_urls = []
if assigned_knowledge_filters:
assigned_knowledge_user_context_active = (
- user_workspace_context_requested
+ (user_workspace_context_requested or document_context_requested)
and _assigned_knowledge_allows_user_workspace_context(assigned_knowledge_filters)
and _assigned_knowledge_allows_document_action(
assigned_knowledge_filters,
@@ -16832,6 +19438,16 @@ def stream_cancel_requested():
f"public_workspaces={len(effective_active_public_workspace_ids)} | "
f"tags={len(tags_filter)}"
)
+ mixed_source_explicit_selection = bool(
+ is_mixed_source_chat_search_enabled(settings)
+ and request_has_explicit_document_selection
+ and (
+ not assigned_knowledge_filters
+ or assigned_knowledge_user_context_active
+ )
+ )
+ mixed_source_document_context_active = request_document_context_enabled
+ mixed_source_has_authorized_evidence_sources = False
explicit_external_retrieval_requested = _is_explicit_external_retrieval_requested(
web_search_enabled=web_search_enabled,
url_access_enabled=url_access_enabled,
@@ -16849,8 +19465,10 @@ def stream_cancel_requested():
def build_streaming_capability_usage():
source_review_was_used = _source_review_metadata_used(source_review_result)
return _build_capability_usage_metadata(
- workspace_search_enabled=hybrid_search_enabled or history_grounded_search_used,
- workspace_search_used=bool(search_results),
+ workspace_search_enabled=mixed_source_document_context_active,
+ workspace_search_used=bool(
+ search_results or mixed_source_has_authorized_evidence_sources
+ ),
workspace_search_result_count=len(hybrid_citations_list or []),
document_action_type=DOCUMENT_ACTION_TYPE_NONE,
document_scope=effective_document_scope,
@@ -17002,7 +19620,13 @@ def build_streaming_capability_usage():
)
except Exception as e:
- yield f"data: {json.dumps({'error': f'Model initialization failed: {str(e)}'})}\n\n"
+ log_event(
+ f'[Streaming] Model initialization failed: {e}',
+ extra={'conversation_id': conversation_id, 'user_id': user_id},
+ level=logging.ERROR,
+ exceptionTraceback=True,
+ )
+ yield build_stream_error_event('Failed to initialize AI model')
return
# Load or create conversation (simplified)
@@ -17021,11 +19645,14 @@ def build_streaming_capability_usage():
return
auto_linked_chat_upload_document_ids = []
- auto_merge_chat_upload_workspace_context = _should_auto_merge_chat_upload_workspace_context(
- explicit_external_retrieval_requested,
- hybrid_search_enabled,
- assigned_knowledge_filters=assigned_knowledge_filters,
- assigned_knowledge_user_context_active=assigned_knowledge_user_context_active,
+ auto_merge_chat_upload_workspace_context = (
+ not mixed_source_explicit_selection
+ and _should_auto_merge_chat_upload_workspace_context(
+ explicit_external_retrieval_requested,
+ hybrid_search_enabled,
+ assigned_knowledge_filters=assigned_knowledge_filters,
+ assigned_knowledge_user_context_active=assigned_knowledge_user_context_active,
+ )
)
if auto_merge_chat_upload_workspace_context:
chat_upload_context = _resolve_chat_upload_workspace_context(
@@ -17067,24 +19694,100 @@ def build_streaming_capability_usage():
and assigned_knowledge_filters.get('has_workspace_knowledge')
and not assigned_knowledge_user_context_active
)
- if auto_linked_assigned_knowledge_user_context:
- assigned_knowledge_user_context_active = True
- g.assigned_knowledge_user_context_active = True
- tags_filter = []
- debug_print(
- "[ChatUploadWorkspaceContext] Enabled Assigned Knowledge user context "
- f"from {len(auto_linked_chat_upload_document_ids)} linked chat upload workspace document(s)."
+ if auto_linked_assigned_knowledge_user_context:
+ assigned_knowledge_user_context_active = True
+ g.assigned_knowledge_user_context_active = True
+ tags_filter = []
+ debug_print(
+ "[ChatUploadWorkspaceContext] Enabled Assigned Knowledge user context "
+ f"from {len(auto_linked_chat_upload_document_ids)} linked chat upload workspace document(s)."
+ )
+ hybrid_search_enabled = True
+ original_hybrid_search_enabled = True
+ effective_selected_document_id = (
+ effective_selected_document_ids[0]
+ if len(effective_selected_document_ids) == 1
+ else None
+ )
+ selected_document_ids = list(effective_selected_document_ids)
+ selected_document_id = effective_selected_document_id
+ document_scope = effective_document_scope
+
+ mixed_source_manifest = []
+ mixed_source_partitions = {}
+ mixed_source_narrative_document_ids = []
+ mixed_source_tabular_sources = []
+ mixed_source_evidence_envelopes = []
+ mixed_source_native_token_usage = None
+ mixed_source_request_correlation_id = normalize_mixed_source_correlation_id()
+ if mixed_source_explicit_selection:
+ try:
+ explicit_context = _resolve_chat_mixed_source_partition(
+ settings,
+ user_id,
+ conversation_id,
+ effective_selected_document_ids,
+ 'selected',
+ active_group_ids=effective_active_group_ids,
+ active_public_workspace_ids=effective_active_public_workspace_ids,
+ cancel_requested=stream_cancel_requested,
+ request_correlation_id=mixed_source_request_correlation_id,
+ )
+ except ValueError as manifest_error:
+ debug_print(f'[Streaming] Mixed-source manifest validation failed: {manifest_error}')
+ yield build_stream_error_event(
+ 'Selected document context is unavailable. Please refresh and try again.'
+ )
+ return
+ mixed_source_manifest = explicit_context.get('manifest') or []
+ mixed_source_partitions = explicit_context.get('partitions') or {}
+ mixed_source_narrative_document_ids = list(
+ explicit_context.get('narrative_document_ids') or []
+ )
+ mixed_source_tabular_sources = list(
+ explicit_context.get('tabular_sources') or []
+ )
+ effective_selected_document_ids = (
+ mixed_source_narrative_document_ids
+ + _get_manifest_partition_document_ids(
+ mixed_source_partitions,
+ 'tabular_sources',
)
- hybrid_search_enabled = True
- original_hybrid_search_enabled = True
+ )
effective_selected_document_id = (
effective_selected_document_ids[0]
if len(effective_selected_document_ids) == 1
else None
)
- selected_document_ids = list(effective_selected_document_ids)
- selected_document_id = effective_selected_document_id
- document_scope = effective_document_scope
+ log_event(
+ '[MixedSourceChatSearch] Activated streaming explicit selected-source context.',
+ extra={
+ 'selection_mode': 'selected',
+ 'requested_source_count': len(mixed_source_manifest),
+ 'authorized_source_count': len(effective_selected_document_ids),
+ 'narrative_source_count': len(mixed_source_narrative_document_ids),
+ 'tabular_source_count': len(mixed_source_tabular_sources),
+ 'omitted_source_count': len(
+ mixed_source_partitions.get('unresolved_sources') or []
+ ),
+ },
+ level=logging.INFO,
+ )
+ else:
+ _maybe_resolve_chat_source_manifest(
+ settings,
+ user_id,
+ conversation_id,
+ effective_selected_document_ids,
+ scope_context,
+ )
+ request_document_context_enabled = bool(
+ hybrid_search_enabled
+ or (
+ is_mixed_source_chat_search_enabled(settings)
+ and document_context_requested
+ )
+ )
# Determine chat type
actual_chat_type = 'personal_single_user'
@@ -17175,13 +19878,13 @@ def build_streaming_capability_usage():
user_metadata['button_states'] = {
'image_generation': False,
- 'document_search': hybrid_search_enabled,
+ 'document_search': request_document_context_enabled,
'web_search': bool(web_search_enabled),
'url_access': bool(url_access_enabled),
'deep_research': bool(deep_research_enabled)
}
user_metadata['capability_usage'] = _build_capability_usage_metadata(
- workspace_search_enabled=hybrid_search_enabled,
+ workspace_search_enabled=request_document_context_enabled,
document_action_type=DOCUMENT_ACTION_TYPE_NONE,
document_scope=effective_document_scope,
selected_document_ids=effective_selected_document_ids,
@@ -17193,13 +19896,16 @@ def build_streaming_capability_usage():
deep_research_enabled=deep_research_enabled,
)
- # Document search scope and selections
- if hybrid_search_enabled:
+ if request_document_context_enabled:
user_metadata['workspace_search'] = {
'search_enabled': True,
+ 'selection_mode': selection_mode,
+ 'document_context_requested': document_context_requested,
+ 'hybrid_search_preference': bool(hybrid_search_enabled),
'document_scope': effective_document_scope,
'selected_document_id': effective_selected_document_id,
'selected_document_ids': effective_selected_document_ids,
+ 'requested_document_ids': requested_selected_document_ids,
'active_group_ids': effective_active_group_ids,
'active_public_workspace_ids': effective_active_public_workspace_ids,
'classification': classifications_to_send
@@ -17218,7 +19924,6 @@ def build_streaming_capability_usage():
user_metadata['workspace_search']['auto_linked_chat_upload_document_ids'] = auto_linked_chat_upload_document_ids
user_metadata['workspace_search']['auto_linked_chat_upload_document_count'] = len(auto_linked_chat_upload_document_ids)
- # Get document details if specific document selected
if effective_selected_document_id and effective_selected_document_id != "all":
try:
doc_info = _resolve_chat_selected_document_metadata(
@@ -17236,10 +19941,8 @@ def build_streaming_capability_usage():
except Exception as e:
debug_print(f"Error retrieving document details: {e}")
- # Add scope-specific details
if effective_document_scope == 'group' and effective_active_group_id:
try:
- from functions_debug import debug_print
debug_print(f"Workspace search - looking up group for id: {effective_active_group_id}")
group_doc = find_group_by_id(effective_active_group_id)
debug_print(f"Workspace search group lookup result: {group_doc}")
@@ -17251,7 +19954,6 @@ def build_streaming_capability_usage():
else:
debug_print(f"Workspace search - no group found or no name for id: {effective_active_group_id}")
user_metadata['workspace_search']['group_name'] = None
-
except Exception as e:
debug_print(f"Error retrieving group details: {e}")
user_metadata['workspace_search']['group_name'] = None
@@ -17259,7 +19961,6 @@ def build_streaming_capability_usage():
traceback.print_exc()
if effective_document_scope == 'public' and effective_active_public_workspace_id:
- # Check if public workspace status allows chat operations
try:
from functions_public_workspaces import find_public_workspace_by_id, check_public_workspace_status_allows_operation
workspace_doc = find_public_workspace_by_id(effective_active_public_workspace_id)
@@ -17300,7 +20001,6 @@ def build_streaming_capability_usage():
'conversation_id': conversation_id
}
- # --- Threading Logic for Streaming ---
previous_thread_id = None
try:
last_msg_query = f"""
@@ -17320,8 +20020,6 @@ def build_streaming_capability_usage():
current_user_thread_id = str(uuid.uuid4())
latest_thread_id = current_user_thread_id
-
- # Add thread information to user metadata
user_metadata['thread_info'] = {
'thread_id': current_user_thread_id,
'previous_thread_id': previous_thread_id,
@@ -17344,14 +20042,13 @@ def build_streaming_capability_usage():
f"[Streaming] Saved user message {user_message_id} | thread_id={current_user_thread_id} | previous_thread_id={previous_thread_id}"
)
- # Log activity
try:
log_chat_activity(
user_id=user_id,
conversation_id=conversation_id,
message_type='user_message',
message_length=len(user_message) if user_message else 0,
- has_document_search=hybrid_search_enabled,
+ has_document_search=request_document_context_enabled,
has_image_generation=False,
document_scope=effective_document_scope,
chat_context=actual_chat_type,
@@ -17362,7 +20059,6 @@ def build_streaming_capability_usage():
except Exception as e:
debug_print(f"Activity logging error: {e}")
- # Update conversation title
title_updated = _set_initial_conversation_title(conversation_item, user_message)
conversation_item['last_updated'] = datetime.utcnow().isoformat()
@@ -17556,9 +20252,20 @@ def record_and_publish_streaming_thought(thought_payload):
except Exception as ex:
debug_print(f"[Content Safety - Streaming] Unexpected error: {ex}")
- if not original_hybrid_search_enabled and not explicit_external_retrieval_requested:
+ if (
+ not original_hybrid_search_enabled
+ and not explicit_external_retrieval_requested
+ and not mixed_source_explicit_selection
+ ):
prior_grounded_document_refs = _normalize_prior_grounded_document_refs(conversation_item)
if prior_grounded_document_refs:
+ continuity_decision = _resolve_reauthorized_continuity_decision(
+ settings,
+ user_id,
+ conversation_id,
+ prior_grounded_document_refs,
+ request_correlation_id=mixed_source_request_correlation_id,
+ )
yield emit_thought(
'history_context',
'Checking whether prior conversation context already answers the question',
@@ -17597,7 +20304,10 @@ def record_and_publish_streaming_thought(thought_payload):
f"[Streaming][History Fallback] History-only sufficiency assessment failed: {assessment_error}"
)
- if history_only_answerability and history_only_answerability.get('can_answer_from_history'):
+ if _can_reuse_prior_grounded_history(
+ history_only_answerability,
+ continuity_decision,
+ ):
yield emit_thought(
'history_context',
'Prior conversation context appears sufficient without new document retrieval',
@@ -17671,9 +20381,68 @@ def record_and_publish_streaming_thought(thought_payload):
'No prior grounded documents were available; using conversation history only'
)
+ if (
+ is_mixed_source_chat_search_enabled(settings)
+ and history_grounded_search_used
+ ):
+ history_context = _resolve_chat_mixed_source_partition(
+ settings,
+ user_id,
+ conversation_id,
+ effective_selected_document_ids,
+ 'history',
+ active_group_ids=effective_active_group_ids,
+ active_public_workspace_ids=effective_active_public_workspace_ids,
+ cancel_requested=stream_cancel_requested,
+ request_correlation_id=mixed_source_request_correlation_id,
+ )
+ mixed_source_manifest = history_context.get('manifest') or []
+ mixed_source_partitions = history_context.get('partitions') or {}
+ mixed_source_narrative_document_ids = list(
+ history_context.get('narrative_document_ids') or []
+ )
+ mixed_source_tabular_sources = list(
+ history_context.get('tabular_sources') or []
+ )
+ if is_mixed_source_conversation_continuity_enabled(settings):
+ continuity_decision = _build_reauthorized_continuity_decision(
+ prior_grounded_document_refs,
+ mixed_source_manifest,
+ explicit_selection=False,
+ )
+ effective_selected_document_ids = (
+ mixed_source_narrative_document_ids
+ + _get_manifest_partition_document_ids(
+ mixed_source_partitions,
+ 'tabular_sources',
+ )
+ )
+ effective_selected_document_id = (
+ effective_selected_document_ids[0]
+ if len(effective_selected_document_ids) == 1
+ else None
+ )
+
# Hybrid search (if enabled)
combined_documents = []
- if hybrid_search_enabled or history_grounded_search_used:
+ mixed_source_document_context_active = bool(
+ (hybrid_search_enabled or history_grounded_search_used)
+ if not is_mixed_source_chat_search_enabled(settings)
+ else (
+ document_context_requested
+ or hybrid_search_enabled
+ or history_grounded_search_used
+ )
+ )
+ mixed_source_narrative_search_active = bool(
+ mixed_source_document_context_active
+ and (
+ not is_mixed_source_chat_search_enabled(settings)
+ or not mixed_source_manifest
+ or mixed_source_narrative_document_ids
+ )
+ )
+ if mixed_source_narrative_search_active:
debug_print(
"[Streaming] Starting hybrid search | "
f"conversation_id={conversation_id} | doc_scope={effective_document_scope} | "
@@ -17693,7 +20462,7 @@ def record_and_publish_streaming_thought(thought_payload):
search_args = {
"query": search_query,
"user_id": user_id,
- "top_n": 12,
+ "top_n": 50,
"doc_scope": effective_document_scope,
}
@@ -17716,8 +20485,14 @@ def record_and_publish_streaming_thought(thought_payload):
):
search_args['active_public_workspace_id'] = effective_active_public_workspace_id
- if effective_selected_document_ids:
- search_args['document_ids'] = effective_selected_document_ids
+ search_document_ids = (
+ mixed_source_narrative_document_ids
+ if is_mixed_source_chat_search_enabled(settings)
+ and mixed_source_manifest
+ else effective_selected_document_ids
+ )
+ if search_document_ids:
+ search_args['document_ids'] = search_document_ids
elif effective_selected_document_id:
search_args['document_id'] = effective_selected_document_id
if auto_linked_chat_upload_document_ids:
@@ -17747,20 +20522,75 @@ def record_and_publish_streaming_thought(thought_payload):
search_results = assigned_search_results
else:
search_results = hybrid_search(**search_args)
+
+ if (
+ is_mixed_source_chat_search_enabled(settings)
+ and not mixed_source_explicit_selection
+ and not history_grounded_search_used
+ ):
+ relevance_context = _resolve_chat_mixed_source_relevance_context(
+ settings=settings,
+ user_id=user_id,
+ conversation_id=conversation_id,
+ query=search_query,
+ search_results=search_results,
+ document_scope=effective_document_scope,
+ candidate_document_ids=(
+ assigned_knowledge_filters.get('document_ids')
+ if assigned_knowledge_filters
+ and assigned_knowledge_filters.get('has_workspace_knowledge')
+ else None
+ ),
+ tags_filter=tags_filter,
+ active_group_ids=effective_active_group_ids,
+ active_public_workspace_ids=effective_active_public_workspace_ids,
+ cancel_requested=stream_cancel_requested,
+ request_correlation_id=mixed_source_request_correlation_id,
+ )
+ mixed_source_manifest = relevance_context.get('manifest') or []
+ mixed_source_partitions = relevance_context.get('partitions') or {}
+ mixed_source_narrative_document_ids = list(
+ relevance_context.get('narrative_document_ids') or []
+ )
+ mixed_source_tabular_sources = list(
+ relevance_context.get('tabular_sources') or []
+ )
+ search_results = list(
+ relevance_context.get('search_results') or []
+ )
debug_print(
f"[Streaming] Hybrid search completed | results={len(search_results) if search_results else 0}"
)
except SemanticSearchQuotaExceededError as e:
debug_print(f"Semantic search quota exceeded during streaming hybrid search: {e}")
- yield emit_thought(
- 'search',
- 'Workspace search warning: Semantic Ranker quota has been exceeded.',
- detail=e.user_message,
- )
- yield f"data: {json.dumps({'error': e.user_message, 'warning_type': SEMANTIC_SEARCH_QUOTA_WARNING_TYPE, 'service_health_warning': True})}\n\n"
- return
+ if (
+ is_mixed_source_chat_search_enabled(settings)
+ and mixed_source_manifest
+ and mixed_source_tabular_sources
+ ):
+ mixed_source_narrative_retrieval_failed = True
+ search_results = []
+ yield emit_thought(
+ 'search',
+ 'Narrative search was unavailable; continuing with available table evidence.',
+ )
+ else:
+ yield emit_thought(
+ 'search',
+ 'Workspace search warning: Semantic Ranker quota has been exceeded.',
+ detail=e.user_message,
+ )
+ yield f"data: {json.dumps({'error': e.user_message, 'warning_type': SEMANTIC_SEARCH_QUOTA_WARNING_TYPE, 'service_health_warning': True})}\n\n"
+ return
except Exception as e:
debug_print(f"Error during hybrid search: {e}")
+ if (
+ is_mixed_source_chat_search_enabled(settings)
+ and mixed_source_manifest
+ and mixed_source_tabular_sources
+ ):
+ mixed_source_narrative_retrieval_failed = True
+ search_results = []
if search_results:
unique_doc_names_stream = set(doc.get('file_name', 'Unknown') for doc in search_results)
@@ -17952,11 +20782,12 @@ def record_and_publish_streaming_thought(thought_payload):
retrieved_content = "\n\n".join(retrieved_texts)
system_prompt_search = build_search_augmentation_system_prompt(retrieved_content)
- system_messages_for_augmentation.append({
- 'role': 'system',
- 'content': system_prompt_search,
- 'documents': combined_documents
- })
+ if not is_mixed_source_chat_search_enabled(settings):
+ system_messages_for_augmentation.append({
+ 'role': 'system',
+ 'content': system_prompt_search,
+ 'documents': combined_documents
+ })
hybrid_citations_list.sort(key=_build_hybrid_citation_sort_key, reverse=True)
elif history_grounded_search_used:
@@ -17985,7 +20816,11 @@ def record_and_publish_streaming_thought(thought_payload):
workspace_tabular_file_contexts = []
workspace_tabular_files = set()
- if (hybrid_search_enabled or history_grounded_search_used) and is_tabular_processing_enabled(settings):
+ if (
+ not is_mixed_source_chat_search_enabled(settings)
+ and (hybrid_search_enabled or history_grounded_search_used)
+ and is_tabular_processing_enabled(settings)
+ ):
workspace_tabular_file_contexts = collect_workspace_tabular_file_contexts(
combined_documents=combined_documents,
selected_document_ids=effective_selected_document_ids,
@@ -18001,7 +20836,144 @@ def record_and_publish_streaming_thought(thought_payload):
file_context['file_name'] for file_context in workspace_tabular_file_contexts
}
- if (hybrid_search_enabled or history_grounded_search_used) and workspace_tabular_files and is_tabular_processing_enabled(settings):
+ if (
+ is_mixed_source_chat_search_enabled(settings)
+ and mixed_source_document_context_active
+ and mixed_source_manifest
+ ):
+ effective_mixed_source_selection_mode = (
+ 'selected'
+ if mixed_source_explicit_selection
+ else 'history'
+ if history_grounded_search_used
+ else 'relevance'
+ )
+ mixed_source_evidence_envelopes.extend(
+ build_failed_narrative_evidence_envelopes(
+ mixed_source_partitions.get('narrative_sources') or [],
+ effective_mixed_source_selection_mode,
+ )
+ if mixed_source_narrative_retrieval_failed
+ else build_narrative_evidence_envelopes(
+ mixed_source_partitions.get('narrative_sources') or [],
+ search_results,
+ effective_mixed_source_selection_mode,
+ )
+ )
+ mixed_source_tabular_result = _execute_mixed_source_tabular_evidence(
+ tabular_sources=mixed_source_tabular_sources,
+ selection_mode=effective_mixed_source_selection_mode,
+ has_narrative_sources=bool(mixed_source_narrative_document_ids),
+ user_question=user_message,
+ user_id=user_id,
+ conversation_id=conversation_id,
+ gpt_model=gpt_model,
+ settings=settings,
+ thought_tracker=thought_tracker,
+ live_thought_callback=publish_live_plugin_thought,
+ model_context=tabular_model_context,
+ cancel_requested=stream_cancel_requested,
+ request_correlation_id=mixed_source_request_correlation_id,
+ )
+ mixed_source_evidence_envelopes.extend(
+ mixed_source_tabular_result.get('evidence_envelopes') or []
+ )
+ mixed_source_native_token_usage = mixed_source_tabular_result.get('token_usage')
+ agent_citations_list.extend(
+ mixed_source_tabular_result.get('agent_citations') or []
+ )
+ generated_tabular_outputs_list.extend(
+ mixed_source_tabular_result.get('generated_outputs') or []
+ )
+ generated_analysis_artifacts_list.extend(
+ mixed_source_tabular_result.get('generated_outputs') or []
+ )
+ mixed_source_handoff = build_mixed_source_evidence_handoff(
+ mixed_source_manifest,
+ mixed_source_evidence_envelopes,
+ effective_mixed_source_selection_mode,
+ mode='chat',
+ telemetry_settings=settings,
+ request_correlation_id=mixed_source_request_correlation_id,
+ )
+ system_messages_for_augmentation.append(mixed_source_handoff)
+ mixed_source_coverage = mixed_source_handoff.get(
+ 'mixed_source_coverage',
+ {},
+ )
+ mixed_source_has_authorized_evidence_sources = bool(
+ mixed_source_narrative_document_ids
+ or mixed_source_tabular_sources
+ )
+ user_metadata['mixed_source_coverage'] = mixed_source_coverage
+ if continuity_decision:
+ user_metadata['source_continuity'] = continuity_decision
+ user_message_doc['metadata'] = user_metadata
+ cosmos_messages_container.upsert_item(user_message_doc)
+ if continuity_decision:
+ emit_mixed_source_telemetry(
+ settings,
+ 'continuity',
+ 'chat',
+ request_correlation_id=mixed_source_request_correlation_id,
+ metrics={
+ 'history_source_count': continuity_decision.get('prior_source_count', 0),
+ 'history_rerun_count': int(bool(continuity_decision.get('requires_native_execution'))),
+ 'history_reuse_count': int(not continuity_decision.get('requires_native_execution')),
+ },
+ dimensions={
+ 'selection_mode': effective_mixed_source_selection_mode,
+ 'continuity_decision': (
+ 'rerun'
+ if continuity_decision.get('requires_native_execution')
+ else 'reuse'
+ ),
+ },
+ )
+ emit_mixed_source_telemetry(
+ settings,
+ 'background_export',
+ 'chat',
+ request_correlation_id=mixed_source_request_correlation_id,
+ metrics={
+ 'background_export_count': sum(
+ bool(output.get('background_export'))
+ for output in generated_tabular_outputs_list
+ if isinstance(output, dict)
+ ),
+ 'artifact_count': len(generated_analysis_artifacts_list),
+ 'citation_count': len(agent_citations_list) + len(hybrid_citations_list),
+ },
+ dimensions={
+ 'selection_mode': effective_mixed_source_selection_mode,
+ },
+ )
+ log_event(
+ '[MixedSourceChatSearch] Prepared streaming bounded mixed-source synthesis evidence.',
+ extra={
+ 'selection_mode': effective_mixed_source_selection_mode,
+ 'narrative_result_count': len(search_results or []),
+ 'tabular_candidate_count': len(mixed_source_tabular_sources),
+ 'tabular_completed_count': sum(
+ 1
+ for envelope in mixed_source_evidence_envelopes
+ if envelope.get('source_kind') == 'tabular'
+ and envelope.get('status') == 'completed'
+ ),
+ 'mixed_synthesis_count': 1,
+ 'partial_coverage': bool(
+ mixed_source_coverage.get('partial_coverage')
+ ),
+ },
+ level=logging.INFO,
+ )
+
+ if (
+ not is_mixed_source_chat_search_enabled(settings)
+ and (hybrid_search_enabled or history_grounded_search_used)
+ and workspace_tabular_files
+ and is_tabular_processing_enabled(settings)
+ ):
tabular_source_hint = determine_tabular_source_hint(
effective_document_scope,
active_group_id=effective_active_group_id,
@@ -18273,7 +21245,9 @@ def record_and_publish_streaming_thought(thought_payload):
# Update message chat type
message_chat_type = None
- if (hybrid_search_enabled or history_grounded_search_used) and search_results and len(search_results) > 0:
+ if mixed_source_document_context_active and (
+ search_results or mixed_source_has_authorized_evidence_sources
+ ):
if effective_document_scope == 'group':
message_chat_type = 'group'
elif effective_document_scope == 'public':
@@ -18285,8 +21259,10 @@ def record_and_publish_streaming_thought(thought_payload):
source_review_used = _source_review_metadata_used(source_review_result)
user_metadata['capability_usage'] = _build_capability_usage_metadata(
- workspace_search_enabled=hybrid_search_enabled or history_grounded_search_used,
- workspace_search_used=bool(search_results),
+ workspace_search_enabled=mixed_source_document_context_active,
+ workspace_search_used=bool(
+ search_results or mixed_source_has_authorized_evidence_sources
+ ),
workspace_search_result_count=len(search_results or []),
document_action_type=DOCUMENT_ACTION_TYPE_NONE,
document_scope=effective_document_scope,
@@ -18328,7 +21304,10 @@ def record_and_publish_streaming_thought(thought_payload):
gpt_model=gpt_model,
user_message_id=user_message_id,
fallback_user_message=user_message,
- include_assistant_citation_context=not explicit_external_retrieval_requested,
+ include_assistant_citation_context=(
+ not explicit_external_retrieval_requested
+ and not mixed_source_explicit_selection
+ ),
)
summary_of_older = history_segments['summary_of_older']
chat_tabular_files = history_segments['chat_tabular_files']
@@ -18355,7 +21334,14 @@ def record_and_publish_streaming_thought(thought_payload):
final_api_source_refs.extend(history_debug_info.get('history_message_source_refs', []))
# --- Mini SK analysis for tabular files uploaded directly to chat ---
- if chat_tabular_files and is_tabular_processing_enabled(settings):
+ if (
+ chat_tabular_files
+ and is_tabular_processing_enabled(settings)
+ and not (
+ is_mixed_source_chat_search_enabled(settings)
+ and (mixed_source_explicit_selection or mixed_source_tabular_sources)
+ )
+ ):
chat_tabular_filenames_str = ", ".join(chat_tabular_files)
chat_tabular_execution_mode = get_tabular_execution_mode(user_message)
log_event(
@@ -18492,7 +21478,13 @@ def record_and_publish_streaming_thought(thought_payload):
debug_print("[Chat Tabular SK] Streaming: Analysis returned None, relying on existing file context")
except Exception as e:
- yield f"data: {json.dumps({'error': f'History error: {str(e)}'})}\n\n"
+ log_event(
+ f'[Streaming] Failed to prepare conversation history: {e}',
+ extra={'conversation_id': conversation_id, 'user_id': user_id},
+ level=logging.ERROR,
+ exceptionTraceback=True,
+ )
+ yield build_stream_error_event('Failed to prepare conversation history')
return
# Add system prompt
@@ -18527,6 +21519,10 @@ def record_and_publish_streaming_thought(thought_payload):
original_hybrid_search_enabled,
prior_grounded_document_refs,
explicit_external_retrieval_requested,
+ current_document_context_requested=(
+ is_mixed_source_chat_search_enabled(settings)
+ and document_context_requested
+ ),
):
history_grounding_message = build_history_grounding_system_message()
insert_idx = 0
@@ -18684,6 +21680,24 @@ def finalize_cancelled_stream_response():
'cancel_reason': cancel_reason,
}
+ if mixed_source_manifest:
+ _rollback_mixed_source_chat_publication(
+ user_id,
+ conversation_id,
+ generated_analysis_artifacts_list + generated_tabular_outputs_list,
+ )
+ return _build_stream_cancel_event(
+ conversation_id,
+ user_message_id=user_message_id,
+ reason=cancel_reason,
+ message_persisted=False,
+ extra_payload={
+ 'augmented': bool(system_messages_for_augmentation),
+ 'metadata': cancel_metadata,
+ 'thoughts_enabled': thought_tracker.enabled,
+ },
+ )
+
if partial_content:
assistant_timestamp = datetime.utcnow().isoformat()
prepared_agent_citations = persist_agent_citation_artifacts(
@@ -18879,6 +21893,8 @@ def finalize_cancelled_agent_stream_response():
'document_scope': effective_document_scope,
'group_id': effective_active_group_id if chat_type == 'group' else None,
'hybrid_search_enabled': hybrid_search_enabled,
+ 'selection_mode': selection_mode,
+ 'document_context_requested': mixed_source_document_context_active,
'selected_document_id': effective_selected_document_id,
'selected_document_ids': effective_selected_document_ids,
'active_group_ids': effective_active_group_ids,
@@ -18966,7 +21982,7 @@ def finalize_cancelled_agent_stream_response():
)
debug_print(f"❌ Agent streaming error: {stream_error}")
traceback.print_exc()
- error_payload = {'error': f'Agent streaming failed: {str(stream_error)}'}
+ error_payload = {'error': 'Agent streaming failed. Please try again.'}
if isinstance(stream_error, FoundryAgentUserAuthenticationRequired):
auth_response = getattr(stream_error, 'auth_response', {}) or {}
error_payload = {
@@ -19096,7 +22112,10 @@ def finalize_cancelled_agent_stream_response():
foundry_plugin_name = _get_foundry_agent_plugin_name(stream_selected_agent_type)
foundry_label = agent_name_used or _get_foundry_agent_label(stream_selected_agent_type)
for citation in foundry_citations:
- yield emit_thought('agent_tool_call', f"Agent retrieved citation from {_get_foundry_agent_label(stream_selected_agent_type)}")
+ yield emit_thought(
+ 'agent_tool_call',
+ _build_foundry_citation_thought_content(stream_selected_agent_type, citation)
+ )
serializable = make_json_serializable(citation)
if not isinstance(serializable, dict):
serializable = {'value': str(citation)}
@@ -19253,6 +22272,25 @@ def finalize_cancelled_agent_stream_response():
yield finalize_cancelled_stream_response()
return
+ token_usage_data = _merge_chat_token_usage(
+ mixed_source_native_token_usage,
+ token_usage_data,
+ )
+ if mixed_source_manifest:
+ emit_mixed_source_telemetry(
+ settings,
+ 'native_execution',
+ 'chat',
+ request_correlation_id=mixed_source_request_correlation_id,
+ metrics={
+ 'prompt_tokens': (token_usage_data or {}).get('prompt_tokens', 0),
+ 'completion_tokens': (token_usage_data or {}).get('completion_tokens', 0),
+ 'total_tokens': (token_usage_data or {}).get('total_tokens', 0),
+ 'token_request_count': (token_usage_data or {}).get('request_count', 0),
+ 'request_count': (token_usage_data or {}).get('request_count', 0),
+ },
+ )
+
# Stream complete - save message and send final metadata
accumulated_content_before_chart_append = accumulated_content
accumulated_content = _append_inline_chart_blocks_to_message(accumulated_content, agent_citations_list)
@@ -19265,6 +22303,30 @@ def finalize_cancelled_agent_stream_response():
user_info_for_assistant = response_message_context.get('user_info')
user_thread_id = response_message_context.get('thread_id')
user_previous_thread_id = response_message_context.get('previous_thread_id')
+ if mixed_source_manifest:
+ raise_if_mixed_source_cancelled(
+ stream_cancel_requested,
+ 'finalization',
+ request_correlation_id=mixed_source_request_correlation_id,
+ )
+ fresh_finalization_manifest = resolve_authorized_source_manifest(
+ [source.get('document_id') for source in mixed_source_manifest],
+ user_id=user_id,
+ selection_mode=effective_mixed_source_selection_mode,
+ conversation_id=conversation_id,
+ active_group_ids=effective_active_group_ids,
+ active_public_workspace_ids=effective_active_public_workspace_ids,
+ doc_scope=effective_document_scope,
+ cancel_requested=stream_cancel_requested,
+ request_correlation_id=mixed_source_request_correlation_id,
+ )
+ _validate_reauthorized_manifest_finalization(
+ mixed_source_manifest,
+ fresh_finalization_manifest,
+ settings=settings,
+ mode='chat',
+ request_correlation_id=mixed_source_request_correlation_id,
+ )
assistant_timestamp = datetime.utcnow().isoformat()
prepared_agent_citations = persist_agent_citation_artifacts(
conversation_id=conversation_id,
@@ -19272,16 +22334,27 @@ def finalize_cancelled_agent_stream_response():
agent_citations=agent_citations_list,
created_timestamp=assistant_timestamp,
user_info=user_info_for_assistant,
+ cancel_requested=stream_cancel_requested,
+ request_correlation_id=mixed_source_request_correlation_id,
+ )
+ raise_if_mixed_source_cancelled(
+ stream_cancel_requested,
+ 'artifact_publication',
+ request_correlation_id=mixed_source_request_correlation_id,
)
- assistant_table_generated_output = maybe_create_assistant_table_generated_output(
+ generated_file_output = maybe_create_generated_file_output(
user_question=user_message,
assistant_content=accumulated_content,
conversation_id=conversation_id,
+ function_results=agent_citations_list,
existing_outputs=generated_analysis_artifacts_list + generated_tabular_outputs_list,
+ cancel_requested=stream_cancel_requested,
+ request_correlation_id=mixed_source_request_correlation_id,
)
- if assistant_table_generated_output:
- generated_analysis_artifacts_list.append(assistant_table_generated_output)
- generated_tabular_outputs_list.append(assistant_table_generated_output)
+ if generated_file_output:
+ generated_analysis_artifacts_list.append(generated_file_output)
+ if generated_file_output.get('output_format') == 'csv':
+ generated_tabular_outputs_list.append(generated_file_output)
assistant_file_generated_output = maybe_create_assistant_file_generated_output(
user_question=user_message,
assistant_content=accumulated_content,
@@ -19291,6 +22364,25 @@ def finalize_cancelled_agent_stream_response():
if assistant_file_generated_output:
generated_analysis_artifacts_list.append(assistant_file_generated_output)
accumulated_content = _build_assistant_file_output_handoff(assistant_file_generated_output)
+ if mixed_source_manifest:
+ fresh_finalization_manifest = resolve_authorized_source_manifest(
+ [source.get('document_id') for source in mixed_source_manifest],
+ user_id=user_id,
+ selection_mode=effective_mixed_source_selection_mode,
+ conversation_id=conversation_id,
+ active_group_ids=effective_active_group_ids,
+ active_public_workspace_ids=effective_active_public_workspace_ids,
+ doc_scope=effective_document_scope,
+ cancel_requested=stream_cancel_requested,
+ request_correlation_id=mixed_source_request_correlation_id,
+ )
+ _validate_reauthorized_manifest_finalization(
+ mixed_source_manifest,
+ fresh_finalization_manifest,
+ settings=settings,
+ mode='chat',
+ request_correlation_id=mixed_source_request_correlation_id,
+ )
generated_analysis_metadata = _build_generated_analysis_metadata(
generated_analysis_artifacts=generated_analysis_artifacts_list,
generated_tabular_outputs=generated_tabular_outputs_list,
@@ -19342,6 +22434,11 @@ def finalize_cancelled_agent_stream_response():
}
})
cosmos_messages_container.upsert_item(assistant_doc)
+ raise_if_mixed_source_cancelled(
+ stream_cancel_requested,
+ 'finalization',
+ request_correlation_id=mixed_source_request_correlation_id,
+ )
if use_agent_streaming and agent_name_used:
agent_scope_for_usage = 'personal'
agent_group_id_for_usage = None
@@ -19420,6 +22517,16 @@ def finalize_cancelled_agent_stream_response():
debug_print(f"Warning: Could not update streaming user message metadata: {e}")
try:
+ source_continuity_refs = None
+ if (
+ is_mixed_source_conversation_continuity_enabled(settings)
+ and mixed_source_manifest
+ ):
+ source_continuity_refs = _build_mixed_source_continuity_refs(
+ mixed_source_manifest,
+ mixed_source_evidence_envelopes,
+ effective_mixed_source_selection_mode,
+ )
conversation_item = collect_conversation_metadata(
user_message=user_message,
conversation_id=conversation_id,
@@ -19429,7 +22536,7 @@ def finalize_cancelled_agent_stream_response():
document_scope=effective_document_scope,
selected_document_id=effective_selected_document_id,
model_deployment=final_model_used if use_agent_streaming else gpt_model,
- hybrid_search_enabled=hybrid_search_enabled or history_grounded_search_used,
+ hybrid_search_enabled=mixed_source_document_context_active,
image_gen_enabled=False,
selected_documents=combined_documents if combined_documents else None,
selected_agent=agent_name_used if use_agent_streaming else None,
@@ -19437,7 +22544,8 @@ def finalize_cancelled_agent_stream_response():
search_results=search_results if search_results else None,
conversation_item=conversation_item,
active_public_workspace_id=effective_active_public_workspace_id,
- active_public_workspace_ids=effective_active_public_workspace_ids
+ active_public_workspace_ids=effective_active_public_workspace_ids,
+ source_continuity_refs=source_continuity_refs,
)
except Exception as e:
debug_print(f"Error collecting conversation metadata: {e}")
@@ -19505,6 +22613,52 @@ def finalize_cancelled_agent_stream_response():
)
yield f"data: {json.dumps(final_data)}\n\n"
+ except MixedSourceCancellationError:
+ if mixed_source_manifest:
+ try:
+ cosmos_messages_container.delete_item(
+ item=assistant_message_id,
+ partition_key=conversation_id,
+ )
+ except Exception:
+ pass
+ _rollback_mixed_source_chat_publication(
+ user_id,
+ conversation_id,
+ generated_analysis_artifacts_list + generated_tabular_outputs_list,
+ compact_citations=locals().get('prepared_agent_citations') or [],
+ )
+ yield _build_stream_cancel_event(
+ conversation_id,
+ user_message_id=user_message_id,
+ reason=stream_session.get_cancel_reason() if stream_session else 'user_requested',
+ message_persisted=False,
+ extra_payload={
+ 'augmented': bool(system_messages_for_augmentation),
+ 'thoughts_enabled': thought_tracker.enabled,
+ },
+ )
+ return
+ yield finalize_cancelled_stream_response()
+ return
+ except MixedSourceFinalizationError:
+ if mixed_source_manifest:
+ try:
+ cosmos_messages_container.delete_item(
+ item=assistant_message_id,
+ partition_key=conversation_id,
+ )
+ except Exception:
+ pass
+ _rollback_mixed_source_chat_publication(
+ user_id,
+ conversation_id,
+ generated_analysis_artifacts_list + generated_tabular_outputs_list,
+ compact_citations=locals().get('prepared_agent_citations') or [],
+ )
+ yield f"data: {json.dumps({'error': 'Selected source state changed before final output could be published.', 'conversation_id': conversation_id})}\n\n"
+ return
+ raise
except Exception as e:
error_msg = str(e)
debug_print(f"Error during streaming: {error_msg}")
@@ -19541,7 +22695,8 @@ def finalize_cancelled_agent_stream_response():
'agent_name': agent_name_used if use_agent_streaming else None,
'metadata': {
'incomplete': True,
- 'error': error_msg,
+ 'error': 'stream_interrupted',
+ 'error_message': CLIENT_SAFE_STREAM_ERROR_MESSAGE,
'reasoning_effort': reasoning_effort,
'history_context': history_debug_info,
'capability_usage': build_streaming_capability_usage(),
@@ -19561,14 +22716,26 @@ def finalize_cancelled_agent_stream_response():
except Exception as ex:
pass
- yield f"data: {json.dumps({'error': error_msg, 'partial_content': accumulated_content})}\n\n"
+ yield build_stream_error_event(
+ CLIENT_SAFE_STREAM_ERROR_MESSAGE,
+ partial_content=accumulated_content,
+ )
except Exception as e:
- import traceback
error_traceback = traceback.format_exc()
debug_print(f"[STREAM API ERROR] Unhandled exception: {str(e)}")
debug_print(f"[STREAM API ERROR] Full traceback:\n{error_traceback}")
- yield f"data: {json.dumps({'error': f'Internal server error: {str(e)}'})}\n\n"
+ log_event(
+ f'[Streaming] Unhandled stream error: {e}',
+ extra={
+ 'conversation_id': finalized_conversation_id,
+ 'user_id': user_id,
+ 'traceback': error_traceback,
+ },
+ level=logging.ERROR,
+ exceptionTraceback=True,
+ )
+ yield build_stream_error_event()
return build_background_stream_response(generate, stream_session=stream_session)
@@ -19638,10 +22805,29 @@ def tabular_generated_output_run_resume_api(run_id):
resume_result = resume_tabular_generated_output_run(user_id, run_id)
if not resume_result:
return jsonify({'error': 'Tabular generated-output run not found'}), 404
+ if resume_result.get('authorization_failed'):
+ return jsonify(resume_result), 403
if not resume_result.get('success'):
return jsonify(resume_result), 409
return jsonify(resume_result)
+ @bp.route('/api/tabular/generated-output/runs//cancel', methods=['POST'])
+ @swagger_route(security=get_auth_security())
+ @login_required
+ @user_required
+ def tabular_generated_output_run_cancel_api(run_id):
+ """Cancel a generated-output run owned by the current user."""
+ user_id = get_current_user_id()
+ if not user_id:
+ return jsonify({'error': 'User not authenticated'}), 401
+
+ cancel_result = cancel_tabular_generated_output_run(user_id, run_id)
+ if not cancel_result:
+ return jsonify({'error': 'Tabular generated-output run not found'}), 404
+ if not cancel_result.get('success'):
+ return jsonify(cancel_result), 409
+ return jsonify(cancel_result)
+
@bp.route('/api/chat/stream/reattach/', methods=['GET'])
@swagger_route(security=get_auth_security())
@login_required
@@ -19828,7 +23014,13 @@ def mask_message_api(message_id):
except Exception as e:
debug_print(f"Error fetching message {message_id}: {str(e)}")
- return jsonify({'error': f'Error fetching message: {str(e)}'}), 500
+ log_event(
+ f'[MaskMessage] Failed to fetch message: {e}',
+ extra={'message_id': message_id, 'user_id': user_id},
+ level=logging.ERROR,
+ exceptionTraceback=True,
+ )
+ return build_json_error_response('Failed to fetch message')
# Initialize metadata if it doesn't exist
if 'metadata' not in message_doc:
@@ -19844,14 +23036,21 @@ def mask_message_api(message_id):
user_display_name,
)
except ValueError as ex:
- return jsonify({'error': str(ex)}), 400
+ debug_print(f'[MaskMessage] Invalid mask request: {ex}')
+ return jsonify({'error': 'Invalid mask request'}), 400
# Update the message in Cosmos DB
try:
cosmos_messages_container.upsert_item(message_doc)
except Exception as e:
debug_print(f"Error updating message {message_id}: {str(e)}")
- return jsonify({'error': f'Error updating message: {str(e)}'}), 500
+ log_event(
+ f'[MaskMessage] Failed to update message: {e}',
+ extra={'message_id': message_id, 'user_id': user_id},
+ level=logging.ERROR,
+ exceptionTraceback=True,
+ )
+ return build_json_error_response('Failed to update message')
return jsonify({
'success': True,
@@ -19861,14 +23060,20 @@ def mask_message_api(message_id):
}), 200
except Exception as e:
- import traceback
error_traceback = traceback.format_exc()
debug_print(f"[MASK API ERROR] Unhandled exception: {str(e)}")
debug_print(f"[MASK API ERROR] Full traceback:\n{error_traceback}")
- return jsonify({
- 'error': f'Internal server error: {str(e)}',
- 'details': error_traceback if current_app.debug else None
- }), 500
+ log_event(
+ f'[MaskMessage] Unhandled exception: {e}',
+ extra={
+ 'message_id': message_id,
+ 'user_id': user_id if 'user_id' in locals() else None,
+ 'traceback': error_traceback,
+ },
+ level=logging.ERROR,
+ exceptionTraceback=True,
+ )
+ return build_json_error_response()
def _format_history_message_ref(message):
@@ -20213,6 +23418,17 @@ def add_ref(raw_ref):
'scope_id': scope_id,
'file_name': raw_ref.get('file_name') or raw_ref.get('title'),
'classification': raw_ref.get('classification'),
+ 'source_role': raw_ref.get('source_role'),
+ 'requested_order': raw_ref.get('requested_order'),
+ 'source_kind': raw_ref.get('source_kind'),
+ 'engine': raw_ref.get('engine'),
+ 'source_version': raw_ref.get('source_version'),
+ 'status': raw_ref.get('status'),
+ 'coverage': dict(raw_ref.get('coverage') or {}),
+ 'selection_origin': raw_ref.get('selection_origin'),
+ 'action_mode': raw_ref.get('action_mode'),
+ 'citation_count': _safe_metadata_int(raw_ref.get('citation_count')),
+ 'artifact_count': _safe_metadata_int(raw_ref.get('artifact_count')),
}
if scope == 'group':
@@ -20251,6 +23467,8 @@ def build_prior_grounded_document_search_parameters(grounded_refs):
document_ids = []
group_ids = []
public_workspace_ids = []
+ chat_conversation_ids = []
+ document_scopes = {}
scope_types = set()
for ref in grounded_refs or []:
@@ -20265,6 +23483,10 @@ def build_prior_grounded_document_search_parameters(grounded_refs):
if not scope:
continue
scope_types.add(scope)
+ document_scopes[document_id] = {
+ 'scope': scope,
+ 'scope_id': str(ref.get('scope_id') or '').strip(),
+ }
if scope == 'group':
group_id = str(ref.get('group_id') or ref.get('scope_id') or '').strip()
@@ -20274,6 +23496,10 @@ def build_prior_grounded_document_search_parameters(grounded_refs):
public_workspace_id = str(ref.get('public_workspace_id') or ref.get('scope_id') or '').strip()
if public_workspace_id and public_workspace_id not in public_workspace_ids:
public_workspace_ids.append(public_workspace_id)
+ elif scope == 'chat':
+ chat_conversation_id = str(ref.get('scope_id') or '').strip()
+ if chat_conversation_id and chat_conversation_id not in chat_conversation_ids:
+ chat_conversation_ids.append(chat_conversation_id)
if len(scope_types) == 1:
doc_scope = next(iter(scope_types))
@@ -20287,6 +23513,8 @@ def build_prior_grounded_document_search_parameters(grounded_refs):
'active_group_id': group_ids[0] if group_ids else None,
'active_public_workspace_ids': public_workspace_ids,
'active_public_workspace_id': public_workspace_ids[0] if public_workspace_ids else None,
+ 'chat_conversation_ids': chat_conversation_ids,
+ 'document_scopes': document_scopes,
'scope_types': sorted(scope_types),
}
@@ -20302,6 +23530,13 @@ def revalidate_prior_grounded_document_search_parameters(user_id, search_paramet
)
allowed_group_ids = scope_context['active_group_ids']
allowed_public_workspace_ids = scope_context['active_public_workspace_ids']
+ allowed_chat_conversation_ids = []
+ for conversation_id in normalized_parameters.get('chat_conversation_ids') or []:
+ try:
+ _authorize_personal_conversation_access(user_id, conversation_id)
+ allowed_chat_conversation_ids.append(conversation_id)
+ except (LookupError, PermissionError):
+ continue
allowed_scope_types = []
if 'personal' in scope_types:
@@ -20310,12 +23545,25 @@ def revalidate_prior_grounded_document_search_parameters(user_id, search_paramet
allowed_scope_types.append('group')
if allowed_public_workspace_ids:
allowed_scope_types.append('public')
+ if allowed_chat_conversation_ids:
+ allowed_scope_types.append('chat')
normalized_parameters['active_group_ids'] = allowed_group_ids
normalized_parameters['active_group_id'] = scope_context['active_group_id']
normalized_parameters['active_public_workspace_ids'] = allowed_public_workspace_ids
normalized_parameters['active_public_workspace_id'] = scope_context['active_public_workspace_id']
+ normalized_parameters['chat_conversation_ids'] = allowed_chat_conversation_ids
normalized_parameters['scope_types'] = allowed_scope_types
+ document_scopes = normalized_parameters.get('document_scopes') or {}
+ normalized_parameters['document_ids'] = [
+ document_id
+ for document_id in normalized_parameters.get('document_ids') or []
+ if (
+ (document_scopes.get(document_id) or {}).get('scope') != 'chat'
+ or (document_scopes.get(document_id) or {}).get('scope_id')
+ in allowed_chat_conversation_ids
+ )
+ ]
if not allowed_scope_types:
normalized_parameters['document_ids'] = []
@@ -20323,7 +23571,9 @@ def revalidate_prior_grounded_document_search_parameters(user_id, search_paramet
return normalized_parameters
normalized_parameters['doc_scope'] = (
- allowed_scope_types[0] if len(allowed_scope_types) == 1 else 'all'
+ allowed_scope_types[0]
+ if len(allowed_scope_types) == 1 and allowed_scope_types[0] != 'chat'
+ else 'all'
)
return normalized_parameters
@@ -20414,11 +23664,13 @@ def should_apply_history_grounding_message(
original_hybrid_search_enabled,
prior_grounded_document_refs,
explicit_external_retrieval_requested=False,
+ current_document_context_requested=False,
):
"""Apply bounded grounding only when prior grounded docs exist for this conversation."""
return (
not bool(original_hybrid_search_enabled)
and not bool(explicit_external_retrieval_requested)
+ and not bool(current_document_context_requested)
and bool(prior_grounded_document_refs)
)
diff --git a/application/single_app/route_backend_collaboration.py b/application/single_app/route_backend_collaboration.py
index 1d00cec85..0733372e8 100644
--- a/application/single_app/route_backend_collaboration.py
+++ b/application/single_app/route_backend_collaboration.py
@@ -307,6 +307,8 @@ def _build_collaboration_stream_request_payload(data, source_conversation_id, me
'message': message_content,
'conversation_id': source_conversation_id,
'hybrid_search': bool(data.get('hybrid_search')),
+ 'selection_mode': data.get('selection_mode'),
+ 'document_context_requested': data.get('document_context_requested'),
'web_search_enabled': bool(data.get('web_search_enabled')),
'selected_document_id': data.get('selected_document_id'),
'selected_document_ids': data.get('selected_document_ids') or [],
diff --git a/application/single_app/route_backend_conversations.py b/application/single_app/route_backend_conversations.py
index edcb42f3a..525f2cf53 100644
--- a/application/single_app/route_backend_conversations.py
+++ b/application/single_app/route_backend_conversations.py
@@ -173,6 +173,67 @@ def _normalize_workspace_document_delete_ids(raw_document_ids):
return normalized_document_ids
+def _build_replayed_document_context(original_metadata):
+ """Rebuild document-context intent from stored metadata for retry and edit."""
+ metadata = original_metadata if isinstance(original_metadata, dict) else {}
+ workspace_search = metadata.get('workspace_search')
+ if not isinstance(workspace_search, dict):
+ workspace_search = metadata.get('document_search')
+ workspace_search = workspace_search if isinstance(workspace_search, dict) else {}
+
+ selected_document_ids = (
+ workspace_search.get('requested_document_ids')
+ or workspace_search.get('selected_document_ids')
+ or []
+ )
+ if not isinstance(selected_document_ids, list):
+ selected_document_ids = [selected_document_ids]
+ selected_document_ids = [
+ str(document_id or '').strip()
+ for document_id in selected_document_ids
+ if str(document_id or '').strip()
+ ]
+ selected_document_id = str(
+ workspace_search.get('selected_document_id')
+ or workspace_search.get('document_id')
+ or ''
+ ).strip()
+ if selected_document_id and selected_document_id not in selected_document_ids:
+ selected_document_ids.insert(0, selected_document_id)
+
+ selection_mode = str(workspace_search.get('selection_mode') or '').strip().lower()
+ if selection_mode not in {'selected', 'all', 'history', 'relevance'}:
+ selection_mode = 'selected' if selected_document_ids else 'relevance'
+ document_context_requested = workspace_search.get('document_context_requested')
+ if not isinstance(document_context_requested, bool):
+ document_context_requested = bool(
+ workspace_search.get('search_enabled')
+ or workspace_search.get('enabled')
+ or selected_document_ids
+ )
+
+ return {
+ 'hybrid_search': bool(
+ workspace_search.get('hybrid_search_preference')
+ if 'hybrid_search_preference' in workspace_search
+ else workspace_search.get('enabled')
+ or workspace_search.get('search_enabled')
+ ),
+ 'selection_mode': selection_mode,
+ 'document_context_requested': document_context_requested,
+ 'selected_document_id': selected_document_ids[0] if selected_document_ids else None,
+ 'selected_document_ids': selected_document_ids,
+ 'doc_scope': workspace_search.get('document_scope') or workspace_search.get('scope'),
+ 'top_n': workspace_search.get('top_n'),
+ 'classifications': (
+ workspace_search.get('classification')
+ or workspace_search.get('classifications')
+ ),
+ 'active_group_ids': workspace_search.get('active_group_ids') or [],
+ 'active_public_workspace_ids': workspace_search.get('active_public_workspace_ids') or [],
+ }
+
+
def _get_requested_workspace_document_delete_ids_for_conversation(payload, conversation_id):
if not isinstance(payload, dict):
return []
@@ -2732,16 +2793,13 @@ def retry_message(message_id):
"message_retry_created",
)
# Build chat request parameters from original message metadata
+ replayed_document_context = _build_replayed_document_context(original_metadata)
chat_request = {
'message': user_content,
'conversation_id': conversation_id,
'model_deployment': selected_model or original_metadata.get('model_selection', {}).get('selected_model'),
'reasoning_effort': reasoning_effort or original_metadata.get('reasoning_effort'),
- 'hybrid_search': original_metadata.get('document_search', {}).get('enabled', False),
- 'selected_document_id': original_metadata.get('document_search', {}).get('document_id'),
- 'doc_scope': original_metadata.get('document_search', {}).get('scope'),
- 'top_n': original_metadata.get('document_search', {}).get('top_n'),
- 'classifications': original_metadata.get('document_search', {}).get('classifications'),
+ **replayed_document_context,
'image_generation': original_metadata.get('image_generation', {}).get('enabled', False),
'active_group_id': original_metadata.get('chat_context', {}).get('group_id'),
'active_public_workspace_id': original_metadata.get('chat_context', {}).get('public_workspace_id'),
@@ -2955,16 +3013,13 @@ def edit_message(message_id):
)
# Build chat request parameters from original message metadata
# Keep all original settings (model, reasoning, doc search, etc.)
+ replayed_document_context = _build_replayed_document_context(original_metadata)
chat_request = {
'message': edited_content, # Use edited content
'conversation_id': conversation_id,
'model_deployment': original_metadata.get('model_selection', {}).get('selected_model'),
'reasoning_effort': original_metadata.get('reasoning_effort'),
- 'hybrid_search': original_metadata.get('document_search', {}).get('enabled', False),
- 'selected_document_id': original_metadata.get('document_search', {}).get('document_id'),
- 'doc_scope': original_metadata.get('document_search', {}).get('scope'),
- 'top_n': original_metadata.get('document_search', {}).get('top_n'),
- 'classifications': original_metadata.get('document_search', {}).get('classifications'),
+ **replayed_document_context,
'image_generation': original_metadata.get('image_generation', {}).get('enabled', False),
'active_group_id': original_metadata.get('chat_context', {}).get('group_id'),
'active_public_workspace_id': original_metadata.get('chat_context', {}).get('public_workspace_id'),
diff --git a/application/single_app/semantic_kernel_plugins/plugin_invocation_logger.py b/application/single_app/semantic_kernel_plugins/plugin_invocation_logger.py
index 92dedaf5f..ffdba3a59 100644
--- a/application/single_app/semantic_kernel_plugins/plugin_invocation_logger.py
+++ b/application/single_app/semantic_kernel_plugins/plugin_invocation_logger.py
@@ -64,6 +64,15 @@
AUTHORIZATION_VALUE_RE = re.compile(r"(?i)\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+")
+class PluginInvocationResult(str):
+ """String tool result with server-only metadata retained in invocation history."""
+
+ def __new__(cls, value: str, internal_metadata: Optional[Dict[str, Any]] = None):
+ instance = super().__new__(cls, value)
+ instance.internal_metadata = dict(internal_metadata or {})
+ return instance
+
+
def _normalize_sensitive_key(key: Any) -> str:
return re.sub(r"[^a-z0-9]", "", str(key or "").strip().lower())
diff --git a/application/single_app/semantic_kernel_plugins/tabular_processing_plugin.py b/application/single_app/semantic_kernel_plugins/tabular_processing_plugin.py
index 99ce381e2..a041c12f1 100644
--- a/application/single_app/semantic_kernel_plugins/tabular_processing_plugin.py
+++ b/application/single_app/semantic_kernel_plugins/tabular_processing_plugin.py
@@ -13,15 +13,18 @@
import json
import logging
import re
+import tempfile
import warnings
import pandas
+from azure.core import MatchConditions
from flask import g, has_request_context
from typing import Annotated, Dict, List, Optional, Set
from urllib.parse import urlsplit, urlunsplit
from semantic_kernel.functions import kernel_function
-from semantic_kernel_plugins.plugin_invocation_logger import plugin_function_logger
+from semantic_kernel_plugins.plugin_invocation_logger import PluginInvocationResult, plugin_function_logger
from functions_appinsights import log_event
from functions_authentication import get_current_user_id
+from functions_tabular_csv_query import iter_tabular_csv_query_rows
from functions_group import find_group_by_id, get_user_role_in_group
from functions_public_workspaces import get_user_visible_public_workspace_ids_from_settings
from config import (
@@ -124,6 +127,7 @@ def __init__(self):
self._df_cache = {} # Per-instance cache: (container, blob_name, sheet_name) -> DataFrame
self._blob_data_cache = {} # Per-instance cache: (container, blob_name) -> raw bytes
self._workbook_metadata_cache = {} # Per-instance cache: (container, blob_name) -> workbook metadata
+ self._blob_version_cache = {} # Per-instance cache: (container, blob_name) -> immutable blob version metadata
self._default_sheet_overrides = {} # (container, blob_name) -> default sheet name
self._resolved_blob_location_overrides = {} # (source, filename) -> (container, blob_name)
@@ -230,6 +234,14 @@ def _get_authorized_chat_context(self) -> dict:
for workspace_id in (authorized_context.get('active_public_workspace_ids') or [])
if str(workspace_id or '').strip()
]
+ authorized_blob_locations = [
+ [str(location[0]), str(location[1])]
+ for location in authorized_context.get('authorized_blob_locations') or []
+ if isinstance(location, (list, tuple))
+ and len(location) == 2
+ and str(location[0] or '').strip()
+ and str(location[1] or '').strip()
+ ]
return {
'user_id': authorized_user_id,
@@ -240,6 +252,7 @@ def _get_authorized_chat_context(self) -> dict:
'active_public_workspace_id': (
str(authorized_context.get('active_public_workspace_id') or '').strip() or None
),
+ 'authorized_blob_locations': authorized_blob_locations,
}
def _resolve_authorized_scope_arguments(
@@ -342,6 +355,14 @@ def _is_authorized_public_workspace_scope(
def _is_authorized_blob_location(self, container_name: str, blob_path: str, authorized_context: dict) -> bool:
"""Ensure remembered blob locations still fall within the caller's authorized request scope."""
+ exact_authorized_locations = {
+ (str(location[0]), str(location[1]))
+ for location in authorized_context.get('authorized_blob_locations') or []
+ if isinstance(location, (list, tuple)) and len(location) == 2
+ }
+ if (str(container_name or ''), str(blob_path or '')) in exact_authorized_locations:
+ return True
+
source = self._infer_source_from_container(container_name)
blob_parts = [part for part in str(blob_path or '').split('/') if part]
if not source or not blob_parts:
@@ -392,11 +413,45 @@ def _download_tabular_blob_bytes(self, container_name: str, blob_name: str) -> b
client = self._get_blob_service_client()
blob_client = client.get_blob_client(container=container_name, blob=blob_name)
- stream = blob_client.download_blob()
+ blob_version = self._get_tabular_blob_version(
+ container_name,
+ blob_name,
+ refresh=True,
+ )
+ stream = blob_client.download_blob(
+ etag=blob_version['blob_etag'],
+ match_condition=MatchConditions.IfNotModified,
+ )
data = stream.readall()
self._blob_data_cache[cache_key] = data
return data
+ def _get_tabular_blob_version(self, container_name: str, blob_name: str, refresh: bool = False) -> dict:
+ """Return the exact blob version used by this plugin instance."""
+ cache_key = (container_name, blob_name)
+ if not refresh and cache_key in self._blob_version_cache:
+ return dict(self._blob_version_cache[cache_key])
+
+ blob_client = self._get_blob_service_client().get_blob_client(
+ container=container_name,
+ blob=blob_name,
+ )
+ blob_properties = blob_client.get_blob_properties()
+ blob_etag = getattr(blob_properties, 'etag', None)
+ blob_size = getattr(blob_properties, 'size', None)
+ if isinstance(blob_properties, dict):
+ blob_etag = blob_etag or blob_properties.get('etag')
+ blob_size = blob_size if blob_size is not None else blob_properties.get('size')
+ if not blob_etag:
+ raise ValueError('Tabular source version could not be determined')
+
+ blob_version = {
+ 'blob_etag': str(blob_etag),
+ 'blob_size': int(blob_size or 0),
+ }
+ self._blob_version_cache[cache_key] = blob_version
+ return dict(blob_version)
+
def _get_excel_engine(self, blob_name: str) -> Optional[str]:
"""Return the pandas Excel engine for a workbook, or None for CSV files."""
name_lower = blob_name.lower()
@@ -3384,6 +3439,169 @@ def _resolve_blob_location_with_fallback(self, user_id: str, conversation_id: st
return attempts[0]
raise ValueError(f"Could not resolve blob location for {filename}")
+ def build_generated_export_query_descriptor(
+ self,
+ user_id: str,
+ conversation_id: str,
+ filename: str,
+ query_expression: str,
+ source: str = 'chat',
+ return_columns: Optional[str] = None,
+ group_id: Optional[str] = None,
+ public_workspace_id: Optional[str] = None,
+ expected_row_count: int = 0,
+ ) -> dict:
+ """Resolve an authorized, version-pinned CSV query for durable export replay."""
+ container_name, blob_path = self._resolve_blob_location_with_fallback(
+ user_id,
+ conversation_id,
+ filename,
+ source,
+ group_id=group_id,
+ public_workspace_id=public_workspace_id,
+ )
+ return self._build_generated_export_query_descriptor_from_location(
+ container_name=container_name,
+ blob_path=blob_path,
+ filename=filename,
+ query_expression=query_expression,
+ return_columns=return_columns,
+ expected_row_count=expected_row_count,
+ )
+
+ def _build_generated_export_query_descriptor_from_location(
+ self,
+ container_name: str,
+ blob_path: str,
+ filename: str,
+ query_expression: str,
+ return_columns: Optional[str] = None,
+ expected_row_count: int = 0,
+ blob_version: Optional[dict] = None,
+ ) -> dict:
+ """Pin a durable query descriptor to an already-authorized blob location."""
+ if not str(blob_path or '').lower().endswith('.csv'):
+ raise ValueError('Durable source-backed generated exports currently require a CSV source')
+
+ blob_version = dict(blob_version or self._get_tabular_blob_version(container_name, blob_path))
+ blob_etag = blob_version.get('blob_etag')
+ blob_size = blob_version.get('blob_size')
+
+ resolved_source = self._infer_source_from_container(container_name)
+ blob_parts = [part for part in str(blob_path or '').split('/') if part]
+ scope_id = blob_parts[0] if resolved_source in {'group', 'public'} and blob_parts else None
+ return {
+ 'version': 1,
+ 'kind': 'query_tabular_data',
+ 'source': resolved_source,
+ 'scope_id': scope_id,
+ 'container': container_name,
+ 'blob_path': blob_path,
+ 'blob_etag': str(blob_etag),
+ 'blob_size': int(blob_size or 0),
+ 'filename': str(filename or '').strip(),
+ 'query_expression': str(query_expression or '').strip(),
+ 'return_columns': return_columns,
+ 'expected_row_count': max(0, int(expected_row_count or 0)),
+ }
+
+ def _build_source_authorization_from_location(
+ self,
+ container_name: str,
+ blob_path: str,
+ blob_version: Optional[dict] = None,
+ ) -> dict:
+ """Build exact server-only scope metadata for later worker revalidation."""
+ resolved_source = self._infer_source_from_container(container_name)
+ blob_parts = [part for part in str(blob_path or '').split('/') if part]
+ scope_id = blob_parts[0] if resolved_source in {'group', 'public'} and blob_parts else None
+ return {
+ 'source': resolved_source,
+ 'scope_id': scope_id,
+ 'container': container_name,
+ 'blob_path': blob_path,
+ 'blob_etag': (blob_version or self._get_tabular_blob_version(container_name, blob_path)).get(
+ 'blob_etag'
+ ),
+ }
+
+ def _query_csv_data_in_bounded_chunks(
+ self,
+ container_name: str,
+ blob_path: str,
+ filename: str,
+ query_expression: str,
+ return_columns: Optional[str],
+ start_row,
+ max_rows,
+ ) -> PluginInvocationResult:
+ """Execute foreground CSV pagination through the durable replay query engine."""
+ start, limit = self._parse_row_page_arguments(start_row, max_rows)
+ replay_stats = {'used_reviewer_style_fallback': False}
+ matched_row_count = 0
+ page_rows = []
+ blob_client = self._get_blob_service_client().get_blob_client(
+ container=container_name,
+ blob=blob_path,
+ )
+ blob_version = self._get_tabular_blob_version(
+ container_name,
+ blob_path,
+ refresh=True,
+ )
+ with tempfile.SpooledTemporaryFile(max_size=1024 * 1024, mode='w+b') as source_stream:
+ blob_client.download_blob(
+ etag=blob_version['blob_etag'],
+ match_condition=MatchConditions.IfNotModified,
+ ).readinto(source_stream)
+ source_stream.seek(0)
+ for _, source_row in iter_tabular_csv_query_rows(
+ csv_stream=source_stream,
+ query_expression=query_expression,
+ return_columns=return_columns,
+ source_chunk_rows=1000,
+ tabular_plugin=self,
+ replay_stats=replay_stats,
+ ):
+ if start <= matched_row_count < start + limit:
+ page_rows.append(source_row)
+ matched_row_count += 1
+
+ response_payload = {
+ 'filename': filename,
+ 'selected_sheet': None,
+ 'query_expression': query_expression,
+ 'query_expression_fallback': replay_stats['used_reviewer_style_fallback'],
+ 'total_matches': matched_row_count,
+ }
+ response_payload.update(self._build_tabular_row_page_payload(
+ page_rows,
+ matched_row_count,
+ start_row=start,
+ max_rows=limit,
+ return_columns=self._parse_optional_column_list_argument(return_columns),
+ ))
+ source_descriptor = self._build_generated_export_query_descriptor_from_location(
+ container_name=container_name,
+ blob_path=blob_path,
+ filename=filename,
+ query_expression=query_expression,
+ return_columns=return_columns,
+ expected_row_count=matched_row_count,
+ blob_version=blob_version,
+ )
+ return PluginInvocationResult(
+ json.dumps(response_payload, indent=2, default=str),
+ internal_metadata={
+ 'tabular_generated_export_source': source_descriptor,
+ 'tabular_source_authorization': self._build_source_authorization_from_location(
+ container_name,
+ blob_path,
+ blob_version=blob_version,
+ ),
+ },
+ )
+
@kernel_function(
description=(
"List all tabular data files available for a user. Checks workspace documents "
@@ -4462,6 +4680,16 @@ def _sync_work():
user_id, conversation_id, filename, source,
group_id=group_id, public_workspace_id=public_workspace_id
)
+ if str(blob_path or '').lower().endswith('.csv'):
+ return self._query_csv_data_in_bounded_chunks(
+ container_name=container,
+ blob_path=blob_path,
+ filename=filename,
+ query_expression=query_expression,
+ return_columns=return_columns,
+ start_row=start_row,
+ max_rows=max_rows,
+ )
# When no explicit sheet_name is given, try cross-sheet query first
normalized_sheet = (sheet_name or '').strip()
normalized_sheet_idx = None if sheet_index is None else str(sheet_index).strip()
@@ -4510,7 +4738,31 @@ def _sync_work():
max_rows=limit,
return_columns=parsed_return_columns,
))
- return json.dumps(response_payload, indent=2, default=str)
+ response_json = json.dumps(response_payload, indent=2, default=str)
+ source_blob_version = self._get_tabular_blob_version(container, blob_path)
+ internal_metadata = {
+ 'tabular_source_authorization': self._build_source_authorization_from_location(
+ container,
+ blob_path,
+ blob_version=source_blob_version,
+ ),
+ }
+ if str(blob_path or '').lower().endswith('.csv'):
+ internal_metadata['tabular_generated_export_source'] = (
+ self._build_generated_export_query_descriptor_from_location(
+ container_name=container,
+ blob_path=blob_path,
+ filename=filename,
+ query_expression=query_expression,
+ return_columns=return_columns,
+ expected_row_count=len(result_df),
+ blob_version=source_blob_version,
+ )
+ )
+ return PluginInvocationResult(
+ response_json,
+ internal_metadata=internal_metadata,
+ )
except Exception as e:
log_event(f"[TabularProcessingPlugin] Error querying data: {e}", level=logging.WARNING)
return json.dumps({"error": f"Query error: {str(e)}. Ensure column names and values are correct."})
diff --git a/application/single_app/static/js/chat/chat-messages.js b/application/single_app/static/js/chat/chat-messages.js
index 5138acf16..7674e6cbf 100644
--- a/application/single_app/static/js/chat/chat-messages.js
+++ b/application/single_app/static/js/chat/chat-messages.js
@@ -3333,7 +3333,12 @@ function renderReplyQuoteHtml(fullMessageObject = null) {
const normalizedDocumentId = String(output.document_id || '').trim();
const normalizedExportRunId = String(output.export_run_id || output.run_id || '').trim();
const isBackgroundExport = Boolean(output.background_export) && Boolean(normalizedExportRunId);
- if (!normalizedArtifactMessageId && !normalizedDocumentId && !isBackgroundExport) {
+ const terminalStatus = String(output.status || '').trim().toLowerCase();
+ const isTerminalExportStatus = Boolean(
+ output.suppress_assistant_table_export
+ && ['failed', 'canceled'].includes(terminalStatus)
+ );
+ if (!normalizedArtifactMessageId && !normalizedDocumentId && !isBackgroundExport && !isTerminalExportStatus) {
return null;
}
@@ -3344,7 +3349,8 @@ function renderReplyQuoteHtml(fullMessageObject = null) {
document_id: normalizedDocumentId,
export_run_id: normalizedExportRunId,
run_id: normalizedExportRunId,
- background_export: isBackgroundExport,
+ background_export: isBackgroundExport || isTerminalExportStatus,
+ suppress_assistant_table_export: Boolean(output.suppress_assistant_table_export),
};
}
@@ -3530,6 +3536,36 @@ function renderReplyQuoteHtml(fullMessageObject = null) {
}
}
+ function canCancelBackgroundGeneratedOutput(outputMetadata) {
+ return Boolean(outputMetadata?.background_export && outputMetadata?.can_cancel);
+ }
+
+ function setBackgroundGeneratedOutputCancelButtonLabel(cancelButton, label) {
+ if (!(cancelButton instanceof HTMLElement)) {
+ return;
+ }
+
+ const icon = document.createElement('i');
+ icon.className = 'bi bi-x-circle me-1';
+ icon.setAttribute('aria-hidden', 'true');
+ const labelText = document.createElement('span');
+ labelText.textContent = label;
+ cancelButton.replaceChildren(icon, labelText);
+ }
+
+ function updateBackgroundGeneratedOutputCancelButton(cancelButton, outputMetadata) {
+ if (!(cancelButton instanceof HTMLElement)) {
+ return;
+ }
+
+ const canCancel = canCancelBackgroundGeneratedOutput(outputMetadata);
+ cancelButton.classList.toggle('d-none', !canCancel);
+ cancelButton.disabled = !canCancel;
+ if (cancelButton.dataset.busy !== 'true') {
+ setBackgroundGeneratedOutputCancelButtonLabel(cancelButton, 'Cancel');
+ }
+ }
+
function formatGeneratedTabularPreviewValue(value, maxLength = 120) {
let formattedValue = '';
@@ -4364,6 +4400,7 @@ function renderReplyQuoteHtml(fullMessageObject = null) {
updateBackgroundGeneratedOutputStatusCard(statusElements, outputMetadata);
updateBackgroundGeneratedOutputContinueButton(statusElements.continueButton, outputMetadata);
+ updateBackgroundGeneratedOutputCancelButton(statusElements.cancelButton, outputMetadata);
} catch (error) {
if (statusElements.detailText) {
statusElements.detailText.textContent = error.message || 'Could not refresh export progress.';
@@ -4435,6 +4472,53 @@ function renderReplyQuoteHtml(fullMessageObject = null) {
}
}
+ async function cancelBackgroundGeneratedOutputRun(outputMetadata, card, statusElements = {}, cancelButton = null) {
+ const runId = String(outputMetadata?.export_run_id || outputMetadata?.run_id || '').trim();
+ if (!runId || !(card instanceof HTMLElement) || !document.body.contains(card)) {
+ return;
+ }
+
+ if (cancelButton) {
+ cancelButton.dataset.busy = 'true';
+ cancelButton.disabled = true;
+ setBackgroundGeneratedOutputCancelButtonLabel(cancelButton, 'Canceling...');
+ }
+
+ try {
+ const response = await fetch(`/api/tabular/generated-output/runs/${encodeURIComponent(runId)}/cancel`, {
+ method: 'POST',
+ headers: {
+ 'Accept': 'application/json',
+ },
+ });
+ const responseData = await response.json().catch(() => ({}));
+ if (!response.ok) {
+ throw new Error(responseData?.message || responseData?.error || `Server responded with status ${response.status}`);
+ }
+
+ const runStatus = responseData?.run || {};
+ Object.assign(outputMetadata, runStatus, {
+ export_run_id: runStatus.run_id || runId,
+ run_id: runStatus.run_id || runId,
+ background_export: true,
+ });
+ updateBackgroundGeneratedOutputStatusCard(statusElements, outputMetadata);
+ updateBackgroundGeneratedOutputContinueButton(statusElements.continueButton, outputMetadata);
+ updateBackgroundGeneratedOutputCancelButton(cancelButton, outputMetadata);
+ showToast(responseData?.message || 'Background export canceled.', 'success');
+ } catch (error) {
+ if (statusElements.detailText) {
+ statusElements.detailText.textContent = error.message || 'Could not cancel background export.';
+ }
+ showToast(error.message || 'Could not cancel background export.', 'danger');
+ } finally {
+ if (cancelButton) {
+ delete cancelButton.dataset.busy;
+ updateBackgroundGeneratedOutputCancelButton(cancelButton, outputMetadata);
+ }
+ }
+ }
+
function shouldPollBackgroundGeneratedOutput(outputMetadata) {
if (!outputMetadata?.background_export) {
return false;
@@ -4588,6 +4672,11 @@ function renderReplyQuoteHtml(fullMessageObject = null) {
actions.className = 'd-flex flex-wrap gap-2 mt-3';
if (outputMetadata?.background_export) {
+ const backgroundRunId = String(outputMetadata?.export_run_id || outputMetadata?.run_id || '').trim();
+ if (!backgroundRunId) {
+ return card;
+ }
+
const continueButton = document.createElement('button');
continueButton.type = 'button';
continueButton.className = 'btn btn-sm btn-outline-primary generated-tabular-continue-btn d-none';
@@ -4601,18 +4690,20 @@ function renderReplyQuoteHtml(fullMessageObject = null) {
updateBackgroundGeneratedOutputContinueButton(continueButton, outputMetadata);
actions.appendChild(continueButton);
- const refreshStatusButton = document.createElement('button');
- refreshStatusButton.type = 'button';
- refreshStatusButton.className = 'btn btn-sm btn-outline-secondary generated-tabular-refresh-status-btn';
- refreshStatusButton.textContent = 'Refresh Status';
- refreshStatusButton.addEventListener('click', async () => {
- refreshStatusButton.disabled = true;
- refreshStatusButton.textContent = 'Refreshing...';
- await refreshBackgroundGeneratedOutputStatus(outputMetadata, card, backgroundStatusElements || {});
- refreshStatusButton.disabled = false;
- refreshStatusButton.textContent = 'Refresh Status';
+ const cancelButton = document.createElement('button');
+ cancelButton.type = 'button';
+ cancelButton.className = 'btn btn-sm btn-outline-danger generated-tabular-cancel-btn d-none';
+ cancelButton.setAttribute('aria-label', 'Cancel background export');
+ setBackgroundGeneratedOutputCancelButtonLabel(cancelButton, 'Cancel');
+ cancelButton.addEventListener('click', async () => {
+ await cancelBackgroundGeneratedOutputRun(outputMetadata, card, backgroundStatusElements || {}, cancelButton);
});
- actions.appendChild(refreshStatusButton);
+ if (backgroundStatusElements) {
+ backgroundStatusElements.cancelButton = cancelButton;
+ }
+ updateBackgroundGeneratedOutputCancelButton(cancelButton, outputMetadata);
+ actions.appendChild(cancelButton);
+
card.appendChild(actions);
scheduleBackgroundGeneratedOutputStatusPolling(outputMetadata, card, backgroundStatusElements || {});
return card;
@@ -6176,6 +6267,8 @@ export function buildChatRequestPayload(finalMessageToSend, conversationId = cur
.filter(value => value);
selectedDocumentId = selectedDocumentIds.length > 0 ? selectedDocumentIds[0] : null;
}
+ const selectionMode = selectedDocumentIds.length > 0 ? 'selected' : 'relevance';
+ const documentContextRequested = hybridSearchEnabled || selectedDocumentIds.length > 0;
let imageGenEnabled = false;
const igbtn = document.getElementById('image-generate-btn');
@@ -6290,6 +6383,8 @@ export function buildChatRequestPayload(finalMessageToSend, conversationId = cur
message: finalMessageToSend,
conversation_id: conversationId,
hybrid_search: hybridSearchEnabled,
+ selection_mode: selectionMode,
+ document_context_requested: documentContextRequested,
user_workspace_context_enabled: userWorkspaceContextEnabled,
web_search_enabled: webSearchEnabled,
url_access_enabled: urlAccessEnabled,
@@ -6351,6 +6446,13 @@ export function buildCollaborativeInvocationTarget(messageData = {}, explicitInv
messageData.agent_info
&& (messageData.agent_info.id || messageData.agent_info.name || messageData.agent_info.display_name)
);
+ const workspaceContextInvocationRequested = Boolean(
+ messageData.hybrid_search
+ || (
+ messageData.document_context_requested
+ && window.appSettings?.enable_mixed_source_chat_search
+ )
+ );
const sourceMode = messageData.image_generation
? 'image_generation'
: hasAgentTarget
@@ -6361,7 +6463,7 @@ export function buildCollaborativeInvocationTarget(messageData = {}, explicitInv
? 'url_access'
: messageData.web_search_enabled
? 'web_search'
- : messageData.hybrid_search
+ : workspaceContextInvocationRequested
? 'workspace'
: messageData.prompt_info
? 'prompt'
diff --git a/application/single_app/static/js/plugin_modal_stepper.js b/application/single_app/static/js/plugin_modal_stepper.js
index 7394fc06c..c4e4e1ed6 100644
--- a/application/single_app/static/js/plugin_modal_stepper.js
+++ b/application/single_app/static/js/plugin_modal_stepper.js
@@ -2271,7 +2271,7 @@ export class PluginModalStepper {
populateDocumentSearchForm(additionalFields = {}) {
document.getElementById('document-search-scope').value = additionalFields.default_doc_scope || 'all';
- document.getElementById('document-search-top-n').value = additionalFields.default_top_n || 12;
+ document.getElementById('document-search-top-n').value = additionalFields.default_top_n || 25;
document.getElementById('document-search-window-unit').value = additionalFields.default_window_unit || 'pages';
document.getElementById('document-search-window-size').value = additionalFields.default_window_size || '';
document.getElementById('document-search-window-percent').value = additionalFields.default_window_percent || '';
@@ -5687,7 +5687,7 @@ export class PluginModalStepper {
const config = this.getDocumentSearchAdditionalFields();
document.getElementById('summary-search-scope').textContent = this.formatDocumentScope(config.default_doc_scope);
- document.getElementById('summary-search-top-n').textContent = String(config.default_top_n || 12);
+ document.getElementById('summary-search-top-n').textContent = String(config.default_top_n || 25);
document.getElementById('summary-search-chunk-behavior').textContent = 'Returns all chunks by default';
document.getElementById('summary-search-windowing').textContent = this.formatDocumentSearchWindowing(config);
document.getElementById('summary-search-window-target-length').textContent = config.default_window_target_length || '2 pages';
diff --git a/application/single_app/static/json/schemas/document_search_plugin.additional_settings.schema.json b/application/single_app/static/json/schemas/document_search_plugin.additional_settings.schema.json
index d147ddd5f..a8c8a3384 100644
--- a/application/single_app/static/json/schemas/document_search_plugin.additional_settings.schema.json
+++ b/application/single_app/static/json/schemas/document_search_plugin.additional_settings.schema.json
@@ -17,7 +17,7 @@
"type": "integer",
"minimum": 1,
"maximum": 500,
- "default": 12
+ "default": 50
},
"default_window_unit": {
"type": "string",
diff --git a/application/single_app/templates/chats.html b/application/single_app/templates/chats.html
index e07ffffb8..2e5df97a6 100644
--- a/application/single_app/templates/chats.html
+++ b/application/single_app/templates/chats.html
@@ -1580,6 +1580,7 @@
enable_multi_model_endpoints: {{ enable_multi_model_endpoints|tojson }},
enable_thoughts: {{ settings.enable_thoughts|tojson }},
enable_collaborative_conversations: {{ settings.enable_collaborative_conversations|tojson }},
+ enable_mixed_source_chat_search: {{ settings.enable_mixed_source_chat_search|default(false, true)|tojson }},
enable_desktop_notifications: {{ settings.enable_desktop_notifications|default(false, true)|tojson }},
desktop_notifications_enabled: {{ desktop_notifications_enabled|default(false, true)|tojson }},
app_title: {{ settings.app_title|default('Simple Chat', true)|tojson }},
diff --git a/application/single_app/utils_cache.py b/application/single_app/utils_cache.py
index 4b7854429..03ac3c427 100644
--- a/application/single_app/utils_cache.py
+++ b/application/single_app/utils_cache.py
@@ -319,7 +319,7 @@ def generate_search_cache_key(
active_group_id: Optional[str] = None,
active_group_ids: Optional[List[str]] = None,
active_public_workspace_id: Optional[str] = None,
- top_n: int = 12,
+ top_n: int = 50,
enable_file_sharing: bool = True,
tags_filter: Optional[List[str]] = None,
document_filter_mode: str = "intersection"
diff --git a/docs/admin_configuration.md b/docs/admin_configuration.md
index 98d8d91cd..02fb44543 100644
--- a/docs/admin_configuration.md
+++ b/docs/admin_configuration.md
@@ -260,6 +260,8 @@ Use this section when you need to configure an area, validate it, and know what
2. Enable agents, choose workspace-specific or global mode, set orchestration behavior, and manage global agents or approvals if admins curate shared agents centrally.
3. Enable action scopes and core plugins users are allowed to invoke. Save global agent/action changes, restart the web app when the tab notes it is required, and then verify the runtime action menus.
+Mixed-source rollout is independently reversible. Keep `enable_mixed_source_manifest`, `enable_mixed_source_chat_search`, `enable_mixed_source_analyze`, `enable_cross_format_compare`, and `enable_mixed_source_conversation_continuity` off until the preceding stage is validated. The subordinate relevance, Analyze All, one-to-many Compare, and development telemetry stages also default off. `enable_mixed_source_development_telemetry` records aggregate counts and latency only; it must never be used to capture prompts, evidence, source identifiers, filenames, or storage paths.
+
### Logging

diff --git a/docs/application_workflows.md b/docs/application_workflows.md
index 3d5fec20b..c794b551f 100644
--- a/docs/application_workflows.md
+++ b/docs/application_workflows.md
@@ -80,3 +80,11 @@ This workflow covers what happens when users upload content into personal or gro
8. Once indexing completes, the document becomes available to hybrid retrieval in chat and workspace search.
Typical chunk metadata includes document identity, filename, workspace scope, sequence numbers, page references, timestamps, and optional classification or extraction metadata.
+
+## Mixed-Source Document Actions
+
+When mixed-source flags are enabled, Chat and workflow Search remain relevance bounded. Analyze and Compare resolve an ordered authorized manifest, dispatch narrative documents to bounded window analysis and CSV/Excel documents to native tabular tools, and combine only bounded evidence summaries.
+
+Every selected or planned source ends with `completed`, `partial`, `failed`, or `skipped` plus a bounded reason. Analyze reduces only when at least one source succeeds. Compare requires a prepared Source and can continue past a failed Target. Cancellation stops active branches and prevents later reduction, citation/artifact publication, and assistant response persistence.
+
+The staged Analyze All backend enumerates current documents through the ready document access index and rejects catalogs above the configured workflow Analyze limit. Every enumerated ID is reauthorized before execution. This stage remains default off and is not newly exposed in the workflow selector in version **0.250.070**.
diff --git a/docs/explanation/features/CROSS_FORMAT_COMPARE.md b/docs/explanation/features/CROSS_FORMAT_COMPARE.md
new file mode 100644
index 000000000..509a9cbbf
--- /dev/null
+++ b/docs/explanation/features/CROSS_FORMAT_COMPARE.md
@@ -0,0 +1,45 @@
+# Cross-Format Compare
+
+Implemented in version: **0.250.067**
+
+GitHub issue: [#1059](https://github.com/microsoft/simplechat/issues/1059)
+
+Parent initiative: [#1055](https://github.com/microsoft/simplechat/issues/1055)
+
+Prerequisites: [#1056](https://github.com/microsoft/simplechat/issues/1056), [#1057](https://github.com/microsoft/simplechat/issues/1057), and [#1058](https://github.com/microsoft/simplechat/issues/1058)
+
+## Overview
+
+Phase 4 introduces a default-off cross-format Compare coordinator. It resolves one fresh authorized manifest for the Source and ordered Targets, dispatches narrative sources to document-window analysis and tabular sources to the existing tabular analysis runner, then performs the established one-Source-to-many-Targets pairwise and final reduction using bounded evidence envelopes.
+
+## Configuration
+
+- `enable_cross_format_compare`: default `false`; enables native mixed narrative/tabular Compare.
+- `enable_cross_format_compare_one_to_many`: default `false`; permits more than one mixed-format Target after pairwise coverage and performance are verified.
+
+When the main flag is disabled, same-type Compare stays on its established path. A mixed request fails with a clear temporary limitation rather than treating a table as narrative chunks.
+
+## Architecture
+
+- `functions_mixed_source_orchestration.py` remains the sole manifest, partition, authorization, and bounded-envelope contract.
+- `functions_workflow_runner.py` reuses `run_document_analysis(...)` for narrative sources and `_maybe_execute_tabular_document_action(...)` for every tabular source.
+- `functions_document_comparison.py` retains the existing pairwise and multi-target reduction prompts; `run_evidence_document_comparison(...)` supplies native engine-neutral evidence and keeps failed targets visible.
+- Existing citation, token aggregation, ThoughtTracker, generated tabular output, background-export, and comparison artifact flows are retained.
+
+## Security and Coverage
+
+Every enabled execution resolves the source manifest fresh, rechecking personal ownership or exact approved shares, active group membership, public visibility, and chat-upload conversation ownership. Caller-provided scope or metadata is not authorization. Unresolved and unauthorized sources remain scrubbed terminal coverage entries.
+
+The final comparison reports compared targets, failed or partial targets, participating engines, and whether its conclusion is aggregate/narrative. Narrative assertions remain distinct from computed tabular facts. Generated exports remain artifacts rather than comparison prose.
+
+## Testing
+
+`functional_tests/test_cross_format_compare_workflow.py` covers the native coordinator wiring, Source/Target ordering, partial target visibility, engine reporting, staged rollout flags, and rollback limitation. Additional scope, authorization-revocation, source-version, streaming, and UI coverage should remain part of the rollout gate before enabling either flag.
+
+## Limitations
+
+This phase does not add many-to-many Compare, all-document discovery, persisted follow-up source reuse, Phase 5 selection semantics, or Phase 6 broad extraction and rollout completion. Table-to-table row-level assertions require a validated structured table operation; bounded prose evidence alone is not treated as row-level proof.
+
+## Phase 6 Hardening
+
+Version **0.250.070** applies the [#1061](https://github.com/microsoft/simplechat/issues/1061) failure policy: an unprepared Source fails the operation, while a failed Target or pairwise Target reduction remains visible and later valid Targets continue. Mixed Compare citations and generated tabular outputs now survive the outer model and agent return paths with stable deduplication. Cancellation prevents later pairwise work, final reduction, or artifact publication.
\ No newline at end of file
diff --git a/docs/explanation/features/GENERATED_FILE_EXPORT_FRAMEWORK.md b/docs/explanation/features/GENERATED_FILE_EXPORT_FRAMEWORK.md
new file mode 100644
index 000000000..7b4d6fc9f
--- /dev/null
+++ b/docs/explanation/features/GENERATED_FILE_EXPORT_FRAMEWORK.md
@@ -0,0 +1,94 @@
+# Generated File Export Framework
+
+Implemented in version: **0.250.072**
+
+GitHub issue: [#1071](https://github.com/microsoft/simplechat/issues/1071)
+
+Related config.py update: `VERSION = "0.250.072"`
+
+## Overview
+
+Generated file output is a first-class response capability. The framework accepts the completed assistant response and the successful structured function results produced during the same turn, selects a requested renderer, and publishes one authorized downloadable chat artifact.
+
+CSV, Word (`.docx`), and PDF are separate renderer capabilities. They share source normalization, output intent detection, artifact metadata, authorization-safe publication, downloads, and workspace-promotion behavior.
+
+## Purpose
+
+Function results previously remained available as citations, while downloadable output depended on the model reproducing those rows in its final response. That made an action that returned structured data less reliable as an export source than a manually formatted assistant table.
+
+The framework normalizes current-turn structured function results once and makes them available to every supported renderer. CSV remains the first durable renderer; DOCX and PDF provide immediate generated artifacts for supported response-sized outputs.
+
+## Dependencies
+
+- `functions_generated_file_exports.py` for output intent, structured function-result normalization, renderer dispatch, and artifact metadata
+- `functions_assistant_table_exports.py` for CSV intent, table parsing, safe headers, and formula-injection protection
+- `functions_simplechat_operations.py` for authorized generated chat-artifact upload, download, promotion, and rollback
+- `functions_tabular_generated_exports.py` for durable CSV batching, checkpoints, cancellation, reauthorization, and publication
+- `python-docx` for DOCX rendering and PyMuPDF for PDF rendering
+
+## Technical Specifications
+
+### Supported Renderers
+
+- **CSV**: Renders structured rows with safe headers, formula neutralization, quoted/multiline values, and durable background execution when the existing row or batch threshold is exceeded.
+- **DOCX**: Renders a titled document with final assistant content and, when present, a structured function-result table.
+- **PDF**: Renders a titled PDF with final assistant content and, when present, a structured function-result table.
+
+The response request selects the format through natural language such as `create a CSV`, `create a Word document`, or `export to PDF`.
+
+### Function Result Source Contract
+
+Only function results from the current completed response are considered. The adapter:
+
+- accepts successful citation payloads in conventional `rows`, `data`, `items`, `results`, `records`, `value`, `values`, `result`, `body`, `output`, or `payload` envelopes
+- supports a row-like result object when no envelope is present
+- parses JSON-string payloads when they contain structured values
+- defensively excludes sensitive key names and secret-like fields even after plugin invocation sanitization
+- labels merged rows with their originating action when more than one action contributes rows
+- ignores `TabularProcessingPlugin` results so CSV/XLSX rows continue through the existing coverage-aware, revision-aware tabular export path
+
+A valid assistant-rendered table takes precedence over function-result rows for CSV. For DOCX and PDF, the final assistant response is included alongside normalized function-result tables.
+
+### Response Paths
+
+The same finalizer is invoked after:
+
+- standard Chat and streaming Chat
+- selected agents and action/tool calls
+- Chat Search
+- Analyze and Compare document actions
+- direct-model and agent workflows
+- source-free model responses
+
+Each path supplies the final assistant content plus its current-turn function citations. The framework does not read arbitrary historical citations or externally supplied action identifiers.
+
+### Artifact Publication
+
+The existing generated chat-artifact uploader remains the sole publication mechanism. It validates conversation ownership, allowed output extension, content size, and artifact metadata before creating a blob-backed file message.
+
+Generated artifacts retain their format, capability, summary, preview metadata, and source provenance. The existing authorized download and workspace-promotion routes work without a new browser transport or external runtime asset.
+
+## Usage
+
+Examples:
+
+- `Ask the billing action for invoices and save the action results as one CSV.`
+- `Create a Word document from the action results.`
+- `Export the agent's findings to PDF.`
+- `Create a PDF report from this response.`
+
+When an action returns structured data and the assistant summarizes it instead of reprinting a table, the requested generated file still receives the normalized rows. If a request is ambiguous only for CSV row granularity or columns, the assistant asks the existing single conversation clarification before finalization.
+
+## Testing and Validation
+
+- `functional_tests/test_assistant_table_csv_artifact.py` covers CSV, DOCX, PDF, structured function-result normalization, sensitive-field exclusion, multi-action provenance, assistant-table precedence, and tabular-plugin exclusion.
+- `functional_tests/test_mixed_source_hardening.py` covers cancellation and artifact rollback through the generic finalizer.
+- `functional_tests/test_document_action_token_usage_aggregation.py` covers workflow assistant-message persistence with the shared finalizer.
+- Existing durable CSV, document action, workflow, and generated-artifact tests remain part of validation.
+
+## Performance and Limitations
+
+- CSV retains the existing durable background path for large row sets.
+- DOCX and PDF render immediately for response-sized content; durable long-form DOCX work is tracked separately in [#1072](https://github.com/microsoft/simplechat/issues/1072).
+- The framework deliberately does not route tabular-plugin rows around source coverage, authorization, or source-version checks.
+- Unsupported, failed, unresolved, canceled, or partial source states remain visible through their existing evidence and export contracts; the framework does not fabricate missing rows.
diff --git a/docs/explanation/features/MIXED_SOURCE_ANALYZE.md b/docs/explanation/features/MIXED_SOURCE_ANALYZE.md
new file mode 100644
index 000000000..84703b315
--- /dev/null
+++ b/docs/explanation/features/MIXED_SOURCE_ANALYZE.md
@@ -0,0 +1,68 @@
+# Mixed-Source Analyze
+
+Implemented in version: **0.250.066**
+
+GitHub issue: [#1058](https://github.com/microsoft/simplechat/issues/1058)
+
+Parent initiative: [#1055](https://github.com/microsoft/simplechat/issues/1055)
+
+Prerequisites: [#1056](https://github.com/microsoft/simplechat/issues/1056) and [#1057](https://github.com/microsoft/simplechat/issues/1057)
+
+## Overview
+
+Phase 3 adds a default-off mixed-source combined Analyze coordinator for explicit document selections. It resolves one fresh authorized manifest, partitions it by native capability, and keeps the existing engines responsible for their own source types:
+
+- Narrative documents are sent only to `run_document_analysis(...)`, preserving document windows, retries, progress, citations, and narrative artifacts.
+- Tabular documents are sent one at a time to the existing tabular document-action runner, preserving tool-backed results, CSV/JSON generated outputs, background-export summary handoff, and assistant-table fallback suppression.
+- One bounded Phase 1 evidence handoff is reduced once into a collective answer that distinguishes computed table facts from narrative excerpts and declares terminal coverage gaps.
+
+No separate authorization model, manifest resolver, tabular runner, evidence contract, route, or persisted source-context mechanism was added.
+
+## Rollout
+
+- `enable_mixed_source_analyze`: default `false`. Enables selected combined Analyze only.
+- `enable_mixed_source_analyze_all`: default `false`. Reserved for a later independently staged exhaustive Analyze All catalog implementation after preflight-limit and performance validation.
+
+With the main flag off, the legacy execution remains available. It does not silently fall back to treating a mixed table as narrative evidence.
+
+## Execution And Coverage
+
+The coordinator publishes these stages through existing document-action activity plumbing:
+
+1. Resolving sources
+2. Analyzing narrative documents
+3. Analyzing tabular documents
+4. Combining findings
+5. Complete or partial terminal coverage
+
+Coverage is built from every fresh manifest entry. Unresolved and unsupported entries remain terminal failures in the bounded handoff without exposing their metadata. Engine/status totals are retained alongside the existing coverage fields.
+
+If narrative or tabular execution fails, completed evidence from the other branch remains available to the one reduction. The reduction is explicitly instructed not to claim coverage for omitted, failed, unresolved, unsupported, or unprocessed sources.
+
+Per-document Analyze remains unchanged: each document is executed separately through its native engine, and no collective reduction is added.
+
+## Security
+
+The coordinator uses `resolve_authorized_source_manifest(...)` for every enabled combined request. That reauthorizes personal ownership or exact approved shares, current group membership, public visibility, and chat-upload conversation ownership at the object boundary. Caller-provided scope, workspace, ownership, and selected metadata remain untrusted hints.
+
+The existing request-scoped tabular runner continues validating authorized source context. Evidence envelopes and aggregate diagnostics are bounded, and the orchestration does not log source identifiers, names, content, locations, prompts, credentials, or raw settings.
+
+## Validation
+
+- `functional_tests/test_mixed_source_analyze_workflow.py`
+- `functional_tests/test_tabular_document_actions_workflow.py`
+- Python compilation for changed modules and tests
+
+The focused coverage verifies native partitioning, bounded collective reduction instructions, partial failure coverage, default-off rollout behavior, per-document exclusion, and tabular artifact/citation propagation.
+
+## Limitations
+
+This Phase 3 increment does not enable Analyze All Documents. The current action contract exposes selected and recent targets only, so enabling an `all` mode before a dedicated exhaustive authorized catalog enumerator exists would violate the Analyze contract. The separate `enable_mixed_source_analyze_all` flag remains disabled until that enumerator, count preflight rejection, object-boundary reauthorization, and performance validation are delivered.
+
+This phase does not implement cross-format Compare (#1059), persisted follow-up source reuse, Phase 5 conversation-selection semantics, or Phase 6 route extraction and rollout completion.
+
+## Phase 6 Hardening
+
+Version **0.250.070** completes the bounded backend contract for `enable_mixed_source_analyze_all` under [#1061](https://github.com/microsoft/simplechat/issues/1061). The ready document access index enumerates current candidates using the configured Analyze limit plus one, rejects over-limit catalogs without truncation, and sends every candidate through the existing authorized source manifest before execution. The flag remains default off and the workflow selector is not newly exposed pending staged production approval.
+
+Combined Analyze now reduces only when at least one source succeeds. Terminal coverage, cancellation, generated outputs, citations, and finalization reauthorization are preserved across narrative and table branches.
\ No newline at end of file
diff --git a/docs/explanation/features/MIXED_SOURCE_CHAT_AND_SEARCH_CONSISTENCY.md b/docs/explanation/features/MIXED_SOURCE_CHAT_AND_SEARCH_CONSISTENCY.md
new file mode 100644
index 000000000..2985d938c
--- /dev/null
+++ b/docs/explanation/features/MIXED_SOURCE_CHAT_AND_SEARCH_CONSISTENCY.md
@@ -0,0 +1,168 @@
+# Mixed-Source Chat and Search Consistency
+
+Implemented in version: **0.250.064**
+
+GitHub issue: [#1057](https://github.com/microsoft/simplechat/issues/1057)
+
+Parent initiative: [#1055](https://github.com/microsoft/simplechat/issues/1055)
+
+Phase 1 dependency: [#1056](https://github.com/microsoft/simplechat/issues/1056)
+
+## Overview
+
+SimpleChat Chat and workflow Search now treat explicit document selections as authoritative context even when the Search Documents panel is closed. Narrative documents continue through bounded hybrid retrieval, while selected or relevance-chosen CSV and Excel sources use the existing tabular planner and tool runner. Both branches normalize their results into the bounded evidence-envelope contract introduced in Phase 1 and provide one coverage-aware synthesis handoff to the selected model or agent.
+
+This is Phase 2 of the mixed-source orchestration initiative. Explicit-selection behavior changes only when the independent `enable_mixed_source_chat_search` flag is enabled. Relevance-derived table candidates use a second default-off rollout stage.
+
+## Purpose
+
+Previously, the browser could send valid `selected_document_ids` with `hybrid_search: false` after the Search Documents panel closed. Standard Chat, streaming Chat, and tabular execution then treated the panel-derived toggle as the authority and ignored the explicit selection. Workflow Search retrieved narrative chunks but did not run selected tables through tabular tools.
+
+Phase 2 separates source intent from retrieval preference and gives every explicit selected source a terminal coverage state without turning Chat or Search into exhaustive catalog analysis.
+
+## Dependencies
+
+- The Phase 1 authorized source manifest, capability partition, selection mode, and bounded evidence envelope from `functions_mixed_source_orchestration.py`
+- Existing authorization-aware document resolution in `functions_search_service.py`
+- Existing hybrid search, tabular planning, tool invocation, citation, generated-output, model, agent, and workflow runners
+- Existing conversation history sufficiency and authorized grounding revalidation helpers
+- Structured telemetry through `functions_appinsights.log_event(...)`
+
+No new authorization model, tabular runner, synthesis contract, route, storage container, or persisted source-reuse mechanism is introduced.
+
+## Technical Specifications
+
+### Request Contract
+
+The shared Chat payload now sends:
+
+- `selection_mode`: `selected` for one or more explicit IDs; otherwise `relevance`
+- `selected_document_ids`: the ordered explicit selection
+- `document_context_requested`: true whenever explicit IDs exist, even if the panel is closed
+- `hybrid_search`: the existing panel-derived compatibility and retrieval preference
+
+The backend validates contradictory values and fails closed. Explicit IDs force selected mode and document context. With the rollout flag disabled, the legacy toggle-gated behavior remains active.
+
+Collaboration streaming and retry/edit replay preserve the same fields. Replayed IDs remain untrusted hints and are reauthorized before use.
+
+### Effective Context Order
+
+Chat resolves evidence in this order:
+
+1. A fresh authorized manifest for the current explicit selection
+2. Freshly reauthorized conversation grounding only when there is no current selection and the history assessor requires new evidence
+3. Relevance-bounded catalog candidates
+
+Current explicit selections suppress stale conversation source guidance, automatic source expansion, and duplicate chat-upload table execution. History-sufficient turns still answer from existing conversation context without rerunning retrieval or tabular tools. Phase 2 does not persist a new follow-up source-selection record.
+
+### Native Engine Dispatch
+
+The manifest is partitioned once by source capability:
+
+- Narrative sources are sent to bounded hybrid retrieval using only their authorized IDs.
+- Explicit tabular sources are sent one at a time through the existing tabular planner and runner so each table reaches a completed, failed, or intentionally skipped terminal state.
+- A tabular source is completed only after the existing runner records at least one successful native tabular tool call. Nonempty model text without tool coverage fails closed.
+- Clearly narrative-only prompts may skip row-level processing for a selected table; collective summaries, schema questions, calculations, and table-oriented prompts invoke the tabular path.
+- Unauthorized, unresolved, and unsupported sources never reach an engine and contribute only scrubbed failure coverage.
+
+The manifest carries an internal request-scoped storage locator for authorized tabular records. The existing tabular plugin accepts that exact location only when it appears in the current request authorization context, preserving archived revision identity and approved shared-document access without authorizing arbitrary blob prefixes. The locator is not logged or included in synthesis evidence.
+
+Tabular tool citations, inline chart citations, and generated outputs continue through their existing channels. Narrative excerpts continue through hybrid citations.
+
+### Bounded Relevance Candidates
+
+All Documents Chat and Search remain relevance-bounded. When `enable_mixed_source_relevance_candidates` is also enabled, a second authorized search over indexed schema-rich chunks considers spreadsheet, workbook, worksheet, CSV, column, and table terms. It requests at most 36 results and admits at most six unique tabular document candidates.
+
+Candidate IDs are resolved again through the Phase 1 manifest before tabular execution. Assigned-knowledge searches remain constrained to their trusted document allowlist. No code enumerates or processes the entire authorized catalog.
+
+### Evidence and Synthesis
+
+Narrative and tabular results are normalized into the Phase 1 evidence envelope. The final bounded handoff records:
+
+- Selection origin: selected, history, or relevance
+- Native engine and terminal status for each source
+- Bounded narrative excerpts and citation identifiers
+- Bounded computed tabular summaries and tool citations
+- Missing, unauthorized, unsupported, skipped, or failed coverage
+- Whether the final answer must state partial coverage
+
+The same handoff reaches standard Chat, streaming Chat, local model runners, local agent runners, Foundry-backed runners, and model or agent workflow Search.
+
+### Diagnostics
+
+Phase 2 emits aggregate structured diagnostics for:
+
+- Explicit-selection activations
+- Narrative result counts
+- Tabular candidate, completed, failed, and skipped counts
+- Mixed synthesis count
+- Selected, history, and relevance decisions
+- Partial coverage and authorization/failure omissions
+
+Diagnostics do not include document IDs, filenames, content, blob paths, prompts, evidence, credentials, or raw configuration.
+
+### API Endpoints
+
+No routes are added or moved. Existing Chat, streaming Chat, collaboration, retry/edit, and workflow execution paths consume the new internal request fields.
+
+### Configuration
+
+- `enable_mixed_source_chat_search`: default off; enables Phase 2 Chat and Search behavior.
+- `enable_mixed_source_relevance_candidates`: default off and effective only when the Phase 2 flag is enabled; activates relevance-derived table candidates after explicit-selection telemetry is stable.
+- `enable_mixed_source_manifest`: remains the independent Phase 1 shadow flag and is not a prerequisite for Phase 2.
+
+Disabling the Phase 2 flag restores legacy toggle-gated Chat and narrative-only workflow Search behavior without a data migration.
+
+## Security
+
+- Personal sources are reauthorized through current ownership or approved sharing checks.
+- Group sources are reauthorized against current group membership.
+- Public sources are reauthorized against currently visible public workspaces.
+- Chat-upload sources require ownership of the active conversation.
+- Caller-supplied scope, workspace IDs, ownership fields, CSS state, and persisted metadata are never accepted as authorization decisions.
+- Missing and unauthorized sources retain the same scrubbed unresolved shape, preventing a metadata enumeration oracle.
+- Workflow tabular tool context is derived from the freshly authorized manifest, not from workflow payload scope values.
+- Browser rendering and payload changes introduce no HTML sinks, external runtime assets, or raw settings exposure.
+
+## Usage
+
+Enable `enable_mixed_source_chat_search` in application settings. Users may select narrative and tabular documents, close the Search Documents panel, and send a standard Chat prompt. The explicit selection remains active. Workflow Search uses the same mixed-source behavior for selected or bounded recent targets. After explicit-selection telemetry is stable, enable `enable_mixed_source_relevance_candidates` to allow bounded relevance-derived tables to compete outside the initial chunk hits.
+
+Disable the flag to roll back to the legacy `hybrid_search` gate while retaining the Phase 1 contracts.
+
+## Testing and Validation
+
+- Functional coverage: `functional_tests/test_mixed_source_chat_search_consistency.py`
+- Shared contract coverage: `functional_tests/test_mixed_source_manifest_contracts.py`
+- Browser payload coverage: `ui_tests/test_chat_mixed_source_selection_payload.py`
+- Existing workflow, history-grounding, selected-document authorization, route-policy, broken-access-control, and XSS guardrails remain part of the validation gate.
+
+Coverage includes panel-open/panel-closed equivalence, standard/streaming shared execution, PDF plus XLSX, DOCX plus CSV in both orders, calculations, narrative-only prompts, per-table terminal coverage, partial failure, personal/group/public/chat-upload scopes, workflow model and agent runners, relevant tables outside initial hits, collaboration/replay/Foundry propagation, diagnostics privacy, and flag-off rollback.
+
+## Performance
+
+- Initial narrative retrieval remains bounded by existing Chat and Search limits.
+- The metadata/schema candidate stage is capped at 36 search results and six tabular candidates.
+- Only planner-selected relevance candidates receive tabular analytical calls.
+- Explicit tables run independently to guarantee terminal coverage; evidence is bounded before final synthesis.
+- Exhaustive rows remain in existing generated artifacts and checkpoints rather than model context.
+
+## Known Limitations and Scope Guardrails
+
+Phase 2 does not implement:
+
+- Exhaustive Analyze All Documents semantics from Phase 3 (#1058)
+- Cross-format Compare from Phase 4 (#1059)
+- New persisted follow-up source reuse from Phase 5 (#1060)
+- Full route-to-service extraction, broader hardening, or rollout completion from Phase 6 (#1061)
+- Per-document workflow-mode changes
+
+Chat and Search remain relevance-bounded. Analyze and Compare behavior is otherwise unchanged by this phase.
+
+## Related Version Update
+
+`application/single_app/config.py` was updated from **0.250.062** to **0.250.064** after preserving a concurrent application version increment while completing the Phase 2 delivery associated with #1057.
+
+## Phase 6 Hardening
+
+Version **0.250.070** adds manifest-aligned terminal coverage, standard/streaming failure parity, and reference deduplication under [#1061](https://github.com/microsoft/simplechat/issues/1061). When a mixed request still has successful table evidence, a narrative retrieval or quota failure becomes an explicit per-source omission instead of aborting the available native branch. Chat and Search remain relevance bounded; no full catalog enumeration was added to either mode.
\ No newline at end of file
diff --git a/docs/explanation/features/MIXED_SOURCE_CONVERSATION_CONTINUITY.md b/docs/explanation/features/MIXED_SOURCE_CONVERSATION_CONTINUITY.md
new file mode 100644
index 000000000..79474d18d
--- /dev/null
+++ b/docs/explanation/features/MIXED_SOURCE_CONVERSATION_CONTINUITY.md
@@ -0,0 +1,42 @@
+# Mixed-Source Conversation Continuity
+
+Implemented in version: **0.250.068**
+
+GitHub issue: [#1060](https://github.com/microsoft/simplechat/issues/1060)
+
+Parent initiative: [#1055](https://github.com/microsoft/simplechat/issues/1055)
+
+Prerequisites: [#1056](https://github.com/microsoft/simplechat/issues/1056), [#1057](https://github.com/microsoft/simplechat/issues/1057), [#1058](https://github.com/microsoft/simplechat/issues/1058), and [#1059](https://github.com/microsoft/simplechat/issues/1059)
+
+## Overview
+
+Phase 5 persists a compact continuity reference for the most recent mixed-source Chat grounding. It is a reauthorization hint, never an authorization decision or evidence cache. A follow-up with no current explicit selection retains the established history fallback, which resolves a new Phase 1 authorized manifest before native narrative retrieval or tabular execution.
+
+## Precedence And Authorization
+
+1. Current explicit selection is authoritative and suppresses previous continuity context.
+2. A no-selection follow-up first uses existing history when it is sufficient.
+3. When fresh grounding is needed, only the immediately relevant compact references are considered and every source is resolved through `resolve_authorized_source_manifest(...)`.
+4. Chat/Search relevance candidates remain available only under their existing bounded Phase 2 rules.
+
+The fresh resolver rechecks personal ownership or exact approved shares, group membership, public visibility, and chat-upload conversation ownership. Missing, revoked, unsupported, or unresolved sources do not become evidence. Changed source versions and partial or failed prior coverage remain terminal state and require native execution rather than treating old evidence as current.
+
+## Metadata Contract
+
+The continuity record contains document ID, canonical scope identity, source role, requested order, source kind, native engine, source version, terminal status, bounded coverage flags, selection origin, action mode, and citation/artifact counts. It never includes document content, filenames, prompts, evidence summaries, blob paths, storage locators, credentials, raw configuration, or authorization snapshots.
+
+## Rollout And Limitations
+
+`enable_mixed_source_conversation_continuity` is default-off and is effective only with `enable_mixed_source_chat_search`. Disabling it preserves existing Phase 2 history grounding and explicit-selection behavior. This phase does not add cross-conversation sharing, many-to-many Compare, target discovery, or persisted raw-evidence reuse.
+
+## Validation
+
+- `functional_tests/test_mixed_source_conversation_continuity.py`
+- `functional_tests/test_chat_history_grounded_follow_up_fix.py`
+- Python compilation and editor diagnostics for changed modules
+
+Related version update: `application/single_app/config.py` moved from **0.250.067** to **0.250.068**.
+
+## Phase 6 Hardening
+
+Version **0.250.070** preserves source version, terminal status, bounded coverage, role, and order through continuity normalization. A fresh manifest decision is now evaluated before history-only reuse, so revoked, changed, partial, failed, or truncated prior grounding forces native execution even when the history assessor would otherwise reuse an earlier answer. Chat-upload hints are filtered through fresh conversation ownership.
\ No newline at end of file
diff --git a/docs/explanation/features/MIXED_SOURCE_HARDENING_EXTRACTION_AND_ROLLOUT.md b/docs/explanation/features/MIXED_SOURCE_HARDENING_EXTRACTION_AND_ROLLOUT.md
new file mode 100644
index 000000000..9b02ae51d
--- /dev/null
+++ b/docs/explanation/features/MIXED_SOURCE_HARDENING_EXTRACTION_AND_ROLLOUT.md
@@ -0,0 +1,112 @@
+# Mixed-Source Hardening, Extraction, and Rollout
+
+Implemented in version: **0.250.070**
+
+GitHub issue: [#1061](https://github.com/microsoft/simplechat/issues/1061)
+
+Parent initiative: [#1055](https://github.com/microsoft/simplechat/issues/1055)
+
+Prerequisites: [#1056](https://github.com/microsoft/simplechat/issues/1056), [#1057](https://github.com/microsoft/simplechat/issues/1057), [#1058](https://github.com/microsoft/simplechat/issues/1058), [#1059](https://github.com/microsoft/simplechat/issues/1059), and [#1060](https://github.com/microsoft/simplechat/issues/1060)
+
+## Overview
+
+Phase 6 consolidates the Phase 1-5 mixed-source contracts into one manifest-aligned terminal coverage ledger and applies consistent partial-failure, cancellation, finalization, reference, and observability rules to Chat, workflow Search, Analyze, Compare, and conversation continuity.
+
+The change does not replace the existing authorization resolver, narrative window engine, tabular runner, comparison engine, generated-output system, citation storage, ThoughtTracker, or token accounting. All mixed-source behavior flags remain independently default off.
+
+## Architecture
+
+### Terminal coverage ledger
+
+`functions_mixed_source_orchestration.py` now aligns exactly one terminal entry to every fresh manifest source. Each entry preserves:
+
+- Document ID
+- Canonical scope and scope ID
+- Source version
+- Source kind
+- Source or Target role
+- Original request order
+- `completed`, `partial`, `failed`, or `skipped` status
+- A bounded non-sensitive reason for non-success
+
+Evidence is filtered, deduplicated, and reordered against the fresh manifest before synthesis. Unrelated or duplicate terminal evidence fails closed, duplicate filenames remain distinct by canonical identity, unresolved source IDs are scrubbed from the model-facing handoff, and skipped or compacted evidence makes coverage partial.
+
+### Mode failure policy
+
+- **Chat** answers from available native evidence and carries explicit terminal omissions into synthesis.
+- **Search** keeps bounded available results when one native cohort fails. Narrative retrieval failure can coexist with successful table evidence.
+- **Analyze** reduces only when at least one source succeeds. Zero-success requests fail before reduction.
+- **Compare** fails when the Source cannot be prepared. Failed Target preparation or pairwise reduction remains visible while later valid Targets continue.
+
+A failed native table never falls back to generic narrative processing.
+
+### Cancellation and finalization
+
+One `MixedSourceCancellationError` and optional cancellation predicate now span manifest resolution, narrative window calls and reductions, tabular source calls, comparison pairs and reduction, generated export queue/upload, and final response publication.
+
+Cancellation is checked after blocking model/tool calls so returned content is discarded when cancellation arrives in flight. Newly queued background exports use the existing cancellation API. Newly uploaded generated artifacts and citation records are rolled back by exact stored identity. No final reduction, completed progress event, citation artifact, generated output, or assistant message is published after accepted cancellation.
+
+Every previously authorized contributing source is resolved again before standard or streaming Chat and document-action publication. Canonical scope and source version must still match. Sources that were already unresolved remain explicit partial coverage and do not become evidence.
+
+### Bounded Analyze All
+
+The existing `enable_mixed_source_analyze_all` subordinate flag now has a backend contract:
+
+1. The ready document access index enumerates current candidates with a `configured limit + 1` query.
+2. Any catalog above the configured Analyze limit is rejected without truncation.
+3. Group memberships and public visibility are refreshed before enumeration.
+4. Every candidate ID is resolved again through `resolve_authorized_source_manifest(...)` before native execution.
+
+The flag remains default off. The workflow selector is not newly exposed in this phase; activation requires deliberate staged rollout after access-index readiness and production latency/error review.
+
+### Bounded extraction
+
+`get_new_plugin_invocations(...)` moved from `route_backend_chats.py` to `functions_tabular_analysis.py`. The route retains the original symbol as a compatibility shim, while the reusable implementation no longer imports the route at runtime. No second tabular runner or broad route-to-service rewrite was introduced.
+
+## Security
+
+- The existing authorized source manifest remains the sole source resolver and authorization model.
+- Personal ownership and exact approved shares, group membership, public workspace visibility, and chat-upload conversation ownership are rechecked at execution and finalization boundaries.
+- Caller scope, active settings, selected metadata, and continuity records remain untrusted hints.
+- Continuity status, version, coverage, role, and order survive normalization. Partial, failed, truncated, changed, or revoked history forces fresh native work even if the history assessor would otherwise reuse an earlier answer.
+- Foundry `include_document_context=false` filtering remains unchanged and continues to remove mixed-source evidence and file inputs.
+- No raw settings, evidence, prompts, filenames, document IDs, blob paths, or authorization snapshots are added to frontend responses or telemetry.
+
+## Observability
+
+`enable_mixed_source_development_telemetry` is independent and default off. When enabled, an internal UUID correlation ID links manifest, native branches, terminal coverage, reduction, continuity decisions, cancellation phase, and background export/artifact counts.
+
+The emitter accepts only allowlisted aggregate counts, finite latency/token metrics, and bounded categorical dimensions. Source-shaped or other non-allowlisted fields are rejected.
+
+## Rollout And Rollback
+
+The following flags remain independently default off:
+
+- `enable_mixed_source_manifest`
+- `enable_mixed_source_chat_search`
+- `enable_mixed_source_analyze`
+- `enable_cross_format_compare`
+- `enable_mixed_source_conversation_continuity`
+
+Subordinate stages also remain off:
+
+- `enable_mixed_source_relevance_candidates`
+- `enable_mixed_source_analyze_all`
+- `enable_cross_format_compare_one_to_many`
+- `enable_mixed_source_development_telemetry`
+
+No mixed-source mode is made default on. Production rollout still requires error, omission, and latency evidence plus explicit approval.
+
+## Testing And Validation
+
+Primary behavior coverage is in `functional_tests/test_mixed_source_hardening.py`, with Phase 1-5 regression suites retained. Coverage includes manifest-aligned identity, mixed scopes and duplicate filenames, native table failure, mode-specific partial failures, failed Compare Source and Target behavior, continuity precedence, bounded Analyze All, cancellation across lifecycle phases, exact artifact rollback, finalization authorization/version loss, reference deduplication, and telemetry privacy.
+
+The release validation also includes Python compilation, Pylance/editor diagnostics, route policy tests when route code changes, broken-access-control and XSS scans, token/artifact/progress regressions, and CRLF-aware whitespace checks.
+
+## Known Limitations
+
+- Many-to-many Compare and automatic Target discovery remain out of scope.
+- Chat and Search catalog processing remains relevance bounded.
+- Analyze All remains a staged backend capability and is not default on or newly exposed in the selector.
+- Richer relational joins and persistent computed evidence caches remain future work.
+- Production telemetry evidence and explicit approval are still required before enabling mixed-source behavior by default.
diff --git a/docs/explanation/features/MIXED_SOURCE_MANIFEST_AND_EVIDENCE_CONTRACTS.md b/docs/explanation/features/MIXED_SOURCE_MANIFEST_AND_EVIDENCE_CONTRACTS.md
new file mode 100644
index 000000000..d5d7aec74
--- /dev/null
+++ b/docs/explanation/features/MIXED_SOURCE_MANIFEST_AND_EVIDENCE_CONTRACTS.md
@@ -0,0 +1,115 @@
+# Mixed-Source Manifest and Evidence Contracts
+
+Implemented in version: **0.250.062**
+
+GitHub issue: [#1056](https://github.com/microsoft/simplechat/issues/1056)
+
+Parent initiative: [#1055](https://github.com/microsoft/simplechat/issues/1055)
+
+## Overview
+
+SimpleChat now has a shared, authorization-safe contract for describing mixed document selections before any processing engine is selected. The ordered manifest classifies each authorized source as tabular, narrative, or unsupported, while sources that cannot be resolved or authorized receive the same scrubbed unresolved shape.
+
+This is Phase 1 of the mixed-source orchestration initiative. It establishes internal contracts and diagnostics without changing Chat, Search, Analyze, Compare, conversation follow-up, or rollout behavior.
+
+## Purpose
+
+The manifest removes the need for later orchestration phases to repeatedly resolve the same document IDs into incompatible shapes. Its pure partition helper also preserves valid tabular and narrative cohorts when another selected source is unsupported or unresolved.
+
+The bounded evidence envelope gives later native engines one JSON-safe result shape without placing exhaustive rows or unbounded content into synthesis context.
+
+## Dependencies
+
+- Existing personal document ownership checks in `functions_documents.get_document_record(...)`
+- Existing group membership checks used by `functions_search_service.resolve_document_context(...)`
+- Existing public workspace visibility checks in `functions_public_workspaces.py`
+- Personal conversation ownership checks for chat-upload message resolution
+- Structured telemetry through `functions_appinsights.log_event(...)`
+
+No new authorization model, tabular runner, export subsystem, route, database container, or persisted migration is introduced.
+
+## Technical Specifications
+
+### Architecture
+
+`functions_mixed_source_orchestration.py` provides four internal contracts:
+
+- `resolve_authorized_source_manifest(...)` resolves each unique requested document ID once, preserves first-occurrence order, and ignores caller-supplied scope or identity metadata.
+- `partition_source_manifest(...)` returns independent tabular, narrative, unsupported, and unresolved cohorts while preserving order inside each cohort.
+- `normalize_selection_mode(...)` validates `selected`, `all`, `history`, and `relevance` modes for later phases.
+- `build_evidence_envelope(...)` and `serialize_evidence_envelope(...)` validate engine/status values and enforce deterministic item, string, collection, and serialized-size limits.
+
+Authorized manifest entries include normalized document identity, display/file names, extension, source kind, canonical scope and scope ID, applicable group/public/conversation IDs, source version when available, and authorization status.
+
+Unresolved and unauthorized requests are deliberately indistinguishable. Their entries retain only the caller-requested document ID and return null source metadata with `source_kind` and `authorization_status` set to `unresolved`.
+
+### Authorization Boundaries
+
+- Personal sources are returned only when the current user owns the document or has an existing approved share.
+- Group source candidates are restricted to current group memberships before document lookup.
+- Public source candidates are restricted to currently visible public workspaces before document lookup.
+- Chat-upload metadata is queried only after the personal conversation record is loaded and its owner matches the current user. The manifest query projects identity, filename/title, version, role, and inert artifact capability fields without loading embedded file content, extracted text, vision output, or blob data.
+- Requested scope, scope IDs, owner IDs, group IDs, public workspace IDs, and conversation IDs embedded in source payloads are not accepted as authorization decisions.
+
+Current group memberships, public workspace visibility, and chat conversation ownership are resolved once per manifest request and reused for its bounded document lookups. Authorization is still revalidated on every new manifest request.
+
+Manifest diagnostics contain aggregate counts, scope distribution, duplicate count, error count, and resolution duration only. They do not contain document IDs, filenames, content, blob paths, credentials, or raw configuration.
+
+### Evidence Bounds
+
+The evidence envelope has a maximum serialized size of 65,536 bytes. Summary text, error text, collection counts, individual structured values, nesting depth, and coverage metadata are bounded independently. When limits are applied, coverage records `evidence_envelope_truncated`; exhaustive output remains the responsibility of generated artifacts or durable checkpoints.
+
+Source manifests accept at most 100 requested entries. Over-limit requests fail before document resolution and emit count-only diagnostics; sources are never silently truncated.
+
+### API Endpoints
+
+No API endpoints are added or changed in Phase 1.
+
+### Configuration Options
+
+- `enable_mixed_source_manifest`: internal, default-off flag for producing shadow manifests in Chat and workflow requests.
+
+The flag is intentionally not exposed in the admin UI in this phase. Disabling it restores the previous caller path with no data rollback because manifests are request-scoped and not persisted.
+
+### File Structure
+
+- `application/single_app/functions_mixed_source_orchestration.py`
+- `application/single_app/functions_search_service.py`
+- `application/single_app/functions_workflow_runner.py`
+- `application/single_app/route_backend_chats.py`
+- `functional_tests/test_mixed_source_manifest_contracts.py`
+- `functional_tests/test_tabular_document_actions_workflow.py`
+
+## Usage Instructions
+
+This phase has no user workflow or UI changes. Internal callers may enable `enable_mixed_source_manifest` to produce authorization-safe shadow manifests for selected Chat or workflow sources while legacy execution remains unchanged.
+
+Later phases can consume the shared partition and evidence contracts instead of resolving document IDs again. They must continue to reauthorize sources at the object boundary and must not treat a persisted manifest as proof of current access.
+
+## Testing and Validation
+
+- Executable functional coverage: `functional_tests/test_mixed_source_manifest_contracts.py`
+- Updated workflow regression: `functional_tests/test_tabular_document_actions_workflow.py`
+- Coverage includes PDF plus XLSX, DOCX plus CSV in both orders, duplicate IDs, duplicate filenames across scopes, unresolved and unsupported sources among valid sources, personal/group/public/chat authorization, authorization loss, ordering, partitioning, evidence serialization/bounds, selection modes, and privacy-safe diagnostics.
+- Python compilation, editor diagnostics, broken-access-control checks, XSS checks, route-policy checks, and whitespace validation are part of the Phase 1 validation gate.
+
+## Performance Considerations
+
+- Duplicate requested IDs are removed before resolution, preserving the first occurrence.
+- Each unique requested ID is looked up once within a request-scoped authorization snapshot.
+- Requests are capped at 100 source entries before authorization or document reads begin.
+- Classification uses normalized metadata and does not read source content.
+- Evidence bounding occurs before serialization so synthesis payloads remain predictable.
+
+## Known Limitations
+
+- Phase 1 does not activate document retrieval from explicit Chat selections.
+- Phase 1 does not run mixed Analyze engines or synthesize their outputs.
+- Phase 1 does not implement cross-format Compare.
+- Phase 1 does not persist or reuse source context across follow-up turns.
+- Phase 1 does not enumerate an Analyze All Documents catalog.
+- Rollout and native-engine behavior changes remain scoped to #1057 through #1061.
+
+## Related Version Updates
+
+- `application/single_app/config.py` was updated from **0.250.061** to **0.250.062** for #1056.
\ No newline at end of file
diff --git a/docs/explanation/features/UNIVERSAL_CSV_GENERATION.md b/docs/explanation/features/UNIVERSAL_CSV_GENERATION.md
new file mode 100644
index 000000000..52eb7d24d
--- /dev/null
+++ b/docs/explanation/features/UNIVERSAL_CSV_GENERATION.md
@@ -0,0 +1,131 @@
+# Universal CSV Generation
+
+Implemented in version: **0.250.072**
+
+GitHub issue: [#1071](https://github.com/microsoft/simplechat/issues/1071)
+
+Related config.py update: `VERSION = "0.250.072"`
+
+## Overview
+
+CSV is a shared response-output capability rather than a CSV/XLSX-only feature. When a user requests CSV and a response contains valid structured rows, SimpleChat creates one downloadable CSV artifact through the existing authorized chat-artifact contract.
+
+CSV is the durable tabular renderer in the broader [Generated File Export Framework](GENERATED_FILE_EXPORT_FRAMEWORK.md). The framework can also render DOCX and PDF artifacts from final responses and current-turn structured function results.
+
+The finalizer is source-neutral. Native evidence adapters continue to handle PDF, Office, text, image/media-derived, CSV, XLSX, and mixed-source evidence; once a response has a valid Markdown table, tab-separated table, or CSV-shaped result, the same CSV artifact path is used.
+
+## Purpose
+
+Previously, natural requests such as "turn these into a single CSV" could miss the shared intent detector, and workflow replies did not pass through the assistant-table artifact finalizer. Analyze and Compare could also replace a structured analysis response with a concise existing-artifact message before CSV finalization.
+
+This implementation gives ordinary Chat, streaming Chat, Chat Search, selected agents, Analyze, Compare, workflows, and source-free prompts the same final structured-response CSV contract.
+
+## Dependencies
+
+- `functions_assistant_table_exports.py` for CSV intent detection, structured-row parsing, formula protection, duplicate suppression, and authoritative document-action reply selection
+- `functions_simplechat_operations.py` for owner-authorized chat artifact uploads and downloads
+- `functions_tabular_generated_exports.py` for shared row batching, durable queueing, checkpoints, cancellation, reauthorization, and final artifact publication
+- Existing mixed-source manifest and evidence-envelope contracts for selected-source authorization and coverage semantics
+- Azure Cosmos DB `tabular_export_runs` and personal chat blob storage for oversized exports
+
+## Technical Specifications
+
+### Unified Intent and Structured Rows
+
+The shared intent detector recognizes direct CSV requests plus natural variants including `single CSV`, `combined CSV`, and `one CSV`. It excludes negated requests and prompts that merely discuss an input CSV.
+
+The exporter accepts valid structured response forms:
+
+- Markdown tables
+- Tab-separated tables
+- CSV, including quoted commas, multiline values, and escaped quotes
+- CSV-shaped output in supported fenced blocks
+
+Every generated CSV uses safe headers and neutralizes spreadsheet formula-like values while preserving signed numeric text.
+
+Successful current-turn structured function results are also accepted through the generated file export framework when the assistant summarizes an action rather than reproducing its rows. Sensitive fields are excluded, merged action rows carry source provenance, and tabular-plugin results remain on the existing coverage-aware tabular export path.
+
+### Row and Schema Clarification
+
+For an ambiguous CSV request, Chat and workflow model/agent prompts direct the assistant to ask exactly one concise question before generating a file: whether each row represents files, documents, or extracted records, and which columns to include. The assistant response is persisted in the conversation, so the next user turn can answer the clarification without a separate temporary state store.
+
+Explicit instructions such as `one row per document` or `columns: file name, amount` bypass that clarification. When native evidence already establishes a clear structured result, the assistant proceeds directly to valid rows and the downloadable CSV.
+
+### Response Path Finalization
+
+The common finalizer runs after the response is complete in:
+
+- Standard and streaming Chat, including Chat Search and selected agents
+- Analyze and Compare document actions
+- Personal and group workflow assistant messages
+- Source-free prompts that return valid structured rows
+
+For Analyze and Compare, the finalizer prefers `analysis_result.analysis_reply` over a concise `reply` that may only describe a separately generated artifact. That ensures a valid structured result remains exportable without changing the user-visible response.
+
+### Evidence, Coverage, and Source Types
+
+CSV finalization does not retrieve or infer source data itself. It consumes only valid rows already produced by the native evidence path. This keeps source authorization, revision handling, and selected-source coverage in the existing mixed-source orchestration layer.
+
+Explicit selections remain authoritative upstream. A generated CSV cannot silently invent unprocessed source rows: if native evidence is partial, failed, unsupported, unresolved, canceled, or revision-changed, the response coverage contract remains responsible for disclosing that state. The CSV serializer only writes validated structured rows supplied to it.
+
+### Immediate and Durable Artifacts
+
+Small result sets upload immediately through the generic chat artifact uploader. The uploader verifies that the conversation is owned by the workflow/chat user before writing the blob-backed file message.
+
+Large assistant-rendered tables use the existing durable tabular export queue:
+
+1. Rows are batched with the shared row and character budget.
+2. The queue stages passthrough rows, so it does not call a model again to serialize an already validated table.
+3. The worker reauthorizes conversation ownership before processing.
+4. The staged-chat source uses `source: chat` without pretending that staged rows are an external source blob.
+5. The existing progress card exposes status, cancellation, retry/resume, and final authorized download behavior.
+
+### Configuration
+
+The durable threshold and batch settings are shared with tabular exports:
+
+- `enable_tabular_generated_output_background_exports`
+- `tabular_generated_output_inline_max_rows`
+- `tabular_generated_output_inline_max_batches`
+- `tabular_generated_output_max_batch_rows`
+- `tabular_generated_output_max_batch_chars`
+
+## File Structure
+
+- `application/single_app/functions_assistant_table_exports.py`
+- `application/single_app/functions_tabular_generated_exports.py`
+- `application/single_app/route_backend_chats.py`
+- `application/single_app/functions_workflow_runner.py`
+- `functional_tests/test_assistant_table_csv_artifact.py`
+- `functional_tests/test_tabular_row_orchestration_scale.py`
+
+## Usage
+
+Users can request a CSV in natural language, for example:
+
+- `Turn these into a single CSV.`
+- `Save one CSV with the extracted invoice fields.`
+- `Create a combined CSV from the selected sources.`
+
+When the response contains valid structured rows, the chat displays the downloadable CSV artifact. Large results display the existing background-export status card until the authorized artifact is published.
+
+When the requested row unit or columns are unclear, the assistant asks one clarification in the conversation. Reply with the desired row unit and columns, then the normal CSV finalization path creates the artifact from the resulting structured response.
+
+## Testing and Validation
+
+- `functional_tests/test_assistant_table_csv_artifact.py` validates intent variants, non-tabular response parsing, Analyze/Compare structured-reply selection, workflow artifacts, duplicate suppression, formula safety, and immediate/background paths.
+- `functional_tests/test_tabular_row_orchestration_scale.py` validates durable row contracts, worker reauthorization, staged-chat authorization, cancellation, recovery, and idempotent publication.
+- `functional_tests/test_tabular_background_generated_exports.py` validates queue, status, and browser-contract wiring.
+
+## Performance Considerations
+
+- Small tables avoid queue overhead and upload immediately.
+- Large tables use the existing bounded row/character batch budget and checkpointed durable worker.
+- Passthrough batches avoid duplicate model inference and preserve the validated row order.
+- Worker reauthorization uses compact identifiers and does not log source row contents.
+
+## Known Limitations
+
+- A CSV artifact requires parseable structured rows. Prose-only model output does not produce an empty or fabricated CSV.
+- Clarification state is represented by the persisted assistant conversation message rather than a separate job or schema-state container.
+- Native evidence adapters and mixed-source coverage are intentionally retained as their existing specialized contracts. This feature finalizes their valid structured response output rather than replacing retrieval, document analysis, or source orchestration.
diff --git a/docs/explanation/fixes/BACKGROUND_CSV_EXPORT_THROUGHPUT_AND_TIMEOUT_FIX.md b/docs/explanation/fixes/BACKGROUND_CSV_EXPORT_THROUGHPUT_AND_TIMEOUT_FIX.md
new file mode 100644
index 000000000..f92f50c18
--- /dev/null
+++ b/docs/explanation/fixes/BACKGROUND_CSV_EXPORT_THROUGHPUT_AND_TIMEOUT_FIX.md
@@ -0,0 +1,59 @@
+# Background CSV Export Throughput and Timeout Fix
+
+Fixed in version: **0.250.070**
+
+Related issue: [#1071](https://github.com/microsoft/simplechat/issues/1071)
+
+## Issue
+
+Large structured CSV exports could appear stalled while a long-running model batch
+occupied the background worker. The worker processed only two post-schema batches
+at once, and an individual model request had no export-specific timeout.
+
+## Root Cause
+
+The first batch must remain serial because it establishes the validated output
+schema used by every later batch. Once that schema existed, the background worker
+used a conservative default of two concurrent model batches. A slow or hung model
+request could hold the worker far longer than the user-facing progress state
+suggested.
+
+## Technical Details
+
+- Increased default post-schema batch concurrency from two to three, within the
+ existing maximum of five concurrent batches.
+- Added a configurable per-batch model timeout with a five-minute default.
+- Caps the batch timeout below the stale-worker threshold so a stuck request is
+ requeued before stale-worker recovery can create competing execution.
+- Treats a timed-out batch as a retryable timeout, preserving existing durable
+ checkpoints and idempotent artifact publication.
+- Retains serial schema discovery so parallel batches cannot produce incompatible
+ CSV schemas.
+
+Modified files:
+
+- `application/single_app/functions_tabular_generated_exports.py`
+- `functional_tests/test_tabular_background_generated_exports.py`
+- `application/single_app/config.py`
+
+## Validation
+
+- The focused durable-export functional suite passed 7/7 checks.
+- The suite now executes a stalled asynchronous model-call case and verifies it
+ produces a retryable timeout rather than waiting indefinitely.
+- Existing bounded concurrency, checkpointing, scheduler, and UI polling checks
+ continue to pass.
+
+## Impact
+
+For a multi-batch export, post-schema work can now use three concurrent model
+batches. This reduces the number of execution windows for common six-batch runs
+while preserving the validated schema and ordered output guarantees. A genuinely
+stuck batch is retried from its last durable checkpoint instead of indefinitely
+occupying a background worker.
+
+## Follow-up
+
+Issue #1071 tracks the broader format-neutral job framework, including
+forward-progress detection, benchmark gates, and comparable performance behavior
+for CSV, Word, and PowerPoint generation.
\ No newline at end of file
diff --git a/docs/explanation/fixes/COSMOS_MIGRATION_JSON_PROPERTY_AND_SKIP_REPORTING_FIX.md b/docs/explanation/fixes/COSMOS_MIGRATION_JSON_PROPERTY_AND_SKIP_REPORTING_FIX.md
new file mode 100644
index 000000000..10363e58b
--- /dev/null
+++ b/docs/explanation/fixes/COSMOS_MIGRATION_JSON_PROPERTY_AND_SKIP_REPORTING_FIX.md
@@ -0,0 +1,65 @@
+# Cosmos Migration JSON Property, Skip Reporting, and Throttling Recovery Fix
+
+Fixed in version: **0.250.064**
+
+## Issue
+
+The Cosmos DB migration stopped while parsing a document-feed response when a
+source document contained a JSON property whose name was an empty string.
+PowerShell's default `ConvertFrom-Json` object conversion rejects that valid
+JSON shape. Any individual destination write failure also stopped the entire
+migration without leaving a durable document-level audit record. A sustained
+Cosmos DB HTTP 429 response similarly ended the run after its request retry
+budget was exhausted, requiring an administrator to restart it manually.
+
+## Root Cause
+
+Cosmos document responses and writable document clones were converted to
+`PSCustomObject`. That representation cannot preserve empty or case-distinct
+JSON property names. Parallel write failures were emitted as events, but the
+parent migration treated every failed event as fatal.
+
+## Technical Details
+
+- Document feeds and document write responses use
+ `ConvertFrom-Json -AsHashtable`, preserving empty, nested-empty, and
+ case-distinct property names.
+- Writable document clones use the same ordered hash-table representation and
+ remove only Cosmos-managed properties before serialization.
+- Document preparation failures and document-scoped HTTP 400, 409, 413, and
+ 422 write failures are skipped without stopping later documents.
+- Authentication, connectivity, exhausted transient retries, feed failures,
+ and other systemic errors remain fatal.
+- Exhausted HTTP 429 document writes enter bounded automatic recovery. The
+ script pauses with a per-second progress countdown, then retries only the
+ throttled documents. `MaxThrottleRecoveryPauses` defaults to `5`, and
+ `ThrottleRecoveryPauseSeconds` defaults to `60`. Once the pause budget is
+ exhausted, the failure explains the affected container/document count and
+ recommends lowering `MaxConcurrentDocuments`, increasing destination RU
+ capacity, or rerunning with the same state file.
+- Each affected container records `ErrorSkippedCount` and structured
+ `SkippedDocuments` entries in the migration state JSON. Completed containers
+ store them under `result`; interrupted or failed containers retain the latest
+ audit under `progress`.
+- The final summary records the aggregate count and prints an admin warning
+ with the state path. If a later systemic error stops the run, the ordinary
+ migration failure message and an admin review warning are shown, while any
+ earlier document skips remain in the failed container's
+ `progress.SkippedDocuments` list.
+
+Files modified:
+
+- `scripts/Migration-Cosmos.ps1`
+- `functional_tests/test_cosmos_migration_document_skip_reporting.py`
+- `application/single_app/config.py`
+
+## Validation
+
+The focused functional test runs the bounded parallel-write path against a
+mock Cosmos REST API. It verifies that a document containing empty and
+case-distinct property names is copied unchanged, a rejected document is
+recorded, a later document still copies, the state counts are correct, and the
+completion output directs the admin to the audit details.
+
+The application version in `application/single_app/config.py` was updated to
+`0.250.064` with this fix.
\ No newline at end of file
diff --git a/docs/explanation/fixes/MIXED_SOURCE_ANALYZE_GATING_REMOVAL_FIX.md b/docs/explanation/fixes/MIXED_SOURCE_ANALYZE_GATING_REMOVAL_FIX.md
new file mode 100644
index 000000000..6155754b0
--- /dev/null
+++ b/docs/explanation/fixes/MIXED_SOURCE_ANALYZE_GATING_REMOVAL_FIX.md
@@ -0,0 +1,25 @@
+# Mixed-Source Analyze Gating Removal Fix
+
+Version: 0.250.071
+
+Fixed/Implemented in version: **0.250.071**
+
+Related config.py update: `VERSION = "0.250.071"`
+
+## Header Information
+
+- Issue description: Combined Analyze rejected a selected mixture of narrative and tabular documents when the internal mixed-source rollout setting was false.
+- Root cause analysis: The workflow runner used a default-off settings flag to choose between the native mixed-source workflow and legacy single-engine paths. The legacy path then raised an error for mixed document types.
+- Version implemented: 0.250.071
+
+## Technical Details
+
+- Files modified: `application/single_app/functions_workflow_runner.py`, `application/single_app/functions_settings.py`, `application/single_app/config.py`, `functional_tests/test_mixed_source_analyze_workflow.py`.
+- Code changes summary: Combined Analyze now always uses the existing authorization-safe mixed-source workflow for both agent and direct-model runners. The obsolete settings defaults, helpers, and legacy rejection path were removed.
+- Testing approach: Updated the focused functional test to require automatic mixed-source routing, preserve per-document behavior, and reject reintroduction of the settings flag.
+
+## Validation
+
+- Test results: `functional_tests/test_mixed_source_analyze_workflow.py` passes with all three tests successful.
+- Before/after comparison: Before the fix, a PDF/DOCX plus XLSX/CSV selection could fail with a disabled mixed-source Analyze message. After the fix, selected narrative and tabular sources are partitioned and analyzed by their native engines before a combined response is produced.
+- User experience improvements: Users can select compatible narrative and tabular documents together for Analyze without requiring an administrator or deployment setting change.
\ No newline at end of file
diff --git a/docs/explanation/fixes/NON_TABULAR_DOCUMENT_CSV_ARTIFACT_FIX.md b/docs/explanation/fixes/NON_TABULAR_DOCUMENT_CSV_ARTIFACT_FIX.md
new file mode 100644
index 000000000..7b990f3d9
--- /dev/null
+++ b/docs/explanation/fixes/NON_TABULAR_DOCUMENT_CSV_ARTIFACT_FIX.md
@@ -0,0 +1,51 @@
+# Non-Tabular Document CSV Artifact Fix
+
+Fixed in version: **0.250.065**
+
+Related issue: [#1066](https://github.com/microsoft/simplechat/issues/1066)
+
+## Issue
+
+When a user selected a PDF, Word document, or other non-tabular source and explicitly requested a CSV, the assistant could return valid comma-delimited rows without creating a downloadable CSV artifact.
+
+## Root Cause
+
+The assistant table export helper recognized Markdown pipe tables and tab-separated output, but not comma-delimited output. The chat route already invoked the helper and uploaded successful results, so valid CSV text stopped at the parser boundary.
+
+## Technical Details
+
+### Files Modified
+
+- `application/single_app/functions_assistant_table_exports.py`
+- `application/single_app/route_backend_chats.py`
+- `application/single_app/functions_tabular_generated_exports.py`
+- `application/single_app/functions_workflow_runner.py`
+- `application/single_app/config.py`
+- `functional_tests/test_assistant_table_csv_artifact.py`
+- `functional_tests/test_document_analysis_lossless_artifacts.py`
+- `functional_tests/test_tabular_row_orchestration_scale.py`
+- `docs/explanation/release_notes.md`
+
+### Changes
+
+- Parse CSV from explicit CSV, text, and plaintext code fences.
+- Parse conservative comma-delimited blocks when the model returns plain CSV text.
+- Recognize broader explicit CSV phrasing through one shared intent predicate used by generic and tabular export paths.
+- Use Python's CSV parser to preserve quoted commas, escaped quotes, multiline values, and column order.
+- Exclude surrounding prose and document citation lines from generated CSV rows.
+- Neutralize spreadsheet formula prefixes in downloaded headers and values while preserving signed numeric values across assistant-table, immediate tabular, durable background, and workflow analysis CSV writers.
+- Preserve every source column when duplicate headers collide with already-suffixed header names.
+- Continue requiring an explicit CSV or table request before creating an artifact.
+- Send large assistant-derived CSV row sets through the existing checkpointed background exporter without a second model transformation; the chat displays the normal queued/running/completed status and download link.
+
+## Validation
+
+The focused functional tests cover PDF-style fenced CSV, plain CSV followed by source citations, Word-style multiline and escaped-quote values, blank lines inside quoted values, alternate text fence labels, comma-bearing prose, formula-prefixed headers and cells across every generated CSV writer, duplicate header collisions, Markdown tables, tab-separated tables, broader explicit request phrasing, non-export requests, and the bounded 30,000-row background writer.
+
+Before the fix, a valid comma-delimited response produced no export payload. After the fix, the existing artifact uploader receives normalized rows and creates a downloadable `.csv` file; large row sets use the existing background export status and checkpoint flow.
+
+## Impact
+
+Users can request a CSV from structured information contained in non-tabular documents without manually copying the model's rendered CSV text into a local file. The fix does not infer rows from prose itself; the assistant response must contain valid table-shaped output.
+
+The application version was updated in `application/single_app/config.py` from `0.250.064` to `0.250.065`.
\ No newline at end of file
diff --git a/docs/explanation/fixes/PR_1145_CODEQL_ALERT_REMEDIATION_PLAN.md b/docs/explanation/fixes/PR_1145_CODEQL_ALERT_REMEDIATION_PLAN.md
new file mode 100644
index 000000000..e925d93d6
--- /dev/null
+++ b/docs/explanation/fixes/PR_1145_CODEQL_ALERT_REMEDIATION_PLAN.md
@@ -0,0 +1,512 @@
+# PR 1145 CodeQL Alert Remediation Plan
+
+Planning baseline version: **0.250.110**
+
+Related PR: **microsoft/simplechat#1145**
+
+Implementation status: **In progress. Phase 1 items 1-3 implemented in versions 0.250.111, 0.250.112, and 0.250.113; Phase 2 items 4-8 implemented in versions 0.250.114, 0.250.115, 0.250.116, 0.250.117, and 0.250.118; Phase 3 item 9 skipped, item 10 implemented in version 0.250.119; deferred import-cycle items B2-B3 implemented in version 0.250.120; and items 11-13 are still pending.**
+
+## Purpose
+
+This plan converts the 62 CodeQL annotations from PR #1145 into an execution queue. The main execution plan follows the implementation decisions from the alert review. Optional test-file cleanup items and intentionally deferred architecture cleanup are captured in a separate deferred remediation plan at the end of this document.
+
+## Execution Policy
+
+- Implement all failure and warning alerts unless they are explicitly deferred below.
+- Implement low-risk hygiene notices when they are mechanically scoped and do not require broad import-boundary refactoring.
+- Defer optional test-file cleanup items even if they are easy, so the main remediation stays focused on security, runtime correctness, and deterministic cleanup.
+- Defer cyclic-import and `import *` cleanup into separate planning work because those changes can alter import timing and route-module behavior.
+- For simple Python hygiene findings, follow the CodeQL remediation example directly.
+- For URL validation, regex performance, and exception disclosure, follow CodeQL's security principle but use repo-specific helpers and response patterns that fit SimpleChat logging, streaming, and mixed-source workflows.
+
+## Main Execution Plan
+
+### Phase 1: Security and Runtime-Failure Remediation
+
+#### 1. Replace SharePoint substring URL checks with host-aware validation
+
+- Alerts: 1, 2
+- CodeQL rule: Incomplete URL substring sanitization
+- Severity: failure
+- Status: **Implemented in version 0.250.111**
+- Locations:
+ - [application/single_app/route_backend_chats.py](../../../application/single_app/route_backend_chats.py#L8577)
+ - [application/single_app/route_backend_chats.py](../../../application/single_app/route_backend_chats.py#L8548)
+- Decision: Implement.
+- Why: Substring checks allow attacker-controlled hostnames or paths that merely contain `sharepoint.com`. This is a real validation issue.
+- Remediation style: Develop our own repo-specific helper while following CodeQL's host-validation guidance.
+- Execution tasks:
+ - Create one shared helper for tabular URL-like detection and SharePoint host validation.
+ - Parse URLs with `urlparse` instead of checking arbitrary substrings.
+ - Require `http` or `https` where a full URL is present.
+ - Accept only `sharepoint.com` or subdomains ending in `.sharepoint.com` for SharePoint-specific detection.
+ - Preserve existing `/sites/` path detection only where it is intentionally path-like content, not a trusted external URL decision.
+- Validation starting point:
+ - Add or update focused functional coverage for valid SharePoint URLs, subdomain SharePoint URLs, malicious lookalike domains, path-only `/sites/` values, and ordinary non-URL values.
+- Validation completed:
+ - `python functional_tests/test_tabular_llm_reviewer_recovery.py`
+ - `python -m py_compile application/single_app/route_backend_chats.py application/single_app/config.py`
+ - `git -c core.whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol diff --check -- application/single_app/route_backend_chats.py functional_tests/test_tabular_llm_reviewer_recovery.py application/single_app/config.py`
+
+#### 2. Replace polynomial markdown-fence regex parsing with linear parsing
+
+- Alerts: 4, 5, 6
+- CodeQL rule: Polynomial regular expression used on uncontrolled data
+- Severity: failure
+- Status: **Implemented in version 0.250.112**
+- Locations:
+ - [application/single_app/functions_workflow_runner.py](../../../application/single_app/functions_workflow_runner.py#L383)
+ - [application/single_app/functions_assistant_table_exports.py](../../../application/single_app/functions_assistant_table_exports.py#L389)
+ - [application/single_app/functions_assistant_table_exports.py](../../../application/single_app/functions_assistant_table_exports.py#L414)
+- Decision: Implement.
+- Why: These helpers process model or user-influenced text. A regex with backtracking risk can create avoidable request latency or denial-of-service exposure.
+- Remediation style: Develop our own linear parser rather than tuning the regex.
+- Execution tasks:
+ - Replace the workflow code-fence stripper with prefix/suffix checks and bounded slicing.
+ - Introduce or reuse a linear fenced-block iterator for assistant table export parsing.
+ - Preserve language-label behavior for CSV fences and generic fences.
+ - Keep unfenced CSV parsing behavior unchanged.
+- Validation starting point:
+ - Add focused tests for fenced JSON, fenced CSV, generic fenced CSV-like content, unfenced CSV content, unterminated fences, and adversarial strings with many spaces or tabs after opening fences.
+- Validation completed:
+ - `python functional_tests/test_assistant_table_csv_artifact.py`
+ - `python functional_tests/test_document_analysis_lossless_artifacts.py`
+ - `python functional_tests/test_document_analysis_structured_output.py`
+ - `python -m py_compile application/single_app/functions_workflow_runner.py application/single_app/functions_assistant_table_exports.py application/single_app/config.py`
+
+#### 3. Stop exposing raw exception messages or tracebacks to browser responses
+
+- Alerts: 7-17, 20-23, 25-27
+- CodeQL rule: Information exposure through an exception
+- Severity: warning
+- Status: **Implemented in version 0.250.113**
+- Locations:
+ - [application/single_app/route_backend_chats.py](../../../application/single_app/route_backend_chats.py#L14244)
+ - [application/single_app/route_backend_chats.py](../../../application/single_app/route_backend_chats.py#L14316)
+ - [application/single_app/route_backend_chats.py](../../../application/single_app/route_backend_chats.py#L14467)
+ - [application/single_app/route_backend_chats.py](../../../application/single_app/route_backend_chats.py#L14469)
+ - [application/single_app/route_backend_chats.py](../../../application/single_app/route_backend_chats.py#L14485)
+ - [application/single_app/route_backend_chats.py](../../../application/single_app/route_backend_chats.py#L14675)
+ - [application/single_app/route_backend_chats.py](../../../application/single_app/route_backend_chats.py#L14989)
+ - [application/single_app/route_backend_chats.py](../../../application/single_app/route_backend_chats.py#L15005)
+ - [application/single_app/route_backend_chats.py](../../../application/single_app/route_backend_chats.py#L15096)
+ - [application/single_app/route_backend_chats.py](../../../application/single_app/route_backend_chats.py#L16568-L16570)
+ - [application/single_app/route_backend_chats.py](../../../application/single_app/route_backend_chats.py#L17212)
+ - [application/single_app/route_backend_chats.py](../../../application/single_app/route_backend_chats.py#L18405-L18433)
+ - [application/single_app/route_backend_chats.py](../../../application/single_app/route_backend_chats.py#L18450-L18453)
+ - [application/single_app/route_backend_chats.py](../../../application/single_app/route_backend_chats.py#L18479)
+ - [application/single_app/route_backend_chats.py](../../../application/single_app/route_backend_chats.py#L22523)
+ - [application/single_app/route_backend_chats.py](../../../application/single_app/route_backend_chats.py#L22539)
+ - [application/single_app/route_backend_chats.py](../../../application/single_app/route_backend_chats.py#L22546)
+ - [application/single_app/route_backend_chats.py](../../../application/single_app/route_backend_chats.py#L22560-L22563)
+- Decision: Implement.
+- Why: Raw exception strings can contain provider details, internal object names, stack traces, paths, query text, storage metadata, or authorization state. The route should log details server-side and return stable client-safe messages.
+- Remediation style: Follow CodeQL's principle, but develop SimpleChat-specific response helpers for JSON and SSE paths.
+- Execution tasks:
+ - Keep detailed exception data in `log_event` with `exceptionTraceback=True` where appropriate.
+ - Return generic client-safe messages for unexpected server failures.
+ - Preserve specific 400, 401, 403, and 404 messages only when they are intentional validation or authorization outcomes.
+ - For content-safety and moderation paths, use explicit allowlisted user-facing messages.
+ - For streaming routes, emit sanitized SSE error events and avoid embedding raw exception text in `error` or `partial_content` metadata.
+ - Remove traceback details from browser responses even when Flask debug is enabled.
+- Validation starting point:
+ - Add focused route or helper tests that simulate unexpected exceptions and assert that responses do not include raw exception text, traceback text, local paths, provider class names, or internal query/source descriptors.
+ - Include both JSON responses and SSE error events.
+- Validation completed:
+ - `python functional_tests/test_chat_error_response_sanitization.py`
+ - `python -m py_compile application/single_app/route_backend_chats.py application/single_app/config.py functional_tests/test_chat_error_response_sanitization.py`
+ - `git -c core.whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol diff --check -- application/single_app/route_backend_chats.py application/single_app/config.py functional_tests/test_chat_error_response_sanitization.py docs/explanation/fixes/PR_1145_CODEQL_ALERT_REMEDIATION_PLAN.md`
+- Validation notes:
+ - `python functional_tests/test_foundry_delegated_user_auth.py` was attempted; 7/8 checks passed, and the remaining failure is the test's hardcoded historic `VERSION = "0.241.196"` assertion.
+ - `python functional_tests/test_content_safety_error_handling.py` was attempted; it fails before checking behavior because it points at stale root-level `route_backend_chats.py` and `static/js/chat/chat-messages.js` paths.
+
+### Phase 2: Correctness and Behavior Cleanup
+
+#### 4. Fix unused citation loop variable
+
+- Alert: 3
+- CodeQL rule: Suspicious unused loop iteration variable
+- Severity: failure
+- Status: **Implemented in version 0.250.114**
+- Location: [application/single_app/route_backend_chats.py](../../../application/single_app/route_backend_chats.py#L17737)
+- Decision: Implement.
+- Why: The loop currently iterates citations without using the citation value. That can indicate repeated duplicate thoughts or missing citation-specific detail.
+- Remediation style: Follow CodeQL directly if only the count matters; otherwise use the citation object meaningfully.
+- Execution tasks:
+ - Decide whether one thought per citation is intended.
+ - If yes, include sanitized citation-specific detail in the thought payload.
+ - If no, replace the loop with a single aggregate thought or use `_` only for intentional repeated emission.
+- Validation starting point:
+ - Add or update a focused Foundry citation test to assert the expected number and content of citation thoughts.
+- Validation completed:
+ - `python functional_tests/test_foundry_citation_thoughts.py`
+ - `python -m py_compile application/single_app/route_backend_chats.py application/single_app/config.py functional_tests/test_foundry_citation_thoughts.py`
+ - `git -c core.whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol diff --check -- application/single_app/route_backend_chats.py application/single_app/config.py functional_tests/test_foundry_citation_thoughts.py docs/explanation/fixes/PR_1145_CODEQL_ALERT_REMEDIATION_PLAN.md`
+
+#### 5. Remove duplicate keys in token usage aggregation test fixture
+
+- Alerts: 18, 24
+- CodeQL rule: Duplicate key in dict literal
+- Severity: warning
+- Status: **Implemented in version 0.250.115**
+- Location: [functional_tests/test_document_action_token_usage_aggregation.py](../../../functional_tests/test_document_action_token_usage_aggregation.py#L225-L226)
+- Decision: Implement.
+- Why: Duplicate keys hide fixture intent and can make a test pass with the wrong mocked behavior.
+- Remediation style: Follow CodeQL directly.
+- Execution tasks:
+ - Remove the overwritten duplicate entries.
+ - Keep one canonical fixture value for each helper.
+ - Confirm the test still exercises cross-format compare behavior intentionally.
+- Validation starting point:
+ - Run `python functional_tests/test_document_action_token_usage_aggregation.py`.
+- Validation completed:
+ - `python functional_tests/test_document_action_token_usage_aggregation.py`
+ - `python -m py_compile functional_tests/test_document_action_token_usage_aggregation.py application/single_app/config.py`
+ - `git -c core.whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol diff --check -- functional_tests/test_document_action_token_usage_aggregation.py application/single_app/config.py docs/explanation/fixes/PR_1145_CODEQL_ALERT_REMEDIATION_PLAN.md docs/explanation/release_notes.md`
+
+#### 6. Resolve unreachable code in chat route
+
+- Alert: 19
+- CodeQL rule: Unreachable code
+- Severity: warning
+- Status: **Implemented in version 0.250.116**
+- Location: [application/single_app/route_backend_chats.py](../../../application/single_app/route_backend_chats.py#L18404)
+- Decision: Implement.
+- Why: Unreachable code in a large route can hide a missing branch or stale error handling.
+- Remediation style: Follow CodeQL directly after local inspection.
+- Execution tasks:
+ - Inspect the surrounding control flow.
+ - Remove the statement if it is stale.
+ - Move it before the terminal return if it was intended to run.
+- Validation starting point:
+ - Compile the route module and run the focused chat route tests that cover the edited path.
+- Validation completed:
+ - `python functional_tests/test_chat_route_unreachable_code_cleanup.py`
+ - `python functional_tests/test_chat_error_response_sanitization.py`
+ - `python -m py_compile application/single_app/route_backend_chats.py application/single_app/config.py functional_tests/test_chat_route_unreachable_code_cleanup.py`
+
+#### 7. Resolve no-effect statement in tabular lifecycle thought helper
+
+- Alert: 47
+- CodeQL rule: Statement has no effect
+- Severity: notice
+- Status: **Implemented in version 0.250.117**
+- Location: [application/single_app/route_backend_chats.py](../../../application/single_app/route_backend_chats.py#L9773)
+- Decision: Implement after inspection.
+- Why: A no-effect statement is usually leftover code or a missed assignment/call.
+- Remediation style: Follow CodeQL directly once intent is known.
+- Execution tasks:
+ - Inspect the helper around the flagged line.
+ - Remove the statement if it is leftover.
+ - Convert it into the intended assignment or function call only if nearby logic proves that was the intent.
+- Resolution:
+ - The active PR merge annotation mapped to the async callback wait in the tabular post-processing thought emitter.
+ - Converted the awaited callback result into an explicit return so the async callback execution is still awaited while the statement is no longer effect-free to static analysis.
+ - Propagated the callback result through the lifecycle thought wrapper without changing existing callers, which already ignore the return value.
+- Validation starting point:
+ - Compile the route module and run tabular chat/thought tests that cover lifecycle thought emission.
+- Validation completed:
+ - `python functional_tests/test_workspace_tabular_trigger_and_thoughts.py`
+ - `python -m py_compile application/single_app/route_backend_chats.py application/single_app/config.py functional_tests/test_workspace_tabular_trigger_and_thoughts.py`
+ - `git -c core.whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol diff --check -- application/single_app/route_backend_chats.py application/single_app/config.py functional_tests/test_workspace_tabular_trigger_and_thoughts.py docs/explanation/fixes/PR_1145_CODEQL_ALERT_REMEDIATION_PLAN.md`
+
+#### 8. Make mixed explicit and implicit returns explicit
+
+- Alert: 58
+- CodeQL rule: Explicit returns mixed with implicit fall-through returns
+- Severity: notice
+- Status: **Implemented in version 0.250.118**
+- Location: [application/single_app/route_backend_chats.py](../../../application/single_app/route_backend_chats.py#L17310)
+- Decision: Implement after inspection.
+- Why: A silent `None` fall-through can turn into confusing model-call behavior or skipped error handling.
+- Remediation style: Develop the smallest local fix after inspecting the nested function's contract.
+- Execution tasks:
+ - Identify the nested function and expected return shape.
+ - Add an explicit terminal return if `None` is valid.
+ - Otherwise add the missing return path that matches the function contract.
+- Resolution:
+ - Identified the nested Semantic Kernel `run_sk_call(...)` helper as the alert source.
+ - Preserved the existing empty async-generator behavior by returning `None` explicitly after the async iteration completes without yielding.
+ - Added focused functional coverage for the helper's explicit async-generator return contract and core result shapes.
+- Validation starting point:
+ - Run focused tests around the Semantic Kernel call path or add a small unit-style functional test for the function contract.
+- Validation completed:
+ - `python functional_tests/test_chat_semantic_kernel_return_contract.py`
+ - `python -m py_compile application/single_app/route_backend_chats.py application/single_app/config.py functional_tests/test_chat_semantic_kernel_return_contract.py`
+
+### Phase 3: Low-Risk Hygiene Cleanup
+
+#### 9. Remove unused imports in route and workflow modules
+
+- SKIPPING THIS ONE.
+
+#### 10. Remove duplicate local `json` import
+
+- Alert: 30
+- CodeQL rule: Module is imported more than once
+- Severity: notice
+- Status: **Implemented in version 0.250.119**
+- Location: [application/single_app/route_backend_chats.py](../../../application/single_app/route_backend_chats.py#L18657)
+- Decision: Implement.
+- Why: The module already imports `json` at top level. The local import is redundant.
+- Remediation style: Follow CodeQL directly.
+- Execution tasks:
+ - Remove the nested duplicate import.
+ - Confirm the function still resolves the top-level module import.
+- Resolution:
+ - Removed the nested `import json` from the `chat_stream_api` route.
+ - Confirmed the route still resolves `json` from the existing module-level import.
+- Validation starting point:
+ - Compile [application/single_app/route_backend_chats.py](../../../application/single_app/route_backend_chats.py).
+- Validation completed:
+ - `python -m py_compile application/single_app/route_backend_chats.py application/single_app/config.py`
+ - `git -c core.whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol diff --check -- application/single_app/route_backend_chats.py application/single_app/config.py docs/explanation/fixes/PR_1145_CODEQL_ALERT_REMEDIATION_PLAN.md`
+- Validation notes:
+ - `python functional_tests/test_chat_stream_compatibility_sse_syntax.py` was attempted; it fails before validating behavior because it asserts the stale historic version `0.239.185` in `config.py`.
+
+#### 11. Replace empty except blocks with intentional handling
+
+- Alerts: 32, 33, 34
+- CodeQL rule: Empty except
+- Severity: notice
+- Locations:
+ - [application/single_app/route_backend_chats.py](../../../application/single_app/route_backend_chats.py#L22142)
+ - [application/single_app/route_backend_chats.py](../../../application/single_app/route_backend_chats.py#L22170)
+ - [application/single_app/route_backend_chats.py](../../../application/single_app/route_backend_chats.py#L22234)
+- Decision: Implement.
+- Why: Silent exception swallowing makes rollback or cleanup failures difficult to diagnose.
+- Remediation style: Use repo-specific logging, not generic print statements.
+- Execution tasks:
+ - Narrow the caught exception type if possible.
+ - Log cleanup failures with `log_event` or `debug_print`, depending on expected frequency and severity.
+ - Add a short explanatory comment only if the exception is intentionally ignored.
+- Validation starting point:
+ - Compile the route module and run streaming cancellation or rollback tests that cover these cleanup paths.
+
+#### 12. Remove unused local variables and dead debug comments
+
+- Alerts: 28, 46, 59
+- CodeQL rules: Unused local variable; commented-out code
+- Severity: notice
+- Locations:
+ - [application/single_app/route_backend_chats.py](../../../application/single_app/route_backend_chats.py#L11239)
+ - [application/single_app/route_backend_chats.py](../../../application/single_app/route_backend_chats.py#L12820)
+ - [application/single_app/route_backend_chats.py](../../../application/single_app/route_backend_chats.py#L17655-L17659)
+- Decision: Implement.
+- Why: These findings add noise in a large route module and can obscure meaningful analysis.
+- Remediation style: Follow CodeQL directly unless local inspection shows the variable should be wired into behavior.
+- Execution tasks:
+ - Remove `previous_execution_gap_messages` assignment only if it is not needed for retry telemetry.
+ - Remove or wire `get_facts_for_context` based on whether fact-memory context still expects the helper.
+ - Delete the commented debug block for enhanced agent citations.
+- Validation starting point:
+ - Compile the route module.
+ - Run focused tabular retry and fact-memory/chat context tests if those paths are edited.
+
+#### 13. Fix explicit `None` comparison in non-optional test cleanup
+
+- Alert: 44
+- CodeQL rule: Testing equality to None
+- Severity: notice
+- Location: [functional_tests/test_tabular_document_actions_workflow.py](../../../functional_tests/test_tabular_document_actions_workflow.py#L238)
+- Decision: Implement.
+- Why: This is a low-risk standards cleanup and was not classified as optional lambda-only test cleanup.
+- Remediation style: Follow CodeQL directly.
+- Execution tasks:
+ - Replace equality comparison with identity comparison.
+- Validation starting point:
+ - Run `python functional_tests/test_tabular_document_actions_workflow.py`.
+
+## Suggested Validation Sequence for Main Plan
+
+- SKIPPED
+
+## Deferred Remediation Plan
+
+These items are intentionally deferred from the main execution plan. Each deferred item includes the reason and a starting point for a future remediation plan.
+
+### Deferred Group A: Optional Test-File Lambda Cleanup
+
+#### A1. Remove unnecessary lambda in mixed-source chat search fixture
+
+- Alert: 41
+- CodeQL rule: Unnecessary lambda
+- Location: [functional_tests/test_mixed_source_chat_search_consistency.py](../../../functional_tests/test_mixed_source_chat_search_consistency.py#L149)
+- Deferred because: This is optional test hygiene and does not affect runtime security, production correctness, or PR #1145 behavior.
+- Starting point for future plan:
+ - Inspect whether the lambda simply forwards to an existing callable with identical arguments.
+ - Replace only if the direct callable preserves test readability and fixture behavior.
+ - Run `python functional_tests/test_mixed_source_chat_search_consistency.py`.
+
+#### A2. Remove unnecessary lambda in workflow search helper fixture
+
+- Alert: 42
+- CodeQL rule: Unnecessary lambda
+- Location: [functional_tests/test_mixed_source_chat_search_consistency.py](../../../functional_tests/test_mixed_source_chat_search_consistency.py#L390)
+- Deferred because: This is optional test hygiene and can be handled with other fixture simplification work.
+- Starting point for future plan:
+ - Confirm the lambda is only adapting an already compatible callable.
+ - Replace with the callable object directly if there is no argument transformation.
+ - Run the mixed-source chat search consistency test.
+
+#### A3. Remove unnecessary lambda in Foundry context fixture
+
+- Alert: 43
+- CodeQL rule: Unnecessary lambda
+- Location: [functional_tests/test_mixed_source_chat_search_consistency.py](../../../functional_tests/test_mixed_source_chat_search_consistency.py#L816)
+- Deferred because: This is optional test cleanup in a Foundry-context fixture and should not distract from CodeQL failures and warnings.
+- Starting point for future plan:
+ - Verify whether `str` or another direct callable exactly matches the fixture's intended behavior.
+ - Replace only if the fixture remains clear.
+ - Run the mixed-source chat search consistency test.
+
+#### A4. Remove unnecessary lambda in tabular row orchestration scale migration fixture
+
+- Alert: 45
+- CodeQL rule: Unnecessary lambda
+- Location: [functional_tests/test_tabular_row_orchestration_scale.py](../../../functional_tests/test_tabular_row_orchestration_scale.py#L467)
+- Deferred because: This is optional test hygiene and the scale test is already a sensitive regression harness for the PR's core behavior.
+- Starting point for future plan:
+ - Confirm the lambda does not intentionally adapt arguments or return values.
+ - Replace with the callable object directly if behavior is identical.
+ - Run `python functional_tests/test_tabular_row_orchestration_scale.py`.
+
+### Deferred Group B: Import-Cycle Remediation
+
+#### B1. Resolve workflow runner to tabular analysis import cycle
+
+- Alert: 35
+- CodeQL rule: Cyclic import
+- Location: [application/single_app/functions_workflow_runner.py](../../../application/single_app/functions_workflow_runner.py#L5592)
+- Deferred because: The import is local and appears intentionally placed to avoid top-level cycle failures. Moving it mechanically could break application startup or workflow execution order.
+- Starting point for future plan:
+ - Draw the dependency path among workflow runner, tabular analysis, mixed-source orchestration, and document analysis.
+ - Identify a neutral module for shared contracts or helper functions.
+ - Move only pure types/constants/helpers first, then retest workflow imports and document-action workflows.
+
+#### B2. Resolve mixed-source orchestration logging import cycle
+
+- Alert: 39
+- CodeQL rule: Cyclic import
+- Location: [application/single_app/functions_mixed_source_orchestration.py](../../../application/single_app/functions_mixed_source_orchestration.py#L11)
+- Status: **Implemented in version 0.250.120**
+- Decision: Implement with a targeted lazy import.
+- Why: The top-level App Insights import pulls settings cache dependencies into the mixed-source orchestration import graph. Deferring telemetry resolution removes the CodeQL import-cycle edge without changing logging call sites.
+- Resolution:
+ - Replaced the module-level `functions_appinsights.log_event` import with a local lazy wrapper in [application/single_app/functions_mixed_source_orchestration.py](../../../application/single_app/functions_mixed_source_orchestration.py).
+ - Kept existing telemetry messages, levels, and structured properties unchanged.
+- Validation completed:
+ - `python functional_tests/test_codeql_import_cycle_lazy_imports.py`
+ - `python -m py_compile application/single_app/functions_mixed_source_orchestration.py application/single_app/functions_document_analysis.py application/single_app/config.py functional_tests/test_codeql_import_cycle_lazy_imports.py`
+ - `git -c core.whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol diff --check -- application/single_app/functions_mixed_source_orchestration.py application/single_app/functions_document_analysis.py application/single_app/config.py functional_tests/test_codeql_import_cycle_lazy_imports.py docs/explanation/fixes/PR_1145_CODEQL_ALERT_REMEDIATION_PLAN.md`
+- Starting point for future plan:
+ - Extract a shared telemetry adapter only if additional modules need the same import-cycle pattern removed.
+
+#### B3. Resolve document analysis to mixed-source orchestration import cycle
+
+- Alert: 40
+- CodeQL rule: Cyclic import
+- Location: [application/single_app/functions_document_analysis.py](../../../application/single_app/functions_document_analysis.py#L11-L14)
+- Status: **Implemented in version 0.250.120**
+- Decision: Implement with a targeted lazy import.
+- Why: Document analysis only needs mixed-source cancellation contracts inside runtime analysis paths. Deferring that import removes the module-load cycle while preserving cancellation behavior.
+- Resolution:
+ - Added a lazy `_get_mixed_source_orchestration_helpers()` resolver in [application/single_app/functions_document_analysis.py](../../../application/single_app/functions_document_analysis.py).
+ - Bound `MixedSourceCancellationError` and `raise_if_mixed_source_cancelled` inside the document-analysis functions that use them.
+ - Added AST-based functional coverage to prevent reintroducing the top-level cycle-causing imports.
+- Validation completed:
+ - `python functional_tests/test_codeql_import_cycle_lazy_imports.py`
+ - `python -m py_compile application/single_app/functions_mixed_source_orchestration.py application/single_app/functions_document_analysis.py application/single_app/config.py functional_tests/test_codeql_import_cycle_lazy_imports.py`
+ - `git -c core.whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol diff --check -- application/single_app/functions_mixed_source_orchestration.py application/single_app/functions_document_analysis.py application/single_app/config.py functional_tests/test_codeql_import_cycle_lazy_imports.py docs/explanation/fixes/PR_1145_CODEQL_ALERT_REMEDIATION_PLAN.md`
+- Starting point for future plan:
+ - Extract cancellation exceptions and cancellation guard helpers into a small neutral module.
+ - Consider that broader boundary cleanup only if additional import-cycle alerts remain in the same cancellation contract area.
+
+#### B4. Resolve chat route to workflow runner import cycle
+
+- Alert: 48
+- CodeQL rule: Cyclic import
+- Location: [application/single_app/route_backend_chats.py](../../../application/single_app/route_backend_chats.py#L199)
+- Deferred because: [application/single_app/route_backend_chats.py](../../../application/single_app/route_backend_chats.py) is a large route module with many import-time dependencies. A mechanical import move could affect app startup.
+- Starting point for future plan:
+ - Identify which chat route functions require `_execute_document_action_workflow`.
+ - Consider moving workflow invocation behind a small service adapter or local import at the use site.
+ - Validate Flask app startup, chat document-action routes, and workflow execution.
+
+#### B5. Resolve chat route to tabular analysis import cycle
+
+- Alert: 61
+- CodeQL rule: Cyclic import
+- Location: [application/single_app/route_backend_chats.py](../../../application/single_app/route_backend_chats.py#L57-L59)
+- Deferred because: The chat route uses tabular analysis callbacks and shared invocation helpers. Refactoring this safely requires understanding both synchronous and streaming paths.
+- Starting point for future plan:
+ - Inventory all tabular analysis symbols used by the chat route.
+ - Move shared callback or invocation-inspection helpers into a neutral module if possible.
+ - Validate tabular chat analysis, generated exports, and streaming progress.
+
+### Deferred Group C: Star Import Remediation
+
+#### C1. Replace `functions_chat` star import with explicit imports
+
+- Alert: 51
+- CodeQL rule: `import *` may pollute namespace
+- Location: [application/single_app/route_backend_chats.py](../../../application/single_app/route_backend_chats.py#L120)
+- Deferred because: Converting star imports in a large route module can expose many implicit dependencies and create a broad regression surface.
+- Starting point for future plan:
+ - Use static analysis to list actually used symbols from `functions_chat`.
+ - Replace the star import in one commit with explicit imports only.
+ - Compile the module and run chat route tests before touching other star imports.
+
+#### C2. Replace `functions_settings` star import with explicit imports
+
+- Alert: 53
+- CodeQL rule: `import *` may pollute namespace
+- Location: [application/single_app/route_backend_chats.py](../../../application/single_app/route_backend_chats.py#L88)
+- Deferred because: Settings helpers are widely used in the route and include security-sensitive feature gates. A dedicated import cleanup needs careful validation.
+- Starting point for future plan:
+ - Inventory settings symbols used by the chat route.
+ - Replace with explicit imports in isolation.
+ - Run chat settings, model selection, source review, and generated export tests.
+
+#### C3. Replace `functions_search` star import with explicit imports
+
+- Alert: 54
+- CodeQL rule: `import *` may pollute namespace
+- Location: [application/single_app/route_backend_chats.py](../../../application/single_app/route_backend_chats.py#L81)
+- Deferred because: Search helpers interact with hybrid search, tabular candidate search, and assigned knowledge paths.
+- Starting point for future plan:
+ - Inventory search symbols used by the chat route.
+ - Replace with explicit imports and avoid mixing this with behavior changes.
+ - Run hybrid search, assigned knowledge, and mixed-source search tests.
+
+#### C4. Replace `functions_authentication` star import with explicit imports
+
+- Alert: 55
+- CodeQL rule: `import *` may pollute namespace
+- Location: [application/single_app/route_backend_chats.py](../../../application/single_app/route_backend_chats.py#L80)
+- Deferred because: Authentication symbols affect route access and user context. This cleanup should be isolated and covered by route policy tests.
+- Starting point for future plan:
+ - Inventory authentication decorators and helpers used by the route.
+ - Replace with explicit imports.
+ - Run route policy and chat authorization tests.
+
+#### C5. Replace `config` star import with explicit imports
+
+- Alert: 56
+- CodeQL rule: `import *` may pollute namespace
+- Location: [application/single_app/route_backend_chats.py](../../../application/single_app/route_backend_chats.py#L78)
+- Deferred because: Config star imports often hide many container and app constants. Replacing it is valuable but high-churn.
+- Starting point for future plan:
+ - Inventory config symbols used by the route, especially Cosmos containers and feature constants.
+ - Replace with explicit imports in a standalone cleanup PR.
+ - Run Flask startup, chat, route policy, and import smoke tests.
+
+## Deferred Validation Strategy
+
+When the deferred work is planned, split it into at least three separate changes:
+
+1. Optional test-file lambda cleanup.
+2. Import-cycle boundary refactoring.
+3. Star-import conversion in [application/single_app/route_backend_chats.py](../../../application/single_app/route_backend_chats.py).
+
+Each deferred change should include focused compile checks, route startup validation when route imports change, and the smallest functional test set that covers the affected import boundary.
\ No newline at end of file
diff --git a/docs/explanation/fixes/TABULAR_ROW_ORCHESTRATION_REMEDIATION_PLAN.md b/docs/explanation/fixes/TABULAR_ROW_ORCHESTRATION_REMEDIATION_PLAN.md
new file mode 100644
index 000000000..a9d99a2f3
--- /dev/null
+++ b/docs/explanation/fixes/TABULAR_ROW_ORCHESTRATION_REMEDIATION_PLAN.md
@@ -0,0 +1,192 @@
+# Tabular Row Orchestration Remediation Plan
+
+Fixed in version: **0.250.060**
+
+UI follow-up implemented in version: **0.250.061**
+
+Related issue: **microsoft/simplechat#1031**
+
+## Issue Description
+
+Per-row tabular analysis could produce a complete export for small files but fail once a query result was split across tool pages. The generated-output selector evaluated each page independently, rejected every partial page, and could then allow the generic assistant-table exporter to save a small summary table as though it were the requested exhaustive CSV.
+
+The durable export runner also staged all input batches in one JSON blob and loaded every output checkpoint into one Python list during finalization. Those two operations prevented a defensible bounded-memory guarantee for 3,000- and 30,000-row exports.
+
+## Root Cause Analysis
+
+- Compatible `query_tabular_data` pages were ranked independently instead of being validated as ordered intervals from one query.
+- Large runs could only be queued after all source rows had already been materialized by the chat request.
+- Input staging used one aggregate `input_batches.json` payload.
+- Final CSV/JSON assembly consolidated all output rows in memory.
+- Generated batches had no authoritative source ordinal, persisted output schema, or explicit schema-drift validation.
+- Background execution trusted the stored run identity without revalidating current conversation ownership and workspace access.
+- The runner had a canceled status constant but no cancellation transition or user control.
+
+## Version Implemented
+
+Fixed in version: **0.250.060**.
+
+`application/single_app/config.py` was updated from `0.250.059` to `0.250.060`.
+
+## Technical Details
+
+### Architecture
+
+The existing durable generated-export subsystem remains the only background execution path. Issue #1031 extends it with two input modes:
+
+1. **Direct rows** for small, complete tool results. Rows are assigned canonical source ordinals and identities before per-batch staging.
+2. **Authorized source queries** for incomplete, multi-page, or threshold-large CSV queries. The request resolves an exact blob location and ETag, and the worker revalidates access before replaying the query in bounded CSV chunks.
+
+Both modes converge on the same model batching, retry, checkpoint, progress, cancellation, and final artifact lifecycle.
+
+### Source Contract
+
+Every input row receives:
+
+- `__simplechat_source_row_number`: a canonical one-based ordinal.
+- `__simplechat_source_row_identity`: a stable source identifier selected from fields such as Case ID, record ID, comment ID, submission ID, or ID, with the ordinal as fallback.
+- `__simplechat_source_row_token`: a deterministic opaque token that the model must echo for the matching row.
+
+Every output row receives authoritative `source_row_number` and `source_row_identity` fields. Model-supplied values for those fields are ignored.
+
+The echoed opaque tokens must match the exact ordered input sequence. A same-length response with swapped rows therefore fails before source identities are attached.
+
+The first successful generated batch establishes the output schema. Later batches must contain exactly the same field set, and finalization validates schema, source ordinal continuity, and total row count before publication.
+
+### Paginated Query Handling
+
+Compatible tabular invocations are grouped by plugin, function, file, worksheet, query, projection, and authorized source parameters while excluding pagination controls. Their intervals are sorted and validated.
+
+The grouping key also includes the server-resolved container, blob path, workspace scope, and ETag. Each page download is conditionally pinned to that ETag. Pages from different blob paths or versions fail explicitly instead of being coalesced.
+
+- Contiguous pages are coalesced in source order.
+- Gaps, overlaps, inconsistent totals, and declared/actual page-size mismatches remain incomplete and fail closed.
+- Replayable multi-page and incomplete structured queries are queued from an authorized source descriptor instead of sending all rows through model context.
+
+### Bounded Source Staging
+
+Source-backed runs persist the resolved source scope, blob path, ETag, expected match count, query expression, projection, and batch limits. The descriptor is never returned in public run status.
+
+The exact authorized blob path and ETag are captured as server-only invocation metadata on the original query result. Descriptor creation never resolves the file again by filename.
+
+At each worker start or resume:
+
+- Personal conversation ownership is revalidated.
+- Personal, group, or public workspace access is revalidated against current authorization state.
+- The source ETag is compared with the queued version.
+- Foreground CSV pagination and durable replay use the same bounded query engine, numeric-column inference, row-local expression validator, projection, and hidden-reference preservation.
+- The row-local expression validator parses a strict grammar of column references, comparisons, boolean operators, arithmetic, constants, and list/tuple membership. Function calls, attributes, subscripting, external variables, aggregations, and other cross-row operations are rejected before queueing.
+- Each complete input batch is written to its own blob.
+- The physical source row reached, staged batch count, and staged output-row count are checkpointed for resume. Resumed CSV reads use a callable skip predicate, keeping skip state constant-size instead of allocating one entry per skipped row.
+
+A changed source, authorization loss, or result-count mismatch fails explicitly before model processing or final artifact publication.
+
+Authorization runs immediately after claim and before legacy migration, source staging, or checkpoint reads. Manual Continue also reauthorizes before its ETag transition; revoked access returns a forbidden response without submitting work.
+
+### Model Output Checkpoints
+
+The runner processes bounded concurrent windows while forcing the first batch to run alone and establish the schema. Successful batches are checkpointed independently. Existing output checkpoints are reused after transient failures or worker restarts.
+
+Malformed JSON, row-count mismatch, missing fields, unexpected fields, or schema drift fails the batch before it advances contiguous progress.
+
+Each checkpoint also stores a compact bounded summary containing field completeness and limited scalar value counts. These summaries are merged after validation to produce the completed artifact card's compact overall analysis without putting all output rows back into model context.
+
+### Atomic Finalization
+
+Final CSV and JSON assembly reads one ordered checkpoint at a time into a disk-backed spooled stream.
+
+- CSV uses `csv.DictWriter` for quoting and encoding.
+- JSON is emitted as one valid ordered array.
+- Every row is revalidated for schema and contiguous source ordinal.
+- The expected row count must match exactly.
+- The configured generated-artifact size limit is enforced before upload.
+- A unique final blob is uploaded before its chat artifact message is published.
+
+A validation or upload failure leaves no user-visible completed artifact.
+
+### Progress, Retry, and Cancellation
+
+Existing retry classification, scheduler recovery, leases, progress status, and manual Continue behavior are preserved. The run status now also exposes `can_cancel`.
+
+Every worker claim increments a lease generation. Cosmos state writes are ETag-conditional, stale workers stop on holder/generation mismatch, and generated checkpoint blobs use create-only first-writer-wins semantics for current contracts.
+
+Scheduler status scans stream runs oldest-first, evaluate the real due/stale/retryable predicate, and only then apply the configured candidate limit. Ineligible rows at the front of a status partition therefore cannot starve later recoverable runs.
+
+Users can cancel queued, running, retryable, or failed runs from the generated-output card. Workers check the durable canceled state at source and model checkpoint boundaries and immediately before final publication. Canceled runs retain their checkpoint summary but cannot resume or attach a final artifact.
+
+Status polling is automatic. Version **0.250.061** removed the redundant manual Refresh Status action so running cards show only Cancel; Continue appears only for runs that can genuinely resume.
+
+Final artifact message and blob identities are deterministic per run. A retry after a partial publication reconciles the same artifact instead of creating a duplicate visible file. Cancellation closes before the fenced publication phase begins, and authorization is revalidated again immediately before upload.
+
+Runs queued by the pre-`0.250.060` contract are migrated once: aggregate inputs become deterministic per-batch inputs, progress resets, and legacy outputs are regenerated under token/schema validation.
+
+### Assistant-Table Fallback
+
+A queued source-backed run returns generated tabular output metadata immediately. Because that metadata identifies a CSV export even while queued, running, failed, or canceled, the generic assistant-table exporter cannot save a partial summary table as the requested exhaustive deliverable.
+
+All exhaustive outputs carry a format-independent `suppress_assistant_table_export` contract. Terminal failures are preserved through server and browser normalization even when queue creation never produced a run ID, so failed JSON and CSV requests remain visible and cannot fall through to a summary-table CSV.
+
+## Files Modified
+
+- `application/single_app/config.py`
+- `application/single_app/functions_simplechat_operations.py`
+- `application/single_app/functions_tabular_csv_query.py`
+- `application/single_app/functions_tabular_generated_exports.py`
+- `application/single_app/route_backend_chats.py`
+- `application/single_app/semantic_kernel_plugins/plugin_invocation_logger.py`
+- `application/single_app/semantic_kernel_plugins/tabular_processing_plugin.py`
+- `application/single_app/static/js/chat/chat-messages.js`
+- `functional_tests/test_tabular_background_generated_exports.py`
+- `functional_tests/test_tabular_large_result_pagination.py`
+- `functional_tests/test_tabular_row_orchestration_scale.py`
+- `functional_tests/test_assistant_table_csv_artifact.py`
+- `ui_tests/test_chat_background_generated_export_status.py`
+- `docs/explanation/release_notes.md`
+
+## Testing Approach
+
+Focused functional coverage validates:
+
+- Direct 10-row source identity and stable schema behavior.
+- Coalescing the 300-row `94 + 95 + 94 + 17` page sequence into ordered rows from `SC-2001` through `SC-2300`.
+- Explicit gap and schema-drift rejection.
+- Exact opaque-token rejection for swapped model rows.
+- Mixed source-path/ETag page rejection.
+- Bounded 30,000-row CSV source scanning and resume from physical row 15,000.
+- Real plugin CSV pagination through the shared bounded engine without the whole-DataFrame reader.
+- Bounded 30,000-row final CSV assembly across 600 checkpoints.
+- Source ordinal gap rejection before publication.
+- Current personal, group, public workspace, and conversation authorization at worker execution.
+- Idempotent durable cancellation.
+- ETag/lease-generation fencing for stale workers and deterministic legacy-run migration.
+- Eligibility-before-limit scheduler coverage beyond six ineligible rows.
+- Retry-idempotent final artifact publication.
+- Compact post-run analysis from 600 batch summaries.
+- Source-backed routing and assistant-table fallback suppression.
+
+Existing background lifecycle, tabular pagination, assistant-table, Python compile, JavaScript syntax, and Flask route-policy tests are also run.
+
+The authenticated Playwright cancellation workflow is included in `ui_tests/test_chat_background_generated_export_status.py`; it requires `SIMPLECHAT_UI_BASE_URL` and `SIMPLECHAT_UI_STORAGE_STATE`.
+
+## Impact Analysis
+
+### Before
+
+- A paginated 300-row query could be rejected as incomplete even when all pages were present.
+- A small assistant summary table could be saved as a misleading CSV.
+- Source and final output materialization scaled with the total row count.
+- Worker execution did not revalidate current source authorization.
+
+### After
+
+- Compatible pages are treated as one validated ordered result.
+- Multi-page and large exhaustive transforms run through durable authorized source replay.
+- Input and output memory are bounded by source chunks, model batches, and a disk-backed final stream.
+- Completed rows survive model interruptions and worker restarts.
+- The final artifact appears only after count, order, schema, source version, authorization, encoding, and size validation.
+- Progress, retry, Continue, Cancel, failure details, and compact final analysis remain visible in the chat card.
+
+## Known Limitations
+
+- Source-backed replay currently targets CSV `query_tabular_data` runs. Complete small workbook results continue to use the direct bounded-batch path.
+- Query replay supports row-wise pandas query semantics. Operations that require cross-row aggregation should continue using the dedicated aggregate tabular tools rather than per-row orchestration.
diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md
index d7337c0c2..683615650 100644
--- a/docs/explanation/release_notes.md
+++ b/docs/explanation/release_notes.md
@@ -2,6 +2,42 @@
For feature-focused and fix-focused drill-downs by version, see [Features by Version](/explanation/features/) and [Fixes by Version](/explanation/fixes/).
+### **(v0.250.119)**
+
+#### Bug Fixes
+
+* **Duplicate Chat Stream JSON Import Cleanup**
+ * Removed the redundant local `json` import from the chat streaming route while keeping the existing module-level import, clearing the PR #1145 CodeQL duplicate-module-import notice without changing streaming behavior.
+ * Updated the PR 1145 remediation plan with the implementation version and validation results.
+ * (Ref: microsoft/simplechat#1145, `route_backend_chats.py`, CodeQL alert 30)
+
+### **(v0.250.118)**
+
+#### Bug Fixes
+
+* **Semantic Kernel Return Contract Cleanup**
+ * Made the nested chat Semantic Kernel invocation helper return `None` explicitly when an async generator completes without yielding, clearing the PR #1145 CodeQL mixed explicit/implicit return alert without changing runtime behavior.
+ * Added focused functional coverage for direct values, coroutine results, yielded async-generator values, and empty async generators.
+ * (Ref: microsoft/simplechat#1145, `route_backend_chats.py`, `test_chat_semantic_kernel_return_contract.py`)
+
+### **(v0.250.115)**
+
+#### Bug Fixes
+
+* **Token Usage Aggregation Fixture Cleanup**
+ * Removed duplicate mocked helper keys from the document action token usage aggregation functional test so the fixture intent is explicit and CodeQL no longer reports overwritten dictionary entries.
+ * Kept comparison coverage focused on cross-format compare behavior while preserving aggregate token usage assertions for analysis, comparison, workflow assistant persistence, and chat persistence markers.
+ * (Ref: microsoft/simplechat#1145, `test_document_action_token_usage_aggregation.py`, token usage aggregation fixtures)
+
+### **(v0.250.114)**
+
+#### Bug Fixes
+
+* **Foundry Citation Thought Detail Cleanup**
+ * Fixed a CodeQL finding where Foundry citation thoughts iterated citations without using the citation value, causing duplicate generic thought messages.
+ * Foundry citation thoughts now include safe citation-specific labels when available while avoiding raw payloads, URL query strings, userinfo, and long unbounded text.
+ * (Ref: microsoft/simplechat#1145, `route_backend_chats.py`, `test_foundry_citation_thoughts.py`)
+
### **(v0.250.114)**
#### New Features
@@ -61,6 +97,15 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Ver
* Aligned the refresh with the restore workflow from Backup Inventory so admins can review backup readiness, choose restore policy/surfaces, run preflight, and queue supported restore jobs.
* (Ref: #1140, `admin_settings.html`, `admin_data_management.js`, `functions_data_management.py`, Data Management docs and tests)
+### **(v0.250.107)**
+
+#### Bug Fixes
+
+* **Mixed Source Manifest Storage Locator Preservation**
+ * Preserved explicit blob storage locators for authorized non-chat mixed-source manifest entries when archived-revision document metadata already contains a resolved container and blob path.
+ * Updated focused mixed-source Analyze and conversation-continuity tests for the current rollout/version contract.
+ * (Ref: #1055, #1056, mixed-source manifests, `functions_mixed_source_orchestration.py`, `test_mixed_source_manifest_contracts.py`)
+
### **(v0.250.106)**
#### New Features
diff --git a/docs/latest-release/analyze-compare.md b/docs/latest-release/analyze-compare.md
index ed1a615b9..ed120565a 100644
--- a/docs/latest-release/analyze-compare.md
+++ b/docs/latest-release/analyze-compare.md
@@ -5,7 +5,7 @@ description: "How chat and workspace document actions support full-document revi
section: "Latest Release"
---
-Current release version: **0.241.183**
+Current release version: **0.250.070**
Analyze and Compare give users deliberate document-action modes beyond regular workspace search.
@@ -38,4 +38,7 @@ Some questions need exhaustive review or side-by-side comparison instead of top-
- Interactive chat analysis targets a deliberately bounded set of selected documents.
- Workflow Analyze runs can cover larger repeated batches and changed synced documents.
-- Compare treats one selected document as the source baseline and compares selected target documents against it.
\ No newline at end of file
+- Compare treats one selected document as the source baseline and compares selected target documents against it.
+- Mixed-source Analyze and cross-format Compare remain behind default-off rollout flags.
+- Every selected source has terminal coverage. Analyze requires at least one successful source, and Compare fails when its Source cannot be prepared while retaining failed Target visibility.
+- The bounded Analyze All backend remains default off and is not newly exposed in the workflow selector pending production rollout approval.
\ No newline at end of file
diff --git a/docs/reference/admin_configuration.md b/docs/reference/admin_configuration.md
index dd3febf5c..cb36c149e 100644
--- a/docs/reference/admin_configuration.md
+++ b/docs/reference/admin_configuration.md
@@ -215,6 +215,24 @@ Control how document citations are displayed and linked.
4. Enable enhanced citations feature
5. Verify citation links in chat interface
+#### Mixed-Source Rollout Controls
+
+Mixed-source document orchestration uses independently reversible settings. Keep every stage off until the preceding stage has acceptable omission, error, and latency metrics:
+
+| Stage | Setting | Default |
+|---|---|---|
+| Manifest diagnostics | `enable_mixed_source_manifest` | Off |
+| Chat and workflow Search | `enable_mixed_source_chat_search` | Off |
+| Combined Analyze | `enable_mixed_source_analyze` | Off |
+| Cross-format Compare | `enable_cross_format_compare` | Off |
+| Reauthorized continuity | `enable_mixed_source_conversation_continuity` | Off |
+| Relevance table candidates | `enable_mixed_source_relevance_candidates` | Off |
+| Bounded Analyze All | `enable_mixed_source_analyze_all` | Off |
+| One-to-many mixed Compare | `enable_cross_format_compare_one_to_many` | Off |
+| Aggregate development telemetry | `enable_mixed_source_development_telemetry` | Off |
+
+Analyze All requires a ready document access index and uses the configured workflow Analyze document limit. A catalog above that limit is rejected rather than truncated. Development telemetry contains allowlisted aggregate counts and timings only; source identifiers, filenames, prompts, evidence, paths, locators, credentials, and raw settings are prohibited.
+
### 7. Safety Configuration
Configure content moderation and user feedback systems.
diff --git a/docs/troubleshooting/troubleshooting.md b/docs/troubleshooting/troubleshooting.md
index 96c730184..9256c77ba 100644
--- a/docs/troubleshooting/troubleshooting.md
+++ b/docs/troubleshooting/troubleshooting.md
@@ -76,3 +76,15 @@ exceptions
If startup logs show an error while Flask instrumentation is initializing, disable it with the `DISABLE_FLASK_INSTRUMENTATION` environment variable. Set the value to `1` or `true`, then restart the app service so the process starts cleanly without the instrumentation hook.
+## Mixed-Source Partial Coverage
+
+A mixed-source answer may complete with partial coverage when one narrative retrieval, table tool call, authorization check, or comparison Target cannot complete. This is expected fail-closed behavior: a failed table is not silently treated as narrative text, and prior conversation evidence does not fill a gap in the current selection.
+
+1. Review the response coverage summary for completed, partial, failed, and skipped source counts.
+2. Confirm the relevant mixed-source mode flag is enabled and subordinate rollout flags are not being assumed.
+3. Recheck personal ownership or approved sharing, group membership, public visibility, and chat-upload conversation ownership.
+4. For Analyze All, confirm the document access index is ready and the authorized catalog does not exceed the configured workflow Analyze limit.
+5. If aggregate development telemetry is enabled, correlate `MixedSourceTelemetry` events by `request_correlation_id` and inspect only counts, mode, status, cancellation phase, and latency. Source content or identity should never appear.
+
+If cancellation occurs, no final assistant response or new generated artifact should be published. A background tabular export that was already queued is canceled through its existing export run status.
+
diff --git a/functional_tests/test_assistant_table_csv_artifact.py b/functional_tests/test_assistant_table_csv_artifact.py
index 5b4ba9e50..344a9aeb4 100644
--- a/functional_tests/test_assistant_table_csv_artifact.py
+++ b/functional_tests/test_assistant_table_csv_artifact.py
@@ -2,16 +2,18 @@
#!/usr/bin/env python3
"""
Functional test for assistant-rendered table CSV artifacts.
-Version: 0.241.051
-Implemented in: 0.241.050
+Version: 0.250.112
+Implemented in: 0.241.050; non-tabular document CSV parsing in 0.250.065; generated file export framework in 0.250.072; updated in 0.250.073; linear fence parsing coverage in 0.250.112
This test ensures that explicit table-format requests with assistant-rendered
-tables and natural CSV/table conversion requests are converted into
-downloadable CSV artifact metadata for the chat UI.
+tables, including CSV rows extracted from non-tabular documents, are converted
+into downloadable CSV artifact metadata for the chat UI.
"""
+import ast
import csv
import io
+import json
import sys
import traceback
from pathlib import Path
@@ -21,14 +23,26 @@
APP_DIR = ROOT / 'application' / 'single_app'
CONFIG_FILE = APP_DIR / 'config.py'
CHAT_ROUTE_FILE = APP_DIR / 'route_backend_chats.py'
-EXPECTED_VERSION = '0.241.051'
+BACKGROUND_EXPORT_FILE = APP_DIR / 'functions_tabular_generated_exports.py'
+WORKFLOW_RUNNER_FILE = APP_DIR / 'functions_workflow_runner.py'
+EXPECTED_VERSION = '0.250.112'
sys.path.append(str(APP_DIR))
from functions_assistant_table_exports import ( # noqa: E402
assistant_table_export_requested,
+ build_csv_output_clarification_guidance,
+ build_safe_csv_headers,
build_assistant_table_csv_export,
extract_assistant_table_entries,
+ neutralize_csv_spreadsheet_formula,
+)
+from functions_generated_file_exports import ( # noqa: E402
+ build_generated_file_artifact_metadata,
+ build_generated_file_export,
+ get_generated_file_export_content,
+ get_requested_generated_file_format,
+ has_generated_file_output,
)
@@ -53,6 +67,44 @@ def parse_csv_rows(csv_content):
return list(csv.DictReader(io.StringIO(csv_content)))
+def load_csv_writer_helpers(source_file, function_names):
+ module_tree = ast.parse(read_text(source_file), filename=str(source_file))
+ selected_nodes = [
+ node
+ for node in module_tree.body
+ if isinstance(node, ast.FunctionDef) and node.name in function_names
+ ]
+ if len(selected_nodes) != len(function_names):
+ raise AssertionError(f'Expected CSV writer helpers {sorted(function_names)} in {source_file.name}.')
+
+ namespace = {
+ 'build_safe_csv_headers': build_safe_csv_headers,
+ 'csv': csv,
+ 'io': io,
+ 'json': json,
+ 'neutralize_csv_spreadsheet_formula': neutralize_csv_spreadsheet_formula,
+ }
+ extracted_module = ast.Module(body=selected_nodes, type_ignores=[])
+ exec(compile(extracted_module, str(source_file), 'exec'), namespace)
+ return {function_name: namespace[function_name] for function_name in function_names}
+
+
+def load_workflow_generated_file_export_helper(namespace):
+ module_tree = ast.parse(read_text(WORKFLOW_RUNNER_FILE), filename=str(WORKFLOW_RUNNER_FILE))
+ selected_nodes = [
+ node
+ for node in module_tree.body
+ if isinstance(node, ast.FunctionDef)
+ and node.name == '_maybe_create_workflow_generated_file_output'
+ ]
+ if len(selected_nodes) != 1:
+ raise AssertionError('Expected workflow generated-file artifact helper.')
+
+ extracted_module = ast.Module(body=selected_nodes, type_ignores=[])
+ exec(compile(extracted_module, str(WORKFLOW_RUNNER_FILE), 'exec'), namespace)
+ return namespace['_maybe_create_workflow_generated_file_output']
+
+
def test_markdown_table_response_builds_csv_export():
print('Testing Markdown table response CSV export creation...')
@@ -98,6 +150,608 @@ def test_tab_separated_table_response_builds_rows():
assert_true(table_rows[1]['Date'] == 'December 11, 2025 at 1:58 PM', 'Expected TSV table parser to preserve the Date column.')
+def test_non_tabular_document_csv_response_builds_export():
+ print('Testing non-tabular document CSV response export creation...')
+
+ assistant_content = '''```csv
+Name,Invoice Number,Description,Notes
+Paul Lizer,DCAW1366188,"PassPark Premium Reserve - South","Includes parking, taxes, and fees"
+```
+
+Source: ParkingPrint.pdf, Page: 1
+'''
+
+ export_payload = build_assistant_table_csv_export(
+ 'generate a csv from this file',
+ assistant_content,
+ )
+
+ assert_true(
+ export_payload is not None,
+ 'Expected comma-delimited output from a non-tabular document to produce an export payload.',
+ )
+ csv_rows = parse_csv_rows(export_payload.get('file_content'))
+ assert_true(len(csv_rows) == 1, 'Expected the PDF citation outside the CSV block to be excluded.')
+ assert_true(csv_rows[0]['Invoice Number'] == 'DCAW1366188', 'Expected the invoice number to be preserved.')
+ assert_true(
+ csv_rows[0]['Notes'] == 'Includes parking, taxes, and fees',
+ 'Expected quoted commas in generated CSV values to be preserved.',
+ )
+
+
+def test_document_action_analysis_reply_builds_csv_export():
+ print('Testing structured document-action CSV source selection...')
+
+ assistant_result = {
+ 'reply': 'The detailed analysis is available in the attached artifact.',
+ 'analysis_result': {
+ 'analysis_reply': '''```csv
+Name,Invoice Number
+Contoso,DCAW1366188
+```''',
+ },
+ }
+ selected_content = get_generated_file_export_content(assistant_result)
+ export_payload = build_assistant_table_csv_export(
+ 'turn these into a single CSV',
+ selected_content,
+ )
+
+ assert_true(
+ export_payload is not None,
+ 'Expected a structured document-action analysis reply to produce a CSV artifact.',
+ )
+ csv_rows = parse_csv_rows(export_payload.get('file_content'))
+ assert_true(csv_rows[0]['Invoice Number'] == 'DCAW1366188', 'Expected the analysis reply row to be exported.')
+
+
+def test_structured_action_result_builds_csv_when_assistant_summarizes():
+ print('Testing structured action-result CSV export fallback...')
+
+ action_results = [{
+ 'plugin_name': 'BillingPlugin',
+ 'function_name': 'list_invoices',
+ 'success': True,
+ 'function_result': {
+ 'rows': [
+ {'Invoice Number': 'DCAW1366188', 'Amount': '=42.50', 'api_key': 'must-not-export'},
+ {'Invoice Number': 'DCAW1366189', 'Amount': '-10.00', 'api_key': 'must-not-export'},
+ ],
+ },
+ }]
+ export_payload = build_generated_file_export(
+ 'save the action results as one CSV',
+ 'The billing action returned two invoices.',
+ function_results=action_results,
+ )
+
+ assert_true(export_payload is not None, 'Expected structured action data to produce a CSV when the assistant summarizes it.')
+ assert_true(export_payload.get('row_source') == 'structured function result', 'Expected function-result CSV provenance.')
+ csv_rows = parse_csv_rows(export_payload.get('file_content'))
+ assert_true(len(csv_rows) == 2, 'Expected both action result rows in the CSV artifact.')
+ assert_true(csv_rows[0]['Invoice Number'] == 'DCAW1366188', 'Expected action result fields to be preserved.')
+ assert_true(csv_rows[0]['Amount'].startswith("'="), 'Expected action result formulas to be neutralized.')
+ assert_true('api_key' not in csv_rows[0], 'Expected sensitive action result fields to be omitted.')
+
+
+def test_structured_action_results_combine_and_preserve_assistant_priority():
+ print('Testing combined action-result CSV rows and assistant table priority...')
+
+ action_results = [
+ {
+ 'plugin_name': 'DirectoryPlugin',
+ 'function_name': 'list_people',
+ 'success': True,
+ 'function_result': '{"value":[{"Name":"Ada","Department":"Engineering"}]}',
+ },
+ {
+ 'plugin_name': 'DirectoryPlugin',
+ 'function_name': 'list_contractors',
+ 'success': True,
+ 'function_result': {'items': [{'Name': 'Grace', 'Department': 'Operations'}]},
+ },
+ ]
+ action_export = build_generated_file_export(
+ 'create a combined CSV',
+ 'The directory actions completed.',
+ function_results=action_results,
+ )
+ action_rows = parse_csv_rows(action_export.get('file_content'))
+ assert_true(len(action_rows) == 2, 'Expected data rows from both action results.')
+ assert_true(
+ {row['Source action'] for row in action_rows} == {'list_people', 'list_contractors'},
+ 'Expected combined action rows to retain their source action.',
+ )
+
+ assistant_export = build_generated_file_export(
+ 'create a combined CSV',
+ '''| Name | Department |
+| --- | --- |
+| Assistant-selected | Finance |
+''',
+ function_results=action_results,
+ )
+ assistant_rows = parse_csv_rows(assistant_export.get('file_content'))
+ assert_true(len(assistant_rows) == 1, 'Expected a valid assistant table to take priority over action rows.')
+ assert_true(assistant_rows[0]['Name'] == 'Assistant-selected', 'Expected assistant-selected table data to remain authoritative.')
+
+
+def test_tabular_action_result_does_not_bypass_coverage_aware_exports():
+ print('Testing tabular action-result exclusion...')
+
+ export_payload = build_generated_file_export(
+ 'download CSV',
+ 'The table query returned a partial page.',
+ function_results=[{
+ 'plugin_name': 'TabularProcessingPlugin',
+ 'function_name': 'query_tabular_data',
+ 'success': True,
+ 'function_result': {'data': [{'Case ID': 'SC-1'}]},
+ }],
+ )
+ assert_true(
+ export_payload is None,
+ 'Expected tabular action rows to remain on their coverage-aware export path.',
+ )
+
+
+def test_function_results_render_docx_and_pdf_capabilities():
+ print('Testing DOCX and PDF function-result export capabilities...')
+
+ function_results = [{
+ 'plugin_name': 'DirectoryPlugin',
+ 'function_name': 'list_people',
+ 'success': True,
+ 'function_result': {'value': [{'Name': 'Ada', 'Department': 'Engineering'}]},
+ }]
+ docx_export = build_generated_file_export(
+ 'create a Word document from the action results',
+ 'The directory action completed successfully.',
+ function_results=function_results,
+ )
+ pdf_export = build_generated_file_export(
+ 'export the action results to PDF',
+ 'The directory action completed successfully.',
+ function_results=function_results,
+ )
+
+ assert_true(get_requested_generated_file_format('create a Word document') == 'docx', 'Expected DOCX output intent.')
+ assert_true(get_requested_generated_file_format('export to PDF') == 'pdf', 'Expected PDF output intent.')
+ assert_true(get_requested_generated_file_format('I need a DOCX') == 'docx', 'Expected natural DOCX output intent.')
+ assert_true(get_requested_generated_file_format('Give me a PDF') == 'pdf', 'Expected natural PDF output intent.')
+ assert_true(docx_export is not None and docx_export['file_content'].startswith(b'PK'), 'Expected a DOCX file export.')
+ assert_true(pdf_export is not None and pdf_export['file_content'].startswith(b'%PDF'), 'Expected a PDF file export.')
+ assert_true(docx_export['row_source'] == 'structured function result', 'Expected DOCX to include function-result rows.')
+ assert_true(pdf_export['row_source'] == 'structured function result', 'Expected PDF to include function-result rows.')
+
+
+def test_plain_document_csv_response_excludes_surrounding_prose_and_citation():
+ print('Testing plain document CSV response boundary detection...')
+
+ assistant_content = '''I extracted the requested invoice fields, including the billed service.
+
+Name,Invoice Number,Description
+Paul Lizer,DCAW1366188,PassPark Premium Reserve - South
+(Source: ParkingPrint.pdf, Page: 1)
+
+The values reflect the uploaded file, not an external source.
+'''
+
+ export_payload = build_assistant_table_csv_export(
+ 'turn this into a CSV',
+ assistant_content,
+ )
+
+ assert_true(export_payload is not None, 'Expected plain comma-delimited document output to produce an export.')
+ csv_rows = parse_csv_rows(export_payload.get('file_content'))
+ assert_true(len(csv_rows) == 1, 'Expected surrounding prose and the PDF citation to be excluded from CSV rows.')
+ assert_true(csv_rows[0]['Name'] == 'Paul Lizer', 'Expected the extracted document row to be preserved.')
+
+
+def test_document_csv_response_preserves_multiline_and_escaped_quotes():
+ print('Testing lossless document CSV value parsing...')
+
+ assistant_content = '''```csv
+Name,Description
+Contoso,"First line
+Second ""quoted"" line"
+```
+
+Source: Contract.docx, Page: 2
+'''
+
+ export_payload = build_assistant_table_csv_export(
+ 'create a csv from this word file',
+ assistant_content,
+ )
+
+ assert_true(export_payload is not None, 'Expected Word-derived CSV output to produce an export.')
+ csv_rows = parse_csv_rows(export_payload.get('file_content'))
+ assert_true(
+ csv_rows[0]['Description'] == 'First line\nSecond "quoted" line',
+ 'Expected multiline values and escaped quotes to survive the CSV artifact round trip.',
+ )
+
+
+def test_fenced_document_csv_preserves_sentence_shaped_rows():
+ print('Testing sentence-shaped rows inside trusted CSV fences...')
+
+ assistant_content = '''```csv
+Name,Description
+Contoso,This is a primary service contract.
+Fabrikam,This is a secondary support agreement.
+```
+'''
+
+ export_payload = build_assistant_table_csv_export('respond as CSV', assistant_content)
+
+ assert_true(export_payload is not None, 'Expected valid sentence-shaped fenced CSV rows to produce an export.')
+ csv_rows = parse_csv_rows(export_payload.get('file_content'))
+ assert_true(len(csv_rows) == 2, 'Expected trusted fenced CSV not to truncate sentence-shaped rows.')
+ assert_true(csv_rows[1]['Name'] == 'Fabrikam', 'Expected all trusted fenced rows to remain ordered.')
+
+
+def test_fenced_document_csv_wins_over_larger_markdown_table():
+ print('Testing fenced CSV precedence over larger Markdown tables...')
+
+ assistant_content = '''| Name | Value |
+| --- | --- |
+| Wrong 1 | 11 |
+| Wrong 2 | 12 |
+| Wrong 3 | 13 |
+
+```csv
+Name,Value
+Right,1
+```
+'''
+
+ export_payload = build_assistant_table_csv_export('create a CSV', assistant_content)
+
+ assert_true(export_payload is not None, 'Expected the explicit fenced CSV to produce an export.')
+ csv_rows = parse_csv_rows(export_payload.get('file_content'))
+ assert_true(len(csv_rows) == 1, 'Expected larger Markdown tables not to override explicit fenced CSV.')
+ assert_true(csv_rows[0]['Name'] == 'Right', 'Expected the fenced CSV row to be authoritative.')
+
+
+def test_explicit_csv_fence_wins_over_larger_generic_fence():
+ print('Testing explicit CSV fence precedence over generic fences...')
+
+ assistant_content = '''```text
+Name,Value
+Wrong 1,11
+Wrong 2,12
+Wrong 3,13
+```
+
+```csv
+Name,Value
+Right,1
+```
+'''
+
+ export_payload = build_assistant_table_csv_export('download CSV', assistant_content)
+
+ assert_true(export_payload is not None, 'Expected the explicitly labeled CSV fence to produce an export.')
+ csv_rows = parse_csv_rows(export_payload.get('file_content'))
+ assert_true(len(csv_rows) == 1, 'Expected generic text fences not to override explicit CSV fences.')
+ assert_true(csv_rows[0]['Name'] == 'Right', 'Expected the explicitly labeled CSV row to be authoritative.')
+
+
+def test_generic_fenced_csv_like_content_builds_export():
+ print('Testing generic fenced CSV-like content parsing...')
+
+ assistant_content = '''```
+Name,Value
+Generic,7
+```
+'''
+
+ export_payload = build_assistant_table_csv_export('return CSV', assistant_content)
+
+ assert_true(export_payload is not None, 'Expected unlabeled fenced CSV-like content to produce an export.')
+ csv_rows = parse_csv_rows(export_payload.get('file_content'))
+ assert_true(len(csv_rows) == 1, 'Expected one row from generic fenced CSV-like content.')
+ assert_true(csv_rows[0]['Name'] == 'Generic', 'Expected generic fenced row to be preserved.')
+
+
+def test_unterminated_csv_fence_allows_unfenced_fallback():
+ print('Testing unterminated CSV fence fallback behavior...')
+
+ assistant_content = '''```csv
+Incomplete,Header,Only
+Broken
+
+Name,Value
+Fallback,9
+'''
+
+ export_payload = build_assistant_table_csv_export('download CSV', assistant_content)
+
+ assert_true(export_payload is not None, 'Expected valid unfenced CSV after an unterminated fence to produce an export.')
+ csv_rows = parse_csv_rows(export_payload.get('file_content'))
+ assert_true(csv_rows[0]['Name'] == 'Fallback', 'Expected unfenced fallback rows to remain available.')
+
+
+def test_adversarial_fence_opening_uses_linear_csv_parsing():
+ print('Testing adversarial fence opening CSV parsing...')
+
+ assistant_content = f'''```{' \t' * 200}csv
+Name,Value
+Linear,1
+```
+'''
+
+ export_payload = build_assistant_table_csv_export('create a CSV', assistant_content)
+
+ assert_true(export_payload is not None, 'Expected long whitespace before the CSV fence label to parse.')
+ csv_rows = parse_csv_rows(export_payload.get('file_content'))
+ assert_true(csv_rows[0]['Name'] == 'Linear', 'Expected adversarial fence opening not to change CSV rows.')
+
+
+def test_plain_document_csv_preserves_quoted_blank_lines_and_ignores_comma_prose():
+ print('Testing plain document CSV quoted blank lines and prose exclusion...')
+
+ assistant_content = '''I extracted the requested invoice fields, including the billed service.
+Name,Description
+Contoso,"First paragraph
+
+Second paragraph"
+Source: Contract.docx, Page: 2
+'''
+
+ export_payload = build_assistant_table_csv_export(
+ 'provide the extracted rows as CSV',
+ assistant_content,
+ )
+
+ assert_true(export_payload is not None, 'Expected valid plain CSV after comma-bearing prose to produce an export.')
+ csv_rows = parse_csv_rows(export_payload.get('file_content'))
+ assert_true(list(csv_rows[0]) == ['Name', 'Description'], 'Expected prose before the header to be excluded.')
+ assert_true(
+ csv_rows[0]['Description'] == 'First paragraph\n\nSecond paragraph',
+ 'Expected blank lines inside quoted CSV values to be preserved.',
+ )
+
+
+def test_plain_document_csv_preserves_long_headers_and_sentence_values():
+ print('Testing long headers and sentence-shaped values in plain CSV...')
+
+ assistant_content = '''Official Full Legal Name Used for Payroll and Tax Reporting,Primary Work Location Description
+Alice,This is a complete sentence.
+Here is the requested information.,Open
+'''
+
+ export_payload = build_assistant_table_csv_export('return the results in CSV', assistant_content)
+
+ assert_true(export_payload is not None, 'Expected valid plain CSV with long headers to produce an export.')
+ csv_rows = parse_csv_rows(export_payload.get('file_content'))
+ assert_true(len(csv_rows) == 2, 'Expected sentence-shaped plain CSV values not to truncate data rows.')
+ assert_true(
+ list(csv_rows[0]) == [
+ 'Official Full Legal Name Used for Payroll and Tax Reporting',
+ 'Primary Work Location Description',
+ ],
+ 'Expected the earliest structural header not to be replaced by a shorter data row.',
+ )
+ assert_true(
+ csv_rows[0]['Official Full Legal Name Used for Payroll and Tax Reporting'] == 'Alice',
+ 'Expected the first data row to remain intact.',
+ )
+ assert_true(
+ csv_rows[1]['Official Full Legal Name Used for Payroll and Tax Reporting'] == 'Here is the requested information.',
+ 'Expected discourse-like first-column values to remain valid data.',
+ )
+
+
+def test_plain_document_csv_excludes_prose_and_short_page_citations():
+ print('Testing plain document CSV prose and short citation boundaries...')
+
+ assistant_content = '''For clarity, see below
+Name,Description
+Contoso,Primary contract
+For context, this came from page one.
+Source: Contract.docx, p. 2
+'''
+
+ export_payload = build_assistant_table_csv_export(
+ 'CSV version, please',
+ assistant_content,
+ )
+
+ assert_true(export_payload is not None, 'Expected CSV-version phrasing to produce an export.')
+ csv_rows = parse_csv_rows(export_payload.get('file_content'))
+ assert_true(len(csv_rows) == 1, 'Expected comma-bearing prose and short page citations to be excluded.')
+ assert_true(list(csv_rows[0]) == ['Name', 'Description'], 'Expected the actual CSV header to be selected.')
+
+
+def test_plain_document_csv_excludes_generic_prose_and_non_page_citations():
+ print('Testing generic prose and non-page citation boundaries...')
+
+ assistant_content = '''CSV data, ready for download.
+Name,Description
+Contoso,Primary contract
+In summary, the extraction is complete.
+(Source: Contract.docx, Section: Fees)
+'''
+
+ export_payload = build_assistant_table_csv_export(
+ 'download this as CSV',
+ assistant_content,
+ )
+
+ assert_true(export_payload is not None, 'Expected plain CSV surrounded by generic prose to produce an export.')
+ csv_rows = parse_csv_rows(export_payload.get('file_content'))
+ assert_true(len(csv_rows) == 1, 'Expected generic prose and non-page citations to be excluded.')
+ assert_true(list(csv_rows[0]) == ['Name', 'Description'], 'Expected generic prose not to become CSV headers.')
+
+
+def test_plain_document_csv_normalizes_preambles_and_citation_variants():
+ print('Testing scored preambles and citation variants...')
+
+ citation_rows = (
+ 'Sources: Contract.docx, Section: Fees',
+ '[Source: Contract.docx, Section: Fees]',
+ '[1] Source: Contract.docx, Section: Fees',
+ '(1) Source: Contract.docx, Section: Fees',
+ '[Source]: Contract.docx, Section: Fees',
+ '(Citation): Contract.docx, Section: Fees',
+ '* Citation: Contract.docx, Section: Fees',
+ 'Citation: Contract.docx, Section: Fees',
+ '- (Source: Contract.docx, Section: Fees)',
+ )
+ for citation_row in citation_rows:
+ assistant_content = f'''The requested export is ready, with the fields below
+Name,Description
+Contoso,Primary contract
+{citation_row}
+'''
+ export_payload = build_assistant_table_csv_export('put the extracted fields in CSV', assistant_content)
+ assert_true(export_payload is not None, f'Expected CSV before citation variant {citation_row!r}.')
+ csv_rows = parse_csv_rows(export_payload.get('file_content'))
+ assert_true(len(csv_rows) == 1, f'Expected citation variant {citation_row!r} to be excluded.')
+ assert_true(list(csv_rows[0]) == ['Name', 'Description'], 'Expected the scored table header to beat prose.')
+
+
+def test_document_csv_supports_alternate_text_fences():
+ print('Testing alternate CSV fence labels...')
+
+ for fence_language in ('txt', 'text/csv', 'markdown', 'md'):
+ assistant_content = f'''```{fence_language}
+Name,Amount
+Contoso,42
+```
+'''
+ export_payload = build_assistant_table_csv_export(
+ 'return CSV for this Word document',
+ assistant_content,
+ )
+ assert_true(export_payload is not None, f'Expected the {fence_language} fence to support valid CSV output.')
+
+
+def test_document_csv_neutralizes_spreadsheet_formulas():
+ print('Testing spreadsheet formula neutralization...')
+
+ assistant_content = '''```csv
+Name,Value
+External input,"=HYPERLINK(""https://example.invalid"",""Open"")"
+Command,@SUM(1+1)
+Balance,-42.50
+Grouped balance,"-1,234.50"
+```
+'''
+
+ export_payload = build_assistant_table_csv_export(
+ 'output the extracted values in CSV format',
+ assistant_content,
+ )
+
+ assert_true(export_payload is not None, 'Expected formula-prefixed document values to produce a safe export.')
+ csv_rows = parse_csv_rows(export_payload.get('file_content'))
+ assert_true(csv_rows[0]['Value'].startswith("'="), 'Expected equals-prefixed formulas to be neutralized.')
+ assert_true(csv_rows[1]['Value'].startswith("'@"), 'Expected at-prefixed formulas to be neutralized.')
+ assert_true(csv_rows[2]['Value'] == '-42.50', 'Expected signed numeric values to remain numeric text.')
+ assert_true(csv_rows[3]['Value'] == '-1,234.50', 'Expected grouped signed numeric values to remain numeric text.')
+
+
+def test_document_csv_accepts_punctuation_and_duplicate_headers():
+ print('Testing punctuation and duplicate CSV headers...')
+
+ assistant_content = '''```csv
+Invoice No.,Approved?,Amount,Amount
+DCAW1366188,Yes,10,20
+```
+'''
+
+ export_payload = build_assistant_table_csv_export(
+ 'Can I get a CSV of this?',
+ assistant_content,
+ )
+
+ assert_true(export_payload is not None, 'Expected punctuation and duplicate headers to produce an export.')
+ csv_rows = parse_csv_rows(export_payload.get('file_content'))
+ assert_true(
+ list(csv_rows[0]) == ['Invoice No.', 'Approved?', 'Amount', 'Amount 2'],
+ 'Expected punctuation to be preserved and duplicate headers to be disambiguated.',
+ )
+
+
+def test_document_csv_preserves_header_suffix_collisions():
+ print('Testing generated header suffix collisions...')
+
+ assistant_content = '''```csv
+Amount,Amount,Amount 2
+10,20,30
+```
+'''
+
+ export_payload = build_assistant_table_csv_export('create a CSV', assistant_content)
+
+ assert_true(export_payload is not None, 'Expected colliding duplicate headers to produce an export.')
+ csv_rows = parse_csv_rows(export_payload.get('file_content'))
+ assert_true(len(csv_rows[0]) == 3, 'Expected all colliding header columns to remain present.')
+ assert_true(list(csv_rows[0].values()) == ['10', '20', '30'], 'Expected no colliding column value to be overwritten.')
+
+
+def test_document_csv_neutralizes_formula_headers_without_losing_rows():
+ print('Testing formula-like CSV header parsing...')
+
+ assistant_content = '''```csv
+=Name,Value
+Alice,1
+Bob,2
+```
+'''
+
+ export_payload = build_assistant_table_csv_export('I need the results in CSV', assistant_content)
+
+ assert_true(export_payload is not None, 'Expected formula-like headers to produce a safe export.')
+ csv_rows = parse_csv_rows(export_payload.get('file_content'))
+ assert_true(len(csv_rows) == 2, 'Expected formula header safety not to discard the first data row.')
+ assert_true("'=Name" in csv_rows[0], 'Expected the formula-like header to be neutralized.')
+ assert_true(csv_rows[0]["'=Name"] == 'Alice', 'Expected the first data row to remain intact.')
+
+
+def test_all_generated_csv_writers_neutralize_formulas():
+ print('Testing formula safety across generated CSV writers...')
+
+ writer_specs = (
+ (
+ CHAT_ROUTE_FILE,
+ {'_serialize_tabular_generated_output_value', '_build_tabular_generated_output_csv'},
+ '_build_tabular_generated_output_csv',
+ ),
+ (
+ BACKGROUND_EXPORT_FILE,
+ {'_serialize_generated_output_value', '_build_generated_output_csv'},
+ '_build_generated_output_csv',
+ ),
+ (
+ WORKFLOW_RUNNER_FILE,
+ {'_serialize_document_analysis_csv_value', '_build_document_analysis_rows_csv'},
+ '_build_document_analysis_rows_csv',
+ ),
+ )
+ entries = [
+ {
+ '=Header': '=WEBSERVICE("https://example.invalid")',
+ 'Amount': '-1,234.50',
+ 'Count': 0,
+ 'Enabled': False,
+ },
+ ]
+
+ for source_file, function_names, writer_name in writer_specs:
+ helpers = load_csv_writer_helpers(source_file, function_names)
+ csv_content = helpers[writer_name](entries)
+ csv_rows = parse_csv_rows(csv_content)
+ safe_header = next(header for header in csv_rows[0] if header.startswith("'="))
+ assert_true(csv_rows[0][safe_header].startswith("'="), f'Expected {source_file.name} to neutralize formula values.')
+ assert_true(csv_rows[0]['Amount'] == '-1,234.50', f'Expected {source_file.name} to preserve signed numbers.')
+ assert_true(csv_rows[0]['Count'] == '0', f'Expected {source_file.name} to preserve zero values.')
+ assert_true(csv_rows[0]['Enabled'] == 'False', f'Expected {source_file.name} to preserve boolean values.')
+
+
def test_non_table_requests_do_not_create_exports():
print('Testing non-table request exclusion...')
@@ -114,6 +768,20 @@ def test_non_table_requests_do_not_create_exports():
build_assistant_table_csv_export('summarize these contacts', assistant_content) is None,
'Expected non-table requests not to create CSV exports even when a table is present.',
)
+ for non_export_request in (
+ "Don't respond as CSV; summarize this document.",
+ 'I do not want CSV output.',
+ 'Get the totals from CSV and explain them.',
+ 'Get the totals from the CSV file and explain them.',
+ 'Summarize this CSV file.',
+ 'Analyze this spreadsheet.',
+ 'I need to analyze the CSV file and explain the totals.',
+ 'Please provide JSON, not CSV.',
+ ):
+ assert_true(
+ assistant_table_export_requested(non_export_request) is False,
+ f'Expected {non_export_request!r} not to request a CSV artifact.',
+ )
def test_natural_table_request_phrase_is_recognized():
@@ -151,6 +819,20 @@ def test_natural_csv_and_create_table_phrases_are_recognized():
'turn this into csv',
'convert that to csv',
'export as csv',
+ 'provide the extracted rows as CSV',
+ 'return CSV for this Word document',
+ 'output the invoice fields in CSV format',
+ 'give me a CSV',
+ 'Can I get a CSV of this?',
+ 'CSV version, please',
+ 'Please respond as CSV',
+ 'I need the results in CSV',
+ 'Put the extracted fields in CSV',
+ 'I want CSV output',
+ 'I need a direct CSV file.',
+ 'Do not summarize; create a CSV.',
+ 'CSV file, please.',
+ 'Make a spreadsheet from this document.',
'create a table of the days of the week',
]
@@ -165,6 +847,186 @@ def test_natural_csv_and_create_table_phrases_are_recognized():
)
+def test_universal_csv_request_variants_are_recognized():
+ print('Testing universal CSV request variants...')
+
+ assistant_content = """| Name | Amount |
+| --- | --- |
+| Contoso | 42 |
+"""
+ request_phrases = (
+ 'turn these into a single CSV',
+ 'turn these into one CSV',
+ 'turn these into a combined CSV',
+ 'create a single CSV file',
+ 'save one CSV',
+ )
+
+ for request_phrase in request_phrases:
+ assert_true(
+ assistant_table_export_requested(request_phrase),
+ f"Expected '{request_phrase}' to request a CSV artifact.",
+ )
+ assert_true(
+ build_assistant_table_csv_export(request_phrase, assistant_content) is not None,
+ f"Expected '{request_phrase}' to produce an assistant table CSV export.",
+ )
+
+
+def test_csv_schema_clarification_guidance_is_specific_and_resumable():
+ print('Testing CSV schema clarification guidance...')
+
+ ambiguous_guidance = build_csv_output_clarification_guidance('turn these into a single CSV')
+ assert_true(
+ 'ask exactly one concise clarification' in ambiguous_guidance,
+ 'Expected ambiguous CSV requests to instruct one schema clarification.',
+ )
+ assert_true(
+ 'latest answer instead of asking again' in ambiguous_guidance,
+ 'Expected a prior CSV clarification to be resumed from conversation history.',
+ )
+
+ explicit_guidance = build_csv_output_clarification_guidance(
+ 'Create a CSV with one row per document and columns: file name, invoice number, amount.',
+ )
+ assert_true(
+ 'explicitly specified CSV row or column structure' in explicit_guidance,
+ 'Expected explicit row and column instructions to bypass a schema clarification.',
+ )
+ assert_true(
+ 'ask exactly one concise clarification' not in explicit_guidance,
+ 'Expected explicit schema requests not to request a clarification.',
+ )
+ assert_true(
+ build_csv_output_clarification_guidance('Summarize the selected sources.') == '',
+ 'Expected non-CSV requests not to receive CSV clarification guidance.',
+ )
+
+
+def test_workflow_generated_file_artifacts_reuse_shared_contract():
+ print('Testing workflow generated-file artifact finalization...')
+
+ uploaded_requests = []
+ queue_requests = []
+ shared_namespace = {
+ 'build_generated_file_artifact_metadata': build_generated_file_artifact_metadata,
+ 'build_generated_file_export': build_generated_file_export,
+ 'get_requested_generated_file_format': get_requested_generated_file_format,
+ 'has_generated_file_output': has_generated_file_output,
+ 'has_generated_tabular_csv_output': lambda outputs: any(
+ output.get('output_format') == 'csv'
+ for output in outputs or []
+ if isinstance(output, dict)
+ ),
+ 'get_settings': lambda: {},
+ 'build_tabular_generated_output_row_batches': lambda rows, settings=None: [rows],
+ 'should_queue_tabular_generated_output_background': lambda *args: False,
+ 'queue_tabular_generated_output_run': lambda **kwargs: queue_requests.append(kwargs),
+ 'build_background_tabular_generated_output_metadata': lambda run: run,
+ 'upload_generated_analysis_artifact_for_user': (
+ lambda **kwargs: uploaded_requests.append(kwargs) or {
+ 'message': {'id': 'workflow-csv-artifact', 'file_name': kwargs['file_name']},
+ }
+ ),
+ 'log_event': lambda *args, **kwargs: None,
+ 'logging': type('Logging', (), {'ERROR': 'ERROR'}),
+ 'storage_account_personal_chat_container_name': 'personal-chat',
+ }
+ helper = load_workflow_generated_file_export_helper(shared_namespace)
+ workflow = {
+ 'id': 'workflow-1',
+ 'user_id': 'user-1',
+ 'task_prompt': 'turn these into a single CSV',
+ }
+ assistant_content = '''| Name | Invoice Number |
+| --- | --- |
+| Contoso | DCAW1366188 |
+
+Source: ParkingPrint.pdf, Page: 1
+'''
+
+ artifact = helper(
+ workflow,
+ 'conversation-1',
+ workflow['task_prompt'],
+ assistant_content,
+ )
+ assert_true(artifact is not None, 'Expected a workflow CSV artifact for valid assistant rows.')
+ assert_true(artifact['artifact_message_id'] == 'workflow-csv-artifact', 'Expected uploaded workflow artifact metadata.')
+ assert_true(len(uploaded_requests) == 1, 'Expected one authorized artifact upload.')
+ assert_true(uploaded_requests[0]['current_user_id'] == 'user-1', 'Expected upload to use the workflow owner.')
+ assert_true(uploaded_requests[0]['output_format'] == 'csv', 'Expected a CSV artifact upload.')
+
+ word_workflow = {
+ **workflow,
+ 'task_prompt': 'create a Word document from the action results',
+ }
+ word_artifact = helper(
+ word_workflow,
+ 'conversation-1',
+ word_workflow['task_prompt'],
+ 'The directory action completed successfully.',
+ function_results=[{
+ 'plugin_name': 'DirectoryPlugin',
+ 'function_name': 'list_people',
+ 'success': True,
+ 'function_result': {'rows': [{'Name': 'Ada', 'Department': 'Engineering'}]},
+ }],
+ )
+ assert_true(word_artifact is not None, 'Expected a workflow DOCX artifact from structured function results.')
+ assert_true(uploaded_requests[-1]['output_format'] == 'docx', 'Expected workflow DOCX artifact metadata.')
+ assert_true(uploaded_requests[-1]['capability'] == 'file_export', 'Expected generic file-export capability metadata.')
+ assert_true(uploaded_requests[-1]['file_content'].startswith(b'PK'), 'Expected a rendered DOCX upload payload.')
+ assert_true(
+ helper(
+ workflow,
+ 'conversation-1',
+ workflow['task_prompt'],
+ assistant_content,
+ existing_outputs=[{'capability': 'tabular', 'output_format': 'csv'}],
+ ) is None,
+ 'Expected existing tabular CSV output to suppress a duplicate workflow artifact.',
+ )
+
+ background_namespace = dict(shared_namespace)
+ background_namespace.update({
+ 'should_queue_tabular_generated_output_background': lambda *args: True,
+ 'queue_tabular_generated_output_run': (
+ lambda **kwargs: queue_requests.append(kwargs) or {'id': 'workflow-export-run'}
+ ),
+ 'build_background_tabular_generated_output_metadata': (
+ lambda run: {
+ 'background_export': True,
+ 'export_run_id': run['id'],
+ 'suppress_assistant_table_export': True,
+ }
+ ),
+ })
+ background_helper = load_workflow_generated_file_export_helper(background_namespace)
+ background_artifact = background_helper(
+ workflow,
+ 'conversation-1',
+ workflow['task_prompt'],
+ assistant_content,
+ )
+ assert_true(background_artifact['background_export'] is True, 'Expected large workflow exports to queue durably.')
+ assert_true(queue_requests[-1]['passthrough_input_rows'] is True, 'Expected workflow rows to avoid a second model call.')
+ assert_true(
+ queue_requests[-1]['source_candidate']['source_authorization'] == {'source': 'chat'},
+ 'Expected staged workflow rows to use valid chat authorization without a source blob path.',
+ )
+
+ workflow_runner_content = read_text(WORKFLOW_RUNNER_FILE)
+ assert_true(
+ 'generated_file_output = _maybe_create_workflow_generated_file_output(' in workflow_runner_content,
+ 'Expected workflow assistant messages to finalize shared file artifacts.',
+ )
+ assert_true(
+ 'generated_analysis_artifacts.append(generated_file_output)' in workflow_runner_content,
+ 'Expected workflow generated-file metadata to reach the generic artifact UI.',
+ )
+
+
def test_chat_route_wires_assistant_table_artifacts():
print('Testing chat route assistant-table artifact plumbing...')
@@ -173,24 +1035,69 @@ def test_chat_route_wires_assistant_table_artifacts():
assert_true(current_version == EXPECTED_VERSION, f'Expected config.py version {EXPECTED_VERSION}.')
assert_true(
- 'TABLE_EXPORT_REQUEST_MARKERS' in chat_route_content,
- 'Expected route_backend_chats.py to reuse assistant table export request markers.',
+ 'assistant_table_export_requested' in chat_route_content,
+ 'Expected route_backend_chats.py to reuse the shared assistant table export intent predicate.',
+ )
+ assert_true(
+ 'def maybe_create_generated_file_output(' in chat_route_content,
+ 'Expected route_backend_chats.py to expose generic generated-file artifact creation.',
+ )
+ assert_true(
+ "output_format == 'csv' and should_queue_tabular_generated_output_background(" in chat_route_content,
+ 'Expected large generated CSV artifacts to use the durable background export threshold.',
+ )
+ assert_true(
+ 'queue_tabular_generated_output_run(' in chat_route_content,
+ 'Expected large assistant-derived CSV artifacts to queue through the background tabular exporter.',
+ )
+ assert_true(
+ 'passthrough_input_rows=True' in chat_route_content,
+ 'Expected large assistant-derived CSV artifacts to avoid a second model transformation.',
+ )
+ assert_true(
+ "'background_export': True" in read_text(BACKGROUND_EXPORT_FILE),
+ 'Expected queued assistant exports to reuse standard background-export metadata.',
+ )
+ assert_true(
+ 'document_generated_analysis_artifacts.append(generated_file_output)' in chat_route_content,
+ 'Expected document-action assistant messages to include generated file artifacts.',
+ )
+ assert_true(
+ 'generated_analysis_artifacts_list.append(generated_file_output)' in chat_route_content,
+ 'Expected normal and streaming assistant messages to include generated file artifacts.',
+ )
+ assert_true(
+ 'assistant_content=get_generated_file_export_content(execution_result)' in chat_route_content,
+ 'Expected document-action file exports to use the structured analysis reply when available.',
+ )
+ assert_true(
+ 'assistant_content=get_generated_file_export_content(result)' in read_text(WORKFLOW_RUNNER_FILE),
+ 'Expected workflow file exports to use the structured analysis reply when available.',
+ )
+ assert_true(
+ chat_route_content.count('build_generated_file_output_guidance(user_message)') == 2,
+ 'Expected normal and streaming Chat to apply the same file-output guidance.',
+ )
+ workflow_runner_content = read_text(WORKFLOW_RUNNER_FILE)
+ assert_true(
+ workflow_runner_content.count('build_generated_file_output_guidance(prompt_text)') == 2,
+ 'Expected workflow model and agent execution to apply the same file-output guidance.',
)
assert_true(
- 'def maybe_create_assistant_table_generated_output(' in chat_route_content,
- 'Expected route_backend_chats.py to expose assistant table artifact creation.',
+ chat_route_content.count('function_results=agent_citations_list') == 2,
+ 'Expected normal and streaming Chat to pass current-turn action results to generated-file exports.',
)
assert_true(
- 'document_generated_analysis_artifacts.append(assistant_table_generated_output)' in chat_route_content,
- 'Expected document-action assistant messages to include assistant table CSV artifacts.',
+ 'function_results=execution_result.get(\'agent_citations\') or []' in chat_route_content,
+ 'Expected document actions to pass current-turn action results to generated-file exports.',
)
assert_true(
- 'generated_analysis_artifacts_list.append(assistant_table_generated_output)' in chat_route_content,
- 'Expected normal and streaming assistant messages to include assistant table CSV artifacts.',
+ 'function_results=raw_agent_citations' in workflow_runner_content,
+ 'Expected workflows to pass current-turn action results to generated-file exports.',
)
assert_true(
- 'csv_markers = TABLE_EXPORT_REQUEST_MARKERS' in chat_route_content,
- 'Expected tabular output intent detection to use shared CSV/table request markers.',
+ 'if assistant_table_export_requested(user_question):' in chat_route_content,
+ 'Expected tabular output format detection to use the shared CSV/table intent predicate.',
)
assert_true(
"requested_format == 'csv'" in chat_route_content,
@@ -202,9 +1109,37 @@ def run_tests() -> bool:
tests = [
test_markdown_table_response_builds_csv_export,
test_tab_separated_table_response_builds_rows,
+ test_non_tabular_document_csv_response_builds_export,
+ test_document_action_analysis_reply_builds_csv_export,
+ test_structured_action_result_builds_csv_when_assistant_summarizes,
+ test_structured_action_results_combine_and_preserve_assistant_priority,
+ test_tabular_action_result_does_not_bypass_coverage_aware_exports,
+ test_function_results_render_docx_and_pdf_capabilities,
+ test_plain_document_csv_response_excludes_surrounding_prose_and_citation,
+ test_document_csv_response_preserves_multiline_and_escaped_quotes,
+ test_fenced_document_csv_preserves_sentence_shaped_rows,
+ test_fenced_document_csv_wins_over_larger_markdown_table,
+ test_explicit_csv_fence_wins_over_larger_generic_fence,
+ test_generic_fenced_csv_like_content_builds_export,
+ test_unterminated_csv_fence_allows_unfenced_fallback,
+ test_adversarial_fence_opening_uses_linear_csv_parsing,
+ test_plain_document_csv_preserves_quoted_blank_lines_and_ignores_comma_prose,
+ test_plain_document_csv_preserves_long_headers_and_sentence_values,
+ test_plain_document_csv_excludes_prose_and_short_page_citations,
+ test_plain_document_csv_excludes_generic_prose_and_non_page_citations,
+ test_plain_document_csv_normalizes_preambles_and_citation_variants,
+ test_document_csv_supports_alternate_text_fences,
+ test_document_csv_neutralizes_spreadsheet_formulas,
+ test_document_csv_accepts_punctuation_and_duplicate_headers,
+ test_document_csv_preserves_header_suffix_collisions,
+ test_document_csv_neutralizes_formula_headers_without_losing_rows,
+ test_all_generated_csv_writers_neutralize_formulas,
test_non_table_requests_do_not_create_exports,
test_natural_table_request_phrase_is_recognized,
test_natural_csv_and_create_table_phrases_are_recognized,
+ test_universal_csv_request_variants_are_recognized,
+ test_csv_schema_clarification_guidance_is_specific_and_resumable,
+ test_workflow_generated_file_artifacts_reuse_shared_contract,
test_chat_route_wires_assistant_table_artifacts,
]
diff --git a/functional_tests/test_chat_document_action_user_message_metadata.py b/functional_tests/test_chat_document_action_user_message_metadata.py
index 3a44d3816..c78844177 100644
--- a/functional_tests/test_chat_document_action_user_message_metadata.py
+++ b/functional_tests/test_chat_document_action_user_message_metadata.py
@@ -1,7 +1,7 @@
# test_chat_document_action_user_message_metadata.py
"""
Functional test for document-action user message metadata enrichment.
-Version: 0.241.095
+Version: 0.250.070
Implemented in: 0.241.095
This test ensures analysis and document comparison user messages
@@ -23,8 +23,8 @@ def test_document_action_user_metadata_is_enriched() -> None:
route_content = _read_workspace_file('application', 'single_app', 'route_backend_chats.py')
config_content = _read_workspace_file('application', 'single_app', 'config.py')
- assert 'VERSION = "0.241.095"' in config_content, (
- 'Expected config.py version 0.241.095 for the document-action user metadata logging fix.'
+ assert 'VERSION = "0.250.070"' in config_content, (
+ 'Expected the current application version for document-action user metadata coverage.'
)
assert 'def _build_document_action_user_metadata(' in route_content, (
'Expected a dedicated helper for document-action user message metadata.'
@@ -41,8 +41,8 @@ def test_document_action_user_metadata_is_enriched() -> None:
assert "'selected_document_names': resolved_document_names," in route_content, (
'Expected document-action user messages to record resolved selected document names.'
)
- assert "selected_document_summary = f'Left: {left_document_name} | Right: {right_document_summary}'" in route_content, (
- 'Expected comparison user messages to summarize left and right document selections.'
+ assert "selected_document_summary = f'Source: {left_document_name} | Targets: {right_document_summary}'" in route_content, (
+ 'Expected comparison user messages to summarize Source and Target document selections.'
)
assert "'streaming': bool(streaming_enabled)," in route_content, (
'Expected document-action user messages to log whether the request was streamed.'
diff --git a/functional_tests/test_chat_error_response_sanitization.py b/functional_tests/test_chat_error_response_sanitization.py
new file mode 100644
index 000000000..addc20aef
--- /dev/null
+++ b/functional_tests/test_chat_error_response_sanitization.py
@@ -0,0 +1,137 @@
+# test_chat_error_response_sanitization.py
+#!/usr/bin/env python3
+"""
+Functional test for chat error response sanitization.
+Version: 0.250.113
+Implemented in: 0.250.113
+
+This test ensures that unexpected chat exceptions are logged server-side and
+browser-visible JSON/SSE responses do not include raw exception text,
+traceback details, provider class names, local paths, or internal descriptors.
+"""
+
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parents[1]
+CHAT_ROUTE_FILE = ROOT / "application" / "single_app" / "route_backend_chats.py"
+
+
+def read_chat_route():
+ """Return the chat route source for response-boundary assertions."""
+ return CHAT_ROUTE_FILE.read_text(encoding="utf-8")
+
+
+def assert_not_contains(content, unexpected):
+ """Fail with a readable message when an unsafe source pattern remains."""
+ if unexpected in content:
+ raise AssertionError(f"Unsafe browser response pattern remains: {unexpected}")
+
+
+def assert_contains(content, expected):
+ """Fail with a readable message when an expected safe pattern is missing."""
+ if expected not in content:
+ raise AssertionError(f"Expected safe response pattern is missing: {expected}")
+
+
+def test_unexpected_exception_text_is_not_sent_to_browser():
+ """Validate known raw exception response patterns are removed."""
+ content = read_chat_route()
+ unsafe_patterns = [
+ "Internal server error: {str(e)}",
+ "Failed to initialize AI model: {str(e)}",
+ "Error reading conversation: {str(e)}",
+ "Error preparing conversation history: {str(e)}",
+ "Model initialization failed: {str(e)}",
+ "History error: {str(e)}",
+ "Agent streaming failed: {str(stream_error)}",
+ "Failed to parse request: {str(e)}",
+ "Error fetching message: {str(e)}",
+ "Error updating message: {str(e)}",
+ "Details: {str(e)}",
+ "Image generation request was invalid: {error_message}",
+ "Image generation failed due to a technical error: {error_message}",
+ "'details': error_traceback if current_app.debug else None",
+ "{'error': error_msg, 'partial_content': accumulated_content}",
+ ]
+ for unsafe_pattern in unsafe_patterns:
+ assert_not_contains(content, unsafe_pattern)
+
+
+def test_safe_json_and_sse_helpers_are_used():
+ """Validate route-level JSON and SSE responses use stable public messages."""
+ content = read_chat_route()
+ expected_patterns = [
+ "CLIENT_SAFE_INTERNAL_ERROR_MESSAGE = 'Something went wrong while processing the request. Please try again.'",
+ "CLIENT_SAFE_STREAM_ERROR_MESSAGE = 'Something went wrong while streaming the response. Please try again.'",
+ "def build_stream_error_event(message=CLIENT_SAFE_STREAM_ERROR_MESSAGE, **extra):",
+ "def build_json_error_response(message=CLIENT_SAFE_INTERNAL_ERROR_MESSAGE, status_code=500, **extra):",
+ "return jsonify({'error': 'Invalid request payload'}), 400",
+ "return build_json_error_response('Failed to initialize AI model')",
+ "return build_json_error_response('Failed to read conversation')",
+ "return build_json_error_response('Failed to prepare conversation history')",
+ "yield build_stream_error_event('Failed to initialize AI model')",
+ "yield build_stream_error_event('Failed to prepare conversation history')",
+ "return jsonify({'error': 'Document context request is invalid. Please review the selected sources and try again.'}), 400",
+ "return jsonify({'error': 'Selected document context is unavailable. Please refresh and try again.'}), 400",
+ "return jsonify({'error': 'Invalid mask request'}), 400",
+ "'error': 'Document action request is invalid. Please review the selected documents and try again.'",
+ ]
+ for expected_pattern in expected_patterns:
+ assert_contains(content, expected_pattern)
+
+
+def test_stream_partial_content_keeps_sanitized_error_metadata():
+ """Validate partial SSE content can flow without raw exception metadata."""
+ content = read_chat_route()
+ expected_patterns = [
+ "'error': 'stream_interrupted',",
+ "'error_message': CLIENT_SAFE_STREAM_ERROR_MESSAGE,",
+ "yield build_stream_error_event(",
+ "partial_content=accumulated_content,",
+ ]
+ for expected_pattern in expected_patterns:
+ assert_contains(content, expected_pattern)
+
+ unsafe_metadata_patterns = [
+ "'error': error_msg,",
+ '"error": error_msg,',
+ "yield f\"data: {json.dumps({'error': error_msg, 'partial_content': accumulated_content})}",
+ ]
+ for unsafe_pattern in unsafe_metadata_patterns:
+ assert_not_contains(content, unsafe_pattern)
+
+
+def test_intentional_user_facing_contracts_are_preserved():
+ """Validate allowlisted auth, validation, and content-safety responses remain."""
+ content = read_chat_route()
+ expected_patterns = [
+ "return jsonify({'error': 'Conversation not found'}), 404",
+ "return jsonify({'error': 'Forbidden'}), 403",
+ "return jsonify({'error': 'User not authenticated'}), 401",
+ "Image generation was blocked by content safety policies",
+ "if isinstance(stream_error, FoundryAgentUserAuthenticationRequired):",
+ "'auth_required': True",
+ "'scopes': auth_response.get('scopes') or [],",
+ ]
+ for expected_pattern in expected_patterns:
+ assert_contains(content, expected_pattern)
+
+
+def main():
+ """Run the focused sanitization checks."""
+ tests = [
+ test_unexpected_exception_text_is_not_sent_to_browser,
+ test_safe_json_and_sse_helpers_are_used,
+ test_stream_partial_content_keeps_sanitized_error_metadata,
+ test_intentional_user_facing_contracts_are_preserved,
+ ]
+ for test in tests:
+ print(f"Running {test.__name__}...")
+ test()
+ print("All chat error response sanitization checks passed.")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
\ No newline at end of file
diff --git a/functional_tests/test_chat_route_unreachable_code_cleanup.py b/functional_tests/test_chat_route_unreachable_code_cleanup.py
new file mode 100644
index 000000000..73122bd42
--- /dev/null
+++ b/functional_tests/test_chat_route_unreachable_code_cleanup.py
@@ -0,0 +1,57 @@
+#!/usr/bin/env python3
+# test_chat_route_unreachable_code_cleanup.py
+"""
+Functional test for chat route unreachable-code cleanup.
+Version: 0.250.116
+Implemented in: 0.250.116
+
+This test ensures the stale, locally disabled kernel persistence branch that
+triggered the PR #1145 CodeQL unreachable-code alert remains removed from the
+chat route.
+"""
+
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parents[1]
+CONFIG_FILE = ROOT / "application" / "single_app" / "config.py"
+CHAT_ROUTE_FILE = ROOT / "application" / "single_app" / "route_backend_chats.py"
+EXPECTED_VERSION = "0.250.116"
+
+
+def read_text(path):
+ """Return UTF-8 source text for simple route contract assertions."""
+ return path.read_text(encoding="utf-8")
+
+
+def read_current_version():
+ """Return the application version declared in config.py."""
+ for line in read_text(CONFIG_FILE).splitlines():
+ stripped_line = line.strip()
+ if stripped_line.startswith('VERSION = '):
+ return stripped_line.split('"')[1]
+ raise AssertionError("Expected config.py to define VERSION")
+
+
+def test_stale_kernel_persistence_branch_removed():
+ """Validate the unreachable per-user kernel persistence branch is gone."""
+ print("Testing chat route unreachable-code cleanup...")
+
+ current_version = read_current_version()
+ chat_route_content = read_text(CHAT_ROUTE_FILE)
+
+ assert current_version == EXPECTED_VERSION, (
+ f"Expected config.py version {EXPECTED_VERSION} for the unreachable-code cleanup."
+ )
+ assert "enable_redis_for_kernel" not in chat_route_content, (
+ "Expected the locally disabled enable_redis_for_kernel guard to remain removed."
+ )
+ assert "save_user_kernel(" not in chat_route_content, (
+ "Expected the unreachable save_user_kernel call to remain removed from the chat route."
+ )
+
+ print("Chat route unreachable-code cleanup checks passed")
+
+
+if __name__ == "__main__":
+ test_stale_kernel_persistence_branch_removed()
\ No newline at end of file
diff --git a/functional_tests/test_chat_semantic_kernel_return_contract.py b/functional_tests/test_chat_semantic_kernel_return_contract.py
new file mode 100644
index 000000000..9e9f9345c
--- /dev/null
+++ b/functional_tests/test_chat_semantic_kernel_return_contract.py
@@ -0,0 +1,141 @@
+#!/usr/bin/env python3
+# test_chat_semantic_kernel_return_contract.py
+"""
+Functional test for chat Semantic Kernel return contract cleanup.
+Version: 0.250.118
+Implemented in: 0.250.118
+
+This test ensures the nested chat route run_sk_call helper keeps explicit
+return behavior for Semantic Kernel result shapes, including empty async
+generators that intentionally resolve to None.
+"""
+
+import ast
+import asyncio
+import logging
+import sys
+import types
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parents[1]
+CONFIG_FILE = ROOT / "application" / "single_app" / "config.py"
+CHAT_ROUTE_FILE = ROOT / "application" / "single_app" / "route_backend_chats.py"
+IMPLEMENTED_VERSION = "0.250.118"
+
+
+def read_text(path):
+ """Return UTF-8 source text for simple contract assertions."""
+ return path.read_text(encoding="utf-8")
+
+
+def read_current_version():
+ """Return the application version declared in config.py."""
+ for line in read_text(CONFIG_FILE).splitlines():
+ stripped_line = line.strip()
+ if stripped_line.startswith('VERSION = '):
+ return stripped_line.split('"')[1]
+ raise AssertionError("Expected config.py to define VERSION")
+
+
+def parse_version(version):
+ """Return a comparable tuple for SimpleChat version strings."""
+ return tuple(int(part) for part in version.split('.'))
+
+
+def find_run_sk_call_node():
+ """Return the nested run_sk_call async function from the chat route AST."""
+ parsed_route = ast.parse(read_text(CHAT_ROUTE_FILE), filename=str(CHAT_ROUTE_FILE))
+ for node in ast.walk(parsed_route):
+ if isinstance(node, ast.AsyncFunctionDef) and node.name == 'run_sk_call':
+ return node
+ raise AssertionError("Expected route_backend_chats.py to define nested run_sk_call")
+
+
+def find_async_generator_branch(run_sk_call_node):
+ """Return the branch that handles async generator results."""
+ for node in ast.walk(run_sk_call_node):
+ if not isinstance(node, ast.If):
+ continue
+ if ast.unparse(node.test) == 'isinstance(result, types.AsyncGeneratorType)':
+ return node
+ raise AssertionError("Expected run_sk_call to handle types.AsyncGeneratorType")
+
+
+def load_run_sk_call():
+ """Compile the nested helper as an isolated async function."""
+ run_sk_call_node = find_run_sk_call_node()
+ module = ast.Module(body=[run_sk_call_node], type_ignores=[])
+ ast.fix_missing_locations(module)
+ namespace = {
+ 'asyncio': asyncio,
+ 'logging': logging,
+ 'log_event': lambda *args, **kwargs: None,
+ 'types': types,
+ }
+ exec(compile(module, str(CHAT_ROUTE_FILE), 'exec'), namespace)
+ return namespace['run_sk_call']
+
+
+def test_async_generator_branch_has_explicit_none_return():
+ """Verify the async-generator branch has an explicit terminal return None."""
+ print("Testing explicit async-generator return contract...")
+
+ current_version = read_current_version()
+ run_sk_call_node = find_run_sk_call_node()
+ async_generator_branch = find_async_generator_branch(run_sk_call_node)
+
+ explicit_none_returns = [
+ statement for statement in async_generator_branch.body
+ if (
+ isinstance(statement, ast.Return)
+ and isinstance(statement.value, ast.Constant)
+ and statement.value.value is None
+ )
+ ]
+
+ assert parse_version(current_version) >= parse_version(IMPLEMENTED_VERSION), (
+ f"Expected config.py version at least {IMPLEMENTED_VERSION} for the return-contract cleanup."
+ )
+ assert explicit_none_returns, (
+ "Expected run_sk_call to return None explicitly when an async generator yields no values."
+ )
+
+ print("Explicit async-generator return contract checks passed")
+
+
+def test_run_sk_call_result_shapes():
+ """Verify run_sk_call preserves direct, coroutine, and async-generator results."""
+ print("Testing Semantic Kernel helper result shapes...")
+
+ run_sk_call = load_run_sk_call()
+
+ async def coroutine_value():
+ return "awaited-value"
+
+ async def async_generator_value():
+ yield "first-yielded-value"
+ yield "second-yielded-value"
+
+ async def empty_async_generator():
+ if False:
+ yield "unreachable-value"
+
+ assert asyncio.run(run_sk_call(lambda: "direct-value")) == "direct-value"
+ assert asyncio.run(run_sk_call(coroutine_value)) == "awaited-value"
+ assert asyncio.run(run_sk_call(async_generator_value)) == "first-yielded-value"
+ assert asyncio.run(run_sk_call(empty_async_generator)) is None
+
+ print("Semantic Kernel helper result shape checks passed")
+
+
+if __name__ == "__main__":
+ tests = [
+ test_async_generator_branch_has_explicit_none_return,
+ test_run_sk_call_result_shapes,
+ ]
+
+ for test in tests:
+ test()
+
+ print(f"Passed {len(tests)}/{len(tests)} chat Semantic Kernel return contract tests")
\ No newline at end of file
diff --git a/functional_tests/test_chat_stream_stop_control.py b/functional_tests/test_chat_stream_stop_control.py
index b7c1babdd..7ae83783f 100644
--- a/functional_tests/test_chat_stream_stop_control.py
+++ b/functional_tests/test_chat_stream_stop_control.py
@@ -2,7 +2,7 @@
# test_chat_stream_stop_control.py
"""
Functional test for chat stream stop control.
-Version: 0.241.098
+Version: 0.250.070
Implemented in: 0.241.097
This test ensures chat streams expose a user-scoped cancellation endpoint,
@@ -38,29 +38,28 @@ def test_chat_stream_stop_control_wiring() -> None:
collaboration_js_content = read_text("application/single_app/static/js/chat/chat-collaboration.js")
feature_doc_content = read_text("docs/explanation/features/v0.241.097/CHAT_STREAM_STOP_CONTROL.md")
- assert_contains(config_content, 'VERSION = "0.241.098"', "config version")
+ assert_contains(config_content, 'VERSION = "0.250.070"', "config version")
assert_contains(route_content, "STREAM_STATUS_CANCEL_REQUESTED = 'cancel_requested'", "chat route")
assert_contains(route_content, "STREAM_STATUS_CANCELED = 'canceled'", "chat route")
assert_contains(route_content, "def request_cancel(self, reason='user_requested'):", "chat stream session")
assert_contains(route_content, "def is_cancel_requested(self):", "chat stream session")
assert_contains(route_content, "def _build_stream_cancel_event(", "cancel event builder")
- assert_contains(route_content, "@app.route('/api/chat/stream/cancel/', methods=['POST'])", "chat cancel route")
+ assert_contains(route_content, "@bp.route('/api/chat/stream/cancel/', methods=['POST'])", "chat cancel route")
assert_contains(route_content, "if stream_cancel_requested():", "stream cancellation checkpoints")
assert_contains(route_content, "yield finalize_cancelled_stream_response()", "cancelled stream finalization")
assert_contains(
collaboration_content,
- "@app.route('/api/collaboration/conversations//stream/cancel', methods=['POST'])",
+ "@bp.route('/api/collaboration/conversations//stream/cancel', methods=['POST'])",
"collaboration cancel route",
)
assert_contains(collaboration_content, "source_conversation_id = str((conversation_doc or {}).get('source_conversation_id')", "source conversation lookup")
assert_contains(collaboration_content, "CHAT_STREAM_REGISTRY.get_session(", "collaboration source stream lookup")
assert_contains(collaboration_content, "stream_payload.get('cancelled') or stream_payload.get('canceled')", "collaboration cancel transform")
- assert_contains(streaming_content, "className = 'btn btn-sm btn-danger stream-stop-btn", "message-local Stop button")
+ assert_contains(streaming_content, "className = 'btn btn-sm stream-stop-btn", "message-local Stop button")
assert_contains(streaming_content, "rounded-circle p-0 border-0", "compact icon-only Stop button")
- assert_contains(streaming_content, "stopButton.style.width = '1.65rem'", "fixed-size Stop button")
assert_contains(streaming_content, "async function requestStreamCancellation", "frontend cancel request")
assert_contains(streaming_content, "fetch(streamContext.cancelEndpoint", "cancel endpoint POST")
assert_contains(streaming_content, "finalizeCancelledStreamingMessage", "cancelled UI finalizer")
diff --git a/functional_tests/test_codeql_import_cycle_lazy_imports.py b/functional_tests/test_codeql_import_cycle_lazy_imports.py
new file mode 100644
index 000000000..3a450f346
--- /dev/null
+++ b/functional_tests/test_codeql_import_cycle_lazy_imports.py
@@ -0,0 +1,88 @@
+# test_codeql_import_cycle_lazy_imports.py
+#!/usr/bin/env python3
+"""
+Functional test for CodeQL import-cycle lazy-import remediation.
+Version: 0.250.120
+Implemented in: 0.250.120
+
+This test ensures the mixed-source and document-analysis modules do not reintroduce
+the module-level imports that triggered PR 1145 CodeQL cyclic-import alerts.
+"""
+
+import ast
+import os
+import sys
+
+
+REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+SINGLE_APP_DIR = os.path.join(REPO_ROOT, "application", "single_app")
+
+
+def _read_module_tree(module_filename):
+ module_path = os.path.join(SINGLE_APP_DIR, module_filename)
+ with open(module_path, "r", encoding="utf-8") as module_file:
+ return ast.parse(module_file.read(), filename=module_path)
+
+
+def _top_level_imports_from(tree, module_name):
+ imports = []
+ for node in tree.body:
+ if isinstance(node, ast.ImportFrom) and node.module == module_name:
+ imports.append(node)
+ return imports
+
+
+def _has_function(tree, function_name):
+ return any(
+ isinstance(node, ast.FunctionDef) and node.name == function_name
+ for node in tree.body
+ )
+
+
+def test_mixed_source_log_event_import_is_lazy():
+ """Validate mixed-source orchestration no longer imports App Insights at module load."""
+ print("Testing mixed-source orchestration lazy telemetry import...")
+
+ tree = _read_module_tree("functions_mixed_source_orchestration.py")
+ top_level_imports = _top_level_imports_from(tree, "functions_appinsights")
+
+ assert top_level_imports == [], "functions_appinsights must not be imported at module scope."
+ assert _has_function(tree, "log_event"), "Expected lazy log_event wrapper to remain available."
+
+
+def test_document_analysis_mixed_source_import_is_lazy():
+ """Validate document analysis no longer imports mixed-source contracts at module load."""
+ print("Testing document-analysis lazy mixed-source helper import...")
+
+ tree = _read_module_tree("functions_document_analysis.py")
+ top_level_imports = _top_level_imports_from(tree, "functions_mixed_source_orchestration")
+
+ assert top_level_imports == [], "functions_mixed_source_orchestration must not be imported at module scope."
+ assert _has_function(tree, "_get_mixed_source_orchestration_helpers"), (
+ "Expected lazy mixed-source helper resolver to remain available."
+ )
+
+
+def main():
+ tests = [
+ test_mixed_source_log_event_import_is_lazy,
+ test_document_analysis_mixed_source_import_is_lazy,
+ ]
+ results = []
+
+ for test in tests:
+ try:
+ test()
+ print(f"PASS: {test.__name__}")
+ results.append(True)
+ except Exception as ex:
+ print(f"FAIL: {test.__name__}: {ex}")
+ results.append(False)
+
+ success = all(results)
+ print(f"Results: {sum(results)}/{len(results)} tests passed")
+ return 0 if success else 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
\ No newline at end of file
diff --git a/functional_tests/test_conversation_chart_and_tabular_reuse.py b/functional_tests/test_conversation_chart_and_tabular_reuse.py
index 5306bb956..f3ed727fd 100644
--- a/functional_tests/test_conversation_chart_and_tabular_reuse.py
+++ b/functional_tests/test_conversation_chart_and_tabular_reuse.py
@@ -2,7 +2,7 @@
# test_conversation_chart_and_tabular_reuse.py
"""
Functional test for conversation chart abilities and reusable tabular analysis.
-Version: 0.241.033
+Version: 0.250.070
Implemented in: 0.241.031; proactive chart guidance added in 0.241.033
This test ensures the built-in chart plugin is loaded as a conversation-level
@@ -27,7 +27,7 @@
SEMANTIC_KERNEL_LOADER_FILE = APP_ROOT / "semantic_kernel_loader.py"
CHAT_ROUTE_FILE = APP_ROOT / "route_backend_chats.py"
WORKFLOW_RUNNER_FILE = APP_ROOT / "functions_workflow_runner.py"
-EXPECTED_VERSION = "0.241.033"
+EXPECTED_VERSION = "0.250.070"
TARGET_CHART_HELPERS = {
"user_requested_chart_visualization",
diff --git a/functional_tests/test_cross_format_compare_workflow.py b/functional_tests/test_cross_format_compare_workflow.py
new file mode 100644
index 000000000..8dd286225
--- /dev/null
+++ b/functional_tests/test_cross_format_compare_workflow.py
@@ -0,0 +1,130 @@
+#!/usr/bin/env python3
+# test_cross_format_compare_workflow.py
+"""
+Functional test for Phase 4 cross-format Compare.
+Version: 0.250.067
+Implemented in: 0.250.067
+
+This test ensures #1059 retains one Source and ordered Targets, uses bounded
+native evidence, and preserves failed Targets during pairwise reduction.
+Parent: #1055. Prerequisites: #1056, #1057, and #1058.
+"""
+
+import ast
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parents[1]
+COMPARISON = ROOT / 'application' / 'single_app' / 'functions_document_comparison.py'
+WORKFLOW = ROOT / 'application' / 'single_app' / 'functions_workflow_runner.py'
+SETTINGS = ROOT / 'application' / 'single_app' / 'functions_settings.py'
+
+
+def _load_evidence_comparison():
+ source = COMPARISON.read_text(encoding='utf-8')
+ tree = ast.parse(source)
+ names = {
+ '_build_pairwise_comparison_prompt',
+ '_build_comparison_reduction_prompt',
+ 'run_evidence_document_comparison',
+ }
+ module = ast.Module(
+ body=[node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name in names],
+ type_ignores=[],
+ )
+ ast.fix_missing_locations(module)
+ namespace = {}
+ exec(compile(module, str(COMPARISON), 'exec'), namespace)
+ return namespace['run_evidence_document_comparison']
+
+
+def test_pairwise_reducer_preserves_target_order_and_partial_failure():
+ """Completed Targets are compared in order while a failed Target remains visible."""
+ compare = _load_evidence_comparison()
+ calls = []
+
+ def invoke_prompt(prompt, stage='', metadata=None):
+ calls.append((stage, metadata or {}))
+ return f"{stage}:{metadata.get('right_document_id', 'reduction')}"
+
+ result = compare(
+ 'Compare calculated facts with stated policy.',
+ {
+ 'document_id': 'source-csv',
+ 'document_name': 'Source.csv',
+ 'source_kind': 'tabular',
+ 'engine': 'tabular_tools',
+ 'status': 'completed',
+ 'summary': 'Computed total: 24.',
+ },
+ [
+ {
+ 'document_id': 'target-pdf',
+ 'document_name': 'Target.pdf',
+ 'source_kind': 'narrative',
+ 'engine': 'document_analysis',
+ 'status': 'completed',
+ 'summary': 'The policy states a total of 23.',
+ },
+ {
+ 'document_id': 'target-xlsx',
+ 'document_name': 'Target.xlsx',
+ 'source_kind': 'tabular',
+ 'engine': 'tabular_tools',
+ 'status': 'failed',
+ 'summary': '',
+ },
+ ],
+ invoke_prompt,
+ )
+
+ assert [item['right_document_id'] for item in result['comparison_items']] == ['target-pdf']
+ assert result['coverage']['failed_targets'] == ['Target.xlsx']
+ assert calls == [('comparison', {'comparison_index': 1, 'comparison_count': 2, 'left_document_id': 'source-csv', 'right_document_id': 'target-pdf'})]
+ assert 'Evidence engines: document_analysis, tabular_tools' in result['reply']
+ assert 'Conclusion level: aggregate or narrative' in result['reply']
+
+
+def test_cross_format_coordinator_uses_native_partitions_and_rollout_guards():
+ """CSV/XLSX and PDF/DOCX combinations must use native branches, not chunk fallback."""
+ source = WORKFLOW.read_text(encoding='utf-8')
+ tree = ast.parse(source)
+ helper = next(
+ node for node in tree.body
+ if isinstance(node, ast.FunctionDef) and node.name == '_execute_cross_format_comparison_workflow'
+ )
+ helper_source = ast.get_source_segment(source, helper) or ''
+
+ assert '_resolve_cross_format_comparison_manifest(' in helper_source
+ assert "partitions['narrative_sources']" in helper_source
+ assert "partitions['tabular_sources']" in helper_source
+ assert 'run_document_analysis(' in helper_source
+ assert '_maybe_execute_tabular_document_action(' in helper_source
+ assert 'DOCUMENT_ACTION_TYPE_ANALYZE' in helper_source
+ assert 'build_evidence_envelope(' in helper_source
+ assert 'source_version' in helper_source
+ assert 'run_evidence_document_comparison(' in helper_source
+ assert "'computed tabular facts'" in helper_source
+ assert "'narrative document analysis'" in helper_source
+ assert 'is_cross_format_compare_one_to_many_enabled(settings)' in helper_source
+ assert 'Mixed narrative and tabular Compare is temporarily unavailable while cross-format Compare is disabled.' in source
+
+
+def test_phase_4_flags_default_off_and_all_runner_paths_use_them():
+ """Model and agent Compare retain flag-off rollback and staged one-to-many rollout."""
+ settings_source = SETTINGS.read_text(encoding='utf-8')
+ workflow_source = WORKFLOW.read_text(encoding='utf-8')
+
+ assert "'enable_cross_format_compare': False" in settings_source
+ assert "'enable_cross_format_compare_one_to_many': False" in settings_source
+ assert 'def is_cross_format_compare_enabled(settings):' in settings_source
+ assert 'def is_cross_format_compare_one_to_many_enabled(settings):' in settings_source
+ assert workflow_source.count('mixed_comparison_enabled = is_cross_format_compare_enabled(settings)') == 2
+ assert workflow_source.count('_execute_cross_format_comparison_workflow(') >= 3
+
+
+if __name__ == '__main__':
+ test_pairwise_reducer_preserves_target_order_and_partial_failure()
+ test_cross_format_coordinator_uses_native_partitions_and_rollout_guards()
+ test_phase_4_flags_default_off_and_all_runner_paths_use_them()
+ print('Phase 4 cross-format Compare tests passed.')
\ No newline at end of file
diff --git a/functional_tests/test_data_management_history_pagination.py b/functional_tests/test_data_management_history_pagination.py
index 454910add..2f0e955d4 100644
--- a/functional_tests/test_data_management_history_pagination.py
+++ b/functional_tests/test_data_management_history_pagination.py
@@ -207,8 +207,10 @@ class FakeHistoryPaginationError(ValueError):
data_management_module.DATA_MANAGEMENT_OPERATION_RESTORE = "restore"
imported_function_names = [
+ "cleanup_expired_data_management_backups",
"create_data_management_migration_review_authorization",
"create_data_management_restore_review_authorization",
+ "delete_data_management_backup",
"export_data_management_migration_manifest",
"generate_data_management_encryption_key",
"get_data_management_cosmos_editor_containers",
diff --git a/functional_tests/test_data_management_security_patterns.py b/functional_tests/test_data_management_security_patterns.py
index 06e0c6c76..996bc23c8 100644
--- a/functional_tests/test_data_management_security_patterns.py
+++ b/functional_tests/test_data_management_security_patterns.py
@@ -327,7 +327,7 @@ def test_admin_javascript_uses_safe_dom_patterns():
r"\.outerHTML\b",
r"insertAdjacentHTML\s*\(",
r"setAttribute\s*\(\s*['\"]on",
- r"javascript:",
+ r"javascript:", # xss-check: ignore - denylist literal, not rendered content.
r"\bonclick\b",
r"\bonerror\b",
r"\bonload\b",
diff --git a/functional_tests/test_document_action_conversation_scope_metadata.py b/functional_tests/test_document_action_conversation_scope_metadata.py
index 5235a6fcd..3e33bb52f 100644
--- a/functional_tests/test_document_action_conversation_scope_metadata.py
+++ b/functional_tests/test_document_action_conversation_scope_metadata.py
@@ -2,8 +2,8 @@
# test_document_action_conversation_scope_metadata.py
"""
Functional test for document-action conversation scope metadata.
-Version: 0.241.124
-Implemented in: 0.241.124
+Version: 0.250.073
+Implemented in: 0.241.124; Updated in: 0.250.073
This test ensures Analyze and tabular document-action results can assign
conversation workspace metadata from selected document summaries when no
@@ -20,7 +20,7 @@
METADATA_FILE = os.path.join(ROOT_DIR, 'application', 'single_app', 'functions_conversation_metadata.py')
ROUTE_FILE = os.path.join(ROOT_DIR, 'application', 'single_app', 'route_backend_chats.py')
CONFIG_FILE = os.path.join(ROOT_DIR, 'application', 'single_app', 'config.py')
-FIX_VERSION = '0.241.124'
+FIX_VERSION = '0.250.073'
TEST_USER_ID = 'scope-user-1'
CRIMSON_GROUP_ID = 'crimson-group-1'
PUBLIC_WORKSPACE_ID = 'public-workspace-1'
diff --git a/functional_tests/test_document_action_stream_reconnect.py b/functional_tests/test_document_action_stream_reconnect.py
index 94491352a..ca38dd59e 100644
--- a/functional_tests/test_document_action_stream_reconnect.py
+++ b/functional_tests/test_document_action_stream_reconnect.py
@@ -2,7 +2,7 @@
# test_document_action_stream_reconnect.py
"""
Functional test for document action stream reconnect support.
-Version: 0.241.023
+Version: 0.250.070
Implemented in: 0.241.090
This test ensures analysis and document comparison streaming
@@ -41,22 +41,22 @@ def test_document_action_stream_reconnect_wiring() -> None:
document_action_stream_block = slice_between(
route_content,
- "@app.route('/api/chat/document-action/stream', methods=['POST'])",
- "@app.route('/api/chat/analyze', methods=['POST'])",
+ "@bp.route('/api/chat/document-action/stream', methods=['POST'])",
+ "@bp.route('/api/chat/analyze', methods=['POST'])",
)
analyze_stream_block = slice_between(
route_content,
- "@app.route('/api/chat/analyze/stream', methods=['POST'])",
- "@app.route('/api/chat', methods=['POST'])",
+ "@bp.route('/api/chat/analyze/stream', methods=['POST'])",
+ "@bp.route('/api/chat/image-proposals/generate', methods=['POST'])",
)
- assert 'VERSION = "0.241.023"' in config_content, (
- "Expected config.py version 0.241.023 for the document action reconnect fix."
+ assert 'VERSION = "0.250.070"' in config_content, (
+ "Expected the current application version for document action reconnect coverage."
)
- assert "@app.route('/api/chat/stream/status/', methods=['GET'])" in route_content, (
+ assert "@bp.route('/api/chat/stream/status/', methods=['GET'])" in route_content, (
"Expected the shared chat stream status endpoint to exist for reconnect support."
)
- assert "@app.route('/api/chat/stream/reattach/', methods=['GET'])" in route_content, (
+ assert "@bp.route('/api/chat/stream/reattach/', methods=['GET'])" in route_content, (
"Expected the shared chat stream reattach endpoint to exist for reconnect support."
)
diff --git a/functional_tests/test_document_action_token_usage_aggregation.py b/functional_tests/test_document_action_token_usage_aggregation.py
index 12dd670e0..9c09a1dd3 100644
--- a/functional_tests/test_document_action_token_usage_aggregation.py
+++ b/functional_tests/test_document_action_token_usage_aggregation.py
@@ -1,8 +1,8 @@
# test_document_action_token_usage_aggregation.py
"""
Functional test for document action token usage aggregation.
-Version: 0.241.023
-Implemented in: 0.241.116
+Version: 0.250.115
+Implemented in: 0.241.116; updated for generated file exports in 0.250.072; updated in 0.250.073; updated for PR 1145 duplicate fixture key remediation in 0.250.115
This test ensures analysis and comparison aggregate tokens across
all internal model calls and persist the aggregate usage on assistant metadata.
@@ -12,6 +12,15 @@
import os
import uuid
+from pathlib import Path
+
+
+sys_path = str(Path(__file__).resolve().parents[1] / 'application' / 'single_app')
+if sys_path not in os.sys.path:
+ os.sys.path.insert(0, sys_path)
+
+import functions_mixed_source_orchestration as orchestration
+
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
WORKFLOW_RUNNER_PATH = os.path.join(REPO_ROOT, 'application', 'single_app', 'functions_workflow_runner.py')
@@ -119,12 +128,30 @@ def test_document_analysis_token_aggregation():
extra_globals={
'DOCUMENT_ACTION_TYPE_ANALYZE': 'analyze',
'DOCUMENT_ACTION_CONTEXT_WORKFLOW': 'workflow',
+ 'raise_if_mixed_source_cancelled': orchestration.raise_if_mixed_source_cancelled,
'get_document_action_max_documents': lambda *args, **kwargs: 10,
+ '_is_per_document_analysis_mode': lambda *args, **kwargs: False,
+ '_raise_legacy_mixed_source_analyze_limitation': lambda *args, **kwargs: None,
'_chain_activity_callbacks': lambda *callbacks: None,
'_build_document_action_activity_callback': lambda *args, **kwargs: None,
'_maybe_execute_tabular_document_action': lambda *args, **kwargs: None,
'_maybe_create_document_analysis_generated_artifacts': lambda *args, **kwargs: {'artifacts': [], 'assistant_reply': None},
+ '_reauthorize_mixed_source_workflow_result': lambda *args, **kwargs: None,
+ '_execute_mixed_source_analyze_workflow': lambda workflow, action_config, settings, invoke_prompt, **kwargs: (
+ invoke_prompt('analysis window 1', stage='window_analysis'),
+ invoke_prompt('analysis window 2', stage='reduction'),
+ {
+ 'reply': 'Aggregated analysis answer',
+ 'coverage': {
+ 'processed_windows': 2,
+ 'failed_windows': 0,
+ },
+ }
+ )[-1],
'_resolve_model_workflow_client': lambda *args, **kwargs: (fake_client, 'gpt-5.4', 'aoai'),
+ '_build_workflow_chat_messages': lambda prompt_text, **kwargs: [
+ {'role': 'user', 'content': prompt_text},
+ ],
'run_document_analysis': lambda **kwargs: (
kwargs['invoke_prompt']('analysis window 1', stage='window_analysis'),
kwargs['invoke_prompt']('analysis window 2', stage='reduction'),
@@ -193,11 +220,19 @@ def test_document_comparison_token_aggregation():
},
extra_globals={
'DOCUMENT_ACTION_TYPE_COMPARISON': 'comparison',
+ 'raise_if_mixed_source_cancelled': orchestration.raise_if_mixed_source_cancelled,
+ 'deduplicate_mixed_source_references': orchestration.deduplicate_mixed_source_references,
+ 'is_cross_format_compare_enabled': lambda settings: False,
+ '_raise_legacy_cross_format_compare_limitation': lambda *args, **kwargs: None,
'_chain_activity_callbacks': lambda *callbacks: None,
'_build_document_action_activity_callback': lambda *args, **kwargs: None,
'_maybe_execute_tabular_document_action': lambda *args, **kwargs: None,
'_maybe_create_comparison_generated_artifacts': lambda *args, **kwargs: {'artifacts': [], 'assistant_reply': None},
+ '_reauthorize_mixed_source_workflow_result': lambda *args, **kwargs: None,
'_resolve_model_workflow_client': lambda *args, **kwargs: (fake_client, 'gpt-5.4', 'aoai'),
+ '_build_workflow_chat_messages': lambda prompt_text, **kwargs: [
+ {'role': 'user', 'content': prompt_text},
+ ],
'run_document_comparison': lambda **kwargs: (
kwargs['invoke_prompt']('summary left', stage='summary'),
kwargs['invoke_prompt']('summary right', stage='summary'),
@@ -260,6 +295,10 @@ def test_workflow_assistant_persists_token_usage():
extra_globals={
'_utc_now_iso': lambda: '2025-01-01T00:00:00+00:00',
'_get_document_action_config': lambda workflow: workflow.get('document_action', {}),
+ '_get_workflow_scope': lambda workflow: 'personal',
+ '_get_workflow_group_id': lambda workflow: '',
+ '_maybe_create_workflow_generated_file_output': lambda **kwargs: None,
+ 'get_generated_file_export_content': lambda result: result.get('reply', ''),
'_persist_agent_citation_artifacts': lambda **kwargs: [],
'cosmos_messages_container': message_container,
'cosmos_conversations_container': conversation_container,
@@ -332,7 +371,7 @@ def test_version_update():
with open(CONFIG_PATH, 'r', encoding='utf-8') as handle:
content = handle.read()
- assert_in('VERSION = "0.241.023"', content, 'config version update')
+ assert_in('VERSION = "0.250.115"', content, 'config version update')
print('Version update passed.')
return True
diff --git a/functional_tests/test_document_analysis_lossless_artifacts.py b/functional_tests/test_document_analysis_lossless_artifacts.py
index c2e262dad..6d8728fe3 100644
--- a/functional_tests/test_document_analysis_lossless_artifacts.py
+++ b/functional_tests/test_document_analysis_lossless_artifacts.py
@@ -2,10 +2,12 @@
# test_document_analysis_lossless_artifacts.py
"""
Functional test for document analysis lossless artifacts.
-Version: 0.241.197
+Version: 0.250.112
Implemented in: 0.241.040
Updated in: 0.241.065
Updated in: 0.241.197
+Updated in: 0.250.065
+Updated in: 0.250.112
This test ensures exhaustive/table-style document analysis preserves raw window
outputs and can build both structured CSV rows and Markdown raw-note artifacts
@@ -21,6 +23,7 @@
import logging
import os
import re
+import sys
import traceback
from contextlib import contextmanager
from typing import Any, Callable, Dict, List, Optional
@@ -40,6 +43,13 @@
'functions_workflow_runner.py',
)
CONFIG_PATH = os.path.join(REPO_ROOT, 'application', 'single_app', 'config.py')
+APP_ROOT = os.path.join(REPO_ROOT, 'application', 'single_app')
+sys.path.append(APP_ROOT)
+
+from functions_assistant_table_exports import ( # noqa: E402
+ build_safe_csv_headers,
+ neutralize_csv_spreadsheet_formula,
+)
def assert_equal(actual, expected, label):
@@ -67,6 +77,7 @@ def load_module_functions(file_path, extra_globals=None):
namespace = {
'__builtins__': __builtins__,
'Any': Any,
+ 'build_safe_csv_headers': build_safe_csv_headers,
'Callable': Callable,
'Dict': Dict,
'List': List,
@@ -76,8 +87,10 @@ def load_module_functions(file_path, extra_globals=None):
'io': io,
'json': json,
'logging': logging,
+ 'neutralize_csv_spreadsheet_formula': neutralize_csv_spreadsheet_formula,
'os': os,
're': re,
+ 'WORKFLOW_TASK_CONTEXT_MAX_CHARS': 12000,
}
if extra_globals:
namespace.update(extra_globals)
@@ -183,6 +196,7 @@ def get_document_chunks_payload(document_id, **_kwargs):
'debug_print': lambda *args, **kwargs: None,
'normalize_search_id_list': lambda value: list(value or []),
'normalize_search_scope': lambda value: str(value or 'all').strip() or 'all',
+ 'raise_if_mixed_source_cancelled': lambda *args, **kwargs: None,
},
)
namespace['_get_search_service_helpers'] = lambda: (
@@ -297,6 +311,7 @@ def fake_upload_generated_artifact(**kwargs):
'DOCUMENT_ANALYSIS_ARTIFACT_PREVIEW_LINE_LENGTH': 220,
'debug_print': lambda *args, **kwargs: None,
'has_request_context': lambda: True,
+ 'raise_if_mixed_source_cancelled': lambda *args, **kwargs: None,
'upload_generated_analysis_artifact_for_current_user': fake_upload_generated_artifact,
},
)
@@ -391,6 +406,7 @@ def fake_upload_generated_artifact(**kwargs):
'DOCUMENT_ANALYSIS_ARTIFACT_PREVIEW_LINE_LENGTH': 220,
'debug_print': lambda *args, **kwargs: None,
'has_request_context': lambda: True,
+ 'raise_if_mixed_source_cancelled': lambda *args, **kwargs: None,
'upload_generated_analysis_artifact_for_current_user': fake_upload_generated_artifact,
},
)
@@ -447,9 +463,30 @@ def fake_upload_generated_artifact(**kwargs):
print('JSON artifact opt-in behavior verified.')
+def test_workflow_markdown_fence_parser_is_linear_and_compatible():
+ print('Testing workflow Markdown fence parser compatibility...')
+
+ namespace = load_module_functions(WORKFLOW_RUNNER_PATH)
+ strip_fence = namespace['_strip_markdown_code_fence']
+ parse_json = namespace['_parse_json_artifact_payload']
+
+ assert_equal(parse_json('```json\n{"rows": [1]}\n```'), {'rows': [1]}, 'fenced JSON payload')
+ assert_equal(parse_json('```\n{"rows": [2]}\n```'), {'rows': [2]}, 'unlabeled fenced JSON payload')
+ assert_equal(parse_json('```json{"rows": [3]}```'), {'rows': [3]}, 'same-line fenced JSON payload')
+ assert_equal(parse_json('{"rows": [4]}'), {'rows': [4]}, 'unfenced JSON payload')
+ assert_equal(strip_fence('```foo bar\nbody\n```'), 'bar\nbody', 'label token with body text')
+
+ unterminated_text = '```json\n{"rows": [5]}'
+ assert_equal(strip_fence(unterminated_text), unterminated_text, 'unterminated fence remains unchanged')
+
+ adversarial_text = f'```json{" \t" * 1000}{{"rows": [6]}}{" \t" * 1000}```'
+ assert_equal(parse_json(adversarial_text), {'rows': [6]}, 'adversarial whitespace fenced JSON payload')
+ print('Workflow Markdown fence parser compatibility verified.')
+
+
def test_version_alignment():
print('Testing version alignment...')
- assert_equal(read_config_version(), '0.241.197', 'config version')
+ assert_equal(read_config_version(), '0.250.112', 'config version')
print('Version alignment verified.')
@@ -459,6 +496,7 @@ def run_tests():
test_lossless_artifact_helpers_build_csv_and_markdown,
test_primary_tabular_output_demotes_secondary_artifacts,
test_json_artifact_requires_explicit_json_request,
+ test_workflow_markdown_fence_parser_is_linear_and_compatible,
test_version_alignment,
]
results = []
diff --git a/functional_tests/test_document_analysis_structured_output.py b/functional_tests/test_document_analysis_structured_output.py
index d8c75da52..e2e9b7e4c 100644
--- a/functional_tests/test_document_analysis_structured_output.py
+++ b/functional_tests/test_document_analysis_structured_output.py
@@ -1,8 +1,9 @@
# test_document_analysis_structured_output.py
"""
Functional test for analysis structured output preservation.
-Version: 0.241.023
+Version: 0.250.112
Implemented in: 0.241.117
+Updated in: 0.250.112
This test ensures document analysis preserves one structured JSON
result per analyzed document instead of making a lossy global reduction call
@@ -263,6 +264,7 @@ def get_document_chunks_payload(document_id, **_kwargs):
'debug_print': lambda *args, **kwargs: None,
'normalize_search_id_list': lambda value: list(value or []),
'normalize_search_scope': lambda value: str(value or 'all').strip() or 'all',
+ 'raise_if_mixed_source_cancelled': lambda *args, **kwargs: None,
},
)
namespace['_get_search_service_helpers'] = lambda: (
@@ -314,7 +316,7 @@ def get_document_chunks_payload(document_id, **_kwargs):
def test_version_alignment():
print('Testing version alignment...')
- assert_equal(read_config_version(), '0.241.023', 'config version')
+ assert_equal(read_config_version(), '0.250.112', 'config version')
print('Version alignment passed.')
return True
diff --git a/functional_tests/test_document_search_api_and_plugin.py b/functional_tests/test_document_search_api_and_plugin.py
index e6fe84a02..ed4281ea8 100644
--- a/functional_tests/test_document_search_api_and_plugin.py
+++ b/functional_tests/test_document_search_api_and_plugin.py
@@ -66,7 +66,7 @@ def test_functions_search_contract():
return False
required_snippets = [
- 'SEARCH_DEFAULT_TOP_N = 12',
+ 'SEARCH_DEFAULT_TOP_N = 50',
'SEARCH_MAX_TOP_N = 500',
'"document_id": r.get("document_id")',
'select=get_search_select_fields("personal")',
diff --git a/functional_tests/test_foundry_citation_thoughts.py b/functional_tests/test_foundry_citation_thoughts.py
new file mode 100644
index 000000000..2af909a03
--- /dev/null
+++ b/functional_tests/test_foundry_citation_thoughts.py
@@ -0,0 +1,179 @@
+# test_foundry_citation_thoughts.py
+#!/usr/bin/env python3
+"""
+Functional test for Foundry citation thought display.
+Version: 0.250.114
+Implemented in: 0.250.114
+
+This test ensures Foundry citation thoughts use each citation value safely instead
+of emitting duplicate generic messages for every citation.
+"""
+
+import ast
+import sys
+import traceback
+from pathlib import Path
+from urllib.parse import urlparse
+
+
+ROOT = Path(__file__).resolve().parents[1]
+APP_ROOT = ROOT / 'application' / 'single_app'
+ROUTE_FILE = APP_ROOT / 'route_backend_chats.py'
+CONFIG_FILE = APP_ROOT / 'config.py'
+EXPECTED_VERSION = '0.250.114'
+
+TARGET_NAMES = {
+ 'FOUNDRY_AGENT_LABELS',
+ 'FOUNDRY_CITATION_DISPLAY_FIELDS',
+ 'FOUNDRY_CITATION_NESTED_FIELDS',
+ 'FOUNDRY_CITATION_URL_FIELDS',
+ '_build_foundry_citation_thought_content',
+ '_get_foundry_agent_label',
+ '_get_foundry_citation_display_label',
+ '_get_foundry_citation_url_label',
+ '_iter_foundry_citation_sources',
+ '_normalize_foundry_citation_display_text',
+}
+
+
+def read_text(path):
+ """Read a UTF-8 source file."""
+ return path.read_text(encoding='utf-8')
+
+
+def read_current_version():
+ """Return the current app version from config.py."""
+ for line in read_text(CONFIG_FILE).splitlines():
+ stripped_line = line.strip()
+ if stripped_line.startswith('VERSION = '):
+ return stripped_line.split('"')[1]
+ raise AssertionError('Expected config.py to define VERSION')
+
+
+def load_foundry_citation_helpers():
+ """Load only the Foundry citation helper definitions from the chat route."""
+ route_content = read_text(ROUTE_FILE)
+ parsed = ast.parse(route_content, filename=str(ROUTE_FILE))
+ selected_nodes = []
+ for node in parsed.body:
+ if isinstance(node, ast.Assign):
+ target_names = {
+ target.id
+ for target in node.targets
+ if isinstance(target, ast.Name)
+ }
+ if target_names & TARGET_NAMES:
+ selected_nodes.append(node)
+ elif isinstance(node, ast.FunctionDef) and node.name in TARGET_NAMES:
+ selected_nodes.append(node)
+
+ namespace = {'urlparse': urlparse}
+ exec(compile(ast.Module(body=selected_nodes, type_ignores=[]), str(ROUTE_FILE), 'exec'), namespace)
+ return namespace, route_content
+
+
+def test_version_matches_fix_header():
+ """Validate the functional test header tracks the current app version."""
+ print('Testing version header...')
+ assert read_current_version() == EXPECTED_VERSION
+ print('PASS: version header')
+
+
+def test_foundry_citation_thoughts_use_safe_display_values():
+ """Validate citation-specific thoughts are useful without exposing raw payloads."""
+ print('Testing Foundry citation thought labels...')
+ helpers, _ = load_foundry_citation_helpers()
+ build_thought = helpers['_build_foundry_citation_thought_content']
+
+ cases = [
+ (
+ 'new_foundry',
+ {'title': ' Mission Report '},
+ 'Agent retrieved citation from New Foundry Application: Mission Report',
+ ),
+ (
+ 'foundry_workflow',
+ {'metadata': {'file_name': 'analysis-summary.docx'}},
+ 'Agent retrieved citation from Foundry Workflow: analysis-summary.docx',
+ ),
+ (
+ 'aifoundry',
+ {'url': 'https://contoso.example/reports/mission.pdf?sig=secret-token'},
+ 'Agent retrieved citation from Azure AI Foundry Agent: contoso.example',
+ ),
+ (
+ 'aifoundry',
+ {'url': 'https://user:token@contoso.example:8443/reports/mission.pdf'},
+ 'Agent retrieved citation from Azure AI Foundry Agent: contoso.example',
+ ),
+ (
+ 'new_foundry',
+ {'quote': 'Do not surface raw quoted text in the thought stream.'},
+ 'Agent retrieved citation from New Foundry Application',
+ ),
+ (
+ 'foundry_workflow',
+ 'raw string citation payload',
+ 'Agent retrieved citation from Foundry Workflow',
+ ),
+ ]
+
+ for agent_type, citation, expected in cases:
+ actual = build_thought(agent_type, citation)
+ assert actual == expected, f'Expected {expected!r}, got {actual!r}'
+
+ unsafe_title = build_thought('new_foundry', {'title': ''})
+ forbidden_fragments = ['