unique_web_search 2026.44.0.dev4__tar.gz → 2026.44.0.dev5__tar.gz
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.
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/PKG-INFO +5 -5
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/README.md +1 -1
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/pyproject.toml +4 -4
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/metrics.py +15 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/proxy/mappers.py +10 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/search_engine/README.md +8 -2
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/search_engine/base.py +20 -3
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/search_engine/bing.py +33 -24
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/search_engine/utils/grounding/bing/__init__.py +4 -0
- unique_web_search-2026.44.0.dev5/src/unique_web_search/services/search_engine/utils/grounding/bing/citation_instructions.j2 +18 -0
- unique_web_search-2026.44.0.dev5/src/unique_web_search/services/search_engine/utils/grounding/bing/citation_parsing.py +302 -0
- unique_web_search-2026.44.0.dev5/src/unique_web_search/services/search_engine/utils/grounding/bing/models.py +39 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/search_engine/utils/grounding/bing/runner.py +104 -60
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/search_engine/utils/grounding/response_parsing.py +28 -5
- unique_web_search-2026.44.0.dev4/src/unique_web_search/services/search_engine/utils/grounding/bing/models.py +0 -16
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/__init__.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/client_settings.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/config.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/invocation_stats.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/prompts.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/schema.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/service.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/_registry.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/argument_screening/__init__.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/argument_screening/config.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/argument_screening/exceptions.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/argument_screening/prompts/__init__.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/argument_screening/prompts/guidelines.j2 +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/argument_screening/prompts/guidelines_with_keywords.j2 +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/argument_screening/prompts/rejection_response.j2 +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/argument_screening/prompts/system_prompt.j2 +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/argument_screening/prompts/user_prompt.j2 +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/argument_screening/service.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/client/http_client.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/content_processing/__init__.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/content_processing/cleaning/__init__.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/content_processing/cleaning/character_sanitize.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/content_processing/cleaning/clean.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/content_processing/cleaning/config.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/content_processing/config.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/content_processing/processing_strategies/base.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/content_processing/processing_strategies/config.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/content_processing/processing_strategies/llm_guard_judge.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/content_processing/processing_strategies/llm_keyword_redact.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/content_processing/processing_strategies/llm_process.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/content_processing/processing_strategies/prompts/__init__.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/content_processing/processing_strategies/prompts/judge_and_sanitize_prompt.j2 +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/content_processing/processing_strategies/prompts/judge_prompt.j2 +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/content_processing/processing_strategies/prompts/keyword_extract_prompt.j2 +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/content_processing/processing_strategies/prompts/page_context.j2 +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/content_processing/processing_strategies/prompts/sanitize_rules.j2 +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/content_processing/processing_strategies/prompts/system_prompt.j2 +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/content_processing/processing_strategies/prompts/user_prompt.j2 +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/content_processing/processing_strategies/schema.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/content_processing/processing_strategies/settings.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/content_processing/processing_strategies/truncate.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/content_processing/service.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/crawlers/README.md +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/crawlers/__init__.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/crawlers/base.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/crawlers/basic.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/crawlers/firecrawl.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/crawlers/jina.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/crawlers/registry.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/crawlers/tavily.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/crawlers/url_safety/__init__.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/crawlers/utils.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/executors/README.md +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/executors/__init__.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/executors/base_config.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/executors/base_executor.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/executors/context.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/executors/modes.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/executors/v1/__init__.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/executors/v1/config.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/executors/v1/executor.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/executors/v1/prompts/__init__.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/executors/v1/prompts/refine_query_system_prompt.j2 +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/executors/v1/prompts/tool_description.j2 +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/executors/v1/prompts/tool_description_for_system_prompt.j2 +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/executors/v1/schema.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/executors/v1/strategy.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/executors/v2/__init__.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/executors/v2/config.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/executors/v2/executor.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/executors/v2/prompts/__init__.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/executors/v2/prompts/step_query_description.j2 +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/executors/v2/prompts/tool_description.j2 +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/executors/v2/prompts/tool_description_for_system_prompt.j2 +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/executors/v2/schema.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/executors/v2/strategy.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/executors/v3/__init__.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/executors/v3/config.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/executors/v3/executor.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/executors/v3/prompts/__init__.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/executors/v3/prompts/tool_description.j2 +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/executors/v3/prompts/tool_description_for_system_prompt.j2 +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/executors/v3/schema.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/executors/v3/strategy.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/helpers.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/httpx_logging.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/message_log.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/proxy/__init__.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/proxy/bridge.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/query_elicitation.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/search_engine/__init__.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/search_engine/brave.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/search_engine/custom_api.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/search_engine/google.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/search_engine/perplexity.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/search_engine/registry.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/search_engine/schema.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/search_engine/utils/google/schema.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/search_engine/utils/grounding/__init__.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/search_engine/utils/grounding/bing/client.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/search_engine/utils/grounding/models.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/search_engine/utils/grounding/vertexai/__init__.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/search_engine/utils/grounding/vertexai/client.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/search_engine/utils/grounding/vertexai/exceptions.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/search_engine/utils/grounding/vertexai/gemini.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/search_engine/utils/grounding/vertexai/response_handler.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/search_engine/vertexai.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/snippet_judge/__init__.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/snippet_judge/prompts.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/snippet_judge/schema.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/snippet_judge/service.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/services/structured_llm.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/settings.py +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/skills/web-search-v3.md +0 -0
- {unique_web_search-2026.44.0.dev4 → unique_web_search-2026.44.0.dev5}/src/unique_web_search/utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: unique-web-search
|
|
3
|
-
Version: 2026.44.0.
|
|
3
|
+
Version: 2026.44.0.dev5
|
|
4
4
|
Summary:
|
|
5
5
|
Author: Andreas Hauri, Gustav Hartz, Rami Azouz
|
|
6
6
|
Author-email: Andreas Hauri <andreas@unique.ch>, Gustav Hartz <gustav.hartz.ext@unique.ch>, Rami Azouz <rami.ext@unique.ch>
|
|
@@ -16,7 +16,7 @@ Requires-Dist: tavily-python>=0.7.11,<1
|
|
|
16
16
|
Requires-Dist: unidecode>=1.4.0,<2
|
|
17
17
|
Requires-Dist: azure-ai-projects>=2.3,<3
|
|
18
18
|
Requires-Dist: azure-identity>=1.25.0,<2
|
|
19
|
-
Requires-Dist: unique-toolkit[monitoring]>=2026.44.0.
|
|
19
|
+
Requires-Dist: unique-toolkit[monitoring]>=2026.44.0.dev10,<2026.44.0rc0
|
|
20
20
|
Requires-Dist: azure-core>=1.36.0,<2
|
|
21
21
|
Requires-Dist: google-cloud-aiplatform>=1.128.0,<2
|
|
22
22
|
Requires-Dist: google-auth>=2.43.0,<3
|
|
@@ -25,8 +25,8 @@ Requires-Dist: rapidfuzz>=3.0.0,<4
|
|
|
25
25
|
Requires-Dist: httpx>=0.28.1
|
|
26
26
|
Requires-Dist: certifi>=2025.11.12
|
|
27
27
|
Requires-Dist: jinja2>=3.1.6
|
|
28
|
-
Requires-Dist: unique-search-proxy-core>=2026.44.0.
|
|
29
|
-
Requires-Dist: unique-search-proxy-sdk>=2026.44.0.
|
|
28
|
+
Requires-Dist: unique-search-proxy-core>=2026.44.0.dev10,<2026.44.0rc0
|
|
29
|
+
Requires-Dist: unique-search-proxy-sdk>=2026.44.0.dev5,<2026.44.0rc0
|
|
30
30
|
Requires-Dist: openai>=1.105.0,<3
|
|
31
31
|
Requires-Python: >=3.12, <4.0
|
|
32
32
|
Description-Content-Type: text/markdown
|
|
@@ -73,7 +73,7 @@ Standard engines that live in proxy-core use **`ExposableParam`** (`expose` + `v
|
|
|
73
73
|
|
|
74
74
|
### Agent search (grounding)
|
|
75
75
|
|
|
76
|
-
An upstream AI agent / grounded model searches and returns opaque text that this tool parses into `WebSearchResult`s.
|
|
76
|
+
An upstream AI agent / grounded model searches and returns opaque text that this tool parses into `WebSearchResult`s. Bing results take their URLs only from the citations Foundry attaches to the answer, never from the answer text.
|
|
77
77
|
|
|
78
78
|
| Engine | Proxy | Notes |
|
|
79
79
|
|--------|-------|-------|
|
|
@@ -40,7 +40,7 @@ Standard engines that live in proxy-core use **`ExposableParam`** (`expose` + `v
|
|
|
40
40
|
|
|
41
41
|
### Agent search (grounding)
|
|
42
42
|
|
|
43
|
-
An upstream AI agent / grounded model searches and returns opaque text that this tool parses into `WebSearchResult`s.
|
|
43
|
+
An upstream AI agent / grounded model searches and returns opaque text that this tool parses into `WebSearchResult`s. Bing results take their URLs only from the citations Foundry attaches to the answer, never from the answer text.
|
|
44
44
|
|
|
45
45
|
| Engine | Proxy | Notes |
|
|
46
46
|
|--------|-------|-------|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "unique_web_search"
|
|
3
|
-
version = "2026.44.0.
|
|
3
|
+
version = "2026.44.0.dev5"
|
|
4
4
|
description = ""
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.12,<4.0"
|
|
@@ -22,7 +22,7 @@ dependencies = [
|
|
|
22
22
|
"unidecode>=1.4.0,<2",
|
|
23
23
|
"azure-ai-projects>=2.3,<3",
|
|
24
24
|
"azure-identity>=1.25.0,<2",
|
|
25
|
-
"unique-toolkit[monitoring]>=2026.44.0.
|
|
25
|
+
"unique-toolkit[monitoring]>=2026.44.0.dev10,<2026.44.0rc0",
|
|
26
26
|
"azure-core>=1.36.0,<2",
|
|
27
27
|
"google-cloud-aiplatform>=1.128.0,<2",
|
|
28
28
|
"google-auth>=2.43.0,<3",
|
|
@@ -31,8 +31,8 @@ dependencies = [
|
|
|
31
31
|
"httpx>=0.28.1",
|
|
32
32
|
"certifi>=2025.11.12",
|
|
33
33
|
"jinja2>=3.1.6",
|
|
34
|
-
"unique-search-proxy-core>=2026.44.0.
|
|
35
|
-
"unique-search-proxy-sdk>=2026.44.0.
|
|
34
|
+
"unique-search-proxy-core>=2026.44.0.dev10,<2026.44.0rc0",
|
|
35
|
+
"unique-search-proxy-sdk>=2026.44.0.dev5,<2026.44.0rc0",
|
|
36
36
|
"openai>=1.105.0,<3",
|
|
37
37
|
]
|
|
38
38
|
|
|
@@ -15,6 +15,21 @@ custom_api_url_safety_report = m.counter(
|
|
|
15
15
|
"Report-only URL safety outcomes for legacy Custom API searches",
|
|
16
16
|
["outcome", "reason_category"],
|
|
17
17
|
)
|
|
18
|
+
grounding_parse_failures = m.counter(
|
|
19
|
+
"grounding_parse_failures_total",
|
|
20
|
+
"Grounded agent answers that yielded no usable sources",
|
|
21
|
+
["engine", "reason"],
|
|
22
|
+
)
|
|
23
|
+
grounding_dropped_citations = m.counter(
|
|
24
|
+
"grounding_dropped_citations_total",
|
|
25
|
+
"Citations dropped from grounded agent answers because they failed validation",
|
|
26
|
+
["engine", "reason"],
|
|
27
|
+
)
|
|
28
|
+
grounding_dropped_lines = m.counter(
|
|
29
|
+
"grounding_dropped_lines_total",
|
|
30
|
+
"Answer lines dropped from grounded agent answers",
|
|
31
|
+
["engine", "reason"],
|
|
32
|
+
)
|
|
18
33
|
|
|
19
34
|
# End-to-End Tool (P1)
|
|
20
35
|
tool_duration = m.histogram(
|
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
from collections.abc import Sequence
|
|
4
4
|
from typing import TYPE_CHECKING
|
|
5
5
|
|
|
6
|
+
from unique_search_proxy_core.schema import AnswerSpanCitation
|
|
6
7
|
from unique_search_proxy_sdk._generated.models.agent_search_response import (
|
|
7
8
|
AgentSearchResponse,
|
|
8
9
|
)
|
|
@@ -71,3 +72,12 @@ def agent_answer_text(response: AgentSearchResponse) -> str:
|
|
|
71
72
|
if isinstance(response.answer, str):
|
|
72
73
|
return response.answer
|
|
73
74
|
return ""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def agent_citations(response: AgentSearchResponse) -> list[AnswerSpanCitation]:
|
|
78
|
+
if not isinstance(response.citations, list):
|
|
79
|
+
return []
|
|
80
|
+
return [
|
|
81
|
+
AnswerSpanCitation.model_validate(citation.to_dict())
|
|
82
|
+
for citation in response.citations
|
|
83
|
+
]
|
|
@@ -31,7 +31,7 @@ Deployment configs for Google / Brave / Perplexity (and agent base fields) live
|
|
|
31
31
|
| | Standard | Agent (grounding) |
|
|
32
32
|
|--|----------|-------------------|
|
|
33
33
|
| Proxy route | `POST /v1/search` | `POST /v1/agent-search` |
|
|
34
|
-
| Result shape | `SearchResponse` → `WebSearchResult` list | Opaque `answer` text → parsed locally |
|
|
34
|
+
| Result shape | `SearchResponse` → `WebSearchResult` list | Opaque `answer` text (+ provider `citations`) → parsed locally |
|
|
35
35
|
| LLM knobs | `ExposableParam` + `config.merge()` | `ExposableParam` (Bing only) |
|
|
36
36
|
| Typical scrape | Often yes (Google) | Often no (`requires_scraping=false`) |
|
|
37
37
|
|
|
@@ -158,7 +158,13 @@ CUSTOM_WEB_SEARCH_API_HEADERS='{"Authorization": "Bearer ..."}'
|
|
|
158
158
|
|
|
159
159
|
### Bing (Grounding with Bing)
|
|
160
160
|
|
|
161
|
-
**Provider:** Azure AI Foundry Agent with Bing grounding · Config extends `BingAgentConfig` with `requires_scraping
|
|
161
|
+
**Provider:** Azure AI Foundry Agent with Bing grounding · Config extends `BingAgentConfig` with `requires_scraping`.
|
|
162
|
+
|
|
163
|
+
The agent answers with one bullet per source, each ending in a citation marker.
|
|
164
|
+
`BingCitationParser` (`utils/grounding/bing/citation_parsing.py`) turns each
|
|
165
|
+
cited source into one result, taking its URL and title only from Foundry's
|
|
166
|
+
`url_citation` annotations. Unusable citations and lines are dropped one by one
|
|
167
|
+
and counted in the `grounding_*` metrics rather than failing the whole answer.
|
|
162
168
|
|
|
163
169
|
| Field | Notes |
|
|
164
170
|
|-------|-------|
|
|
@@ -24,6 +24,7 @@ from unique_web_search.services.proxy.bridge import (
|
|
|
24
24
|
)
|
|
25
25
|
from unique_web_search.services.proxy.mappers import (
|
|
26
26
|
agent_answer_text,
|
|
27
|
+
agent_citations,
|
|
27
28
|
map_agent_answer,
|
|
28
29
|
map_search_response,
|
|
29
30
|
)
|
|
@@ -32,6 +33,10 @@ from unique_web_search.services.search_engine.schema import (
|
|
|
32
33
|
)
|
|
33
34
|
|
|
34
35
|
if TYPE_CHECKING:
|
|
36
|
+
from collections.abc import Sequence
|
|
37
|
+
|
|
38
|
+
from unique_search_proxy_core.schema import AnswerSpanCitation
|
|
39
|
+
|
|
35
40
|
from unique_web_search.services.search_engine.utils.grounding.response_parsing import (
|
|
36
41
|
ResponseParser,
|
|
37
42
|
)
|
|
@@ -72,7 +77,7 @@ class SearchEngine(ABC, Generic[SearchEngineConfig]):
|
|
|
72
77
|
it, otherwise it reroutes to the local legacy implementation.
|
|
73
78
|
"""
|
|
74
79
|
|
|
75
|
-
# Set by agent engines
|
|
80
|
+
# Set by agent engines that use the default ``_parse_agent_answer``.
|
|
76
81
|
response_parsers: list[ResponseParser]
|
|
77
82
|
|
|
78
83
|
def __init__(
|
|
@@ -180,12 +185,24 @@ class SearchEngine(ABC, Generic[SearchEngineConfig]):
|
|
|
180
185
|
engine=engine.value,
|
|
181
186
|
**invocation,
|
|
182
187
|
)
|
|
183
|
-
results = await
|
|
188
|
+
results = await self._parse_agent_answer(
|
|
184
189
|
agent_answer_text(response),
|
|
185
|
-
|
|
190
|
+
agent_citations(response),
|
|
186
191
|
)
|
|
187
192
|
return await self._postprocess_results(results)
|
|
188
193
|
|
|
194
|
+
async def _parse_agent_answer(
|
|
195
|
+
self,
|
|
196
|
+
answer: str,
|
|
197
|
+
citations: Sequence[AnswerSpanCitation],
|
|
198
|
+
) -> list[WebSearchResult]:
|
|
199
|
+
"""Turn an agent answer into results.
|
|
200
|
+
|
|
201
|
+
Parses only the answer text. Engines whose provider cites its sources
|
|
202
|
+
alongside the answer override this, as Bing does.
|
|
203
|
+
"""
|
|
204
|
+
return await map_agent_answer(answer, self.response_parsers)
|
|
205
|
+
|
|
189
206
|
def _agent_proxy_invocation(
|
|
190
207
|
self,
|
|
191
208
|
engine: AgentEngineType,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
+
from collections.abc import Sequence
|
|
2
3
|
from typing import Any, override
|
|
3
4
|
|
|
4
5
|
from pydantic import Field
|
|
@@ -10,10 +11,13 @@ from unique_search_proxy_core.agent_engines.bing.schema import BingAgentConfig
|
|
|
10
11
|
from unique_search_proxy_core.agent_engines.bing.settings import resolve_market
|
|
11
12
|
from unique_search_proxy_core.context import LOCAL_REQUEST_CONTEXT, RequestContext
|
|
12
13
|
from unique_search_proxy_core.param_policy.exposed_params import ExposedParams
|
|
13
|
-
from
|
|
14
|
-
from unique_toolkit._common.validators import LMI, get_LMI_default_field
|
|
15
|
-
from unique_toolkit.language_model import LanguageModelService
|
|
14
|
+
from unique_search_proxy_core.schema import AnswerSpanCitation
|
|
16
15
|
|
|
16
|
+
from unique_web_search.metrics import (
|
|
17
|
+
grounding_dropped_citations,
|
|
18
|
+
grounding_dropped_lines,
|
|
19
|
+
grounding_parse_failures,
|
|
20
|
+
)
|
|
17
21
|
from unique_web_search.services.proxy.bridge import (
|
|
18
22
|
search_proxy_client_enabled,
|
|
19
23
|
)
|
|
@@ -22,11 +26,8 @@ from unique_web_search.services.search_engine.registry import register_search_en
|
|
|
22
26
|
from unique_web_search.services.search_engine.schema import (
|
|
23
27
|
WebSearchResult,
|
|
24
28
|
)
|
|
25
|
-
from unique_web_search.services.search_engine.utils.grounding import (
|
|
26
|
-
JsonConversionStrategy,
|
|
27
|
-
LLMParserStrategy,
|
|
28
|
-
)
|
|
29
29
|
from unique_web_search.services.search_engine.utils.grounding.bing import (
|
|
30
|
+
BingCitationParser,
|
|
30
31
|
create_and_process_run,
|
|
31
32
|
credentials_are_valid,
|
|
32
33
|
get_credentials,
|
|
@@ -35,10 +36,6 @@ from unique_web_search.services.search_engine.utils.grounding.bing import (
|
|
|
35
36
|
|
|
36
37
|
|
|
37
38
|
class BingSearchConfig(BingAgentConfig):
|
|
38
|
-
language_model: LMI = get_LMI_default_field(
|
|
39
|
-
DEFAULT_LANGUAGE_MODEL,
|
|
40
|
-
description="The language model to use in as a fallback parser if the agent response is not a valid JSON.",
|
|
41
|
-
)
|
|
42
39
|
requires_scraping: bool = Field(
|
|
43
40
|
default=False,
|
|
44
41
|
description="Whether the search engine requires scraping.",
|
|
@@ -51,26 +48,17 @@ class BingSearchConfig(BingAgentConfig):
|
|
|
51
48
|
config_cls=BingSearchConfig,
|
|
52
49
|
mode=SearchEngineMode.AGENT,
|
|
53
50
|
config_display_name="Grounding with Bing",
|
|
54
|
-
needs_language_model=True,
|
|
55
51
|
)
|
|
56
52
|
class BingSearch(SearchEngine[BingSearchConfig]):
|
|
57
53
|
def __init__(
|
|
58
54
|
self,
|
|
59
55
|
config: BingSearchConfig,
|
|
60
|
-
language_model_service: LanguageModelService,
|
|
61
56
|
*,
|
|
62
57
|
request_context: RequestContext = LOCAL_REQUEST_CONTEXT,
|
|
63
58
|
):
|
|
64
59
|
super().__init__(config, request_context=request_context)
|
|
65
60
|
self.credentials = get_credentials()
|
|
66
|
-
|
|
67
|
-
self.response_parsers = [
|
|
68
|
-
JsonConversionStrategy(),
|
|
69
|
-
LLMParserStrategy(
|
|
70
|
-
config.language_model,
|
|
71
|
-
language_model_service,
|
|
72
|
-
),
|
|
73
|
-
]
|
|
61
|
+
self.citation_response_parser = BingCitationParser()
|
|
74
62
|
|
|
75
63
|
@property
|
|
76
64
|
def is_configured(self) -> bool:
|
|
@@ -116,12 +104,33 @@ class BingSearch(SearchEngine[BingSearchConfig]):
|
|
|
116
104
|
agent_client = get_project_client(self.credentials)
|
|
117
105
|
|
|
118
106
|
async with agent_client:
|
|
119
|
-
|
|
107
|
+
answer, citations = await create_and_process_run(
|
|
120
108
|
agent_client,
|
|
121
109
|
query=query,
|
|
122
110
|
grounding=self._grounding_configuration(),
|
|
123
|
-
response_parsers_strategies=self.response_parsers,
|
|
124
111
|
generation_instructions=self.config.generation_instructions,
|
|
125
112
|
)
|
|
126
113
|
|
|
127
|
-
return
|
|
114
|
+
return await self._parse_agent_answer(answer, citations)
|
|
115
|
+
|
|
116
|
+
@override
|
|
117
|
+
async def _parse_agent_answer(
|
|
118
|
+
self,
|
|
119
|
+
answer: str,
|
|
120
|
+
citations: Sequence[AnswerSpanCitation],
|
|
121
|
+
) -> list[WebSearchResult]:
|
|
122
|
+
parsed = await self.citation_response_parser(answer, citations)
|
|
123
|
+
engine = self.config.engine.value
|
|
124
|
+
for reason, count in parsed.dropped_citations.items():
|
|
125
|
+
grounding_dropped_citations.labels(engine=engine, reason=reason.value).inc(
|
|
126
|
+
count
|
|
127
|
+
)
|
|
128
|
+
for reason, count in parsed.dropped_lines.items():
|
|
129
|
+
grounding_dropped_lines.labels(engine=engine, reason=reason.value).inc(
|
|
130
|
+
count
|
|
131
|
+
)
|
|
132
|
+
if parsed.failure is not None:
|
|
133
|
+
grounding_parse_failures.labels(
|
|
134
|
+
engine=engine, reason=parsed.failure.value
|
|
135
|
+
).inc()
|
|
136
|
+
return parsed.results
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
from unique_web_search.services.search_engine.utils.grounding.bing.citation_parsing import (
|
|
2
|
+
BingCitationParser,
|
|
3
|
+
)
|
|
1
4
|
from unique_web_search.services.search_engine.utils.grounding.bing.client import (
|
|
2
5
|
credentials_are_valid,
|
|
3
6
|
get_credentials,
|
|
@@ -8,6 +11,7 @@ from unique_web_search.services.search_engine.utils.grounding.bing.runner import
|
|
|
8
11
|
)
|
|
9
12
|
|
|
10
13
|
__all__ = [
|
|
14
|
+
"BingCitationParser",
|
|
11
15
|
"credentials_are_valid",
|
|
12
16
|
"get_credentials",
|
|
13
17
|
"get_project_client",
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{{ generation_instructions }}
|
|
2
|
+
|
|
3
|
+
## Source-isolated evidence list
|
|
4
|
+
Use Bing Grounding to select distinct useful sources.
|
|
5
|
+
The complete response must consist only of lines in this form:
|
|
6
|
+
- <a paragraph of facts supported only by one source> <that source's citation>
|
|
7
|
+
|
|
8
|
+
Each line is a paragraph, not a sentence. Write several sentences per line that report every relevant fact, figure, date, name and quote from that source, with as much detail as the source offers, often 100 to 200 words. Lines can be long; do not shorten them.
|
|
9
|
+
|
|
10
|
+
Hard requirements:
|
|
11
|
+
1. Exactly one line per distinct source and exactly one distinct source per line.
|
|
12
|
+
2. Every line begins with `- ` and contains exactly one citation marker as its final content.
|
|
13
|
+
3. Never synthesize, compare, or combine facts across sources within a line.
|
|
14
|
+
4. Put all useful facts from a source into that source's one line; never cite it again.
|
|
15
|
+
5. No headings, preface, conclusion, line breaks inside a paragraph, nested bullets, JSON, or uncited claims.
|
|
16
|
+
6. Never invent, reconstruct, type, or guess citation markers or source URLs.
|
|
17
|
+
|
|
18
|
+
Before responding, silently verify that the number of lines equals the number of distinct cited sources.
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import re
|
|
3
|
+
from collections import Counter
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from urllib.parse import urlparse
|
|
8
|
+
|
|
9
|
+
from unique_search_proxy_core.schema import AnswerSpanCitation
|
|
10
|
+
|
|
11
|
+
from unique_web_search.services.search_engine.schema import WebSearchResult
|
|
12
|
+
|
|
13
|
+
_LOGGER = logging.getLogger(__name__)
|
|
14
|
+
_LIST_MARKER_PATTERN = re.compile(r"\s*(?:[-*+•]|\d+[.)])\s+")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class GroundingCitationDropReason(StrEnum):
|
|
18
|
+
"""Why one citation was left out of the results."""
|
|
19
|
+
|
|
20
|
+
INVALID_OFFSETS = "invalid_offsets"
|
|
21
|
+
MARKER_MISMATCH = "marker_mismatch"
|
|
22
|
+
NOT_ON_ONE_LINE = "citation_not_on_one_line"
|
|
23
|
+
NON_HTTP_URL = "non_http_url"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class GroundingLineDropReason(StrEnum):
|
|
27
|
+
"""Why one answer line was left out of the results."""
|
|
28
|
+
|
|
29
|
+
NO_CITATION = "no_citation"
|
|
30
|
+
NO_CONTENT = "no_content"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class GroundingParseFailureReason(StrEnum):
|
|
34
|
+
"""Why an answer produced no results at all."""
|
|
35
|
+
|
|
36
|
+
NO_TEXT = "no_text"
|
|
37
|
+
NO_CITATIONS = "no_citations"
|
|
38
|
+
NO_USABLE_CITATIONS = "no_usable_citations"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class CitationParseResult:
|
|
43
|
+
"""Results mapped from a grounded answer, plus the parts that were dropped.
|
|
44
|
+
|
|
45
|
+
``failure`` is set exactly when no result survived.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
results: list[WebSearchResult]
|
|
49
|
+
dropped_citations: Counter[GroundingCitationDropReason] = field(
|
|
50
|
+
default_factory=Counter
|
|
51
|
+
)
|
|
52
|
+
dropped_lines: Counter[GroundingLineDropReason] = field(default_factory=Counter)
|
|
53
|
+
failure: GroundingParseFailureReason | None = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class BingCitationParser:
|
|
57
|
+
"""Map a Grounding with Bing Search answer onto one result per cited source.
|
|
58
|
+
|
|
59
|
+
The agent is prompted to answer with one bullet per source, each ending in
|
|
60
|
+
the marker Foundry renders for that source. Foundry returns the answer text
|
|
61
|
+
and, separately, one citation per marker with the marker's character span
|
|
62
|
+
in the answer and the source it points to. For example, this answer::
|
|
63
|
+
|
|
64
|
+
- Python 3.14 adds template string literals (PEP 750). 【4:0†source】
|
|
65
|
+
- Free-threaded Python is officially supported. 【4:1†source】
|
|
66
|
+
|
|
67
|
+
comes with these citations::
|
|
68
|
+
|
|
69
|
+
AnswerSpanCitation(
|
|
70
|
+
url="https://docs.python.org/3/whatsnew/3.14.html",
|
|
71
|
+
title="What's new in Python 3.14",
|
|
72
|
+
marker="【4:0†source】",
|
|
73
|
+
start_index=55,
|
|
74
|
+
end_index=67,
|
|
75
|
+
)
|
|
76
|
+
AnswerSpanCitation(
|
|
77
|
+
url="https://peps.python.org/pep-0779/",
|
|
78
|
+
title="PEP 779",
|
|
79
|
+
marker="【4:1†source】",
|
|
80
|
+
start_index=116,
|
|
81
|
+
end_index=128,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
and is parsed into one result per URL. A result's content is the text of
|
|
85
|
+
the lines citing it, without list markers or citation markers, such as
|
|
86
|
+
``"Python 3.14 adds template string literals (PEP 750)."``. Result URLs and
|
|
87
|
+
titles come only from citations, never from the answer text.
|
|
88
|
+
|
|
89
|
+
The model does not always follow the format, so these deviations are
|
|
90
|
+
accepted:
|
|
91
|
+
|
|
92
|
+
- Other list markers (``*``, ``+``, ``•``, ``1.``, ``1)``), indentation, or
|
|
93
|
+
no list marker at all.
|
|
94
|
+
- A marker anywhere in the line; the text after it is kept.
|
|
95
|
+
- Several sources cited on one line; the line is added to each of them.
|
|
96
|
+
- One source cited on several lines; its lines are joined in order.
|
|
97
|
+
|
|
98
|
+
Whatever cannot be used is dropped on its own and counted, and the rest is
|
|
99
|
+
kept:
|
|
100
|
+
|
|
101
|
+
- A citation whose offsets fall outside the answer, whose span does not
|
|
102
|
+
hold its marker, whose span crosses a line break, or whose URL is not
|
|
103
|
+
http(s). A citation dropped only for its URL still has its marker
|
|
104
|
+
removed from the line text.
|
|
105
|
+
- A line with no usable citation, such as a heading or a preface, or with
|
|
106
|
+
no text left once its markers are removed.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
async def __call__(
|
|
110
|
+
self,
|
|
111
|
+
response: str,
|
|
112
|
+
citations: Sequence[AnswerSpanCitation],
|
|
113
|
+
) -> CitationParseResult:
|
|
114
|
+
lines = _answer_lines(response)
|
|
115
|
+
if not lines:
|
|
116
|
+
_LOGGER.warning("Grounding response contained no text")
|
|
117
|
+
return CitationParseResult(
|
|
118
|
+
results=[], failure=GroundingParseFailureReason.NO_TEXT
|
|
119
|
+
)
|
|
120
|
+
if not citations:
|
|
121
|
+
_LOGGER.warning("Grounding response contained no citations")
|
|
122
|
+
return CitationParseResult(
|
|
123
|
+
results=[], failure=GroundingParseFailureReason.NO_CITATIONS
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
cited_lines, dropped_citations = _assign_citations_to_lines(
|
|
127
|
+
response, lines, citations
|
|
128
|
+
)
|
|
129
|
+
sources, dropped_lines = _group_lines_by_source(response, cited_lines)
|
|
130
|
+
results = [source.to_search_result() for source in sources]
|
|
131
|
+
|
|
132
|
+
if dropped_citations or dropped_lines:
|
|
133
|
+
_LOGGER.info(
|
|
134
|
+
"Grounding response parsed with drops result_count=%d "
|
|
135
|
+
"dropped_citations=%s dropped_lines=%s",
|
|
136
|
+
len(results),
|
|
137
|
+
{reason.value: count for reason, count in dropped_citations.items()},
|
|
138
|
+
{reason.value: count for reason, count in dropped_lines.items()},
|
|
139
|
+
)
|
|
140
|
+
if not results:
|
|
141
|
+
_LOGGER.warning("Grounding response had no usable citations")
|
|
142
|
+
return CitationParseResult(
|
|
143
|
+
results=results,
|
|
144
|
+
dropped_citations=dropped_citations,
|
|
145
|
+
dropped_lines=dropped_lines,
|
|
146
|
+
failure=(
|
|
147
|
+
None if results else GroundingParseFailureReason.NO_USABLE_CITATIONS
|
|
148
|
+
),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclass(frozen=True)
|
|
153
|
+
class _AnswerLine:
|
|
154
|
+
"""A non-empty line of the answer, with its offsets into the answer."""
|
|
155
|
+
|
|
156
|
+
start: int
|
|
157
|
+
end: int
|
|
158
|
+
text: str
|
|
159
|
+
|
|
160
|
+
def contains(self, citation: AnswerSpanCitation) -> bool:
|
|
161
|
+
return self.start <= citation.start_index and citation.end_index <= self.end
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@dataclass
|
|
165
|
+
class _CitedLine:
|
|
166
|
+
"""An answer line and the verified citations whose markers are on it.
|
|
167
|
+
|
|
168
|
+
``markers`` holds all of them, since every marker is removed from the text.
|
|
169
|
+
``sources`` holds the ones whose URL can become a result.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
line: _AnswerLine
|
|
173
|
+
markers: list[AnswerSpanCitation] = field(default_factory=list)
|
|
174
|
+
sources: list[AnswerSpanCitation] = field(default_factory=list)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@dataclass
|
|
178
|
+
class _Source:
|
|
179
|
+
"""A cited URL and the content of every line that cites it."""
|
|
180
|
+
|
|
181
|
+
url: str
|
|
182
|
+
title: str
|
|
183
|
+
# Keyed by line index, so a line citing the same URL twice is added once.
|
|
184
|
+
contents: dict[int, str] = field(default_factory=dict)
|
|
185
|
+
|
|
186
|
+
def to_search_result(self) -> WebSearchResult:
|
|
187
|
+
content = "\n".join(self.contents.values())
|
|
188
|
+
return WebSearchResult(
|
|
189
|
+
url=self.url, title=self.title, snippet=content, content=content
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _answer_lines(response: str) -> list[_AnswerLine]:
|
|
194
|
+
"""Split the answer into its non-empty lines, keeping their offsets."""
|
|
195
|
+
lines: list[_AnswerLine] = []
|
|
196
|
+
offset = 0
|
|
197
|
+
for raw_line in response.splitlines(keepends=True):
|
|
198
|
+
text = raw_line.rstrip("\r\n")
|
|
199
|
+
if text.strip():
|
|
200
|
+
lines.append(_AnswerLine(start=offset, end=offset + len(text), text=text))
|
|
201
|
+
offset += len(raw_line)
|
|
202
|
+
return lines
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _assign_citations_to_lines(
|
|
206
|
+
response: str,
|
|
207
|
+
lines: Sequence[_AnswerLine],
|
|
208
|
+
citations: Sequence[AnswerSpanCitation],
|
|
209
|
+
) -> tuple[list[_CitedLine], Counter[GroundingCitationDropReason]]:
|
|
210
|
+
"""Attach each verified citation to the line its marker is on.
|
|
211
|
+
|
|
212
|
+
Returns every line, cited or not, and the dropped citations counted by
|
|
213
|
+
reason.
|
|
214
|
+
"""
|
|
215
|
+
cited_lines = [_CitedLine(line) for line in lines]
|
|
216
|
+
dropped: Counter[GroundingCitationDropReason] = Counter()
|
|
217
|
+
for citation in citations:
|
|
218
|
+
reason = _marker_drop_reason(response, citation)
|
|
219
|
+
if reason is not None:
|
|
220
|
+
dropped[reason] += 1
|
|
221
|
+
continue
|
|
222
|
+
cited_line = _line_containing(cited_lines, citation)
|
|
223
|
+
if cited_line is None:
|
|
224
|
+
dropped[GroundingCitationDropReason.NOT_ON_ONE_LINE] += 1
|
|
225
|
+
continue
|
|
226
|
+
cited_line.markers.append(citation)
|
|
227
|
+
if not _is_http_url(citation.url):
|
|
228
|
+
dropped[GroundingCitationDropReason.NON_HTTP_URL] += 1
|
|
229
|
+
continue
|
|
230
|
+
cited_line.sources.append(citation)
|
|
231
|
+
return cited_lines, dropped
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _marker_drop_reason(
|
|
235
|
+
response: str, citation: AnswerSpanCitation
|
|
236
|
+
) -> GroundingCitationDropReason | None:
|
|
237
|
+
"""Return why the citation's span does not hold its marker, or ``None`` if it does."""
|
|
238
|
+
if not (0 <= citation.start_index < citation.end_index <= len(response)):
|
|
239
|
+
return GroundingCitationDropReason.INVALID_OFFSETS
|
|
240
|
+
if response[citation.start_index : citation.end_index] != citation.marker:
|
|
241
|
+
return GroundingCitationDropReason.MARKER_MISMATCH
|
|
242
|
+
return None
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _line_containing(
|
|
246
|
+
cited_lines: Sequence[_CitedLine], citation: AnswerSpanCitation
|
|
247
|
+
) -> _CitedLine | None:
|
|
248
|
+
"""Return the line holding the citation's whole span, if there is one."""
|
|
249
|
+
return next((cited for cited in cited_lines if cited.line.contains(citation)), None)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _is_http_url(url: str) -> bool:
|
|
253
|
+
parsed_url = urlparse(url)
|
|
254
|
+
return parsed_url.scheme in {"http", "https"} and bool(parsed_url.netloc)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _group_lines_by_source(
|
|
258
|
+
response: str,
|
|
259
|
+
cited_lines: Sequence[_CitedLine],
|
|
260
|
+
) -> tuple[list[_Source], Counter[GroundingLineDropReason]]:
|
|
261
|
+
"""Add each line's content to every source it cites.
|
|
262
|
+
|
|
263
|
+
Returns the sources in the order they are first cited, and the dropped
|
|
264
|
+
lines counted by reason.
|
|
265
|
+
"""
|
|
266
|
+
sources: dict[str, _Source] = {}
|
|
267
|
+
dropped: Counter[GroundingLineDropReason] = Counter()
|
|
268
|
+
for index, cited_line in enumerate(cited_lines):
|
|
269
|
+
if not cited_line.sources:
|
|
270
|
+
dropped[GroundingLineDropReason.NO_CITATION] += 1
|
|
271
|
+
continue
|
|
272
|
+
content = _line_content(response, cited_line.line, cited_line.markers)
|
|
273
|
+
if not content:
|
|
274
|
+
dropped[GroundingLineDropReason.NO_CONTENT] += 1
|
|
275
|
+
continue
|
|
276
|
+
for citation in cited_line.sources:
|
|
277
|
+
source = sources.setdefault(
|
|
278
|
+
citation.url,
|
|
279
|
+
_Source(url=citation.url, title=citation.title or citation.url),
|
|
280
|
+
)
|
|
281
|
+
source.contents[index] = content
|
|
282
|
+
return list(sources.values()), dropped
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _line_content(
|
|
286
|
+
response: str,
|
|
287
|
+
line: _AnswerLine,
|
|
288
|
+
markers: Sequence[AnswerSpanCitation],
|
|
289
|
+
) -> str:
|
|
290
|
+
"""Return the line text without its list marker and citation markers.
|
|
291
|
+
|
|
292
|
+
Each marker is removed together with the whitespace before it, so
|
|
293
|
+
``"- Claim 【4:0†source】."`` becomes ``"Claim."``.
|
|
294
|
+
"""
|
|
295
|
+
list_marker = _LIST_MARKER_PATTERN.match(line.text)
|
|
296
|
+
cursor = line.start + (list_marker.end() if list_marker else 0)
|
|
297
|
+
parts: list[str] = []
|
|
298
|
+
for citation in sorted(markers, key=lambda item: item.start_index):
|
|
299
|
+
parts.append(response[cursor : citation.start_index].rstrip())
|
|
300
|
+
cursor = max(cursor, citation.end_index)
|
|
301
|
+
parts.append(response[cursor : line.end])
|
|
302
|
+
return "".join(parts).strip()
|