diff --git a/application/single_app/config.py b/application/single_app/config.py index 497d32d97..b41c2fc85 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -96,7 +96,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.250.171" +VERSION = "0.250.185" IS_DEVELOPMENT = is_development_env_enabled() SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax') diff --git a/application/single_app/functions_analysis_deliverables.py b/application/single_app/functions_analysis_deliverables.py new file mode 100644 index 000000000..20aab3b89 --- /dev/null +++ b/application/single_app/functions_analysis_deliverables.py @@ -0,0 +1,920 @@ +# functions_analysis_deliverables.py +"""Versioned Analyze/Search deliverable contracts and pure validators.""" + +import hashlib +import json +import logging +import re +from dataclasses import asdict, dataclass, field +from typing import Mapping + +from functions_tabular_transformations import normalize_tabular_transformation_spec + + +def log_event(*args, **kwargs): + """Lazily resolve telemetry logging so functional tests do not require Azure packages.""" + try: + from functions_appinsights import log_event as _log_event_impl + except ImportError: + return None + return _log_event_impl(*args, **kwargs) + + +ANALYSIS_DELIVERABLE_CONTRACT_VERSION = "analysis-deliverables-v3" +ANALYSIS_DELIVERABLE_LEGACY_CONTRACT_VERSIONS = frozenset({ + "analysis-deliverables-v1", + "analysis-deliverables-v2", +}) + +ANALYSIS_DELIVERABLE_ACTION_ANALYZE = "analyze" +ANALYSIS_DELIVERABLE_ACTION_SEARCH = "search" +ANALYSIS_DELIVERABLE_ACTION_COMPARE = "compare" +ANALYSIS_DELIVERABLE_ACTION_CHAT = "chat" +ANALYSIS_DELIVERABLE_ACTION_WORKFLOW = "workflow" +ANALYSIS_DELIVERABLE_ACTION_UNKNOWN = "unknown" +ANALYSIS_DELIVERABLE_ACTION_MODES = frozenset({ + ANALYSIS_DELIVERABLE_ACTION_ANALYZE, + ANALYSIS_DELIVERABLE_ACTION_SEARCH, + ANALYSIS_DELIVERABLE_ACTION_COMPARE, + ANALYSIS_DELIVERABLE_ACTION_CHAT, + ANALYSIS_DELIVERABLE_ACTION_WORKFLOW, + ANALYSIS_DELIVERABLE_ACTION_UNKNOWN, +}) + +ANALYSIS_ARTIFACT_ROLE_PRIMARY_ANALYSIS = "primary_analysis" +ANALYSIS_ARTIFACT_ROLE_REQUESTED_OUTPUT = "requested_output" +ANALYSIS_ARTIFACT_ROLE_SUPPORTING_OUTPUT = "supporting_output" +ANALYSIS_ARTIFACT_ROLES = frozenset({ + ANALYSIS_ARTIFACT_ROLE_PRIMARY_ANALYSIS, + ANALYSIS_ARTIFACT_ROLE_REQUESTED_OUTPUT, + ANALYSIS_ARTIFACT_ROLE_SUPPORTING_OUTPUT, +}) + +ANALYSIS_DELIVERABLE_FORMATS = frozenset({ + "csv", + "docx", + "json", + "md", + "pdf", + "xls", + "xlsm", + "xlsx", + "xml", +}) + +ANALYSIS_TRANSFORMATION_MODE_PASSTHROUGH = "passthrough" +ANALYSIS_TRANSFORMATION_MODE_DETERMINISTIC = "deterministic" +ANALYSIS_TRANSFORMATION_MODE_SEMANTIC = "semantic" +ANALYSIS_TRANSFORMATION_MODE_HYBRID = "hybrid" +ANALYSIS_TRANSFORMATION_MODES = frozenset({ + ANALYSIS_TRANSFORMATION_MODE_PASSTHROUGH, + ANALYSIS_TRANSFORMATION_MODE_DETERMINISTIC, + ANALYSIS_TRANSFORMATION_MODE_SEMANTIC, + ANALYSIS_TRANSFORMATION_MODE_HYBRID, +}) + +ANALYSIS_ROW_CARDINALITY_ONE_PER_SOURCE_ROW = "one_per_source_row" +ANALYSIS_ROW_CARDINALITY_NOT_APPLICABLE = "not_applicable" +ANALYSIS_ROW_CARDINALITIES = frozenset({ + ANALYSIS_ROW_CARDINALITY_ONE_PER_SOURCE_ROW, + ANALYSIS_ROW_CARDINALITY_NOT_APPLICABLE, +}) + +ANALYSIS_ORDERING_SOURCE_ORDER = "source_order" +ANALYSIS_ORDERING_NOT_APPLICABLE = "not_applicable" +ANALYSIS_ORDERINGS = frozenset({ + ANALYSIS_ORDERING_SOURCE_ORDER, + ANALYSIS_ORDERING_NOT_APPLICABLE, +}) + +ANALYSIS_VALIDATION_PROFILE_ARTIFACT_SET = "artifact_set" +ANALYSIS_VALIDATION_PROFILE_EXACT_ROWS_SCHEMA = "exact_rows_schema" +ANALYSIS_VALIDATION_PROFILE_EXACT_ROWS_SCHEMA_AND_RULES = "exact_rows_schema_and_rules" +ANALYSIS_VALIDATION_PROFILES = frozenset({ + ANALYSIS_VALIDATION_PROFILE_ARTIFACT_SET, + ANALYSIS_VALIDATION_PROFILE_EXACT_ROWS_SCHEMA, + ANALYSIS_VALIDATION_PROFILE_EXACT_ROWS_SCHEMA_AND_RULES, +}) + +ANALYSIS_PUBLICATION_POLICY_ALL_REQUIRED_ARTIFACTS = "all_required_artifacts" +ANALYSIS_PUBLICATION_POLICY_NO_REQUIRED_ARTIFACTS = "no_required_artifacts" +ANALYSIS_PUBLICATION_POLICIES = frozenset({ + ANALYSIS_PUBLICATION_POLICY_ALL_REQUIRED_ARTIFACTS, + ANALYSIS_PUBLICATION_POLICY_NO_REQUIRED_ARTIFACTS, +}) + +ANALYSIS_DELIVERABLE_EVENT_PLANNED = "planned" +ANALYSIS_DELIVERABLE_EVENT_FINALIZED = "finalized" +ANALYSIS_DELIVERABLE_EVENT_VALIDATED = "validated" +ANALYSIS_DELIVERABLE_EVENT_NAMES = frozenset({ + ANALYSIS_DELIVERABLE_EVENT_PLANNED, + ANALYSIS_DELIVERABLE_EVENT_FINALIZED, + ANALYSIS_DELIVERABLE_EVENT_VALIDATED, +}) +ANALYSIS_DELIVERABLE_CONTRACT_MODES = frozenset({"off", "observe", "shadow"}) +ANALYSIS_DELIVERABLE_CONTRACT_OBSERVATION_MODES = frozenset({"observe", "shadow"}) + +ANALYSIS_INTERNAL_LINEAGE_FIELD_NAMES = frozenset({ + "source_row_number", + "source_row_identity", +}) +ANALYSIS_DEFAULT_LINEAGE_SCHEMA = ( + "source_row_number", + "source_row_identity", +) +ANALYSIS_INTERNAL_LINEAGE_FIELD_PREFIX = "__simplechat" + +ANALYSIS_DELIVERABLE_MAX_ARTIFACTS = 12 +ANALYSIS_DELIVERABLE_MAX_SCHEMA_FIELDS = 200 +ANALYSIS_DELIVERABLE_MAX_FIELD_NAME_LENGTH = 128 +ANALYSIS_DELIVERABLE_MAX_ARTIFACT_ID_LENGTH = 96 +ANALYSIS_DELIVERABLE_MAX_METADATA_BYTES = 32768 + + +@dataclass(frozen=True) +class AnalysisDeliverableArtifact: + """Serializable artifact descriptor for an analysis deliverable contract.""" + + artifact_id: str + role: str + format: str + required: bool = True + request_order: int = 0 + + def to_dict(self): + return asdict(self) + + +@dataclass(frozen=True) +class AnalysisDeliverableContract: + """Serializable server-owned deliverable plan.""" + + contract_version: str = ANALYSIS_DELIVERABLE_CONTRACT_VERSION + action_mode: str = ANALYSIS_DELIVERABLE_ACTION_UNKNOWN + analysis_required: bool = False + primary_artifact_role: str = "" + requested_artifacts: tuple = field(default_factory=tuple) + public_output_schema: tuple = field(default_factory=tuple) + internal_checkpoint_schema: tuple = field(default_factory=tuple) + lineage_schema: tuple = field(default_factory=tuple) + row_cardinality: str = ANALYSIS_ROW_CARDINALITY_NOT_APPLICABLE + ordering: str = ANALYSIS_ORDERING_NOT_APPLICABLE + transformation_mode: str = ANALYSIS_TRANSFORMATION_MODE_SEMANTIC + transformation_spec: dict = field(default_factory=dict) + validation_profile: str = ANALYSIS_VALIDATION_PROFILE_ARTIFACT_SET + publication_policy: str = ANALYSIS_PUBLICATION_POLICY_NO_REQUIRED_ARTIFACTS + source_fingerprint: str = "" + request_fingerprint: str = "" + + def to_dict(self): + payload = asdict(self) + payload["requested_artifacts"] = [ + artifact.to_dict() if isinstance(artifact, AnalysisDeliverableArtifact) else dict(artifact) + for artifact in self.requested_artifacts + ] + payload["public_output_schema"] = list(self.public_output_schema) + payload["internal_checkpoint_schema"] = list(self.internal_checkpoint_schema) + payload["lineage_schema"] = list(self.lineage_schema) + payload["transformation_spec"] = dict(self.transformation_spec or {}) + return payload + + +@dataclass(frozen=True) +class AnalysisDeliverableValidationReport: + """Safe validator result with counts and reason codes only.""" + + valid: bool + reason_codes: tuple = field(default_factory=tuple) + counts: dict = field(default_factory=dict) + + def to_dict(self): + return { + "valid": self.valid, + "reason_codes": list(self.reason_codes), + "counts": dict(self.counts), + } + + +def _safe_bool(value): + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "yes", "on"} + return bool(value) + + +def _safe_int(value, default=0, minimum=None, maximum=None): + try: + parsed_value = int(value) + except (TypeError, ValueError): + parsed_value = default + if minimum is not None: + parsed_value = max(minimum, parsed_value) + if maximum is not None: + parsed_value = min(maximum, parsed_value) + return parsed_value + + +def _normalize_reason_code(value): + normalized_value = re.sub(r"[^a-z0-9_]+", "_", str(value or "").strip().lower()) + normalized_value = re.sub(r"_+", "_", normalized_value).strip("_") + return normalized_value[:80] or "unknown" + + +def _normalize_dimension_value(value): + dimension_text = str(value or "").strip() + if re.fullmatch(r"[A-Za-z0-9_-]{1,80}", dimension_text): + return _normalize_reason_code(dimension_text) + first_token = re.split(r"[:/\\\s]+", dimension_text, maxsplit=1)[0] + return _normalize_reason_code(first_token) + + +def _normalize_action_mode(action_mode): + normalized_mode = str(action_mode or "").strip().lower() + if normalized_mode in ANALYSIS_DELIVERABLE_ACTION_MODES: + return normalized_mode + return ANALYSIS_DELIVERABLE_ACTION_UNKNOWN + + +def normalize_analysis_artifact_role(role): + """Return a supported artifact role or raise for unknown roles.""" + normalized_role = str(role or "").strip().lower() + if normalized_role not in ANALYSIS_ARTIFACT_ROLES: + raise ValueError(f"Unsupported analysis artifact role: {role}") + return normalized_role + + +def _normalize_artifact_format(artifact_format): + normalized_format = str(artifact_format or "").strip().lower().lstrip(".") + if normalized_format not in ANALYSIS_DELIVERABLE_FORMATS: + raise ValueError(f"Unsupported analysis artifact format: {artifact_format}") + return normalized_format + + +def _normalize_bounded_mode(value, allowed_values, default_value, label): + normalized_value = str(value or default_value).strip().lower() + if normalized_value not in allowed_values: + raise ValueError(f"Unsupported {label}: {value}") + return normalized_value + + +def _normalize_artifact_id(artifact_id, role, artifact_format, request_order): + normalized_id = str(artifact_id or "").strip() + if not normalized_id: + normalized_id = f"{role}-{artifact_format}-{_safe_int(request_order, minimum=0)}" + normalized_id = re.sub(r"[^A-Za-z0-9_.:-]+", "-", normalized_id).strip("-") + if not normalized_id: + raise ValueError("Analysis artifact id is empty") + if len(normalized_id) > ANALYSIS_DELIVERABLE_MAX_ARTIFACT_ID_LENGTH: + raise ValueError("Analysis artifact id exceeds the bounded metadata limit") + return normalized_id + + +def _normalize_public_output_schema(public_output_schema=None): + normalized_schema = [] + seen_fields = set() + for field_name in list(public_output_schema or []): + normalized_field = str(field_name or "").strip() + if not normalized_field: + raise ValueError("Analysis public output schema contains an empty field name") + if len(normalized_field) > ANALYSIS_DELIVERABLE_MAX_FIELD_NAME_LENGTH: + raise ValueError("Analysis public output schema field exceeds the bounded metadata limit") + if _is_internal_lineage_field(normalized_field): + raise ValueError("Analysis public output schema cannot include reserved internal fields") + if normalized_field in seen_fields: + raise ValueError("Analysis public output schema contains duplicate fields") + seen_fields.add(normalized_field) + normalized_schema.append(normalized_field) + if len(normalized_schema) > ANALYSIS_DELIVERABLE_MAX_SCHEMA_FIELDS: + raise ValueError("Analysis public output schema exceeds the bounded metadata limit") + return tuple(normalized_schema) + + +def _normalize_lineage_schema(lineage_schema=None): + normalized_schema = [] + seen_fields = set() + raw_schema = list(lineage_schema or ANALYSIS_DEFAULT_LINEAGE_SCHEMA) + for field_name in raw_schema: + normalized_field = str(field_name or "").strip() + if not normalized_field: + raise ValueError("Analysis lineage schema contains an empty field name") + if len(normalized_field) > ANALYSIS_DELIVERABLE_MAX_FIELD_NAME_LENGTH: + raise ValueError("Analysis lineage schema field exceeds the bounded metadata limit") + if not _is_internal_lineage_field(normalized_field): + raise ValueError("Analysis lineage schema can only include reserved internal fields") + if normalized_field in seen_fields: + raise ValueError("Analysis lineage schema contains duplicate fields") + seen_fields.add(normalized_field) + normalized_schema.append(normalized_field) + return tuple(normalized_schema) + + +def _normalize_internal_checkpoint_schema( + public_output_schema=None, + internal_checkpoint_schema=None, + lineage_schema=None, +): + normalized_public_schema = tuple(public_output_schema or []) + normalized_lineage_schema = _normalize_lineage_schema(lineage_schema) + if internal_checkpoint_schema is None: + return tuple(list(normalized_lineage_schema) + list(normalized_public_schema)) + + normalized_schema = [] + seen_fields = set() + for field_name in list(internal_checkpoint_schema or []): + normalized_field = str(field_name or "").strip() + if not normalized_field: + raise ValueError("Analysis internal checkpoint schema contains an empty field name") + if len(normalized_field) > ANALYSIS_DELIVERABLE_MAX_FIELD_NAME_LENGTH: + raise ValueError("Analysis internal checkpoint schema field exceeds the bounded metadata limit") + if normalized_field in seen_fields: + raise ValueError("Analysis internal checkpoint schema contains duplicate fields") + seen_fields.add(normalized_field) + normalized_schema.append(normalized_field) + + expected_schema = tuple(list(normalized_lineage_schema) + list(normalized_public_schema)) + if tuple(normalized_schema) != expected_schema: + raise ValueError("Analysis internal checkpoint schema must be lineage fields followed by public fields") + return tuple(normalized_schema) + + +def build_analysis_deliverable_artifact( + artifact_id, + role, + artifact_format, + required=True, + request_order=0, +): + """Build one bounded artifact descriptor.""" + normalized_role = normalize_analysis_artifact_role(role) + normalized_format = _normalize_artifact_format(artifact_format) + normalized_order = _safe_int(request_order, default=0, minimum=0) + return AnalysisDeliverableArtifact( + artifact_id=_normalize_artifact_id( + artifact_id, + normalized_role, + normalized_format, + normalized_order, + ), + role=normalized_role, + format=normalized_format, + required=bool(required), + request_order=normalized_order, + ) + + +def _coerce_artifact_descriptor(artifact): + if isinstance(artifact, AnalysisDeliverableArtifact): + return artifact + artifact_payload = dict(artifact or {}) if isinstance(artifact, Mapping) else {} + return build_analysis_deliverable_artifact( + artifact_payload.get("artifact_id"), + artifact_payload.get("role"), + artifact_payload.get("format"), + required=artifact_payload.get("required", True), + request_order=artifact_payload.get("request_order", 0), + ) + + +def _normalize_artifact_descriptors(artifacts=None): + descriptors = tuple(_coerce_artifact_descriptor(artifact) for artifact in list(artifacts or [])) + if len(descriptors) > ANALYSIS_DELIVERABLE_MAX_ARTIFACTS: + raise ValueError("Analysis deliverable artifact count exceeds the bounded metadata limit") + artifact_ids = [artifact.artifact_id for artifact in descriptors] + if len(set(artifact_ids)) != len(artifact_ids): + raise ValueError("Analysis deliverable artifact ids must be unique") + return descriptors + + +def _fingerprint_payload(payload): + serialized_payload = json.dumps(payload or {}, sort_keys=True, separators=(",", ":"), default=str) + return hashlib.sha256(serialized_payload.encode("utf-8")).hexdigest() + + +def build_analysis_deliverable_contract( + action_mode=None, + requested_output_format=None, + requested_output_formats=None, + requested_artifacts=None, + public_output_schema=None, + internal_checkpoint_schema=None, + lineage_schema=None, + row_cardinality=None, + ordering=None, + transformation_mode=None, + transformation_spec=None, + validation_profile=None, + publication_policy=None, + analysis_required=None, + primary_artifact_role=None, + source_fingerprint="", + request_fingerprint="", +): + """Build a versioned, JSON-serializable deliverable plan.""" + normalized_action = _normalize_action_mode(action_mode) + normalized_analysis_required = _safe_bool( + normalized_action == ANALYSIS_DELIVERABLE_ACTION_ANALYZE + if analysis_required is None + else analysis_required + ) + normalized_schema = _normalize_public_output_schema(public_output_schema) + normalized_lineage_schema = _normalize_lineage_schema(lineage_schema) + normalized_internal_checkpoint_schema = _normalize_internal_checkpoint_schema( + public_output_schema=normalized_schema, + internal_checkpoint_schema=internal_checkpoint_schema, + lineage_schema=normalized_lineage_schema, + ) + + if requested_artifacts is None: + artifact_descriptors = [] + request_order = 0 + output_formats = list(requested_output_formats or []) + if requested_output_format and not output_formats: + output_formats = [requested_output_format] + if normalized_analysis_required: + artifact_descriptors.append(build_analysis_deliverable_artifact( + "analysis", + ANALYSIS_ARTIFACT_ROLE_PRIMARY_ANALYSIS, + "md", + required=True, + request_order=request_order, + )) + request_order += 1 + seen_output_formats = set() + for output_format in output_formats: + normalized_output_format = _normalize_artifact_format(output_format) + if normalized_analysis_required and normalized_output_format == "md": + continue + if normalized_output_format in seen_output_formats: + continue + seen_output_formats.add(normalized_output_format) + artifact_descriptors.append(build_analysis_deliverable_artifact( + f"requested-{normalized_output_format}", + ANALYSIS_ARTIFACT_ROLE_REQUESTED_OUTPUT, + normalized_output_format, + required=True, + request_order=request_order, + )) + request_order += 1 + else: + artifact_descriptors = list(requested_artifacts or []) + + normalized_artifacts = _normalize_artifact_descriptors(artifact_descriptors) + normalized_primary_role = str(primary_artifact_role or "").strip().lower() + if normalized_analysis_required: + normalized_primary_role = normalize_analysis_artifact_role( + normalized_primary_role or ANALYSIS_ARTIFACT_ROLE_PRIMARY_ANALYSIS + ) + elif normalized_primary_role: + normalized_primary_role = normalize_analysis_artifact_role(normalized_primary_role) + + normalized_row_cardinality = _normalize_bounded_mode( + row_cardinality, + ANALYSIS_ROW_CARDINALITIES, + ANALYSIS_ROW_CARDINALITY_NOT_APPLICABLE, + "row cardinality", + ) + normalized_ordering = _normalize_bounded_mode( + ordering, + ANALYSIS_ORDERINGS, + ANALYSIS_ORDERING_NOT_APPLICABLE, + "ordering", + ) + normalized_transformation_mode = _normalize_bounded_mode( + transformation_mode, + ANALYSIS_TRANSFORMATION_MODES, + ANALYSIS_TRANSFORMATION_MODE_SEMANTIC, + "transformation mode", + ) + normalized_transformation_spec = normalize_tabular_transformation_spec( + transformation_spec, + public_output_schema=normalized_schema, + ) + normalized_validation_profile = _normalize_bounded_mode( + validation_profile, + ANALYSIS_VALIDATION_PROFILES, + ANALYSIS_VALIDATION_PROFILE_ARTIFACT_SET, + "validation profile", + ) + normalized_publication_policy = _normalize_bounded_mode( + publication_policy, + ANALYSIS_PUBLICATION_POLICIES, + ( + ANALYSIS_PUBLICATION_POLICY_ALL_REQUIRED_ARTIFACTS + if any(artifact.required for artifact in normalized_artifacts) + else ANALYSIS_PUBLICATION_POLICY_NO_REQUIRED_ARTIFACTS + ), + "publication policy", + ) + + contract = AnalysisDeliverableContract( + action_mode=normalized_action, + analysis_required=normalized_analysis_required, + primary_artifact_role=normalized_primary_role, + requested_artifacts=normalized_artifacts, + public_output_schema=normalized_schema, + internal_checkpoint_schema=normalized_internal_checkpoint_schema, + lineage_schema=normalized_lineage_schema, + row_cardinality=normalized_row_cardinality, + ordering=normalized_ordering, + transformation_mode=normalized_transformation_mode, + transformation_spec=normalized_transformation_spec, + validation_profile=normalized_validation_profile, + publication_policy=normalized_publication_policy, + source_fingerprint=str(source_fingerprint or "").strip()[:64], + request_fingerprint=str(request_fingerprint or "").strip()[:64], + ) + serialized_contract = json.dumps(contract.to_dict(), sort_keys=True, separators=(",", ":")) + if len(serialized_contract.encode("utf-8")) > ANALYSIS_DELIVERABLE_MAX_METADATA_BYTES: + raise ValueError("Analysis deliverable contract exceeds the bounded metadata limit") + return contract + + +def coerce_analysis_deliverable_contract(contract): + """Load a persisted contract and ignore unknown additive fields.""" + if isinstance(contract, AnalysisDeliverableContract): + return contract + payload = dict(contract or {}) if isinstance(contract, Mapping) else {} + if payload.get("contract_version") not in { + None, + "", + ANALYSIS_DELIVERABLE_CONTRACT_VERSION, + *ANALYSIS_DELIVERABLE_LEGACY_CONTRACT_VERSIONS, + }: + raise ValueError("Unsupported analysis deliverable contract version") + return build_analysis_deliverable_contract( + action_mode=payload.get("action_mode"), + requested_artifacts=payload.get("requested_artifacts") or [], + public_output_schema=payload.get("public_output_schema") or [], + internal_checkpoint_schema=payload.get("internal_checkpoint_schema"), + lineage_schema=payload.get("lineage_schema"), + row_cardinality=payload.get("row_cardinality"), + ordering=payload.get("ordering"), + transformation_mode=payload.get("transformation_mode"), + transformation_spec=payload.get("transformation_spec"), + validation_profile=payload.get("validation_profile"), + publication_policy=payload.get("publication_policy"), + analysis_required=payload.get("analysis_required", False), + primary_artifact_role=payload.get("primary_artifact_role"), + source_fingerprint=payload.get("source_fingerprint", ""), + request_fingerprint=payload.get("request_fingerprint", ""), + ) + + +def _build_report(reason_codes=None, counts=None): + normalized_reasons = tuple(sorted({_normalize_reason_code(reason) for reason in list(reason_codes or []) if reason})) + normalized_counts = { + _normalize_reason_code(key): _safe_int(value, default=0, minimum=0) + for key, value in dict(counts or {}).items() + } + return AnalysisDeliverableValidationReport( + valid=not normalized_reasons, + reason_codes=normalized_reasons, + counts=normalized_counts, + ) + + +def _actual_artifact_key(artifact): + artifact_id = str((artifact or {}).get("artifact_id") or "").strip() + role = str((artifact or {}).get("role") or "").strip().lower() + artifact_format = str((artifact or {}).get("format") or (artifact or {}).get("output_format") or "").strip().lower() + if artifact_id: + return artifact_id + return f"{role}:{artifact_format}" + + +def _expected_artifact_key(artifact): + if isinstance(artifact, AnalysisDeliverableArtifact): + return artifact.artifact_id + return _actual_artifact_key(artifact) + + +def _artifact_is_completed(artifact): + if _safe_bool((artifact or {}).get("valid", False)): + return True + status = str((artifact or {}).get("status") or "").strip().lower() + return status in {"completed", "published", "valid"} + + +def validate_analysis_artifact_set(contract, artifacts=None): + """Validate that an artifact set satisfies a deliverable contract.""" + normalized_contract = coerce_analysis_deliverable_contract(contract) + expected_artifacts = list(normalized_contract.requested_artifacts) + actual_artifacts = [dict(artifact or {}) for artifact in list(artifacts or []) if isinstance(artifact, Mapping)] + expected_by_key = {_expected_artifact_key(artifact): artifact for artifact in expected_artifacts} + actual_by_key = {_actual_artifact_key(artifact): artifact for artifact in actual_artifacts} + reason_codes = [] + counts = { + "required_artifact_count": sum(1 for artifact in expected_artifacts if artifact.required), + "expected_artifact_count": len(expected_artifacts), + "actual_artifact_count": len(actual_artifacts), + "missing_artifact_count": 0, + "invalid_required_artifact_count": 0, + "extra_artifact_count": 0, + "artifact_role_mismatch_count": 0, + "artifact_format_mismatch_count": 0, + "required_artifact_completion_count": 0, + "primary_artifact_count": 0, + } + + for expected_key, expected_artifact in expected_by_key.items(): + actual_artifact = actual_by_key.get(expected_key) + if expected_artifact.required and actual_artifact is None: + counts["missing_artifact_count"] += 1 + reason_codes.append("missing_required_artifact") + continue + if expected_artifact.required and actual_artifact is not None and not _artifact_is_completed(actual_artifact): + counts["invalid_required_artifact_count"] += 1 + reason_codes.append("required_artifact_not_valid") + if expected_artifact.required and actual_artifact is not None and _artifact_is_completed(actual_artifact): + counts["required_artifact_completion_count"] += 1 + if actual_artifact is not None: + actual_role = str(actual_artifact.get("role") or "").strip().lower() + actual_format = str( + actual_artifact.get("format") or actual_artifact.get("output_format") or "" + ).strip().lower().lstrip(".") + if actual_role and actual_role != expected_artifact.role: + counts["artifact_role_mismatch_count"] += 1 + reason_codes.append("artifact_role_mismatch") + if actual_format and actual_format != expected_artifact.format: + counts["artifact_format_mismatch_count"] += 1 + reason_codes.append("artifact_format_mismatch") + + for actual_key in actual_by_key: + if actual_key not in expected_by_key: + counts["extra_artifact_count"] += 1 + reason_codes.append("extra_artifact") + + if normalized_contract.primary_artifact_role: + primary_role = normalized_contract.primary_artifact_role + counts["primary_artifact_count"] = sum( + 1 + for artifact in actual_artifacts + if str(artifact.get("role") or "").strip().lower() == primary_role + ) + if counts["primary_artifact_count"] != 1: + reason_codes.append("wrong_primary_artifact_role") + + return _build_report(reason_codes, counts) + + +def _is_internal_lineage_field(field_name): + normalized_field = str(field_name or "").strip() + return ( + normalized_field in ANALYSIS_INTERNAL_LINEAGE_FIELD_NAMES + or normalized_field.startswith(ANALYSIS_INTERNAL_LINEAGE_FIELD_PREFIX) + ) + + +def is_analysis_internal_lineage_field(field_name): + """Return whether a field name is reserved for server lineage metadata.""" + return _is_internal_lineage_field(field_name) + + +def project_structured_deliverable_row(row, public_output_schema, require_all_fields=True): + """Project one internal generated row to the persisted public schema.""" + if not isinstance(row, Mapping): + raise ValueError("Structured deliverable row must be an object") + normalized_schema = _normalize_public_output_schema(public_output_schema) + if not normalized_schema: + raise ValueError("Structured deliverable projection requires a public output schema") + if require_all_fields: + missing_fields = [field_name for field_name in normalized_schema if field_name not in row] + if missing_fields: + raise ValueError("Structured deliverable row is missing required public fields") + return {field_name: row.get(field_name) for field_name in normalized_schema} + + +def project_structured_deliverable_rows(rows, public_output_schema, require_all_fields=True): + """Project internal generated rows to the exact user-facing schema and order.""" + return [ + project_structured_deliverable_row( + row, + public_output_schema, + require_all_fields=require_all_fields, + ) + for row in list(rows or []) + ] + + +def _row_identity(row, identity_field): + if not identity_field or not isinstance(row, Mapping): + return None + return str(row.get(identity_field) or "").strip() + + +def _field_sequence(rows): + for row in rows: + if isinstance(row, Mapping): + return list(row.keys()) + return [] + + +def validate_structured_deliverable_rows( + contract, + output_rows=None, + source_rows=None, + expected_rows=None, + identity_field=None, +): + """Validate public structured rows without logging row values.""" + normalized_contract = coerce_analysis_deliverable_contract(contract) + rows = list(output_rows or []) + sources = list(source_rows or []) + expected = list(expected_rows or []) + expected_schema = list(normalized_contract.public_output_schema) + actual_schema = _field_sequence(rows) + reason_codes = [] + counts = { + "output_row_count": len(rows), + "source_row_count": len(sources), + "expected_row_count": len(expected), + "public_schema_field_count": len(expected_schema), + "actual_schema_field_count": len(actual_schema), + "missing_field_count": 0, + "extra_field_count": 0, + "extra_internal_field_count": 0, + "row_schema_mismatch_count": 0, + "duplicate_row_identity_count": 0, + "deterministic_mismatch_count": 0, + "deterministic_mismatched_row_count": 0, + } + + non_object_count = sum(1 for row in rows if not isinstance(row, Mapping)) + if non_object_count: + counts["row_not_object_count"] = non_object_count + reason_codes.append("row_not_object") + + if expected_schema: + expected_field_set = set(expected_schema) + actual_field_set = set(actual_schema) + missing_fields = expected_field_set - actual_field_set + extra_fields = actual_field_set - expected_field_set + counts["missing_field_count"] = len(missing_fields) + counts["extra_field_count"] = len(extra_fields) + counts["extra_internal_field_count"] = len([ + field_name for field_name in extra_fields if _is_internal_lineage_field(field_name) + ]) + if missing_fields or extra_fields: + reason_codes.append("schema_mismatch") + elif actual_schema != expected_schema: + reason_codes.append("schema_order_mismatch") + if counts["extra_internal_field_count"]: + reason_codes.append("extra_internal_fields") + + for row in rows: + if not isinstance(row, Mapping): + continue + if set(row.keys()) != expected_field_set: + counts["row_schema_mismatch_count"] += 1 + if counts["row_schema_mismatch_count"]: + reason_codes.append("row_schema_mismatch") + + if ( + normalized_contract.row_cardinality == ANALYSIS_ROW_CARDINALITY_ONE_PER_SOURCE_ROW + and sources + and len(rows) != len(sources) + ): + reason_codes.append("row_count_mismatch") + + if identity_field and sources and rows: + source_identities = [_row_identity(row, identity_field) for row in sources] + output_identities = [_row_identity(row, identity_field) for row in rows] + output_identity_set = set(output_identities) + counts["duplicate_row_identity_count"] = len(output_identities) - len(output_identity_set) + if counts["duplicate_row_identity_count"]: + reason_codes.append("duplicate_row_identity") + if set(source_identities) != output_identity_set: + reason_codes.append("row_identity_mismatch") + elif normalized_contract.ordering == ANALYSIS_ORDERING_SOURCE_ORDER and source_identities != output_identities: + reason_codes.append("row_order_mismatch") + + if expected: + if len(rows) != len(expected): + reason_codes.append("deterministic_row_count_mismatch") + deterministic_fields = expected_schema or _field_sequence(expected) + mismatched_rows = 0 + mismatch_count = 0 + for row, expected_row in zip(rows, expected): + if not isinstance(row, Mapping) or not isinstance(expected_row, Mapping): + continue + row_mismatch_found = False + for field_name in deterministic_fields: + if row.get(field_name) != expected_row.get(field_name): + mismatch_count += 1 + row_mismatch_found = True + if row_mismatch_found: + mismatched_rows += 1 + counts["deterministic_mismatch_count"] = mismatch_count + counts["deterministic_mismatched_row_count"] = mismatched_rows + if mismatch_count: + reason_codes.append("deterministic_value_mismatch") + + return _build_report(reason_codes, counts) + + +def is_analysis_deliverable_telemetry_enabled(settings): + """Return whether Phase 1 deliverable-contract shadow telemetry is enabled.""" + normalized_settings = settings if isinstance(settings, Mapping) else {} + contract_mode = str(normalized_settings.get("analysis_deliverable_contract_mode") or "off").strip().lower() + if contract_mode not in ANALYSIS_DELIVERABLE_CONTRACT_MODES: + contract_mode = "off" + return ( + _safe_bool(normalized_settings.get("enable_analysis_deliverable_contract_telemetry", False)) + and contract_mode in ANALYSIS_DELIVERABLE_CONTRACT_OBSERVATION_MODES + ) + + +def build_safe_analysis_deliverable_event_properties( + event_name, + contract=None, + validation_report=None, + metrics=None, + dimensions=None, +): + """Build telemetry dimensions that exclude prompts, row values, and storage locations.""" + normalized_contract = coerce_analysis_deliverable_contract(contract) if contract else None + contract_payload = normalized_contract.to_dict() if normalized_contract else {} + report_payload = ( + validation_report.to_dict() + if isinstance(validation_report, AnalysisDeliverableValidationReport) + else dict(validation_report or {}) + ) + report_counts = dict(report_payload.get("counts") or {}) + metric_payload = dict(metrics or {}) + required_completion_count = report_counts.get("required_artifact_completion_count") + if required_completion_count is None: + required_completion_count = metric_payload.get("required_artifact_completion_count") + requested_artifacts = list(contract_payload.get("requested_artifacts") or []) + requested_formats = sorted({ + str(artifact.get("format") or "").strip().lower() + for artifact in requested_artifacts + if str(artifact.get("format") or "").strip() + }) + public_schema = list(contract_payload.get("public_output_schema") or []) + properties = { + "event_name": event_name if event_name in ANALYSIS_DELIVERABLE_EVENT_NAMES else "unknown", + "contract_version": str(contract_payload.get("contract_version") or "")[:64], + "action_mode": _normalize_action_mode(contract_payload.get("action_mode")), + "analysis_required": bool(contract_payload.get("analysis_required")), + "requested_artifact_count": len(requested_artifacts), + "required_artifact_count": sum(1 for artifact in requested_artifacts if bool(artifact.get("required"))), + "requested_artifact_formats": ",".join(requested_formats)[:80], + "public_schema_field_count": len(public_schema), + "public_schema_internal_field_count": len([ + field_name for field_name in public_schema if _is_internal_lineage_field(field_name) + ]), + "row_cardinality": str(contract_payload.get("row_cardinality") or "")[:64], + "ordering": str(contract_payload.get("ordering") or "")[:64], + "transformation_mode": str(contract_payload.get("transformation_mode") or "")[:64], + "validation_profile": str(contract_payload.get("validation_profile") or "")[:64], + "publication_policy": str(contract_payload.get("publication_policy") or "")[:64], + "primary_artifact_role": str(contract_payload.get("primary_artifact_role") or "")[:64], + "source_fingerprint": str(contract_payload.get("source_fingerprint") or "").strip()[:24], + "request_fingerprint": str(contract_payload.get("request_fingerprint") or "").strip()[:24], + "validation_valid": bool(report_payload.get("valid", True)), + "validation_reason_count": len(report_payload.get("reason_codes") or []), + "structural_mismatch_count": _safe_int(report_counts.get("row_schema_mismatch_count")) + + _safe_int(report_counts.get("missing_field_count")) + + _safe_int(report_counts.get("extra_field_count")), + "extra_internal_field_count": _safe_int(report_counts.get("extra_internal_field_count")), + "deterministic_mismatch_count": _safe_int(report_counts.get("deterministic_mismatch_count")), + "required_artifact_completion_count": _safe_int(required_completion_count), + } + for key, value in metric_payload.items(): + properties[f"metric_{_normalize_reason_code(key)}"] = _safe_int(value) + for key, value in dict(dimensions or {}).items(): + safe_key = _normalize_reason_code(key) + if isinstance(value, bool): + properties[f"dimension_{safe_key}"] = value + elif isinstance(value, (int, float)): + properties[f"dimension_{safe_key}"] = value + else: + properties[f"dimension_{safe_key}"] = _normalize_dimension_value(value) + return properties + + +def emit_analysis_deliverable_contract_event( + settings, + event_name, + contract=None, + validation_report=None, + metrics=None, + dimensions=None, + level=logging.INFO, +): + """Emit a gated shadow deliverable-contract event.""" + if not is_analysis_deliverable_telemetry_enabled(settings): + return None + properties = build_safe_analysis_deliverable_event_properties( + event_name, + contract=contract, + validation_report=validation_report, + metrics=metrics, + dimensions=dimensions, + ) + log_event( + "[ANALYSIS_DELIVERABLE_CONTRACT] Analysis deliverable contract observation event.", + properties, + level=level, + debug_only=True, + ) + return properties diff --git a/application/single_app/functions_data_management.py b/application/single_app/functions_data_management.py index 6bce1d453..21f3ff89f 100644 --- a/application/single_app/functions_data_management.py +++ b/application/single_app/functions_data_management.py @@ -40,6 +40,7 @@ from azure.search.documents.indexes.models import SearchField, SearchFieldDataType, SearchIndex from azure.storage.blob import BlobBlock, BlobServiceClient, ContentSettings from cryptography.fernet import Fernet, InvalidToken +from flask import has_request_context import config as app_config from config import ( @@ -18892,14 +18893,14 @@ def process_data_management_job(job_id): def submit_data_management_job(app, job_id): executor = app.extensions.get("executor") if app else None - if executor and hasattr(executor, "submit_stored"): + if executor and has_request_context() and hasattr(executor, "submit_stored"): executor.submit_stored( f"data_management_{job_id}", process_data_management_job, job_id=job_id, ) return True - if executor and hasattr(executor, "submit"): + if executor and has_request_context() and hasattr(executor, "submit"): executor.submit(process_data_management_job, job_id) return True worker_thread = Thread( diff --git a/application/single_app/functions_generated_file_exports.py b/application/single_app/functions_generated_file_exports.py index 8ec36bb06..edd46aa24 100644 --- a/application/single_app/functions_generated_file_exports.py +++ b/application/single_app/functions_generated_file_exports.py @@ -32,6 +32,7 @@ } SUPPORTED_GENERATED_EXPORT_FORMATS = {'csv', 'json', 'xml'} GENERATED_FILE_PREVIEW_ROWS = 3 +REQUESTED_ARTIFACT_FORMATS = ('csv', 'json', 'xml', 'md', 'docx', 'pdf') STRUCTURED_ARTIFACT_FORMAT_MARKERS = { 'json': ( 'json artifact', @@ -165,37 +166,84 @@ th, td { border: 0.6pt solid #aab7c4; padding: 4pt; vertical-align: top; } """ +MARKDOWN_OUTPUT_REQUEST_PATTERNS = ( + re.compile( + r'\b(?:build|create|download|export|generate|make|prepare|save|write)\b' + r'.{0,120}\b(?:markdown|md)(?:\s+(?:analysis|artifact|document|file|output|report))?\b' + ), + re.compile(r'\b(?:markdown|md)\s+(?:analysis|artifact|document|file|output|report|version)\b'), + re.compile(r'\b(?:in|as)\s+(?:a\s+)?(?:markdown|md)(?:\s+(?:document|file|report))?\b'), +) -def get_requested_generated_file_format(user_question: str) -> Optional[str]: - """Return the requested generated file format, if any.""" - if assistant_table_export_requested(user_question): - return GENERATED_FILE_FORMAT_CSV +PASSTHROUGH_DERIVED_OUTPUT_PATTERNS = ( + re.compile(r'\b(?:derive|derived|classify|classification|categorize|category|calculate|computed?|map|mapping)\b'), + re.compile(r'\b(?:score|rank|judge|evaluate|determine|flag|label|extract|populate|fill)\b'), + re.compile(r'\b(?:analy[sz]e|summari[sz]e)\b'), + re.compile(r'\b(?:exactly|only)\s+(?:these\s+)?(?:fields|columns)\b'), + re.compile(r'\b(?:output|requested|derived)\s+(?:fields|columns|schema)\b'), + re.compile(r'\bone\s+output\s+row\s+(?:for|per)\s+(?:each|every|source)\s+row\b'), +) +PASSTHROUGH_COPY_PATTERNS = ( + re.compile(r'\b(?:unchanged|as-is|as\s+is|verbatim|raw|original)\s+(?:copy|rows?|data|table|result|results)\b'), + re.compile(r'\b(?:copy|export|download|save)\b[\w\s,.:;\-/]{0,100}\b(?:unchanged|as-is|as\s+is|verbatim|raw|original|source)\b'), +) +PASSTHROUGH_SERIALIZE_PATTERNS = ( + re.compile(r'\b(?:build|create|download|export|format|generate|make|prepare|save|serialize|convert)\b[\w\s,.:;\-/]{0,100}\b(?:csv|json|xml|docx|word|pdf|spreadsheet)\b'), + re.compile(r'\b(?:csv|json|xml|docx|word|pdf|spreadsheet)\b[\w\s,.:;\-/]{0,100}\b(?:export|download|file|format|copy)\b'), +) - normalized_question = re.sub(r'\s+', ' ', str(user_question or '').strip().casefold()) - if not normalized_question: - return None - if any(pattern.search(normalized_question) for pattern in DOCX_OUTPUT_REQUEST_PATTERNS): - return GENERATED_FILE_FORMAT_DOCX - if any(pattern.search(normalized_question) for pattern in PDF_OUTPUT_REQUEST_PATTERNS): - return GENERATED_FILE_FORMAT_PDF - return None +def _normalize_question_for_artifact_detection(user_question: str) -> str: + return re.sub(r'\s+', ' ', str(user_question or '').strip().casefold()) -def get_requested_structured_artifact_format(user_question: str) -> Optional[str]: - """Return a requested CSV, JSON, or XML artifact target without resolving source orchestration.""" - normalized_question = re.sub(r'\s+', ' ', str(user_question or '').strip().casefold()) - if not normalized_question: + +def _iter_request_clauses(normalized_question: str): + for match in re.finditer(r'[^.!?;\n]+', normalized_question): + clause = match.group(0).strip() + if clause: + leading_offset = len(match.group(0)) - len(match.group(0).lstrip()) + yield clause, match.start() + leading_offset + + +def _first_pattern_position(normalized_question: str, patterns) -> Optional[int]: + positions = [match.start() for pattern in patterns for match in [pattern.search(normalized_question)] if match] + return min(positions) if positions else None + + +def _format_aliases(output_format: str) -> Tuple[str, ...]: + aliases = { + 'docx': ('docx', 'word'), + 'md': ('md', 'markdown'), + } + return aliases.get(output_format, (output_format,)) + + +def _clause_negates_output_format(clause: str, output_format: str) -> bool: + return any( + _structured_artifact_format_is_negated(clause, format_alias) + for format_alias in _format_aliases(output_format) + ) + + +def _first_csv_artifact_position(user_question: str, normalized_question: str) -> Optional[int]: + if not assistant_table_export_requested(user_question): return None + for clause, clause_offset in _iter_request_clauses(normalized_question): + if _clause_negates_output_format(clause, 'csv'): + continue + for marker in ('csv', 'spreadsheet'): + marker_position = clause.find(marker) + if marker_position >= 0: + return clause_offset + marker_position + return 0 - clauses = [ - clause.strip() - for clause in re.split(r'[.!?;\n]+', normalized_question) - if clause.strip() - ] - destination_matches = [] - for clause_index, clause in enumerate(clauses): + +def _collect_structured_artifact_format_matches(normalized_question: str) -> List[Tuple[int, str]]: + matches = [] + destination_formats_by_clause_offset = {} + for clause, clause_offset in _iter_request_clauses(normalized_question): for output_format in ('json', 'xml'): - if output_format not in clause or _structured_artifact_format_is_negated(clause, output_format): + if output_format not in clause or _clause_negates_output_format(clause, output_format): continue destination_match = re.search( rf'\b(?:{STRUCTURED_ARTIFACT_DESTINATION_ACTION_PATTERN})\b' @@ -205,25 +253,104 @@ def get_requested_structured_artifact_format(user_question: str) -> Optional[str clause, ) if destination_match: - destination_matches.append((clause_index, destination_match.start(), output_format)) - if destination_matches: - return min(destination_matches)[2] + format_position = clause.find(output_format, destination_match.start()) + matches.append((clause_offset + (format_position if format_position >= 0 else destination_match.start()), output_format)) + destination_formats_by_clause_offset.setdefault(clause_offset, set()).add(output_format) - for output_format in ('json', 'xml'): - for clause in clauses: - if output_format not in clause or _structured_artifact_format_is_negated(clause, output_format): + for clause, clause_offset in _iter_request_clauses(normalized_question): + destination_formats = destination_formats_by_clause_offset.get(clause_offset, set()) + for output_format in ('json', 'xml'): + if output_format not in clause or _clause_negates_output_format(clause, output_format): + continue + if destination_formats and output_format not in destination_formats: + continue + marker_positions = [ + clause.find(marker) + for marker in STRUCTURED_ARTIFACT_FORMAT_MARKERS[output_format] + if marker in clause + ] + if marker_positions: + matches.append((clause_offset + min(marker_positions), output_format)) continue - if any(marker in clause for marker in STRUCTURED_ARTIFACT_FORMAT_MARKERS[output_format]): - return output_format - if re.search( + generic_match = re.search( rf'\b(?:{STRUCTURED_ARTIFACT_ACTION_PATTERN})\b' rf'[\w\s.,:;\-/]{{0,80}}\b(?:an?\s+)?{output_format}\b', clause, - ): - return output_format - if assistant_table_export_requested(user_question): - return GENERATED_FILE_FORMAT_CSV - return None + ) + if generic_match: + format_position = clause.find(output_format, generic_match.start()) + matches.append((clause_offset + (format_position if format_position >= 0 else generic_match.start()), output_format)) + return matches + + +def get_requested_artifact_formats(user_question: str) -> List[str]: + """Return explicitly requested artifact formats in user-request order.""" + normalized_question = _normalize_question_for_artifact_detection(user_question) + if not normalized_question: + return [] + + matches = [] + csv_position = _first_csv_artifact_position(user_question, normalized_question) + if csv_position is not None: + matches.append((csv_position, 'csv')) + matches.extend(_collect_structured_artifact_format_matches(normalized_question)) + + format_pattern_sets = { + 'md': MARKDOWN_OUTPUT_REQUEST_PATTERNS, + 'docx': DOCX_OUTPUT_REQUEST_PATTERNS, + 'pdf': PDF_OUTPUT_REQUEST_PATTERNS, + } + for output_format, patterns in format_pattern_sets.items(): + position = _first_pattern_position(normalized_question, patterns) + if position is None: + continue + containing_clause = next( + ( + clause + for clause, clause_offset in _iter_request_clauses(normalized_question) + if clause_offset <= position < clause_offset + len(clause) + ), + normalized_question, + ) + if _clause_negates_output_format(containing_clause, output_format): + continue + matches.append((position, output_format)) + + ordered_formats = [] + for _, output_format in sorted(matches, key=lambda item: (item[0], REQUESTED_ARTIFACT_FORMATS.index(item[1]))): + if output_format not in ordered_formats: + ordered_formats.append(output_format) + return ordered_formats + + +def get_requested_structured_artifact_formats(user_question: str) -> List[str]: + """Return requested durable structured artifact formats in user-request order.""" + return [ + output_format + for output_format in get_requested_artifact_formats(user_question) + if output_format in SUPPORTED_GENERATED_EXPORT_FORMATS + ] + + +def get_requested_generated_file_formats(user_question: str) -> List[str]: + """Return requested single-reply generated file formats in user-request order.""" + return [ + output_format + for output_format in get_requested_artifact_formats(user_question) + if output_format in GENERATED_FILE_FORMATS + ] + + +def get_requested_generated_file_format(user_question: str) -> Optional[str]: + """Return the requested generated file format, if any.""" + requested_formats = get_requested_generated_file_formats(user_question) + return requested_formats[0] if requested_formats else None + + +def get_requested_structured_artifact_format(user_question: str) -> Optional[str]: + """Return a requested CSV, JSON, or XML artifact target without resolving source orchestration.""" + requested_formats = get_requested_structured_artifact_formats(user_question) + return requested_formats[0] if requested_formats else None def _structured_artifact_format_is_negated(clause: str, output_format: str) -> bool: @@ -244,6 +371,57 @@ def generated_file_export_requested(user_question: str) -> bool: return get_requested_generated_file_format(user_question) is not None +def _question_requires_derived_output(normalized_question: str) -> bool: + return any(pattern.search(normalized_question) for pattern in PASSTHROUGH_DERIVED_OUTPUT_PATTERNS) + + +def _question_requests_unchanged_copy(normalized_question: str) -> bool: + return any(pattern.search(normalized_question) for pattern in PASSTHROUGH_COPY_PATTERNS) + + +def _question_requests_serialization(normalized_question: str) -> bool: + return any(pattern.search(normalized_question) for pattern in PASSTHROUGH_SERIALIZE_PATTERNS) + + +def _collect_row_schema(rows: Sequence[Dict[str, Any]]) -> List[str]: + for row in rows or []: + if isinstance(row, dict): + return [str(field_name or '').strip() for field_name in row if str(field_name or '').strip()] + return [] + + +def evaluate_generated_file_passthrough_eligibility( + user_question: str, + rows: Optional[Sequence[Dict[str, Any]]] = None, + public_output_schema: Optional[Sequence[str]] = None, +) -> Dict[str, Any]: + """Return whether raw rows can satisfy a requested generated file contract.""" + normalized_question = _normalize_question_for_artifact_detection(user_question) + normalized_rows = [row for row in list(rows or []) if isinstance(row, dict)] + if not normalized_rows: + return {'allowed': False, 'reason_code': 'source_result_incomplete'} + + normalized_public_schema = [ + str(field_name or '').strip() + for field_name in list(public_output_schema or []) + if str(field_name or '').strip() + ] + if normalized_public_schema: + expected_schema = set(normalized_public_schema) + if _collect_row_schema(normalized_rows) != normalized_public_schema: + return {'allowed': False, 'reason_code': 'schema_not_satisfied'} + if any(set(row.keys()) != expected_schema for row in normalized_rows): + return {'allowed': False, 'reason_code': 'schema_not_satisfied'} + + if _question_requests_unchanged_copy(normalized_question): + return {'allowed': True, 'reason_code': 'explicit_unchanged_copy'} + if _question_requires_derived_output(normalized_question): + return {'allowed': False, 'reason_code': 'derived_output_requires_transform'} + if _question_requests_serialization(normalized_question): + return {'allowed': True, 'reason_code': 'explicit_format_conversion'} + return {'allowed': False, 'reason_code': 'no_explicit_passthrough_contract'} + + def build_generated_file_output_guidance( user_question: str, requested_format: Optional[str] = None, @@ -307,9 +485,13 @@ def build_generated_file_export( assistant_text = str(assistant_content or '').strip() assistant_rows = extract_assistant_table_entries(assistant_text) function_rows = extract_authorized_function_result_rows(function_results) + function_passthrough = evaluate_generated_file_passthrough_eligibility( + user_question, + rows=function_rows, + ) if function_rows else {'allowed': False, 'reason_code': 'source_result_incomplete'} if output_format == GENERATED_FILE_FORMAT_CSV: - rows = assistant_rows or function_rows + rows = assistant_rows or (function_rows if function_passthrough.get('allowed') else []) if not rows: return None row_source = 'assistant response' if assistant_rows else 'structured function result' @@ -319,12 +501,15 @@ def build_generated_file_export( rows=rows, row_source=row_source, assistant_content=assistant_text, + passthrough_reason_code=(None if assistant_rows else function_passthrough.get('reason_code')), ) if not assistant_text and not assistant_rows and not function_rows: return None - rows = function_rows or assistant_rows - row_source = 'structured function result' if function_rows else 'assistant response' + rows = function_rows if function_rows and function_passthrough.get('allowed') else assistant_rows + if not assistant_text and not rows: + return None + row_source = 'structured function result' if rows and rows is function_rows else 'assistant response' title = _build_generated_file_title(output_format) if output_format == GENERATED_FILE_FORMAT_DOCX: file_content = _render_docx_file_export(title, assistant_text, rows, row_source) @@ -337,6 +522,7 @@ def build_generated_file_export( row_source=row_source, assistant_content=assistant_text, title=title, + passthrough_reason_code=(function_passthrough.get('reason_code') if row_source == 'structured function result' else None), ) @@ -437,11 +623,12 @@ def _build_generated_file_payload( row_source: str, assistant_content: str, title: str = '', + passthrough_reason_code: Optional[str] = None, ) -> Dict[str, Any]: normalized_output_format = str(output_format or '').strip().lower() row_count = len(rows or []) normalized_title = str(title or _build_generated_file_title(normalized_output_format)).strip() - return { + payload = { 'capability': 'file_export', 'file_name': _build_generated_file_name(normalized_output_format), 'file_content': file_content, @@ -458,6 +645,9 @@ def _build_generated_file_payload( normalized_title, ), } + if passthrough_reason_code: + payload['passthrough_reason_code'] = str(passthrough_reason_code or '').strip()[:80] + return payload def _build_generated_file_name(output_format: str) -> str: diff --git a/application/single_app/functions_settings.py b/application/single_app/functions_settings.py index 1e6063a09..c1cd44a72 100644 --- a/application/single_app/functions_settings.py +++ b/application/single_app/functions_settings.py @@ -87,6 +87,8 @@ "web_search_agent.other_settings.azure_ai_foundry.client_secret", ) TABULAR_GENERATION_BACKEND_SETTING_KEYS = { + 'enable_analysis_deliverable_contract_telemetry', + 'analysis_deliverable_contract_mode', 'enable_tabular_hierarchical_analysis', 'enable_tabular_parity_contract_telemetry', 'tabular_parity_contract_mode', @@ -96,6 +98,7 @@ 'tabular_generated_output_model_validation_auto_retries', 'tabular_generation_rollout_percentage', 'tabular_analyze_parity_rollout_percent', + 'tabular_analyze_parity_rollout_state', 'tabular_background_handoff_mode', 'tabular_request_planner_mode', 'enable_tabular_search_shared_preflight', @@ -105,6 +108,9 @@ 'tabular_legacy_post_tool_fallback_mode', 'enable_tabular_generation_plan', 'tabular_generation_plan_mode', + 'tabular_semantic_validation_mode', + 'tabular_semantic_repair_max_attempts', + 'tabular_semantic_repair_max_rows', 'enable_tabular_compact_response_protocol', 'enable_tabular_completion_driven_checkpointing', 'enable_tabular_rolling_worker_pool', @@ -1026,6 +1032,8 @@ def get_settings(use_cosmos=False, include_source=False): 'enable_default_embedding_model_plugin': False, 'enable_fact_memory_plugin': True, 'enable_tabular_processing_plugin': False, + 'enable_analysis_deliverable_contract_telemetry': False, + 'analysis_deliverable_contract_mode': 'off', 'enable_tabular_hierarchical_analysis': False, 'enable_tabular_parity_contract_telemetry': False, 'tabular_parity_contract_mode': 'off', @@ -1038,6 +1046,7 @@ def get_settings(use_cosmos=False, include_source=False): 'tabular_generated_output_model_validation_auto_retries': 3, 'tabular_generation_rollout_percentage': 100, 'tabular_analyze_parity_rollout_percent': 100, + 'tabular_analyze_parity_rollout_state': 'active', 'tabular_background_handoff_mode': 'legacy', 'tabular_request_planner_mode': 'off', 'enable_tabular_search_shared_preflight': False, @@ -1047,6 +1056,9 @@ def get_settings(use_cosmos=False, include_source=False): 'tabular_legacy_post_tool_fallback_mode': 'enabled', 'enable_tabular_generation_plan': True, 'tabular_generation_plan_mode': 'shadow', + 'tabular_semantic_validation_mode': 'off', + 'tabular_semantic_repair_max_attempts': 2, + 'tabular_semantic_repair_max_rows': 100, 'enable_tabular_compact_response_protocol': False, 'enable_tabular_completion_driven_checkpointing': True, 'enable_tabular_rolling_worker_pool': False, diff --git a/application/single_app/functions_simplechat_operations.py b/application/single_app/functions_simplechat_operations.py index ceb6dd42f..1237c3cea 100644 --- a/application/single_app/functions_simplechat_operations.py +++ b/application/single_app/functions_simplechat_operations.py @@ -23,6 +23,7 @@ cosmos_conversations_container, cosmos_groups_container, cosmos_messages_container, + cosmos_tabular_export_runs_container, storage_account_personal_chat_container_name, TABULAR_EXTENSIONS, ) @@ -71,6 +72,10 @@ SIMPLECHAT_PLUGIN_TYPE = "simplechat" SIMPLECHAT_DEFAULT_ENDPOINT = "simplechat://internal" +GENERATED_CHAT_ARTIFACT_LIFECYCLE_STAGED = "staged" +GENERATED_CHAT_ARTIFACT_LIFECYCLE_PUBLISHED = "published" +GENERATED_CHAT_ARTIFACT_LIFECYCLE_ROLLED_BACK = "rolled_back" +GENERATED_CHAT_ARTIFACT_VALIDATION_VALIDATED = "validated" FORKABLE_SINGLE_USER_CHAT_TYPES = { "", "new", @@ -1295,6 +1300,7 @@ def upload_generated_analysis_artifact_for_user( capability: str = "analysis", output_format: str = "", summary: str = "", + artifact_lifecycle_metadata: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: """Upload generated analysis content for a known authorized user outside request context.""" normalized_user_id = str(current_user_id or "").strip() @@ -1340,6 +1346,7 @@ def upload_generated_analysis_artifact_for_user( "capability": normalized_capability, "output_format": normalized_output_format, "summary": normalized_summary, + **(artifact_lifecycle_metadata if isinstance(artifact_lifecycle_metadata, dict) else {}), }, ) @@ -1354,6 +1361,7 @@ def upload_generated_analysis_artifact_stream_for_user( output_format: str = "", summary: str = "", artifact_idempotency_key: str = "", + artifact_lifecycle_metadata: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: """Upload a bounded-memory generated artifact stream for an authorized user.""" normalized_user_id = str(current_user_id or "").strip() @@ -1399,6 +1407,7 @@ def upload_generated_analysis_artifact_stream_for_user( "capability": normalized_capability, "output_format": normalized_output_format, "summary": normalized_summary, + **(artifact_lifecycle_metadata if isinstance(artifact_lifecycle_metadata, dict) else {}), }, artifact_idempotency_key=artifact_idempotency_key, ) @@ -2567,6 +2576,7 @@ def _upload_generated_chat_artifact_for_current_user( artifact_capability = str(artifact_metadata.get("capability") or "analysis").strip().lower() or "analysis" artifact_output_format = str(artifact_metadata.get("output_format") or file_extension).strip().lower() or file_extension artifact_summary = str(artifact_metadata.get("summary") or "").strip() + lifecycle_metadata = _build_generated_chat_artifact_lifecycle_metadata(artifact_metadata) message_doc = { "id": artifact_message_id, @@ -2586,6 +2596,7 @@ def _upload_generated_chat_artifact_for_current_user( "generated_artifact_output_format": artifact_output_format, "generated_artifact_summary": artifact_summary, "generated_artifact_idempotency_key": normalized_idempotency_key or None, + **lifecycle_metadata, "thread_info": { "thread_id": current_thread_id, "previous_thread_id": previous_thread_id, @@ -2618,11 +2629,180 @@ def _upload_generated_chat_artifact_for_current_user( "blob_path": blob_path, "capability": artifact_capability, "output_format": artifact_output_format, + **_build_generated_chat_artifact_lifecycle_response(message_doc.get("metadata")), }, "conversation_id": conversation_id, } +def _safe_positive_int(value: Any) -> int: + try: + return max(0, int(value or 0)) + except (TypeError, ValueError): + return 0 + + +def _build_generated_chat_artifact_lifecycle_metadata(artifact_metadata: Dict[str, Any]) -> Dict[str, Any]: + metadata = artifact_metadata if isinstance(artifact_metadata, dict) else {} + run_id = str(metadata.get("artifact_run_id") or metadata.get("run_id") or "").strip() + set_id = str(metadata.get("artifact_set_id") or "").strip() + member_id = str(metadata.get("artifact_member_id") or "").strip() + if not run_id and not set_id and not member_id: + return {} + + lifecycle_state = str( + metadata.get("artifact_lifecycle_state") or GENERATED_CHAT_ARTIFACT_LIFECYCLE_STAGED + ).strip().lower() + if lifecycle_state not in { + GENERATED_CHAT_ARTIFACT_LIFECYCLE_STAGED, + GENERATED_CHAT_ARTIFACT_LIFECYCLE_PUBLISHED, + GENERATED_CHAT_ARTIFACT_LIFECYCLE_ROLLED_BACK, + }: + lifecycle_state = GENERATED_CHAT_ARTIFACT_LIFECYCLE_STAGED + validation_state = str(metadata.get("artifact_validation_state") or lifecycle_state).strip().lower()[:40] + return { + "generated_artifact_run_id": run_id, + "generated_artifact_set_id": set_id, + "generated_artifact_member_id": member_id, + "generated_artifact_lifecycle_state": lifecycle_state, + "generated_artifact_validation_state": validation_state, + "generated_artifact_publication_generation": _safe_positive_int( + metadata.get("artifact_publication_generation") + ), + "generated_artifact_staged_at": str(metadata.get("artifact_staged_at") or datetime.now(timezone.utc).isoformat()), + "generated_artifact_committed_at": metadata.get("artifact_committed_at"), + } + + +def _build_generated_chat_artifact_lifecycle_response(metadata: Optional[Dict[str, Any]]) -> Dict[str, Any]: + normalized_metadata = metadata if isinstance(metadata, dict) else {} + response = {} + for key in ( + "generated_artifact_run_id", + "generated_artifact_set_id", + "generated_artifact_member_id", + "generated_artifact_lifecycle_state", + "generated_artifact_validation_state", + "generated_artifact_publication_generation", + ): + if key in normalized_metadata: + response[key] = normalized_metadata.get(key) + return response + + +def _generated_artifact_has_lifecycle_contract(metadata: Dict[str, Any]) -> bool: + return bool( + metadata.get("generated_artifact_run_id") + or metadata.get("generated_artifact_set_id") + or metadata.get("generated_artifact_member_id") + or metadata.get("generated_artifact_lifecycle_state") + ) + + +def assert_generated_chat_artifact_is_published_for_user(current_user_id: str, message_item: Dict[str, Any]) -> None: + """Reauthorize a generated artifact against its committed artifact-set manifest.""" + metadata = message_item.get("metadata") if isinstance(message_item.get("metadata"), dict) else {} + if not _generated_artifact_has_lifecycle_contract(metadata): + return + + lifecycle_state = str(metadata.get("generated_artifact_lifecycle_state") or "").strip().lower() + validation_state = str(metadata.get("generated_artifact_validation_state") or "").strip().lower() + publication_generation = _safe_positive_int(metadata.get("generated_artifact_publication_generation")) + if ( + lifecycle_state != GENERATED_CHAT_ARTIFACT_LIFECYCLE_PUBLISHED + or validation_state != GENERATED_CHAT_ARTIFACT_VALIDATION_VALIDATED + or publication_generation <= 0 + ): + raise PermissionError("Artifact is not published") + + run_id = str(metadata.get("generated_artifact_run_id") or "").strip() + set_id = str(metadata.get("generated_artifact_set_id") or "").strip() + member_id = str(metadata.get("generated_artifact_member_id") or "").strip() + conversation_id = str(message_item.get("conversation_id") or "").strip() + if not run_id or not set_id or not member_id or not conversation_id: + raise PermissionError("Artifact publication metadata is incomplete") + + try: + run = cosmos_tabular_export_runs_container.read_item( + item=run_id, + partition_key=str(current_user_id or "").strip(), + ) + except CosmosResourceNotFoundError as exc: + raise PermissionError("Artifact publication run is unavailable") from exc + + manifest = run.get("artifact_set_manifest") if isinstance(run.get("artifact_set_manifest"), dict) else {} + if ( + str(run.get("conversation_id") or "").strip() != conversation_id + or str(run.get("user_id") or "").strip() != str(current_user_id or "").strip() + or str(manifest.get("set_id") or "").strip() != set_id + or str(manifest.get("lifecycle_state") or "").strip().lower() != "completed" + or str(manifest.get("validation_state") or "").strip().lower() != GENERATED_CHAT_ARTIFACT_VALIDATION_VALIDATED + or _safe_positive_int(manifest.get("publication_generation")) != publication_generation + ): + raise PermissionError("Artifact is not published") + + for member in list(manifest.get("members") or []): + if not isinstance(member, dict) or str(member.get("member_id") or "").strip() != member_id: + continue + if ( + str(member.get("artifact_message_id") or "").strip() == str(message_item.get("id") or "").strip() + and str(member.get("lifecycle_state") or "").strip().lower() == GENERATED_CHAT_ARTIFACT_LIFECYCLE_PUBLISHED + and str(member.get("validation_state") or "").strip().lower() == GENERATED_CHAT_ARTIFACT_VALIDATION_VALIDATED + ): + return + break + raise PermissionError("Artifact is not published") + + +def commit_generated_chat_artifact_publication_for_user( + current_user_id: str, + conversation_id: str, + artifact_message_id: str, + artifact_set_id: str, + artifact_member_id: str, + publication_generation: int, +) -> Dict[str, Any]: + """Mark one staged generated artifact message as published after manifest validation.""" + current_user_id = str(current_user_id or "").strip() + normalized_conversation_id = str(conversation_id or "").strip() + normalized_message_id = str(artifact_message_id or "").strip() + normalized_set_id = str(artifact_set_id or "").strip() + normalized_member_id = str(artifact_member_id or "").strip() + if not current_user_id or not normalized_conversation_id or not normalized_message_id: + raise ValueError("Artifact publication target is incomplete") + + conversation_item = cosmos_conversations_container.read_item( + item=normalized_conversation_id, + partition_key=normalized_conversation_id, + ) + if str(conversation_item.get("user_id") or "").strip() != current_user_id: + raise PermissionError("Forbidden") + message_item = cosmos_messages_container.read_item( + item=normalized_message_id, + partition_key=normalized_conversation_id, + ) + metadata = message_item.get("metadata") if isinstance(message_item.get("metadata"), dict) else {} + if ( + str(message_item.get("conversation_id") or "").strip() != normalized_conversation_id + or message_item.get("role") != "file" + or not metadata.get("is_generated_chat_artifact") + or str(metadata.get("generated_artifact_set_id") or "").strip() != normalized_set_id + or str(metadata.get("generated_artifact_member_id") or "").strip() != normalized_member_id + ): + raise PermissionError("Forbidden") + + metadata.update({ + "generated_artifact_lifecycle_state": GENERATED_CHAT_ARTIFACT_LIFECYCLE_PUBLISHED, + "generated_artifact_validation_state": GENERATED_CHAT_ARTIFACT_VALIDATION_VALIDATED, + "generated_artifact_publication_generation": _safe_positive_int(publication_generation), + "generated_artifact_committed_at": datetime.now(timezone.utc).isoformat(), + }) + message_item["metadata"] = metadata + cosmos_messages_container.upsert_item(message_item) + return message_item + + + def _resolve_group_upload_target_for_current_user( current_user_id: str, group_id: str = "", @@ -3154,4 +3334,4 @@ def _notify_personal_collaboration_conversation_created( "created_by_display_name": creator_display_name, "audience": "participant", }, - ) \ No newline at end of file + ) diff --git a/application/single_app/functions_tabular_analysis.py b/application/single_app/functions_tabular_analysis.py index 247d1d1ec..3996cabe9 100644 --- a/application/single_app/functions_tabular_analysis.py +++ b/application/single_app/functions_tabular_analysis.py @@ -131,6 +131,11 @@ async def maybe_create_tabular_generated_output(*args, **kwargs): return await helper(*args, **kwargs) +def maybe_queue_direct_tabular_generated_output(*args, **kwargs): + helper = _load_chat_helper('maybe_queue_direct_tabular_generated_output') + return helper(*args, **kwargs) + + async def run_tabular_analysis_with_thought_tracking(*args, **kwargs): helper = _load_chat_helper('run_tabular_analysis_with_thought_tracking') return await helper(*args, **kwargs) diff --git a/application/single_app/functions_tabular_generated_exports.py b/application/single_app/functions_tabular_generated_exports.py index 310c3c862..8ee41eef3 100644 --- a/application/single_app/functions_tabular_generated_exports.py +++ b/application/single_app/functions_tabular_generated_exports.py @@ -17,6 +17,7 @@ import time import uuid from datetime import datetime, timedelta, timezone +from xml.sax.saxutils import escape as escape_xml_text from azure.core import MatchConditions from azure.core.exceptions import ResourceExistsError @@ -37,7 +38,24 @@ storage_account_user_documents_container_name, ) from functions_appinsights import log_event +from functions_analysis_deliverables import ( + ANALYSIS_ARTIFACT_ROLE_PRIMARY_ANALYSIS, + ANALYSIS_ARTIFACT_ROLE_REQUESTED_OUTPUT, + ANALYSIS_ARTIFACT_ROLE_SUPPORTING_OUTPUT, + build_analysis_deliverable_contract, + is_analysis_internal_lineage_field, + project_structured_deliverable_row, + validate_analysis_artifact_set, +) from functions_assistant_table_exports import build_safe_csv_headers, neutralize_csv_spreadsheet_formula +from functions_tabular_transformations import ( + TABULAR_TRANSFORMATION_FIELD_MODE_DETERMINISTIC, + TABULAR_TRANSFORMATION_SPEC_VERSION, + evaluate_tabular_transformation_row, + get_tabular_transformation_model_fields, + is_tabular_transformation_deterministic_only, + normalize_tabular_transformation_spec, +) from functions_tabular_csv_query import ( iter_tabular_csv_query_rows, validate_tabular_csv_query_expression, @@ -51,7 +69,15 @@ from functions_model_endpoint_runtime import build_semantic_kernel_chat_service_for_model from functions_public_workspaces import get_user_visible_public_workspace_ids_from_settings from functions_settings import get_settings -from functions_simplechat_operations import upload_generated_analysis_artifact_stream_for_user +from functions_simplechat_operations import ( + commit_generated_chat_artifact_publication_for_user, + upload_generated_analysis_artifact_stream_for_user, +) +from functions_tabular_semantic_validation import ( + TABULAR_SEMANTIC_VALIDATION_CONTRACT_VERSION, + build_safe_semantic_validation_counts, + verify_and_repair_semantic_rows, +) TABULAR_EXPORT_RUN_TYPE = 'tabular_generated_output_run' @@ -66,8 +92,30 @@ TABULAR_COMPACT_PLAN_HASH_PREFIX_LENGTH = 12 TABULAR_EXECUTOR_MODE_FIXED_WINDOW = 'fixed-window-v1' TABULAR_EXECUTOR_MODE_ROLLING_POOL = 'rolling-pool-v1' -TABULAR_GENERATION_PLAN_VERSION = 1 -TABULAR_GENERATION_PLAN_PROMPT_VERSION = 'tabular-generation-plan-v1' +TABULAR_GENERATION_PLAN_VERSION = 2 +TABULAR_GENERATION_PLAN_LEGACY_VERSIONS = {1} +TABULAR_GENERATION_PLAN_PROMPT_VERSION = 'tabular-generation-plan-v2' +TABULAR_GENERATION_PLAN_LEGACY_PROMPT_VERSIONS = { + 1: 'tabular-generation-plan-v1', +} +TABULAR_GENERATION_PLAN_REVIEW_VERSION = 1 +TABULAR_GENERATION_PLAN_REVIEW_STATUSES = {'passed', 'failed'} +TABULAR_GENERATION_PLAN_REVIEW_REASON_CODES = { + 'boundary_ambiguous', + 'field_missing', + 'precedence_mismatch', + 'review_passed', + 'rule_missing', + 'unknown_source_field', + 'unrequested_inference', + 'unsupported_rule', +} +TABULAR_GENERATION_PLAN_MAX_REVIEW_REASON_CODES = 20 +TABULAR_SEMANTIC_VALIDATION_MODES = {'off', 'shadow', 'active'} +TABULAR_SEMANTIC_DEFAULT_REPAIR_ATTEMPTS = 2 +TABULAR_SEMANTIC_DEFAULT_MAX_REPAIR_ROWS = 100 +TABULAR_SEMANTIC_MAX_PROMPT_CHARS = 180000 +TABULAR_SEMANTIC_CANDIDATE_CHECKPOINT_VERSION = 1 TABULAR_GENERATION_PLAN_DEFAULT_RETRY_ATTEMPTS = 2 TABULAR_GENERATION_PLAN_MAX_SAMPLE_ROWS = 5 TABULAR_GENERATION_PLAN_MAX_COLUMNS = 200 @@ -113,6 +161,52 @@ TABULAR_EXPORT_STATUS_FAILED, TABULAR_EXPORT_STATUS_CANCELED, } +TABULAR_ARTIFACT_SET_CONTRACT_VERSION = 'tabular-artifact-set-v1' +TABULAR_ARTIFACT_SET_LIFECYCLE_PLANNED = 'planned' +TABULAR_ARTIFACT_SET_LIFECYCLE_GENERATING = 'generating' +TABULAR_ARTIFACT_SET_LIFECYCLE_VALIDATING = 'validating' +TABULAR_ARTIFACT_SET_LIFECYCLE_READY_TO_PUBLISH = 'ready_to_publish' +TABULAR_ARTIFACT_SET_LIFECYCLE_PUBLISHING = 'publishing' +TABULAR_ARTIFACT_SET_LIFECYCLE_COMPLETED = 'completed' +TABULAR_ARTIFACT_SET_LIFECYCLE_FAILED = 'failed' +TABULAR_ARTIFACT_SET_LIFECYCLE_CANCELED = 'canceled' +TABULAR_ARTIFACT_SET_LIFECYCLE_ROLLBACK_REQUIRED = 'rollback_required' +TABULAR_ARTIFACT_SET_LIFECYCLE_ROLLED_BACK = 'rolled_back' +TABULAR_ARTIFACT_SET_LIFECYCLE_STATES = { + TABULAR_ARTIFACT_SET_LIFECYCLE_PLANNED, + TABULAR_ARTIFACT_SET_LIFECYCLE_GENERATING, + TABULAR_ARTIFACT_SET_LIFECYCLE_VALIDATING, + TABULAR_ARTIFACT_SET_LIFECYCLE_READY_TO_PUBLISH, + TABULAR_ARTIFACT_SET_LIFECYCLE_PUBLISHING, + TABULAR_ARTIFACT_SET_LIFECYCLE_COMPLETED, + TABULAR_ARTIFACT_SET_LIFECYCLE_FAILED, + TABULAR_ARTIFACT_SET_LIFECYCLE_CANCELED, + TABULAR_ARTIFACT_SET_LIFECYCLE_ROLLBACK_REQUIRED, + TABULAR_ARTIFACT_SET_LIFECYCLE_ROLLED_BACK, +} +TABULAR_ARTIFACT_MEMBER_LIFECYCLE_PLANNED = 'planned' +TABULAR_ARTIFACT_MEMBER_LIFECYCLE_GENERATING = 'generating' +TABULAR_ARTIFACT_MEMBER_LIFECYCLE_STAGED = 'staged' +TABULAR_ARTIFACT_MEMBER_LIFECYCLE_VALIDATED = 'validated' +TABULAR_ARTIFACT_MEMBER_LIFECYCLE_PUBLISHING = 'publishing' +TABULAR_ARTIFACT_MEMBER_LIFECYCLE_PUBLISHED = 'published' +TABULAR_ARTIFACT_MEMBER_LIFECYCLE_FAILED = 'failed' +TABULAR_ARTIFACT_MEMBER_LIFECYCLE_CANCELED = 'canceled' +TABULAR_ARTIFACT_MEMBER_LIFECYCLE_ROLLED_BACK = 'rolled_back' +TABULAR_ARTIFACT_MEMBER_LIFECYCLE_STATES = { + TABULAR_ARTIFACT_MEMBER_LIFECYCLE_PLANNED, + TABULAR_ARTIFACT_MEMBER_LIFECYCLE_GENERATING, + TABULAR_ARTIFACT_MEMBER_LIFECYCLE_STAGED, + TABULAR_ARTIFACT_MEMBER_LIFECYCLE_VALIDATED, + TABULAR_ARTIFACT_MEMBER_LIFECYCLE_PUBLISHING, + TABULAR_ARTIFACT_MEMBER_LIFECYCLE_PUBLISHED, + TABULAR_ARTIFACT_MEMBER_LIFECYCLE_FAILED, + TABULAR_ARTIFACT_MEMBER_LIFECYCLE_CANCELED, + TABULAR_ARTIFACT_MEMBER_LIFECYCLE_ROLLED_BACK, +} +TABULAR_ARTIFACT_MEMBER_PUBLIC_LIFECYCLE_STATES = { + TABULAR_ARTIFACT_MEMBER_LIFECYCLE_PUBLISHED, +} TABULAR_EXPORT_DEFAULT_INLINE_MAX_BATCHES = 75 TABULAR_EXPORT_DEFAULT_INLINE_MAX_ROWS = 500 @@ -277,9 +371,10 @@ class TabularExportLeaseLostError(RuntimeError): class TabularGenerationPlanError(RuntimeError): """Raised when the bounded planner exhausts its allowed attempts.""" - def __init__(self, reason): + def __init__(self, reason, failed_run=None): super().__init__('Tabular generation planner did not produce a valid plan') self.reason = str(reason or 'provider_failure') + self.failed_run = failed_run if isinstance(failed_run, dict) else None def _now_utc(): @@ -459,12 +554,16 @@ def _build_tabular_generation_plan_input_contract(sample_rows): } -def _validate_tabular_generation_plan_output_fields(output_fields): +def _validate_tabular_generation_plan_output_fields(output_fields, allowed_sources=None): if not isinstance(output_fields, list) or not output_fields: raise ValueError('Planner response must include at least one output field') if len(output_fields) > TABULAR_GENERATION_PLAN_MAX_FIELDS: raise ValueError('Planner response contains too many output fields') + normalized_allowed_sources = set(allowed_sources or {'llm'}) + if not normalized_allowed_sources or normalized_allowed_sources - {'llm', 'server'}: + raise ValueError('Planner output field sources are unsupported') + normalized_fields = [] seen_names = set() allowed_keys = {'name', 'description', 'type', 'nullable', 'source'} @@ -502,7 +601,8 @@ def _validate_tabular_generation_plan_output_fields(output_fields): nullable = output_field.get('nullable') if not isinstance(nullable, bool): raise ValueError(f'Planner output field {field_index} must declare nullability') - if str(output_field.get('source') or '').strip().lower() != 'llm': + field_source = str(output_field.get('source') or '').strip().lower() + if field_source not in normalized_allowed_sources: raise ValueError(f'Planner output field {field_index} has an unsupported source') normalized_fields.append({ @@ -510,7 +610,7 @@ def _validate_tabular_generation_plan_output_fields(output_fields): 'description': field_description, 'type': value_type, 'nullable': nullable, - 'source': 'llm', + 'source': field_source, }) return normalized_fields @@ -524,15 +624,59 @@ def _get_tabular_generation_plan_source(run): } +def _validate_tabular_generation_plan_field_ownership(output_fields, transformation_spec): + fields_by_name = { + str(field.get('name') or '').strip(): field + for field in list((transformation_spec or {}).get('fields') or []) + if isinstance(field, dict) + } + for output_field in output_fields: + field_name = output_field['name'] + transformation_field = fields_by_name.get(field_name) + if not transformation_field: + raise ValueError(f'Planner transformation ownership is missing field {field_name}') + expected_source = ( + 'server' + if transformation_field.get('mode') == TABULAR_TRANSFORMATION_FIELD_MODE_DETERMINISTIC + else 'llm' + ) + if output_field.get('source') != expected_source: + raise ValueError(f'Planner output field {field_name} has inconsistent transformation ownership') + transformation_type = str(transformation_field.get('type') or '').strip() + if transformation_type and transformation_type != output_field.get('type'): + raise ValueError(f'Planner output field {field_name} has inconsistent transformation type') + if ( + 'nullable' in transformation_field + and bool(transformation_field.get('nullable')) != output_field.get('nullable') + ): + raise ValueError(f'Planner output field {field_name} has inconsistent nullability') + + def _build_tabular_generation_plan(run, planner_payload, input_contract, planner_model, created_at=None): if not isinstance(planner_payload, dict): raise ValueError('Planner response was not a JSON object') - if set(planner_payload) - {'output_fields', 'output_verbosity'}: + if set(planner_payload) - {'output_fields', 'output_verbosity', 'transformation_spec'}: raise ValueError('Planner response contains unsupported top-level properties') - llm_fields = _validate_tabular_generation_plan_output_fields( - planner_payload.get('output_fields') + planned_fields = _validate_tabular_generation_plan_output_fields( + planner_payload.get('output_fields'), + allowed_sources={'llm', 'server'}, ) + public_output_schema = [field['name'] for field in planned_fields] + source_schema = [ + str(column.get('name') or '').strip() + for column in list(input_contract.get('columns') or []) + if isinstance(column, dict) and str(column.get('name') or '').strip() + ] + raw_transformation_spec = planner_payload.get('transformation_spec') + if not raw_transformation_spec: + raise ValueError('Planner response requires explicit transformation ownership for every field') + transformation_spec = normalize_tabular_transformation_spec( + raw_transformation_spec, + public_output_schema=public_output_schema, + source_schema=source_schema, + ) + _validate_tabular_generation_plan_field_ownership(planned_fields, transformation_spec) output_verbosity = str(planner_payload.get('output_verbosity') or '').strip() if len(output_verbosity) > TABULAR_GENERATION_PLAN_MAX_GUIDANCE_CHARS: raise ValueError('Planner output verbosity guidance is too long') @@ -586,8 +730,9 @@ def _build_tabular_generation_plan(run, planner_payload, input_contract, planner 'nullable': False, 'source': 'server', }, - *llm_fields, + *planned_fields, ], + 'transformation_spec': transformation_spec, 'response_protocol': response_protocol, 'prompt_version': TABULAR_GENERATION_PLAN_PROMPT_VERSION, 'batch_budget': { @@ -621,13 +766,19 @@ def _validate_tabular_generation_plan(plan, run, input_schema_hash=None): 'batch_budget', 'plan_hash', } - allowed_keys = required_keys | {'output_verbosity'} + plan_version = _safe_int(plan.get('version')) + legacy_plan = plan_version in TABULAR_GENERATION_PLAN_LEGACY_VERSIONS + if plan_version != TABULAR_GENERATION_PLAN_VERSION and not legacy_plan: + raise ValueError('Stored generation plan version is not supported') + if legacy_plan: + allowed_keys = required_keys | {'output_verbosity'} + else: + required_keys |= {'transformation_spec', 'review'} + allowed_keys = required_keys | {'output_verbosity'} if set(plan) - allowed_keys: raise ValueError('Stored generation plan contains unsupported properties') if not required_keys.issubset(plan): raise ValueError('Stored generation plan is missing required properties') - if _safe_int(plan.get('version')) != TABULAR_GENERATION_PLAN_VERSION: - raise ValueError('Stored generation plan version is not supported') if str(plan.get('run_id') or '').strip() != str((run or {}).get('id') or '').strip(): raise ValueError('Stored generation plan run identity does not match') if not str(plan.get('created_at') or '').strip(): @@ -687,7 +838,12 @@ def _validate_tabular_generation_plan(plan, run, input_schema_hash=None): (run or {}).get('response_protocol_version') or TABULAR_RESPONSE_PROTOCOL_OBJECT_V1 ): raise ValueError('Stored generation plan response protocol does not match') - if plan.get('prompt_version') != TABULAR_GENERATION_PLAN_PROMPT_VERSION: + expected_prompt_version = ( + TABULAR_GENERATION_PLAN_LEGACY_PROMPT_VERSIONS.get(plan_version) + if legacy_plan + else TABULAR_GENERATION_PLAN_PROMPT_VERSION + ) + if plan.get('prompt_version') != expected_prompt_version: raise ValueError('Stored generation plan prompt version does not match') stored_batch_budget = plan.get('batch_budget') @@ -730,9 +886,30 @@ def _validate_tabular_generation_plan(plan, run, input_schema_hash=None): description = str(output_field.get('description') or '').strip() if not description or len(description) > TABULAR_GENERATION_PLAN_MAX_FIELD_DESCRIPTION_CHARS: raise ValueError('Stored generation plan server field description is invalid') - normalized_llm_fields = _validate_tabular_generation_plan_output_fields(output_fields[2:]) - if normalized_llm_fields != output_fields[2:]: + normalized_public_fields = _validate_tabular_generation_plan_output_fields( + output_fields[2:], + allowed_sources={'llm'} if legacy_plan else {'llm', 'server'}, + ) + if normalized_public_fields != output_fields[2:]: raise ValueError('Stored generation plan output fields are not normalized') + if not legacy_plan: + public_output_schema = [field['name'] for field in normalized_public_fields] + normalized_transformation_spec = normalize_tabular_transformation_spec( + plan.get('transformation_spec'), + public_output_schema=public_output_schema, + ) + if normalized_transformation_spec != plan.get('transformation_spec'): + raise ValueError('Stored generation plan transformation specification is not normalized') + _validate_tabular_generation_plan_field_ownership( + normalized_public_fields, + normalized_transformation_spec, + ) + normalized_review = _normalize_tabular_generation_plan_review( + plan.get('review'), + public_output_schema, + ) + if normalized_review != plan.get('review'): + raise ValueError('Stored generation plan review is not normalized') return plan @@ -753,6 +930,93 @@ def _get_tabular_generation_plan_llm_fields(plan): ] +def _get_tabular_generation_plan_public_fields(plan): + return [ + output_field + for output_field in (plan or {}).get('output_fields') or [] + if isinstance(output_field, dict) + and str(output_field.get('name') or '').strip() not in { + TABULAR_EXPORT_OUTPUT_ROW_NUMBER_FIELD, + TABULAR_EXPORT_OUTPUT_ROW_IDENTITY_FIELD, + } + ] + + +def _normalize_tabular_generation_plan_review(review_payload, expected_fields, reviewer_model=None): + if not isinstance(review_payload, dict): + raise ValueError('Generation plan review was not a JSON object') + payload_keys = {'status', 'represented_fields', 'reason_codes'} + stored_keys = payload_keys | {'version', 'model'} + if set(review_payload) not in {frozenset(payload_keys), frozenset(stored_keys)}: + raise ValueError('Generation plan review contains invalid properties') + + status = str(review_payload.get('status') or '').strip().lower() + if status not in TABULAR_GENERATION_PLAN_REVIEW_STATUSES: + raise ValueError('Generation plan review status is unsupported') + represented_fields = [ + str(field_name or '').strip() + for field_name in list(review_payload.get('represented_fields') or []) + if str(field_name or '').strip() + ] + if len(represented_fields) != len(set(represented_fields)): + raise ValueError('Generation plan review contains duplicate represented fields') + normalized_expected_fields = [str(field_name or '').strip() for field_name in expected_fields] + if represented_fields != normalized_expected_fields: + raise ValueError('Generation plan review does not cover every public output field in order') + + reason_codes = [ + str(reason_code or '').strip().lower() + for reason_code in list(review_payload.get('reason_codes') or []) + if str(reason_code or '').strip() + ] + if len(reason_codes) > TABULAR_GENERATION_PLAN_MAX_REVIEW_REASON_CODES: + raise ValueError('Generation plan review contains too many reason codes') + if len(reason_codes) != len(set(reason_codes)): + raise ValueError('Generation plan review contains duplicate reason codes') + if set(reason_codes) - TABULAR_GENERATION_PLAN_REVIEW_REASON_CODES: + raise ValueError('Generation plan review contains an unsupported reason code') + if status == 'passed' and reason_codes: + raise ValueError('Passed generation plan review cannot contain failure reasons') + if status == 'failed' and not reason_codes: + raise ValueError('Failed generation plan review requires a reason code') + + model_payload = reviewer_model if reviewer_model is not None else review_payload.get('model') + normalized_model = { + field_name: str((model_payload or {}).get(field_name) or '').strip() + for field_name in ('endpoint_id', 'model_id', 'deployment') + } + if not any(normalized_model.values()) or any(len(value) > 500 for value in normalized_model.values()): + raise ValueError('Generation plan review model identity is invalid') + return { + 'version': TABULAR_GENERATION_PLAN_REVIEW_VERSION, + 'status': status, + 'represented_fields': represented_fields, + 'reason_codes': reason_codes, + 'model': normalized_model, + } + + +def _finalize_tabular_generation_plan_review(plan, review_payload, reviewer_model): + if _safe_int((plan or {}).get('version')) != TABULAR_GENERATION_PLAN_VERSION: + raise ValueError('Only current generation plans can receive a new review') + finalized_plan = dict(plan or {}) + finalized_plan.pop('plan_hash', None) + public_fields = [ + field['name'] + for field in _get_tabular_generation_plan_public_fields(finalized_plan) + ] + review = _normalize_tabular_generation_plan_review( + review_payload, + public_fields, + reviewer_model=reviewer_model, + ) + if review['status'] != 'passed': + raise ValueError('Generation plan review did not pass') + finalized_plan['review'] = review + finalized_plan['plan_hash'] = _hash_tabular_generation_plan(finalized_plan) + return finalized_plan + + def _get_compact_plan_hash_prefix(plan): plan_hash = str((plan or {}).get('plan_hash') or '').strip() if not plan_hash: @@ -889,6 +1153,26 @@ def _normalize_tabular_generation_rollout_settings(settings): 'shadow', TABULAR_ROLLOUT_PLANNER_MODES, ), + 'tabular_semantic_validation_mode': _settings_mode( + settings, + 'tabular_semantic_validation_mode', + 'off', + TABULAR_SEMANTIC_VALIDATION_MODES, + ), + 'tabular_semantic_repair_max_attempts': _settings_int( + settings, + 'tabular_semantic_repair_max_attempts', + TABULAR_SEMANTIC_DEFAULT_REPAIR_ATTEMPTS, + minimum=0, + maximum=5, + ), + 'tabular_semantic_repair_max_rows': _settings_int( + settings, + 'tabular_semantic_repair_max_rows', + TABULAR_SEMANTIC_DEFAULT_MAX_REPAIR_ROWS, + minimum=1, + maximum=500, + ), 'enable_tabular_generation_plan': _settings_bool( settings, 'enable_tabular_generation_plan', @@ -974,6 +1258,7 @@ def _build_tabular_generation_rollout_assignment(settings, user_id, conversation rollout_settings.update({ 'tabular_background_handoff_mode': 'legacy', 'tabular_generation_plan_mode': 'off', + 'tabular_semantic_validation_mode': 'off', 'enable_tabular_generation_plan': False, 'enable_tabular_compact_response_protocol': False, 'enable_tabular_completion_driven_checkpointing': False, @@ -1109,12 +1394,179 @@ def _sync_tabular_generation_contract_fields(run): run.setdefault('systemic_failure_category', None) run.setdefault('systemic_failure_signature', None) run.setdefault('systemic_failure_opened_at', None) + run['lineage_schema'] = _get_tabular_run_lineage_schema(run) + run['public_output_schema'] = _get_tabular_run_public_output_schema(run) + run['internal_checkpoint_schema'] = _get_tabular_run_internal_checkpoint_schema(run) run['checkpointed_row_count'] = checkpointed_row_count run.setdefault('generation_started_at', run.get('started_at')) run.setdefault('generation_completed_at', None) return run +def _get_tabular_run_lineage_schema(run): + raw_schema = list((run or {}).get('lineage_schema') or [ + TABULAR_EXPORT_OUTPUT_ROW_NUMBER_FIELD, + TABULAR_EXPORT_OUTPUT_ROW_IDENTITY_FIELD, + ]) + normalized_schema = [] + seen_fields = set() + for field_name in raw_schema: + normalized_field = str(field_name or '').strip() + if not normalized_field or normalized_field in seen_fields: + continue + if not is_analysis_internal_lineage_field(normalized_field): + continue + seen_fields.add(normalized_field) + normalized_schema.append(normalized_field) + return normalized_schema or [ + TABULAR_EXPORT_OUTPUT_ROW_NUMBER_FIELD, + TABULAR_EXPORT_OUTPUT_ROW_IDENTITY_FIELD, + ] + + +def _get_tabular_run_public_output_schema(run): + raw_public_schema = list((run or {}).get('public_output_schema') or []) + if not raw_public_schema: + deliverable_contract = ( + ((run or {}).get('tabular_planner_metadata') or {}).get('deliverable_contract') + if isinstance((run or {}).get('tabular_planner_metadata'), dict) + else {} + ) + raw_public_schema = list((deliverable_contract or {}).get('public_output_schema') or []) + if not raw_public_schema: + raw_public_schema = [ + field_name + for field_name in list((run or {}).get('output_schema') or []) + if not is_analysis_internal_lineage_field(field_name) + ] + + normalized_schema = [] + seen_fields = set() + for field_name in raw_public_schema: + normalized_field = str(field_name or '').strip() + if not normalized_field or normalized_field in seen_fields: + continue + if is_analysis_internal_lineage_field(normalized_field): + continue + seen_fields.add(normalized_field) + normalized_schema.append(normalized_field) + return normalized_schema + + +def _get_tabular_run_internal_checkpoint_schema(run): + raw_internal_schema = list((run or {}).get('internal_checkpoint_schema') or []) + if raw_internal_schema: + return [str(field_name or '').strip() for field_name in raw_internal_schema if str(field_name or '').strip()] + output_schema = list((run or {}).get('output_schema') or []) + if output_schema: + return [str(field_name or '').strip() for field_name in output_schema if str(field_name or '').strip()] + return _get_tabular_run_lineage_schema(run) + _get_tabular_run_public_output_schema(run) + + +def _get_tabular_run_transformation_spec(run, public_output_schema=None): + raw_spec = (run or {}).get('transformation_spec') + if not raw_spec: + deliverable_contract = ( + ((run or {}).get('tabular_planner_metadata') or {}).get('deliverable_contract') + if isinstance((run or {}).get('tabular_planner_metadata'), dict) + else {} + ) + raw_spec = (deliverable_contract or {}).get('transformation_spec') + if not raw_spec: + return {} + return normalize_tabular_transformation_spec( + raw_spec, + public_output_schema=public_output_schema or _get_tabular_run_public_output_schema(run), + ) + + +def _get_public_fields_from_output_schema(output_schema): + return [ + str(field_name or '').strip() + for field_name in list(output_schema or []) + if str(field_name or '').strip() + and not is_analysis_internal_lineage_field(field_name) + ] + + +def _get_lineage_fields_from_output_schema(output_schema): + lineage_fields = [ + str(field_name or '').strip() + for field_name in list(output_schema or []) + if str(field_name or '').strip() + and is_analysis_internal_lineage_field(field_name) + ] + return lineage_fields or [ + TABULAR_EXPORT_OUTPUT_ROW_NUMBER_FIELD, + TABULAR_EXPORT_OUTPUT_ROW_IDENTITY_FIELD, + ] + + +def _build_model_expected_output_schema(expected_output_schema, transformation_spec=None): + if not transformation_spec: + return list(expected_output_schema or []) + public_schema = _get_public_fields_from_output_schema(expected_output_schema) + if not public_schema: + return list(expected_output_schema or []) + model_fields = get_tabular_transformation_model_fields( + transformation_spec, + public_output_schema=public_schema, + ) + return _get_lineage_fields_from_output_schema(expected_output_schema) + model_fields + + +def _merge_deterministic_transformation_entries( + source_rows, + generated_entries, + expected_output_schema, + transformation_spec=None, +): + if not transformation_spec: + return generated_entries, list(expected_output_schema or []) + final_output_schema = list(expected_output_schema or []) + public_schema = _get_public_fields_from_output_schema(final_output_schema) + if not public_schema: + return generated_entries, final_output_schema + normalized_spec = normalize_tabular_transformation_spec( + transformation_spec, + public_output_schema=public_schema, + ) + if not normalized_spec: + return generated_entries, final_output_schema + + merged_entries = [] + for row_index, (source_row, generated_entry) in enumerate( + zip(source_rows or [], generated_entries or []), + start=1, + ): + deterministic_values = evaluate_tabular_transformation_row(normalized_spec, source_row) + merged_entry = {} + for field_name in final_output_schema: + if field_name in deterministic_values: + merged_entry[field_name] = deterministic_values.get(field_name) + elif field_name in generated_entry: + merged_entry[field_name] = generated_entry.get(field_name) + elif is_analysis_internal_lineage_field(field_name): + merged_entry[field_name] = generated_entry.get(field_name) + else: + raise ValueError( + f'Deterministic transformation merge missing field {field_name} at row {row_index}' + ) + merged_entries.append(merged_entry) + return merged_entries, final_output_schema + + +def _get_tabular_run_serialized_public_schema(run): + public_schema = _get_tabular_run_public_output_schema(run) + if public_schema: + return public_schema + return [ + field_name + for field_name in list((run or {}).get('output_schema') or []) + if not is_analysis_internal_lineage_field(field_name) + ] + + def _build_generation_progress_contract_fields(run, completed_batches, processed_rows): batch_count = _safe_int((run or {}).get('batch_count')) normalized_completed_batches = _safe_int(completed_batches) @@ -1440,6 +1892,24 @@ def _serialize_generated_output_value(value): return neutralize_csv_spreadsheet_formula(value) +def _sanitize_generated_xml_tag_name(value, fallback_value='Field'): + normalized_name = re.sub(r'[^A-Za-z0-9_.-]+', '_', str(value or '').strip()).strip('._-') + if not normalized_name: + normalized_name = fallback_value + if not re.match(r'^[A-Za-z_]', normalized_name): + normalized_name = f'{fallback_value}_{normalized_name}' + return normalized_name + + +def _write_generated_xml_row(output_stream, row): + output_stream.write(' \n') + for field_name, field_value in (row or {}).items(): + tag_name = _sanitize_generated_xml_tag_name(field_name) + serialized_value = _serialize_generated_output_value(field_value) + output_stream.write(f' <{tag_name}>{escape_xml_text(serialized_value)}\n') + output_stream.write(' \n') + + def _normalize_source_identity_label(value): return re.sub(r'[^a-z0-9]+', '', str(value or '').strip().casefold()) @@ -1874,8 +2344,16 @@ def _input_batches_blob_path(user_id, conversation_id, run_id): return f"{user_id}/{conversation_id}/generated/tabular_runs/{run_id}/input/input_batches.json" -def _tabular_generation_plan_blob_path(user_id, conversation_id, run_id): - return f"{user_id}/{conversation_id}/generated/tabular_runs/{run_id}/plan/plan_v1.json" +def _tabular_generation_plan_blob_path(user_id, conversation_id, run_id, plan_version=None): + normalized_version = _safe_int( + plan_version, + default=TABULAR_GENERATION_PLAN_VERSION, + minimum=1, + ) + return ( + f"{user_id}/{conversation_id}/generated/tabular_runs/{run_id}/plan/" + f"plan_v{normalized_version}.json" + ) def _chunk_manifest_blob_prefix(user_id, conversation_id, run_id): @@ -1898,6 +2376,13 @@ def _output_summary_blob_path(user_id, conversation_id, run_id, batch_number): return f"{user_id}/{conversation_id}/generated/tabular_runs/{run_id}/summary/batch_{batch_number:06d}.json" +def _semantic_candidate_blob_path(user_id, conversation_id, run_id, batch_number): + return ( + f"{user_id}/{conversation_id}/generated/tabular_runs/{run_id}/semantic/candidates/" + f"batch_{batch_number:06d}.json" + ) + + def _retry_blob_path(user_id, conversation_id, run_id, batch_number): return f"{user_id}/{conversation_id}/generated/tabular_runs/{run_id}/retry/batch_{batch_number:06d}.json" @@ -3723,16 +4208,88 @@ def _build_tabular_generation_plan_prompt(run, input_contract): f"Requested output format: {str((run or {}).get('output_format') or '').strip().lower()}\n" f"Source row count: {_safe_int((run or {}).get('row_count'), minimum=0)}\n" f'Bounded input schema and redacted value shapes:\n{_dump_generated_output_json(planner_input)}\n\n' - 'Return ONLY one JSON object with output_fields and optional output_verbosity. ' + 'Return ONLY one JSON object with output_fields, transformation_spec, and optional output_verbosity. ' 'output_fields must be a non-empty array in exact output order. Each field object must contain ' - 'name, description, type, nullable, and source. source must be "llm". Supported types are ' + 'name, description, type, nullable, and source. source must be "server" only when the field is ' + 'fully represented by a deterministic transformation expression; otherwise source must be "llm". ' + 'Supported types are ' 'string, integer, number, boolean, object, and array. Do not include source_row_number, ' 'source_row_identity, or any __simplechat fields; the server adds source metadata. ' + 'transformation_spec must use version tabular-transform-v1 and contain exactly one field descriptor ' + 'for every output field. Deterministic expressions may use only copy, case, coalesce, comparisons, ' + 'boolean all/any/not, membership, null checks, and bounded arithmetic. Preserve ordered condition ' + 'precedence and explicit inclusive or exclusive boundaries. Mark genuinely interpretive fields semantic. ' 'Preserve every explicit output field requested by the user. Do not answer any source row, ' 'copy sample content, include markdown, or add other top-level properties.' ) +def _build_tabular_generation_plan_review_prompt(run, input_contract, plan): + user_question = str((run or {}).get('user_question') or '').strip() + if not user_question or len(user_question) > TABULAR_GENERATION_PLAN_MAX_QUESTION_CHARS: + raise ValueError('Reviewer user instructions are empty or exceed the bounded planning limit') + review_contract = { + 'columns': input_contract.get('columns') or [], + 'output_fields': _get_tabular_generation_plan_public_fields(plan), + 'transformation_spec': (plan or {}).get('transformation_spec') or {}, + } + return ( + 'Review this tabular generation plan against the user instructions.\n\n' + f'User instructions:\n{user_question}\n\n' + f'Normalized plan and source schema:\n{_dump_generated_output_json(review_contract)}\n\n' + 'Return ONLY one JSON object with status, represented_fields, and reason_codes. ' + 'status must be passed or failed. represented_fields must list every requested public output field ' + 'exactly once in output order. Use reason codes only from: boundary_ambiguous, field_missing, ' + 'precedence_mismatch, rule_missing, unknown_source_field, unrequested_inference, unsupported_rule. ' + 'Fail when a requested field or rule is absent, precedence or date boundaries changed, a source field ' + 'is unknown, or the plan added an unrequested inference. Verify every deterministic field uses only ' + 'the supported expression graph and valid source or prior deterministic field references. Verify fields ' + 'marked semantic genuinely require interpretation rather than a direct copy or representable rule. ' + 'Do not answer rows or include explanations.' + ) + + +async def _generate_tabular_generation_plan_review( + chat_service, + run, + input_contract, + plan, + reviewer_model, + timeout_seconds, +): + review_prompt = _build_tabular_generation_plan_review_prompt(run, input_contract, plan) + chat_history = SKChatHistory() + chat_history.add_system_message( + 'You independently verify bounded tabular transformation plans. Return only the requested JSON review.' + ) + chat_history.add_user_message(review_prompt) + execution_settings = AzureChatPromptExecutionSettings( + service_id='tabular-generated-output-plan-review' + ) + review_started_at = time.monotonic() + result = await asyncio.wait_for( + chat_service.get_chat_message_contents(chat_history, execution_settings), + timeout=timeout_seconds, + ) + review_latency_seconds = time.monotonic() - review_started_at + raw_response_content = result[0].content if result and result[0].content else '' + review_payload = _parse_generated_json_object(raw_response_content) + reviewed_plan = _finalize_tabular_generation_plan_review( + plan, + review_payload, + reviewer_model, + ) + usage = _extract_tabular_response_usage(result) + return reviewed_plan, { + 'latency_seconds': round(review_latency_seconds, 3), + 'input_char_count': len(review_prompt), + 'response_char_count': len(raw_response_content), + 'input_token_count': usage.get('input_token_count'), + 'output_token_count': usage.get('output_token_count'), + 'total_token_count': usage.get('total_token_count'), + } + + async def _generate_tabular_generation_plan( chat_service, run, @@ -3778,6 +4335,14 @@ async def _generate_tabular_generation_plan( input_contract, planner_model, ) + plan, review_metrics = await _generate_tabular_generation_plan_review( + chat_service, + run, + input_contract, + plan, + planner_model, + bounded_timeout_seconds, + ) usage = _extract_tabular_response_usage(result) metrics = { 'attempt_count': attempt_number, @@ -3788,6 +4353,12 @@ async def _generate_tabular_generation_plan( 'input_token_count': usage.get('input_token_count'), 'output_token_count': usage.get('output_token_count'), 'total_token_count': usage.get('total_token_count'), + 'review_latency_seconds': review_metrics.get('latency_seconds'), + 'review_input_char_count': review_metrics.get('input_char_count'), + 'review_response_char_count': review_metrics.get('response_char_count'), + 'review_input_token_count': review_metrics.get('input_token_count'), + 'review_output_token_count': review_metrics.get('output_token_count'), + 'review_total_token_count': review_metrics.get('total_token_count'), } return plan, metrics except asyncio.TimeoutError as exc: @@ -3820,6 +4391,48 @@ def _apply_active_tabular_generation_plan(run, plan): if current_output_schema and current_output_schema != planned_output_schema: raise ValueError('Active generation plan schema does not match the persisted run schema') run['output_schema'] = planned_output_schema + run['lineage_schema'] = _get_tabular_run_lineage_schema(run) + run['public_output_schema'] = [ + str(output_field.get('name') or '').strip() + for output_field in _get_tabular_generation_plan_public_fields(plan) + ] + run['internal_checkpoint_schema'] = planned_output_schema + run['transformation_spec'] = dict((plan or {}).get('transformation_spec') or {}) + planner_metadata = ( + (run or {}).get('tabular_planner_metadata') + if isinstance((run or {}).get('tabular_planner_metadata'), dict) + else {} + ) + deliverable_contract = ( + planner_metadata.get('deliverable_contract') + if isinstance(planner_metadata.get('deliverable_contract'), dict) + else {} + ) + current_plan = _safe_int((plan or {}).get('version')) == TABULAR_GENERATION_PLAN_VERSION + if current_plan and not deliverable_contract: + raise ValueError('Active generation plan v2 requires an initialized deliverable contract') + if deliverable_contract and current_plan: + transformation_spec = dict(run.get('transformation_spec') or {}) + mode_counts = dict(transformation_spec.get('field_mode_counts') or {}) + deterministic_count = _safe_int(mode_counts.get('deterministic'), minimum=0) + semantic_count = _safe_int(mode_counts.get('semantic'), minimum=0) + hybrid_count = _safe_int(mode_counts.get('hybrid'), minimum=0) + if deterministic_count and not semantic_count and not hybrid_count: + transformation_mode = 'deterministic' + elif deterministic_count or hybrid_count: + transformation_mode = 'hybrid' + else: + transformation_mode = 'semantic' + deliverable_contract.update({ + 'public_output_schema': list(run.get('public_output_schema') or []), + 'internal_checkpoint_schema': list(planned_output_schema), + 'lineage_schema': _get_tabular_run_lineage_schema(run), + 'transformation_mode': transformation_mode, + 'transformation_spec': transformation_spec, + 'validation_profile': 'exact_rows_schema_and_rules', + }) + planner_metadata['deliverable_contract'] = deliverable_contract + run['tabular_planner_metadata'] = planner_metadata def _recover_tabular_generation_plan(run, input_contract, plan_blob_path, plan_mode): @@ -3874,119 +4487,499 @@ def _mark_tabular_generation_plan_fallback(run, reason, attempt_count=0, latency return persisted_run -def _ensure_tabular_generation_plan( - run, - chat_service, - input_batches, - settings, - batch_timeout_seconds, -): - plan_mode = _get_tabular_generation_plan_mode(run) - task_type = _normalize_tabular_run_task_type((run or {}).get('task_type')) - if ( - task_type not in {TABULAR_RUN_TASK_STRUCTURED_EXPORT, TABULAR_RUN_TASK_COMBINED} - or (run or {}).get('passthrough_input_rows') - or chat_service is None - ): - if (run or {}).get('plan_status') not in {'ready', 'fallback', 'not_applicable'}: - run.update({ - 'plan_mode': 'off', - 'plan_status': 'not_applicable', - 'updated_at': _now_iso(), - }) - return _replace_claimed_run(run) - return run - if plan_mode == 'off' and not ((run or {}).get('plan_blob_path') or (run or {}).get('plan_hash')): - if (run or {}).get('plan_status') not in {'disabled', 'fallback'}: - run.update({ - 'plan_mode': 'off', - 'plan_status': 'disabled', - 'updated_at': _now_iso(), - }) - return _replace_claimed_run(run) - return run - if plan_mode == 'shadow' and not ((run or {}).get('plan_blob_path') or (run or {}).get('plan_hash')): - if (run or {}).get('plan_status') != 'deferred': - now = _now_iso() - run.update({ - 'plan_mode': 'shadow', - 'plan_status': 'deferred', - 'plan_failure_reason': 'deferred_off_critical_path', - 'planner_attempt_count': 0, - 'planner_latency_seconds': 0, - 'planner_model_latency_seconds': 0, - 'planner_started_at': None, - 'planner_completed_at': None, - 'updated_at': now, - 'last_heartbeat_at': now, - 'last_message': 'Generating the initial schema checkpoint', - }) - return _replace_claimed_run(run) - return run - - sample_rows = _load_tabular_generation_plan_sample_rows(run, input_batches) - input_contract = _build_tabular_generation_plan_input_contract(sample_rows) - plan_blob_path = _tabular_generation_plan_blob_path( - (run or {}).get('user_id'), - (run or {}).get('conversation_id'), - (run or {}).get('id'), - ) - stored_plan_blob_path = str((run or {}).get('plan_blob_path') or '').strip() - if stored_plan_blob_path and stored_plan_blob_path != plan_blob_path: - raise ValueError('Stored generation plan path does not match the run identity') - if _blob_exists(plan_blob_path): - recovered_run, _ = _recover_tabular_generation_plan( - run, - input_contract, - plan_blob_path, - plan_mode, - ) - return recovered_run - if stored_plan_blob_path or (run or {}).get('plan_hash'): - raise ValueError('Stored generation plan blob is missing') - if (run or {}).get('plan_status') == 'fallback': - return run - if (run or {}).get('plan_status') == 'planning': - return _mark_tabular_generation_plan_fallback(run, 'interrupted_before_persistence') - - planner_model = _resolve_tabular_generation_planner_model(run, settings) +def _fail_active_tabular_generation_plan(run, reason, attempt_count=0, latency_seconds=None): now = _now_iso() run.update({ - 'plan_mode': plan_mode, - 'plan_status': 'planning', - 'plan_failure_reason': None, - 'planner_model': planner_model, - 'planner_started_at': now, + 'plan_status': 'failed', + 'plan_failure_reason': str(reason or 'provider_failure'), + 'planner_attempt_count': _safe_int(attempt_count, minimum=0), + 'planner_latency_seconds': latency_seconds, + 'planner_completed_at': now, 'updated_at': now, 'last_heartbeat_at': now, + 'last_message': 'Reviewed generation planning failed; required output was not generated', }) - run = _replace_claimed_run(run) - try: - plan, metrics = asyncio.run(_generate_tabular_generation_plan( - chat_service, - run, - input_contract, - planner_model, - batch_timeout_seconds, - )) - except TabularGenerationPlanError as exc: - return _mark_tabular_generation_plan_fallback( - run, - exc.reason, - attempt_count=TABULAR_GENERATION_PLAN_DEFAULT_RETRY_ATTEMPTS, - ) - except ValueError: - return _mark_tabular_generation_plan_fallback(run, 'invalid_input') + persisted_run = _replace_claimed_run(run) + raise TabularGenerationPlanError( + str(reason or 'provider_failure'), + failed_run=persisted_run, + ) - try: - _upload_json_blob( - plan_blob_path, - plan, - metadata={ - 'run_id': run.get('id'), - 'conversation_id': run.get('conversation_id'), - 'generation_plan': 'true', - 'plan_hash': plan.get('plan_hash'), + +def _get_tabular_semantic_validation_options(run): + rollout_settings = _get_tabular_generation_rollout_settings_for_run(run, {}) + return { + 'mode': str(rollout_settings.get('tabular_semantic_validation_mode') or 'off').strip().lower(), + 'max_repair_attempts': _safe_int( + rollout_settings.get('tabular_semantic_repair_max_attempts'), + default=TABULAR_SEMANTIC_DEFAULT_REPAIR_ATTEMPTS, + minimum=0, + maximum=5, + ), + 'max_repair_rows': _safe_int( + rollout_settings.get('tabular_semantic_repair_max_rows'), + default=TABULAR_SEMANTIC_DEFAULT_MAX_REPAIR_ROWS, + minimum=1, + maximum=500, + ), + } + + +def _get_tabular_semantic_checkpoint_contract_hash(run): + plan_hash = str((run or {}).get('plan_hash') or '').strip() + if plan_hash: + return plan_hash + planner_metadata = ( + (run or {}).get('tabular_planner_metadata') + if isinstance((run or {}).get('tabular_planner_metadata'), dict) + else {} + ) + deliverable_contract = ( + planner_metadata.get('deliverable_contract') + if isinstance(planner_metadata.get('deliverable_contract'), dict) + else {} + ) + transformation_spec = (run or {}).get('transformation_spec') or deliverable_contract.get('transformation_spec') + if not isinstance(transformation_spec, dict) or not transformation_spec: + return '' + fingerprint_payload = { + 'transformation_spec': transformation_spec, + 'public_output_schema': list( + (run or {}).get('public_output_schema') + or deliverable_contract.get('public_output_schema') + or [] + ), + 'request_fingerprint': str(deliverable_contract.get('request_fingerprint') or '').strip(), + } + return hashlib.sha256( + json.dumps( + fingerprint_payload, + ensure_ascii=False, + separators=(',', ':'), + sort_keys=True, + ).encode('utf-8') + ).hexdigest() + + +def _build_tabular_semantic_checkpoint_context(run, batch_number): + return { + 'user_id': str((run or {}).get('user_id') or '').strip(), + 'conversation_id': str((run or {}).get('conversation_id') or '').strip(), + 'run_id': str((run or {}).get('id') or '').strip(), + 'batch_number': _safe_int(batch_number, minimum=1), + 'plan_hash': _get_tabular_semantic_checkpoint_contract_hash(run), + } + + +def _load_tabular_semantic_candidate_checkpoint( + checkpoint_context, + expected_output_schema, + expected_row_count, +): + context = checkpoint_context if isinstance(checkpoint_context, dict) else {} + if not all(context.get(field_name) for field_name in ('user_id', 'conversation_id', 'run_id', 'plan_hash')): + return None + blob_path = _semantic_candidate_blob_path( + context['user_id'], + context['conversation_id'], + context['run_id'], + context['batch_number'], + ) + if not _blob_exists(blob_path): + return None + payload = _download_json_blob(blob_path) + if not isinstance(payload, dict) or set(payload) != { + 'version', + 'plan_hash', + 'output_schema', + 'rows', + 'validation_counts', + 'repair_attempt_count', + }: + raise ValueError('Semantic candidate checkpoint shape is invalid') + if _safe_int(payload.get('version')) != TABULAR_SEMANTIC_CANDIDATE_CHECKPOINT_VERSION: + raise ValueError('Semantic candidate checkpoint version is unsupported') + if str(payload.get('plan_hash') or '').strip() != context['plan_hash']: + raise ValueError('Semantic candidate checkpoint plan hash does not match') + output_schema = list(payload.get('output_schema') or []) + if output_schema != list(expected_output_schema or []): + raise ValueError('Semantic candidate checkpoint schema does not match') + rows = payload.get('rows') + if not isinstance(rows, list) or len(rows) != _safe_int(expected_row_count, minimum=0): + raise ValueError('Semantic candidate checkpoint row count does not match') + if any(not isinstance(row, dict) or list(row) != output_schema for row in rows): + raise ValueError('Semantic candidate checkpoint row schema does not match') + validation_counts = payload.get('validation_counts') + if not isinstance(validation_counts, dict): + raise ValueError('Semantic candidate checkpoint validation counts are invalid') + return { + 'rows': rows, + 'validation_counts': validation_counts, + 'repair_attempt_count': _safe_int(payload.get('repair_attempt_count'), minimum=0, maximum=5), + 'blob_path': blob_path, + } + + +def _persist_tabular_semantic_candidate_checkpoint( + checkpoint_context, + output_schema, + rows, + validation_counts=None, + repair_attempt_count=0, +): + context = checkpoint_context if isinstance(checkpoint_context, dict) else {} + if not all(context.get(field_name) for field_name in ('user_id', 'conversation_id', 'run_id', 'plan_hash')): + return None + normalized_schema = list(output_schema or []) + normalized_rows = [dict(row) for row in list(rows or [])] + if any(list(row) != normalized_schema for row in normalized_rows): + raise ValueError('Semantic candidate checkpoint row schema is invalid') + blob_path = _semantic_candidate_blob_path( + context['user_id'], + context['conversation_id'], + context['run_id'], + context['batch_number'], + ) + _upload_json_blob( + blob_path, + { + 'version': TABULAR_SEMANTIC_CANDIDATE_CHECKPOINT_VERSION, + 'plan_hash': context['plan_hash'], + 'output_schema': normalized_schema, + 'rows': normalized_rows, + 'validation_counts': dict(validation_counts or {}), + 'repair_attempt_count': _safe_int(repair_attempt_count, minimum=0, maximum=5), + }, + metadata={ + 'run_id': context['run_id'], + 'conversation_id': context['conversation_id'], + 'batch_number': context['batch_number'], + 'plan_hash': context['plan_hash'], + 'semantic_candidate': 'true', + }, + overwrite=True, + ) + return blob_path + + +def _build_tabular_semantic_field_guidance(generation_plan, transformation_spec): + descriptions = { + str(field.get('name') or '').strip(): str(field.get('description') or '').strip() + for field in _get_tabular_generation_plan_public_fields(generation_plan) + } + guidance = [] + for field in list((transformation_spec or {}).get('fields') or []): + if not isinstance(field, dict) or str(field.get('mode') or '').strip().lower() == 'deterministic': + continue + field_name = str(field.get('name') or '').strip() + guidance.append({ + 'name': field_name, + 'description': descriptions.get(field_name, ''), + 'type': str(field.get('type') or 'string').strip().lower(), + 'nullable': bool(field.get('nullable', True)), + 'allowed_values': list(field.get('allowed_values') or []), + }) + return guidance + + +def _build_tabular_semantic_verification_prompt( + user_question, + verification_request, + generation_plan, + transformation_spec, +): + payload = { + 'objective': str(user_question or '').strip()[:TABULAR_GENERATION_PLAN_MAX_QUESTION_CHARS], + 'fields': _build_tabular_semantic_field_guidance(generation_plan, transformation_spec), + 'rows': verification_request.get('rows') or [], + } + serialized_payload = _dump_generated_output_json(payload) + if len(serialized_payload) > TABULAR_SEMANTIC_MAX_PROMPT_CHARS: + raise ValueError('Semantic verification prompt exceeds the bounded limit') + return ( + 'Verify every semantic field against the bounded source evidence and objective below. ' + 'Return ONLY one JSON object with version and rows. Use version ' + f'{TABULAR_SEMANTIC_VALIDATION_CONTRACT_VERSION}. Each row must preserve row_key and contain ' + 'every semantic field exactly once with name, status, reason_code, and evidence_fields. ' + 'status must be pass, fail, uncertain, or unsupported. evidence_fields may contain only source ' + 'field names present in that row. Do not include reasoning, repaired values, markdown, or extra fields.\n\n' + f'{serialized_payload}' + ) + + +def _build_tabular_semantic_repair_prompt( + user_question, + verification_request, + repair_targets, + attempt_number, + generation_plan, + transformation_spec, +): + target_keys = { + (str(target.get('row_key') or ''), str(target.get('field_name') or '')) + for target in list(repair_targets or []) + } + target_row_keys = {row_key for row_key, _ in target_keys} + payload = { + 'objective': str(user_question or '').strip()[:TABULAR_GENERATION_PLAN_MAX_QUESTION_CHARS], + 'attempt': _safe_int(attempt_number, minimum=1, maximum=5), + 'fields': [ + field + for field in _build_tabular_semantic_field_guidance(generation_plan, transformation_spec) + if any(field['name'] == field_name for _, field_name in target_keys) + ], + 'targets': list(repair_targets or []), + 'rows': [ + row + for row in list(verification_request.get('rows') or []) + if row.get('row_key') in target_row_keys + ], + } + serialized_payload = _dump_generated_output_json(payload) + if len(serialized_payload) > TABULAR_SEMANTIC_MAX_PROMPT_CHARS: + raise ValueError('Semantic repair prompt exceeds the bounded limit') + return ( + 'Repair only the requested semantic row-field targets using the bounded source evidence and objective. ' + 'Return ONLY one JSON object with version and rows. Use version ' + f'{TABULAR_SEMANTIC_VALIDATION_CONTRACT_VERSION}. Each row must contain row_key and values, and ' + 'values must contain only targeted fields. Preserve declared types and allowed values. Do not return ' + 'untargeted fields, reasoning, markdown, or extra rows.\n\n' + f'{serialized_payload}' + ) + + +async def _invoke_tabular_semantic_model(chat_service, system_message, prompt, service_id, timeout_seconds): + chat_history = SKChatHistory() + chat_history.add_system_message(system_message) + chat_history.add_user_message(prompt) + execution_settings = AzureChatPromptExecutionSettings(service_id=service_id) + result = await asyncio.wait_for( + chat_service.get_chat_message_contents(chat_history, execution_settings), + timeout=max(0.001, _safe_float(timeout_seconds, default=TABULAR_EXPORT_DEFAULT_BATCH_TIMEOUT_SECONDS)), + ) + raw_response_content = result[0].content if result and result[0].content else '' + if not raw_response_content: + raise ValueError('Semantic validation model returned an empty response') + return _parse_generated_json_object(raw_response_content) + + +async def _verify_and_repair_tabular_batch_entries( + chat_service, + user_question, + source_rows, + output_rows, + transformation_spec, + generation_plan, + semantic_validation_options, + timeout_seconds, + semantic_checkpoint_context=None, +): + options = semantic_validation_options or {} + mode = str(options.get('mode') or 'off').strip().lower() + public_source_rows = [ + { + str(field_name): field_value + for field_name, field_value in source_row.items() + if not is_analysis_internal_lineage_field(field_name) + } + for source_row in list(source_rows or []) + ] + + async def invoke_verifier(verification_request): + return await _invoke_tabular_semantic_model( + chat_service, + 'You are an independent structured-data field verifier. Return only the requested JSON contract.', + _build_tabular_semantic_verification_prompt( + user_question, + verification_request, + generation_plan, + transformation_spec, + ), + 'tabular-generated-output-semantic-verifier', + timeout_seconds, + ) + + async def invoke_repair(verification_request, repair_targets, attempt_number): + return await _invoke_tabular_semantic_model( + chat_service, + 'You repair only explicitly failed structured-data fields. Return only the requested JSON contract.', + _build_tabular_semantic_repair_prompt( + user_question, + verification_request, + repair_targets, + attempt_number, + generation_plan, + transformation_spec, + ), + 'tabular-generated-output-semantic-repair', + timeout_seconds, + ) + + async def checkpoint_candidate(rows, validation_counts, attempt_number): + if not rows: + return + await asyncio.to_thread( + _persist_tabular_semantic_candidate_checkpoint, + semantic_checkpoint_context, + list(rows[0]), + rows, + validation_counts, + attempt_number, + ) + + return await verify_and_repair_semantic_rows( + public_source_rows, + output_rows, + transformation_spec, + mode, + invoke_verifier, + invoke_repair, + max_repair_attempts=options.get('max_repair_attempts'), + max_repair_rows=options.get('max_repair_rows'), + checkpoint_candidate=checkpoint_candidate, + ) + + +def _ensure_tabular_generation_plan( + run, + chat_service, + input_batches, + settings, + batch_timeout_seconds, +): + plan_mode = _get_tabular_generation_plan_mode(run) + task_type = _normalize_tabular_run_task_type((run or {}).get('task_type')) + if ( + task_type not in {TABULAR_RUN_TASK_STRUCTURED_EXPORT, TABULAR_RUN_TASK_COMBINED} + or (run or {}).get('passthrough_input_rows') + or ( + bool(_get_tabular_run_transformation_spec(run)) + and not ((run or {}).get('plan_blob_path') or (run or {}).get('plan_hash')) + ) + or chat_service is None + ): + if (run or {}).get('plan_status') not in {'ready', 'fallback', 'not_applicable'}: + run.update({ + 'plan_mode': 'off', + 'plan_status': 'not_applicable', + 'updated_at': _now_iso(), + }) + return _replace_claimed_run(run) + return run + if plan_mode == 'off' and not ((run or {}).get('plan_blob_path') or (run or {}).get('plan_hash')): + if (run or {}).get('plan_status') not in {'disabled', 'fallback'}: + run.update({ + 'plan_mode': 'off', + 'plan_status': 'disabled', + 'updated_at': _now_iso(), + }) + return _replace_claimed_run(run) + return run + if plan_mode == 'shadow' and not ((run or {}).get('plan_blob_path') or (run or {}).get('plan_hash')): + if (run or {}).get('plan_status') != 'deferred': + now = _now_iso() + run.update({ + 'plan_mode': 'shadow', + 'plan_status': 'deferred', + 'plan_failure_reason': 'deferred_off_critical_path', + 'planner_attempt_count': 0, + 'planner_latency_seconds': 0, + 'planner_model_latency_seconds': 0, + 'planner_started_at': None, + 'planner_completed_at': None, + 'updated_at': now, + 'last_heartbeat_at': now, + 'last_message': 'Generating the initial schema checkpoint', + }) + return _replace_claimed_run(run) + return run + + sample_rows = _load_tabular_generation_plan_sample_rows(run, input_batches) + input_contract = _build_tabular_generation_plan_input_contract(sample_rows) + stored_plan_blob_path = str((run or {}).get('plan_blob_path') or '').strip() + current_plan_blob_path = _tabular_generation_plan_blob_path( + (run or {}).get('user_id'), + (run or {}).get('conversation_id'), + (run or {}).get('id'), + ) + legacy_plan_blob_paths = { + _tabular_generation_plan_blob_path( + (run or {}).get('user_id'), + (run or {}).get('conversation_id'), + (run or {}).get('id'), + plan_version=legacy_version, + ) + for legacy_version in TABULAR_GENERATION_PLAN_LEGACY_VERSIONS + } + if stored_plan_blob_path and stored_plan_blob_path not in { + current_plan_blob_path, + *legacy_plan_blob_paths, + }: + raise ValueError('Stored generation plan path does not match the run identity') + plan_blob_path = stored_plan_blob_path or current_plan_blob_path + if _blob_exists(plan_blob_path): + recovered_run, _ = _recover_tabular_generation_plan( + run, + input_contract, + plan_blob_path, + plan_mode, + ) + return recovered_run + if stored_plan_blob_path or (run or {}).get('plan_hash'): + raise ValueError('Stored generation plan blob is missing') + if (run or {}).get('plan_status') == 'fallback': + return run + if (run or {}).get('plan_status') == 'planning': + if plan_mode == 'active': + return _fail_active_tabular_generation_plan(run, 'interrupted_before_persistence') + return _mark_tabular_generation_plan_fallback(run, 'interrupted_before_persistence') + + planner_model = _resolve_tabular_generation_planner_model(run, settings) + now = _now_iso() + run.update({ + 'plan_mode': plan_mode, + 'plan_status': 'planning', + 'plan_failure_reason': None, + 'planner_model': planner_model, + 'planner_started_at': now, + 'updated_at': now, + 'last_heartbeat_at': now, + }) + run = _replace_claimed_run(run) + try: + plan, metrics = asyncio.run(_generate_tabular_generation_plan( + chat_service, + run, + input_contract, + planner_model, + batch_timeout_seconds, + )) + except TabularGenerationPlanError as exc: + if plan_mode == 'active': + return _fail_active_tabular_generation_plan( + run, + exc.reason, + attempt_count=TABULAR_GENERATION_PLAN_DEFAULT_RETRY_ATTEMPTS, + ) + return _mark_tabular_generation_plan_fallback( + run, + exc.reason, + attempt_count=TABULAR_GENERATION_PLAN_DEFAULT_RETRY_ATTEMPTS, + ) + except ValueError: + if plan_mode == 'active': + return _fail_active_tabular_generation_plan(run, 'invalid_input') + return _mark_tabular_generation_plan_fallback(run, 'invalid_input') + + try: + _upload_json_blob( + plan_blob_path, + plan, + metadata={ + 'run_id': run.get('id'), + 'conversation_id': run.get('conversation_id'), + 'generation_plan': 'true', + 'plan_hash': plan.get('plan_hash'), 'source_etag': str((plan.get('source') or {}).get('blob_etag') or '').strip('"'), 'contract_version': TABULAR_GENERATION_PLAN_VERSION, }, @@ -4011,11 +5004,17 @@ def _ensure_tabular_generation_plan( 'planner_attempt_count': metrics.get('attempt_count'), 'planner_latency_seconds': metrics.get('latency_seconds'), 'planner_model_latency_seconds': metrics.get('model_latency_seconds'), + 'planner_review_latency_seconds': metrics.get('review_latency_seconds'), 'planner_input_char_count': metrics.get('input_char_count'), 'planner_response_char_count': metrics.get('response_char_count'), 'planner_input_token_count': metrics.get('input_token_count'), 'planner_output_token_count': metrics.get('output_token_count'), 'planner_total_token_count': metrics.get('total_token_count'), + 'planner_review_input_char_count': metrics.get('review_input_char_count'), + 'planner_review_response_char_count': metrics.get('review_response_char_count'), + 'planner_review_input_token_count': metrics.get('review_input_token_count'), + 'planner_review_output_token_count': metrics.get('review_output_token_count'), + 'planner_review_total_token_count': metrics.get('review_total_token_count'), 'planner_completed_at': plan.get('created_at'), 'updated_at': _now_iso(), 'last_heartbeat_at': _now_iso(), @@ -4034,6 +5033,10 @@ def _ensure_tabular_generation_plan( 'planner_input_token_count': persisted_run.get('planner_input_token_count'), 'planner_output_token_count': persisted_run.get('planner_output_token_count'), 'planner_total_token_count': persisted_run.get('planner_total_token_count'), + 'planner_review_latency_seconds': persisted_run.get('planner_review_latency_seconds'), + 'planner_review_input_token_count': persisted_run.get('planner_review_input_token_count'), + 'planner_review_output_token_count': persisted_run.get('planner_review_output_token_count'), + 'planner_review_total_token_count': persisted_run.get('planner_review_total_token_count'), }, level=logging.INFO, ) @@ -4054,10 +5057,54 @@ async def _generate_batch_entries( batch_timeout_seconds=TABULAR_EXPORT_DEFAULT_BATCH_TIMEOUT_SECONDS, response_protocol=TABULAR_RESPONSE_PROTOCOL_OBJECT_V1, generation_plan=None, + transformation_spec=None, + semantic_validation_options=None, + semantic_checkpoint_context=None, ): batch_number = batch_index + 1 normalized_response_protocol = str(response_protocol or TABULAR_RESPONSE_PROTOCOL_OBJECT_V1).strip() compact_protocol = _is_compact_row_array_protocol(normalized_response_protocol) + model_expected_output_schema = _build_model_expected_output_schema( + expected_output_schema, + transformation_spec=transformation_spec, + ) + semantic_mode = str((semantic_validation_options or {}).get('mode') or 'off').strip().lower() + candidate_checkpoint = None + if transformation_spec and semantic_mode in {'shadow', 'active'} and expected_output_schema: + candidate_checkpoint = _load_tabular_semantic_candidate_checkpoint( + semantic_checkpoint_context, + expected_output_schema, + len(batch_rows), + ) + if candidate_checkpoint: + resumed_entries = list(candidate_checkpoint['rows']) + ( + resumed_entries, + semantic_validation_counts, + semantic_validation_attempts, + ) = await _verify_and_repair_tabular_batch_entries( + chat_service, + user_question, + batch_rows, + resumed_entries, + transformation_spec, + generation_plan, + semantic_validation_options, + batch_timeout_seconds, + semantic_checkpoint_context=semantic_checkpoint_context, + ) + return resumed_entries, 0, list(expected_output_schema), { + 'input_char_count': 0, + 'response_char_count': 0, + 'model_latency_seconds': 0, + 'validation_seconds': None, + 'input_token_count': 0, + 'output_token_count': 0, + 'total_token_count': 0, + 'semantic_validation_counts': semantic_validation_counts, + 'semantic_validation_attempts': semantic_validation_attempts, + 'semantic_candidate_reused': True, + } batch_prompt = _build_batch_prompt( user_question, batch_rows, @@ -4065,7 +5112,7 @@ async def _generate_batch_entries( total_batches, source_file_name, selected_sheet=selected_sheet, - output_schema=expected_output_schema, + output_schema=model_expected_output_schema, response_protocol=normalized_response_protocol, generation_plan=generation_plan, ) @@ -4160,15 +5207,50 @@ async def _generate_batch_entries( normalized_entries, output_schema = _normalize_model_generated_batch_entries( batch_rows, parsed_entries, - expected_output_schema=expected_output_schema, + expected_output_schema=model_expected_output_schema, allow_source_token_recovery=not compact_protocol, run_id=run_id, batch_number=batch_number, ) + normalized_entries, output_schema = _merge_deterministic_transformation_entries( + batch_rows, + normalized_entries, + expected_output_schema or output_schema, + transformation_spec=transformation_spec, + ) + semantic_validation_counts = {} + semantic_validation_attempts = [] + if transformation_spec: + if semantic_mode in {'shadow', 'active'}: + await asyncio.to_thread( + _persist_tabular_semantic_candidate_checkpoint, + semantic_checkpoint_context, + output_schema, + normalized_entries, + {}, + 0, + ) + ( + normalized_entries, + semantic_validation_counts, + semantic_validation_attempts, + ) = await _verify_and_repair_tabular_batch_entries( + chat_service, + user_question, + batch_rows, + normalized_entries, + transformation_spec, + generation_plan, + semantic_validation_options, + timeout_seconds, + semantic_checkpoint_context=semantic_checkpoint_context, + ) last_attempt_metrics['validation_seconds'] = round( time.monotonic() - validation_started_at, 3, ) + last_attempt_metrics['semantic_validation_counts'] = semantic_validation_counts + last_attempt_metrics['semantic_validation_attempts'] = semantic_validation_attempts return normalized_entries, mismatch_count, output_schema, last_attempt_metrics except ValueError as exc: last_validation_error = str(exc) @@ -4220,6 +5302,9 @@ async def _generate_batch_entries_for_window( batch_timeout_seconds, response_protocol, generation_plan, + transformation_spec, + semantic_validation_options, + semantic_checkpoint_context, ): queued_at = time.monotonic() async with semaphore: @@ -4239,8 +5324,13 @@ async def _generate_batch_entries_for_window( batch_timeout_seconds=batch_timeout_seconds, response_protocol=response_protocol, generation_plan=generation_plan, + transformation_spec=transformation_spec, + semantic_validation_options=semantic_validation_options, + semantic_checkpoint_context=semantic_checkpoint_context, ) elapsed_seconds = time.monotonic() - batch_started_at + semantic_validation_counts = dict(attempt_metrics.get('semantic_validation_counts') or {}) + semantic_validation_attempts = list(attempt_metrics.get('semantic_validation_attempts') or [])[:5] log_event( '[TABULAR_GENERATED_OUTPUT] Background export batch model completed', { @@ -4259,13 +5349,25 @@ async def _generate_batch_entries_for_window( 'output_token_count': attempt_metrics.get('output_token_count'), 'total_token_count': attempt_metrics.get('total_token_count'), 'mismatch_count': mismatch_count, + 'semantic_pass_count': _safe_int(semantic_validation_counts.get('pass_count')), + 'semantic_fail_count': _safe_int(semantic_validation_counts.get('fail_count')), + 'semantic_uncertain_count': _safe_int(semantic_validation_counts.get('uncertain_count')), + 'semantic_unsupported_count': _safe_int(semantic_validation_counts.get('unsupported_count')), + 'semantic_repair_target_count': _safe_int(semantic_validation_counts.get('repair_target_count')), + 'semantic_repair_attempt_count': _safe_int(semantic_validation_counts.get('repair_attempt_count')), }, debug_only=True, ) + batch_summary = _build_generated_batch_summary(batch_entries) + if semantic_validation_counts: + batch_summary['semantic_validation'] = { + 'final': semantic_validation_counts, + 'attempts': semantic_validation_attempts, + } return { 'batch_number': batch_request['batch_number'], 'batch_entries': batch_entries, - 'batch_summary': _build_generated_batch_summary(batch_entries), + 'batch_summary': batch_summary, 'batch_row_count': len(batch_entries), 'elapsed_seconds': elapsed_seconds, 'queue_wait_seconds': queue_wait_seconds, @@ -4278,6 +5380,7 @@ async def _generate_batch_entries_for_window( 'total_token_count': attempt_metrics.get('total_token_count'), 'mismatch_count': mismatch_count, 'output_schema': output_schema, + 'semantic_validation_counts': semantic_validation_counts, } @@ -4295,6 +5398,9 @@ async def _generate_batch_window_entries( batch_timeout_seconds=TABULAR_EXPORT_DEFAULT_BATCH_TIMEOUT_SECONDS, response_protocol=TABULAR_RESPONSE_PROTOCOL_OBJECT_V1, generation_plan=None, + transformation_spec=None, + semantic_validation_options=None, + semantic_checkpoint_run=None, ): semaphore = asyncio.Semaphore(max(1, batch_concurrency)) tasks = [ @@ -4312,6 +5418,12 @@ async def _generate_batch_window_entries( batch_timeout_seconds, response_protocol, generation_plan, + transformation_spec, + semantic_validation_options, + _build_tabular_semantic_checkpoint_context( + semantic_checkpoint_run, + batch_request['batch_number'], + ), ) for batch_request in batch_requests ] @@ -4388,6 +5500,8 @@ async def _generate_and_checkpoint_batch_window_entries( batch_timeout_seconds=TABULAR_EXPORT_DEFAULT_BATCH_TIMEOUT_SECONDS, response_protocol=TABULAR_RESPONSE_PROTOCOL_OBJECT_V1, generation_plan=None, + transformation_spec=None, + semantic_validation_options=None, ): semaphore = asyncio.Semaphore(max(1, batch_concurrency)) writer_semaphore = asyncio.Semaphore(max(1, checkpoint_writer_concurrency)) @@ -4407,6 +5521,12 @@ async def _generate_and_checkpoint_batch_window_entries( batch_timeout_seconds, response_protocol, generation_plan, + transformation_spec, + semantic_validation_options, + _build_tabular_semantic_checkpoint_context( + run, + batch_request['batch_number'], + ), ) ) for batch_request in batch_requests @@ -4498,6 +5618,8 @@ async def _generate_and_checkpoint_rolling_pool_entries( batch_timeout_seconds=TABULAR_EXPORT_DEFAULT_BATCH_TIMEOUT_SECONDS, response_protocol=TABULAR_RESPONSE_PROTOCOL_OBJECT_V1, generation_plan=None, + transformation_spec=None, + semantic_validation_options=None, ): model_semaphore = asyncio.Semaphore(max(1, batch_concurrency)) writer_semaphore = asyncio.Semaphore(max(1, checkpoint_writer_concurrency)) @@ -4711,6 +5833,9 @@ def build_pending_batch_task(batch_number): batch_timeout_seconds, response_protocol, generation_plan, + transformation_spec, + semantic_validation_options, + _build_tabular_semantic_checkpoint_context(run, batch_number), ) ) active_batch_requests[model_task] = batch_request @@ -5014,9 +6139,18 @@ async def _generate_combined_chunk_result( retry_attempts, batch_timeout_seconds, expected_output_schema=None, + transformation_spec=None, + generation_plan=None, + semantic_validation_options=None, ): batch_number = batch_request['batch_number'] batch_rows = batch_request['rows'] + semantic_checkpoint_context = _build_tabular_semantic_checkpoint_context(run, batch_number) + semantic_mode = str((semantic_validation_options or {}).get('mode') or 'off').strip().lower() + model_expected_output_schema = _build_model_expected_output_schema( + expected_output_schema, + transformation_spec=transformation_spec, + ) timeout_seconds = max( _safe_float( batch_timeout_seconds, @@ -5045,7 +6179,7 @@ async def _generate_combined_chunk_result( batch_rows, batch_number, total_batches, - output_schema=expected_output_schema, + output_schema=model_expected_output_schema, ) ) @@ -5069,9 +6203,58 @@ async def _generate_combined_chunk_result( parsed_payload, batch_rows, batch_number, - expected_output_schema=expected_output_schema, + expected_output_schema=model_expected_output_schema, + ) + normalized_entries, output_schema = _merge_deterministic_transformation_entries( + batch_rows, + normalized_entries, + expected_output_schema or output_schema, + transformation_spec=transformation_spec, + ) + candidate_checkpoint = None + if transformation_spec and semantic_mode in {'shadow', 'active'}: + candidate_checkpoint = _load_tabular_semantic_candidate_checkpoint( + semantic_checkpoint_context, + output_schema, + len(batch_rows), + ) + if candidate_checkpoint: + normalized_entries = list(candidate_checkpoint['rows']) + elif transformation_spec and semantic_mode in {'shadow', 'active'}: + await asyncio.to_thread( + _persist_tabular_semantic_candidate_checkpoint, + semantic_checkpoint_context, + output_schema, + normalized_entries, + {}, + 0, + ) + semantic_validation_counts = {} + semantic_validation_attempts = [] + if transformation_spec: + ( + normalized_entries, + semantic_validation_counts, + semantic_validation_attempts, + ) = await _verify_and_repair_tabular_batch_entries( + chat_service, + run.get('user_question'), + batch_rows, + normalized_entries, + transformation_spec, + generation_plan, + semantic_validation_options, + timeout_seconds, + semantic_checkpoint_context=semantic_checkpoint_context, + ) + return ( + normalized_entries, + output_schema, + analysis_summary, + mismatch_count, + semantic_validation_counts, + semantic_validation_attempts, ) - return normalized_entries, output_schema, analysis_summary, mismatch_count except ValueError as exc: last_validation_error = str(exc) @@ -5106,10 +6289,20 @@ async def _generate_combined_chunk_result_for_window( retry_attempts, batch_timeout_seconds, expected_output_schema, + transformation_spec, + generation_plan, + semantic_validation_options, ): async with semaphore: batch_started_at = time.monotonic() - batch_entries, output_schema, analysis_summary, mismatch_count = await _generate_combined_chunk_result( + ( + batch_entries, + output_schema, + analysis_summary, + mismatch_count, + semantic_validation_counts, + semantic_validation_attempts, + ) = await _generate_combined_chunk_result( chat_service, run, batch_request, @@ -5117,16 +6310,26 @@ async def _generate_combined_chunk_result_for_window( retry_attempts, batch_timeout_seconds, expected_output_schema=expected_output_schema, + transformation_spec=transformation_spec, + generation_plan=generation_plan, + semantic_validation_options=semantic_validation_options, ) + batch_summary = _build_generated_batch_summary(batch_entries) + if semantic_validation_counts: + batch_summary['semantic_validation'] = { + 'final': semantic_validation_counts, + 'attempts': semantic_validation_attempts[:5], + } return { 'batch_number': batch_request['batch_number'], 'batch_entries': batch_entries, - 'batch_summary': _build_generated_batch_summary(batch_entries), + 'batch_summary': batch_summary, 'analysis_summary': analysis_summary, 'batch_row_count': len(batch_entries), 'elapsed_seconds': time.monotonic() - batch_started_at, 'mismatch_count': mismatch_count, 'output_schema': output_schema, + 'semantic_validation_counts': semantic_validation_counts, } @@ -5139,6 +6342,9 @@ async def _generate_combined_chunk_result_window( batch_concurrency, batch_timeout_seconds, expected_output_schema=None, + transformation_spec=None, + generation_plan=None, + semantic_validation_options=None, ): semaphore = asyncio.Semaphore(max(1, batch_concurrency)) tasks = [ @@ -5151,6 +6357,9 @@ async def _generate_combined_chunk_result_window( retry_attempts, batch_timeout_seconds, expected_output_schema, + transformation_spec, + generation_plan, + semantic_validation_options, ) for batch_request in batch_requests ] @@ -5480,12 +6689,34 @@ def _can_resume_run(run, settings=None): if status == TABULAR_EXPORT_STATUS_RUNNING: return _is_stale_running_run(run, settings or {}) if status == TABULAR_EXPORT_STATUS_FAILED: - return _is_retryable_failed_run(run) or _has_exhausted_independent_batch_retries(run) + return ( + _is_artifact_publication_recoverable(run) + or _is_retryable_failed_run(run) + or _has_exhausted_independent_batch_retries(run) + ) return False +def _is_artifact_publication_recoverable(run): + status = str((run or {}).get('status') or '').strip().lower() + manifest = (run or {}).get('artifact_set_manifest') if isinstance((run or {}).get('artifact_set_manifest'), dict) else {} + manifest_state = str(manifest.get('lifecycle_state') or '').strip().lower() + return bool( + status == TABULAR_EXPORT_STATUS_FAILED + and (run or {}).get('publishing_started_at') + and manifest_state in { + TABULAR_ARTIFACT_SET_LIFECYCLE_VALIDATING, + TABULAR_ARTIFACT_SET_LIFECYCLE_PUBLISHING, + TABULAR_ARTIFACT_SET_LIFECYCLE_ROLLBACK_REQUIRED, + TABULAR_ARTIFACT_SET_LIFECYCLE_FAILED, + } + ) + + def _can_cancel_run(run): status = str((run or {}).get('status') or '').strip().lower() + if _is_artifact_publication_recoverable(run): + return True return not run.get('publishing_started_at') and status not in { TABULAR_EXPORT_STATUS_COMPLETED, TABULAR_EXPORT_STATUS_CANCELED, @@ -5555,7 +6786,9 @@ def _normalize_tabular_run_rollout_assignment(raw_assignment): 'contract_version': str(raw_assignment.get('contract_version') or '').strip()[:80], 'mode': str(raw_assignment.get('mode') or '').strip().lower()[:40], 'planner_mode': str(raw_assignment.get('planner_mode') or '').strip().lower()[:40], + 'rollout_state': str(raw_assignment.get('rollout_state') or 'active').strip().lower()[:40], 'assigned': bool(raw_assignment.get('assigned')), + 'assignment_reason_code': str(raw_assignment.get('assignment_reason_code') or '').strip().lower()[:80], 'cohort_bucket': _safe_int(raw_assignment.get('cohort_bucket'), minimum=0, maximum=99), 'rollout_percent': _safe_int(raw_assignment.get('rollout_percent'), minimum=0, maximum=100), 'search_shared_preflight_enabled': bool(raw_assignment.get('search_shared_preflight_enabled')), @@ -5601,7 +6834,7 @@ def _normalize_tabular_run_planner_metadata(planner_metadata): if not planner_metadata: return {} - return { + normalized_metadata = { 'planner_contract_version': str(planner_metadata.get('planner_contract_version') or '').strip()[:80], 'execution_contract': str(planner_metadata.get('execution_contract') or '').strip().lower()[:80], 'execution_state': str(planner_metadata.get('execution_state') or '').strip().lower()[:40], @@ -5615,6 +6848,84 @@ def _normalize_tabular_run_planner_metadata(planner_metadata): planner_metadata.get('rollout_assignment'), ), } + deliverable_contract = planner_metadata.get('deliverable_contract') + if isinstance(deliverable_contract, dict): + normalized_metadata['deliverable_contract'] = { + 'contract_version': str(deliverable_contract.get('contract_version') or '').strip()[:80], + 'action_mode': str(deliverable_contract.get('action_mode') or '').strip().lower()[:40], + 'analysis_required': bool(deliverable_contract.get('analysis_required')), + 'primary_artifact_role': str(deliverable_contract.get('primary_artifact_role') or '').strip().lower()[:80], + 'public_output_schema': [ + str(field_name or '').strip() + for field_name in list(deliverable_contract.get('public_output_schema') or [])[:TABULAR_GENERATION_PLAN_MAX_FIELDS] + if str(field_name or '').strip() + and not is_analysis_internal_lineage_field(field_name) + ], + 'internal_checkpoint_schema': [ + str(field_name or '').strip() + for field_name in list(deliverable_contract.get('internal_checkpoint_schema') or [])[ + :TABULAR_GENERATION_PLAN_MAX_FIELDS + 2 + ] + if str(field_name or '').strip() + ], + 'lineage_schema': [ + str(field_name or '').strip() + for field_name in list(deliverable_contract.get('lineage_schema') or [])[:8] + if str(field_name or '').strip() + and is_analysis_internal_lineage_field(field_name) + ], + 'row_cardinality': str(deliverable_contract.get('row_cardinality') or '').strip().lower()[:80], + 'ordering': str(deliverable_contract.get('ordering') or '').strip().lower()[:80], + 'transformation_mode': str(deliverable_contract.get('transformation_mode') or '').strip().lower()[:80], + 'validation_profile': str(deliverable_contract.get('validation_profile') or '').strip().lower()[:80], + 'publication_policy': str(deliverable_contract.get('publication_policy') or '').strip().lower()[:80], + } + if isinstance(deliverable_contract.get('transformation_spec'), dict): + normalized_metadata['deliverable_contract']['transformation_spec'] = normalize_tabular_transformation_spec( + deliverable_contract.get('transformation_spec'), + public_output_schema=normalized_metadata['deliverable_contract']['public_output_schema'], + ) + return normalized_metadata + + +def _ensure_active_tabular_run_deliverable_contract( + planner_metadata, + requested_plan_mode, + task_type, + output_format, + user_question, +): + """Add a server-owned contract for new active runs from legacy direct preflight.""" + normalized_metadata = dict(planner_metadata or {}) + if ( + str(requested_plan_mode or '').strip().lower() != 'active' + or isinstance(normalized_metadata.get('deliverable_contract'), dict) + ): + return normalized_metadata + normalized_task_type = _normalize_tabular_run_task_type(task_type) + fallback_action_mode = ( + 'analyze' + if normalized_task_type == TABULAR_RUN_TASK_COMBINED + else 'search' + ) + fallback_contract = build_analysis_deliverable_contract( + action_mode=fallback_action_mode, + requested_output_format=output_format, + row_cardinality='one_per_source_row', + ordering='source_order', + transformation_mode='semantic', + validation_profile='exact_rows_schema', + request_fingerprint=hashlib.sha256( + str(user_question or '').encode('utf-8') + ).hexdigest(), + ) + normalized_metadata.update({ + 'execution_contract': normalized_task_type, + 'durable_task_type': normalized_task_type, + 'reason_code': 'legacy_direct_preflight', + 'deliverable_contract': fallback_contract.to_dict(), + }) + return normalized_metadata def _normalize_tabular_run_source_format(run): @@ -5998,57 +7309,31 @@ def _build_run_public_status(run, settings=None): deferred_composition = _build_tabular_run_deferred_composition_reference(run) rollout_assignment = _build_tabular_run_rollout_assignment_public_fields(run) checkpoint_summary = _build_checkpoint_summary(completed_batches, batch_count, processed_rows, row_count) - generated_artifacts = [] - - def append_generated_artifact(artifact, fallback_file_name, fallback_output_format, summary): - artifact = artifact if isinstance(artifact, dict) else {} - if not artifact.get('artifact_message_id'): - return - generated_artifacts.append({ - 'capability': artifact.get('capability') or 'tabular', - 'artifact_message_id': artifact.get('artifact_message_id'), - 'conversation_id': run.get('conversation_id'), - 'file_name': artifact.get('file_name') or fallback_file_name, - 'output_format': artifact.get('output_format') or fallback_output_format, - 'row_count': processed_rows or row_count, - 'storage_scope': 'chat', - 'source_file_name': run.get('source_file_name'), - 'selected_sheet': run.get('selected_sheet'), - 'summary': summary, - 'preview_rows': list(artifact.get('preview_rows') or [])[ - :TABULAR_EXPORT_ARTIFACT_PREVIEW_MAX_ROWS - ], - 'preview_columns': list(artifact.get('preview_columns') or [])[ - :TABULAR_GENERATION_PLAN_MAX_FIELDS + 2 - ], - 'preview_text': str(artifact.get('preview_text') or '')[ - :TABULAR_EXPORT_ARTIFACT_PREVIEW_MAX_CHARS - ], - 'suppress_assistant_text': bool(artifact.get('suppress_assistant_text')), - 'suppress_assistant_table_export': True, - }) - - if task_type == TABULAR_RUN_TASK_COMBINED: - append_generated_artifact( - run.get('structured_export_artifact') or final_artifact, - run.get('generated_file_name'), - run.get('output_format'), - run.get('post_run_export_summary'), - ) - append_generated_artifact( - run.get('analysis_artifact'), - run.get('analysis_generated_file_name'), - 'md', - run.get('post_run_summary'), - ) - else: - append_generated_artifact( - final_artifact, - run.get('generated_file_name'), - run.get('output_format'), - run.get('post_run_summary'), - ) + artifact_set_manifest = _build_or_update_artifact_set_manifest(run) + generated_artifacts = _build_public_generated_artifacts_from_manifest(run, artifact_set_manifest) generated_artifact = generated_artifacts[0] if generated_artifacts else None + primary_final_artifact = generated_artifact or ( + _build_public_artifact_projection(final_artifact) + if ( + run.get('status') == TABULAR_EXPORT_STATUS_COMPLETED + and artifact_set_manifest.get('lifecycle_state') == TABULAR_ARTIFACT_SET_LIFECYCLE_COMPLETED + ) + else None + ) or {} + structured_export_public_artifact = next( + ( + artifact for artifact in generated_artifacts + if artifact.get('role') == ANALYSIS_ARTIFACT_ROLE_REQUESTED_OUTPUT + ), + None, + ) + analysis_public_artifact = next( + ( + artifact for artifact in generated_artifacts + if artifact.get('role') == ANALYSIS_ARTIFACT_ROLE_PRIMARY_ANALYSIS + ), + None, + ) return { 'run_id': run.get('id'), @@ -6128,12 +7413,22 @@ def append_generated_artifact(artifact, fallback_file_name, fallback_output_form 'can_resume': can_resume, 'can_cancel': _can_cancel_run(run), 'retryable_failure': retryable_failure, - 'artifact_message_id': final_artifact.get('artifact_message_id'), - 'file_name': final_artifact.get('file_name') or run.get('generated_file_name'), + 'artifact_message_id': primary_final_artifact.get('artifact_message_id'), + 'file_name': primary_final_artifact.get('file_name') or run.get('generated_file_name'), 'generated_artifact': generated_artifact, 'generated_artifacts': generated_artifacts, - 'structured_export_artifact': run.get('structured_export_artifact'), - 'analysis_artifact': run.get('analysis_artifact'), + 'artifact_set': { + 'contract_version': artifact_set_manifest.get('contract_version'), + 'set_id': artifact_set_manifest.get('set_id'), + 'lifecycle_state': artifact_set_manifest.get('lifecycle_state'), + 'validation_state': artifact_set_manifest.get('validation_state'), + 'primary_artifact_id': artifact_set_manifest.get('primary_artifact_id'), + 'member_count': len(artifact_set_manifest.get('members') or []), + 'published_member_count': len(generated_artifacts), + 'publication_generation': _safe_int(artifact_set_manifest.get('publication_generation'), minimum=0), + }, + 'structured_export_artifact': structured_export_public_artifact, + 'analysis_artifact': analysis_public_artifact, 'capability': 'tabular', 'suppress_assistant_table_export': True, 'background_export': not ( @@ -6279,6 +7574,7 @@ def resume_tabular_generated_output_run(user_id, run_id): 'lease_holder_id': None, 'lease_expires_at': None, 'next_attempt_at': now, + 'publishing_started_at': None, 'last_message': 'Manual resume queued; export will continue from completed checkpoints', 'transient_failure_count': 0, 'auto_retry_exhausted': False, @@ -6856,17 +8152,22 @@ def _write_ordered_output_stream(run, output_stream): expected_row_count = _safe_int(run.get('row_count')) output_format = str(run.get('output_format') or 'json').strip().lower() or 'json' output_schema = list(run.get('output_schema') or []) + public_output_schema = _get_tabular_run_serialized_public_schema(run) if not output_schema: raise ValueError('Generated output schema is missing') + if not public_output_schema: + raise ValueError('Generated public output schema is missing') if TABULAR_EXPORT_OUTPUT_ROW_NUMBER_FIELD not in output_schema: raise ValueError('Generated output schema is missing source row order') csv_writer = None safe_output_schema = None if output_format == 'csv': - safe_output_schema = build_safe_csv_headers(output_schema) + safe_output_schema = build_safe_csv_headers(public_output_schema) csv_writer = csv.DictWriter(output_stream, fieldnames=safe_output_schema, lineterminator='\n') csv_writer.writeheader() + elif output_format == 'xml': + output_stream.write('\n\n') else: output_stream.write('[\n') @@ -6899,20 +8200,29 @@ def _write_ordered_output_stream(run, output_stream): field_name: entry.get(field_name) for field_name in output_schema } + public_entry = project_structured_deliverable_row( + ordered_entry, + public_output_schema, + require_all_fields=True, + ) if csv_writer: csv_writer.writerow({ - safe_field_name: _serialize_generated_output_value(ordered_entry.get(field_name)) - for field_name, safe_field_name in zip(output_schema, safe_output_schema) + safe_field_name: _serialize_generated_output_value(public_entry.get(field_name)) + for field_name, safe_field_name in zip(public_output_schema, safe_output_schema) }) + elif output_format == 'xml': + _write_generated_xml_row(output_stream, public_entry) else: if written_row_count: output_stream.write(',\n') - output_stream.write(json.dumps(ordered_entry, default=str, ensure_ascii=False)) + output_stream.write(json.dumps(public_entry, default=str, ensure_ascii=False)) written_row_count += 1 expected_source_row_number += 1 - if output_format != 'csv': + if output_format == 'xml': + output_stream.write('\n') + elif output_format != 'csv': output_stream.write('\n]\n') if written_row_count != expected_row_count: raise ValueError( @@ -6923,14 +8233,17 @@ def _write_ordered_output_stream(run, output_stream): def _build_structured_export_preview_rows(run): output_schema = list((run or {}).get('output_schema') or []) + public_output_schema = _get_tabular_run_serialized_public_schema(run) if not output_schema: return [] + if not public_output_schema: + return [] output_format = str((run or {}).get('output_format') or 'json').strip().lower() or 'json' preview_schema = ( - build_safe_csv_headers(output_schema) + build_safe_csv_headers(public_output_schema) if output_format == 'csv' - else output_schema + else public_output_schema ) preview_rows = [] preview_char_count = 0 @@ -6961,8 +8274,13 @@ def _build_structured_export_preview_rows(run): ) preview_row = {} - for field_name, preview_field_name in zip(output_schema, preview_schema): - rendered_value = _serialize_generated_output_value(entry.get(field_name)) + public_entry = project_structured_deliverable_row( + entry, + public_output_schema, + require_all_fields=True, + ) + for field_name, preview_field_name in zip(public_output_schema, preview_schema): + rendered_value = _serialize_generated_output_value(public_entry.get(field_name)) if len(rendered_value) > TABULAR_EXPORT_ARTIFACT_PREVIEW_CELL_MAX_CHARS: rendered_value = ( f'{rendered_value[:TABULAR_EXPORT_ARTIFACT_PREVIEW_CELL_MAX_CHARS - 3]}...' @@ -7008,12 +8326,535 @@ def _build_artifact_metadata( } -def _publish_structured_export_artifact(run): - output_format = normalize_generated_output_format(run.get('output_format')) +def _normalize_tabular_artifact_lifecycle_state(value, allowed_states, default_state): + normalized_state = str(value or '').strip().lower() + if normalized_state in allowed_states: + return normalized_state + return default_state + + +def _normalize_tabular_artifact_format(value, fallback_format='json'): + normalized_format = str(value or '').strip().lower().lstrip('.') + if normalized_format: + return normalized_format[:20] + return str(fallback_format or 'json').strip().lower().lstrip('.')[:20] or 'json' + + +def _normalize_tabular_artifact_role(value, fallback_role=ANALYSIS_ARTIFACT_ROLE_REQUESTED_OUTPUT): + normalized_role = str(value or '').strip().lower() + if normalized_role in { + ANALYSIS_ARTIFACT_ROLE_PRIMARY_ANALYSIS, + ANALYSIS_ARTIFACT_ROLE_REQUESTED_OUTPUT, + ANALYSIS_ARTIFACT_ROLE_SUPPORTING_OUTPUT, + }: + return normalized_role + return fallback_role + + +def _normalize_tabular_artifact_member_id(value, role, output_format, request_order): + normalized_value = re.sub(r'[^a-z0-9_-]+', '-', str(value or '').strip().lower()) + normalized_value = re.sub(r'-+', '-', normalized_value).strip('-') + if normalized_value: + return normalized_value[:96] + if role == ANALYSIS_ARTIFACT_ROLE_PRIMARY_ANALYSIS: + return 'analysis' + return f'requested-{output_format or request_order}'[:96] + + +def _get_tabular_run_deliverable_contract(run): + planner_metadata = (run or {}).get('tabular_planner_metadata') + if not isinstance(planner_metadata, dict): + return {} + deliverable_contract = planner_metadata.get('deliverable_contract') + if not isinstance(deliverable_contract, dict): + return {} + return deliverable_contract + + +def _normalize_artifact_descriptor(raw_descriptor, fallback_order=0): + raw_descriptor = raw_descriptor if isinstance(raw_descriptor, dict) else {} + request_order = _safe_int(raw_descriptor.get('request_order'), default=fallback_order, minimum=0) + role = _normalize_tabular_artifact_role(raw_descriptor.get('role')) + output_format = _normalize_tabular_artifact_format( + raw_descriptor.get('format') or raw_descriptor.get('output_format'), + fallback_format='md' if role == ANALYSIS_ARTIFACT_ROLE_PRIMARY_ANALYSIS else 'json', + ) + member_id = _normalize_tabular_artifact_member_id( + raw_descriptor.get('artifact_id') or raw_descriptor.get('member_id'), + role, + output_format, + request_order, + ) + return { + 'member_id': member_id, + 'artifact_id': member_id, + 'role': role, + 'format': output_format, + 'required': bool(raw_descriptor.get('required', True)), + 'request_order': request_order, + } + + +def _default_artifact_descriptors_for_run(run): + task_type = _normalize_tabular_run_task_type((run or {}).get('task_type')) + output_format = _normalize_tabular_artifact_format((run or {}).get('output_format'), fallback_format='json') + if task_type == TABULAR_RUN_TASK_HIERARCHICAL_ANALYSIS: + return [{ + 'artifact_id': 'analysis', + 'role': ANALYSIS_ARTIFACT_ROLE_PRIMARY_ANALYSIS, + 'format': 'md', + 'required': True, + 'request_order': 0, + }] + if task_type == TABULAR_RUN_TASK_COMBINED: + return [ + { + 'artifact_id': 'analysis', + 'role': ANALYSIS_ARTIFACT_ROLE_PRIMARY_ANALYSIS, + 'format': 'md', + 'required': True, + 'request_order': 0, + }, + { + 'artifact_id': f'requested-{output_format}', + 'role': ANALYSIS_ARTIFACT_ROLE_REQUESTED_OUTPUT, + 'format': output_format, + 'required': True, + 'request_order': 1, + }, + ] + return [{ + 'artifact_id': f'requested-{output_format}', + 'role': ANALYSIS_ARTIFACT_ROLE_REQUESTED_OUTPUT, + 'format': output_format, + 'required': True, + 'request_order': 0, + }] + + +def _get_artifact_descriptors_for_run(run): + deliverable_contract = _get_tabular_run_deliverable_contract(run) + contract_artifacts = list(deliverable_contract.get('requested_artifacts') or []) + if not contract_artifacts: + contract_artifacts = _default_artifact_descriptors_for_run(run) + descriptors = [ + _normalize_artifact_descriptor(raw_descriptor, fallback_order=index) + for index, raw_descriptor in enumerate(contract_artifacts) + ] + return sorted(descriptors, key=lambda descriptor: ( + _safe_int(descriptor.get('request_order'), minimum=0), + str(descriptor.get('member_id') or ''), + )) + + +def _get_primary_artifact_member_id(run, descriptors): + deliverable_contract = _get_tabular_run_deliverable_contract(run) + primary_role = str(deliverable_contract.get('primary_artifact_role') or '').strip().lower() + if not primary_role: + task_type = _normalize_tabular_run_task_type((run or {}).get('task_type')) + primary_role = ( + ANALYSIS_ARTIFACT_ROLE_PRIMARY_ANALYSIS + if task_type in {TABULAR_RUN_TASK_HIERARCHICAL_ANALYSIS, TABULAR_RUN_TASK_COMBINED} + else ANALYSIS_ARTIFACT_ROLE_REQUESTED_OUTPUT + ) + for descriptor in descriptors: + if descriptor.get('role') == primary_role: + return descriptor.get('member_id') + return descriptors[0].get('member_id') if descriptors else '' + + +def _get_structured_artifact_member_id(run): + output_format = _normalize_tabular_artifact_format((run or {}).get('output_format'), fallback_format='json') + for descriptor in _get_artifact_descriptors_for_run(run): + if ( + descriptor.get('role') == ANALYSIS_ARTIFACT_ROLE_REQUESTED_OUTPUT + and descriptor.get('format') == output_format + ): + return descriptor.get('member_id') + return f'requested-{output_format}' + + +def _get_structured_artifact_descriptors_for_run(run): + descriptors = [ + descriptor for descriptor in _get_artifact_descriptors_for_run(run) + if descriptor.get('role') == ANALYSIS_ARTIFACT_ROLE_REQUESTED_OUTPUT + ] + if descriptors: + return descriptors + output_format = _normalize_tabular_artifact_format((run or {}).get('output_format'), fallback_format='json') + return [{ + 'member_id': f'requested-{output_format}', + 'artifact_id': f'requested-{output_format}', + 'role': ANALYSIS_ARTIFACT_ROLE_REQUESTED_OUTPUT, + 'format': output_format, + 'required': True, + 'request_order': 0, + }] + + +def _get_structured_export_artifact_for_member(run, member): + member_id = str((member or {}).get('member_id') or '').strip() + member_format = str((member or {}).get('format') or '').strip().lower() + for artifact in list((run or {}).get('structured_export_artifacts') or []): + if not isinstance(artifact, dict): + continue + if str(artifact.get('artifact_id') or artifact.get('member_id') or '').strip() == member_id: + return artifact + artifact = (run or {}).get('structured_export_artifact') if isinstance((run or {}).get('structured_export_artifact'), dict) else {} + if artifact and str(artifact.get('output_format') or '').strip().lower() == member_format: + return artifact + if artifact and not (run or {}).get('structured_export_artifacts'): + return artifact + return {} + + +def _get_analysis_artifact_member_id(run): + for descriptor in _get_artifact_descriptors_for_run(run): + if descriptor.get('role') == ANALYSIS_ARTIFACT_ROLE_PRIMARY_ANALYSIS: + return descriptor.get('member_id') + return 'analysis' + + +def _build_artifact_member_idempotency_key(run, member): + role = str((member or {}).get('role') or '').strip().lower() + output_format = str((member or {}).get('format') or '').strip().lower() + member_id = str((member or {}).get('member_id') or '').strip() + run_id = (run or {}).get('id') + if role == ANALYSIS_ARTIFACT_ROLE_PRIMARY_ANALYSIS and output_format == 'md': + return f'tabular-hierarchical-analysis:{run_id}' + if role == ANALYSIS_ARTIFACT_ROLE_REQUESTED_OUTPUT and output_format == (run or {}).get('output_format'): + return f'tabular-generated-output:{run_id}' + return f'tabular-artifact-set:{run_id}:{member_id}' + + +def _build_artifact_set_member(run, descriptor, existing_member=None): + existing_member = existing_member if isinstance(existing_member, dict) else {} + lifecycle_state = _normalize_tabular_artifact_lifecycle_state( + existing_member.get('lifecycle_state'), + TABULAR_ARTIFACT_MEMBER_LIFECYCLE_STATES, + TABULAR_ARTIFACT_MEMBER_LIFECYCLE_PLANNED, + ) + validation_state = str(existing_member.get('validation_state') or '').strip().lower()[:40] + member = { + 'member_id': descriptor.get('member_id'), + 'artifact_id': descriptor.get('artifact_id') or descriptor.get('member_id'), + 'role': descriptor.get('role'), + 'format': descriptor.get('format'), + 'required': bool(descriptor.get('required', True)), + 'request_order': _safe_int(descriptor.get('request_order'), minimum=0), + 'lifecycle_state': lifecycle_state, + 'validation_state': validation_state or TABULAR_ARTIFACT_MEMBER_LIFECYCLE_PLANNED, + 'artifact_message_id': existing_member.get('artifact_message_id'), + 'file_name': existing_member.get('file_name'), + 'capability': existing_member.get('capability') or 'tabular', + 'output_format': existing_member.get('output_format') or descriptor.get('format'), + 'row_count': _safe_int(existing_member.get('row_count'), default=_safe_int((run or {}).get('row_count'))), + 'preview_rows': list(existing_member.get('preview_rows') or [])[:TABULAR_EXPORT_ARTIFACT_PREVIEW_MAX_ROWS], + 'preview_columns': list(existing_member.get('preview_columns') or [])[:TABULAR_GENERATION_PLAN_MAX_FIELDS + 2], + 'preview_text': str(existing_member.get('preview_text') or '')[:TABULAR_EXPORT_ARTIFACT_PREVIEW_MAX_CHARS], + 'suppress_assistant_text': bool(existing_member.get('suppress_assistant_text', True)), + } + member['idempotency_key'] = existing_member.get('idempotency_key') or _build_artifact_member_idempotency_key( + run, + member, + ) + return member + + +def _artifact_lifecycle_for_existing_run_artifact(run, artifact): + artifact = artifact if isinstance(artifact, dict) else {} + status = str((run or {}).get('status') or '').strip().lower() + if not artifact.get('artifact_message_id'): + return TABULAR_ARTIFACT_MEMBER_LIFECYCLE_PLANNED + if status == TABULAR_EXPORT_STATUS_COMPLETED: + return TABULAR_ARTIFACT_MEMBER_LIFECYCLE_PUBLISHED + if status == TABULAR_EXPORT_STATUS_CANCELED: + return TABULAR_ARTIFACT_MEMBER_LIFECYCLE_CANCELED + if status == TABULAR_EXPORT_STATUS_FAILED: + return TABULAR_ARTIFACT_MEMBER_LIFECYCLE_FAILED + return TABULAR_ARTIFACT_MEMBER_LIFECYCLE_STAGED + + +def _artifact_set_lifecycle_for_run(run, members): + status = str((run or {}).get('status') or '').strip().lower() + member_states = { + str((member or {}).get('lifecycle_state') or '').strip().lower() + for member in list(members or []) + if isinstance(member, dict) + } + if status == TABULAR_EXPORT_STATUS_COMPLETED: + return TABULAR_ARTIFACT_SET_LIFECYCLE_COMPLETED + if status == TABULAR_EXPORT_STATUS_CANCELED: + return TABULAR_ARTIFACT_SET_LIFECYCLE_CANCELED + if status == TABULAR_EXPORT_STATUS_FAILED: + if TABULAR_ARTIFACT_MEMBER_LIFECYCLE_PUBLISHED in member_states: + return TABULAR_ARTIFACT_SET_LIFECYCLE_ROLLBACK_REQUIRED + return TABULAR_ARTIFACT_SET_LIFECYCLE_FAILED + if (run or {}).get('publishing_started_at'): + return TABULAR_ARTIFACT_SET_LIFECYCLE_PUBLISHING + if TABULAR_ARTIFACT_MEMBER_LIFECYCLE_STAGED in member_states: + return TABULAR_ARTIFACT_SET_LIFECYCLE_VALIDATING + return TABULAR_ARTIFACT_SET_LIFECYCLE_GENERATING if status == TABULAR_EXPORT_STATUS_RUNNING else TABULAR_ARTIFACT_SET_LIFECYCLE_PLANNED + + +def _merge_artifact_metadata_into_member(member, artifact, lifecycle_state=None, validation_state=None): + artifact = artifact if isinstance(artifact, dict) else {} + if not artifact: + return member + member.update({ + 'artifact_message_id': artifact.get('artifact_message_id') or member.get('artifact_message_id'), + 'file_name': artifact.get('file_name') or member.get('file_name'), + 'capability': artifact.get('capability') or member.get('capability') or 'tabular', + 'output_format': artifact.get('output_format') or member.get('output_format') or member.get('format'), + 'row_count': _safe_int(artifact.get('row_count'), default=_safe_int(member.get('row_count'))), + 'preview_rows': list(artifact.get('preview_rows') or member.get('preview_rows') or [])[ + :TABULAR_EXPORT_ARTIFACT_PREVIEW_MAX_ROWS + ], + 'preview_columns': list(artifact.get('preview_columns') or member.get('preview_columns') or [])[ + :TABULAR_GENERATION_PLAN_MAX_FIELDS + 2 + ], + 'preview_text': str(artifact.get('preview_text') or member.get('preview_text') or '')[ + :TABULAR_EXPORT_ARTIFACT_PREVIEW_MAX_CHARS + ], + 'suppress_assistant_text': bool(artifact.get('suppress_assistant_text', member.get('suppress_assistant_text', True))), + }) + if lifecycle_state: + member['lifecycle_state'] = _normalize_tabular_artifact_lifecycle_state( + lifecycle_state, + TABULAR_ARTIFACT_MEMBER_LIFECYCLE_STATES, + member.get('lifecycle_state') or TABULAR_ARTIFACT_MEMBER_LIFECYCLE_PLANNED, + ) + if validation_state: + member['validation_state'] = str(validation_state or '').strip().lower()[:40] + return member + + +def _build_or_update_artifact_set_manifest(run): + run = run if isinstance(run, dict) else {} + existing_manifest = run.get('artifact_set_manifest') if isinstance(run.get('artifact_set_manifest'), dict) else {} + descriptors = _get_artifact_descriptors_for_run(run) + existing_members = { + str(member.get('member_id') or '').strip(): member + for member in list(existing_manifest.get('members') or []) + if isinstance(member, dict) + } + members = [] + for descriptor in descriptors: + member = _build_artifact_set_member( + run, + descriptor, + existing_members.get(descriptor.get('member_id')), + ) + if member.get('role') == ANALYSIS_ARTIFACT_ROLE_PRIMARY_ANALYSIS: + artifact = run.get('analysis_artifact') if isinstance(run.get('analysis_artifact'), dict) else {} + _merge_artifact_metadata_into_member( + member, + artifact, + lifecycle_state=_artifact_lifecycle_for_existing_run_artifact(run, artifact), + validation_state='validated' if artifact.get('artifact_message_id') else None, + ) + elif member.get('role') == ANALYSIS_ARTIFACT_ROLE_REQUESTED_OUTPUT: + artifact = _get_structured_export_artifact_for_member(run, member) + if not artifact and _normalize_tabular_run_task_type(run.get('task_type')) != TABULAR_RUN_TASK_COMBINED: + artifact = run.get('final_artifact') if isinstance(run.get('final_artifact'), dict) else {} + _merge_artifact_metadata_into_member( + member, + artifact, + lifecycle_state=_artifact_lifecycle_for_existing_run_artifact(run, artifact), + validation_state='validated' if artifact.get('artifact_message_id') else None, + ) + members.append(member) + + primary_member_id = existing_manifest.get('primary_artifact_id') or _get_primary_artifact_member_id(run, descriptors) + lifecycle_state = _normalize_tabular_artifact_lifecycle_state( + existing_manifest.get('lifecycle_state'), + TABULAR_ARTIFACT_SET_LIFECYCLE_STATES, + _artifact_set_lifecycle_for_run(run, members), + ) + if lifecycle_state == TABULAR_ARTIFACT_SET_LIFECYCLE_PLANNED: + lifecycle_state = _artifact_set_lifecycle_for_run(run, members) + manifest = { + 'contract_version': TABULAR_ARTIFACT_SET_CONTRACT_VERSION, + 'set_id': existing_manifest.get('set_id') or f"tabular-artifact-set:{run.get('id')}", + 'run_id': run.get('id'), + 'conversation_id': run.get('conversation_id'), + 'user_id': run.get('user_id'), + 'task_type': _normalize_tabular_run_task_type(run.get('task_type')), + 'source_fingerprint': str(_get_tabular_run_deliverable_contract(run).get('source_fingerprint') or '')[:64], + 'request_fingerprint': str(_get_tabular_run_deliverable_contract(run).get('request_fingerprint') or '')[:64], + 'lifecycle_state': lifecycle_state, + 'publication_generation': _safe_int(existing_manifest.get('publication_generation'), minimum=0), + 'primary_artifact_id': primary_member_id, + 'validation_state': str(existing_manifest.get('validation_state') or '').strip().lower()[:40] or 'planned', + 'rollback_state': str(existing_manifest.get('rollback_state') or '').strip().lower()[:40], + 'members': members, + } + return manifest + + +def _set_artifact_set_member_state(run, member_id, artifact=None, lifecycle_state=None, validation_state=None): + manifest = _build_or_update_artifact_set_manifest(run) + for member in manifest.get('members') or []: + if member.get('member_id') == member_id: + _merge_artifact_metadata_into_member( + member, + artifact, + lifecycle_state=lifecycle_state, + validation_state=validation_state, + ) + break + manifest['lifecycle_state'] = _artifact_set_lifecycle_for_run(run, manifest.get('members') or []) + run['artifact_set_manifest'] = manifest + return manifest + + +def _build_artifact_member_upload_metadata(run, member_id): + manifest = _build_or_update_artifact_set_manifest(run) + return { + 'artifact_run_id': run.get('id'), + 'artifact_set_id': manifest.get('set_id'), + 'artifact_member_id': member_id, + 'artifact_lifecycle_state': TABULAR_ARTIFACT_MEMBER_LIFECYCLE_STAGED, + 'artifact_validation_state': TABULAR_ARTIFACT_MEMBER_LIFECYCLE_STAGED, + 'artifact_publication_generation': _safe_int(manifest.get('publication_generation'), minimum=0), + } + + +def _publish_artifact_set_members(run, published_member_ids): + published_ids = {str(member_id or '').strip() for member_id in list(published_member_ids or []) if member_id} + manifest = _build_or_update_artifact_set_manifest(run) + for member in manifest.get('members') or []: + if member.get('member_id') in published_ids and member.get('artifact_message_id'): + member['lifecycle_state'] = TABULAR_ARTIFACT_MEMBER_LIFECYCLE_PUBLISHED + member['validation_state'] = 'validated' + validation_artifacts = [ + { + 'artifact_id': member.get('artifact_id') or member.get('member_id'), + 'role': member.get('role'), + 'format': member.get('format'), + 'status': member.get('lifecycle_state'), + } + for member in manifest.get('members') or [] + ] + deliverable_contract = _get_tabular_run_deliverable_contract(run) + artifact_set_valid = True + if deliverable_contract: + validation_report = validate_analysis_artifact_set(deliverable_contract, validation_artifacts) + artifact_set_valid = validation_report.valid + manifest['validation_state'] = 'validated' if validation_report.valid else 'invalid' + manifest['validation_report'] = validation_report.to_dict() + else: + manifest['validation_state'] = 'validated' + manifest['lifecycle_state'] = ( + TABULAR_ARTIFACT_SET_LIFECYCLE_COMPLETED + if artifact_set_valid + else TABULAR_ARTIFACT_SET_LIFECYCLE_ROLLBACK_REQUIRED + ) + next_publication_generation = _safe_int(manifest.get('publication_generation'), minimum=0) + 1 + if artifact_set_valid: + for member in manifest.get('members') or []: + if member.get('member_id') not in published_ids or not member.get('artifact_message_id'): + continue + commit_generated_chat_artifact_publication_for_user( + run.get('user_id'), + run.get('conversation_id'), + member.get('artifact_message_id'), + manifest.get('set_id'), + member.get('member_id'), + next_publication_generation, + ) + manifest['publication_generation'] = next_publication_generation + run['artifact_set_manifest'] = manifest + return manifest + + +def _build_public_generated_artifact_from_member(run, manifest, member): + if not isinstance(member, dict) or not member.get('artifact_message_id'): + return None + if member.get('lifecycle_state') not in TABULAR_ARTIFACT_MEMBER_PUBLIC_LIFECYCLE_STATES: + return None + return { + 'artifact_id': member.get('artifact_id') or member.get('member_id'), + 'artifact_set_id': manifest.get('set_id'), + 'artifact_set_contract_version': manifest.get('contract_version'), + 'role': member.get('role'), + 'required': bool(member.get('required', True)), + 'request_order': _safe_int(member.get('request_order'), minimum=0), + 'lifecycle_state': member.get('lifecycle_state'), + 'validation_state': member.get('validation_state'), + 'capability': member.get('capability') or 'tabular', + 'artifact_message_id': member.get('artifact_message_id'), + 'conversation_id': run.get('conversation_id'), + 'file_name': member.get('file_name') or run.get('generated_file_name'), + 'output_format': member.get('output_format') or member.get('format'), + 'row_count': _safe_int(member.get('row_count'), default=_safe_int(run.get('processed_rows'), default=_safe_int(run.get('row_count')))), + 'storage_scope': 'chat', + 'source_file_name': run.get('source_file_name'), + 'selected_sheet': run.get('selected_sheet'), + 'summary': run.get('post_run_summary') if member.get('role') == ANALYSIS_ARTIFACT_ROLE_PRIMARY_ANALYSIS else run.get('post_run_export_summary') or run.get('post_run_summary'), + 'preview_rows': list(member.get('preview_rows') or [])[:TABULAR_EXPORT_ARTIFACT_PREVIEW_MAX_ROWS], + 'preview_columns': list(member.get('preview_columns') or [])[:TABULAR_GENERATION_PLAN_MAX_FIELDS + 2], + 'preview_text': str(member.get('preview_text') or '')[:TABULAR_EXPORT_ARTIFACT_PREVIEW_MAX_CHARS], + 'suppress_assistant_text': bool(member.get('suppress_assistant_text')), + 'suppress_assistant_table_export': True, + } + + +def _build_public_generated_artifacts_from_manifest(run, manifest): + manifest = manifest if isinstance(manifest, dict) else {} + if manifest.get('lifecycle_state') != TABULAR_ARTIFACT_SET_LIFECYCLE_COMPLETED: + return [] + public_artifacts = [] + for member in sorted( + list(manifest.get('members') or []), + key=lambda item: (_safe_int(item.get('request_order'), minimum=0), str(item.get('member_id') or '')), + ): + public_artifact = _build_public_generated_artifact_from_member(run, manifest, member) + if public_artifact: + public_artifacts.append(public_artifact) + primary_member_id = str(manifest.get('primary_artifact_id') or '').strip() + primary_artifact = next( + ( + artifact for artifact in public_artifacts + if str(artifact.get('artifact_id') or '').strip() == primary_member_id + ), + public_artifacts[0] if public_artifacts else None, + ) + if primary_artifact and public_artifacts and public_artifacts[0] != primary_artifact: + public_artifacts = [primary_artifact] + [ + artifact for artifact in public_artifacts + if artifact.get('artifact_id') != primary_artifact.get('artifact_id') + ] + return public_artifacts + + +def _build_public_artifact_projection(artifact): + artifact = artifact if isinstance(artifact, dict) else {} + if not artifact.get('artifact_message_id'): + return None + return { + 'artifact_message_id': artifact.get('artifact_message_id'), + 'file_name': artifact.get('file_name'), + 'capability': artifact.get('capability') or 'tabular', + 'output_format': artifact.get('output_format'), + 'preview_rows': list(artifact.get('preview_rows') or [])[:TABULAR_EXPORT_ARTIFACT_PREVIEW_MAX_ROWS], + 'preview_columns': list(artifact.get('preview_columns') or [])[:TABULAR_GENERATION_PLAN_MAX_FIELDS + 2], + 'preview_text': str(artifact.get('preview_text') or '')[:TABULAR_EXPORT_ARTIFACT_PREVIEW_MAX_CHARS], + 'suppress_assistant_text': bool(artifact.get('suppress_assistant_text')), + } + + +def _publish_structured_export_artifact(run, descriptor=None): + descriptor = descriptor if isinstance(descriptor, dict) else {} + member_id = str(descriptor.get('member_id') or _get_structured_artifact_member_id(run)).strip() + output_format = normalize_generated_output_format(descriptor.get('format') or run.get('output_format')) generated_file_name = run.get('generated_file_name') or _build_generated_file_name( run.get('source_file_name'), output_format, ) + run_for_output = dict(run) + run_for_output['output_format'] = output_format + run_for_output['generated_file_name'] = generated_file_name with tempfile.SpooledTemporaryFile( max_size=TABULAR_EXPORT_FINAL_SPOOL_MAX_MEMORY_BYTES, mode='w+b', @@ -7025,18 +8866,21 @@ def _publish_structured_export_artifact(run): write_through=True, ) try: - output_entry_count = _write_ordered_output_stream(run, text_output_stream) - post_run_summary = _build_compact_post_run_summary(run) - _authorize_tabular_export_run_execution(run) - _raise_if_tabular_export_canceled(run) + output_entry_count = _write_ordered_output_stream(run_for_output, text_output_stream) + post_run_summary = _build_compact_post_run_summary(run_for_output) + _authorize_tabular_export_run_execution(run_for_output) + _raise_if_tabular_export_canceled(run_for_output) if not run.get('publishing_started_at'): run.update({ 'publishing_started_at': _now_iso(), 'last_message': 'Final validation passed; publishing the generated artifact', }) run = _replace_claimed_run(run) - _authorize_tabular_export_run_execution(run) - _revalidate_tabular_source_version_for_publication(run) + run_for_output.update(run) + run_for_output['output_format'] = output_format + run_for_output['generated_file_name'] = generated_file_name + _authorize_tabular_export_run_execution(run_for_output) + _revalidate_tabular_source_version_for_publication(run_for_output) text_output_stream.flush() output_size = binary_output_stream.tell() binary_output_stream.seek(0) @@ -7049,21 +8893,64 @@ def _publish_structured_export_artifact(run): capability='tabular', output_format=output_format, summary=post_run_summary, - artifact_idempotency_key=f"tabular-generated-output:{run.get('id')}", + artifact_idempotency_key=_build_artifact_member_idempotency_key(run, { + 'role': ANALYSIS_ARTIFACT_ROLE_REQUESTED_OUTPUT, + 'format': output_format, + 'member_id': member_id, + }), + artifact_lifecycle_metadata=_build_artifact_member_upload_metadata( + run, + member_id, + ), ) - _raise_if_tabular_export_canceled(run) + _raise_if_tabular_export_canceled(run_for_output) finally: text_output_stream.detach() uploaded_message = upload_result.get('message') or {} - return run, uploaded_message, post_run_summary, output_entry_count, output_format, generated_file_name + artifact_metadata = _build_artifact_metadata( + uploaded_message, + generated_file_name, + output_format, + preview_rows=_build_structured_export_preview_rows(run_for_output), + suppress_assistant_text=True, + ) + artifact_metadata['artifact_id'] = member_id + artifact_metadata['member_id'] = member_id + artifact_metadata['request_order'] = _safe_int(descriptor.get('request_order'), minimum=0) + return run, artifact_metadata, post_run_summary, output_entry_count, output_format, generated_file_name + + +def _publish_structured_export_artifacts(run): + artifacts = [] + first_summary = '' + first_entry_count = None + first_output_format = normalize_generated_output_format(run.get('output_format')) + first_file_name = run.get('generated_file_name') or _build_generated_file_name( + run.get('source_file_name'), + first_output_format, + ) + for descriptor in _get_structured_artifact_descriptors_for_run(run): + run, artifact, post_run_summary, output_entry_count, output_format, generated_file_name = _publish_structured_export_artifact( + run, + descriptor=descriptor, + ) + if first_entry_count is None: + first_entry_count = output_entry_count + first_summary = post_run_summary + first_output_format = output_format + first_file_name = generated_file_name + elif first_entry_count != output_entry_count: + raise ValueError('Structured artifact sibling row counts do not match') + artifacts.append(artifact) + return run, artifacts, first_summary, _safe_int(first_entry_count), first_output_format, first_file_name def _complete_run(run): - run, uploaded_message, post_run_summary, output_entry_count, output_format, generated_file_name = ( - _publish_structured_export_artifact(run) + run, structured_artifacts, post_run_summary, output_entry_count, output_format, generated_file_name = ( + _publish_structured_export_artifacts(run) ) - artifact_preview_rows = _build_structured_export_preview_rows(run) + structured_artifact = structured_artifacts[0] if structured_artifacts else {} now = _now_iso() run.update({ 'status': TABULAR_EXPORT_STATUS_COMPLETED, @@ -7077,16 +8964,16 @@ def _complete_run(run): 'failed_chunk_count': 0, 'last_message': 'Background structured export completed', 'post_run_summary': post_run_summary, - 'generated_file_name': uploaded_message.get('file_name') or generated_file_name, - 'final_artifact': _build_artifact_metadata( - uploaded_message, - generated_file_name, - output_format, - preview_rows=artifact_preview_rows, - suppress_assistant_text=True, - ), + 'generated_file_name': structured_artifact.get('file_name') or generated_file_name, + 'structured_export_artifacts': structured_artifacts, + 'structured_export_artifact': structured_artifact, + 'final_artifact': structured_artifact, 'estimated_remaining_seconds': 0, }) + _publish_artifact_set_members( + run, + [artifact.get('artifact_id') or artifact.get('member_id') for artifact in structured_artifacts], + ) run.update(_build_generation_progress_contract_fields( run, run.get('batch_count'), @@ -7108,8 +8995,8 @@ def _complete_run(run): 'checkpointed_row_count': run.get('checkpointed_row_count'), 'generation_contract_version': run.get('generation_contract_version'), 'response_protocol_version': run.get('response_protocol_version'), - 'artifact_message_id': uploaded_message.get('id'), - 'generated_file_name': uploaded_message.get('file_name') or generated_file_name, + 'artifact_message_id': structured_artifact.get('artifact_message_id'), + 'generated_file_name': structured_artifact.get('file_name') or generated_file_name, **run.get('performance_summary', {}), }, level=logging.INFO, @@ -7216,6 +9103,10 @@ def _publish_analysis_artifact(run, final_summary): output_format='md', summary=final_summary.get('summary'), artifact_idempotency_key=f"tabular-hierarchical-analysis:{run.get('id')}", + artifact_lifecycle_metadata=_build_artifact_member_upload_metadata( + run, + _get_analysis_artifact_member_id(run), + ), ) _raise_if_tabular_export_canceled(run) @@ -7251,6 +9142,7 @@ def _complete_analysis_run(run, final_summary): ), 'estimated_remaining_seconds': 0, }) + _publish_artifact_set_members(run, [_get_analysis_artifact_member_id(run)]) run.update(_build_generation_progress_contract_fields( run, run.get('batch_count'), @@ -7280,16 +9172,10 @@ def _publish_combined_structured_export_phase(run): if isinstance(run.get('structured_export_artifact'), dict) and run.get('structured_export_artifact'): return run - run, uploaded_message, post_run_summary, output_entry_count, output_format, generated_file_name = ( - _publish_structured_export_artifact(run) - ) - structured_artifact = _build_artifact_metadata( - uploaded_message, - generated_file_name, - output_format, - preview_rows=_build_structured_export_preview_rows(run), - suppress_assistant_text=True, + run, structured_artifacts, post_run_summary, output_entry_count, output_format, generated_file_name = ( + _publish_structured_export_artifacts(run) ) + structured_artifact = structured_artifacts[0] if structured_artifacts else {} now = _now_iso() run.update({ 'updated_at': now, @@ -7301,11 +9187,20 @@ def _publish_combined_structured_export_phase(run): 'analysis_phase': 'reducing', 'last_message': 'Combined structured export published; reducing tabular analysis summaries', 'post_run_export_summary': post_run_summary, - 'generated_file_name': uploaded_message.get('file_name') or generated_file_name, + 'generated_file_name': structured_artifact.get('file_name') or generated_file_name, + 'structured_export_artifacts': structured_artifacts, 'structured_export_artifact': structured_artifact, 'final_artifact': structured_artifact, 'estimated_remaining_seconds': None, }) + for artifact in structured_artifacts: + _set_artifact_set_member_state( + run, + artifact.get('artifact_id') or artifact.get('member_id'), + artifact=artifact, + lifecycle_state=TABULAR_ARTIFACT_MEMBER_LIFECYCLE_STAGED, + validation_state='validated', + ) run = _replace_claimed_run(run) log_event( '[TABULAR_GENERATED_OUTPUT] Combined structured export artifact published', @@ -7317,8 +9212,8 @@ def _publish_combined_structured_export_phase(run): 'output_format': output_format, 'row_count': output_entry_count, 'batch_count': run.get('batch_count'), - 'artifact_message_id': uploaded_message.get('id'), - 'generated_file_name': uploaded_message.get('file_name') or generated_file_name, + 'artifact_message_id': structured_artifact.get('artifact_message_id'), + 'generated_file_name': structured_artifact.get('file_name') or generated_file_name, }, level=logging.INFO, ) @@ -7334,6 +9229,7 @@ def _complete_combined_analysis_run(run, final_summary): ) analysis_artifact = existing_analysis_artifact generated_file_name = existing_analysis_artifact.get('file_name') or run.get('analysis_generated_file_name') + uploaded_message = {'file_name': generated_file_name} else: run, uploaded_message, final_summary, generated_file_name = _publish_analysis_artifact(run, final_summary) analysis_artifact = _build_artifact_metadata( @@ -7344,7 +9240,25 @@ def _complete_combined_analysis_run(run, final_summary): suppress_assistant_text=True, ) + structured_artifacts = list(run.get('structured_export_artifacts') or []) structured_artifact = run.get('structured_export_artifact') or run.get('final_artifact') or {} + if not structured_artifacts and structured_artifact: + structured_artifacts = [structured_artifact] + for artifact in structured_artifacts: + _set_artifact_set_member_state( + run, + artifact.get('artifact_id') or artifact.get('member_id') or _get_structured_artifact_member_id(run), + artifact=artifact, + lifecycle_state=TABULAR_ARTIFACT_MEMBER_LIFECYCLE_STAGED, + validation_state='validated', + ) + _set_artifact_set_member_state( + run, + _get_analysis_artifact_member_id(run), + artifact=analysis_artifact, + lifecycle_state=TABULAR_ARTIFACT_MEMBER_LIFECYCLE_STAGED, + validation_state='validated', + ) now = _now_iso() run.update({ 'status': TABULAR_EXPORT_STATUS_COMPLETED, @@ -7360,14 +9274,25 @@ def _complete_combined_analysis_run(run, final_summary): 'last_message': 'Background combined tabular analysis and export completed', 'post_run_summary': final_summary.get('summary'), 'analysis_generated_file_name': uploaded_message.get('file_name') or generated_file_name, + 'structured_export_artifacts': structured_artifacts, 'structured_export_artifact': structured_artifact, 'analysis_artifact': analysis_artifact, 'combined_artifacts': [ - artifact for artifact in (structured_artifact, analysis_artifact) if artifact + artifact for artifact in [analysis_artifact, *structured_artifacts] if artifact ], - 'final_artifact': structured_artifact or analysis_artifact, + 'final_artifact': analysis_artifact or structured_artifact, 'estimated_remaining_seconds': 0, }) + _publish_artifact_set_members( + run, + [ + _get_analysis_artifact_member_id(run), + *[ + artifact.get('artifact_id') or artifact.get('member_id') + for artifact in structured_artifacts + ], + ], + ) run.update(_build_generation_progress_contract_fields( run, run.get('batch_count'), @@ -7668,15 +9593,22 @@ def _record_shadow_tabular_generation_plan_comparison(run, actual_output_schema) def _load_active_compact_generation_plan(run): if not _is_compact_row_array_protocol((run or {}).get('response_protocol_version')): return None - if _get_tabular_generation_plan_mode(run) != 'active' or (run or {}).get('plan_status') != 'ready': + plan = _load_ready_active_tabular_generation_plan(run) + if plan is None: raise ValueError('Compact row protocol requires a ready active generation plan') + return plan + + +def _load_ready_active_tabular_generation_plan(run): + if _get_tabular_generation_plan_mode(run) != 'active' or (run or {}).get('plan_status') != 'ready': + return None plan_blob_path = str((run or {}).get('plan_blob_path') or '').strip() if not plan_blob_path: - raise ValueError('Compact row protocol plan path is missing') + raise ValueError('Active generation plan path is missing') plan = _download_json_blob(plan_blob_path) _validate_tabular_generation_plan(plan, run) if plan.get('plan_hash') != (run or {}).get('plan_hash'): - raise ValueError('Compact row protocol plan hash does not match the run record') + raise ValueError('Active generation plan hash does not match the run record') return plan @@ -7699,6 +9631,8 @@ def _checkpoint_generated_batch_results(run, generated_results): run_contract_changed = False if list(run.get('output_schema') or []) != expected_output_schema: run['output_schema'] = expected_output_schema + run['public_output_schema'] = _get_tabular_run_public_output_schema(run) + run['internal_checkpoint_schema'] = _get_tabular_run_internal_checkpoint_schema(run) run_contract_changed = True if _record_shadow_tabular_generation_plan_comparison(run, expected_output_schema): run_contract_changed = True @@ -7917,6 +9851,49 @@ def _build_passthrough_batch_results(run, batch_requests): return generated_results +def _build_deterministic_transformation_batch_results(run, batch_requests): + """Create checkpoint entries directly from a deterministic transformation spec.""" + expected_output_schema = list(run.get('output_schema') or _get_tabular_run_internal_checkpoint_schema(run)) + public_output_schema = _get_public_fields_from_output_schema(expected_output_schema) + transformation_spec = _get_tabular_run_transformation_spec( + run, + public_output_schema=public_output_schema, + ) + if not is_tabular_transformation_deterministic_only( + transformation_spec, + public_output_schema=public_output_schema, + ): + raise ValueError('Deterministic transformation checkpoints require a deterministic-only spec') + + generated_results = [] + for batch_request in batch_requests: + batch_started_at = time.monotonic() + batch_entries = [] + for source_row in batch_request['rows']: + deterministic_values = evaluate_tabular_transformation_row(transformation_spec, source_row) + checkpoint_entry = {} + for field_name in expected_output_schema: + if field_name == TABULAR_EXPORT_OUTPUT_ROW_NUMBER_FIELD: + checkpoint_entry[field_name] = source_row.get(TABULAR_EXPORT_INPUT_ROW_NUMBER_FIELD) + elif field_name == TABULAR_EXPORT_OUTPUT_ROW_IDENTITY_FIELD: + checkpoint_entry[field_name] = str(source_row.get(TABULAR_EXPORT_INPUT_ROW_IDENTITY_FIELD) or '') + elif field_name in deterministic_values: + checkpoint_entry[field_name] = deterministic_values.get(field_name) + else: + raise ValueError(f'Deterministic transformation did not produce field {field_name}') + batch_entries.append(checkpoint_entry) + generated_results.append({ + 'batch_number': batch_request['batch_number'], + 'batch_entries': batch_entries, + 'batch_summary': _build_generated_batch_summary(batch_entries), + 'batch_row_count': len(batch_entries), + 'elapsed_seconds': time.monotonic() - batch_started_at, + 'mismatch_count': 0, + 'output_schema': expected_output_schema, + }) + return generated_results + + def _advance_run_progress_for_window(run, batch_results, completed_batches, processed_rows, window_start, window_end): window_results = [] window_rows = 0 @@ -8279,6 +10256,9 @@ def _process_combined_run( batch_concurrency, batch_timeout_seconds, expected_output_schema=run.get('output_schema'), + transformation_spec=_get_tabular_run_transformation_spec(run), + generation_plan=_load_ready_active_tabular_generation_plan(run), + semantic_validation_options=_get_tabular_semantic_validation_options(run), ) ) _raise_if_tabular_export_canceled(run) @@ -8365,6 +10345,8 @@ def _process_structured_export_rolling_pool( batch_timeout_seconds=batch_timeout_seconds, response_protocol=run.get('response_protocol_version'), generation_plan=generation_plan, + transformation_spec=_get_tabular_run_transformation_spec(run), + semantic_validation_options=_get_tabular_semantic_validation_options(run), ) ) del last_logged_at @@ -8426,8 +10408,17 @@ def process_tabular_generated_output_run(run_id, user_id): ), max(30, stale_seconds - 30), ) + normalized_task_type = _normalize_tabular_run_task_type(run.get('task_type')) + transformation_spec = _get_tabular_run_transformation_spec(run) + deterministic_only_structured_export = ( + normalized_task_type == TABULAR_RUN_TASK_STRUCTURED_EXPORT + and is_tabular_transformation_deterministic_only( + transformation_spec, + public_output_schema=_get_tabular_run_public_output_schema(run), + ) + ) chat_service = None - if not run.get('passthrough_input_rows'): + if not run.get('passthrough_input_rows') and not deterministic_only_structured_export: has_snapshotted_chunk_model = bool(str(run.get('chunk_gpt_model') or '').strip()) chat_service = _build_chat_service( run.get('chunk_gpt_model') if has_snapshotted_chunk_model else run.get('gpt_model'), @@ -8457,7 +10448,7 @@ def process_tabular_generated_output_run(run_id, user_id): settings, batch_timeout_seconds, ) - generation_plan = _load_active_compact_generation_plan(run) + generation_plan = _load_ready_active_tabular_generation_plan(run) if str(run.get('executor_mode') or '').strip() == TABULAR_EXECUTOR_MODE_ROLLING_POOL: if not _is_rolling_executor_ready(run): if str(run.get('plan_status') or '').strip().lower() in {'fallback', 'disabled', 'not_applicable'}: @@ -8484,7 +10475,6 @@ def process_tabular_generated_output_run(run_id, user_id): level=logging.INFO, ) - normalized_task_type = _normalize_tabular_run_task_type(run.get('task_type')) if normalized_task_type == TABULAR_RUN_TASK_COMBINED: return _process_combined_run( run, @@ -8556,6 +10546,8 @@ def process_tabular_generated_output_run(run_id, user_id): ) if run.get('passthrough_input_rows'): generated_results = _build_passthrough_batch_results(run, batch_requests) + elif deterministic_only_structured_export: + generated_results = _build_deterministic_transformation_batch_results(run, batch_requests) elif _is_completion_driven_checkpointing_enabled(settings, run): generated_batch_results, generation_error = asyncio.run( _generate_and_checkpoint_batch_window_entries( @@ -8574,6 +10566,9 @@ def process_tabular_generated_output_run(run_id, user_id): batch_timeout_seconds=batch_timeout_seconds, response_protocol=run.get('response_protocol_version'), generation_plan=generation_plan, + transformation_spec=_get_tabular_run_transformation_spec(run), + semantic_validation_options=_get_tabular_semantic_validation_options(run), + semantic_checkpoint_run=run, ) ) batch_results.update(generated_batch_results) @@ -8595,6 +10590,8 @@ def process_tabular_generated_output_run(run_id, user_id): batch_timeout_seconds=batch_timeout_seconds, response_protocol=run.get('response_protocol_version'), generation_plan=generation_plan, + transformation_spec=_get_tabular_run_transformation_spec(run), + semantic_validation_options=_get_tabular_semantic_validation_options(run), ) ) _raise_if_tabular_export_canceled(run) @@ -8624,6 +10621,9 @@ def process_tabular_generated_output_run(run_id, user_id): return _read_run(normalized_user_id, normalized_run_id) except TabularExportLeaseLostError: return _read_run(normalized_user_id, normalized_run_id) + except TabularGenerationPlanError as exc: + failed_run = exc.failed_run if isinstance(exc.failed_run, dict) else run + return _mark_run_failed(failed_run, exc) except Exception as exc: if _is_retryable_export_error(exc): return _mark_run_retryable(run, exc, settings, retry_category='transient') @@ -8700,6 +10700,17 @@ def queue_tabular_generated_output_run( settings = settings or {} source_descriptor = dict(source_descriptor or {}) tabular_planner_metadata = _normalize_tabular_run_planner_metadata(planner_metadata) + deliverable_contract = tabular_planner_metadata.get('deliverable_contract') or {} + contract_public_output_schema = list(deliverable_contract.get('public_output_schema') or []) + contract_internal_checkpoint_schema = list(deliverable_contract.get('internal_checkpoint_schema') or []) + contract_transformation_spec = dict(deliverable_contract.get('transformation_spec') or {}) + deterministic_only_structured_export = ( + normalized_task_type == TABULAR_RUN_TASK_STRUCTURED_EXPORT + and is_tabular_transformation_deterministic_only( + contract_transformation_spec, + public_output_schema=contract_public_output_schema, + ) + ) source_authorization = dict(source_candidate.get('source_authorization') or {}) staged_row_count = 0 staged_char_count = 0 @@ -8728,8 +10739,20 @@ def queue_tabular_generated_output_run( if rollout_settings.get('enable_tabular_generation_plan') else 'off' ) - if normalized_task_type == TABULAR_RUN_TASK_HIERARCHICAL_ANALYSIS or passthrough_input_rows: + if ( + normalized_task_type == TABULAR_RUN_TASK_HIERARCHICAL_ANALYSIS + or passthrough_input_rows + or deterministic_only_structured_export + or bool(contract_transformation_spec) + ): requested_plan_mode = 'off' + tabular_planner_metadata = _ensure_active_tabular_run_deliverable_contract( + tabular_planner_metadata, + requested_plan_mode, + normalized_task_type, + normalized_output_format, + user_question, + ) response_protocol_version = _select_tabular_response_protocol( rollout_settings, requested_plan_mode, @@ -8885,6 +10908,11 @@ def queue_tabular_generated_output_run( 'chunk_gpt_model': str(chunk_gpt_model or '').strip(), 'chunk_model_context': chunk_model_context if isinstance(chunk_model_context, dict) else {}, 'passthrough_input_rows': bool(passthrough_input_rows), + 'passthrough_reason_code': ( + str(source_candidate.get('passthrough_reason_code') or '').strip()[:80] + if passthrough_input_rows + else None + ), 'generated_file_name': generated_file_name, 'analysis_generated_file_name': analysis_generated_file_name, 'row_count': staged_row_count, @@ -8918,7 +10946,14 @@ def queue_tabular_generated_output_run( 'planner_started_at': None, 'planner_completed_at': None, 'processed_rows': 0, - 'output_schema': None, + 'output_schema': contract_internal_checkpoint_schema or None, + 'public_output_schema': contract_public_output_schema, + 'internal_checkpoint_schema': contract_internal_checkpoint_schema, + 'lineage_schema': [ + TABULAR_EXPORT_OUTPUT_ROW_NUMBER_FIELD, + TABULAR_EXPORT_OUTPUT_ROW_IDENTITY_FIELD, + ], + 'transformation_spec': contract_transformation_spec, 'source_descriptor': source_descriptor or None, 'batch_budget': model_batch_budget, 'source_authorization': source_authorization or None, @@ -8957,6 +10992,7 @@ def queue_tabular_generated_output_run( 'analysis_artifact': None, 'combined_artifacts': [], } + run['artifact_set_manifest'] = _build_or_update_artifact_set_manifest(run) cosmos_tabular_export_runs_container.create_item(body=run) submitted = submit_tabular_generated_output_run(run_id, normalized_user_id) run['submitted_to_executor'] = submitted diff --git a/application/single_app/functions_tabular_orchestration.py b/application/single_app/functions_tabular_orchestration.py index cb408da99..bafb7308a 100644 --- a/application/single_app/functions_tabular_orchestration.py +++ b/application/single_app/functions_tabular_orchestration.py @@ -6,8 +6,25 @@ import os from typing import Mapping +from functions_analysis_deliverables import ( + ANALYSIS_DELIVERABLE_EVENT_FINALIZED, + ANALYSIS_DELIVERABLE_EVENT_PLANNED, + ANALYSIS_ORDERING_NOT_APPLICABLE, + ANALYSIS_ORDERING_SOURCE_ORDER, + ANALYSIS_ROW_CARDINALITY_NOT_APPLICABLE, + ANALYSIS_ROW_CARDINALITY_ONE_PER_SOURCE_ROW, + ANALYSIS_TRANSFORMATION_MODE_DETERMINISTIC, + ANALYSIS_TRANSFORMATION_MODE_SEMANTIC, + ANALYSIS_VALIDATION_PROFILE_ARTIFACT_SET, + ANALYSIS_VALIDATION_PROFILE_EXACT_ROWS_SCHEMA, + ANALYSIS_VALIDATION_PROFILE_EXACT_ROWS_SCHEMA_AND_RULES, + build_analysis_deliverable_contract, + emit_analysis_deliverable_contract_event, +) from functions_assistant_table_exports import assistant_table_export_requested +from functions_generated_file_exports import get_requested_artifact_formats from functions_generated_file_exports import get_requested_structured_artifact_format +from functions_generated_file_exports import get_requested_structured_artifact_formats TABULAR_ORCHESTRATION_PLANNER_CONTRACT_VERSION = "tabular-orchestration-v1" @@ -34,6 +51,7 @@ TABULAR_PLANNER_MODE_ACTIVE, } TABULAR_PARITY_ROLLOUT_CONTRACT_VERSION = "tabular-parity-rollout-v1" +TABULAR_PARITY_ROLLOUT_STATES = {"active", "paused", "rollback"} TABULAR_LEGACY_POST_TOOL_FALLBACK_MODES = {"enabled", "observe", "disabled"} TABULAR_LEGACY_POST_TOOL_FALLBACK_DECISION_VERSION = "tabular-legacy-fallback-retirement-v1" @@ -65,6 +83,17 @@ def _coerce_rollout_percentage(value, default=100): return max(0, min(100, parsed_value)) +def normalize_tabular_parity_rollout_state(settings=None, state=None): + """Return the supported rollout state for new shared tabular assignments.""" + raw_state = state + if raw_state is None: + raw_state = (settings or {}).get("tabular_analyze_parity_rollout_state", "active") + normalized_state = str(raw_state or "").strip().lower() + if normalized_state in TABULAR_PARITY_ROLLOUT_STATES: + return normalized_state + return "active" + + def normalize_tabular_legacy_post_tool_fallback_mode(settings=None, mode=None): """Return the supported legacy fallback mode for post-tool recovery.""" raw_mode = mode @@ -131,6 +160,7 @@ def build_tabular_parity_rollout_assignment(settings=None, request_key=None, mod normalized_mode = str(mode or "").strip().lower() if normalized_mode not in {"search", "analyze", "multifile", "mixed"}: normalized_mode = "tabular" + rollout_state = normalize_tabular_parity_rollout_state(settings) rollout_percent = _coerce_rollout_percentage( settings.get( "tabular_analyze_parity_rollout_percent", @@ -148,11 +178,23 @@ def build_tabular_parity_rollout_assignment(settings=None, request_key=None, mod separators=(",", ":"), ) cohort_bucket = int(hashlib.sha256(assignment_key.encode("utf-8")).hexdigest()[:8], 16) % 100 + assigned_by_cohort = cohort_bucket < rollout_percent + assigned = rollout_state == "active" and assigned_by_cohort + if rollout_state == "rollback": + assignment_reason_code = "rollback_active" + elif rollout_state == "paused": + assignment_reason_code = "rollout_paused" + elif assigned_by_cohort: + assignment_reason_code = "assigned" + else: + assignment_reason_code = "outside_rollout_cohort" return { "contract_version": TABULAR_PARITY_ROLLOUT_CONTRACT_VERSION, "mode": normalized_mode, "planner_mode": normalize_tabular_request_planner_mode(settings), - "assigned": cohort_bucket < rollout_percent, + "rollout_state": rollout_state, + "assigned": assigned, + "assignment_reason_code": assignment_reason_code, "cohort_bucket": cohort_bucket, "rollout_percent": rollout_percent, "search_shared_preflight_enabled": settings_flag_enabled( @@ -181,7 +223,13 @@ def build_tabular_parity_rollout_assignment(settings=None, request_key=None, mod def get_tabular_generated_output_format(user_question): """Return the requested generated-output file format when the user asked for one.""" - return get_requested_structured_artifact_format(user_question) + requested_formats = get_tabular_generated_output_formats(user_question) + return requested_formats[0] if requested_formats else None + + +def get_tabular_generated_output_formats(user_question): + """Return requested durable tabular artifact formats in user-request order.""" + return get_requested_structured_artifact_formats(user_question) def question_requests_tabular_generated_output(user_question): @@ -266,6 +314,7 @@ def get_tabular_generated_output_task_type( generated_output_requested, hierarchical_analysis_requested, settings, + action_mode=None, ): """Map request intent to the existing durable generated-output task type.""" hierarchical_analysis_enabled = settings_flag_enabled( @@ -273,6 +322,9 @@ def get_tabular_generated_output_task_type( "enable_tabular_hierarchical_analysis", False, ) + analysis_required = str(action_mode or "").strip().lower() == "analyze" + if generated_output_requested and analysis_required: + return TABULAR_RUN_TASK_COMBINED if generated_output_requested and hierarchical_analysis_requested and hierarchical_analysis_enabled: return TABULAR_RUN_TASK_COMBINED if generated_output_requested: @@ -472,6 +524,15 @@ def _build_request_fingerprint( return hashlib.sha256(serialized_payload.encode("utf-8")).hexdigest() +def _build_source_coverage_fingerprint(source_coverage): + fingerprint_payload = { + "planner_contract_version": TABULAR_ORCHESTRATION_PLANNER_CONTRACT_VERSION, + "source_coverage": list(source_coverage or []), + } + serialized_payload = json.dumps(fingerprint_payload, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(serialized_payload.encode("utf-8")).hexdigest() + + def _callback_requested_cancellation(cancel_requested=None): if cancel_requested is None: return False @@ -494,14 +555,20 @@ def plan_tabular_request( for file_context in _dedupe_tabular_file_contexts(file_contexts) if _is_supported_tabular_context(file_context) ] + output_hints = dict(requested_output_hints or {}) if isinstance(requested_output_hints, Mapping) else {} + normalized_action_mode = str(action_mode or "").strip().lower() + analysis_required = normalized_action_mode == "analyze" + requested_output_formats = get_requested_artifact_formats(user_question) + structured_output_formats = get_tabular_generated_output_formats(user_question) generated_output_requested = question_requests_tabular_generated_output(user_question) hierarchical_analysis_requested = question_requests_tabular_hierarchical_analysis(user_question) durable_task_type = get_tabular_generated_output_task_type( generated_output_requested, hierarchical_analysis_requested, settings, + action_mode=normalized_action_mode, ) - output_format = get_tabular_generated_output_format(user_question) + output_format = structured_output_formats[0] if structured_output_formats else None execution_contract = durable_task_type or TABULAR_EXECUTION_CONTRACT_FOREGROUND_AGGREGATE source_coverage = _build_source_coverage(normalized_contexts) execution_group_id = _build_execution_group_id( @@ -556,16 +623,55 @@ def plan_tabular_request( request_key=request_fingerprint, mode=action_mode, ) + transformation_spec = output_hints.get("transformation_spec") + transformation_mode = output_hints.get("transformation_mode") or ( + ANALYSIS_TRANSFORMATION_MODE_DETERMINISTIC + if transformation_spec + else ANALYSIS_TRANSFORMATION_MODE_SEMANTIC + ) + validation_profile = ( + ANALYSIS_VALIDATION_PROFILE_EXACT_ROWS_SCHEMA_AND_RULES + if generated_output_requested and transformation_spec + else ANALYSIS_VALIDATION_PROFILE_EXACT_ROWS_SCHEMA + if generated_output_requested + else ANALYSIS_VALIDATION_PROFILE_ARTIFACT_SET + ) + deliverable_contract = build_analysis_deliverable_contract( + action_mode=action_mode, + requested_output_formats=requested_output_formats, + public_output_schema=( + output_hints.get("public_output_schema") + or output_hints.get("output_schema") + or [] + ), + row_cardinality=( + ANALYSIS_ROW_CARDINALITY_ONE_PER_SOURCE_ROW + if generated_output_requested + else ANALYSIS_ROW_CARDINALITY_NOT_APPLICABLE + ), + ordering=( + ANALYSIS_ORDERING_SOURCE_ORDER + if generated_output_requested + else ANALYSIS_ORDERING_NOT_APPLICABLE + ), + transformation_mode=transformation_mode, + transformation_spec=transformation_spec, + validation_profile=validation_profile, + source_fingerprint=_build_source_coverage_fingerprint(source_coverage), + request_fingerprint=request_fingerprint, + ) - return { + result = { "planner_contract_version": TABULAR_ORCHESTRATION_PLANNER_CONTRACT_VERSION, + "deliverable_contract": deliverable_contract.to_dict(), "execution_contract": execution_contract, "execution_state": execution_state, "durable_task_type": durable_task_type, "generated_output_requested": generated_output_requested, "hierarchical_analysis_requested": hierarchical_analysis_requested, + "requested_output_formats": requested_output_formats, "output_format": output_format, - "action_mode": str(action_mode or "").strip().lower(), + "action_mode": normalized_action_mode, "caller": str(caller or "").strip().lower(), "source_count": len(normalized_contexts), "source_coverage": source_coverage, @@ -578,9 +684,7 @@ def plan_tabular_request( fallback_source="planner", ), "reason_code": reason_code, - "requested_output_hints": dict(requested_output_hints or {}) - if isinstance(requested_output_hints, Mapping) - else {}, + "requested_output_hints": output_hints, "token_usage": None, "citations": [], "generated_output_metadata": None, @@ -588,6 +692,17 @@ def plan_tabular_request( "deferred_composition": None, "safe_failure_details": safe_failure_details, } + emit_analysis_deliverable_contract_event( + settings, + ANALYSIS_DELIVERABLE_EVENT_PLANNED, + contract=deliverable_contract, + dimensions={ + "selected_execution_task_type": durable_task_type or execution_contract, + "passthrough_selected": False, + "planner_reason_code": reason_code, + }, + ) + return result def execute_tabular_plan( @@ -772,4 +887,19 @@ def orchestrate_tabular_request( planner_result=result, fallback_source="shared_preflight", ) + emit_analysis_deliverable_contract_event( + settings, + ANALYSIS_DELIVERABLE_EVENT_FINALIZED, + contract=result.get("deliverable_contract"), + dimensions={ + "selected_execution_task_type": result.get("durable_task_type") or result.get("execution_contract"), + "execution_state": result.get("execution_state"), + "planner_reason_code": result.get("reason_code"), + }, + metrics={ + "required_artifact_completion_count": 1 + if isinstance(result.get("generated_output_metadata"), Mapping) + else 0, + }, + ) return result diff --git a/application/single_app/functions_tabular_semantic_validation.py b/application/single_app/functions_tabular_semantic_validation.py new file mode 100644 index 000000000..f99b7266f --- /dev/null +++ b/application/single_app/functions_tabular_semantic_validation.py @@ -0,0 +1,386 @@ +# functions_tabular_semantic_validation.py +"""Bounded field-level verification and repair contracts for tabular outputs.""" + +import hashlib +import json +from decimal import Decimal, InvalidOperation + + +TABULAR_SEMANTIC_VALIDATION_CONTRACT_VERSION = "tabular-semantic-validation-v1" +TABULAR_SEMANTIC_VALIDATION_STATUSES = frozenset({"pass", "fail", "uncertain", "unsupported"}) +TABULAR_SEMANTIC_REPAIRABLE_STATUSES = frozenset({"fail", "uncertain"}) +TABULAR_SEMANTIC_MAX_ROWS = 500 +TABULAR_SEMANTIC_MAX_FIELDS = 50 +TABULAR_SEMANTIC_MAX_EVIDENCE_FIELDS = 20 +TABULAR_SEMANTIC_MAX_REASON_CODE_LENGTH = 80 +TABULAR_SEMANTIC_VALIDATION_MODES = frozenset({"off", "shadow", "active"}) +TABULAR_SEMANTIC_MAX_STRING_LENGTH = 4096 +TABULAR_SEMANTIC_MAX_COLLECTION_ITEMS = 200 +TABULAR_SEMANTIC_MAX_COLLECTION_CHARS = 16384 +TABULAR_SEMANTIC_MAX_NUMERIC_ABS = Decimal("1e18") + + +class TabularSemanticValidationError(ValueError): + """Raised when verifier or repair output violates the bounded contract.""" + + +def _normalize_field_name(value, label): + normalized_value = str(value or "").strip() + if not normalized_value or len(normalized_value) > 128: + raise TabularSemanticValidationError(f"Semantic {label} is invalid") + return normalized_value + + +def _normalize_row_key(value): + normalized_value = str(value or "").strip() + if not normalized_value or len(normalized_value) > 80: + raise TabularSemanticValidationError("Semantic row key is invalid") + return normalized_value + + +def _semantic_field_contracts(transformation_spec): + fields = [] + for field in list((transformation_spec or {}).get("fields") or []): + if not isinstance(field, dict) or str(field.get("mode") or "").strip().lower() == "deterministic": + continue + fields.append({ + "name": _normalize_field_name(field.get("name"), "field name"), + "type": str(field.get("type") or "string").strip().lower() or "string", + "nullable": bool(field.get("nullable", True)), + "allowed_values": list(field.get("allowed_values") or []), + }) + if len(fields) > TABULAR_SEMANTIC_MAX_FIELDS: + raise TabularSemanticValidationError("Semantic field count exceeds the bounded limit") + return fields + + +def build_semantic_verification_request(source_rows, output_rows, transformation_spec): + """Build a bounded verifier payload with opaque row keys and public values.""" + sources = list(source_rows or []) + outputs = list(output_rows or []) + if len(sources) != len(outputs): + raise TabularSemanticValidationError("Semantic verification row counts do not match") + if len(outputs) > TABULAR_SEMANTIC_MAX_ROWS: + raise TabularSemanticValidationError("Semantic verification row count exceeds the bounded limit") + field_contracts = _semantic_field_contracts(transformation_spec) + if not field_contracts: + return {"version": TABULAR_SEMANTIC_VALIDATION_CONTRACT_VERSION, "fields": [], "rows": []} + rows = [] + for row_index, (source_row, output_row) in enumerate(zip(sources, outputs), start=1): + if not isinstance(source_row, dict) or not isinstance(output_row, dict): + raise TabularSemanticValidationError("Semantic verification rows must be objects") + rows.append({ + "row_key": f"r{row_index}", + "source": dict(source_row), + "candidate": { + field["name"]: output_row.get(field["name"]) + for field in field_contracts + }, + }) + return { + "version": TABULAR_SEMANTIC_VALIDATION_CONTRACT_VERSION, + "fields": field_contracts, + "rows": rows, + } + + +def normalize_semantic_verification_response(response_payload, verification_request): + """Validate one exact field-level verifier response without retaining reasoning.""" + if not isinstance(response_payload, dict) or set(response_payload) != {"version", "rows"}: + raise TabularSemanticValidationError("Semantic verifier response shape is invalid") + if response_payload.get("version") != TABULAR_SEMANTIC_VALIDATION_CONTRACT_VERSION: + raise TabularSemanticValidationError("Semantic verifier response version is unsupported") + + expected_rows = list((verification_request or {}).get("rows") or []) + expected_fields = [field["name"] for field in list((verification_request or {}).get("fields") or [])] + expected_row_keys = [row["row_key"] for row in expected_rows] + raw_rows = response_payload.get("rows") + if not isinstance(raw_rows, list) or len(raw_rows) != len(expected_rows): + raise TabularSemanticValidationError("Semantic verifier response row count is invalid") + + normalized_rows = [] + status_counts = {status: 0 for status in sorted(TABULAR_SEMANTIC_VALIDATION_STATUSES)} + seen_row_keys = set() + for raw_row in raw_rows: + if not isinstance(raw_row, dict) or set(raw_row) != {"row_key", "fields"}: + raise TabularSemanticValidationError("Semantic verifier row shape is invalid") + row_key = _normalize_row_key(raw_row.get("row_key")) + if row_key not in expected_row_keys or row_key in seen_row_keys: + raise TabularSemanticValidationError("Semantic verifier row identity is invalid") + seen_row_keys.add(row_key) + raw_fields = raw_row.get("fields") + if not isinstance(raw_fields, list) or len(raw_fields) != len(expected_fields): + raise TabularSemanticValidationError("Semantic verifier field count is invalid") + normalized_fields = [] + seen_fields = set() + for raw_field in raw_fields: + if not isinstance(raw_field, dict) or set(raw_field) != { + "name", + "status", + "reason_code", + "evidence_fields", + }: + raise TabularSemanticValidationError("Semantic verifier field shape is invalid") + field_name = _normalize_field_name(raw_field.get("name"), "field name") + if field_name not in expected_fields or field_name in seen_fields: + raise TabularSemanticValidationError("Semantic verifier field identity is invalid") + seen_fields.add(field_name) + status = str(raw_field.get("status") or "").strip().lower() + if status not in TABULAR_SEMANTIC_VALIDATION_STATUSES: + raise TabularSemanticValidationError("Semantic verifier field status is unsupported") + reason_code = str(raw_field.get("reason_code") or "").strip().lower() + if not reason_code or len(reason_code) > TABULAR_SEMANTIC_MAX_REASON_CODE_LENGTH: + raise TabularSemanticValidationError("Semantic verifier reason code is invalid") + evidence_fields = [ + _normalize_field_name(value, "evidence field") + for value in list(raw_field.get("evidence_fields") or []) + ] + if len(evidence_fields) > TABULAR_SEMANTIC_MAX_EVIDENCE_FIELDS: + raise TabularSemanticValidationError("Semantic verifier evidence field count is too large") + source_fields = set(expected_rows[expected_row_keys.index(row_key)]["source"]) + if set(evidence_fields) - source_fields: + raise TabularSemanticValidationError("Semantic verifier referenced unknown evidence fields") + status_counts[status] += 1 + normalized_fields.append({ + "name": field_name, + "status": status, + "reason_code": reason_code, + "evidence_fields": evidence_fields, + }) + normalized_rows.append({"row_key": row_key, "fields": normalized_fields}) + + if [row["row_key"] for row in normalized_rows] != expected_row_keys: + raise TabularSemanticValidationError("Semantic verifier row order is invalid") + return { + "version": TABULAR_SEMANTIC_VALIDATION_CONTRACT_VERSION, + "rows": normalized_rows, + "status_counts": status_counts, + } + + +def collect_semantic_repair_targets(verification_report): + """Return only failed or uncertain row-field pairs eligible for repair.""" + targets = [] + for row in list((verification_report or {}).get("rows") or []): + for field in list(row.get("fields") or []): + if field.get("status") in TABULAR_SEMANTIC_REPAIRABLE_STATUSES: + targets.append({ + "row_key": row.get("row_key"), + "field_name": field.get("name"), + "reason_code": field.get("reason_code"), + }) + return targets + + +def _value_matches_contract(value, field_contract): + if value is None: + return bool(field_contract.get("nullable")) + value_type = field_contract.get("type") + if value_type == "string": + type_valid = isinstance(value, str) and len(value) <= TABULAR_SEMANTIC_MAX_STRING_LENGTH + elif value_type == "boolean": + type_valid = isinstance(value, bool) + elif value_type == "integer": + type_valid = ( + isinstance(value, int) + and not isinstance(value, bool) + and abs(value) <= TABULAR_SEMANTIC_MAX_NUMERIC_ABS + ) + elif value_type == "number": + try: + parsed_value = Decimal(str(value)) + type_valid = ( + not isinstance(value, bool) + and parsed_value.is_finite() + and abs(parsed_value) <= TABULAR_SEMANTIC_MAX_NUMERIC_ABS + ) + except (InvalidOperation, TypeError, ValueError): + type_valid = False + elif value_type == "object": + try: + serialized_value = json.dumps(value, ensure_ascii=False, allow_nan=False, separators=(",", ":")) + except (TypeError, ValueError): + serialized_value = "" + type_valid = ( + isinstance(value, dict) + and len(value) <= TABULAR_SEMANTIC_MAX_COLLECTION_ITEMS + and len(serialized_value) <= TABULAR_SEMANTIC_MAX_COLLECTION_CHARS + ) + elif value_type == "array": + try: + serialized_value = json.dumps(value, ensure_ascii=False, allow_nan=False, separators=(",", ":")) + except (TypeError, ValueError): + serialized_value = "" + type_valid = ( + isinstance(value, list) + and len(value) <= TABULAR_SEMANTIC_MAX_COLLECTION_ITEMS + and len(serialized_value) <= TABULAR_SEMANTIC_MAX_COLLECTION_CHARS + ) + else: + type_valid = False + allowed_values = list(field_contract.get("allowed_values") or []) + return type_valid and (not allowed_values or value in allowed_values) + + +def apply_semantic_repair_response(output_rows, repair_payload, repair_targets, transformation_spec): + """Apply an exact targeted repair response and reject extra rows or fields.""" + rows = [dict(row) for row in list(output_rows or [])] + targets = list(repair_targets or []) + expected_targets = { + (target.get("row_key"), target.get("field_name")) + for target in targets + } + if not isinstance(repair_payload, dict) or set(repair_payload) != {"version", "rows"}: + raise TabularSemanticValidationError("Semantic repair response shape is invalid") + if repair_payload.get("version") != TABULAR_SEMANTIC_VALIDATION_CONTRACT_VERSION: + raise TabularSemanticValidationError("Semantic repair response version is unsupported") + field_contracts = { + field["name"]: field + for field in _semantic_field_contracts(transformation_spec) + } + applied_targets = set() + for raw_row in list(repair_payload.get("rows") or []): + if not isinstance(raw_row, dict) or set(raw_row) != {"row_key", "values"}: + raise TabularSemanticValidationError("Semantic repair row shape is invalid") + row_key = _normalize_row_key(raw_row.get("row_key")) + if not row_key.startswith("r") or not row_key[1:].isdigit(): + raise TabularSemanticValidationError("Semantic repair row key is invalid") + row_index = int(row_key[1:]) - 1 + if row_index < 0 or row_index >= len(rows): + raise TabularSemanticValidationError("Semantic repair row key is out of range") + values = raw_row.get("values") + if not isinstance(values, dict) or not values: + raise TabularSemanticValidationError("Semantic repair values are invalid") + for field_name, field_value in values.items(): + normalized_field = _normalize_field_name(field_name, "repair field") + target_key = (row_key, normalized_field) + if target_key not in expected_targets or target_key in applied_targets: + raise TabularSemanticValidationError("Semantic repair changed an unrequested field") + field_contract = field_contracts.get(normalized_field) + if not field_contract or not _value_matches_contract(field_value, field_contract): + raise TabularSemanticValidationError("Semantic repair value violates its field contract") + rows[row_index][normalized_field] = field_value + applied_targets.add(target_key) + if applied_targets != expected_targets: + raise TabularSemanticValidationError("Semantic repair response omitted required targets") + return rows + + +def build_safe_semantic_validation_counts(verification_report, repair_targets=None, repair_attempt_count=0): + """Return low-cardinality counts safe for run metadata and telemetry.""" + status_counts = dict((verification_report or {}).get("status_counts") or {}) + return { + "pass_count": int(status_counts.get("pass") or 0), + "fail_count": int(status_counts.get("fail") or 0), + "uncertain_count": int(status_counts.get("uncertain") or 0), + "unsupported_count": int(status_counts.get("unsupported") or 0), + "repair_target_count": len(list(repair_targets or [])), + "repair_attempt_count": max(0, int(repair_attempt_count or 0)), + } + + + +def _repair_signature(repair_payload): + return hashlib.sha256( + json.dumps(repair_payload, sort_keys=True, separators=(",", ":"), default=str).encode("utf-8") + ).hexdigest() + + +async def verify_and_repair_semantic_rows( + source_rows, + output_rows, + transformation_spec, + mode, + invoke_verifier, + invoke_repair, + max_repair_attempts=2, + max_repair_rows=100, + checkpoint_candidate=None, +): + """Verify semantic fields and repair only failed targets before checkpointing.""" + normalized_mode = str(mode or "off").strip().lower() + if normalized_mode not in TABULAR_SEMANTIC_VALIDATION_MODES: + raise TabularSemanticValidationError("Semantic validation mode is unsupported") + rows = [dict(row) for row in list(output_rows or [])] + verification_request = build_semantic_verification_request( + source_rows, + rows, + transformation_spec, + ) + if normalized_mode == "off" or not verification_request["fields"]: + return rows, build_safe_semantic_validation_counts({}, [], 0), [] + + attempt_summaries = [] + try: + verification_payload = await invoke_verifier(verification_request) + verification_report = normalize_semantic_verification_response( + verification_payload, + verification_request, + ) + except Exception: + if normalized_mode == "shadow": + return rows, build_safe_semantic_validation_counts({}, [], 0), attempt_summaries + raise + repair_targets = collect_semantic_repair_targets(verification_report) + if normalized_mode == "shadow": + return ( + rows, + build_safe_semantic_validation_counts(verification_report, repair_targets, 0), + attempt_summaries, + ) + + if verification_report["status_counts"].get("unsupported"): + raise TabularSemanticValidationError("Semantic verification reported unsupported required fields") + target_row_count = len({target["row_key"] for target in repair_targets}) + if target_row_count > max(0, int(max_repair_rows or 0)): + raise TabularSemanticValidationError("Semantic repair row count exceeds the bounded limit") + + max_attempts = max(0, min(5, int(max_repair_attempts or 0))) + seen_repair_signatures = set() + attempt_number = 0 + while repair_targets and attempt_number < max_attempts: + attempt_number += 1 + repair_payload = await invoke_repair(verification_request, repair_targets, attempt_number) + signature = _repair_signature(repair_payload) + if signature in seen_repair_signatures: + raise TabularSemanticValidationError("Semantic repair repeated an identical response") + seen_repair_signatures.add(signature) + rows = apply_semantic_repair_response( + rows, + repair_payload, + repair_targets, + transformation_spec, + ) + verification_request = build_semantic_verification_request( + source_rows, + rows, + transformation_spec, + ) + verification_payload = await invoke_verifier(verification_request) + verification_report = normalize_semantic_verification_response( + verification_payload, + verification_request, + ) + repair_targets = collect_semantic_repair_targets(verification_report) + attempt_summary = build_safe_semantic_validation_counts( + verification_report, + repair_targets, + attempt_number, + ) + attempt_summaries.append(attempt_summary) + if callable(checkpoint_candidate): + await checkpoint_candidate(rows, attempt_summary, attempt_number) + if verification_report["status_counts"].get("unsupported"): + raise TabularSemanticValidationError("Semantic verification reported unsupported required fields") + + if repair_targets or verification_report["status_counts"].get("fail") or verification_report["status_counts"].get("uncertain"): + raise TabularSemanticValidationError("Semantic repair attempts were exhausted") + return ( + rows, + build_safe_semantic_validation_counts( + verification_report, + repair_targets, + attempt_number, + ), + attempt_summaries, + ) diff --git a/application/single_app/functions_tabular_transformations.py b/application/single_app/functions_tabular_transformations.py new file mode 100644 index 000000000..ea0c66ffa --- /dev/null +++ b/application/single_app/functions_tabular_transformations.py @@ -0,0 +1,705 @@ +# functions_tabular_transformations.py +"""Bounded transformation specifications for tabular generated outputs.""" + +from datetime import date, datetime +from decimal import Decimal, InvalidOperation +from graphlib import CycleError, TopologicalSorter +import math + + +TABULAR_TRANSFORMATION_SPEC_VERSION = "tabular-transform-v1" +TABULAR_TRANSFORMATION_FIELD_MODE_DETERMINISTIC = "deterministic" +TABULAR_TRANSFORMATION_FIELD_MODE_SEMANTIC = "semantic" +TABULAR_TRANSFORMATION_FIELD_MODE_HYBRID = "hybrid" +TABULAR_TRANSFORMATION_FIELD_MODES = frozenset({ + TABULAR_TRANSFORMATION_FIELD_MODE_DETERMINISTIC, + TABULAR_TRANSFORMATION_FIELD_MODE_SEMANTIC, + TABULAR_TRANSFORMATION_FIELD_MODE_HYBRID, +}) + +TABULAR_TRANSFORMATION_MAX_FIELDS = 200 +TABULAR_TRANSFORMATION_MAX_FIELD_NAME_LENGTH = 128 +TABULAR_TRANSFORMATION_MAX_EXPRESSION_DEPTH = 24 +TABULAR_TRANSFORMATION_MAX_EXPRESSION_STEPS = 2000 +TABULAR_TRANSFORMATION_MAX_BRANCHES = 100 +TABULAR_TRANSFORMATION_MAX_LIST_ITEMS = 200 +TABULAR_TRANSFORMATION_MAX_STRING_LENGTH = 4096 +TABULAR_TRANSFORMATION_MAX_NUMERIC_ABS = Decimal("1e18") +TABULAR_TRANSFORMATION_INTERNAL_FIELD_PREFIX = "__simplechat" +TABULAR_TRANSFORMATION_INTERNAL_FIELD_NAMES = frozenset({ + "source_row_number", + "source_row_identity", +}) + +TABULAR_TRANSFORMATION_COMPARISON_OPS = frozenset({"eq", "ne", "lt", "lte", "gt", "gte"}) +TABULAR_TRANSFORMATION_BOOLEAN_OPS = frozenset({"all", "any", "not"}) +TABULAR_TRANSFORMATION_ARITHMETIC_OPS = frozenset({"add", "subtract", "multiply", "divide"}) +TABULAR_TRANSFORMATION_ALLOWED_OPS = frozenset({ + "case", + "coalesce", + "copy", + "in", + "is_null", + *TABULAR_TRANSFORMATION_COMPARISON_OPS, + *TABULAR_TRANSFORMATION_BOOLEAN_OPS, + *TABULAR_TRANSFORMATION_ARITHMETIC_OPS, +}) + + +class TabularTransformationSpecError(ValueError): + """Raised when a tabular transformation spec is unsupported or unsafe.""" + + +class TabularTransformationEvaluationError(ValueError): + """Raised when a valid transformation spec cannot evaluate one row.""" + + +def _is_internal_field_name(field_name): + normalized_field = str(field_name or "").strip() + return ( + normalized_field in TABULAR_TRANSFORMATION_INTERNAL_FIELD_NAMES + or normalized_field.startswith(TABULAR_TRANSFORMATION_INTERNAL_FIELD_PREFIX) + ) + + +def _normalize_field_name(field_name, label="field"): + normalized_field = str(field_name or "").strip() + if not normalized_field: + raise TabularTransformationSpecError(f"Tabular transformation {label} name is empty") + if len(normalized_field) > TABULAR_TRANSFORMATION_MAX_FIELD_NAME_LENGTH: + raise TabularTransformationSpecError(f"Tabular transformation {label} name is too long") + if _is_internal_field_name(normalized_field): + raise TabularTransformationSpecError(f"Tabular transformation {label} uses a reserved field name") + return normalized_field + + +def _normalize_field_list(field_names=None, label="field"): + normalized_fields = [] + seen_fields = set() + for field_name in list(field_names or []): + normalized_field = _normalize_field_name(field_name, label=label) + if normalized_field in seen_fields: + raise TabularTransformationSpecError(f"Tabular transformation {label} list contains duplicates") + seen_fields.add(normalized_field) + normalized_fields.append(normalized_field) + if len(normalized_fields) > TABULAR_TRANSFORMATION_MAX_FIELDS: + raise TabularTransformationSpecError(f"Tabular transformation {label} list is too large") + return normalized_fields + + +def _normalize_mode(mode, expression=None): + normalized_mode = str(mode or "").strip().lower() + if not normalized_mode: + normalized_mode = ( + TABULAR_TRANSFORMATION_FIELD_MODE_DETERMINISTIC + if expression is not None + else TABULAR_TRANSFORMATION_FIELD_MODE_SEMANTIC + ) + if normalized_mode not in TABULAR_TRANSFORMATION_FIELD_MODES: + raise TabularTransformationSpecError("Tabular transformation field mode is unsupported") + return normalized_mode + + +def _validate_literal_value(value): + if value is None or isinstance(value, bool): + return value + if isinstance(value, int) and not isinstance(value, bool): + return value + if isinstance(value, float): + if not math.isfinite(value): + raise TabularTransformationSpecError("Tabular transformation literal number is not finite") + return value + if isinstance(value, str): + if len(value) > TABULAR_TRANSFORMATION_MAX_STRING_LENGTH: + raise TabularTransformationSpecError("Tabular transformation literal string is too long") + return value + if isinstance(value, list): + if len(value) > TABULAR_TRANSFORMATION_MAX_LIST_ITEMS: + raise TabularTransformationSpecError("Tabular transformation literal list is too large") + return [_validate_literal_value(item) for item in value] + if isinstance(value, dict): + if len(value) > TABULAR_TRANSFORMATION_MAX_LIST_ITEMS: + raise TabularTransformationSpecError("Tabular transformation literal object is too large") + normalized_object = {} + for key, item in value.items(): + normalized_key = str(key or "").strip() + if not normalized_key or len(normalized_key) > TABULAR_TRANSFORMATION_MAX_FIELD_NAME_LENGTH: + raise TabularTransformationSpecError("Tabular transformation literal object key is invalid") + normalized_object[normalized_key] = _validate_literal_value(item) + return normalized_object + raise TabularTransformationSpecError("Tabular transformation literal type is unsupported") + + +def _normalize_reference_name(expression, key_name): + return _normalize_field_name(expression.get(key_name), label=key_name) + + +def _normalize_value_type(value_type): + normalized_type = str(value_type or "").strip().lower() + if normalized_type not in {"", "string", "number", "integer", "date", "boolean"}: + raise TabularTransformationSpecError("Tabular transformation value type is unsupported") + return normalized_type + + +def _normalize_field_type(value_type): + normalized_type = str(value_type or "").strip().lower() + if normalized_type not in {"", "string", "number", "integer", "date", "boolean", "object", "array"}: + raise TabularTransformationSpecError("Tabular transformation field type is unsupported") + return normalized_type + + +def _normalize_expression(expression, depth=0): + if depth > TABULAR_TRANSFORMATION_MAX_EXPRESSION_DEPTH: + raise TabularTransformationSpecError("Tabular transformation expression is too deep") + if not isinstance(expression, dict): + return _validate_literal_value(expression) + + if "source" in expression and set(expression) == {"source"}: + return {"source": _normalize_reference_name(expression, "source")} + if "field" in expression and set(expression) == {"field"}: + return {"field": _normalize_reference_name(expression, "field")} + if "value" in expression and set(expression) == {"value"}: + return {"value": _validate_literal_value(expression.get("value"))} + + op_name = str(expression.get("op") or "").strip().lower() + if op_name not in TABULAR_TRANSFORMATION_ALLOWED_OPS: + raise TabularTransformationSpecError("Tabular transformation expression operation is unsupported") + + if op_name == "copy": + if set(expression) != {"op", "source"}: + raise TabularTransformationSpecError("Tabular transformation copy expression has invalid properties") + return {"op": op_name, "source": _normalize_reference_name(expression, "source")} + + if op_name == "case": + if set(expression) != {"op", "branches", "else"}: + raise TabularTransformationSpecError("Tabular transformation case expression has invalid properties") + branches = expression.get("branches") + if not isinstance(branches, list) or not branches: + raise TabularTransformationSpecError("Tabular transformation case expression requires branches") + if len(branches) > TABULAR_TRANSFORMATION_MAX_BRANCHES: + raise TabularTransformationSpecError("Tabular transformation case expression has too many branches") + normalized_branches = [] + for branch in branches: + if not isinstance(branch, dict) or set(branch) != {"when", "then"}: + raise TabularTransformationSpecError("Tabular transformation case branch is invalid") + normalized_branches.append({ + "when": _normalize_expression(branch.get("when"), depth=depth + 1), + "then": _normalize_expression(branch.get("then"), depth=depth + 1), + }) + return { + "op": op_name, + "branches": normalized_branches, + "else": _normalize_expression(expression.get("else"), depth=depth + 1), + } + + if op_name == "coalesce": + if set(expression) != {"op", "values"}: + raise TabularTransformationSpecError("Tabular transformation coalesce expression has invalid properties") + values = expression.get("values") + if not isinstance(values, list) or not values: + raise TabularTransformationSpecError("Tabular transformation coalesce expression requires values") + if len(values) > TABULAR_TRANSFORMATION_MAX_LIST_ITEMS: + raise TabularTransformationSpecError("Tabular transformation coalesce expression has too many values") + return { + "op": op_name, + "values": [_normalize_expression(value, depth=depth + 1) for value in values], + } + + if op_name in {"all", "any"}: + if set(expression) != {"op", "values"}: + raise TabularTransformationSpecError("Tabular transformation boolean expression has invalid properties") + values = expression.get("values") + if not isinstance(values, list) or not values: + raise TabularTransformationSpecError("Tabular transformation boolean expression requires values") + if len(values) > TABULAR_TRANSFORMATION_MAX_LIST_ITEMS: + raise TabularTransformationSpecError("Tabular transformation boolean expression has too many values") + return { + "op": op_name, + "values": [_normalize_expression(value, depth=depth + 1) for value in values], + } + + if op_name == "not": + if set(expression) != {"op", "value"}: + raise TabularTransformationSpecError("Tabular transformation not expression has invalid properties") + return {"op": op_name, "value": _normalize_expression(expression.get("value"), depth=depth + 1)} + + if op_name == "is_null": + if set(expression) != {"op", "value"}: + raise TabularTransformationSpecError("Tabular transformation null expression has invalid properties") + return {"op": op_name, "value": _normalize_expression(expression.get("value"), depth=depth + 1)} + + if op_name == "in": + allowed_keys = {"op", "value", "values", "case_sensitive"} + if set(expression) - allowed_keys or not {"value", "values"}.issubset(expression): + raise TabularTransformationSpecError("Tabular transformation membership expression has invalid properties") + values = expression.get("values") + if not isinstance(values, list) or len(values) > TABULAR_TRANSFORMATION_MAX_LIST_ITEMS: + raise TabularTransformationSpecError("Tabular transformation membership expression values are invalid") + return { + "op": op_name, + "value": _normalize_expression(expression.get("value"), depth=depth + 1), + "values": [_normalize_expression(value, depth=depth + 1) for value in values], + "case_sensitive": bool(expression.get("case_sensitive", True)), + } + + if op_name in TABULAR_TRANSFORMATION_COMPARISON_OPS: + allowed_keys = {"op", "left", "right", "value_type", "case_sensitive"} + if set(expression) - allowed_keys or not {"left", "right"}.issubset(expression): + raise TabularTransformationSpecError("Tabular transformation comparison expression has invalid properties") + return { + "op": op_name, + "left": _normalize_expression(expression.get("left"), depth=depth + 1), + "right": _normalize_expression(expression.get("right"), depth=depth + 1), + "value_type": _normalize_value_type(expression.get("value_type")), + "case_sensitive": bool(expression.get("case_sensitive", True)), + } + + if op_name in TABULAR_TRANSFORMATION_ARITHMETIC_OPS: + allowed_keys = {"op", "values", "left", "right"} + if set(expression) - allowed_keys: + raise TabularTransformationSpecError("Tabular transformation arithmetic expression has invalid properties") + if "values" in expression: + values = expression.get("values") + if not isinstance(values, list) or not values: + raise TabularTransformationSpecError("Tabular transformation arithmetic values are invalid") + if len(values) > TABULAR_TRANSFORMATION_MAX_LIST_ITEMS: + raise TabularTransformationSpecError("Tabular transformation arithmetic expression has too many values") + return { + "op": op_name, + "values": [_normalize_expression(value, depth=depth + 1) for value in values], + } + if not {"left", "right"}.issubset(expression): + raise TabularTransformationSpecError("Tabular transformation arithmetic expression requires operands") + return { + "op": op_name, + "left": _normalize_expression(expression.get("left"), depth=depth + 1), + "right": _normalize_expression(expression.get("right"), depth=depth + 1), + } + + raise TabularTransformationSpecError("Tabular transformation expression operation is unsupported") + + +def _collect_expression_references(expression, source_refs, field_refs): + if not isinstance(expression, dict): + return + if set(expression) == {"source"}: + source_refs.add(expression["source"]) + return + if set(expression) == {"field"}: + field_refs.add(expression["field"]) + return + if set(expression) == {"value"}: + return + + op_name = expression.get("op") + if op_name == "copy": + source_refs.add(expression["source"]) + elif op_name == "case": + for branch in expression.get("branches") or []: + _collect_expression_references(branch.get("when"), source_refs, field_refs) + _collect_expression_references(branch.get("then"), source_refs, field_refs) + _collect_expression_references(expression.get("else"), source_refs, field_refs) + elif op_name in {"coalesce", "all", "any"}: + for value in expression.get("values") or []: + _collect_expression_references(value, source_refs, field_refs) + elif op_name in {"not", "is_null"}: + _collect_expression_references(expression.get("value"), source_refs, field_refs) + elif op_name == "in": + _collect_expression_references(expression.get("value"), source_refs, field_refs) + for value in expression.get("values") or []: + _collect_expression_references(value, source_refs, field_refs) + elif op_name in TABULAR_TRANSFORMATION_COMPARISON_OPS: + _collect_expression_references(expression.get("left"), source_refs, field_refs) + _collect_expression_references(expression.get("right"), source_refs, field_refs) + elif op_name in TABULAR_TRANSFORMATION_ARITHMETIC_OPS: + if "values" in expression: + for value in expression.get("values") or []: + _collect_expression_references(value, source_refs, field_refs) + else: + _collect_expression_references(expression.get("left"), source_refs, field_refs) + _collect_expression_references(expression.get("right"), source_refs, field_refs) + + +def _normalize_field_descriptor(field_descriptor): + if not isinstance(field_descriptor, dict): + raise TabularTransformationSpecError("Tabular transformation field descriptor is invalid") + allowed_keys = {"name", "mode", "expression", "type", "nullable", "allowed_values"} + if set(field_descriptor) - allowed_keys: + raise TabularTransformationSpecError("Tabular transformation field descriptor has unsupported properties") + + field_name = _normalize_field_name(field_descriptor.get("name")) + expression_present = "expression" in field_descriptor + mode = _normalize_mode(field_descriptor.get("mode"), expression=field_descriptor.get("expression")) + expression = None + if mode == TABULAR_TRANSFORMATION_FIELD_MODE_DETERMINISTIC: + if not expression_present: + raise TabularTransformationSpecError("Deterministic tabular transformation field requires an expression") + expression = _normalize_expression(field_descriptor.get("expression")) + elif expression_present and field_descriptor.get("expression") not in ({}, None): + expression = _normalize_expression(field_descriptor.get("expression")) + + normalized_descriptor = { + "name": field_name, + "mode": mode, + } + if expression is not None: + normalized_descriptor["expression"] = expression + field_type = _normalize_field_type(field_descriptor.get("type")) + if field_type: + normalized_descriptor["type"] = field_type + if "nullable" in field_descriptor: + normalized_descriptor["nullable"] = bool(field_descriptor.get("nullable")) + if "allowed_values" in field_descriptor: + allowed_values = field_descriptor.get("allowed_values") + if not isinstance(allowed_values, list) or len(allowed_values) > TABULAR_TRANSFORMATION_MAX_LIST_ITEMS: + raise TabularTransformationSpecError("Tabular transformation allowed values are invalid") + normalized_descriptor["allowed_values"] = [_validate_literal_value(value) for value in allowed_values] + return normalized_descriptor + + +def _build_deterministic_field_order(field_descriptors): + fields_by_name = {field["name"]: field for field in field_descriptors} + graph = {} + for field in field_descriptors: + if field["mode"] != TABULAR_TRANSFORMATION_FIELD_MODE_DETERMINISTIC: + continue + source_refs = set() + field_refs = set() + _collect_expression_references(field.get("expression"), source_refs, field_refs) + deterministic_dependencies = set() + for field_ref in field_refs: + referenced_field = fields_by_name.get(field_ref) + if referenced_field is None: + continue + if referenced_field["mode"] != TABULAR_TRANSFORMATION_FIELD_MODE_DETERMINISTIC: + raise TabularTransformationSpecError( + "Deterministic tabular transformation field cannot depend on semantic output" + ) + deterministic_dependencies.add(field_ref) + graph[field["name"]] = deterministic_dependencies + try: + return list(TopologicalSorter(graph).static_order()) + except CycleError as exc: + raise TabularTransformationSpecError("Tabular transformation field dependencies contain a cycle") from exc + + +def normalize_tabular_transformation_spec( + transformation_spec, + public_output_schema=None, + source_schema=None, +): + """Return a bounded normalized transformation spec or an empty spec.""" + if not transformation_spec: + return {} + if not isinstance(transformation_spec, dict): + raise TabularTransformationSpecError("Tabular transformation spec must be an object") + allowed_keys = {"version", "fields", "deterministic_field_order", "field_mode_counts"} + if set(transformation_spec) - allowed_keys: + raise TabularTransformationSpecError("Tabular transformation spec has unsupported properties") + if str(transformation_spec.get("version") or "").strip() != TABULAR_TRANSFORMATION_SPEC_VERSION: + raise TabularTransformationSpecError("Tabular transformation spec version is unsupported") + + normalized_public_schema = _normalize_field_list(public_output_schema, label="public output field") + normalized_source_schema = _normalize_field_list(source_schema, label="source field") + source_schema_set = set(normalized_source_schema) + + raw_fields = transformation_spec.get("fields") + if not isinstance(raw_fields, list) or not raw_fields: + raise TabularTransformationSpecError("Tabular transformation spec requires fields") + if len(raw_fields) > TABULAR_TRANSFORMATION_MAX_FIELDS: + raise TabularTransformationSpecError("Tabular transformation spec has too many fields") + + normalized_fields = [] + seen_fields = set() + for raw_field in raw_fields: + normalized_field = _normalize_field_descriptor(raw_field) + field_name = normalized_field["name"] + if field_name in seen_fields: + raise TabularTransformationSpecError("Tabular transformation spec contains duplicate output fields") + seen_fields.add(field_name) + normalized_fields.append(normalized_field) + + if normalized_public_schema: + public_schema_set = set(normalized_public_schema) + if seen_fields != public_schema_set: + raise TabularTransformationSpecError("Tabular transformation spec fields must match the public schema") + + for normalized_field in normalized_fields: + source_refs = set() + field_refs = set() + _collect_expression_references(normalized_field.get("expression"), source_refs, field_refs) + if source_schema_set and source_refs - source_schema_set: + raise TabularTransformationSpecError("Tabular transformation spec references an unknown source field") + unknown_field_refs = field_refs - seen_fields + if unknown_field_refs: + raise TabularTransformationSpecError("Tabular transformation spec references an unknown output field") + + deterministic_order = _build_deterministic_field_order(normalized_fields) + mode_counts = { + mode: sum(1 for field in normalized_fields if field["mode"] == mode) + for mode in sorted(TABULAR_TRANSFORMATION_FIELD_MODES) + } + return { + "version": TABULAR_TRANSFORMATION_SPEC_VERSION, + "fields": normalized_fields, + "deterministic_field_order": deterministic_order, + "field_mode_counts": mode_counts, + } + + +def get_tabular_transformation_deterministic_fields(transformation_spec): + """Return deterministic output field names in evaluation order.""" + normalized_spec = normalize_tabular_transformation_spec(transformation_spec) + return list(normalized_spec.get("deterministic_field_order") or []) + + +def get_tabular_transformation_model_fields(transformation_spec, public_output_schema=None): + """Return public fields that must still be generated or verified by the model.""" + normalized_spec = normalize_tabular_transformation_spec( + transformation_spec, + public_output_schema=public_output_schema, + ) + if not normalized_spec: + return list(public_output_schema or []) + deterministic_fields = set(normalized_spec.get("deterministic_field_order") or []) + ordered_public_schema = list(public_output_schema or [field["name"] for field in normalized_spec["fields"]]) + return [field_name for field_name in ordered_public_schema if field_name not in deterministic_fields] + + +def is_tabular_transformation_deterministic_only(transformation_spec, public_output_schema=None): + """Return True when every public field is server-computable.""" + normalized_spec = normalize_tabular_transformation_spec( + transformation_spec, + public_output_schema=public_output_schema, + ) + if not normalized_spec: + return False + return not get_tabular_transformation_model_fields(normalized_spec, public_output_schema=public_output_schema) + + +def _parse_date_value(value): + if isinstance(value, datetime): + return value.date() + if isinstance(value, date): + return value + normalized_value = str(value or "").strip() + if not normalized_value: + raise TabularTransformationEvaluationError("Date value is empty") + try: + return date.fromisoformat(normalized_value[:10]) + except ValueError as exc: + raise TabularTransformationEvaluationError("Date value is not ISO formatted") from exc + + +def _parse_decimal_value(value): + if isinstance(value, bool) or value in (None, ""): + raise TabularTransformationEvaluationError("Numeric value is empty or boolean") + try: + parsed_value = Decimal(str(value).strip()) + except (InvalidOperation, ValueError) as exc: + raise TabularTransformationEvaluationError("Numeric value is invalid") from exc + if abs(parsed_value) > TABULAR_TRANSFORMATION_MAX_NUMERIC_ABS: + raise TabularTransformationEvaluationError("Numeric value exceeds the bounded range") + return parsed_value + + +def _coerce_comparison_value(value, value_type): + if value_type == "date": + return _parse_date_value(value) + if value_type in {"number", "integer"}: + return _parse_decimal_value(value) + if value_type == "boolean": + if isinstance(value, bool): + return value + normalized_value = str(value or "").strip().lower() + if normalized_value in {"true", "1", "yes", "y"}: + return True + if normalized_value in {"false", "0", "no", "n"}: + return False + raise TabularTransformationEvaluationError("Boolean value is invalid") + return value + + +def _decimal_to_json_value(value): + if value == value.to_integral_value(): + return int(value) + return float(value) + + +def _compare_values(left_value, right_value, op_name, value_type="", case_sensitive=True): + coerced_left = _coerce_comparison_value(left_value, value_type) + coerced_right = _coerce_comparison_value(right_value, value_type) + if value_type in {"", "string"} and isinstance(coerced_left, str) and isinstance(coerced_right, str): + if not case_sensitive: + coerced_left = coerced_left.casefold() + coerced_right = coerced_right.casefold() + if op_name == "eq": + return coerced_left == coerced_right + if op_name == "ne": + return coerced_left != coerced_right + if op_name == "lt": + return coerced_left < coerced_right + if op_name == "lte": + return coerced_left <= coerced_right + if op_name == "gt": + return coerced_left > coerced_right + if op_name == "gte": + return coerced_left >= coerced_right + raise TabularTransformationEvaluationError("Comparison operation is unsupported") + + +def _is_empty_value(value): + return value is None or value == "" or value == [] or value == {} + + +class _EvaluationContext: + def __init__(self, source_row, derived_values): + self.source_row = source_row if isinstance(source_row, dict) else {} + self.derived_values = derived_values + self.step_count = 0 + + def consume_step(self): + self.step_count += 1 + if self.step_count > TABULAR_TRANSFORMATION_MAX_EXPRESSION_STEPS: + raise TabularTransformationEvaluationError("Transformation evaluation exceeded the step limit") + + +def _evaluate_expression(expression, context): + context.consume_step() + if not isinstance(expression, dict): + return expression + if set(expression) == {"source"}: + return context.source_row.get(expression["source"]) + if set(expression) == {"field"}: + field_name = expression["field"] + if field_name not in context.derived_values: + raise TabularTransformationEvaluationError("Referenced derived field has not been evaluated") + return context.derived_values.get(field_name) + if set(expression) == {"value"}: + return expression.get("value") + + op_name = expression.get("op") + if op_name == "copy": + return context.source_row.get(expression["source"]) + if op_name == "case": + for branch in expression.get("branches") or []: + if bool(_evaluate_expression(branch.get("when"), context)): + return _evaluate_expression(branch.get("then"), context) + return _evaluate_expression(expression.get("else"), context) + if op_name == "coalesce": + for value_expression in expression.get("values") or []: + value = _evaluate_expression(value_expression, context) + if not _is_empty_value(value): + return value + return None + if op_name == "all": + return all(bool(_evaluate_expression(value_expression, context)) for value_expression in expression.get("values") or []) + if op_name == "any": + return any(bool(_evaluate_expression(value_expression, context)) for value_expression in expression.get("values") or []) + if op_name == "not": + return not bool(_evaluate_expression(expression.get("value"), context)) + if op_name == "is_null": + return _is_empty_value(_evaluate_expression(expression.get("value"), context)) + if op_name == "in": + member_value = _evaluate_expression(expression.get("value"), context) + expected_values = [_evaluate_expression(value, context) for value in expression.get("values") or []] + if not expression.get("case_sensitive", True) and isinstance(member_value, str): + member_value = member_value.casefold() + expected_values = [value.casefold() if isinstance(value, str) else value for value in expected_values] + return member_value in expected_values + if op_name in TABULAR_TRANSFORMATION_COMPARISON_OPS: + return _compare_values( + _evaluate_expression(expression.get("left"), context), + _evaluate_expression(expression.get("right"), context), + op_name, + value_type=expression.get("value_type") or "", + case_sensitive=expression.get("case_sensitive", True), + ) + if op_name in TABULAR_TRANSFORMATION_ARITHMETIC_OPS: + if "values" in expression: + numeric_values = [_parse_decimal_value(_evaluate_expression(value, context)) for value in expression.get("values") or []] + else: + numeric_values = [ + _parse_decimal_value(_evaluate_expression(expression.get("left"), context)), + _parse_decimal_value(_evaluate_expression(expression.get("right"), context)), + ] + if op_name == "add": + result = sum(numeric_values, Decimal("0")) + elif op_name == "subtract": + result = numeric_values[0] + for value in numeric_values[1:]: + result -= value + elif op_name == "multiply": + result = Decimal("1") + for value in numeric_values: + result *= value + else: + result = numeric_values[0] + for value in numeric_values[1:]: + if value == 0: + raise TabularTransformationEvaluationError("Division by zero is not allowed") + result /= value + if abs(result) > TABULAR_TRANSFORMATION_MAX_NUMERIC_ABS: + raise TabularTransformationEvaluationError("Numeric result exceeds the bounded range") + return _decimal_to_json_value(result) + raise TabularTransformationEvaluationError("Transformation operation is unsupported") + + +def _validate_evaluated_field_value(field_descriptor, field_value): + if field_value is None: + if field_descriptor.get("nullable") is False: + raise TabularTransformationEvaluationError("Non-nullable deterministic field evaluated to null") + return None + field_type = str(field_descriptor.get("type") or "").strip().lower() + if field_type == "string" and not isinstance(field_value, str): + field_value = str(field_value) + elif field_type == "integer": + parsed_value = _parse_decimal_value(field_value) + if parsed_value != parsed_value.to_integral_value(): + raise TabularTransformationEvaluationError("Deterministic integer field evaluated to a fractional value") + field_value = int(parsed_value) + elif field_type == "number": + field_value = _decimal_to_json_value(_parse_decimal_value(field_value)) + elif field_type == "boolean" and not isinstance(field_value, bool): + field_value = _coerce_comparison_value(field_value, "boolean") + elif field_type == "date": + field_value = _parse_date_value(field_value).isoformat() + elif field_type == "object" and not isinstance(field_value, dict): + raise TabularTransformationEvaluationError("Deterministic object field evaluated to a non-object value") + elif field_type == "array" and not isinstance(field_value, list): + raise TabularTransformationEvaluationError("Deterministic array field evaluated to a non-array value") + allowed_values = field_descriptor.get("allowed_values") + if allowed_values is not None and field_value not in allowed_values: + raise TabularTransformationEvaluationError("Deterministic field evaluated outside allowed values") + return field_value + + +def evaluate_tabular_transformation_row(transformation_spec, source_row): + """Evaluate deterministic fields for one source row.""" + normalized_spec = normalize_tabular_transformation_spec(transformation_spec) + if not normalized_spec: + return {} + normalized_fields = list(normalized_spec.get("fields") or []) + fields_by_name = {field["name"]: field for field in normalized_fields} + derived_values = {} + context = _EvaluationContext(source_row, derived_values) + for field_name in normalized_spec.get("deterministic_field_order") or []: + field_descriptor = fields_by_name[field_name] + derived_values[field_name] = _validate_evaluated_field_value( + field_descriptor, + _evaluate_expression(field_descriptor.get("expression"), context), + ) + return { + field["name"]: derived_values[field["name"]] + for field in normalized_fields + if field["name"] in derived_values + } + + +def evaluate_tabular_transformation_rows(transformation_spec, source_rows): + """Evaluate deterministic fields for source rows in source order.""" + normalized_spec = normalize_tabular_transformation_spec(transformation_spec) + return [ + evaluate_tabular_transformation_row(normalized_spec, source_row) + for source_row in list(source_rows or []) + ] diff --git a/application/single_app/functions_workflow_runner.py b/application/single_app/functions_workflow_runner.py index e5ac553eb..27e98fa69 100644 --- a/application/single_app/functions_workflow_runner.py +++ b/application/single_app/functions_workflow_runner.py @@ -733,6 +733,14 @@ def _get_primary_tabular_generated_outputs(primary_generated_outputs): return normalized_outputs +def _primary_tabular_generated_outputs_are_pending(primary_tabular_outputs): + for output in primary_tabular_outputs or []: + status = str(output.get('status') or output.get('run_status') or '').strip().lower() + if output.get('background_export') or status in {'pending', 'queued', 'running', 'in_progress'}: + return True + return False + + def _prompt_explicitly_requests_markdown_artifact(analysis_prompt): prompt_text = str(analysis_prompt or '').strip().lower() if not prompt_text: @@ -1339,13 +1347,16 @@ def _maybe_create_document_analysis_generated_artifacts( create_lossless_artifacts = bool( artifact_intent.get('exhaustive') or artifact_intent.get('table_output_requested') + or json_artifact_requested or xml_artifact_requested or primary_tabular_outputs + or analysis_reply ) deferred_composition = analysis_result.get('deferred_composition') if isinstance(analysis_result.get('deferred_composition'), dict) else {} if deferred_composition.get('status') in {'pending', 'gate_disabled', 'continuation_unavailable'}: return {'artifacts': [], 'assistant_reply': None} + primary_tabular_outputs_pending = _primary_tabular_generated_outputs_are_pending(primary_tabular_outputs) if create_lossless_artifacts: artifacts = [] structured_rows = _build_document_analysis_structured_rows(analysis_result) @@ -1372,15 +1383,8 @@ def _maybe_create_document_analysis_generated_artifacts( markdown_output = _build_document_analysis_markdown_artifact(analysis_result) should_create_markdown_artifact = bool( - ( - artifact_intent.get('markdown_analysis_artifact_recommended') - or (json_payload is not None and not json_artifact_requested) - ) - and markdown_output - and ( - not primary_tabular_outputs - or _prompt_explicitly_requests_markdown_artifact(analysis_prompt) - ) + markdown_output + and not primary_tabular_outputs_pending ) if should_create_markdown_artifact: markdown_file_name = _build_document_analysis_artifact_file_name(analysis_result, 'md') @@ -1446,14 +1450,26 @@ def _maybe_create_document_analysis_generated_artifacts( raise if artifacts or primary_tabular_outputs: - assistant_reply = _build_document_analysis_multi_artifact_reply( - document_count, - artifacts, - len(structured_rows), - len(raw_analysis_items), - analysis_reply, - structured_rows=structured_rows, + markdown_only_artifacts = bool( + artifacts + and not primary_tabular_outputs + and all(str(artifact.get('output_format') or '').strip().lower() == 'md' for artifact in artifacts) ) + if markdown_only_artifacts: + assistant_reply = _build_document_analysis_artifact_reply( + document_count, + 'md', + analysis_reply=analysis_reply, + ) + else: + assistant_reply = _build_document_analysis_multi_artifact_reply( + document_count, + artifacts, + len(structured_rows), + len(raw_analysis_items), + analysis_reply, + structured_rows=structured_rows, + ) if primary_tabular_outputs: assistant_reply = _build_document_analysis_primary_output_reply( document_count, @@ -2551,15 +2567,9 @@ def _build_mixed_source_deferred_composition_descriptor( def _build_mixed_source_deferred_reply(deferred_descriptor): - descriptor = deferred_descriptor if isinstance(deferred_descriptor, dict) else {} - pending_source_count = _coerce_document_analysis_count( - descriptor.get('pending_source_count'), - ) - source_label = 'source' if pending_source_count == 1 else 'sources' return ( - f'Completed narrative and bounded evidence has been preserved, but {pending_source_count} tabular {source_label} ' - 'still require full-source generated-output processing. Automatic deferred composition is unavailable, so no ' - 'collective conclusion was generated from incomplete table evidence. Individual generated outputs will continue.' + 'We are analyzing the data and generating the requested file in the background. ' + 'It will be available here shortly.' ) @@ -2589,7 +2599,11 @@ def _emit_analyze_shared_preflight_event( safe_dimensions.update({ 'rollout_contract_version': str(rollout_assignment.get('contract_version') or '').strip()[:80], 'rollout_mode': str(rollout_assignment.get('mode') or '').strip().lower()[:40], + 'rollout_state': str(rollout_assignment.get('rollout_state') or 'active').strip().lower()[:40], 'rollout_assigned': str(bool(rollout_assignment.get('assigned'))).lower(), + 'rollout_assignment_reason': str( + rollout_assignment.get('assignment_reason_code') or '' + ).strip().lower()[:80], 'rollout_percent': str(_coerce_document_analysis_count(rollout_assignment.get('rollout_percent'))), 'rollout_cohort_bucket': str(_coerce_document_analysis_count(rollout_assignment.get('cohort_bucket'))), 'legacy_post_tool_fallback_mode': str( @@ -3761,6 +3775,11 @@ def _maybe_execute_tabular_document_action( gpt_model = _resolve_tabular_document_action_model_name(workflow, settings) if not gpt_model: return None + tabular_model_context = _build_workflow_model_context( + workflow, + gpt_model, + workflow.get('model_provider'), + ) # Import lazily to avoid a circular dependency during workflow startup. from functions_tabular_analysis import ( @@ -3768,6 +3787,8 @@ def _maybe_execute_tabular_document_action( build_tabular_related_document_evidence_summary, get_new_plugin_invocations, maybe_create_tabular_generated_output, + maybe_queue_direct_tabular_generated_output, + plan_tabular_request, run_tabular_analysis_with_thought_tracking, ) @@ -3797,6 +3818,37 @@ def _maybe_execute_tabular_document_action( plugin_logger.get_invocations_for_conversation(user_id, conversation_id, limit=1000) ) + tabular_file_context = { + 'file_name': tabular_document.get('file_name'), + 'source_hint': tabular_document.get('source_hint', 'workspace'), + 'group_id': tabular_document.get('group_id'), + 'public_workspace_id': tabular_document.get('public_workspace_id'), + } + if action_type == DOCUMENT_ACTION_TYPE_ANALYZE: + tabular_plan = plan_tabular_request( + task_prompt, + [tabular_file_context], + action_mode='analyze', + settings=settings, + ) + if isinstance(tabular_plan, dict) and tabular_plan.get('durable_task_type'): + direct_generated_output = maybe_queue_direct_tabular_generated_output( + user_question=task_prompt, + file_contexts=[tabular_file_context], + user_id=user_id, + conversation_id=conversation_id, + gpt_model=gpt_model, + settings=settings, + model_context=tabular_model_context, + thought_callback=tabular_post_processing_thought_callback, + cancel_requested=cancel_requested, + request_correlation_id=request_correlation_id, + planner_metadata=tabular_plan, + ) + if direct_generated_output: + generated_tabular_outputs.append(direct_generated_output) + continue + parity_result = classify_tabular_parity_request(task_prompt) emit_tabular_parity_event( settings, @@ -3824,12 +3876,8 @@ def _maybe_execute_tabular_document_action( group_id=tabular_document.get('group_id'), public_workspace_id=tabular_document.get('public_workspace_id'), execution_mode='analysis', - tabular_file_contexts=[{ - 'file_name': tabular_document.get('file_name'), - 'source_hint': tabular_document.get('source_hint', 'workspace'), - 'group_id': tabular_document.get('group_id'), - 'public_workspace_id': tabular_document.get('public_workspace_id'), - }], + tabular_file_contexts=[tabular_file_context], + model_context=tabular_model_context, thought_tracker=thought_tracker, live_thought_callback=live_thought_callback, token_usage_callback=token_usage_callback, @@ -3877,6 +3925,7 @@ def _maybe_execute_tabular_document_action( conversation_id=conversation_id, thought_callback=tabular_post_processing_thought_callback, user_id=user_id, + model_context=tabular_model_context, cancel_requested=cancel_requested, request_correlation_id=request_correlation_id, token_usage_callback=token_usage_callback, @@ -3915,6 +3964,88 @@ def _maybe_execute_tabular_document_action( tabular_agent_citations = _build_agent_citations_from_plugin_invocations(tabular_invocations) if action_type == DOCUMENT_ACTION_TYPE_ANALYZE: + pending_generated_output = _get_pending_tabular_generated_output(generated_tabular_outputs) + terminal_unsuccessful_output = _get_terminal_unsuccessful_tabular_generated_output(generated_tabular_outputs) + completed_tabular_documents = [ + tabular_document + for tabular_document in tabular_documents + if str(tabular_document.get('analysis') or '').strip() + ] + if (pending_generated_output or terminal_unsuccessful_output) and not completed_tabular_documents: + terminal_status = _get_tabular_generated_output_status(terminal_unsuccessful_output) + terminal_canceled = terminal_status in {'canceled', 'cancelled'} + evidence_status = ( + EVIDENCE_STATUS_PENDING + if pending_generated_output + else (EVIDENCE_STATUS_CANCELED if terminal_canceled else EVIDENCE_STATUS_FAILED) + ) + failed_units = 0 if pending_generated_output else 1 + handoff_reply = ( + _build_tabular_analyze_durable_handoff(pending_generated_output) + if pending_generated_output + else ( + 'The full-source tabular work was canceled before completion. No exhaustive tabular result has been completed.' + if terminal_canceled + else 'The full-source tabular work could not be completed. No exhaustive tabular result has been completed.' + ) + ) + coverage = _build_tabular_document_action_coverage( + tabular_documents, + 'Queued' if pending_generated_output else ('Canceled' if terminal_canceled else 'Failed'), + ) + for document_summary in list(coverage.get('documents') or []): + document_summary.update({ + 'processed_windows': 0, + 'processed_chunks': 0, + 'failed_windows': failed_units, + 'failed_chunks': failed_units, + 'status': evidence_status, + 'status_text': ( + 'Full-source tabular work is pending' + if pending_generated_output + else ( + 'Full-source tabular work was canceled' + if terminal_canceled + else 'Full-source tabular work failed' + ) + ), + }) + coverage.update({ + 'processed_windows': 0, + 'processed_chunks': 0, + 'failed_windows': failed_units, + 'failed_chunks': failed_units, + }) + coverage['progress_meta'].update({ + 'phase': 'queued' if pending_generated_output else ('canceled' if terminal_canceled else 'failed'), + 'phase_label': 'Queued' if pending_generated_output else ('Canceled' if terminal_canceled else 'Failed'), + 'phase_detail': ( + 'Full-source tabular work is running in the background' + if pending_generated_output + else ( + 'Full-source tabular work was canceled before completion' + if terminal_canceled + else 'Full-source tabular work could not be completed' + ) + ), + 'status': evidence_status, + 'percent_override': 0 if pending_generated_output else 100, + }) + return { + 'result': { + 'reply': handoff_reply, + 'analysis_reply': handoff_reply, + 'coverage': coverage, + 'documents': coverage.get('documents', []), + 'document_ids': [tabular_document.get('document_id') for tabular_document in tabular_documents], + 'doc_scope': action_config.get('doc_scope'), + 'window_unit': 'tabular', + 'window_size': None, + 'window_percent': None, + }, + 'agent_citations': tabular_agent_citations, + 'generated_tabular_outputs': generated_tabular_outputs, + } raise_if_mixed_source_cancelled( cancel_requested, 'reduction', @@ -5282,6 +5413,7 @@ def _maybe_create_workflow_generated_file_output( source_candidate={ 'filename': generated_file_name, 'selected_sheet': '', + 'passthrough_reason_code': export_payload.get('passthrough_reason_code'), 'source_authorization': { 'source': 'chat', }, @@ -7228,6 +7360,15 @@ def _build_workflow_model_context(workflow, deployment_name, provider): group_id = _get_workflow_group_id(workflow) if group_id: model_context['active_group_ids'] = [group_id] + else: + document_action = workflow.get('document_action') if isinstance(workflow.get('document_action'), dict) else {} + active_group_ids = [ + str(group_id or '').strip() + for group_id in document_action.get('active_group_ids') or [] + if str(group_id or '').strip() + ] + if active_group_ids: + model_context['active_group_ids'] = active_group_ids return model_context diff --git a/application/single_app/route_backend_chats.py b/application/single_app/route_backend_chats.py index c0cb8b1b6..bd314cf7f 100644 --- a/application/single_app/route_backend_chats.py +++ b/application/single_app/route_backend_chats.py @@ -147,6 +147,7 @@ build_generated_file_artifact_metadata, build_generated_file_export, build_generated_file_output_guidance, + evaluate_generated_file_passthrough_eligibility, get_generated_file_export_content, get_requested_generated_file_format, get_requested_structured_artifact_format, @@ -2301,6 +2302,7 @@ def maybe_create_generated_file_output( source_candidate={ 'filename': generated_file_name, 'selected_sheet': '', + 'passthrough_reason_code': export_payload.get('passthrough_reason_code'), 'source_authorization': { 'source': 'chat', }, @@ -5015,11 +5017,12 @@ def _settings_flag_enabled(settings, key, default=False): return _shared_settings_flag_enabled(settings, key, default=default) -def _get_tabular_generated_output_task_type(generated_output_requested, hierarchical_analysis_requested, settings): +def _get_tabular_generated_output_task_type(generated_output_requested, hierarchical_analysis_requested, settings, action_mode=None): return _shared_get_tabular_generated_output_task_type( generated_output_requested, hierarchical_analysis_requested, settings, + action_mode=action_mode, ) @@ -5036,6 +5039,11 @@ def question_requests_tabular_structured_object_output(user_question): 'one row per comment', 'one object per submission', 'one object for each row', + 'one output row for each source row', + 'one output row per source row', + 'one output row for every source row', + 'one output row for each row', + 'exactly one output row', 'for each row', 'for every row', 'every row', @@ -5741,7 +5749,7 @@ def _build_tabular_generated_output_query_descriptor( return descriptor -def _build_direct_tabular_generated_output_source(user_question, file_contexts, user_id, conversation_id, settings): +def _build_direct_tabular_generated_output_source(user_question, file_contexts, user_id, conversation_id, settings, action_mode=None): """Build a replayable full-tabular source descriptor without requiring a prior tool page.""" generated_output_requested = question_requests_tabular_generated_output(user_question) hierarchical_analysis_requested = question_requests_tabular_hierarchical_analysis(user_question) @@ -5749,9 +5757,12 @@ def _build_direct_tabular_generated_output_source(user_question, file_contexts, generated_output_requested, hierarchical_analysis_requested, settings, + action_mode=action_mode, ) analysis_only_requested = durable_task_type == TABULAR_RUN_TASK_HIERARCHICAL_ANALYSIS combined_requested = durable_task_type == TABULAR_RUN_TASK_COMBINED + if generated_output_requested and str(action_mode or '').strip().lower() == 'analyze' and not durable_task_type: + return None if not generated_output_requested and not analysis_only_requested: return None if hierarchical_analysis_requested and not generated_output_requested and not analysis_only_requested: @@ -5991,6 +6002,7 @@ def emit_direct_parity_event(event_name, planner_result=None, metrics=None, dime user_id, conversation_id, settings, + action_mode=planner_action_mode, ) if not direct_source: emit_direct_parity_event( @@ -6165,7 +6177,11 @@ def _emit_search_shared_preflight_event( safe_dimensions.update({ 'rollout_contract_version': str(rollout_assignment.get('contract_version') or '').strip()[:80], 'rollout_mode': str(rollout_assignment.get('mode') or '').strip().lower()[:40], + 'rollout_state': str(rollout_assignment.get('rollout_state') or 'active').strip().lower()[:40], 'rollout_assigned': str(bool(rollout_assignment.get('assigned'))).lower(), + 'rollout_assignment_reason': str( + rollout_assignment.get('assignment_reason_code') or '' + ).strip().lower()[:80], 'rollout_percent': str(_safe_int(rollout_assignment.get('rollout_percent'))), 'rollout_cohort_bucket': str(_safe_int(rollout_assignment.get('cohort_bucket'))), 'legacy_post_tool_fallback_mode': str( @@ -6864,9 +6880,12 @@ async def maybe_create_tabular_generated_output( generated_output_requested, hierarchical_analysis_requested, settings, + action_mode=mode, ) analysis_only_requested = durable_task_type == TABULAR_RUN_TASK_HIERARCHICAL_ANALYSIS combined_requested = durable_task_type == TABULAR_RUN_TASK_COMBINED + if generated_output_requested and str(mode or '').strip().lower() == 'analyze' and not durable_task_type: + return None if hierarchical_analysis_requested and not generated_output_requested and not analysis_only_requested: return None if not generated_output_requested and not hierarchical_analysis_requested: @@ -7286,6 +7305,7 @@ def emit_fallback_parity_event(event_name, planner_result=None, metrics=None, di if not output_format or not rows: return None + passthrough_reason_code = None if question_requests_tabular_structured_object_output(user_question): raise_if_mixed_source_cancelled( cancel_requested, @@ -7317,6 +7337,31 @@ def emit_fallback_parity_event(event_name, planner_result=None, metrics=None, di if isinstance(output_entries, dict) and output_entries.get('background_export'): return output_entries else: + passthrough_eligibility = evaluate_generated_file_passthrough_eligibility( + user_question, + rows=rows, + ) + if not passthrough_eligibility.get('allowed'): + reason_code = str(passthrough_eligibility.get('reason_code') or 'schema_not_satisfied').strip() + log_event( + '[TABULAR_GENERATED_OUTPUT] Refused source-row passthrough for generated export', + { + 'conversation_id': conversation_id, + 'source_file_name': source_candidate.get('filename'), + 'output_format': output_format, + 'row_count': len(rows), + 'passthrough_reason_code': reason_code, + }, + level=logging.WARNING, + ) + return _build_failed_tabular_generated_output_metadata( + source_candidate, + output_format, + 'The requested export requires generated output and could not be safely created from raw source rows. No partial file was created.', + ) + passthrough_reason_code = str( + passthrough_eligibility.get('reason_code') or 'explicit_format_conversion' + ).strip()[:80] output_entries = rows if output_format == 'csv': @@ -7356,6 +7401,7 @@ def emit_fallback_parity_event(event_name, planner_result=None, metrics=None, di 'generated_file_name': generated_file_name, 'output_format': output_format, 'row_count': len(output_entries), + 'passthrough_reason_code': passthrough_reason_code, }, debug_only=True, ) @@ -7406,6 +7452,7 @@ def emit_fallback_parity_event(event_name, planner_result=None, metrics=None, di 'generated_file_name': uploaded_file_name, 'output_format': output_format, 'row_count': len(output_entries), + 'passthrough_reason_code': passthrough_reason_code, }, debug_only=True, ) @@ -7421,6 +7468,7 @@ def emit_fallback_parity_event(event_name, planner_result=None, metrics=None, di 'source_file_name': source_candidate.get('filename'), 'selected_sheet': source_candidate.get('selected_sheet'), 'preview_rows': preview_rows, + 'passthrough_reason_code': passthrough_reason_code, 'summary': ( f"Saved {len(output_entries)} row(s) to {uploaded_file_name} " 'in this chat as a downloadable export.' @@ -15039,7 +15087,8 @@ def execute_document_action_chat_request( 'assigned_knowledge_context': assigned_context_metadata, 'model_endpoint_id': str(data.get('model_endpoint_id') or '').strip(), 'model_id': str(data.get('model_id') or '').strip(), - 'legacy_model_deployment': str(data.get('model_deployment') or '').strip(), + 'model_provider': str(data.get('model_provider') or '').strip(), + 'legacy_model_deployment': str(data.get('model_deployment') or data.get('model_id') or '').strip(), 'model_binding_summary': { 'endpoint_id': str(data.get('model_endpoint_id') or '').strip(), 'model_id': str(data.get('model_id') or '').strip(), diff --git a/application/single_app/route_enhanced_citations.py b/application/single_app/route_enhanced_citations.py index 164931e84..e4e380814 100644 --- a/application/single_app/route_enhanced_citations.py +++ b/application/single_app/route_enhanced_citations.py @@ -24,7 +24,11 @@ from functions_group import check_group_status_allows_operation, find_group_by_id, get_user_groups, require_active_group from functions_notifications import create_group_notification, create_notification, create_public_workspace_notification from functions_public_workspaces import check_public_workspace_status_allows_operation, get_user_visible_public_workspace_ids_from_settings, require_active_public_workspace -from functions_simplechat_operations import download_blob_content, upload_generated_document_for_current_user +from functions_simplechat_operations import ( + assert_generated_chat_artifact_is_published_for_user, + download_blob_content, + upload_generated_document_for_current_user, +) from swagger_wrapper import swagger_route, get_auth_security from config import CLIENTS, storage_account_user_documents_container_name, storage_account_group_documents_container_name, storage_account_public_documents_container_name, storage_account_personal_chat_container_name, IMAGE_EXTENSIONS, VIDEO_EXTENSIONS, AUDIO_EXTENSIONS, TABULAR_EXTENSIONS, VISIO_EXTENSIONS, cosmos_messages_container, cosmos_conversations_container from functions_debug import debug_print @@ -65,6 +69,7 @@ def _get_authorized_chat_artifact_message(user_id, conversation_id, message_id): if not str(message_item.get('blob_container') or '').strip() or not str(message_item.get('blob_path') or '').strip(): raise LookupError('Chat artifact content is unavailable') + assert_generated_chat_artifact_is_published_for_user(user_id, message_item) return message_item diff --git a/application/single_app/static/js/chat/chat-messages.js b/application/single_app/static/js/chat/chat-messages.js index 594a019fb..224c9104b 100644 --- a/application/single_app/static/js/chat/chat-messages.js +++ b/application/single_app/static/js/chat/chat-messages.js @@ -3394,6 +3394,273 @@ function renderReplyQuoteHtml(fullMessageObject = null) { return getGeneratedAnalysisArtifacts(fullMessageObject).filter(output => output.capability === 'tabular'); } + function getGeneratedArtifactSetDedupeKey(outputMetadata) { + const artifactMessageId = String(outputMetadata?.artifact_message_id || '').trim(); + if (artifactMessageId) { + return `message:${artifactMessageId}`; + } + + const stableArtifactId = String( + outputMetadata?.artifact_id + || outputMetadata?.member_id + || outputMetadata?.id + || outputMetadata?.document_id + || '' + ).trim(); + if (stableArtifactId) { + return `artifact:${stableArtifactId}`; + } + + const fileName = String(outputMetadata?.file_name || '').trim(); + const outputFormat = String(outputMetadata?.output_format || '').trim().toLowerCase(); + return `${fileName}:${outputFormat}`; + } + + function isGeneratedArtifactSetComplete(statusMetadata = {}) { + const normalizedRunStatus = String(statusMetadata?.status || '').trim().toLowerCase(); + if (normalizedRunStatus !== 'completed') { + return false; + } + + const artifactSet = statusMetadata?.artifact_set && typeof statusMetadata.artifact_set === 'object' + ? statusMetadata.artifact_set + : null; + if (!artifactSet) { + return true; + } + + const lifecycleState = String(artifactSet.lifecycle_state || '').trim().toLowerCase(); + if (lifecycleState && lifecycleState !== 'completed') { + return false; + } + + const validationState = String(artifactSet.validation_state || '').trim().toLowerCase(); + return !['failed', 'invalid', 'rollback_required', 'rolled_back'].includes(validationState); + } + + function normalizeGeneratedArtifactSetMember(rawMember, statusMetadata = {}, fallbackMetadata = {}) { + if (!rawMember || typeof rawMember !== 'object') { + return null; + } + + const runId = String(statusMetadata?.run_id || fallbackMetadata?.run_id || fallbackMetadata?.export_run_id || '').trim(); + const defaultCapability = String( + rawMember.capability + || fallbackMetadata?.capability + || statusMetadata?.capability + || 'tabular' + ).trim().toLowerCase() || 'tabular'; + return normalizeGeneratedAnalysisArtifact({ + ...fallbackMetadata, + ...statusMetadata, + ...rawMember, + capability: defaultCapability, + status: 'completed', + export_run_id: runId, + run_id: runId, + background_export: false, + suppress_assistant_table_export: Boolean( + rawMember.suppress_assistant_table_export + || fallbackMetadata?.suppress_assistant_table_export + || statusMetadata?.suppress_assistant_table_export + ), + }, defaultCapability); + } + + function sortGeneratedArtifactSetMembers(members, primaryArtifactId) { + const normalizedPrimaryArtifactId = String(primaryArtifactId || '').trim(); + const primaryAnalysisIndex = members.findIndex(member => { + const role = String(member?.role || member?.artifact_role || '').trim().toLowerCase(); + if (role === 'primary_analysis') { + return true; + } + return Boolean( + normalizedPrimaryArtifactId + && String(member?.artifact_id || member?.member_id || member?.id || '').trim() === normalizedPrimaryArtifactId + && isGeneratedMarkdownArtifact(member, member?.output_format) + ); + }); + + if (primaryAnalysisIndex <= 0) { + return members; + } + + const sortedMembers = members.slice(); + const primaryMember = sortedMembers.splice(primaryAnalysisIndex, 1)[0]; + sortedMembers.unshift(primaryMember); + return sortedMembers; + } + + function normalizeGeneratedArtifactSet(statusMetadata = {}, fallbackMetadata = {}) { + const artifactSet = statusMetadata?.artifact_set && typeof statusMetadata.artifact_set === 'object' + ? statusMetadata.artifact_set + : {}; + const pluralMembersProvided = Array.isArray(statusMetadata?.generated_artifacts); + const pluralMembers = pluralMembersProvided ? statusMetadata.generated_artifacts : []; + const singularMember = statusMetadata?.generated_artifact && typeof statusMetadata.generated_artifact === 'object' + ? statusMetadata.generated_artifact + : null; + const legacyAnalysisMembers = Array.isArray(statusMetadata?.generated_analysis_artifacts) + ? statusMetadata.generated_analysis_artifacts + : []; + const legacyTabularMembers = Array.isArray(statusMetadata?.generated_tabular_outputs) + ? statusMetadata.generated_tabular_outputs + : []; + const rawMembers = pluralMembersProvided + ? pluralMembers + : (singularMember ? [singularMember] : [...legacyAnalysisMembers, ...legacyTabularMembers]); + const seenMemberKeys = new Set(); + const members = []; + let duplicateSuppressedCount = 0; + + rawMembers.forEach(rawMember => { + const normalizedMember = normalizeGeneratedArtifactSetMember(rawMember, statusMetadata, fallbackMetadata); + if (!normalizedMember) { + return; + } + const dedupeKey = getGeneratedArtifactSetDedupeKey(normalizedMember); + if (dedupeKey && seenMemberKeys.has(dedupeKey)) { + duplicateSuppressedCount += 1; + return; + } + if (dedupeKey) { + seenMemberKeys.add(dedupeKey); + } + members.push(normalizedMember); + }); + + const orderedMembers = sortGeneratedArtifactSetMembers(members, artifactSet.primary_artifact_id); + return { + contractVersion: String(artifactSet.contract_version || statusMetadata?.contract_version || '').trim(), + setId: String(artifactSet.set_id || statusMetadata?.artifact_set_id || '').trim(), + status: String(statusMetadata?.status || '').trim().toLowerCase(), + lifecycleState: String(artifactSet.lifecycle_state || '').trim().toLowerCase(), + validationState: String(artifactSet.validation_state || '').trim().toLowerCase(), + primaryArtifactId: String(artifactSet.primary_artifact_id || '').trim(), + publicationGeneration: Number.parseInt(artifactSet.publication_generation, 10) || 0, + isComplete: isGeneratedArtifactSetComplete(statusMetadata), + legacyFallbackUsed: !pluralMembers.length && Boolean(singularMember), + duplicateSuppressedCount, + members: orderedMembers, + }; + } + + function recordGeneratedArtifactSetUiEvent(eventType, details = {}) { + const boundedFormats = Array.isArray(details.formats) + ? details.formats.map(format => String(format || '').trim().toLowerCase()).filter(Boolean).slice(0, 8) + : []; + const eventDetail = { + eventType: String(eventType || '').trim().toLowerCase().slice(0, 80), + runId: String(details.runId || '').trim().slice(0, 96), + status: String(details.status || '').trim().toLowerCase().slice(0, 40), + lifecycleState: String(details.lifecycleState || '').trim().toLowerCase().slice(0, 40), + memberCount: Number.isFinite(Number.parseInt(details.memberCount, 10)) + ? Math.max(0, Number.parseInt(details.memberCount, 10)) + : 0, + formats: boundedFormats, + primaryRendered: Boolean(details.primaryRendered), + legacyFallbackUsed: Boolean(details.legacyFallbackUsed), + duplicateSuppressedCount: Number.isFinite(Number.parseInt(details.duplicateSuppressedCount, 10)) + ? Math.max(0, Number.parseInt(details.duplicateSuppressedCount, 10)) + : 0, + }; + + document.dispatchEvent(new CustomEvent('simplechat:generated-artifact-set', { detail: eventDetail })); + } + + function createGeneratedArtifactSetGroup(artifactSet, runId = '') { + const group = document.createElement('div'); + group.className = 'generated-artifact-set-group d-grid gap-3 mt-3'; + group.dataset.generatedArtifactSet = 'true'; + if (artifactSet.setId) { + group.dataset.generatedArtifactSetId = artifactSet.setId; + } + if (runId) { + group.dataset.generatedArtifactRunId = runId; + } + group.setAttribute('role', 'group'); + group.setAttribute('aria-label', artifactSet.members.length === 1 ? 'Generated artifact' : 'Generated artifacts'); + + if (artifactSet.members.length > 1) { + const heading = document.createElement('div'); + heading.className = 'small fw-semibold'; + heading.textContent = `${artifactSet.members.length.toLocaleString()} generated artifacts`; + group.appendChild(heading); + } + + artifactSet.members.forEach(memberMetadata => { + group.appendChild(createGeneratedAnalysisArtifactCard(memberMetadata)); + }); + + return group; + } + + function replaceBackgroundGeneratedOutputCardWithArtifacts(outputMetadata, card, artifactSet) { + if (!(card instanceof HTMLElement) || !document.body.contains(card) || !artifactSet?.isComplete || !artifactSet.members.length) { + return false; + } + + const runId = String(outputMetadata?.export_run_id || outputMetadata?.run_id || artifactSet.members[0]?.run_id || '').trim(); + const primaryMember = artifactSet.members.find(member => { + const role = String(member?.role || member?.artifact_role || '').trim().toLowerCase(); + return role === 'primary_analysis'; + }) || artifactSet.members[0]; + const group = createGeneratedArtifactSetGroup(artifactSet, runId); + const formats = artifactSet.members.map(member => member?.output_format || ''); + const primaryRendered = Boolean(primaryMember); + + card.dataset.generatedArtifactSetCompleted = 'true'; + card.replaceWith(group); + hideCompletedGeneratedArtifactHandoff(group, primaryMember || outputMetadata); + + recordGeneratedArtifactSetUiEvent('set_card_hydrated', { + runId, + status: artifactSet.status, + lifecycleState: artifactSet.lifecycleState, + memberCount: artifactSet.members.length, + formats, + primaryRendered, + legacyFallbackUsed: artifactSet.legacyFallbackUsed, + duplicateSuppressedCount: artifactSet.duplicateSuppressedCount, + }); + recordGeneratedArtifactSetUiEvent('plural_completion_rendered', { + runId, + status: artifactSet.status, + lifecycleState: artifactSet.lifecycleState, + memberCount: artifactSet.members.length, + formats, + primaryRendered, + legacyFallbackUsed: artifactSet.legacyFallbackUsed, + duplicateSuppressedCount: artifactSet.duplicateSuppressedCount, + }); + + if (artifactSet.legacyFallbackUsed) { + recordGeneratedArtifactSetUiEvent('legacy_singular_fallback_used', { + runId, + status: artifactSet.status, + lifecycleState: artifactSet.lifecycleState, + memberCount: artifactSet.members.length, + formats, + primaryRendered, + legacyFallbackUsed: true, + }); + } + + if (artifactSet.duplicateSuppressedCount > 0) { + recordGeneratedArtifactSetUiEvent('duplicate_member_suppressed', { + runId, + status: artifactSet.status, + lifecycleState: artifactSet.lifecycleState, + memberCount: artifactSet.members.length, + formats, + primaryRendered, + duplicateSuppressedCount: artifactSet.duplicateSuppressedCount, + }); + } + + return true; + } + function getGeneratedTabularStorageNote(outputMetadata) { if (outputMetadata?.background_export) { return 'Continuing in the background. Progress is checkpointed and the download will appear here when complete.'; @@ -4804,24 +5071,27 @@ function renderReplyQuoteHtml(fullMessageObject = null) { throw new Error(responseData?.error || `Server responded with status ${response.status}`); } + if (!document.body.contains(card)) { + return; + } + const runStatus = responseData?.run || {}; - const generatedArtifact = runStatus?.generated_artifact || null; + const artifactSet = normalizeGeneratedArtifactSet(runStatus, outputMetadata); + const hasCompletedArtifactSet = Boolean(artifactSet.isComplete && artifactSet.members.length); Object.assign(outputMetadata, runStatus, { export_run_id: runStatus.run_id || runId, run_id: runStatus.run_id || runId, - background_export: String(runStatus.status || '').toLowerCase() !== 'completed' || !generatedArtifact, + background_export: !hasCompletedArtifactSet, }); - if (String(runStatus.status || '').toLowerCase() === 'completed' && generatedArtifact) { - Object.assign(outputMetadata, generatedArtifact, { + if (hasCompletedArtifactSet) { + Object.assign(outputMetadata, artifactSet.members[0], { background_export: false, status: 'completed', export_run_id: runStatus.run_id || runId, run_id: runStatus.run_id || runId, }); - const refreshedCard = createGeneratedAnalysisArtifactCard(outputMetadata); - hideCompletedGeneratedArtifactHandoff(card, outputMetadata); - card.replaceWith(refreshedCard); + replaceBackgroundGeneratedOutputCardWithArtifacts(outputMetadata, card, artifactSet); return; } @@ -4862,23 +5132,22 @@ function renderReplyQuoteHtml(fullMessageObject = null) { } const runStatus = responseData?.run || {}; - const generatedArtifact = runStatus?.generated_artifact || null; + const artifactSet = normalizeGeneratedArtifactSet(runStatus, outputMetadata); + const hasCompletedArtifactSet = Boolean(artifactSet.isComplete && artifactSet.members.length); Object.assign(outputMetadata, runStatus, { export_run_id: runStatus.run_id || runId, run_id: runStatus.run_id || runId, - background_export: String(runStatus.status || '').toLowerCase() !== 'completed' || !generatedArtifact, + background_export: !hasCompletedArtifactSet, }); - if (String(runStatus.status || '').toLowerCase() === 'completed' && generatedArtifact) { - Object.assign(outputMetadata, generatedArtifact, { + if (hasCompletedArtifactSet) { + Object.assign(outputMetadata, artifactSet.members[0], { background_export: false, status: 'completed', export_run_id: runStatus.run_id || runId, run_id: runStatus.run_id || runId, }); - const refreshedCard = createGeneratedAnalysisArtifactCard(outputMetadata); - hideCompletedGeneratedArtifactHandoff(card, outputMetadata); - card.replaceWith(refreshedCard); + replaceBackgroundGeneratedOutputCardWithArtifacts(outputMetadata, card, artifactSet); showToast(responseData?.message || 'Background export is already complete.', 'success'); return; } @@ -5176,7 +5445,14 @@ function renderReplyQuoteHtml(fullMessageObject = null) { downloadButton.type = 'button'; downloadButton.className = 'btn btn-sm btn-outline-primary generated-tabular-download-btn'; downloadButton.textContent = `Download ${outputFormat.toUpperCase()}`; + downloadButton.setAttribute('aria-label', `Download ${fileName}`); downloadButton.addEventListener('click', () => { + recordGeneratedArtifactSetUiEvent('member_download_action', { + runId: outputMetadata?.run_id || outputMetadata?.export_run_id, + status: outputMetadata?.status, + memberCount: 1, + formats: [outputFormat], + }); triggerGeneratedTabularOutputDownload(outputMetadata); }); actions.appendChild(downloadButton); @@ -5186,8 +5462,14 @@ function renderReplyQuoteHtml(fullMessageObject = null) { viewButton.type = 'button'; viewButton.className = 'btn btn-sm btn-outline-secondary generated-artifact-view-btn'; viewButton.textContent = `View ${outputFormat.toUpperCase()}`; - viewButton.setAttribute('aria-label', `View generated ${outputFormat.toUpperCase()} preview`); + viewButton.setAttribute('aria-label', `View ${fileName}`); viewButton.addEventListener('click', () => { + recordGeneratedArtifactSetUiEvent('member_view_action', { + runId: outputMetadata?.run_id || outputMetadata?.export_run_id, + status: outputMetadata?.status, + memberCount: 1, + formats: [outputFormat], + }); showGeneratedArtifactPreviewModal(outputMetadata, outputFormat); }); actions.appendChild(viewButton); @@ -5201,9 +5483,16 @@ function renderReplyQuoteHtml(fullMessageObject = null) { exportPowerPointButton.type = 'button'; exportPowerPointButton.className = 'btn btn-sm btn-outline-primary generated-artifact-export-ppt-btn'; exportPowerPointButton.textContent = 'Create PowerPoint'; + exportPowerPointButton.setAttribute('aria-label', `Create PowerPoint from ${fileName}`); exportPowerPointButton.dataset.artifactMessageId = normalizedArtifactMessageId; exportPowerPointButton.dataset.conversationId = normalizedConversationId; exportPowerPointButton.addEventListener('click', () => { + recordGeneratedArtifactSetUiEvent('member_powerpoint_action', { + runId: outputMetadata?.run_id || outputMetadata?.export_run_id, + status: outputMetadata?.status, + memberCount: 1, + formats: [outputFormat], + }); exportGeneratedMarkdownArtifactAsPowerPoint(outputMetadata, exportPowerPointButton); }); actions.appendChild(exportPowerPointButton); @@ -5213,7 +5502,14 @@ function renderReplyQuoteHtml(fullMessageObject = null) { viewButton.type = 'button'; viewButton.className = 'btn btn-sm btn-outline-secondary generated-artifact-view-md-btn'; viewButton.textContent = 'View MD'; + viewButton.setAttribute('aria-label', `View ${fileName}`); viewButton.addEventListener('click', () => { + recordGeneratedArtifactSetUiEvent('member_view_action', { + runId: outputMetadata?.run_id || outputMetadata?.export_run_id, + status: outputMetadata?.status, + memberCount: 1, + formats: [outputFormat], + }); viewGeneratedMarkdownArtifact(outputMetadata, viewButton); }); actions.appendChild(viewButton); @@ -5226,7 +5522,14 @@ function renderReplyQuoteHtml(fullMessageObject = null) { promoteButton.type = 'button'; promoteButton.className = 'btn btn-sm btn-outline-secondary generated-artifact-promote-btn'; promoteButton.textContent = 'Add to Workspace'; + promoteButton.setAttribute('aria-label', `Add ${fileName} to workspace`); promoteButton.addEventListener('click', () => { + recordGeneratedArtifactSetUiEvent('member_promotion_action', { + runId: outputMetadata?.run_id || outputMetadata?.export_run_id, + status: outputMetadata?.status, + memberCount: 1, + formats: [outputFormat], + }); promoteGeneratedArtifactToWorkspace(outputMetadata, promoteButton); }); actions.appendChild(promoteButton); diff --git a/docs/explanation/features/ANALYZE_DELIVERABLE_CONTRACT.md b/docs/explanation/features/ANALYZE_DELIVERABLE_CONTRACT.md new file mode 100644 index 000000000..7ca108286 --- /dev/null +++ b/docs/explanation/features/ANALYZE_DELIVERABLE_CONTRACT.md @@ -0,0 +1,283 @@ +# Analyze Deliverable Contract + +Implemented in version: **0.250.171** + +Phase 2 updated in version: **0.250.172** + +Phase 3 updated in version: **0.250.173** + +Phase 5 updated in version: **0.250.175** + +Phase 6 updated in version: **0.250.176** + +Phase 7 updated in version: **0.250.177** + +Phase 7A stabilization updated in version: **0.250.178** + +Phase 7B correctness updated in version: **0.250.179** + +Phase 7C publication updated in version: **0.250.180** + +Phase 7D validation completed in version: **0.250.180** + +## Overview + +The Analyze deliverable contract defines a server-owned, versioned plan for analysis artifacts before production routing changes are made. It records whether an action requires a primary Markdown analysis artifact, which sibling artifacts were explicitly requested, the public structured schema, row cardinality, ordering, transformation mode, validation profile, and publication policy. + +Phase 2 keeps the contract additive, but begins enforcing intent admission for shared tabular planning and bounded document Analyze finalization. Successful bounded Analyze results now publish a primary Markdown artifact. Explicit JSON, XML, or CSV requests are represented as ordered sibling artifacts, and Analyze plus structured output can no longer silently downgrade to structured-only export work when analysis is required. + +Phase 3 separates public structured schemas from internal checkpoint lineage. Durable tabular checkpoints still retain source row number and identity for validation, retries, audit, and restart, but final CSV, JSON, XML, preview rows, and preview columns are projected through the persisted public schema. Raw source or function rows are no longer accepted as a derived generated-output artifact unless the request is an explicit unchanged copy, serialization, or format conversion and the rows satisfy the public schema contract. + +Phase 5 adds a versioned durable artifact-set manifest for tabular generated-output runs. Combined Analyze runs can stage a requested structured sibling while hierarchical reduction continues, but public status withholds generated artifacts until every required member is validated and the set lifecycle reaches `completed`. New completed combined runs project Markdown as the primary artifact and requested structured files as ordered siblings. + +Phase 6 updates the chat browser completion path to consume the plural artifact-set projection. When polling or Continue receives a completed set, the progress card is replaced by one unframed generated-artifact group that renders every published member. Analyze Markdown is shown first, requested siblings retain their server order, and old singular `generated_artifact` responses still render as one compatible card. + +Phase 7 adds an explicit rollout state for new shared tabular parity assignments. Administrators can pause or roll back new assignment while preserving accepted run contracts, readers, status endpoints, and artifact-set recovery for already queued work. + +Phase 7A restores explicit Word/DOCX serialization of authorized current-turn non-tabular function-result rows. The passthrough guard still rejects derived requests before serialization, keeps tabular tool rows on their coverage-aware durable path, and omits sensitive fields. It also restores the cumulative lifecycle and scale harnesses to the current schema, planner, and artifact-set contracts. + +Phase 7B makes the production durable runner own rule-faithful structured output planning. Generation plan version 2 carries one normalized allowlisted transformation specification, deterministic or semantic field ownership, and an independent bounded review result. Active plans update the persisted deliverable contract before row generation. Deterministic fields execute server-side, semantic fields receive isolated field-level verification, and only failed or uncertain row-field pairs enter bounded targeted repair before canonical checkpoints are written. + +Phase 7C makes artifact-set publication the visibility boundary for new generated tabular artifacts. Uploaded artifact messages are staged with server-owned run, set, member, and publication-generation metadata. Direct download and workspace promotion reauthorize the caller against the committed run manifest before serving the blob. Completed manifests commit every required member in one publication generation, while staged, rolled-back, stale-generation, or incomplete members remain inaccessible through direct artifact routes. The same validated checkpoint set can now publish multiple requested durable structured siblings, such as JSON and XML, in request order. + +Phase 7D completed the deterministic final integration gate for the closure. The executed matrix covered generated-file compatibility, Analyze deliverable contracts, public schema projection, deterministic and semantic validation, production Search/Analyze exact 200-row equivalence, artifact-set publication, rollout metadata, legacy fallback decisions, route policy, available browser artifact UI checks, 30,000-row bounded finalization, and 100,000-row deterministic planning and hardening contracts. Live paid semantic validation above 3,000 rows and destructive legacy code deletion remain explicitly outside this closure. + +## Dependencies + +- `application/single_app/functions_analysis_deliverables.py` for contract construction, artifact-set validation, structured-row validation, and gated shadow telemetry. +- `application/single_app/functions_generated_file_exports.py` for ordered, negation-aware requested artifact format detection. +- `application/single_app/functions_tabular_orchestration.py` for attaching the contract to shared tabular planner results and selecting Analyze-safe execution contracts. +- `application/single_app/functions_tabular_generated_exports.py` for durable checkpoint lineage, public projection, and final artifact serialization. +- `application/single_app/functions_tabular_semantic_validation.py` for exact verifier responses, targeted repair, exhaustion policy, and safe aggregate counts. +- `application/single_app/functions_workflow_runner.py` for bounded Analyze Markdown artifact finalization. +- `functional_tests/test_analyze_deliverable_contract.py` for the executable regression oracle. +- `functional_tests/test_tabular_phase3_public_schema_projection.py` for public schema projection and passthrough guard coverage. +- `functional_tests/test_tabular_phase5_artifact_set_lifecycle.py` for durable artifact-set lifecycle and public projection coverage. +- `ui_tests/test_chat_background_generated_export_status.py` for plural completion rendering, ordering, actions, and safe UI event coverage. +- `functional_tests/test_document_analysis_lossless_artifacts.py` for document-analysis artifact finalizer behavior. +- `application/single_app/config.py` version `0.250.185`. + +## Technical Specifications + +### Contract Fields + +- `contract_version`: currently `analysis-deliverables-v3`. +- `action_mode`: normalized caller action such as `analyze` or `search`. +- `analysis_required`: true for Analyze by server policy. +- `requested_artifacts`: ordered artifact descriptors with role, format, required state, and request order. +- `primary_artifact_role`: `primary_analysis` when Analyze requires Markdown. +- `public_output_schema`: ordered public fields for a requested structured output. +- `internal_checkpoint_schema`: lineage fields followed by public fields for durable validation and resume. +- `lineage_schema`: server-owned row lineage fields such as `source_row_number` and `source_row_identity`. +- `row_cardinality` and `ordering`: row coverage expectations for structured deliverables. +- `transformation_mode`: `passthrough`, `deterministic`, `semantic`, or `hybrid`. +- `validation_profile`: artifact-only, exact row/schema, or exact row/schema/rule validation. +- `publication_policy`: whether all required artifacts must be valid before publication. +- `source_fingerprint` and `request_fingerprint`: bounded hashes used for correlation without logging row values or prompt text. + +### Artifact Roles + +- `primary_analysis`: the Markdown artifact required by successful Analyze actions. +- `requested_output`: a file explicitly requested by the user, such as CSV, JSON, XML, workbook, DOCX, or PDF. +- `supporting_output`: optional server-generated supporting material. + +Roles are product semantics, not formats. A Search-requested Markdown file is `requested_output`; an Analyze-required Markdown file is `primary_analysis`. + +### Public Schema Projection + +Durable structured export checkpoints keep the internal schema required by the runner: + +```text +source_row_number, source_row_identity, +``` + +Published artifacts and browser metadata use only `public_output_schema`: + +- CSV headers and row values +- JSON object fields and order +- XML row elements and escaped text +- preview rows and preview columns +- generated artifact summaries consumed by the chat UI + +Reserved fields such as `source_row_number`, `source_row_identity`, and `__simplechat_*` cannot be requested as public output fields. Legacy runs that only have `output_schema` are interpreted by filtering reserved lineage fields at publication time; old checkpoints are not rewritten. + +### Passthrough Eligibility + +Raw source or function rows can satisfy a generated file request only when the request is explicitly an unchanged copy, serialization, or format conversion, and any supplied public schema matches the row fields. Derived requests are refused instead of publishing source-shaped output. + +Refusal reason codes include: + +- `derived_output_requires_transform` +- `source_result_incomplete` +- `schema_not_satisfied` +- `no_explicit_passthrough_contract` + +Allowed passthrough reason codes include: + +- `explicit_unchanged_copy` +- `explicit_format_conversion` + +### Validation + +Pure validators report safe counts and reason codes for: + +- missing, extra, invalid, or wrongly-role artifacts +- wrong primary artifact role +- row count, schema, and schema-order mismatches +- internal lineage fields such as `source_row_number`, `source_row_identity`, and `__simplechat_*` +- duplicate or reordered row identities when an identity field is supplied +- deterministic value mismatches when an oracle is supplied + +Validation reports intentionally omit prompts, row values, storage paths, credentials, and provider errors. + +### Artifact-Set Publication + +Durable tabular generated-output runs now persist an `artifact_set_manifest` with contract version `tabular-artifact-set-v1`. The manifest records: + +- set, run, conversation, user, source, and request identifiers +- ordered member descriptors with role, format, required state, request order, and idempotency key +- member lifecycle state, validation state, and staged artifact metadata +- set lifecycle state, validation state, publication generation, rollback state, and primary artifact id + +Member lifecycle states include `planned`, `generating`, `staged`, `validated`, `publishing`, `published`, `failed`, `canceled`, and `rolled_back`. Set lifecycle states include `planned`, `generating`, `validating`, `ready_to_publish`, `publishing`, `completed`, `failed`, `canceled`, `rollback_required`, and `rolled_back`. + +Public generated-output status treats `generated_artifacts` as the authoritative ordered projection. A member appears there only when the full set lifecycle is `completed` and the member lifecycle is `published`. For new combined Analyze runs, the primary member is the Markdown analysis artifact and requested structured outputs follow as siblings. Singular compatibility fields are derived from the same primary projection and do not expose staged or invalid members. + +If a required member remains missing or unpublished, the set validation state becomes `invalid`, the lifecycle becomes `rollback_required`, and no generated artifact is projected as a completed request. + +### Reviewed Correctness Planning + +New active generated-output runs persist `tabular-generation-plan-v2` in a +versioned plan blob. The plan contains: + +- exact public output fields and field order +- a normalized `tabular-transform-v1` specification +- server or model ownership for every field +- source, request, model, batch-budget, and response-protocol fingerprints +- an independent plan review with exact represented-field coverage + +The reviewer checks requested field and rule coverage, condition precedence, +date boundaries, source references, and unrequested inference. Planner or +review exhaustion fails active required output before row generation. Existing +version 1 plans retain their persisted schema and behavior and remain resumable. +New version 2 plans require explicit transformation ownership for every field +and an initialized deliverable contract; missing ownership or contract state +fails before active row generation. +When active generation planning is enabled before shared-preflight rollout, a +new legacy-direct run receives the equivalent server-owned Search or Analyze +deliverable contract at queue time. Persisted older runs are not modified. + +Deterministic fields use the allowlisted evaluator and do not enter row-model +generation. Semantic and hybrid fields use a separate verifier response with +field-level `pass`, `fail`, `uncertain`, or `unsupported` status. Active mode +repairs only failed or uncertain row-field pairs, re-verifies the repaired +candidate, detects repeated responses, and fails closed when bounded attempts +are exhausted. Shadow mode records safe counts without changing generated rows. + +Before verification, each semantic candidate is written to a plan-hash-fenced +per-batch checkpoint. Repaired candidates replace that checkpoint after every +attempt. A restarted worker reloads the checkpoint before constructing a new +row-generation prompt, preserving successful fields while re-verifying the +current candidate. Combined Analyze runs use the same structured candidate +checkpoint while their analysis summary may be regenerated. + +Verifier responses, repair payloads, row values, and reasoning are not stored +in run metadata. Durable batch summaries and telemetry retain only bounded +pass, fail, uncertain, unsupported, target, and attempt counts. + +### Browser Artifact-Set Rendering + +The chat UI treats `generated_artifacts` as the authoritative completed set when present. It falls back to the legacy singular `generated_artifact` only for old status payloads. The client deduplicates members by artifact message id first, then stable artifact id, and only renders completed artifacts when the run status is `completed` and any supplied artifact-set lifecycle is also `completed`. + +Completed Analyze sets render as one grouped region: + +1. Primary Markdown analysis artifact. +2. Explicitly requested siblings in server order. +3. Optional supporting outputs when supplied by the backend. + +Each member keeps its own Download, View, PowerPoint, and workspace-promotion actions when the backing metadata supports those actions. Download, view, and promotion controls use filename-specific accessible names, but filenames are not included in the bounded UI event payloads emitted for rendering and member actions. + +## Usage + +Shared tabular planning attaches a `deliverable_contract` field to planner results. When `enable_analysis_deliverable_contract_telemetry` is true and `analysis_deliverable_contract_mode` is `observe` or `shadow`, the planner emits debug-only `[ANALYSIS_DELIVERABLE_CONTRACT]` events with safe dimensions. + +The planner preserves explicit requested artifact order and direct negation. For example, Analyze with CSV plans Markdown first and CSV second. Analyze with JSON and XML preserves both requested siblings in order and selects durable combined execution when hierarchical analysis is enabled. Search can request Markdown as a normal requested output, but Search does not receive automatic primary Markdown. + +After Phase 6, a completed combined Analyze plus CSV run visibly presents both artifacts after live polling, after Continue, and when compatible completed metadata is hydrated. The progress card remains visible for queued, running, retry-waiting, failed, canceled, rollback, or otherwise nonterminal sets and does not expose staged or rolled-back downloads. + +When Analyze requests a structured tabular artifact, the shared planner selects `combined` only when hierarchical analysis is enabled. If the required analysis capability is disabled, the request is declined before durable execution rather than being reinterpreted as `structured_export`. + +Phase 7 rollout assignment includes backend-only `tabular_analyze_parity_rollout_state`. Supported values are `active`, `paused`, and `rollback`. `active` keeps percent-based cohort assignment. `paused` and `rollback` stop new shared durable assignment with safe reason codes while old run metadata remains readable and resumable under its persisted contract. The setting is sanitized away from frontend settings and appears in public run status only as normalized low-cardinality metadata. + +The default settings keep telemetry off: + +```python +enable_analysis_deliverable_contract_telemetry = False +analysis_deliverable_contract_mode = "off" +``` + +Phase 7B semantic validation is also backend-only and defaults off: + +```python +tabular_semantic_validation_mode = "off" +tabular_semantic_repair_max_attempts = 2 +tabular_semantic_repair_max_rows = 100 +``` + +Operators may move validation through `off`, `shadow`, and `active` after the +Phase 7D rollout gates. The effective mode and bounds are snapshotted on each +new run; accepted runs are never reinterpreted from current settings. + +## Testing and Validation + +The committed 200-row fixture builder in `functional_tests/test_support/analyze_deliverable_contract_fixture.py` includes the exact nine requested output columns, source-order requirements, assessment-date boundaries, 30-day due-soon boundary cases, and dependencies between concern fields and `Overall_Attention`. + +`functional_tests/test_analyze_deliverable_contract.py` verifies: + +- Analyze requires Markdown while Search does not receive automatic Markdown. +- The requested structured contract is shared across Search and Analyze. +- Analyze plus CSV maps to Markdown plus CSV and selects `combined` when hierarchical analysis is enabled. +- Analyze plus JSON and XML preserves both requested siblings in order and selects durable combined execution when hierarchical analysis is enabled. +- Analyze plus structured output does not silently downgrade to `structured_export` when the hierarchical capability is disabled. +- Bounded document Analyze publishes Markdown, and explicit JSON is a sibling rather than a replacement. +- JSON serialization round trips and unknown additive fields are ignored for forward compatibility. +- The source-shaped Analyze failure is rejected. +- The Search-shaped output with `source_row_number`, `source_row_identity`, and five known rule mismatches is rejected. +- Safe telemetry excludes prompt text, row values, file names, and storage paths. + +`functional_tests/test_tabular_phase3_public_schema_projection.py` verifies: + +- Contract version `analysis-deliverables-v2` persists distinct public, internal checkpoint, and lineage schemas. +- Reserved lineage fields are rejected in public schemas. +- Durable CSV, JSON, XML, and preview metadata expose only public fields in order. +- XML output escapes projected values without leaking lineage fields. +- Generic generated-file finalizers refuse raw function rows for derived requests but allow explicit serialization. + +`functional_tests/test_tabular_phase5_artifact_set_lifecycle.py` verifies: + +- A staged structured sibling in a running combined Analyze set is not public. +- A completed combined set publishes Markdown first and the requested structured sibling second. +- An invalid required set fails closed with `rollback_required` and no public generated artifacts. + +`ui_tests/test_chat_background_generated_export_status.py` verifies: + +- Completed combined status replaces one progress card with a plural generated-artifact group. +- Analyze Markdown is rendered before a requested CSV sibling even when the status response lists the CSV first. +- Each member retains unique Download and View actions. +- The client emits bounded artifact-set UI events without filenames or storage details. + +`functional_tests/test_tabular_phase7b_production_correctness.py` verifies the +real shared Search and Analyze facade with no injected output hints. The +200-row financial-review fixture produces exactly nine requested fields, zero +value mismatches, four durable output checkpoints, no row-model-owned fields, +equivalent Search/Analyze structured output, and the additional Analyze +Markdown member. + +`functional_tests/test_tabular_semantic_validation_phase7b.py` verifies exact +verifier contracts, evidence-field bounds, active targeted repair, +re-verification, shadow observation, repair exhaustion, service separation, +pre-checkpoint ownership, and safe count-only batch summaries. + +## Known Limitations + +Phase 7C does not yet add long-running cleanup sweepers for abandoned staged members. Broader lifecycle race, authenticated UI, and canary validation remain Phase 7D work. diff --git a/docs/explanation/features/TABULAR_TRANSFORMATION_CONTRACT.md b/docs/explanation/features/TABULAR_TRANSFORMATION_CONTRACT.md new file mode 100644 index 000000000..372bdadd5 --- /dev/null +++ b/docs/explanation/features/TABULAR_TRANSFORMATION_CONTRACT.md @@ -0,0 +1,96 @@ +# Tabular Transformation Contract + +Implemented in version: **0.250.174** + +Production planning and semantic validation updated in version: **0.250.179** + +## Overview + +The tabular transformation contract adds a versioned, server-owned specification for row-local generated outputs. When a tabular Search or Analyze request includes a supported deterministic transformation specification, SimpleChat can compute those public fields on the server instead of asking the model to reproduce rule-based values. + +Analyze still uses the deliverable contract introduced by the artifact-output roadmap. The transformation contract is an additive child contract used by generated tabular artifacts when exact row rules are representable without arbitrary code. + +## Dependencies + +- `application/single_app/functions_analysis_deliverables.py` +- `application/single_app/functions_tabular_transformations.py` +- `application/single_app/functions_tabular_generated_exports.py` +- `application/single_app/functions_tabular_orchestration.py` + +## Technical Specifications + +The contract version is `tabular-transform-v1`. It is persisted inside the analysis deliverable contract as `transformation_spec` and normalized before a durable generated-output run is stored. + +Initial supported operations are deliberately bounded: + +- source field copy +- literal values +- ordered `case` branches +- equality and ordered comparisons +- ISO date comparisons +- numeric arithmetic with bounded `Decimal` values +- null coalescing +- boolean `all`, `any`, and `not` +- membership checks +- references to previously derived deterministic fields + +Unsupported operations fail during planning or normalization. The evaluator does not use `eval`, `exec`, dynamic imports, reflection, filesystem access, network access, database access, process access, or environment access. + +## Execution Behavior + +For deterministic-only structured exports, the durable runner checkpoints generated rows directly from the evaluator and does not call the model for row generation. For hybrid runs, deterministic fields are removed from the model-owned output schema, the model generates only remaining semantic fields, and the server merges deterministic values back into the full checkpoint schema. + +Combined Analyze plus structured-output runs still use the model for analysis summaries. Deterministic structured fields are computed by the same server evaluator before publication. + +## Production Planning And Review + +Generated-output plan version 2 asks the existing bounded planner for exact +field order and a `tabular-transform-v1` graph. Planner output is treated as +untrusted data and normalized through the same source-field, dependency, +cycle, depth, branch, list, string, and numeric limits used by direct +server-supplied contracts. + +A separate model invocation reviews the normalized plan before persistence. +It must account for every requested field in order and reject missing rules, +changed precedence or boundaries, unknown source fields, unsupported rules, +unrequested inference, invalid deterministic ownership, and semantic fields +that could be represented as direct copies or deterministic rules. Active +planning or review failure stops required output instead of falling back to +unchecked row generation. + +Version 1 generation plans remain readable and resumable. They are not +upgraded or reinterpreted as version 2 contracts. + +## Semantic Verification And Repair + +Semantic and hybrid fields are verified independently after deterministic +fields are merged and before canonical output checkpoints are written. The +verifier returns an exact field-level contract with status, a bounded reason +code, and source evidence field names. It does not return hidden reasoning. + +Active mode repairs only failed or uncertain row-field pairs, enforces field +types, nullability, and allowed values, and re-verifies each repaired candidate. +Repeated responses, unsupported required fields, row-budget overflow, or +attempt exhaustion fail the batch closed. Shadow mode records safe aggregate +counts without changing output. + +Repair values are bounded by type, finite numeric range, string length, +collection size, serialized collection size, nullability, and allowed values. +Candidates are checkpointed under the run, batch, and immutable plan hash +before verification and after each repair attempt so restarts do not regenerate +already-successful fields. + +## Validation + +Functional coverage is in `functional_tests/test_tabular_transformations_phase4.py`. + +The test verifies: + +- all 200 financial-review fixture rows match the independent oracle +- unsafe operations, reserved fields, cycles, and unknown source fields are rejected +- deliverable contracts persist and round-trip `transformation_spec` +- deterministic-only specs produce no model-owned public fields + +## Limitations + +The production planner can now produce the bounded transformation graph from user instructions after source schema staging. Arbitrary executable code, unsupported expression operations, subjective deterministic claims, and unreviewed active plans remain prohibited. Semantic model verification is evidence-based but is not represented as deterministic proof. diff --git a/docs/explanation/fixes/ANALYZE_ARTIFACT_PHASE_7A_STABILIZATION_FIX.md b/docs/explanation/fixes/ANALYZE_ARTIFACT_PHASE_7A_STABILIZATION_FIX.md new file mode 100644 index 000000000..178af6ad3 --- /dev/null +++ b/docs/explanation/fixes/ANALYZE_ARTIFACT_PHASE_7A_STABILIZATION_FIX.md @@ -0,0 +1,61 @@ +# Analyze Artifact Phase 7A Stabilization Fix + +Fixed in version: **0.250.178** + +Related issue: **#1233** + +## Issue Description + +The Analyze artifact integration branch had three stabilization failures after +the first seven implementation slices: + +- Word/DOCX requests that explicitly serialized authorized current-turn action + results created a document but silently omitted the structured rows. +- The Phase 7 lifecycle test stub did not expose the ordered artifact-format + API required by the shared planner. +- The cumulative scale harness omitted schema, transformation, and projection + dependencies added to production helpers in later phases. + +## Root Cause Analysis + +The DOCX intent detector accepted both `word` and `docx`, while the guarded +passthrough serializer accepted only `docx`. The test harnesses intentionally +load narrow function slices, but their dependency inventories had not been +updated when the production functions gained public-schema, lineage, +transformation, action-mode, and artifact-set projection dependencies. + +## Technical Details + +Files modified: + +- `application/single_app/functions_generated_file_exports.py` +- `functional_tests/test_assistant_table_csv_artifact.py` +- `functional_tests/test_tabular_phase7_lifecycle_coverage.py` +- `functional_tests/test_tabular_row_orchestration_scale.py` +- `docs/explanation/features/ANALYZE_DELIVERABLE_CONTRACT.md` +- `application/single_app/config.py` + +The serializer now treats `word` as the same explicit format-conversion alias +as `docx`. Derived-output detection still runs before serialization, tabular +plugin results remain excluded, and sensitive function-result fields continue +to be removed by the existing authorization and projection path. + +The isolated lifecycle and scale harnesses now load the same ordered artifact +format, schema, lineage, transformation, stream projection, and route-neutral +task-classifier dependencies used by production. + +## Validation + +- DOCX/PDF function-result serialization includes authorized structured rows. +- Derived Word requests do not publish untransformed function rows. +- The complete Phase 7 lifecycle coverage suite passes. +- The cumulative scale suite passes through 100,000-row planning, 30,000-row + bounded streaming finalization, authorization revalidation, cancellation, + restart, lease fencing, artifact projection, and legacy migration checks. + +## Impact Analysis + +Users regain the documented Word export behavior for explicit action-result +serialization. No new source or authorization path is introduced, and the fix +does not activate unfinished transformation planning, semantic repair, +multi-format publication, or legacy retirement work. diff --git a/docs/explanation/fixes/ANALYZE_ARTIFACT_PHASE_7B_CORRECTNESS_FIX.md b/docs/explanation/fixes/ANALYZE_ARTIFACT_PHASE_7B_CORRECTNESS_FIX.md new file mode 100644 index 000000000..2836b97b9 --- /dev/null +++ b/docs/explanation/fixes/ANALYZE_ARTIFACT_PHASE_7B_CORRECTNESS_FIX.md @@ -0,0 +1,78 @@ +# Analyze Artifact Phase 7B Correctness Fix + +Fixed in version: **0.250.179** + +Related issue: **#1233** + +## Issue Description + +The durable runner could preserve row count, order, schema, and lineage while +still producing values that violated explicit user rules. The deterministic +evaluator existed, but production Search and Analyze requests did not create a +transformation specification unless an internal caller injected output hints. +Semantic fields also had no independent field-level verification or targeted +repair before checkpoint publication. + +## Root Cause Analysis + +Generation plan version 1 owned only the row-model schema. It did not persist +deterministic or semantic field ownership, did not receive an independent plan +review, and did not update the deliverable contract with the effective public +schema and rule-validation profile. Batch validation therefore proved +structure but not requested value semantics. + +## Technical Details + +Files modified: + +- `application/single_app/functions_tabular_generated_exports.py` +- `application/single_app/functions_tabular_semantic_validation.py` +- `application/single_app/functions_tabular_transformations.py` +- `application/single_app/functions_settings.py` +- `functional_tests/test_tabular_phase7b_production_correctness.py` +- `functional_tests/test_tabular_semantic_validation_phase7b.py` +- `functional_tests/test_tabular_row_orchestration_scale.py` +- `functional_tests/test_tabular_transformations_phase4.py` +- `docs/explanation/features/ANALYZE_DELIVERABLE_CONTRACT.md` +- `docs/explanation/features/TABULAR_TRANSFORMATION_CONTRACT.md` +- `application/single_app/config.py` + +New plan version 2 persists a normalized allowlisted transformation graph and +requires a separate review invocation before active execution. Deterministic +fields execute on the server. Semantic fields use an isolated verifier and +bounded targeted repair before canonical output checkpoints. Active planning, +review, verification, or repair exhaustion fails required output closed. + +Version 2 requires explicit ownership for every field and an initialized +deliverable contract. Semantic candidates are checkpointed per batch and plan +hash before verification and after each repair attempt, allowing restart-safe +re-verification without regenerating successful fields. Repair values are +bounded by declared type, finite numeric range, nullability, allowed values, +string length, and collection size. +New active runs accepted through legacy direct preflight receive a server-owned +fallback deliverable contract, preserving compatibility while shared planner +adapters remain gated. Existing persisted runs are not reinterpreted. + +Version 1 generation plans remain readable and resumable under their recorded +behavior. The new semantic settings are backend-only and default to `off`. + +## Validation + +- The 200-row financial-review prompt enters through the real shared Search and + Analyze facade without injected output hints. +- Both actions persist the same nine-field deterministic contract. +- All 200 rows pass exact schema, order, cardinality, and value validation with + zero mismatches after four durable checkpoints. +- Deterministic-only output requires no row-model-owned fields. +- Planner and reviewer use separate model invocations. +- Semantic verifier, shadow, targeted repair, re-verification, duplicate + response, invalid repair, and exhaustion policies have executable coverage. +- The cumulative scale suite continues through 30,000-row bounded finalization + and 100,000-row planning, authorization, cancellation, and manifest checks. + +## Impact Analysis + +New active generated-output runs can establish and enforce value-level rules +before publication. Existing runs are not reinterpreted. Semantic validation +remains disabled by default until the final rollout phase records canary and +live semantic evidence. diff --git a/docs/explanation/fixes/ANALYZE_ARTIFACT_PHASE_7C_PUBLICATION_FIX.md b/docs/explanation/fixes/ANALYZE_ARTIFACT_PHASE_7C_PUBLICATION_FIX.md new file mode 100644 index 000000000..16e7e0057 --- /dev/null +++ b/docs/explanation/fixes/ANALYZE_ARTIFACT_PHASE_7C_PUBLICATION_FIX.md @@ -0,0 +1,82 @@ +# Analyze Artifact Phase 7C Publication Fix + +Fixed in version: **0.250.180** + +Related issue: **#1233** + +## Issue Description + +Combined durable Analyze runs could stage a requested structured artifact before +the Markdown analysis member was validated. Public status hid the staged member, +but direct generated-artifact download and workspace-promotion routes authorized +only conversation ownership and generated-artifact metadata. A user with the +staged artifact message id could therefore access a sibling before the required +artifact set completed. + +Multi-format durable requests were also rejected even though the artifact-set +contract could represent multiple requested siblings. + +## Root Cause Analysis + +Generated artifact messages did not carry server-owned artifact-set lifecycle +metadata, and direct artifact routes had no way to distinguish legacy published +artifacts from new staged artifact-set members. Publication validation updated +the run manifest but did not commit the backing generated message metadata. + +The durable publisher also serialized only one structured requested format from +the canonical checkpoint set. + +## Technical Details + +Files modified: + +- `application/single_app/functions_simplechat_operations.py` +- `application/single_app/functions_tabular_generated_exports.py` +- `application/single_app/functions_tabular_orchestration.py` +- `application/single_app/route_enhanced_citations.py` +- `functional_tests/test_generated_artifact_lifecycle_authorization.py` +- `functional_tests/test_tabular_phase5_artifact_set_lifecycle.py` +- `functional_tests/test_analyze_deliverable_contract.py` +- `functional_tests/test_tabular_row_orchestration_scale.py` +- `docs/explanation/features/ANALYZE_DELIVERABLE_CONTRACT.md` +- `application/single_app/config.py` + +New generated tabular artifact messages include run id, artifact-set id, +member id, lifecycle state, validation state, and publication generation. +Legacy generated artifacts without those fields retain existing visibility. + +Direct generated-artifact download and promotion now reauthorize new +artifact-set members against the owning run's completed and validated manifest. +The route rejects staged, rolled-back, stale-generation, incomplete, or +cross-object members before serving or promoting blob content. + +The durable structured publisher now serializes each requested CSV, JSON, or XML +sibling from the same validated ordered checkpoints. The manifest publishes all +required members in one generation and preserves Markdown as the primary Analyze +artifact. + +Failed post-staging publication states can pass the resume/cancel guard when +their artifact-set manifest is still validating, publishing, failed, or +rollback-required, preventing runs from being stranded behind +`publishing_started_at`. + +## Validation + +- Staged generated artifacts are rejected by direct artifact authorization. +- Legacy generated artifacts without artifact-set metadata remain accessible. +- Committed artifacts require a completed, validated run manifest with matching + set id, member id, message id, and publication generation. +- Valid combined artifact sets commit every member in one publication generation. +- Invalid required sets fail closed without committing backing messages. +- Analyze plus multiple requested structured formats selects durable combined + execution and publishes Markdown plus each requested sibling in order. +- The cumulative scale suite passes through 30,000-row bounded finalization, + 100,000-row planning and hardening, publication idempotency, cancellation, + restart, authorization, and route suppression checks. + +## Impact Analysis + +New artifact-set members are no longer directly accessible before the set is +valid and completed. Users can request multiple durable structured siblings for +supported formats without rerunning generation. Existing generated artifacts and +old run readers are preserved. diff --git a/docs/explanation/fixes/DATA_MANAGEMENT_SCHEDULER_CONTEXT_FIX.md b/docs/explanation/fixes/DATA_MANAGEMENT_SCHEDULER_CONTEXT_FIX.md new file mode 100644 index 000000000..285626a97 --- /dev/null +++ b/docs/explanation/fixes/DATA_MANAGEMENT_SCHEDULER_CONTEXT_FIX.md @@ -0,0 +1,47 @@ +# DATA MANAGEMENT SCHEDULER CONTEXT FIX + +Fixed in version: **0.250.185** + +## Issue Description + +The Data Management scheduler emitted repeated backend errors from background task threads: `copy_current_request_context can only be used when a request context is active`. + +## Root Cause Analysis + +- Background scheduler scans call `submit_data_management_job(app, job_id)` outside a Flask request context. +- That helper used the configured Flask executor whenever available. +- In this app, the executor submission path can copy request context, which is invalid from scheduler threads that do not have an active request. + +## Version Implemented + +- **0.250.185** + +## Files Modified + +- `application/single_app/functions_data_management.py` +- `application/single_app/config.py` +- `functional_tests/test_data_management_migration_recovery.py` +- `docs/explanation/release_notes.md` + +## Code Changes Summary + +- Added an explicit `has_request_context()` guard before using executor submission APIs in `submit_data_management_job()`. +- Preserved executor-backed route submissions when a request context exists. +- Kept the existing worker-thread submission path for scheduler/background submissions. + +## Testing Approach + +- Added regression coverage proving background submissions without a request context avoid executor APIs and use the worker-thread path. +- Preserved recovery coverage for request-context executor submissions. +- Compiled changed Python files with `py_compile`. + +## Impact Analysis + +- Data Management scheduler scans no longer repeatedly emit request-context exceptions. +- User-triggered Data Management jobs can still use the configured executor when submitted from routes. +- Existing durable job recovery behavior is preserved. + +## Validation + +- Before: scheduler scans could call executor APIs from background threads and trigger `copy_current_request_context` errors. +- After: scheduler submissions bypass request-context-copying executor APIs unless a Flask request context is active. diff --git a/docs/explanation/fixes/TABULAR_ANALYZE_COMBINED_DURABLE_ROUTING_FIX.md b/docs/explanation/fixes/TABULAR_ANALYZE_COMBINED_DURABLE_ROUTING_FIX.md new file mode 100644 index 000000000..0e5b09091 --- /dev/null +++ b/docs/explanation/fixes/TABULAR_ANALYZE_COMBINED_DURABLE_ROUTING_FIX.md @@ -0,0 +1,56 @@ +# TABULAR ANALYZE COMBINED DURABLE ROUTING FIX + +Fixed in version: **0.250.185** + +## Issue Description + +Selected tabular Analyze requests that asked for row-level answers and a CSV output could fail mid-stream with `Document action failed (500)`. Production logs showed foreground tabular tools returning no computed inline results, followed by mixed-source Analyze failing because no evidence was prepared. + +## Root Cause Analysis + +- The shared tabular planner treated generated-output Analyze as dependent on the older hierarchical-analysis enablement flag. +- When that flag was off, an Analyze prompt such as `for each row, answer each question and generate a csv` was routed through foreground tabular tools instead of the combined durable generated-output path. +- Empty foreground tool output then caused the selected-source workflow to raise `Mixed-source Analyze could not prepare evidence from any selected source.` +- After combined routing was enabled, background runs for non-default model endpoints could fail immediately if the selected model endpoint context was not carried into the durable run record. + +## Version Implemented + +- **0.250.185** + +## Files Modified + +- `application/single_app/functions_tabular_orchestration.py` +- `application/single_app/functions_workflow_runner.py` +- `application/single_app/functions_tabular_analysis.py` +- `application/single_app/route_backend_chats.py` +- `application/single_app/config.py` +- `functional_tests/test_analyze_deliverable_contract.py` +- `functional_tests/test_tabular_document_actions_workflow.py` +- `docs/explanation/features/ANALYZE_DELIVERABLE_CONTRACT.md` +- `docs/explanation/release_notes.md` + +## Code Changes Summary + +- Maps generated-output Analyze requests to the existing `combined` durable task type as first-class planner behavior. +- Queues planner-approved combined tabular Analyze work before foreground tabular tools run. +- Carries the selected model endpoint id, model id, provider, and active group context into the tabular generated-output run. +- Preserves pending, failed, and canceled generated-output evidence handling without synthesizing from empty computed results. +- Keeps bounded inline tabular Analyze on the foreground path when no generated output is requested. + +## Testing Approach + +- Updated Analyze deliverable-contract coverage to assert generated-output Analyze remains `combined` even when hierarchical-analysis-only settings are off. +- Added workflow coverage proving generated-output Analyze queues durable work before foreground tabular tools run and passes selected model endpoint context. +- Compiled changed Python files with `py_compile`. + +## Impact Analysis + +- Analyze requests that ask for both row-level analysis and a structured file now produce the intended Markdown analysis artifact plus the requested output file artifact. +- Exhaustive generated-output Analyze no longer depends on foreground tabular tools producing inline text. +- Background generated-output workers use the same selected endpoint context as the document-action request instead of assuming the model name is an Azure OpenAI deployment on the default resource. +- Non-generated-output Analyze and comparison workflows remain on their existing paths. + +## Validation + +- Before: explicit row-level Analyze plus CSV requests could be routed to foreground tools and fail with `Document action failed (500)` when no inline computed result was returned. +- After: the planner classifies the request as combined durable Analyze and the workflow queues that durable work with selected model endpoint context before foreground tabular tools run. diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index 4f08af54c..befbf48cd 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -2,6 +2,50 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Version](/explanation/features/) and [Fixes by Version](/explanation/fixes/). +### **(v0.250.185)** + +#### Bug Fixes + +* **Analyze Combined Generated Output Routing** + * Treats Analyze requests that ask for row-level answers plus CSV/JSON/XML output as first-class combined durable work, producing both the Markdown analysis artifact and requested structured output artifacts. + * Queues planner-approved combined tabular Analyze work before foreground tabular tools run, preventing empty inline tool output from becoming a stream-level 500. + * Carries the selected model endpoint context into background generated-output runs so non-default endpoints do not fall back to an Azure OpenAI deployment name lookup. + * (Ref: `functions_tabular_orchestration.py`, `functions_workflow_runner.py`, Analyze deliverable contract, combined durable generated output) + +* **Data Management Scheduler Context Guard** + * Prevented the background Data Management scheduler from using request-context-copying executor APIs when no Flask request context exists. + * Scheduler-submitted jobs now use the existing worker-thread path outside request handling, while route-triggered submissions can still use the configured executor. + * (Ref: `functions_data_management.py`, Data Management scheduler, background job submission) + +### **(v0.250.182)** + +#### Bug Fixes + +* **Analyze Artifact Copilot Review Cleanup** + * Preserved explicit request order for combined JSON/XML artifact requests when both formats share the same action phrase. + * Kept explicit unchanged-copy requests eligible even when source field names include descriptive terms such as risk or status. + * Made semantic validation shadow mode fail open on verifier errors and prevented the chat UI from falling back to withheld legacy artifacts when `generated_artifacts` is explicitly empty. + * (Ref: PR #1238, Copilot review comments, generated artifact ordering, semantic validation shadow mode, plural artifact UI) + +### **(v0.250.181)** + +#### Bug Fixes + +* **Analyze Artifact Advanced Security Cleanup** + * Replaced a self-comparison float finite check in the tabular transformation validator with an explicit finite-number check. + * Simplified an unnecessary callable wrapper in the Phase 7B production-correctness functional test harness. + * (Ref: PR #1238, GitHub Advanced Security comments, tabular transformation validation) + +### **(v0.250.180)** + +#### Bug Fixes + +* **Analyze Artifact Output Contract Closure** + * Made Analyze generated-output delivery Markdown-first and contract-faithful across durable tabular execution by adding reviewed transformation planning, deterministic server-side rules, bounded semantic verification and repair, and exact Search/Analyze 200-row parity validation. + * Hardened artifact-set publication so new staged generated artifacts are not downloadable or promotable until the completed run manifest commits every required member, while preserving legacy generated artifact compatibility. + * Restored explicit Word/DOCX current-turn function-result serialization and repaired cumulative lifecycle, scale, route, and UI validation harnesses through 30,000-row bounded finalization and 100,000-row deterministic planning/hardening contracts. + * (Ref: #1233, PR #1234, PR #1235, PR #1236, Analyze deliverable contract, tabular transformation contract, artifact-set publication lifecycle) + ### **(v0.250.170)** #### Bug Fixes diff --git a/docs/reference/logging-tags.md b/docs/reference/logging-tags.md index f655b95df..e4811f592 100644 --- a/docs/reference/logging-tags.md +++ b/docs/reference/logging-tags.md @@ -21,6 +21,7 @@ Last inventoried: 2026-08-10 - `[AGENT_RESPONSE_CALLBACK]` - `[AGENT_STREAMING]` - `[AGENT_STREAMING_TOKENS]` +- `[ANALYSIS_DELIVERABLE_CONTRACT]` - `[AKV_TEST]` - `[APPROVALS]` - `[APP_INSIGHTS]` diff --git a/functional_tests/test_analyze_artifact_phase7_rollout_rollback.py b/functional_tests/test_analyze_artifact_phase7_rollout_rollback.py new file mode 100644 index 000000000..a673865b8 --- /dev/null +++ b/functional_tests/test_analyze_artifact_phase7_rollout_rollback.py @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 +# test_analyze_artifact_phase7_rollout_rollback.py +""" +Functional test for Analyze artifact Phase 7 rollout rollback controls. +Version: 0.250.177 +Implemented in: 0.250.177 + +This test ensures Phase 7 can stop new shared tabular parity assignments +through a backend-only rollback state without exposing prompts, filenames, +storage locators, or breaking already-persisted run readers. +""" + +import ast +import sys +import traceback +import types +from pathlib import Path + +from test_support.versioning import assert_app_version_at_least + + +REPO_ROOT = Path(__file__).resolve().parents[1] +APP_ROOT = REPO_ROOT / "application" / "single_app" +EXPORT_MODULE = APP_ROOT / "functions_tabular_generated_exports.py" +SETTINGS_MODULE = APP_ROOT / "functions_settings.py" +IMPLEMENTED_VERSION = "0.250.177" +sys.path.insert(0, str(APP_ROOT)) + + +def install_lightweight_planner_dependency_stubs(): + assistant_exports_module = types.ModuleType("functions_assistant_table_exports") + assistant_exports_module.assistant_table_export_requested = ( + lambda prompt: "csv" in str(prompt or "").lower() + ) + generated_exports_module = types.ModuleType("functions_generated_file_exports") + + def get_requested_artifact_formats(prompt): + normalized_prompt = str(prompt or "").lower() + return [output_format for output_format in ("json", "xml", "csv") if output_format in normalized_prompt] + + generated_exports_module.get_requested_artifact_formats = get_requested_artifact_formats + generated_exports_module.get_requested_structured_artifact_format = ( + lambda prompt: next(iter(get_requested_artifact_formats(prompt)), None) + ) + generated_exports_module.get_requested_structured_artifact_formats = get_requested_artifact_formats + sys.modules.setdefault("functions_assistant_table_exports", assistant_exports_module) + sys.modules.setdefault("functions_generated_file_exports", generated_exports_module) + + +install_lightweight_planner_dependency_stubs() + +from functions_tabular_orchestration import ( # noqa: E402 + build_tabular_parity_rollout_assignment, + normalize_tabular_parity_rollout_state, + orchestrate_tabular_request, +) + + +def assert_equal(actual, expected, label): + if actual != expected: + raise AssertionError(f"{label}: expected {expected!r}, got {actual!r}") + + +def assert_true(value, label): + if not value: + raise AssertionError(f"Expected truthy value for {label}") + + +def assert_false(value, label): + if value: + raise AssertionError(f"Expected falsy value for {label}") + + +def build_context(): + return { + "document_id": "table-1", + "file_name": "phase7-private-source.csv", + "source_hint": "workspace", + "source_version": "etag-table-1", + "storage_locator": { + "container": "private-documents", + "blob_path": "user-1/private/phase7-private-source.csv", + }, + } + + +def load_public_rollout_normalizer(): + source = EXPORT_MODULE.read_text(encoding="utf-8") + tree = ast.parse(source, filename=str(EXPORT_MODULE)) + selected_nodes = [ + node + for node in tree.body + if isinstance(node, ast.FunctionDef) and node.name == "_normalize_tabular_run_rollout_assignment" + ] + assert_equal(len(selected_nodes), 1, "loaded rollout normalizer") + + def safe_int(value, default=0, minimum=None, maximum=None): + try: + normalized_value = int(value) + except (TypeError, ValueError): + normalized_value = int(default) + if minimum is not None: + normalized_value = max(int(minimum), normalized_value) + if maximum is not None: + normalized_value = min(int(maximum), normalized_value) + return normalized_value + + namespace = {"_safe_int": safe_int} + exec(compile(ast.Module(body=selected_nodes, type_ignores=[]), str(EXPORT_MODULE), "exec"), namespace) + return namespace["_normalize_tabular_run_rollout_assignment"] + + +def test_rollout_state_normalization_and_assignment_reasons(): + """Rollout states must be deterministic, safe, and assignment-gating.""" + print("Testing Phase 7 rollout state assignment gates...") + assert_app_version_at_least(IMPLEMENTED_VERSION) + + base_settings = { + "tabular_request_planner_mode": "active", + "tabular_analyze_parity_rollout_percent": 100, + "tabular_legacy_post_tool_fallback_mode": "observe", + } + active_assignment = build_tabular_parity_rollout_assignment( + base_settings, + request_key="stable-phase7-request", + mode="analyze", + ) + repeated_assignment = build_tabular_parity_rollout_assignment( + base_settings, + request_key="stable-phase7-request", + mode="analyze", + ) + paused_assignment = build_tabular_parity_rollout_assignment( + {**base_settings, "tabular_analyze_parity_rollout_state": "paused"}, + request_key="stable-phase7-request", + mode="analyze", + ) + rollback_assignment = build_tabular_parity_rollout_assignment( + {**base_settings, "tabular_analyze_parity_rollout_state": "rollback"}, + request_key="stable-phase7-request", + mode="analyze", + ) + excluded_assignment = build_tabular_parity_rollout_assignment( + {**base_settings, "tabular_analyze_parity_rollout_percent": 0}, + request_key="stable-phase7-request", + mode="analyze", + ) + + assert_equal(active_assignment, repeated_assignment, "stable active assignment") + assert_equal(active_assignment["rollout_state"], "active", "active rollout state") + assert_true(active_assignment["assigned"], "active rollout assignment") + assert_equal(active_assignment["assignment_reason_code"], "assigned", "active reason") + assert_equal(paused_assignment["assignment_reason_code"], "rollout_paused", "paused reason") + assert_false(paused_assignment["assigned"], "paused assignment") + assert_equal(rollback_assignment["assignment_reason_code"], "rollback_active", "rollback reason") + assert_false(rollback_assignment["assigned"], "rollback assignment") + assert_equal(excluded_assignment["assignment_reason_code"], "outside_rollout_cohort", "cohort reason") + assert_false(excluded_assignment["assigned"], "cohort assignment") + assert_equal(normalize_tabular_parity_rollout_state(state="invalid"), "active", "invalid default") + + serialized_assignment = str(rollback_assignment) + assert_false("phase7-private-source.csv" in serialized_assignment, "no filenames in assignment") + assert_false("blob_path" in serialized_assignment, "no blob paths in assignment") + + +def test_rollback_state_declines_new_execution_without_calling_executor(): + """Rollback must stop new durable assignment before side effects.""" + print("Testing Phase 7 rollback execution gate...") + calls = [] + + def fake_durable_executor(plan, **execution_context): + calls.append({"plan": plan, "execution_context": execution_context}) + return { + "export_run_id": "run-phase7", + "status": "queued", + "task_type": plan["durable_task_type"], + } + + result = orchestrate_tabular_request( + "Analyze every row and create a CSV file with one output row per source row.", + [build_context()], + action_mode="analyze", + caller="analyze", + settings={ + "enable_tabular_hierarchical_analysis": True, + "tabular_request_planner_mode": "active", + "tabular_analyze_parity_rollout_percent": 100, + "tabular_analyze_parity_rollout_state": "rollback", + }, + planner_mode="active", + durable_execution_callback=fake_durable_executor, + ) + + assert_equal(calls, [], "durable executor calls") + assert_equal(result["execution_state"], "declined", "execution state") + assert_equal(result["reason_code"], "rollout_not_assigned", "execution reason") + assert_equal(result["rollout_assignment"]["rollout_state"], "rollback", "rollout state") + assert_equal( + result["rollout_assignment"]["assignment_reason_code"], + "rollback_active", + "assignment reason", + ) + assert_equal(result["generated_output_metadata"], None, "generated metadata") + + +def test_rollout_state_is_backend_only_and_status_safe(): + """Rollback settings must stay backend-only and public status metadata-safe.""" + print("Testing Phase 7 backend setting and public status safety...") + settings_source = SETTINGS_MODULE.read_text(encoding="utf-8") + assert_true("'tabular_analyze_parity_rollout_state'" in settings_source, "default rollout state setting") + backend_key_start = settings_source.index("TABULAR_GENERATION_BACKEND_SETTING_KEYS = {") + sanitizer_start = settings_source.index("def sanitize_settings_for_user") + assert_true( + "'tabular_analyze_parity_rollout_state'" in settings_source[backend_key_start:sanitizer_start], + "backend-only rollout state setting", + ) + + normalize_public_assignment = load_public_rollout_normalizer() + public_assignment = normalize_public_assignment({ + "contract_version": "tabular-parity-rollout-v1", + "mode": "analyze", + "planner_mode": "active", + "rollout_state": "rollback", + "assigned": False, + "assignment_reason_code": "rollback_active", + "cohort_bucket": 7, + "rollout_percent": 100, + "legacy_post_tool_fallback_mode": "observe", + "prompt": "do not echo", + "file_name": "phase7-private-source.csv", + "blob_path": "user-1/private/phase7-private-source.csv", + }) + + assert_equal(public_assignment["rollout_state"], "rollback", "public rollout state") + assert_equal(public_assignment["assignment_reason_code"], "rollback_active", "public reason") + serialized_public_assignment = str(public_assignment) + for forbidden_value in ( + "do not echo", + "phase7-private-source.csv", + "user-1/private/phase7-private-source.csv", + "blob_path", + ): + assert_false(forbidden_value in serialized_public_assignment, f"redacted {forbidden_value}") + + +def run_tests(): + tests = [ + test_rollout_state_normalization_and_assignment_reasons, + test_rollback_state_declines_new_execution_without_calling_executor, + test_rollout_state_is_backend_only_and_status_safe, + ] + results = [] + for test in tests: + print(f"\nRunning {test.__name__}...") + try: + test() + results.append(True) + print(f"PASS {test.__name__}") + except Exception as exc: + print(f"FAIL {test.__name__}: {exc}") + traceback.print_exc() + results.append(False) + print(f"\nResults: {sum(results)}/{len(results)} tests passed") + return all(results) + + +if __name__ == "__main__": + sys.exit(0 if run_tests() else 1) diff --git a/functional_tests/test_analyze_deliverable_contract.py b/functional_tests/test_analyze_deliverable_contract.py new file mode 100644 index 000000000..feefb9e66 --- /dev/null +++ b/functional_tests/test_analyze_deliverable_contract.py @@ -0,0 +1,380 @@ +#!/usr/bin/env python3 +# test_analyze_deliverable_contract.py +""" +Functional test for Analyze deliverable contract baselines. +Version: 0.250.185 +Implemented in: 0.250.171; multi-format durable admission updated in 0.250.180; generated-output Analyze routing updated in 0.250.184 + +This test ensures Phase 1 defines a versioned Analyze deliverable contract, +keeps Analyze Markdown distinct from requested structured siblings, and uses +a deterministic 200-row oracle to detect source passthrough, lineage leakage, +schema drift, ordering failures, and known rule mismatches. +""" + +import json +import sys +from pathlib import Path +from unittest.mock import patch + +from test_support.analyze_deliverable_contract_fixture import ( + FINANCIAL_REVIEW_OUTPUT_COLUMNS, + FINANCIAL_REVIEW_PROMPT, + KNOWN_FAULTY_SEARCH_VALUE_MISMATCHES, + build_expected_financial_review_output_rows, + build_faulty_search_output_rows, + build_financial_review_source_rows, + build_source_shaped_analyze_output_rows, + find_value_mismatches, +) +from test_support.versioning import assert_app_version_at_least + + +ROOT = Path(__file__).resolve().parents[1] +APP_ROOT = ROOT / "application" / "single_app" +if str(APP_ROOT) not in sys.path: + sys.path.insert(0, str(APP_ROOT)) + +from functions_analysis_deliverables import ( # noqa: E402 + ANALYSIS_ARTIFACT_ROLE_PRIMARY_ANALYSIS, + ANALYSIS_ARTIFACT_ROLE_REQUESTED_OUTPUT, + ANALYSIS_DELIVERABLE_EVENT_PLANNED, + ANALYSIS_ORDERING_SOURCE_ORDER, + ANALYSIS_ROW_CARDINALITY_ONE_PER_SOURCE_ROW, + ANALYSIS_TRANSFORMATION_MODE_DETERMINISTIC, + ANALYSIS_VALIDATION_PROFILE_EXACT_ROWS_SCHEMA_AND_RULES, + build_analysis_deliverable_contract, + build_safe_analysis_deliverable_event_properties, + coerce_analysis_deliverable_contract, + emit_analysis_deliverable_contract_event, + normalize_analysis_artifact_role, + validate_analysis_artifact_set, + validate_structured_deliverable_rows, +) +from functions_tabular_orchestration import get_tabular_generated_output_task_type, plan_tabular_request # noqa: E402 + + +IMPLEMENTED_VERSION = "0.250.171" + + +def _build_fixture_contract(action_mode="analyze"): + return build_analysis_deliverable_contract( + action_mode=action_mode, + requested_output_format="csv", + public_output_schema=FINANCIAL_REVIEW_OUTPUT_COLUMNS, + row_cardinality=ANALYSIS_ROW_CARDINALITY_ONE_PER_SOURCE_ROW, + ordering=ANALYSIS_ORDERING_SOURCE_ORDER, + transformation_mode=ANALYSIS_TRANSFORMATION_MODE_DETERMINISTIC, + validation_profile=ANALYSIS_VALIDATION_PROFILE_EXACT_ROWS_SCHEMA_AND_RULES, + source_fingerprint="fixture-source-fingerprint", + request_fingerprint="fixture-request-fingerprint", + ) + + +def test_contract_roles_and_serialization_round_trip(): + print("Testing Analyze deliverable contract roles and serialization...") + assert_app_version_at_least(IMPLEMENTED_VERSION) + + analyze_contract = _build_fixture_contract("analyze") + analyze_payload = analyze_contract.to_dict() + assert analyze_payload["analysis_required"] is True + assert analyze_payload["primary_artifact_role"] == ANALYSIS_ARTIFACT_ROLE_PRIMARY_ANALYSIS + assert [artifact["role"] for artifact in analyze_payload["requested_artifacts"]] == [ + ANALYSIS_ARTIFACT_ROLE_PRIMARY_ANALYSIS, + ANALYSIS_ARTIFACT_ROLE_REQUESTED_OUTPUT, + ] + assert [artifact["format"] for artifact in analyze_payload["requested_artifacts"]] == ["md", "csv"] + + search_contract = _build_fixture_contract("search") + search_payload = search_contract.to_dict() + assert search_payload["analysis_required"] is False + assert search_payload["primary_artifact_role"] == "" + assert [artifact["role"] for artifact in search_payload["requested_artifacts"]] == [ + ANALYSIS_ARTIFACT_ROLE_REQUESTED_OUTPUT, + ] + assert [artifact["format"] for artifact in search_payload["requested_artifacts"]] == ["csv"] + assert search_payload["public_output_schema"] == analyze_payload["public_output_schema"] + + serialized = json.dumps(analyze_payload, sort_keys=True) + reloaded = coerce_analysis_deliverable_contract(json.loads(serialized)) + assert reloaded.to_dict() == analyze_payload + + payload_with_future_field = dict(analyze_payload) + payload_with_future_field["future_additive_field"] = "ignored" + assert coerce_analysis_deliverable_contract(payload_with_future_field).to_dict() == analyze_payload + + try: + normalize_analysis_artifact_role("not_a_role") + except ValueError: + pass + else: + raise AssertionError("Unknown artifact role was not rejected") + + +def test_artifact_set_requires_markdown_and_requested_sibling(): + print("Testing Analyze artifact set validation...") + assert_app_version_at_least(IMPLEMENTED_VERSION) + + contract = _build_fixture_contract("analyze") + missing_markdown = validate_analysis_artifact_set( + contract, + artifacts=[{ + "artifact_id": "requested-csv", + "role": ANALYSIS_ARTIFACT_ROLE_REQUESTED_OUTPUT, + "format": "csv", + "status": "completed", + }], + ) + assert not missing_markdown.valid + assert "missing_required_artifact" in missing_markdown.reason_codes + assert "wrong_primary_artifact_role" in missing_markdown.reason_codes + + wrong_descriptor = validate_analysis_artifact_set( + contract, + artifacts=[ + { + "artifact_id": "analysis", + "role": ANALYSIS_ARTIFACT_ROLE_REQUESTED_OUTPUT, + "format": "csv", + "status": "completed", + }, + { + "artifact_id": "requested-csv", + "role": ANALYSIS_ARTIFACT_ROLE_REQUESTED_OUTPUT, + "format": "csv", + "status": "completed", + }, + ], + ) + assert not wrong_descriptor.valid + assert "artifact_role_mismatch" in wrong_descriptor.reason_codes + assert "artifact_format_mismatch" in wrong_descriptor.reason_codes + + valid_set = validate_analysis_artifact_set( + contract, + artifacts=[ + { + "artifact_id": "analysis", + "role": ANALYSIS_ARTIFACT_ROLE_PRIMARY_ANALYSIS, + "format": "md", + "status": "completed", + }, + { + "artifact_id": "requested-csv", + "role": ANALYSIS_ARTIFACT_ROLE_REQUESTED_OUTPUT, + "format": "csv", + "status": "completed", + }, + ], + ) + assert valid_set.valid + assert valid_set.counts["required_artifact_count"] == 2 + assert valid_set.counts["primary_artifact_count"] == 1 + + +def test_financial_review_fixture_rejects_observed_failure_shapes(): + print("Testing 200-row fixture oracle against observed failure shapes...") + assert_app_version_at_least(IMPLEMENTED_VERSION) + + source_rows = build_financial_review_source_rows() + expected_rows = build_expected_financial_review_output_rows(source_rows) + assert len(source_rows) == 200 + assert len(expected_rows) == 200 + assert list(expected_rows[0].keys()) == FINANCIAL_REVIEW_OUTPUT_COLUMNS + assert "2026-08-12" in FINANCIAL_REVIEW_PROMPT + + contract = _build_fixture_contract("analyze") + source_passthrough_rows = build_source_shaped_analyze_output_rows(source_rows) + passthrough_report = validate_structured_deliverable_rows( + contract, + source_passthrough_rows, + source_rows=source_rows, + expected_rows=expected_rows, + identity_field="Item_ID", + ) + assert not passthrough_report.valid + assert "schema_mismatch" in passthrough_report.reason_codes + assert passthrough_report.counts["output_row_count"] == 200 + assert passthrough_report.counts["actual_schema_field_count"] == 10 + assert passthrough_report.counts["public_schema_field_count"] == 9 + + faulty_search_rows = build_faulty_search_output_rows(expected_rows, include_lineage=True) + faulty_report = validate_structured_deliverable_rows( + contract, + faulty_search_rows, + source_rows=source_rows, + expected_rows=expected_rows, + identity_field="Item_ID", + ) + assert not faulty_report.valid + assert "extra_internal_fields" in faulty_report.reason_codes + assert "deterministic_value_mismatch" in faulty_report.reason_codes + assert faulty_report.counts["extra_internal_field_count"] == 2 + assert faulty_report.counts["deterministic_mismatch_count"] == 5 + + mismatches = find_value_mismatches( + expected_rows, + faulty_search_rows, + field_names=FINANCIAL_REVIEW_OUTPUT_COLUMNS, + ) + assert [(item["identity"], item["field"], item["actual"]) for item in mismatches] == [ + (item_id, field_name, actual_value) + for item_id, field_name, actual_value in KNOWN_FAULTY_SEARCH_VALUE_MISMATCHES + ] + + +def test_planner_attaches_shadow_deliverable_contract(): + print("Testing shared planner deliverable contract attachment...") + assert_app_version_at_least(IMPLEMENTED_VERSION) + + plan = plan_tabular_request( + FINANCIAL_REVIEW_PROMPT + " Download the result as CSV.", + [{"file_name": "financial_review.csv", "document_id": "doc-1", "source_version": "v1"}], + action_mode="analyze", + settings={"enable_tabular_hierarchical_analysis": True}, + requested_output_hints={"public_output_schema": FINANCIAL_REVIEW_OUTPUT_COLUMNS}, + ) + deliverable_contract = plan["deliverable_contract"] + assert deliverable_contract["analysis_required"] is True + assert deliverable_contract["public_output_schema"] == FINANCIAL_REVIEW_OUTPUT_COLUMNS + assert [artifact["format"] for artifact in deliverable_contract["requested_artifacts"]] == ["md", "csv"] + assert plan["durable_task_type"] == "combined" + assert plan["execution_contract"] == "combined" + + +def test_phase2_planner_normalizes_ordered_artifact_intent(): + print("Testing Phase 2 normalized Analyze artifact intent...") + assert_app_version_at_least("0.250.172") + + csv_plan = plan_tabular_request( + "Analyze every row and create a CSV artifact.", + [{"file_name": "financial_review.csv", "document_id": "doc-1", "source_version": "v1"}], + action_mode="analyze", + settings={"enable_tabular_hierarchical_analysis": True}, + ) + assert csv_plan["requested_output_formats"] == ["csv"] + assert csv_plan["durable_task_type"] == "combined" + assert [artifact["format"] for artifact in csv_plan["deliverable_contract"]["requested_artifacts"]] == [ + "md", + "csv", + ] + + multi_plan = plan_tabular_request( + "Analyze every row and export as JSON, then create XML too. Do not create CSV.", + [{"file_name": "financial_review.csv", "document_id": "doc-1", "source_version": "v1"}], + action_mode="analyze", + settings={"enable_tabular_hierarchical_analysis": True}, + ) + assert_app_version_at_least("0.250.180") + assert multi_plan["requested_output_formats"] == ["json", "xml"] + assert [artifact["format"] for artifact in multi_plan["deliverable_contract"]["requested_artifacts"]] == [ + "md", + "json", + "xml", + ] + assert multi_plan["durable_task_type"] == "combined" + assert multi_plan["execution_contract"] == "combined" + assert multi_plan["execution_state"] == "declined" + assert multi_plan["reason_code"] == "durable_intent" + + generated_output_analyze_plan = plan_tabular_request( + "Analyze every row and create a CSV artifact.", + [{"file_name": "financial_review.csv", "document_id": "doc-1", "source_version": "v1"}], + action_mode="analyze", + settings={"enable_tabular_hierarchical_analysis": False}, + ) + assert generated_output_analyze_plan["durable_task_type"] == "combined" + assert generated_output_analyze_plan["execution_contract"] == "combined" + assert generated_output_analyze_plan["execution_state"] == "declined" + assert generated_output_analyze_plan["reason_code"] == "durable_intent" + assert get_tabular_generated_output_task_type( + True, + False, + {"enable_tabular_hierarchical_analysis": False}, + action_mode="analyze", + ) == "combined" + assert get_tabular_generated_output_task_type( + True, + False, + {"enable_tabular_hierarchical_analysis": False}, + action_mode="search", + ) == "structured_export" + + search_markdown_plan = plan_tabular_request( + "Search every row and write a Markdown report.", + [{"file_name": "financial_review.csv", "document_id": "doc-1", "source_version": "v1"}], + action_mode="search", + settings={"enable_tabular_hierarchical_analysis": True}, + ) + assert search_markdown_plan["generated_output_requested"] is False + assert [artifact["format"] for artifact in search_markdown_plan["deliverable_contract"]["requested_artifacts"]] == [ + "md", + ] + + +def test_safe_deliverable_telemetry_excludes_prompt_and_row_values(): + print("Testing deliverable contract telemetry sanitization...") + assert_app_version_at_least(IMPLEMENTED_VERSION) + + contract = _build_fixture_contract("analyze") + report = validate_structured_deliverable_rows( + contract, + build_faulty_search_output_rows(include_lineage=True), + source_rows=build_financial_review_source_rows(), + expected_rows=build_expected_financial_review_output_rows(), + identity_field="Item_ID", + ) + properties = build_safe_analysis_deliverable_event_properties( + ANALYSIS_DELIVERABLE_EVENT_PLANNED, + contract=contract, + validation_report=report, + dimensions={"error_type": "ValueError: secret/path/financial_review.csv"}, + ) + serialized = str(properties) + assert "FRI-062" not in serialized + assert "High Attention" not in serialized + assert "financial_review" not in serialized + assert "secret/path" not in serialized + assert properties["dimension_error_type"] == "valueerror" + assert properties["deterministic_mismatch_count"] == 5 + assert properties["extra_internal_field_count"] == 2 + + assert emit_analysis_deliverable_contract_event( + {}, + ANALYSIS_DELIVERABLE_EVENT_PLANNED, + contract=contract, + ) is None + with patch("functions_analysis_deliverables.log_event") as log_event_mock: + emitted = emit_analysis_deliverable_contract_event( + { + "enable_analysis_deliverable_contract_telemetry": True, + "analysis_deliverable_contract_mode": "shadow", + }, + ANALYSIS_DELIVERABLE_EVENT_PLANNED, + contract=contract, + validation_report=report, + ) + assert emitted is not None + assert log_event_mock.called + + +if __name__ == "__main__": + tests = [ + test_contract_roles_and_serialization_round_trip, + test_artifact_set_requires_markdown_and_requested_sibling, + test_financial_review_fixture_rejects_observed_failure_shapes, + test_planner_attaches_shadow_deliverable_contract, + test_phase2_planner_normalizes_ordered_artifact_intent, + test_safe_deliverable_telemetry_excludes_prompt_and_row_values, + ] + results = [] + for test in tests: + print(f"\nRunning {test.__name__}...") + try: + test() + results.append(True) + except Exception as exc: + print(f"Test failed: {exc}") + results.append(False) + passed_count = sum(1 for result in results if result) + print(f"\nResults: {passed_count}/{len(results)} tests passed") + sys.exit(0 if all(results) else 1) diff --git a/functional_tests/test_assistant_table_csv_artifact.py b/functional_tests/test_assistant_table_csv_artifact.py index 344a9aeb4..93996a06d 100644 --- a/functional_tests/test_assistant_table_csv_artifact.py +++ b/functional_tests/test_assistant_table_csv_artifact.py @@ -2,8 +2,8 @@ #!/usr/bin/env python3 """ Functional test for assistant-rendered table CSV artifacts. -Version: 0.250.112 -Implemented in: 0.241.050; non-tabular document CSV parsing in 0.250.065; generated file export framework in 0.250.072; updated in 0.250.073; linear fence parsing coverage in 0.250.112 +Version: 0.250.178 +Implemented in: 0.241.050; non-tabular document CSV parsing in 0.250.065; generated file export framework in 0.250.072; updated in 0.250.073; linear fence parsing coverage in 0.250.112; version assertion compatibility updated in 0.250.172; Word function-result serialization restored in 0.250.178 This test ensures that explicit table-format requests with assistant-rendered tables, including CSV rows extracted from non-tabular documents, are converted @@ -18,14 +18,18 @@ import traceback from pathlib import Path +from test_support.versioning import assert_app_version_at_least + ROOT = Path(__file__).resolve().parents[1] APP_DIR = ROOT / 'application' / 'single_app' CONFIG_FILE = APP_DIR / 'config.py' CHAT_ROUTE_FILE = APP_DIR / 'route_backend_chats.py' BACKGROUND_EXPORT_FILE = APP_DIR / 'functions_tabular_generated_exports.py' +GENERATED_EXPORTS_FILE = APP_DIR / 'functions_generated_file_exports.py' +TABULAR_ORCHESTRATION_FILE = APP_DIR / 'functions_tabular_orchestration.py' WORKFLOW_RUNNER_FILE = APP_DIR / 'functions_workflow_runner.py' -EXPECTED_VERSION = '0.250.112' +IMPLEMENTED_VERSION = '0.250.112' sys.path.append(str(APP_DIR)) @@ -297,6 +301,7 @@ def test_tabular_action_result_does_not_bypass_coverage_aware_exports(): def test_function_results_render_docx_and_pdf_capabilities(): print('Testing DOCX and PDF function-result export capabilities...') + assert_app_version_at_least('0.250.178') function_results = [{ 'plugin_name': 'DirectoryPlugin', @@ -323,6 +328,35 @@ def test_function_results_render_docx_and_pdf_capabilities(): assert_true(pdf_export is not None and pdf_export['file_content'].startswith(b'%PDF'), 'Expected a PDF file export.') assert_true(docx_export['row_source'] == 'structured function result', 'Expected DOCX to include function-result rows.') assert_true(pdf_export['row_source'] == 'structured function result', 'Expected PDF to include function-result rows.') + assert_true( + docx_export.get('passthrough_reason_code') == 'explicit_format_conversion', + 'Expected Word function-result serialization to record its explicit format-conversion contract.', + ) + assert_true( + pdf_export.get('passthrough_reason_code') == 'explicit_format_conversion', + 'Expected PDF function-result serialization to record its explicit format-conversion contract.', + ) + + +def test_derived_word_export_does_not_serialize_function_rows(): + print('Testing derived Word export function-result exclusion...') + assert_app_version_at_least('0.250.178') + + export_payload = build_generated_file_export( + 'create a Word document from the action results and classify each person by risk', + 'The directory action completed successfully.', + function_results=[{ + 'plugin_name': 'DirectoryPlugin', + 'function_name': 'list_people', + 'success': True, + 'function_result': {'value': [{'Name': 'Ada', 'Department': 'Engineering'}]}, + }], + ) + + assert_true(export_payload is not None, 'Expected the assistant response to remain exportable as a Word document.') + assert_true(export_payload['row_source'] == 'assistant response', 'Expected derived function rows to remain excluded.') + assert_true(export_payload['row_count'] == 0, 'Expected no untransformed function rows in the derived Word export.') + assert_true('passthrough_reason_code' not in export_payload, 'Expected no passthrough claim for a derived request.') def test_plain_document_csv_response_excludes_surrounding_prose_and_citation(): @@ -1030,10 +1064,9 @@ def test_workflow_generated_file_artifacts_reuse_shared_contract(): def test_chat_route_wires_assistant_table_artifacts(): print('Testing chat route assistant-table artifact plumbing...') - current_version = read_current_version() chat_route_content = read_text(CHAT_ROUTE_FILE) - assert_true(current_version == EXPECTED_VERSION, f'Expected config.py version {EXPECTED_VERSION}.') + assert_app_version_at_least(IMPLEMENTED_VERSION) assert_true( 'assistant_table_export_requested' in chat_route_content, 'Expected route_backend_chats.py to reuse the shared assistant table export intent predicate.', @@ -1067,7 +1100,8 @@ def test_chat_route_wires_assistant_table_artifacts(): 'Expected normal and streaming assistant messages to include generated file artifacts.', ) assert_true( - 'assistant_content=get_generated_file_export_content(execution_result)' in chat_route_content, + 'document_action_reply_content = get_generated_file_export_content(execution_result)' in chat_route_content + and 'assistant_content=document_action_reply_content' in chat_route_content, 'Expected document-action file exports to use the structured analysis reply when available.', ) assert_true( @@ -1075,10 +1109,12 @@ def test_chat_route_wires_assistant_table_artifacts(): 'Expected workflow file exports to use the structured analysis reply when available.', ) assert_true( - chat_route_content.count('build_generated_file_output_guidance(user_message)') == 2, + chat_route_content.count('build_generated_file_output_guidance(') == 2, 'Expected normal and streaming Chat to apply the same file-output guidance.', ) workflow_runner_content = read_text(WORKFLOW_RUNNER_FILE) + generated_exports_content = read_text(GENERATED_EXPORTS_FILE) + tabular_orchestration_content = read_text(TABULAR_ORCHESTRATION_FILE) assert_true( workflow_runner_content.count('build_generated_file_output_guidance(prompt_text)') == 2, 'Expected workflow model and agent execution to apply the same file-output guidance.', @@ -1096,11 +1132,11 @@ def test_chat_route_wires_assistant_table_artifacts(): 'Expected workflows to pass current-turn action results to generated-file exports.', ) assert_true( - 'if assistant_table_export_requested(user_question):' in chat_route_content, + 'if not assistant_table_export_requested(user_question):' in generated_exports_content, 'Expected tabular output format detection to use the shared CSV/table intent predicate.', ) assert_true( - "requested_format == 'csv'" in chat_route_content, + 'return get_requested_structured_artifact_formats(user_question)' in tabular_orchestration_content, 'Expected CSV request markers to create tabular generated outputs when available.', ) @@ -1115,6 +1151,7 @@ def run_tests() -> bool: test_structured_action_results_combine_and_preserve_assistant_priority, test_tabular_action_result_does_not_bypass_coverage_aware_exports, test_function_results_render_docx_and_pdf_capabilities, + test_derived_word_export_does_not_serialize_function_rows, test_plain_document_csv_response_excludes_surrounding_prose_and_citation, test_document_csv_response_preserves_multiline_and_escaped_quotes, test_fenced_document_csv_preserves_sentence_shaped_rows, diff --git a/functional_tests/test_data_management_migration_recovery.py b/functional_tests/test_data_management_migration_recovery.py index de73f6a0d..0dda101cd 100644 --- a/functional_tests/test_data_management_migration_recovery.py +++ b/functional_tests/test_data_management_migration_recovery.py @@ -1,9 +1,9 @@ # test_data_management_migration_recovery.py """ Functional test for Data Management migration recovery scheduling. -Version: 0.250.076 +Version: 0.250.185 Implemented in: 0.250.071 -Updated in: 0.250.076 +Updated in: 0.250.076; scheduler request-context submission guard added in 0.250.185 This test ensures delayed queued and stale migration jobs are resubmitted to the executor, including when scheduled backup processing is disabled and @@ -65,7 +65,7 @@ def load_data_management_module(monkeypatch, job_container): """Load production recovery helpers with in-memory Cosmos dependencies.""" config_module = types.ModuleType("config") config_module.CLIENTS = {} - config_module.VERSION = "0.250.076" + config_module.VERSION = "0.250.185" config_module.cosmos_data_management_jobs_container = job_container config_module.cosmos_data_management_job_items_container = job_container config_module.cosmos_settings_container = job_container @@ -126,6 +126,7 @@ def test_recovery_resubmits_delayed_queued_and_stale_migrations(monkeypatch): ) job_container = FakeJobContainer([queued_job, stale_job]) module = load_data_management_module(monkeypatch, job_container) + monkeypatch.setattr(module, "has_request_context", lambda: True) monkeypatch.setattr(module, "_record_data_management_job_event", lambda *_args, **_kwargs: None) executor = FakeExecutor() @@ -144,6 +145,33 @@ def test_recovery_resubmits_delayed_queued_and_stale_migrations(monkeypatch): assert job_container.jobs[stale_job["id"]]["recovery_attempt_count"] == 1 +def test_background_submission_without_request_context_uses_worker_thread(monkeypatch): + """Validate scheduler submissions avoid request-context-copying executor APIs.""" + module = load_data_management_module(monkeypatch, FakeJobContainer([])) + monkeypatch.setattr(module, "has_request_context", lambda: False) + executor = FakeExecutor() + started_threads = [] + + class FakeThread: + def __init__(self, **kwargs): + self.kwargs = kwargs + + def start(self): + started_threads.append(self.kwargs) + + monkeypatch.setattr(module, "Thread", FakeThread) + + submitted = module.submit_data_management_job( + FakeApp(executor), + "11111111-2222-3333-4444-555555555555", + ) + + assert submitted is True + assert executor.submissions == [] + assert len(started_threads) == 1 + assert started_threads[0]["kwargs"] == {"job_id": "11111111-2222-3333-4444-555555555555"} + + def test_recovery_runs_when_scheduled_backups_are_disabled(monkeypatch): """Validate disabled backup schedules do not suppress migration recovery.""" module = load_data_management_module(monkeypatch, FakeJobContainer([])) @@ -168,4 +196,4 @@ def test_background_scheduler_receives_flask_app_for_executor_recovery(): assert "def run_data_management_scheduler_loop(app=None):" in background_source assert "check_due_data_management_jobs_once(app=app)" in background_source assert "lambda: run_data_management_scheduler_loop(app=app)" in background_source - assert "start_background_task_threads(app=app)" in app_source \ No newline at end of file + assert "start_background_task_threads(app=app)" in app_source diff --git a/functional_tests/test_document_analysis_lossless_artifacts.py b/functional_tests/test_document_analysis_lossless_artifacts.py index a63b27eb5..fdc41c1c7 100644 --- a/functional_tests/test_document_analysis_lossless_artifacts.py +++ b/functional_tests/test_document_analysis_lossless_artifacts.py @@ -2,19 +2,21 @@ # test_document_analysis_lossless_artifacts.py """ Functional test for document analysis lossless artifacts. -Version: 0.250.154 +Version: 0.250.172 Implemented in: 0.241.040 Updated in: 0.241.065 Updated in: 0.241.197 Updated in: 0.250.065 Updated in: 0.250.112 Updated in: 0.250.154 +Updated in: 0.250.172 This test ensures exhaustive/table-style document analysis preserves raw window outputs and can build both structured CSV rows and Markdown raw-note artifacts instead of relying only on the reduced final answer. It also ensures primary tabular generated exports suppress redundant analysis JSON/Markdown cards, and -that JSON artifacts are only created when the prompt explicitly requests JSON. +that explicitly requested JSON artifacts are siblings of the required Markdown +analysis artifact. """ import ast @@ -461,15 +463,22 @@ def fake_upload_generated_artifact(**kwargs): conversation_id='conversation-1', ) - assert_equal(len(uploaded_artifacts), 1, 'explicit JSON upload count') - assert_equal(uploaded_artifacts[0]['output_format'], 'json', 'explicit JSON artifact format') + assert_equal(len(uploaded_artifacts), 2, 'explicit JSON upload count') assert_equal( - uploaded_artifacts[0]['file_name'], - '14-cfr-part-91-general-operating-and-flight-rules-analysis.json', + [artifact['output_format'] for artifact in uploaded_artifacts], + ['md', 'json'], + 'explicit JSON artifact formats', + ) + assert_equal( + [artifact['file_name'] for artifact in uploaded_artifacts], + [ + '14-cfr-part-91-general-operating-and-flight-rules-analysis.md', + '14-cfr-part-91-general-operating-and-flight-rules-analysis.json', + ], 'explicit JSON artifact filename', ) explicit_assistant_reply = explicit_artifact_payload.get('assistant_reply') or '' - assert_contains(explicit_assistant_reply, 'downloadable JSON artifact', 'explicit JSON assistant reply') + assert_contains(explicit_assistant_reply, 'MD, JSON artifacts', 'explicit JSON assistant reply') print('JSON artifact opt-in behavior verified.') diff --git a/functional_tests/test_generated_artifact_lifecycle_authorization.py b/functional_tests/test_generated_artifact_lifecycle_authorization.py new file mode 100644 index 000000000..feda366c3 --- /dev/null +++ b/functional_tests/test_generated_artifact_lifecycle_authorization.py @@ -0,0 +1,302 @@ +# test_generated_artifact_lifecycle_authorization.py +#!/usr/bin/env python3 +""" +Functional test for generated artifact lifecycle authorization. +Version: 0.250.180 +Implemented in: 0.250.180 + +This test ensures staged artifact-set members are not directly downloadable or +promotable, committed members require a completed artifact-set manifest, and +legacy generated artifacts without artifact-set metadata remain compatible. +""" + +import ast +from pathlib import Path +from typing import Any, Dict, Optional +from datetime import datetime, timezone + +from test_support.versioning import assert_app_version_at_least + + +ROOT = Path(__file__).resolve().parents[1] +APP_ROOT = ROOT / "application" / "single_app" +OPERATIONS_FILE = APP_ROOT / "functions_simplechat_operations.py" +ROUTE_FILE = APP_ROOT / "route_enhanced_citations.py" +EXPORT_MODULE = APP_ROOT / "functions_tabular_generated_exports.py" +IMPLEMENTED_VERSION = "0.250.180" + + +class FakeNotFound(Exception): + pass + + +class FakeContainer: + def __init__(self, items=None): + self.items = dict(items or {}) + self.upserted = [] + + def read_item(self, item, partition_key): + del partition_key + if item not in self.items: + raise FakeNotFound(item) + return self.items[item] + + def upsert_item(self, body): + self.items[body["id"]] = body + self.upserted.append(body) + return body + + +def load_operation_helpers(conversation_item, message_item, run_item=None): + source = OPERATIONS_FILE.read_text(encoding="utf-8") + tree = ast.parse(source, filename=str(OPERATIONS_FILE)) + helper_names = { + "_safe_positive_int", + "_build_generated_chat_artifact_lifecycle_metadata", + "_build_generated_chat_artifact_lifecycle_response", + "_generated_artifact_has_lifecycle_contract", + "assert_generated_chat_artifact_is_published_for_user", + "commit_generated_chat_artifact_publication_for_user", + } + selected_nodes = [] + for node in tree.body: + if isinstance(node, ast.Assign): + assigned_names = {target.id for target in node.targets if isinstance(target, ast.Name)} + if any(name.startswith("GENERATED_CHAT_ARTIFACT_") for name in assigned_names): + selected_nodes.append(node) + elif isinstance(node, ast.FunctionDef) and node.name in helper_names: + selected_nodes.append(node) + namespace = { + "Any": Any, + "Dict": Dict, + "Optional": Optional, + "datetime": datetime, + "timezone": timezone, + "CosmosResourceNotFoundError": FakeNotFound, + "cosmos_conversations_container": FakeContainer({"conversation-1": conversation_item}), + "cosmos_messages_container": FakeContainer({"message-1": message_item}), + "cosmos_tabular_export_runs_container": FakeContainer({"run-1": run_item} if run_item else {}), + } + module = ast.Module(body=selected_nodes, type_ignores=[]) + ast.fix_missing_locations(module) + exec(compile(module, str(OPERATIONS_FILE), "exec"), namespace) + return namespace + + +def load_route_helper(message_item, publication_assertion): + source = ROUTE_FILE.read_text(encoding="utf-8") + tree = ast.parse(source, filename=str(ROUTE_FILE)) + helper = next( + node for node in tree.body + if isinstance(node, ast.FunctionDef) and node.name == "_get_authorized_chat_artifact_message" + ) + namespace = { + "CosmosResourceNotFoundError": FakeNotFound, + "cosmos_conversations_container": FakeContainer({"conversation-1": {"user_id": "user-1"}}), + "cosmos_messages_container": FakeContainer({"message-1": message_item}), + "assert_generated_chat_artifact_is_published_for_user": publication_assertion, + } + module = ast.Module(body=[helper], type_ignores=[]) + ast.fix_missing_locations(module) + exec(compile(module, str(ROUTE_FILE), "exec"), namespace) + return namespace["_get_authorized_chat_artifact_message"] + + +def load_recovery_helpers(): + source = EXPORT_MODULE.read_text(encoding="utf-8") + tree = ast.parse(source, filename=str(EXPORT_MODULE)) + helper_names = { + "_is_artifact_publication_recoverable", + "_can_resume_run", + "_can_cancel_run", + } + selected_nodes = [ + node for node in tree.body + if isinstance(node, ast.FunctionDef) and node.name in helper_names + ] + namespace = { + "TABULAR_EXPORT_STATUS_COMPLETED": "completed", + "TABULAR_EXPORT_STATUS_CANCELED": "canceled", + "TABULAR_EXPORT_STATUS_FAILED": "failed", + "TABULAR_EXPORT_STATUS_QUEUED": "queued", + "TABULAR_EXPORT_STATUS_RUNNING": "running", + "TABULAR_ARTIFACT_SET_LIFECYCLE_VALIDATING": "validating", + "TABULAR_ARTIFACT_SET_LIFECYCLE_PUBLISHING": "publishing", + "TABULAR_ARTIFACT_SET_LIFECYCLE_ROLLBACK_REQUIRED": "rollback_required", + "TABULAR_ARTIFACT_SET_LIFECYCLE_FAILED": "failed", + "_is_waiting_for_retry": lambda run: False, + "_is_due_queued_retry_run": lambda run: False, + "_is_stale_queued_run": lambda run, settings: False, + "_is_stale_running_run": lambda run, settings: False, + "_is_retryable_failed_run": lambda run: False, + "_has_exhausted_independent_batch_retries": lambda run: False, + } + module = ast.Module(body=selected_nodes, type_ignores=[]) + ast.fix_missing_locations(module) + exec(compile(module, str(EXPORT_MODULE), "exec"), namespace) + return namespace + + +def build_message(metadata): + return { + "id": "message-1", + "conversation_id": "conversation-1", + "role": "file", + "file_content_source": "blob", + "blob_container": "chat", + "blob_path": "user-1/conversation-1/generated/message-1/output.csv", + "metadata": { + "is_generated_chat_artifact": True, + **metadata, + }, + } + + +def test_lifecycle_metadata_defaults_to_staged_and_legacy_is_visible(): + assert_app_version_at_least(IMPLEMENTED_VERSION) + helpers = load_operation_helpers({"user_id": "user-1"}, build_message({})) + metadata = helpers["_build_generated_chat_artifact_lifecycle_metadata"]({ + "artifact_run_id": "run-1", + "artifact_set_id": "set-1", + "artifact_member_id": "requested-csv", + }) + assert metadata["generated_artifact_lifecycle_state"] == "staged" + assert metadata["generated_artifact_publication_generation"] == 0 + + helpers["assert_generated_chat_artifact_is_published_for_user"]("user-1", build_message({})) + + +def test_staged_artifact_is_not_published_until_manifest_commits(): + assert_app_version_at_least(IMPLEMENTED_VERSION) + staged_message = build_message({ + "generated_artifact_run_id": "run-1", + "generated_artifact_set_id": "set-1", + "generated_artifact_member_id": "requested-csv", + "generated_artifact_lifecycle_state": "staged", + "generated_artifact_validation_state": "staged", + "generated_artifact_publication_generation": 0, + }) + helpers = load_operation_helpers({"user_id": "user-1"}, staged_message) + try: + helpers["assert_generated_chat_artifact_is_published_for_user"]("user-1", staged_message) + except PermissionError: + pass + else: + raise AssertionError("Staged artifact was authorized for direct access") + + +def test_committed_artifact_requires_completed_manifest_member(): + assert_app_version_at_least(IMPLEMENTED_VERSION) + published_message = build_message({ + "generated_artifact_run_id": "run-1", + "generated_artifact_set_id": "set-1", + "generated_artifact_member_id": "requested-csv", + "generated_artifact_lifecycle_state": "published", + "generated_artifact_validation_state": "validated", + "generated_artifact_publication_generation": 2, + }) + run = { + "id": "run-1", + "user_id": "user-1", + "conversation_id": "conversation-1", + "artifact_set_manifest": { + "set_id": "set-1", + "lifecycle_state": "completed", + "validation_state": "validated", + "publication_generation": 2, + "members": [{ + "member_id": "requested-csv", + "artifact_message_id": "message-1", + "lifecycle_state": "published", + "validation_state": "validated", + }], + }, + } + helpers = load_operation_helpers({"user_id": "user-1"}, published_message, run) + helpers["assert_generated_chat_artifact_is_published_for_user"]("user-1", published_message) + + +def test_commit_updates_message_lifecycle_metadata(): + assert_app_version_at_least(IMPLEMENTED_VERSION) + staged_message = build_message({ + "generated_artifact_run_id": "run-1", + "generated_artifact_set_id": "set-1", + "generated_artifact_member_id": "analysis", + "generated_artifact_lifecycle_state": "staged", + "generated_artifact_validation_state": "staged", + "generated_artifact_publication_generation": 0, + }) + helpers = load_operation_helpers({"user_id": "user-1"}, staged_message) + committed = helpers["commit_generated_chat_artifact_publication_for_user"]( + "user-1", + "conversation-1", + "message-1", + "set-1", + "analysis", + 3, + ) + metadata = committed["metadata"] + assert metadata["generated_artifact_lifecycle_state"] == "published" + assert metadata["generated_artifact_validation_state"] == "validated" + assert metadata["generated_artifact_publication_generation"] == 3 + assert metadata["generated_artifact_committed_at"] + + +def test_route_helper_enforces_publication_gate(): + assert_app_version_at_least(IMPLEMENTED_VERSION) + calls = [] + + def deny_staged(user_id, message_item): + calls.append((user_id, message_item["id"])) + raise PermissionError("Artifact is not published") + + route_helper = load_route_helper(build_message({"generated_artifact_lifecycle_state": "staged"}), deny_staged) + try: + route_helper("user-1", "conversation-1", "message-1") + except PermissionError: + pass + else: + raise AssertionError("Route helper returned a staged artifact") + assert calls == [("user-1", "message-1")] + + +def test_failed_post_staging_runs_can_resume_or_cancel(): + assert_app_version_at_least(IMPLEMENTED_VERSION) + helpers = load_recovery_helpers() + recoverable_run = { + "status": "failed", + "publishing_started_at": "2026-08-12T00:00:00+00:00", + "artifact_set_manifest": {"lifecycle_state": "rollback_required"}, + } + completed_failed_run = { + "status": "failed", + "publishing_started_at": "2026-08-12T00:00:00+00:00", + "artifact_set_manifest": {"lifecycle_state": "completed"}, + } + running_publish_run = { + "status": "running", + "publishing_started_at": "2026-08-12T00:00:00+00:00", + "artifact_set_manifest": {"lifecycle_state": "publishing"}, + } + + assert helpers["_is_artifact_publication_recoverable"](recoverable_run) is True + assert helpers["_can_resume_run"](recoverable_run, {}) is True + assert helpers["_can_cancel_run"](recoverable_run) is True + assert helpers["_can_resume_run"](completed_failed_run, {}) is False + assert helpers["_can_cancel_run"](running_publish_run) is False + + +if __name__ == "__main__": + tests = [ + test_lifecycle_metadata_defaults_to_staged_and_legacy_is_visible, + test_staged_artifact_is_not_published_until_manifest_commits, + test_committed_artifact_requires_completed_manifest_member, + test_commit_updates_message_lifecycle_metadata, + test_route_helper_enforces_publication_gate, + test_failed_post_staging_runs_can_resume_or_cancel, + ] + for test in tests: + print(f"Running {test.__name__}...") + test() + print(f"PASS {test.__name__}") + print(f"Results: {len(tests)}/{len(tests)} tests passed") diff --git a/functional_tests/test_generated_json_xml_exports.py b/functional_tests/test_generated_json_xml_exports.py index 83b8f6865..4a3b71d0b 100644 --- a/functional_tests/test_generated_json_xml_exports.py +++ b/functional_tests/test_generated_json_xml_exports.py @@ -2,8 +2,8 @@ # test_generated_json_xml_exports.py """ Functional test for generated JSON/XML export artifacts. -Version: 0.250.156 -Implemented in: 0.250.114; completed file-export cards and View actions in 0.250.152; truthful private payload streaming in 0.250.153; shared structured-format intent terminology in 0.250.154; source-only intent guardrails in 0.250.156 +Version: 0.250.172 +Implemented in: 0.250.114; completed file-export cards and View actions in 0.250.152; truthful private payload streaming in 0.250.153; shared structured-format intent terminology in 0.250.154; source-only intent guardrails in 0.250.156; ordered artifact intent in 0.250.172 This test ensures JSON/XML generation requests are recognized as downloadable artifact workflows, reuse shared serialization helpers, avoid duplicate XML @@ -133,7 +133,7 @@ def test_chat_route_json_xml_artifact_hooks(): assert_contains(chat_source, "return _shared_get_tabular_generated_output_format(user_question)", "Chat format delegation") assert_contains( orchestration_source, - "return get_requested_structured_artifact_format(user_question)", + "return get_requested_structured_artifact_formats(user_question)", "Shared planner format delegation", ) assert_contains(chat_source, "_build_assistant_file_output_handoff", "no-inline assistant handoff builder") @@ -184,6 +184,7 @@ def load_predicates(source_file, function_names): assert len(selected_nodes) == len(function_names) namespace = { 'get_requested_structured_artifact_format': module.get_requested_structured_artifact_format, + '_shared_get_tabular_generated_output_format': module.get_requested_structured_artifact_format, } exec(compile(ast.Module(body=selected_nodes, type_ignores=[]), str(source_file), 'exec'), namespace) return namespace diff --git a/functional_tests/test_mixed_source_analyze_workflow.py b/functional_tests/test_mixed_source_analyze_workflow.py index c1c873014..8ca223f09 100644 --- a/functional_tests/test_mixed_source_analyze_workflow.py +++ b/functional_tests/test_mixed_source_analyze_workflow.py @@ -53,6 +53,8 @@ def test_phase_3_mixed_analyze_contracts_are_wired(): assert "'phase': 'complete'" in helper_source assert 'Tabular evidence could not be completed for this source.' in helper_source assert 'Narrative evidence could not be completed for this source.' in helper_source + assert 'We are analyzing the data and generating the requested file in the background.' in source + assert 'Automatic deferred composition is unavailable' not in source assert "'generated_tabular_outputs': generated_tabular_outputs" in helper_source assert "'agent_citations': tabular_agent_citations" in helper_source diff --git a/functional_tests/test_support/analyze_deliverable_contract_fixture.py b/functional_tests/test_support/analyze_deliverable_contract_fixture.py new file mode 100644 index 000000000..6d0fbac0f --- /dev/null +++ b/functional_tests/test_support/analyze_deliverable_contract_fixture.py @@ -0,0 +1,279 @@ +# analyze_deliverable_contract_fixture.py +"""Deterministic 200-row oracle for Analyze deliverable contract tests.""" + +from copy import deepcopy +from datetime import date, timedelta + + +ASSESSMENT_DATE = date(2026, 8, 12) + +FINANCIAL_REVIEW_SOURCE_COLUMNS = [ + "Item_ID", + "Review_Date", + "Due_Date", + "Invoice_Amount", + "Spend_Category", + "Vendor_Risk", + "Control_Status", + "Exception_Count", + "Owner_Response", + "Escalation_Flag", +] + +FINANCIAL_REVIEW_OUTPUT_COLUMNS = [ + "Item_ID", + "Timeline_Status", + "Spend_Risk", + "Control_Concern", + "Owner_Response_Status", + "Escalation_Required", + "Overall_Attention", + "Review_Window", + "Recommended_Action", +] + +FINANCIAL_REVIEW_PROMPT = """ +Use assessment date 2026-08-12. Create one output row per source row in source order. +Return exactly these columns: Item_ID, Timeline_Status, Spend_Risk, Control_Concern, +Owner_Response_Status, Escalation_Required, Overall_Attention, Review_Window, +Recommended_Action. + +Rules: +1. Timeline_Status is Overdue when Due_Date is before 2026-08-12, Due Soon when Due_Date + is on or before 2026-09-11, otherwise On Track. +2. Spend_Risk is High Spend Risk for Invoice_Amount >= 75000 or Vendor_Risk High, + Moderate Spend Risk for Invoice_Amount >= 25000 or Vendor_Risk Medium, otherwise Low Spend Risk. +3. Control_Concern is Control Concern when Control_Status is Missing Approval or Policy Exception, + or Exception_Count is at least 2; otherwise No Control Concern. +4. Owner_Response_Status is Responded when Owner_Response is Received; otherwise Needs Response. +5. Escalation_Required is Yes when Escalation_Flag is Y or an overdue item needs response. +6. Overall_Attention uses ordered conditions: High Attention when escalation is required, or a + control concern needs response, or spend risk is high; Monitor when timeline is not On Track, + spend risk is moderate, or a control concern exists; otherwise Low Attention. +7. Review_Window is Past Due, Due Today, Within 30 Days, or Beyond 30 Days using Due_Date. +8. Recommended_Action follows Overall_Attention first, then overdue and due-soon timelines. +""".strip() + +KNOWN_FAULTY_SEARCH_VALUE_MISMATCHES = [ + ("FRI-062", "Overall_Attention", "Monitor"), + ("FRI-073", "Overall_Attention", "Monitor"), + ("FRI-115", "Timeline_Status", "Due Soon"), + ("FRI-141", "Timeline_Status", "Due Soon"), + ("FRI-159", "Timeline_Status", "Due Soon"), +] + + +def _iso_date(days_from_assessment): + return (ASSESSMENT_DATE + timedelta(days=days_from_assessment)).isoformat() + + +def build_financial_review_source_rows(): + """Build 200 sanitized source rows with boundary and dependency cases.""" + categories = ["Software", "Travel", "Facilities", "Services", "Hardware"] + vendor_risks = ["Low", "Medium", "Low", "High"] + control_statuses = ["Complete", "Missing Approval", "Complete", "Policy Exception"] + source_rows = [] + + for item_number in range(1, 201): + due_delta = ((item_number * 7) % 96) - 20 + row = { + "Item_ID": f"FRI-{item_number:03d}", + "Review_Date": _iso_date(-(item_number % 21)), + "Due_Date": _iso_date(due_delta), + "Invoice_Amount": 8000 + ((item_number * 3100) % 98000), + "Spend_Category": categories[item_number % len(categories)], + "Vendor_Risk": vendor_risks[item_number % len(vendor_risks)], + "Control_Status": control_statuses[item_number % len(control_statuses)], + "Exception_Count": item_number % 3, + "Owner_Response": "Missing" if item_number % 5 in {0, 2} else "Received", + "Escalation_Flag": "Y" if item_number % 37 == 0 else "N", + } + source_rows.append(row) + + boundary_overrides = { + 1: {"Due_Date": _iso_date(-1)}, + 2: {"Due_Date": _iso_date(0)}, + 3: {"Due_Date": _iso_date(30)}, + 4: {"Due_Date": _iso_date(31)}, + 62: { + "Due_Date": _iso_date(45), + "Invoice_Amount": 19000, + "Vendor_Risk": "Low", + "Control_Status": "Missing Approval", + "Exception_Count": 2, + "Owner_Response": "Missing", + "Escalation_Flag": "N", + }, + 73: { + "Due_Date": _iso_date(52), + "Invoice_Amount": 22000, + "Vendor_Risk": "Low", + "Control_Status": "Policy Exception", + "Exception_Count": 1, + "Owner_Response": "Missing", + "Escalation_Flag": "N", + }, + 115: { + "Due_Date": _iso_date(31), + "Invoice_Amount": 12000, + "Vendor_Risk": "Low", + "Control_Status": "Complete", + "Exception_Count": 0, + "Owner_Response": "Received", + "Escalation_Flag": "N", + }, + 141: { + "Due_Date": _iso_date(45), + "Invoice_Amount": 18000, + "Vendor_Risk": "Low", + "Control_Status": "Complete", + "Exception_Count": 0, + "Owner_Response": "Received", + "Escalation_Flag": "N", + }, + 159: { + "Due_Date": _iso_date(60), + "Invoice_Amount": 21000, + "Vendor_Risk": "Low", + "Control_Status": "Complete", + "Exception_Count": 0, + "Owner_Response": "Received", + "Escalation_Flag": "N", + }, + } + for item_number, updates in boundary_overrides.items(): + source_rows[item_number - 1].update(updates) + + return [ + {column_name: row[column_name] for column_name in FINANCIAL_REVIEW_SOURCE_COLUMNS} + for row in source_rows + ] + + +def _timeline_status(due_date): + parsed_due_date = date.fromisoformat(due_date) + if parsed_due_date < ASSESSMENT_DATE: + return "Overdue" + if parsed_due_date <= ASSESSMENT_DATE + timedelta(days=30): + return "Due Soon" + return "On Track" + + +def _review_window(due_date): + parsed_due_date = date.fromisoformat(due_date) + if parsed_due_date < ASSESSMENT_DATE: + return "Past Due" + if parsed_due_date == ASSESSMENT_DATE: + return "Due Today" + if parsed_due_date <= ASSESSMENT_DATE + timedelta(days=30): + return "Within 30 Days" + return "Beyond 30 Days" + + +def _spend_risk(row): + amount = int(row["Invoice_Amount"]) + vendor_risk = str(row["Vendor_Risk"]) + if amount >= 75000 or vendor_risk == "High": + return "High Spend Risk" + if amount >= 25000 or vendor_risk == "Medium": + return "Moderate Spend Risk" + return "Low Spend Risk" + + +def _control_concern(row): + if row["Control_Status"] in {"Missing Approval", "Policy Exception"}: + return "Control Concern" + if int(row["Exception_Count"]) >= 2: + return "Control Concern" + return "No Control Concern" + + +def _owner_response_status(row): + return "Responded" if row["Owner_Response"] == "Received" else "Needs Response" + + +def build_expected_financial_review_output_rows(source_rows=None): + """Compute the expected nine-column output with an independent deterministic oracle.""" + expected_rows = [] + for row in list(source_rows or build_financial_review_source_rows()): + timeline_status = _timeline_status(row["Due_Date"]) + spend_risk = _spend_risk(row) + control_concern = _control_concern(row) + owner_response_status = _owner_response_status(row) + escalation_required = ( + "Yes" + if row["Escalation_Flag"] == "Y" + or (timeline_status == "Overdue" and owner_response_status == "Needs Response") + else "No" + ) + if ( + escalation_required == "Yes" + or (control_concern == "Control Concern" and owner_response_status == "Needs Response") + or spend_risk == "High Spend Risk" + ): + overall_attention = "High Attention" + elif ( + timeline_status != "On Track" + or spend_risk == "Moderate Spend Risk" + or control_concern == "Control Concern" + ): + overall_attention = "Monitor" + else: + overall_attention = "Low Attention" + + if overall_attention == "High Attention": + recommended_action = "Escalate review" + elif timeline_status == "Overdue": + recommended_action = "Review overdue item" + elif timeline_status == "Due Soon": + recommended_action = "Schedule follow-up" + else: + recommended_action = "Routine monitoring" + + expected_rows.append({ + "Item_ID": row["Item_ID"], + "Timeline_Status": timeline_status, + "Spend_Risk": spend_risk, + "Control_Concern": control_concern, + "Owner_Response_Status": owner_response_status, + "Escalation_Required": escalation_required, + "Overall_Attention": overall_attention, + "Review_Window": _review_window(row["Due_Date"]), + "Recommended_Action": recommended_action, + }) + return expected_rows + + +def build_source_shaped_analyze_output_rows(source_rows=None): + """Return the observed Analyze failure shape: unchanged source rows.""" + return deepcopy(list(source_rows or build_financial_review_source_rows())) + + +def build_faulty_search_output_rows(expected_rows=None, include_lineage=True): + """Return the observed Search failure shape with two lineage fields and five wrong values.""" + rows = deepcopy(list(expected_rows or build_expected_financial_review_output_rows())) + by_id = {row["Item_ID"]: row for row in rows} + for item_id, field_name, faulty_value in KNOWN_FAULTY_SEARCH_VALUE_MISMATCHES: + by_id[item_id][field_name] = faulty_value + if include_lineage: + for row_number, row in enumerate(rows, start=1): + row["source_row_number"] = row_number + row["source_row_identity"] = row["Item_ID"] + return rows + + +def find_value_mismatches(expected_rows, actual_rows, field_names=None, identity_field="Item_ID"): + """Return deterministic value mismatches for test assertions.""" + fields = list(field_names or FINANCIAL_REVIEW_OUTPUT_COLUMNS) + mismatches = [] + for expected_row, actual_row in zip(list(expected_rows or []), list(actual_rows or [])): + identity = expected_row.get(identity_field) + for field_name in fields: + if expected_row.get(field_name) != actual_row.get(field_name): + mismatches.append({ + "identity": identity, + "field": field_name, + "expected": expected_row.get(field_name), + "actual": actual_row.get(field_name), + }) + return mismatches diff --git a/functional_tests/test_tabular_background_generated_exports.py b/functional_tests/test_tabular_background_generated_exports.py index a2d7f4c6f..9bc234823 100644 --- a/functional_tests/test_tabular_background_generated_exports.py +++ b/functional_tests/test_tabular_background_generated_exports.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 """ Functional test for durable tabular generated-output background exports. -Version: 0.250.152 -Implemented in: 0.241.060; throughput and timeout hardening in: 0.250.070; unified durable run contract in: 0.250.128; Phase 6 rolling worker pool compatibility in: 0.250.142; safe retry reason status text in: 0.250.147; collapsed operational details in: 0.250.150; simplified completed artifact cards in: 0.250.151; balanced batches and foreground JSON/XML cards in: 0.250.152 +Version: 0.250.176 +Implemented in: 0.241.060; throughput and timeout hardening in: 0.250.070; unified durable run contract in: 0.250.128; Phase 6 rolling worker pool compatibility in: 0.250.142; safe retry reason status text in: 0.250.147; collapsed operational details in: 0.250.150; simplified completed artifact cards in: 0.250.151; balanced batches and foreground JSON/XML cards in: 0.250.152; plural artifact-set completion rendering in: 0.250.176 This test ensures that large tabular structured exports are wired through the durable background queue, status API, queued retry recovery, and chat progress @@ -181,6 +181,9 @@ async def get_chat_message_contents(self, _history, _settings): 'time': __import__('time'), '_safe_float': lambda value, default=0.0: float(value) if value is not None else default, '_is_compact_row_array_protocol': lambda _response_protocol: False, + '_build_model_expected_output_schema': ( + lambda expected_output_schema, transformation_spec=None: list(expected_output_schema or []) + ), '_build_batch_prompt': lambda *args, **kwargs: 'test prompt', } extracted_module = ast.Module(body=[helper_node], type_ignores=[]) @@ -294,6 +297,12 @@ def test_chat_ui_renders_and_polls_background_exports(): assert_contains(source_text, 'generated-artifact-view-btn', 'completed artifact View action') assert_contains(source_text, 'generated-artifact-preview-modal', 'bounded artifact preview modal') assert_contains(source_text, 'hideCompletedGeneratedArtifactHandoff', 'stale completion handoff suppression') + assert_contains(source_text, 'normalizeGeneratedArtifactSet', 'plural artifact-set normalizer') + assert_contains(source_text, 'replaceBackgroundGeneratedOutputCardWithArtifacts', 'plural completion replacement path') + assert_contains(source_text, "role === 'primary_analysis'", 'Analyze Markdown primary ordering') + assert_contains(source_text, 'generated_artifacts', 'authoritative plural status field') + assert_contains(source_text, 'simplechat:generated-artifact-set', 'safe artifact-set UI event') + assert_contains(source_text, 'Download ${fileName}', 'unique download accessible name') if 'details.open = true' in source_text: raise AssertionError('Background export operational details must remain collapsed until the user expands them') if 'generated-tabular-refresh-status-btn' in source_text or 'Refresh Status' in source_text: @@ -332,11 +341,24 @@ def test_completed_artifact_preview_is_bounded_and_ordered(): 'TABULAR_EXPORT_ARTIFACT_PREVIEW_CELL_MAX_CHARS': 12, 'TABULAR_EXPORT_OUTPUT_ROW_NUMBER_FIELD': 'source_row_number', '_safe_int': lambda value: int(value or 0), + '_get_tabular_run_serialized_public_schema': ( + lambda run: [ + field_name + for field_name in list((run or {}).get('output_schema') or []) + if field_name not in {'source_row_number', 'source_row_identity'} + ] + ), '_output_blob_path': lambda user_id, conversation_id, run_id, batch_number: f'output-{batch_number}', '_validate_tabular_output_checkpoint_metadata': ( lambda run, path, batch_number: validated_batches.append((path, batch_number)) ), '_download_json_blob': lambda path: batches[path], + 'project_structured_deliverable_row': ( + lambda entry, public_schema, require_all_fields=True: { + field_name: entry[field_name] + for field_name in public_schema + } + ), '_serialize_generated_output_value': lambda value: '' if value is None else str(value), 'build_safe_csv_headers': lambda values: list(values), 'json': __import__('json'), @@ -364,11 +386,12 @@ def test_completed_artifact_preview_is_bounded_and_ordered(): suppress_assistant_text=True, ) - assert [row['source_row_number'] for row in preview_rows] == ['1', '2', '3'] - assert preview_rows[1]['answer'] == 'xxxxxxxxx...' + assert [row['answer'] for row in preview_rows] == ['first', 'xxxxxxxxx...', 'third'] + assert 'source_row_number' not in preview_rows[0] + assert 'source_row_identity' not in preview_rows[0] assert validated_batches == [('output-1', 1), ('output-2', 2)] assert artifact['preview_rows'] == preview_rows - assert artifact['preview_columns'] == ['source_row_number', 'source_row_identity', 'answer'] + assert artifact['preview_columns'] == ['answer'] assert len(artifact['preview_text']) == 24000 assert artifact['suppress_assistant_text'] is True diff --git a/functional_tests/test_tabular_document_actions_workflow.py b/functional_tests/test_tabular_document_actions_workflow.py index 97100e5f5..5e68b2afd 100644 --- a/functional_tests/test_tabular_document_actions_workflow.py +++ b/functional_tests/test_tabular_document_actions_workflow.py @@ -2,8 +2,8 @@ # test_tabular_document_actions_workflow.py """ Functional test for tabular document-action workflow support. -Version: 0.250.070 -Implemented in: 0.241.038; mixed-source manifest coverage added in 0.250.062 +Version: 0.250.185 +Implemented in: 0.241.038; mixed-source manifest coverage added in 0.250.062; generated-output Analyze durable routing added in 0.250.184; model endpoint context added in 0.250.185 This test ensures tabular document actions reuse the shared tabular analysis path for Analyze and comparison workflows instead of relying only on the @@ -13,10 +13,12 @@ """ import ast +import asyncio import logging from pathlib import Path import sys import traceback +import types ROOT = Path(__file__).resolve().parents[1] @@ -67,6 +69,9 @@ def test_shared_tabular_document_action_helper_exists() -> None: assert 'maybe_create_tabular_generated_output(' in workflow_runner_content, ( "Expected the shared helper to reuse generated tabular export creation for workflow-backed tabular actions." ) + assert 'maybe_queue_direct_tabular_generated_output(' in workflow_runner_content, ( + "Expected the shared helper to queue direct durable tabular work when foreground analysis yields no computed results." + ) print("Shared tabular document-action helper checks passed") @@ -172,6 +177,166 @@ def test_mixed_sources_preserve_valid_tabular_partition() -> None: print("Mixed-source tabular partition checks passed") +def test_generated_output_tabular_analyze_queues_direct_output_before_foreground() -> None: + print("Testing generated-output tabular Analyze primary durable routing...") + + workflow_runner_tree = ast.parse(read_text(WORKFLOW_RUNNER_FILE)) + helper_node = next( + node + for node in workflow_runner_tree.body + if isinstance(node, ast.FunctionDef) + and node.name == "_maybe_execute_tabular_document_action" + ) + helper_module = ast.Module(body=[helper_node], type_ignores=[]) + ast.fix_missing_locations(helper_module) + + queued_calls = [] + + class FakePluginLogger: + def get_invocations_for_conversation(self, *args, **kwargs): + return [] + + def clear_invocations_for_conversation(self, *args, **kwargs): + raise AssertionError("Primary durable routing should not clear plugin invocations.") + + async def fake_run_tabular_analysis_with_thought_tracking(**kwargs): + raise AssertionError("Generated-output Analyze should queue durable work before foreground tools run.") + + async def fake_maybe_create_tabular_generated_output(**kwargs): + raise AssertionError("Primary durable routing should not build exports from empty invocations.") + + def fake_maybe_queue_direct_tabular_generated_output(**kwargs): + queued_calls.append(kwargs) + return { + "background_export": True, + "status": "queued", + "task_type": "combined", + "output_format": "csv", + "export_run_id": "run-1", + "source_file_name": "bank.csv", + } + + fake_tabular_module = types.ModuleType("functions_tabular_analysis") + fake_tabular_module.augment_tabular_invocations_with_related_document_evidence = lambda *args, **kwargs: {} + fake_tabular_module.build_tabular_related_document_evidence_summary = lambda *args, **kwargs: "" + fake_tabular_module.get_new_plugin_invocations = lambda invocations, baseline_count: [] + fake_tabular_module.maybe_create_tabular_generated_output = fake_maybe_create_tabular_generated_output + fake_tabular_module.maybe_queue_direct_tabular_generated_output = fake_maybe_queue_direct_tabular_generated_output + fake_tabular_module.plan_tabular_request = lambda *args, **kwargs: { + "action_mode": "analyze", + "durable_task_type": "combined", + "execution_contract": "combined", + "reason_code": "durable_intent", + } + fake_tabular_module.run_tabular_analysis_with_thought_tracking = fake_run_tabular_analysis_with_thought_tracking + + original_tabular_module = sys.modules.get("functions_tabular_analysis") + sys.modules["functions_tabular_analysis"] = fake_tabular_module + try: + namespace = { + "asyncio": asyncio, + "DOCUMENT_ACTION_TYPE_ANALYZE": "analyze", + "DOCUMENT_ACTION_TYPE_COMPARISON": "comparison", + "EVIDENCE_STATUS_PENDING": "pending", + "MixedSourceCancellationError": orchestration.MixedSourceCancellationError, + "TABULAR_PARITY_EVENT_FIRST_FOREGROUND_TABULAR_INVOCATION": "foreground_invocation", + "raise_if_mixed_source_cancelled": orchestration.raise_if_mixed_source_cancelled, + "is_tabular_processing_enabled": lambda settings: True, + "is_mixed_source_manifest_enabled": lambda settings: False, + "_resolve_tabular_document_action_documents": lambda *args, **kwargs: [{ + "document_id": "table-1", + "document_name": "Bank Treasury Operations Dataset", + "file_name": "bank.csv", + "scope": "personal", + "source_hint": "workspace", + "group_id": None, + "public_workspace_id": None, + }], + "_resolve_tabular_document_action_model_name": lambda workflow, settings: "gpt-4o", + "_build_workflow_model_context": lambda workflow, deployment_name, provider: { + "endpoint_id": workflow.get("model_endpoint_id"), + "model_id": workflow.get("model_id"), + "model_deployment": deployment_name, + "provider": provider, + }, + "_build_tabular_document_action_thought_callback": lambda **kwargs: None, + "_build_tabular_analysis_request_prompt": lambda *args, **kwargs: "analyze every row and produce csv", + "_build_agent_citations_from_plugin_invocations": lambda invocations: [], + "_build_tabular_analyze_durable_handoff": lambda output: "queued handoff", + "_get_pending_tabular_generated_output": lambda outputs: next( + (output for output in outputs if output.get("status") == "queued"), + None, + ), + "_get_terminal_unsuccessful_tabular_generated_output": lambda outputs: next( + (output for output in outputs if output.get("status") in {"failed", "canceled", "cancelled"}), + None, + ), + "_get_tabular_generated_output_status": lambda output: str((output or {}).get("status") or "").lower(), + "_build_tabular_document_action_coverage": lambda documents, phase_label: { + "processed_windows": len(documents), + "processed_chunks": len(documents), + "documents": [{"document_id": document.get("document_id")} for document in documents], + "progress_meta": {"phase_label": phase_label}, + }, + "classify_tabular_parity_request": lambda prompt: {"reason_code": "durable_intent"}, + "emit_tabular_parity_event": lambda *args, **kwargs: None, + "get_plugin_logger": lambda: FakePluginLogger(), + "log_event": lambda *args, **kwargs: None, + "logging": logging, + } + exec(compile(helper_module, str(WORKFLOW_RUNNER_FILE), "exec"), namespace) + helper = namespace["_maybe_execute_tabular_document_action"] + + def fail_invoke_prompt(*args, **kwargs): + raise AssertionError("Primary durable routing should not synthesize empty tabular results.") + + result = helper( + "analyze", + { + "user_id": "user-1", + "task_prompt": "Analyze all rows and create a CSV.", + "model_endpoint_id": "endpoint-1", + "model_id": "model-1", + "model_provider": "new_foundry", + }, + {"type": "analyze", "document_ids": ["table-1"], "doc_scope": "personal"}, + {"enable_tabular_processing_plugin": True}, + conversation_id="conversation-1", + invoke_prompt=fail_invoke_prompt, + ) + finally: + if original_tabular_module is None: + sys.modules.pop("functions_tabular_analysis", None) + else: + sys.modules["functions_tabular_analysis"] = original_tabular_module + + assert queued_calls, "Expected generated-output Analyze to queue a direct durable run." + assert queued_calls[0]["file_contexts"] == [{ + "file_name": "bank.csv", + "source_hint": "workspace", + "group_id": None, + "public_workspace_id": None, + }] + assert queued_calls[0]["planner_metadata"] == { + "action_mode": "analyze", + "durable_task_type": "combined", + "execution_contract": "combined", + "reason_code": "durable_intent", + } + assert queued_calls[0]["model_context"] == { + "endpoint_id": "endpoint-1", + "model_id": "model-1", + "model_deployment": "gpt-4o", + "provider": "new_foundry", + } + assert result["generated_tabular_outputs"][0]["status"] == "queued" + assert result["result"]["analysis_reply"] == "queued handoff" + assert result["result"]["coverage"]["processed_windows"] == 0 + assert result["result"]["coverage"]["progress_meta"]["status"] == "pending" + + print("Generated-output tabular Analyze primary durable routing checks passed") + + def test_manifest_flag_does_not_change_workflow_dispatch() -> None: print("Testing workflow manifest flag behavior equivalence...") @@ -288,6 +453,7 @@ def run_tests() -> bool: test_analyze_and_compare_dispatch_use_tabular_helper, test_tabular_document_actions_stream_live_activity, test_mixed_sources_preserve_valid_tabular_partition, + test_generated_output_tabular_analyze_queues_direct_output_before_foreground, test_manifest_flag_does_not_change_workflow_dispatch, test_document_action_chat_does_not_duplicate_shadow_manifest, ] @@ -313,4 +479,4 @@ def run_tests() -> bool: if __name__ == "__main__": - raise SystemExit(0 if run_tests() else 1) \ No newline at end of file + raise SystemExit(0 if run_tests() else 1) diff --git a/functional_tests/test_tabular_phase3_public_schema_projection.py b/functional_tests/test_tabular_phase3_public_schema_projection.py new file mode 100644 index 000000000..c5f619573 --- /dev/null +++ b/functional_tests/test_tabular_phase3_public_schema_projection.py @@ -0,0 +1,292 @@ +#!/usr/bin/env python3 +# test_tabular_phase3_public_schema_projection.py +""" +Functional test for Phase 3 public schema projection and passthrough safety. +Version: 0.250.182 +Implemented in: 0.250.173; request-order and unchanged-copy guard compatibility updated in 0.250.182 + +This test ensures generated tabular artifacts expose only the persisted public +schema while retaining internal checkpoint lineage, and that raw row passthrough +is refused for derived generated-output requests. +""" + +import ast +import io +import json +import sys +import traceback +from pathlib import Path +from xml.etree import ElementTree +from xml.sax.saxutils import escape as escape_xml_text + +from test_support.versioning import assert_app_version_at_least + + +REPO_ROOT = Path(__file__).resolve().parents[1] +APP_ROOT = REPO_ROOT / "application" / "single_app" +TABULAR_EXPORTS = APP_ROOT / "functions_tabular_generated_exports.py" +IMPLEMENTED_VERSION = "0.250.173" +sys.path.insert(0, str(APP_ROOT)) + +from functions_analysis_deliverables import ( # noqa: E402 + build_analysis_deliverable_contract, + project_structured_deliverable_row, +) +from functions_assistant_table_exports import ( # noqa: E402 + build_safe_csv_headers, + neutralize_csv_spreadsheet_formula, +) +from functions_generated_file_exports import ( # noqa: E402 + build_generated_file_export, + evaluate_generated_file_passthrough_eligibility, + get_requested_artifact_formats, +) + + +def assert_equal(actual, expected, label): + if actual != expected: + raise AssertionError(f"{label}: expected {expected!r}, got {actual!r}") + + +def assert_true(value, label): + if not value: + raise AssertionError(f"Expected truthy value for {label}") + + +def assert_false(value, label): + if value: + raise AssertionError(f"Expected falsy value for {label}") + + +def load_tabular_export_namespace(checkpoint_rows): + source = TABULAR_EXPORTS.read_text(encoding="utf-8") + module_tree = ast.parse(source, filename=str(TABULAR_EXPORTS)) + function_names = { + "_safe_int", + "_serialize_generated_output_value", + "_sanitize_generated_xml_tag_name", + "_write_generated_xml_row", + "_get_tabular_run_lineage_schema", + "_get_tabular_run_public_output_schema", + "_get_tabular_run_internal_checkpoint_schema", + "_get_tabular_run_serialized_public_schema", + "_write_ordered_output_stream", + "_build_structured_export_preview_rows", + } + selected_nodes = [ + node + for node in module_tree.body + if isinstance(node, ast.FunctionDef) and node.name in function_names + ] + assert_equal({node.name for node in selected_nodes}, function_names, "loaded function set") + + namespace = { + "TABULAR_EXPORT_OUTPUT_ROW_NUMBER_FIELD": "source_row_number", + "TABULAR_EXPORT_OUTPUT_ROW_IDENTITY_FIELD": "source_row_identity", + "TABULAR_EXPORT_ARTIFACT_PREVIEW_MAX_ROWS": 10, + "TABULAR_EXPORT_ARTIFACT_PREVIEW_MAX_CHARS": 24000, + "TABULAR_EXPORT_ARTIFACT_PREVIEW_CELL_MAX_CHARS": 240, + "build_safe_csv_headers": build_safe_csv_headers, + "csv": __import__("csv"), + "escape_xml_text": escape_xml_text, + "io": io, + "is_analysis_internal_lineage_field": lambda field_name: str(field_name or "").strip() in { + "source_row_number", + "source_row_identity", + } or str(field_name or "").strip().startswith("__simplechat"), + "json": json, + "neutralize_csv_spreadsheet_formula": neutralize_csv_spreadsheet_formula, + "project_structured_deliverable_row": project_structured_deliverable_row, + "re": __import__("re"), + "_download_json_blob": lambda blob_path: checkpoint_rows[blob_path], + "_output_blob_path": lambda user_id, conversation_id, run_id, batch_number: f"batch-{batch_number}", + "_validate_tabular_output_checkpoint_metadata": lambda run, blob_path, batch_number: None, + } + exec(compile(ast.Module(body=selected_nodes, type_ignores=[]), str(TABULAR_EXPORTS), "exec"), namespace) + return namespace + + +def build_internal_run(output_format): + return { + "user_id": "user-1", + "conversation_id": "conversation-1", + "id": "run-1", + "batch_count": 1, + "row_count": 2, + "output_format": output_format, + "output_schema": ["source_row_number", "source_row_identity", "Decision", "Amount"], + "public_output_schema": ["Decision", "Amount"], + "lineage_schema": ["source_row_number", "source_row_identity"], + "internal_checkpoint_schema": ["source_row_number", "source_row_identity", "Decision", "Amount"], + } + + +def test_contract_separates_public_internal_and_lineage_schema(): + print("Testing deliverable contract schema separation...") + assert_app_version_at_least(IMPLEMENTED_VERSION) + contract = build_analysis_deliverable_contract( + action_mode="search", + requested_output_format="csv", + public_output_schema=["Decision", "Amount"], + row_cardinality="one_per_source_row", + ordering="source_order", + ).to_dict() + + assert_equal(contract["contract_version"], "analysis-deliverables-v3", "contract version") + assert_equal(contract["public_output_schema"], ["Decision", "Amount"], "public schema") + assert_equal(contract["lineage_schema"], ["source_row_number", "source_row_identity"], "lineage schema") + assert_equal( + contract["internal_checkpoint_schema"], + ["source_row_number", "source_row_identity", "Decision", "Amount"], + "internal checkpoint schema", + ) + + for reserved_field in ("source_row_number", "source_row_identity", "__simplechat_row_token"): + try: + build_analysis_deliverable_contract( + action_mode="search", + requested_output_format="csv", + public_output_schema=[reserved_field], + ) + except ValueError: + continue + raise AssertionError(f"Reserved field {reserved_field!r} was accepted in the public schema") + + +def test_public_projection_drives_csv_json_xml_and_preview(): + print("Testing public projection across durable output formats and preview metadata...") + checkpoint_rows = { + "batch-1": [ + { + "source_row_number": 1, + "source_row_identity": "FRI-001", + "Decision": "Monitor", + "Amount": "100", + }, + { + "source_row_number": 2, + "source_row_identity": "FRI-002", + "Decision": "High ", + "Amount": "=2+2", + }, + ], + } + namespace = load_tabular_export_namespace(checkpoint_rows) + + csv_stream = io.StringIO() + namespace["_write_ordered_output_stream"](build_internal_run("csv"), csv_stream) + csv_payload = csv_stream.getvalue() + assert_true(csv_payload.startswith("Decision,Amount\n"), "csv public headers") + assert_false("source_row_number" in csv_payload, "csv lineage leakage") + assert_true("'=2+2" in csv_payload, "csv formula neutralization") + + json_stream = io.StringIO() + namespace["_write_ordered_output_stream"](build_internal_run("json"), json_stream) + json_rows = json.loads(json_stream.getvalue()) + assert_equal(list(json_rows[0]), ["Decision", "Amount"], "json public field order") + assert_false("source_row_identity" in json_rows[0], "json lineage leakage") + + xml_stream = io.StringIO() + namespace["_write_ordered_output_stream"](build_internal_run("xml"), xml_stream) + xml_payload = xml_stream.getvalue() + ElementTree.fromstring(xml_payload) + assert_true("High <Attention>" in xml_payload, "xml escaping") + assert_false("source_row_identity" in xml_payload, "xml lineage leakage") + + preview_rows = namespace["_build_structured_export_preview_rows"](build_internal_run("csv")) + assert_equal(list(preview_rows[0]), ["Decision", "Amount"], "preview columns") + assert_false("source_row_number" in preview_rows[0], "preview lineage leakage") + + +def test_passthrough_eligibility_and_generic_finalizer_guard(): + print("Testing passthrough eligibility and generic finalizer guard...") + assert_app_version_at_least("0.250.182") + rows = [{"Case": "A", "Amount": 100}] + ordered_formats = get_requested_artifact_formats("Create XML and JSON files from these rows.") + assert_equal(ordered_formats, ["xml", "json"], "format token request order") + + allowed = evaluate_generated_file_passthrough_eligibility( + "Export these results as CSV.", + rows=rows, + ) + assert_true(allowed["allowed"], "explicit serialization passthrough") + assert_equal(allowed["reason_code"], "explicit_format_conversion", "serialization reason") + + unchanged = evaluate_generated_file_passthrough_eligibility( + "Download an unchanged copy of the source rows as CSV.", + rows=rows, + ) + assert_true(unchanged["allowed"], "explicit unchanged copy passthrough") + assert_equal(unchanged["reason_code"], "explicit_unchanged_copy", "unchanged reason") + + unchanged_risk_status = evaluate_generated_file_passthrough_eligibility( + "Download an unchanged copy of the risk status rows as CSV.", + rows=rows, + ) + assert_true(unchanged_risk_status["allowed"], "descriptive risk/status unchanged copy passthrough") + assert_equal(unchanged_risk_status["reason_code"], "explicit_unchanged_copy", "risk/status unchanged reason") + + derived = evaluate_generated_file_passthrough_eligibility( + "Create a CSV with exactly one output row for each source row and classify risk.", + rows=rows, + ) + assert_false(derived["allowed"], "derived passthrough rejection") + assert_equal(derived["reason_code"], "derived_output_requires_transform", "derived reason") + + schema_mismatch = evaluate_generated_file_passthrough_eligibility( + "Export these results as CSV.", + rows=rows, + public_output_schema=["Risk"], + ) + assert_false(schema_mismatch["allowed"], "schema mismatch passthrough rejection") + assert_equal(schema_mismatch["reason_code"], "schema_not_satisfied", "schema mismatch reason") + + function_results = [{ + "success": True, + "plugin_name": "SimpleChatPlugin", + "function_name": "lookup_rows", + "function_result": json.dumps({"rows": rows}), + }] + guarded_payload = build_generated_file_export( + "Create a CSV with exactly these columns: Risk, Reason.", + "", + function_results=function_results, + ) + assert_equal(guarded_payload, None, "derived function rows are not serialized") + + passthrough_payload = build_generated_file_export( + "Export these results as CSV.", + "", + function_results=function_results, + ) + assert_true(passthrough_payload, "explicit function row serialization") + assert_equal(passthrough_payload["row_source"], "structured function result", "function row source") + assert_equal( + passthrough_payload["passthrough_reason_code"], + "explicit_format_conversion", + "function passthrough reason", + ) + + +def run_all_tests(): + tests = [ + test_contract_separates_public_internal_and_lineage_schema, + test_public_projection_drives_csv_json_xml_and_preview, + test_passthrough_eligibility_and_generic_finalizer_guard, + ] + results = [] + for test in tests: + try: + test() + print(f"PASS: {test.__name__}") + results.append(True) + except Exception as exc: + print(f"FAIL: {test.__name__}: {exc}") + traceback.print_exc() + results.append(False) + print(f"Results: {sum(results)}/{len(results)} tests passed") + return all(results) + + +if __name__ == "__main__": + sys.exit(0 if run_all_tests() else 1) diff --git a/functional_tests/test_tabular_phase5_artifact_set_lifecycle.py b/functional_tests/test_tabular_phase5_artifact_set_lifecycle.py new file mode 100644 index 000000000..5f58ff45b --- /dev/null +++ b/functional_tests/test_tabular_phase5_artifact_set_lifecycle.py @@ -0,0 +1,347 @@ +#!/usr/bin/env python3 +# test_tabular_phase5_artifact_set_lifecycle.py +""" +Functional test for Phase 5 tabular artifact-set lifecycle publication. +Version: 0.250.180 +Implemented in: 0.250.175; publication commit compatibility updated in 0.250.180 + +This test ensures durable tabular artifact sets hide staged members until the +whole required set is valid, publish Analyze Markdown as the primary member, +and fail closed when a required sibling is missing. +""" + +import ast +import re +import sys +from pathlib import Path + +from test_support.versioning import assert_app_version_at_least + + +REPO_ROOT = Path(__file__).resolve().parents[1] +APP_ROOT = REPO_ROOT / "application" / "single_app" +EXPORT_MODULE = APP_ROOT / "functions_tabular_generated_exports.py" +if str(APP_ROOT) not in sys.path: + sys.path.insert(0, str(APP_ROOT)) + +from functions_analysis_deliverables import ( # noqa: E402 + ANALYSIS_ARTIFACT_ROLE_PRIMARY_ANALYSIS, + ANALYSIS_ARTIFACT_ROLE_REQUESTED_OUTPUT, + build_analysis_deliverable_contract, + validate_analysis_artifact_set, +) + + +IMPLEMENTED_VERSION = "0.250.180" + + +def load_artifact_set_helpers(): + source = EXPORT_MODULE.read_text(encoding="utf-8") + tree = ast.parse(source, filename=str(EXPORT_MODULE)) + helper_names = { + "_safe_int", + "_normalize_tabular_run_task_type", + "_normalize_tabular_artifact_lifecycle_state", + "_normalize_tabular_artifact_format", + "_normalize_tabular_artifact_role", + "_normalize_tabular_artifact_member_id", + "_get_tabular_run_deliverable_contract", + "_normalize_artifact_descriptor", + "_default_artifact_descriptors_for_run", + "_get_artifact_descriptors_for_run", + "_get_primary_artifact_member_id", + "_get_structured_artifact_member_id", + "_get_structured_export_artifact_for_member", + "_get_analysis_artifact_member_id", + "_build_artifact_member_idempotency_key", + "_build_artifact_set_member", + "_artifact_lifecycle_for_existing_run_artifact", + "_artifact_set_lifecycle_for_run", + "_merge_artifact_metadata_into_member", + "_build_or_update_artifact_set_manifest", + "_set_artifact_set_member_state", + "_publish_artifact_set_members", + "_build_public_generated_artifact_from_member", + "_build_public_generated_artifacts_from_manifest", + "_build_public_artifact_projection", + } + selected_functions = [ + node for node in tree.body + if isinstance(node, ast.FunctionDef) and node.name in helper_names + ] + publication_commits = [] + + def commit_publication(current_user_id, conversation_id, artifact_message_id, artifact_set_id, artifact_member_id, publication_generation): + publication_commits.append({ + "current_user_id": current_user_id, + "conversation_id": conversation_id, + "artifact_message_id": artifact_message_id, + "artifact_set_id": artifact_set_id, + "artifact_member_id": artifact_member_id, + "publication_generation": publication_generation, + }) + + namespace = { + "re": re, + "validate_analysis_artifact_set": validate_analysis_artifact_set, + "commit_generated_chat_artifact_publication_for_user": commit_publication, + "publication_commits": publication_commits, + "ANALYSIS_ARTIFACT_ROLE_PRIMARY_ANALYSIS": ANALYSIS_ARTIFACT_ROLE_PRIMARY_ANALYSIS, + "ANALYSIS_ARTIFACT_ROLE_REQUESTED_OUTPUT": ANALYSIS_ARTIFACT_ROLE_REQUESTED_OUTPUT, + "ANALYSIS_ARTIFACT_ROLE_SUPPORTING_OUTPUT": "supporting_output", + "TABULAR_RUN_TASK_STRUCTURED_EXPORT": "structured_export", + "TABULAR_RUN_TASK_HIERARCHICAL_ANALYSIS": "hierarchical_analysis", + "TABULAR_RUN_TASK_COMBINED": "combined", + "TABULAR_RUN_TASK_TYPES": {"structured_export", "hierarchical_analysis", "combined"}, + "TABULAR_EXPORT_STATUS_RUNNING": "running", + "TABULAR_EXPORT_STATUS_COMPLETED": "completed", + "TABULAR_EXPORT_STATUS_FAILED": "failed", + "TABULAR_EXPORT_STATUS_CANCELED": "canceled", + "TABULAR_EXPORT_ARTIFACT_PREVIEW_MAX_ROWS": 10, + "TABULAR_EXPORT_ARTIFACT_PREVIEW_MAX_CHARS": 24000, + "TABULAR_GENERATION_PLAN_MAX_FIELDS": 50, + "TABULAR_ARTIFACT_SET_CONTRACT_VERSION": "tabular-artifact-set-v1", + "TABULAR_ARTIFACT_SET_LIFECYCLE_PLANNED": "planned", + "TABULAR_ARTIFACT_SET_LIFECYCLE_GENERATING": "generating", + "TABULAR_ARTIFACT_SET_LIFECYCLE_VALIDATING": "validating", + "TABULAR_ARTIFACT_SET_LIFECYCLE_READY_TO_PUBLISH": "ready_to_publish", + "TABULAR_ARTIFACT_SET_LIFECYCLE_PUBLISHING": "publishing", + "TABULAR_ARTIFACT_SET_LIFECYCLE_COMPLETED": "completed", + "TABULAR_ARTIFACT_SET_LIFECYCLE_FAILED": "failed", + "TABULAR_ARTIFACT_SET_LIFECYCLE_CANCELED": "canceled", + "TABULAR_ARTIFACT_SET_LIFECYCLE_ROLLBACK_REQUIRED": "rollback_required", + "TABULAR_ARTIFACT_SET_LIFECYCLE_ROLLED_BACK": "rolled_back", + "TABULAR_ARTIFACT_SET_LIFECYCLE_STATES": { + "planned", + "generating", + "validating", + "ready_to_publish", + "publishing", + "completed", + "failed", + "canceled", + "rollback_required", + "rolled_back", + }, + "TABULAR_ARTIFACT_MEMBER_LIFECYCLE_PLANNED": "planned", + "TABULAR_ARTIFACT_MEMBER_LIFECYCLE_GENERATING": "generating", + "TABULAR_ARTIFACT_MEMBER_LIFECYCLE_STAGED": "staged", + "TABULAR_ARTIFACT_MEMBER_LIFECYCLE_VALIDATED": "validated", + "TABULAR_ARTIFACT_MEMBER_LIFECYCLE_PUBLISHING": "publishing", + "TABULAR_ARTIFACT_MEMBER_LIFECYCLE_PUBLISHED": "published", + "TABULAR_ARTIFACT_MEMBER_LIFECYCLE_FAILED": "failed", + "TABULAR_ARTIFACT_MEMBER_LIFECYCLE_CANCELED": "canceled", + "TABULAR_ARTIFACT_MEMBER_LIFECYCLE_ROLLED_BACK": "rolled_back", + "TABULAR_ARTIFACT_MEMBER_LIFECYCLE_STATES": { + "planned", + "generating", + "staged", + "validated", + "publishing", + "published", + "failed", + "canceled", + "rolled_back", + }, + "TABULAR_ARTIFACT_MEMBER_PUBLIC_LIFECYCLE_STATES": {"published"}, + } + module = ast.Module(body=selected_functions, type_ignores=[]) + ast.fix_missing_locations(module) + exec(compile(module, str(EXPORT_MODULE), "exec"), namespace) + return namespace + + +def build_combined_contract(): + return build_analysis_deliverable_contract( + action_mode="analyze", + requested_output_format="csv", + source_fingerprint="source-fingerprint", + request_fingerprint="request-fingerprint", + ).to_dict() + + +def build_multi_format_combined_contract(): + return build_analysis_deliverable_contract( + action_mode="analyze", + requested_output_formats=["json", "xml"], + source_fingerprint="source-fingerprint", + request_fingerprint="request-fingerprint", + ).to_dict() + + +def build_run(status="running"): + return { + "id": "run-1", + "conversation_id": "conversation-1", + "user_id": "user-1", + "task_type": "combined", + "status": status, + "output_format": "csv", + "source_file_name": "financial_review.csv", + "row_count": 200, + "processed_rows": 200, + "post_run_summary": "Analysis completed.", + "post_run_export_summary": "CSV export completed.", + "tabular_planner_metadata": { + "deliverable_contract": build_combined_contract(), + }, + } + + +def build_artifact(message_id, file_name, output_format): + return { + "artifact_message_id": message_id, + "file_name": file_name, + "capability": "tabular", + "output_format": output_format, + "preview_rows": [{"Column": "Value"}], + "preview_columns": ["Column"], + "preview_text": "# Preview" if output_format == "md" else "", + "suppress_assistant_text": True, + } + + +def test_staged_structured_member_is_not_public_until_set_completion(): + print("Testing staged structured member visibility...") + assert_app_version_at_least(IMPLEMENTED_VERSION) + helpers = load_artifact_set_helpers() + run = build_run(status="running") + run["structured_export_artifact"] = build_artifact("csv-message", "financial_review.csv", "csv") + + manifest = helpers["_build_or_update_artifact_set_manifest"](run) + members_by_id = {member["member_id"]: member for member in manifest["members"]} + assert manifest["lifecycle_state"] == "validating" + assert members_by_id["analysis"]["lifecycle_state"] == "planned" + assert members_by_id["requested-csv"]["lifecycle_state"] == "staged" + assert helpers["_build_public_generated_artifacts_from_manifest"](run, manifest) == [] + + +def test_completed_combined_set_publishes_markdown_primary_then_sibling(): + print("Testing completed combined artifact-set publication order...") + assert_app_version_at_least(IMPLEMENTED_VERSION) + helpers = load_artifact_set_helpers() + run = build_run(status="running") + structured_artifact = build_artifact("csv-message", "financial_review.csv", "csv") + analysis_artifact = build_artifact("md-message", "financial_review.md", "md") + + helpers["_set_artifact_set_member_state"]( + run, + "requested-csv", + artifact=structured_artifact, + lifecycle_state="staged", + validation_state="validated", + ) + run["structured_export_artifact"] = structured_artifact + helpers["_set_artifact_set_member_state"]( + run, + "analysis", + artifact=analysis_artifact, + lifecycle_state="staged", + validation_state="validated", + ) + run["analysis_artifact"] = analysis_artifact + run["status"] = "completed" + + manifest = helpers["_publish_artifact_set_members"](run, ["analysis", "requested-csv"]) + assert manifest["lifecycle_state"] == "completed" + assert manifest["validation_state"] == "validated" + assert manifest["primary_artifact_id"] == "analysis" + assert manifest["publication_generation"] == 1 + assert helpers["publication_commits"] == [ + { + "current_user_id": "user-1", + "conversation_id": "conversation-1", + "artifact_message_id": "md-message", + "artifact_set_id": "tabular-artifact-set:run-1", + "artifact_member_id": "analysis", + "publication_generation": 1, + }, + { + "current_user_id": "user-1", + "conversation_id": "conversation-1", + "artifact_message_id": "csv-message", + "artifact_set_id": "tabular-artifact-set:run-1", + "artifact_member_id": "requested-csv", + "publication_generation": 1, + }, + ] + + public_artifacts = helpers["_build_public_generated_artifacts_from_manifest"](run, manifest) + assert [artifact["artifact_id"] for artifact in public_artifacts] == ["analysis", "requested-csv"] + assert public_artifacts[0]["role"] == ANALYSIS_ARTIFACT_ROLE_PRIMARY_ANALYSIS + assert public_artifacts[0]["output_format"] == "md" + assert public_artifacts[1]["role"] == ANALYSIS_ARTIFACT_ROLE_REQUESTED_OUTPUT + assert public_artifacts[1]["output_format"] == "csv" + + +def test_completed_combined_set_publishes_multiple_requested_siblings(): + print("Testing completed multi-sibling artifact-set publication order...") + assert_app_version_at_least("0.250.180") + helpers = load_artifact_set_helpers() + run = build_run(status="running") + run["output_format"] = "json" + run["tabular_planner_metadata"]["deliverable_contract"] = build_multi_format_combined_contract() + json_artifact = build_artifact("json-message", "financial_review.json", "json") + json_artifact["artifact_id"] = "requested-json" + xml_artifact = build_artifact("xml-message", "financial_review.xml", "xml") + xml_artifact["artifact_id"] = "requested-xml" + analysis_artifact = build_artifact("md-message", "financial_review.md", "md") + + run["structured_export_artifacts"] = [json_artifact, xml_artifact] + run["structured_export_artifact"] = json_artifact + run["analysis_artifact"] = analysis_artifact + run["status"] = "completed" + + manifest = helpers["_publish_artifact_set_members"](run, ["analysis", "requested-json", "requested-xml"]) + assert manifest["lifecycle_state"] == "completed" + assert manifest["validation_state"] == "validated" + assert manifest["publication_generation"] == 1 + assert [commit["artifact_member_id"] for commit in helpers["publication_commits"]] == [ + "analysis", + "requested-json", + "requested-xml", + ] + + public_artifacts = helpers["_build_public_generated_artifacts_from_manifest"](run, manifest) + assert [artifact["artifact_id"] for artifact in public_artifacts] == [ + "analysis", + "requested-json", + "requested-xml", + ] + assert [artifact["output_format"] for artifact in public_artifacts] == ["md", "json", "xml"] + + +def test_invalid_required_set_fails_closed_without_public_artifacts(): + print("Testing invalid artifact-set fail-closed behavior...") + assert_app_version_at_least(IMPLEMENTED_VERSION) + helpers = load_artifact_set_helpers() + run = build_run(status="completed") + run["structured_export_artifact"] = build_artifact("csv-message", "financial_review.csv", "csv") + + manifest = helpers["_publish_artifact_set_members"](run, ["requested-csv"]) + assert manifest["lifecycle_state"] == "rollback_required" + assert manifest["validation_state"] == "invalid" + assert "required_artifact_not_valid" in manifest["validation_report"]["reason_codes"] + assert helpers["publication_commits"] == [] + assert helpers["_build_public_generated_artifacts_from_manifest"](run, manifest) == [] + + +if __name__ == "__main__": + tests = [ + test_staged_structured_member_is_not_public_until_set_completion, + test_completed_combined_set_publishes_markdown_primary_then_sibling, + test_completed_combined_set_publishes_multiple_requested_siblings, + test_invalid_required_set_fails_closed_without_public_artifacts, + ] + results = [] + for test in tests: + print(f"\nRunning {test.__name__}...") + try: + test() + results.append(True) + except Exception as exc: + print(f"Test failed: {exc}") + results.append(False) + passed_count = sum(1 for result in results if result) + print(f"\nResults: {passed_count}/{len(results)} tests passed") + sys.exit(0 if all(results) else 1) diff --git a/functional_tests/test_tabular_phase7_lifecycle_coverage.py b/functional_tests/test_tabular_phase7_lifecycle_coverage.py index c38d0fe81..a87c7b2a9 100644 --- a/functional_tests/test_tabular_phase7_lifecycle_coverage.py +++ b/functional_tests/test_tabular_phase7_lifecycle_coverage.py @@ -2,8 +2,8 @@ # test_tabular_phase7_lifecycle_coverage.py """ Functional test for Phase 7 tabular lifecycle coverage hardening. -Version: 0.250.167 -Implemented in: 0.250.163 +Version: 0.250.178 +Implemented in: 0.250.163; planner dependency compatibility updated in 0.250.178 This test ensures shared tabular planner coverage starts as planned pending evidence, canceled durable tabular evidence remains terminal but incomplete, @@ -38,9 +38,21 @@ def get_requested_structured_artifact_format(prompt): return output_format return None + def get_requested_artifact_formats(prompt): + normalized_prompt = str(prompt or "").lower() + return [ + output_format + for output_format in ("json", "xml", "csv") + if output_format in normalized_prompt + ] + + generated_exports_module.get_requested_artifact_formats = get_requested_artifact_formats generated_exports_module.get_requested_structured_artifact_format = ( get_requested_structured_artifact_format ) + generated_exports_module.get_requested_structured_artifact_formats = ( + get_requested_artifact_formats + ) sys.modules.setdefault("functions_assistant_table_exports", assistant_exports_module) sys.modules.setdefault("functions_generated_file_exports", generated_exports_module) diff --git a/functional_tests/test_tabular_phase7b_production_correctness.py b/functional_tests/test_tabular_phase7b_production_correctness.py new file mode 100644 index 000000000..6f9ead4ce --- /dev/null +++ b/functional_tests/test_tabular_phase7b_production_correctness.py @@ -0,0 +1,351 @@ +# test_tabular_phase7b_production_correctness.py +#!/usr/bin/env python3 +""" +Functional test for Phase 7B production tabular correctness planning. +Version: 0.250.181 +Implemented in: 0.250.179; Advanced Security cleanup updated in 0.250.181 + +This test ensures real Search and Analyze shared-facade requests need no +injected output hints, persist the same reviewed deterministic contract, write +all 200 rows through durable checkpoints, and produce exact equivalent output. +""" + +import ast +import hashlib +import logging +import sys +import time +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +APP_ROOT = ROOT / "application" / "single_app" +if str(APP_ROOT) not in sys.path: + sys.path.insert(0, str(APP_ROOT)) +if str(Path(__file__).resolve().parent) not in sys.path: + sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from functions_analysis_deliverables import ( # noqa: E402 + build_analysis_deliverable_contract, + project_structured_deliverable_rows, + validate_structured_deliverable_rows, +) +from functions_tabular_orchestration import orchestrate_tabular_request # noqa: E402 +from functions_tabular_transformations import ( # noqa: E402 + evaluate_tabular_transformation_rows, + get_tabular_transformation_model_fields, +) +from test_support.analyze_deliverable_contract_fixture import ( # noqa: E402 + FINANCIAL_REVIEW_OUTPUT_COLUMNS, + FINANCIAL_REVIEW_PROMPT, + build_expected_financial_review_output_rows, + build_financial_review_source_rows, + find_value_mismatches, +) +from test_support.versioning import assert_app_version_at_least # noqa: E402 +from test_tabular_row_orchestration_scale import _load_generation_plan_helpers # noqa: E402 +from test_tabular_transformations_phase4 import build_financial_review_transformation_spec # noqa: E402 + + +IMPLEMENTED_VERSION = "0.250.179" +EXPORT_MODULE = APP_ROOT / "functions_tabular_generated_exports.py" + + +def _build_context(): + return { + "document_id": "financial-review-doc", + "file_name": "financial_review.csv", + "source_hint": "workspace", + "source_version": "etag-financial-review-v1", + "storage_locator": { + "container": "user-documents", + "blob_path": "user-1/financial_review.csv", + }, + } + + +def _capture_production_plan(action_mode): + captured = {} + + def durable_callback(plan, **execution_context): + captured["plan"] = plan + captured["execution_context"] = execution_context + return { + "background_export": True, + "export_run_id": f"run-{action_mode}", + "status": "queued", + "task_type": plan["durable_task_type"], + "output_format": "csv", + } + + result = orchestrate_tabular_request( + f"{FINANCIAL_REVIEW_PROMPT}\nDownload the result as CSV.", + [_build_context()], + action_mode=action_mode, + caller=action_mode, + settings={ + "enable_tabular_hierarchical_analysis": True, + "tabular_analyze_parity_rollout_percent": 100, + "tabular_analyze_parity_rollout_state": "active", + }, + planner_mode="active", + durable_execution_callback=durable_callback, + user_id="user-1", + conversation_id="conversation-1", + gpt_model="gpt-plan", + ) + assert result["execution_state"] == "queued" + assert result["reason_code"] == "active_execution_accepted" + assert captured["plan"]["requested_output_hints"] == {} + return captured["plan"] + + +def _build_reviewed_run(action_mode, shared_plan, source_rows): + helpers, _, _ = _load_generation_plan_helpers() + run = { + "id": f"run-{action_mode}", + "user_id": "user-1", + "conversation_id": "conversation-1", + "user_question": f"{FINANCIAL_REVIEW_PROMPT}\nDownload the result as CSV.", + "output_format": "csv", + "response_protocol_version": "object-v1", + "task_type": shared_plan["durable_task_type"], + "source_descriptor": { + "blob_path": "user-1/financial_review.csv", + "blob_etag": "etag-financial-review-v1", + }, + "row_count": len(source_rows), + "batch_count": 4, + "batch_budget": { + "max_rows": 50, + "max_chars": 60000, + "input_token_budget": 60000, + "output_token_budget": 30000, + }, + "plan_mode": "active", + "plan_status": "pending", + "output_schema": None, + "public_output_schema": [], + "internal_checkpoint_schema": [], + "transformation_spec": {}, + "tabular_planner_metadata": shared_plan, + } + input_contract = helpers["_build_tabular_generation_plan_input_contract"](source_rows[:5]) + transformation_spec = build_financial_review_transformation_spec() + planner_payload = { + "output_fields": [ + { + "name": field_name, + "description": f"Deterministic requested field {field_name}.", + "type": "string", + "nullable": False, + "source": "server", + } + for field_name in FINANCIAL_REVIEW_OUTPUT_COLUMNS + ], + "transformation_spec": transformation_spec, + } + plan = helpers["_build_tabular_generation_plan"]( + run, + planner_payload, + input_contract, + { + "endpoint_id": "endpoint-1", + "model_id": "gpt-plan", + "deployment": "gpt-plan", + }, + created_at="2026-08-12T12:00:00+00:00", + ) + reviewed_plan = helpers["_finalize_tabular_generation_plan_review"]( + plan, + { + "status": "passed", + "represented_fields": FINANCIAL_REVIEW_OUTPUT_COLUMNS, + "reason_codes": [], + }, + { + "endpoint_id": "endpoint-1", + "model_id": "gpt-review", + "deployment": "gpt-review", + }, + ) + helpers["_apply_active_tabular_generation_plan"](run, reviewed_plan) + return run, reviewed_plan + + +def _load_checkpoint_writer(): + source = EXPORT_MODULE.read_text(encoding="utf-8") + tree = ast.parse(source, filename=str(EXPORT_MODULE)) + function = next( + node + for node in tree.body + if isinstance(node, ast.FunctionDef) and node.name == "_checkpoint_generated_batch_results" + ) + blobs = {} + + def upload(path, payload, metadata=None, overwrite=True): + del metadata + if not overwrite and path in blobs: + raise FileExistsError(path) + blobs[path] = payload + + def record_shadow_plan_comparison(run, schema): + del run, schema + return False + + namespace = { + "ResourceExistsError": FileExistsError, + "logging": logging, + "time": time, + "_raise_if_tabular_export_canceled": lambda run: None, + "_get_tabular_run_public_output_schema": lambda run: list(run["public_output_schema"]), + "_get_tabular_run_internal_checkpoint_schema": lambda run: list(run["internal_checkpoint_schema"]), + "_record_shadow_tabular_generation_plan_comparison": record_shadow_plan_comparison, + "_replace_claimed_run": lambda run: dict(run), + "_output_blob_path": lambda user_id, conversation_id, run_id, batch_number: f"output/{batch_number}", + "_output_summary_blob_path": lambda user_id, conversation_id, run_id, batch_number: f"summary/{batch_number}", + "_upload_json_blob": upload, + "_build_tabular_output_checkpoint_metadata": lambda run, metadata: metadata, + "_validate_tabular_output_checkpoint_metadata": lambda run, path, batch_number: None, + "_download_json_blob": lambda path: blobs[path], + "_build_generated_batch_summary": lambda entries: {"row_count": len(entries)}, + "log_event": lambda *args, **kwargs: None, + } + module = ast.Module(body=[function], type_ignores=[]) + ast.fix_missing_locations(module) + exec(compile(module, str(EXPORT_MODULE), "exec"), namespace) + return namespace["_checkpoint_generated_batch_results"], blobs + + +def _load_fallback_contract_helper(): + source = EXPORT_MODULE.read_text(encoding="utf-8") + tree = ast.parse(source, filename=str(EXPORT_MODULE)) + function = next( + node + for node in tree.body + if isinstance(node, ast.FunctionDef) + and node.name == "_ensure_active_tabular_run_deliverable_contract" + ) + namespace = { + "hashlib": hashlib, + "TABULAR_RUN_TASK_COMBINED": "combined", + "_normalize_tabular_run_task_type": lambda value: value or "structured_export", + "build_analysis_deliverable_contract": build_analysis_deliverable_contract, + } + module = ast.Module(body=[function], type_ignores=[]) + ast.fix_missing_locations(module) + exec(compile(module, str(EXPORT_MODULE), "exec"), namespace) + return namespace["_ensure_active_tabular_run_deliverable_contract"] + + +def _checkpoint_deterministic_rows(run, source_rows, transformed_rows): + generated_results = [] + for batch_number, batch_start in enumerate(range(0, len(source_rows), 50), start=1): + batch_rows = [] + for offset, public_row in enumerate(transformed_rows[batch_start:batch_start + 50], start=1): + source_row_number = batch_start + offset + batch_rows.append({ + "source_row_number": source_row_number, + "source_row_identity": public_row["Item_ID"], + **public_row, + }) + generated_results.append({ + "batch_number": batch_number, + "batch_entries": batch_rows, + "batch_summary": {"row_count": len(batch_rows)}, + "batch_row_count": len(batch_rows), + "elapsed_seconds": 0.01, + "mismatch_count": 0, + "output_schema": list(run["output_schema"]), + }) + checkpoint_writer, blobs = _load_checkpoint_writer() + checkpoint_writer(run, generated_results) + checkpointed_rows = [] + for batch_number in range(1, 5): + checkpointed_rows.extend(blobs[f"output/{batch_number}"]) + return project_structured_deliverable_rows( + checkpointed_rows, + run["public_output_schema"], + require_all_fields=True, + ) + + +def test_financial_review_real_search_and_analyze_contracts_are_exact(): + assert_app_version_at_least(IMPLEMENTED_VERSION) + source_rows = build_financial_review_source_rows() + expected_rows = build_expected_financial_review_output_rows(source_rows) + actual_by_action = {} + contracts_by_action = {} + + for action_mode in ("search", "analyze"): + shared_plan = _capture_production_plan(action_mode) + run, reviewed_plan = _build_reviewed_run(action_mode, shared_plan, source_rows) + transformation_spec = reviewed_plan["transformation_spec"] + assert get_tabular_transformation_model_fields( + transformation_spec, + public_output_schema=FINANCIAL_REVIEW_OUTPUT_COLUMNS, + ) == [] + transformed_rows = evaluate_tabular_transformation_rows(transformation_spec, source_rows) + actual_rows = _checkpoint_deterministic_rows(run, source_rows, transformed_rows) + contract = run["tabular_planner_metadata"]["deliverable_contract"] + report = validate_structured_deliverable_rows( + contract, + output_rows=actual_rows, + source_rows=source_rows, + expected_rows=expected_rows, + identity_field="Item_ID", + ) + assert report.valid, report.to_dict() + assert find_value_mismatches(expected_rows, actual_rows) == [] + assert contract["validation_profile"] == "exact_rows_schema_and_rules" + assert contract["transformation_mode"] == "deterministic" + actual_by_action[action_mode] = actual_rows + contracts_by_action[action_mode] = contract + + assert actual_by_action["search"] == actual_by_action["analyze"] + assert contracts_by_action["search"]["public_output_schema"] == FINANCIAL_REVIEW_OUTPUT_COLUMNS + assert contracts_by_action["analyze"]["public_output_schema"] == FINANCIAL_REVIEW_OUTPUT_COLUMNS + assert [ + artifact["format"] + for artifact in contracts_by_action["analyze"]["requested_artifacts"] + ] == ["md", "csv"] + + +def test_active_legacy_direct_preflight_gets_server_owned_contract(): + assert_app_version_at_least(IMPLEMENTED_VERSION) + ensure_contract = _load_fallback_contract_helper() + fallback_metadata = ensure_contract( + {}, + "active", + "combined", + "csv", + "Analyze every row and create a CSV.", + ) + contract = fallback_metadata["deliverable_contract"] + assert contract["action_mode"] == "analyze" + assert contract["analysis_required"] is True + assert [artifact["format"] for artifact in contract["requested_artifacts"]] == ["md", "csv"] + assert fallback_metadata["reason_code"] == "legacy_direct_preflight" + + existing_metadata = {"deliverable_contract": {"contract_version": "existing"}} + assert ensure_contract( + existing_metadata, + "active", + "structured_export", + "csv", + "Export rows.", + ) == existing_metadata + assert ensure_contract( + {}, + "shadow", + "structured_export", + "csv", + "Export rows.", + ) == {} + + +if __name__ == "__main__": + test_financial_review_real_search_and_analyze_contracts_are_exact() + test_active_legacy_direct_preflight_gets_server_owned_contract() + print("PASS test_financial_review_real_search_and_analyze_contracts_are_exact") + print("PASS test_active_legacy_direct_preflight_gets_server_owned_contract") diff --git a/functional_tests/test_tabular_phase8_ui_telemetry_rollout.py b/functional_tests/test_tabular_phase8_ui_telemetry_rollout.py index 1cf01e4f0..776639769 100644 --- a/functional_tests/test_tabular_phase8_ui_telemetry_rollout.py +++ b/functional_tests/test_tabular_phase8_ui_telemetry_rollout.py @@ -2,8 +2,8 @@ # test_tabular_phase8_ui_telemetry_rollout.py """ Functional test for Phase 8 tabular UI telemetry and rollout metadata. -Version: 0.250.167 -Implemented in: 0.250.164; planning-only metadata hardening in 0.250.167 +Version: 0.250.177 +Implemented in: 0.250.164; planning-only metadata hardening in 0.250.167; Phase 7 harness compatibility in 0.250.177 This test ensures shared tabular planner rollout assignment is stable and redacted, backend-only rollout controls remain sanitized from frontend @@ -38,16 +38,15 @@ def install_lightweight_planner_dependency_stubs(): ) generated_exports_module = types.ModuleType("functions_generated_file_exports") - def get_requested_structured_artifact_format(prompt): + def get_requested_artifact_formats(prompt): normalized_prompt = str(prompt or "").lower() - for output_format in ("json", "xml", "csv"): - if output_format in normalized_prompt: - return output_format - return None + return [output_format for output_format in ("json", "xml", "csv") if output_format in normalized_prompt] + generated_exports_module.get_requested_artifact_formats = get_requested_artifact_formats generated_exports_module.get_requested_structured_artifact_format = ( - get_requested_structured_artifact_format + lambda prompt: next(iter(get_requested_artifact_formats(prompt)), None) ) + generated_exports_module.get_requested_structured_artifact_formats = get_requested_artifact_formats sys.modules.setdefault("functions_assistant_table_exports", assistant_exports_module) sys.modules.setdefault("functions_generated_file_exports", generated_exports_module) @@ -142,15 +141,21 @@ def normalize_task_type(task_type): "status_detail": "Queued and waiting for a worker.", }, "_build_checkpoint_summary": lambda completed_batches, batch_count, processed_rows, row_count: "", + "_build_or_update_artifact_set_manifest": lambda run: {"lifecycle_state": "completed"}, + "_build_public_generated_artifacts_from_manifest": lambda run, artifact_set_manifest: [], + "_build_public_artifact_projection": lambda artifact: dict(artifact or {}), "TABULAR_EXPORT_STATUS_QUEUED": "queued", "TABULAR_EXPORT_STATUS_RUNNING": "running", "TABULAR_EXPORT_STATUS_COMPLETED": "completed", "TABULAR_EXPORT_STATUS_FAILED": "failed", "TABULAR_EXPORT_STATUS_CANCELED": "canceled", "TABULAR_EXPORT_TERMINAL_STATUSES": {"completed", "failed", "canceled"}, + "TABULAR_ARTIFACT_SET_LIFECYCLE_COMPLETED": "completed", "TABULAR_RUN_TASK_STRUCTURED_EXPORT": "structured_export", "TABULAR_RUN_TASK_HIERARCHICAL_ANALYSIS": "hierarchical_analysis", "TABULAR_RUN_TASK_COMBINED": "combined", + "ANALYSIS_ARTIFACT_ROLE_REQUESTED_OUTPUT": "requested_output", + "ANALYSIS_ARTIFACT_ROLE_PRIMARY_ANALYSIS": "primary_analysis", "TABULAR_EXPORT_ARTIFACT_PREVIEW_MAX_ROWS": 3, "TABULAR_GENERATION_PLAN_MAX_FIELDS": 50, "TABULAR_EXPORT_ARTIFACT_PREVIEW_MAX_CHARS": 1200, diff --git a/functional_tests/test_tabular_phase9_legacy_retirement.py b/functional_tests/test_tabular_phase9_legacy_retirement.py index ebccb6a8d..6919ec9b9 100644 --- a/functional_tests/test_tabular_phase9_legacy_retirement.py +++ b/functional_tests/test_tabular_phase9_legacy_retirement.py @@ -2,8 +2,8 @@ # test_tabular_phase9_legacy_retirement.py """ Functional test for Phase 9 tabular legacy fallback retirement controls. -Version: 0.250.167 -Implemented in: 0.250.165 +Version: 0.250.177 +Implemented in: 0.250.165; Phase 7 harness compatibility in 0.250.177 This test ensures the shared planner records safe legacy post-tool fallback retirement decisions, active shared durable acceptance suppresses duplicate @@ -35,16 +35,15 @@ def install_lightweight_planner_dependency_stubs(): ) generated_exports_module = types.ModuleType("functions_generated_file_exports") - def get_requested_structured_artifact_format(prompt): + def get_requested_artifact_formats(prompt): normalized_prompt = str(prompt or "").lower() - for output_format in ("json", "xml", "csv"): - if output_format in normalized_prompt: - return output_format - return None + return [output_format for output_format in ("json", "xml", "csv") if output_format in normalized_prompt] + generated_exports_module.get_requested_artifact_formats = get_requested_artifact_formats generated_exports_module.get_requested_structured_artifact_format = ( - get_requested_structured_artifact_format + lambda prompt: next(iter(get_requested_artifact_formats(prompt)), None) ) + generated_exports_module.get_requested_structured_artifact_formats = get_requested_artifact_formats sys.modules.setdefault("functions_assistant_table_exports", assistant_exports_module) sys.modules.setdefault("functions_generated_file_exports", generated_exports_module) @@ -189,7 +188,9 @@ def fake_emit_tabular_parity_event(settings, event_name, mode, **kwargs): namespace = { "TABULAR_RUN_TASK_COMBINED": "combined", "TABULAR_RUN_TASK_HIERARCHICAL_ANALYSIS": "hierarchical_analysis", - "_get_tabular_generated_output_task_type": lambda generated, hierarchical, settings: "structured_export", + "_get_tabular_generated_output_task_type": ( + lambda generated, hierarchical, settings, action_mode=None: "structured_export" + ), "_shared_build_tabular_legacy_post_tool_fallback_decision": build_tabular_legacy_post_tool_fallback_decision, "classify_tabular_parity_request": lambda prompt: {"execution_contract": "structured_export"}, "emit_tabular_parity_event": fake_emit_tabular_parity_event, diff --git a/functional_tests/test_tabular_row_orchestration_scale.py b/functional_tests/test_tabular_row_orchestration_scale.py index 030bc0055..73fb8e0bc 100644 --- a/functional_tests/test_tabular_row_orchestration_scale.py +++ b/functional_tests/test_tabular_row_orchestration_scale.py @@ -1,8 +1,8 @@ # test_tabular_row_orchestration_scale.py """ Functional test for scalable per-row tabular orchestration. -Version: 0.250.167 -Implemented in: 0.250.060; generated CSV formula safety in 0.250.065; generated file export routing in 0.250.072; source descriptor generalization in 0.250.127; unified durable run contract in 0.250.128; hierarchical analysis in 0.250.129; combined analysis and export in 0.250.130; scale validation in 0.250.132; direct source-backed exhaustive queueing in 0.250.133; direct queue call-site hardening in 0.250.134; model-validation auto retry in 0.250.135; model-aware parallel throughput in 0.250.136; Phase 1 acceleration contracts and observability in 0.250.137; Phase 2 truthful background handoff in 0.250.138; Phase 3 durable LLM generation planning in 0.250.139; Phase 4 compact row response protocol in 0.250.140; Phase 5 completion-driven checkpointing in 0.250.141; Phase 6 rolling worker pool in 0.250.142; Phase 7 independent batch retries in 0.250.143; Phase 8 scale, chaos, and rollout in 0.250.144; background metadata streaming fix in 0.250.145; source-token echo recovery in 0.250.146; fixed-window stale heartbeat fix in 0.250.147; nested CSV output recovery in 0.250.148; generic tabular artifact routing and fast startup in 0.250.149; balanced concurrency waves and default completion checkpoints in 0.250.152; Search shared preflight adapter in 0.250.159; aggregate route-helper harness coverage in 0.250.166 +Version: 0.250.180 +Implemented in: 0.250.060; generated CSV formula safety in 0.250.065; generated file export routing in 0.250.072; source descriptor generalization in 0.250.127; unified durable run contract in 0.250.128; hierarchical analysis in 0.250.129; combined analysis and export in 0.250.130; scale validation in 0.250.132; direct source-backed exhaustive queueing in 0.250.133; direct queue call-site hardening in 0.250.134; model-validation auto retry in 0.250.135; model-aware parallel throughput in 0.250.136; Phase 1 acceleration contracts and observability in 0.250.137; Phase 2 truthful background handoff in 0.250.138; Phase 3 durable LLM generation planning in 0.250.139; Phase 4 compact row response protocol in 0.250.140; Phase 5 completion-driven checkpointing in 0.250.141; Phase 6 rolling worker pool in 0.250.142; Phase 7 independent batch retries in 0.250.143; Phase 8 scale, chaos, and rollout in 0.250.144; background metadata streaming fix in 0.250.145; source-token echo recovery in 0.250.146; fixed-window stale heartbeat fix in 0.250.147; nested CSV output recovery in 0.250.148; generic tabular artifact routing and fast startup in 0.250.149; balanced concurrency waves and default completion checkpoints in 0.250.152; Search shared preflight adapter in 0.250.159; aggregate route-helper harness coverage in 0.250.166; Analyze artifact Phase 7A harness compatibility updated in 0.250.178; reviewed correctness planning and semantic validation updated in 0.250.179; artifact publication lifecycle updated in 0.250.180 This test ensures generated exports preserve source identity and row order while enforcing one stable output schema across independently generated batches. @@ -57,6 +57,15 @@ get_requested_generated_file_format, get_requested_structured_artifact_format, ) +from functions_analysis_deliverables import ( # noqa: E402 + is_analysis_internal_lineage_field, + project_structured_deliverable_row, +) +from functions_tabular_transformations import normalize_tabular_transformation_spec # noqa: E402 +from functions_tabular_transformations import ( # noqa: E402 + TABULAR_TRANSFORMATION_FIELD_MODE_DETERMINISTIC, + TABULAR_TRANSFORMATION_SPEC_VERSION, +) from functions_tabular_orchestration import ( # noqa: E402 build_tabular_legacy_post_tool_fallback_decision, get_tabular_generated_output_format, @@ -91,6 +100,8 @@ STREAM_FUNCTIONS = { '_serialize_generated_output_value', '_validate_tabular_output_checkpoint_metadata', + '_get_tabular_run_public_output_schema', + '_get_tabular_run_serialized_public_schema', '_write_ordered_output_stream', } SOURCE_VERSION_FUNCTIONS = {'_revalidate_tabular_source_version_for_publication'} @@ -102,6 +113,7 @@ } AUTHORIZATION_FUNCTIONS = {'_authorize_tabular_export_run_execution'} CANCELLATION_FUNCTIONS = { + '_is_artifact_publication_recoverable', '_can_cancel_run', 'cancel_tabular_generated_output_run', } @@ -124,6 +136,7 @@ '_has_exhausted_independent_batch_retries', '_is_auto_retry_exhausted', '_can_auto_retry_failed_run', + '_is_artifact_publication_recoverable', '_can_resume_run', '_mark_run_failed', '_get_auto_retry_limit_for_category', @@ -160,7 +173,12 @@ } BACKGROUND_METADATA_FUNCTIONS = {'build_background_tabular_generated_output_metadata'} STATUS_DETAIL_FUNCTIONS = {'_build_run_status_detail'} -ARTIFACT_FUNCTIONS = {'_upload_generated_chat_artifact_for_current_user'} +ARTIFACT_FUNCTIONS = { + '_safe_positive_int', + '_build_generated_chat_artifact_lifecycle_metadata', + '_build_generated_chat_artifact_lifecycle_response', + '_upload_generated_chat_artifact_for_current_user', +} SCHEDULER_FUNCTIONS = {'_query_scheduler_candidates_by_status'} MANIFEST_FUNCTIONS = { '_normalize_tabular_run_task_type', @@ -210,6 +228,9 @@ '_build_tabular_generation_rollout_assignment', '_get_tabular_generation_rollout_settings_for_run', '_sync_tabular_generation_contract_fields', + '_get_tabular_run_lineage_schema', + '_get_tabular_run_public_output_schema', + '_get_tabular_run_internal_checkpoint_schema', '_build_generation_progress_contract_fields', '_extract_tabular_response_usage', '_resolve_tabular_batch_concurrency', @@ -246,9 +267,17 @@ '_build_tabular_generation_plan_input_contract', '_validate_tabular_generation_plan_output_fields', '_get_tabular_generation_plan_source', + '_validate_tabular_generation_plan_field_ownership', '_build_tabular_generation_plan', '_validate_tabular_generation_plan', '_get_tabular_generation_plan_output_schema', + '_get_tabular_generation_plan_llm_fields', + '_get_tabular_generation_plan_public_fields', + '_normalize_tabular_generation_plan_review', + '_finalize_tabular_generation_plan_review', + '_get_tabular_run_lineage_schema', + '_get_tabular_run_public_output_schema', + '_get_tabular_run_transformation_spec', '_tabular_generation_plan_blob_path', '_get_tabular_generation_plan_source_etag', '_build_tabular_output_checkpoint_metadata', @@ -256,10 +285,13 @@ '_get_tabular_generation_plan_mode', '_load_tabular_generation_plan_sample_rows', '_build_tabular_generation_plan_prompt', + '_build_tabular_generation_plan_review_prompt', + '_generate_tabular_generation_plan_review', '_generate_tabular_generation_plan', '_apply_active_tabular_generation_plan', '_recover_tabular_generation_plan', '_mark_tabular_generation_plan_fallback', + '_fail_active_tabular_generation_plan', '_ensure_tabular_generation_plan', '_record_shadow_tabular_generation_plan_comparison', '_dump_generated_output_json', @@ -300,10 +332,14 @@ '_build_tabular_generation_plan_input_contract', '_validate_tabular_generation_plan_output_fields', '_get_tabular_generation_plan_source', + '_validate_tabular_generation_plan_field_ownership', '_build_tabular_generation_plan', '_validate_tabular_generation_plan', '_get_tabular_generation_plan_output_schema', '_get_tabular_generation_plan_llm_fields', + '_get_tabular_generation_plan_public_fields', + '_normalize_tabular_generation_plan_review', + '_finalize_tabular_generation_plan_review', '_get_compact_plan_hash_prefix', '_build_compact_batch_row_key', '_build_compact_batch_key_map', @@ -780,6 +816,8 @@ def _load_stream_writer(download_json_blob): '_output_blob_path': lambda user_id, conversation_id, run_id, batch_number: batch_number, '_download_json_blob': download_json_blob, 'TABULAR_EXPORT_OUTPUT_ROW_NUMBER_FIELD': 'source_row_number', + 'is_analysis_internal_lineage_field': is_analysis_internal_lineage_field, + 'project_structured_deliverable_row': project_structured_deliverable_row, } extracted_module = ast.Module(body=selected_nodes, type_ignores=[]) exec(compile(extracted_module, str(EXPORT_MODULE), 'exec'), namespace) @@ -943,8 +981,13 @@ def replace_run(run): namespace = { 'logging': logging, 'CosmosResourceNotFoundError': CosmosResourceNotFoundError, + 'TABULAR_EXPORT_STATUS_FAILED': 'failed', 'TABULAR_EXPORT_STATUS_COMPLETED': 'completed', 'TABULAR_EXPORT_STATUS_CANCELED': 'canceled', + 'TABULAR_ARTIFACT_SET_LIFECYCLE_VALIDATING': 'validating', + 'TABULAR_ARTIFACT_SET_LIFECYCLE_PUBLISHING': 'publishing', + 'TABULAR_ARTIFACT_SET_LIFECYCLE_ROLLBACK_REQUIRED': 'rollback_required', + 'TABULAR_ARTIFACT_SET_LIFECYCLE_FAILED': 'failed', 'get_settings': lambda: {}, '_read_run': read_run, '_replace_run': replace_run, @@ -1343,13 +1386,19 @@ def get_blob_client(self, container, blob): return self.clients.setdefault((container, blob), BlobClient()) module_tree = ast.parse(SIMPLECHAT_OPERATIONS.read_text(encoding='utf-8'), filename=str(SIMPLECHAT_OPERATIONS)) - selected_nodes = [ - node - for node in module_tree.body - if isinstance(node, ast.FunctionDef) and node.name in ARTIFACT_FUNCTIONS - ] - if len(selected_nodes) != len(ARTIFACT_FUNCTIONS): - raise AssertionError('Missing idempotent artifact helper') + selected_nodes = [] + found_functions = set() + for node in module_tree.body: + if isinstance(node, ast.Assign): + assigned_names = {target.id for target in node.targets if isinstance(target, ast.Name)} + if any(name.startswith('GENERATED_CHAT_ARTIFACT_') for name in assigned_names): + selected_nodes.append(node) + elif isinstance(node, ast.FunctionDef) and node.name in ARTIFACT_FUNCTIONS: + selected_nodes.append(node) + found_functions.add(node.name) + missing_functions = ARTIFACT_FUNCTIONS - found_functions + if missing_functions: + raise AssertionError(f'Missing idempotent artifact helpers: {sorted(missing_functions)}') message_container = MessageContainer() blob_service_client = BlobServiceClient() @@ -1434,6 +1483,7 @@ def _load_performance_helpers(progress_updates=None): or name.startswith('TABULAR_EXECUTOR_') or name.startswith('TABULAR_RETRY_') or name.startswith('TABULAR_ROLLOUT_') + or name.startswith('TABULAR_SEMANTIC_') for name in assigned_names ): selected_nodes.append(node) @@ -1451,6 +1501,7 @@ def _load_performance_helpers(progress_updates=None): 'os': os, 're': re, 'timezone': timezone, + 'is_analysis_internal_lineage_field': is_analysis_internal_lineage_field, } extracted_module = ast.Module(body=selected_nodes, type_ignores=[]) exec(compile(extracted_module, str(EXPORT_MODULE), 'exec'), namespace) @@ -1505,16 +1556,20 @@ def _load_generation_plan_helpers(): raise AssertionError(f'Missing generation plan helpers: {sorted(missing_functions)}') class PlannerError(RuntimeError): - def __init__(self, reason): + def __init__(self, reason, failed_run=None): super().__init__('planner failed') self.reason = reason + self.failed_run = failed_run class ChatHistory: + def __init__(self): + self.messages = [] + def add_system_message(self, message): - del message + self.messages.append(('system', message)) def add_user_message(self, message): - del message + self.messages.append(('user', message)) class ExecutionSettings: def __init__(self, **kwargs): @@ -1571,6 +1626,10 @@ def replace_claimed_run(run): (input_batches or run['_test_batches'])[batch_number - 1] ), '_normalize_tabular_run_task_type': lambda value: value or 'structured_export', + 'is_analysis_internal_lineage_field': is_analysis_internal_lineage_field, + 'normalize_tabular_transformation_spec': normalize_tabular_transformation_spec, + 'TABULAR_TRANSFORMATION_FIELD_MODE_DETERMINISTIC': TABULAR_TRANSFORMATION_FIELD_MODE_DETERMINISTIC, + 'TABULAR_TRANSFORMATION_SPEC_VERSION': TABULAR_TRANSFORMATION_SPEC_VERSION, '_resolve_tabular_generation_planner_model': lambda run, settings: { 'endpoint_id': 'endpoint-1', 'model_id': 'gpt-plan', @@ -1604,6 +1663,7 @@ def _load_compact_protocol_helpers(): or name.startswith('TABULAR_RUN_TASK_') or name.startswith('TABULAR_ROLLOUT_') or name.startswith('TABULAR_COMPACT_') + or name.startswith('TABULAR_SEMANTIC_') for name in assigned_names ): selected_nodes.append(node) @@ -1616,6 +1676,9 @@ def _load_compact_protocol_helpers(): 'hashlib': hashlib, 'json': json, 're': re, + 'normalize_tabular_transformation_spec': normalize_tabular_transformation_spec, + 'TABULAR_TRANSFORMATION_FIELD_MODE_DETERMINISTIC': TABULAR_TRANSFORMATION_FIELD_MODE_DETERMINISTIC, + 'TABULAR_TRANSFORMATION_SPEC_VERSION': TABULAR_TRANSFORMATION_SPEC_VERSION, } extracted_module = ast.Module(body=selected_nodes, type_ignores=[]) exec(compile(extracted_module, str(EXPORT_MODULE), 'exec'), namespace) @@ -1701,6 +1764,32 @@ def _build_phase_three_test_run(plan_mode='shadow', plan_status='pending'): 'plan_hash': None, 'output_schema': None, 'passthrough_input_rows': False, + 'tabular_planner_metadata': { + 'deliverable_contract': { + 'contract_version': 'analysis-deliverables-v3', + 'action_mode': 'search', + 'analysis_required': False, + 'primary_artifact_role': '', + 'requested_artifacts': [{ + 'artifact_id': 'requested-csv', + 'role': 'requested_output', + 'format': 'csv', + 'required': True, + 'request_order': 0, + }], + 'public_output_schema': [], + 'internal_checkpoint_schema': [], + 'lineage_schema': ['source_row_number', 'source_row_identity'], + 'row_cardinality': 'one_per_source_row', + 'ordering': 'source_order', + 'transformation_mode': 'semantic', + 'transformation_spec': {}, + 'validation_profile': 'exact_rows_schema', + 'publication_policy': 'all_required_artifacts', + 'source_fingerprint': 'source-fixture', + 'request_fingerprint': 'request-fixture', + }, + }, '_test_batches': [ [{ 'Case ID': 'SC-1', @@ -1743,6 +1832,23 @@ def _build_phase_three_plan(helpers, run): 'source': 'llm', }, ], + 'transformation_spec': { + 'version': 'tabular-transform-v1', + 'fields': [ + { + 'name': 'answer', + 'mode': 'semantic', + 'type': 'string', + 'nullable': False, + }, + { + 'name': 'risk', + 'mode': 'semantic', + 'type': 'string', + 'nullable': True, + }, + ], + }, 'output_verbosity': 'concise', }, input_contract, @@ -1753,7 +1859,20 @@ def _build_phase_three_plan(helpers, run): }, created_at='2026-08-10T12:00:00+00:00', ) - return plan, input_contract + reviewed_plan = helpers['_finalize_tabular_generation_plan_review']( + plan, + { + 'status': 'passed', + 'represented_fields': ['answer', 'risk'], + 'reason_codes': [], + }, + { + 'endpoint_id': 'endpoint-1', + 'model_id': 'gpt-plan', + 'deployment': 'gpt-plan', + }, + ) + return reviewed_plan, input_contract def _build_phase_four_plan(helpers, run): @@ -1807,6 +1926,17 @@ def _build_phase_four_plan(helpers, run): 'source': 'llm', }, ], + 'transformation_spec': { + 'version': 'tabular-transform-v1', + 'fields': [ + {'name': 'answer', 'mode': 'semantic', 'type': 'string', 'nullable': False}, + {'name': 'risk', 'mode': 'semantic', 'type': 'string', 'nullable': True}, + {'name': 'score', 'mode': 'semantic', 'type': 'number', 'nullable': False}, + {'name': 'flagged', 'mode': 'semantic', 'type': 'boolean', 'nullable': False}, + {'name': 'evidence', 'mode': 'semantic', 'type': 'object', 'nullable': False}, + {'name': 'tags', 'mode': 'semantic', 'type': 'array', 'nullable': False}, + ], + }, 'output_verbosity': 'concise', }, input_contract, @@ -2049,6 +2179,9 @@ def test_phase_three_rollout_activates_shadow_only_and_stays_backend_only(): 'tabular_generation_rollout_percentage': 100, 'tabular_background_handoff_mode': 'legacy', 'tabular_generation_plan_mode': 'shadow', + 'tabular_semantic_validation_mode': 'off', + 'tabular_semantic_repair_max_attempts': 2, + 'tabular_semantic_repair_max_rows': 100, 'enable_tabular_generation_plan': True, 'enable_tabular_compact_response_protocol': False, 'enable_tabular_completion_driven_checkpointing': True, @@ -2063,6 +2196,9 @@ def test_phase_three_rollout_activates_shadow_only_and_stays_backend_only(): overridden = normalize_rollout({ 'tabular_background_handoff_mode': 'server', 'tabular_generation_plan_mode': 'shadow', + 'tabular_semantic_validation_mode': 'active', + 'tabular_semantic_repair_max_attempts': 99, + 'tabular_semantic_repair_max_rows': 9999, 'enable_tabular_generation_plan': 'true', 'enable_tabular_compact_response_protocol': 'yes', 'enable_tabular_completion_driven_checkpointing': '1', @@ -2075,6 +2211,9 @@ def test_phase_three_rollout_activates_shadow_only_and_stays_backend_only(): }) assert overridden['tabular_background_handoff_mode'] == 'server' assert overridden['tabular_generation_plan_mode'] == 'shadow' + assert overridden['tabular_semantic_validation_mode'] == 'active' + assert overridden['tabular_semantic_repair_max_attempts'] == 5 + assert overridden['tabular_semantic_repair_max_rows'] == 500 assert overridden['enable_tabular_generation_plan'] is True assert overridden['enable_tabular_compact_response_protocol'] is True assert overridden['enable_tabular_completion_driven_checkpointing'] is True @@ -2614,6 +2753,202 @@ def test_phase_three_plan_contract_is_bounded_immutable_and_private(): raise AssertionError('Plan mutation must fail canonical hash validation') +def test_phase_7b_generation_plan_persists_reviewed_transformation_contract(): + """Active plans persist reviewed deterministic and semantic field ownership.""" + helpers, _, _ = _load_generation_plan_helpers() + run = _build_phase_three_test_run(plan_mode='active') + input_contract = helpers['_build_tabular_generation_plan_input_contract']( + run['_test_batches'][0] + run['_test_batches'][1] + ) + plan = helpers['_build_tabular_generation_plan']( + run, + { + 'output_fields': [ + { + 'name': 'answer', + 'description': 'Copy the source comment exactly.', + 'type': 'string', + 'nullable': False, + 'source': 'server', + }, + { + 'name': 'risk', + 'description': 'Semantic risk classification for the source row.', + 'type': 'string', + 'nullable': False, + 'source': 'llm', + }, + ], + 'transformation_spec': { + 'version': 'tabular-transform-v1', + 'fields': [ + { + 'name': 'answer', + 'mode': 'deterministic', + 'type': 'string', + 'nullable': False, + 'expression': {'op': 'copy', 'source': 'Comment'}, + }, + { + 'name': 'risk', + 'mode': 'semantic', + 'type': 'string', + 'nullable': False, + 'allowed_values': ['High', 'Medium', 'Low'], + }, + ], + }, + }, + input_contract, + { + 'endpoint_id': 'endpoint-1', + 'model_id': 'gpt-plan', + 'deployment': 'gpt-plan', + }, + created_at='2026-08-12T12:00:00+00:00', + ) + reviewed_plan = helpers['_finalize_tabular_generation_plan_review']( + plan, + { + 'status': 'passed', + 'represented_fields': ['answer', 'risk'], + 'reason_codes': [], + }, + { + 'endpoint_id': 'endpoint-1', + 'model_id': 'gpt-review', + 'deployment': 'gpt-review', + }, + ) + + assert reviewed_plan['version'] == 2 + assert reviewed_plan['review']['status'] == 'passed' + assert reviewed_plan['transformation_spec']['deterministic_field_order'] == ['answer'] + assert reviewed_plan['transformation_spec']['field_mode_counts'] == { + 'deterministic': 1, + 'hybrid': 0, + 'semantic': 1, + } + assert [ + field['name'] + for field in helpers['_get_tabular_generation_plan_llm_fields'](reviewed_plan) + ] == ['risk'] + assert [ + field['name'] + for field in helpers['_get_tabular_generation_plan_public_fields'](reviewed_plan) + ] == ['answer', 'risk'] + helpers['_validate_tabular_generation_plan']( + reviewed_plan, + run, + input_schema_hash=input_contract['input_schema_hash'], + ) + + helpers['_apply_active_tabular_generation_plan'](run, reviewed_plan) + assert run['output_schema'] == [ + 'source_row_number', + 'source_row_identity', + 'answer', + 'risk', + ] + assert run['public_output_schema'] == ['answer', 'risk'] + assert run['transformation_spec'] == reviewed_plan['transformation_spec'] + + missing_contract_run = _build_phase_three_test_run(plan_mode='active') + missing_contract_run.pop('tabular_planner_metadata') + try: + helpers['_apply_active_tabular_generation_plan'](missing_contract_run, reviewed_plan) + except ValueError as exc: + assert 'deliverable contract' in str(exc).lower() + else: + raise AssertionError('Active v2 plans must not run without a deliverable contract') + + +def test_phase_7b_planner_requires_independent_review_invocation(): + """The planner cannot return a persistable plan without a separate review call.""" + helpers, _, _ = _load_generation_plan_helpers() + run = _build_phase_three_test_run(plan_mode='active') + input_contract = helpers['_build_tabular_generation_plan_input_contract']( + run['_test_batches'][0] + run['_test_batches'][1] + ) + planner_payload = { + 'output_fields': [ + { + 'name': 'answer', + 'description': 'Copy the source comment exactly.', + 'type': 'string', + 'nullable': False, + 'source': 'server', + }, + { + 'name': 'risk', + 'description': 'Semantic risk classification for the source row.', + 'type': 'string', + 'nullable': False, + 'source': 'llm', + }, + ], + 'transformation_spec': { + 'version': 'tabular-transform-v1', + 'fields': [ + { + 'name': 'answer', + 'mode': 'deterministic', + 'type': 'string', + 'nullable': False, + 'expression': {'op': 'copy', 'source': 'Comment'}, + }, + { + 'name': 'risk', + 'mode': 'semantic', + 'type': 'string', + 'nullable': False, + 'allowed_values': ['High', 'Medium', 'Low'], + }, + ], + }, + } + review_payload = { + 'status': 'passed', + 'represented_fields': ['answer', 'risk'], + 'reason_codes': [], + } + + class PlanAndReviewService: + def __init__(self): + self.service_ids = [] + self.chat_histories = [] + + async def get_chat_message_contents(self, chat_history, execution_settings): + self.service_ids.append(execution_settings.kwargs.get('service_id')) + self.chat_histories.append(list(chat_history.messages)) + payload = planner_payload if len(self.service_ids) == 1 else review_payload + return [SimpleNamespace( + content=json.dumps(payload), + metadata={'usage': {'prompt_tokens': 10, 'completion_tokens': 5, 'total_tokens': 15}}, + )] + + service = PlanAndReviewService() + reviewed_plan, metrics = asyncio.run(helpers['_generate_tabular_generation_plan']( + service, + run, + input_contract, + { + 'endpoint_id': 'endpoint-1', + 'model_id': 'gpt-plan', + 'deployment': 'gpt-plan', + }, + 30, + )) + + assert service.service_ids == [ + 'tabular-generated-output-background', + 'tabular-generated-output-plan-review', + ] + assert service.chat_histories[0] != service.chat_histories[1] + assert reviewed_plan['review']['status'] == 'passed' + assert metrics['review_total_token_count'] == 15 + + def test_phase_three_plan_rejects_malformed_fields_and_source_changes(): """Duplicate, reserved, excessive, unsupported, and source-mismatched plans fail closed.""" helpers, _, _ = _load_generation_plan_helpers() @@ -2741,7 +3076,7 @@ def test_phase_three_shadow_active_and_checkpoint_contracts(): def test_phase_three_planner_timeout_retries_before_fallback(): - """The bounded planner retries provider timeouts and reports a safe fallback reason.""" + """The bounded planner retries provider timeouts and reports a safe failure reason.""" helpers, PlannerError, _ = _load_generation_plan_helpers() run = _build_phase_three_test_run(plan_mode='active') _, input_contract = _build_phase_three_plan(helpers, run) @@ -2767,12 +3102,12 @@ async def get_chat_message_contents(self, chat_history, execution_settings): except PlannerError as exc: assert exc.reason == 'timeout' else: - raise AssertionError('Planner timeout exhaustion must request legacy fallback') + raise AssertionError('Planner timeout exhaustion must report bounded failure') assert planner.calls == 2 def test_phase_three_plan_persistence_boundaries_never_replan(): - """Resume recovers an immutable plan or falls back without automatically replanning.""" + """Resume recovers immutable plans and active planning failures remain fail-closed.""" helpers, PlannerError, state = _load_generation_plan_helpers() active_run = _build_phase_three_test_run(plan_mode='active', plan_status='planning') plan, _ = _build_phase_three_plan(helpers, active_run) @@ -2804,17 +3139,24 @@ async def unexpected_planner_call(*args, **kwargs): interrupted_run = _build_phase_three_test_run(plan_mode='active', plan_status='planning') state['blobs'].clear() - fallback_run = helpers['_ensure_tabular_generation_plan']( - interrupted_run, - object(), - interrupted_run['_test_batches'], - {}, - 60, - ) + try: + helpers['_ensure_tabular_generation_plan']( + interrupted_run, + object(), + interrupted_run['_test_batches'], + {}, + 60, + ) + except PlannerError as exc: + assert exc.reason == 'interrupted_before_persistence' + assert exc.failed_run['plan_status'] == 'failed' + assert exc.failed_run['plan_failure_reason'] == 'interrupted_before_persistence' + else: + raise AssertionError('Interrupted active planning must fail closed') assert planner_calls == [] - assert fallback_run['plan_status'] == 'fallback' - assert fallback_run['plan_failure_reason'] == 'interrupted_before_persistence' - assert fallback_run['output_schema'] is None + assert interrupted_run['plan_status'] == 'failed' + assert interrupted_run['plan_failure_reason'] == 'interrupted_before_persistence' + assert interrupted_run['output_schema'] is None pending_run = _build_phase_three_test_run(plan_mode='active', plan_status='pending') @@ -2823,16 +3165,22 @@ async def timed_out_planner(*args, **kwargs): raise PlannerError('timeout') helpers['_generate_tabular_generation_plan'] = timed_out_planner - timeout_fallback_run = helpers['_ensure_tabular_generation_plan']( - pending_run, - object(), - pending_run['_test_batches'], - {}, - 60, - ) - assert timeout_fallback_run['plan_status'] == 'fallback' - assert timeout_fallback_run['plan_failure_reason'] == 'timeout' - assert timeout_fallback_run['output_schema'] is None + try: + helpers['_ensure_tabular_generation_plan']( + pending_run, + object(), + pending_run['_test_batches'], + {}, + 60, + ) + except PlannerError as exc: + assert exc.reason == 'timeout' + assert exc.failed_run['plan_status'] == 'failed' + assert exc.failed_run['plan_failure_reason'] == 'timeout' + else: + raise AssertionError('Timed-out active planning must fail closed') + assert pending_run['plan_status'] == 'planning' + assert pending_run['output_schema'] is None upload_helpers, _, upload_state = _load_generation_plan_helpers() new_run = _build_phase_three_test_run(plan_mode='active', plan_status='pending') @@ -2863,7 +3211,7 @@ async def successful_planner(*args, **kwargs): assert len(successful_planner_calls) == 1 assert len(upload_state['uploads']) == 1 assert upload_state['uploads'][0]['overwrite'] is False - assert upload_state['uploads'][0]['path'].endswith('/plan/plan_v1.json') + assert upload_state['uploads'][0]['path'].endswith('/plan/plan_v2.json') assert upload_state['uploads'][0]['metadata']['plan_hash'] == new_plan['plan_hash'] assert planned_run['plan_status'] == 'ready' assert planned_run['output_schema'][-2:] == ['answer', 'risk'] @@ -2882,6 +3230,43 @@ async def ready_state_planner(*args, **kwargs): assert reloaded_run['plan_hash'] == new_plan['plan_hash'] assert len(upload_state['uploads']) == 1 + legacy_run = _build_phase_three_test_run(plan_mode='active', plan_status='ready') + legacy_plan = json.loads(json.dumps(new_plan)) + legacy_plan['version'] = 1 + legacy_plan['prompt_version'] = 'tabular-generation-plan-v1' + legacy_plan.pop('transformation_spec') + legacy_plan.pop('review') + for output_field in legacy_plan['output_fields'][2:]: + output_field['source'] = 'llm' + legacy_plan['plan_hash'] = upload_helpers['_hash_tabular_generation_plan'](legacy_plan) + legacy_plan_path = upload_helpers['_tabular_generation_plan_blob_path']( + legacy_run['user_id'], + legacy_run['conversation_id'], + legacy_run['id'], + plan_version=1, + ) + upload_state['blobs'][legacy_plan_path] = legacy_plan + legacy_run.update({ + 'plan_blob_path': legacy_plan_path, + 'plan_hash': legacy_plan['plan_hash'], + }) + legacy_contract_before = json.loads(json.dumps( + legacy_run['tabular_planner_metadata']['deliverable_contract'] + )) + recovered_legacy_run = upload_helpers['_ensure_tabular_generation_plan']( + legacy_run, + object(), + legacy_run['_test_batches'], + {}, + 60, + ) + assert recovered_legacy_run['plan_blob_path'].endswith('/plan/plan_v1.json') + assert recovered_legacy_run['output_schema'][-2:] == ['answer', 'risk'] + assert ( + recovered_legacy_run['tabular_planner_metadata']['deliverable_contract'] + == legacy_contract_before + ) + mismatched_run = dict(planned_run) mismatched_run['plan_hash'] = '0' * 64 try: @@ -2956,7 +3341,10 @@ def test_phase_four_compact_protocol_requires_active_plan_rollout(): try: helpers['_build_tabular_generation_plan']( invalid_run, - {'output_fields': plan['output_fields'][2:]}, + { + 'output_fields': plan['output_fields'][2:], + 'transformation_spec': plan['transformation_spec'], + }, input_contract, {'model_id': 'gpt-plan', 'deployment': 'gpt-plan'}, ) @@ -4573,6 +4961,7 @@ def _build_source_authorization_from_location(container_name, blob_path, blob_ve fake_module.TabularProcessingPlugin = FakeWorkbookPlugin sys.modules['semantic_kernel_plugins.tabular_processing_plugin'] = fake_module queued_runs = [] + log_events = [] try: helpers = _load_direct_source_queue_helpers({ '_safe_int': lambda value: int(value or 0), @@ -4580,7 +4969,7 @@ def _build_source_authorization_from_location(container_name, blob_path, blob_ve 'max_rows': 60, 'max_chars': 60000, }, - '_get_tabular_generated_output_task_type': lambda *args: None, + '_get_tabular_generated_output_task_type': lambda *args, **kwargs: None, 'question_requests_tabular_generated_output': lambda question: True, 'question_requests_tabular_hierarchical_analysis': lambda question: False, 'get_tabular_generated_output_format': lambda question: 'csv', @@ -4601,7 +4990,7 @@ def _build_source_authorization_from_location(container_name, blob_path, blob_ve 'status': 'failed', }, 'logging': logging, - 'log_event': lambda *args, **kwargs: None, + 'log_event': lambda *args, **kwargs: log_events.append((args, kwargs)), }) for source_format in ('xlsx', 'xls', 'xlsm'): output_metadata = helpers['maybe_queue_direct_tabular_generated_output']( @@ -4615,7 +5004,7 @@ def _build_source_authorization_from_location(container_name, blob_path, blob_ve gpt_model='test-model', settings={}, ) - assert output_metadata['background_export'] is True + assert output_metadata.get('background_export') is True, log_events finally: if original_module is None: sys.modules.pop('semantic_kernel_plugins.tabular_processing_plugin', None) @@ -4702,7 +5091,10 @@ def _query_csv_data_in_bounded_chunks(self, container_name, blob_path, filename, 'max_rows': 60, 'max_chars': 60000, }, - '_get_tabular_generated_output_task_type': lambda generated, analysis, settings: 'combined' if generated and analysis else None, + '_get_tabular_generated_output_task_type': ( + lambda generated, analysis, settings, action_mode=None: + 'combined' if generated and analysis else None + ), 'question_requests_tabular_generated_output': lambda question: True, 'question_requests_tabular_hierarchical_analysis': lambda question: True, 'get_tabular_generated_output_format': lambda question: 'csv', @@ -4783,7 +5175,9 @@ def _resolve_blob_location_with_fallback(self, *args, **kwargs): 'max_rows': 60, 'max_chars': 60000, }, - '_get_tabular_generated_output_task_type': lambda generated, analysis, settings: None, + '_get_tabular_generated_output_task_type': ( + lambda generated, analysis, settings, action_mode=None: None + ), 'question_requests_tabular_generated_output': lambda question: True, 'question_requests_tabular_hierarchical_analysis': lambda question: False, 'get_tabular_generated_output_format': lambda question: 'csv', @@ -5666,8 +6060,10 @@ def test_runner_routes_combined_analysis_and_export_once(): ) assert '_analysis_chunk_summary_blob_path' in load_summaries_source assert "'generated_artifacts': generated_artifacts" in public_status_source - assert "'structured_export_artifact': run.get('structured_export_artifact')" in public_status_source - assert "'analysis_artifact': run.get('analysis_artifact')" in public_status_source + assert "'structured_export_artifact': structured_export_public_artifact" in public_status_source + assert "'analysis_artifact': analysis_public_artifact" in public_status_source + assert "'structured_export_artifact': run.get('structured_export_artifact')" not in public_status_source + assert "'analysis_artifact': run.get('analysis_artifact')" not in public_status_source assert 'analysis_generated_file_name' in queue_source assert '_generate_combined_chunk_result_window' in export_source assert 'tabular_combined_analysis_summary' in export_source @@ -5983,6 +6379,8 @@ def main(): test_phase_one_observability_uses_safe_metrics_not_response_content, test_phase_one_fake_harnesses_control_completion_order_and_storage_failures, test_phase_three_plan_contract_is_bounded_immutable_and_private, + test_phase_7b_generation_plan_persists_reviewed_transformation_contract, + test_phase_7b_planner_requires_independent_review_invocation, test_phase_three_plan_rejects_malformed_fields_and_source_changes, test_phase_three_shadow_active_and_checkpoint_contracts, test_phase_three_planner_timeout_retries_before_fallback, @@ -6042,4 +6440,4 @@ def main(): if __name__ == '__main__': - sys.exit(0 if main() else 1) \ No newline at end of file + sys.exit(0 if main() else 1) diff --git a/functional_tests/test_tabular_semantic_validation_phase7b.py b/functional_tests/test_tabular_semantic_validation_phase7b.py new file mode 100644 index 000000000..e51bb2c17 --- /dev/null +++ b/functional_tests/test_tabular_semantic_validation_phase7b.py @@ -0,0 +1,663 @@ +# test_tabular_semantic_validation_phase7b.py +#!/usr/bin/env python3 +""" +Functional test for Phase 7B semantic field verification and targeted repair. +Version: 0.250.182 +Implemented in: 0.250.179; shadow verifier fail-open compatibility updated in 0.250.182 + +This test ensures verifier output is exact and bounded, repairs only failed or +uncertain row fields, and persists only safe aggregate counts. +""" + +import asyncio +import ast +import hashlib +import json +import logging +import sys +import time +from pathlib import Path +from types import SimpleNamespace + + +ROOT = Path(__file__).resolve().parents[1] +APP_ROOT = ROOT / "application" / "single_app" +if str(APP_ROOT) not in sys.path: + sys.path.insert(0, str(APP_ROOT)) + +from functions_tabular_semantic_validation import ( # noqa: E402 + TABULAR_SEMANTIC_VALIDATION_CONTRACT_VERSION, + TabularSemanticValidationError, + apply_semantic_repair_response, + build_safe_semantic_validation_counts, + build_semantic_verification_request, + collect_semantic_repair_targets, + normalize_semantic_verification_response, + verify_and_repair_semantic_rows, +) +from functions_analysis_deliverables import is_analysis_internal_lineage_field # noqa: E402 +from test_support.versioning import assert_app_version_at_least # noqa: E402 + + +IMPLEMENTED_VERSION = "0.250.182" +EXPORT_MODULE = APP_ROOT / "functions_tabular_generated_exports.py" + + +def _load_runner_semantic_helpers(): + source = EXPORT_MODULE.read_text(encoding="utf-8") + tree = ast.parse(source, filename=str(EXPORT_MODULE)) + helper_names = { + "_safe_int", + "_safe_float", + "_dump_generated_output_json", + "_get_tabular_generation_plan_public_fields", + "_build_tabular_semantic_field_guidance", + "_build_tabular_semantic_verification_prompt", + "_build_tabular_semantic_repair_prompt", + "_invoke_tabular_semantic_model", + "_verify_and_repair_tabular_batch_entries", + "_generate_batch_entries_for_window", + "_semantic_candidate_blob_path", + "_get_tabular_semantic_checkpoint_contract_hash", + "_build_tabular_semantic_checkpoint_context", + "_persist_tabular_semantic_candidate_checkpoint", + "_load_tabular_semantic_candidate_checkpoint", + } + selected_nodes = [ + node + for node in tree.body + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name in helper_names + ] + if len(selected_nodes) != len(helper_names): + raise AssertionError("Missing runner semantic validation helpers") + + class ChatHistory: + def __init__(self): + self.messages = [] + + def add_system_message(self, message): + self.messages.append(("system", message)) + + def add_user_message(self, message): + self.messages.append(("user", message)) + + class ExecutionSettings: + def __init__(self, **kwargs): + self.kwargs = kwargs + + blobs = {} + + def upload_json_blob(path, payload, metadata=None, overwrite=True): + del metadata, overwrite + blobs[path] = payload + + namespace = { + "asyncio": asyncio, + "hashlib": hashlib, + "json": json, + "logging": logging, + "time": time, + "SKChatHistory": ChatHistory, + "AzureChatPromptExecutionSettings": ExecutionSettings, + "TABULAR_EXPORT_DEFAULT_BATCH_TIMEOUT_SECONDS": 120, + "TABULAR_EXPORT_OUTPUT_ROW_NUMBER_FIELD": "source_row_number", + "TABULAR_EXPORT_OUTPUT_ROW_IDENTITY_FIELD": "source_row_identity", + "TABULAR_GENERATION_PLAN_MAX_QUESTION_CHARS": 24000, + "TABULAR_SEMANTIC_MAX_PROMPT_CHARS": 180000, + "TABULAR_SEMANTIC_CANDIDATE_CHECKPOINT_VERSION": 1, + "TABULAR_SEMANTIC_VALIDATION_CONTRACT_VERSION": TABULAR_SEMANTIC_VALIDATION_CONTRACT_VERSION, + "is_analysis_internal_lineage_field": is_analysis_internal_lineage_field, + "verify_and_repair_semantic_rows": verify_and_repair_semantic_rows, + "_parse_generated_json_object": lambda content: json.loads(content), + "_build_generated_batch_summary": lambda entries: {"row_count": len(entries)}, + "_blob_exists": lambda path: path in blobs, + "_download_json_blob": lambda path: blobs[path], + "_upload_json_blob": upload_json_blob, + "log_event": lambda *args, **kwargs: None, + } + module = ast.Module(body=selected_nodes, type_ignores=[]) + ast.fix_missing_locations(module) + exec(compile(module, str(EXPORT_MODULE), "exec"), namespace) + return namespace + + +def _transformation_spec(): + return { + "version": "tabular-transform-v1", + "fields": [ + { + "name": "Item_ID", + "mode": "deterministic", + "type": "string", + "nullable": False, + "expression": {"op": "copy", "source": "Item_ID"}, + }, + { + "name": "Risk", + "mode": "semantic", + "type": "string", + "nullable": False, + "allowed_values": ["High", "Medium", "Low"], + }, + ], + } + + +def test_semantic_verifier_and_targeted_repair_contract(): + assert_app_version_at_least(IMPLEMENTED_VERSION) + source_rows = [ + {"Item_ID": "A", "Narrative": "Urgent unresolved issue"}, + {"Item_ID": "B", "Narrative": "Routine review"}, + ] + output_rows = [ + {"Item_ID": "A", "Risk": "Low"}, + {"Item_ID": "B", "Risk": "Low"}, + ] + request = build_semantic_verification_request(source_rows, output_rows, _transformation_spec()) + report = normalize_semantic_verification_response( + { + "version": TABULAR_SEMANTIC_VALIDATION_CONTRACT_VERSION, + "rows": [ + { + "row_key": "r1", + "fields": [{ + "name": "Risk", + "status": "fail", + "reason_code": "source_conflict", + "evidence_fields": ["Narrative"], + }], + }, + { + "row_key": "r2", + "fields": [{ + "name": "Risk", + "status": "pass", + "reason_code": "source_supported", + "evidence_fields": ["Narrative"], + }], + }, + ], + }, + request, + ) + targets = collect_semantic_repair_targets(report) + assert targets == [{"row_key": "r1", "field_name": "Risk", "reason_code": "source_conflict"}] + + repaired_rows = apply_semantic_repair_response( + output_rows, + { + "version": TABULAR_SEMANTIC_VALIDATION_CONTRACT_VERSION, + "rows": [{"row_key": "r1", "values": {"Risk": "High"}}], + }, + targets, + _transformation_spec(), + ) + assert repaired_rows == [ + {"Item_ID": "A", "Risk": "High"}, + {"Item_ID": "B", "Risk": "Low"}, + ] + assert build_safe_semantic_validation_counts(report, targets, 1) == { + "pass_count": 1, + "fail_count": 1, + "uncertain_count": 0, + "unsupported_count": 0, + "repair_target_count": 1, + "repair_attempt_count": 1, + } + + +def test_semantic_repair_rejects_extra_or_invalid_fields(): + assert_app_version_at_least(IMPLEMENTED_VERSION) + rows = [{"Item_ID": "A", "Risk": "Low"}] + targets = [{"row_key": "r1", "field_name": "Risk", "reason_code": "source_conflict"}] + invalid_payloads = [ + { + "version": TABULAR_SEMANTIC_VALIDATION_CONTRACT_VERSION, + "rows": [{"row_key": "r1", "values": {"Risk": "Critical"}}], + }, + { + "version": TABULAR_SEMANTIC_VALIDATION_CONTRACT_VERSION, + "rows": [{"row_key": "r1", "values": {"Risk": "High", "Item_ID": "B"}}], + }, + ] + for payload in invalid_payloads: + try: + apply_semantic_repair_response(rows, payload, targets, _transformation_spec()) + except TabularSemanticValidationError: + continue + raise AssertionError("Invalid semantic repair payload was accepted") + + number_spec = { + "version": "tabular-transform-v1", + "fields": [{"name": "Score", "mode": "semantic", "type": "number", "nullable": False}], + } + try: + apply_semantic_repair_response( + [{"Score": 0}], + { + "version": TABULAR_SEMANTIC_VALIDATION_CONTRACT_VERSION, + "rows": [{"row_key": "r1", "values": {"Score": float("nan")}}], + }, + [{"row_key": "r1", "field_name": "Score", "reason_code": "invalid_number"}], + number_spec, + ) + except TabularSemanticValidationError: + pass + else: + raise AssertionError("Non-finite semantic repair values must be rejected") + + +def test_active_semantic_validation_repairs_then_reverifies(): + assert_app_version_at_least(IMPLEMENTED_VERSION) + source_rows = [{"Item_ID": "A", "Narrative": "Urgent unresolved issue"}] + output_rows = [{"Item_ID": "A", "Risk": "Low"}] + verifier_calls = [] + repair_calls = [] + + async def invoke_verifier(request): + verifier_calls.append(request) + status = "fail" if request["rows"][0]["candidate"]["Risk"] == "Low" else "pass" + return { + "version": TABULAR_SEMANTIC_VALIDATION_CONTRACT_VERSION, + "rows": [{ + "row_key": "r1", + "fields": [{ + "name": "Risk", + "status": status, + "reason_code": "source_conflict" if status == "fail" else "source_supported", + "evidence_fields": ["Narrative"], + }], + }], + } + + async def invoke_repair(request, targets, attempt_number): + repair_calls.append((request, targets, attempt_number)) + return { + "version": TABULAR_SEMANTIC_VALIDATION_CONTRACT_VERSION, + "rows": [{"row_key": "r1", "values": {"Risk": "High"}}], + } + + repaired_rows, counts, attempts = asyncio.run(verify_and_repair_semantic_rows( + source_rows, + output_rows, + _transformation_spec(), + "active", + invoke_verifier, + invoke_repair, + max_repair_attempts=2, + max_repair_rows=10, + )) + assert repaired_rows[0]["Risk"] == "High" + assert len(verifier_calls) == 2 + assert len(repair_calls) == 1 + assert counts["pass_count"] == 1 + assert counts["repair_attempt_count"] == 1 + assert attempts[-1]["fail_count"] == 0 + + +def test_runner_invokes_verifier_and_repair_before_checkpoint_boundary(): + assert_app_version_at_least(IMPLEMENTED_VERSION) + helpers = _load_runner_semantic_helpers() + + class SemanticModel: + def __init__(self): + self.service_ids = [] + + async def get_chat_message_contents(self, chat_history, execution_settings): + del chat_history + service_id = execution_settings.kwargs["service_id"] + self.service_ids.append(service_id) + if self.service_ids == ["tabular-generated-output-semantic-verifier"]: + payload = { + "version": TABULAR_SEMANTIC_VALIDATION_CONTRACT_VERSION, + "rows": [{ + "row_key": "r1", + "fields": [{ + "name": "Risk", + "status": "fail", + "reason_code": "source_conflict", + "evidence_fields": ["Narrative"], + }], + }], + } + elif service_id == "tabular-generated-output-semantic-repair": + payload = { + "version": TABULAR_SEMANTIC_VALIDATION_CONTRACT_VERSION, + "rows": [{"row_key": "r1", "values": {"Risk": "High"}}], + } + else: + payload = { + "version": TABULAR_SEMANTIC_VALIDATION_CONTRACT_VERSION, + "rows": [{ + "row_key": "r1", + "fields": [{ + "name": "Risk", + "status": "pass", + "reason_code": "source_supported", + "evidence_fields": ["Narrative"], + }], + }], + } + return [SimpleNamespace(content=json.dumps(payload))] + + model = SemanticModel() + generation_plan = { + "output_fields": [ + { + "name": "source_row_number", + "description": "Server row number.", + "type": "integer", + "nullable": False, + "source": "server", + }, + { + "name": "source_row_identity", + "description": "Server row identity.", + "type": "string", + "nullable": False, + "source": "server", + }, + { + "name": "Risk", + "description": "Risk supported by the narrative evidence.", + "type": "string", + "nullable": False, + "source": "llm", + }, + ], + } + repaired_rows, counts, _attempts = asyncio.run( + helpers["_verify_and_repair_tabular_batch_entries"]( + model, + "Classify risk from the narrative.", + [{ + "Narrative": "Urgent unresolved issue", + "__simplechat_source_row_number": 1, + "__simplechat_source_row_identity": "A", + }], + [{"source_row_number": 1, "source_row_identity": "A", "Risk": "Low"}], + _transformation_spec(), + generation_plan, + {"mode": "active", "max_repair_attempts": 2, "max_repair_rows": 10}, + 30, + ) + ) + assert repaired_rows[0]["Risk"] == "High" + assert counts["pass_count"] == 1 + assert model.service_ids == [ + "tabular-generated-output-semantic-verifier", + "tabular-generated-output-semantic-repair", + "tabular-generated-output-semantic-verifier", + ] + + tree = ast.parse(EXPORT_MODULE.read_text(encoding="utf-8"), filename=str(EXPORT_MODULE)) + generate_function = next( + node + for node in tree.body + if isinstance(node, ast.AsyncFunctionDef) and node.name == "_generate_batch_entries" + ) + called_functions = { + call.func.id + for call in ast.walk(generate_function) + if isinstance(call, ast.Call) and isinstance(call.func, ast.Name) + } + assert "_verify_and_repair_tabular_batch_entries" in called_functions + assert "_checkpoint_generated_batch_results" not in called_functions + + +def test_shadow_semantic_validation_observes_without_repairing(): + assert_app_version_at_least(IMPLEMENTED_VERSION) + repair_calls = [] + + async def invoke_verifier(request): + del request + return { + "version": TABULAR_SEMANTIC_VALIDATION_CONTRACT_VERSION, + "rows": [{ + "row_key": "r1", + "fields": [{ + "name": "Risk", + "status": "uncertain", + "reason_code": "insufficient_evidence", + "evidence_fields": ["Narrative"], + }], + }], + } + + async def invoke_repair(*args): + repair_calls.append(args) + raise AssertionError("Shadow validation must not repair rows") + + rows = [{"Item_ID": "A", "Risk": "Low"}] + observed_rows, counts, attempts = asyncio.run(verify_and_repair_semantic_rows( + [{"Item_ID": "A", "Narrative": "Ambiguous evidence"}], + rows, + _transformation_spec(), + "shadow", + invoke_verifier, + invoke_repair, + )) + assert observed_rows == rows + assert repair_calls == [] + assert attempts == [] + assert counts["uncertain_count"] == 1 + assert counts["repair_target_count"] == 1 + + +def test_shadow_semantic_validation_fails_open_on_verifier_errors(): + assert_app_version_at_least("0.250.182") + + async def invoke_verifier(request): + del request + raise TimeoutError("verifier timed out") + + async def invoke_repair(*args): + raise AssertionError("Shadow validation must not repair rows after verifier failure") + + rows = [{"Item_ID": "A", "Risk": "Low"}] + observed_rows, counts, attempts = asyncio.run(verify_and_repair_semantic_rows( + [{"Item_ID": "A", "Narrative": "Ambiguous evidence"}], + rows, + _transformation_spec(), + "shadow", + invoke_verifier, + invoke_repair, + )) + assert observed_rows == rows + assert attempts == [] + assert counts == { + "pass_count": 0, + "fail_count": 0, + "uncertain_count": 0, + "unsupported_count": 0, + "repair_target_count": 0, + "repair_attempt_count": 0, + } + + +def test_active_semantic_repair_exhaustion_fails_closed(): + assert_app_version_at_least(IMPLEMENTED_VERSION) + repair_values = iter(["High", "Medium"]) + + async def invoke_verifier(request): + del request + return { + "version": TABULAR_SEMANTIC_VALIDATION_CONTRACT_VERSION, + "rows": [{ + "row_key": "r1", + "fields": [{ + "name": "Risk", + "status": "fail", + "reason_code": "source_conflict", + "evidence_fields": ["Narrative"], + }], + }], + } + + async def invoke_repair(request, targets, attempt_number): + del request, targets, attempt_number + return { + "version": TABULAR_SEMANTIC_VALIDATION_CONTRACT_VERSION, + "rows": [{"row_key": "r1", "values": {"Risk": next(repair_values)}}], + } + + try: + asyncio.run(verify_and_repair_semantic_rows( + [{"Item_ID": "A", "Narrative": "Conflicting evidence"}], + [{"Item_ID": "A", "Risk": "Low"}], + _transformation_spec(), + "active", + invoke_verifier, + invoke_repair, + max_repair_attempts=2, + max_repair_rows=10, + )) + except TabularSemanticValidationError as exc: + assert "exhausted" in str(exc).lower() + else: + raise AssertionError("Unresolved semantic failures must not reach checkpointing") + + +def test_batch_wrapper_persists_only_safe_semantic_counts(): + assert_app_version_at_least(IMPLEMENTED_VERSION) + helpers = _load_runner_semantic_helpers() + + async def generate_batch_entries(*args, **kwargs): + del args, kwargs + return ( + [{"source_row_number": 1, "source_row_identity": "A", "Risk": "High"}], + 0, + ["source_row_number", "source_row_identity", "Risk"], + { + "semantic_validation_counts": { + "pass_count": 1, + "fail_count": 0, + "uncertain_count": 0, + "unsupported_count": 0, + "repair_target_count": 0, + "repair_attempt_count": 1, + }, + "semantic_validation_attempts": [{ + "pass_count": 1, + "fail_count": 0, + "uncertain_count": 0, + "unsupported_count": 0, + "repair_target_count": 0, + "repair_attempt_count": 1, + }], + }, + ) + + helpers["_generate_batch_entries"] = generate_batch_entries + result = asyncio.run(helpers["_generate_batch_entries_for_window"]( + asyncio.Semaphore(1), + object(), + "Classify risk.", + {"batch_number": 1, "rows": [{"Narrative": "Urgent"}]}, + 1, + "source.csv", + None, + 1, + "run-1", + ["source_row_number", "source_row_identity", "Risk"], + 30, + "object-v1", + None, + _transformation_spec(), + {"mode": "active"}, + None, + )) + assert result["semantic_validation_counts"]["pass_count"] == 1 + assert result["batch_summary"]["semantic_validation"]["final"]["repair_attempt_count"] == 1 + assert "Narrative" not in json.dumps(result["batch_summary"], sort_keys=True) + + +def test_semantic_candidate_checkpoint_is_restart_safe_and_plan_fenced(): + assert_app_version_at_least(IMPLEMENTED_VERSION) + helpers = _load_runner_semantic_helpers() + run = { + "id": "run-1", + "user_id": "user-1", + "conversation_id": "conversation-1", + "plan_hash": "a" * 64, + } + context = helpers["_build_tabular_semantic_checkpoint_context"](run, 1) + rows = [{"source_row_number": 1, "source_row_identity": "A", "Risk": "High"}] + schema = ["source_row_number", "source_row_identity", "Risk"] + helpers["_persist_tabular_semantic_candidate_checkpoint"]( + context, + schema, + rows, + {"pass_count": 1, "repair_attempt_count": 1}, + 1, + ) + checkpoint = helpers["_load_tabular_semantic_candidate_checkpoint"]( + context, + schema, + 1, + ) + assert checkpoint["rows"] == rows + assert checkpoint["repair_attempt_count"] == 1 + assert checkpoint["validation_counts"] == {"pass_count": 1, "repair_attempt_count": 1} + + wrong_plan_context = {**context, "plan_hash": "b" * 64} + try: + helpers["_load_tabular_semantic_candidate_checkpoint"]( + wrong_plan_context, + schema, + 1, + ) + except ValueError as exc: + assert "plan hash" in str(exc).lower() + else: + raise AssertionError("A stale semantic candidate must not cross plan generations") + + supplied_contract_run = { + "id": "run-2", + "user_id": "user-1", + "conversation_id": "conversation-1", + "plan_hash": None, + "public_output_schema": ["Risk"], + "transformation_spec": _transformation_spec(), + } + supplied_context = helpers["_build_tabular_semantic_checkpoint_context"]( + supplied_contract_run, + 1, + ) + assert len(supplied_context["plan_hash"]) == 64 + + tree = ast.parse(EXPORT_MODULE.read_text(encoding="utf-8"), filename=str(EXPORT_MODULE)) + generate_function = next( + node + for node in tree.body + if isinstance(node, ast.AsyncFunctionDef) and node.name == "_generate_batch_entries" + ) + generate_source = ast.unparse(generate_function) + assert generate_source.index("_load_tabular_semantic_candidate_checkpoint") < generate_source.index( + "_build_batch_prompt" + ) + + +if __name__ == "__main__": + tests = [ + test_semantic_verifier_and_targeted_repair_contract, + test_semantic_repair_rejects_extra_or_invalid_fields, + test_active_semantic_validation_repairs_then_reverifies, + test_runner_invokes_verifier_and_repair_before_checkpoint_boundary, + test_shadow_semantic_validation_observes_without_repairing, + test_shadow_semantic_validation_fails_open_on_verifier_errors, + test_active_semantic_repair_exhaustion_fails_closed, + test_batch_wrapper_persists_only_safe_semantic_counts, + test_semantic_candidate_checkpoint_is_restart_safe_and_plan_fenced, + ] + results = [] + for test in tests: + try: + test() + print(f"PASS {test.__name__}") + results.append(True) + except Exception as exc: + print(f"FAIL {test.__name__}: {exc}") + results.append(False) + print(f"Results: {sum(results)}/{len(results)} tests passed") + sys.exit(0 if all(results) else 1) diff --git a/functional_tests/test_tabular_shared_request_planner.py b/functional_tests/test_tabular_shared_request_planner.py index d82798f0e..b4c35256d 100644 --- a/functional_tests/test_tabular_shared_request_planner.py +++ b/functional_tests/test_tabular_shared_request_planner.py @@ -1,8 +1,8 @@ # test_tabular_shared_request_planner.py """ Functional test for the shared tabular request planner. -Version: 0.250.167 -Implemented in: 0.250.158; Phase 6 execution units added in 0.250.162; rollout and fingerprint hardening in 0.250.167 +Version: 0.250.177 +Implemented in: 0.250.158; Phase 6 execution units added in 0.250.162; rollout and fingerprint hardening in 0.250.167; Phase 7 harness compatibility in 0.250.177 This test ensures Phase 2 tabular request planning classifies Search and Analyze caller metadata through one route-neutral planner before row retrieval. @@ -34,16 +34,15 @@ def install_lightweight_planner_dependency_stubs(): ) generated_exports_module = types.ModuleType("functions_generated_file_exports") - def get_requested_structured_artifact_format(prompt): + def get_requested_artifact_formats(prompt): normalized_prompt = str(prompt or "").lower() - for output_format in ("json", "xml", "csv"): - if output_format in normalized_prompt: - return output_format - return None + return [output_format for output_format in ("json", "xml", "csv") if output_format in normalized_prompt] + generated_exports_module.get_requested_artifact_formats = get_requested_artifact_formats generated_exports_module.get_requested_structured_artifact_format = ( - get_requested_structured_artifact_format + lambda prompt: next(iter(get_requested_artifact_formats(prompt)), None) ) + generated_exports_module.get_requested_structured_artifact_formats = get_requested_artifact_formats sys.modules.setdefault("functions_assistant_table_exports", assistant_exports_module) sys.modules.setdefault("functions_generated_file_exports", generated_exports_module) @@ -114,29 +113,33 @@ def test_classification_contracts(): ( "Export every row as JSON with one object per row.", TABULAR_EXECUTION_CONTRACT_STRUCTURED_EXPORT, + TABULAR_EXECUTION_CONTRACT_COMBINED, "json", "durable_intent", ), ( "Analyze all rows and summarize the risk patterns.", TABULAR_EXECUTION_CONTRACT_HIERARCHICAL_ANALYSIS, + TABULAR_EXECUTION_CONTRACT_HIERARCHICAL_ANALYSIS, None, "durable_intent", ), ( "Analyze every row and create a CSV file with one output row per source row.", TABULAR_EXECUTION_CONTRACT_COMBINED, + TABULAR_EXECUTION_CONTRACT_COMBINED, "csv", "durable_intent", ), ( "What is the average score by department?", TABULAR_EXECUTION_CONTRACT_FOREGROUND_AGGREGATE, + TABULAR_EXECUTION_CONTRACT_FOREGROUND_AGGREGATE, None, "bounded_foreground", ), ] - for prompt, expected_contract, expected_format, expected_reason in cases: + for prompt, expected_search_contract, expected_analyze_contract, expected_format, expected_reason in cases: search_plan = plan_for(prompt, caller="search") analyze_plan = plan_for(prompt, caller="analyze") assert_equal( @@ -146,19 +149,20 @@ def test_classification_contracts(): ) assert_equal( search_plan["execution_contract"], - expected_contract, + expected_search_contract, f"Search execution contract for {prompt}", ) assert_equal( analyze_plan["execution_contract"], - expected_contract, + expected_analyze_contract, f"Analyze execution contract for {prompt}", ) - assert_equal( - search_plan["durable_task_type"], - analyze_plan["durable_task_type"], - f"caller parity durable task type for {prompt}", - ) + if expected_search_contract == expected_analyze_contract: + assert_equal( + search_plan["durable_task_type"], + analyze_plan["durable_task_type"], + f"caller parity durable task type for {prompt}", + ) assert_equal(search_plan["output_format"], expected_format, "output format") assert_equal(search_plan["reason_code"], expected_reason, "reason code") diff --git a/functional_tests/test_tabular_transformations_phase4.py b/functional_tests/test_tabular_transformations_phase4.py new file mode 100644 index 000000000..2e7e9070a --- /dev/null +++ b/functional_tests/test_tabular_transformations_phase4.py @@ -0,0 +1,495 @@ +#!/usr/bin/env python3 +# test_tabular_transformations_phase4.py +""" +Functional test for Phase 4 tabular transformation correctness. +Version: 0.250.179 +Implemented in: 0.250.174; semantic object/array ownership compatibility updated in 0.250.179 + +This test ensures deterministic tabular transformation specs are bounded, +server-evaluable, persisted in deliverable contracts, and sufficient to +produce the 200-row financial review oracle without model-generated fields. +""" + +from pathlib import Path +import sys +import traceback + + +ROOT = Path(__file__).resolve().parents[1] +APP_ROOT = ROOT / "application" / "single_app" +if str(APP_ROOT) not in sys.path: + sys.path.insert(0, str(APP_ROOT)) + +from test_support.analyze_deliverable_contract_fixture import ( # noqa: E402 + FINANCIAL_REVIEW_OUTPUT_COLUMNS, + FINANCIAL_REVIEW_SOURCE_COLUMNS, + build_expected_financial_review_output_rows, + build_financial_review_source_rows, + find_value_mismatches, +) +from test_support.versioning import assert_app_version_at_least # noqa: E402 + +from functions_analysis_deliverables import ( # noqa: E402 + ANALYSIS_ORDERING_SOURCE_ORDER, + ANALYSIS_ROW_CARDINALITY_ONE_PER_SOURCE_ROW, + ANALYSIS_TRANSFORMATION_MODE_DETERMINISTIC, + ANALYSIS_VALIDATION_PROFILE_EXACT_ROWS_SCHEMA_AND_RULES, + build_analysis_deliverable_contract, + coerce_analysis_deliverable_contract, +) +from functions_tabular_orchestration import plan_tabular_request # noqa: E402 +from functions_tabular_transformations import ( # noqa: E402 + TABULAR_TRANSFORMATION_SPEC_VERSION, + TabularTransformationSpecError, + evaluate_tabular_transformation_rows, + get_tabular_transformation_model_fields, + is_tabular_transformation_deterministic_only, + normalize_tabular_transformation_spec, +) + + +IMPLEMENTED_VERSION = "0.250.174" + + +def _source(name): + return {"source": name} + + +def _field(name): + return {"field": name} + + +def _eq(left, right, value_type="", case_sensitive=True): + expression = {"op": "eq", "left": left, "right": right, "case_sensitive": case_sensitive} + if value_type: + expression["value_type"] = value_type + return expression + + +def _ne(left, right): + return {"op": "ne", "left": left, "right": right} + + +def _gte(left, right, value_type="number"): + return {"op": "gte", "left": left, "right": right, "value_type": value_type} + + +def _lt_date(left, right): + return {"op": "lt", "left": left, "right": right, "value_type": "date"} + + +def _lte_date(left, right): + return {"op": "lte", "left": left, "right": right, "value_type": "date"} + + +def _any(*values): + return {"op": "any", "values": list(values)} + + +def _all(*values): + return {"op": "all", "values": list(values)} + + +def _case(branches, else_value): + return {"op": "case", "branches": branches, "else": else_value} + + +def _branch(when, then): + return {"when": when, "then": then} + + +def build_financial_review_transformation_spec(): + return { + "version": TABULAR_TRANSFORMATION_SPEC_VERSION, + "fields": [ + { + "name": "Item_ID", + "mode": "deterministic", + "type": "string", + "nullable": False, + "expression": {"op": "copy", "source": "Item_ID"}, + }, + { + "name": "Timeline_Status", + "mode": "deterministic", + "type": "string", + "nullable": False, + "allowed_values": ["Overdue", "Due Soon", "On Track"], + "expression": _case( + [ + _branch(_lt_date(_source("Due_Date"), "2026-08-12"), "Overdue"), + _branch(_lte_date(_source("Due_Date"), "2026-09-11"), "Due Soon"), + ], + "On Track", + ), + }, + { + "name": "Spend_Risk", + "mode": "deterministic", + "type": "string", + "nullable": False, + "allowed_values": ["High Spend Risk", "Moderate Spend Risk", "Low Spend Risk"], + "expression": _case( + [ + _branch( + _any( + _gte(_source("Invoice_Amount"), 75000), + _eq(_source("Vendor_Risk"), "High"), + ), + "High Spend Risk", + ), + _branch( + _any( + _gte(_source("Invoice_Amount"), 25000), + _eq(_source("Vendor_Risk"), "Medium"), + ), + "Moderate Spend Risk", + ), + ], + "Low Spend Risk", + ), + }, + { + "name": "Control_Concern", + "mode": "deterministic", + "type": "string", + "nullable": False, + "allowed_values": ["Control Concern", "No Control Concern"], + "expression": _case( + [ + _branch( + _any( + { + "op": "in", + "value": _source("Control_Status"), + "values": ["Missing Approval", "Policy Exception"], + }, + _gte(_source("Exception_Count"), 2), + ), + "Control Concern", + ), + ], + "No Control Concern", + ), + }, + { + "name": "Owner_Response_Status", + "mode": "deterministic", + "type": "string", + "nullable": False, + "allowed_values": ["Responded", "Needs Response"], + "expression": _case( + [_branch(_eq(_source("Owner_Response"), "Received"), "Responded")], + "Needs Response", + ), + }, + { + "name": "Escalation_Required", + "mode": "deterministic", + "type": "string", + "nullable": False, + "allowed_values": ["Yes", "No"], + "expression": _case( + [ + _branch( + _any( + _eq(_source("Escalation_Flag"), "Y"), + _all( + _eq(_field("Timeline_Status"), "Overdue"), + _eq(_field("Owner_Response_Status"), "Needs Response"), + ), + ), + "Yes", + ), + ], + "No", + ), + }, + { + "name": "Overall_Attention", + "mode": "deterministic", + "type": "string", + "nullable": False, + "allowed_values": ["High Attention", "Monitor", "Low Attention"], + "expression": _case( + [ + _branch( + _any( + _eq(_field("Escalation_Required"), "Yes"), + _all( + _eq(_field("Control_Concern"), "Control Concern"), + _eq(_field("Owner_Response_Status"), "Needs Response"), + ), + _eq(_field("Spend_Risk"), "High Spend Risk"), + ), + "High Attention", + ), + _branch( + _any( + _ne(_field("Timeline_Status"), "On Track"), + _eq(_field("Spend_Risk"), "Moderate Spend Risk"), + _eq(_field("Control_Concern"), "Control Concern"), + ), + "Monitor", + ), + ], + "Low Attention", + ), + }, + { + "name": "Review_Window", + "mode": "deterministic", + "type": "string", + "nullable": False, + "allowed_values": ["Past Due", "Due Today", "Within 30 Days", "Beyond 30 Days"], + "expression": _case( + [ + _branch(_lt_date(_source("Due_Date"), "2026-08-12"), "Past Due"), + _branch(_eq(_source("Due_Date"), "2026-08-12", value_type="date"), "Due Today"), + _branch(_lte_date(_source("Due_Date"), "2026-09-11"), "Within 30 Days"), + ], + "Beyond 30 Days", + ), + }, + { + "name": "Recommended_Action", + "mode": "deterministic", + "type": "string", + "nullable": False, + "allowed_values": [ + "Escalate review", + "Review overdue item", + "Schedule follow-up", + "Routine monitoring", + ], + "expression": _case( + [ + _branch(_eq(_field("Overall_Attention"), "High Attention"), "Escalate review"), + _branch(_eq(_field("Timeline_Status"), "Overdue"), "Review overdue item"), + _branch(_eq(_field("Timeline_Status"), "Due Soon"), "Schedule follow-up"), + ], + "Routine monitoring", + ), + }, + ], + } + + +def test_financial_review_transformation_matches_oracle(): + print("Testing deterministic transformation against the 200-row financial review oracle...") + assert_app_version_at_least(IMPLEMENTED_VERSION) + + source_rows = build_financial_review_source_rows() + expected_rows = build_expected_financial_review_output_rows(source_rows) + transformation_spec = build_financial_review_transformation_spec() + normalized_spec = normalize_tabular_transformation_spec( + transformation_spec, + public_output_schema=FINANCIAL_REVIEW_OUTPUT_COLUMNS, + source_schema=FINANCIAL_REVIEW_SOURCE_COLUMNS, + ) + actual_rows = evaluate_tabular_transformation_rows(normalized_spec, source_rows) + + assert len(actual_rows) == 200 + assert list(actual_rows[0].keys()) == FINANCIAL_REVIEW_OUTPUT_COLUMNS + assert find_value_mismatches(expected_rows, actual_rows, FINANCIAL_REVIEW_OUTPUT_COLUMNS) == [] + assert normalized_spec["field_mode_counts"]["deterministic"] == 9 + assert is_tabular_transformation_deterministic_only( + normalized_spec, + public_output_schema=FINANCIAL_REVIEW_OUTPUT_COLUMNS, + ) + + +def test_transformation_spec_rejects_unsafe_and_ambiguous_contracts(): + print("Testing transformation spec safety checks...") + assert_app_version_at_least(IMPLEMENTED_VERSION) + + unsafe_spec = { + "version": TABULAR_TRANSFORMATION_SPEC_VERSION, + "fields": [{"name": "X", "mode": "deterministic", "expression": {"op": "eval", "value": "1"}}], + } + try: + normalize_tabular_transformation_spec(unsafe_spec, public_output_schema=["X"]) + except TabularTransformationSpecError: + pass + else: + raise AssertionError("Unsupported operation was not rejected") + + reserved_spec = { + "version": TABULAR_TRANSFORMATION_SPEC_VERSION, + "fields": [{"name": "__simplechat_secret", "mode": "deterministic", "expression": "x"}], + } + try: + normalize_tabular_transformation_spec(reserved_spec) + except TabularTransformationSpecError: + pass + else: + raise AssertionError("Reserved output field was not rejected") + + cycle_spec = { + "version": TABULAR_TRANSFORMATION_SPEC_VERSION, + "fields": [ + {"name": "A", "mode": "deterministic", "expression": {"field": "B"}}, + {"name": "B", "mode": "deterministic", "expression": {"field": "A"}}, + ], + } + try: + normalize_tabular_transformation_spec(cycle_spec, public_output_schema=["A", "B"]) + except TabularTransformationSpecError: + pass + else: + raise AssertionError("Derived-field cycle was not rejected") + + missing_source_spec = { + "version": TABULAR_TRANSFORMATION_SPEC_VERSION, + "fields": [{"name": "A", "mode": "deterministic", "expression": {"source": "Missing"}}], + } + try: + normalize_tabular_transformation_spec( + missing_source_spec, + public_output_schema=["A"], + source_schema=["Known"], + ) + except TabularTransformationSpecError: + pass + else: + raise AssertionError("Unknown source field was not rejected") + + +def test_contract_and_planner_persist_transformation_spec(): + print("Testing deliverable contract and planner transformation-spec persistence...") + assert_app_version_at_least(IMPLEMENTED_VERSION) + + transformation_spec = build_financial_review_transformation_spec() + contract = build_analysis_deliverable_contract( + action_mode="analyze", + requested_output_format="csv", + public_output_schema=FINANCIAL_REVIEW_OUTPUT_COLUMNS, + row_cardinality=ANALYSIS_ROW_CARDINALITY_ONE_PER_SOURCE_ROW, + ordering=ANALYSIS_ORDERING_SOURCE_ORDER, + transformation_mode=ANALYSIS_TRANSFORMATION_MODE_DETERMINISTIC, + transformation_spec=transformation_spec, + validation_profile=ANALYSIS_VALIDATION_PROFILE_EXACT_ROWS_SCHEMA_AND_RULES, + source_fingerprint="source-fixture", + request_fingerprint="request-fixture", + ) + payload = contract.to_dict() + assert payload["contract_version"] == "analysis-deliverables-v3" + assert payload["transformation_spec"]["version"] == TABULAR_TRANSFORMATION_SPEC_VERSION + assert payload["transformation_spec"]["field_mode_counts"]["deterministic"] == 9 + assert coerce_analysis_deliverable_contract(payload).to_dict() == payload + + plan = plan_tabular_request( + "Analyze every row and download the result as CSV.", + [{"file_name": "financial_review.csv", "document_id": "doc-1", "source_version": "v1"}], + action_mode="analyze", + settings={"enable_tabular_hierarchical_analysis": True}, + requested_output_hints={ + "public_output_schema": FINANCIAL_REVIEW_OUTPUT_COLUMNS, + "transformation_spec": transformation_spec, + }, + ) + planned_contract = plan["deliverable_contract"] + assert planned_contract["transformation_mode"] == ANALYSIS_TRANSFORMATION_MODE_DETERMINISTIC + assert planned_contract["validation_profile"] == ANALYSIS_VALIDATION_PROFILE_EXACT_ROWS_SCHEMA_AND_RULES + assert planned_contract["transformation_spec"]["version"] == TABULAR_TRANSFORMATION_SPEC_VERSION + assert plan["durable_task_type"] == "combined" + + +def test_model_field_selection_excludes_deterministic_fields(): + print("Testing model-owned field selection for deterministic and hybrid specs...") + assert_app_version_at_least(IMPLEMENTED_VERSION) + + deterministic_spec = normalize_tabular_transformation_spec( + build_financial_review_transformation_spec(), + public_output_schema=FINANCIAL_REVIEW_OUTPUT_COLUMNS, + ) + assert get_tabular_transformation_model_fields( + deterministic_spec, + public_output_schema=FINANCIAL_REVIEW_OUTPUT_COLUMNS, + ) == [] + + hybrid_spec = { + "version": TABULAR_TRANSFORMATION_SPEC_VERSION, + "fields": [ + {"name": "Item_ID", "mode": "deterministic", "expression": {"op": "copy", "source": "Item_ID"}}, + {"name": "Narrative", "mode": "semantic"}, + ], + } + assert get_tabular_transformation_model_fields( + hybrid_spec, + public_output_schema=["Item_ID", "Narrative"], + ) == ["Narrative"] + + +def test_semantic_object_and_array_types_do_not_expand_comparison_coercion(): + print("Testing semantic object/array field ownership and scalar comparison safety...") + assert_app_version_at_least("0.250.179") + + normalized_spec = normalize_tabular_transformation_spec( + { + "version": TABULAR_TRANSFORMATION_SPEC_VERSION, + "fields": [ + {"name": "Metadata", "mode": "semantic", "type": "object", "nullable": False}, + {"name": "Tags", "mode": "semantic", "type": "array", "nullable": True}, + ], + }, + public_output_schema=["Metadata", "Tags"], + ) + assert get_tabular_transformation_model_fields( + normalized_spec, + public_output_schema=["Metadata", "Tags"], + ) == ["Metadata", "Tags"] + + unsafe_comparison_spec = { + "version": TABULAR_TRANSFORMATION_SPEC_VERSION, + "fields": [{ + "name": "Invalid", + "mode": "deterministic", + "type": "boolean", + "expression": { + "op": "eq", + "left": {"source": "Payload"}, + "right": {"value": {}}, + "value_type": "object", + }, + }], + } + try: + normalize_tabular_transformation_spec( + unsafe_comparison_spec, + public_output_schema=["Invalid"], + source_schema=["Payload"], + ) + except TabularTransformationSpecError: + pass + else: + raise AssertionError("Object comparison coercion must remain unsupported") + + +def run_tests(): + tests = [ + test_financial_review_transformation_matches_oracle, + test_transformation_spec_rejects_unsafe_and_ambiguous_contracts, + test_contract_and_planner_persist_transformation_spec, + test_model_field_selection_excludes_deterministic_fields, + test_semantic_object_and_array_types_do_not_expand_comparison_coercion, + ] + results = [] + for test in tests: + print(f"\nRunning {test.__name__}...") + try: + test() + print("PASS") + results.append(True) + except Exception as exc: + print(f"FAIL: {exc}") + traceback.print_exc() + results.append(False) + + success = all(results) + print(f"\nResults: {sum(results)}/{len(results)} tests passed") + return success + + +if __name__ == "__main__": + sys.exit(0 if run_tests() else 1) diff --git a/ui_tests/test_chat_background_generated_export_status.py b/ui_tests/test_chat_background_generated_export_status.py index 382e7237d..8ca20efce 100644 --- a/ui_tests/test_chat_background_generated_export_status.py +++ b/ui_tests/test_chat_background_generated_export_status.py @@ -1,8 +1,8 @@ # test_chat_background_generated_export_status.py """ UI test for chat background generated export status cards. -Version: 0.250.169 -Implemented in: 0.241.046; cancellation in 0.250.060; automatic-only refresh in 0.250.061; combined progress and large-run confirmation in 0.250.131; throughput and concurrency status in 0.250.136; truthful background handoff in 0.250.138; collapsed operational details in 0.250.150; confirmation deduplication in 0.250.169 +Version: 0.250.182 +Implemented in: 0.241.046; cancellation in 0.250.060; automatic-only refresh in 0.250.061; combined progress and large-run confirmation in 0.250.131; throughput and concurrency status in 0.250.136; truthful background handoff in 0.250.138; collapsed operational details in 0.250.150; confirmation deduplication in 0.250.169; plural artifact-set completion rendering in 0.250.176; empty plural artifact-set fallback suppression in 0.250.182 This test ensures queued tabular generated exports render progress in chat and turn into a downloadable artifact when complete or a visible canceled state. @@ -29,6 +29,34 @@ HARNESS_PATH = "ui_tests/fixtures/chat_thought_progress_harness.html" +def _install_minimal_chat_dom(page) -> None: + """Install the minimum chat DOM and globals needed by chat-messages.js.""" + page.evaluate( + """ + () => { + window.appSettings = { + enable_text_to_speech: false, + enable_thoughts: false, + documentActionCapabilities: {}, + }; + window.enable_document_classification = false; + window.currentConversationId = 'conversation-ui-test'; + window.marked = { parse: value => String(value || '') }; + window.DOMPurify = { sanitize: value => String(value || '') }; + window.Prism = { highlightElement: () => {} }; + window.scrollChatToBottom = () => {}; + window.showToast = () => {}; + + const root = document.getElementById('test-root'); + root.replaceChildren(); + const chatbox = document.createElement('div'); + chatbox.id = 'chatbox'; + root.appendChild(chatbox); + } + """ + ) + + def _get_free_local_port() -> int: """Reserve an available local port for a static test server.""" with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: @@ -179,6 +207,442 @@ def test_chat_background_generated_export_status_card_auto_refreshes_to_download browser.close() + +@pytest.mark.ui +def test_chat_combined_completion_renders_plural_artifact_set(playwright) -> None: + """Validate completed combined runs render every artifact with Analyze Markdown first.""" + browser = playwright.chromium.launch() + context = browser.new_context(viewport={"width": 1440, "height": 900}) + page = context.new_page() + page_errors = [] + page.on("pageerror", lambda error: page_errors.append(str(error))) + + try: + with _start_static_test_server() as server_base_url: + page.route( + "**/api/tabular/generated-output/runs/run-plural-artifacts", + lambda route: route.fulfill( + status=200, + content_type="application/json", + json={ + "success": True, + "run": { + + "run_id": "run-plural-artifacts", + "conversation_id": "conversation-ui-test", + "task_type": "combined", + "status": "completed", + "row_count": 200, + "processed_rows": 200, + "batch_count": 4, + "completed_batches": 4, + "progress_percent": 100, + "artifact_set": { + "contract_version": "tabular-artifact-set-v1", + "set_id": "artifact-set-ui-test", + "lifecycle_state": "completed", + "validation_state": "validated", + "primary_artifact_id": "analysis-md", + "member_count": 2, + "published_member_count": 2, + "publication_generation": 1, + }, + "generated_artifacts": [ + { + "artifact_id": "requested-csv", + "role": "requested_output", + "capability": "tabular", + "artifact_message_id": "artifact-csv-ui-test", + "conversation_id": "conversation-ui-test", + "file_name": "financial_review.csv", + "output_format": "csv", + "row_count": 200, + "storage_scope": "chat", + "preview_rows": [ + {"Item_ID": "FRI-001", "Overall_Attention": "Monitor"} + ], + }, + { + "artifact_id": "analysis-md", + "role": "primary_analysis", + "capability": "analyze", + "artifact_message_id": "artifact-md-ui-test", + "conversation_id": "conversation-ui-test", + "file_name": "financial_review_analysis.md", + "output_format": "md", + "storage_scope": "chat", + "preview_lines": ["# Financial review", "All rows were analyzed."], + }, + ], + "generated_artifact": { + "artifact_id": "requested-csv", + "role": "requested_output", + "capability": "tabular", + "artifact_message_id": "artifact-csv-ui-test", + "conversation_id": "conversation-ui-test", + "file_name": "financial_review.csv", + "output_format": "csv", + "row_count": 200, + "storage_scope": "chat", + }, + }, + }, + ), + ) + response = page.goto( + f"{server_base_url}/{HARNESS_PATH}", + wait_until="domcontentloaded", + ) + assert response is not None and response.ok + _install_minimal_chat_dom(page) + page.evaluate( + """ + async () => { + window.generatedArtifactSetEvents = []; + document.addEventListener('simplechat:generated-artifact-set', event => { + window.generatedArtifactSetEvents.push(event.detail); + }); + + const module = await import('/application/single_app/static/js/chat/chat-messages.js'); + module.appendMessage( + 'AI', + 'The combined Analyze run is continuing in the background.', + null, + 'message-plural-artifacts', + false, + [], + [], + [], + null, + null, + { + metadata: { + generated_tabular_outputs: [ + { + capability: 'tabular', + background_export: true, + export_run_id: 'run-plural-artifacts', + run_id: 'run-plural-artifacts', + task_type: 'combined', + status: 'running', + file_name: 'financial_review.csv', + output_format: 'csv', + row_count: 200, + processed_rows: 40, + batch_count: 4, + completed_batches: 1, + source_file_name: 'financial_review.xlsx', + suppress_assistant_text: true, + } + ] + } + }, + false + ); + } + """ + ) + + message = page.locator('[data-message-id="message-plural-artifacts"]') + expect(message.get_by_text("Background analysis + export")).to_be_visible() + expect(message.get_by_role("button", name="Download financial_review_analysis.md")).to_be_visible(timeout=15000) + expect(message.get_by_role("button", name="Download financial_review.csv")).to_be_visible() + expect(message.locator('[data-generated-artifact-set="true"]')).to_have_count(1) + expect(message.get_by_text("2 generated artifacts", exact=True)).to_be_visible() + + cards = message.locator('.generated-tabular-output-card') + expect(cards).to_have_count(2) + expect(cards.nth(0).get_by_text("Analyze MD artifact", exact=True)).to_be_visible() + expect(cards.nth(1).get_by_text("Generated CSV export", exact=True)).to_be_visible() + expect(message.get_by_role("button", name="View financial_review_analysis.md")).to_be_visible() + expect(message.get_by_role("button", name="View financial_review.csv")).to_be_visible() + expect(message.get_by_role("button", name="Cancel background export")).to_have_count(0) + expect(message.get_by_role("button", name="Continue")).to_have_count(0) + + events = page.evaluate("() => window.generatedArtifactSetEvents") + completion_events = [ + event for event in events if event.get("eventType") == "plural_completion_rendered" + ] + assert completion_events + assert completion_events[-1]["memberCount"] == 2 + assert completion_events[-1]["formats"] == ["md", "csv"] + assert completion_events[-1]["primaryRendered"] is True + assert page_errors == [] + finally: + context.close() + browser.close() + + +@pytest.mark.ui +def test_chat_empty_plural_artifact_set_does_not_render_legacy_fallback(playwright) -> None: + """Validate an explicit empty generated_artifacts array suppresses legacy singular fallback.""" + browser = playwright.chromium.launch() + context = browser.new_context(viewport={"width": 1440, "height": 900}) + page = context.new_page() + page_errors = [] + page.on("pageerror", lambda error: page_errors.append(str(error))) + + try: + with _start_static_test_server() as server_base_url: + page.route( + "**/api/tabular/generated-output/runs/run-empty-plural-artifacts", + lambda route: route.fulfill( + status=200, + content_type="application/json", + json={ + "success": True, + "run": { + "run_id": "run-empty-plural-artifacts", + "conversation_id": "conversation-ui-test", + "task_type": "combined", + "status": "completed", + "artifact_set": { + "contract_version": "tabular-artifact-set-v1", + "set_id": "artifact-set-empty-ui-test", + "lifecycle_state": "completed", + "validation_state": "invalid", + "primary_artifact_id": "analysis-md", + "member_count": 2, + "published_member_count": 0, + "publication_generation": 1, + }, + "generated_artifacts": [], + "generated_artifact": { + "artifact_id": "legacy-csv", + "role": "requested_output", + "capability": "tabular", + "artifact_message_id": "legacy-artifact-csv", + "conversation_id": "conversation-ui-test", + "file_name": "legacy.csv", + "output_format": "csv", + "storage_scope": "chat", + }, + }, + }, + ), + ) + response = page.goto( + f"{server_base_url}/{HARNESS_PATH}", + wait_until="domcontentloaded", + ) + assert response is not None and response.ok + _install_minimal_chat_dom(page) + page.evaluate( + """ + async () => { + const module = await import('/application/single_app/static/js/chat/chat-messages.js'); + module.appendMessage( + 'AI', + 'The combined Analyze run is continuing in the background.', + null, + 'message-empty-plural-artifacts', + false, + [], + [], + [], + null, + null, + { + metadata: { + generated_tabular_outputs: [ + { + capability: 'tabular', + background_export: true, + export_run_id: 'run-empty-plural-artifacts', + run_id: 'run-empty-plural-artifacts', + task_type: 'combined', + status: 'running', + file_name: 'legacy.csv', + output_format: 'csv', + row_count: 200, + processed_rows: 40, + batch_count: 4, + completed_batches: 1, + suppress_assistant_text: true, + } + ] + } + }, + false + ); + } + """ + ) + + message = page.locator('[data-message-id="message-empty-plural-artifacts"]') + expect(message.get_by_role("button", name="Download legacy.csv")).to_have_count(0, timeout=15000) + expect(message.locator('[data-generated-artifact-set="true"]')).to_have_count(0) + expect(message.locator('.generated-tabular-output-card')).to_have_count(1) + assert page_errors == [] + finally: + context.close() + browser.close() + + +@pytest.mark.ui +def test_chat_continue_completion_renders_plural_artifact_set(playwright) -> None: + """Validate Continue uses the same plural artifact-set replacement path.""" + browser = playwright.chromium.launch() + context = browser.new_context(viewport={"width": 1440, "height": 900}) + page = context.new_page() + page_errors = [] + page.on("pageerror", lambda error: page_errors.append(str(error))) + + try: + with _start_static_test_server() as server_base_url: + page.route( + "**/api/tabular/generated-output/runs/run-plural-continue/resume", + lambda route: route.fulfill( + status=200, + content_type="application/json", + json={ + "success": True, + "message": "Background export is already complete.", + "run": { + "run_id": "run-plural-continue", + "conversation_id": "conversation-ui-test", + "task_type": "combined", + "status": "completed", + "row_count": 12, + "processed_rows": 12, + "batch_count": 2, + "completed_batches": 2, + "progress_percent": 100, + "artifact_set": { + "contract_version": "tabular-artifact-set-v1", + "set_id": "artifact-set-continue-test", + "lifecycle_state": "completed", + "validation_state": "validated", + "primary_artifact_id": "analysis-md", + "member_count": 2, + "published_member_count": 2, + "publication_generation": 1, + }, + "generated_artifacts": [ + { + "artifact_id": "analysis-md", + "role": "primary_analysis", + "capability": "analyze", + "artifact_message_id": "artifact-md-continue-test", + "conversation_id": "conversation-ui-test", + "file_name": "continue_analysis.md", + "output_format": "md", + "storage_scope": "chat", + "preview_lines": ["# Continue analysis"], + }, + { + "artifact_id": "requested-json", + "role": "requested_output", + "capability": "tabular", + "artifact_message_id": "artifact-json-continue-test", + "conversation_id": "conversation-ui-test", + "file_name": "continue_output.json", + "output_format": "json", + "row_count": 12, + "storage_scope": "chat", + "preview_rows": [{"id": "row-1", "status": "ready"}], + }, + ], + "generated_artifact": { + "artifact_id": "analysis-md", + "role": "primary_analysis", + "capability": "analyze", + "artifact_message_id": "artifact-md-continue-test", + "conversation_id": "conversation-ui-test", + "file_name": "continue_analysis.md", + "output_format": "md", + "storage_scope": "chat", + }, + }, + }, + ), + ) + response = page.goto( + f"{server_base_url}/{HARNESS_PATH}", + wait_until="domcontentloaded", + ) + assert response is not None and response.ok + _install_minimal_chat_dom(page) + page.evaluate( + """ + async () => { + window.generatedArtifactSetEvents = []; + document.addEventListener('simplechat:generated-artifact-set', event => { + window.generatedArtifactSetEvents.push(event.detail); + }); + + const module = await import('/application/single_app/static/js/chat/chat-messages.js'); + module.appendMessage( + 'AI', + 'The combined run needs a manual continue.', + null, + 'message-plural-continue', + false, + [], + [], + [], + null, + null, + { + metadata: { + generated_tabular_outputs: [ + { + capability: 'tabular', + background_export: true, + export_run_id: 'run-plural-continue', + run_id: 'run-plural-continue', + task_type: 'combined', + status: 'failed', + status_label: 'Retry waiting', + status_tone: 'warning', + status_detail: 'A retryable batch needs a manual continue.', + retryable_failure: true, + can_resume: true, + can_cancel: false, + waiting_for_retry: true, + file_name: 'continue_output.json', + output_format: 'json', + row_count: 12, + processed_rows: 6, + batch_count: 2, + completed_batches: 1, + } + ] + } + }, + false + ); + } + """ + ) + + message = page.locator('[data-message-id="message-plural-continue"]') + continue_button = message.get_by_role("button", name="Continue Now") + expect(continue_button).to_be_visible() + continue_button.click() + + expect(message.get_by_role("button", name="Download continue_analysis.md")).to_be_visible(timeout=15000) + expect(message.get_by_role("button", name="Download continue_output.json")).to_be_visible() + cards = message.locator('.generated-tabular-output-card') + expect(cards).to_have_count(2) + expect(cards.nth(0).get_by_text("Analyze MD artifact", exact=True)).to_be_visible() + expect(cards.nth(1).get_by_text("Generated JSON export", exact=True)).to_be_visible() + expect(message.get_by_role("button", name="Continue Now")).to_have_count(0) + expect(message.get_by_role("button", name="Cancel background export")).to_have_count(0) + + events = page.evaluate("() => window.generatedArtifactSetEvents") + completion_events = [ + event for event in events if event.get("eventType") == "plural_completion_rendered" + ] + assert completion_events + assert completion_events[-1]["memberCount"] == 2 + assert completion_events[-1]["formats"] == ["md", "json"] + assert page_errors == [] + finally: + context.close() + browser.close() + + @pytest.mark.ui def test_chat_background_generated_export_can_be_canceled(playwright) -> None: """Validate a running export exposes Cancel and renders the durable canceled state.""" @@ -290,6 +754,7 @@ def test_chat_background_generated_export_can_be_canceled(playwright) -> None: browser.close() + @pytest.mark.ui def test_chat_combined_background_status_shows_reduce_progress(playwright) -> None: """Validate combined runs show map/reduce phase and remaining work.""" @@ -664,4 +1129,4 @@ def test_chat_failed_exhaustive_export_without_run_id_remains_visible(playwright assert page_errors == [] finally: context.close() - browser.close() \ No newline at end of file + browser.close()