diff --git a/application/single_app/config.py b/application/single_app/config.py index 2ae26af44..94954c66f 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -96,7 +96,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.250.196" +VERSION = "0.250.200" IS_DEVELOPMENT = is_development_env_enabled() SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax') diff --git a/application/single_app/functions_document_analysis.py b/application/single_app/functions_document_analysis.py index 7900b8175..c9eb17197 100644 --- a/application/single_app/functions_document_analysis.py +++ b/application/single_app/functions_document_analysis.py @@ -452,14 +452,19 @@ def _prompt_requests_per_source_output(analysis_prompt): source_output_markers = ( 'one object per comment', 'one row per comment', + 'one line per comment', 'one object per submission', 'one row per submission', + 'one line per submission', 'one object per document', 'one row per document', + 'one line per document', 'one object per source', 'one row per source', + 'one line per source', 'each object must contain', 'each row must contain', + 'each line must contain', 'exactly these fields', 'treat each standalone document as one comment', ) diff --git a/application/single_app/functions_settings.py b/application/single_app/functions_settings.py index 903c02c18..ce864e130 100644 --- a/application/single_app/functions_settings.py +++ b/application/single_app/functions_settings.py @@ -1027,9 +1027,51 @@ def _apply_tabular_parity_env_kill_switch(settings_payload): settings_payload['tabular_request_planner_mode'] = 'off' settings_payload['enable_tabular_search_shared_preflight'] = False settings_payload['enable_tabular_analyze_durable_preflight'] = False + settings_payload['enable_tabular_hierarchical_analysis'] = False return settings_payload +# Backend-only tabular durable-preflight parity flags that ship "active" by default with no +# admin UI toggle. The only sanctioned way to disable them is the +# SIMPLECHAT_DISABLE_TABULAR_PARITY_DURABLE_PREFLIGHT environment kill switch (applied later, +# dynamically, in _apply_tabular_parity_env_kill_switch()) -- never a persisted settings value. +TABULAR_PARITY_DURABLE_PREFLIGHT_ACTIVE_DEFAULTS = { + 'tabular_request_planner_mode': 'active', + 'enable_tabular_search_shared_preflight': True, + 'enable_tabular_analyze_durable_preflight': True, + 'enable_tabular_hierarchical_analysis': True, +} + + +def normalize_tabular_parity_durable_preflight_defaults(settings): + """Upgrade stale persisted tabular durable-preflight parity flags to their active defaults. + + deep_merge_dicts() only fills in keys that are *missing* from a persisted settings + document; it never overwrites a key that already exists. These four flags were + originally introduced with off/False defaults, so the first settings load in any + existing deployment permanently persisted the old off/False values to Cosmos DB. + Later raising the code-level default to active/True (see + TABULAR_PARITY_DURABLE_PREFLIGHT_ACTIVE_DEFAULTS) therefore had no effect for any + deployment whose settings document already had these keys -- every tabular Analyze/ + Search request kept silently falling back to the legacy bounded foreground path. + + Because these settings have no admin UI, any stored value that differs from the + active default can only be stale drift (never an intentional admin choice), so it is + safe to unconditionally correct it here on every load. This runs independently of the + env kill switch, which is still applied afterwards in _apply_tabular_parity_env_kill_switch() + and continues to work exactly as before. + """ + if not isinstance(settings, dict): + return False + + changed = False + for key, active_value in TABULAR_PARITY_DURABLE_PREFLIGHT_ACTIVE_DEFAULTS.items(): + if settings.get(key) != active_value: + settings[key] = active_value + changed = True + return changed + + def get_settings(use_cosmos=False, include_source=False): import secrets default_settings = { @@ -1054,7 +1096,7 @@ def get_settings(use_cosmos=False, include_source=False): 'enable_tabular_processing_plugin': False, 'enable_analysis_deliverable_contract_telemetry': False, 'analysis_deliverable_contract_mode': 'off', - 'enable_tabular_hierarchical_analysis': False, + 'enable_tabular_hierarchical_analysis': True, 'enable_tabular_parity_contract_telemetry': False, 'tabular_parity_contract_mode': 'off', 'tabular_hierarchical_analysis_reduce_fan_in': 25, @@ -1745,6 +1787,7 @@ def _format_result(settings_payload, source): inbound_mcp_settings_updated = normalize_inbound_mcp_settings(merged) public_workspace_display_settings_updated = normalize_public_workspace_display_settings(merged) key_vault_reminder_settings_updated = normalize_key_vault_reminder_settings(merged) + tabular_parity_durable_preflight_settings_updated = normalize_tabular_parity_durable_preflight_defaults(merged) merged['enable_tabular_processing_plugin'] = is_tabular_processing_enabled(merged) @@ -1759,6 +1802,7 @@ def _format_result(settings_payload, source): or inbound_mcp_settings_updated or public_workspace_display_settings_updated or key_vault_reminder_settings_updated + or tabular_parity_durable_preflight_settings_updated ): cosmos_settings_container.upsert_item(merged) _refresh_app_settings_cache_after_write(merged, context="merge_upsert") diff --git a/application/single_app/functions_tabular_generated_exports.py b/application/single_app/functions_tabular_generated_exports.py index 9f147650e..44031a5ae 100644 --- a/application/single_app/functions_tabular_generated_exports.py +++ b/application/single_app/functions_tabular_generated_exports.py @@ -7051,6 +7051,68 @@ def _build_tabular_run_rollout_assignment_public_fields(run): } +def _build_safe_tabular_run_failure(run): + """Return a stable client-safe failure category without exposing provider details.""" + run = run if isinstance(run, dict) else {} + error_text = str(run.get('last_error') or '').strip().lower() + retry_category = str(run.get('last_retry_category') or '').strip().lower() + artifact_manifest = run.get('artifact_set_manifest') if isinstance(run.get('artifact_set_manifest'), dict) else {} + artifact_lifecycle = str(artifact_manifest.get('lifecycle_state') or '').strip().lower() + + if artifact_lifecycle in { + TABULAR_ARTIFACT_SET_LIFECYCLE_ROLLBACK_REQUIRED, + TABULAR_ARTIFACT_SET_LIFECYCLE_FAILED, + } or 'artifact set failed publication validation' in error_text: + return { + 'failure_code': 'artifact_publication_failed', + 'failure_detail': 'The analysis finished, but its downloadable artifact could not be published.', + } + if 'deploymentnotfound' in error_text or 'api deployment for this resource does not exist' in error_text: + return { + 'failure_code': 'model_deployment_unavailable', + 'failure_detail': 'The selected model deployment is unavailable. Select another model or ask an administrator to verify the model endpoint.', + } + if retry_category == 'rate_limit' or 'http_429' in error_text or 'rate limit' in error_text: + return { + 'failure_code': 'model_rate_limited', + 'failure_detail': 'The model service is temporarily rate limited. Continue the run after a short wait.', + } + if retry_category == 'timeout' or 'timed out' in error_text or 'timeout' in error_text: + return { + 'failure_code': 'model_timeout', + 'failure_detail': 'The model service did not finish this batch before the processing timeout.', + } + if 'authorization' in error_text or 'permission' in error_text or 'access is no longer authorized' in error_text: + return { + 'failure_code': 'source_access_changed', + 'failure_detail': 'Access to the selected source changed before processing completed.', + } + if 'source csv changed' in error_text or 'source etag' in error_text or 'source version' in error_text: + return { + 'failure_code': 'source_changed', + 'failure_detail': 'The selected source changed during processing. Start a new run against the latest version.', + } + if ( + retry_category == 'model_validation' + or 'schema' in error_text + or 'validation' in error_text + or 'valid combined output' in error_text + ): + return { + 'failure_code': 'output_validation_failed', + 'failure_detail': 'The generated output did not satisfy the required row and artifact structure.', + } + if retry_category in {'transient', 'connection', 'provider_transient'}: + return { + 'failure_code': 'model_service_interrupted', + 'failure_detail': 'The model service was interrupted while processing the background run.', + } + return { + 'failure_code': 'background_processing_failed', + 'failure_detail': 'The background analysis could not be completed.', + } + + def _build_run_status_detail(run, settings, retryable_failure, can_resume): status = str((run or {}).get('status') or '').strip().lower() task_type = _normalize_tabular_run_task_type((run or {}).get('task_type')) @@ -7258,14 +7320,11 @@ def retry_reason_text(): 'retry_delay_seconds': None, } if status == TABULAR_EXPORT_STATUS_FAILED: + safe_failure = _build_safe_tabular_run_failure(run) return { 'status_label': 'Failed', 'status_tone': 'danger', - 'status_detail': ( - 'Analysis failed and cannot continue from checkpoints.' - if is_analysis_like - else 'Export failed and cannot continue from checkpoints.' - ), + 'status_detail': safe_failure['failure_detail'], 'is_stale': False, 'waiting_for_retry': False, 'retry_due': False, @@ -7323,6 +7382,33 @@ def _build_run_public_status(run, settings=None): rollout_assignment = _build_tabular_run_rollout_assignment_public_fields(run) checkpoint_summary = _build_checkpoint_summary(completed_batches, batch_count, processed_rows, row_count) artifact_set_manifest = _build_or_update_artifact_set_manifest(run) + safe_failure = _build_safe_tabular_run_failure({ + **run, + 'artifact_set_manifest': artifact_set_manifest, + }) + artifact_publication_incomplete = bool( + str(run.get('status') or '').strip().lower() == TABULAR_EXPORT_STATUS_COMPLETED + and artifact_set_manifest.get('lifecycle_state') != TABULAR_ARTIFACT_SET_LIFECYCLE_COMPLETED + ) + public_status = run.get('status') + if artifact_publication_incomplete: + public_status = TABULAR_EXPORT_STATUS_FAILED + status_detail = { + 'status_label': 'Failed', + 'status_tone': 'danger', + 'status_detail': safe_failure['failure_detail'], + 'is_stale': False, + 'waiting_for_retry': False, + 'retry_due': False, + 'retry_delay_seconds': None, + } + lifecycle_fields.update({ + 'lifecycle_state': 'intervention_required', + 'execution_state': 'intervention_required', + 'evidence_status': 'failed', + 'terminal': True, + 'safe_reason_code': safe_failure['failure_code'], + }) generated_artifacts = _build_public_generated_artifacts_from_manifest(run, artifact_set_manifest) generated_artifact = generated_artifacts[0] if generated_artifacts else None primary_final_artifact = generated_artifact or ( @@ -7352,7 +7438,7 @@ def _build_run_public_status(run, settings=None): 'run_id': run.get('id'), 'conversation_id': run.get('conversation_id'), 'task_type': task_type, - 'status': run.get('status'), + 'status': public_status, 'metadata_contract_version': 'phase8.v1', 'planner_contract_version': planner_metadata.get('planner_contract_version'), 'execution_contract': planner_metadata.get('execution_contract') or task_type, @@ -7402,10 +7488,20 @@ def _build_run_public_status(run, settings=None): 'updated_at': run.get('updated_at'), 'completed_at': run.get('completed_at'), 'last_heartbeat_at': run.get('last_heartbeat_at'), - 'last_message': run.get('last_message'), + 'last_message': status_detail.get('status_detail'), 'status_label': status_detail.get('status_label'), 'status_tone': status_detail.get('status_tone'), 'status_detail': status_detail.get('status_detail'), + 'failure_code': ( + safe_failure['failure_code'] + if public_status == TABULAR_EXPORT_STATUS_FAILED + else None + ), + 'failure_detail': ( + safe_failure['failure_detail'] + if public_status == TABULAR_EXPORT_STATUS_FAILED + else None + ), 'checkpoint_summary': checkpoint_summary, 'is_stale': status_detail.get('is_stale'), 'waiting_for_retry': status_detail.get('waiting_for_retry'), @@ -7501,6 +7597,10 @@ def get_tabular_generated_output_run_status(user_id, run_id): ) except CosmosResourceNotFoundError: return None + # Legacy completion repair is a bounded, idempotent status-read exception: + # the partition-authorized run already owns the uploaded artifact, and the + # missing publication commit is the only mutation performed. + run = _reconcile_completed_tabular_artifact_set(run) return _build_run_public_status(run, settings=settings) @@ -7862,6 +7962,7 @@ def _mark_run_failed(run, error_message): 'last_error': str(error_message or 'Unknown error')[:1000], 'last_message': 'Background structured export failed', }) + run['failure_code'] = _build_safe_tabular_run_failure(run)['failure_code'] run['performance_summary'] = _build_tabular_generation_performance_summary(run, completed_at=now) try: run = _replace_claimed_run(run) @@ -8381,7 +8482,22 @@ def _get_tabular_run_deliverable_contract(run): deliverable_contract = planner_metadata.get('deliverable_contract') if not isinstance(deliverable_contract, dict): return {} - return deliverable_contract + normalized_contract = dict(deliverable_contract) + if not list(normalized_contract.get('requested_artifacts') or []): + # Every durable task publishes at least one artifact; empty lists only + # occur on legacy or malformed durable contracts. + task_type = _normalize_tabular_run_task_type((run or {}).get('task_type')) + normalized_contract['requested_artifacts'] = _default_artifact_descriptors_for_run(run) + normalized_contract['analysis_required'] = task_type in { + TABULAR_RUN_TASK_HIERARCHICAL_ANALYSIS, + TABULAR_RUN_TASK_COMBINED, + } + normalized_contract['primary_artifact_role'] = ( + ANALYSIS_ARTIFACT_ROLE_PRIMARY_ANALYSIS + if normalized_contract['analysis_required'] + else ANALYSIS_ARTIFACT_ROLE_REQUESTED_OUTPUT + ) + return normalized_contract def _normalize_artifact_descriptor(raw_descriptor, fallback_order=0): @@ -8660,6 +8776,11 @@ def _build_or_update_artifact_set_manifest(run): ) if member.get('role') == ANALYSIS_ARTIFACT_ROLE_PRIMARY_ANALYSIS: artifact = run.get('analysis_artifact') if isinstance(run.get('analysis_artifact'), dict) else {} + if ( + not artifact + and _normalize_tabular_run_task_type(run.get('task_type')) == TABULAR_RUN_TASK_HIERARCHICAL_ANALYSIS + ): + artifact = run.get('final_artifact') if isinstance(run.get('final_artifact'), dict) else {} _merge_artifact_metadata_into_member( member, artifact, @@ -8828,6 +8949,48 @@ def _publish_artifact_set_members(run, published_member_ids): return manifest +def _reconcile_completed_tabular_artifact_set(run): + """Repair completed legacy runs whose uploaded artifacts were never committed.""" + run = run if isinstance(run, dict) else {} + if str(run.get('status') or '').strip().lower() != TABULAR_EXPORT_STATUS_COMPLETED: + return run + + existing_manifest = run.get('artifact_set_manifest') if isinstance(run.get('artifact_set_manifest'), dict) else {} + if existing_manifest.get('lifecycle_state') == TABULAR_ARTIFACT_SET_LIFECYCLE_COMPLETED: + return run + + manifest = _build_or_update_artifact_set_manifest(run) + published_member_ids = [ + member.get('member_id') + for member in list(manifest.get('members') or []) + if member.get('member_id') and member.get('artifact_message_id') + ] + if not published_member_ids: + return run + + reconciled_manifest = _publish_artifact_set_members(run, published_member_ids) + if reconciled_manifest.get('lifecycle_state') != TABULAR_ARTIFACT_SET_LIFECYCLE_COMPLETED: + return run + + try: + repaired_run = _replace_run(run) + except Exception as exc: + if getattr(exc, 'status_code', None) not in (409, 412): + raise + repaired_run = _read_run(run.get('user_id'), run.get('id')) + log_event( + '[TABULAR_GENERATED_OUTPUT] Reconciled completed artifact-set publication', + { + 'run_id': repaired_run.get('id'), + 'conversation_id': repaired_run.get('conversation_id'), + 'user_id': repaired_run.get('user_id'), + 'published_member_ids': published_member_ids, + }, + level=logging.INFO, + ) + return repaired_run + + def _build_public_generated_artifact_from_member(run, manifest, member): if not isinstance(member, dict) or not member.get('artifact_message_id'): return None @@ -9013,10 +9176,7 @@ def _complete_run(run): structured_artifact = structured_artifacts[0] if structured_artifacts else {} now = _now_iso() run.update({ - 'status': TABULAR_EXPORT_STATUS_COMPLETED, 'updated_at': now, - 'completed_at': now, - 'generation_completed_at': now, 'last_heartbeat_at': now, 'processed_rows': output_entry_count, 'completed_batches': _safe_int(run.get('batch_count')), @@ -9030,10 +9190,17 @@ def _complete_run(run): 'final_artifact': structured_artifact, 'estimated_remaining_seconds': 0, }) - _publish_artifact_set_members( + artifact_set_manifest = _publish_artifact_set_members( run, [artifact.get('artifact_id') or artifact.get('member_id') for artifact in structured_artifacts], ) + if artifact_set_manifest.get('lifecycle_state') != TABULAR_ARTIFACT_SET_LIFECYCLE_COMPLETED: + raise ValueError('Generated artifact set failed publication validation') + run.update({ + 'status': TABULAR_EXPORT_STATUS_COMPLETED, + 'completed_at': now, + 'generation_completed_at': now, + }) run.update(_build_generation_progress_contract_fields( run, run.get('batch_count'), @@ -9177,14 +9344,25 @@ def _publish_analysis_artifact(run, final_summary): def _complete_analysis_run(run, final_summary): run, uploaded_message, final_summary, generated_file_name = _publish_analysis_artifact(run, final_summary) artifact_preview_text = _build_analysis_summary_markdown(run, final_summary) + analysis_artifact = _build_artifact_metadata( + uploaded_message, + generated_file_name, + 'md', + preview_text=artifact_preview_text, + suppress_assistant_text=True, + ) + _set_artifact_set_member_state( + run, + _get_analysis_artifact_member_id(run), + artifact=analysis_artifact, + lifecycle_state=TABULAR_ARTIFACT_MEMBER_LIFECYCLE_STAGED, + validation_state='validated', + ) now = _now_iso() run.update({ - 'status': TABULAR_EXPORT_STATUS_COMPLETED, 'updated_at': now, - 'completed_at': now, - 'generation_completed_at': now, 'last_heartbeat_at': now, - 'analysis_phase': 'completed', + 'analysis_phase': 'publishing', 'processed_rows': _safe_int(final_summary.get('row_count'), default=_safe_int(run.get('row_count'))), 'completed_batches': _safe_int(run.get('batch_count')), 'processed_chunk_count': _safe_int(run.get('batch_count')), @@ -9193,16 +9371,20 @@ def _complete_analysis_run(run, final_summary): 'post_run_summary': final_summary.get('summary'), 'generated_file_name': uploaded_message.get('file_name') or generated_file_name, 'output_format': 'md', - 'final_artifact': _build_artifact_metadata( - uploaded_message, - generated_file_name, - 'md', - preview_text=artifact_preview_text, - suppress_assistant_text=True, - ), + 'analysis_artifact': analysis_artifact, + 'final_artifact': analysis_artifact, 'estimated_remaining_seconds': 0, }) - _publish_artifact_set_members(run, [_get_analysis_artifact_member_id(run)]) + artifact_set_manifest = _publish_artifact_set_members(run, [_get_analysis_artifact_member_id(run)]) + if artifact_set_manifest.get('lifecycle_state') != TABULAR_ARTIFACT_SET_LIFECYCLE_COMPLETED: + raise ValueError('Generated analysis artifact failed publication validation') + run.update({ + 'status': TABULAR_EXPORT_STATUS_COMPLETED, + 'completed_at': now, + 'generation_completed_at': now, + 'analysis_phase': 'completed', + 'last_message': 'Background tabular analysis completed', + }) run.update(_build_generation_progress_contract_fields( run, run.get('batch_count'), @@ -9321,12 +9503,9 @@ def _complete_combined_analysis_run(run, final_summary): ) now = _now_iso() run.update({ - 'status': TABULAR_EXPORT_STATUS_COMPLETED, 'updated_at': now, - 'completed_at': now, - 'generation_completed_at': now, 'last_heartbeat_at': now, - 'analysis_phase': 'completed', + 'analysis_phase': 'publishing', 'processed_rows': _safe_int(final_summary.get('row_count'), default=_safe_int(run.get('row_count'))), 'completed_batches': _safe_int(run.get('batch_count')), 'processed_chunk_count': _safe_int(run.get('batch_count')), @@ -9343,7 +9522,7 @@ def _complete_combined_analysis_run(run, final_summary): 'final_artifact': analysis_artifact or structured_artifact, 'estimated_remaining_seconds': 0, }) - _publish_artifact_set_members( + artifact_set_manifest = _publish_artifact_set_members( run, [ _get_analysis_artifact_member_id(run), @@ -9353,6 +9532,15 @@ def _complete_combined_analysis_run(run, final_summary): ], ], ) + if artifact_set_manifest.get('lifecycle_state') != TABULAR_ARTIFACT_SET_LIFECYCLE_COMPLETED: + raise ValueError('Generated combined artifact set failed publication validation') + run.update({ + 'status': TABULAR_EXPORT_STATUS_COMPLETED, + 'completed_at': now, + 'generation_completed_at': now, + 'analysis_phase': 'completed', + 'last_message': 'Background combined tabular analysis and export completed', + }) run.update(_build_generation_progress_contract_fields( run, run.get('batch_count'), @@ -10322,6 +10510,8 @@ def _process_combined_run( ) ) _raise_if_tabular_export_canceled(run) + if generation_error and not generated_results: + raise generation_error batch_results.update(_checkpoint_combined_batch_results(run, generated_results)) previous_completed_batches = completed_batches diff --git a/application/single_app/functions_tabular_orchestration.py b/application/single_app/functions_tabular_orchestration.py index bafb7308a..298a91360 100644 --- a/application/single_app/functions_tabular_orchestration.py +++ b/application/single_app/functions_tabular_orchestration.py @@ -244,6 +244,11 @@ def question_requests_tabular_generated_output(user_question): "every row", "for each row", "for every row", + "all lines", + "every line", + "for each line", + "for every line", + "line by line", "full json", "full csv", "full xml", @@ -258,9 +263,11 @@ def question_requests_tabular_generated_output(user_question): "populate", "one object per", "one row per", + "one line per", "one output row per", "each object", "each row", + "each line", ) if requested_format == "csv" and assistant_table_export_requested(user_question): return True @@ -280,6 +287,12 @@ def question_requests_tabular_hierarchical_analysis(user_question): "each row", "for each row", "for every row", + "all lines", + "every line", + "each line", + "for each line", + "for every line", + "line by line", "entire dataset", "entire file", "whole dataset", @@ -639,6 +652,13 @@ def plan_tabular_request( deliverable_contract = build_analysis_deliverable_contract( action_mode=action_mode, requested_output_formats=requested_output_formats, + analysis_required=( + normalized_action_mode == "analyze" + or durable_task_type in { + TABULAR_RUN_TASK_HIERARCHICAL_ANALYSIS, + TABULAR_RUN_TASK_COMBINED, + } + ), public_output_schema=( output_hints.get("public_output_schema") or output_hints.get("output_schema") diff --git a/application/single_app/functions_tabular_parity_contract.py b/application/single_app/functions_tabular_parity_contract.py index 9e2212119..68552f2dd 100644 --- a/application/single_app/functions_tabular_parity_contract.py +++ b/application/single_app/functions_tabular_parity_contract.py @@ -201,16 +201,23 @@ def _question_requests_structured_artifact(normalized_question, output_format): ) structured_markers = ( 'one row per', + 'one line per', 'one object per', 'each row', 'every row', + 'each line', + 'every line', 'for each row', 'for every row', + 'for each line', + 'for every line', 'all rows', + 'all lines', 'all records', 'full dataset', 'entire dataset', 'no omissions', + 'line by line', ) format_phrase_patterns = ( rf'\bas\s+(?:a\s+)?{re.escape(output_format)}\b', @@ -231,6 +238,12 @@ def _question_requests_full_source(normalized_question): 'each row', 'for each row', 'for every row', + 'all lines', + 'every line', + 'each line', + 'for each line', + 'for every line', + 'line by line', 'all records', 'every record', 'each record', diff --git a/application/single_app/functions_workflow_runner.py b/application/single_app/functions_workflow_runner.py index f48d0f764..79edf69f9 100644 --- a/application/single_app/functions_workflow_runner.py +++ b/application/single_app/functions_workflow_runner.py @@ -1373,6 +1373,13 @@ def _maybe_create_document_analysis_generated_artifacts( return {'artifacts': [], 'assistant_reply': None} primary_tabular_outputs_pending = _primary_tabular_generated_outputs_are_pending(primary_tabular_outputs) + pure_tabular_durable_handoff = bool( + primary_tabular_outputs + and isinstance(analysis_result.get('tabular_preflight_result'), dict) + ) + if pure_tabular_durable_handoff: + return {'artifacts': [], 'assistant_reply': None} + if create_lossless_artifacts: artifacts = [] structured_rows = _build_document_analysis_structured_rows(analysis_result) @@ -2710,6 +2717,11 @@ def _maybe_execute_pure_tabular_analyze_preflight( gpt_model = _resolve_tabular_document_action_model_name(workflow, settings) if not user_id or not gpt_model: return None + model_context = _build_workflow_model_context( + workflow, + gpt_model, + workflow.get('model_provider'), + ) file_contexts = build_tabular_file_contexts_from_manifest(tabular_sources) if len(file_contexts) != 1: @@ -2749,6 +2761,7 @@ def publish_post_processing_thought(thought_payload): user_id=user_id, conversation_id=conversation_id, gpt_model=gpt_model, + model_context=model_context, thought_callback=publish_post_processing_thought, cancel_requested=cancel_requested, request_correlation_id=request_correlation_id, @@ -7413,6 +7426,7 @@ def _workflow_model_chat_capabilities_enabled(workflow): def _build_workflow_model_context(workflow, deployment_name, provider): + """Build non-secret model selection identifiers for deferred execution.""" workflow = workflow if isinstance(workflow, dict) else {} binding_summary = workflow.get('model_binding_summary') if isinstance(workflow.get('model_binding_summary'), dict) else {} endpoint_id = str(workflow.get('model_endpoint_id') or binding_summary.get('endpoint_id') or '').strip() diff --git a/application/single_app/route_backend_chats.py b/application/single_app/route_backend_chats.py index bd314cf7f..dd1df3ca5 100644 --- a/application/single_app/route_backend_chats.py +++ b/application/single_app/route_backend_chats.py @@ -4009,6 +4009,9 @@ def question_requests_attachment_backed_row_follow_up(user_question): 'each row', 'every row', 'per row', + 'each line', + 'every line', + 'per line', 'each comment', 'every comment', 'per comment', @@ -5036,7 +5039,9 @@ def question_requests_tabular_structured_object_output(user_question): 'one object per comment', 'one json object per comment', 'one object per row', + 'one object per line', 'one row per comment', + 'one line per comment', 'one object per submission', 'one object for each row', 'one output row for each source row', @@ -5046,9 +5051,15 @@ def question_requests_tabular_structured_object_output(user_question): 'exactly one output row', 'for each row', 'for every row', + 'for each line', + 'for every line', 'every row', 'each row', + 'every line', + 'each line', 'one row per', + 'one line per', + 'line by line', 'each object must contain', 'exactly these fields', ) @@ -9406,16 +9417,22 @@ def question_requests_tabular_exhaustive_results(user_question): explicit_phrases = ( 'all results', 'all rows', + 'all lines', 'all values', 'all of them', 'complete list', 'each row', + 'each line', 'each one', 'every row', + 'every line', 'every one', 'exhaustive', 'for each row', 'for every row', + 'for each line', + 'for every line', + 'line by line', 'full list', 'list all', 'list each', @@ -9433,11 +9450,17 @@ def question_requests_tabular_exhaustive_results(user_question): r'\bone object per comment row\b', r'\bone object per (?:comment|submission)\b', r'\bone object per (?:comment|submission|input )?row\b', + r'\bone object per (?:comment|submission|input )?line\b', r'\bone row per (?:comment|submission|input )?row\b', + r'\bone line per (?:comment|submission|input )?line\b', r'\bone row per (?:comment|submission)\b', + r'\bone line per (?:comment|submission)\b', r'\bone object for each row\b', + r'\bone object for each line\b', r'\bone row per\b', + r'\bone line per\b', r'\bfor (?:each|every) row\b', + r'\bfor (?:each|every) line\b', ) structured_output_markers = ( 'json array', diff --git a/application/single_app/route_frontend_chats.py b/application/single_app/route_frontend_chats.py index ae57778a9..71adc39dc 100644 --- a/application/single_app/route_frontend_chats.py +++ b/application/single_app/route_frontend_chats.py @@ -34,7 +34,12 @@ from functions_governance import ensure_governance_access from functions_image_messages import build_image_message_documents from functions_prompts import list_all_prompts_for_scope -from functions_public_workspaces import find_public_workspace_by_id, get_user_visible_public_workspace_ids_from_settings +from functions_public_workspaces import ( + add_visible_public_workspace, + find_public_workspace_by_id, + get_user_role_in_public_workspace, + get_user_visible_public_workspace_ids_from_settings, +) from functions_simplechat_operations import upload_chat_image_bytes_for_user from functions_appinsights import log_event from functions_chat_bootstrap_cache import ( @@ -664,6 +669,29 @@ def _is_valid_chat_bootstrap_payload(payload): ) +def _ensure_public_chat_workspace_visible(user_id, request_args, user_settings_dict): + search_documents = str(request_args.get('search_documents') or '').strip().lower() == 'true' + doc_scope = str(request_args.get('doc_scope') or '').strip().lower() + workspace_id = str(request_args.get('workspace_id') or '').strip() + if not search_documents or doc_scope != 'public' or not workspace_id: + return False + + public_directory_settings = user_settings_dict.get('publicDirectorySettings') + if isinstance(public_directory_settings, dict) and public_directory_settings.get(workspace_id) is True: + return False + + workspace = find_public_workspace_by_id(workspace_id) + if not workspace or not get_user_role_in_public_workspace(workspace, user_id): + return False + + add_visible_public_workspace(user_id, workspace_id) + if not isinstance(public_directory_settings, dict): + public_directory_settings = {} + user_settings_dict['publicDirectorySettings'] = public_directory_settings + public_directory_settings[workspace_id] = True + return True + + def register_route_frontend_chats(bp): @bp.route('/chats', methods=['GET']) @swagger_route(security=get_auth_security()) @@ -677,6 +705,7 @@ def chats(): settings = get_settings() user_settings = get_user_settings(user_id) user_settings_dict = user_settings.get("settings", {}) if isinstance(user_settings, dict) else {} + _ensure_public_chat_workspace_visible(user_id, request.args, user_settings_dict) public_settings = sanitize_settings_for_user(settings) ai_notice = get_ai_notice_config(public_settings) ai_notice['dismissed'] = is_ai_notice_dismissed( diff --git a/docs/explanation/fixes/PUBLIC_WORKSPACE_HIDDEN_DOCUMENT_CHAT_VISIBILITY_FIX.md b/docs/explanation/fixes/PUBLIC_WORKSPACE_HIDDEN_DOCUMENT_CHAT_VISIBILITY_FIX.md new file mode 100644 index 000000000..5ad06c82a --- /dev/null +++ b/docs/explanation/fixes/PUBLIC_WORKSPACE_HIDDEN_DOCUMENT_CHAT_VISIBILITY_FIX.md @@ -0,0 +1,47 @@ +# Hidden Public Workspace Document Chat Visibility Fix - v0.250.200 + +Fixed in version: **0.250.200** + +Related issue: [#1245](https://github.com/microsoft/simplechat/issues/1245) + +Related version update: `application/single_app/config.py` was updated to `0.250.200` for this fix. + +## Issue Description + +When a user hid an accessible public workspace from the public directory, they could still visit the workspace and choose **Chat** for one or more documents. The Chat page appeared to carry the document selection, but the workspace remained absent from the public scope selector and its documents were excluded from grounded search. + +## Root Cause Analysis + +- Public workspace document chat links supplied the workspace and document IDs to the Chat page. +- The Chat page and public document endpoint intentionally loaded only workspaces enabled in the user's `publicDirectorySettings`. +- The handoff did not add the explicitly selected workspace to that visibility preference before the Chat page built its public workspace and document state. + +## Technical Details + +### Files Modified + +- `application/single_app/route_frontend_chats.py` +- `application/single_app/config.py` +- `functional_tests/test_public_workspace_hidden_document_chat_visibility.py` + +### Code Changes Summary + +- The Chat route now recognizes explicit public document-search handoffs before it builds the public workspace selector. +- The caller-supplied workspace ID is resolved and access is revalidated before user settings are changed. +- A hidden workspace is added to `publicDirectorySettings` while all existing visibility choices are preserved. +- Already-visible workspaces do not trigger a redundant settings write. + +### Testing Approach + +- Added a focused functional regression test for hidden, already-visible, invalid-scope, malformed, and unauthorized handoffs. +- Verified that visibility is applied before visible public workspace data is loaded for the Chat page. + +## Impact Analysis + +- Choosing **Chat** for a document in an accessible hidden public workspace now makes that workspace available in Chat. +- The selected document can be loaded by the visible-workspace document endpoint and included in grounded search. +- Other public workspaces that the user has made visible remain visible. + +## Validation + +- The focused regression test passes all visibility, authorization, ordering, and version checks. diff --git a/docs/explanation/fixes/TABULAR_DURABLE_ARTIFACT_LIFECYCLE_FIX.md b/docs/explanation/fixes/TABULAR_DURABLE_ARTIFACT_LIFECYCLE_FIX.md new file mode 100644 index 000000000..a8ba162bd --- /dev/null +++ b/docs/explanation/fixes/TABULAR_DURABLE_ARTIFACT_LIFECYCLE_FIX.md @@ -0,0 +1,78 @@ +# TABULAR DURABLE ARTIFACT LIFECYCLE FIX + +Fixed in version: **0.250.199** + +## Issue Description + +After durable tabular parity was activated for existing deployments, four production scenarios exposed different failures in the same artifact lifecycle: + +| Mode and request | Production run | Observed result | +|---|---|---| +| Search with explicit CSV | `f9ba9a10-5807-4b1f-baf4-7b7c1dacb600` | Correct 200-row CSV | +| Analyze with explicit CSV | `24346ece-6351-4653-91c4-a96f4c578052` | One-row Analyze CSV preview, then background failure | +| Search with no explicit format | `858f1f97-e22c-4f5f-b5c3-d642ad1f50f3` | Markdown uploaded, but card remained at 100% without download controls | +| Analyze with no explicit format | `10846d74-6431-4bd1-9c27-7b35c175be1d` | One-row Analyze CSV preview, then `DeploymentNotFound` | + +The intended output contract is: + +- Search plus explicit CSV: one CSV requested-output artifact. +- Analyze plus explicit CSV: one primary Markdown analysis artifact and one requested CSV artifact. +- Search exhaustive analysis without an explicit format: one primary Markdown analysis artifact. +- Analyze exhaustive analysis without an explicit format: one primary Markdown analysis artifact. + +## Root Cause Analysis + +The production failures had seven related causes: + +1. Pure-tabular Analyze preflight did not pass the selected non-secret model context into the shared durable callback. Background workers therefore resolved the displayed model name against the default Azure OpenAI resource instead of the selected endpoint. +2. Combined execution checkpointed an empty successful-result list before re-raising the captured provider exception, replacing the original failure with `Generated output schema could not be established`. +3. Search hierarchical plans inherited `analysis_required=False` from Search action mode even though the selected durable task was analysis. Their persisted contracts expected zero artifacts. +4. The hierarchical completion path uploaded Markdown without attaching its artifact metadata to the manifest member before validation. +5. Runs were marked `completed` before the required artifact set had validated and committed publication. The UI correctly stopped polling terminal runs, leaving an inconsistent completed run with a `rollback_required` artifact set permanently stuck. +6. Analyze artifact generation treated any exhaustive handoff sentence as CSV-recommended structured content, creating a misleading one-row CSV while the real durable output was pending. +7. The status API stored detailed errors internally but returned only a generic failure statement, so users could not distinguish model deployment, timeout, validation, source-access, or publication failures. + +## Technical Details + +### Files Modified + +- `application/single_app/functions_tabular_orchestration.py` +- `application/single_app/functions_workflow_runner.py` +- `application/single_app/functions_tabular_generated_exports.py` +- `application/single_app/config.py` +- Related functional and Playwright regression tests + +### Code Changes Summary + +- Passed `_build_workflow_model_context(...)` through pure-tabular Analyze preflight using only model and endpoint identifiers, provider, user id, and authorized group context; no credentials are persisted. +- Made hierarchical and combined durable task types explicitly set `analysis_required=True`, independent of whether the initiating action was Search or Analyze. +- Derived required artifacts from durable task semantics for legacy or malformed empty contracts. +- Attached uploaded Markdown metadata to the analysis manifest member before publication validation. +- Required artifact-set lifecycle `completed` before setting run status `completed` for structured, hierarchical, and combined work. +- Reconciled completed legacy runs whose uploaded artifact was hidden solely because its publication commit was missing. The repair is partition-authorized, bounded, and idempotent. +- Re-raised the original generation exception when a combined window produced zero successful batches. +- Suppressed document-analysis companion artifacts when a pure-tabular durable preflight owns the final deliverables. +- Added stable, sanitized failure codes and descriptions while keeping raw SDK/provider error text server-side. +- Projected inconsistent completed runs with incomplete artifact publication as failed instead of displaying a false success state. + +## Validation + +- New four-scenario matrix drives the real planner, persisted metadata sanitizer, artifact manifest, validation, and public projection against the deterministic 200-row financial-review fixture. +- New lifecycle tests verify Markdown member staging, validation-before-completion, legacy contract repair, original exception preservation, and completed artifact reconciliation. +- Updated Analyze preflight tests verify selected endpoint context reaches the durable callback. +- Updated document-analysis tests verify pending pure-tabular handoffs upload no companion CSV. +- Updated public-status tests verify raw provider endpoints and error payloads are excluded. +- Playwright tests verify hierarchical Markdown completion renders download controls and failed cards show the sanitized reason. +- Full tabular scale coverage, including 100,000-row planning, leases, retries, combined execution, and idempotent publication, passes. + +## Impact Analysis + +- Exhaustive tabular requests now have the same output semantics in Search and Analyze. +- Analyze uses the selected model endpoint for background work. +- A run cannot report success before its required downloads are available. +- Existing uploaded-but-hidden Markdown artifacts can self-repair when their owner checks status. +- Failed runs provide an actionable category without leaking configuration or provider internals. + +## Related Version Updates + +- `application/single_app/config.py` was updated to version **0.250.199**. diff --git a/docs/explanation/fixes/TABULAR_LINE_TERMINOLOGY_ROUTING_FIX.md b/docs/explanation/fixes/TABULAR_LINE_TERMINOLOGY_ROUTING_FIX.md new file mode 100644 index 000000000..433b6125d --- /dev/null +++ b/docs/explanation/fixes/TABULAR_LINE_TERMINOLOGY_ROUTING_FIX.md @@ -0,0 +1,171 @@ +# Tabular Analyze/Search Line Terminology Routing Fix + +## Issue Description + +A customer reported that a prompt phrased around "line" instead of "row" +never triggered the durable tabular Analyze/Search parity pipeline for +**either** Analyze or Search: + +> "For each line in this document, I need eight questions answered. I want +> the questions to be answered individually for each line item. Do not +> consolidate by bank or by activity. Go line by line and make sure all +> eight questions are answered for each line..." + +Production evidence (chat export PDFs and Application Insights logs) +confirmed the request was handled entirely by the old bounded foreground +`TabularProcessingPlugin.query_tabular_data` tool-calling loop instead of the +durable background pipeline: the assistant's response explicitly stated +"the supplied evidence is truncated after FRI-007" out of 200 total rows, +and a second attempt (with both Document Search and Workspace Search +enabled) produced only a 3-row sample plus a claim that "the full assessment +covers FRI-001 through FRI-200" without ever generating that assessment. + +Version implemented: **0.250.197**. + +## Root Cause Analysis + +Two independent gaps combined to produce this failure. + +### 1. Every exhaustive/per-row intent detector recognized "row" but not "line" + +At least eight separate keyword-list functions across four files decide +whether a prompt should be treated as an exhaustive, whole-dataset, +per-row request (which routes to the durable pipeline) versus a bounded, +sampled, or aggregate query (which stays on the foreground tool path): + +- `functions_tabular_orchestration.py`: `question_requests_tabular_generated_output()`, + `question_requests_tabular_hierarchical_analysis()` +- `functions_tabular_parity_contract.py`: `_question_requests_structured_artifact()`, + `_question_requests_full_source()` +- `route_backend_chats.py`: `question_requests_attachment_backed_row_follow_up()`, + `question_requests_tabular_structured_object_output()`, + `question_requests_tabular_exhaustive_results()` +- `functions_document_analysis.py`: `_prompt_requests_per_source_output()` + +Every one of these lists matched "each row", "every row", "for each row", +"for every row", "one row per", "all rows", etc., but **none** recognized +"line" as an equally natural synonym ("each line", "line by line", "for +each line", "one line per", "all lines"). The customer's prompt used "line" +exclusively and never used the word "row" at all, so none of these +detectors classified it as an exhaustive per-row request. + +(Two closely related functions — `functions_document_analysis.py`'s +`_prompt_requests_exhaustive_output()` and +`functions_workflow_runner.py`'s `_prompt_requests_exhaustive_analysis_output()` +— already worked correctly for "line" phrasing by coincidence, because they +match the bare, generic substrings `"every "` and `"each "` rather than +requiring the following word to be "row".) + +### 2. `enable_tabular_hierarchical_analysis` defaulted to off with no admin UI + +Even after fixing the keyword gap, `question_requests_tabular_hierarchical_analysis()` +returning `True` is not sufficient on its own. +`get_tabular_generated_output_task_type()` only selects the durable +`hierarchical_analysis` task type when a second, backend-only setting, +`enable_tabular_hierarchical_analysis`, is also enabled: + +```python +hierarchical_analysis_enabled = settings_flag_enabled( + settings, "enable_tabular_hierarchical_analysis", False, +) +... +if hierarchical_analysis_requested and hierarchical_analysis_enabled: + return TABULAR_RUN_TASK_HIERARCHICAL_ANALYSIS +return None +``` + +This flag defaulted to `False` in `DEFAULT_SETTINGS` and is listed in +`TABULAR_GENERATION_BACKEND_SETTING_KEYS`, meaning it has **no admin UI +toggle** — the same "always-on feature shipped disabled with no way to turn +it on" pattern already fixed once this session for +`tabular_request_planner_mode` / `enable_tabular_search_shared_preflight` / +`enable_tabular_analyze_durable_preflight` (see +[TABULAR_ANALYZE_SEARCH_PARITY_DEFAULT_ACTIVATION_FIX.md](./TABULAR_ANALYZE_SEARCH_PARITY_DEFAULT_ACTIVATION_FIX.md)). +This flag specifically gates **narrative** (non-CSV/JSON/XML-export) +exhaustive per-row/per-line Analyze and Search requests — exactly the +customer's scenario — so it needed the same fix. + +Both gaps had to be fixed together: the keyword fix alone would have made +`hierarchical_analysis_requested` `True` but `get_tabular_generated_output_task_type()` +would still have returned `None` with the flag off; the flag fix alone +would have had no effect because the request was never classified as +hierarchical-analysis intent in the first place. + +## Technical Details + +### Files Modified + +- `application/single_app/functions_tabular_orchestration.py`: added + `"all lines"`, `"every line"`, `"for each line"`, `"for every line"`, + `"line by line"`, `"one line per"`, `"each line"` to the exhaustive-marker + tuples in `question_requests_tabular_generated_output()` and + `question_requests_tabular_hierarchical_analysis()`. +- `application/single_app/functions_tabular_parity_contract.py`: added the + same line-phrase set to `_question_requests_structured_artifact()`'s + `structured_markers` and `_question_requests_full_source()`'s + `exhaustive_markers`. +- `application/single_app/route_backend_chats.py`: added line-phrase + variants to `question_requests_attachment_backed_row_follow_up()`'s + `per_row_markers`, `question_requests_tabular_structured_object_output()`'s + `structured_markers`, and `question_requests_tabular_exhaustive_results()`'s + `explicit_phrases` plus two new `structured_row_patterns` regexes for + "one line per"/"one object for each line"/"for each/every line". +- `application/single_app/functions_document_analysis.py`: added line-phrase + variants to `_prompt_requests_per_source_output()`'s `source_output_markers`. +- `application/single_app/functions_settings.py`: flipped + `enable_tabular_hierarchical_analysis` default from `False` to `True`; + extended `_apply_tabular_parity_env_kill_switch()` to also force it back + off when `SIMPLECHAT_DISABLE_TABULAR_PARITY_DURABLE_PREFLIGHT` is set. +- `application/single_app/config.py`: version bump to `0.250.197`. +- `functional_tests/test_tabular_line_terminology_routing_fix.py` (new): + verifies "line" phrasing is recognized, the exact customer prompt resolves + to the `hierarchical_analysis` durable task type for both Analyze and + Search, reproduces the bug with the flag off, and verifies the default + value plus the extended env kill switch. + +### Code Changes Summary + +```python +# functions_settings.py +'enable_tabular_hierarchical_analysis': True, # was False +... +if _env_flag_enabled('SIMPLECHAT_DISABLE_TABULAR_PARITY_DURABLE_PREFLIGHT'): + settings_payload['tabular_request_planner_mode'] = 'off' + settings_payload['enable_tabular_search_shared_preflight'] = False + settings_payload['enable_tabular_analyze_durable_preflight'] = False + settings_payload['enable_tabular_hierarchical_analysis'] = False # added +``` + +### Testing Approach + +- New: `test_tabular_line_terminology_routing_fix.py` (3/3 passing) — + directly imports `functions_tabular_orchestration`/`functions_tabular_parity_contract` + (both import-safe with two lightweight dependency stubs) and drives the + exact customer prompt end to end through `get_tabular_generated_output_task_type()`. +- Re-validated with no regressions: `test_tabular_shared_request_planner.py`, + `test_tabular_analyze_search_parity_contract.py`, `test_analyze_deliverable_contract.py`, + `test_tabular_phase7_lifecycle_coverage.py`, `test_tabular_phase7b_production_correctness.py`, + `test_tabular_phase8_ui_telemetry_rollout.py`, `test_tabular_phase9_legacy_retirement.py`, + `test_tabular_analyze_search_parity_default_activation.py`, + `test_tabular_document_actions_workflow.py`, `test_tabular_search_shared_preflight_adapter.py`, + `test_tabular_analyze_shared_preflight_adapter.py`, `test_tabular_transformations_phase4.py`, + `test_analyze_artifact_phase7_rollout_rollback.py`, `test_tabular_exhaustive_result_synthesis_fix.py`, + `test_tabular_execution_settings_sanitization.py`, `test_tabular_row_orchestration_scale.py` + (full suite, exit code 0). All pass. +- Confirmed pre-existing, unrelated failure in `test_tabular_entity_lookup_mode.py::test_per_row_exports_route_to_exhaustive_mode` + (`_shared_question_requests_tabular_generated_output` is not defined in that + test's AST-extracted namespace) reproduces identically on unmodified + `Development` — not caused by this change, out of scope for this fix. +- `python -m py_compile` and editor diagnostics clean across all changed files. + +## Impact Analysis + +- Fixes exhaustive per-row/per-line detection for **every** caller of the + affected functions, not just Analyze/Search chat prompts (e.g., document + action workflows that route through `functions_document_analysis.py`). +- `enable_tabular_hierarchical_analysis` defaulting to active affects any + narrative (non-export) whole-dataset Analyze/Search request, broadening + durable-pipeline coverage beyond just "line"-phrased prompts. +- No behavior change for requests that don't match any exhaustive-intent + marker; bounded/sampled/aggregate queries continue to use the foreground + tool path as before. diff --git a/docs/explanation/fixes/TABULAR_PARITY_STALE_SETTINGS_MIGRATION_FIX.md b/docs/explanation/fixes/TABULAR_PARITY_STALE_SETTINGS_MIGRATION_FIX.md new file mode 100644 index 000000000..2cf3845a9 --- /dev/null +++ b/docs/explanation/fixes/TABULAR_PARITY_STALE_SETTINGS_MIGRATION_FIX.md @@ -0,0 +1,158 @@ +# TABULAR PARITY STALE SETTINGS MIGRATION FIX + +Fixed in version: **0.250.198** + +## Issue Description + +After deploying the "line" terminology + `enable_tabular_hierarchical_analysis` default-activation +fix (`0.250.197`), the customer re-tested the exact same exhaustive per-line prompt +("For each line in this document, I need eight questions answered... Go line by line...") +through both Analyze and Search. Instead of truncating (the prior symptom), the request now +hung indefinitely — the UI showed "Tabular analysis... Current tabular step: Analyzing workbook +evidence (attempt 2 of 3)" frozen at 76-80% for 20+ minutes with no error and no completion. + +Production log analysis (`TABULAR_SK_ANALYSIS` tag) confirmed the legacy foreground SK +mini-agent (`run_tabular_sk_analysis()` / `TabularProcessingPlugin`) was running for both the +Analyze and Search test conversations, and continued calling tools (`filter_rows`, `count_rows`) +for 10+ minutes without ever converging on a complete answer for a 200-row x 8-question +exhaustive request. Critically, the string `hierarchical_analysis` did not appear anywhere in +the log — the new durable planner path was never reached at all, even though the prior fix had +already verified (via direct unit-level function calls) that `get_tabular_generated_output_task_type()` +correctly resolves to `hierarchical_analysis` for this exact prompt when +`enable_tabular_hierarchical_analysis` is enabled. + +## Root Cause Analysis + +`get_settings()` merges the code-level `default_settings` dict into the persisted Cosmos +`app_settings` document via `deep_merge_dicts(default_settings, settings_item)`. Per that +function's own docstring: **it only fills in keys that are *missing* from the existing +document; it never overwrites a key that already exists.** + +Four backend-only tabular durable-preflight parity flags (no admin UI toggle) were originally +introduced with conservative `off`/`False` defaults: + +- `tabular_request_planner_mode` (was `'off'`) +- `enable_tabular_search_shared_preflight` (was `False`) +- `enable_tabular_analyze_durable_preflight` (was `False`) +- `enable_tabular_hierarchical_analysis` (was `False`) + +The very first time `get_settings()` ran in any existing deployment after each of these keys was +introduced, `deep_merge_dicts()` treated the key as "missing," added it to the settings document +with its *then-current* off/False value, and immediately upserted that document back to Cosmos DB. + +Later releases raised the code-level defaults to `active`/`True` +(`TABULAR_ANALYZE_SEARCH_PARITY_DEFAULT_ACTIVATION_FIX`, v0.250.186, for the first three; this +session's line-terminology fix, v0.250.197, for the fourth). **Neither change had any effect for +an existing deployment**, because the persisted document already had each key stored with the +old value, and `deep_merge_dicts()` never overwrites an existing key. The customer's environment +had been running SimpleChat long enough that all four flags were already persisted with their +original off/False values, so every settings load kept resolving them back to legacy behavior — +completely independent of what the code-level defaults said. + +Both `maybe_queue_search_tabular_generated_output()` (Search, `route_backend_chats.py`) and +`_maybe_execute_pure_tabular_analyze_preflight()` (Analyze, `functions_workflow_runner.py`) gate +the durable preflight on these same settings: + +```python +if not _settings_bool(settings, 'enable_tabular_analyze_durable_preflight', False): + return None +planner_mode = str((settings or {}).get('tabular_request_planner_mode') or '').strip().lower() +if planner_mode not in {'shadow', 'active'}: + return None +``` + +With the persisted values stuck at `False`/`'off'`, both code paths returned `None` immediately, +so the request always fell through to the legacy bounded foreground path +(`run_tabular_analysis_with_thought_tracking()` -> `run_tabular_sk_analysis()`), which has its +own internal retry loop (`attempt N of 3`) that is not designed to complete an exhaustive +200-row x 8-question narrative request — it kept calling more tools without ever converging, +producing the observed indefinite hang. + +## Version Implemented + +- **0.250.198** + +## Files Modified + +- `application/single_app/functions_settings.py` +- `application/single_app/config.py` +- `functional_tests/test_tabular_parity_stale_settings_migration.py` (new) +- `docs/explanation/fixes/TABULAR_PARITY_STALE_SETTINGS_MIGRATION_FIX.md` (new) +- `docs/explanation/release_notes.md` + +## Code Changes Summary + +- Added `TABULAR_PARITY_DURABLE_PREFLIGHT_ACTIVE_DEFAULTS`, a map of the four backend-only + tabular durable-preflight parity flags to their intended active values. +- Added `normalize_tabular_parity_durable_preflight_defaults(settings)`, which unconditionally + corrects any of the four flags found with a stale/off value to its active default, mutating + the settings dict in place and returning whether anything changed. Because these settings have + no admin UI, any stored value that differs from the active default can only be stale drift, + never an intentional admin choice — so it is safe to correct unconditionally on every load. +- Wired the new function into `get_settings()`'s existing merge/migration sequence (alongside + `normalize_key_vault_reminder_settings()` and similar helpers) and included its `changed` flag + in the upsert-trigger condition, so corrected values are persisted back to Cosmos DB. +- This runs independently of `_apply_tabular_parity_env_kill_switch()`, which is still applied + afterwards in `_format_result()` and continues to provide the same emergency rollback path + (forcing the flags back off at read time) regardless of what is now persisted in Cosmos DB. + +## Testing Approach + +- New `functional_tests/test_tabular_parity_stale_settings_migration.py` (6/6 passing): + validates the active-defaults map, confirms stale pre-activation values are upgraded, confirms + already-active settings are left untouched (no unnecessary Cosmos churn), confirms partial + drift on a single flag is corrected without disturbing the others, confirms non-dict input is + handled safely, and confirms the migration is wired into `get_settings()`'s merge and + upsert-trigger condition via source inspection. +- Re-ran the existing tabular parity/settings regression suite: `test_tabular_analyze_search_parity_default_activation.py`, + `test_tabular_line_terminology_routing_fix.py`, `test_tabular_shared_request_planner.py`, + `test_tabular_analyze_shared_preflight_adapter.py`, `test_tabular_search_shared_preflight_adapter.py`, + `test_tabular_phase8_ui_telemetry_rollout.py`, `test_tabular_execution_settings_sanitization.py`, + `test_analyze_artifact_phase7_rollout_rollback.py`, `test_tabular_combined_artifact_set_download_visibility.py`, + `test_tabular_combined_output_schema_deferral_fix.py` — all pass. +- Confirmed two unrelated failures (`test_get_settings_merge_bool_regression.py`, + `test_settings_deep_merge_persistence_fix.py`) are pre-existing on unmodified `Development` via + `git stash`/re-run/`git stash pop`; both check exact literal source strings from versions + 0.240.002/0.240.006 that have since evolved (more migration conditions were added to the + upsert-trigger `if` block over time), unrelated to this fix. +- Compiled `functions_settings.py` with `py_compile`. + +## Impact Analysis + +- Any existing SimpleChat deployment whose Cosmos `app_settings` document already contains these + four keys (i.e., any deployment that has been running since before each flag's default was + raised) will have them corrected to active on the very next settings load, with the correction + persisted back to Cosmos DB so it survives future reads/restarts. +- This closes the gap left by the prior two "raise the default" fixes + (`TABULAR_ANALYZE_SEARCH_PARITY_DEFAULT_ACTIVATION_FIX` and this session's line-terminology fix), + which only changed the code-level default and had no effect on any deployment with a + pre-existing settings document. +- The env kill switch (`SIMPLECHAT_DISABLE_TABULAR_PARITY_DURABLE_PREFLIGHT`) continues to work + unchanged as the sole emergency rollback path. +- New deployments (fresh Cosmos document, no existing `app_settings` item) were never affected by + this bug — they always read the current code-level defaults directly. + +## Validation + +### Before + +- A settings document persisted before any of the four flags' defaults were raised keeps + `tabular_request_planner_mode='off'`, `enable_tabular_search_shared_preflight=False`, + `enable_tabular_analyze_durable_preflight=False`, `enable_tabular_hierarchical_analysis=False` + forever, regardless of code-level default changes, because `deep_merge_dicts()` never + overwrites existing keys. +- Exhaustive per-row/per-line Analyze and Search requests silently fall back to the legacy + bounded foreground SK mini-agent path, which can hang indefinitely on genuinely exhaustive + requests instead of completing or reporting a clear error. + +### After + +- `normalize_tabular_parity_durable_preflight_defaults()` corrects all four flags to their active + defaults on the next settings load for any deployment with stale persisted values, and persists + the correction back to Cosmos DB. +- `functional_tests/test_tabular_parity_stale_settings_migration.py` passes 6/6. +- Existing tabular parity regression suites remain green. + +## Related Version Updates + +- `application/single_app/config.py` was updated to version **0.250.198**. diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index 7ecaa7060..4d48cfe0d 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -2,6 +2,45 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Version](/explanation/features/) and [Fixes by Version](/explanation/fixes/). +### **(v0.250.200)** + +#### Bug Fixes + +* **Hidden Public Workspace Document Chat Grounding** + * Fixed document chat handoffs from accessible public workspaces that users had hidden from the public directory, so the selected document is now available to grounded search instead of appearing selected while being silently excluded. + * Adds the selected workspace to the user's visible Chat workspaces without hiding any existing choices and revalidates the requested public workspace before updating user settings. + * (Ref: #1245, `route_frontend_chats.py`, `test_public_workspace_hidden_document_chat_visibility.py`, `PUBLIC_WORKSPACE_HIDDEN_DOCUMENT_CHAT_VISIBILITY_FIX.md`) + +### **(v0.250.199)** + +#### Bug Fixes + +* **Tabular Analyze/Search Artifact Lifecycle Completion** + * Preserved the selected model endpoint for pure-tabular Analyze background work, preventing non-default model selections from falling back to an unavailable deployment on the default Azure OpenAI resource. + * Enforced one artifact contract across Search and Analyze: Search CSV produces CSV; Analyze CSV produces Markdown plus CSV; exhaustive requests without an explicit output format produce Markdown in either mode. + * Made artifact publication complete before run completion, repaired previously uploaded-but-hidden Markdown artifacts during status reconciliation, and preserved original generation failures instead of masking them as schema errors. + * Removed misleading one-row Analyze CSV handoff artifacts and added sanitized user-visible failure reasons without exposing provider errors or endpoint details. + * (Ref: `functions_tabular_orchestration.py`, `functions_workflow_runner.py`, `functions_tabular_generated_exports.py`, `TABULAR_DURABLE_ARTIFACT_LIFECYCLE_FIX.md`) + +### **(v0.250.198)** + +#### Bug Fixes + +* **Tabular Parity Stale Settings Migration** + * Fixed the four backend-only tabular durable-preflight parity flags (`tabular_request_planner_mode`, `enable_tabular_search_shared_preflight`, `enable_tabular_analyze_durable_preflight`, `enable_tabular_hierarchical_analysis`) silently staying disabled on any deployment whose Cosmos settings document already stored them from before their defaults were raised to active. + * `get_settings()` merges code defaults into the persisted document via `deep_merge_dicts()`, which only fills in missing keys and never overwrites an existing one, so raising a default in code alone never took effect for upgraded-in-place deployments. + * Both Analyze and Search durable preflight now self-correct to the active defaults on the next settings load and persist the fix back to Cosmos DB; the `SIMPLECHAT_DISABLE_TABULAR_PARITY_DURABLE_PREFLIGHT` emergency rollback env var continues to work unchanged. + * (Ref: `functions_settings.py`, `normalize_tabular_parity_durable_preflight_defaults()`, `TABULAR_PARITY_STALE_SETTINGS_MIGRATION_FIX.md`) + +### **(v0.250.197)** + +#### Bug Fixes + +* **Tabular "Line" Terminology Routing** + * Recognized "line"-phrased exhaustive tabular requests (for example, "for each line," "line by line," "one line per") as equivalent to "row"-phrased requests across eight duplicated keyword-detection functions, so they route through the durable generated-output/analysis pipeline instead of the bounded foreground tool-calling path. + * Activated `enable_tabular_hierarchical_analysis` by default so narrative (non-export) exhaustive whole-dataset Analyze/Search requests can resolve to the durable `hierarchical_analysis` task type, extending the existing emergency env kill switch to also cover this flag. + * (Ref: `functions_tabular_orchestration.py`, `functions_tabular_parity_contract.py`, `route_backend_chats.py`, `functions_document_analysis.py`, `TABULAR_LINE_TERMINOLOGY_ROUTING_FIX.md`) + ### **(v0.250.196)** #### Bug Fixes diff --git a/functional_tests/test_document_analysis_lossless_artifacts.py b/functional_tests/test_document_analysis_lossless_artifacts.py index fdc41c1c7..ddc4b4114 100644 --- a/functional_tests/test_document_analysis_lossless_artifacts.py +++ b/functional_tests/test_document_analysis_lossless_artifacts.py @@ -2,7 +2,7 @@ # test_document_analysis_lossless_artifacts.py """ Functional test for document analysis lossless artifacts. -Version: 0.250.172 +Version: 0.250.199 Implemented in: 0.241.040 Updated in: 0.241.065 Updated in: 0.241.197 @@ -10,6 +10,7 @@ Updated in: 0.250.112 Updated in: 0.250.154 Updated in: 0.250.172 +Updated in: 0.250.199 This test ensures exhaustive/table-style document analysis preserves raw window outputs and can build both structured CSV rows and Markdown raw-note artifacts @@ -396,6 +397,71 @@ def fake_upload_generated_artifact(**kwargs): print('Primary generated tabular output artifact presentation verified.') +def test_pure_tabular_durable_handoff_does_not_create_companion_artifacts(): + print('Testing pure-tabular durable handoff artifact suppression...') + uploaded_artifacts = [] + + def fake_upload_generated_artifact(**kwargs): + uploaded_artifacts.append(kwargs) + return { + 'message': { + 'id': f'artifact-{len(uploaded_artifacts)}', + 'file_name': kwargs.get('file_name'), + } + } + + namespace = load_module_functions( + WORKFLOW_RUNNER_PATH, + extra_globals={ + 'DOCUMENT_ANALYSIS_ARTIFACT_PREVIEW_ITEM_COUNT': 3, + 'DOCUMENT_ANALYSIS_ARTIFACT_PREVIEW_ROW_COUNT': 5, + 'DOCUMENT_ANALYSIS_ARTIFACT_PREVIEW_LINE_COUNT': 5, + '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, + }, + ) + + analysis_result = { + 'analysis_reply': 'The full-source analysis has been accepted for background processing.', + 'analysis_intent': { + 'exhaustive': True, + 'csv_artifact_recommended': True, + 'markdown_analysis_artifact_recommended': True, + }, + 'documents': [{'file_name': 'financial_review.csv'}], + 'tabular_execution_state': 'queued', + 'tabular_preflight_result': { + 'execution_state': 'queued', + 'durable_task_type': 'hierarchical_analysis', + }, + } + primary_generated_outputs = [{ + 'capability': 'tabular', + 'background_export': True, + 'export_run_id': 'run-hierarchical', + 'status': 'queued', + 'task_type': 'hierarchical_analysis', + 'output_format': 'md', + 'row_count': 200, + 'batch_count': 1, + }] + + artifact_payload = namespace['_maybe_create_document_analysis_generated_artifacts']( + analysis_result, + 'For each line, answer all eight questions.', + conversation_id='conversation-1', + primary_generated_outputs=primary_generated_outputs, + ) + + assert_equal(artifact_payload.get('artifacts'), [], 'durable handoff artifacts') + assert_equal(artifact_payload.get('assistant_reply'), None, 'durable handoff assistant override') + assert_equal(uploaded_artifacts, [], 'durable handoff upload count') + print('Pure-tabular durable handoff artifact suppression verified.') + + def test_json_artifact_requires_explicit_json_request(): print('Testing JSON artifact opt-in behavior for document analysis...') uploaded_artifacts = [] @@ -514,6 +580,7 @@ def run_tests(): test_analysis_preserves_raw_outputs, test_lossless_artifact_helpers_build_csv_and_markdown, test_primary_tabular_output_demotes_secondary_artifacts, + test_pure_tabular_durable_handoff_does_not_create_companion_artifacts, test_json_artifact_requires_explicit_json_request, test_workflow_markdown_fence_parser_is_linear_and_compatible, test_version_alignment, diff --git a/functional_tests/test_public_workspace_hidden_document_chat_visibility.py b/functional_tests/test_public_workspace_hidden_document_chat_visibility.py new file mode 100644 index 000000000..e7b926e36 --- /dev/null +++ b/functional_tests/test_public_workspace_hidden_document_chat_visibility.py @@ -0,0 +1,175 @@ +# test_public_workspace_hidden_document_chat_visibility.py +#!/usr/bin/env python3 +""" +Functional test for hidden public workspace document chat visibility. +Version: 0.250.200 +Implemented in: 0.250.200 + +This test ensures a public workspace document chat handoff makes the workspace +visible without hiding other workspaces and rejects unauthorized mutations. +""" + +import ast +import sys +from pathlib import Path + +from test_support.versioning import assert_app_version_at_least + + +REPO_ROOT = Path(__file__).resolve().parents[1] +ROUTE_FILE = REPO_ROOT / "application" / "single_app" / "route_frontend_chats.py" + + +def load_visibility_helper(workspaces, roles): + """Load the route helper with focused workspace and persistence doubles.""" + source = ROUTE_FILE.read_text(encoding="utf-8") + parsed = ast.parse(source, filename=str(ROUTE_FILE)) + helper_nodes = [ + node + for node in parsed.body + if isinstance(node, ast.FunctionDef) + and node.name == "_ensure_public_chat_workspace_visible" + ] + if len(helper_nodes) != 1: + raise AssertionError("Expected one public chat workspace visibility helper.") + + persisted = [] + namespace = { + "find_public_workspace_by_id": lambda workspace_id: workspaces.get(workspace_id), + "get_user_role_in_public_workspace": ( + lambda workspace, user_id: roles.get((workspace.get("id"), user_id)) + ), + "add_visible_public_workspace": ( + lambda user_id, workspace_id: persisted.append((user_id, workspace_id)) + ), + } + module = ast.Module(body=helper_nodes, type_ignores=[]) + exec(compile(module, str(ROUTE_FILE), "exec"), namespace) + return namespace["_ensure_public_chat_workspace_visible"], persisted + + +def test_hidden_public_workspace_handoff_adds_visibility(): + """A valid document handoff should add visibility and preserve existing choices.""" + helper, persisted = load_visibility_helper( + {"workspace-hidden": {"id": "workspace-hidden"}}, + {("workspace-hidden", "user-1"): "User"}, + ) + user_settings = { + "publicDirectorySettings": { + "workspace-visible": True, + "workspace-hidden": False, + } + } + + changed = helper( + "user-1", + { + "search_documents": "true", + "doc_scope": "public", + "workspace_id": "workspace-hidden", + }, + user_settings, + ) + + assert changed is True + assert persisted == [("user-1", "workspace-hidden")] + assert user_settings["publicDirectorySettings"] == { + "workspace-visible": True, + "workspace-hidden": True, + } + + +def test_visible_workspace_handoff_does_not_write_again(): + """An already-visible workspace should not trigger a redundant settings write.""" + helper, persisted = load_visibility_helper({}, {}) + user_settings = {"publicDirectorySettings": {"workspace-visible": True}} + + changed = helper( + "user-1", + { + "search_documents": "TRUE", + "doc_scope": "PUBLIC", + "workspace_id": "workspace-visible", + }, + user_settings, + ) + + assert changed is False + assert persisted == [] + + +def test_invalid_or_unauthorized_handoffs_do_not_change_visibility(): + """Only an authorized public document-search handoff may update visibility.""" + helper, persisted = load_visibility_helper( + {"workspace-hidden": {"id": "workspace-hidden"}}, + {("workspace-hidden", "user-1"): None}, + ) + + ignored_requests = [ + {"search_documents": "false", "doc_scope": "public", "workspace_id": "workspace-hidden"}, + {"search_documents": "true", "doc_scope": "group", "workspace_id": "workspace-hidden"}, + {"search_documents": "true", "doc_scope": "public", "workspace_id": ""}, + ] + for request_args in ignored_requests: + user_settings = {"publicDirectorySettings": {"workspace-existing": True}} + assert helper("user-1", request_args, user_settings) is False + assert user_settings == {"publicDirectorySettings": {"workspace-existing": True}} + + user_settings = {"publicDirectorySettings": {"workspace-hidden": False}} + assert helper( + "user-1", + { + "search_documents": "true", + "doc_scope": "public", + "workspace_id": "workspace-hidden", + }, + user_settings, + ) is False + assert persisted == [] + assert user_settings["publicDirectorySettings"]["workspace-hidden"] is False + + +def test_chat_route_applies_visibility_before_building_public_scope(): + """The chat route must persist handoff visibility before loading selector data.""" + source = ROUTE_FILE.read_text(encoding="utf-8") + chats_source = source[source.index(" def chats():"):] + + visibility_call = chats_source.index( + "_ensure_public_chat_workspace_visible(user_id, request.args, user_settings_dict)" + ) + visible_workspace_load = chats_source.index( + "get_user_visible_public_workspace_ids_from_settings(user_id)" + ) + assert visibility_call < visible_workspace_load + + +def test_version_contract(): + """The application version should include this fix.""" + assert_app_version_at_least("0.250.200") + + +def main(): + tests = [ + test_hidden_public_workspace_handoff_adds_visibility, + test_visible_workspace_handoff_does_not_write_again, + test_invalid_or_unauthorized_handoffs_do_not_change_visibility, + test_chat_route_applies_visibility_before_building_public_scope, + test_version_contract, + ] + results = [] + for test in tests: + print(f"Running {test.__name__}...") + try: + test() + print(f"PASS: {test.__name__}") + results.append(True) + except Exception as exc: + print(f"FAIL: {test.__name__}: {exc}") + results.append(False) + + print(f"Results: {sum(results)}/{len(results)} tests passed") + return 0 if all(results) else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/functional_tests/test_tabular_analyze_shared_preflight_adapter.py b/functional_tests/test_tabular_analyze_shared_preflight_adapter.py index 80e3ed78a..1b3184e24 100644 --- a/functional_tests/test_tabular_analyze_shared_preflight_adapter.py +++ b/functional_tests/test_tabular_analyze_shared_preflight_adapter.py @@ -2,8 +2,8 @@ # test_tabular_analyze_shared_preflight_adapter.py """ Functional test for the Analyze shared tabular preflight adapter. -Version: 0.250.167 -Implemented in: 0.250.160; updated in 0.250.161 +Version: 0.250.199 +Implemented in: 0.250.160; updated in 0.250.161 and 0.250.199 This test ensures Phase 4 routes pure single-source tabular Analyze durable work through the shared planner before foreground tabular tools or immediate @@ -133,6 +133,12 @@ def fake_invoke_prompt(prompt, **kwargs): "SELECTION_MODE_SELECTED": "selected", "_get_document_action_source_ids": lambda config: (list(config.get("document_ids") or []), {}), "_resolve_tabular_document_action_model_name": lambda workflow, settings: "gpt-4o", + "_build_workflow_model_context": lambda workflow, deployment_name, provider: { + "endpoint_id": workflow.get("model_endpoint_id"), + "model_id": workflow.get("model_id"), + "model_deployment": deployment_name, + "provider": provider, + }, "_resolve_analyze_all_document_ids": lambda *args, **kwargs: {}, "_shared_orchestrate_tabular_request": fake_orchestrate_tabular_request, "_shared_queue_direct_tabular_generated_output_from_plan": object(), @@ -167,7 +173,13 @@ def fake_invoke_prompt(prompt, **kwargs): def call_analyze(namespace, settings=None, document_ids=None): return namespace["_execute_mixed_source_analyze_workflow"]( - {"user_id": "user-1", "task_prompt": "Analyze every row and create a CSV file."}, + { + "user_id": "user-1", + "task_prompt": "Analyze every row and create a CSV file.", + "model_endpoint_id": "endpoint-1", + "model_id": "model-1", + "model_provider": "aoai", + }, {"type": "analyze", "document_ids": document_ids or ["table-1"]}, settings or { "enable_tabular_analyze_durable_preflight": True, @@ -214,6 +226,16 @@ def test_active_durable_preflight_short_circuits_foreground_and_synthesis(): assert_equal(file_context["storage_locator"]["blob_path"], "user-1/survey.csv", "authorized storage locator") assert_equal(orchestrator_call["kwargs"]["action_mode"], "analyze", "shared action mode") assert_equal(orchestrator_call["kwargs"]["planner_mode"], "active", "shared planner mode") + assert_equal( + orchestrator_call["kwargs"]["model_context"], + { + "endpoint_id": "endpoint-1", + "model_id": "model-1", + "model_deployment": "gpt-4o", + "provider": "aoai", + }, + "selected model context", + ) assert_true("token_usage_callback" not in orchestrator_call["kwargs"], "durable callback keyword compatibility") diff --git a/functional_tests/test_tabular_durable_artifact_lifecycle_recovery.py b/functional_tests/test_tabular_durable_artifact_lifecycle_recovery.py new file mode 100644 index 000000000..e73ad2fa8 --- /dev/null +++ b/functional_tests/test_tabular_durable_artifact_lifecycle_recovery.py @@ -0,0 +1,330 @@ +# test_tabular_durable_artifact_lifecycle_recovery.py +#!/usr/bin/env python3 +""" +Functional tests for durable tabular artifact completion and failure preservation. +Version: 0.250.199 +Implemented in: 0.250.199 + +This test ensures hierarchical analysis records its uploaded Markdown artifact +before publication validation, only marks the run completed after the artifact +set commits, repairs empty legacy durable contracts from task semantics, and +preserves the original combined-generation exception when no batch succeeds. +""" + +import ast +import asyncio +import logging +import sys +from pathlib import Path + +from test_support.versioning import assert_app_version_at_least + + +ROOT_DIR = Path(__file__).resolve().parents[1] +APP_ROOT = ROOT_DIR / "application" / "single_app" +EXPORT_FILE = APP_ROOT / "functions_tabular_generated_exports.py" +IMPLEMENTED_VERSION = "0.250.199" + +if str(APP_ROOT) not in sys.path: + sys.path.insert(0, str(APP_ROOT)) +if str(Path(__file__).resolve().parent) not in sys.path: + sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from functions_analysis_deliverables import build_analysis_deliverable_contract # noqa: E402 +from test_tabular_phase5_artifact_set_lifecycle import ( # noqa: E402 + build_artifact, + load_artifact_set_helpers, +) + + +def _extract_function(function_name): + source = EXPORT_FILE.read_text(encoding="utf-8") + tree = ast.parse(source, filename=str(EXPORT_FILE)) + function_node = next( + node + for node in tree.body + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == function_name + ) + return ast.Module(body=[function_node], type_ignores=[]) + + +def _load_complete_analysis_run(artifact_set_lifecycle="completed"): + calls = [] + + def publish_analysis_artifact(run, final_summary): + calls.append("upload") + return run, {"id": "md-message", "file_name": "analysis.md"}, final_summary, "analysis.md" + + def build_artifact_metadata(uploaded_message, file_name, output_format, **kwargs): + return { + "artifact_message_id": uploaded_message["id"], + "file_name": file_name, + "output_format": output_format, + "capability": "tabular", + "preview_text": kwargs.get("preview_text", ""), + "suppress_assistant_text": kwargs.get("suppress_assistant_text", False), + } + + def set_member_state(run, member_id, artifact=None, **kwargs): + calls.append("stage") + assert run["status"] == "running" + assert member_id == "analysis" + assert artifact["artifact_message_id"] == "md-message" + + def publish_members(run, member_ids): + calls.append("publish") + assert run["status"] == "running" + assert run["analysis_phase"] == "publishing" + assert run["analysis_artifact"]["artifact_message_id"] == "md-message" + assert member_ids == ["analysis"] + return {"lifecycle_state": artifact_set_lifecycle} + + def replace_claimed_run(run): + calls.append("persist") + assert run["status"] == "completed" + assert run["analysis_phase"] == "completed" + return dict(run) + + namespace = { + "logging": logging, + "log_event": lambda *args, **kwargs: None, + "_publish_analysis_artifact": publish_analysis_artifact, + "_build_analysis_summary_markdown": lambda run, summary: "# Analysis", + "_build_artifact_metadata": build_artifact_metadata, + "_set_artifact_set_member_state": set_member_state, + "_get_analysis_artifact_member_id": lambda run: "analysis", + "_now_iso": lambda: "2026-08-14T17:00:00+00:00", + "_safe_int": lambda value, default=0, minimum=None: int(value if value is not None else default), + "_publish_artifact_set_members": publish_members, + "_build_generation_progress_contract_fields": lambda run, batches, rows: {}, + "_build_tabular_generation_performance_summary": lambda run, completed_at=None: {}, + "_replace_claimed_run": replace_claimed_run, + "TABULAR_EXPORT_STATUS_COMPLETED": "completed", + "TABULAR_ARTIFACT_MEMBER_LIFECYCLE_STAGED": "staged", + "TABULAR_ARTIFACT_SET_LIFECYCLE_COMPLETED": "completed", + } + module = _extract_function("_complete_analysis_run") + exec(compile(module, str(EXPORT_FILE), "exec"), namespace) + return namespace["_complete_analysis_run"], calls + + +def test_hierarchical_completion_publishes_before_marking_run_completed(): + """The uploaded Markdown member must validate and commit before terminal status.""" + assert_app_version_at_least(IMPLEMENTED_VERSION) + complete_analysis_run, calls = _load_complete_analysis_run() + run = { + "id": "run-1", + "status": "running", + "row_count": 200, + "batch_count": 1, + } + + completed_run = complete_analysis_run(run, {"summary": "Done", "row_count": 200}) + + assert calls == ["upload", "stage", "publish", "persist"] + assert completed_run["status"] == "completed" + assert completed_run["analysis_artifact"]["artifact_message_id"] == "md-message" + + +def test_hierarchical_completion_fails_before_terminal_status_when_publication_is_invalid(): + """An invalid artifact set must not create a completed run with no downloadable file.""" + assert_app_version_at_least(IMPLEMENTED_VERSION) + complete_analysis_run, calls = _load_complete_analysis_run("rollback_required") + run = { + "id": "run-2", + "status": "running", + "row_count": 200, + "batch_count": 1, + } + + try: + complete_analysis_run(run, {"summary": "Done", "row_count": 200}) + except ValueError as exc: + assert str(exc) == "Generated analysis artifact failed publication validation" + else: + raise AssertionError("Invalid publication must raise before terminal completion") + + assert calls == ["upload", "stage", "publish"] + assert run["status"] == "running" + + +def test_empty_search_hierarchical_contract_defaults_to_required_markdown(): + """Legacy Search runs with an empty contract must not reject their Markdown as extra.""" + assert_app_version_at_least(IMPLEMENTED_VERSION) + helpers = load_artifact_set_helpers() + empty_contract = build_analysis_deliverable_contract( + action_mode="search", + requested_artifacts=[], + ).to_dict() + run = { + "id": "run-search-md", + "conversation_id": "conversation-1", + "user_id": "user-1", + "task_type": "hierarchical_analysis", + "status": "running", + "output_format": "md", + "source_file_name": "financial_review.csv", + "row_count": 200, + "tabular_planner_metadata": {"deliverable_contract": empty_contract}, + "final_artifact": build_artifact("md-message", "financial_review.md", "md"), + } + + manifest = helpers["_publish_artifact_set_members"](run, ["analysis"]) + + assert manifest["lifecycle_state"] == "completed", manifest + assert manifest["validation_report"]["valid"] is True + assert manifest["validation_report"]["reason_codes"] == [] + + +def _load_process_combined_run(original_error, checkpoint_calls): + async def generate_combined_results(*args, **kwargs): + return [], original_error + + def checkpoint_results(run, generated_results): + checkpoint_calls.append(list(generated_results)) + raise AssertionError("Empty generated results must not reach schema checkpointing") + + namespace = { + "asyncio": asyncio, + "logging": logging, + "log_event": lambda *args, **kwargs: None, + "_safe_int": lambda value, default=0, minimum=None: int(value if value is not None else default), + "_raise_if_tabular_export_canceled": lambda run: None, + "_build_combined_batch_window": lambda *args, **kwargs: ( + {}, + [{"batch_number": 1, "rows": [{"Item_ID": "FRI-001"}]}], + ), + "_generate_combined_chunk_result_window": generate_combined_results, + "_checkpoint_combined_batch_results": checkpoint_results, + "_get_tabular_run_transformation_spec": lambda run: None, + "_load_ready_active_tabular_generation_plan": lambda run: None, + "_get_tabular_semantic_validation_options": lambda run: {}, + "_advance_analysis_map_progress_for_window": lambda *args, **kwargs: args[0:1] + (0, 0), + "_log_progress_if_due": lambda run, last_logged_at: last_logged_at, + "_publish_combined_structured_export_phase": lambda run: run, + "_run_analysis_reduce_tree": lambda *args, **kwargs: {}, + "_complete_combined_analysis_run": lambda run, summary: run, + } + module = _extract_function("_process_combined_run") + exec(compile(module, str(EXPORT_FILE), "exec"), namespace) + return namespace["_process_combined_run"] + + +def test_combined_zero_success_window_preserves_original_generation_error(): + """A provider failure must not be replaced by an empty-schema checkpoint error.""" + assert_app_version_at_least(IMPLEMENTED_VERSION) + + class DeploymentNotFoundError(Exception): + pass + + original_error = DeploymentNotFoundError("selected model deployment was not found") + checkpoint_calls = [] + process_combined_run = _load_process_combined_run(original_error, checkpoint_calls) + run = { + "id": "run-combined", + "user_id": "user-1", + "conversation_id": "conversation-1", + "status": "running", + "batch_count": 1, + "completed_batches": 0, + "processed_rows": 0, + "output_schema": None, + } + + try: + process_combined_run( + run, + object(), + [], + retry_attempts=1, + batch_concurrency=1, + batch_timeout_seconds=30, + settings={}, + ) + except DeploymentNotFoundError as exc: + assert exc is original_error + else: + raise AssertionError("The original generation error must be re-raised") + + assert checkpoint_calls == [] + + +def test_completed_legacy_run_reconciles_uploaded_markdown_publication(): + """Status reconciliation must commit an uploaded legacy artifact exactly once.""" + assert_app_version_at_least(IMPLEMENTED_VERSION) + source = EXPORT_FILE.read_text(encoding="utf-8") + tree = ast.parse(source, filename=str(EXPORT_FILE)) + function_node = next( + node + for node in tree.body + if isinstance(node, ast.FunctionDef) and node.name == "_reconcile_completed_tabular_artifact_set" + ) + published_member_ids = [] + persisted_runs = [] + logged_events = [] + + def publish_members(run, member_ids): + published_member_ids.extend(member_ids) + manifest = { + "lifecycle_state": "completed", + "members": [{"member_id": "analysis", "artifact_message_id": "md-message"}], + } + run["artifact_set_manifest"] = manifest + return manifest + + def replace_run(run): + persisted_runs.append(dict(run)) + return dict(run) + + namespace = { + "logging": logging, + "log_event": lambda message, extra=None, level=logging.INFO: logged_events.append((message, extra)), + "_build_or_update_artifact_set_manifest": lambda run: { + "lifecycle_state": "rollback_required", + "members": [{"member_id": "analysis", "artifact_message_id": "md-message"}], + }, + "_publish_artifact_set_members": publish_members, + "_replace_run": replace_run, + "_read_run": lambda user_id, run_id: None, + "TABULAR_EXPORT_STATUS_COMPLETED": "completed", + "TABULAR_ARTIFACT_SET_LIFECYCLE_COMPLETED": "completed", + } + exec( + compile(ast.Module(body=[function_node], type_ignores=[]), str(EXPORT_FILE), "exec"), + namespace, + ) + run = { + "id": "run-legacy", + "conversation_id": "conversation-1", + "user_id": "user-1", + "status": "completed", + "artifact_set_manifest": {"lifecycle_state": "rollback_required"}, + } + + repaired_run = namespace["_reconcile_completed_tabular_artifact_set"](run) + + assert published_member_ids == ["analysis"] + assert len(persisted_runs) == 1 + assert repaired_run["artifact_set_manifest"]["lifecycle_state"] == "completed" + assert logged_events[0][0] == "[TABULAR_GENERATED_OUTPUT] Reconciled completed artifact-set publication" + + +if __name__ == "__main__": + tests = [ + test_hierarchical_completion_publishes_before_marking_run_completed, + test_hierarchical_completion_fails_before_terminal_status_when_publication_is_invalid, + test_empty_search_hierarchical_contract_defaults_to_required_markdown, + test_combined_zero_success_window_preserves_original_generation_error, + test_completed_legacy_run_reconciles_uploaded_markdown_publication, + ] + failures = 0 + for test in tests: + try: + test() + print(f"PASS: {test.__name__}") + except Exception as exc: + failures += 1 + print(f"FAIL: {test.__name__}: {exc}") + + print(f"\n{len(tests) - failures}/{len(tests)} tests passed") + sys.exit(0 if failures == 0 else 1) diff --git a/functional_tests/test_tabular_line_terminology_routing_fix.py b/functional_tests/test_tabular_line_terminology_routing_fix.py new file mode 100644 index 000000000..8d598083e --- /dev/null +++ b/functional_tests/test_tabular_line_terminology_routing_fix.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +# test_tabular_line_terminology_routing_fix.py +""" +Functional test for tabular exhaustive per-line terminology routing. +Version: 0.250.199 +Implemented in: 0.250.197; updated in 0.250.199 + +A customer reported that a prompt phrased as "For each line in this document, +I need eight questions answered... Go line by line and make sure all eight +questions are answered for each line" did not trigger the durable tabular +Analyze/Search parity pipeline for either Analyze or Search, and instead fell +back to the old bounded foreground TabularProcessingPlugin tool-calling path, +which truncated the response after only a handful of rows. + +Two independent root causes combined to produce this failure: + +1. Every exhaustive/per-row intent-detection keyword list across the codebase + (functions_tabular_orchestration.py, functions_tabular_parity_contract.py, + route_backend_chats.py, functions_document_analysis.py) recognized "row" + phrasing ("each row", "every row", "for each row", "one row per", ...) but + not the equally natural "line" synonym ("each line", "line by line", ...), + so a prompt using "line" terminology was never classified as an exhaustive + per-row request at all. +2. Even when hierarchical-analysis intent *was* recognized, the backend-only + `enable_tabular_hierarchical_analysis` setting defaulted to False with no + admin UI toggle, so `get_tabular_generated_output_task_type()` could never + return the `hierarchical_analysis` durable task type for narrative + (non-CSV/JSON/XML-export) per-row requests. + +This test verifies both fixes and the exact customer prompt end to end. +""" + +import sys +import types +from pathlib import Path + +from test_support.versioning import assert_app_version_at_least + +REPO_ROOT = Path(__file__).resolve().parents[1] +APP_ROOT = REPO_ROOT / "application" / "single_app" +if str(APP_ROOT) not in sys.path: + sys.path.insert(0, str(APP_ROOT)) + +IMPLEMENTED_VERSION = "0.250.197" + +CUSTOMER_PROMPT = ( + "For each line in this document, I need eight questions answered. I want " + "the questions to be answered individually for each line item. Do not " + "consolidate by bank or by activity. Go line by line and make sure all " + "eight questions are answered for each line. The questions are as " + "follows:\n" + "What is this and what is it trying to accomplish?\n" + "Why are we doing it?\n" + "What value does it produce?\n" + "What resources are identified or implied?\n" + "What is the timeline or schedule?\n" + "What happens if we stop?\n" + "Does this appear reasonable? Concerns / duplication / measurable outcomes\n" + "What information is missing to assess this activity?" +) + + +def _install_lightweight_planner_dependency_stubs(): + assistant_exports_module = types.ModuleType("functions_assistant_table_exports") + assistant_exports_module.assistant_table_export_requested = ( + lambda prompt: "csv" in str(prompt or "").lower() + ) + generated_exports_module = types.ModuleType("functions_generated_file_exports") + + def get_requested_artifact_formats(prompt): + normalized_prompt = str(prompt or "").lower() + return [output_format for output_format in ("json", "xml", "csv") if output_format in normalized_prompt] + + generated_exports_module.get_requested_artifact_formats = get_requested_artifact_formats + generated_exports_module.get_requested_structured_artifact_format = ( + lambda prompt: next(iter(get_requested_artifact_formats(prompt)), None) + ) + generated_exports_module.get_requested_structured_artifact_formats = get_requested_artifact_formats + sys.modules.setdefault("functions_assistant_table_exports", assistant_exports_module) + sys.modules.setdefault("functions_generated_file_exports", generated_exports_module) + + +_install_lightweight_planner_dependency_stubs() + +from functions_tabular_orchestration import ( # noqa: E402 + get_tabular_generated_output_task_type, + plan_tabular_request, + question_requests_tabular_generated_output, + question_requests_tabular_hierarchical_analysis, +) +from functions_tabular_parity_contract import _question_requests_full_source # noqa: E402 + + +def test_line_phrasing_is_recognized_as_hierarchical_analysis_intent(): + """'For each line'/'line by line' must be recognized the same as 'row' phrasing.""" + print("Testing line-terminology intent detection...") + assert_app_version_at_least(IMPLEMENTED_VERSION) + + assert question_requests_tabular_hierarchical_analysis(CUSTOMER_PROMPT) is True, CUSTOMER_PROMPT + assert _question_requests_full_source(CUSTOMER_PROMPT.strip().lower()) is True, CUSTOMER_PROMPT + # The prompt never asks for a CSV/JSON/XML export, so this must stay False. + assert question_requests_tabular_generated_output(CUSTOMER_PROMPT) is False, CUSTOMER_PROMPT + + # Row phrasing must keep working (no regression from the added markers). + row_prompt = "For each row in this document, answer these eight questions. Go row by row." + assert question_requests_tabular_hierarchical_analysis(row_prompt) is True, row_prompt + + +def test_customer_prompt_routes_to_durable_hierarchical_analysis_for_analyze_and_search(): + """The exact reported prompt must resolve to the hierarchical_analysis task + type for both Analyze and Search action modes once the feature default is + active, and must fall back to no durable routing when the flag is off + (reproducing the reported bug).""" + print("Testing customer prompt routes to durable hierarchical analysis...") + assert_app_version_at_least(IMPLEMENTED_VERSION) + + generated_output_requested = question_requests_tabular_generated_output(CUSTOMER_PROMPT) + hierarchical_analysis_requested = question_requests_tabular_hierarchical_analysis(CUSTOMER_PROMPT) + assert hierarchical_analysis_requested is True + + active_settings = {"enable_tabular_hierarchical_analysis": True} + assert get_tabular_generated_output_task_type( + generated_output_requested, hierarchical_analysis_requested, active_settings, action_mode="analyze" + ) == "hierarchical_analysis" + assert get_tabular_generated_output_task_type( + generated_output_requested, hierarchical_analysis_requested, active_settings, action_mode="search" + ) == "hierarchical_analysis" + for action_mode in ("analyze", "search"): + plan = plan_tabular_request( + CUSTOMER_PROMPT, + [{"file_name": "simple_financial_review_test_200.csv", "document_id": "doc-1"}], + action_mode=action_mode, + settings=active_settings, + ) + assert plan["durable_task_type"] == "hierarchical_analysis" + assert plan["deliverable_contract"]["analysis_required"] is True + assert [ + artifact["format"] + for artifact in plan["deliverable_contract"]["requested_artifacts"] + ] == ["md"] + + # Reproduce the reported bug: with the flag off, no durable task type is selected. + disabled_settings = {"enable_tabular_hierarchical_analysis": False} + assert get_tabular_generated_output_task_type( + generated_output_requested, hierarchical_analysis_requested, disabled_settings, action_mode="analyze" + ) is None + assert get_tabular_generated_output_task_type( + generated_output_requested, hierarchical_analysis_requested, disabled_settings, action_mode="search" + ) is None + + +def test_enable_tabular_hierarchical_analysis_defaults_active(): + """The backend-only hierarchical-analysis flag must default to active, like + the other durable Analyze/Search parity controls, with no admin UI + toggle, and must be forced off by the existing emergency env kill switch.""" + print("Testing enable_tabular_hierarchical_analysis defaults active...") + assert_app_version_at_least(IMPLEMENTED_VERSION) + + import ast + + settings_file = APP_ROOT / "functions_settings.py" + source = settings_file.read_text(encoding="utf-8") + tree = ast.parse(source, filename=str(settings_file)) + + default_settings_dict = None + for node in ast.walk(tree): + if isinstance(node, ast.FunctionDef) and node.name == "get_settings": + for stmt in node.body: + if ( + isinstance(stmt, ast.Assign) + and len(stmt.targets) == 1 + and isinstance(stmt.targets[0], ast.Name) + and stmt.targets[0].id == "default_settings" + ): + default_settings_dict = stmt.value + assert default_settings_dict is not None, "Could not locate default_settings dict in get_settings()" + + found_value = None + for key_node, value_node in zip(default_settings_dict.keys, default_settings_dict.values): + if isinstance(key_node, ast.Constant) and key_node.value == "enable_tabular_hierarchical_analysis": + found_value = ast.literal_eval(value_node) + assert found_value is True, "enable_tabular_hierarchical_analysis must default to True" + + admin_settings_html = (APP_ROOT / "templates" / "admin_settings.html").read_text(encoding="utf-8") + assert "enable_tabular_hierarchical_analysis" not in admin_settings_html, ( + "Always-on backend-only setting should not gain an admin UI toggle" + ) + + selected_nodes = [ + node + for node in tree.body + if isinstance(node, ast.FunctionDef) + and node.name in {"_env_flag_enabled", "_apply_tabular_parity_env_kill_switch"} + ] + assert len(selected_nodes) == 2 + namespace = {"os": __import__("os")} + exec( + compile(ast.Module(body=selected_nodes, type_ignores=[]), str(settings_file), "exec"), + namespace, + ) + apply_kill_switch = namespace["_apply_tabular_parity_env_kill_switch"] + + import os + + os.environ["SIMPLECHAT_DISABLE_TABULAR_PARITY_DURABLE_PREFLIGHT"] = "true" + try: + settings = { + "tabular_request_planner_mode": "active", + "enable_tabular_search_shared_preflight": True, + "enable_tabular_analyze_durable_preflight": True, + "enable_tabular_hierarchical_analysis": True, + } + result = apply_kill_switch(settings) + assert result["enable_tabular_hierarchical_analysis"] is False + finally: + del os.environ["SIMPLECHAT_DISABLE_TABULAR_PARITY_DURABLE_PREFLIGHT"] + + +if __name__ == "__main__": + tests = [ + test_line_phrasing_is_recognized_as_hierarchical_analysis_intent, + test_customer_prompt_routes_to_durable_hierarchical_analysis_for_analyze_and_search, + test_enable_tabular_hierarchical_analysis_defaults_active, + ] + results = [] + for test in tests: + print(f"\nRunning {test.__name__}...") + try: + test() + results.append(True) + print(f"PASS {test.__name__}") + except Exception as exc: + print(f"FAIL {test.__name__}: {exc}") + import traceback + traceback.print_exc() + results.append(False) + passed_count = sum(1 for result in results if result) + print(f"\nResults: {passed_count}/{len(results)} tests passed") + sys.exit(0 if all(results) else 1) diff --git a/functional_tests/test_tabular_parity_stale_settings_migration.py b/functional_tests/test_tabular_parity_stale_settings_migration.py new file mode 100644 index 000000000..43718c940 --- /dev/null +++ b/functional_tests/test_tabular_parity_stale_settings_migration.py @@ -0,0 +1,186 @@ +# test_tabular_parity_stale_settings_migration.py +#!/usr/bin/env python3 +""" +Functional test for the tabular durable-preflight parity stale-settings migration. +Version: 0.250.198 +Implemented in: 0.250.198 + +deep_merge_dicts() (used by get_settings() to merge code-level defaults into a +persisted Cosmos settings document) only fills in keys that are *missing* from +the stored document; it never overwrites a key that already exists. The four +tabular durable-preflight parity flags (tabular_request_planner_mode, +enable_tabular_search_shared_preflight, enable_tabular_analyze_durable_preflight, +enable_tabular_hierarchical_analysis) were originally introduced with off/False +defaults, so any deployment whose settings document already stored those keys +kept the old off/False values forever, even after the code-level defaults were +later raised to active/True. Every tabular Analyze/Search request in such a +deployment silently kept falling back to the legacy bounded foreground path. + +This test ensures normalize_tabular_parity_durable_preflight_defaults() corrects +stale persisted values to the active defaults, leaves already-correct settings +untouched (no unnecessary Cosmos upsert), and that it is wired into +get_settings()'s merge/upsert flow. +""" + +import ast +from pathlib import Path + +from test_support.versioning import assert_app_version_at_least + +ROOT_DIR = Path(__file__).resolve().parents[1] +SETTINGS_FILE = ROOT_DIR / "application" / "single_app" / "functions_settings.py" +IMPLEMENTED_VERSION = "0.250.198" + +ACTIVE_DEFAULTS = { + "tabular_request_planner_mode": "active", + "enable_tabular_search_shared_preflight": True, + "enable_tabular_analyze_durable_preflight": True, + "enable_tabular_hierarchical_analysis": True, +} + +STALE_PRE_ACTIVATION_VALUES = { + "tabular_request_planner_mode": "off", + "enable_tabular_search_shared_preflight": False, + "enable_tabular_analyze_durable_preflight": False, + "enable_tabular_hierarchical_analysis": False, +} + + +def load_migration_function(): + """Load normalize_tabular_parity_durable_preflight_defaults() without importing + the full functions_settings module (it constructs Azure clients at import time).""" + source = SETTINGS_FILE.read_text(encoding="utf-8") + tree = ast.parse(source, filename=str(SETTINGS_FILE)) + + selected_nodes = [] + for node in tree.body: + if isinstance(node, ast.FunctionDef) and node.name == "normalize_tabular_parity_durable_preflight_defaults": + selected_nodes.append(node) + if ( + isinstance(node, ast.Assign) + and len(node.targets) == 1 + and isinstance(node.targets[0], ast.Name) + and node.targets[0].id == "TABULAR_PARITY_DURABLE_PREFLIGHT_ACTIVE_DEFAULTS" + ): + selected_nodes.append(node) + + assert len(selected_nodes) == 2, ( + "Expected both TABULAR_PARITY_DURABLE_PREFLIGHT_ACTIVE_DEFAULTS and " + "normalize_tabular_parity_durable_preflight_defaults() to be present" + ) + + namespace = {} + exec( + compile(ast.Module(body=selected_nodes, type_ignores=[]), str(SETTINGS_FILE), "exec"), + namespace, + ) + return namespace["normalize_tabular_parity_durable_preflight_defaults"], namespace[ + "TABULAR_PARITY_DURABLE_PREFLIGHT_ACTIVE_DEFAULTS" + ] + + +def test_active_defaults_constant_matches_expected_values(): + """The active-defaults map must match the values get_settings() ships.""" + assert_app_version_at_least(IMPLEMENTED_VERSION) + _, active_defaults = load_migration_function() + assert active_defaults == ACTIVE_DEFAULTS + + +def test_stale_pre_activation_settings_are_upgraded(): + """A settings document persisted before the parity defaults were raised gets corrected.""" + assert_app_version_at_least(IMPLEMENTED_VERSION) + normalize_fn, _ = load_migration_function() + + settings = dict(STALE_PRE_ACTIVATION_VALUES) + settings["unrelated_key"] = "left_alone" + + changed = normalize_fn(settings) + + assert changed is True, "Stale off/False parity flags must be reported as changed" + for key, expected_value in ACTIVE_DEFAULTS.items(): + assert settings[key] == expected_value, f"{key} was not upgraded to its active default" + assert settings["unrelated_key"] == "left_alone" + + +def test_already_active_settings_are_left_untouched(): + """Settings that already match the active defaults report no change (avoids Cosmos churn).""" + assert_app_version_at_least(IMPLEMENTED_VERSION) + normalize_fn, _ = load_migration_function() + + settings = dict(ACTIVE_DEFAULTS) + changed = normalize_fn(settings) + + assert changed is False, "Already-active parity flags must not be reported as changed" + assert settings == ACTIVE_DEFAULTS + + +def test_partial_drift_is_corrected(): + """Only the flags that drifted from active should be corrected; others stay reported as changed.""" + assert_app_version_at_least(IMPLEMENTED_VERSION) + normalize_fn, _ = load_migration_function() + + settings = dict(ACTIVE_DEFAULTS) + settings["enable_tabular_hierarchical_analysis"] = False + + changed = normalize_fn(settings) + + assert changed is True + assert settings["enable_tabular_hierarchical_analysis"] is True + assert settings["tabular_request_planner_mode"] == "active" + + +def test_non_dict_input_is_handled_safely(): + """Defensive guard: non-dict input must not raise and must report no change.""" + assert_app_version_at_least(IMPLEMENTED_VERSION) + normalize_fn, _ = load_migration_function() + + assert normalize_fn(None) is False + assert normalize_fn("not-a-dict") is False + + +def test_migration_is_wired_into_get_settings_merge_flow(): + """normalize_tabular_parity_durable_preflight_defaults() must run on every settings load + and trigger the Cosmos upsert when it corrects stale values.""" + assert_app_version_at_least(IMPLEMENTED_VERSION) + source = SETTINGS_FILE.read_text(encoding="utf-8") + + assert "tabular_parity_durable_preflight_settings_updated = normalize_tabular_parity_durable_preflight_defaults(merged)" in source, ( + "get_settings() must call normalize_tabular_parity_durable_preflight_defaults(merged) " + "during its merge/migration step" + ) + + get_settings_start = source.index("def get_settings(") + upsert_condition_start = source.index("cosmos_settings_container.upsert_item(merged)", get_settings_start) + condition_block = source[get_settings_start:upsert_condition_start] + + assert "or tabular_parity_durable_preflight_settings_updated" in condition_block, ( + "tabular_parity_durable_preflight_settings_updated must be included in the " + "upsert-trigger condition so corrected values are persisted back to Cosmos DB" + ) + + +if __name__ == "__main__": + tests = [ + test_active_defaults_constant_matches_expected_values, + test_stale_pre_activation_settings_are_upgraded, + test_already_active_settings_are_left_untouched, + test_partial_drift_is_corrected, + test_non_dict_input_is_handled_safely, + test_migration_is_wired_into_get_settings_merge_flow, + ] + failures = 0 + for test in tests: + try: + test() + print(f"PASS: {test.__name__}") + except AssertionError as exc: + failures += 1 + print(f"FAIL: {test.__name__}: {exc}") + except Exception as exc: + failures += 1 + print(f"ERROR: {test.__name__}: {exc}") + + total = len(tests) + print(f"\n{total - failures}/{total} tests passed") + import sys + sys.exit(0 if failures == 0 else 1) diff --git a/functional_tests/test_tabular_phase8_ui_telemetry_rollout.py b/functional_tests/test_tabular_phase8_ui_telemetry_rollout.py index 776639769..41ab5451e 100644 --- a/functional_tests/test_tabular_phase8_ui_telemetry_rollout.py +++ b/functional_tests/test_tabular_phase8_ui_telemetry_rollout.py @@ -2,8 +2,8 @@ # test_tabular_phase8_ui_telemetry_rollout.py """ Functional test for Phase 8 tabular UI telemetry and rollout metadata. -Version: 0.250.177 -Implemented in: 0.250.164; planning-only metadata hardening in 0.250.167; Phase 7 harness compatibility in 0.250.177 +Version: 0.250.199 +Implemented in: 0.250.164; planning-only metadata hardening in 0.250.167; Phase 7 harness compatibility in 0.250.177; safe failure metadata in 0.250.199 This test ensures shared tabular planner rollout assignment is stable and redacted, backend-only rollout controls remain sanitized from frontend @@ -100,6 +100,7 @@ def load_public_status_helpers(): "_build_tabular_run_deferred_composition_reference", "_build_tabular_run_rollout_assignment_public_fields", "_build_tabular_run_lifecycle_public_fields", + "_build_safe_tabular_run_failure", "_build_run_public_status", } selected_nodes = [ @@ -151,6 +152,8 @@ def normalize_task_type(task_type): "TABULAR_EXPORT_STATUS_CANCELED": "canceled", "TABULAR_EXPORT_TERMINAL_STATUSES": {"completed", "failed", "canceled"}, "TABULAR_ARTIFACT_SET_LIFECYCLE_COMPLETED": "completed", + "TABULAR_ARTIFACT_SET_LIFECYCLE_FAILED": "failed", + "TABULAR_ARTIFACT_SET_LIFECYCLE_ROLLBACK_REQUIRED": "rollback_required", "TABULAR_RUN_TASK_STRUCTURED_EXPORT": "structured_export", "TABULAR_RUN_TASK_HIERARCHICAL_ANALYSIS": "hierarchical_analysis", "TABULAR_RUN_TASK_COMBINED": "combined", @@ -369,6 +372,35 @@ def test_public_generated_output_status_has_safe_phase8_metadata(): assert_false(forbidden_value in serialized_status, f"redacted {forbidden_value}") +def test_public_generated_output_status_exposes_only_safe_failure_details(): + print("Testing safe generated-output failure status metadata...") + assert_app_version_at_least("0.250.199") + helpers = load_public_status_helpers() + raw_provider_error = ( + "DeploymentNotFound at https://private-resource.openai.azure.com with secret-token-value" + ) + public_status = helpers["_build_run_public_status"]({ + "id": "run-failed", + "conversation_id": "conversation-1", + "task_type": "hierarchical_analysis", + "status": "failed", + "last_error": raw_provider_error, + "output_format": "md", + "row_count": 200, + "batch_count": 1, + "completed_batches": 0, + }) + + assert_equal(public_status["failure_code"], "model_deployment_unavailable", "failure code") + assert_true( + "selected model deployment is unavailable" in public_status["failure_detail"].lower(), + "safe failure detail", + ) + serialized_status = json.dumps(public_status).lower() + assert_false("private-resource" in serialized_status, "private endpoint excluded") + assert_false("secret-token-value" in serialized_status, "provider payload excluded") + + def test_shared_preflight_telemetry_uses_safe_rollout_dimensions(): """Search and Analyze shared preflight emitters must expose only safe rollout dimensions.""" chat_route_source = CHAT_ROUTES.read_text(encoding="utf-8") @@ -386,6 +418,7 @@ def test_shared_preflight_telemetry_uses_safe_rollout_dimensions(): test_rollout_assignment_is_stable_redacted_and_percent_gated, test_backend_rollout_settings_stay_sanitized, test_public_generated_output_status_has_safe_phase8_metadata, + test_public_generated_output_status_exposes_only_safe_failure_details, test_shared_preflight_telemetry_uses_safe_rollout_dimensions, ] failures = [] diff --git a/functional_tests/test_tabular_row_orchestration_scale.py b/functional_tests/test_tabular_row_orchestration_scale.py index 73fb8e0bc..ae8833440 100644 --- a/functional_tests/test_tabular_row_orchestration_scale.py +++ b/functional_tests/test_tabular_row_orchestration_scale.py @@ -1,8 +1,8 @@ # test_tabular_row_orchestration_scale.py """ Functional test for scalable per-row tabular orchestration. -Version: 0.250.180 -Implemented in: 0.250.060; generated CSV formula safety in 0.250.065; generated file export routing in 0.250.072; source descriptor generalization in 0.250.127; unified durable run contract in 0.250.128; hierarchical analysis in 0.250.129; combined analysis and export in 0.250.130; scale validation in 0.250.132; direct source-backed exhaustive queueing in 0.250.133; direct queue call-site hardening in 0.250.134; model-validation auto retry in 0.250.135; model-aware parallel throughput in 0.250.136; Phase 1 acceleration contracts and observability in 0.250.137; Phase 2 truthful background handoff in 0.250.138; Phase 3 durable LLM generation planning in 0.250.139; Phase 4 compact row response protocol in 0.250.140; Phase 5 completion-driven checkpointing in 0.250.141; Phase 6 rolling worker pool in 0.250.142; Phase 7 independent batch retries in 0.250.143; Phase 8 scale, chaos, and rollout in 0.250.144; background metadata streaming fix in 0.250.145; source-token echo recovery in 0.250.146; fixed-window stale heartbeat fix in 0.250.147; nested CSV output recovery in 0.250.148; generic tabular artifact routing and fast startup in 0.250.149; balanced concurrency waves and default completion checkpoints in 0.250.152; Search shared preflight adapter in 0.250.159; aggregate route-helper harness coverage in 0.250.166; Analyze artifact Phase 7A harness compatibility updated in 0.250.178; reviewed correctness planning and semantic validation updated in 0.250.179; artifact publication lifecycle updated in 0.250.180 +Version: 0.250.199 +Implemented in: 0.250.060; generated CSV formula safety in 0.250.065; generated file export routing in 0.250.072; source descriptor generalization in 0.250.127; unified durable run contract in 0.250.128; hierarchical analysis in 0.250.129; combined analysis and export in 0.250.130; scale validation in 0.250.132; direct source-backed exhaustive queueing in 0.250.133; direct queue call-site hardening in 0.250.134; model-validation auto retry in 0.250.135; model-aware parallel throughput in 0.250.136; Phase 1 acceleration contracts and observability in 0.250.137; Phase 2 truthful background handoff in 0.250.138; Phase 3 durable LLM generation planning in 0.250.139; Phase 4 compact row response protocol in 0.250.140; Phase 5 completion-driven checkpointing in 0.250.141; Phase 6 rolling worker pool in 0.250.142; Phase 7 independent batch retries in 0.250.143; Phase 8 scale, chaos, and rollout in 0.250.144; background metadata streaming fix in 0.250.145; source-token echo recovery in 0.250.146; fixed-window stale heartbeat fix in 0.250.147; nested CSV output recovery in 0.250.148; generic tabular artifact routing and fast startup in 0.250.149; balanced concurrency waves and default completion checkpoints in 0.250.152; Search shared preflight adapter in 0.250.159; aggregate route-helper harness coverage in 0.250.166; Analyze artifact Phase 7A harness compatibility updated in 0.250.178; reviewed correctness planning and semantic validation updated in 0.250.179; artifact publication lifecycle updated in 0.250.180; safe failure helper compatibility updated in 0.250.199 This test ensures generated exports preserve source identity and row order while enforcing one stable output schema across independently generated batches. @@ -138,6 +138,7 @@ '_can_auto_retry_failed_run', '_is_artifact_publication_recoverable', '_can_resume_run', + '_build_safe_tabular_run_failure', '_mark_run_failed', '_get_auto_retry_limit_for_category', '_mark_run_retryable', @@ -152,6 +153,8 @@ 'TABULAR_EXPORT_DEFAULT_MODEL_VALIDATION_AUTO_RETRIES', 'TABULAR_EXPORT_RETRYABLE_MESSAGE_MARKERS', 'TABULAR_EXPORT_MODEL_VALIDATION_RETRYABLE_MESSAGE_MARKERS', + 'TABULAR_ARTIFACT_SET_LIFECYCLE_FAILED', + 'TABULAR_ARTIFACT_SET_LIFECYCLE_ROLLBACK_REQUIRED', } LEGACY_MIGRATION_FUNCTIONS = { '_normalize_source_identity_label', diff --git a/functional_tests/test_tabular_search_analyze_artifact_matrix.py b/functional_tests/test_tabular_search_analyze_artifact_matrix.py new file mode 100644 index 000000000..0bb75cd1e --- /dev/null +++ b/functional_tests/test_tabular_search_analyze_artifact_matrix.py @@ -0,0 +1,175 @@ +# test_tabular_search_analyze_artifact_matrix.py +#!/usr/bin/env python3 +""" +Functional matrix for Search and Analyze durable tabular artifact ownership. +Version: 0.250.199 +Implemented in: 0.250.199 + +This test drives the real shared planner, persisted-metadata sanitizer, +artifact-set manifest, validation, and public projection for four customer +scenarios over the deterministic 200-row financial-review fixture. +""" + +import sys +from pathlib import Path + +from test_support.versioning import assert_app_version_at_least + + +ROOT_DIR = Path(__file__).resolve().parents[1] +APP_ROOT = ROOT_DIR / "application" / "single_app" +TEST_ROOT = Path(__file__).resolve().parent +IMPLEMENTED_VERSION = "0.250.199" + +for import_path in (APP_ROOT, TEST_ROOT): + if str(import_path) not in sys.path: + sys.path.insert(0, str(import_path)) + +from functions_tabular_orchestration import plan_tabular_request # noqa: E402 +from test_support.analyze_deliverable_contract_fixture import ( # noqa: E402 + FINANCIAL_REVIEW_PROMPT, + build_financial_review_source_rows, +) +from test_tabular_phase5_artifact_set_lifecycle import ( # noqa: E402 + build_artifact, + load_artifact_set_helpers, +) + + +LINE_BY_LINE_PROMPT = ( + "For each line in this document, answer all eight questions individually. " + "Go line by line and do not consolidate or omit any line." +) + +SETTINGS = { + "enable_tabular_hierarchical_analysis": True, + "tabular_request_planner_mode": "active", + "tabular_analyze_parity_rollout_percent": 100, + "tabular_analyze_parity_rollout_state": "active", +} + +FILE_CONTEXT = { + "file_name": "financial_review.csv", + "document_id": "financial-review-doc", + "source_version": "etag-financial-review-v1", + "source_hint": "workspace", +} + + +def _publish_planned_artifacts(plan, expected_formats): + helpers = load_artifact_set_helpers() + sanitized_metadata = helpers["_normalize_tabular_run_planner_metadata"](plan) + run = { + "id": f"run-{plan['action_mode']}-{plan['durable_task_type']}", + "conversation_id": "conversation-1", + "user_id": "user-1", + "task_type": plan["durable_task_type"], + "status": "running", + "output_format": plan["output_format"] or "md", + "source_file_name": "financial_review.csv", + "row_count": 200, + "processed_rows": 200, + "tabular_planner_metadata": sanitized_metadata, + } + descriptors = helpers["_get_artifact_descriptors_for_run"](run) + structured_artifacts = [] + published_member_ids = [] + + for descriptor in descriptors: + artifact = build_artifact( + f"message-{descriptor['member_id']}", + f"financial_review.{descriptor['format']}", + descriptor["format"], + ) + artifact["artifact_id"] = descriptor["member_id"] + artifact["member_id"] = descriptor["member_id"] + if descriptor["role"] == "primary_analysis": + run["analysis_artifact"] = artifact + else: + structured_artifacts.append(artifact) + helpers["_set_artifact_set_member_state"]( + run, + descriptor["member_id"], + artifact=artifact, + lifecycle_state="staged", + validation_state="validated", + ) + published_member_ids.append(descriptor["member_id"]) + + if structured_artifacts: + run["structured_export_artifacts"] = structured_artifacts + run["structured_export_artifact"] = structured_artifacts[0] + run["status"] = "completed" + + manifest = helpers["_publish_artifact_set_members"](run, published_member_ids) + public_artifacts = helpers["_build_public_generated_artifacts_from_manifest"](run, manifest) + + assert manifest["lifecycle_state"] == "completed", manifest + assert manifest["validation_report"]["valid"] is True + assert [artifact["output_format"] for artifact in public_artifacts] == expected_formats + assert [artifact["row_count"] for artifact in public_artifacts] == [200] * len(expected_formats) + assert len(public_artifacts) == len(expected_formats) + + +def test_search_analyze_artifact_matrix(): + """Each mode and prompt shape must own exactly its contract-defined artifacts.""" + assert_app_version_at_least(IMPLEMENTED_VERSION) + assert len(build_financial_review_source_rows()) == 200 + + scenarios = [ + { + "name": "search_explicit_csv", + "action_mode": "search", + "prompt": f"{FINANCIAL_REVIEW_PROMPT}\nCreate one complete downloadable CSV.", + "task_type": "structured_export", + "formats": ["csv"], + }, + { + "name": "analyze_explicit_csv", + "action_mode": "analyze", + "prompt": f"{FINANCIAL_REVIEW_PROMPT}\nCreate one complete downloadable CSV.", + "task_type": "combined", + "formats": ["md", "csv"], + }, + { + "name": "search_implicit_markdown", + "action_mode": "search", + "prompt": LINE_BY_LINE_PROMPT, + "task_type": "hierarchical_analysis", + "formats": ["md"], + }, + { + "name": "analyze_implicit_markdown", + "action_mode": "analyze", + "prompt": LINE_BY_LINE_PROMPT, + "task_type": "hierarchical_analysis", + "formats": ["md"], + }, + ] + + for scenario in scenarios: + plan = plan_tabular_request( + scenario["prompt"], + [FILE_CONTEXT], + action_mode=scenario["action_mode"], + settings=SETTINGS, + ) + assert plan["durable_task_type"] == scenario["task_type"], scenario["name"] + contract_formats = [ + artifact["format"] + for artifact in plan["deliverable_contract"]["requested_artifacts"] + ] + assert contract_formats == scenario["formats"], scenario["name"] + assert plan["deliverable_contract"]["analysis_required"] is ( + scenario["task_type"] != "structured_export" + ) + _publish_planned_artifacts(plan, scenario["formats"]) + + +if __name__ == "__main__": + try: + test_search_analyze_artifact_matrix() + print("PASS: test_search_analyze_artifact_matrix") + except Exception as exc: + print(f"FAIL: test_search_analyze_artifact_matrix: {exc}") + raise diff --git a/ui_tests/test_chat_background_generated_export_status.py b/ui_tests/test_chat_background_generated_export_status.py index 8ca20efce..caecacea9 100644 --- a/ui_tests/test_chat_background_generated_export_status.py +++ b/ui_tests/test_chat_background_generated_export_status.py @@ -1,8 +1,8 @@ # test_chat_background_generated_export_status.py """ UI test for chat background generated export status cards. -Version: 0.250.182 -Implemented in: 0.241.046; cancellation in 0.250.060; automatic-only refresh in 0.250.061; combined progress and large-run confirmation in 0.250.131; throughput and concurrency status in 0.250.136; truthful background handoff in 0.250.138; collapsed operational details in 0.250.150; confirmation deduplication in 0.250.169; plural artifact-set completion rendering in 0.250.176; empty plural artifact-set fallback suppression in 0.250.182 +Version: 0.250.199 +Implemented in: 0.241.046; cancellation in 0.250.060; automatic-only refresh in 0.250.061; combined progress and large-run confirmation in 0.250.131; throughput and concurrency status in 0.250.136; truthful background handoff in 0.250.138; collapsed operational details in 0.250.150; confirmation deduplication in 0.250.169; plural artifact-set completion rendering in 0.250.176; empty plural artifact-set fallback suppression in 0.250.182; hierarchical completion and safe failure details in 0.250.199 This test ensures queued tabular generated exports render progress in chat and turn into a downloadable artifact when complete or a visible canceled state. @@ -373,6 +373,202 @@ def test_chat_combined_completion_renders_plural_artifact_set(playwright) -> Non browser.close() +@pytest.mark.ui +def test_chat_hierarchical_completion_renders_markdown_download(playwright) -> None: + """Validate completed hierarchical analysis replaces progress with its Markdown artifact.""" + browser = playwright.chromium.launch() + context = browser.new_context(viewport={"width": 1440, "height": 900}) + page = context.new_page() + page_errors = [] + page.on("pageerror", lambda error: page_errors.append(str(error))) + + try: + with _start_static_test_server() as server_base_url: + page.route( + "**/api/tabular/generated-output/runs/run-hierarchical-md", + lambda route: route.fulfill( + status=200, + content_type="application/json", + json={ + "success": True, + "run": { + "run_id": "run-hierarchical-md", + "conversation_id": "conversation-ui-test", + "task_type": "hierarchical_analysis", + "status": "completed", + "row_count": 200, + "processed_rows": 200, + "batch_count": 1, + "completed_batches": 1, + "progress_percent": 100, + "artifact_set": { + "lifecycle_state": "completed", + "validation_state": "validated", + "primary_artifact_id": "analysis", + "member_count": 1, + "published_member_count": 1, + }, + "generated_artifacts": [{ + "artifact_id": "analysis", + "role": "primary_analysis", + "capability": "analyze", + "artifact_message_id": "artifact-md-ui-test", + "conversation_id": "conversation-ui-test", + "file_name": "financial_review_analysis.md", + "output_format": "md", + "row_count": 200, + "storage_scope": "chat", + "preview_text": "# Financial review\n\nAll 200 rows were analyzed.", + }], + }, + }, + ), + ) + response = page.goto(f"{server_base_url}/{HARNESS_PATH}", wait_until="domcontentloaded") + assert response is not None and response.ok + _install_minimal_chat_dom(page) + page.evaluate( + """ + async () => { + const module = await import('/application/single_app/static/js/chat/chat-messages.js'); + module.appendMessage( + 'AI', + 'The full-source analysis is continuing in the background.', + null, + 'message-hierarchical-md', + false, + [], [], [], null, null, + { + metadata: { + generated_tabular_outputs: [{ + capability: 'tabular', + background_export: true, + export_run_id: 'run-hierarchical-md', + run_id: 'run-hierarchical-md', + task_type: 'hierarchical_analysis', + status: 'running', + output_format: 'md', + row_count: 200, + processed_rows: 0, + batch_count: 1, + completed_batches: 0, + suppress_assistant_text: true, + }] + } + }, + false + ); + } + """ + ) + + message = page.locator('[data-message-id="message-hierarchical-md"]') + expect(message.get_by_text("Background analysis")).to_be_visible() + expect( + message.get_by_role("button", name="Download financial_review_analysis.md") + ).to_be_visible(timeout=15000) + expect(message.get_by_text("Analyze MD artifact", exact=True)).to_be_visible() + assert page_errors == [] + finally: + context.close() + browser.close() + + +@pytest.mark.ui +def test_chat_failed_background_analysis_shows_safe_reason(playwright) -> None: + """Validate failed background analysis explains the safe failure category in details.""" + browser = playwright.chromium.launch() + context = browser.new_context(viewport={"width": 1440, "height": 900}) + page = context.new_page() + page_errors = [] + page.on("pageerror", lambda error: page_errors.append(str(error))) + + try: + with _start_static_test_server() as server_base_url: + page.route( + "**/api/tabular/generated-output/runs/run-safe-failure", + lambda route: route.fulfill( + status=200, + content_type="application/json", + json={ + "success": True, + "run": { + "run_id": "run-safe-failure", + "task_type": "hierarchical_analysis", + "status": "failed", + "status_label": "Failed", + "status_tone": "danger", + "status_detail": "The selected model deployment is unavailable. Select another model or ask an administrator to verify the model endpoint.", + "failure_code": "model_deployment_unavailable", + "failure_detail": "The selected model deployment is unavailable. Select another model or ask an administrator to verify the model endpoint.", + "retryable_failure": False, + "can_resume": False, + "can_cancel": True, + "row_count": 200, + "processed_rows": 0, + "batch_count": 1, + "completed_batches": 0, + "progress_percent": 0, + "artifact_set": { + "lifecycle_state": "failed", + "validation_state": "invalid", + "member_count": 1, + "published_member_count": 0, + }, + }, + }, + ), + ) + response = page.goto(f"{server_base_url}/{HARNESS_PATH}", wait_until="domcontentloaded") + assert response is not None and response.ok + _install_minimal_chat_dom(page) + page.evaluate( + """ + async () => { + const module = await import('/application/single_app/static/js/chat/chat-messages.js'); + module.appendMessage( + 'AI', + 'The full-source analysis is continuing in the background.', + null, + 'message-safe-failure', + false, + [], [], [], null, null, + { + metadata: { + generated_tabular_outputs: [{ + capability: 'tabular', + background_export: true, + export_run_id: 'run-safe-failure', + run_id: 'run-safe-failure', + task_type: 'hierarchical_analysis', + status: 'running', + output_format: 'md', + row_count: 200, + batch_count: 1, + completed_batches: 0, + suppress_assistant_table_export: true, + }] + } + }, + false + ); + } + """ + ) + + message = page.locator('[data-message-id="message-safe-failure"]') + expect(message.get_by_text("Failed", exact=True)).to_be_visible(timeout=15000) + message.get_by_text("View details", exact=True).click() + expect( + message.get_by_text("The selected model deployment is unavailable.", exact=False) + ).to_be_visible() + expect(message.get_by_text("DeploymentNotFound", exact=False)).to_have_count(0) + assert page_errors == [] + finally: + context.close() + browser.close() + + @pytest.mark.ui def test_chat_empty_plural_artifact_set_does_not_render_legacy_fallback(playwright) -> None: """Validate an explicit empty generated_artifacts array suppresses legacy singular fallback."""