From 572a3c71b1e475f3c1234f3271f1579780237e81 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Sun, 8 Nov 2026 15:12:58 -0500 Subject: [PATCH] Fix document orchestration pipeline reliability Repair unambiguous selected-source wiring with two shared planner corrections, route native Word comments through the authorized shared reader and retained citations, and repair malformed checker evidence labels without weakening grounding. Add content-free correlated diagnostics, regressions and documentation for v0.261.422. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- application/single_app/config.py | 2 +- .../single_app/functions_appinsights.py | 2 +- .../single_app/functions_office_media.py | 80 ++++- .../functions_orchestration_adapters.py | 73 +++-- .../functions_orchestration_composition.py | 24 +- .../functions_orchestration_excel.py | 37 ++- .../functions_orchestration_planner.py | 114 ++++++- .../functions_orchestration_registry.py | 9 +- .../functions_orchestration_result_runtime.py | 35 +- .../functions_orchestration_schema.py | 85 +++-- .../functions_orchestration_source_context.py | 9 +- .../single_app/functions_search_service.py | 42 ++- .../single_app/functions_task_completeness.py | 65 +++- .../document_search_plugin.py | 5 +- .../static/js/chat/chat-citations.js | 34 +- .../src/components/chat/CitationChip.tsx | 46 ++- application/v2_ui/src/lib/types.ts | 15 + docs/admin/orchestration.md | 3 +- .../features/CHAT_ORCHESTRATION.md | 31 +- .../features/CHAT_ORCHESTRATION_ACTIONS.md | 13 +- ..._ACCESSIBILITY_AND_STRUCTURE_EXTRACTION.md | 39 ++- ...STRATION_DOCUMENT_PLANNING_RECOVERY_FIX.md | 12 +- ...ESTRATION_PLANNER_CONTRACT_RECOVERY_FIX.md | 11 +- .../ORCHESTRATION_RELIABILITY_PIPELINE_FIX.md | 201 ++++++++++++ .../review-and-edit-orchestration-plans.md | 32 +- docs/reference/actions/document-search.md | 30 +- docs/reference/chat-controls.md | 14 +- docs/reference/logging-tags.md | 89 ++++- .../cosmos_response_boundaries.json | 8 +- ...rchestration_attachment_native_evidence.py | 310 +++++++++++++++++- ...test_orchestration_conversation_sources.py | 5 +- ...rchestration_document_planning_recovery.py | 54 +-- .../test_orchestration_failure_telemetry.py | 9 +- ...orchestration_planner_contract_recovery.py | 106 +++++- ...test_orchestration_planning_persistence.py | 9 +- ..._orchestration_selected_source_planning.py | 13 +- ...test_orchestration_source_kind_planning.py | 6 +- .../test_task_completeness_engine.py | 50 ++- ui_tests/test_v2_stream_citation_delivery.py | 119 ++++++- 39 files changed, 1646 insertions(+), 195 deletions(-) create mode 100644 docs/explanation/fixes/ORCHESTRATION_RELIABILITY_PIPELINE_FIX.md diff --git a/application/single_app/config.py b/application/single_app/config.py index c82e58803..b64e3d74a 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -103,7 +103,7 @@ EXECUTOR_MAX_WORKERS = 30 CHAT_STREAM_EXECUTOR_MAX_WORKERS = max(8, int(os.getenv('SIMPLECHAT_CHAT_STREAM_WORKERS', '32') or '32')) SESSION_TYPE = 'filesystem' -VERSION = "0.261.421" +VERSION = "0.261.422" IS_DEVELOPMENT = is_development_env_enabled() # Opt-out for deployments where App Service Easy Auth is active but the platform diff --git a/application/single_app/functions_appinsights.py b/application/single_app/functions_appinsights.py index 02dff73ae..b72aec48f 100644 --- a/application/single_app/functions_appinsights.py +++ b/application/single_app/functions_appinsights.py @@ -142,7 +142,7 @@ "validationcode", "validationrule", "responsefailure", "executioncode", "outputcode", "durablestatus", "failurecode", "authorityreason", "capabilityid", "outputformat", "producercapabilityid", "executionpath", "outcome", "analysisvalidationstatus", "analysissemantics", - "schemakeyword", "argumentfield", "resultkind", + "schemakeyword", "argumentfield", "resultkind", "sourceformat", }) diff --git a/application/single_app/functions_office_media.py b/application/single_app/functions_office_media.py index 13713b355..d327076a0 100644 --- a/application/single_app/functions_office_media.py +++ b/application/single_app/functions_office_media.py @@ -754,7 +754,7 @@ def _extract_docx_comment_paragraph_text(paragraph_elem): return '\n'.join(line for line in lines if line).strip() -def build_docx_comment_anchors_and_offsets(archive): +def build_docx_comment_anchors_and_offsets(archive, *, strict=False, anchor_completeness=None): """Return ``(comment_word_offsets, comment_anchors, total_body_words)`` from ``word/document.xml``. Walking ``word/document.xml`` in document order captures both the reading-order word offset @@ -768,16 +768,22 @@ def build_docx_comment_anchors_and_offsets(archive): archive, DOCX_MAIN_DOCUMENT_PART, OFFICE_DOCUMENT_PART_MAX_BYTES ) if document_bytes is None: + if strict: + raise ValueError('word_comment_anchors_unavailable') return comment_word_offsets, completed_anchors, 0 - try: document_root = defused_fromstring(document_bytes) except (DefusedParseError, ValueError): + if strict: + raise ValueError('word_comment_anchors_invalid') from None return comment_word_offsets, completed_anchors, 0 + if strict and _xml_local_name(document_root.tag) != 'document': + raise ValueError('word_comment_anchors_invalid') word_count = 0 active_ranges = {} active_range_lengths = {} + clipped_ranges = set() for element in document_root.iter(): local_name = _xml_local_name(element.tag) @@ -788,6 +794,8 @@ def build_docx_comment_anchors_and_offsets(archive): if comment_id not in completed_anchors and comment_id not in active_ranges: active_ranges[comment_id] = [] active_range_lengths[comment_id] = 0 + if anchor_completeness is not None: + anchor_completeness[comment_id] = False continue if local_name == 'commentRangeEnd': @@ -799,6 +807,9 @@ def build_docx_comment_anchors_and_offsets(archive): completed_anchors.setdefault( comment_id, anchor_text[:OFFICE_COMMENT_ANCHOR_MAX_CHARS] ) + if anchor_completeness is not None: + anchor_completeness[comment_id] = comment_id not in clipped_ranges + clipped_ranges.discard(comment_id) continue if local_name == 'commentReference': @@ -814,6 +825,8 @@ def build_docx_comment_anchors_and_offsets(archive): for comment_id, parts in active_ranges.items(): current_len = active_range_lengths.get(comment_id, 0) remaining = OFFICE_COMMENT_ANCHOR_MAX_CHARS - current_len + if len(text_value) > remaining: + clipped_ranges.add(comment_id) if remaining > 0: snippet = text_value[:remaining] parts.append(snippet) @@ -826,6 +839,8 @@ def build_docx_comment_anchors_and_offsets(archive): if current_len < OFFICE_COMMENT_ANCHOR_MAX_CHARS: parts.append(' ') active_range_lengths[comment_id] = current_len + 1 + else: + clipped_ranges.add(comment_id) for comment_id, parts in active_ranges.items(): anchor_text = re.sub(r'\s+', ' ', ''.join(parts)).strip() @@ -835,19 +850,25 @@ def build_docx_comment_anchors_and_offsets(archive): return comment_word_offsets, completed_anchors, word_count -def _parse_docx_comments_extended(archive): +def _parse_docx_comments_extended(archive, *, strict=False): """Parse ``word/commentsExtended.xml`` for reply parent links and resolved state.""" extended_by_para_id = {} extended_bytes = _read_zip_entry_bounded( archive, DOCX_COMMENTS_EXTENDED_PART, OFFICE_COMMENTS_PART_MAX_BYTES ) if extended_bytes is None: + if strict and DOCX_COMMENTS_EXTENDED_PART in archive.namelist(): + raise ValueError('word_comment_threads_unavailable') return extended_by_para_id try: extended_root = defused_fromstring(extended_bytes) except (DefusedParseError, ValueError): + if strict: + raise ValueError('word_comment_threads_invalid') from None return extended_by_para_id + if strict and _xml_local_name(extended_root.tag) != 'commentsEx': + raise ValueError('word_comment_threads_invalid') for elem in extended_root.iter(): if _xml_local_name(elem.tag) != 'commentEx': @@ -860,28 +881,38 @@ def _parse_docx_comments_extended(archive): extended_by_para_id[para_id] = { 'parent_para_id': parent_para_id or None, 'done': done_raw in ('1', 'true', 'on'), + 'done_available': done_raw in ('1', 'true', 'on', '0', 'false', 'off'), } return extended_by_para_id -def extract_docx_comments(file_path): +def extract_docx_comments(file_path, *, strict=False): """Extract structured review comments from an OOXML Word package (``.docx``, ``.docm``, ``.dotx``). Returns ``(comments, total_body_words)`` where ``comments`` is a list of top-level comment dicts (with nested ``replies`` when ``word/commentsExtended.xml`` links reply threads). + Native inspection uses ``strict`` to distinguish an inspected empty package from + unavailable, malformed or over-limit parts; legacy ingestion behavior is unchanged. """ if not file_path or not zipfile.is_zipfile(file_path): + if strict: + raise ValueError('word_comment_package_invalid') return [], 0 try: with zipfile.ZipFile(file_path) as archive: entry_names = archive.namelist() if len(entry_names) > OFFICE_ZIP_MAX_ENTRIES: + if strict: + raise ValueError('word_comment_package_limit') return [], 0 + anchor_completeness = {} comment_word_offsets, comment_anchors, total_body_words = ( - build_docx_comment_anchors_and_offsets(archive) + build_docx_comment_anchors_and_offsets( + archive, strict=strict, anchor_completeness=anchor_completeness, + ) ) if DOCX_COMMENTS_PART not in entry_names: return [], total_body_words @@ -890,14 +921,20 @@ def extract_docx_comments(file_path): archive, DOCX_COMMENTS_PART, OFFICE_COMMENTS_PART_MAX_BYTES ) if comments_bytes is None: + if strict: + raise ValueError('word_comments_unavailable') return [], total_body_words try: comments_root = defused_fromstring(comments_bytes) except (DefusedParseError, ValueError): + if strict: + raise ValueError('word_comments_invalid') from None return [], total_body_words + if strict and _xml_local_name(comments_root.tag) != 'comments': + raise ValueError('word_comments_invalid') - extended_by_para_id = _parse_docx_comments_extended(archive) + extended_by_para_id = _parse_docx_comments_extended(archive, strict=strict) raw_comments = [] comments_by_id = {} @@ -907,11 +944,17 @@ def extract_docx_comments(file_path): if _xml_local_name(element.tag) != 'comment': continue if len(raw_comments) >= OFFICE_COMMENTS_MAX_COUNT: + if strict: + raise ValueError('word_comment_inventory_limit') break comment_id = _xml_attr_by_local_name(element, 'id').strip() if not comment_id: + if strict: + raise ValueError('word_comment_identity_invalid') comment_id = str(len(raw_comments) + 1) + if strict and comment_id in comments_by_id: + raise ValueError('word_comment_identity_invalid') author = _xml_attr_by_local_name(element, 'author').strip() initials = _xml_attr_by_local_name(element, 'initials').strip() @@ -936,7 +979,7 @@ def extract_docx_comments(file_path): paragraph_texts.append(fallback_text) comment_text = '\n'.join(paragraph_texts).strip() - if not comment_text: + if not comment_text and not strict: continue word_offset = comment_word_offsets.get(comment_id) @@ -950,9 +993,14 @@ def extract_docx_comments(file_path): 'word_offset': word_offset, 'position_known': word_offset is not None, 'parent_id': None, - 'done': False, + 'done': None if strict else False, 'replies': [], '_para_ids': para_ids, + **({ + 'resolution_available': False, + 'anchor_complete': bool(comment_anchors.get(comment_id)) and anchor_completeness.get(comment_id, False), + 'anchor_character_limit': OFFICE_COMMENT_ANCHOR_MAX_CHARS, + } if strict else {}), } raw_comments.append(comment_record) comments_by_id[comment_id] = comment_record @@ -962,7 +1010,10 @@ def extract_docx_comments(file_path): ext_info = extended_by_para_id.get(para_id) if not ext_info: continue - if ext_info.get('done'): + if strict and ext_info.get('done_available'): + comment_record['done'] = ext_info['done'] + comment_record['resolution_available'] = True + elif not strict and ext_info.get('done'): comment_record['done'] = True parent_para_id = ext_info.get('parent_para_id') if parent_para_id: @@ -976,6 +1027,8 @@ def _resolve_root_comment(record): while current.get('parent_id') and current['parent_id'] in comments_by_id: parent_id = current['parent_id'] if parent_id in visited: + if strict: + raise ValueError('word_comment_thread_cycle') break visited.add(parent_id) current = comments_by_id[parent_id] @@ -994,15 +1047,20 @@ def _resolve_root_comment(record): 'date': comment_record['date'], 'text': comment_record['text'], 'done': comment_record['done'], + **({ + 'resolution_available': comment_record['resolution_available'], + } if strict else {}), }) - if root_comment.get('word_offset') is None and comment_record.get('word_offset') is not None: + if not strict and root_comment.get('word_offset') is None and comment_record.get('word_offset') is not None: root_comment['word_offset'] = comment_record['word_offset'] root_comment['position_known'] = True - if not root_comment.get('anchor_text') and comment_record.get('anchor_text'): + if not strict and not root_comment.get('anchor_text') and comment_record.get('anchor_text'): root_comment['anchor_text'] = comment_record['anchor_text'] return top_level_comments, total_body_words except (zipfile.BadZipFile, FileNotFoundError, OSError): + if strict: + raise ValueError('word_comment_package_unavailable') from None return [], 0 diff --git a/application/single_app/functions_orchestration_adapters.py b/application/single_app/functions_orchestration_adapters.py index 4169fae70..ebb2cd5b9 100644 --- a/application/single_app/functions_orchestration_adapters.py +++ b/application/single_app/functions_orchestration_adapters.py @@ -666,7 +666,7 @@ def validate_source(): def run_document_search(step, context, *, settings, user_id, emit, cancel_requested): arguments = _arguments(step) if arguments.get('native_section') == 'comments': - return _run_excel_comments( + return _run_native_comments( step, context, settings=settings, user_id=user_id, emit=emit, cancel_requested=cancel_requested, ) @@ -834,17 +834,23 @@ def run_document_search(step, context, *, settings, user_id, emit, cancel_reques # document_analyze -> functions_document_analysis.run_document_analysis # -------------------------------------------------------------------------------------- -def _run_excel_comments(step, context, *, settings, user_id, emit, cancel_requested): +def _run_native_comments(step, context, *, settings, user_id, emit, cancel_requested): # Search/storage initialization belongs at execution, not registry discovery. - from functions_orchestration_excel import collect_excel_comments + from functions_orchestration_excel import collect_excel_comments, collect_word_comments from functions_search_service import get_document_accessibility_payload + correlation = workflow_log_context( + conversation_id=_ctx(context, 'conversation_id'), run_id=_ctx(context, 'run_id'), + turn_id=_ctx(context, 'turn_id'), step_id=step.get('step_id'), + ) + failure_rule, source_format = 'native_source_binding', None try: identifiers = _resolve_step_document_ids( step, context, settings=settings, capability_id=CAPABILITY_DOCUMENT_SEARCH, ) if not identifiers: - raise ValueError('Native annotation inspection requires selected workbooks.') + raise ValueError('Native annotation inspection requires selected documents.') + failure_rule = 'native_source_authorization' manifest = resolve_context_source_manifest( context, identifiers, settings=settings, user_id=user_id, cancel_requested=cancel_requested, ) @@ -852,22 +858,42 @@ def _run_excel_comments(step, context, *, settings, user_id, emit, cancel_reques [source.get('document_id') for source in manifest] != identifiers or any(source.get('authorization_status') != 'authorized' for source in manifest) ): - raise PermissionError('The selected workbooks are unavailable.') - _emit(emit, _progress(step, CAPABILITY_DOCUMENT_SEARCH, 'Reviewing workbook comments')) + raise PermissionError('The selected documents are unavailable.') + _emit(emit, _progress(step, CAPABILITY_DOCUMENT_SEARCH, 'Reviewing native document comments')) results, coverage, citations = [], [], [] for source in manifest: - if not _text(source.get('file_name')).lower().endswith(('.xlsx', '.xlsm', '.xls')): - raise ValueError('Native annotation inspection requires an Excel workbook.') - records, source_coverage = collect_excel_comments( + failure_rule = 'native_comments_format_unsupported' + extension = _text(source.get('file_name')).lower().rsplit('.', 1)[-1] + source_format = extension if extension in ('xlsx', 'xlsm', 'xls', 'docx', 'docm', 'dotx') else 'unsupported' + if source_format == 'unsupported': + raise ValueError('Native comments require a supported Word or Excel document.') + word = source_format in ('docx', 'docm', 'dotx') + collector = collect_word_comments if word else collect_excel_comments + log_event('[ORCHESTRATION_ADAPTERS] Native comment read checkpoint.', extra={ + **correlation, 'stage': 'native_comment_read', 'outcome': 'started', + 'source_format': source_format, 'selected_document_count': len(manifest), + }) + failure_rule = 'native_comment_inventory_invalid' + records, source_coverage = collector( source['document_id'], user_id, read_page=get_document_accessibility_payload, - cancel_check=lambda: raise_if_mixed_source_cancelled(cancel_requested, 'excel_comments'), + cancel_check=lambda: raise_if_mixed_source_cancelled(cancel_requested, 'native_comments'), source_count=len(manifest), conversation_id=_ctx(context, 'conversation_id'), doc_scope=source['scope'], active_group_ids=[source['scope_id']] if source['scope'] == 'group' else None, active_public_workspace_id=source['scope_id'] if source['scope'] == 'public' else None, ) + source_coverage['format'] = source_format + log_event('[ORCHESTRATION_ADAPTERS] Native comment read checkpoint.', extra={ + **correlation, 'stage': 'native_comment_read', 'outcome': 'completed', + 'source_format': source_format, 'returned_record_count': len(records), + 'total_record_count': source_coverage.get('total_records'), + **({ + 'returned_reply_count': sum(len(record['replies']) for record in records), + } if word and all(type(record.get('replies')) is list for record in records) else {}), + 'partial': bool(source_coverage.get('partial')), + }) coverage.append({'document_id': source['document_id'], **source_coverage}) - # Include an explicit coverage record even for an inspected, empty workbook. + # An inspected empty inventory is evidence; an unavailable read never reaches retention. results.append({ 'document_id': source['document_id'], 'file_name': source.get('file_name'), 'id': f"{source['document_id']}:native-comments:coverage", @@ -883,21 +909,24 @@ def _run_excel_comments(step, context, *, settings, user_id, emit, cancel_reques results.append({ 'document_id': source['document_id'], 'file_name': source.get('file_name'), 'id': f"{source['document_id']}:native-comments:{index}", - 'chunk_text': json.dumps({'excel_annotation': record}, ensure_ascii=False), + 'chunk_text': json.dumps({'word_annotation' if word else 'excel_annotation': record}, ensure_ascii=False), 'sheet': record.get('sheet'), 'cell': record.get('cell'), }) citations.append({ 'source_type': 'document', 'document_id': source['document_id'], 'file_name': source.get('file_name'), 'sheet': record.get('sheet'), 'cell': record.get('cell'), 'annotation_id': record.get('id'), - 'evidence_kind': 'native_excel_annotation', + 'evidence_kind': 'native_word_annotation' if word else 'native_excel_annotation', 'citation_id': results[-1]['id'], 'sheet_name': record.get('sheet'), 'location_label': 'Location', 'location_value': ( - f"{record['sheet']}!{record['cell']}" - if record.get('sheet') and record.get('cell') else 'Workbook annotation' + f"Word comment {record['id']}" if word else ( + f"{record['sheet']}!{record['cell']}" + if record.get('sheet') and record.get('cell') else 'Workbook annotation' + ) ), }) + failure_rule = 'native_source_changed' current_manifest = resolve_context_source_manifest( context, identifiers, settings=settings, user_id=user_id, cancel_requested=cancel_requested, ) @@ -905,20 +934,26 @@ def _run_excel_comments(step, context, *, settings, user_id, emit, cancel_reques if any(source.get('authorization_status') != 'authorized' for source in current_manifest) or [ tuple(source.get(key) for key in identity_fields) for source in current_manifest ] != [tuple(source.get(key) for key in identity_fields) for source in manifest]: - raise PermissionError('The selected workbook changed during inspection.') + raise PermissionError('The selected document changed during inspection.') + failure_rule = 'native_result_retention' task = retain_document_search_result( step, context, results, source_manifest=manifest, citations=citations, search_scope={'native_inspection': coverage}, ) return build_step_result( status=STEP_STATUS_PARTIAL if task.status == 'partial' else STEP_STATUS_COMPLETED, - summary=f'Retained native annotation evidence from {len(manifest)} workbook(s).', + summary=f'Retained native annotation evidence from {len(manifest)} document(s).', task_result=task, citations=citations, ) except MixedSourceCancellationError: - return _cancelled_result('Workbook inspection was cancelled.') + return _cancelled_result('Native comment inspection was cancelled.') except Exception as exc: - return _failed_result('The workbook annotation inventory could not be read.', exc) + log_event('[ORCHESTRATION_ADAPTERS] Native comment read checkpoint.', level=logging.WARNING, extra={ + **correlation, 'stage': 'native_comment_read', 'outcome': 'failed', + 'source_format': source_format, 'validation_rule': failure_rule, + 'error_type': type(exc).__name__, + }) + return _failed_result('The native document annotation inventory could not be read.', exc) def _resolve_step_document_ids(step, context, *, settings=None, capability_id=None): diff --git a/application/single_app/functions_orchestration_composition.py b/application/single_app/functions_orchestration_composition.py index 61be5c3ce..791461bde 100644 --- a/application/single_app/functions_orchestration_composition.py +++ b/application/single_app/functions_orchestration_composition.py @@ -184,6 +184,16 @@ } +def _log_completeness_diagnostic(step, context, diagnostic, message): + log_event(message, level=logging.WARNING, extra={ + **workflow_log_context( + conversation_id=context.conversation_id, run_id=context.run_id, + turn_id=getattr(context, 'turn_id', None), step_id=step['step_id'], + ), + **diagnostic, 'stage': 'task_completeness_assessment', + }) + + def _quoted(value): return json.dumps(value, ensure_ascii=False) @@ -537,9 +547,9 @@ def checkpoint(value): validate=lambda value: [], checkpoint=checkpoint, saved_state=saved, recheck=recheck, owner_step_id=step['step_id'], evidence_basis='mixed' if legacy_evidence else 'request', legacy_evidence=legacy_evidence, - on_error=lambda error_type: log_event( + on_diagnostic=lambda diagnostic: _log_completeness_diagnostic( + step, context, diagnostic, '[ORCHESTRATION_EXECUTOR] Retained final-answer verification unavailable.', - level=logging.WARNING, extra={'error_type': error_type, 'step_id': step['step_id']}, ), ) except MixedSourceCancellationError: @@ -547,7 +557,11 @@ def checkpoint(value): except Exception as error: log_event( '[ORCHESTRATION_EXECUTOR] Retained final-answer verification could not complete.', - level=logging.WARNING, extra={'error_type': type(error).__name__, 'step_id': step['step_id']}, + level=logging.WARNING, extra={ + **workflow_log_context( + conversation_id=context.conversation_id, run_id=context.run_id, step_id=step['step_id'], + ), 'error_type': type(error).__name__, 'stage': 'task_completeness_assessment', + }, ) checked = {**result, 'task_completeness': public_completeness_report(state)} if state['status'] != 'complete': @@ -944,9 +958,9 @@ def save_completeness(state): ) else 'request' ), legacy_evidence=legacy_evidence, - on_error=lambda error_type: log_event( + on_diagnostic=lambda diagnostic: _log_completeness_diagnostic( + step, context, diagnostic, '[ORCHESTRATION_EXECUTOR] Task completeness verification unavailable.', - level=logging.WARNING, extra={'error_type': error_type, 'step_id': step['step_id']}, ), ) completeness_report = public_completeness_report(completeness_state) diff --git a/application/single_app/functions_orchestration_excel.py b/application/single_app/functions_orchestration_excel.py index 019e8406b..8b43ce841 100644 --- a/application/single_app/functions_orchestration_excel.py +++ b/application/single_app/functions_orchestration_excel.py @@ -1,11 +1,46 @@ # functions_orchestration_excel.py -"""Read authorized Excel annotation inventories, not worksheet-row previews.""" +"""Read exact native annotation inventories, preserving Excel paging and Word threads.""" import json from functions_orchestration_results import MAX_VALUE_BYTES +def collect_word_comments(document_id, user_id, *, read_page, cancel_check, source_count=1, **scope): + cancel_check() + report = read_page(document_id, user_id, sections="comments", **scope) + cancel_check() + inventory = report.get("word_inspection") + if type(inventory) is not dict or inventory.get("section") != "comments": + raise LookupError("The native Word comment inventory is unavailable.") + values, total = inventory.get("records"), inventory.get("total_records") + if ( + inventory.get("inventory_complete") is not True or type(values) is not list + or type(total) is not int or total != len(values) + or type(inventory.get("source_sha256")) is not str + or any(type(record) is not dict for record in values) + ): + raise ValueError("The native Word comment inventory has invalid coverage.") + budget = MAX_VALUE_BYTES // (4 * max(1, source_count)) + records, used = [], 0 + for record in values: + cancel_check() + size = len(json.dumps(record, ensure_ascii=False).encode("utf-8")) + if used + size > budget: + break + records.append(record) + used += size + partial = len(records) != total + return records, { + "execution_mode": "native_inspection", "section": "comments", "format": report.get("format"), + "source_sha256": inventory["source_sha256"], "total_records": total, + "returned_records": len(records), "total_replies": inventory.get("total_replies"), + "inventory_complete": True, "partial": partial, "pages_read": 1, + "has_more": partial, "limit_reached": partial, "next_cursor": None, + "limitations": inventory.get("limitations") or [], + } + + def collect_excel_comments(document_id, user_id, *, read_page, cancel_check, source_count=1, **scope): """Keep exact native records, including paginated large-record fragments.""" cursor = None diff --git a/application/single_app/functions_orchestration_planner.py b/application/single_app/functions_orchestration_planner.py index 99f23037b..0722b8237 100644 --- a/application/single_app/functions_orchestration_planner.py +++ b/application/single_app/functions_orchestration_planner.py @@ -102,8 +102,8 @@ PLANNER_MAX_TOKENS = 4000 PLANNER_TEMPERATURE = 0.1 -# Known declaration and source-binding failures share one correction round. -PLAN_REPAIR_ATTEMPTS = 1 +# All correctable draft failures share this allowance; the initial proposal is separate. +PLAN_REPAIR_ATTEMPTS = 2 PLANNER_DEBUG_MAX_ITEMS = 32 # A workflow proposal gets the same correction round; one that still fails is dropped from the # plan, and the rest of the plan runs without it. @@ -1633,6 +1633,57 @@ def _with_analysis_semantics(raw, edit_context=None): return plan +def _selected_source_default(selected_sources, selected_document_ids, authorized_document_ids): + if ( + not isinstance(selected_document_ids, (list, tuple)) or len(selected_document_ids) != 1 + or not isinstance(selected_document_ids[0], str) + or authorized_document_ids is None + or selected_document_ids[0] not in authorized_document_ids + ): + return None + matches = [ + source for source in selected_sources or () + if isinstance(source, dict) and source.get('document_id') == selected_document_ids[0] + ] + if ( + len(matches) != 1 or matches[0].get('availability') != 'available' + or matches[0].get('source_kind') not in ('narrative', 'tabular') + ): + return None + return matches[0] + + +def _with_selected_source_bindings( + raw, *, selected_sources, selected_document_ids, authorized_document_ids, actions, +): + """Fill only absent, unambiguous reader wiring; explicit and dynamic work is immutable.""" + source = _selected_source_default(selected_sources, selected_document_ids, authorized_document_ids) + if source is None or not isinstance(raw, dict) or not isinstance(raw.get('steps'), list): + return raw, [] + plan, repairs = deepcopy(raw), [] + for step in plan['steps']: + if ( + not isinstance(step, dict) or step.get('enabled', True) is not True + or step.get('inputs') or step.get('depends_on') + or not isinstance(step.get('arguments'), dict) + or 'document_ids' in step['arguments'] + ): + continue + capability_id, arguments = step.get('capability_id'), step['arguments'] + eligible = capability_id == 'document_search' or ( + capability_id == CAPABILITY_DOCUMENT_ANALYZE and source['source_kind'] == 'narrative' + ) + if capability_id == 'action_invoke': + eligible = any( + isinstance(action, dict) and action.get('action_ref') == arguments.get('action_ref') + and is_document_search_action(action) for action in actions or () + ) + if eligible: + arguments['document_ids'] = [source['document_id']] + repairs.append(f'Connected the selected document to step "{step.get("step_id")}".') + return plan, repairs + + def plan_request( user_message, planner_context, @@ -1794,6 +1845,27 @@ def _failure(reason, error=None, *, stage=None, message=None, attempt=None): for candidate in context.get('candidate_documents') or [] if isinstance(candidate, dict) and candidate.get('source_kind') } + source_candidates = context.get('selected_sources') or context.get('candidate_documents') or [] + source_default = _selected_source_default( + source_candidates, (seeds or {}).get('document_ids'), authorized_document_ids, + ) + if source_default is not None: + for capability in context['capabilities']: + if capability['id'] == 'document_search' or ( + capability['id'] == CAPABILITY_DOCUMENT_ANALYZE and source_default['source_kind'] == 'narrative' + ): + capability['default_arguments'] = { + **capability.get('default_arguments', {}), + 'document_ids': [source_default['document_id']], + } + context['actions'] = [ + {**action, 'default_arguments': {'document_ids': [source_default['document_id']]}} + if is_document_search_action(action) else action for action in context['actions'] + ] + document_source_formats = { + candidate['document_id']: str(candidate.get('file_name') or '').rsplit('.', 1)[-1].lower() + for candidate in source_candidates if isinstance(candidate, dict) and candidate.get('file_name') + } def _diagnose(stage, proposal=None, *, attempt=None, error=None): if not is_debug_enabled(): @@ -1920,6 +1992,19 @@ def _correction_messages(error, stage, attempt, reply, *, handoff_present=False) def _normalize(raw, capability_ids, availability, selected_image, **options): raw = _with_analysis_semantics(raw, edit_context) _diagnose('analysis_defaults', raw, attempt=attempt) + local_repairs = [] + if edit_context is None and not conversation_only: + raw, local_repairs = _with_selected_source_bindings( + raw, selected_sources=source_candidates, + selected_document_ids=(seeds or {}).get('document_ids'), + authorized_document_ids=authorized_document_ids, actions=actions, + ) + if local_repairs: + log_event('[ORCHESTRATION_PLANNER] Selected source wiring repaired.', extra={ + **correlation, 'stage': 'selected_source_defaults', 'attempt': attempt, + 'source_binding_repair_count': len(local_repairs), 'selected_document_count': 1, + }) + _diagnose('selected_source_defaults', raw, attempt=attempt) if conversation_only and any( step.get('enabled', True) and step.get('capability_id') in CONVERSATION_RETRIEVAL_CAPABILITIES for step in raw.get('steps') or [] if isinstance(step, dict) @@ -1972,7 +2057,10 @@ def _normalize(raw, capability_ids, availability, selected_image, **options): image_selected=selected_image, **options, ) - validate_plan_document_source_kinds(normalized, document_source_kinds) + validate_plan_document_source_kinds( + normalized, document_source_kinds, document_source_formats=document_source_formats, + ) + normalized['validation']['repairs'].extend(local_repairs) return normalized log_event( @@ -2022,6 +2110,7 @@ def _normalize(raw, capability_ids, availability, selected_image, **options): } reasoning_metadata = build_model_reasoning_metadata(planner_model, 'planner') token_usage, usage_seen = {}, False + elicitation_plan_correction = False for attempt in range(1, PLAN_REPAIR_ATTEMPTS + 2): try: reply, usage = _call_planner(client, deployment, messages, require_complete_response=True) @@ -2134,6 +2223,7 @@ def _normalize(raw, capability_ids, availability, selected_image, **options): if attempt > PLAN_REPAIR_ATTEMPTS: return _failure('invalid_elicitation', exc, stage='plan_clarification', attempt=attempt) allow_elicitation = False + elicitation_plan_correction = True messages = [ *messages, {'role': 'assistant', 'content': reply}, {'role': 'user', 'content': ( @@ -2144,6 +2234,24 @@ def _normalize(raw, capability_ids, availability, selected_image, **options): continue if kind == 'elicitation': + if elicitation_plan_correction and attempt <= PLAN_REPAIR_ATTEMPTS: + log_event( + '[ORCHESTRATION_PLANNER] Discarding an unrenderable question set.', + level=logging.WARNING, extra={ + **correlation, 'stage': 'plan_clarification', 'attempt': attempt, + 'validation_code': 'plan_structure_invalid', 'validation_rule': 'elicitation_not_allowed', + }, + ) + messages = [ + *messages, {'role': 'assistant', 'content': reply}, + {'role': 'user', 'content': ( + 'The rejected clarification is still not an executable plan. Use the remaining ' + 'shared correction allowance to return the complete plan for the unchanged request, ' + 'authorized sources and requested deliverables, not another elicitation. ' + 'Do not invent choices, missing facts or source access.' + )}, + ] + continue return _failure('repeated_elicitation', stage='plan_clarification', attempt=attempt) raw_steps = parsed.get('steps') diff --git a/application/single_app/functions_orchestration_registry.py b/application/single_app/functions_orchestration_registry.py index 5cf60804e..b1c8db985 100644 --- a/application/single_app/functions_orchestration_registry.py +++ b/application/single_app/functions_orchestration_registry.py @@ -764,9 +764,10 @@ def _document_transform_descriptor(capability_id, label, instruction): "The question asks about information likely held in the user's own documents, " "and no particular document has been named. Prefer this over analysing a whole " "document when a few passages would answer the question." - " For Excel Notes or threaded comments, use native_section=comments with explicit " - "document_ids. This reads the native inventory across pages, including available " - "authors, replies, dates, resolution, worksheet/cell and saved cell values. Bind " + " For Word review comments (.docx, .docm, .dotx), or Excel Notes/threaded comments, " + "use native_section=comments with document_ids. This reads the native inventory, " + "including available anchors, authors, replies, dates and resolution; Excel also " + "includes worksheet/cell and saved cell values. Bind " "prepared or evidence to compose; row analysis alone cannot read annotations." ), 'settings_gates': (), @@ -798,7 +799,7 @@ def _document_transform_descriptor(capability_id, label, instruction): 'top_n': {'type': 'integer', 'minimum': 1, 'maximum': 50, 'default': 12}, 'native_section': { 'type': 'string', 'enum': ['comments'], - 'description': 'Read actual Excel Notes/threaded comments instead of search excerpts.', + 'description': 'Read actual Word review comments or Excel Notes/threaded comments, not search excerpts.', }, }, 'required': ['query'], diff --git a/application/single_app/functions_orchestration_result_runtime.py b/application/single_app/functions_orchestration_result_runtime.py index 31bb26dc1..2a860bef4 100644 --- a/application/single_app/functions_orchestration_result_runtime.py +++ b/application/single_app/functions_orchestration_result_runtime.py @@ -347,7 +347,9 @@ def visit(current): or type(prepared) is not dict or prepared.get('version') != 'orchestration-gathered-content-v1' or prepared.get('capability_id') != 'document_search' - or prepared.get('content_scope') not in ('returned_excerpts', 'native_excel_annotations') + or prepared.get('content_scope') not in ( + 'returned_excerpts', 'native_excel_annotations', 'native_document_annotations', + ) or type(prepared.get('citations')) is not list ): raise ResultContractError('result_citations_invalid') @@ -355,7 +357,7 @@ def visit(current): _search_evidence_citations(reader, prepared) if current.output_name == 'evidence' else prepared['citations'] ) - if prepared['content_scope'] == 'native_excel_annotations': + if prepared['content_scope'] in ('native_excel_annotations', 'native_document_annotations'): native_reference = prepared_reader.reference.to_dict() for source in lineage['sources']: search_counts.setdefault(source_key(source), set()) @@ -454,7 +456,7 @@ def read_native_citation_evidence(service, reference, *, citation_id, document_i """Resolve an exact committed annotation, with current source access and version. Unlike ordinary retained-result inspection, a source citation also authorizes the - workbook it offers to preview. Never search a current inventory by cell or decode + document it offers to preview. Never search a current inventory by location or decode a document identity from the synthetic citation ID. """ if ( @@ -465,7 +467,9 @@ def read_native_citation_evidence(service, reference, *, citation_id, document_i reader = service.open_result(reference, allow_partial=True, require_current_sources=True) prepared = read_complete_input(reader) if ( - type(prepared) is not dict or prepared.get('content_scope') != 'native_excel_annotations' + type(prepared) is not dict or prepared.get('content_scope') not in ( + 'native_excel_annotations', 'native_document_annotations', + ) or type(prepared.get('citations')) is not list or type(prepared.get('search_results')) is not list ): raise ResultContractError('native_citation_reference_invalid') @@ -489,6 +493,7 @@ def read_native_citation_evidence(service, reference, *, citation_id, document_i if type(value) is not dict: raise ResultContractError('native_citation_evidence_invalid') annotation = value.get('excel_annotation') + word_annotation = value.get('word_annotation') coverage = value.get('native_coverage') if type(annotation) is dict: citation = citations[0] @@ -497,6 +502,13 @@ def read_native_citation_evidence(service, reference, *, citation_id, document_i )): raise ResultContractError('native_citation_evidence_invalid') response = {'native_annotation': deepcopy(annotation), 'cited_text': annotation.get('text') or ''} + elif type(word_annotation) is dict: + if ( + citations[0].get('annotation_id') != word_annotation.get('id') + or citations[0].get('evidence_kind') != 'native_word_annotation' + ): + raise ResultContractError('native_citation_evidence_invalid') + response = {'native_annotation': deepcopy(word_annotation), 'cited_text': word_annotation.get('text') or ''} elif type(coverage) is dict: response = {'native_coverage': deepcopy(coverage), 'cited_text': ''} else: @@ -506,7 +518,10 @@ def read_native_citation_evidence(service, reference, *, citation_id, document_i return { **response, 'citation_id': citation_id, 'document_id': document_id, 'file_name': hits[0].get('file_name') or '', 'source_version': sources[0]['source_version'], - 'evidence_kind': 'native_excel_annotation' if annotation is not None else 'native_annotation_coverage', + 'evidence_kind': ( + 'native_word_annotation' if type(word_annotation) is dict + else 'native_excel_annotation' if type(annotation) is dict else 'native_annotation_coverage' + ), } @@ -627,8 +642,8 @@ def retain_document_search_result(step, context, results, *, source_manifest, ci partial = bool(native_coverage and any(item.get('partial') for item in native_coverage)) if native_coverage: limitations = ( - 'Native annotation evidence contains saved workbook records, not recalculated worksheet data.', - 'Reviewer feedback is distinct from the saved cell value; missing metadata must not be inferred.', + 'Native annotation evidence contains saved document records, not inferred or recalculated data.', + 'Reviewer feedback is distinct from document text or a saved cell value; missing metadata must not be inferred.', ) if partial: limitations += ('The native annotation inventory is partial; remaining records were not inspected.',) @@ -666,7 +681,11 @@ def retain_document_search_result(step, context, results, *, source_manifest, ci }, } if native_coverage: - prepared['content_scope'] = 'native_excel_annotations' + prepared['content_scope'] = ( + 'native_excel_annotations' if all( + item.get('format') in (None, 'xlsx', 'xlsm', 'xls') for item in native_coverage + ) else 'native_document_annotations' + ) prepared['coverage']['native_inspection'] = deepcopy(native_coverage) return service.persist_task_result( producer=context.result_producer(step), role='gather', status='partial' if partial else 'complete', diff --git a/application/single_app/functions_orchestration_schema.py b/application/single_app/functions_orchestration_schema.py index 017f12d3e..0635ceddb 100644 --- a/application/single_app/functions_orchestration_schema.py +++ b/application/single_app/functions_orchestration_schema.py @@ -1073,7 +1073,10 @@ def validate_dependency_plan( raise _step_contract_error('A model task must be a named task category.', raw, code='plan_structure_invalid', rule='model_task_invalid') if 'model_binding' in raw and type(raw['model_binding']) is not dict: - raise PlanValidationError('A model binding is server-owned structured data.') + raise _step_contract_error( + 'A model binding is server-owned structured data.', raw, + code='plan_structure_invalid', rule='model_binding_object_required', + ) if capability_id == CAPABILITY_WORKFLOW_RUN: # Loaded only when a plan starts a workflow. It runs before the generic limit and # contract checks, so each problem with the step is reported with its own rule. @@ -1115,26 +1118,33 @@ def validate_dependency_plan( 'The declared Analyze options do not match their contract.', code='analysis_options_invalid', rule='analysis_options_schema_invalid', ) - argument_error = next(Draft202012Validator(capability['inputs']).iter_errors(arguments), None) - if argument_error is not None: + argument_problems = [] + for argument_error in Draft202012Validator(capability['inputs']).iter_errors(arguments): + field = next(iter(argument_error.absolute_path), None) + if field is None and argument_error.validator == 'required': + field = next(( + name for name in capability['inputs'].get('required', ()) if name not in arguments + ), None) + argument_problems.append({ + 'argument_field': field if field in capability['inputs']['properties'] else 'unknown', + 'schema_keyword': argument_error.validator.lower(), + }) + if len(argument_problems) == 8: + break + if argument_problems: if capability_id in ( CAPABILITY_WORKFLOW_PROPOSE, CAPABILITY_WORKFLOW_RUN, CAPABILITY_WORKFLOW_RESULTS, CAPABILITY_WORKFLOW_HANDOFF, ): # These owners have their own correction/drop policy; preserve its boundary. raise PlanValidationError('Step arguments do not match the capability contract.') - field = next(iter(argument_error.absolute_path), None) - if field is None and argument_error.validator == 'required': - field = next(( - name for name in capability['inputs'].get('required', ()) if name not in arguments - ), None) - field = field if field in capability['inputs']['properties'] else 'unknown' - keyword = argument_error.validator.lower() + field = argument_problems[0]['argument_field'] + keyword = argument_problems[0]['schema_keyword'] raise _step_contract_error( 'Step arguments do not match the capability contract.', raw, code='plan_arguments_invalid', rule='unknown_argument' if keyword == 'additionalproperties' else 'argument_schema_invalid', - schema_keyword=keyword, argument_field=field, + schema_keyword=keyword, argument_field=field, argument_problems=argument_problems, ) arguments = deepcopy(arguments) if analysis_options is not None: @@ -1203,14 +1213,19 @@ def validate_dependency_plan( ): raise PlanValidationError('A destination conversation cannot reference itself as a source.') if any(isinstance(value, str) and not value.strip() for value in arguments.values()): - raise PlanValidationError('String arguments must not be empty or whitespace.') + raise _step_contract_error( + 'String arguments must not be empty or whitespace.', raw, + code='plan_arguments_invalid', rule='argument_string_empty', + ) for name, rule in capability['inputs']['properties'].items(): if name not in arguments and 'default' in rule: arguments[name] = deepcopy(rule['default']) if 'agent_name' in arguments and arguments['agent_name'] not in (agent_names or ()): - raise PlanValidationError('The selected agent is unavailable.') + raise PlanValidationError('The selected agent is unavailable.', + code='capability_unavailable', rule='agent_unavailable') if 'action_ref' in arguments and arguments['action_ref'] not in (action_refs or ()): - raise PlanValidationError('The selected action is unavailable.') + raise PlanValidationError('The selected action is unavailable.', + code='capability_unavailable', rule='action_unavailable') if capability_id == CAPABILITY_ACTION_INVOKE and 'document_ids' in arguments: action = next(( item for item in actions or () @@ -1225,9 +1240,15 @@ def validate_dependency_plan( for name in ('document_ids', 'right_document_ids'): if name in arguments: if any(not value or value != value.strip() for value in arguments[name]): - raise PlanValidationError('Document IDs must be exact, nonempty identifiers.') + raise _step_contract_error( + 'Document IDs must be exact, nonempty identifiers.', raw, + code='plan_arguments_invalid', rule='document_id_invalid', argument_field=name, + ) if len(set(arguments[name])) != len(arguments[name]): - raise PlanValidationError('Document selections must not contain duplicates.') + raise _step_contract_error( + 'Document selections must not contain duplicates.', raw, + code='plan_arguments_invalid', rule='document_selection_duplicate', argument_field=name, + ) if limit and len(arguments[name]) > limit: if handoff_offered: raise PlanValidationError( @@ -1235,9 +1256,15 @@ def validate_dependency_plan( 'plan, or hand the work off with one workflow_handoff step.', code=PLAN_BUDGET_EXCEEDED_CODE, rule='plan_document_budget', ) - raise PlanValidationError('The complete source selection exceeds the document limit.') + raise _step_contract_error( + 'The complete source selection exceeds the document limit.', raw, + code='source_binding_invalid', rule='document_limit_exceeded', argument_field=name, + ) if authorized_document_ids is not None and set(arguments[name]) - set(authorized_document_ids): - raise PlanValidationError('A required source is unavailable.') + raise _step_contract_error( + 'A required source is unavailable.', raw, + code='source_unavailable', rule='document_authorization_required', + ) if 'reference_document_ids' in arguments: _validate_reference_id_list(arguments['reference_document_ids'], 'Reference document IDs') if authorized_document_ids is not None and set(arguments['reference_document_ids']) - set(authorized_document_ids): @@ -1289,7 +1316,10 @@ def validate_dependency_plan( code='source_binding_required', rule='document_sources_required', ) if capability_id == 'document_analyze' and arguments.get('document_ids') and 'sources' in step['inputs']: - raise PlanValidationError('Use either explicit document IDs or a named source-set input.') + raise _step_contract_error( + 'Use either explicit document IDs or a named source-set input.', raw, + code='plan_inputs_invalid', rule='source_binding_conflict', + ) if capability_id == CAPABILITY_TABULAR_MERGE: if not arguments.get('document_ids') and 'sources' not in step['inputs']: raise PlanValidationError( @@ -1496,11 +1526,26 @@ def effective_plan_document_ids(plan, seeds=None): return list(dict.fromkeys(document_ids)) -def validate_plan_document_source_kinds(plan, document_source_kinds): +def validate_plan_document_source_kinds(plan, document_source_kinds, *, document_source_formats=None): """Check known source kinds without treating model-supplied labels as authority.""" if not isinstance(document_source_kinds, dict): raise PlanValidationError('Document type metadata is unavailable.', code='source_metadata_invalid') for step in plan.get('steps') or []: + if ( + step.get('enabled', True) and step.get('capability_id') == 'document_search' + and (step.get('arguments') or {}).get('native_section') == 'comments' + and any( + (document_source_formats or {}).get(document_id) not in ( + None, 'xlsx', 'xlsm', 'xls', 'docx', 'docm', 'dotx', + ) + for document_id in plan_document_ids({'steps': [step]}) + ) + ): + raise _step_contract_error( + 'Native comments require a supported Word or Excel source. Use another offered ' + 'reader for this format without dropping the selected document.', step, + code='source_kind_invalid', rule='native_comments_format_unsupported', + ) if step.get('enabled', True) and step.get('capability_id') in ( CAPABILITY_TABULAR_MERGE, CAPABILITY_TABULAR_INSPECT, ): diff --git a/application/single_app/functions_orchestration_source_context.py b/application/single_app/functions_orchestration_source_context.py index 461afde39..df18ebd90 100644 --- a/application/single_app/functions_orchestration_source_context.py +++ b/application/single_app/functions_orchestration_source_context.py @@ -159,10 +159,11 @@ def validate_source_clarification(context, selected_sources): Multiple plausible images require a focused source_selection question. Use normal presentation defaults rather than blocking on optional formatting preferences. -For Excel Notes or threaded comments, use document_search native_section="comments" -with explicit document_ids, then compose from prepared/evidence. This native inventory -contains available authors, replies, dates, resolution and saved worksheet/cell values, -including blank/out-of-table cells. Do not replace it with row previews or tabular_analyze. +For Word review comments (.docx, .docm, .dotx), or Excel Notes/threaded comments, use +document_search native_section="comments" with document_ids, then compose from +prepared/evidence. This native inventory contains available anchors, authors, replies, +dates and resolution; Excel also includes saved worksheet/cell values, including +blank/out-of-table cells. Do not replace it with row previews or tabular_analyze. Use tabular computation additionally only for actual calculations. Reviewer feedback is not a replacement worksheet value. Respect native coverage and unavailable metadata. diff --git a/application/single_app/functions_search_service.py b/application/single_app/functions_search_service.py index 4dc812229..549fd29cb 100644 --- a/application/single_app/functions_search_service.py +++ b/application/single_app/functions_search_service.py @@ -1,6 +1,7 @@ # functions_search_service.py """Shared search, retrieval, and summarization services for documents.""" +import hashlib import io import json import logging @@ -46,6 +47,7 @@ ) from functions_group import get_user_groups from functions_model_endpoint_identity_header import build_model_endpoint_identity_headers +from functions_office_media import extract_docx_comments from model_endpoint_clients import build_openai_client_timeout_options from public_source_access import get_public_source_workspace_ids from public_source_policy import uses_all_public_sources @@ -1109,6 +1111,7 @@ def get_document_chunks_payload( ACCESSIBILITY_REPORT_SECTIONS = ("accessibility", "style", "issues") +NATIVE_WORD_COMMENT_EXTENSIONS = (".docx", ".docm", ".dotx") ACCESSIBILITY_REPORT_STYLE_KEY = "style_inventory" ACCESSIBILITY_REPORT_ISSUES_KEY = "issues" @@ -1118,10 +1121,32 @@ def _normalize_accessibility_sections(sections): requested = [part.strip().lower() for part in sections.split(",")] else: requested = [str(part or "").strip().lower() for part in (sections or [])] - requested = [part for part in requested if part in ACCESSIBILITY_REPORT_SECTIONS] + requested = [part for part in requested if part in (*ACCESSIBILITY_REPORT_SECTIONS, "comments")] return requested or list(ACCESSIBILITY_REPORT_SECTIONS) +def _read_word_comment_inspection(document_context, user_id): + """Reuse the ingestion parser over the existing authorized original-file reader.""" + document, source_bytes = screening_access.read_available_document_bytes( + document_context["document"], user_id=user_id, + group_id=document_context.get("group_id"), + public_workspace_id=document_context.get("public_workspace_id"), purpose="accessibility", + ) + document_context["document"] = document + comments, total_body_words = extract_docx_comments(io.BytesIO(source_bytes), strict=True) + return { + "section": "comments", "source_sha256": hashlib.sha256(source_bytes).hexdigest(), + "records": comments, "total_records": len(comments), + "total_replies": sum(len(comment["replies"]) for comment in comments), + "total_body_words": total_body_words, "inventory_complete": True, + "limitations": [ + "Anchored text is limited to 300 characters; anchor_complete identifies a bounded or unavailable anchor.", + "Resolution is unknown unless resolution_available is true; a missing flag is not an unresolved comment.", + "Reply relationships come from the available Word commentsExtended part, not inferred from comment text.", + ], + } + + def _read_excel_inspection( document_context, user_id, *, native_section="comments", native_cursor=None, native_page_size=100, sheet_name=None, cell_range=None, @@ -1242,6 +1267,16 @@ def get_document_accessibility_payload( stored_metadata = current_document.get("accessibility_metadata") file_name = str(current_document.get("file_name") or "") extension = "." + file_name.rsplit(".", 1)[-1].lower() if "." in file_name else "" + requested_sections = _normalize_accessibility_sections(sections) + word_inspection = None + if "comments" in requested_sections and extension in NATIVE_WORD_COMMENT_EXTENSIONS: + if native_cursor or cell_range or sheet_name or native_section != "comments": + raise ValueError("Word comments do not accept Excel sections, cursors or cell filters.") + word_inspection = _read_word_comment_inspection(document_context, user_id) + current_document = document_context["document"] + stored_metadata = current_document.get("accessibility_metadata") + if not stored_metadata and all(section == "comments" for section in requested_sections): + stored_metadata = {"format": extension.lstrip(".")} if not stored_metadata and extension in (".xlsx", ".xlsm", ".xls") and ( current_document.get("source_file_available") or current_document.get("enhanced_citations") or current_document.get("blob_path") or current_document.get("archived_blob_path") @@ -1270,8 +1305,11 @@ def get_document_accessibility_payload( elif native_cursor or cell_range: raise LookupError("The original workbook or full native inventory is unavailable for this inspection.") - requested_sections = _normalize_accessibility_sections(sections) + if "comments" in requested_sections and word_inspection is None and excel_inspection is None: + raise LookupError("The native comment inventory is unavailable for this document format.") report = {} + if word_inspection is not None: + report["word_inspection"] = word_inspection if "accessibility" in requested_sections: report["accessibility_metadata"] = { key: value diff --git a/application/single_app/functions_task_completeness.py b/application/single_app/functions_task_completeness.py index bcc6b0d28..57e6c5e7e 100644 --- a/application/single_app/functions_task_completeness.py +++ b/application/single_app/functions_task_completeness.py @@ -190,14 +190,22 @@ def context_only_review_step_ids(steps): return frozenset((step["step_id"],)) -def assessment_response_format(previous=()): - """Close known obligation identities in the transport, not in model-authored prose.""" +def assessment_response_format(previous=(), *, evidence=None): + """Close obligation identities and supplied evidence labels in the transport.""" + refs = {"type": "array", "items": {"type": "string"}} + if evidence is not None: + if type(evidence) is not dict or any(type(key) is not str for key in evidence): + raise CompletenessError("completeness_input_invalid") + if evidence: + refs["items"]["enum"] = list(evidence) + else: + refs["maxItems"] = 0 judgment = { "type": "object", "properties": { "status": {"type": "string", "enum": sorted(REQUIREMENT_STATUSES)}, "reason": {"type": "string"}, - "evidence_refs": {"type": "array", "items": {"type": "string"}}, + "evidence_refs": refs, "answer_quote": {"type": "string"}, }, "required": ["status", "reason", "evidence_refs", "answer_quote"], @@ -356,12 +364,19 @@ def _evidence_aliases(evidence): def _resolve_evidence_refs(refs, evidence, aliases): + def invalid(rule): + raise CompletenessError("completeness_evidence_invalid", details={ + "evidence_rule": rule, "allowed_evidence_refs": list(evidence), + "evidence_ref_count": len(refs) if type(refs) is list else 0, + "evidence_refs_list": type(refs) is list, + }) + if type(refs) is not list or len(refs) > 100: - raise CompletenessError("completeness_evidence_invalid") + invalid("evidence_reference_list_invalid") resolved = [] for ref in refs: if type(ref) is not str: - raise CompletenessError("completeness_evidence_invalid") + invalid("evidence_reference_string_required") cleaned = ref.strip() if not cleaned: continue @@ -378,7 +393,7 @@ def _resolve_evidence_refs(refs, evidence, aliases): if owners: resolved.extend(key for key in evidence if key in owners and key not in resolved) continue - raise CompletenessError("completeness_evidence_invalid") + invalid("evidence_reference_unknown") return resolved @@ -504,7 +519,7 @@ def public_completeness_report(state): def check_and_correct( candidate, *, instruction, contract, evidence, policy, invoke, validate, checkpoint=None, saved_state=None, recheck=None, on_error=None, owner_step_id=None, - evidence_basis=None, legacy_evidence=None, + evidence_basis=None, legacy_evidence=None, on_diagnostic=None, ): """Check every candidate, with one persisted allowance shared by all validators. @@ -581,6 +596,30 @@ def stop(reason): save() return state["candidate"], state + def assessment_error(error): + code = str(error) if isinstance(error, CompletenessError) else type(error).__name__ + if callable(on_error): + on_error(code) + if callable(on_diagnostic): + known = code in _REPAIRABLE_REPORT_CODES or code in _OPTIONAL_CHECKER_ERRORS + diagnostic = { + "error_type": code if known else type(error).__name__, + "validation_code": code if known else "completeness_verification_unavailable", + "report_repair_count": state.get("report_repairs", 0), + "evidence_key_count": len(evidence), + } + if isinstance(error, CompletenessError): + for key in ("evidence_ref_count", "evidence_refs_list"): + if type(error.details.get(key)) in (int, bool): + diagnostic[key] = error.details[key] + rule = error.details.get("evidence_rule") + if rule in ( + "evidence_reference_list_invalid", "evidence_reference_string_required", + "evidence_reference_unknown", + ): + diagnostic["validation_rule"] = rule + on_diagnostic(diagnostic) + if state["status"] in ("complete", "incomplete", "verification_unavailable"): return state["candidate"], state if state["status"] == "correcting": @@ -637,9 +676,13 @@ def skip_optional_error(reason): return skipped state["semantic_status"] = "running" save() - response_format = assessment_response_format(state["requirements"]) + response_format = assessment_response_format(state["requirements"], evidence=evidence) payload = { **request, "candidate": current, + "evidence_reference_rule": ( + "Use only the top-level evidence labels permitted by assessment_schema. " + "Use [] when an obligation needs no source reference; never invent a label." + ), "assessment_schema": response_format["json_schema"]["schema"], "previous_requirements": [ {"id": item["id"], "requirement": item["requirement"]} @@ -661,8 +704,7 @@ def assess(): try: requirements = assess() except Exception as error: - if callable(on_error): - on_error(str(error) if isinstance(error, CompletenessError) else type(error).__name__) + assessment_error(error) if isinstance(error, CompletenessError): skipped = skip_optional_error(str(error)) if skipped is not None: @@ -679,8 +721,7 @@ def assess(): try: requirements = assess() except Exception as repair_error: - if callable(on_error): - on_error(str(repair_error) if isinstance(repair_error, CompletenessError) else type(repair_error).__name__) + assessment_error(repair_error) return stop( str(repair_error) if isinstance(repair_error, CompletenessError) else "completeness_verification_unavailable" diff --git a/application/single_app/semantic_kernel_plugins/document_search_plugin.py b/application/single_app/semantic_kernel_plugins/document_search_plugin.py index 0a19ca864..def5a7261 100644 --- a/application/single_app/semantic_kernel_plugins/document_search_plugin.py +++ b/application/single_app/semantic_kernel_plugins/document_search_plugin.py @@ -448,6 +448,9 @@ def retrieve_document_chunks( 'Use it to build custom style-guide or accessibility checks. This is extracted metadata, not a ' 'WCAG or PDF/UA conformance result. Use sections to limit the output and page_number to also ' 'return that page\'s stored chunk text. ' + 'For Word .docx/.docm/.dotx review comments, request sections=comments; word_inspection ' + 'contains exact comment threads, anchors and available resolution metadata from the authorized ' + 'original. Missing originals or malformed comment parts are errors, not empty inventories. ' 'For Excel, inspect native_section and follow excel_inspection.page.next_cursor until has_more ' 'is false when the question requires every record. Compatibility notes are not extra comments. ' 'Use sheet_name and cell_range for actual saved values and formula caches; do not infer them ' @@ -459,7 +462,7 @@ def get_document_accessibility_report( self, document_id: Annotated[str, 'Document id to read accessibility and formatting metadata for.'], doc_scope: Annotated[str, 'all, personal, group, or public.'] = '', - sections: Annotated[str, 'Optional comma-separated sections: accessibility, style, issues. Defaults to all.'] = '', + sections: Annotated[str, 'Optional comma-separated sections: accessibility, style, issues, comments. Defaults to accessibility/style/issues; Word comments are opt-in.'] = '', page_number: Annotated[int, 'Optional 1-based stored page/slide or Excel evidence segment number, not an Excel printed page.'] = 0, active_group_ids: Annotated[str, 'Optional comma-separated group ids when resolving group content.'] = '', active_public_workspace_id: Annotated[str, 'Optional public workspace id when resolving public content.'] = '', diff --git a/application/single_app/static/js/chat/chat-citations.js b/application/single_app/static/js/chat/chat-citations.js index 9cabf5e35..d37079d50 100644 --- a/application/single_app/static/js/chat/chat-citations.js +++ b/application/single_app/static/js/chat/chat-citations.js @@ -421,7 +421,9 @@ export function fetchCitedText(citationId, citationContext = {}) { // Check for expected data fields explicitly if (data.native_annotation || data.native_coverage) { const record = data.native_annotation; - const text = record ? [ + const word = data.evidence_kind === 'native_word_annotation' + || ['docx', 'docm', 'dotx'].includes(data.native_coverage?.format); + const text = record && word ? nativeWordCommentText(record, data.cited_text) : record ? [ record.kind === 'note' ? 'Workbook note' : record.parent_id ? 'Threaded comment reply' : 'Threaded comment', `Author: ${record.author || 'Unavailable'}`, `Worksheet / cell: ${record.sheet || 'Unavailable'} / ${record.cell || 'Unavailable'}`, @@ -436,8 +438,8 @@ export function fetchCitedText(citationId, citationContext = {}) { 'Inventory coverage is not a worksheet cell or reviewer comment.', ].join('\n'); showCitedTextPopup(text, data.file_name, '', { - title: `Native workbook evidence: ${data.file_name}`, - workbookPreview: () => showEnhancedCitationModal( + title: `Native ${word ? 'Word' : 'workbook'} evidence: ${data.file_name}`, + workbookPreview: word ? undefined : () => showEnhancedCitationModal( data.document_id, record?.sheet || '', null, record?.sheet || null, ), }); @@ -459,6 +461,32 @@ export function fetchCitedText(citationId, citationContext = {}) { }); } +function nativeWordCommentText(record, citedText) { + const resolution = (comment) => comment.resolution_available && typeof comment.done === 'boolean' + ? comment.done ? 'Yes' : 'No' : 'Unavailable'; + const lines = [ + 'Word review comment', + `Author: ${record.author || 'Unavailable'}`, + `Date: ${record.date || 'Unavailable'}`, + `Anchored text: ${record.anchor_text || 'Unavailable'}`, + `Anchor coverage: ${record.anchor_complete ? 'Complete' : 'Bounded or unavailable'}`, + `Resolved: ${resolution(record)}`, + '', 'Reviewer text', citedText || 'No stored reviewer text.', + ]; + if (Array.isArray(record.replies)) { + for (const reply of record.replies) { + lines.push( + '', `Reply ${reply.id}`, `Author: ${reply.author || 'Unavailable'}`, + `Date: ${reply.date || 'Unavailable'}`, `Resolved: ${resolution(reply)}`, + reply.text || 'No stored reply text.', + ); + } + } else { + lines.push('', 'Reply metadata unavailable.'); + } + return lines.join('\n'); +} + function renderMarkdownIntoCitationElement(contentElement, markdownText) { if (typeof marked === 'undefined' || typeof DOMPurify === 'undefined') { contentElement.textContent = String(markdownText || ''); diff --git a/application/v2_ui/src/components/chat/CitationChip.tsx b/application/v2_ui/src/components/chat/CitationChip.tsx index 8e4203d5b..7ffbefa09 100644 --- a/application/v2_ui/src/components/chat/CitationChip.tsx +++ b/application/v2_ui/src/components/chat/CitationChip.tsx @@ -88,6 +88,9 @@ function CitationDetail({ return () => document.removeEventListener('keydown', onKeyDown); }, [onClose, preview]); + const wordAnnotation = data?.evidence_kind === 'native_word_annotation' + || ['docx', 'docm', 'dotx'].includes(data?.native_coverage?.format ?? ''); + if (preview) return
- {data?.native_annotation &&
+ {data?.native_annotation &&

- {data.native_annotation.kind === 'note' ? 'Workbook note' + {wordAnnotation ? 'Word review comment' : data.native_annotation.kind === 'note' ? 'Workbook note' : data.native_annotation.parent_id ? 'Threaded comment reply' : 'Threaded comment'}

Author: {data.native_annotation.author || 'Unavailable'}

-

Worksheet / cell: {data.native_annotation.sheet || 'Unavailable'} / {data.native_annotation.cell || 'Unavailable'}

+ {wordAnnotation + ? <> +

Anchored text: {data.native_annotation.anchor_text || 'Unavailable'}

+

Anchor coverage: {data.native_annotation.anchor_complete ? 'Complete' : 'Bounded or unavailable'}

+ + :

Worksheet / cell: {data.native_annotation.sheet || 'Unavailable'} / {data.native_annotation.cell || 'Unavailable'}

}

Date: {data.native_annotation.date || 'Unavailable'}

{data.native_annotation.parent_id &&

Reply to: {data.native_annotation.parent_id}

} -

Resolved: {typeof data.native_annotation.resolved === 'boolean' - ? data.native_annotation.resolved ? 'Yes' : 'No' : 'Unavailable'}

-

Saved cell value: {data.native_annotation.cell_value_status === 'blank' ? 'Blank' +

Resolved: {wordAnnotation + ? data.native_annotation.resolution_available && typeof data.native_annotation.done === 'boolean' + ? data.native_annotation.done ? 'Yes' : 'No' : 'Unavailable' + : typeof data.native_annotation.resolved === 'boolean' + ? data.native_annotation.resolved ? 'Yes' : 'No' : 'Unavailable'}

+ {!wordAnnotation &&

Saved cell value: {data.native_annotation.cell_value_status === 'blank' ? 'Blank' : data.native_annotation.cell_value == null ? 'Unavailable (not recalculated)' - : JSON.stringify(data.native_annotation.cell_value)}

+ : JSON.stringify(data.native_annotation.cell_value)}

}

Reviewer text

} {data?.native_coverage &&
@@ -151,7 +162,7 @@ function CitationDetail({

{data.native_coverage.returned_records ?? 'Unknown'} of {data.native_coverage.total_records ?? 'unknown'} records retained.

{data.native_coverage.partial || !data.native_coverage.inventory_complete ? 'Partial inventory; remaining annotations were not inspected.' : 'The saved annotation inventory was inspected completely.'}

-

This is inventory coverage, not a cited worksheet cell or reviewer comment.

+

This is inventory coverage, not a cited {wordAnnotation ? 'document passage' : 'worksheet cell'} or reviewer comment.

} {notice && (

@@ -178,17 +189,32 @@ function CitationDetail({ )} {data?.cited_text && ( -

+

{data.cited_text}

)} + {wordAnnotation && data?.native_annotation?.replies && data.native_annotation.replies.length > 0 && ( +
+

Replies

+ {data.native_annotation.replies.map((reply) => ( +
+

Author: {reply.author || 'Unavailable'}

+

Date: {reply.date || 'Unavailable'}

+

Resolved: {reply.resolution_available && typeof reply.done === 'boolean' + ? reply.done ? 'Yes' : 'No' : 'Unavailable'}

+

{reply.text || 'No stored reply text.'}

+
+ ))} +
+ )} + {!loading && !error && !data?.cited_text && (

This citation has no stored passage to show.

)} - {!loading && !error && citation.nativeEvidence && }
diff --git a/application/v2_ui/src/lib/types.ts b/application/v2_ui/src/lib/types.ts index 28d80713e..7634c34f7 100644 --- a/application/v2_ui/src/lib/types.ts +++ b/application/v2_ui/src/lib/types.ts @@ -1447,7 +1447,9 @@ export interface Citation { cited_text?: string; file_name?: string; page_number?: number | string; + evidence_kind?: string; native_annotation?: { + id?: string; kind?: string; author?: string; date?: string; @@ -1457,8 +1459,21 @@ export interface Citation { resolved?: boolean; cell_value?: unknown; cell_value_status?: string; + anchor_text?: string; + anchor_complete?: boolean; + done?: boolean | null; + resolution_available?: boolean; + replies?: Array<{ + id: string; + author?: string; + date?: string; + text?: string; + done?: boolean | null; + resolution_available?: boolean; + }>; }; native_coverage?: { + format?: string; returned_records?: number; total_records?: number; partial?: boolean; diff --git a/docs/admin/orchestration.md b/docs/admin/orchestration.md index 9c3cd9aab..941343805 100644 --- a/docs/admin/orchestration.md +++ b/docs/admin/orchestration.md @@ -684,7 +684,8 @@ without calling the integration again. Since **0.261.293**, what a plan gathers always reaches the user. Every gather step's results must be read by a step that writes the answer or prepares a file or image, and a chart or diagram must end up in the answer or a file. Starting a saved workflow is the one -exception. A plan that breaks the rule gets the planner's usual single correction. A chart +exception. Since **0.261.422**, recognized plan errors share at most two planner +corrections without widening source permissions. A chart an action drew is always shown, and one the answer doesn't show is listed in the delivery notes and makes the run partially completed, so **Retry from failed step** can draw it again. Retrying runs the action again, so the user confirms it first. diff --git a/docs/explanation/features/CHAT_ORCHESTRATION.md b/docs/explanation/features/CHAT_ORCHESTRATION.md index ba397cb7c..6d8d235a6 100644 --- a/docs/explanation/features/CHAT_ORCHESTRATION.md +++ b/docs/explanation/features/CHAT_ORCHESTRATION.md @@ -1,6 +1,6 @@ # Chat Orchestration -**Version: 0.261.417** (tracked in `application/single_app/config.py`) +**Version: 0.261.422** (tracked in `application/single_app/config.py`) **Implemented in version: 0.261.086** **Knowledge phase added in version: 0.261.089** @@ -32,6 +32,7 @@ **Selected-document planning recovery and debug diagnostics fixed/implemented in version: 0.261.413** **Initial-turn persistence and browser-independent Auto planning implemented in version: 0.261.415** **Confirmed progress, exact native workbook citations, and saved-analysis cleanup fixed in version: 0.261.417** +**Conservative source wiring, two shared planning corrections, native Word comments, and evidence-label checking fixed in version: 0.261.422** ## Overview @@ -101,9 +102,14 @@ Only a validated, approved plan reaches the execution adapters. Automatic approv does not bypass validation. Since **0.261.413**, a proposal that omits a selected file or declares incompatible -Analyze options receives targeted correction feedback. It shares the existing -single correction with other repairable planning failures: a second, different -failure does not start another retry budget. The server keeps source access, +Analyze options receives targeted correction feedback. Since **0.261.422**, eligible +failures share two model corrections: at most three proposals, not a new budget +for each error. When exactly one selected source has verified authorization and +availability, the server can fill an absent document binding on an existing, +enabled independent reader without another model call. It does not invent steps, +replace explicit IDs or dynamic inputs, narrow multiple selections, or restore +disabled work. Plan editing keeps its explicit source choices. +The server keeps source access, source-kind checks, every required selection, and the selected planner model. An explicit later plan edit can still narrow the selection, with a review warning. @@ -114,12 +120,27 @@ version/mode combinations are checked together; saved legacy meanings remain unchanged. An uncorrectable proposal fails clearly rather than becoming an ungrounded answer or silently excluding a file. +Native annotation requests distinguish Word from Excel. Word `.docx`, `.docm`, +and `.dotx` comments use the existing Word parser over authorized original bytes, +not the workbook reader or a search preview. Parsed reviewer text, anchored text, +available authors/dates/resolution, and linked replies travel through the retained +result and its citations. Missing metadata is unknown; missing originals and +malformed or over-limit packages are not successful empty inventories. + +Prepared-answer checking advertises only the evidence labels supplied to that +check. An invalid checker reference receives one report repair with usable labels; +it does not by itself prove the answer is factually wrong. A genuinely unsupported +answer still needs correction, and required review still blocks when it cannot +finish. This report repair is separate from the planner's two corrections. + The existing debug logging switch enables content-free proposal and source-binding diagnostics in console and Application Insights. These distinguish a model omission from a normalization change using hashes, counts, and rejection rules, without recording document contents or the model reply. See the [document planning recovery fix](../fixes/ORCHESTRATION_DOCUMENT_PLANNING_RECOVERY_FIX.md) and [logging reference](../../reference/logging-tags.md#selected-document-planning-diagnostics). +See the [pipeline reliability fix](../fixes/ORCHESTRATION_RELIABILITY_PIPELINE_FIX.md) +for native Word evidence, checker boundaries, and validation limitations. ## Internal dependency contract (M4) @@ -1484,7 +1505,7 @@ to the front. | `functional_tests/test_v2_orchestration_deliverables.mjs` | Browser normalization of deliverables and `delivers`, deliverable states that follow their steps, labels, generated-image helpers, a terminal frame read through the real run stream client, and a retry's image grouped under every answer that lists it, in React V2 and the classic client | | `ui_tests/test_v2_orchestration_generated_images.py` | The live chat loading an answer's generated images after the run, planned image cards that never offer Approve or Approve all while their image loads, and a reused image shown under both the earlier and the retried answer | | `functional_tests/test_orchestration_context_picker.py` | Picked tags reach the seeds and both search paths under the parameter `hybrid_search` really takes; a tag scopes the probe rather than replacing it; a picked document reaches the planner and the approval card by name; a browser-supplied name cannot widen access; search citations carry the workspace a document came from; a step can read what an earlier step found, an unusable binding is refused, and a run-time document still respects the configured ceiling | -| `functional_tests/test_orchestration_document_planning_recovery.py` | Picked-document HTTP planning across five file formats, preserved previews and Analyze availability, strict option semantics, source/operation retention, one shared correction including malformed questions, editor compatibility, Auto routing, no execution during planning, and private-content-free debug diagnostics in both sinks | +| `functional_tests/test_orchestration_document_planning_recovery.py` | Picked-document HTTP planning across five file formats, preserved previews and Analyze availability, strict option semantics, source/operation retention, conservative local wiring, two shared corrections including malformed questions, editor compatibility, Auto routing, no execution during planning, and private-content-free debug diagnostics in both sinks | | `functional_tests/test_orchestration_conversation_context.py` | Message eligibility, bounds, snapshot validation, nullable unused clarifications, strict response validation, bounded repair, token accounting, provider/refusal handling, contextualized adapters, synthesis roles, and URL provenance | | `functional_tests/test_orchestration_conversation_context_routes.py` | New and existing conversations across HTTP/SSE planning and execution, all approval modes, null clarifications, bounded recovery, model selection and attribution, revocation, completion failures, stream cleanup, stale sources and legacy cutoffs | | `ui_tests/test_v2_orchestration_conversation_context.py` | Matching clarification/model transport, cancellation, original-turn continuity, all approval modes, visible answer model names and navigation | diff --git a/docs/explanation/features/CHAT_ORCHESTRATION_ACTIONS.md b/docs/explanation/features/CHAT_ORCHESTRATION_ACTIONS.md index f168d7358..5a9009e39 100644 --- a/docs/explanation/features/CHAT_ORCHESTRATION_ACTIONS.md +++ b/docs/explanation/features/CHAT_ORCHESTRATION_ACTIONS.md @@ -1,6 +1,6 @@ # Chat Orchestration Action Access -Version: **0.261.421** +Version: **0.261.422** Implemented in version: **0.261.098** @@ -12,6 +12,8 @@ Selected-document action binding implemented in version: **0.261.419** Canonical plugin discovery and action-stage diagnostics implemented in version: **0.261.421** +Source defaults and shared corrections fixed/implemented in version: **0.261.422** + Application version tracking remains in `application/single_app/config.py`. ## Purpose @@ -134,12 +136,19 @@ authority; retained findings keep their existing conversation/run and integratio access policies. No source binding makes missing native metadata available, establishes conformance, or permits an outgoing operation. -The planner's single correction can repair classified contract declarations +Since **0.261.422**, the planner's two shared corrections can repair classified contract declarations without changing its model or executing tools. Invalid source permissions and unsupported integrations remain explicit failures. See the [planner contract recovery explanation](../fixes/ORCHESTRATION_PLANNER_CONTRACT_RECOVERY_FIX.md) for defaulted Analyze output variants, diagnostics, and full-path coverage. +For exactly one verified available selection, an existing independent Document +Search step can receive an absent binding locally before model correction. +Explicit IDs, dynamic inputs, disabled work, and edited selections remain intact. +Word comment-only metadata calls opt into the authorized original reader with +`sections="comments"`; they do not send Word files to an Excel collector. See the +[pipeline reliability fix](../fixes/ORCHESTRATION_RELIABILITY_PIPELINE_FIX.md). + ### Charting retrieved data Since **0.261.132**, an action step can chart what it retrieves. When the user's current diff --git a/docs/explanation/features/DOCUMENT_ACCESSIBILITY_AND_STRUCTURE_EXTRACTION.md b/docs/explanation/features/DOCUMENT_ACCESSIBILITY_AND_STRUCTURE_EXTRACTION.md index 9b20f48a6..ef8e5c90a 100644 --- a/docs/explanation/features/DOCUMENT_ACCESSIBILITY_AND_STRUCTURE_EXTRACTION.md +++ b/docs/explanation/features/DOCUMENT_ACCESSIBILITY_AND_STRUCTURE_EXTRACTION.md @@ -3,7 +3,8 @@ **Implemented in version: 0.261.391** (formatting, style inventory, and programmatic access added in **0.261.392**) **Excel implemented in version: 0.261.399** **Excel native content/table improvements implemented in version: 0.261.408** -**Related config.py version: `0.261.408`** +**Native Word comment inspection fixed/implemented in version: 0.261.422** +**Related config.py version: `0.261.422`** ## 1. Overview and Purpose @@ -272,10 +273,38 @@ The **Document Search** action exposes a `get_document_accessibility_report` Sem |---|---| | `document_id` | The document to read (required). | | `doc_scope` | `all`, `personal`, `group`, or `public`. | -| `sections` | Comma-separated subset of `accessibility`, `style`, `issues`. Defaults to all three. | +| `sections` | Comma-separated subset of `accessibility`, `style`, `issues`. Defaults to all three. Since 0.261.422, opt-in `comments` reads a workspace Word original. | | `page_number` | Optional 1-based page, slide, or Excel indexed evidence segment. Also returns that segment's stored chunk text. Excel segments are not physical print pages. | -Access follows the existing Document Search rules: the current user's workspace, group, and public access and the action's allowed scopes. Chat-scope uploads are refused, and documents without stored metadata return guidance to re-upload. There is deliberately no REST endpoint for this. +Access follows the existing Document Search rules: the current user's workspace, group, and public access and the action's allowed scopes. Chat-scope uploads are refused. Stored-report requests without metadata return extraction guidance; Word comment inspection can use an available original even when stored style metadata is absent. There is deliberately no separate REST endpoint for this function. + +### Opt-in native Word comments (0.261.422) + +Fixed/Implemented in version: **0.261.422** +(`application/single_app/config.py`). + +Structured Word comments are parsed during ingestion and incorporated into +searchable comment chunks; ordinary stored Word accessibility/style metadata is +not that structured inventory. `sections="comments"` reuses +`functions_office_media.extract_docx_comments` over the authorized active original, +with strict inspection that distinguishes an inspected empty package from +unavailable, malformed, or over-limit parts. + +The `word_inspection` result contains parsed root comments and supported linked +replies, available author/date/resolution, bounded anchored text, counts, and +limitations. Anchors are at most 300 characters; clipped or unclosed ranges are +not marked complete. Missing resolution or threading metadata is not invented. +Default stored-metadata calls and default ingestion parsing keep their previous +behavior. A comment-only request can proceed without stored metadata; a combined +request still requires the stored sections rather than returning empty style or +issue data for an uninspected section. + +Orchestration dispatches this work by actual source format and retains the parsed +records before composition. Native citations reopen the exact saved result with +source-access, version/revision, and integrity checks; they do not choose a fresh +search hit. Both chat citation interfaces distinguish Word evidence from workbook +cells. Existing Search, Analyze, accessibility, and OCR/vision paths for other +formats are unchanged. ### Building Custom Checks @@ -304,3 +333,7 @@ Rules that need data SimpleChat does not extract (for example full tint/shade co - Validates `get_document_accessibility_report` section filtering, page chunks, chat-scope and missing-metadata errors, and the plugin and citation wiring. - **Functional Test**: `functional_tests/test_excel_document_accessibility_extraction.py` (implemented in **0.261.399**) - Exercises real OOXML and synthetic legacy BIFF workbooks, native properties, comments/replies, alt text/decorative flags, controls, formatting, print metadata, unchanged source bytes, bounded/failing inspection, both tabular ingestion modes, workspace scope propagation, schema retries, authorized report/citations and native-versus-data question routing. +- **Functional Test**: `functional_tests/test_orchestration_attachment_native_evidence.py` (Word coverage added in **0.261.422**) + - Exercises DOCX/DOCM/DOTX parsing, authorized original reads, exact retained comments/replies and citations, empty/unavailable/malformed/partial inventories, anchor limits, checker-label repair, unsupported findings, and content-free logging. +- **UI Test**: `ui_tests/test_v2_stream_citation_delivery.py` (Word coverage added in **0.261.422**) + - Checks Word anchors/replies/resolution and inert reviewer text in classic and V2 citation views at mobile and desktop widths, alongside existing Excel evidence. diff --git a/docs/explanation/fixes/ORCHESTRATION_DOCUMENT_PLANNING_RECOVERY_FIX.md b/docs/explanation/fixes/ORCHESTRATION_DOCUMENT_PLANNING_RECOVERY_FIX.md index 58f6f6650..3a26c5d8b 100644 --- a/docs/explanation/fixes/ORCHESTRATION_DOCUMENT_PLANNING_RECOVERY_FIX.md +++ b/docs/explanation/fixes/ORCHESTRATION_DOCUMENT_PLANNING_RECOVERY_FIX.md @@ -3,12 +3,14 @@ Fixed/Implemented in version: **0.261.413**, recorded in `application/single_app/config.py`. -Current behavior updated in version: **0.261.419**. +Current behavior updated in version: **0.261.422**. +The [pipeline reliability follow-up](ORCHESTRATION_RELIABILITY_PIPELINE_FIX.md) +adds conservative single-source wiring and two shared model corrections. The [planner contract recovery follow-up](ORCHESTRATION_PLANNER_CONTRACT_RECOVERY_FIX.md) aligns defaulted Analyze output variants, gives native document actions an enforced source binding, and carries their provenance/citations through retention and composition. It also classifies additional model-draft contract failures within -the same single correction allowance and preserves typed failure information on +the same shared correction allowance and preserves typed failure information on the saved question. These changes do not identify the exact historical model JSON behind a generic rejection. @@ -67,7 +69,9 @@ omissions separately. `functions_orchestration_planner.py` validates selected requirements inside the existing correction loop. Targeted feedback preserves every selection and asks for compatible, explicit source bindings. All repairable categories share -`PLAN_REPAIR_ATTEMPTS = 1`: a second different error does not start another budget. +`PLAN_REPAIR_ATTEMPTS = 2` since **0.261.422** (originally 1): a different error +does not start another budget. The initial proposal and at most two corrections +share that limit. An unrenderable clarification stays in that same loop rather than recursively restarting the planner and resetting its allowance. The same planner/model is used, and uncorrectable proposals return stable, specific @@ -105,7 +109,7 @@ for event names, properties, and a bounded query. request/context/planner/validator/logger boundaries with external I/O isolated. It covers PNG, PDF, DOCX, CSV and XLSX selections, manual/automatic approval, Auto routing, correctable and terminal option/source failures, mixed errors -sharing one correction, implicit scoped search, multiple/disabled sources, source-free +sharing one allowance, implicit scoped search, multiple/disabled sources, source-free controls, existing-plan narrowing, legacy semantics, explicit requirement bounds, provider failures, and debug privacy/gating/serialization. diff --git a/docs/explanation/fixes/ORCHESTRATION_PLANNER_CONTRACT_RECOVERY_FIX.md b/docs/explanation/fixes/ORCHESTRATION_PLANNER_CONTRACT_RECOVERY_FIX.md index 0b1ad2f2b..50035fc23 100644 --- a/docs/explanation/fixes/ORCHESTRATION_PLANNER_CONTRACT_RECOVERY_FIX.md +++ b/docs/explanation/fixes/ORCHESTRATION_PLANNER_CONTRACT_RECOVERY_FIX.md @@ -10,6 +10,11 @@ Post-integration validation passed 179 logging/native/saved-findings functional tests and 75 browser source-inspector checks. The merged V2 build, documentation checks and reviewed Cosmos inventory also passed. +Current planning behavior updated in **0.261.422**: +[pipeline reliability follow-up](ORCHESTRATION_RELIABILITY_PIPELINE_FIX.md). +It adds conservative single-source defaults and two shared model corrections +without changing the source, output, or approval contracts described here. + ## Issue and root cause Similar document requests could either run normally or stop with "The request @@ -102,7 +107,7 @@ native sources are provenance, not permission to reread an original. External integration access continues to be checked. The change does not replace those policies or promote partial findings into complete datasets. -### One targeted correction, not blanket retries +### Shared targeted corrections, not blanket retries `functions_orchestration_schema.py` and `functions_orchestration_result_contracts.py` identify owned step-shape, @@ -110,8 +115,8 @@ argument, output, named-input and graph failures. The same planner receives contract-aware feedback, and the complete replacement plan passes every normal validator. -All eligible failures share **one correction attempt total**. A different second -error does not reset that allowance. Unknown model argument names can be +All eligible failures share **two correction attempts total** since **0.261.422** +(originally one). A different error does not reset that allowance. Unknown model argument names can be corrected without logging those names. Permission failures, unavailable capabilities, untrusted retained aliases, held sources, provider refusals and unclassified exceptions do not become blanket-retryable. Workflow-owned argument diff --git a/docs/explanation/fixes/ORCHESTRATION_RELIABILITY_PIPELINE_FIX.md b/docs/explanation/fixes/ORCHESTRATION_RELIABILITY_PIPELINE_FIX.md new file mode 100644 index 000000000..7710e1467 --- /dev/null +++ b/docs/explanation/fixes/ORCHESTRATION_RELIABILITY_PIPELINE_FIX.md @@ -0,0 +1,201 @@ +# Orchestration pipeline reliability (v0.261.422) + +Fixed/Implemented in version: **0.261.422**, tracked in +`application/single_app/config.py`. + +## Issue and root cause + +Straightforward document reviews could work in ordinary chat but fail in +Orchestrate. Read-only Application Insights and App Service console investigation +identified separate boundaries, not one general inability to read the files. + +| Boundary | Why work stopped | What the failure does not prove | +| --- | --- | --- | +| Planning | A proposed or corrected read step could still omit source wiring or contain another argument/output contract error. The previous shared allowance ended after one correction. | A missing file binding is not evidence that upload extraction or OCR failed. | +| Native Word comments | Valid Word comment requests reached an Excel-only native collector. Ordinary Word style/accessibility metadata was not the structured comment inventory. | A workbook-reader rejection does not mean the Word comments are unreadable. | +| Prepared-answer checking | A checker's evidence reference could fail its transport contract even when useful findings had already been retained. Repair feedback did not provide usable reference labels. | An invalid assessment reference is not itself proof that the prepared answer is factually incorrect. | + +Historical generic rejections did not retain every rejected field or raw model +response. This fix does not invent those values or attribute every historical +failure to the same cause. + +## Technical details + +### Source wiring and bounded plan correction + +`functions_orchestration_planner.py` now supplies request-specific source defaults +and fills an absent document binding only when exactly one selection has verified +authorization, available source metadata, and a supported source kind. It acts on +an existing enabled independent reader, including the verified Document Search +action. It does not create a missing step, overwrite explicit IDs, replace dynamic +inputs, narrow multiple selections, or restore disabled work. Editing keeps +explicit source choices. + +All normal validators still run on the repaired proposal. The same planner can +then receive at most **two model corrections**, shared across recognized errors: +three total proposals, not two new attempts per error. Repeated unrenderable +clarification uses the remaining original allowance rather than restarting it. +Permission, unavailable-source/capability, provider, and unknown failures remain +terminal. + +`functions_orchestration_schema.py` adds owned argument/source rules and bounded +feedback for up to eight safe argument-schema problems in a failing step. +Registered fields or `unknown` are reported instead of arbitrary supplied names +or values. Known incompatible native-comment formats are rejected before +execution. + +### Shared Word reader and retained citations + +`functions_search_service.py` exposes opt-in `sections="comments"` through the +existing Document Search metadata function. For `.docx`, `.docm`, and `.dotx`, +it uses the existing authorized original-byte service and +`functions_office_media.extract_docx_comments`; no second extraction engine or +new route is introduced. Default stored-metadata calls and default ingestion +parsing preserve their previous behavior. +Comment-only inspection can succeed without stored style metadata; a combined +request still requires its stored sections. A successful comment read cannot +certify absent style or issue metadata as empty. + +The strict native path preserves parsed reviewer text and supported replies, +available author/date/resolution, and bounded anchors. A missing resolution flag +stays unknown. Anchors are limited to 300 characters and require an unclipped +closed range to be marked complete. An inspected package without a comments part +is verified empty; missing originals, malformed parts, identity/thread failures, +and resource limits are not successful empty inventories. + +`functions_orchestration_excel.py` adds a Word collector alongside the existing +Excel paging/fragment collector. Whole thread records are retained within the +existing budget; over-budget coverage is explicitly partial. The adapter chooses +the collector by source format and rechecks source identity, scope, version, and +revision before retention. + +`functions_orchestration_result_runtime.py` supports Word/mixed native evidence +while retaining Excel content-scope compatibility. Native citation resolution +reads the exact committed record with integrity and current source checks, not a +fresh Search fallback. `chat-citations.js`, V2 `CitationChip.tsx`, and its API types +show Word anchors/replies/resolution without worksheet fields or an invalid Excel +preview. Reviewer text remains inert browser text. + +### Checker report repair is not answer repair + +`functions_task_completeness.py` closes new assessment reference schemas over +the evidence labels actually supplied to that check. Empty evidence permits no +references. Existing compatible exact paths/identifiers still normalize; arbitrary +prose and nonexistent references do not. + +One malformed assessment can receive one report repair with allowed labels and +an owned rejection rule. That does not rewrite the candidate or spend an answer +correction merely because the checker named its evidence incorrectly. Genuine +unsupported findings still trigger answer correction and required verification. +Repeated invalid reports, unavailable required checking, and unresolved substantive +deficiencies still block delivery. + +`functions_orchestration_composition.py` records content-free checker diagnostics +at both prepared-content and retained-final-answer review boundaries. Existing +checkpoint and answer-correction limits are preserved; they are separate from the +planner's two corrections. + +### Privacy and operating diagnostics + +The existing planner, adapter, and executor tags record stage, owned rule/code, +actual correction counts, known source format, counts, and applicable hashed +workflow identifiers. Native-reader and checker checkpoints work with debug +logging disabled. Detailed planner snapshots retain their existing debug gate. + +No raw failed reference, prompt, source text, filename, ID, or provider exception +message is added to those diagnostics. See the +[logging reference](../../reference/logging-tags.md#native-comments-and-checker-report-repair) +for `Properties.sc_message` queries and the distinction between a reader +checkpoint and a confirmed terminal execution outcome. + +## Validation and impact + +Regression coverage uses real planner/compiler, Word parser, shared metadata +service, collector, retained result, checker, citation HTTP, and browser rendering +boundaries with external storage/model transport isolated. + +| Regression area | Coverage | +| --- | --- | +| Planning | Unambiguous local wiring; immutable explicit/dynamic/disabled work; ambiguous and unavailable selections; mixed errors sharing two corrections; valid third proposals and exhaustion. | +| Native reading | DOCX/DOCM/DOTX comments/replies; absent metadata; empty, missing, malformed, partial, and unsupported inventories; clipped/unclosed anchors; source revocation/change. | +| Answer checking | Legacy and canonical assessment schemas; one invalid-reference repair without candidate rewriting; real serialized diagnostics; repeated unsupported findings still blocked. | +| Citation delivery | Exact retained Word records and replies; HTTP lookup and source checks; desktop/mobile classic and V2 text safety; unchanged Excel preview and saved identities. | +| Storage and imports | Renewed affected Cosmos owner-runtime reviews, real SDK contracts with network blocked, and canonical plugin identity. | + +Key suites are `test_orchestration_attachment_native_evidence.py`, +`test_orchestration_planner_contract_recovery.py`, +`test_orchestration_document_planning_recovery.py`, +`test_orchestration_source_kind_planning.py`, `test_task_completeness_engine.py`, +`test_task_completeness_runtime.py`, and `test_orchestration_failure_telemetry.py`. +Browser coverage is in `ui_tests/test_v2_stream_citation_delivery.py` and the +existing Excel/inline-citation suites. + +### Verified local results + +These runs overlap; their counts are not an aggregate end-to-end success rate. + +| Check | Result | +| --- | --- | +| Combined planner, checker, native-reader, action, and citation regressions | 343 passed. | +| Selected-source, conversation-scope, and durable planning-failure regressions | 93 passed. | +| Coupled action, composition, source-integrity, import, extraction, and registry regressions | 262 passed; 3 subtests passed; 1 confirmed baseline case deselected. | +| Classic/V2 Word, Excel, and reopened-citation browser regressions | 30 passed. | +| Affected Search Service owner test using real SDK document shapes with network blocked | 1 passed. | +| Full network-blocked Cosmos contract gate | 2,026 passed; 431 subtests passed; 1,094 owners and 425 doubles reviewed with 0 review gaps. | +| V2 TypeScript/Vite build | Passed. | +| Impeccable detector on the two changed citation clients | Passed; no findings. | +| Documentation inventory coverage and site quality | 7/7 and 6/6 passed, respectively. | + +The coupled regression command is: + +```powershell +$env:PYTHONPATH = "$PWD\application\single_app;$PWD\functional_tests" +python -m pytest -q -p no:cacheprovider --disable-warnings --tb=short --show-capture=no ` + .\functional_tests\test_orchestration_selected_source_planning.py ` + .\functional_tests\test_orchestration_plan_revision_planner.py ` + .\functional_tests\test_orchestration_document_action_binding.py ` + .\functional_tests\test_orchestration_document_action_end_to_end.py ` + .\functional_tests\test_orchestration_composition_metadata_recovery.py ` + .\functional_tests\test_orchestration_compose_output_retry.py ` + .\functional_tests\test_orchestration_retained_analysis_publication.py ` + .\functional_tests\test_orchestration_search_result_integrity.py ` + .\functional_tests\test_orchestration_result_imports.py ` + .\functional_tests\test_plugin_discovery_identity.py ` + .\functional_tests\test_document_accessibility_extraction.py ` + .\functional_tests\test_excel_document_accessibility_extraction.py ` + .\functional_tests\test_orchestration_registry_contract.py ` + -k "not test_an_endpoint_refusing_json_mode_is_asked_again_without_it" +``` + +The excluded test has an unchanged exact-log expectation that omits the +`requested_format` and `next_format` fields already emitted by the endpoint +client. A separately attempted legacy planner-diagnostics suite also has ten +setup errors because its AST-only harness omits the imported +`STORAGE_FAILURE_CODE`. Both limitations were reproduced against unmodified +HEAD; neither was changed or treated as a product fix. These scoped exclusions +do not waive the full network-blocked storage contract gate. + +The full storage gate passed with: + +```powershell +$env:PYTHONPATH = "$PWD\application\single_app;$PWD\functional_tests" +python .\scripts\check_cosmos_response_boundaries.py --run-contracts +``` + +The remaining reproducible build/documentation checks are: + +```powershell +npm --prefix .\application\v2_ui run build +python .\scripts\build_docs_inventory.py +python .\functional_tests\test_docs_app_surface_coverage.py +python .\functional_tests\test_docs_site_quality.py +``` + +The documentation quality check verifies source templates and search indexing +contracts; a full Jekyll site build was not run. + +This is an offline implementation and contract-verification result, **not a 100% +deployed-model success-rate claim**. Deployment and live replay require separate +approval. The change does not automatically replay historical failures, repair +corrupt input files, certify accessibility compliance, widen source access, or +replace existing Analyze and OCR/vision paths. diff --git a/docs/guides/review-and-edit-orchestration-plans.md b/docs/guides/review-and-edit-orchestration-plans.md index 3384170f3..0b04aa393 100644 --- a/docs/guides/review-and-edit-orchestration-plans.md +++ b/docs/guides/review-and-edit-orchestration-plans.md @@ -4,7 +4,7 @@ title: "Review and edit orchestration plans" description: "Refine proposed work with the planner before running it." section: "Guides" audience: user -version: "0.261.418" +version: "0.261.422" --- ## Decide what should run @@ -109,6 +109,24 @@ planner may Analyze or Compare those inputs without adding a Search step merely because they were pinned. Explicitly selected Search still constrains the plan, and the validator still rejects a plan that omits selected documents. +### Recover ordinary planning mistakes without widening the work + +Fixed/Implemented in version: **0.261.422** +(`application/single_app/config.py`). + +A missing file ID should not require you to rebuild an otherwise useful plan. +For a single verified available selection, SimpleChat can fill an absent binding +on an existing independent reader. The visible plan then contains the source it +will actually read. Explicit IDs, dependent/dynamic inputs, disabled work, +unavailable sources, and ambiguous multi-file selections are not overwritten. + +Other recognized draft mistakes receive specific feedback to the same planner. +The initial proposal and at most two corrections share one allowance across +different validation errors. No source reader or external action runs during +those corrections, and your selected approval mode still applies afterward. +Permissions, unavailable capabilities, provider refusals, and exhausted correction +attempts remain real failures rather than permission to run an invalid plan. + ### Open the evidence behind document answers Since **0.261.356**, Analyze and Compare supply source references through the same @@ -146,6 +164,18 @@ For workspace-backed Excel workbooks, annotation requests use native Notes and threaded-comment inspection, preserving available metadata and saved cell values. Reported pagination and resource limits still bound what was inspected. +Since **0.261.422**, workspace Word `.docx`, `.docm`, and `.dotx` review comments +use the Word reader instead. Ask to include reviewer text and replies, and open a +native citation to inspect the retained comment, its available anchored text, +author/date, and resolution metadata. A bounded or unavailable anchor is not a +complete quotation, and a missing resolution flag is not proof that a comment is +unresolved. Word evidence does not offer an Excel worksheet preview. + +The reader needs accessible original bytes; ordinary stored style metadata is not +the structured Word comment inventory. Unavailable or malformed originals remain +read failures. An inspected package with no comments is distinct from a failed +inspection. Historical failed runs are not automatically replayed by this change. + Fixed/Implemented in version: **0.261.341** (`application/single_app/config.py`). When you ask to rewrite, audit, summarize, or otherwise transform material diff --git a/docs/reference/actions/document-search.md b/docs/reference/actions/document-search.md index 2729da3be..4354b0ead 100644 --- a/docs/reference/actions/document-search.md +++ b/docs/reference/actions/document-search.md @@ -4,6 +4,7 @@ title: "Document Search" description: "Reference for the Document Search SimpleChat action." section: "Reference" audience: user +version: "0.261.422" --- @@ -50,13 +51,40 @@ The action includes a `get_document_accessibility_report` function for agents th From **0.261.399**, workspace Excel files also expose native workbook properties, cell notes/threaded comments, object alternative text/decorative flags, formatting/layout and header/footer expressions. Modern `.xlsx`/`.xlsm` inspection has explicit saved-state coverage limits; legacy `.xls` is best effort. Use the report for the bounded workbook-wide inventory and document search for sheet/cell-addressed `Excel native inspection` evidence. Enhanced-citation workbooks still index a schema preview plus native evidence, not the complete worksheet data. -- Choose which parts to return with `sections` (`accessibility`, `style`, `issues`); all are returned by default. +- Choose which stored parts to return with `sections` (`accessibility`, `style`, `issues`); all three are returned by default. Opt-in Word `comments` inspection is separate. - Pass `page_number` to also get that page, slide or Excel indexed evidence segment's stored text. Excel segment numbers are not physical print-page numbers. - The stored workspace report does not grant access to chat uploads. For an existing workspace Excel file, retained active source bytes can be inspected on demand without re-uploading; unavailable originals need the normal re-upload/reprocessing workflow. - The issues list is heuristic. It is not a formal WCAG, Section 508, or PDF/UA conformance check. To apply your own rules, write them into the agent's instructions and have it compare them with the returned values. See [Document accessibility, structure, and comment extraction](https://github.com/microsoft/simplechat/blob/13db876626a043e2c7b30f054354f07c9a1633b9/docs/explanation/features/DOCUMENT_ACCESSIBILITY_AND_STRUCTURE_EXTRACTION.md) for what is extracted for each file type. +### Native Word review comments + +Fixed/Implemented in version: **0.261.422** +(`application/single_app/config.py`). + +For workspace `.docx`, `.docm`, or `.dotx` files, call +`get_document_accessibility_report(document_id=..., sections="comments")`. +This uses the existing authorized original-file reader and Word parser, returning +`word_inspection` with parsed comments, supported linked replies, inventory counts, +and limitations. Normal accessibility/style/issue calls do not add this original +file read. + +This is useful for checking what a reviewer actually wrote, independently of +document prose or a search summary. Anchors are limited to 300 characters; +`anchor_complete` distinguishes a complete parsed range from bounded or +unavailable text. Resolution is known only when `resolution_available` is true. +Relationships come from supported Word metadata, not guesses about author names +or reply wording. + +Excel cursors, worksheet/cell filters, and non-comment native sections do not apply +to Word. A comment-only request for an unsupported format, missing original, or +malformed package fails explicitly; it does not certify that there are no comments. +Stored Word style metadata can be absent while original comment inspection is +available. A combined comments/style/accessibility/issue request still requires +the stored sections; a successful comment read does not certify missing style or +issue data as empty. This does not grant native-report access to chat-only uploads. + ### Selected sources in Orchestrate Implemented in version: **0.261.419** (`application/single_app/config.py`). diff --git a/docs/reference/chat-controls.md b/docs/reference/chat-controls.md index 62af34157..9e2499903 100644 --- a/docs/reference/chat-controls.md +++ b/docs/reference/chat-controls.md @@ -4,27 +4,31 @@ title: "Chat interface controls" description: "Reference for every documented control in the SimpleChat chat interface." section: "Reference" audience: user -version: "0.261.419" +version: "0.261.422" --- ## How to use this reference Use this page when you can see a control in Chat but are not sure what it does or when to use it. The generated inventory lists 47 real controls plus 3 child sub-elements. The child elements `document-comparison-edit-btn-label`, `fork-conversation-button-label`, and `fork-conversation-button-spinner` are mentioned with their parent controls instead of receiving separate rows. -### Native workbook evidence +### Native Word and workbook evidence -Fixed/Implemented in version: **0.261.417** +Fixed/Implemented in version: **0.261.417**, extended in **0.261.422** (`application/single_app/config.py`). | Control | Purpose | Availability | | --- | --- | --- | | Native note/comment citation | Opens exact retained reviewer text and its worksheet/cell, author, reply metadata, and saved cell value. | A citation with an unambiguous retained native-evidence reference | -| Open workbook preview (separate from annotation evidence) | Opens worksheet data independently; the preview is not presented as the cited reviewer text. | Successfully authorized native-evidence view | +| Native Word comment citation | Shows the retained comment and linked replies, available author/date/resolution, and anchored text with its completeness limit. | Authorized native Word evidence from `.docx`, `.docm`, or `.dotx` | +| Open workbook preview (separate from annotation evidence) | Opens worksheet data independently; the preview is not presented as the cited reviewer text. | Successfully authorized workbook evidence, not Word comments | -Source access and the inspected workbook version are rechecked when evidence +Source access and the inspected document version are rechecked when evidence opens. An unavailable historical reference stays unavailable rather than choosing a newer comment at the same cell. See [Inspect Excel data, comments, and structure]({{ '/guides/inspect-excel-workbooks/' | relative_url }}). +Word comment evidence uses document anchors rather than invented page/cell +locations. An absent resolution flag remains unavailable, not automatically +unresolved. See [Review and edit orchestration plans]({{ '/guides/review-and-edit-orchestration-plans/' | relative_url }}). ## Conversation list diff --git a/docs/reference/logging-tags.md b/docs/reference/logging-tags.md index 641cb3837..0b7f1b857 100644 --- a/docs/reference/logging-tags.md +++ b/docs/reference/logging-tags.md @@ -602,7 +602,7 @@ Insights `customDimensions` or Log Analytics `AppTraces.Properties`. | `sc_validation_code` | Validation category, including `deliverables_invalid`, `source_kind_invalid`, `source_binding_required`, `analysis_options_invalid`, or `selected_requirements_missing`. | | `sc_validation_rule` | Specific application-owned rejection, such as `invalid_quantity`, `non_file_format`, `answer_producer_mismatch`, `missing_final_response`, `file_format_mismatch`, `narrative_source_required`, or `document_sources_required`. | | `sc_response_failure` | A missing, incomplete, or refused planner completion, when applicable. | -| `sc_attempt` | Planner proposal number: 1 for the initial proposal and 2 for its single shared correction. Since 0.261.413, also present on Analyze-option and selected-requirement correction/failure events. | +| `sc_attempt` | Planner proposal number: 1 initially, then 2 or 3 for up to two shared model corrections since 0.261.422. Also present on Analyze-option and selected-requirement correction/failure events. | | `sc_execution_code`, `sc_output_code` | Safe execution or output-store failure category, where available. | | `sc_failure_code` | On step and adapter events, a step failure category, such as `step_timeout`, `run_timeout`, or `context_unavailable`. On `[CONTENT_SCREENING]` events, the source-authority code, such as `source_authority_unverified`, `source_authority_unavailable`, or `screening_revision_conflict`. | | `sc_authority_reason` | Which check failed for a source. With `source_authority_unverified`, the check that could not verify the source's current authority: `document_record_invalid`, `screening_marker_invalid`, `screening_state_unknown`, `screening_marker_incomplete`, `document_scope_invalid`, `workspace_record_invalid`, `scan_record_invalid`, `provenance_mismatch`, `document_id_invalid`, `manifest_context_invalid`, `manifest_scope_missing`, `manifest_revision_invalid`, or `manifest_batch_invalid`. With `screening_revision_conflict`, what saved evidence no longer matches in the current document: `source_revision_changed` (the document was re-versioned), `screening_generation_changed` (it was re-screened or remediated), `source_scope_changed` (its id or workspace differs), `provenance_shape_invalid` (the saved provenance has a different set of fields), `screened_record_changed` (a saved copy of the screened record differs), `source_version_changed` (a legacy reference without provenance), or `cached_evidence_unproven` (cached evidence from before screening enrollment). On `[ORCHESTRATION_EXECUTOR]` step, saved-wait, finalization and content-preparation failure events (since 0.261.209), the stable code of a refused external source, such as `external_identity_session_unavailable` (the step ran in a background continuation with no signed-in session), `external_identity_role_required`, `external_identity_access_restricted`, `external_identity_conversation_unavailable`, `external_identity_settings_unavailable`, or `external_configuration_changed`. On `[ORCHESTRATION_EXTERNAL_SOURCES]` refusal events (since 0.261.291), the same refusal code, such as `result_external_source_unavailable` or `result_external_capability_unavailable`, with the check that failed in `sc_reason`. | @@ -676,6 +676,7 @@ remain available with debug logging off. | `[ORCHESTRATION_PLANNER] Step binding diagnostic.` | One bounded step entry: hashed step ID, known capability, enabled state, binding-field presence, and Analyze option shape/known-value flags. | Stages are `planner_input`, `plan_proposal` (before defaults), `analysis_defaults`, +`selected_source_defaults` (after an eligible local wiring repair), `plan_normalized`, and `plan_validated`. A rejected attempt also uses its actual boundary, such as `plan_normalization` or `selected_requirements`. @@ -690,6 +691,7 @@ boundary, such as `plan_normalization` or `selected_requirements`. | `sc_required_capability_count`, `sc_missing_capability_count` | Positive-operation coverage, separate from document coverage. | | `sc_normalization_lost_document_count` | References present in the raw proposal but absent after normalization. Zero missing references at proposal time followed by loss here differs from a model omission. | | `sc_remaining_corrections` | Remaining shared proposal-correction allowance; never a new per-error budget. | +| `sc_source_binding_repair_count` | Eligible absent bindings filled locally for one verified available selected source since 0.261.422. This is not another model correction or permission to overwrite explicit work. | | `sc_model_routing_auto`, `sc_planner_binding_provided` | Auto-routing selection and whether planning uses a resolved binding rather than the legacy client resolver. | | `sc_step_count`, `sc_omitted_step_count`, `sc_omitted_document_count` | Total steps and explicit diagnostic omissions. At most 32 source and 32 step detail events are emitted per snapshot; summaries still count all references. | | `sc_options_version_present`, `sc_options_version_v1`, `sc_options_version_v2`, `sc_options_mode_present`, `sc_options_mode_observations`, `sc_options_mode_records`, `sc_options_mode_legacy` | Presence/known-value flags, not arbitrary supplied option strings. | @@ -713,6 +715,19 @@ a schema keyword and registered argument name (or `unknown`), never an arbitrary model-supplied property name. `sc_result_kind` uses a known underscore-form code; `sc_accepts_*` flags describe compatible kinds. +Since **0.261.422**, additional owned rules identify `model_binding_object_required`, +`argument_string_empty`, `document_id_invalid`, `document_selection_duplicate`, +`document_limit_exceeded`, `document_authorization_required`, +`source_binding_conflict`, and `native_comments_format_unsupported`. Argument +correction feedback can describe up to eight safe schema problems in a failing +step; it never includes arbitrary supplied field names or values. Recognized +draft mistakes share two corrections. Source authorization and unavailable +capabilities remain terminal rather than being repaired by widening access. + +`[ORCHESTRATION_PLANNER] Selected source wiring repaired.` is an ordinary +content-free event, available with debug logging off. Detailed before/after +snapshots remain debug-gated. + A picked document missing from both `plan_proposal` and `plan_normalized` indicates an omitted recognized binding in the proposal, not a normalization loss. These structural diagnostics do not establish extraction fidelity or prove that every @@ -740,6 +755,78 @@ traces | take 500 ``` +### Native comments and checker report repair + +Implemented in version **0.261.422**, tracked in +`application/single_app/config.py`. These checkpoints use the normal application +logger and remain available with **Enable Debug Logging** off. + +| Event message | Boundary | +| --- | --- | +| `[ORCHESTRATION_ADAPTERS] Native comment read checkpoint.` | `native_comment_read`: started/completed/failed for the actual Word or Excel collector; failed source recheck or retention can follow a completed read. | +| `[ORCHESTRATION_EXECUTOR] Task completeness verification unavailable.` | `task_completeness_assessment`: an assessment failed during prepared-content checking, before possible report repair. | +| `[ORCHESTRATION_EXECUTOR] Retained final-answer verification unavailable.` | The same assessment boundary for an already retained final answer. | +| `[ORCHESTRATION_EXECUTOR] Retained final-answer verification could not complete.` | The outer review boundary failed before a usable completed check. | + +The assessment warning is not itself a terminal answer failure. A single invalid +report may be repaired without regenerating the candidate; unresolved substantive +findings or unavailable required checking still block delivery. + +| Property | Meaning | +| --- | --- | +| `sc_application_version` | Application build from `config.py`; a mutable container image tag does not identify the deployed build. | +| `sc_source_format` | Known native format: `docx`, `docm`, `dotx`, `xlsx`, `xlsm`, `xls`, or `unsupported`; no filename. | +| `sc_selected_document_count` | Selection size at read start, not a count to sum across repeated per-source events. | +| `sc_returned_record_count`, `sc_total_record_count` | Returned collection count and total inventory count reported at the completed read; these do not confirm later result publication. | +| `sc_returned_reply_count` | Replies included in returned Word records when that metadata is available; separate from root-record count. | +| `sc_partial` | The collection hit its retention allowance; complete inventory discovery is not the same as retaining every record. | +| `sc_validation_rule`, `sc_error_type` | Owned refusal boundary and exception class, never the raw exception message. | +| `sc_validation_code` | Checker failure category, such as `completeness_evidence_invalid`; not a finding that the candidate is factually wrong. | +| `sc_report_repair_count` | Repairs already spent when the assessment failed: zero initially, one on a failed repaired report. Separate from plan and answer corrections. | +| `sc_evidence_key_count` | Number of top-level evidence labels supplied to the checker, without the labels or source bodies. | +| `sc_evidence_ref_count`, `sc_evidence_refs_list` | Size/type of the failing reference list when validation can report it safely, without bad references. | + +Applicable conversation/run/step/turn hashes correlate these events. No failed +reference string, source prose, raw workflow identifier, filename, or provider +exception message is added. + +```kusto +let conversationHash = hash_sha256(""); +AppTraces +| where TimeGenerated > ago(2h) +| extend p=todynamic(Properties) +| where tostring(p.sc_conversation_id_hash) == conversationHash +| where tostring(p.sc_stage) in ("selected_source_defaults", "native_comment_read", "task_completeness_assessment") +| project TimeGenerated, + event=tostring(p.sc_message), + applicationVersion=tostring(p.sc_application_version), + runHash=tostring(p.sc_run_id_hash), + stepHash=tostring(p.sc_step_id_hash), + stage=tostring(p.sc_stage), + outcome=tostring(p.sc_outcome), + format=tostring(p.sc_source_format), + returnedRecords=toint(p.sc_returned_record_count), + totalRecords=toint(p.sc_total_record_count), + returnedReplies=toint(p.sc_returned_reply_count), + partial=tobool(p.sc_partial), + rule=tostring(p.sc_validation_rule), + checkerCode=tostring(p.sc_validation_code), + reportRepairs=toint(p.sc_report_repair_count) +| order by TimeGenerated asc +| take 500 +``` + +Read `Properties.sc_message`, not `AppTraces.Message`: the envelope message is +`[SIMPLE_CHAT_LOG_EVENT]`. Application Insights' `traces` table uses +`customDimensions.sc_message`. App Service console records mirror the same +static tags when console logging is configured. + +To measure reliability, use the existing confirmed terminal `[CHAT_EXECUTION]` +semantic outcomes and actual application version. Exclude pending/started events, +and distinguish complete, partial, failed, and uncertain outcomes. A validated +plan, successful native read, absent unhandled exception, or HTTP 200 is not an +end-to-end delivered-answer success. + ### Native action and retained-result lifecycle checkpoints Implemented in version **0.261.419**, tracked in diff --git a/functional_tests/cosmos_response_boundaries.json b/functional_tests/cosmos_response_boundaries.json index c89a001ee..11ff752da 100644 --- a/functional_tests/cosmos_response_boundaries.json +++ b/functional_tests/cosmos_response_boundaries.json @@ -23447,10 +23447,10 @@ ], "queries": [], "batches": [], - "module_sha256": "0afaa631fb35d52b9022fa8c7e777695aa84a450fc6fcbff2e7b6f1b04636a77" + "module_sha256": "8835a019046bdee0d13641133cc22180068071f27d8ec5f0258baec00c3e8859" }, "classification": "sdk-compatible", - "contract": "_authorize_chat_upload_conversation: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners. Public discovery now uses the bootstrap-free owner-configured boundary; this storage function and SDK response consumption are unchanged, with representative owner-runtime evidence retained. Review renewed for 0.261.405: exact owner AST, referenced import bindings, and SDK point/query/batch operations are unchanged. Module-only Excel helper/caller additions do not change this response consumer; existing specific runtime/source evidence is retained.", + "contract": "_authorize_chat_upload_conversation consumes the pinned SDK dictionary subclass through isinstance/get and verifies the exact conversation owner; SDK representation does not bypass strict source authority. Review renewed for 0.261.422: this owner AST, point call and imported authority bindings are unchanged. Module-only Word original-comment parsing and the comment-only metadata guard do not change this chat-upload consumer. The representative real-owner SDK test verifies both allowed and denied users with network blocked.", "evidence_scope": "owner-runtime", "tests": [ "functional_tests/test_workflow_authoring_sdk_boundaries.py::test_search_upload_owners_consume_real_sdk_point_documents" @@ -23474,10 +23474,10 @@ } ], "batches": [], - "module_sha256": "0afaa631fb35d52b9022fa8c7e777695aa84a450fc6fcbff2e7b6f1b04636a77" + "module_sha256": "8835a019046bdee0d13641133cc22180068071f27d8ec5f0258baec00c3e8859" }, "classification": "sdk-compatible", - "contract": "_resolve_chat_upload_context: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners. Public discovery now uses the bootstrap-free owner-configured boundary; this storage function and SDK response consumption are unchanged, with representative owner-runtime evidence retained. Review renewed for 0.261.405: exact owner AST, referenced import bindings, and SDK point/query/batch operations are unchanged. Module-only Excel helper/caller additions do not change this response consumer; existing specific runtime/source evidence is retained.", + "contract": "_resolve_chat_upload_context reads pinned SDK point mappings and plain query rows via isinstance/get, applies conversation/source authority checks, and constructs the authorized upload projection from nested JSON fields. Review renewed for 0.261.422: this owner AST, point/query calls and authority imports are unchanged; module-only Word parsing and opt-in metadata behavior do not alter the chat-upload consumer. The existing real-owner SDK test verifies authorized text projection with network blocked.", "evidence_scope": "owner-runtime", "tests": [ "functional_tests/test_workflow_authoring_sdk_boundaries.py::test_search_upload_owners_consume_real_sdk_point_documents" diff --git a/functional_tests/test_orchestration_attachment_native_evidence.py b/functional_tests/test_orchestration_attachment_native_evidence.py index f6842b02c..4722c2d1b 100644 --- a/functional_tests/test_orchestration_attachment_native_evidence.py +++ b/functional_tests/test_orchestration_attachment_native_evidence.py @@ -1,16 +1,21 @@ # test_orchestration_attachment_native_evidence.py """Real planning, inventory paging, retained evidence and composition regressions. -Version: 0.261.417 +Version: 0.261.422 Implemented in: 0.261.416 +Word-native comment routing and evidence delivery: 0.261.422 Only external storage/model I/O is replaced; no provider calls are made. """ import importlib import json import base64 +import hashlib +import logging +import zipfile from io import BytesIO from copy import deepcopy +from pathlib import Path import pytest from PIL import Image @@ -264,6 +269,299 @@ def small_pages(*args, **kwargs): return step, calls, document +def word_comment_package(*, comments=True, extended=True, malformed=False, anchor="15000", closed_anchor=True): + namespace = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" + range_end = '' if closed_anchor else '' + document = ( + f'' + 'Budget cap ' + f'{anchor}{range_end}' + '' + ) + comment_xml = ( + f'' + '' + 'Raise cap to 19500.' + '' + 'Keep cap at 15000.' + '' + ) + stream = BytesIO() + with zipfile.ZipFile(stream, "w") as archive: + archive.writestr("word/document.xml", document) + if comments: + archive.writestr("word/comments.xml", "' + '' + '' + '' + )) + return stream.getvalue() + + +def configure_word_inventory(runtime, monkeypatch, content, *, extension="docx"): + document = { + "id": "document-1", "user_id": "owner", "version": 1, + "file_name": f"review.{extension}", "accessibility_metadata": {"format": extension}, + } + service = importlib.import_module("functions_search_service") + monkeypatch.setattr(service, "resolve_document_context", lambda **kwargs: { + "document": deepcopy(document), "scope": "personal", + }) + monkeypatch.setattr(service, "assert_document_available", lambda value, **kwargs: value) + monkeypatch.setattr(service, "assert_evidence_available", lambda *args, **kwargs: None) + calls = [] + + def read_bytes(value, **kwargs): + calls.append(deepcopy(kwargs)) + if isinstance(content, Exception): + raise content + return deepcopy(value), content + + monkeypatch.setattr(service.screening_access, "read_available_document_bytes", read_bytes) + step = { + "step_id": "comments", "capability_id": "document_search", + "arguments": {"query": "Read every Word review comment and reply.", + "document_ids": ["document-1"], "native_section": "comments"}, + } + return step, calls, document + + +def test_word_metadata_reader_uses_original_comments_not_stored_style_metadata(runtime, monkeypatch): + _, calls, _ = configure_word_inventory(runtime, monkeypatch, word_comment_package()) + service = importlib.import_module("functions_search_service") + report = service.get_document_accessibility_payload("document-1", "owner", sections="comments") + assert report["word_inspection"]["records"][0]["text"] == "Raise cap to 19500." + assert len(calls) == 1 + + +@pytest.mark.parametrize("metadata", [None, {}]) +def test_word_comment_only_inspection_does_not_need_stored_metadata(runtime, monkeypatch, metadata): + _, calls, document = configure_word_inventory(runtime, monkeypatch, word_comment_package()) + document["accessibility_metadata"] = metadata + service = importlib.import_module("functions_search_service") + report = service.get_document_accessibility_payload("document-1", "owner", sections="comments") + assert report["word_inspection"]["records"][0]["text"] == "Raise cap to 19500." + assert report["format"] == "docx" and len(calls) == 1 + assert not set(report).intersection({"accessibility_metadata", "style_inventory", "issues"}) + + +@pytest.mark.parametrize("metadata", [None, {}]) +@pytest.mark.parametrize("section", ["accessibility", "style", "issues"]) +def test_word_comments_do_not_certify_missing_stored_sections(runtime, monkeypatch, metadata, section): + _, _, document = configure_word_inventory(runtime, monkeypatch, word_comment_package()) + document["accessibility_metadata"] = metadata + service = importlib.import_module("functions_search_service") + with pytest.raises(LookupError, match="No accessibility metadata is stored"): + service.get_document_accessibility_payload("document-1", "owner", sections=f"comments,{section}") + + +@pytest.mark.parametrize("metadata", [None, {}]) +def test_word_default_metadata_requests_do_not_read_the_original(runtime, monkeypatch, metadata): + _, calls, document = configure_word_inventory(runtime, monkeypatch, word_comment_package()) + document["accessibility_metadata"] = metadata + service = importlib.import_module("functions_search_service") + with pytest.raises(LookupError, match="No accessibility metadata is stored"): + service.get_document_accessibility_payload("document-1", "owner") + assert calls == [] + + +@pytest.mark.parametrize("extension", ["docx", "docm", "dotx"]) +@pytest.mark.parametrize("repair_checker", [False, True]) +@pytest.mark.parametrize("prepared_text", [False, True]) +def test_word_comments_use_the_real_word_reader_and_reach_retained_answers( + runtime, monkeypatch, caplog, extension, repair_checker, prepared_text, +): + content = word_comment_package() + step, calls, document = configure_word_inventory(runtime, monkeypatch, content, extension=extension) + candidate = "The reviewer requests 19500; the author replies to keep 15000." + completeness = importlib.import_module("functions_task_completeness") + assessment_item = { + "status": "satisfied", "reason": "", "evidence_refs": ["comments"], "answer_quote": candidate, + } + assessment = {"requirements": { + identifier: deepcopy(assessment_item) for identifier, _ in completeness.NARRATIVE_OBLIGATIONS + } if prepared_text else [ + {"id": "review", "requirement": "Explain the reviewer comment and reply.", **assessment_item}, + ]} + invalid_assessment = deepcopy(assessment) + for item in ( + invalid_assessment["requirements"].values() if prepared_text else invalid_assessment["requirements"] + ): + item["evidence_refs"] = ["PRIVATE_INVALID_SOURCE_LABEL"] + replies = [candidate] + if repair_checker: + replies.extend([json.dumps(invalid_assessment), json.dumps(assessment)]) + answer_step = compose("answer", inputs={"comments": source_input("comments", "prepared")}) + if prepared_text: + answer_step["arguments"]["response_policy"] = "available_information" + case = runtime.make([ + step, answer_step, + ], replies, final_response=binding("answer"), + settings={"enable_chat_orchestration_completeness_check": repair_checker}) + case.fixture.sources["document-1"] = {**source(), "file_name": document["file_name"]} + spec = importlib.util.spec_from_file_location( + "native_comment_telemetry", + Path(__file__).resolve().parents[1] / "application" / "single_app" / "functions_appinsights.py", + ) + telemetry = importlib.util.module_from_spec(spec) + spec.loader.exec_module(telemetry) + adapters = importlib.import_module("functions_orchestration_adapters") + logger = logging.getLogger("test.orchestration.native_comments") + monkeypatch.setattr(telemetry, "_load_logging_settings", lambda: {"enable_debug_logging": False}) + monkeypatch.setattr(telemetry, "get_appinsights_logger", lambda: logger) + monkeypatch.setattr(adapters, "log_event", telemetry.log_event) + monkeypatch.setattr(adapters, "workflow_log_context", telemetry.workflow_log_context) + monkeypatch.setattr(runtime.composition, "log_event", telemetry.log_event) + monkeypatch.setattr(runtime.composition, "workflow_log_context", telemetry.workflow_log_context) + with caplog.at_level(logging.INFO, logger=logger.name): + result = execute(runtime, case) + assert result["status"] == "completed", { + row["step_id"]: row.get("failure") for row in result["steps"] + } + assert len(calls) == 1 and calls[0]["user_id"] == "owner" and calls[0]["purpose"] == "accessibility" + task = case.context.task_results["comments"] + prepared = case.fixture.restart().open_result(task.output("prepared")).read_value() + assert prepared["content_scope"] == "native_document_annotations" + record = json.loads(prepared["search_results"][1]["chunk_text"])["word_annotation"] + assert record["id"] == "0" and record["author"] == "Reviewer" + assert record["text"] == "Raise cap to 19500." and record["anchor_text"] == "15000" + assert record["date"] == "2026-01-01T10:00:00Z" + assert record["resolution_available"] and record["done"] is False + assert record["anchor_complete"] and record["position_known"] + assert record["replies"][0]["text"] == "Keep cap at 15000." + assert record["replies"][0]["author"] == "Author" and record["replies"][0]["done"] is True + coverage = prepared["coverage"]["native_inspection"][0] + assert coverage["total_records"] == coverage["returned_records"] == 1 + assert coverage["total_replies"] == 1 and not coverage["partial"] + assert coverage["source_sha256"] == hashlib.sha256(content).hexdigest() + payload = json.loads(case.model.calls[0][0][-1]["content"]) + assert "Raise cap to 19500." in json.dumps(payload) and "Keep cap at 15000." in json.dumps(payload) + reader = importlib.import_module("functions_orchestration_result_runtime") + citations = reader.read_result_document_citations(case.context, task.output("prepared")) + citation = next(item for item in citations if item.get("evidence_kind") == "native_word_annotation") + contracts = importlib.import_module("functions_orchestration_result_contracts") + cited = reader.read_native_citation_evidence( + case.fixture.restart(), contracts.ResultRef.from_dict(citation["native_evidence"]), + citation_id=citation["citation_id"], document_id="document-1", + ) + assert cited["native_annotation"] == record and cited["evidence_kind"] == "native_word_annotation" + checkpoints = [entry for entry in caplog.records if getattr(entry, "sc_stage", None) == "native_comment_read"] + assert [entry.sc_outcome for entry in checkpoints] == ["started", "completed"] + assert all(entry.sc_source_format == extension and entry.sc_run_id_hash and entry.sc_step_id_hash for entry in checkpoints) + serialized = json.dumps([{key: value for key, value in entry.__dict__.items() if key.startswith("sc_")} for entry in checkpoints]) + assert "Raise cap" not in serialized and document["file_name"] not in serialized + assert len(case.model.calls) == (3 if repair_checker else 1) + if repair_checker: + feedback = json.loads(case.model.calls[2][0][-1]["content"])["assessment_problem"] + assert feedback["allowed_evidence_refs"] == ["comments", "user_provided_content"] + diagnostics = [entry for entry in caplog.records if getattr(entry, "sc_stage", None) == "task_completeness_assessment"] + assert len(diagnostics) == 1 + assert diagnostics[0].sc_validation_code == "completeness_evidence_invalid" + assert diagnostics[0].sc_run_id_hash and diagnostics[0].sc_step_id_hash + assert diagnostics[0].sc_report_repair_count == 0 and diagnostics[0].sc_evidence_key_count == 2 + assert diagnostics[0].sc_evidence_ref_count == 1 and diagnostics[0].sc_evidence_refs_list is True + safe_fields = {key: value for key, value in diagnostics[0].__dict__.items() if key.startswith("sc_")} + assert "PRIVATE_INVALID_SOURCE_LABEL" not in json.dumps(safe_fields) + answer = case.fixture.restart().open_result(case.context.task_results["answer"].output("answer")).read_text() + assert answer == candidate + + +def test_word_evidence_does_not_bypass_repeated_unsupported_answer_findings(runtime, monkeypatch): + step, _, _ = configure_word_inventory(runtime, monkeypatch, word_comment_package()) + candidate = "The author accepted the higher cap of 19500." + assessment = json.dumps({"requirements": [{ + "id": "grounding", "requirement": "Describe what the author actually replied.", + "status": "missing", "reason": "The source says to keep 15000, not accept 19500.", + "evidence_refs": ["comments"], "answer_quote": "", + }]}) + case = runtime.make([ + step, compose("answer", inputs={"comments": source_input("comments", "prepared")}), + ], [ + candidate, assessment, "The higher cap of 19500 was approved.", assessment, + "The final approved policy cap is 19500.", assessment, + ], final_response=binding("answer"), + settings={"enable_chat_orchestration_completeness_check": True}) + case.fixture.sources["document-1"] = {**source(), "file_name": "review.docx"} + result = execute(runtime, case) + assert result["status"] == "failed" + answer = next(row for row in result["steps"] if row["step_id"] == "answer") + assert answer["failure"]["code"] == "completeness_incomplete" + assert len(case.model.calls) == 6 + assert "comments" in case.context.task_results and "answer" not in case.context.task_results + + +@pytest.mark.parametrize("anchor,closed", [(" " + "x" * 400, True), ("15000", False)]) +def test_word_anchor_completeness_requires_an_unclipped_closed_range(runtime, monkeypatch, anchor, closed): + configure_word_inventory(runtime, monkeypatch, word_comment_package(anchor=anchor, closed_anchor=closed)) + service = importlib.import_module("functions_search_service") + payload = service.get_document_accessibility_payload("document-1", "owner", sections="comments") + annotation = payload["word_inspection"]["records"][0] + assert annotation["anchor_text"] and not annotation["anchor_complete"] + + +def test_comment_only_metadata_request_rejects_an_unsupported_format(runtime, monkeypatch): + _, calls, _ = configure_word_inventory(runtime, monkeypatch, word_comment_package(), extension="pdf") + service = importlib.import_module("functions_search_service") + with pytest.raises(LookupError, match="native comment inventory is unavailable"): + service.get_document_accessibility_payload("document-1", "owner", sections="comments") + assert calls == [] + + +@pytest.mark.parametrize("problem", ["empty", "missing", "malformed", "revoked", "changed", "limit"]) +def test_word_native_unavailable_sources_never_become_empty_success(runtime, monkeypatch, problem): + content = ( + LookupError("PRIVATE_ORIGINAL_UNAVAILABLE") if problem == "missing" + else word_comment_package(comments=problem != "empty", malformed=problem == "malformed") + ) + step, _, _ = configure_word_inventory(runtime, monkeypatch, content) + case = runtime.make([step]) + case.fixture.sources["document-1"] = {**source(), "file_name": "review.docx"} + service = importlib.import_module("functions_search_service") + original = service.screening_access.read_available_document_bytes + + def read_then_change(*args, **kwargs): + value = original(*args, **kwargs) + if problem == "revoked": + case.fixture.denied.add("document-1") + elif problem == "changed": + case.fixture.sources["document-1"]["source_revision"] = "new-revision" + return value + + monkeypatch.setattr(service.screening_access, "read_available_document_bytes", read_then_change) + if problem == "limit": + collector = importlib.import_module("functions_orchestration_excel") + monkeypatch.setattr(collector, "MAX_VALUE_BYTES", 4) + result = execute(runtime, case) + if problem in ("empty", "limit"): + assert "comments" in case.context.task_results, result["steps"] + task = case.context.task_results["comments"] + prepared = case.fixture.restart().open_result(task.output("prepared"), allow_partial=True).read_value() + coverage = prepared["coverage"]["native_inspection"][0] + assert coverage["returned_records"] == 0 and coverage["partial"] is (problem == "limit") + assert task.output("prepared").completeness.status == ("partial" if problem == "limit" else "complete") + else: + assert result["status"] != "completed" and "comments" not in case.context.task_results + assert "PRIVATE_ORIGINAL" not in json.dumps(result) + + +def test_word_resolution_and_bounded_anchor_remain_explicitly_unavailable(runtime, monkeypatch): + step, _, _ = configure_word_inventory( + runtime, monkeypatch, word_comment_package(extended=False, anchor="a" * 400), + ) + case = runtime.make([step]) + case.fixture.sources["document-1"] = {**source(), "file_name": "review.docx"} + result = execute(runtime, case) + assert result["status"] == "completed", result["steps"] + prepared = case.fixture.restart().open_result(case.context.task_results["comments"].output("prepared")).read_value() + record = json.loads(prepared["search_results"][1]["chunk_text"])["word_annotation"] + assert record["done"] is None and record["resolution_available"] is False + assert len(record["anchor_text"]) == 300 and record["anchor_complete"] is False + assert prepared["coverage"]["native_inspection"][0]["total_records"] == 2 + + def test_native_workbook_comments_reach_retained_evidence_and_composition( runtime, monkeypatch, inventory_store, review_workbook, ): @@ -353,12 +651,16 @@ def test_native_citation_lookup_rejects_stale_or_untrusted_evidence( ) +@pytest.mark.parametrize("source_format", ["xlsx", "docx"]) def test_native_citation_http_lookup_uses_retained_result_not_search( - runtime, real_http_harness, monkeypatch, inventory_store, review_workbook, + runtime, real_http_harness, monkeypatch, inventory_store, review_workbook, source_format, ): - step, _, _ = configure_inventory(runtime, monkeypatch, inventory_store, review_workbook) + if source_format == "docx": + step, _, _ = configure_word_inventory(runtime, monkeypatch, word_comment_package()) + else: + step, _, _ = configure_inventory(runtime, monkeypatch, inventory_store, review_workbook) case = runtime.make([step]) - case.fixture.sources["document-1"] = {**source(), "file_name": "review.xlsx"} + case.fixture.sources["document-1"] = {**source(), "file_name": f"review.{source_format}"} result = execute(runtime, case) assert result["status"] == "completed" reader = importlib.import_module("functions_orchestration_result_runtime") diff --git a/functional_tests/test_orchestration_conversation_sources.py b/functional_tests/test_orchestration_conversation_sources.py index fdc5241bf..7a364dce3 100644 --- a/functional_tests/test_orchestration_conversation_sources.py +++ b/functional_tests/test_orchestration_conversation_sources.py @@ -1,8 +1,9 @@ # test_orchestration_conversation_sources.py """ Functional regressions for exact conversation-source transformations. -Version: 0.261.349 +Version: 0.261.422 Implemented in: 0.261.341 +Shared two-correction allowance updated in: 0.261.422 Exercises real discovery, resolver, Flask planning and initialized execution with offline storage/model boundaries. Discovery previews never substitute for full @@ -200,10 +201,12 @@ def test_repeated_inconsistent_plan_fails_instead_of_broadening_scope(real_http_ _replace_history(runtime.harness, long_sources()) runtime.harness.replies = [ transform_resolution(), _search_then_answer_plan(REQUEST), _search_then_answer_plan(REQUEST), + _search_then_answer_plan(REQUEST), ] events = _plan(runtime, REQUEST) assert any('not a workspace search' in event.get('error', '') for event in events), events assert not any(event.get('type') == 'orchestration_plan' for event in events) + assert len(runtime.harness.model_calls) == 4 def test_explicit_workspace_retrieval_overrides_no_retrieval_resolution(real_http_harness): diff --git a/functional_tests/test_orchestration_document_planning_recovery.py b/functional_tests/test_orchestration_document_planning_recovery.py index 86abd7325..c2821d973 100644 --- a/functional_tests/test_orchestration_document_planning_recovery.py +++ b/functional_tests/test_orchestration_document_planning_recovery.py @@ -2,10 +2,11 @@ #!/usr/bin/env python3 """ Functional regressions for picked-document planning and debug diagnostics. -Version: 0.261.420 +Version: 0.261.422 Implemented in: 0.261.413 Server-owned Auto handoff integration: 0.261.415 Shared draft-contract recovery integration: 0.261.419 +Two shared corrective proposals and local source wiring: 0.261.422 Real authenticated routes, source metadata, planner, validation and logger serialization run with external model/storage I/O isolated. Diagnostics must @@ -91,12 +92,22 @@ def proposal(document_ids=(), *, capability="document_search", options=None): def run_plan( runtime, replies, *, identifiers=(LEFT,), context_updates=None, seed_updates=None, - message=PRIVATE, **options, + message=PRIVATE, prepare_sources=False, **options, ): harness = runtime.harness selected, seeds = candidates(runtime, identifiers) seeds.update(seed_updates or {}) - offered = enrich(runtime, selected, seeds) + if prepare_sources: + from functions_orchestration_source_context import build_selected_source_context + + offered = runtime.context.enrich_planner_candidates( + selected, "owner", conversation_id="conversation-1", seeds=seeds, + selected_source_context_reader=lambda sources: build_selected_source_context( + sources, read_bytes=lambda *args, **kwargs: pytest.fail("Narrative planning must not read file bytes."), + ), + ) + else: + offered = enrich(runtime, selected, seeds) context = runtime.context.build_planner_context(message, candidates=offered, seeds=seeds) context.update(context_updates or {}) harness.replies = [json.dumps(reply) if isinstance(reply, dict) else reply for reply in replies] @@ -129,12 +140,12 @@ def test_debug_diagnostics_identify_model_omission_in_both_sinks(planning, caplo planning.debug_flags["enable_debug_logging"] = debug with caplog.at_level(logging.INFO, logger=planning.logger.name): with pytest.raises(planning.harness.planner.PlannerError): - run_plan(planning, [proposal(), proposal()]) + run_plan(planning, [proposal(), proposal(), proposal()]) records = diagnostic_records(caplog) assert bool(records) is debug if debug: initial = diagnostic_records(caplog, stage="planner_input", message="[ORCHESTRATION_PLANNER] Planning diagnostic.")[0] - assert initial.sc_remaining_corrections == 1 + assert initial.sc_remaining_corrections == 2 summaries = [ record for record in diagnostic_records(caplog, stage="plan_normalized") if record.sc_message == "[ORCHESTRATION_PLANNER] Planning diagnostic." @@ -165,7 +176,7 @@ def test_debug_options_show_exact_owner_rule_before_defaulting(planning, caplog, invalid = proposal([LEFT], capability="document_analyze", options={"mode": "source_observations"}) with caplog.at_level(logging.INFO, logger=planning.logger.name): with pytest.raises(planning.harness.planner.PlannerError): - run_plan(planning, [invalid, invalid]) + run_plan(planning, [invalid, invalid, invalid]) steps = diagnostic_records(caplog, stage="plan_proposal", message="[ORCHESTRATION_PLANNER] Step binding diagnostic.") analysis = next(record for record in steps if record.sc_capability_id == "document_analyze") assert analysis.sc_options_mode_observations is True @@ -194,7 +205,7 @@ def test_debug_snapshot_bounds_are_explicit(planning, caplog): identifiers = tuple(f"document-{index}" for index in range(101)) with caplog.at_level(logging.INFO, logger=planning.logger.name): with pytest.raises(planning.harness.planner.PlannerError): - run_plan(planning, [proposal(), proposal()], identifiers=identifiers) + run_plan(planning, [proposal(), proposal(), proposal()], identifiers=identifiers) summary = diagnostic_records(caplog, stage="plan_proposal", message="[ORCHESTRATION_PLANNER] Planning diagnostic.")[0] assert summary.sc_selected_document_count == summary.sc_missing_document_count == 101 assert summary.sc_omitted_document_count == 101 - planning.harness.planner.PLANNER_DEBUG_MAX_ITEMS @@ -352,24 +363,21 @@ def test_http_picked_document_recovery_preserves_scope_and_does_not_execute( ("selection", "selection", "selected_requirements"), ("options", "options", "plan_normalization"), ]) -def test_every_failure_path_shares_exactly_one_correction(planning, caplog, first, second, stage): +def test_every_failure_path_shares_two_corrections(planning, caplog, first, second, stage): bad = { "options": proposal([LEFT], capability="document_analyze", options={"mode": "source_observations"}), "selection": proposal(), } with caplog.at_level(logging.INFO, logger=planning.logger.name): - with pytest.raises(planning.harness.planner.PlannerError) as failure: - run_plan(planning, [bad[first], bad[second], proposal([LEFT])]) - assert len(planning.harness.model_calls) == 2 - assert len(planning.harness.replies) == 1 - assert "PRIVATE_" not in str(failure.value) - expected_message = "selected" if second == "selection" else "document-analysis" - assert expected_message in str(failure.value) - terminal = next( - record for record in reversed(caplog.records) - if getattr(record, "sc_message", None) == "[ORCHESTRATION_PLANNER] The request could not be planned." - ) - assert terminal.sc_attempt == 2 and terminal.sc_stage == stage + kind, plan = run_plan(planning, [bad[first], bad[second], proposal([LEFT])]) + assert kind == "plan" and plan["validation"]["ok"] + assert len(planning.harness.model_calls) == 3 and not planning.harness.replies + corrections = [ + record for record in caplog.records + if getattr(record, "sc_message", None) == "[ORCHESTRATION_PLANNER] Asking the planner to correct a rejected plan." + ] + assert len(corrections) == 2 + assert corrections[-1].sc_attempt == 2 and corrections[-1].sc_stage == stage def test_unavailable_capabilities_are_not_made_repairable(planning): @@ -432,7 +440,7 @@ def test_edit_failure_preserves_previous_plan_and_legacy_options(planning): invalid = proposal([LEFT], capability="document_analyze", options={"mode": "source_observations"}) invalid["revised_request"] = "Describe the selected source." with pytest.raises(planning.harness.planner.PlannerError) as failure: - run_plan(planning, [invalid, invalid], edit_context={"current_plan": previous}) + run_plan(planning, [invalid, invalid, invalid], edit_context={"current_plan": previous}) assert "previous plan is unchanged" in str(failure.value) assert previous == before omitted = proposal([LEFT], capability="document_analyze") @@ -585,8 +593,8 @@ def test_unrenderable_question_cannot_reset_the_shared_correction_budget(plannin "question": question, }[first] with pytest.raises(planning.harness.planner.PlannerError): - run_plan(planning, [initial, question, proposal([LEFT])]) - assert len(planning.harness.model_calls) == 2 + run_plan(planning, [initial, question, question, proposal([LEFT])]) + assert len(planning.harness.model_calls) == 3 assert len(planning.harness.replies) == 1 diff --git a/functional_tests/test_orchestration_failure_telemetry.py b/functional_tests/test_orchestration_failure_telemetry.py index a0b05de42..bbf32b4c2 100644 --- a/functional_tests/test_orchestration_failure_telemetry.py +++ b/functional_tests/test_orchestration_failure_telemetry.py @@ -1,12 +1,13 @@ # test_orchestration_failure_telemetry.py """Specific orchestration failure rules reach telemetry without private plan text. -Version: 0.261.342 +Version: 0.261.422 Implemented in: 0.261.140 Step, capability, format, and authority-reason codes covered in: 0.261.141 Partial-input producer capability and step hash fields covered in: 0.261.191 Semantic chat outcomes and persisted-state correlation implemented in: 0.261.341 Bounded, non-regex application-version validation implemented in: 0.261.342 +Two shared planning corrections covered in: 0.261.422 Uses real planner/validation/logging modules with offline provider replies. The synthetic invalid proposals test diagnostics, not the unknown historical @@ -76,7 +77,7 @@ def test_repair_and_terminal_failure_retain_safe_specific_rule( monkeypatch.setattr(telemetry, "get_appinsights_logger", lambda: logger) monkeypatch.setattr(telemetry, "_load_logging_settings", lambda: {"enable_debug_logging": debug}) proposal = invalid_proposal(rule) - harness.replies = [json.dumps(proposal), json.dumps(proposal)] + harness.replies = [json.dumps(proposal)] * 3 with caplog.at_level(logging.INFO, logger=logger.name): with pytest.raises(harness.planner.PlannerError) as failure: harness.planner.plan_request( @@ -92,8 +93,8 @@ def test_repair_and_terminal_failure_retain_safe_specific_rule( "[ORCHESTRATION_PLANNER] The request could not be planned.", } ] - assert len(records) == len(harness.model_calls) == 2 - assert [record.sc_attempt for record in records] == [1, 2] + assert len(records) == len(harness.model_calls) == 3 + assert [record.sc_attempt for record in records] == [1, 2, 3] for record in records: assert record.sc_validation_rule == rule assert record.sc_validation_code == "deliverables_invalid" diff --git a/functional_tests/test_orchestration_planner_contract_recovery.py b/functional_tests/test_orchestration_planner_contract_recovery.py index f59cfffab..0b53544d1 100644 --- a/functional_tests/test_orchestration_planner_contract_recovery.py +++ b/functional_tests/test_orchestration_planner_contract_recovery.py @@ -1,12 +1,12 @@ # test_orchestration_planner_contract_recovery.py """ Functional regressions for effective planner contracts and document action binding. -Version: 0.261.420 -Implemented in: 0.261.419 +Version: 0.261.422 +Implemented in: 0.261.419; 0.261.422 Real registry, planner, validation, source preparation and logger owners run with external model/storage I/O isolated. A corrected draft must retain the original -scope, model, deliverables and single correction budget without executing tools. +scope, model, deliverables and shared correction budget without executing tools. """ import importlib @@ -55,13 +55,22 @@ def action_proposal(document_ids=(LEFT,), *, action=ACTION): } -def run_action_plan(runtime, replies, *, identifiers=(LEFT,), action=ACTION): +def run_action_plan(runtime, replies, *, identifiers=(LEFT,), action=ACTION, prepare_sources=False): harness = runtime.harness harness.settings.update(enable_semantic_kernel=True, enable_chat_orchestration_actions=True) selected, seeds = candidates(runtime, identifiers) - context = runtime.context.build_planner_context( - PRIVATE, candidates=enrich(runtime, selected, seeds), seeds=seeds, actions=[action], - ) + if prepare_sources: + from functions_orchestration_source_context import build_selected_source_context + + offered = runtime.context.enrich_planner_candidates( + selected, "owner", conversation_id="conversation-1", seeds=seeds, + selected_source_context_reader=lambda sources: build_selected_source_context( + sources, read_bytes=lambda *args, **kwargs: pytest.fail("Narrative planning must not read file bytes."), + ), + ) + else: + offered = enrich(runtime, selected, seeds) + context = runtime.context.build_planner_context(PRIVATE, candidates=offered, seeds=seeds, actions=[action]) caller = {**harness.services().capability_request_bindings(), "action_catalog": [action]} harness.replies = [json.dumps(reply) for reply in replies] return harness.planner.plan_request( @@ -184,6 +193,62 @@ def test_missing_action_source_declaration_is_corrected_without_extra_work(plann assert correction_records(caplog)[0].sc_validation_rule == "selected_documents_missing" +def test_single_selected_action_source_is_connected_locally(planning, caplog): + raw = action_proposal(None) + original = deepcopy(raw) + with caplog.at_level(logging.INFO, logger=planning.logger.name): + kind, plan = run_action_plan(planning, [raw], prepare_sources=True) + assert kind == "plan" and len(planning.harness.model_calls) == 1 + assert raw == original + assert plan["steps"][0]["arguments"]["document_ids"] == [LEFT] + assert [step["capability_id"] for step in plan["steps"]] == ["action_invoke", "compose"] + assert plan["validation"]["ok"] and plan["validation"]["repairs"] + assert not correction_records(caplog) + context = json.loads(planning.harness.model_calls[0]["messages"][1]["content"]) + assert context["actions"][0]["default_arguments"]["document_ids"] == [LEFT] + assert planning.harness.assistant_messages() == [] and planning.harness.blobs.file_uploads == 0 + + +def test_single_selected_analyze_source_is_connected_before_validation(planning): + raw = proposal([LEFT], capability="document_analyze") + del raw["steps"][0]["arguments"]["document_ids"] + kind, plan = run_plan(planning, [raw], prepare_sources=True) + assert kind == "plan" and len(planning.harness.model_calls) == 1 + assert plan["steps"][0]["arguments"]["document_ids"] == [LEFT] + assert plan["steps"][0]["arguments"]["analysis_options"]["mode"] == "source_observations" + assert plan["steps"][-1]["inputs"]["source"]["binding"]["step_id"] == "read" + assert plan["validation"]["repairs"] + context = json.loads(planning.harness.model_calls[0]["messages"][1]["content"]) + analyze = next(item for item in context["capabilities"] if item["id"] == "document_analyze") + assert analyze["default_arguments"]["document_ids"] == [LEFT] + + +@pytest.mark.parametrize("change", ["explicit", "disabled", "dynamic", "ambiguous", "unavailable"]) +def test_local_source_wiring_never_replaces_explicit_or_ambiguous_work(planning, change): + planner = planning.harness.planner + raw = action_proposal(None) + selected_sources = [{ + "document_id": LEFT, "source_kind": "narrative", "availability": "available", + }] + if change == "explicit": + raw["steps"][0]["arguments"]["document_ids"] = [] + elif change == "disabled": + raw["steps"][0]["enabled"] = False + elif change == "dynamic": + raw["steps"][0]["inputs"] = {"sources": {"binding": input_binding("discover", "sources")}} + elif change == "ambiguous": + selected_sources.append({"document_id": RIGHT, "source_kind": "tabular", "availability": "available"}) + else: + selected_sources[0]["availability"] = "unavailable" + original = deepcopy(raw) + result, repairs = planner._with_selected_source_bindings( + raw, selected_sources=selected_sources, + selected_document_ids=[item["document_id"] for item in selected_sources], + authorized_document_ids=[LEFT, RIGHT], actions=[ACTION], + ) + assert result == original and raw == original and repairs == [] + + def test_non_document_action_cannot_claim_sources_using_a_forged_catalog_flag(planning): action = {**ACTION, "type": "openapi", "document_binding_supported": True} with pytest.raises(planning.harness.planner.PlannerError): @@ -207,17 +272,16 @@ def test_source_set_cannot_be_passed_as_prepared_action_findings(planning, caplo assert plan["steps"][1]["inputs"]["evidence"]["binding"]["output_name"] == "prepared" -def test_different_contract_errors_share_one_correction_and_keep_private_values_out(planning, caplog): +def test_different_contract_errors_share_two_corrections_and_keep_private_values_out(planning, caplog): first = proposal([LEFT]) first["steps"][0]["outputs"] = [] second = proposal([LEFT]) second["steps"][-1]["inputs"] = {"private": {}} with caplog.at_level(logging.INFO, logger=planning.logger.name): - with pytest.raises(planning.harness.planner.PlannerError) as failure: - run_plan(planning, [first, second, proposal([LEFT])]) - assert len(planning.harness.model_calls) == 2 and len(planning.harness.replies) == 1 - assert len(correction_records(caplog)) == 1 - assert "inputs and outputs" in str(failure.value) + kind, plan = run_plan(planning, [first, second, proposal([LEFT])]) + assert kind == "plan" and plan["validation"]["ok"] + assert len(planning.harness.model_calls) == 3 and not planning.harness.replies + assert len(correction_records(caplog)) == 2 records = [ {name: value for name, value in record.__dict__.items() if name.startswith("sc_")} for record in caplog.records @@ -225,6 +289,22 @@ def test_different_contract_errors_share_one_correction_and_keep_private_values_ assert PRIVATE not in json.dumps(records) +def test_third_invalid_proposal_stops_without_a_new_error_budget(planning, caplog): + first = proposal([LEFT]) + first["steps"][0]["outputs"] = [] + second = proposal([LEFT], capability="document_analyze", options={"version": "invalid"}) + third = proposal([LEFT]) + third["steps"][-1]["inputs"] = {"evidence": {}} + with caplog.at_level(logging.INFO, logger=planning.logger.name): + with pytest.raises(planning.harness.planner.PlannerError) as failure: + run_plan(planning, [first, second, third, proposal([LEFT])]) + assert len(planning.harness.model_calls) == 3 and len(planning.harness.replies) == 1 + assert len(correction_records(caplog)) == 2 + assert failure.value.code == "plan_inputs_invalid" + assert failure.value.rule == "input_binding_required" + assert planning.harness.assistant_messages() == [] + + def test_source_free_draft_uses_the_same_contract_correction_without_adding_retrieval(planning): invalid = proposal() invalid["steps"][0]["outputs"] = [] diff --git a/functional_tests/test_orchestration_planning_persistence.py b/functional_tests/test_orchestration_planning_persistence.py index 369a74dfb..528f94192 100644 --- a/functional_tests/test_orchestration_planning_persistence.py +++ b/functional_tests/test_orchestration_planning_persistence.py @@ -1,9 +1,10 @@ # test_orchestration_planning_persistence.py """ Regression coverage for durable initial orchestration turns. -Version: 0.261.420 +Version: 0.261.422 Implemented in: 0.261.415 Typed planner failure persistence added in: 0.261.419 +Shared two-correction allowance updated in: 0.261.422 Real authenticated routes and SDK-default offline storage exercise admission, clarification, failure, disconnect, Auto handoff, cancellation, and explicit retry. @@ -288,20 +289,20 @@ def test_typed_contract_failure_survives_reload_and_clears_on_explicit_retry(emp runtime = empty_history_runtime invalid = plan_doc() invalid["steps"][0]["outputs"] = [] - runtime.harness.replies.extend([json.dumps(invalid), json.dumps(invalid)]) + runtime.harness.replies.extend([json.dumps(invalid)] * 3) response = runtime.client.post(PLAN_PATH, json=body(), buffered=True) failure = next(frame for frame in sse_frames(response) if frame.get("error")) expected = { "error_stage": "plan_normalization", "error_rule": "outputs_list_required", - "error_attempt": 2, + "error_attempt": 3, } assert failure["code"] == "plan_outputs_invalid" assert all(failure.get(key) == value for key, value in expected.items()) calls = len(runtime.harness.model_calls) saved = status(runtime, include_request=True) reloaded = status(runtime) - assert calls == len(runtime.harness.model_calls) == 2 + assert calls == len(runtime.harness.model_calls) == 3 assert saved["status"] == reloaded["status"] == "failed" assert saved["error_code"] == reloaded["error_code"] == failure["code"] assert all(saved.get(key) == reloaded.get(key) == value for key, value in expected.items()) diff --git a/functional_tests/test_orchestration_selected_source_planning.py b/functional_tests/test_orchestration_selected_source_planning.py index e7040fc5b..94811795e 100644 --- a/functional_tests/test_orchestration_selected_source_planning.py +++ b/functional_tests/test_orchestration_selected_source_planning.py @@ -1,8 +1,9 @@ # test_orchestration_selected_source_planning.py """Selected attachments remain usable during planning and clarification. -Version: 0.261.415 +Version: 0.261.422 Implemented in: 0.261.355 +Shared two-correction allowance updated in: 0.261.422 Uses real source projection, parsers, resolution and HTTP planning with external storage/model I/O replaced. Previews must never become complete export records. @@ -181,7 +182,7 @@ def test_full_planner_repairs_source_question_instead_of_publishing_it(source_ru "type": "object", "properties": {"rows": {"type": "string"}}, "required": ["rows"], }, } - harness.replies = [json.dumps(question), json.dumps(question)] + harness.replies = [json.dumps(question)] * 3 with pytest.raises(harness.planner.PlannerError) as failure: harness.planner.plan_request( "What columns does my file have?", context, "conversation-1", "owner", @@ -190,7 +191,7 @@ def test_full_planner_repairs_source_question_instead_of_publishing_it(source_ru request_context=harness.services().capability_request_bindings(), ) assert failure.value.reason == "selected_source_already_available" - assert len(harness.model_calls) == 2 + assert len(harness.model_calls) == 3 assert harness.blobs.file_uploads == 0 @@ -584,7 +585,7 @@ def test_source_diagnostics_survive_logging_without_source_text(source_runtime, "kind": "elicitation", "message": "PRIVATE_MODEL_QUESTION", "clarification_context": {"reason": "source_content", "document_ids": ["tabular-doc"]}, } - harness.replies = [json.dumps(question), json.dumps(question)] + harness.replies = [json.dumps(question)] * 3 with pytest.raises(harness.planner.PlannerError): harness.planner.plan_request( request, context, "conversation-1", "owner", turn_id="private-turn", @@ -595,7 +596,9 @@ def test_source_diagnostics_survive_logging_without_source_text(source_runtime, logging._build_logger_extra(message, kwargs["extra"]) for message, kwargs in recorded if (kwargs.get("extra") or {}).get("stage") == "plan_clarification" ] - assert len(diagnostics) >= 2 + assert len(diagnostics) >= 3 + assert {entry["sc_attempt"] for entry in diagnostics} == {1, 2, 3} + assert len(harness.model_calls) == 3 assert diagnostics[0]["sc_stage"] == "plan_clarification" assert diagnostics[0]["sc_reason"] == "selected_source_already_available" assert diagnostics[0]["sc_selected_source_count"] == 1 diff --git a/functional_tests/test_orchestration_source_kind_planning.py b/functional_tests/test_orchestration_source_kind_planning.py index 4091b3849..149f6d457 100644 --- a/functional_tests/test_orchestration_source_kind_planning.py +++ b/functional_tests/test_orchestration_source_kind_planning.py @@ -1,7 +1,7 @@ # test_orchestration_source_kind_planning.py """Plan mixed document types from authorized metadata, not user-facing labels. -Version: 0.261.355 +Version: 0.261.422 Implemented in: 0.261.140 The real manifest classifier, context projection, planner, compiler and HTTP @@ -278,14 +278,14 @@ def test_source_kind_repair_stays_bounded_and_never_drops_selected_sources(sourc offered = enrich(runtime, selected, seeds) payload = runtime.context.build_planner_context("Compare the documents.", candidates=offered, seeds=seeds) rejected = proposal(narrative_comparison=True) - harness.replies = [json.dumps(rejected), json.dumps(rejected)] + harness.replies = [json.dumps(rejected)] * 3 with pytest.raises(harness.planner.PlannerError) as failure: harness.planner.plan_request( "Compare the documents.", payload, "conversation-1", "owner", settings=harness.settings, seeds=seeds, authorized_document_ids=[LEFT, RIGHT], request_context=harness.services().capability_request_bindings(), ) - assert len(harness.model_calls) == 2 + assert len(harness.model_calls) == 3 assert failure.value.message == harness.planner.SOURCE_KIND_FAILURE_MESSAGE assert harness.blobs.file_uploads == 0 and harness.assistant_messages() == [] diff --git a/functional_tests/test_task_completeness_engine.py b/functional_tests/test_task_completeness_engine.py index 3ccf25b9f..6b823a97c 100644 --- a/functional_tests/test_task_completeness_engine.py +++ b/functional_tests/test_task_completeness_engine.py @@ -1,8 +1,8 @@ # test_task_completeness_engine.py """Grounded, bounded task checking and recovery. -Version: 0.261.418 -Implemented in: 0.261.382; 0.261.384; 0.261.416; 0.261.418 +Version: 0.261.422 +Implemented in: 0.261.382; 0.261.384; 0.261.416; 0.261.418; 0.261.422 """ import json @@ -61,6 +61,52 @@ def test_complete_original_has_no_correction(): assert state["corrections"] == 0 +@pytest.mark.parametrize("evidence", [{}, {"findings": {"private": "source text"}, "coverage": {}}]) +def test_assessment_transport_only_accepts_actual_evidence_keys(evidence): + response_format = assessment_response_format(evidence=evidence) + schema = response_format["json_schema"]["schema"] + validator = Draft202012Validator(schema) + report = json.loads(assessment(refs=list(evidence))) + valid_errors = list(validator.iter_errors(report)) + assert not valid_errors + report["requirements"][0]["evidence_refs"] = ["invented_reference"] + invalid_errors = list(validator.iter_errors(report)) + assert invalid_errors + assert "source text" not in json.dumps(schema) + + +def test_evidence_reference_repair_gets_valid_labels_without_rewriting_the_answer(): + calls, diagnostics = [], [] + replies = iter([assessment(refs=["PRIVATE_WRONG_REFERENCE"]), assessment()]) + + def invoke(messages, stage, *, response_format=None): + calls.append((deepcopy(messages), stage, deepcopy(response_format))) + return next(replies) + + candidate = "Evaluation" + result, state = check_and_correct( + candidate, instruction="Include the status.", contract={"kind": "text-v1"}, + evidence={"record": {"status": "Evaluation"}}, + policy={"enabled": True, "max_corrections": 2}, + invoke=invoke, validate=lambda value: [], on_diagnostic=diagnostics.append, + ) + assert result == candidate and state["status"] == "complete" + assert state["corrections"] == 0 and state["report_repairs"] == 1 + assert [call[1] for call in calls] == ["completeness_check", "completeness_check"] + repair_payload = json.loads(calls[1][0][-1]["content"]) + problem = repair_payload["assessment_problem"] + assert problem["code"] == "completeness_evidence_invalid" + assert problem["allowed_evidence_refs"] == ["record"] + assert problem["evidence_ref_count"] == 1 + assert "PRIVATE_WRONG_REFERENCE" not in json.dumps(problem) + refs = calls[0][2]["json_schema"]["schema"]["properties"]["requirements"]["items"]["properties"]["evidence_refs"] + assert refs["items"]["enum"] == ["record"] + assert diagnostics[0]["validation_code"] == "completeness_evidence_invalid" + assert diagnostics[0]["validation_rule"] == "evidence_reference_unknown" + assert diagnostics[0]["report_repair_count"] == 0 and diagnostics[0]["evidence_ref_count"] == 1 + assert "PRIVATE_WRONG_REFERENCE" not in json.dumps(diagnostics) + + def test_actual_missing_tool_facts_are_repaired_and_rechecked(): corrected = '{"status":"Evaluation","proposal_due":"2027-02-15","award_target":"2027-04-01","response_days":15}' result, state, calls, _ = run([assessment("missing"), corrected, assessment()]) diff --git a/ui_tests/test_v2_stream_citation_delivery.py b/ui_tests/test_v2_stream_citation_delivery.py index 4fa37b466..895d7725b 100644 --- a/ui_tests/test_v2_stream_citation_delivery.py +++ b/ui_tests/test_v2_stream_citation_delivery.py @@ -1,14 +1,16 @@ # test_v2_stream_citation_delivery.py """ UI regressions for live tool/web evidence and recorded tool outcomes. -Version: 0.261.417 +Version: 0.261.422 Implemented in: 0.261.365 +Word comment inspection fixed in: 0.261.422 Drives the real send/SSE/store/message inspector locally with synthetic HTTP boundaries. The existing Azure Playwright-compatible fixture also supports local browsers. No live agents, actions, conversations, model requests, or connector requests are created. """ +import json from pathlib import Path import re @@ -17,6 +19,7 @@ from ui_tests.fixtures.playwright_connection import connect_options # noqa: F401 from ui_tests.fixtures.orchestration import harness_build +from ui_tests.test_inline_citation_filename_fallback import CitationPage ROOT = Path(__file__).resolve().parents[1] @@ -220,6 +223,120 @@ def test_native_annotation_first_preserves_streamed_and_saved_identity(citation_ expect(dialog.get_by_role("button", name="Open workbook preview (separate from annotation evidence)", exact=True)).to_have_count(0) +def _word_comment_evidence(): + reference = { + "producer": {"user_id": "owner", "conversation_id": "evidence-chat", "run_id": "native-run", + "step_id": "comments", "attempt_index": 1, "capability_id": "document_search"}, + "output_name": "prepared", "kind": "structured-v1", "manifest_sha256": "a" * 64, + } + citation = { + "citation_id": "word:native-comments:0", "document_id": "word", "file_name": "review.docx", + "location_label": "Location", "location_value": "Word comment 0", "native_evidence": reference, + "source_version": 1, "annotation_id": "0", + } + payload = { + "file_name": "review.docx", "evidence_kind": "native_word_annotation", + "native_annotation": { + "id": "0", "author": "Reviewer A", "date": "2026-01-01T10:00:00Z", + "anchor_text": "Original policy paragraph", "anchor_complete": False, + "done": None, "resolution_available": False, + "replies": [{ + "id": "1", "author": "Reviewer B", "date": "2026-01-02T10:00:00Z", + "text": 'Reply ', "done": True, + "resolution_available": True, + }], + }, + "cited_text": "Check the policy. ", + } + content = "Review (Source: review.docx, Location: Word comment 0) [#word:native-comments:0]" + return citation, payload, content + + +@pytest.mark.parametrize("width", [390, 1440]) +def test_word_comment_inspector_retains_anchor_replies_and_saved_evidence(citation_page, width): + page = citation_page + page.set_viewport_size({"width": width, "height": 900}) + citation, payload, content = _word_comment_evidence() + message = _send(page, {"full_content": content, "hybrid_citations": [citation], "augmented": True}) + page.evaluate("""(payload) => { + window.wordRequests = []; + window.wordDenied = false; + const originalFetch = window.fetch; + window.fetch = async (url, init = {}) => { + if (new URL(String(url), location.origin).pathname !== '/api/get_citation') { + return originalFetch(url, init); + } + window.wordRequests.push(JSON.parse(init.body)); + return new Response(JSON.stringify(window.wordDenied + ? {error: 'The cited document is no longer available.'} : payload), + {status: window.wordDenied ? 403 : 200, headers: {'Content-Type': 'application/json'}}); + }; + }""", payload) + chip = message.get_by_role("button", name="review.docx Word comment 0", exact=True) + chip.focus() + chip.press("Enter") + dialog = page.get_by_role("dialog", name="Citation", exact=True) + comment = dialog.get_by_role("region", name="Native Word comment") + expect(comment).to_contain_text("Anchored text: Original policy paragraph") + expect(comment).to_contain_text("Anchor coverage: Bounded or unavailable") + expect(comment).to_contain_text("Resolved: Unavailable") + replies = dialog.get_by_role("region", name="Word comment replies") + expect(replies).to_contain_text("Author: Reviewer B") + expect(replies).to_contain_text("Resolved: Yes") + expect(replies).to_contain_text('Reply ') + expect(dialog).to_contain_text(payload["cited_text"]) + expect(dialog.get_by_text(re.compile("Worksheet / cell:|Saved cell value:"))).to_have_count(0) + expect(dialog.get_by_role("button", name=re.compile("workbook preview"))).to_have_count(0) + assert dialog.locator("script, img").count() == 0 + assert dialog.evaluate("element => element.scrollWidth <= element.clientWidth") + dialog.get_by_role("button", name="Close citation", exact=True).click() + page.evaluate("() => window.OrchHarness.stores.chat.useChatStore.getState().reloadMessages()") + chip.click() + expect(dialog).to_contain_text("Author: Reviewer B") + requests = page.evaluate("() => window.wordRequests") + assert len(requests) == 2 and all(request == requests[0] for request in requests) + assert requests[0]["native_evidence"] == citation["native_evidence"] + assert requests[0]["document_id"] == "word" + dialog.get_by_role("button", name="Close citation", exact=True).click() + page.evaluate("() => window.wordDenied = true") + chip.click() + expect(dialog).to_contain_text("The cited document is no longer available.") + expect(dialog.get_by_role("region", name="Native Word comment")).to_have_count(0) + + +@pytest.mark.parametrize("width", [390, 1440]) +def test_classic_word_comment_inspector_renders_exact_native_evidence(page, width): + page.set_viewport_size({"width": width, "height": 900}) + fixture = CitationPage(page, "classic") + citation, payload, content = _word_comment_evidence() + requests = [] + + def get_citation(route): + requests.append(route.request.post_data_json) + route.fulfill(content_type="application/json", body=json.dumps(payload)) + + page.route("**/api/get_citation", get_citation) + fixture.open() + fixture.load([{ + "id": "word-reply", "conversation_id": "evidence-chat", "role": "assistant", + "content": content, "hybrid_citations": [citation], "augmented": True, "metadata": {}, + }]) + fixture.row("word-reply").locator(".message-text a.citation-link").click() + dialog = page.get_by_role("dialog") + expect(dialog).to_contain_text("Native Word evidence: review.docx") + expect(dialog).to_contain_text("Anchored text: Original policy paragraph") + expect(dialog).to_contain_text("Anchor coverage: Bounded or unavailable") + expect(dialog).to_contain_text("Resolved: Unavailable") + expect(dialog).to_contain_text("Author: Reviewer B") + expect(dialog).to_contain_text("Resolved: Yes") + expect(dialog).to_contain_text(payload["cited_text"]) + expect(dialog).to_contain_text('Reply ') + expect(dialog.get_by_role("button", name=re.compile("workbook preview"))).to_have_count(0) + assert dialog.locator("script, img").count() == 0 + assert requests[0]["native_evidence"] == citation["native_evidence"] + assert fixture.errors == [] + + @pytest.mark.parametrize("result", [{"error": "HTTP 502", "status_code": 502}, None]) def test_live_failed_tool_retains_error_arguments_and_available_result(citation_page, result): failure = {**TOOL, "success": False, "error_message": "Synthetic upstream unavailable.", "function_result": result}