alysis-code 0.13.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- alysis_code/__init__.py +3 -0
- alysis_code/__main__.py +4 -0
- alysis_code/_build_info.py +14 -0
- alysis_code/account_login.py +468 -0
- alysis_code/agent/README.md +35 -0
- alysis_code/agent/__init__.py +11 -0
- alysis_code/agent/acceptance_contract.py +2217 -0
- alysis_code/agent/blast_radius.py +1403 -0
- alysis_code/agent/cache_keepalive.py +227 -0
- alysis_code/agent/completion_certificate.py +366 -0
- alysis_code/agent/completion_gate.py +306 -0
- alysis_code/agent/empty_response_stall.py +403 -0
- alysis_code/agent/errors.py +28 -0
- alysis_code/agent/llm_calls.py +475 -0
- alysis_code/agent/mutation_classification.py +227 -0
- alysis_code/agent/prompt_context.py +2508 -0
- alysis_code/agent/read_ledger.py +253 -0
- alysis_code/agent/regression_baseline.py +642 -0
- alysis_code/agent/reproduction_first.py +610 -0
- alysis_code/agent/sensitive_output.py +629 -0
- alysis_code/agent/session.py +3218 -0
- alysis_code/agent/steering.py +191 -0
- alysis_code/agent/subagent_execution.py +5177 -0
- alysis_code/agent/subagent_workspace.py +666 -0
- alysis_code/agent/tools_assembly.py +4728 -0
- alysis_code/agent/turn/__init__.py +101 -0
- alysis_code/agent/turn/core.py +8483 -0
- alysis_code/agent/turn/events.py +113 -0
- alysis_code/agent/turn/exploration.py +590 -0
- alysis_code/agent/turn/interventions.py +65 -0
- alysis_code/agent/turn/read_cache.py +420 -0
- alysis_code/agent/turn/snapshot.py +179 -0
- alysis_code/agent/turn_contract.py +661 -0
- alysis_code/agent/turn_path.py +129 -0
- alysis_code/agent/verification.py +2885 -0
- alysis_code/agent/verification_commands.py +512 -0
- alysis_code/agent/verification_evidence.py +738 -0
- alysis_code/agent_loop.py +693 -0
- alysis_code/agent_runtimes/__init__.py +51 -0
- alysis_code/agent_runtimes/base.py +114 -0
- alysis_code/agent_runtimes/builtins.py +129 -0
- alysis_code/agent_runtimes/codex_cli.py +664 -0
- alysis_code/agent_runtimes/host.py +263 -0
- alysis_code/agent_runtimes/registry.py +64 -0
- alysis_code/agent_runtimes/service.py +150 -0
- alysis_code/agentbox_client.py +416 -0
- alysis_code/agentbox_integration.py +310 -0
- alysis_code/alysis_cloud.py +152 -0
- alysis_code/approval_scope.py +276 -0
- alysis_code/assets/README.md +33 -0
- alysis_code/assets/__init__.py +126 -0
- alysis_code/assets/asset_read_core.py +281 -0
- alysis_code/assets/budget_allocator.py +456 -0
- alysis_code/assets/comprehender.py +759 -0
- alysis_code/assets/index.py +654 -0
- alysis_code/assets/ingestion.py +275 -0
- alysis_code/assets/legacy_migration.py +413 -0
- alysis_code/assets/models.py +263 -0
- alysis_code/assets/ocr.py +239 -0
- alysis_code/assets/owl/ascii/f-000.txt +13 -0
- alysis_code/assets/owl/ascii/f-001.txt +13 -0
- alysis_code/assets/owl/ascii/f-002.txt +13 -0
- alysis_code/assets/owl/ascii/f-003.txt +13 -0
- alysis_code/assets/owl/ascii/f-004.txt +13 -0
- alysis_code/assets/owl/ascii/f-005.txt +13 -0
- alysis_code/assets/owl/ascii/f-006.txt +13 -0
- alysis_code/assets/owl/ascii/f-007.txt +13 -0
- alysis_code/assets/owl/ascii/f-008.txt +13 -0
- alysis_code/assets/owl/ascii/f-009.txt +13 -0
- alysis_code/assets/owl/ascii/f-010.txt +13 -0
- alysis_code/assets/owl/ascii/f-011.txt +13 -0
- alysis_code/assets/owl/ascii/f-012.txt +13 -0
- alysis_code/assets/owl/ascii/f-013.txt +13 -0
- alysis_code/assets/owl/ascii/f-014.txt +13 -0
- alysis_code/assets/owl/ascii/f-015.txt +13 -0
- alysis_code/assets/owl/ascii/f-016.txt +13 -0
- alysis_code/assets/owl/ascii/f-017.txt +13 -0
- alysis_code/assets/owl/ascii/f-018.txt +13 -0
- alysis_code/assets/owl/ascii/f-019.txt +13 -0
- alysis_code/assets/owl/ascii/f-020.txt +13 -0
- alysis_code/assets/owl/index.html +98 -0
- alysis_code/assets/owl/show-owl.sh +761 -0
- alysis_code/assets/paths.py +49 -0
- alysis_code/assets/plan_binding.py +326 -0
- alysis_code/assets/planner_context.py +466 -0
- alysis_code/assets/planner_tools.py +184 -0
- alysis_code/assets/prompts.py +101 -0
- alysis_code/assets/replanner_context.py +239 -0
- alysis_code/assets/surface.py +521 -0
- alysis_code/assets/untrusted_content.py +48 -0
- alysis_code/assets/usage_logger.py +94 -0
- alysis_code/assets/worker_mirror.py +428 -0
- alysis_code/assets/worker_section.py +303 -0
- alysis_code/assets/worker_tools.py +468 -0
- alysis_code/atomic_io.py +83 -0
- alysis_code/auth_diagnostics.py +272 -0
- alysis_code/background_runner.py +366 -0
- alysis_code/branding.py +270 -0
- alysis_code/budget_policy.py +390 -0
- alysis_code/build_identity.py +465 -0
- alysis_code/builtin_hooks/__init__.py +7 -0
- alysis_code/builtin_hooks/notify_done_windows.py +65 -0
- alysis_code/bwrap_etc.py +76 -0
- alysis_code/cancellation.py +41 -0
- alysis_code/capabilities.py +137 -0
- alysis_code/chatgpt_codex_static_provider.py +133 -0
- alysis_code/cli.py +51 -0
- alysis_code/cli_impl/__init__.py +1 -0
- alysis_code/cli_impl/assets_cli.py +537 -0
- alysis_code/cli_impl/assets_modal.py +412 -0
- alysis_code/cli_impl/chat/__init__.py +156 -0
- alysis_code/cli_impl/chat/commands.py +2616 -0
- alysis_code/cli_impl/chat/loop.py +4508 -0
- alysis_code/cli_impl/chat/mid_turn_policy.py +125 -0
- alysis_code/cli_impl/chat/rendering.py +444 -0
- alysis_code/cli_impl/chat/state.py +124 -0
- alysis_code/cli_impl/chat_resume.py +830 -0
- alysis_code/cli_impl/chat_slash_completer.py +258 -0
- alysis_code/cli_impl/commands/__init__.py +11 -0
- alysis_code/cli_impl/commands/_shared.py +89 -0
- alysis_code/cli_impl/commands/auth.py +623 -0
- alysis_code/cli_impl/commands/chat_resume_helpers.py +1531 -0
- alysis_code/cli_impl/commands/chat_state.py +158 -0
- alysis_code/cli_impl/commands/chat_status.py +1248 -0
- alysis_code/cli_impl/commands/chat_terminal.py +942 -0
- alysis_code/cli_impl/commands/chat_tui_panels.py +1018 -0
- alysis_code/cli_impl/commands/cli_common.py +1223 -0
- alysis_code/cli_impl/commands/cli_surface.py +77 -0
- alysis_code/cli_impl/commands/config.py +131 -0
- alysis_code/cli_impl/commands/conventions.py +85 -0
- alysis_code/cli_impl/commands/execution_helpers.py +350 -0
- alysis_code/cli_impl/commands/extensions.py +401 -0
- alysis_code/cli_impl/commands/forge.py +1282 -0
- alysis_code/cli_impl/commands/forge_asset_view.py +121 -0
- alysis_code/cli_impl/commands/forge_helpers.py +1215 -0
- alysis_code/cli_impl/commands/hooks.py +737 -0
- alysis_code/cli_impl/commands/ide_bridge.py +31 -0
- alysis_code/cli_impl/commands/mcp.py +700 -0
- alysis_code/cli_impl/commands/profile.py +453 -0
- alysis_code/cli_impl/commands/prompt_helpers.py +307 -0
- alysis_code/cli_impl/commands/report.py +88 -0
- alysis_code/cli_impl/commands/root.py +1118 -0
- alysis_code/cli_impl/commands/sandbox.py +184 -0
- alysis_code/cli_impl/commands/server.py +54 -0
- alysis_code/cli_impl/commands/sessions.py +252 -0
- alysis_code/cli_impl/commands/skills.py +404 -0
- alysis_code/cli_impl/commands/startup.py +946 -0
- alysis_code/cli_impl/commands/tools.py +335 -0
- alysis_code/cli_impl/commands/update.py +364 -0
- alysis_code/cli_impl/commands/welcome.py +972 -0
- alysis_code/cli_impl/config_menu.py +3882 -0
- alysis_code/cli_impl/forge.py +4509 -0
- alysis_code/cli_impl/forge_recovery.py +485 -0
- alysis_code/cli_impl/setup_wizard.py +2409 -0
- alysis_code/cli_impl/tui/__init__.py +58 -0
- alysis_code/cli_impl/tui/app.py +4551 -0
- alysis_code/cli_impl/tui/config.py +32 -0
- alysis_code/cli_impl/tui/config_flow.py +2754 -0
- alysis_code/cli_impl/tui/config_overlay.py +566 -0
- alysis_code/cli_impl/tui/content.py +78 -0
- alysis_code/cli_impl/tui/footer.py +218 -0
- alysis_code/cli_impl/tui/forge_status.py +136 -0
- alysis_code/cli_impl/tui/markdown.py +244 -0
- alysis_code/cli_impl/tui/owl.py +109 -0
- alysis_code/cli_impl/tui/plan_meta.py +477 -0
- alysis_code/cli_impl/tui/setup_app.py +519 -0
- alysis_code/cli_impl/tui/setup_flow.py +1622 -0
- alysis_code/cli_impl/tui/state.py +101 -0
- alysis_code/cli_impl/tui/subagent_identity.py +66 -0
- alysis_code/cli_impl/tui/subagent_panel.py +186 -0
- alysis_code/cli_impl/tui/surface.py +796 -0
- alysis_code/cli_impl/tui/transcript.py +514 -0
- alysis_code/cli_impl/tui/update_prompt.py +79 -0
- alysis_code/cli_impl/tui/workspace_guard.py +384 -0
- alysis_code/clipboard.py +172 -0
- alysis_code/code_review.py +1211 -0
- alysis_code/compaction/__init__.py +28 -0
- alysis_code/compaction/conversation_compactor.py +2932 -0
- alysis_code/compaction/importance.py +177 -0
- alysis_code/compaction/settings.py +297 -0
- alysis_code/compaction/tool_output_offload.py +447 -0
- alysis_code/config.py +3509 -0
- alysis_code/conflict_auto_resolver.py +895 -0
- alysis_code/context/__init__.py +1 -0
- alysis_code/context/tool_schema_budgeter.py +220 -0
- alysis_code/crash_diagnostics.py +282 -0
- alysis_code/custom_tools/README.md +34 -0
- alysis_code/custom_tools/__init__.py +43 -0
- alysis_code/custom_tools/discovery.py +903 -0
- alysis_code/custom_tools/runtime.py +1516 -0
- alysis_code/custom_tools/session.py +227 -0
- alysis_code/custom_tools/trust.py +232 -0
- alysis_code/diff_paths.py +113 -0
- alysis_code/direction_change.py +293 -0
- alysis_code/dispatch_timing.py +306 -0
- alysis_code/durable_service_manager.py +1236 -0
- alysis_code/edit_discipline.py +659 -0
- alysis_code/error_text.py +73 -0
- alysis_code/execution_budget.py +411 -0
- alysis_code/execution_context.py +915 -0
- alysis_code/execution_deadline.py +1065 -0
- alysis_code/execution_shared.py +1904 -0
- alysis_code/extensions/README.md +30 -0
- alysis_code/extensions/__init__.py +93 -0
- alysis_code/extensions/activation.py +138 -0
- alysis_code/extensions/install.py +1436 -0
- alysis_code/extensions/manifest.py +487 -0
- alysis_code/extensions/models.py +74 -0
- alysis_code/extensions/paths.py +56 -0
- alysis_code/extensions/registry.json +4 -0
- alysis_code/extensions/registry.py +52 -0
- alysis_code/extensions/state.py +83 -0
- alysis_code/extensions/workspace_trust.py +101 -0
- alysis_code/failed_task_evidence.py +369 -0
- alysis_code/failure_category.py +315 -0
- alysis_code/feedback_report.py +1647 -0
- alysis_code/file_classification.py +485 -0
- alysis_code/forge.py +2064 -0
- alysis_code/forge_completion.py +362 -0
- alysis_code/forge_events.py +475 -0
- alysis_code/frontmatter_utils.py +95 -0
- alysis_code/git_evidence.py +1181 -0
- alysis_code/git_ops.py +560 -0
- alysis_code/git_safe.py +62 -0
- alysis_code/git_worktrees.py +190 -0
- alysis_code/hooks/README.md +33 -0
- alysis_code/hooks/__init__.py +67 -0
- alysis_code/hooks/audit.py +171 -0
- alysis_code/hooks/config.py +225 -0
- alysis_code/hooks/dispatcher.py +1110 -0
- alysis_code/hooks/models.py +447 -0
- alysis_code/hooks/trust.py +202 -0
- alysis_code/host_actions.py +543 -0
- alysis_code/host_browser.py +103 -0
- alysis_code/ide/__init__.py +5 -0
- alysis_code/ide/activity_events.py +399 -0
- alysis_code/ide/approvals.py +337 -0
- alysis_code/ide/artifacts.py +153 -0
- alysis_code/ide/browser_egress_proxy.py +1076 -0
- alysis_code/ide/cdp_websocket_transport.py +1192 -0
- alysis_code/ide/change_ledger.py +1721 -0
- alysis_code/ide/context_blocks.py +979 -0
- alysis_code/ide/event_stream.py +531 -0
- alysis_code/ide/forge_protocol.py +3112 -0
- alysis_code/ide/forge_request_ledger.py +737 -0
- alysis_code/ide/health.py +965 -0
- alysis_code/ide/managed_browser.py +2251 -0
- alysis_code/ide/management_protocol.py +3414 -0
- alysis_code/ide/mcp_oauth_coordinator.py +744 -0
- alysis_code/ide/mcp_oauth_lifecycle.py +1504 -0
- alysis_code/ide/prompt_queue.py +1070 -0
- alysis_code/ide/protocol.py +191 -0
- alysis_code/ide/resumable_swarm.py +1543 -0
- alysis_code/ide/session_search.py +295 -0
- alysis_code/ide/stdio_bridge.py +9935 -0
- alysis_code/ide/structured_state.py +1579 -0
- alysis_code/ide/swarm_protocol.py +816 -0
- alysis_code/integration_gate.py +506 -0
- alysis_code/interactive_input_guard.py +39 -0
- alysis_code/interactive_plan_mode.py +26 -0
- alysis_code/internal_artifacts.py +179 -0
- alysis_code/knowledge_base.py +1409 -0
- alysis_code/knowledge_capture.py +1190 -0
- alysis_code/knowledge_librarian.py +605 -0
- alysis_code/language_policy.py +34 -0
- alysis_code/litellm_static_provider.py +535 -0
- alysis_code/llm/__init__.py +1 -0
- alysis_code/llm/anthropic_messages.py +2288 -0
- alysis_code/llm/base.py +71 -0
- alysis_code/llm/cache_capabilities.py +985 -0
- alysis_code/llm/cache_control_blocks.py +244 -0
- alysis_code/llm/cache_policy.py +388 -0
- alysis_code/llm/factory.py +373 -0
- alysis_code/llm/gemini_generate_content.py +2652 -0
- alysis_code/llm/gemini_interactions.py +739 -0
- alysis_code/llm/metadata.py +450 -0
- alysis_code/llm/openai_compat.py +2947 -0
- alysis_code/llm/openai_responses.py +2604 -0
- alysis_code/llm/protocols.py +609 -0
- alysis_code/llm/provider_limits.py +525 -0
- alysis_code/llm/request_plan.py +389 -0
- alysis_code/llm/request_shape.py +238 -0
- alysis_code/llm/streaming.py +108 -0
- alysis_code/llm/temperature_compat.py +78 -0
- alysis_code/llm/types.py +195 -0
- alysis_code/llm/usage_normalization.py +222 -0
- alysis_code/llm_error_display.py +315 -0
- alysis_code/logging_redaction.py +326 -0
- alysis_code/managed_host_deadline.py +196 -0
- alysis_code/mcp/README.md +33 -0
- alysis_code/mcp/__init__.py +24 -0
- alysis_code/mcp/client.py +1137 -0
- alysis_code/mcp/config.py +597 -0
- alysis_code/mcp/errors.py +113 -0
- alysis_code/mcp/forge_scope.py +154 -0
- alysis_code/mcp/jsonrpc.py +214 -0
- alysis_code/mcp/manager.py +2308 -0
- alysis_code/mcp/models.py +666 -0
- alysis_code/mcp/oauth.py +972 -0
- alysis_code/mcp/oauth_runtime.py +310 -0
- alysis_code/mcp/oauth_store.py +276 -0
- alysis_code/mcp/prompts.py +329 -0
- alysis_code/mcp/resources.py +295 -0
- alysis_code/mcp/roots.py +106 -0
- alysis_code/mcp/server_requests.py +75 -0
- alysis_code/mcp/token_store.py +859 -0
- alysis_code/mcp/transport_http.py +1338 -0
- alysis_code/mcp/transport_stdio.py +1267 -0
- alysis_code/mcp/untrusted_content.py +119 -0
- alysis_code/merge_conflict_reviewer.py +729 -0
- alysis_code/model_catalog/__init__.py +1 -0
- alysis_code/model_catalog/chatgpt_codex_subscription_snapshot.json +186 -0
- alysis_code/model_catalog/litellm_model_prices_snapshot.json +44715 -0
- alysis_code/model_catalog/litellm_model_prices_snapshot.meta.json +17 -0
- alysis_code/model_metadata_policy.py +223 -0
- alysis_code/model_metadata_utils.py +103 -0
- alysis_code/model_registry.py +1420 -0
- alysis_code/model_router.py +147 -0
- alysis_code/permission_policy.py +1016 -0
- alysis_code/personas.py +451 -0
- alysis_code/pipeline_facts.py +233 -0
- alysis_code/plan_assistant.py +4763 -0
- alysis_code/plan_mode.py +393 -0
- alysis_code/plan_reconciliation.py +1228 -0
- alysis_code/plan_repair.py +652 -0
- alysis_code/plan_validation.py +1099 -0
- alysis_code/planning_constraints.py +904 -0
- alysis_code/policy.py +95 -0
- alysis_code/preview_server.py +457 -0
- alysis_code/process_reaping.py +566 -0
- alysis_code/profile_presets.py +1834 -0
- alysis_code/profiles.py +666 -0
- alysis_code/provider_auth/__init__.py +29 -0
- alysis_code/provider_auth/base.py +99 -0
- alysis_code/provider_auth/openai_codex.py +951 -0
- alysis_code/provider_auth/registry.py +76 -0
- alysis_code/provider_auth/store.py +125 -0
- alysis_code/provider_diagnostics.py +1209 -0
- alysis_code/provider_model_catalog.py +685 -0
- alysis_code/provider_telemetry.py +1699 -0
- alysis_code/provider_url.py +75 -0
- alysis_code/reasoning_contracts.py +911 -0
- alysis_code/remote_sync.py +350 -0
- alysis_code/replanning.py +1195 -0
- alysis_code/repo_scan.py +1152 -0
- alysis_code/request_estimation.py +296 -0
- alysis_code/review_gate.py +617 -0
- alysis_code/run_lock.py +1141 -0
- alysis_code/run_outcome.py +58 -0
- alysis_code/run_provenance.py +774 -0
- alysis_code/run_state.py +445 -0
- alysis_code/runtime_artifacts.py +116 -0
- alysis_code/runtime_context_features.py +78 -0
- alysis_code/runtime_kind.py +52 -0
- alysis_code/safety/__init__.py +11 -0
- alysis_code/safety/mcp_sanitize.py +29 -0
- alysis_code/safety/safe_http.py +297 -0
- alysis_code/safety/subagent_report.py +184 -0
- alysis_code/sandbox_doctor.py +682 -0
- alysis_code/sandbox_runner.py +1025 -0
- alysis_code/sandbox_settings.py +423 -0
- alysis_code/serialized_paths.py +355 -0
- alysis_code/server/__init__.py +3 -0
- alysis_code/server/app.py +367 -0
- alysis_code/server/auth.py +34 -0
- alysis_code/server/job_config.py +30 -0
- alysis_code/server/settings.py +215 -0
- alysis_code/server/store.py +193 -0
- alysis_code/server/worker_runner.py +657 -0
- alysis_code/service_persistence.py +355 -0
- alysis_code/session_artifacts.py +108 -0
- alysis_code/session_metrics.py +331 -0
- alysis_code/session_store.py +624 -0
- alysis_code/skills/README.md +34 -0
- alysis_code/skills/__init__.py +104 -0
- alysis_code/skills/conventions.py +84 -0
- alysis_code/skills/discovery.py +176 -0
- alysis_code/skills/eval_models.py +232 -0
- alysis_code/skills/eval_runner.py +372 -0
- alysis_code/skills/evals.py +1344 -0
- alysis_code/skills/install.py +293 -0
- alysis_code/skills/loader.py +118 -0
- alysis_code/skills/matching.py +103 -0
- alysis_code/skills/models.py +71 -0
- alysis_code/skills/paths.py +56 -0
- alysis_code/skills/prompting.py +500 -0
- alysis_code/skills/scaffold.py +142 -0
- alysis_code/skills/state.py +441 -0
- alysis_code/skills/transactions.py +125 -0
- alysis_code/skills/validation.py +304 -0
- alysis_code/step_budget.py +238 -0
- alysis_code/subagent_labels.py +49 -0
- alysis_code/subagents.py +1072 -0
- alysis_code/surface/__init__.py +80 -0
- alysis_code/surface/base.py +305 -0
- alysis_code/surface/console.py +387 -0
- alysis_code/surface/events.py +372 -0
- alysis_code/surface/hidden_surface.py +529 -0
- alysis_code/surface/noop_surface.py +219 -0
- alysis_code/surface/rich_surface.py +1555 -0
- alysis_code/surface/styles.py +67 -0
- alysis_code/surface/theme.py +455 -0
- alysis_code/surface/types.py +100 -0
- alysis_code/swarm_backend.py +926 -0
- alysis_code/swarm_orchestrator.py +4020 -0
- alysis_code/swarm_scheduler.py +441 -0
- alysis_code/swarm_trace.py +429 -0
- alysis_code/swarm_worker.py +2119 -0
- alysis_code/swarm_write_guard.py +348 -0
- alysis_code/task_dependencies.py +170 -0
- alysis_code/task_readiness.py +992 -0
- alysis_code/task_scope.py +2148 -0
- alysis_code/terminal_manager.py +762 -0
- alysis_code/terminal_ownership.py +460 -0
- alysis_code/text_normalization.py +30 -0
- alysis_code/token_budget.py +97 -0
- alysis_code/tools/README.md +34 -0
- alysis_code/tools/__init__.py +1 -0
- alysis_code/tools/artifacts.py +127 -0
- alysis_code/tools/availability.py +188 -0
- alysis_code/tools/fs.py +1456 -0
- alysis_code/tools/git.py +461 -0
- alysis_code/tools/history.py +229 -0
- alysis_code/tools/http_timeout.py +78 -0
- alysis_code/tools/image_generation.py +552 -0
- alysis_code/tools/registry.py +2936 -0
- alysis_code/tools/repo_map.py +476 -0
- alysis_code/tools/search.py +563 -0
- alysis_code/tools/shell.py +135 -0
- alysis_code/tools/symbols.py +1350 -0
- alysis_code/tools/test_discovery.py +643 -0
- alysis_code/tools/web.py +482 -0
- alysis_code/tools/web_search.py +2012 -0
- alysis_code/tools/web_search_dashscope.py +557 -0
- alysis_code/tools/web_search_ddgs.py +221 -0
- alysis_code/tools/web_search_provider_adapters.py +1429 -0
- alysis_code/tools/web_search_tavily.py +194 -0
- alysis_code/updates.py +933 -0
- alysis_code/usage_tracker.py +1990 -0
- alysis_code/verification_command_analysis.py +1004 -0
- alysis_code/verification_contract.py +574 -0
- alysis_code/verification_failure_summary.py +273 -0
- alysis_code/verification_repair.py +385 -0
- alysis_code/verify_gate.py +3129 -0
- alysis_code/web_research.py +1872 -0
- alysis_code/web_search_adapters.py +66 -0
- alysis_code/web_search_policy.py +27 -0
- alysis_code/workspace_binding.py +389 -0
- alysis_code/workspace_binding_ui.py +408 -0
- alysis_code/workspace_context.py +273 -0
- alysis_code/workspace_isolation.py +138 -0
- alysis_code/workspace_provisioning.py +455 -0
- alysis_code-0.13.0.dist-info/METADATA +507 -0
- alysis_code-0.13.0.dist-info/RECORD +458 -0
- alysis_code-0.13.0.dist-info/WHEEL +4 -0
- alysis_code-0.13.0.dist-info/entry_points.txt +3 -0
- alysis_code-0.13.0.dist-info/licenses/LICENSE +176 -0
- alysis_code-0.13.0.dist-info/licenses/NOTICE +4 -0
|
@@ -0,0 +1,1872 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
from typing import Any
|
|
8
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
9
|
+
|
|
10
|
+
# Brackets can be legitimate URL path/query characters, such as ?foo[bar]=1.
|
|
11
|
+
# Surrounding wrapper brackets are removed later by context-aware cleanup.
|
|
12
|
+
_URL_RE = re.compile(r"https?://[^\s<>{}\"]+", re.IGNORECASE)
|
|
13
|
+
_URL_CANDIDATE_STOP_CHARS = set('<>{}"')
|
|
14
|
+
_STRUCTURED_URL_FORBIDDEN_CHARS = set('<>{}"')
|
|
15
|
+
_SIMPLE_TRAILING_URL_PUNCTUATION = ".,;:?"
|
|
16
|
+
_TRAILING_URL_QUOTES = '"'
|
|
17
|
+
_URL_QUOTE_WRAPPERS = {"'", '"'}
|
|
18
|
+
_URL_WRAPPER_PAIRS = {
|
|
19
|
+
"(": ")",
|
|
20
|
+
"[": "]",
|
|
21
|
+
"{": "}",
|
|
22
|
+
'"': '"',
|
|
23
|
+
"'": "'",
|
|
24
|
+
}
|
|
25
|
+
_URL_CLOSING_WRAPPERS = {")": "(", "]": "[", "}": "{"}
|
|
26
|
+
_MARKDOWN_URL_WRAPPER_MARKERS = ("`", "*", "_")
|
|
27
|
+
_USER_PROVIDED = "user_provided"
|
|
28
|
+
_RETURNED_BY_WEB_SEARCH = "returned_by_web_search"
|
|
29
|
+
_CANONICAL_REDIRECT = "canonical_redirect"
|
|
30
|
+
_SEARCH_MEDIATED_RECOVERY = "search_mediated_recovery"
|
|
31
|
+
_SAME_ORIGIN_DERIVED = "same_origin_derived_search_result"
|
|
32
|
+
_FETCHED_PAGE_LINK = "fetched_page_link"
|
|
33
|
+
_TRUSTED_LOCAL_FILE = "trusted_local_file"
|
|
34
|
+
_TRUSTED_TOOL_OUTPUT = "trusted_tool_output"
|
|
35
|
+
_MAX_PROVENANCE_NODES = 512
|
|
36
|
+
_MAX_URLS_PER_EVENT = 24
|
|
37
|
+
_MAX_URL_TEXT_CHARS = 32_000
|
|
38
|
+
_MAX_PROVENANCE_URL_LENGTH = 2048
|
|
39
|
+
_FETCHABLE_PROVENANCE_CLASSES = {
|
|
40
|
+
_USER_PROVIDED,
|
|
41
|
+
_RETURNED_BY_WEB_SEARCH,
|
|
42
|
+
_FETCHED_PAGE_LINK,
|
|
43
|
+
_TRUSTED_LOCAL_FILE,
|
|
44
|
+
_TRUSTED_TOOL_OUTPUT,
|
|
45
|
+
_CANONICAL_REDIRECT,
|
|
46
|
+
_SEARCH_MEDIATED_RECOVERY,
|
|
47
|
+
_SAME_ORIGIN_DERIVED,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _domain_for_url(url: str) -> str:
|
|
52
|
+
try:
|
|
53
|
+
return (urlsplit(url).hostname or "").rstrip(".").lower()
|
|
54
|
+
except ValueError:
|
|
55
|
+
return ""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _dedupe_ordered(items: list[str]) -> list[str]:
|
|
59
|
+
out: list[str] = []
|
|
60
|
+
seen: set[str] = set()
|
|
61
|
+
for item in items:
|
|
62
|
+
normalized = str(item or "").strip()
|
|
63
|
+
if not normalized or normalized in seen:
|
|
64
|
+
continue
|
|
65
|
+
seen.add(normalized)
|
|
66
|
+
out.append(normalized)
|
|
67
|
+
return out
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _canonical_json(value: Any) -> str:
|
|
71
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def normalize_web_query(raw_query: Any) -> str:
|
|
75
|
+
return re.sub(r"\s+", " ", str(raw_query or "").strip()).casefold()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def build_web_fetch_recovery_search_query(raw_url: Any) -> str:
|
|
79
|
+
normalized = normalize_web_url(raw_url) or canonicalize_web_url_input(raw_url)
|
|
80
|
+
if normalized is None:
|
|
81
|
+
return ""
|
|
82
|
+
try:
|
|
83
|
+
split = urlsplit(normalized)
|
|
84
|
+
except ValueError:
|
|
85
|
+
return ""
|
|
86
|
+
host = (split.hostname or "").rstrip(".").lower()
|
|
87
|
+
path_terms = [
|
|
88
|
+
segment for segment in re.split(r"[^A-Za-z0-9]+", split.path or "") if len(segment) >= 2
|
|
89
|
+
][:4]
|
|
90
|
+
return " ".join([host, *path_terms]).strip()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def normalize_web_url(raw_url: Any) -> str | None:
|
|
94
|
+
text = str(raw_url or "").strip()
|
|
95
|
+
if not text:
|
|
96
|
+
return None
|
|
97
|
+
if any(ch.isspace() or ch in _STRUCTURED_URL_FORBIDDEN_CHARS for ch in text):
|
|
98
|
+
return None
|
|
99
|
+
try:
|
|
100
|
+
split = urlsplit(text)
|
|
101
|
+
except ValueError:
|
|
102
|
+
return None
|
|
103
|
+
scheme = str(split.scheme or "").lower()
|
|
104
|
+
if scheme not in {"http", "https"}:
|
|
105
|
+
return None
|
|
106
|
+
if split.username is not None or split.password is not None:
|
|
107
|
+
return None
|
|
108
|
+
hostname = (split.hostname or "").rstrip(".").lower()
|
|
109
|
+
if not hostname:
|
|
110
|
+
return None
|
|
111
|
+
try:
|
|
112
|
+
port = split.port
|
|
113
|
+
except ValueError:
|
|
114
|
+
return None
|
|
115
|
+
if (scheme == "http" and port == 80) or (scheme == "https" and port == 443):
|
|
116
|
+
port = None
|
|
117
|
+
netloc = hostname if port is None else f"{hostname}:{port}"
|
|
118
|
+
path = split.path or "/"
|
|
119
|
+
return urlunsplit((scheme, netloc, path, split.query, ""))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _equivalent_fetch_url_variants(normalized_url: str) -> set[str]:
|
|
123
|
+
normalized = normalize_web_url(normalized_url)
|
|
124
|
+
if normalized is None:
|
|
125
|
+
return set()
|
|
126
|
+
variants = {normalized}
|
|
127
|
+
split = urlsplit(normalized)
|
|
128
|
+
if not split.query:
|
|
129
|
+
path = split.path or "/"
|
|
130
|
+
if path == "/":
|
|
131
|
+
variants.add(urlunsplit((split.scheme, split.netloc, "", "", "")))
|
|
132
|
+
elif path.endswith("/"):
|
|
133
|
+
variants.add(urlunsplit((split.scheme, split.netloc, path.rstrip("/"), "", "")))
|
|
134
|
+
else:
|
|
135
|
+
variants.add(urlunsplit((split.scheme, split.netloc, path + "/", "", "")))
|
|
136
|
+
return {variant for variant in variants if normalize_web_url(variant) is not None}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _same_origin_bounded_derivative(source_url: str, requested_url: str) -> bool:
|
|
140
|
+
source = normalize_web_url(source_url)
|
|
141
|
+
requested = normalize_web_url(requested_url)
|
|
142
|
+
if source is None or requested is None:
|
|
143
|
+
return False
|
|
144
|
+
source_split = urlsplit(source)
|
|
145
|
+
requested_split = urlsplit(requested)
|
|
146
|
+
if (
|
|
147
|
+
source_split.scheme,
|
|
148
|
+
source_split.netloc,
|
|
149
|
+
) != (
|
|
150
|
+
requested_split.scheme,
|
|
151
|
+
requested_split.netloc,
|
|
152
|
+
):
|
|
153
|
+
return False
|
|
154
|
+
source_path = source_split.path.rstrip("/")
|
|
155
|
+
requested_path = requested_split.path.rstrip("/")
|
|
156
|
+
if source_path == requested_path:
|
|
157
|
+
return True
|
|
158
|
+
if not source_path or source_path == "/":
|
|
159
|
+
return False
|
|
160
|
+
bounded_pairs = (
|
|
161
|
+
("/blob/", "/raw/"),
|
|
162
|
+
("/-/blob/", "/-/raw/"),
|
|
163
|
+
)
|
|
164
|
+
for source_marker, requested_marker in bounded_pairs:
|
|
165
|
+
if source_marker not in source_path:
|
|
166
|
+
continue
|
|
167
|
+
expected = source_path.replace(source_marker, requested_marker, 1)
|
|
168
|
+
if requested_path == expected:
|
|
169
|
+
return True
|
|
170
|
+
return False
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _looks_like_http_url(text: str) -> bool:
|
|
174
|
+
lowered = str(text or "").strip().casefold()
|
|
175
|
+
return lowered.startswith("http://") or lowered.startswith("https://")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _strip_outer_markdown_url_wrappers(text: str) -> str:
|
|
179
|
+
candidate = str(text or "").strip()
|
|
180
|
+
for marker in _MARKDOWN_URL_WRAPPER_MARKERS:
|
|
181
|
+
leading = len(candidate) - len(candidate.lstrip(marker))
|
|
182
|
+
if leading == 0:
|
|
183
|
+
continue
|
|
184
|
+
trailing = len(candidate) - len(candidate.rstrip(marker))
|
|
185
|
+
if trailing == 0:
|
|
186
|
+
continue
|
|
187
|
+
trim = min(leading, trailing)
|
|
188
|
+
inner = candidate[trim:-trim].strip()
|
|
189
|
+
if _looks_like_http_url(inner):
|
|
190
|
+
return inner
|
|
191
|
+
return candidate
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _looks_like_wrapped_http_url_candidate(text: str) -> bool:
|
|
195
|
+
candidate = str(text or "").strip()
|
|
196
|
+
if _looks_like_http_url(candidate):
|
|
197
|
+
return True
|
|
198
|
+
stripped = _strip_outer_markdown_url_wrappers(candidate)
|
|
199
|
+
return stripped != candidate and _looks_like_http_url(stripped)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _strip_outer_url_wrappers(text: str) -> str:
|
|
203
|
+
candidate = str(text or "").strip()
|
|
204
|
+
while len(candidate) >= 2:
|
|
205
|
+
opener = candidate[0]
|
|
206
|
+
closer = candidate[-1]
|
|
207
|
+
if _URL_WRAPPER_PAIRS.get(opener) != closer:
|
|
208
|
+
break
|
|
209
|
+
inner = candidate[1:-1].strip()
|
|
210
|
+
if not _looks_like_wrapped_http_url_candidate(inner):
|
|
211
|
+
break
|
|
212
|
+
candidate = inner
|
|
213
|
+
return candidate
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _strip_simple_trailing_url_punctuation(text: str) -> str:
|
|
217
|
+
candidate = str(text or "").strip()
|
|
218
|
+
while candidate:
|
|
219
|
+
tail = candidate[-1]
|
|
220
|
+
if tail in _SIMPLE_TRAILING_URL_PUNCTUATION or tail in _TRAILING_URL_QUOTES:
|
|
221
|
+
candidate = candidate[:-1]
|
|
222
|
+
continue
|
|
223
|
+
break
|
|
224
|
+
return candidate
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _leading_url_wrapper_closer_budget(text: str, *, start: int) -> dict[str, int]:
|
|
228
|
+
budget: dict[str, int] = {}
|
|
229
|
+
index = start - 1
|
|
230
|
+
while index >= 0:
|
|
231
|
+
ch = text[index]
|
|
232
|
+
if ch in _MARKDOWN_URL_WRAPPER_MARKERS:
|
|
233
|
+
index -= 1
|
|
234
|
+
continue
|
|
235
|
+
closer = _URL_WRAPPER_PAIRS.get(ch)
|
|
236
|
+
if closer is None or closer not in _URL_CLOSING_WRAPPERS:
|
|
237
|
+
break
|
|
238
|
+
budget[closer] = budget.get(closer, 0) + 1
|
|
239
|
+
index -= 1
|
|
240
|
+
return budget
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _leading_url_quote_wrapper_budget(text: str, *, start: int) -> dict[str, int]:
|
|
244
|
+
budget: dict[str, int] = {}
|
|
245
|
+
index = start - 1
|
|
246
|
+
while index >= 0:
|
|
247
|
+
ch = text[index]
|
|
248
|
+
if ch in _MARKDOWN_URL_WRAPPER_MARKERS:
|
|
249
|
+
index -= 1
|
|
250
|
+
continue
|
|
251
|
+
if ch not in _URL_QUOTE_WRAPPERS:
|
|
252
|
+
break
|
|
253
|
+
budget[ch] = budget.get(ch, 0) + 1
|
|
254
|
+
index -= 1
|
|
255
|
+
return budget
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _markdown_link_label_start(text: str, *, close_bracket: int) -> int | None:
|
|
259
|
+
depth = 0
|
|
260
|
+
for index in range(close_bracket - 1, -1, -1):
|
|
261
|
+
ch = text[index]
|
|
262
|
+
if ch == "]":
|
|
263
|
+
depth += 1
|
|
264
|
+
continue
|
|
265
|
+
if ch != "[":
|
|
266
|
+
continue
|
|
267
|
+
if depth > 0:
|
|
268
|
+
depth -= 1
|
|
269
|
+
continue
|
|
270
|
+
if "\n" in text[index + 1 : close_bracket]:
|
|
271
|
+
return None
|
|
272
|
+
return index
|
|
273
|
+
return None
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _scan_public_web_url_candidate_end(text: str, *, start: int) -> int:
|
|
277
|
+
end = start
|
|
278
|
+
while end < len(text):
|
|
279
|
+
ch = text[end]
|
|
280
|
+
if ch.isspace() or ch in _URL_CANDIDATE_STOP_CHARS:
|
|
281
|
+
break
|
|
282
|
+
end += 1
|
|
283
|
+
return end
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _strip_bounded_trailing_url_closers(
|
|
287
|
+
text: str,
|
|
288
|
+
*,
|
|
289
|
+
closer_budget: dict[str, int] | None = None,
|
|
290
|
+
) -> tuple[str, dict[str, int]]:
|
|
291
|
+
candidate = str(text or "").strip()
|
|
292
|
+
remaining_budget = {
|
|
293
|
+
str(closer): int(count)
|
|
294
|
+
for closer, count in dict(closer_budget or {}).items()
|
|
295
|
+
if int(count) > 0
|
|
296
|
+
}
|
|
297
|
+
while candidate:
|
|
298
|
+
tail = candidate[-1]
|
|
299
|
+
opener = _URL_CLOSING_WRAPPERS.get(tail)
|
|
300
|
+
if opener is None:
|
|
301
|
+
break
|
|
302
|
+
if remaining_budget.get(tail, 0) <= 0:
|
|
303
|
+
break
|
|
304
|
+
if candidate.count(tail) <= candidate.count(opener):
|
|
305
|
+
break
|
|
306
|
+
candidate = candidate[:-1]
|
|
307
|
+
remaining_budget[tail] -= 1
|
|
308
|
+
if remaining_budget[tail] <= 0:
|
|
309
|
+
remaining_budget.pop(tail, None)
|
|
310
|
+
return candidate, remaining_budget
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _strip_bounded_trailing_url_quotes(
|
|
314
|
+
text: str,
|
|
315
|
+
*,
|
|
316
|
+
quote_budget: dict[str, int] | None = None,
|
|
317
|
+
) -> tuple[str, dict[str, int]]:
|
|
318
|
+
candidate = str(text or "").strip()
|
|
319
|
+
remaining_budget = {
|
|
320
|
+
str(quote): int(count)
|
|
321
|
+
for quote, count in dict(quote_budget or {}).items()
|
|
322
|
+
if str(quote) in _URL_QUOTE_WRAPPERS and int(count) > 0
|
|
323
|
+
}
|
|
324
|
+
while candidate:
|
|
325
|
+
tail = candidate[-1]
|
|
326
|
+
if tail not in _URL_QUOTE_WRAPPERS:
|
|
327
|
+
break
|
|
328
|
+
if remaining_budget.get(tail, 0) <= 0:
|
|
329
|
+
break
|
|
330
|
+
candidate = candidate[:-1]
|
|
331
|
+
remaining_budget[tail] -= 1
|
|
332
|
+
if remaining_budget[tail] <= 0:
|
|
333
|
+
remaining_budget.pop(tail, None)
|
|
334
|
+
return candidate, remaining_budget
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _trailing_markdown_url_wrapper_marker(
|
|
338
|
+
text: str,
|
|
339
|
+
*,
|
|
340
|
+
closer_budget: dict[str, int] | None = None,
|
|
341
|
+
quote_budget: dict[str, int] | None = None,
|
|
342
|
+
) -> str | None:
|
|
343
|
+
candidate = _strip_simple_trailing_url_punctuation(text)
|
|
344
|
+
candidate, _remaining_budget = _strip_bounded_trailing_url_closers(
|
|
345
|
+
candidate,
|
|
346
|
+
closer_budget=closer_budget,
|
|
347
|
+
)
|
|
348
|
+
candidate, _remaining_quote_budget = _strip_bounded_trailing_url_quotes(
|
|
349
|
+
candidate,
|
|
350
|
+
quote_budget=quote_budget,
|
|
351
|
+
)
|
|
352
|
+
for marker in _MARKDOWN_URL_WRAPPER_MARKERS:
|
|
353
|
+
if candidate.endswith(marker):
|
|
354
|
+
return marker
|
|
355
|
+
return None
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def cleanup_public_web_url_candidate(
|
|
359
|
+
raw_url: Any,
|
|
360
|
+
*,
|
|
361
|
+
closer_budget: dict[str, int] | None = None,
|
|
362
|
+
quote_budget: dict[str, int] | None = None,
|
|
363
|
+
) -> str:
|
|
364
|
+
candidate = str(raw_url or "").strip()
|
|
365
|
+
previous = None
|
|
366
|
+
remaining_closer_budget = dict(closer_budget or {})
|
|
367
|
+
remaining_quote_budget = dict(quote_budget or {})
|
|
368
|
+
while candidate and candidate != previous:
|
|
369
|
+
previous = candidate
|
|
370
|
+
candidate = _strip_outer_url_wrappers(candidate)
|
|
371
|
+
candidate = _strip_outer_markdown_url_wrappers(candidate)
|
|
372
|
+
candidate = _strip_simple_trailing_url_punctuation(candidate)
|
|
373
|
+
candidate, remaining_closer_budget = _strip_bounded_trailing_url_closers(
|
|
374
|
+
candidate,
|
|
375
|
+
closer_budget=remaining_closer_budget,
|
|
376
|
+
)
|
|
377
|
+
candidate, remaining_quote_budget = _strip_bounded_trailing_url_quotes(
|
|
378
|
+
candidate,
|
|
379
|
+
quote_budget=remaining_quote_budget,
|
|
380
|
+
)
|
|
381
|
+
return candidate
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def cleanup_structured_web_url_target(
|
|
385
|
+
raw_url: Any,
|
|
386
|
+
*,
|
|
387
|
+
closer_budget: dict[str, int] | None = None,
|
|
388
|
+
quote_budget: dict[str, int] | None = None,
|
|
389
|
+
) -> str:
|
|
390
|
+
candidate = str(raw_url or "").strip()
|
|
391
|
+
previous = None
|
|
392
|
+
remaining_closer_budget = dict(closer_budget or {})
|
|
393
|
+
remaining_quote_budget = dict(quote_budget or {})
|
|
394
|
+
while candidate and candidate != previous:
|
|
395
|
+
previous = candidate
|
|
396
|
+
candidate = _strip_outer_url_wrappers(candidate)
|
|
397
|
+
candidate = _strip_outer_markdown_url_wrappers(candidate)
|
|
398
|
+
candidate, remaining_closer_budget = _strip_bounded_trailing_url_closers(
|
|
399
|
+
candidate,
|
|
400
|
+
closer_budget=remaining_closer_budget,
|
|
401
|
+
)
|
|
402
|
+
candidate, remaining_quote_budget = _strip_bounded_trailing_url_quotes(
|
|
403
|
+
candidate,
|
|
404
|
+
quote_budget=remaining_quote_budget,
|
|
405
|
+
)
|
|
406
|
+
return candidate
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def canonicalize_web_url_input(raw_url: Any) -> str | None:
|
|
410
|
+
return normalize_web_url(cleanup_public_web_url_candidate(raw_url))
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _strip_markdown_link_target_syntax(
|
|
414
|
+
raw_url: str,
|
|
415
|
+
*,
|
|
416
|
+
outer_closer_budget: dict[str, int] | None = None,
|
|
417
|
+
) -> str:
|
|
418
|
+
candidate = str(raw_url or "").strip()
|
|
419
|
+
while candidate and candidate[-1] in _SIMPLE_TRAILING_URL_PUNCTUATION and ")" in candidate:
|
|
420
|
+
candidate = candidate[:-1].rstrip()
|
|
421
|
+
if candidate.endswith(")"):
|
|
422
|
+
candidate = candidate[:-1]
|
|
423
|
+
return cleanup_structured_web_url_target(
|
|
424
|
+
candidate,
|
|
425
|
+
closer_budget=outer_closer_budget,
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _scan_markdown_link_title_end(text: str, *, start: int) -> int | None:
|
|
430
|
+
index = start
|
|
431
|
+
if index >= len(text) or not text[index].isspace():
|
|
432
|
+
return None
|
|
433
|
+
while index < len(text) and text[index].isspace():
|
|
434
|
+
index += 1
|
|
435
|
+
if index >= len(text):
|
|
436
|
+
return None
|
|
437
|
+
|
|
438
|
+
opener = text[index]
|
|
439
|
+
if opener in {"'", '"'}:
|
|
440
|
+
closer = opener
|
|
441
|
+
index += 1
|
|
442
|
+
while index < len(text):
|
|
443
|
+
ch = text[index]
|
|
444
|
+
if ch == "\\":
|
|
445
|
+
index += 2
|
|
446
|
+
continue
|
|
447
|
+
if ch == closer:
|
|
448
|
+
index += 1
|
|
449
|
+
break
|
|
450
|
+
index += 1
|
|
451
|
+
else:
|
|
452
|
+
return None
|
|
453
|
+
elif opener == "(":
|
|
454
|
+
depth = 1
|
|
455
|
+
index += 1
|
|
456
|
+
while index < len(text):
|
|
457
|
+
ch = text[index]
|
|
458
|
+
if ch == "\\":
|
|
459
|
+
index += 2
|
|
460
|
+
continue
|
|
461
|
+
if ch == "(":
|
|
462
|
+
depth += 1
|
|
463
|
+
elif ch == ")":
|
|
464
|
+
depth -= 1
|
|
465
|
+
if depth == 0:
|
|
466
|
+
index += 1
|
|
467
|
+
break
|
|
468
|
+
index += 1
|
|
469
|
+
else:
|
|
470
|
+
return None
|
|
471
|
+
else:
|
|
472
|
+
return None
|
|
473
|
+
|
|
474
|
+
while index < len(text) and text[index].isspace():
|
|
475
|
+
index += 1
|
|
476
|
+
if index < len(text) and text[index] == ")":
|
|
477
|
+
return index + 1
|
|
478
|
+
return None
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _public_web_url_entry(raw_url: str) -> dict[str, str] | None:
|
|
482
|
+
normalized = normalize_web_url(raw_url)
|
|
483
|
+
if normalized is None:
|
|
484
|
+
return None
|
|
485
|
+
return {
|
|
486
|
+
"url": raw_url,
|
|
487
|
+
"normalized_url": normalized,
|
|
488
|
+
"domain": _domain_for_url(normalized),
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _extract_markdown_autolink_web_urls(text: str) -> list[tuple[dict[str, str], tuple[int, int]]]:
|
|
493
|
+
extracted: list[tuple[dict[str, str], tuple[int, int]]] = []
|
|
494
|
+
cursor = 0
|
|
495
|
+
while True:
|
|
496
|
+
opener = text.find("<", cursor)
|
|
497
|
+
if opener < 0:
|
|
498
|
+
break
|
|
499
|
+
url_start = opener + 1
|
|
500
|
+
if not _looks_like_http_url(text[url_start:]):
|
|
501
|
+
cursor = url_start
|
|
502
|
+
continue
|
|
503
|
+
candidate_end = text.find(">", url_start)
|
|
504
|
+
if candidate_end < 0:
|
|
505
|
+
cursor = url_start
|
|
506
|
+
continue
|
|
507
|
+
raw = cleanup_structured_web_url_target(text[url_start:candidate_end])
|
|
508
|
+
entry = _public_web_url_entry(raw)
|
|
509
|
+
if entry is not None:
|
|
510
|
+
extracted.append((entry, (url_start, candidate_end)))
|
|
511
|
+
cursor = candidate_end + 1
|
|
512
|
+
return extracted
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _extract_markdown_link_web_urls(text: str) -> list[tuple[dict[str, str], tuple[int, int]]]:
|
|
516
|
+
extracted: list[tuple[dict[str, str], tuple[int, int]]] = []
|
|
517
|
+
cursor = 0
|
|
518
|
+
while True:
|
|
519
|
+
close_bracket = text.find("](", cursor)
|
|
520
|
+
if close_bracket < 0:
|
|
521
|
+
break
|
|
522
|
+
label_start = _markdown_link_label_start(text, close_bracket=close_bracket)
|
|
523
|
+
target_start = close_bracket + 2
|
|
524
|
+
while target_start < len(text) and text[target_start].isspace():
|
|
525
|
+
target_start += 1
|
|
526
|
+
if label_start is None or target_start >= len(text):
|
|
527
|
+
cursor = close_bracket + 2
|
|
528
|
+
continue
|
|
529
|
+
|
|
530
|
+
if text[target_start] == "<":
|
|
531
|
+
url_start = target_start + 1
|
|
532
|
+
candidate_end = text.find(">", url_start)
|
|
533
|
+
if candidate_end < 0:
|
|
534
|
+
cursor = close_bracket + 2
|
|
535
|
+
continue
|
|
536
|
+
title_end = _scan_markdown_link_title_end(text, start=candidate_end + 1)
|
|
537
|
+
skip_end = title_end or candidate_end + 1
|
|
538
|
+
raw = cleanup_structured_web_url_target(text[url_start:candidate_end])
|
|
539
|
+
else:
|
|
540
|
+
url_start = target_start
|
|
541
|
+
if not _looks_like_http_url(text[url_start:]):
|
|
542
|
+
cursor = close_bracket + 2
|
|
543
|
+
continue
|
|
544
|
+
candidate_end = _scan_public_web_url_candidate_end(text, start=url_start)
|
|
545
|
+
raw_candidate = text[url_start:candidate_end]
|
|
546
|
+
title_end = _scan_markdown_link_title_end(text, start=candidate_end)
|
|
547
|
+
skip_end = title_end or candidate_end
|
|
548
|
+
outer_closer_budget = _leading_url_wrapper_closer_budget(text, start=label_start)
|
|
549
|
+
if title_end is not None:
|
|
550
|
+
raw = cleanup_structured_web_url_target(
|
|
551
|
+
raw_candidate,
|
|
552
|
+
closer_budget=outer_closer_budget,
|
|
553
|
+
)
|
|
554
|
+
else:
|
|
555
|
+
raw = _strip_markdown_link_target_syntax(
|
|
556
|
+
raw_candidate,
|
|
557
|
+
outer_closer_budget=outer_closer_budget,
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
entry = _public_web_url_entry(raw)
|
|
561
|
+
if entry is not None:
|
|
562
|
+
extracted.append((entry, (url_start, skip_end)))
|
|
563
|
+
cursor = max(close_bracket + 2, skip_end)
|
|
564
|
+
return extracted
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _span_overlaps(start: int, end: int, spans: list[tuple[int, int]]) -> bool:
|
|
568
|
+
return any(start < span_end and end > span_start for span_start, span_end in spans)
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _expand_markdown_wrapped_url_candidate(
|
|
572
|
+
text: str,
|
|
573
|
+
*,
|
|
574
|
+
start: int,
|
|
575
|
+
end: int,
|
|
576
|
+
closer_budget: dict[str, int] | None = None,
|
|
577
|
+
) -> str:
|
|
578
|
+
candidate = text[start:end]
|
|
579
|
+
quote_budget = _leading_url_quote_wrapper_budget(text, start=start)
|
|
580
|
+
marker = _trailing_markdown_url_wrapper_marker(
|
|
581
|
+
candidate,
|
|
582
|
+
closer_budget=closer_budget,
|
|
583
|
+
quote_budget=quote_budget,
|
|
584
|
+
)
|
|
585
|
+
if marker is None:
|
|
586
|
+
return candidate
|
|
587
|
+
expanded_start = start
|
|
588
|
+
while expanded_start > 0 and text[expanded_start - 1] == marker:
|
|
589
|
+
expanded_start -= 1
|
|
590
|
+
if expanded_start == start:
|
|
591
|
+
return candidate
|
|
592
|
+
if expanded_start > 0 and text[expanded_start - 1].isalnum():
|
|
593
|
+
return candidate
|
|
594
|
+
return text[expanded_start:end]
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def extract_public_web_urls(text: Any) -> list[dict[str, str]]:
|
|
598
|
+
body = str(text or "")
|
|
599
|
+
candidates: list[tuple[int, dict[str, str]]] = []
|
|
600
|
+
markdown_target_spans: list[tuple[int, int]] = []
|
|
601
|
+
for entry, span in _extract_markdown_autolink_web_urls(body):
|
|
602
|
+
markdown_target_spans.append(span)
|
|
603
|
+
candidates.append((span[0], entry))
|
|
604
|
+
for entry, span in _extract_markdown_link_web_urls(body):
|
|
605
|
+
markdown_target_spans.append(span)
|
|
606
|
+
candidates.append((span[0], entry))
|
|
607
|
+
|
|
608
|
+
for match in _URL_RE.finditer(body):
|
|
609
|
+
if _span_overlaps(match.start(), match.end(), markdown_target_spans):
|
|
610
|
+
continue
|
|
611
|
+
closer_budget = _leading_url_wrapper_closer_budget(body, start=match.start())
|
|
612
|
+
quote_budget = _leading_url_quote_wrapper_budget(body, start=match.start())
|
|
613
|
+
raw_candidate = _expand_markdown_wrapped_url_candidate(
|
|
614
|
+
body,
|
|
615
|
+
start=match.start(),
|
|
616
|
+
end=match.end(),
|
|
617
|
+
closer_budget=closer_budget,
|
|
618
|
+
)
|
|
619
|
+
raw = cleanup_public_web_url_candidate(
|
|
620
|
+
raw_candidate,
|
|
621
|
+
closer_budget=closer_budget,
|
|
622
|
+
quote_budget=quote_budget,
|
|
623
|
+
)
|
|
624
|
+
entry = _public_web_url_entry(raw)
|
|
625
|
+
if entry is not None:
|
|
626
|
+
candidates.append((match.start(), entry))
|
|
627
|
+
|
|
628
|
+
extracted: list[dict[str, str]] = []
|
|
629
|
+
seen: set[str] = set()
|
|
630
|
+
for _start, entry in sorted(candidates, key=lambda item: item[0]):
|
|
631
|
+
normalized = entry["normalized_url"]
|
|
632
|
+
if normalized in seen:
|
|
633
|
+
continue
|
|
634
|
+
seen.add(normalized)
|
|
635
|
+
extracted.append(entry)
|
|
636
|
+
return extracted
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
class SessionWebResearchTracker:
|
|
640
|
+
def __init__(self) -> None:
|
|
641
|
+
self._user_urls: dict[str, dict[str, Any]] = {}
|
|
642
|
+
self._returned_source_urls: dict[str, dict[str, Any]] = {}
|
|
643
|
+
self._canonical_redirect_urls: dict[str, dict[str, Any]] = {}
|
|
644
|
+
self._search_mediated_urls: dict[str, dict[str, Any]] = {}
|
|
645
|
+
self._provenance_nodes: dict[str, dict[str, Any]] = {}
|
|
646
|
+
self._searches: list[dict[str, Any]] = []
|
|
647
|
+
self._fetches: list[dict[str, Any]] = []
|
|
648
|
+
self._pending_search_indices: list[int] = []
|
|
649
|
+
self._pending_fetch_indices: list[int] = []
|
|
650
|
+
|
|
651
|
+
def classify_fetch_url(self, raw_url: Any) -> str | None:
|
|
652
|
+
classification, _effective_url = self.resolve_fetch_url(raw_url)
|
|
653
|
+
return classification
|
|
654
|
+
|
|
655
|
+
def resolve_fetch_url(self, raw_url: Any) -> tuple[str | None, str | None]:
|
|
656
|
+
strict_normalized = normalize_web_url(raw_url)
|
|
657
|
+
if strict_normalized:
|
|
658
|
+
classification = self._classification_for_normalized_url(strict_normalized)
|
|
659
|
+
if classification is not None:
|
|
660
|
+
return classification, strict_normalized
|
|
661
|
+
effective_normalized = canonicalize_web_url_input(raw_url)
|
|
662
|
+
if effective_normalized:
|
|
663
|
+
return self._classification_for_normalized_url(
|
|
664
|
+
effective_normalized
|
|
665
|
+
), effective_normalized
|
|
666
|
+
return None, strict_normalized
|
|
667
|
+
|
|
668
|
+
def fetchable_urls(self, *, limit: int = 10) -> list[str]:
|
|
669
|
+
"""Normalized URLs that ``web_fetch`` will currently authorize.
|
|
670
|
+
|
|
671
|
+
These are the exact canonical forms ``resolve_fetch_url`` matches against,
|
|
672
|
+
so the model can copy one verbatim into ``web_fetch``. web_search source
|
|
673
|
+
URLs come first (the common "fetch a result" case), then user-provided
|
|
674
|
+
URLs; order within each group is the order they were observed (so "the
|
|
675
|
+
first one" maps to the first source). Bounded so a long session does not
|
|
676
|
+
bloat an error payload.
|
|
677
|
+
"""
|
|
678
|
+
ordered: list[str] = []
|
|
679
|
+
seen: set[str] = set()
|
|
680
|
+
for url in (*self._returned_source_urls.keys(), *self._user_urls.keys()):
|
|
681
|
+
normalized = str(url or "").strip()
|
|
682
|
+
if normalized and normalized not in seen:
|
|
683
|
+
seen.add(normalized)
|
|
684
|
+
ordered.append(normalized)
|
|
685
|
+
if limit is not None and limit > 0:
|
|
686
|
+
return ordered[:limit]
|
|
687
|
+
return ordered
|
|
688
|
+
|
|
689
|
+
def observe_event(
|
|
690
|
+
self,
|
|
691
|
+
*,
|
|
692
|
+
event_type: str,
|
|
693
|
+
payload: dict[str, Any],
|
|
694
|
+
ts: str | None,
|
|
695
|
+
event_id: str | None = None,
|
|
696
|
+
) -> bool:
|
|
697
|
+
event_name = str(event_type or "").strip()
|
|
698
|
+
if not isinstance(payload, dict):
|
|
699
|
+
return False
|
|
700
|
+
if event_name == "user_message":
|
|
701
|
+
return self._record_user_message(payload=payload, ts=ts, event_id=event_id)
|
|
702
|
+
if event_name == "tool_call":
|
|
703
|
+
name = str(payload.get("name") or "").strip()
|
|
704
|
+
if name == "web_search":
|
|
705
|
+
return self._record_web_search_call(payload=payload, ts=ts, event_id=event_id)
|
|
706
|
+
if name == "web_fetch":
|
|
707
|
+
return self._record_web_fetch_call(payload=payload, ts=ts, event_id=event_id)
|
|
708
|
+
return False
|
|
709
|
+
if event_name == "tool_result":
|
|
710
|
+
name = str(payload.get("name") or "").strip()
|
|
711
|
+
if name == "web_search":
|
|
712
|
+
return self._record_web_search_result(payload=payload, ts=ts, event_id=event_id)
|
|
713
|
+
if name == "web_fetch":
|
|
714
|
+
return self._record_web_fetch_result(payload=payload, ts=ts, event_id=event_id)
|
|
715
|
+
return self._record_trusted_tool_result_urls(
|
|
716
|
+
tool_name=name,
|
|
717
|
+
payload=payload,
|
|
718
|
+
ts=ts,
|
|
719
|
+
event_id=event_id,
|
|
720
|
+
)
|
|
721
|
+
return False
|
|
722
|
+
|
|
723
|
+
def artifact_payload(self) -> dict[str, Any]:
|
|
724
|
+
searches = [copy.deepcopy(entry) for entry in self._searches]
|
|
725
|
+
fetches = [copy.deepcopy(entry) for entry in self._fetches]
|
|
726
|
+
user_urls = [copy.deepcopy(entry) for entry in self._user_urls.values()]
|
|
727
|
+
returned_urls = [copy.deepcopy(entry) for entry in self._returned_source_urls.values()]
|
|
728
|
+
redirect_urls = [copy.deepcopy(entry) for entry in self._canonical_redirect_urls.values()]
|
|
729
|
+
search_mediated_urls = [
|
|
730
|
+
copy.deepcopy(entry) for entry in self._search_mediated_urls.values()
|
|
731
|
+
]
|
|
732
|
+
provenance_nodes = [copy.deepcopy(entry) for entry in self._provenance_nodes.values()]
|
|
733
|
+
deduped_normalized_queries = _dedupe_ordered(
|
|
734
|
+
[str(entry.get("normalized_query") or "") for entry in searches]
|
|
735
|
+
+ [
|
|
736
|
+
str(normalized)
|
|
737
|
+
for entry in searches
|
|
738
|
+
for normalized in list(entry.get("normalized_queries") or [])
|
|
739
|
+
]
|
|
740
|
+
)
|
|
741
|
+
deduped_normalized_fetch_urls = _dedupe_ordered(
|
|
742
|
+
[str(entry.get("normalized_requested_url") or "") for entry in fetches]
|
|
743
|
+
)
|
|
744
|
+
deduped_normalized_final_fetch_urls = _dedupe_ordered(
|
|
745
|
+
[str(entry.get("normalized_final_url") or "") for entry in fetches]
|
|
746
|
+
)
|
|
747
|
+
return {
|
|
748
|
+
"schema_version": 2,
|
|
749
|
+
"user_provided_urls": user_urls,
|
|
750
|
+
"returned_by_web_search_urls": returned_urls,
|
|
751
|
+
"canonical_redirect_urls": redirect_urls,
|
|
752
|
+
"search_mediated_recovery_urls": search_mediated_urls,
|
|
753
|
+
"url_provenance_graph": {
|
|
754
|
+
"node_count": len(provenance_nodes),
|
|
755
|
+
"max_nodes": _MAX_PROVENANCE_NODES,
|
|
756
|
+
"nodes": provenance_nodes,
|
|
757
|
+
},
|
|
758
|
+
"searches": searches,
|
|
759
|
+
"fetches": fetches,
|
|
760
|
+
"deduped_normalized_queries": deduped_normalized_queries,
|
|
761
|
+
"deduped_normalized_user_urls": list(self._user_urls.keys()),
|
|
762
|
+
"deduped_normalized_search_source_urls": list(self._returned_source_urls.keys()),
|
|
763
|
+
"deduped_normalized_canonical_redirect_urls": list(
|
|
764
|
+
self._canonical_redirect_urls.keys()
|
|
765
|
+
),
|
|
766
|
+
"deduped_normalized_search_mediated_recovery_urls": list(
|
|
767
|
+
self._search_mediated_urls.keys()
|
|
768
|
+
),
|
|
769
|
+
"deduped_normalized_provenance_graph_urls": list(self._provenance_nodes.keys()),
|
|
770
|
+
"deduped_normalized_fetch_urls": deduped_normalized_fetch_urls,
|
|
771
|
+
"deduped_normalized_final_fetch_urls": deduped_normalized_final_fetch_urls,
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
def metrics_payload(self) -> dict[str, int]:
|
|
775
|
+
normalized_queries = [
|
|
776
|
+
str(entry.get("normalized_query") or "")
|
|
777
|
+
for entry in self._searches
|
|
778
|
+
if str(entry.get("normalized_query") or "").strip()
|
|
779
|
+
]
|
|
780
|
+
normalized_fetches = [
|
|
781
|
+
str(entry.get("normalized_requested_url") or "")
|
|
782
|
+
for entry in self._fetches
|
|
783
|
+
if str(entry.get("normalized_requested_url") or "").strip()
|
|
784
|
+
]
|
|
785
|
+
query_counts = {
|
|
786
|
+
query: normalized_queries.count(query) for query in _dedupe_ordered(normalized_queries)
|
|
787
|
+
}
|
|
788
|
+
fetch_counts = {
|
|
789
|
+
url: normalized_fetches.count(url) for url in _dedupe_ordered(normalized_fetches)
|
|
790
|
+
}
|
|
791
|
+
return {
|
|
792
|
+
"web_search_calls": len(self._searches),
|
|
793
|
+
"web_fetch_calls": len(self._fetches),
|
|
794
|
+
"unique_web_queries": len(query_counts),
|
|
795
|
+
"unique_web_fetch_urls": len(fetch_counts),
|
|
796
|
+
"duplicate_web_queries": sum(max(count - 1, 0) for count in query_counts.values()),
|
|
797
|
+
"duplicate_web_fetches": sum(max(count - 1, 0) for count in fetch_counts.values()),
|
|
798
|
+
"total_web_sources_returned": sum(
|
|
799
|
+
len(list(entry.get("returned_sources") or [])) for entry in self._searches
|
|
800
|
+
),
|
|
801
|
+
"total_web_sources_fetched": sum(
|
|
802
|
+
1 for entry in self._fetches if str(entry.get("normalized_final_url") or "").strip()
|
|
803
|
+
),
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
def has_activity(self) -> bool:
|
|
807
|
+
return bool(
|
|
808
|
+
self._user_urls
|
|
809
|
+
or self._returned_source_urls
|
|
810
|
+
or self._canonical_redirect_urls
|
|
811
|
+
or self._search_mediated_urls
|
|
812
|
+
or self._provenance_nodes
|
|
813
|
+
or self._searches
|
|
814
|
+
or self._fetches
|
|
815
|
+
)
|
|
816
|
+
|
|
817
|
+
def _classification_for_normalized_url(self, normalized: str) -> str | None:
|
|
818
|
+
for variant in _equivalent_fetch_url_variants(normalized):
|
|
819
|
+
if variant in self._user_urls:
|
|
820
|
+
return _USER_PROVIDED
|
|
821
|
+
if variant in self._returned_source_urls:
|
|
822
|
+
return _RETURNED_BY_WEB_SEARCH
|
|
823
|
+
if variant in self._canonical_redirect_urls:
|
|
824
|
+
return _CANONICAL_REDIRECT
|
|
825
|
+
if variant in self._search_mediated_urls:
|
|
826
|
+
return _SEARCH_MEDIATED_RECOVERY
|
|
827
|
+
node = self._provenance_nodes.get(variant)
|
|
828
|
+
if node is not None:
|
|
829
|
+
classification = str(node.get("provenance_classification") or "").strip()
|
|
830
|
+
if classification in _FETCHABLE_PROVENANCE_CLASSES:
|
|
831
|
+
return classification
|
|
832
|
+
if self._is_same_origin_derived_search_url(normalized):
|
|
833
|
+
return _SAME_ORIGIN_DERIVED
|
|
834
|
+
return None
|
|
835
|
+
|
|
836
|
+
def establish_search_mediated_fetch_url(
|
|
837
|
+
self,
|
|
838
|
+
*,
|
|
839
|
+
raw_url: Any,
|
|
840
|
+
query: str,
|
|
841
|
+
source_url: str | None = None,
|
|
842
|
+
) -> tuple[bool, str | None]:
|
|
843
|
+
normalized = normalize_web_url(raw_url) or canonicalize_web_url_input(raw_url)
|
|
844
|
+
if normalized is None:
|
|
845
|
+
return False, None
|
|
846
|
+
if normalized not in self._search_mediated_urls:
|
|
847
|
+
self._search_mediated_urls[normalized] = {
|
|
848
|
+
"url": str(raw_url or "").strip() or normalized,
|
|
849
|
+
"normalized_url": normalized,
|
|
850
|
+
"domain": _domain_for_url(normalized),
|
|
851
|
+
"query": str(query or "").strip(),
|
|
852
|
+
"source_url": str(source_url or "").strip(),
|
|
853
|
+
"provenance_classification": _SEARCH_MEDIATED_RECOVERY,
|
|
854
|
+
}
|
|
855
|
+
self._add_provenance_node(
|
|
856
|
+
raw_url=raw_url,
|
|
857
|
+
provenance_classification=_SEARCH_MEDIATED_RECOVERY,
|
|
858
|
+
source_event_id=None,
|
|
859
|
+
parent_url=source_url or "",
|
|
860
|
+
parent_source_event_id=None,
|
|
861
|
+
discovery_mechanism="search_mediated_recovery",
|
|
862
|
+
ts=None,
|
|
863
|
+
redirect_chain=(),
|
|
864
|
+
security_validation_status="syntax_validated_fetch_revalidates_target",
|
|
865
|
+
)
|
|
866
|
+
return True, normalized
|
|
867
|
+
return False, normalized
|
|
868
|
+
|
|
869
|
+
def _is_same_origin_derived_search_url(self, normalized: str) -> bool:
|
|
870
|
+
return any(
|
|
871
|
+
_same_origin_bounded_derivative(source_url, normalized)
|
|
872
|
+
for source_url in self._returned_source_urls
|
|
873
|
+
)
|
|
874
|
+
|
|
875
|
+
def hydrate_from_artifact_payload(self, payload: dict[str, Any]) -> bool:
|
|
876
|
+
if not isinstance(payload, dict):
|
|
877
|
+
return False
|
|
878
|
+
user_urls = payload.get("user_provided_urls")
|
|
879
|
+
returned_urls = payload.get("returned_by_web_search_urls")
|
|
880
|
+
redirect_urls = payload.get("canonical_redirect_urls")
|
|
881
|
+
search_mediated_urls = payload.get("search_mediated_recovery_urls")
|
|
882
|
+
provenance_graph = payload.get("url_provenance_graph")
|
|
883
|
+
searches = payload.get("searches")
|
|
884
|
+
fetches = payload.get("fetches")
|
|
885
|
+
if not any(
|
|
886
|
+
isinstance(value, list) and value
|
|
887
|
+
for value in (
|
|
888
|
+
user_urls,
|
|
889
|
+
returned_urls,
|
|
890
|
+
redirect_urls,
|
|
891
|
+
search_mediated_urls,
|
|
892
|
+
provenance_graph.get("nodes") if isinstance(provenance_graph, dict) else None,
|
|
893
|
+
searches,
|
|
894
|
+
fetches,
|
|
895
|
+
)
|
|
896
|
+
):
|
|
897
|
+
return False
|
|
898
|
+
self._user_urls = self._hydrate_url_index(
|
|
899
|
+
raw_entries=user_urls,
|
|
900
|
+
fallback_classification=_USER_PROVIDED,
|
|
901
|
+
)
|
|
902
|
+
self._returned_source_urls = self._hydrate_url_index(
|
|
903
|
+
raw_entries=returned_urls,
|
|
904
|
+
fallback_classification=_RETURNED_BY_WEB_SEARCH,
|
|
905
|
+
)
|
|
906
|
+
self._canonical_redirect_urls = self._hydrate_url_index(
|
|
907
|
+
raw_entries=redirect_urls,
|
|
908
|
+
fallback_classification=_CANONICAL_REDIRECT,
|
|
909
|
+
)
|
|
910
|
+
self._search_mediated_urls = self._hydrate_url_index(
|
|
911
|
+
raw_entries=search_mediated_urls,
|
|
912
|
+
fallback_classification=_SEARCH_MEDIATED_RECOVERY,
|
|
913
|
+
)
|
|
914
|
+
raw_nodes = provenance_graph.get("nodes") if isinstance(provenance_graph, dict) else None
|
|
915
|
+
self._provenance_nodes = self._hydrate_url_index(
|
|
916
|
+
raw_entries=raw_nodes,
|
|
917
|
+
fallback_classification=_TRUSTED_TOOL_OUTPUT,
|
|
918
|
+
)
|
|
919
|
+
self._searches = self._hydrate_event_entries(searches)
|
|
920
|
+
self._fetches = self._hydrate_event_entries(fetches)
|
|
921
|
+
self._pending_search_indices = []
|
|
922
|
+
self._pending_fetch_indices = []
|
|
923
|
+
return self.has_activity()
|
|
924
|
+
|
|
925
|
+
def merge_from_artifact_payload(self, payload: dict[str, Any]) -> bool:
|
|
926
|
+
other = SessionWebResearchTracker()
|
|
927
|
+
if not other.hydrate_from_artifact_payload(payload):
|
|
928
|
+
return False
|
|
929
|
+
return self.merge_from_tracker(other)
|
|
930
|
+
|
|
931
|
+
def merge_from_tracker(self, other: SessionWebResearchTracker) -> bool:
|
|
932
|
+
changed = False
|
|
933
|
+
changed |= self._merge_url_index(self._user_urls, other._user_urls)
|
|
934
|
+
changed |= self._merge_url_index(self._returned_source_urls, other._returned_source_urls)
|
|
935
|
+
changed |= self._merge_url_index(
|
|
936
|
+
self._canonical_redirect_urls,
|
|
937
|
+
other._canonical_redirect_urls,
|
|
938
|
+
)
|
|
939
|
+
changed |= self._merge_url_index(
|
|
940
|
+
self._search_mediated_urls,
|
|
941
|
+
other._search_mediated_urls,
|
|
942
|
+
)
|
|
943
|
+
changed |= self._merge_url_index(self._provenance_nodes, other._provenance_nodes)
|
|
944
|
+
changed |= self._merge_search_entries(other._searches)
|
|
945
|
+
changed |= self._merge_fetch_entries(other._fetches)
|
|
946
|
+
return changed
|
|
947
|
+
|
|
948
|
+
def clear_pending(self) -> None:
|
|
949
|
+
self._pending_search_indices = []
|
|
950
|
+
self._pending_fetch_indices = []
|
|
951
|
+
|
|
952
|
+
def _add_provenance_node(
|
|
953
|
+
self,
|
|
954
|
+
*,
|
|
955
|
+
raw_url: Any,
|
|
956
|
+
provenance_classification: str,
|
|
957
|
+
source_event_id: str | None,
|
|
958
|
+
parent_url: str,
|
|
959
|
+
parent_source_event_id: str | None,
|
|
960
|
+
discovery_mechanism: str,
|
|
961
|
+
ts: str | None,
|
|
962
|
+
redirect_chain: tuple[str, ...],
|
|
963
|
+
security_validation_status: str,
|
|
964
|
+
source_type: str = "",
|
|
965
|
+
) -> bool:
|
|
966
|
+
normalized = normalize_web_url(raw_url) or canonicalize_web_url_input(raw_url)
|
|
967
|
+
if normalized is None:
|
|
968
|
+
return False
|
|
969
|
+
if len(normalized) > _MAX_PROVENANCE_URL_LENGTH:
|
|
970
|
+
return False
|
|
971
|
+
existing = self._provenance_nodes.get(normalized)
|
|
972
|
+
entry = {
|
|
973
|
+
"url": str(raw_url or "").strip() or normalized,
|
|
974
|
+
"normalized_url": normalized,
|
|
975
|
+
"domain": _domain_for_url(normalized),
|
|
976
|
+
"provenance_classification": str(provenance_classification or "").strip(),
|
|
977
|
+
"source_event_id": str(source_event_id or "").strip() or None,
|
|
978
|
+
"parent_url": str(parent_url or "").strip(),
|
|
979
|
+
"parent_source_event_id": str(parent_source_event_id or "").strip() or None,
|
|
980
|
+
"discovery_mechanism": str(discovery_mechanism or "").strip(),
|
|
981
|
+
"source_type": str(source_type or "").strip(),
|
|
982
|
+
"ts": ts,
|
|
983
|
+
"redirect_chain": [
|
|
984
|
+
item
|
|
985
|
+
for item in (
|
|
986
|
+
normalize_web_url(url) or canonicalize_web_url_input(url) or ""
|
|
987
|
+
for url in redirect_chain
|
|
988
|
+
)
|
|
989
|
+
if item
|
|
990
|
+
][:_MAX_URLS_PER_EVENT],
|
|
991
|
+
"security_validation_status": str(security_validation_status or "").strip(),
|
|
992
|
+
}
|
|
993
|
+
if existing is None:
|
|
994
|
+
if len(self._provenance_nodes) >= _MAX_PROVENANCE_NODES:
|
|
995
|
+
return False
|
|
996
|
+
self._provenance_nodes[normalized] = entry
|
|
997
|
+
return True
|
|
998
|
+
|
|
999
|
+
changed = False
|
|
1000
|
+
for key, value in entry.items():
|
|
1001
|
+
if key == "redirect_chain":
|
|
1002
|
+
if not existing.get(key) and value:
|
|
1003
|
+
existing[key] = value
|
|
1004
|
+
changed = True
|
|
1005
|
+
continue
|
|
1006
|
+
current = existing.get(key)
|
|
1007
|
+
if (current is None or current == "") and value not in (None, ""):
|
|
1008
|
+
existing[key] = value
|
|
1009
|
+
changed = True
|
|
1010
|
+
return changed
|
|
1011
|
+
|
|
1012
|
+
def _record_urls_from_text(
|
|
1013
|
+
self,
|
|
1014
|
+
*,
|
|
1015
|
+
text: Any,
|
|
1016
|
+
provenance_classification: str,
|
|
1017
|
+
source_event_id: str | None,
|
|
1018
|
+
parent_url: str,
|
|
1019
|
+
parent_source_event_id: str | None,
|
|
1020
|
+
discovery_mechanism: str,
|
|
1021
|
+
source_type: str,
|
|
1022
|
+
ts: str | None,
|
|
1023
|
+
) -> bool:
|
|
1024
|
+
body = str(text or "")
|
|
1025
|
+
if not body:
|
|
1026
|
+
return False
|
|
1027
|
+
changed = False
|
|
1028
|
+
for entry in extract_public_web_urls(body[:_MAX_URL_TEXT_CHARS])[:_MAX_URLS_PER_EVENT]:
|
|
1029
|
+
changed |= self._add_provenance_node(
|
|
1030
|
+
raw_url=entry["url"],
|
|
1031
|
+
provenance_classification=provenance_classification,
|
|
1032
|
+
source_event_id=source_event_id,
|
|
1033
|
+
parent_url=parent_url,
|
|
1034
|
+
parent_source_event_id=parent_source_event_id,
|
|
1035
|
+
discovery_mechanism=discovery_mechanism,
|
|
1036
|
+
source_type=source_type,
|
|
1037
|
+
ts=ts,
|
|
1038
|
+
redirect_chain=(),
|
|
1039
|
+
security_validation_status="syntax_validated_fetch_revalidates_target",
|
|
1040
|
+
)
|
|
1041
|
+
return changed
|
|
1042
|
+
|
|
1043
|
+
def _record_trusted_tool_result_urls(
|
|
1044
|
+
self,
|
|
1045
|
+
*,
|
|
1046
|
+
tool_name: str,
|
|
1047
|
+
payload: dict[str, Any],
|
|
1048
|
+
ts: str | None,
|
|
1049
|
+
event_id: str | None,
|
|
1050
|
+
) -> bool:
|
|
1051
|
+
result = payload.get("result") if isinstance(payload.get("result"), dict) else {}
|
|
1052
|
+
if not result or "error" in result or "error_code" in result:
|
|
1053
|
+
return False
|
|
1054
|
+
if tool_name in {"fs_read", "fs_read_lines"}:
|
|
1055
|
+
return self._record_urls_from_text(
|
|
1056
|
+
text=result.get("content"),
|
|
1057
|
+
provenance_classification=_TRUSTED_LOCAL_FILE,
|
|
1058
|
+
source_event_id=event_id,
|
|
1059
|
+
parent_url=str(result.get("path") or ""),
|
|
1060
|
+
parent_source_event_id=event_id,
|
|
1061
|
+
discovery_mechanism="workspace_file_content",
|
|
1062
|
+
source_type=tool_name,
|
|
1063
|
+
ts=ts,
|
|
1064
|
+
)
|
|
1065
|
+
if tool_name in {"web_search", "web_fetch"}:
|
|
1066
|
+
return False
|
|
1067
|
+
snippets = list(self._iter_result_text_snippets(result))
|
|
1068
|
+
if not snippets:
|
|
1069
|
+
return False
|
|
1070
|
+
changed = False
|
|
1071
|
+
for snippet in snippets[:8]:
|
|
1072
|
+
changed |= self._record_urls_from_text(
|
|
1073
|
+
text=snippet,
|
|
1074
|
+
provenance_classification=_TRUSTED_TOOL_OUTPUT,
|
|
1075
|
+
source_event_id=event_id,
|
|
1076
|
+
parent_url=tool_name,
|
|
1077
|
+
parent_source_event_id=event_id,
|
|
1078
|
+
discovery_mechanism="registered_tool_output",
|
|
1079
|
+
source_type=tool_name,
|
|
1080
|
+
ts=ts,
|
|
1081
|
+
)
|
|
1082
|
+
return changed
|
|
1083
|
+
|
|
1084
|
+
def _iter_result_text_snippets(
|
|
1085
|
+
self,
|
|
1086
|
+
value: Any,
|
|
1087
|
+
*,
|
|
1088
|
+
depth: int = 0,
|
|
1089
|
+
) -> Iterable[str]:
|
|
1090
|
+
if depth > 4:
|
|
1091
|
+
return
|
|
1092
|
+
if isinstance(value, str):
|
|
1093
|
+
if "http://" in value or "https://" in value:
|
|
1094
|
+
yield value[:_MAX_URL_TEXT_CHARS]
|
|
1095
|
+
return
|
|
1096
|
+
if isinstance(value, dict):
|
|
1097
|
+
for item in value.values():
|
|
1098
|
+
yield from self._iter_result_text_snippets(item, depth=depth + 1)
|
|
1099
|
+
return
|
|
1100
|
+
if isinstance(value, list):
|
|
1101
|
+
for item in value[:64]:
|
|
1102
|
+
yield from self._iter_result_text_snippets(item, depth=depth + 1)
|
|
1103
|
+
|
|
1104
|
+
def _record_user_message(
|
|
1105
|
+
self,
|
|
1106
|
+
*,
|
|
1107
|
+
payload: dict[str, Any],
|
|
1108
|
+
ts: str | None,
|
|
1109
|
+
event_id: str | None,
|
|
1110
|
+
) -> bool:
|
|
1111
|
+
content = payload.get("content")
|
|
1112
|
+
if not isinstance(content, str):
|
|
1113
|
+
return False
|
|
1114
|
+
changed = False
|
|
1115
|
+
for entry in extract_public_web_urls(content):
|
|
1116
|
+
normalized = entry["normalized_url"]
|
|
1117
|
+
if normalized in self._user_urls:
|
|
1118
|
+
continue
|
|
1119
|
+
self._user_urls[normalized] = {
|
|
1120
|
+
"url": entry["url"],
|
|
1121
|
+
"normalized_url": normalized,
|
|
1122
|
+
"domain": entry["domain"],
|
|
1123
|
+
"ts": ts,
|
|
1124
|
+
"step": None,
|
|
1125
|
+
"source_event_id": event_id,
|
|
1126
|
+
"provenance_classification": _USER_PROVIDED,
|
|
1127
|
+
}
|
|
1128
|
+
self._add_provenance_node(
|
|
1129
|
+
raw_url=entry["url"],
|
|
1130
|
+
provenance_classification=_USER_PROVIDED,
|
|
1131
|
+
source_event_id=event_id,
|
|
1132
|
+
parent_url="",
|
|
1133
|
+
parent_source_event_id=None,
|
|
1134
|
+
discovery_mechanism="user_message_url",
|
|
1135
|
+
ts=ts,
|
|
1136
|
+
redirect_chain=(),
|
|
1137
|
+
security_validation_status="syntax_validated_fetch_revalidates_target",
|
|
1138
|
+
)
|
|
1139
|
+
changed = True
|
|
1140
|
+
return changed
|
|
1141
|
+
|
|
1142
|
+
def _record_web_search_call(
|
|
1143
|
+
self,
|
|
1144
|
+
*,
|
|
1145
|
+
payload: dict[str, Any],
|
|
1146
|
+
ts: str | None,
|
|
1147
|
+
event_id: str | None,
|
|
1148
|
+
) -> bool:
|
|
1149
|
+
arguments = payload.get("arguments") if isinstance(payload.get("arguments"), dict) else {}
|
|
1150
|
+
query = str(arguments.get("query") or "").strip()
|
|
1151
|
+
normalized_query = normalize_web_query(query)
|
|
1152
|
+
allowed_domains = [
|
|
1153
|
+
str(item or "").strip().lower()
|
|
1154
|
+
for item in list(arguments.get("allowed_domains") or [])
|
|
1155
|
+
if str(item or "").strip()
|
|
1156
|
+
]
|
|
1157
|
+
raw_external = arguments.get("external_web_access")
|
|
1158
|
+
external_web_access = raw_external if isinstance(raw_external, bool) else None
|
|
1159
|
+
step = payload.get("step") if isinstance(payload.get("step"), int) else None
|
|
1160
|
+
entry = {
|
|
1161
|
+
"step": step,
|
|
1162
|
+
"call_ts": ts,
|
|
1163
|
+
"result_ts": None,
|
|
1164
|
+
"query": query,
|
|
1165
|
+
"normalized_query": normalized_query,
|
|
1166
|
+
"queries": [query] if query else [],
|
|
1167
|
+
"normalized_queries": [normalized_query] if normalized_query else [],
|
|
1168
|
+
"backend": "",
|
|
1169
|
+
"backend_adapter": "",
|
|
1170
|
+
"protocol": "",
|
|
1171
|
+
"chat_protocol": "",
|
|
1172
|
+
"search_protocol": "",
|
|
1173
|
+
"provider": "",
|
|
1174
|
+
"provider_hosted_search": False,
|
|
1175
|
+
"external_search_provider": "",
|
|
1176
|
+
"citation_count": 0,
|
|
1177
|
+
"source_count": 0,
|
|
1178
|
+
"allowed_domains": allowed_domains,
|
|
1179
|
+
"external_web_access": external_web_access,
|
|
1180
|
+
"source_event_id": event_id,
|
|
1181
|
+
"response_id": "",
|
|
1182
|
+
"sources_truncated": False,
|
|
1183
|
+
"returned_sources": [],
|
|
1184
|
+
"error": "",
|
|
1185
|
+
}
|
|
1186
|
+
self._searches.append(entry)
|
|
1187
|
+
self._pending_search_indices.append(len(self._searches) - 1)
|
|
1188
|
+
return True
|
|
1189
|
+
|
|
1190
|
+
def _record_web_search_result(
|
|
1191
|
+
self,
|
|
1192
|
+
*,
|
|
1193
|
+
payload: dict[str, Any],
|
|
1194
|
+
ts: str | None,
|
|
1195
|
+
event_id: str | None,
|
|
1196
|
+
) -> bool:
|
|
1197
|
+
result = payload.get("result") if isinstance(payload.get("result"), dict) else {}
|
|
1198
|
+
step = payload.get("step") if isinstance(payload.get("step"), int) else None
|
|
1199
|
+
if self._pending_search_indices:
|
|
1200
|
+
entry = self._searches[self._pending_search_indices.pop(0)]
|
|
1201
|
+
else:
|
|
1202
|
+
entry = {
|
|
1203
|
+
"step": step,
|
|
1204
|
+
"call_ts": ts,
|
|
1205
|
+
"result_ts": None,
|
|
1206
|
+
"query": "",
|
|
1207
|
+
"normalized_query": "",
|
|
1208
|
+
"queries": [],
|
|
1209
|
+
"normalized_queries": [],
|
|
1210
|
+
"backend": "",
|
|
1211
|
+
"backend_adapter": "",
|
|
1212
|
+
"protocol": "",
|
|
1213
|
+
"chat_protocol": "",
|
|
1214
|
+
"search_protocol": "",
|
|
1215
|
+
"provider": "",
|
|
1216
|
+
"provider_hosted_search": False,
|
|
1217
|
+
"external_search_provider": "",
|
|
1218
|
+
"citation_count": 0,
|
|
1219
|
+
"source_count": 0,
|
|
1220
|
+
"allowed_domains": [],
|
|
1221
|
+
"external_web_access": None,
|
|
1222
|
+
"source_event_id": event_id,
|
|
1223
|
+
"response_id": "",
|
|
1224
|
+
"sources_truncated": False,
|
|
1225
|
+
"returned_sources": [],
|
|
1226
|
+
"error": "",
|
|
1227
|
+
}
|
|
1228
|
+
self._searches.append(entry)
|
|
1229
|
+
entry["step"] = entry.get("step") if entry.get("step") is not None else step
|
|
1230
|
+
entry["result_ts"] = ts
|
|
1231
|
+
if "error" in result:
|
|
1232
|
+
entry["error"] = str(result.get("error") or "").strip()
|
|
1233
|
+
return True
|
|
1234
|
+
|
|
1235
|
+
query = str(result.get("query") or entry.get("query") or "").strip()
|
|
1236
|
+
normalized_query = normalize_web_query(query)
|
|
1237
|
+
raw_queries = result.get("queries")
|
|
1238
|
+
query_list = (
|
|
1239
|
+
[str(item or "").strip() for item in raw_queries]
|
|
1240
|
+
if isinstance(raw_queries, list)
|
|
1241
|
+
else []
|
|
1242
|
+
)
|
|
1243
|
+
query_list = [item for item in query_list if item]
|
|
1244
|
+
if query and query not in query_list:
|
|
1245
|
+
query_list.insert(0, query)
|
|
1246
|
+
normalized_queries = _dedupe_ordered([normalize_web_query(item) for item in query_list])
|
|
1247
|
+
returned_sources: list[dict[str, Any]] = []
|
|
1248
|
+
for raw_source in list(result.get("sources") or []):
|
|
1249
|
+
if not isinstance(raw_source, dict):
|
|
1250
|
+
continue
|
|
1251
|
+
raw_url = str(raw_source.get("url") or "").strip()
|
|
1252
|
+
normalized_url = normalize_web_url(raw_url)
|
|
1253
|
+
if normalized_url is None:
|
|
1254
|
+
continue
|
|
1255
|
+
source_entry = {
|
|
1256
|
+
"title": str(raw_source.get("title") or "").strip(),
|
|
1257
|
+
"url": raw_url,
|
|
1258
|
+
"normalized_url": normalized_url,
|
|
1259
|
+
"domain": _domain_for_url(normalized_url),
|
|
1260
|
+
"snippet": str(raw_source.get("snippet") or "").strip(),
|
|
1261
|
+
"provenance_classification": _RETURNED_BY_WEB_SEARCH,
|
|
1262
|
+
}
|
|
1263
|
+
returned_sources.append(source_entry)
|
|
1264
|
+
if normalized_url not in self._returned_source_urls:
|
|
1265
|
+
self._returned_source_urls[normalized_url] = {
|
|
1266
|
+
"title": source_entry["title"],
|
|
1267
|
+
"url": raw_url,
|
|
1268
|
+
"normalized_url": normalized_url,
|
|
1269
|
+
"domain": source_entry["domain"],
|
|
1270
|
+
"snippet": source_entry["snippet"],
|
|
1271
|
+
"ts": ts,
|
|
1272
|
+
"step": entry.get("step"),
|
|
1273
|
+
"backend": str(result.get("backend") or "").strip(),
|
|
1274
|
+
"source_event_id": event_id,
|
|
1275
|
+
"provenance_classification": _RETURNED_BY_WEB_SEARCH,
|
|
1276
|
+
}
|
|
1277
|
+
self._add_provenance_node(
|
|
1278
|
+
raw_url=raw_url,
|
|
1279
|
+
provenance_classification=_RETURNED_BY_WEB_SEARCH,
|
|
1280
|
+
source_event_id=event_id,
|
|
1281
|
+
parent_url="",
|
|
1282
|
+
parent_source_event_id=None,
|
|
1283
|
+
discovery_mechanism="web_search_source",
|
|
1284
|
+
ts=ts,
|
|
1285
|
+
redirect_chain=(),
|
|
1286
|
+
security_validation_status="syntax_validated_fetch_revalidates_target",
|
|
1287
|
+
)
|
|
1288
|
+
|
|
1289
|
+
entry["query"] = query
|
|
1290
|
+
entry["normalized_query"] = normalized_query
|
|
1291
|
+
entry["queries"] = query_list
|
|
1292
|
+
entry["normalized_queries"] = normalized_queries
|
|
1293
|
+
entry["backend"] = str(result.get("backend") or "").strip()
|
|
1294
|
+
entry["backend_adapter"] = str(
|
|
1295
|
+
result.get("backend_adapter") or result.get("backend") or ""
|
|
1296
|
+
).strip()
|
|
1297
|
+
entry["protocol"] = str(result.get("protocol") or entry["backend_adapter"]).strip()
|
|
1298
|
+
entry["chat_protocol"] = str(result.get("chat_protocol") or entry["protocol"]).strip()
|
|
1299
|
+
entry["search_protocol"] = str(
|
|
1300
|
+
result.get("search_protocol") or entry["backend_adapter"]
|
|
1301
|
+
).strip()
|
|
1302
|
+
entry["provider"] = str(result.get("backend") or "").strip()
|
|
1303
|
+
entry["provider_hosted_search"] = bool(result.get("provider_hosted_search"))
|
|
1304
|
+
entry["external_search_provider"] = str(
|
|
1305
|
+
result.get("external_search_provider") or ""
|
|
1306
|
+
).strip()
|
|
1307
|
+
raw_citation_count = result.get("citation_count")
|
|
1308
|
+
if isinstance(raw_citation_count, int):
|
|
1309
|
+
entry["citation_count"] = raw_citation_count
|
|
1310
|
+
else:
|
|
1311
|
+
citations = result.get("citations")
|
|
1312
|
+
entry["citation_count"] = len(citations) if isinstance(citations, list) else 0
|
|
1313
|
+
raw_source_count = result.get("source_count")
|
|
1314
|
+
if isinstance(raw_source_count, int):
|
|
1315
|
+
entry["source_count"] = raw_source_count
|
|
1316
|
+
else:
|
|
1317
|
+
entry["source_count"] = len(returned_sources)
|
|
1318
|
+
entry["allowed_domains"] = [
|
|
1319
|
+
str(item or "").strip().lower()
|
|
1320
|
+
for item in list(result.get("allowed_domains") or entry.get("allowed_domains") or [])
|
|
1321
|
+
if str(item or "").strip()
|
|
1322
|
+
]
|
|
1323
|
+
raw_external = result.get("external_web_access")
|
|
1324
|
+
if isinstance(raw_external, bool):
|
|
1325
|
+
entry["external_web_access"] = raw_external
|
|
1326
|
+
entry["response_id"] = str(result.get("response_id") or "").strip()
|
|
1327
|
+
entry["sources_truncated"] = bool(result.get("sources_truncated"))
|
|
1328
|
+
entry["returned_sources"] = returned_sources
|
|
1329
|
+
entry["error"] = ""
|
|
1330
|
+
return True
|
|
1331
|
+
|
|
1332
|
+
def _record_web_fetch_call(
|
|
1333
|
+
self,
|
|
1334
|
+
*,
|
|
1335
|
+
payload: dict[str, Any],
|
|
1336
|
+
ts: str | None,
|
|
1337
|
+
event_id: str | None,
|
|
1338
|
+
) -> bool:
|
|
1339
|
+
arguments = payload.get("arguments") if isinstance(payload.get("arguments"), dict) else {}
|
|
1340
|
+
raw_requested_url = str(arguments.get("url") or "").strip()
|
|
1341
|
+
provenance_classification, effective_requested_url = self.resolve_fetch_url(
|
|
1342
|
+
raw_requested_url
|
|
1343
|
+
)
|
|
1344
|
+
requested_url = effective_requested_url or raw_requested_url
|
|
1345
|
+
normalized_requested_url = normalize_web_url(requested_url)
|
|
1346
|
+
step = payload.get("step") if isinstance(payload.get("step"), int) else None
|
|
1347
|
+
entry = {
|
|
1348
|
+
"step": step,
|
|
1349
|
+
"call_ts": ts,
|
|
1350
|
+
"result_ts": None,
|
|
1351
|
+
"requested_url": requested_url,
|
|
1352
|
+
"normalized_requested_url": normalized_requested_url,
|
|
1353
|
+
"raw_input_url": (
|
|
1354
|
+
raw_requested_url
|
|
1355
|
+
if raw_requested_url and raw_requested_url != requested_url
|
|
1356
|
+
else ""
|
|
1357
|
+
),
|
|
1358
|
+
"final_url": "",
|
|
1359
|
+
"normalized_final_url": "",
|
|
1360
|
+
"status_code": None,
|
|
1361
|
+
"content_type": "",
|
|
1362
|
+
"title": "",
|
|
1363
|
+
"backend": "",
|
|
1364
|
+
"provenance_classification": provenance_classification or "",
|
|
1365
|
+
"source_event_id": event_id,
|
|
1366
|
+
"error": "",
|
|
1367
|
+
"error_code": "",
|
|
1368
|
+
}
|
|
1369
|
+
self._fetches.append(entry)
|
|
1370
|
+
self._pending_fetch_indices.append(len(self._fetches) - 1)
|
|
1371
|
+
return True
|
|
1372
|
+
|
|
1373
|
+
def _record_web_fetch_result(
|
|
1374
|
+
self,
|
|
1375
|
+
*,
|
|
1376
|
+
payload: dict[str, Any],
|
|
1377
|
+
ts: str | None,
|
|
1378
|
+
event_id: str | None,
|
|
1379
|
+
) -> bool:
|
|
1380
|
+
result = payload.get("result") if isinstance(payload.get("result"), dict) else {}
|
|
1381
|
+
step = payload.get("step") if isinstance(payload.get("step"), int) else None
|
|
1382
|
+
if self._pending_fetch_indices:
|
|
1383
|
+
entry = self._fetches[self._pending_fetch_indices.pop(0)]
|
|
1384
|
+
else:
|
|
1385
|
+
entry = {
|
|
1386
|
+
"step": step,
|
|
1387
|
+
"call_ts": ts,
|
|
1388
|
+
"result_ts": None,
|
|
1389
|
+
"requested_url": "",
|
|
1390
|
+
"normalized_requested_url": None,
|
|
1391
|
+
"raw_input_url": "",
|
|
1392
|
+
"final_url": "",
|
|
1393
|
+
"normalized_final_url": "",
|
|
1394
|
+
"status_code": None,
|
|
1395
|
+
"content_type": "",
|
|
1396
|
+
"title": "",
|
|
1397
|
+
"backend": "",
|
|
1398
|
+
"provenance_classification": "",
|
|
1399
|
+
"source_event_id": event_id,
|
|
1400
|
+
"error": "",
|
|
1401
|
+
"error_code": "",
|
|
1402
|
+
}
|
|
1403
|
+
self._fetches.append(entry)
|
|
1404
|
+
entry["step"] = entry.get("step") if entry.get("step") is not None else step
|
|
1405
|
+
entry["result_ts"] = ts
|
|
1406
|
+
|
|
1407
|
+
requested_url = str(result.get("url") or entry.get("requested_url") or "").strip()
|
|
1408
|
+
raw_input_url = str(result.get("raw_input_url") or entry.get("raw_input_url") or "").strip()
|
|
1409
|
+
normalized_requested_url = normalize_web_url(requested_url)
|
|
1410
|
+
if requested_url:
|
|
1411
|
+
entry["requested_url"] = requested_url
|
|
1412
|
+
if normalized_requested_url:
|
|
1413
|
+
entry["normalized_requested_url"] = normalized_requested_url
|
|
1414
|
+
if raw_input_url and raw_input_url != requested_url:
|
|
1415
|
+
entry["raw_input_url"] = raw_input_url
|
|
1416
|
+
if not entry.get("provenance_classification"):
|
|
1417
|
+
entry["provenance_classification"] = (
|
|
1418
|
+
self.classify_fetch_url(raw_input_url or requested_url) or ""
|
|
1419
|
+
)
|
|
1420
|
+
|
|
1421
|
+
if "error" in result:
|
|
1422
|
+
entry["error"] = str(result.get("error") or "").strip()
|
|
1423
|
+
entry["error_code"] = str(result.get("error_code") or "").strip()
|
|
1424
|
+
return True
|
|
1425
|
+
|
|
1426
|
+
final_url = str(result.get("final_url") or "").strip()
|
|
1427
|
+
normalized_final_url = normalize_web_url(final_url)
|
|
1428
|
+
entry["final_url"] = final_url
|
|
1429
|
+
entry["normalized_final_url"] = normalized_final_url or ""
|
|
1430
|
+
entry["status_code"] = (
|
|
1431
|
+
result.get("status_code") if result.get("status_code") is not None else None
|
|
1432
|
+
)
|
|
1433
|
+
entry["content_type"] = str(result.get("content_type") or "").strip()
|
|
1434
|
+
entry["title"] = str(result.get("title") or "").strip()
|
|
1435
|
+
entry["backend"] = str(result.get("backend") or "").strip()
|
|
1436
|
+
entry["error"] = ""
|
|
1437
|
+
entry["error_code"] = ""
|
|
1438
|
+
provenance = str(entry.get("provenance_classification") or "").strip()
|
|
1439
|
+
if (
|
|
1440
|
+
normalized_final_url
|
|
1441
|
+
and normalized_requested_url
|
|
1442
|
+
and normalized_final_url != normalized_requested_url
|
|
1443
|
+
and provenance
|
|
1444
|
+
in {
|
|
1445
|
+
_USER_PROVIDED,
|
|
1446
|
+
_RETURNED_BY_WEB_SEARCH,
|
|
1447
|
+
_SEARCH_MEDIATED_RECOVERY,
|
|
1448
|
+
_SAME_ORIGIN_DERIVED,
|
|
1449
|
+
}
|
|
1450
|
+
and normalized_final_url not in self._canonical_redirect_urls
|
|
1451
|
+
):
|
|
1452
|
+
self._canonical_redirect_urls[normalized_final_url] = {
|
|
1453
|
+
"url": final_url,
|
|
1454
|
+
"normalized_url": normalized_final_url,
|
|
1455
|
+
"domain": _domain_for_url(normalized_final_url),
|
|
1456
|
+
"source_url": requested_url,
|
|
1457
|
+
"source_provenance_classification": provenance,
|
|
1458
|
+
"source_event_id": event_id,
|
|
1459
|
+
"provenance_classification": _CANONICAL_REDIRECT,
|
|
1460
|
+
}
|
|
1461
|
+
self._add_provenance_node(
|
|
1462
|
+
raw_url=final_url,
|
|
1463
|
+
provenance_classification=_CANONICAL_REDIRECT,
|
|
1464
|
+
source_event_id=event_id,
|
|
1465
|
+
parent_url=requested_url,
|
|
1466
|
+
parent_source_event_id=str(entry.get("source_event_id") or "").strip() or None,
|
|
1467
|
+
discovery_mechanism="validated_redirect",
|
|
1468
|
+
ts=ts,
|
|
1469
|
+
redirect_chain=(requested_url, final_url),
|
|
1470
|
+
security_validation_status="validated_by_web_fetch",
|
|
1471
|
+
)
|
|
1472
|
+
self._record_urls_from_text(
|
|
1473
|
+
text=result.get("content"),
|
|
1474
|
+
provenance_classification=_FETCHED_PAGE_LINK,
|
|
1475
|
+
source_event_id=event_id,
|
|
1476
|
+
parent_url=final_url or requested_url,
|
|
1477
|
+
parent_source_event_id=event_id,
|
|
1478
|
+
discovery_mechanism="fetched_page_content",
|
|
1479
|
+
source_type="web_fetch_result",
|
|
1480
|
+
ts=ts,
|
|
1481
|
+
)
|
|
1482
|
+
return True
|
|
1483
|
+
|
|
1484
|
+
def _hydrate_url_index(
|
|
1485
|
+
self,
|
|
1486
|
+
*,
|
|
1487
|
+
raw_entries: Any,
|
|
1488
|
+
fallback_classification: str,
|
|
1489
|
+
) -> dict[str, dict[str, Any]]:
|
|
1490
|
+
hydrated: dict[str, dict[str, Any]] = {}
|
|
1491
|
+
if not isinstance(raw_entries, list):
|
|
1492
|
+
return hydrated
|
|
1493
|
+
for raw_entry in raw_entries:
|
|
1494
|
+
if not isinstance(raw_entry, dict):
|
|
1495
|
+
continue
|
|
1496
|
+
normalized_url = normalize_web_url(
|
|
1497
|
+
raw_entry.get("normalized_url") or raw_entry.get("url")
|
|
1498
|
+
)
|
|
1499
|
+
if not normalized_url or normalized_url in hydrated:
|
|
1500
|
+
continue
|
|
1501
|
+
hydrated[normalized_url] = {
|
|
1502
|
+
"url": str(raw_entry.get("url") or "").strip() or normalized_url,
|
|
1503
|
+
"normalized_url": normalized_url,
|
|
1504
|
+
"domain": str(raw_entry.get("domain") or _domain_for_url(normalized_url)).strip(),
|
|
1505
|
+
"ts": str(raw_entry.get("ts") or "").strip() or None,
|
|
1506
|
+
"step": raw_entry.get("step") if isinstance(raw_entry.get("step"), int) else None,
|
|
1507
|
+
"source_event_id": str(raw_entry.get("source_event_id") or "").strip() or None,
|
|
1508
|
+
"parent_url": str(raw_entry.get("parent_url") or "").strip(),
|
|
1509
|
+
"parent_source_event_id": (
|
|
1510
|
+
str(raw_entry.get("parent_source_event_id") or "").strip() or None
|
|
1511
|
+
),
|
|
1512
|
+
"discovery_mechanism": str(raw_entry.get("discovery_mechanism") or "").strip(),
|
|
1513
|
+
"source_type": str(raw_entry.get("source_type") or "").strip(),
|
|
1514
|
+
"redirect_chain": [
|
|
1515
|
+
str(item or "").strip()
|
|
1516
|
+
for item in list(raw_entry.get("redirect_chain") or [])
|
|
1517
|
+
if str(item or "").strip()
|
|
1518
|
+
][:_MAX_URLS_PER_EVENT],
|
|
1519
|
+
"security_validation_status": str(
|
|
1520
|
+
raw_entry.get("security_validation_status") or ""
|
|
1521
|
+
).strip(),
|
|
1522
|
+
"provenance_classification": str(
|
|
1523
|
+
raw_entry.get("provenance_classification") or fallback_classification
|
|
1524
|
+
).strip()
|
|
1525
|
+
or fallback_classification,
|
|
1526
|
+
"title": str(raw_entry.get("title") or "").strip(),
|
|
1527
|
+
"snippet": str(raw_entry.get("snippet") or "").strip(),
|
|
1528
|
+
"backend": str(raw_entry.get("backend") or "").strip(),
|
|
1529
|
+
}
|
|
1530
|
+
return hydrated
|
|
1531
|
+
|
|
1532
|
+
def _hydrate_event_entries(self, raw_entries: Any) -> list[dict[str, Any]]:
|
|
1533
|
+
if not isinstance(raw_entries, list):
|
|
1534
|
+
return []
|
|
1535
|
+
hydrated: list[dict[str, Any]] = []
|
|
1536
|
+
for raw_entry in raw_entries:
|
|
1537
|
+
if not isinstance(raw_entry, dict):
|
|
1538
|
+
continue
|
|
1539
|
+
hydrated.append(copy.deepcopy(raw_entry))
|
|
1540
|
+
return hydrated
|
|
1541
|
+
|
|
1542
|
+
def _merge_url_index(
|
|
1543
|
+
self,
|
|
1544
|
+
existing: dict[str, dict[str, Any]],
|
|
1545
|
+
incoming: dict[str, dict[str, Any]],
|
|
1546
|
+
) -> bool:
|
|
1547
|
+
changed = False
|
|
1548
|
+
for normalized_url, incoming_entry in incoming.items():
|
|
1549
|
+
current = existing.get(normalized_url)
|
|
1550
|
+
if current is None:
|
|
1551
|
+
existing[normalized_url] = copy.deepcopy(incoming_entry)
|
|
1552
|
+
changed = True
|
|
1553
|
+
continue
|
|
1554
|
+
merged = self._merge_url_index_entry(current, incoming_entry)
|
|
1555
|
+
if merged != current:
|
|
1556
|
+
existing[normalized_url] = merged
|
|
1557
|
+
changed = True
|
|
1558
|
+
return changed
|
|
1559
|
+
|
|
1560
|
+
def _merge_url_index_entry(
|
|
1561
|
+
self,
|
|
1562
|
+
existing: dict[str, Any],
|
|
1563
|
+
incoming: dict[str, Any],
|
|
1564
|
+
) -> dict[str, Any]:
|
|
1565
|
+
merged = copy.deepcopy(existing)
|
|
1566
|
+
for key in (
|
|
1567
|
+
"url",
|
|
1568
|
+
"domain",
|
|
1569
|
+
"ts",
|
|
1570
|
+
"title",
|
|
1571
|
+
"snippet",
|
|
1572
|
+
"backend",
|
|
1573
|
+
"provenance_classification",
|
|
1574
|
+
"source_event_id",
|
|
1575
|
+
"parent_url",
|
|
1576
|
+
"parent_source_event_id",
|
|
1577
|
+
"discovery_mechanism",
|
|
1578
|
+
"source_type",
|
|
1579
|
+
"security_validation_status",
|
|
1580
|
+
):
|
|
1581
|
+
current_value = str(merged.get(key) or "").strip()
|
|
1582
|
+
incoming_value = str(incoming.get(key) or "").strip()
|
|
1583
|
+
if not current_value and incoming_value:
|
|
1584
|
+
merged[key] = incoming_value
|
|
1585
|
+
if not list(merged.get("redirect_chain") or []) and list(
|
|
1586
|
+
incoming.get("redirect_chain") or []
|
|
1587
|
+
):
|
|
1588
|
+
merged["redirect_chain"] = list(incoming.get("redirect_chain") or [])[
|
|
1589
|
+
:_MAX_URLS_PER_EVENT
|
|
1590
|
+
]
|
|
1591
|
+
if not str(merged.get("normalized_url") or "").strip():
|
|
1592
|
+
merged["normalized_url"] = str(incoming.get("normalized_url") or "").strip()
|
|
1593
|
+
if not isinstance(merged.get("step"), int) and isinstance(incoming.get("step"), int):
|
|
1594
|
+
merged["step"] = incoming["step"]
|
|
1595
|
+
return merged
|
|
1596
|
+
|
|
1597
|
+
def _merge_search_entries(self, incoming_entries: list[dict[str, Any]]) -> bool:
|
|
1598
|
+
changed = False
|
|
1599
|
+
for incoming_entry in incoming_entries:
|
|
1600
|
+
incoming = copy.deepcopy(incoming_entry)
|
|
1601
|
+
match_index = self._find_matching_search_index(incoming)
|
|
1602
|
+
if match_index is None:
|
|
1603
|
+
self._searches.append(incoming)
|
|
1604
|
+
changed = True
|
|
1605
|
+
continue
|
|
1606
|
+
merged = self._merge_search_entry(self._searches[match_index], incoming)
|
|
1607
|
+
if merged != self._searches[match_index]:
|
|
1608
|
+
self._searches[match_index] = merged
|
|
1609
|
+
changed = True
|
|
1610
|
+
return changed
|
|
1611
|
+
|
|
1612
|
+
def _merge_fetch_entries(self, incoming_entries: list[dict[str, Any]]) -> bool:
|
|
1613
|
+
changed = False
|
|
1614
|
+
for incoming_entry in incoming_entries:
|
|
1615
|
+
incoming = copy.deepcopy(incoming_entry)
|
|
1616
|
+
match_index = self._find_matching_fetch_index(incoming)
|
|
1617
|
+
if match_index is None:
|
|
1618
|
+
self._fetches.append(incoming)
|
|
1619
|
+
changed = True
|
|
1620
|
+
continue
|
|
1621
|
+
merged = self._merge_fetch_entry(self._fetches[match_index], incoming)
|
|
1622
|
+
if merged != self._fetches[match_index]:
|
|
1623
|
+
self._fetches[match_index] = merged
|
|
1624
|
+
changed = True
|
|
1625
|
+
return changed
|
|
1626
|
+
|
|
1627
|
+
def _find_matching_search_index(self, incoming: dict[str, Any]) -> int | None:
|
|
1628
|
+
incoming_fingerprint = _canonical_json(incoming)
|
|
1629
|
+
incoming_keys = self._search_merge_keys(incoming)
|
|
1630
|
+
for index, existing in enumerate(self._searches):
|
|
1631
|
+
if _canonical_json(existing) == incoming_fingerprint:
|
|
1632
|
+
return index
|
|
1633
|
+
if not incoming_keys:
|
|
1634
|
+
return None
|
|
1635
|
+
for index, existing in enumerate(self._searches):
|
|
1636
|
+
if incoming_keys & self._search_merge_keys(existing):
|
|
1637
|
+
return index
|
|
1638
|
+
return None
|
|
1639
|
+
|
|
1640
|
+
def _find_matching_fetch_index(self, incoming: dict[str, Any]) -> int | None:
|
|
1641
|
+
incoming_fingerprint = _canonical_json(incoming)
|
|
1642
|
+
incoming_keys = self._fetch_merge_keys(incoming)
|
|
1643
|
+
for index, existing in enumerate(self._fetches):
|
|
1644
|
+
if _canonical_json(existing) == incoming_fingerprint:
|
|
1645
|
+
return index
|
|
1646
|
+
if not incoming_keys:
|
|
1647
|
+
return None
|
|
1648
|
+
for index, existing in enumerate(self._fetches):
|
|
1649
|
+
if incoming_keys & self._fetch_merge_keys(existing):
|
|
1650
|
+
return index
|
|
1651
|
+
return None
|
|
1652
|
+
|
|
1653
|
+
def _search_merge_keys(self, entry: dict[str, Any]) -> set[tuple[Any, ...]]:
|
|
1654
|
+
keys: set[tuple[Any, ...]] = set()
|
|
1655
|
+
step = entry.get("step") if isinstance(entry.get("step"), int) else None
|
|
1656
|
+
call_ts = str(entry.get("call_ts") or "").strip()
|
|
1657
|
+
result_ts = str(entry.get("result_ts") or "").strip()
|
|
1658
|
+
normalized_query = str(entry.get("normalized_query") or "").strip()
|
|
1659
|
+
normalized_queries = tuple(
|
|
1660
|
+
_dedupe_ordered(
|
|
1661
|
+
[str(item or "").strip() for item in list(entry.get("normalized_queries") or [])]
|
|
1662
|
+
)
|
|
1663
|
+
)
|
|
1664
|
+
allowed_domains = tuple(
|
|
1665
|
+
_dedupe_ordered(
|
|
1666
|
+
[
|
|
1667
|
+
str(item or "").strip().lower()
|
|
1668
|
+
for item in list(entry.get("allowed_domains") or [])
|
|
1669
|
+
]
|
|
1670
|
+
)
|
|
1671
|
+
)
|
|
1672
|
+
if step is not None and call_ts and normalized_query:
|
|
1673
|
+
keys.add(("search", "step_call_query", step, call_ts, normalized_query))
|
|
1674
|
+
if step is not None and result_ts and normalized_query:
|
|
1675
|
+
keys.add(("search", "step_result_query", step, result_ts, normalized_query))
|
|
1676
|
+
if call_ts and normalized_query:
|
|
1677
|
+
keys.add(("search", "call_query", call_ts, normalized_query))
|
|
1678
|
+
if result_ts and normalized_query:
|
|
1679
|
+
keys.add(("search", "result_query", result_ts, normalized_query))
|
|
1680
|
+
if step is not None and normalized_query and allowed_domains:
|
|
1681
|
+
keys.add(("search", "step_query_domains", step, normalized_query, allowed_domains))
|
|
1682
|
+
if step is not None and normalized_query:
|
|
1683
|
+
keys.add(("search", "step_query", step, normalized_query))
|
|
1684
|
+
if step is not None and normalized_queries:
|
|
1685
|
+
keys.add(("search", "step_queries", step, normalized_queries))
|
|
1686
|
+
return keys
|
|
1687
|
+
|
|
1688
|
+
def _fetch_merge_keys(self, entry: dict[str, Any]) -> set[tuple[Any, ...]]:
|
|
1689
|
+
keys: set[tuple[Any, ...]] = set()
|
|
1690
|
+
step = entry.get("step") if isinstance(entry.get("step"), int) else None
|
|
1691
|
+
call_ts = str(entry.get("call_ts") or "").strip()
|
|
1692
|
+
result_ts = str(entry.get("result_ts") or "").strip()
|
|
1693
|
+
normalized_requested_url = str(entry.get("normalized_requested_url") or "").strip()
|
|
1694
|
+
normalized_final_url = str(entry.get("normalized_final_url") or "").strip()
|
|
1695
|
+
if step is not None and call_ts and normalized_requested_url:
|
|
1696
|
+
keys.add(("fetch", "step_call_requested", step, call_ts, normalized_requested_url))
|
|
1697
|
+
if step is not None and result_ts and normalized_requested_url:
|
|
1698
|
+
keys.add(("fetch", "step_result_requested", step, result_ts, normalized_requested_url))
|
|
1699
|
+
if call_ts and normalized_requested_url:
|
|
1700
|
+
keys.add(("fetch", "call_requested", call_ts, normalized_requested_url))
|
|
1701
|
+
if result_ts and normalized_requested_url:
|
|
1702
|
+
keys.add(("fetch", "result_requested", result_ts, normalized_requested_url))
|
|
1703
|
+
if step is not None and normalized_requested_url:
|
|
1704
|
+
keys.add(("fetch", "step_requested", step, normalized_requested_url))
|
|
1705
|
+
if step is not None and normalized_final_url:
|
|
1706
|
+
keys.add(("fetch", "step_final", step, normalized_final_url))
|
|
1707
|
+
return keys
|
|
1708
|
+
|
|
1709
|
+
def _merge_search_entry(
|
|
1710
|
+
self, existing: dict[str, Any], incoming: dict[str, Any]
|
|
1711
|
+
) -> dict[str, Any]:
|
|
1712
|
+
merged = copy.deepcopy(existing)
|
|
1713
|
+
if not isinstance(merged.get("step"), int) and isinstance(incoming.get("step"), int):
|
|
1714
|
+
merged["step"] = incoming["step"]
|
|
1715
|
+
for key in (
|
|
1716
|
+
"call_ts",
|
|
1717
|
+
"result_ts",
|
|
1718
|
+
"query",
|
|
1719
|
+
"normalized_query",
|
|
1720
|
+
"backend",
|
|
1721
|
+
"provider",
|
|
1722
|
+
"response_id",
|
|
1723
|
+
"error",
|
|
1724
|
+
):
|
|
1725
|
+
current_value = str(merged.get(key) or "").strip()
|
|
1726
|
+
incoming_value = str(incoming.get(key) or "").strip()
|
|
1727
|
+
if not current_value and incoming_value:
|
|
1728
|
+
merged[key] = incoming_value
|
|
1729
|
+
if merged.get("external_web_access") is None and isinstance(
|
|
1730
|
+
incoming.get("external_web_access"), bool
|
|
1731
|
+
):
|
|
1732
|
+
merged["external_web_access"] = incoming["external_web_access"]
|
|
1733
|
+
merged["sources_truncated"] = bool(
|
|
1734
|
+
merged.get("sources_truncated") or incoming.get("sources_truncated")
|
|
1735
|
+
)
|
|
1736
|
+
merged["queries"] = _dedupe_ordered(
|
|
1737
|
+
[str(item or "").strip() for item in list(merged.get("queries") or [])]
|
|
1738
|
+
+ [str(item or "").strip() for item in list(incoming.get("queries") or [])]
|
|
1739
|
+
)
|
|
1740
|
+
merged["normalized_queries"] = _dedupe_ordered(
|
|
1741
|
+
[str(item or "").strip() for item in list(merged.get("normalized_queries") or [])]
|
|
1742
|
+
+ [str(item or "").strip() for item in list(incoming.get("normalized_queries") or [])]
|
|
1743
|
+
)
|
|
1744
|
+
merged["allowed_domains"] = _dedupe_ordered(
|
|
1745
|
+
[str(item or "").strip().lower() for item in list(merged.get("allowed_domains") or [])]
|
|
1746
|
+
+ [
|
|
1747
|
+
str(item or "").strip().lower()
|
|
1748
|
+
for item in list(incoming.get("allowed_domains") or [])
|
|
1749
|
+
]
|
|
1750
|
+
)
|
|
1751
|
+
merged["returned_sources"] = self._merge_source_entries(
|
|
1752
|
+
list(merged.get("returned_sources") or []),
|
|
1753
|
+
list(incoming.get("returned_sources") or []),
|
|
1754
|
+
)
|
|
1755
|
+
return merged
|
|
1756
|
+
|
|
1757
|
+
def _merge_fetch_entry(
|
|
1758
|
+
self, existing: dict[str, Any], incoming: dict[str, Any]
|
|
1759
|
+
) -> dict[str, Any]:
|
|
1760
|
+
merged = copy.deepcopy(existing)
|
|
1761
|
+
if not isinstance(merged.get("step"), int) and isinstance(incoming.get("step"), int):
|
|
1762
|
+
merged["step"] = incoming["step"]
|
|
1763
|
+
for key in (
|
|
1764
|
+
"call_ts",
|
|
1765
|
+
"result_ts",
|
|
1766
|
+
"requested_url",
|
|
1767
|
+
"normalized_requested_url",
|
|
1768
|
+
"raw_input_url",
|
|
1769
|
+
"final_url",
|
|
1770
|
+
"normalized_final_url",
|
|
1771
|
+
"content_type",
|
|
1772
|
+
"title",
|
|
1773
|
+
"backend",
|
|
1774
|
+
"provenance_classification",
|
|
1775
|
+
"error",
|
|
1776
|
+
"error_code",
|
|
1777
|
+
):
|
|
1778
|
+
current_value = str(merged.get(key) or "").strip()
|
|
1779
|
+
incoming_value = str(incoming.get(key) or "").strip()
|
|
1780
|
+
if not current_value and incoming_value:
|
|
1781
|
+
merged[key] = incoming_value
|
|
1782
|
+
if merged.get("status_code") is None and incoming.get("status_code") is not None:
|
|
1783
|
+
merged["status_code"] = incoming.get("status_code")
|
|
1784
|
+
return merged
|
|
1785
|
+
|
|
1786
|
+
def _merge_source_entries(
|
|
1787
|
+
self,
|
|
1788
|
+
existing_entries: list[dict[str, Any]],
|
|
1789
|
+
incoming_entries: list[dict[str, Any]],
|
|
1790
|
+
) -> list[dict[str, Any]]:
|
|
1791
|
+
merged_entries = [
|
|
1792
|
+
copy.deepcopy(entry) for entry in existing_entries if isinstance(entry, dict)
|
|
1793
|
+
]
|
|
1794
|
+
index_by_url: dict[str, int] = {}
|
|
1795
|
+
for index, entry in enumerate(merged_entries):
|
|
1796
|
+
normalized_url = normalize_web_url(entry.get("normalized_url") or entry.get("url"))
|
|
1797
|
+
if normalized_url:
|
|
1798
|
+
entry["normalized_url"] = normalized_url
|
|
1799
|
+
index_by_url[normalized_url] = index
|
|
1800
|
+
for incoming_entry in incoming_entries:
|
|
1801
|
+
if not isinstance(incoming_entry, dict):
|
|
1802
|
+
continue
|
|
1803
|
+
normalized_url = normalize_web_url(
|
|
1804
|
+
incoming_entry.get("normalized_url") or incoming_entry.get("url")
|
|
1805
|
+
)
|
|
1806
|
+
if not normalized_url:
|
|
1807
|
+
continue
|
|
1808
|
+
incoming_copy = copy.deepcopy(incoming_entry)
|
|
1809
|
+
incoming_copy["normalized_url"] = normalized_url
|
|
1810
|
+
existing_index = index_by_url.get(normalized_url)
|
|
1811
|
+
if existing_index is None:
|
|
1812
|
+
merged_entries.append(incoming_copy)
|
|
1813
|
+
index_by_url[normalized_url] = len(merged_entries) - 1
|
|
1814
|
+
continue
|
|
1815
|
+
current = merged_entries[existing_index]
|
|
1816
|
+
for key in ("title", "url", "domain", "snippet", "provenance_classification"):
|
|
1817
|
+
current_value = str(current.get(key) or "").strip()
|
|
1818
|
+
incoming_value = str(incoming_copy.get(key) or "").strip()
|
|
1819
|
+
if not current_value and incoming_value:
|
|
1820
|
+
current[key] = incoming_value
|
|
1821
|
+
return merged_entries
|
|
1822
|
+
|
|
1823
|
+
|
|
1824
|
+
def build_web_research_artifact_from_events(events: list[dict[str, Any]]) -> dict[str, Any]:
|
|
1825
|
+
tracker = SessionWebResearchTracker()
|
|
1826
|
+
for index, event in enumerate(events, start=1):
|
|
1827
|
+
payload = event.get("payload") if isinstance(event.get("payload"), dict) else {}
|
|
1828
|
+
tracker.observe_event(
|
|
1829
|
+
event_type=str(event.get("type") or "").strip(),
|
|
1830
|
+
payload=payload,
|
|
1831
|
+
ts=str(event.get("ts") or "").strip() or None,
|
|
1832
|
+
event_id=str(event.get("event_id") or "").strip() or f"event:{index}",
|
|
1833
|
+
)
|
|
1834
|
+
return tracker.artifact_payload()
|
|
1835
|
+
|
|
1836
|
+
|
|
1837
|
+
def build_web_research_metrics_from_events(events: list[dict[str, Any]]) -> dict[str, int]:
|
|
1838
|
+
tracker = SessionWebResearchTracker()
|
|
1839
|
+
for index, event in enumerate(events, start=1):
|
|
1840
|
+
payload = event.get("payload") if isinstance(event.get("payload"), dict) else {}
|
|
1841
|
+
tracker.observe_event(
|
|
1842
|
+
event_type=str(event.get("type") or "").strip(),
|
|
1843
|
+
payload=payload,
|
|
1844
|
+
ts=str(event.get("ts") or "").strip() or None,
|
|
1845
|
+
event_id=str(event.get("event_id") or "").strip() or f"event:{index}",
|
|
1846
|
+
)
|
|
1847
|
+
return tracker.metrics_payload()
|
|
1848
|
+
|
|
1849
|
+
|
|
1850
|
+
def build_web_research_metrics_from_artifact_payload(payload: dict[str, Any]) -> dict[str, int]:
|
|
1851
|
+
tracker = SessionWebResearchTracker()
|
|
1852
|
+
if not tracker.hydrate_from_artifact_payload(payload):
|
|
1853
|
+
return SessionWebResearchTracker().metrics_payload()
|
|
1854
|
+
return tracker.metrics_payload()
|
|
1855
|
+
|
|
1856
|
+
|
|
1857
|
+
def web_research_artifact_has_activity(payload: dict[str, Any] | None) -> bool:
|
|
1858
|
+
if not isinstance(payload, dict):
|
|
1859
|
+
return False
|
|
1860
|
+
for key in (
|
|
1861
|
+
"user_provided_urls",
|
|
1862
|
+
"returned_by_web_search_urls",
|
|
1863
|
+
"searches",
|
|
1864
|
+
"fetches",
|
|
1865
|
+
):
|
|
1866
|
+
value = payload.get(key)
|
|
1867
|
+
if isinstance(value, list) and value:
|
|
1868
|
+
return True
|
|
1869
|
+
graph = payload.get("url_provenance_graph")
|
|
1870
|
+
if isinstance(graph, dict) and isinstance(graph.get("nodes"), list) and graph["nodes"]:
|
|
1871
|
+
return True
|
|
1872
|
+
return False
|