diff --git a/application/single_app/config.py b/application/single_app/config.py index 6cd524ec4..f270403ae 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -97,7 +97,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.250.066" +VERSION = "0.250.067" IS_DEVELOPMENT = is_development_env_enabled() SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax') diff --git a/application/single_app/functions_document_comparison.py b/application/single_app/functions_document_comparison.py index 565f45eb3..a734eac2d 100644 --- a/application/single_app/functions_document_comparison.py +++ b/application/single_app/functions_document_comparison.py @@ -224,6 +224,117 @@ def _format_comparison_coverage_summary(coverage, left_document_name, right_docu return '\n'.join(lines) +def run_evidence_document_comparison( + comparison_prompt, + left_source, + right_sources, + invoke_prompt, + activity_callback=None, +): + """Run the established one-left-to-many comparison over native evidence envelopes.""" + normalized_prompt = str(comparison_prompt or '').strip() + if not normalized_prompt or not callable(invoke_prompt): + raise ValueError('A comparison prompt and callable invoke_prompt handler are required.') + + left_source = left_source if isinstance(left_source, dict) else {} + right_sources = [source for source in list(right_sources or []) if isinstance(source, dict)] + left_name = str(left_source.get('document_name') or 'Source').strip() or 'Source' + left_summary = str(left_source.get('summary') or '').strip() + comparison_items = [] + compared_targets = [] + failed_targets = [] + + for comparison_index, right_source in enumerate(right_sources, start=1): + right_name = str(right_source.get('document_name') or f'Target {comparison_index}').strip() or f'Target {comparison_index}' + right_status = str(right_source.get('status') or '').strip().lower() + if right_status not in {'completed', 'partial'} or not left_summary: + failed_targets.append(right_name) + continue + + if callable(activity_callback): + activity_callback({ + 'type': 'comparison_started', + 'left_document_id': left_source.get('document_id'), + 'left_document_name': left_name, + 'right_document_id': right_source.get('document_id'), + 'right_document_name': right_name, + 'comparison_index': comparison_index, + 'comparison_count': len(right_sources), + }) + pairwise_text = str(invoke_prompt( + _build_pairwise_comparison_prompt( + normalized_prompt, + left_name, + right_name, + left_summary, + str(right_source.get('summary') or ''), + ), + stage='comparison', + metadata={ + 'comparison_index': comparison_index, + 'comparison_count': len(right_sources), + 'left_document_id': left_source.get('document_id'), + 'right_document_id': right_source.get('document_id'), + }, + ) or '').strip() + if not pairwise_text: + failed_targets.append(right_name) + continue + compared_targets.append(right_name) + comparison_items.append({ + 'right_document_id': right_source.get('document_id'), + 'right_document_name': right_name, + 'text': pairwise_text, + }) + if callable(activity_callback): + activity_callback({ + 'type': 'comparison_completed', + 'left_document_id': left_source.get('document_id'), + 'left_document_name': left_name, + 'right_document_id': right_source.get('document_id'), + 'right_document_name': right_name, + 'comparison_index': comparison_index, + 'comparison_count': len(right_sources), + }) + + if not comparison_items: + final_reply = 'No target comparison could be completed from the available source evidence.' + elif len(comparison_items) == 1: + final_reply = comparison_items[0]['text'] + else: + final_reply = str(invoke_prompt( + _build_comparison_reduction_prompt(normalized_prompt, left_name, comparison_items), + stage='comparison_reduction', + metadata={'comparison_count': len(comparison_items), 'left_document_id': left_source.get('document_id')}, + ) or '').strip() or 'The completed target comparisons could not be reduced into a final response.' + + evidence_engines = sorted({ + str(source.get('engine') or 'unknown') + for source in [left_source, *right_sources] + }) + conclusion_level = 'aggregate or narrative' + if all(str(source.get('source_kind') or '') == 'tabular' for source in [left_source, *right_sources]): + conclusion_level = 'aggregate; row-level conclusions require validated structured table operations' + coverage_note = ( + f'\n\n## Comparison Coverage\n- Targets compared: {len(compared_targets)}\n' + f'- Failed or partial targets: {len(failed_targets)}\n' + f'- Evidence engines: {", ".join(evidence_engines)}\n' + f'- Conclusion level: {conclusion_level}' + ) + return { + 'reply': f'{final_reply}{coverage_note}', + 'analysis_reply': final_reply, + 'coverage': {'document_count': 1 + len(right_sources), 'partial_coverage': bool(failed_targets), 'failed_targets': failed_targets}, + 'documents': [left_source, *right_sources], + 'left_document': {'document_id': left_source.get('document_id'), 'document_name': left_name}, + 'right_documents': [ + {'document_id': source.get('document_id'), 'document_name': source.get('document_name')} + for source in right_sources + ], + 'comparison_items': comparison_items, + } + + def run_document_comparison( user_id, comparison_prompt, diff --git a/application/single_app/functions_settings.py b/application/single_app/functions_settings.py index 0f7331ca6..6c324faed 100644 --- a/application/single_app/functions_settings.py +++ b/application/single_app/functions_settings.py @@ -233,6 +233,19 @@ def is_mixed_source_analyze_all_enabled(settings): ) +def is_cross_format_compare_enabled(settings): + """Return whether Phase 4 native mixed-source Compare behavior is enabled.""" + return bool((settings or {}).get('enable_cross_format_compare', False)) + + +def is_cross_format_compare_one_to_many_enabled(settings): + """Return whether the separately staged one-to-many mixed-target rollout is enabled.""" + return bool( + (settings or {}).get('enable_cross_format_compare', False) + and (settings or {}).get('enable_cross_format_compare_one_to_many', False) + ) + + def is_mixed_source_relevance_candidates_enabled(settings): """Return whether Phase 2 relevance-mode table candidates are enabled.""" return bool( @@ -795,6 +808,8 @@ def get_settings(use_cosmos=False, include_source=False): 'enable_mixed_source_relevance_candidates': False, 'enable_mixed_source_analyze': False, 'enable_mixed_source_analyze_all': False, + 'enable_cross_format_compare': False, + 'enable_cross_format_compare_one_to_many': False, 'max_rounds_per_agent': 1, 'workflow_max_auto_invoke_attempts': 60, 'enable_semantic_kernel': False, diff --git a/application/single_app/functions_workflow_runner.py b/application/single_app/functions_workflow_runner.py index ac6ca0c72..fe787bbe1 100644 --- a/application/single_app/functions_workflow_runner.py +++ b/application/single_app/functions_workflow_runner.py @@ -67,7 +67,7 @@ normalize_document_action_analysis_mode, ) from functions_documents import select_current_documents, sort_documents -from functions_document_comparison import run_document_comparison +from functions_document_comparison import run_document_comparison, run_evidence_document_comparison from functions_debug import debug_print from functions_document_analysis import run_document_analysis from functions_file_sync import get_authorized_sync_source, queue_file_sync_source_run @@ -110,6 +110,8 @@ get_user_settings, is_mixed_source_chat_search_enabled, is_mixed_source_manifest_enabled, + is_cross_format_compare_enabled, + is_cross_format_compare_one_to_many_enabled, is_tabular_processing_enabled, normalize_model_endpoints, resolve_model_endpoint_foundry_scope, @@ -2038,6 +2040,154 @@ def _execute_mixed_source_analyze_workflow( 'agent_citations': tabular_agent_citations, } +def _resolve_cross_format_comparison_manifest(comparison_config, user_id, conversation_id): + """Resolve the Source and ordered Targets once for the mixed Compare decision.""" + requested_ids, role_by_document_id = _get_document_action_source_ids(comparison_config) + manifest = resolve_authorized_source_manifest( + requested_ids, + user_id=user_id, + selection_mode=SELECTION_MODE_SELECTED, + conversation_id=conversation_id, + active_group_ids=comparison_config.get('active_group_ids'), + active_public_workspace_ids=comparison_config.get('active_public_workspace_id'), + doc_scope=comparison_config.get('doc_scope', 'all'), + ) + for source in manifest: + source['comparison_role'] = role_by_document_id.get(source.get('document_id'), 'target') + return manifest + + +def _raise_legacy_cross_format_compare_limitation(comparison_config, user_id, conversation_id=''): + """Fail closed when rollback would otherwise route a table through chunk analysis.""" + manifest = _resolve_cross_format_comparison_manifest(comparison_config, user_id, conversation_id) + partitions = partition_source_manifest(manifest) + if partitions['narrative_sources'] and partitions['tabular_sources']: + raise ValueError( + 'Mixed narrative and tabular Compare is temporarily unavailable while cross-format Compare is disabled.' + ) + + +def _execute_cross_format_comparison_workflow( + workflow, + comparison_config, + settings, + invoke_prompt, + conversation_id='', + activity_callback=None, + thought_tracker=None, + live_thought_callback=None, +): + """Prepare native envelopes for a mixed Source/Target Compare, then reuse pairwise reduction.""" + user_id = str(workflow.get('user_id') or '').strip() + manifest = _resolve_cross_format_comparison_manifest(comparison_config, user_id, conversation_id) + partitions = partition_source_manifest(manifest) + if not (partitions['narrative_sources'] and partitions['tabular_sources']): + return None + target_sources = [source for source in manifest if source.get('comparison_role') == 'right'] + if len(target_sources) > 1 and not is_cross_format_compare_one_to_many_enabled(settings): + raise ValueError('Cross-format Compare currently supports one Target while one-to-many rollout is disabled.') + if callable(activity_callback): + activity_callback({'type': 'mixed_source_progress', 'phase': 'resolving_sources', 'label': 'Resolving Source and Targets'}) + + evidence_by_id = {} + generated_tabular_outputs = [] + tabular_agent_citations = [] + for source in partitions['narrative_sources']: + if callable(activity_callback): + activity_callback({'type': 'mixed_source_progress', 'phase': 'analyzing_narrative', 'label': 'Analyzing narrative source'}) + try: + narrative_result = run_document_analysis( + user_id=user_id, + analysis_prompt=workflow.get('task_prompt', ''), + document_ids=[source.get('document_id')], + invoke_prompt=invoke_prompt, + doc_scope=comparison_config.get('doc_scope'), + active_group_ids=comparison_config.get('active_group_ids'), + active_public_workspace_id=comparison_config.get('active_public_workspace_id'), + conversation_id=conversation_id, + window_unit=comparison_config.get('window_unit'), + window_size=comparison_config.get('window_size'), + window_percent=comparison_config.get('window_percent'), + max_retries_per_window=comparison_config.get('max_retries_per_window'), + activity_callback=activity_callback, + max_documents=1, + include_coverage_summary=False, + ) + document_coverage = list((narrative_result.get('coverage') or {}).get('documents') or [{}])[0] + status = EVIDENCE_STATUS_COMPLETED if not document_coverage.get('failed_windows') else 'partial' + evidence_by_id[source.get('document_id')] = build_evidence_envelope( + document_id=source.get('document_id'), source_kind='narrative', + engine=EVIDENCE_ENGINE_DOCUMENT_ANALYSIS, status=status, + summary=str(narrative_result.get('analysis_reply') or narrative_result.get('reply') or ''), + coverage={'terminal': True, 'source_version': source.get('source_version'), **document_coverage}, + ) + except Exception: + evidence_by_id[source.get('document_id')] = build_evidence_envelope( + document_id=source.get('document_id'), source_kind='narrative', + engine=EVIDENCE_ENGINE_DOCUMENT_ANALYSIS, status=EVIDENCE_STATUS_FAILED, + summary='Narrative evidence could not be completed for this source.', + coverage={'terminal': True, 'source_version': source.get('source_version')}, + error='Narrative analysis could not be completed.', + ) + + for source in partitions['tabular_sources']: + if callable(activity_callback): + activity_callback({'type': 'mixed_source_progress', 'phase': 'analyzing_tabular', 'label': 'Analyzing tabular source'}) + tabular_config = dict(comparison_config) + tabular_payload = _maybe_execute_tabular_document_action( + DOCUMENT_ACTION_TYPE_ANALYZE, workflow, { + **tabular_config, + 'type': DOCUMENT_ACTION_TYPE_ANALYZE, + 'document_ids': [source.get('document_id')], + }, + settings, conversation_id=conversation_id, invoke_prompt=invoke_prompt, + thought_tracker=thought_tracker, live_thought_callback=live_thought_callback, + ) + tabular_result = (tabular_payload or {}).get('result') or {} + if tabular_result.get('analysis_reply'): + evidence_by_id[source.get('document_id')] = build_evidence_envelope( + document_id=source.get('document_id'), source_kind='tabular', + engine=EVIDENCE_ENGINE_TABULAR_TOOLS, status=EVIDENCE_STATUS_COMPLETED, + summary=str(tabular_result.get('analysis_reply') or ''), + citations=list((tabular_payload or {}).get('agent_citations') or []), + generated_artifacts=list((tabular_payload or {}).get('generated_tabular_outputs') or []), + coverage={'terminal': True, 'source_version': source.get('source_version'), 'tool_call_count': 1}, + ) + generated_tabular_outputs.extend((tabular_payload or {}).get('generated_tabular_outputs') or []) + tabular_agent_citations.extend((tabular_payload or {}).get('agent_citations') or []) + else: + evidence_by_id[source.get('document_id')] = build_evidence_envelope( + document_id=source.get('document_id'), source_kind='tabular', + engine=EVIDENCE_ENGINE_TABULAR_TOOLS, status=EVIDENCE_STATUS_FAILED, + summary='Tabular evidence could not be completed for this source.', + coverage={'terminal': True, 'source_version': source.get('source_version')}, + error='Tabular analysis could not be completed.', + ) + + handoff = build_mixed_source_evidence_handoff(manifest, list(evidence_by_id.values()), SELECTION_MODE_SELECTED) + envelopes = {envelope.get('document_id'): envelope for envelope in handoff.get('evidence_envelopes') or []} + def source_payload(source): + envelope = dict(envelopes.get(source.get('document_id')) or {}) + envelope['document_name'] = source.get('display_name') or ('Source' if source.get('comparison_role') == 'left' else 'Target') + role_label = 'Source' if source.get('comparison_role') == 'left' else 'Target' + evidence_type = 'computed tabular facts' if envelope.get('source_kind') == 'tabular' else 'narrative document analysis' + envelope['summary'] = ( + f'Role: {role_label}. Evidence type: {evidence_type}.\n' + f"{str(envelope.get('summary') or '').strip()}" + ) + return envelope + left_source = next((source for source in manifest if source.get('comparison_role') == 'left'), {}) + comparison_result = run_evidence_document_comparison( + workflow.get('task_prompt', ''), source_payload(left_source), + [source_payload(source) for source in target_sources], invoke_prompt, activity_callback=activity_callback, + ) + comparison_result['coverage'] = _build_mixed_source_analysis_coverage(handoff) + comparison_result['mixed_source_manifest'] = manifest + comparison_result['mixed_source_evidence'] = handoff.get('evidence_envelopes') or [] + comparison_result['generated_tabular_outputs'] = generated_tabular_outputs + comparison_result['agent_citations'] = tabular_agent_citations + return comparison_result + def _build_workflow_generation_prompt(task_prompt): return append_proactive_chart_guidance(task_prompt) @@ -5751,17 +5901,24 @@ def invoke_prompt(prompt_text, stage='window_analysis', metadata=None): _accumulate_token_usage(token_usage_aggregate, result) return str(result) - tabular_action_payload = _maybe_execute_tabular_document_action( - DOCUMENT_ACTION_TYPE_COMPARISON, - workflow, - comparison_config, - settings, - conversation_id=conversation_id, - invoke_prompt=invoke_prompt, - thought_tracker=thought_tracker, - live_thought_callback=external_activity_callback, + mixed_comparison_enabled = is_cross_format_compare_enabled(settings) + mixed_comparison_result = ( + _execute_cross_format_comparison_workflow( + workflow, comparison_config, settings, invoke_prompt, + conversation_id=conversation_id, activity_callback=activity_callback, + thought_tracker=thought_tracker, live_thought_callback=external_activity_callback, + ) if mixed_comparison_enabled else None ) - if tabular_action_payload: + if not mixed_comparison_enabled: + _raise_legacy_cross_format_compare_limitation(comparison_config, user_id, conversation_id) + tabular_action_payload = None if mixed_comparison_result else _maybe_execute_tabular_document_action( + DOCUMENT_ACTION_TYPE_COMPARISON, workflow, comparison_config, settings, + conversation_id=conversation_id, invoke_prompt=invoke_prompt, + thought_tracker=thought_tracker, live_thought_callback=external_activity_callback, + ) + if mixed_comparison_result: + comparison_result = mixed_comparison_result + elif tabular_action_payload: comparison_result = tabular_action_payload.get('result') or {} else: comparison_result = run_document_comparison( @@ -5858,17 +6015,24 @@ def invoke_model_prompt(prompt_text, stage='window_analysis', metadata=None): return '' return _extract_message_text(completion.choices[0].message.content) - tabular_action_payload = _maybe_execute_tabular_document_action( - DOCUMENT_ACTION_TYPE_COMPARISON, - workflow, - comparison_config, - settings, - conversation_id=conversation_id, - invoke_prompt=invoke_model_prompt, - thought_tracker=thought_tracker, - live_thought_callback=external_activity_callback, + mixed_comparison_enabled = is_cross_format_compare_enabled(settings) + mixed_comparison_result = ( + _execute_cross_format_comparison_workflow( + workflow, comparison_config, settings, invoke_model_prompt, + conversation_id=conversation_id, activity_callback=activity_callback, + thought_tracker=thought_tracker, live_thought_callback=external_activity_callback, + ) if mixed_comparison_enabled else None ) - if tabular_action_payload: + if not mixed_comparison_enabled: + _raise_legacy_cross_format_compare_limitation(comparison_config, user_id, conversation_id) + tabular_action_payload = None if mixed_comparison_result else _maybe_execute_tabular_document_action( + DOCUMENT_ACTION_TYPE_COMPARISON, workflow, comparison_config, settings, + conversation_id=conversation_id, invoke_prompt=invoke_model_prompt, + thought_tracker=thought_tracker, live_thought_callback=external_activity_callback, + ) + if mixed_comparison_result: + comparison_result = mixed_comparison_result + elif tabular_action_payload: comparison_result = tabular_action_payload.get('result') or {} else: comparison_result = run_document_comparison( diff --git a/docs/explanation/features/CROSS_FORMAT_COMPARE.md b/docs/explanation/features/CROSS_FORMAT_COMPARE.md new file mode 100644 index 000000000..ff114e9c1 --- /dev/null +++ b/docs/explanation/features/CROSS_FORMAT_COMPARE.md @@ -0,0 +1,41 @@ +# Cross-Format Compare + +Implemented in version: **0.250.067** + +GitHub issue: [#1059](https://github.com/microsoft/simplechat/issues/1059) + +Parent initiative: [#1055](https://github.com/microsoft/simplechat/issues/1055) + +Prerequisites: [#1056](https://github.com/microsoft/simplechat/issues/1056), [#1057](https://github.com/microsoft/simplechat/issues/1057), and [#1058](https://github.com/microsoft/simplechat/issues/1058) + +## Overview + +Phase 4 introduces a default-off cross-format Compare coordinator. It resolves one fresh authorized manifest for the Source and ordered Targets, dispatches narrative sources to document-window analysis and tabular sources to the existing tabular analysis runner, then performs the established one-Source-to-many-Targets pairwise and final reduction using bounded evidence envelopes. + +## Configuration + +- `enable_cross_format_compare`: default `false`; enables native mixed narrative/tabular Compare. +- `enable_cross_format_compare_one_to_many`: default `false`; permits more than one mixed-format Target after pairwise coverage and performance are verified. + +When the main flag is disabled, same-type Compare stays on its established path. A mixed request fails with a clear temporary limitation rather than treating a table as narrative chunks. + +## Architecture + +- `functions_mixed_source_orchestration.py` remains the sole manifest, partition, authorization, and bounded-envelope contract. +- `functions_workflow_runner.py` reuses `run_document_analysis(...)` for narrative sources and `_maybe_execute_tabular_document_action(...)` for every tabular source. +- `functions_document_comparison.py` retains the existing pairwise and multi-target reduction prompts; `run_evidence_document_comparison(...)` supplies native engine-neutral evidence and keeps failed targets visible. +- Existing citation, token aggregation, ThoughtTracker, generated tabular output, background-export, and comparison artifact flows are retained. + +## Security and Coverage + +Every enabled execution resolves the source manifest fresh, rechecking personal ownership or exact approved shares, active group membership, public visibility, and chat-upload conversation ownership. Caller-provided scope or metadata is not authorization. Unresolved and unauthorized sources remain scrubbed terminal coverage entries. + +The final comparison reports compared targets, failed or partial targets, participating engines, and whether its conclusion is aggregate/narrative. Narrative assertions remain distinct from computed tabular facts. Generated exports remain artifacts rather than comparison prose. + +## Testing + +`functional_tests/test_cross_format_compare_workflow.py` covers the native coordinator wiring, Source/Target ordering, partial target visibility, engine reporting, staged rollout flags, and rollback limitation. Additional scope, authorization-revocation, source-version, streaming, and UI coverage should remain part of the rollout gate before enabling either flag. + +## Limitations + +This phase does not add many-to-many Compare, all-document discovery, persisted follow-up source reuse, Phase 5 selection semantics, or Phase 6 broad extraction and rollout completion. Table-to-table row-level assertions require a validated structured table operation; bounded prose evidence alone is not treated as row-level proof. \ No newline at end of file diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index 25d83e7b9..190ed068d 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -2,6 +2,15 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Version](/explanation/features/) and [Fixes by Version](/explanation/fixes/). +### **(v0.250.067)** + +#### New Features + +* **Cross-Format Compare Native Evidence** + * Compare can now, behind a default-off rollout flag, resolve one authorization-safe Source/Target manifest and combine document-window narrative evidence with tool-backed tabular evidence without silently routing tables through document chunks. + * The staged one-to-many mixed-target rollout remains off until pairwise coverage and performance are verified; partial target failures remain visible in final comparison coverage. + * (Ref: microsoft/simplechat#1059, parent microsoft/simplechat#1055, prerequisites microsoft/simplechat#1056, microsoft/simplechat#1057, and microsoft/simplechat#1058, `functions_workflow_runner.py`, `functions_document_comparison.py`, `CROSS_FORMAT_COMPARE.md`) + ### **(v0.250.066)** #### New Features diff --git a/functional_tests/test_cross_format_compare_workflow.py b/functional_tests/test_cross_format_compare_workflow.py new file mode 100644 index 000000000..8dd286225 --- /dev/null +++ b/functional_tests/test_cross_format_compare_workflow.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +# test_cross_format_compare_workflow.py +""" +Functional test for Phase 4 cross-format Compare. +Version: 0.250.067 +Implemented in: 0.250.067 + +This test ensures #1059 retains one Source and ordered Targets, uses bounded +native evidence, and preserves failed Targets during pairwise reduction. +Parent: #1055. Prerequisites: #1056, #1057, and #1058. +""" + +import ast +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +COMPARISON = ROOT / 'application' / 'single_app' / 'functions_document_comparison.py' +WORKFLOW = ROOT / 'application' / 'single_app' / 'functions_workflow_runner.py' +SETTINGS = ROOT / 'application' / 'single_app' / 'functions_settings.py' + + +def _load_evidence_comparison(): + source = COMPARISON.read_text(encoding='utf-8') + tree = ast.parse(source) + names = { + '_build_pairwise_comparison_prompt', + '_build_comparison_reduction_prompt', + 'run_evidence_document_comparison', + } + module = ast.Module( + body=[node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name in names], + type_ignores=[], + ) + ast.fix_missing_locations(module) + namespace = {} + exec(compile(module, str(COMPARISON), 'exec'), namespace) + return namespace['run_evidence_document_comparison'] + + +def test_pairwise_reducer_preserves_target_order_and_partial_failure(): + """Completed Targets are compared in order while a failed Target remains visible.""" + compare = _load_evidence_comparison() + calls = [] + + def invoke_prompt(prompt, stage='', metadata=None): + calls.append((stage, metadata or {})) + return f"{stage}:{metadata.get('right_document_id', 'reduction')}" + + result = compare( + 'Compare calculated facts with stated policy.', + { + 'document_id': 'source-csv', + 'document_name': 'Source.csv', + 'source_kind': 'tabular', + 'engine': 'tabular_tools', + 'status': 'completed', + 'summary': 'Computed total: 24.', + }, + [ + { + 'document_id': 'target-pdf', + 'document_name': 'Target.pdf', + 'source_kind': 'narrative', + 'engine': 'document_analysis', + 'status': 'completed', + 'summary': 'The policy states a total of 23.', + }, + { + 'document_id': 'target-xlsx', + 'document_name': 'Target.xlsx', + 'source_kind': 'tabular', + 'engine': 'tabular_tools', + 'status': 'failed', + 'summary': '', + }, + ], + invoke_prompt, + ) + + assert [item['right_document_id'] for item in result['comparison_items']] == ['target-pdf'] + assert result['coverage']['failed_targets'] == ['Target.xlsx'] + assert calls == [('comparison', {'comparison_index': 1, 'comparison_count': 2, 'left_document_id': 'source-csv', 'right_document_id': 'target-pdf'})] + assert 'Evidence engines: document_analysis, tabular_tools' in result['reply'] + assert 'Conclusion level: aggregate or narrative' in result['reply'] + + +def test_cross_format_coordinator_uses_native_partitions_and_rollout_guards(): + """CSV/XLSX and PDF/DOCX combinations must use native branches, not chunk fallback.""" + source = WORKFLOW.read_text(encoding='utf-8') + tree = ast.parse(source) + helper = next( + node for node in tree.body + if isinstance(node, ast.FunctionDef) and node.name == '_execute_cross_format_comparison_workflow' + ) + helper_source = ast.get_source_segment(source, helper) or '' + + assert '_resolve_cross_format_comparison_manifest(' in helper_source + assert "partitions['narrative_sources']" in helper_source + assert "partitions['tabular_sources']" in helper_source + assert 'run_document_analysis(' in helper_source + assert '_maybe_execute_tabular_document_action(' in helper_source + assert 'DOCUMENT_ACTION_TYPE_ANALYZE' in helper_source + assert 'build_evidence_envelope(' in helper_source + assert 'source_version' in helper_source + assert 'run_evidence_document_comparison(' in helper_source + assert "'computed tabular facts'" in helper_source + assert "'narrative document analysis'" in helper_source + assert 'is_cross_format_compare_one_to_many_enabled(settings)' in helper_source + assert 'Mixed narrative and tabular Compare is temporarily unavailable while cross-format Compare is disabled.' in source + + +def test_phase_4_flags_default_off_and_all_runner_paths_use_them(): + """Model and agent Compare retain flag-off rollback and staged one-to-many rollout.""" + settings_source = SETTINGS.read_text(encoding='utf-8') + workflow_source = WORKFLOW.read_text(encoding='utf-8') + + assert "'enable_cross_format_compare': False" in settings_source + assert "'enable_cross_format_compare_one_to_many': False" in settings_source + assert 'def is_cross_format_compare_enabled(settings):' in settings_source + assert 'def is_cross_format_compare_one_to_many_enabled(settings):' in settings_source + assert workflow_source.count('mixed_comparison_enabled = is_cross_format_compare_enabled(settings)') == 2 + assert workflow_source.count('_execute_cross_format_comparison_workflow(') >= 3 + + +if __name__ == '__main__': + test_pairwise_reducer_preserves_target_order_and_partial_failure() + test_cross_format_coordinator_uses_native_partitions_and_rollout_guards() + test_phase_4_flags_default_off_and_all_runner_paths_use_them() + print('Phase 4 cross-format Compare tests passed.') \ No newline at end of file diff --git a/functional_tests/test_tabular_document_actions_workflow.py b/functional_tests/test_tabular_document_actions_workflow.py index 15d3dfdfd..34d2c2d93 100644 --- a/functional_tests/test_tabular_document_actions_workflow.py +++ b/functional_tests/test_tabular_document_actions_workflow.py @@ -82,7 +82,9 @@ def test_analyze_and_compare_dispatch_use_tabular_helper() -> None: assert workflow_runner_content.count('_execute_mixed_source_analyze_workflow(') >= 3, ( "Expected combined Analyze model and agent paths to use the Phase 3 coordinator." ) - assert "DOCUMENT_ACTION_TYPE_COMPARISON,\n workflow,\n comparison_config," in workflow_runner_content, ( + assert workflow_runner_content.count( + 'DOCUMENT_ACTION_TYPE_COMPARISON, workflow, comparison_config, settings,' + ) >= 2, ( "Expected document comparison workflow execution to call the shared tabular document-action helper." ) assert "related_document_evidence_summary=tabular_document.get('related_document_evidence_summary') or ''" in workflow_runner_content, (