From f3a1d375b5f07a7b7b7ee902a3c09e2f26c3c2ee Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Thu, 13 Aug 2026 15:17:25 -0400 Subject: [PATCH 1/2] Fix tabular Analyze truncation and combined-run schema lock (0.250.189) - Default tabular Analyze/Search durable-preflight parity to active (tabular_request_planner_mode, enable_tabular_search_shared_preflight, enable_tabular_analyze_durable_preflight); previously off-by-default with no admin UI toggle, so exhaustive row-by-row requests silently fell back to bounded foreground synthesis and truncated. Add SIMPLECHAT_DISABLE_TABULAR_PARITY_DURABLE_PREFLIGHT env var as the emergency rollback path instead of a UI toggle. - Add scheduler tick/skip visibility logging (debug_print) to the tabular generated-output, file sync, and data management scheduler loops, and stop suppressing the tabular scheduler scan-result log on empty scans, to diagnose stuck background exports. - Fix queue_tabular_generated_output_run locking output_schema to the lineage-only internal checkpoint schema before batch 1 runs whenever no public output schema is known yet (e.g. combined Analyze requests with prose-described columns). This made every batch, including batch 1, fail schema validation against a schema with none of the model's real output columns. Now defers to batch-1 discovery when public_output_schema is empty, matching the working Search/structured_export behavior. - Add regression tests: settings defaults + env kill switch, a real (unmocked) deliverable-contract reproduction of the bug precondition, and a full end-to-end AST-extracted invocation of queue_tabular_generated_output_run itself. Fixes truncated exhaustive tabular Analyze answers and stuck/failing combined CSV exports reported in production. --- application/single_app/background_tasks.py | 19 +- application/single_app/config.py | 2 +- application/single_app/functions_settings.py | 28 +- .../functions_tabular_generated_exports.py | 29 +- .../TABULAR_ANALYZE_SEARCH_PARITY_ROLLOUT.md | 10 +- ...ZE_SEARCH_PARITY_DEFAULT_ACTIVATION_FIX.md | 59 +++ docs/reference/admin_configuration.md | 10 +- ...nalyze_search_parity_default_activation.py | 167 ++++++++ ...lar_combined_output_schema_deferral_fix.py | 144 +++++++ ...ular_queue_run_output_schema_end_to_end.py | 388 ++++++++++++++++++ 10 files changed, 827 insertions(+), 29 deletions(-) create mode 100644 docs/explanation/fixes/TABULAR_ANALYZE_SEARCH_PARITY_DEFAULT_ACTIVATION_FIX.md create mode 100644 functional_tests/test_tabular_analyze_search_parity_default_activation.py create mode 100644 functional_tests/test_tabular_combined_output_schema_deferral_fix.py create mode 100644 functional_tests/test_tabular_queue_run_output_schema_end_to_end.py diff --git a/application/single_app/background_tasks.py b/application/single_app/background_tasks.py index 9434749f5..4d72fde48 100644 --- a/application/single_app/background_tasks.py +++ b/application/single_app/background_tasks.py @@ -705,7 +705,10 @@ def run_file_sync_scheduler_loop(): try: lock_document = acquire_distributed_task_lock('file_sync_scheduler_scan', lease_seconds=300) if lock_document: - check_due_file_sync_sources_once() + due_sources = check_due_file_sync_sources_once() + debug_print(f"File Sync scheduler tick processed {len(due_sources or [])} source(s).") + else: + debug_print('Skipping File Sync scheduler tick because another worker holds the lease.') except Exception as exc: print(f"Error in File Sync scheduler check: {exc}") log_event(f"[FILE_SYNC] Error in scheduler check: {exc}", level=logging.ERROR) @@ -723,7 +726,14 @@ def run_tabular_generated_output_scheduler_loop(): try: lock_document = acquire_distributed_task_lock('tabular_generated_output_scheduler_scan', lease_seconds=120) if lock_document: - check_due_tabular_generated_output_runs_once() + processed_run_ids = check_due_tabular_generated_output_runs_once() + debug_print( + f"Tabular generated-output scheduler tick processed {len(processed_run_ids or [])} run(s)." + ) + else: + debug_print( + 'Skipping tabular generated-output scheduler tick because another worker holds the lease.' + ) except Exception as exc: print(f"Error in tabular generated-output scheduler check: {exc}") log_event(f"[TABULAR_GENERATED_OUTPUT] Error in scheduler check: {exc}", level=logging.ERROR) @@ -741,7 +751,10 @@ def run_data_management_scheduler_loop(app=None): try: lock_document = acquire_distributed_task_lock('data_management_scheduler_scan', lease_seconds=300) if lock_document: - check_due_data_management_jobs_once(app=app) + due_jobs = check_due_data_management_jobs_once(app=app) + debug_print(f"Data Management scheduler tick processed {len(due_jobs or [])} job(s).") + else: + debug_print('Skipping Data Management scheduler tick because another worker holds the lease.') except Exception as exc: print(f"Error in Data Management scheduler check: {exc}") log_event(f"[DATA_MANAGEMENT] Error in scheduler check: {exc}", level=logging.ERROR) diff --git a/application/single_app/config.py b/application/single_app/config.py index b41c2fc85..47c9623d9 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.185" +VERSION = "0.250.189" IS_DEVELOPMENT = is_development_env_enabled() SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax') diff --git a/application/single_app/functions_settings.py b/application/single_app/functions_settings.py index c1cd44a72..903c02c18 100644 --- a/application/single_app/functions_settings.py +++ b/application/single_app/functions_settings.py @@ -18,6 +18,7 @@ import app_settings_cache import inspect import copy +import os import json import uuid from support_menu_config import ( @@ -1010,6 +1011,25 @@ def _update_cache(stage): _update_cache("after_version_bump") +def _env_flag_enabled(name): + return str(os.environ.get(name, '')).strip().lower() in {'1', 'true', 'yes', 'on'} + + +def _apply_tabular_parity_env_kill_switch(settings_payload): + """Force tabular durable-preflight parity off when the emergency env kill switch is set. + + These parity controls ship active by default with no admin UI toggle; this + environment variable is the only rollback path for an operator incident. + """ + if not isinstance(settings_payload, dict): + return settings_payload + 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 + return settings_payload + + def get_settings(use_cosmos=False, include_source=False): import secrets default_settings = { @@ -1048,9 +1068,9 @@ def get_settings(use_cosmos=False, include_source=False): 'tabular_analyze_parity_rollout_percent': 100, 'tabular_analyze_parity_rollout_state': 'active', 'tabular_background_handoff_mode': 'legacy', - 'tabular_request_planner_mode': 'off', - 'enable_tabular_search_shared_preflight': False, - 'enable_tabular_analyze_durable_preflight': False, + 'tabular_request_planner_mode': 'active', + 'enable_tabular_search_shared_preflight': True, + 'enable_tabular_analyze_durable_preflight': True, 'enable_tabular_mixed_deferred_composition_planning': False, 'enable_tabular_multifile_execution_unit_planning': False, 'tabular_legacy_post_tool_fallback_mode': 'enabled', @@ -1615,6 +1635,8 @@ def get_settings(use_cosmos=False, include_source=False): } def _format_result(settings_payload, source): + if isinstance(settings_payload, dict): + settings_payload = _apply_tabular_parity_env_kill_switch(settings_payload) if include_source: return settings_payload, source return settings_payload diff --git a/application/single_app/functions_tabular_generated_exports.py b/application/single_app/functions_tabular_generated_exports.py index 8ee41eef3..d3b298c98 100644 --- a/application/single_app/functions_tabular_generated_exports.py +++ b/application/single_app/functions_tabular_generated_exports.py @@ -10946,7 +10946,9 @@ def queue_tabular_generated_output_run( 'planner_started_at': None, 'planner_completed_at': None, 'processed_rows': 0, - 'output_schema': contract_internal_checkpoint_schema or None, + # Only lock the schema up front when real output columns are already known; otherwise + # defer to batch-1 discovery instead of validating against a lineage-only placeholder. + 'output_schema': contract_internal_checkpoint_schema if contract_public_output_schema else None, 'public_output_schema': contract_public_output_schema, 'internal_checkpoint_schema': contract_internal_checkpoint_schema, 'lineage_schema': [ @@ -11103,17 +11105,16 @@ def check_due_tabular_generated_output_runs_once(limit=None): 'reason': f"{candidate.get('reason')}; claim or processing did not start", }) - if scanned_candidates or candidates: - log_event( - '[TABULAR_GENERATED_OUTPUT] Background scheduler scan result', - { - 'scanned_count': len(scanned_candidates), - 'candidate_count': len(candidates), - 'status_counts': status_counts, - 'processed_run_ids': processed, - 'processed_count': len(processed), - 'skipped': skipped[:10], - }, - debug_only=True, - ) + log_event( + '[TABULAR_GENERATED_OUTPUT] Background scheduler scan result', + { + 'scanned_count': len(scanned_candidates), + 'candidate_count': len(candidates), + 'status_counts': status_counts, + 'processed_run_ids': processed, + 'processed_count': len(processed), + 'skipped': skipped[:10], + }, + debug_only=True, + ) return processed diff --git a/docs/explanation/features/TABULAR_ANALYZE_SEARCH_PARITY_ROLLOUT.md b/docs/explanation/features/TABULAR_ANALYZE_SEARCH_PARITY_ROLLOUT.md index 8164997b9..99cc87839 100644 --- a/docs/explanation/features/TABULAR_ANALYZE_SEARCH_PARITY_ROLLOUT.md +++ b/docs/explanation/features/TABULAR_ANALYZE_SEARCH_PARITY_ROLLOUT.md @@ -37,13 +37,15 @@ Configuration options: All Phase 8 rollout controls are backend-only and are removed by `sanitize_settings_for_user()` before non-admin frontend settings are returned. +> **Update (0.250.186):** `tabular_request_planner_mode` now defaults to `active` and both `enable_tabular_search_shared_preflight` and `enable_tabular_analyze_durable_preflight` default to `True`. Bounded foreground synthesis for exhaustive row-by-row tabular requests is no longer the default behavior. There is intentionally no admin UI toggle for this; use the `SIMPLECHAT_DISABLE_TABULAR_PARITY_DURABLE_PREFLIGHT` environment variable for emergency rollback (see below). + ## Usage Instructions How to enable/configure: -1. Keep the planner mode `off` for the default legacy behavior. -2. Use `shadow` mode to compare planner decisions without queueing shared-planner durable work. -3. Enable the relevant single-source Search or Analyze gate before switching that mode to active traffic. -4. Adjust `tabular_analyze_parity_rollout_percent` for canary cohorts. +1. The planner mode defaults to `active`; no configuration is required for normal operation. +2. Use `shadow` mode only to compare planner decisions without queueing shared-planner durable work, for example while validating a new model or source type. +3. Set the environment variable `SIMPLECHAT_DISABLE_TABULAR_PARITY_DURABLE_PREFLIGHT=true` to force `tabular_request_planner_mode` to `off` and both shared-preflight flags to `False` for every request during an incident; unset it to restore the active default. +4. Adjust `tabular_analyze_parity_rollout_percent` for canary cohorts if a partial rollout is needed. 5. Keep `tabular_legacy_post_tool_fallback_mode='enabled'` until operator telemetry shows no required legacy recovery traffic. Operator telemetry: diff --git a/docs/explanation/fixes/TABULAR_ANALYZE_SEARCH_PARITY_DEFAULT_ACTIVATION_FIX.md b/docs/explanation/fixes/TABULAR_ANALYZE_SEARCH_PARITY_DEFAULT_ACTIVATION_FIX.md new file mode 100644 index 000000000..ce6608552 --- /dev/null +++ b/docs/explanation/fixes/TABULAR_ANALYZE_SEARCH_PARITY_DEFAULT_ACTIVATION_FIX.md @@ -0,0 +1,59 @@ +# Tabular Analyze/Search Parity Default Activation Fix + +Fixed in version: **0.250.186** + +## Issue Description + +Customer testing showed Chat/Analyze still answering exhaustive row-by-row tabular requests (for example, "for each row, answer these eight questions") by producing detailed answers for roughly the first row and a half, then stating the remaining rows were unprocessed, truncated, or outside a bounded evidence handoff. This matched the exact failure mode the multi-phase tabular Analyze/Search parity roadmap (`feature/tabular-analyze-search-parity`, `feature/analyze-artifact-output-contract`) was built to eliminate. + +## Root Cause Analysis + +The durable-preflight parity path (`_maybe_execute_pure_tabular_analyze_preflight` in `functions_workflow_runner.py` for Analyze, and `maybe_queue_search_tabular_generated_output` in `route_backend_chats.py` for Search) is gated by three backend-only settings: `tabular_request_planner_mode`, `enable_tabular_search_shared_preflight`, and `enable_tabular_analyze_durable_preflight`. All three defaulted to `off`/`False` in `functions_settings.py`, and none had an admin UI toggle, so no deployed environment ever ran the durable, exhaustive-coverage path unless an operator manually edited the stored settings document directly (there was no supported way to do this from the Admin Settings UI). Every request instead fell back to the legacy bounded foreground path, which answers using only the tool-call rows that fit in one synthesis turn and explicitly reports the rest as missing/truncated evidence. + +## Version Implemented + +Fixed in version: **0.250.186** + +## Technical Details + +### Files Modified + +- `application/single_app/functions_settings.py` +- `application/single_app/config.py` +- `docs/reference/admin_configuration.md` +- `docs/explanation/features/TABULAR_ANALYZE_SEARCH_PARITY_ROLLOUT.md` +- `functional_tests/test_tabular_analyze_search_parity_default_activation.py` + +### Code Changes Summary + +- `tabular_request_planner_mode` now defaults to `active` (was `off`). +- `enable_tabular_search_shared_preflight` and `enable_tabular_analyze_durable_preflight` now default to `True` (were `False`). +- Added `_apply_tabular_parity_env_kill_switch()` in `functions_settings.py`, applied in `get_settings()`'s `_format_result()` choke point on every return path. When the environment variable `SIMPLECHAT_DISABLE_TABULAR_PARITY_DURABLE_PREFLIGHT` is truthy, it forces `tabular_request_planner_mode` back to `off` and both shared-preflight flags back to `False`, regardless of the stored settings document. This gives operators an emergency rollback path without requiring an admin UI toggle or a direct settings edit, consistent with treating always-on behavior as "on unless an operator opts out," not "off until an operator opts in." +- `enable_tabular_mixed_deferred_composition_planning` and `enable_tabular_multifile_execution_unit_planning` remain `False` by default; per existing documentation these are planning-only metadata controls with no implemented durable execution behind them yet, so enabling them would not change runtime behavior. + +### Testing Approach + +- New functional test asserts the three defaults via AST-based extraction of `get_settings()`'s literal `default_settings` dict (avoids importing the full Flask app), and asserts the env kill switch forces them back off. +- Re-ran the existing tabular parity suites (`test_tabular_shared_request_planner.py`, `test_tabular_analyze_shared_preflight_adapter.py`, `test_tabular_search_shared_preflight_adapter.py`, `test_analyze_artifact_phase7_rollout_rollback.py`, `test_tabular_phase8_ui_telemetry_rollout.py`, `test_tabular_execution_settings_sanitization.py`) to confirm no regressions; all pass unchanged because they construct explicit settings fixtures rather than relying on `get_settings()` defaults. +- Compiled all changed Python files. + +## Impact Analysis + +Chat, Search, and Analyze now route exhaustive per-row/per-source tabular requests through the durable generated-output path by default, matching the behavior validated across the parity roadmap's Phases 1-9 and the analyze-artifact-output-contract Phases 1-7D. Operators who need to roll back during an incident set one environment variable instead of editing settings directly; no code deploy or Cosmos edit is required to disable, and none is required to re-enable. + +## Validation + +### Before + +- `tabular_request_planner_mode=off`, `enable_tabular_search_shared_preflight=False`, `enable_tabular_analyze_durable_preflight=False` in every environment by default. +- Exhaustive row-by-row Chat/Analyze requests answered a small bounded subset of rows, then reported the remainder as unprocessed/truncated evidence, even though the durable parity infrastructure to answer exhaustively already existed and was fully merged. + +### After + +- New defaults route exhaustive tabular requests through the durable preflight/generated-output path automatically. +- `functional_tests/test_tabular_analyze_search_parity_default_activation.py` passes 2/2. +- Existing tabular parity regression suites remain green. + +## Related Version Updates + +- `application/single_app/config.py` was updated to version **0.250.186**. diff --git a/docs/reference/admin_configuration.md b/docs/reference/admin_configuration.md index 50cbb2060..4e0dada47 100644 --- a/docs/reference/admin_configuration.md +++ b/docs/reference/admin_configuration.md @@ -235,15 +235,17 @@ Analyze All requires a ready document access index and uses the configured workf #### Tabular Analyze/Search Parity Controls -Tabular Analyze/Search parity uses backend-only controls so operators can shadow, canary, and roll back shared preflight behavior without exposing rollout settings to the browser: +Tabular Analyze/Search parity ships **active by default** as of `0.250.186`. The durable preflight path is what stops Chat/Search/Analyze from answering exhaustive row-by-row tabular requests with a truncated, bounded foreground answer, so there is no admin UI toggle for it. These remain backend-only controls; use them only for canary/rollback during an incident: | Stage | Setting | Default | |---|---|---| -| Shared planner mode | `tabular_request_planner_mode` | `off` | -| Search shared preflight | `enable_tabular_search_shared_preflight` | Off | -| Pure tabular Analyze durable preflight | `enable_tabular_analyze_durable_preflight` | Off | +| Shared planner mode | `tabular_request_planner_mode` | `active` | +| Search shared preflight | `enable_tabular_search_shared_preflight` | On | +| Pure tabular Analyze durable preflight | `enable_tabular_analyze_durable_preflight` | On | | Mixed deferred-composition planning | `enable_tabular_mixed_deferred_composition_planning` | Off | | Multi-file execution-unit planning | `enable_tabular_multifile_execution_unit_planning` | Off | + +Emergency rollback (no admin UI, no settings edit required): set the App Service/environment variable `SIMPLECHAT_DISABLE_TABULAR_PARITY_DURABLE_PREFLIGHT=true` to force `tabular_request_planner_mode` back to `off` and both shared-preflight flags back to `False` for every request, regardless of stored settings. Remove or unset the variable to restore the active default. | Parity rollout percentage | `tabular_analyze_parity_rollout_percent` | `100` | | Legacy post-tool fallback mode | `tabular_legacy_post_tool_fallback_mode` | `enabled` | diff --git a/functional_tests/test_tabular_analyze_search_parity_default_activation.py b/functional_tests/test_tabular_analyze_search_parity_default_activation.py new file mode 100644 index 000000000..523a802fa --- /dev/null +++ b/functional_tests/test_tabular_analyze_search_parity_default_activation.py @@ -0,0 +1,167 @@ +# test_tabular_analyze_search_parity_default_activation.py +#!/usr/bin/env python3 +""" +Functional test for tabular Analyze/Search durable-preflight parity defaults. +Version: 0.250.186 +Implemented in: 0.250.186 + +This test ensures the tabular Analyze/Search parity durable-preflight controls +default to active (no admin UI toggle required) and that the +SIMPLECHAT_DISABLE_TABULAR_PARITY_DURABLE_PREFLIGHT environment variable can +force them back off for emergency rollback. +""" + +import ast +import sys +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.186" + + +def _find_get_settings_default_dict(tree): + 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" + ): + return stmt.value + raise AssertionError("Could not locate default_settings dict in get_settings()") + + +def load_default_settings_literal(): + """Extract selected literal values from the default_settings dict via AST. + + The dict also contains non-literal expressions (helper function calls), so + only the specific keys under test are evaluated. + """ + source = SETTINGS_FILE.read_text(encoding="utf-8") + tree = ast.parse(source, filename=str(SETTINGS_FILE)) + dict_node = _find_get_settings_default_dict(tree) + + wanted_keys = { + "tabular_request_planner_mode", + "enable_tabular_search_shared_preflight", + "enable_tabular_analyze_durable_preflight", + "enable_tabular_mixed_deferred_composition_planning", + "enable_tabular_multifile_execution_unit_planning", + } + result = {} + for key_node, value_node in zip(dict_node.keys, dict_node.values): + if isinstance(key_node, ast.Constant) and key_node.value in wanted_keys: + result[key_node.value] = ast.literal_eval(value_node) + return result + + +def load_kill_switch_helpers(): + """Load the env kill-switch helpers without importing the full Flask app.""" + source = SETTINGS_FILE.read_text(encoding="utf-8") + tree = ast.parse(source, filename=str(SETTINGS_FILE)) + 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, "Expected both env kill-switch helpers to be present" + + namespace = {"os": __import__("os")} + exec( + compile(ast.Module(body=selected_nodes, type_ignores=[]), str(SETTINGS_FILE), "exec"), + namespace, + ) + return namespace["_apply_tabular_parity_env_kill_switch"] + + +def test_tabular_parity_durable_preflight_defaults_active(): + """The durable-preflight parity controls ship active with no admin UI toggle.""" + assert_app_version_at_least(IMPLEMENTED_VERSION) + default_settings = load_default_settings_literal() + + assert default_settings["tabular_request_planner_mode"] == "active", ( + "tabular_request_planner_mode must default to active" + ) + assert default_settings["enable_tabular_search_shared_preflight"] is True, ( + "enable_tabular_search_shared_preflight must default to True" + ) + assert default_settings["enable_tabular_analyze_durable_preflight"] is True, ( + "enable_tabular_analyze_durable_preflight must default to True" + ) + # Unimplemented planning-only controls stay off; enabling them has no execution effect yet. + assert default_settings["enable_tabular_mixed_deferred_composition_planning"] is False + assert default_settings["enable_tabular_multifile_execution_unit_planning"] is False + + admin_settings_html = (ROOT_DIR / "application" / "single_app" / "templates" / "admin_settings.html").read_text( + encoding="utf-8" + ) + assert "tabular_request_planner_mode" not in admin_settings_html, ( + "Always-on parity settings should not gain an admin UI toggle" + ) + + +def test_env_kill_switch_forces_parity_off(monkeypatch): + """The emergency env var overrides stored settings back to legacy behavior.""" + assert_app_version_at_least(IMPLEMENTED_VERSION) + apply_kill_switch = load_kill_switch_helpers() + + settings = { + "tabular_request_planner_mode": "active", + "enable_tabular_search_shared_preflight": True, + "enable_tabular_analyze_durable_preflight": True, + } + + monkeypatch.delenv("SIMPLECHAT_DISABLE_TABULAR_PARITY_DURABLE_PREFLIGHT", raising=False) + unaffected = apply_kill_switch(dict(settings)) + assert unaffected["tabular_request_planner_mode"] == "active" + assert unaffected["enable_tabular_search_shared_preflight"] is True + assert unaffected["enable_tabular_analyze_durable_preflight"] is True + + monkeypatch.setenv("SIMPLECHAT_DISABLE_TABULAR_PARITY_DURABLE_PREFLIGHT", "true") + forced_off = apply_kill_switch(dict(settings)) + assert forced_off["tabular_request_planner_mode"] == "off" + assert forced_off["enable_tabular_search_shared_preflight"] is False + assert forced_off["enable_tabular_analyze_durable_preflight"] is False + + +if __name__ == "__main__": + failures = 0 + for test in (test_tabular_parity_durable_preflight_defaults_active,): + try: + test() + print(f"PASS: {test.__name__}") + except AssertionError as exc: + failures += 1 + print(f"FAIL: {test.__name__}: {exc}") + + class _FakeMonkeypatch: + def __init__(self): + self._saved = {} + + def setenv(self, name, value): + import os + + self._saved.setdefault(name, os.environ.get(name)) + os.environ[name] = value + + def delenv(self, name, raising=False): + import os + + self._saved.setdefault(name, os.environ.get(name)) + os.environ.pop(name, None) + + fake_monkeypatch = _FakeMonkeypatch() + try: + test_env_kill_switch_forces_parity_off(fake_monkeypatch) + print(f"PASS: {test_env_kill_switch_forces_parity_off.__name__}") + except AssertionError as exc: + failures += 1 + print(f"FAIL: {test_env_kill_switch_forces_parity_off.__name__}: {exc}") + + sys.exit(1 if failures else 0) diff --git a/functional_tests/test_tabular_combined_output_schema_deferral_fix.py b/functional_tests/test_tabular_combined_output_schema_deferral_fix.py new file mode 100644 index 000000000..4dcddacc8 --- /dev/null +++ b/functional_tests/test_tabular_combined_output_schema_deferral_fix.py @@ -0,0 +1,144 @@ +# test_tabular_combined_output_schema_deferral_fix.py +#!/usr/bin/env python3 +""" +Functional test for the combined tabular Analyze output-schema deferral fix. +Version: 0.250.189 +Implemented in: 0.250.189 + +This test ensures a combined (Analyze) tabular run with no upfront output hints +starts with output_schema=None so batch 1 can discover the real model-produced +schema, instead of being locked to the lineage-only internal checkpoint schema +and rejecting every batch (including batch 1) as a schema mismatch. +""" + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +APP_ROOT = ROOT / "application" / "single_app" +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_tabular_orchestration import orchestrate_tabular_request # noqa: E402 +from test_support.analyze_deliverable_contract_fixture import ( # noqa: E402 + FINANCIAL_REVIEW_PROMPT, +) +from test_support.versioning import assert_app_version_at_least # noqa: E402 + +IMPLEMENTED_VERSION = "0.250.189" +EXPORT_MODULE = APP_ROOT / "functions_tabular_generated_exports.py" + +FIXED_LINE = ( + "'output_schema': contract_internal_checkpoint_schema if contract_public_output_schema else None," +) +BUGGY_LINE = "'output_schema': contract_internal_checkpoint_schema or None," + + +def _build_context(): + return { + "document_id": "financial-review-doc", + "file_name": "financial_review.csv", + "source_hint": "workspace", + "source_version": "etag-financial-review-v1", + "storage_locator": { + "container": "user-documents", + "blob_path": "user-1/financial_review.csv", + }, + } + + +def _capture_analyze_plan(): + captured = {} + + def durable_callback(plan, **execution_context): + captured["plan"] = plan + captured["execution_context"] = execution_context + return { + "background_export": True, + "export_run_id": "run-analyze", + "status": "queued", + "task_type": plan["durable_task_type"], + "output_format": "csv", + } + + result = orchestrate_tabular_request( + f"{FINANCIAL_REVIEW_PROMPT}\nDownload the result as CSV.", + [_build_context()], + action_mode="analyze", + caller="analyze", + settings={ + "enable_tabular_analyze_durable_preflight": True, + "tabular_request_planner_mode": "active", + "tabular_analyze_parity_rollout_percent": 100, + "tabular_analyze_parity_rollout_state": "active", + }, + planner_mode="active", + durable_execution_callback=durable_callback, + user_id="user-1", + conversation_id="conversation-1", + gpt_model="gpt-plan", + ) + assert result["execution_state"] == "queued", result + return captured["plan"] + + +def test_combined_run_with_no_output_hints_defers_schema(): + """A real (unmocked) Analyze deliverable contract with no output hints must + still let batch 1 discover the schema instead of locking it to lineage fields.""" + assert_app_version_at_least(IMPLEMENTED_VERSION) + plan = _capture_analyze_plan() + + assert plan["requested_output_hints"] == {}, ( + "Precondition: the financial-review prompt produces no upfront output hints" + ) + deliverable_contract = plan["deliverable_contract"] + contract_public_output_schema = list(deliverable_contract.get("public_output_schema") or []) + contract_internal_checkpoint_schema = list(deliverable_contract.get("internal_checkpoint_schema") or []) + + assert contract_public_output_schema == [], ( + "Precondition: no real output columns are known before batch 1 runs" + ) + assert contract_internal_checkpoint_schema, ( + "Precondition: lineage-only internal checkpoint schema is still non-empty" + ) + + # The historical bug: `contract_internal_checkpoint_schema or None` is truthy here, + # locking the run to a schema with none of the model's real output columns. + buggy_output_schema = contract_internal_checkpoint_schema or None + assert buggy_output_schema == contract_internal_checkpoint_schema, ( + "This case must reproduce the historical bug precondition" + ) + + # The fix: only lock the schema when real output columns are already known. + fixed_output_schema = ( + contract_internal_checkpoint_schema if contract_public_output_schema else None + ) + assert fixed_output_schema is None, ( + "output_schema must defer to batch-1 discovery when no public schema is known" + ) + + +def test_run_creation_source_uses_the_deferral_fix(): + """Guard against silently reverting the one-line output_schema fix.""" + assert_app_version_at_least(IMPLEMENTED_VERSION) + source = EXPORT_MODULE.read_text(encoding="utf-8") + assert FIXED_LINE in source, "queue_tabular_generated_output_run must use the deferral fix" + assert BUGGY_LINE not in source, "the lineage-only schema lock must not be reintroduced" + + +if __name__ == "__main__": + tests = [ + test_combined_run_with_no_output_hints_defers_schema, + test_run_creation_source_uses_the_deferral_fix, + ] + 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}") + sys.exit(1 if failures else 0) diff --git a/functional_tests/test_tabular_queue_run_output_schema_end_to_end.py b/functional_tests/test_tabular_queue_run_output_schema_end_to_end.py new file mode 100644 index 000000000..fc27cbfe3 --- /dev/null +++ b/functional_tests/test_tabular_queue_run_output_schema_end_to_end.py @@ -0,0 +1,388 @@ +# test_tabular_queue_run_output_schema_end_to_end.py +#!/usr/bin/env python3 +""" +Functional test for queue_tabular_generated_output_run() itself. +Version: 0.250.189 +Implemented in: 0.250.189 + +Unlike the other tabular fix tests, this one calls the *actual* +queue_tabular_generated_output_run() function (the function containing the +output_schema fix), not a re-derivation of its inputs. It recursively extracts +the real function and every real helper it transitively calls from +functions_tabular_generated_exports.py via AST, and stubs only the genuine I/O +boundaries: Cosmos DB, blob storage, and telemetry. Cross-module helpers are +imported for real from modules already proven import-safe elsewhere in this +suite (functions_analysis_deliverables, functions_tabular_transformations). + +This test ensures a combined (Analyze) run queued with no upfront output +hints is created with output_schema=None (deferring to batch-1 discovery), +while a run with a known public output schema still locks it up front. +""" + +import ast +import builtins +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +APP_ROOT = ROOT / "application" / "single_app" +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 test_support.versioning import assert_app_version_at_least # noqa: E402 + +IMPLEMENTED_VERSION = "0.250.189" +EXPORT_MODULE = APP_ROOT / "functions_tabular_generated_exports.py" + +# Genuine I/O / cross-cutting boundaries; never pulled from the real module body. +STUB_NAMES = { + "cosmos_tabular_export_runs_container", + "cosmos_conversations_container", + "storage_account_personal_chat_container_name", + "storage_account_group_documents_container_name", + "storage_account_public_documents_container_name", + "storage_account_user_documents_container_name", + "TABULAR_EXTENSIONS", + "CLIENTS", + "log_event", + "_upload_json_blob", + "_download_json_blob", + "_get_blob_service_client", + "current_app", + "has_app_context", + # Submitting to the executor is unreachable in a test (has_app_context() is False), + # but statically references the entire background processing pipeline; stub it with + # its real no-executor behavior instead of pulling that unreachable code in. + "submit_tabular_generated_output_run", +} + +# Real, pure, already-proven-import-safe modules to resolve cross-module names from. +SAFE_MODULES = [ + "functions_analysis_deliverables", + "functions_tabular_transformations", + "functions_generated_file_exports", + "functions_assistant_table_exports", +] + + +def _collect_locally_bound_names(func_node): + """Return names bound *within* func_node (params, assignments, comprehension targets, etc.).""" + bound = set() + args = func_node.args + for arg_list in (args.posonlyargs, args.args, args.kwonlyargs): + for arg in arg_list: + bound.add(arg.arg) + if args.vararg: + bound.add(args.vararg.arg) + if args.kwarg: + bound.add(args.kwarg.arg) + for sub in ast.walk(func_node): + if isinstance(sub, ast.Name) and isinstance(sub.ctx, (ast.Store, ast.Del)): + bound.add(sub.id) + elif isinstance(sub, (ast.FunctionDef, ast.AsyncFunctionDef)) and sub is not func_node: + bound.add(sub.name) + elif isinstance(sub, ast.ExceptHandler) and sub.name: + bound.add(sub.name) + elif isinstance(sub, (ast.Import, ast.ImportFrom)): + for alias in sub.names: + bound.add(alias.asname or alias.name.split(".")[0]) + elif isinstance(sub, ast.comprehension): + for name_node in ast.walk(sub.target): + if isinstance(name_node, ast.Name): + bound.add(name_node.id) + elif isinstance(sub, ast.Lambda): + lambda_args = sub.args + for arg_list in (lambda_args.posonlyargs, lambda_args.args, lambda_args.kwonlyargs): + for arg in arg_list: + bound.add(arg.arg) + if lambda_args.vararg: + bound.add(lambda_args.vararg.arg) + if lambda_args.kwarg: + bound.add(lambda_args.kwarg.arg) + return bound + + +def _module_node_name(node): + """Return the bound name for a module-level FunctionDef/ClassDef/simple Assign, else None.""" + if isinstance(node, (ast.FunctionDef, ast.ClassDef)): + return node.name + if isinstance(node, ast.Assign) and len(node.targets) == 1 and isinstance(node.targets[0], ast.Name): + return node.targets[0].id + return None + + +def _collect_local_closure(tree, root_names): + """Recursively collect module-level FunctionDef/ClassDef/Assign nodes needed by root_names.""" + nodes_by_name = {} + for node in tree.body: + name = _module_node_name(node) + if name: + nodes_by_name[name] = node + + included = {} + unresolved = set() + worklist = list(root_names) + seen = set() + while worklist: + name = worklist.pop() + if name in seen: + continue + seen.add(name) + if name in STUB_NAMES: + continue + node = nodes_by_name.get(name) + if node is None: + unresolved.add(name) + continue + included[name] = node + if isinstance(node, ast.FunctionDef): + local_bound = _collect_locally_bound_names(node) + walk_target = node + elif isinstance(node, ast.Assign): + local_bound = set() + walk_target = node.value + else: + continue + for sub in ast.walk(walk_target): + if ( + isinstance(sub, ast.Name) + and isinstance(sub.ctx, ast.Load) + and sub.id not in local_bound + and sub.id not in seen + and not hasattr(builtins, sub.id) + ): + worklist.append(sub.id) + return included, unresolved + + +def _resolve_cross_module_names(unresolved): + import importlib + + resolved = {} + still_unresolved = set() + for name in unresolved: + found = False + for module_name in SAFE_MODULES: + module = importlib.import_module(module_name) + if hasattr(module, name): + resolved[name] = getattr(module, name) + found = True + break + if not found: + still_unresolved.add(name) + return resolved, still_unresolved + + +class _FakeCosmosContainer: + def __init__(self): + self.created_items = [] + + def create_item(self, body): + self.created_items.append(body) + return body + + +def _build_namespace(): + import logging + import math + import os + import time + import uuid + import json as json_module + from collections import Counter + from datetime import datetime, timedelta, timezone + from flask import current_app, has_app_context + from azure.core import MatchConditions + from azure.core.exceptions import ResourceExistsError + from azure.cosmos.exceptions import CosmosResourceNotFoundError + + source = EXPORT_MODULE.read_text(encoding="utf-8") + tree = ast.parse(source, filename=str(EXPORT_MODULE)) + included, unresolved = _collect_local_closure(tree, {"queue_tabular_generated_output_run"}) + + fake_container = _FakeCosmosContainer() + namespace = { + "__builtins__": __builtins__, + "os": os, + "uuid": uuid, + "json": json_module, + "math": math, + "logging": logging, + "time": time, + "Counter": Counter, + "datetime": datetime, + "timedelta": timedelta, + "timezone": timezone, + "current_app": current_app, + "has_app_context": has_app_context, + "MatchConditions": MatchConditions, + "ResourceExistsError": ResourceExistsError, + "CosmosResourceNotFoundError": CosmosResourceNotFoundError, + "cosmos_tabular_export_runs_container": fake_container, + "cosmos_conversations_container": object(), + "storage_account_personal_chat_container_name": "personal-chat-container", + "storage_account_group_documents_container_name": "group-documents-container", + "storage_account_public_documents_container_name": "public-documents-container", + "storage_account_user_documents_container_name": "user-documents-container", + "TABULAR_EXTENSIONS": {"csv", "xlsx", "xls", "xlsm"}, + "CLIENTS": {}, + "log_event": lambda *args, **kwargs: None, + "_upload_json_blob": lambda *args, **kwargs: None, + "_download_json_blob": lambda *args, **kwargs: {}, + "_get_blob_service_client": lambda: None, + # Faithful to the real function's own behavior outside a Flask app context. + "submit_tabular_generated_output_run": lambda run_id, user_id: False, + } + + still_needed = {name for name in unresolved if name not in namespace} + resolved_cross_module, still_unresolved = _resolve_cross_module_names(still_needed) + namespace.update(resolved_cross_module) + + if still_unresolved: + raise AssertionError( + f"Could not resolve required names for queue_tabular_generated_output_run " + f"end-to-end test: {sorted(still_unresolved)}" + ) + + # Preserve original module source order (not discovery order): default-argument + # values and other top-level expressions are evaluated when each statement runs, + # so constants must appear before the functions/defaults that reference them. + included_names = set(included.keys()) + ordered_nodes = [node for node in tree.body if _module_node_name(node) in included_names] + + module = ast.Module(body=ordered_nodes, type_ignores=[]) + ast.fix_missing_locations(module) + exec(compile(module, str(EXPORT_MODULE), "exec"), namespace) + return namespace, fake_container + + +def _build_financial_review_source_descriptor(): + return { + "blob_path": "user-1/financial_review.csv", + "blob_etag": "etag-financial-review-v1", + "expected_row_count": 200, + "source": "workspace", + "scope_id": "user-1", + "container": "personal-chat-container", + } + + +def test_combined_run_with_no_output_hints_is_created_with_deferred_schema(): + """The real queue function must not lock output_schema when no public schema is known.""" + assert_app_version_at_least(IMPLEMENTED_VERSION) + namespace, fake_container = _build_namespace() + queue_run = namespace["queue_tabular_generated_output_run"] + + planner_metadata = { + "planner_contract_version": "tabular-orchestration-v1", + "execution_contract": "combined", + "execution_state": "queued", + "durable_task_type": "combined", + "reason_code": "active_execution_accepted", + "deliverable_contract": { + "contract_version": "analysis-deliverables-v3", + "action_mode": "analyze", + "analysis_required": True, + "primary_artifact_role": "primary_analysis", + "public_output_schema": [], + "internal_checkpoint_schema": ["source_row_number", "source_row_identity"], + "lineage_schema": ["source_row_number", "source_row_identity"], + "row_cardinality": "one_per_source_row", + "ordering": "source_order", + "transformation_mode": "semantic", + "validation_profile": "exact_rows_schema", + "publication_policy": "primary_then_sibling", + }, + } + + run = queue_run( + user_id="user-1", + conversation_id="conversation-1", + user_question="Per-row financial review. Download the result as CSV.", + source_candidate={"filename": "financial_review.csv"}, + output_format="csv", + row_batches=None, + gpt_model="gpt-5.6-luna", + settings={}, + source_descriptor=_build_financial_review_source_descriptor(), + task_type="combined", + analysis_objective="Per-row financial review", + planner_metadata=planner_metadata, + ) + + assert fake_container.created_items, "the real function must create exactly one Cosmos run item" + persisted_run = fake_container.created_items[0] + assert persisted_run["output_schema"] is None, ( + "output_schema must stay None so batch 1 can discover the real schema; " + f"got {persisted_run['output_schema']!r}" + ) + assert persisted_run["public_output_schema"] == [] + assert persisted_run["internal_checkpoint_schema"] == ["source_row_number", "source_row_identity"] + assert run["output_schema"] is None + + +def test_combined_run_with_known_output_schema_still_locks_it_up_front(): + """When the real business columns are already known, output_schema must still be pre-set.""" + assert_app_version_at_least(IMPLEMENTED_VERSION) + namespace, fake_container = _build_namespace() + queue_run = namespace["queue_tabular_generated_output_run"] + + known_columns = ["Item_ID", "Timeline_Status", "Overall_Attention"] + planner_metadata = { + "planner_contract_version": "tabular-orchestration-v1", + "execution_contract": "combined", + "execution_state": "queued", + "durable_task_type": "combined", + "reason_code": "active_execution_accepted", + "deliverable_contract": { + "contract_version": "analysis-deliverables-v3", + "action_mode": "analyze", + "analysis_required": True, + "primary_artifact_role": "primary_analysis", + "public_output_schema": known_columns, + "internal_checkpoint_schema": ["source_row_number", "source_row_identity"] + known_columns, + "lineage_schema": ["source_row_number", "source_row_identity"], + "row_cardinality": "one_per_source_row", + "ordering": "source_order", + "transformation_mode": "semantic", + "validation_profile": "exact_rows_schema", + "publication_policy": "primary_then_sibling", + }, + } + + run = queue_run( + user_id="user-1", + conversation_id="conversation-1", + user_question="Per-row financial review. Download the result as CSV.", + source_candidate={"filename": "financial_review.csv"}, + output_format="csv", + row_batches=None, + gpt_model="gpt-5.6-luna", + settings={}, + source_descriptor=_build_financial_review_source_descriptor(), + task_type="combined", + analysis_objective="Per-row financial review", + planner_metadata=planner_metadata, + ) + + persisted_run = fake_container.created_items[0] + assert persisted_run["output_schema"] == ["source_row_number", "source_row_identity"] + known_columns + assert run["output_schema"] == persisted_run["output_schema"] + + +if __name__ == "__main__": + tests = [ + test_combined_run_with_no_output_hints_is_created_with_deferred_schema, + test_combined_run_with_known_output_schema_still_locks_it_up_front, + ] + failures = 0 + for test in tests: + try: + test() + print(f"PASS {test.__name__}") + except Exception as exc: # noqa: BLE001 - surface the exact missing-dependency failure + failures += 1 + print(f"FAIL {test.__name__}: {exc}") + sys.exit(1 if failures else 0) From c27b0ff3a70770e902718a0590d3bee2dc4ad694 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Thu, 13 Aug 2026 17:16:50 -0400 Subject: [PATCH 2/2] fix: preserve requested_artifacts when sanitizing tabular run planner metadata Root cause of combined Analyze+CSV runs freezing at 'Complete' with no download button: _normalize_tabular_run_planner_metadata() rebuilt the persisted deliverable_contract from an explicit field whitelist that never included requested_artifacts. Every run sanitized through it persisted an empty expected-artifact list, so validate_analysis_artifact_set() rejected both the real Markdown and CSV artifacts as extra_artifact, permanently locking artifact_set.lifecycle_state below 'completed' with no self-heal path. Confirmed via the 0.250.190 diagnostic log_event additions firing in production for two customer test runs (reason_codes: ['extra_artifact'], expected_artifact_ids: []). Fix: add a bounded, sanitized requested_artifacts list to the whitelist. Verified end-to-end with a real, unmocked deliverable contract routed through the real sanitizer. Tests updated to route real contracts through the real sanitizer (closing the gap that let this slip through originally), plus a new direct regression guard. Full related test suite re-verified with no regressions. Version 0.250.190 -> 0.250.191. --- application/single_app/config.py | 2 +- .../functions_tabular_generated_exports.py | 60 ++++ ...MBINED_ARTIFACT_DOWNLOAD_VISIBILITY_FIX.md | 190 ++++++++++++ ...mbined_artifact_set_download_visibility.py | 285 ++++++++++++++++++ ...t_tabular_phase5_artifact_set_lifecycle.py | 22 ++ 5 files changed, 558 insertions(+), 1 deletion(-) create mode 100644 docs/explanation/fixes/TABULAR_COMBINED_ARTIFACT_DOWNLOAD_VISIBILITY_FIX.md create mode 100644 functional_tests/test_tabular_combined_artifact_set_download_visibility.py diff --git a/application/single_app/config.py b/application/single_app/config.py index 47c9623d9..2a45ecb17 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.189" +VERSION = "0.250.191" IS_DEVELOPMENT = is_development_env_enabled() SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax') diff --git a/application/single_app/functions_tabular_generated_exports.py b/application/single_app/functions_tabular_generated_exports.py index d3b298c98..9f147650e 100644 --- a/application/single_app/functions_tabular_generated_exports.py +++ b/application/single_app/functions_tabular_generated_exports.py @@ -42,6 +42,8 @@ ANALYSIS_ARTIFACT_ROLE_PRIMARY_ANALYSIS, ANALYSIS_ARTIFACT_ROLE_REQUESTED_OUTPUT, ANALYSIS_ARTIFACT_ROLE_SUPPORTING_OUTPUT, + ANALYSIS_DELIVERABLE_MAX_ARTIFACT_ID_LENGTH, + ANALYSIS_DELIVERABLE_MAX_ARTIFACTS, build_analysis_deliverable_contract, is_analysis_internal_lineage_field, project_structured_deliverable_row, @@ -6855,6 +6857,17 @@ def _normalize_tabular_run_planner_metadata(planner_metadata): 'action_mode': str(deliverable_contract.get('action_mode') or '').strip().lower()[:40], 'analysis_required': bool(deliverable_contract.get('analysis_required')), 'primary_artifact_role': str(deliverable_contract.get('primary_artifact_role') or '').strip().lower()[:80], + 'requested_artifacts': [ + { + 'artifact_id': str(artifact.get('artifact_id') or '').strip()[:ANALYSIS_DELIVERABLE_MAX_ARTIFACT_ID_LENGTH], + 'role': str(artifact.get('role') or '').strip().lower()[:40], + 'format': str(artifact.get('format') or '').strip().lower()[:20], + 'required': bool(artifact.get('required', True)), + 'request_order': _safe_int(artifact.get('request_order'), default=0, minimum=0), + } + for artifact in list(deliverable_contract.get('requested_artifacts') or [])[:ANALYSIS_DELIVERABLE_MAX_ARTIFACTS] + if isinstance(artifact, dict) and str(artifact.get('artifact_id') or '').strip() + ], 'public_output_schema': [ str(field_name or '').strip() for field_name in list(deliverable_contract.get('public_output_schema') or [])[:TABULAR_GENERATION_PLAN_MAX_FIELDS] @@ -8689,6 +8702,33 @@ def _build_or_update_artifact_set_manifest(run): 'rollback_state': str(existing_manifest.get('rollback_state') or '').strip().lower()[:40], 'members': members, } + if ( + str((run or {}).get('status') or '').strip().lower() == TABULAR_EXPORT_STATUS_COMPLETED + and lifecycle_state != TABULAR_ARTIFACT_SET_LIFECYCLE_COMPLETED + ): + log_event( + '[TABULAR_GENERATED_OUTPUT] Artifact set stuck below completed lifecycle on a completed run', + { + 'run_id': run.get('id'), + 'conversation_id': run.get('conversation_id'), + 'task_type': manifest.get('task_type'), + 'persisted_lifecycle_state': str(existing_manifest.get('lifecycle_state') or ''), + 'recomputed_lifecycle_state': lifecycle_state, + 'validation_state': manifest.get('validation_state'), + 'validation_report': existing_manifest.get('validation_report'), + 'members': [ + { + 'member_id': member.get('member_id'), + 'role': member.get('role'), + 'lifecycle_state': member.get('lifecycle_state'), + 'validation_state': member.get('validation_state'), + 'has_artifact_message_id': bool(member.get('artifact_message_id')), + } + for member in members + ], + }, + level=logging.WARNING, + ) return manifest @@ -8738,6 +8778,7 @@ def _publish_artifact_set_members(run, published_member_ids): ] deliverable_contract = _get_tabular_run_deliverable_contract(run) artifact_set_valid = True + validation_report = None if deliverable_contract: validation_report = validate_analysis_artifact_set(deliverable_contract, validation_artifacts) artifact_set_valid = validation_report.valid @@ -8745,6 +8786,25 @@ def _publish_artifact_set_members(run, published_member_ids): manifest['validation_report'] = validation_report.to_dict() else: manifest['validation_state'] = 'validated' + log_event( + '[TABULAR_GENERATED_OUTPUT] Artifact set publication validation', + { + 'run_id': run.get('id'), + 'conversation_id': run.get('conversation_id'), + 'task_type': _normalize_tabular_run_task_type(run.get('task_type')), + 'published_member_ids': sorted(published_ids), + 'has_deliverable_contract': bool(deliverable_contract), + 'artifact_set_valid': artifact_set_valid, + 'reason_codes': list(validation_report.reason_codes) if validation_report else [], + 'counts': dict(validation_report.counts) if validation_report else {}, + 'validation_artifacts': validation_artifacts, + 'expected_artifact_ids': [ + artifact.get('artifact_id') + for artifact in list((deliverable_contract or {}).get('requested_artifacts') or []) + ], + }, + level=logging.INFO, + ) manifest['lifecycle_state'] = ( TABULAR_ARTIFACT_SET_LIFECYCLE_COMPLETED if artifact_set_valid diff --git a/docs/explanation/fixes/TABULAR_COMBINED_ARTIFACT_DOWNLOAD_VISIBILITY_FIX.md b/docs/explanation/fixes/TABULAR_COMBINED_ARTIFACT_DOWNLOAD_VISIBILITY_FIX.md new file mode 100644 index 000000000..a00a87c46 --- /dev/null +++ b/docs/explanation/fixes/TABULAR_COMBINED_ARTIFACT_DOWNLOAD_VISIBILITY_FIX.md @@ -0,0 +1,190 @@ +# Tabular Combined Analyze Artifact Download Visibility Fix + +## Issue Description + +After the truncation fix and the combined-run `output_schema` deferral fix +shipped in `0.250.189` (see +[TABULAR_ANALYZE_SEARCH_PARITY_DEFAULT_ACTIVATION_FIX.md](./TABULAR_ANALYZE_SEARCH_PARITY_DEFAULT_ACTIVATION_FIX.md)), +a customer reported a new symptom on combined (Analyze) tabular runs: + +> "its working, yay!, but its sitting at complete and never transitions to +> showing the file for download" + +The chat UI shows the background export card reaching "Complete" at 100% +progress, but the Download CSV / View CSV / Add to Workspace controls never +appear. Production logs confirmed the run's Markdown analysis artifact and +CSV structured-export artifact both uploaded successfully +(`structured_artifact_message_id` and `analysis_artifact_message_id` were +populated in the `Background combined tabular run completed` log event), so +the files exist — the UI simply never surfaces them. This affected every +combined run observed in the follow-up production logs, not just one. + +Version implemented: **0.250.191** (root cause fixed; diagnostics from +`0.250.190` retained permanently as low-noise guardrails). + +## Root Cause Analysis + +### The trigger: sanitization silently dropped `requested_artifacts` + +`_normalize_tabular_run_planner_metadata()` sanitizes shared planner metadata +before it is persisted onto a durable run (stripping locators/raw prompts and +bounding sizes). It rebuilds the `deliverable_contract` sub-object from an +**explicit field whitelist** — and that whitelist never included +`requested_artifacts`: + +```python +normalized_metadata['deliverable_contract'] = { + 'contract_version': ..., + 'action_mode': ..., + 'analysis_required': ..., + 'primary_artifact_role': ..., + 'public_output_schema': [...], + 'internal_checkpoint_schema': [...], + 'lineage_schema': [...], + 'row_cardinality': ..., + 'ordering': ..., + 'transformation_mode': ..., + 'validation_profile': ..., + 'publication_policy': ..., + # requested_artifacts was missing entirely +} +``` + +Every run whose planner metadata passed through this sanitizer therefore +persisted a `deliverable_contract` with **zero** expected artifacts. + +### Why that froze the download UI forever + +At run completion, `_publish_artifact_set_members()` calls +`validate_analysis_artifact_set(deliverable_contract, validation_artifacts)`. +With `requested_artifacts == []`, the expected-artifact set is empty, so +**both** the published Markdown analysis artifact and the CSV sibling are +classified as `extra_artifact` — confirmed directly from the `0.250.190` +diagnostic log added to capture exactly this: + +```text +[TABULAR_GENERATED_OUTPUT] Artifact set publication validation -- +{'artifact_set_valid': False, 'reason_codes': ['extra_artifact'], + 'counts': {'expected_artifact_count': 0, 'actual_artifact_count': 2, + 'extra_artifact_count': 2, ...}, + 'validation_artifacts': [ + {'artifact_id': 'analysis', 'role': 'primary_analysis', 'format': 'md', 'status': 'published'}, + {'artifact_id': 'requested-csv', 'role': 'requested_output', 'format': 'csv', 'status': 'published'}], + 'expected_artifact_ids': []} +``` + +Validation failure sets `artifact_set_manifest.lifecycle_state = +'rollback_required'`. The chat UI (`chat-messages.js`) only swaps the plain +progress card for the downloadable artifact-set card when +`isGeneratedArtifactSetComplete()` sees `lifecycle_state === 'completed'`; +any other value blocks the swap. Worse, `_build_or_update_artifact_set_manifest()` +only recomputes `lifecycle_state` from scratch when the persisted value was +`'planned'` — any other persisted value (like `rollback_required`) is +preserved verbatim on every later read — and the frontend stops polling +entirely once `run.status == 'completed'`. The combination made the freeze +permanent with no self-heal path, confirmed by the second `0.250.190` +diagnostic log (`Artifact set stuck below completed lifecycle on a completed +run`) firing repeatedly across both customer test runs. + +### How this was found + +A prior investigation pass (documented in this same file before the fix) +traced the full "happy path" — contract construction, descriptor resolution, +artifact tagging, and publication — using a real, unmocked deliverable +contract and could not reproduce the bug, because that reproduction built +the run's `tabular_planner_metadata` directly from the raw contract and +never routed it through `_normalize_tabular_run_planner_metadata()`, the +exact step that drops `requested_artifacts`. The two `0.250.190` diagnostic +log points were shipped specifically to close that evidence gap; the +customer's next test run captured the exact `reason_codes`/`expected_artifact_ids` +shown above within the same session, confirming the root cause immediately. + +## Technical Details + +### Files Modified + +- `application/single_app/functions_tabular_generated_exports.py` + - Added `ANALYSIS_DELIVERABLE_MAX_ARTIFACT_ID_LENGTH` and + `ANALYSIS_DELIVERABLE_MAX_ARTIFACTS` to the `functions_analysis_deliverables` + import. + - `_normalize_tabular_run_planner_metadata()`: added a bounded, sanitized + `requested_artifacts` list to the persisted `deliverable_contract`, + carrying `artifact_id`, `role`, `format`, `required`, and `request_order` + per artifact (capped at `ANALYSIS_DELIVERABLE_MAX_ARTIFACTS` entries). + - (From `0.250.190`, retained) `_publish_artifact_set_members()` and + `_build_or_update_artifact_set_manifest()` diagnostic `log_event` calls. +- `application/single_app/config.py`: version bump to `0.250.191`. +- `functional_tests/test_tabular_phase5_artifact_set_lifecycle.py`: extended + the shared AST-extraction helper loader to also expose + `_normalize_tabular_run_planner_metadata()` (plus its transitive + dependencies) so tests can route real planner metadata through the exact + sanitizer production uses. +- `functional_tests/test_tabular_combined_artifact_set_download_visibility.py`: + added `test_planner_metadata_sanitization_preserves_requested_artifacts()` + as a direct regression guard for the root cause, and updated the other two + tests to build the run's `tabular_planner_metadata` via the real sanitizer + instead of the raw contract, so this suite would have caught the bug + before it shipped. + +### Code Changes Summary + +```python +normalized_metadata['deliverable_contract'] = { + ... + 'primary_artifact_role': str(deliverable_contract.get('primary_artifact_role') or '').strip().lower()[:80], + 'requested_artifacts': [ + { + 'artifact_id': str(artifact.get('artifact_id') or '').strip()[:ANALYSIS_DELIVERABLE_MAX_ARTIFACT_ID_LENGTH], + 'role': str(artifact.get('role') or '').strip().lower()[:40], + 'format': str(artifact.get('format') or '').strip().lower()[:20], + 'required': bool(artifact.get('required', True)), + 'request_order': _safe_int(artifact.get('request_order'), default=0, minimum=0), + } + for artifact in list(deliverable_contract.get('requested_artifacts') or [])[:ANALYSIS_DELIVERABLE_MAX_ARTIFACTS] + if isinstance(artifact, dict) and str(artifact.get('artifact_id') or '').strip() + ], + 'public_output_schema': [...], + ... +} +``` + +### Testing Approach + +- New: `test_planner_metadata_sanitization_preserves_requested_artifacts` — + proves the real sanitizer preserves `requested_artifacts` end to end. +- Updated: `test_completed_combined_run_publishes_both_artifacts_for_download` + and `test_stuck_artifact_set_emits_diagnostic_log_on_every_read` — both now + build the run's planner metadata via the real sanitizer. All 3/3 pass. +- Re-validated with no regressions: `test_tabular_phase5_artifact_set_lifecycle.py` + (4/4), `test_tabular_phase8_ui_telemetry_rollout.py` (4/4), + `test_tabular_queue_run_output_schema_end_to_end.py` (2/2), + `test_tabular_combined_output_schema_deferral_fix.py` (2/2), + `test_tabular_analyze_search_parity_default_activation.py` (2/2), + `test_tabular_row_orchestration_scale.py` (full suite, exit code 0). +- `python -m py_compile application/single_app/functions_tabular_generated_exports.py` + and editor diagnostics clean across all changed files. + +## Impact Analysis + +- Fixes artifact-set publication validation for **every** durable tabular + run whose planner metadata passes through `_normalize_tabular_run_planner_metadata()` + with a non-empty `requested_artifacts` contract — not just combined + (Analyze+export) runs, since structured-export-only and + hierarchical-analysis-only runs share the same sanitizer. +- No behavior change for runs whose deliverable contract has no + `requested_artifacts` (falls back to `_default_artifact_descriptors_for_run()` + as before). +- The `0.250.190` diagnostic log points remain in place permanently as a + low-noise (narrowly scoped) early-warning guardrail for any future + regression in this area. + +## Validation + +**Before**: `artifact_set_valid: False`, `reason_codes: ['extra_artifact']`, +`lifecycle_state: rollback_required` (frozen forever, no download UI). + +**After**: `artifact_set_valid: True`, `reason_codes: []`, +`lifecycle_state: completed`, both the Markdown analysis artifact and CSV +sibling returned as public `generated_artifacts`, verified via a real, +unmocked deliverable contract routed through the production sanitizer. + diff --git a/functional_tests/test_tabular_combined_artifact_set_download_visibility.py b/functional_tests/test_tabular_combined_artifact_set_download_visibility.py new file mode 100644 index 000000000..82b0d3328 --- /dev/null +++ b/functional_tests/test_tabular_combined_artifact_set_download_visibility.py @@ -0,0 +1,285 @@ +#!/usr/bin/env python3 +# test_tabular_combined_artifact_set_download_visibility.py +""" +Functional test for the combined Analyze+CSV artifact-set download visibility. +Version: 0.250.191 +Implemented in: 0.250.191 (root cause fixed); diagnostics added in 0.250.190 + +A customer reported that a combined (Analyze) tabular run reached "Complete" +100% progress in the chat UI but never showed the Download/View/Add-to-Workspace +buttons. Production logs captured after shipping the 0.250.190 diagnostics +pinpointed the root cause: `_normalize_tabular_run_planner_metadata()` (which +sanitizes shared planner metadata before persisting it onto a durable run) +rebuilt the `deliverable_contract` dict from an explicit field whitelist that +omitted `requested_artifacts` entirely. Every run that went through this +sanitizer therefore persisted a `deliverable_contract` with zero expected +artifacts, so `validate_analysis_artifact_set()` always rejected both the +published Markdown analysis artifact and the CSV sibling as `extra_artifact`, +permanently freezing `artifact_set.lifecycle_state` below `"completed"` (the +frontend never shows Download/View controls unless it is exactly +`"completed"`, and it also stops polling once `run.status == "completed"`, so +the freeze had no way to self-heal). + +This test: +1. Reproduces the real (unmocked) Analyze+CSV deliverable contract used in + production (no upfront output hints, so `public_output_schema == []`), + routes it through the real `_normalize_tabular_run_planner_metadata()` + sanitizer exactly like `queue_tabular_generated_output_run()` does, and + asserts `requested_artifacts` survives sanitization intact. +2. Drives the actual `_complete_combined_analysis_run`-style dynamic + `published_member_ids` resolution (`artifact.get('artifact_id') or + artifact.get('member_id')`) through the real `_publish_artifact_set_members` + function using the *sanitized* metadata, asserting both the Markdown + primary artifact and the CSV sibling end up published and visible. +3. Verifies the diagnostic log_events added in 0.250.190 fire with the + expected payloads in both the healthy and stuck cases. +""" + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +APP_ROOT = ROOT / "application" / "single_app" +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_tabular_orchestration import orchestrate_tabular_request # noqa: E402 +from test_support.analyze_deliverable_contract_fixture import ( # noqa: E402 + FINANCIAL_REVIEW_PROMPT, +) +from test_support.versioning import assert_app_version_at_least # noqa: E402 +from test_tabular_phase5_artifact_set_lifecycle import ( # noqa: E402 + build_artifact, + load_artifact_set_helpers, +) + +IMPLEMENTED_VERSION = "0.250.191" + + +def _build_context(): + return { + "document_id": "financial-review-doc", + "file_name": "financial_review.csv", + "source_hint": "workspace", + "source_version": "etag-financial-review-v1", + "storage_locator": { + "container": "user-documents", + "blob_path": "user-1/financial_review.csv", + }, + } + + +def _capture_real_planner_metadata(): + """Return the real, unsanitized planner_metadata dict a combined + Analyze+CSV request produces, exactly as queue_tabular_generated_output_run + receives it before persisting.""" + captured = {} + + def durable_callback(plan, **execution_context): + captured["plan"] = plan + return { + "background_export": True, + "export_run_id": "run-analyze", + "status": "queued", + "task_type": plan["durable_task_type"], + "output_format": "csv", + } + + result = orchestrate_tabular_request( + f"{FINANCIAL_REVIEW_PROMPT}\nDownload the result as CSV.", + [_build_context()], + action_mode="analyze", + caller="analyze", + settings={ + "enable_tabular_analyze_durable_preflight": True, + "tabular_request_planner_mode": "active", + "tabular_analyze_parity_rollout_percent": 100, + "tabular_analyze_parity_rollout_state": "active", + }, + planner_mode="active", + durable_execution_callback=durable_callback, + user_id="user-1", + conversation_id="conversation-1", + gpt_model="gpt-plan", + ) + assert result["execution_state"] == "queued", result + return captured["plan"] + + +def test_planner_metadata_sanitization_preserves_requested_artifacts(): + """Regression guard for the root cause: sanitizing planner metadata for + persistence must not drop the deliverable contract's requested_artifacts, + or artifact-set publication validation will reject every real artifact + as 'extra_artifact' forever.""" + print("Testing planner metadata sanitization preserves requested_artifacts...") + assert_app_version_at_least(IMPLEMENTED_VERSION) + helpers = load_artifact_set_helpers() + raw_planner_metadata = _capture_real_planner_metadata() + raw_requested_artifacts = raw_planner_metadata["deliverable_contract"]["requested_artifacts"] + assert raw_requested_artifacts, "Precondition: the real contract must have requested artifacts" + + sanitized = helpers["_normalize_tabular_run_planner_metadata"](raw_planner_metadata) + sanitized_requested_artifacts = sanitized.get("deliverable_contract", {}).get("requested_artifacts") + + assert sanitized_requested_artifacts, ( + "_normalize_tabular_run_planner_metadata dropped requested_artifacts " + f"during sanitization: {sanitized.get('deliverable_contract')}" + ) + assert [a["artifact_id"] for a in sanitized_requested_artifacts] == [ + a["artifact_id"] for a in raw_requested_artifacts + ] + assert [a["role"] for a in sanitized_requested_artifacts] == [ + a["role"] for a in raw_requested_artifacts + ] + assert [a["format"] for a in sanitized_requested_artifacts] == [ + a["format"] for a in raw_requested_artifacts + ] + + +def test_completed_combined_run_publishes_both_artifacts_for_download(): + """A completed combined run using the real Analyze+CSV contract (no + upfront output hints), persisted through the real sanitizer exactly like + production, must resolve both the Markdown analysis artifact and the CSV + sibling via the same dynamic member-id lookup the real + _complete_combined_analysis_run uses, ending with both visible for + download.""" + print("Testing completed combined run publishes both artifacts for download...") + assert_app_version_at_least(IMPLEMENTED_VERSION) + helpers = load_artifact_set_helpers() + raw_planner_metadata = _capture_real_planner_metadata() + assert raw_planner_metadata["deliverable_contract"].get("public_output_schema") == [], ( + "Precondition: the financial-review prompt produces no upfront output schema" + ) + sanitized_planner_metadata = helpers["_normalize_tabular_run_planner_metadata"](raw_planner_metadata) + + run = { + "id": "run-real-1", + "conversation_id": "conversation-1", + "user_id": "user-1", + "task_type": "combined", + "status": "running", + "output_format": "csv", + "source_file_name": "financial_review.csv", + "row_count": 200, + "processed_rows": 200, + "post_run_summary": "Analysis completed.", + "post_run_export_summary": "CSV export completed.", + "tabular_planner_metadata": sanitized_planner_metadata, + } + + # Mirror _publish_structured_export_artifact tagging the structured + # artifact with the descriptor's real member_id/artifact_id. + descriptors = helpers["_get_artifact_descriptors_for_run"](run) + structured_descriptor = next(d for d in descriptors if d["role"] == "requested_output") + structured_artifact = build_artifact("csv-message", "financial_review.csv", "csv") + structured_artifact["artifact_id"] = structured_descriptor["member_id"] + structured_artifact["member_id"] = structured_descriptor["member_id"] + analysis_artifact = build_artifact("md-message", "financial_review.md", "md") + + run["structured_export_artifacts"] = [structured_artifact] + run["structured_export_artifact"] = structured_artifact + run["analysis_artifact"] = analysis_artifact + run["status"] = "completed" + + # Mirror _complete_combined_analysis_run's exact published_member_ids + # construction: dynamic resolution, not hardcoded literals. + published_member_ids = [ + helpers["_get_analysis_artifact_member_id"](run), + *[ + artifact.get("artifact_id") or artifact.get("member_id") + for artifact in [structured_artifact] + ], + ] + + manifest = helpers["_publish_artifact_set_members"](run, published_member_ids) + assert manifest["lifecycle_state"] == "completed", manifest + assert manifest["validation_state"] == "validated", manifest + assert manifest["validation_report"]["valid"] is True + + public_artifacts = helpers["_build_public_generated_artifacts_from_manifest"](run, manifest) + assert [artifact["artifact_id"] for artifact in public_artifacts] == ["analysis", "requested-csv"] + assert [artifact["output_format"] for artifact in public_artifacts] == ["md", "csv"] + + validation_logs = [ + event for event in helpers["logged_events"] + if event["message"] == "[TABULAR_GENERATED_OUTPUT] Artifact set publication validation" + ] + assert len(validation_logs) == 1 + assert validation_logs[0]["extra"]["artifact_set_valid"] is True + assert validation_logs[0]["extra"]["reason_codes"] == [] + + stuck_logs = [ + event for event in helpers["logged_events"] + if event["message"] == "[TABULAR_GENERATED_OUTPUT] Artifact set stuck below completed lifecycle on a completed run" + ] + assert stuck_logs == [], "A healthy completed run must not emit the stuck-lifecycle diagnostic" + + +def test_stuck_artifact_set_emits_diagnostic_log_on_every_read(): + """When a completed run's artifact set is invalid/rollback_required, the + manifest rebuild must emit a diagnostic log carrying the persisted + validation_report and per-member state, instead of failing silently + forever.""" + print("Testing stuck artifact-set lifecycle emits a diagnostic log...") + assert_app_version_at_least(IMPLEMENTED_VERSION) + helpers = load_artifact_set_helpers() + raw_planner_metadata = _capture_real_planner_metadata() + sanitized_planner_metadata = helpers["_normalize_tabular_run_planner_metadata"](raw_planner_metadata) + run = { + "id": "run-stuck-1", + "conversation_id": "conversation-1", + "user_id": "user-1", + "task_type": "combined", + "status": "completed", + "output_format": "csv", + "source_file_name": "financial_review.csv", + "row_count": 200, + "processed_rows": 200, + "tabular_planner_metadata": sanitized_planner_metadata, + } + # Only the structured artifact is present; the required Markdown primary + # artifact never got attached, so publication must fail closed. + run["structured_export_artifact"] = build_artifact("csv-message", "financial_review.csv", "csv") + + manifest = helpers["_publish_artifact_set_members"](run, ["requested-csv"]) + assert manifest["lifecycle_state"] == "rollback_required", manifest + + # Re-read the manifest exactly like a status poll would; this must + # re-emit the stuck-lifecycle diagnostic every time it is observed. + helpers["logged_events"].clear() + rebuilt_manifest = helpers["_build_or_update_artifact_set_manifest"](run) + assert rebuilt_manifest["lifecycle_state"] == "rollback_required" + + stuck_logs = [ + event for event in helpers["logged_events"] + if event["message"] == "[TABULAR_GENERATED_OUTPUT] Artifact set stuck below completed lifecycle on a completed run" + ] + assert len(stuck_logs) == 1 + assert stuck_logs[0]["extra"]["run_id"] == "run-stuck-1" + assert stuck_logs[0]["extra"]["persisted_lifecycle_state"] == "rollback_required" + assert stuck_logs[0]["extra"]["validation_state"] == "invalid" + + +if __name__ == "__main__": + tests = [ + test_planner_metadata_sanitization_preserves_requested_artifacts, + test_completed_combined_run_publishes_both_artifacts_for_download, + test_stuck_artifact_set_emits_diagnostic_log_on_every_read, + ] + 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_phase5_artifact_set_lifecycle.py b/functional_tests/test_tabular_phase5_artifact_set_lifecycle.py index 5f58ff45b..8913f43ad 100644 --- a/functional_tests/test_tabular_phase5_artifact_set_lifecycle.py +++ b/functional_tests/test_tabular_phase5_artifact_set_lifecycle.py @@ -11,8 +11,10 @@ """ import ast +import logging import re import sys +from collections import Counter from pathlib import Path from test_support.versioning import assert_app_version_at_least @@ -27,9 +29,13 @@ from functions_analysis_deliverables import ( # noqa: E402 ANALYSIS_ARTIFACT_ROLE_PRIMARY_ANALYSIS, ANALYSIS_ARTIFACT_ROLE_REQUESTED_OUTPUT, + ANALYSIS_DELIVERABLE_MAX_ARTIFACT_ID_LENGTH, + ANALYSIS_DELIVERABLE_MAX_ARTIFACTS, build_analysis_deliverable_contract, + is_analysis_internal_lineage_field, validate_analysis_artifact_set, ) +from functions_tabular_transformations import normalize_tabular_transformation_spec # noqa: E402 IMPLEMENTED_VERSION = "0.250.180" @@ -64,12 +70,16 @@ def load_artifact_set_helpers(): "_build_public_generated_artifact_from_member", "_build_public_generated_artifacts_from_manifest", "_build_public_artifact_projection", + "_normalize_tabular_run_planner_metadata", + "_build_planner_source_coverage_summary", + "_normalize_tabular_run_rollout_assignment", } selected_functions = [ node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name in helper_names ] publication_commits = [] + logged_events = [] def commit_publication(current_user_id, conversation_id, artifact_message_id, artifact_set_id, artifact_member_id, publication_generation): publication_commits.append({ @@ -81,8 +91,19 @@ def commit_publication(current_user_id, conversation_id, artifact_message_id, ar "publication_generation": publication_generation, }) + def fake_log_event(message, extra=None, level=logging.INFO): + logged_events.append({"message": message, "extra": extra or {}, "level": level}) + namespace = { "re": re, + "logging": logging, + "log_event": fake_log_event, + "logged_events": logged_events, + "Counter": Counter, + "is_analysis_internal_lineage_field": is_analysis_internal_lineage_field, + "ANALYSIS_DELIVERABLE_MAX_ARTIFACT_ID_LENGTH": ANALYSIS_DELIVERABLE_MAX_ARTIFACT_ID_LENGTH, + "ANALYSIS_DELIVERABLE_MAX_ARTIFACTS": ANALYSIS_DELIVERABLE_MAX_ARTIFACTS, + "normalize_tabular_transformation_spec": normalize_tabular_transformation_spec, "validate_analysis_artifact_set": validate_analysis_artifact_set, "commit_generated_chat_artifact_publication_for_user": commit_publication, "publication_commits": publication_commits, @@ -93,6 +114,7 @@ def commit_publication(current_user_id, conversation_id, artifact_message_id, ar "TABULAR_RUN_TASK_HIERARCHICAL_ANALYSIS": "hierarchical_analysis", "TABULAR_RUN_TASK_COMBINED": "combined", "TABULAR_RUN_TASK_TYPES": {"structured_export", "hierarchical_analysis", "combined"}, + "TABULAR_RUN_TASK_TYPES": {"structured_export", "hierarchical_analysis", "combined"}, "TABULAR_EXPORT_STATUS_RUNNING": "running", "TABULAR_EXPORT_STATUS_COMPLETED": "completed", "TABULAR_EXPORT_STATUS_FAILED": "failed",