From 5023cff925ed1b009fceafac39a7b219574e4aea Mon Sep 17 00:00:00 2001 From: rainwalker Date: Tue, 22 Sep 2026 11:30:39 +0800 Subject: [PATCH 01/26] =?UTF-8?q?feat(agent):=20=E2=9C=A8=20=E7=A0=94?= =?UTF-8?q?=E7=A9=B6=E5=BC=BA=E5=BA=A6=E3=80=81=E7=BA=BF=E7=B4=A2=E8=B7=9F?= =?UTF-8?q?=E8=B8=AA=E4=B8=8E=E6=B7=B1=E5=BA=A6=E6=95=88=E7=8E=87=E6=97=8B?= =?UTF-8?q?=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/miroflow-agent/src/cache/result_cache.py | 9 +- apps/miroflow-agent/src/config/settings.py | 76 +- .../src/core/deep_efficiency.py | 226 +++++ apps/miroflow-agent/src/core/lead_tracker.py | 420 ++++++++ apps/miroflow-agent/src/core/orchestrator.py | 938 ++++++++++++++---- apps/miroflow-agent/src/core/pipeline.py | 67 +- .../miroflow-agent/src/logging/task_logger.py | 74 ++ .../tests/test_deep_early_stop_exit.py | 64 ++ .../tests/test_deep_efficiency.py | 111 +++ .../miroflow-agent/tests/test_lead_tracker.py | 83 ++ .../test_verification_gate_efficiency.py | 18 +- 11 files changed, 1888 insertions(+), 198 deletions(-) create mode 100644 apps/miroflow-agent/src/core/deep_efficiency.py create mode 100644 apps/miroflow-agent/src/core/lead_tracker.py create mode 100644 apps/miroflow-agent/tests/test_deep_early_stop_exit.py create mode 100644 apps/miroflow-agent/tests/test_deep_efficiency.py create mode 100644 apps/miroflow-agent/tests/test_lead_tracker.py diff --git a/apps/miroflow-agent/src/cache/result_cache.py b/apps/miroflow-agent/src/cache/result_cache.py index 952b9bf..a1f291a 100644 --- a/apps/miroflow-agent/src/cache/result_cache.py +++ b/apps/miroflow-agent/src/cache/result_cache.py @@ -35,6 +35,7 @@ def make_key( *, search_result_num: Optional[int] = None, verification_min_search_rounds: Optional[int] = None, + research_intensity: Optional[str] = None, ) -> str: """根据查询参数生成缓存 key。 @@ -43,9 +44,9 @@ def make_key( - query 只去除首尾空白,不改变大小写;代码标识符、基因符号和缩写等 查询可能依赖大小写语义,不能复用另一问题的完整研究结论。 - mode/profile/detail 属于策略枚举,统一 strip().lower() 归一化。 - - 纳入 search_result_num / verification_min_search_rounds:这两个参数 - 会改变检索深度,从而改变最终结论,必须参与 key,否则仅条数不同的请求 - 会误命中彼此的缓存。None 表示该参数不影响(保持向后兼容的旧 key)。 + - 纳入 search_result_num / verification_min_search_rounds / research_intensity: + 这些参数会改变检索深度,从而改变最终结论,必须参与 key,否则仅参数不同的 + 请求会误命中彼此的缓存。None 表示该参数不影响(保持向后兼容的旧 key)。 """ parts = [ query.strip(), @@ -57,6 +58,8 @@ def make_key( parts.append(f"srn={search_result_num}") if verification_min_search_rounds is not None: parts.append(f"vmsr={verification_min_search_rounds}") + if research_intensity is not None: + parts.append(f"ri={research_intensity.strip().lower()}") raw = "|".join(parts) return hashlib.sha256(raw.encode("utf-8")).hexdigest() diff --git a/apps/miroflow-agent/src/config/settings.py b/apps/miroflow-agent/src/config/settings.py index 573f76d..89d741f 100644 --- a/apps/miroflow-agent/src/config/settings.py +++ b/apps/miroflow-agent/src/config/settings.py @@ -21,6 +21,17 @@ # Load environment variables from .env file load_dotenv() + +def _mcp_child_env(overrides: dict) -> dict: + """MCP stdio 子进程使用完整 env 覆盖,显式键优先,其余继承父进程。 + + 子进程里读到的 SCRAPE_TIMEOUT_SECONDS / SEARXNG_PRECHECK_* 等调优变量若不在 + 这里继承,用户在 .env 中的设置会静默失效。 + """ + child_env = dict(os.environ) + child_env.update({k: v for k, v in overrides.items() if v is not None}) + return child_env + # API for Google Search SERPER_API_KEY = os.environ.get("SERPER_API_KEY", "") SERPER_BASE_URL = os.environ.get("SERPER_BASE_URL", "https://google.serper.dev") @@ -107,12 +118,12 @@ def create_mcp_server_parameters(cfg: DictConfig, agent_cfg: DictConfig): "-m", "miroflow_tools.mcp_servers.searching_google_mcp_server", ], - env={ + env=_mcp_child_env({ "SERPER_API_KEY": SERPER_API_KEY, "SERPER_BASE_URL": SERPER_BASE_URL, "JINA_API_KEY": JINA_API_KEY, "JINA_BASE_URL": JINA_BASE_URL, - }, + }), ), } ) @@ -130,12 +141,12 @@ def create_mcp_server_parameters(cfg: DictConfig, agent_cfg: DictConfig): "-m", "miroflow_tools.mcp_servers.searching_sogou_mcp_server", ], - env={ + env=_mcp_child_env({ "TENCENTCLOUD_SECRET_ID": TENCENTCLOUD_SECRET_ID, "TENCENTCLOUD_SECRET_KEY": TENCENTCLOUD_SECRET_KEY, "JINA_API_KEY": JINA_API_KEY, "JINA_BASE_URL": JINA_BASE_URL, - }, + }), ), } ) @@ -147,7 +158,7 @@ def create_mcp_server_parameters(cfg: DictConfig, agent_cfg: DictConfig): "params": StdioServerParameters( command=sys.executable, args=["-m", "miroflow_tools.mcp_servers.python_mcp_server"], - env={"E2B_API_KEY": E2B_API_KEY}, + env=_mcp_child_env({"E2B_API_KEY": E2B_API_KEY}), ), } ) @@ -159,10 +170,10 @@ def create_mcp_server_parameters(cfg: DictConfig, agent_cfg: DictConfig): "params": StdioServerParameters( command=sys.executable, args=["-m", "miroflow_tools.mcp_servers.vision_mcp_server"], - env={ + env=_mcp_child_env({ "OPENAI_API_KEY": OPENAI_API_KEY, "OPENAI_BASE_URL": OPENAI_BASE_URL, - }, + }), ), } ) @@ -174,11 +185,11 @@ def create_mcp_server_parameters(cfg: DictConfig, agent_cfg: DictConfig): "params": StdioServerParameters( command=sys.executable, args=["-m", "miroflow_tools.mcp_servers.vision_mcp_server_os"], - env={ + env=_mcp_child_env({ "VISION_API_KEY": VISION_API_KEY, "VISION_BASE_URL": VISION_BASE_URL, "VISION_MODEL_NAME": VISION_MODEL_NAME, - }, + }), ), } ) @@ -193,10 +204,10 @@ def create_mcp_server_parameters(cfg: DictConfig, agent_cfg: DictConfig): "params": StdioServerParameters( command=sys.executable, args=["-m", "miroflow_tools.mcp_servers.audio_mcp_server"], - env={ + env=_mcp_child_env({ "OPENAI_API_KEY": OPENAI_API_KEY, "OPENAI_BASE_URL": OPENAI_BASE_URL, - }, + }), ), } ) @@ -211,11 +222,11 @@ def create_mcp_server_parameters(cfg: DictConfig, agent_cfg: DictConfig): "params": StdioServerParameters( command=sys.executable, args=["-m", "miroflow_tools.mcp_servers.audio_mcp_server_os"], - env={ + env=_mcp_child_env({ "WHISPER_BASE_URL": WHISPER_BASE_URL, "WHISPER_API_KEY": WHISPER_API_KEY, "WHISPER_MODEL_NAME": WHISPER_MODEL_NAME, - }, + }), ), } ) @@ -233,10 +244,10 @@ def create_mcp_server_parameters(cfg: DictConfig, agent_cfg: DictConfig): "-m", "miroflow_tools.mcp_servers.reasoning_mcp_server", ], - env={ + env=_mcp_child_env({ "ANTHROPIC_API_KEY": ANTHROPIC_API_KEY, "ANTHROPIC_BASE_URL": ANTHROPIC_BASE_URL, - }, + }), ), } ) @@ -254,11 +265,11 @@ def create_mcp_server_parameters(cfg: DictConfig, agent_cfg: DictConfig): "-m", "miroflow_tools.mcp_servers.reasoning_mcp_server_os", ], - env={ + env=_mcp_child_env({ "REASONING_API_KEY": REASONING_API_KEY, "REASONING_BASE_URL": REASONING_BASE_URL, "REASONING_MODEL_NAME": REASONING_MODEL_NAME, - }, + }), ), } ) @@ -349,6 +360,16 @@ def create_mcp_server_parameters(cfg: DictConfig, agent_cfg: DictConfig): "SEARCH_CONFIDENCE_HIGH_CONF_DOMAINS", "reuters.com,apnews.com,bbc.com,aljazeera.com,state.gov,un.org,iaea.org,who.int", ) + dynamic_search_provider_order_strict = os.environ.get( + "SEARCH_PROVIDER_ORDER_STRICT", "0" + ) + dynamic_search_searxng_only_allow_downgrade = os.environ.get( + "SEARCH_SEARXNG_ONLY_ALLOW_DOWNGRADE", "0" + ) + dynamic_search_searxng_only_downgrade_order = os.environ.get( + "SEARCH_SEARXNG_ONLY_DOWNGRADE_ORDER", + "serpapi,tavily,serper", + ) dynamic_scrape_proxy_fake_ip_cidrs = os.environ.get( "SCRAPE_PROXY_FAKE_IP_CIDRS", "" ) @@ -361,7 +382,7 @@ def create_mcp_server_parameters(cfg: DictConfig, agent_cfg: DictConfig): "-m", "miroflow_tools.dev_mcp_servers.search_and_scrape_webpage", ], - env={ + env=_mcp_child_env({ "SERPER_API_KEY": dynamic_serper_api_key or "", "SERPER_API_KEYS": dynamic_serper_api_keys or "", "SERPER_BASE_URL": dynamic_serper_base_url or "", @@ -373,6 +394,15 @@ def create_mcp_server_parameters(cfg: DictConfig, agent_cfg: DictConfig): "SEARXNG_BASE_URL": dynamic_searxng_base_url or "", "SEARCH_PROVIDER_ORDER": dynamic_search_provider_order, "SEARCH_PROVIDER_MODE": dynamic_search_provider_mode, + "SEARCH_PROVIDER_ORDER_STRICT": ( + dynamic_search_provider_order_strict + ), + "SEARCH_SEARXNG_ONLY_ALLOW_DOWNGRADE": ( + dynamic_search_searxng_only_allow_downgrade + ), + "SEARCH_SEARXNG_ONLY_DOWNGRADE_ORDER": ( + dynamic_search_searxng_only_downgrade_order + ), "SEARCH_PROVIDER_TRUSTED_ORDER": dynamic_search_provider_trusted_order, "SEARCH_PROVIDER_PARALLEL_MAX_WAIT_MS": dynamic_search_provider_parallel_max_wait_ms, "SEARCH_PROVIDER_PARALLEL_MIN_SUCCESS": dynamic_search_provider_parallel_min_success, @@ -388,7 +418,7 @@ def create_mcp_server_parameters(cfg: DictConfig, agent_cfg: DictConfig): "SCRAPE_PROXY_FAKE_IP_CIDRS": dynamic_scrape_proxy_fake_ip_cidrs, "TENCENTCLOUD_SECRET_ID": TENCENTCLOUD_SECRET_ID, "TENCENTCLOUD_SECRET_KEY": TENCENTCLOUD_SECRET_KEY, - }, + }), ), } ) @@ -406,13 +436,13 @@ def create_mcp_server_parameters(cfg: DictConfig, agent_cfg: DictConfig): "-m", "miroflow_tools.dev_mcp_servers.jina_scrape_llm_summary", ], - env={ + env=_mcp_child_env({ "JINA_API_KEY": JINA_API_KEY, "JINA_BASE_URL": JINA_BASE_URL, "SUMMARY_LLM_BASE_URL": SUMMARY_LLM_BASE_URL, "SUMMARY_LLM_MODEL_NAME": SUMMARY_LLM_MODEL_NAME, "SUMMARY_LLM_API_KEY": SUMMARY_LLM_API_KEY, - }, + }), ), } ) @@ -430,7 +460,7 @@ def create_mcp_server_parameters(cfg: DictConfig, agent_cfg: DictConfig): "-m", "miroflow_tools.dev_mcp_servers.stateless_python_server", ], - env={"E2B_API_KEY": E2B_API_KEY}, + env=_mcp_child_env({"E2B_API_KEY": E2B_API_KEY}), ), } ) @@ -454,7 +484,7 @@ def create_mcp_server_parameters(cfg: DictConfig, agent_cfg: DictConfig): "-m", "miroflow_tools.dev_mcp_servers.task_planner", ], - env={"TASK_ID": todo_task_id}, + env=_mcp_child_env({"TASK_ID": todo_task_id}), ), } ) diff --git a/apps/miroflow-agent/src/core/deep_efficiency.py b/apps/miroflow-agent/src/core/deep_efficiency.py new file mode 100644 index 0000000..24cec53 --- /dev/null +++ b/apps/miroflow-agent/src/core/deep_efficiency.py @@ -0,0 +1,226 @@ +# Copyright (c) 2025 MiroMind +# This source code is licensed under the Apache 2.0 License. + +"""Deep-research efficiency knobs (Round 6–8). + +Pragmatic caps that preserve cross-verification quality while cutting +wasteful clue-chasing, full-page scrapes, and LLM timeout/summary burn. +See docs/DEEP_EFFICIENCY.md. +""" + +from __future__ import annotations + +from typing import Any, Optional, Tuple + + +# Defaults (overridable via agent.* / env / Hydra) +DEFAULT_DEEP_MAX_LEAD_FOLLOW_UPS = 2 +DEFAULT_DEEP_MAX_SCRAPE_PER_TASK = 8 +DEFAULT_STANDARD_MAX_SCRAPE_PER_TASK = 12 +DEFAULT_LIGHT_MAX_SCRAPE_PER_TASK = 4 +DEFAULT_DEEP_EARLY_STOP_MIN_AGREEING_SOURCES = 2 +DEFAULT_PARALLEL_TOOL_CALLS = True +# Round 8: nudge once then force summary (was 2 in Round 7) +DEFAULT_DEEP_POST_EARLY_STOP_TURNS = 1 +DEFAULT_DEEP_EXIT_ON_EARLY_STOP = True +# Round 7: single structured summary (+ local Evidence repair); one repair LLM pass max +DEFAULT_DEEP_MAX_FINAL_ANSWER_RETRIES = 1 +DEFAULT_STANDARD_MAX_FINAL_ANSWER_RETRIES = 3 +# Round 8: one-shot skeleton final report + capped summary context +# deep 与 standard 都走骨架;light 档保留自由发挥(无详细报告需求) +DEFAULT_ONESHOT_FINAL_REPORT_INTENSITIES = ("deep", "standard") +DEFAULT_DEEP_SUMMARY_KEEP_TOOL_RESULT = 2 +DEFAULT_DEEP_SUMMARY_MAX_TOKENS = 4096 +DEFAULT_STANDARD_SUMMARY_KEEP_TOOL_RESULT = -1 # inherit agent.keep_tool_result + + +def _get(container: Any, key: str, default: Any = None) -> Any: + if container is None: + return default + if hasattr(container, "get"): + return container.get(key, default) + return getattr(container, key, default) + + +def resolve_agent_value(cfg: Any, key: str, default: Any = None) -> Any: + """Look up ``agent.``, falling back to ``agent.main_agent.``. + + Accepts flags on either ``agent.*`` (API profile_resolver path) or + ``agent.main_agent.*`` (CLI harness path). + """ + agent = getattr(cfg, "agent", cfg) + value = _get(agent, key, None) + if value is None: + main = _get(agent, "main_agent", None) + value = _get(main, key, None) + return default if value is None else value + + +def _as_bool(value: Any, default: bool = False) -> bool: + if value is None: + return default + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return bool(value) + return str(value).strip().lower() in {"1", "true", "yes", "on"} + + +def _as_int(value: Any, default: int) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default + + +def resolve_research_intensity(cfg: Any) -> str: + intensity = resolve_agent_value(cfg, "research_intensity") + return str(intensity or "standard").strip().lower() + + +def resolve_max_scrape_per_task(cfg: Any) -> int: + """Hard cap on full-page scrapes per task (0 = unlimited).""" + explicit = resolve_agent_value(cfg, "max_scrape_per_task") + if explicit is not None: + return max(0, _as_int(explicit, 0)) + + intensity = resolve_research_intensity(cfg) + if intensity == "deep": + return DEFAULT_DEEP_MAX_SCRAPE_PER_TASK + if intensity == "light": + return DEFAULT_LIGHT_MAX_SCRAPE_PER_TASK + return DEFAULT_STANDARD_MAX_SCRAPE_PER_TASK + + +def resolve_parallel_tool_calls(cfg: Any) -> bool: + explicit = resolve_agent_value(cfg, "parallel_tool_calls") + return _as_bool(explicit, DEFAULT_PARALLEL_TOOL_CALLS) + + +def resolve_early_stop_config(cfg: Any) -> Tuple[bool, int]: + """Return (enabled, min_agreeing_independent_sources).""" + intensity = resolve_research_intensity(cfg) + + explicit = resolve_agent_value(cfg, "deep_early_stop_on_agreement") + # Default ON for deep; OFF otherwise unless explicitly enabled + if explicit is None: + enabled = intensity == "deep" + else: + enabled = _as_bool(explicit, intensity == "deep") + + min_agree = resolve_agent_value(cfg, "deep_early_stop_min_sources") + min_agree_int = max( + 2, + _as_int(min_agree, DEFAULT_DEEP_EARLY_STOP_MIN_AGREEING_SOURCES), + ) + return enabled, min_agree_int + + +def default_max_lead_follow_ups_for_intensity(intensity: str) -> int: + """Clue Top-K default: deep follows only top 1–2 leads.""" + if str(intensity or "").strip().lower() == "deep": + return DEFAULT_DEEP_MAX_LEAD_FOLLOW_UPS + return 3 + + +def scrape_budget_exceeded(scrape_count: int, max_scrape: int) -> bool: + """True when a hard scrape cap is set and already reached.""" + if max_scrape <= 0: + return False + return scrape_count >= max_scrape + + +def scrape_skip_message(max_scrape: int, scrape_count: int) -> str: + return ( + f"[scrape_budget] Full-page scrape skipped " + f"({scrape_count}/{max_scrape} used). " + "Prefer search snippets / titles already retrieved and continue with " + "snippet-level evidence; the budget cannot be reset mid-task, so only " + "conflict-critical URLs justify a scrape while budget remains." + ) + + +def resolve_exit_on_early_stop(cfg: Any) -> Tuple[bool, int]: + """Return (exit_enabled, post_early_stop_turns). + + Round 7: after multi-source agreement early-stop fires, cap remaining + LLM turns tightly so deep runs do not burn the full max_turns budget. + """ + intensity = resolve_research_intensity(cfg) + + explicit = resolve_agent_value(cfg, "deep_exit_on_early_stop") + if explicit is None: + enabled = intensity == "deep" and DEFAULT_DEEP_EXIT_ON_EARLY_STOP + else: + enabled = _as_bool(explicit, intensity == "deep") + + post = resolve_agent_value(cfg, "deep_post_early_stop_turns") + post_turns = max( + 0, + _as_int(post, DEFAULT_DEEP_POST_EARLY_STOP_TURNS), + ) + return enabled, post_turns + + +def resolve_max_final_answer_retries(cfg: Any) -> int: + """Prefer a single summary pass for deep intensity (Round 7).""" + explicit = resolve_agent_value(cfg, "max_final_answer_retries") + if explicit is not None: + return max(1, _as_int(explicit, DEFAULT_STANDARD_MAX_FINAL_ANSWER_RETRIES)) + + intensity = resolve_research_intensity(cfg) + if intensity == "deep": + return DEFAULT_DEEP_MAX_FINAL_ANSWER_RETRIES + return DEFAULT_STANDARD_MAX_FINAL_ANSWER_RETRIES + + +def resolve_oneshot_final_report(cfg: Any) -> bool: + """Round 8: fill a structured skeleton once; repair locally, no rewrite loops.""" + intensity = resolve_research_intensity(cfg) + default = intensity in DEFAULT_ONESHOT_FINAL_REPORT_INTENSITIES + + explicit = resolve_agent_value(cfg, "oneshot_final_report") + if explicit is None: + return default + return _as_bool(explicit, default) + + +def resolve_summary_keep_tool_result( + cfg: Any, *, research_keep: Optional[int] = None +) -> int: + """Tool dumps retained for final_summary only (Round 8). + + Research turns still use ``agent.keep_tool_result``. Final summary keeps + only the last N full dumps so the model does not re-digest omitted stubs + or the entire scrape history. + """ + intensity = resolve_research_intensity(cfg) + + explicit = resolve_agent_value(cfg, "summary_keep_tool_result") + if explicit is not None: + return max(-1, _as_int(explicit, DEFAULT_DEEP_SUMMARY_KEEP_TOOL_RESULT)) + + if intensity == "deep" and resolve_oneshot_final_report(cfg): + return DEFAULT_DEEP_SUMMARY_KEEP_TOOL_RESULT + + if research_keep is not None: + return max( + -1, _as_int(research_keep, DEFAULT_STANDARD_SUMMARY_KEEP_TOOL_RESULT) + ) + keep = resolve_agent_value(cfg, "keep_tool_result") + if keep is None: + return DEFAULT_STANDARD_SUMMARY_KEEP_TOOL_RESULT + return max(-1, _as_int(keep, DEFAULT_STANDARD_SUMMARY_KEEP_TOOL_RESULT)) + + +def resolve_summary_max_tokens_cap(cfg: Any) -> Optional[int]: + """Optional hard cap on summary_max_tokens for deep oneshot (Round 8).""" + intensity = resolve_research_intensity(cfg) + + explicit = resolve_agent_value(cfg, "summary_max_tokens_cap") + if explicit is not None: + return max(1024, _as_int(explicit, DEFAULT_DEEP_SUMMARY_MAX_TOKENS)) + + if intensity == "deep" and resolve_oneshot_final_report(cfg): + return DEFAULT_DEEP_SUMMARY_MAX_TOKENS + return None diff --git a/apps/miroflow-agent/src/core/lead_tracker.py b/apps/miroflow-agent/src/core/lead_tracker.py new file mode 100644 index 0000000..c8a9b48 --- /dev/null +++ b/apps/miroflow-agent/src/core/lead_tracker.py @@ -0,0 +1,420 @@ +# Copyright (c) 2025 MiroMind +# This source code is licensed under the Apache 2.0 License. + +"""Lead tracking for deep research chained clue following.""" + +import logging +import re +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional, Set, Tuple + +from .deep_efficiency import ( + default_max_lead_follow_ups_for_intensity, + resolve_agent_value, +) + +logger = logging.getLogger(__name__) + + +def resolve_lead_tracking_config(cfg: Any) -> Tuple[bool, int]: + """Resolve enable flag and max follow-ups from Hydra agent config. + + Accepts flags / intensity on either ``agent.*`` (API profile_resolver path) + or ``agent.main_agent.*`` (CLI harness path used in Round 2). + + Auto-enables when ``research_intensity == "deep"`` unless explicitly disabled. + """ + explicit = resolve_agent_value(cfg, "enable_lead_tracking") + intensity_norm = ( + str(resolve_agent_value(cfg, "research_intensity") or "").strip().lower() + ) + + # Round 6: deep defaults to Top-K=2 clue follow-ups (was 3). + intensity_default_k = default_max_lead_follow_ups_for_intensity(intensity_norm) + + max_follow_ups = resolve_agent_value(cfg, "max_lead_follow_ups") + if max_follow_ups is None: + max_follow_ups = intensity_default_k + try: + max_follow_ups_int = ( + int(max_follow_ups) if max_follow_ups is not None else intensity_default_k + ) + except (TypeError, ValueError): + max_follow_ups_int = intensity_default_k + + if explicit is not None: + # OmegaConf may yield string "true"/"false" + if isinstance(explicit, str): + enabled = explicit.strip().lower() in {"1", "true", "yes", "on"} + else: + enabled = bool(explicit) + else: + enabled = intensity_norm == "deep" + + return enabled, max(1, max_follow_ups_int) + + +@dataclass +class Lead: + """Represents a research lead or open question.""" + + question: str + source: str + turn: int + priority: float = 1.0 + followed_up: bool = False + follow_up_turn: Optional[int] = None + findings: str = "" + + +@dataclass +class LeadTrail: + """Tracks the complete trail of leads and follow-ups.""" + + original_query: str + leads: List[Lead] = field(default_factory=list) + follow_up_count: int = 0 + max_follow_ups: int = 3 + min_priority: float = 0.5 + + def add_lead( + self, question: str, source: str, turn: int, priority: float = 1.0 + ) -> None: + """Add a new lead to track.""" + # Deduplicate similar leads + normalized_q = self._normalize_question(question) + for existing in self.leads: + if self._normalize_question(existing.question) == normalized_q: + # Update priority if higher + if priority > existing.priority: + existing.priority = priority + return + + lead = Lead( + question=question, + source=source, + turn=turn, + priority=priority, + ) + self.leads.append(lead) + logger.debug("Added lead from turn %d: %s", turn, question[:80]) + + def get_top_unfollowed_leads(self, k: int = 3) -> List[Lead]: + """Get top K unfollowed leads by priority.""" + unfollowed = [ + lead + for lead in self.leads + if not lead.followed_up and lead.priority >= self.min_priority + ] + unfollowed.sort(key=lambda x: x.priority, reverse=True) + return unfollowed[:k] + + def mark_followed_up(self, lead: Lead, turn: int, findings: str = "") -> None: + """Mark a lead as followed up.""" + lead.followed_up = True + lead.follow_up_turn = turn + lead.findings = findings + self.follow_up_count += 1 + logger.info( + "Marked lead as followed up at turn %d: %s", turn, lead.question[:80] + ) + + def should_continue_following(self) -> bool: + """Check if we should continue following leads.""" + if self.follow_up_count >= self.max_follow_ups: + return False + top_leads = self.get_top_unfollowed_leads(k=1) + return len(top_leads) > 0 + + def format_trail_section(self) -> str: + """Format the lead trail for inclusion in the final report. + + Followed leads keep full detail. Pending seeds are collapsed into a short + Chinese summary so the report does not look truncated/unfinished. + """ + if not self.leads: + return "" + + followed_leads = [lead for lead in self.leads if lead.followed_up] + pending_leads = [lead for lead in self.leads if not lead.followed_up] + + lines = ["## 线索追踪 / Lead Trail\n"] + if followed_leads: + lines.append("以下是研究过程中已跟进的关键线索及其发现:\n") + for i, lead in enumerate(followed_leads, 1): + lines.append(f"\n### Lead {i}: {lead.question}") + lines.append(f"**来源**: {lead.source} (Turn {lead.turn})") + lines.append(f"**优先级**: {lead.priority:.2f}") + lines.append("**状态**: followed") + if lead.follow_up_turn: + lines.append(f"**追踪轮次**: Turn {lead.follow_up_turn}") + if lead.findings: + lines.append(f"\n**发现**:\n{lead.findings}") + lines.append("") + if pending_leads: + lines.append("### 未跟进线索(摘要)\n") + for i, lead in enumerate(pending_leads, 1): + q = lead.question.strip() + if len(q) > 80: + q = q[:77] + "…" + lines.append(f"{i}. {q} — **未跟进**") + lines.append("") + else: + lines.append( + "本轮未实际跟进额外线索(种子线索仍为 pending,不视为报告未写完)。\n" + ) + if pending_leads: + lines.append("### 未跟进线索(摘要)\n") + for i, lead in enumerate(pending_leads, 1): + q = lead.question.strip() + if len(q) > 80: + q = q[:77] + "…" + lines.append(f"{i}. {q} — **未跟进**") + lines.append("") + + return "\n".join(lines) + + @staticmethod + def _normalize_question(question: str) -> str: + """Normalize question for deduplication.""" + normalized = question.lower().strip() + normalized = re.sub(r"[^\w\s]", "", normalized) + normalized = re.sub(r"\s+", " ", normalized) + return normalized + + @staticmethod + def extract_leads_from_text( + text: str, turn: int, source: str = "assistant" + ) -> List[Tuple[str, float]]: + """Extract potential leads from assistant response text. + + Returns list of (question, priority) tuples. + """ + leads: List[Tuple[str, float]] = [] + if not text or not text.strip(): + return leads + + # Pattern 1: Explicit investigation / follow-up markers (EN + ZH) + explicit_patterns = [ + r"(?i)(?:需要|应该|可以)(?:进一步)?(?:调查|研究|了解|查询)[::]?\s*(.+?)(?:\.|。|$)", + r"(?i)(?:need(?:s)?\s+to\s+(?:investigate|research|verify|check|explore)|" + r"further\s+(?:research|investigation|study)\s+needed|" + r"worth\s+(?:investigating|exploring)|" + r"should\s+(?:investigate|research|verify)|" + r"follow[- ]?up[:\s]+|" + r"investigate)[::]?\s*(.+?)(?:\.|$)", + r"(?i)(?:open question|remaining question|unanswered)[::]?\s*(.+?)(?:\.|$)", + ] + + for pattern in explicit_patterns: + matches = re.finditer(pattern, text, re.MULTILINE) + for match in matches: + question = match.group(1).strip() + if 10 < len(question) < 200: + leads.append((question, 1.0)) + + # Pattern 2: Standalone questions (EN/ZH interrogatives) + question_pattern = r"(?:^|\n)([^。\.\n]{10,200}[??])" + matches = re.finditer(question_pattern, text) + interrogatives = ( + "如何", + "怎么", + "为什么", + "什么", + "哪些", + "是否", + "how", + "why", + "what", + "when", + "where", + "which", + "who", + "whom", + "could", + "would", + "should", + "is there", + "are there", + "does", + "did", + ) + for match in matches: + question = match.group(1).strip() + q_lower = question.lower() + if not any(word in q_lower for word in interrogatives): + continue + if len(question) > 10: + leads.append((question, 0.75)) + + # Pattern 3: Uncertainty / gap markers + uncertainty_patterns = [ + r"(?i)(?:不确定|不清楚|需要确认|尚不明确)[::]?\s*(.+?)(?:\.|。|$)", + r"(?i)(?:uncertain(?:ty)?|unclear|unknown|needs?\s+verification|" + r"remains?\s+(?:unclear|unknown|unresolved)|" + r"not\s+(?:yet\s+)?(?:clear|known|established)|" + r"gap\s+in)[::\s]+(.+?)(?:\.|$)", + r"(?i)(?:however|but|尽管|但是)[,,]\s*(.+?(?:存疑|unclear|uncertain|unknown).+?)(?:\.|。|$)", + ] + + for pattern in uncertainty_patterns: + matches = re.finditer(pattern, text, re.MULTILINE) + for match in matches: + question = match.group(1).strip() + if 10 < len(question) < 200: + leads.append((question, 0.8)) + + # Pattern 4: Causal / chronological chain hints (common in "how did X lead to Y") + chain_patterns = [ + r"(?i)(?:this\s+(?:led|leads)\s+to|which\s+(?:led|enabled)|" + r"key\s+(?:step|link|bridge)|missing\s+link)[::]?\s*(.+?)(?:\.|$)", + r"(?i)(?:关键环节|关键步骤|缺失环节)[::]?\s*(.+?)(?:\.|。|$)", + ] + for pattern in chain_patterns: + for match in re.finditer(pattern, text, re.MULTILINE): + question = match.group(1).strip() + if 10 < len(question) < 200: + leads.append((f"Investigate intermediate step: {question}", 0.7)) + + # Deduplicate + seen: Set[str] = set() + unique_leads: List[Tuple[str, float]] = [] + for question, priority in leads: + normalized = LeadTrail._normalize_question(question) + if normalized not in seen and len(normalized) > 5: + seen.add(normalized) + unique_leads.append((question, priority)) + + return unique_leads + + @staticmethod + def seed_leads_from_query(query: str) -> List[Tuple[str, float]]: + """Seed deep-research leads from the original user query. + + Helps English flash models that answer narratively without explicit + open-question markers (Case C style chained research). + """ + if not query or not query.strip(): + return [] + + q = query.strip() + seeds: List[Tuple[str, float]] = [] + + # "How did X lead to Y?" / "How did X lead to Y" + m = re.search( + r"(?i)how\s+did\s+(.+?)\s+lead\s+to\s+(.+?)(?:\?|$)", + q, + ) + if m: + start = m.group(1).strip().rstrip("?.") + end = m.group(2).strip().rstrip("?.") + seeds.append( + ( + f"What intermediate scientific discoveries connected {start} to {end}?", + 0.95, + ) + ) + seeds.append( + ( + f"Which specific mechanisms or tools from {start} enabled {end}?", + 0.9, + ) + ) + return seeds + + # Generic deep seed: force at least one follow-up angle + if len(q) > 20: + seeds.append( + ( + f"What are the most important unresolved or contested aspects of: {q[:160]}?", + 0.65, + ) + ) + return seeds + + +class LeadTrackingManager: + """Manages lead tracking for the orchestrator.""" + + def __init__(self, enabled: bool = False, max_follow_ups: int = 3): + self.enabled = enabled + self.trail: Optional[LeadTrail] = None + self.max_follow_ups = max_follow_ups + self._seeded_from_query = False + + def initialize(self, query: str) -> None: + """Initialize lead tracking for a new research task.""" + if not self.enabled: + return + self.trail = LeadTrail( + original_query=query, + max_follow_ups=self.max_follow_ups, + ) + self._seeded_from_query = False + # Seed deep chained-query leads so follow-ups can run even when the + # model answers narratively without explicit "investigate:" markers. + for question, priority in LeadTrail.seed_leads_from_query(query): + self.trail.add_lead(question, "query_seed", turn=0, priority=priority) + self._seeded_from_query = True + logger.info( + "Initialized lead tracking for query: %s (seeded=%s, leads=%d)", + query[:80], + self._seeded_from_query, + len(self.trail.leads), + ) + + def process_turn_response(self, assistant_text: str, turn: int) -> List[str]: + """Process assistant response and extract leads. + + Returns list of lead questions that should be followed up. + """ + if not self.enabled or not self.trail: + return [] + + extracted_leads = LeadTrail.extract_leads_from_text(assistant_text, turn) + for question, priority in extracted_leads: + self.trail.add_lead(question, "assistant_response", turn, priority) + + # Check if we should follow up + if not self.trail.should_continue_following(): + return [] + + # Get top leads to follow + top_leads = self.trail.get_top_unfollowed_leads(k=1) + return [lead.question for lead in top_leads] + + def record_follow_up( + self, lead_question: str, turn: int, findings: str = "" + ) -> None: + """Record that a lead was followed up.""" + if not self.enabled or not self.trail: + return + + # Find the lead + for lead in self.trail.leads: + normalized_q = LeadTrail._normalize_question(lead.question) + normalized_search = LeadTrail._normalize_question(lead_question) + if normalized_q == normalized_search or normalized_search in normalized_q: + self.trail.mark_followed_up(lead, turn, findings) + break + + def get_trail_section(self) -> str: + """Get the formatted trail section for the final report.""" + if not self.enabled or not self.trail: + return "" + return self.trail.format_trail_section() + + def get_stats(self) -> Dict[str, Any]: + """Get tracking statistics.""" + if not self.enabled or not self.trail: + return {"enabled": False} + + return { + "enabled": True, + "total_leads": len(self.trail.leads), + "followed_up": self.trail.follow_up_count, + "unfollowed": len( + [lead for lead in self.trail.leads if not lead.followed_up] + ), + "seeded_from_query": self._seeded_from_query, + } diff --git a/apps/miroflow-agent/src/core/orchestrator.py b/apps/miroflow-agent/src/core/orchestrator.py index 7344ddb..f44d41f 100644 --- a/apps/miroflow-agent/src/core/orchestrator.py +++ b/apps/miroflow-agent/src/core/orchestrator.py @@ -25,6 +25,7 @@ from ..config.settings import expose_sub_agents_as_tools from ..io.input_handler import process_input from ..io.output_formatter import OutputFormatter +from ..io.report_presentation import prepare_user_facing_report from ..llm.base_client import BaseClient from ..logging.task_logger import TaskLog, get_utc_plus_8_time from ..utils.parsing_utils import extract_llm_response_text @@ -35,6 +36,15 @@ refusal_keywords, ) from .answer_generator import AnswerGenerator +from .deep_efficiency import ( + resolve_early_stop_config, + resolve_exit_on_early_stop, + resolve_max_scrape_per_task, + resolve_parallel_tool_calls, + scrape_budget_exceeded, + scrape_skip_message, +) +from .lead_tracker import LeadTrackingManager, resolve_lead_tracking_config from .stream_handler import StreamHandler from .tool_executor import ToolExecutor @@ -75,6 +85,19 @@ "worldbank.org", ] SEARCH_TOOL_NAMES = {"google_search", "sogou_search"} +# Scraping tools that fetch and parse web page content +SCRAPE_TOOL_NAMES = { + "jina_reader", + "firecrawl", + "fetch_page", + "scrape_webpage", + "search_and_scrape_webpage", + "jina_scrape_llm_summary", + "browser_navigate", + "browser_screenshot", + "scrape_url", + "scrape_and_extract_info", +} def _list_tools(sub_agent_tool_managers: Dict[str, ToolManager]): @@ -248,6 +271,8 @@ def __init__( self.verification_search_rounds = 0 self.verification_guidance_attempts = 0 self.verification_high_conf_source_domains: set[str] = set() + # 工具侧 confidence 门控通过后,不再为补齐高置信来源追加检索指令 + self.retrieval_confidence_passed = False self.verification_guidance_anchor_search_rounds = 0 self.verification_guidance_anchor_high_conf_sources = 0 self.verification_stagnant_guidance_attempts = 0 @@ -261,6 +286,32 @@ def __init__( ), ) + # Lead tracking for deep research (Phase 4) + # Resolve from agent root OR main_agent (CLI harness), and auto-enable + # when research_intensity == deep (see resolve_lead_tracking_config). + enable_lead_tracking, max_follow_ups = resolve_lead_tracking_config(cfg) + self.lead_tracker = LeadTrackingManager( + enabled=enable_lead_tracking, + max_follow_ups=max_follow_ups, + ) + + # Round 6–7 deep efficiency knobs + self.max_scrape_per_task = resolve_max_scrape_per_task(cfg) + self.parallel_tool_calls = resolve_parallel_tool_calls(cfg) + ( + self.deep_early_stop_enabled, + self.deep_early_stop_min_sources, + ) = resolve_early_stop_config(cfg) + ( + self.deep_exit_on_early_stop, + self.deep_post_early_stop_turns, + ) = resolve_exit_on_early_stop(cfg) + # Unique domains seen in search results (for early-stop agreement) + self.independent_source_domains: set[str] = set() + self.deep_early_stop_triggered = False + self.deep_early_stop_turn = 0 + self._deep_convergence_nudge_sent = False + async def _emit_stage_heartbeat( self, phase: str, @@ -399,27 +450,345 @@ def _extract_search_links(self, tool_name: str, tool_result: dict) -> List[str]: links.append(link.strip()) return links + def _parse_search_tool_payload(self, tool_result: dict) -> dict: + """Parse google_search / sogou_search tool payload into a dict.""" + raw_result = ( + tool_result.get("result") if isinstance(tool_result, dict) else None + ) + if not raw_result: + return {} + if isinstance(raw_result, str): + try: + parsed = json.loads(raw_result) + except json.JSONDecodeError: + return {} + elif isinstance(raw_result, dict): + parsed = raw_result + else: + return {} + return parsed if isinstance(parsed, dict) else {} + + def _record_search_provider_metrics(self, parsed: dict) -> None: + """Record which providers were attempted / returned results.""" + metrics = self.task_log.run_metrics + params = parsed.get("searchParameters") or {} + if not isinstance(params, dict): + params = {} + + route_trace = parsed.get("route_trace") or params.get("route_trace") or [] + recorded = False + if isinstance(route_trace, list): + for entry in route_trace: + if not isinstance(entry, dict): + continue + provider = entry.get("provider") + if not provider: + continue + metrics.record_search_provider_hit(str(provider)) + recorded = True + + if recorded: + return + + provider = parsed.get("provider") or params.get("provider") + if provider and provider != "multi-route": + metrics.record_search_provider_hit(str(provider)) + return + + order = params.get("provider_order") or [] + if isinstance(order, list): + for name in order: + if name: + metrics.record_search_provider_hit(str(name)) + def _record_search_evidence(self, tool_name: str, tool_result: dict): + # Always count the tool invocation for route comparison (Case D). + self.task_log.run_metrics.record_search_attempt() + parsed = self._parse_search_tool_payload(tool_result) + if parsed: + self._record_search_provider_metrics(parsed) + self._record_retrieval_confidence(parsed) + links = self._extract_search_links(tool_name, tool_result) if not links: return # 无论是否启用验证门控,都递增全局检索轮次 self.task_log.run_metrics.search_rounds += 1 + for link in links: + domain = self._normalize_domain(link) + if domain: + self.independent_source_domains.add(domain) + if self.verification_enabled and self._is_high_conf_domain(domain): + self.verification_high_conf_source_domains.add(domain) + if not self.verification_enabled: return self.verification_search_rounds += 1 - for link in links: - domain = self._normalize_domain(link) - if self._is_high_conf_domain(domain): - self.verification_high_conf_source_domains.add(domain) + + def _record_retrieval_confidence(self, parsed: dict) -> None: + """记录工具侧 confidence 门控结果(通过了就不再凑高置信来源)。""" + if self.retrieval_confidence_passed: + return + confidence = parsed.get("confidence") + if not isinstance(confidence, dict): + params = parsed.get("searchParameters") + confidence = ( + params.get("confidence") if isinstance(params, dict) else None + ) + # enabled=false 表示用户不要置信度门控,此时不拿它当检索质量背书 + if not isinstance(confidence, dict): + return + if not (confidence.get("enabled") and confidence.get("passed")): + return + + self.retrieval_confidence_passed = True + self.task_log.run_metrics.retrieval_confidence_passed = True + self.task_log.log_step( + "info", + "Main Agent | Search Confidence", + "检索质量门控通过(工具侧 confidence passed=yes)," + "不再为补齐来源追加检索指令。", + metadata={ + "confidence_score": confidence.get("score"), + "confidence_metrics": confidence.get("metrics"), + }, + ) + + def _record_scrape_metric( + self, tool_name: str, tool_result: dict, turn_count: int + ) -> None: + """Count only real full-page scrapes (budget-skip results excluded).""" + if ( + tool_name not in SCRAPE_TOOL_NAMES + or "error" in tool_result + or "[scrape_budget]" in str(tool_result.get("result") or "") + ): + return + self.task_log.run_metrics.record_scrape(count=1) + self.task_log.log_step( + "debug", + f"Main Agent | Turn: {turn_count} | Metrics", + f"Recorded scrape for tool: {tool_name}", + ) + + def _should_early_stop_clue_chase(self) -> bool: + """Round 6: stop extra lead follow-ups once multi-source agreement exists. + + Triggers when ≥N independent source domains are present and minimum + search rounds are satisfied — enough to fill Conflicts without endless + clue chasing. + """ + if not self.deep_early_stop_enabled: + return False + agreeing = len(self.independent_source_domains) + if agreeing < self.deep_early_stop_min_sources: + return False + search_rounds = int(self.task_log.run_metrics.search_rounds or 0) + min_rounds = max(2, int(self.verification_min_search_rounds or 2)) + if search_rounds < min_rounds: + return False + return True + + def _note_deep_early_stop(self, turn_count: int, reason: str = "") -> None: + """Record first early-stop trigger for metrics + logs.""" + if self.deep_early_stop_triggered: + return + self.deep_early_stop_triggered = True + self.deep_early_stop_turn = max(0, int(turn_count)) + self.task_log.run_metrics.record_early_stop(self.deep_early_stop_turn) + self.task_log.log_step( + "info", + f"Main Agent | Turn: {turn_count} | Deep Early-Stop", + ( + "≥" + f"{self.deep_early_stop_min_sources} independent sources " + f"({len(self.independent_source_domains)} domains) and " + f"search_rounds={self.task_log.run_metrics.search_rounds}; " + f"stopping extra lead follow-ups" + + ( + f"; will exit after ≤{self.deep_post_early_stop_turns} more turns" + if self.deep_exit_on_early_stop + else "" + ) + + (f" ({reason})" if reason else "") + + "." + ), + metadata={ + "independent_domains": sorted(self.independent_source_domains)[:12], + "search_rounds": self.task_log.run_metrics.search_rounds, + "reason": reason, + "exit_on_early_stop": self.deep_exit_on_early_stop, + "post_early_stop_turns": self.deep_post_early_stop_turns, + }, + ) + + def _should_force_summary_after_early_stop(self, turn_count: int) -> bool: + """Round 7: cap remaining turns once early-stop evidence is enough.""" + if not self.deep_exit_on_early_stop: + return False + if not self._should_early_stop_clue_chase(): + return False + self._note_deep_early_stop(turn_count, reason="force-summary-check") + return turn_count >= ( + self.deep_early_stop_turn + self.deep_post_early_stop_turns + ) + + async def _maybe_nudge_and_force_summary( + self, + message_history: List[Dict[str, Any]], + turn_count: int, + ) -> bool: + """If early-stop turn budget is exhausted, nudge once then force exit. + + Returns True when the main loop should break into final summary. + """ + if not self._should_force_summary_after_early_stop(turn_count): + return False + if not self._deep_convergence_nudge_sent: + self._deep_convergence_nudge_sent = True + message_history.append( + { + "role": "user", + "content": ( + "已有足够独立来源与检索轮次,请停止继续检索/抓取," + "立即基于现有证据撰写完整研究报告。" + "必须包含 Conflicts、Timeline、Evidence、Confirmed vs Unconfirmed;" + "不确定处标明不可确认,勿编造确定性。" + ), + } + ) + self.task_log.log_step( + "info", + f"Main Agent | Turn: {turn_count} | Deep Convergence", + ( + f"Early-stop turn budget exhausted " + f"(triggered@turn={self.deep_early_stop_turn}, " + f"post_turns={self.deep_post_early_stop_turns}); " + "nudging model to write the report on the next turn." + ), + ) + # Allow one more LLM turn to consume the nudge. + return False + self.task_log.log_step( + "info", + f"Main Agent | Turn: {turn_count} | Deep Convergence Exit", + "Exiting main loop after early-stop turn cap to avoid LLM timeout burn.", + ) + return True + + async def _execute_regular_tool_call( + self, + server_name: str, + tool_name: str, + arguments: dict, + turn_count: int, + ) -> dict: + """Execute one non-subagent tool, honoring scrape budget.""" + if tool_name in SCRAPE_TOOL_NAMES and scrape_budget_exceeded( + self.task_log.run_metrics.scrape_count, self.max_scrape_per_task + ): + skip_msg = scrape_skip_message( + self.max_scrape_per_task, self.task_log.run_metrics.scrape_count + ) + self.task_log.log_step( + "info", + f"Main Agent | Turn: {turn_count} | Scrape Budget", + skip_msg, + ) + return { + "server_name": server_name, + "tool_name": tool_name, + "result": skip_msg, + } + return await self.main_agent_tool_manager.execute_tool_call( + server_name=server_name, + tool_name=tool_name, + arguments=arguments, + ) + + async def _parallel_execute_regular_main_tools( + self, + tool_calls: List[Dict[str, Any]], + turn_count: int, + ) -> List[Dict[str, Any]]: + """Run multiple regular (non-subagent) tool calls concurrently. + + Returns a list of dicts with keys: call, tool_result, duration_ms, + tool_call_id, error (optional). + """ + await self._emit_stage_heartbeat( + "并行工具", + turn=turn_count, + detail=f"并行执行 {len(tool_calls)} 个工具调用", + agent_name="main", + ) + self.task_log.log_step( + "info", + f"Main Agent | Turn: {turn_count} | Parallel Tools", + f"Executing {len(tool_calls)} tool calls in parallel", + ) + + prepared: List[Dict[str, Any]] = [] + for call in tool_calls: + arguments = self.tool_executor.fix_tool_call_arguments( + call["tool_name"], call["arguments"] + ) + tool_call_id = await self.stream.tool_call(call["tool_name"], arguments) + prepared.append( + { + "call": call, + "arguments": arguments, + "tool_call_id": tool_call_id, + } + ) + + async def _one(item: Dict[str, Any]) -> Dict[str, Any]: + call = item["call"] + start = time.time() + try: + tool_result = await self._execute_regular_tool_call( + call["server_name"], + call["tool_name"], + item["arguments"], + turn_count, + ) + duration_ms = int((time.time() - start) * 1000) + return { + "call": call, + "arguments": item["arguments"], + "tool_call_id": item["tool_call_id"], + "tool_result": tool_result, + "duration_ms": duration_ms, + "error": None, + } + except Exception as exc: # noqa: BLE001 — surface per-tool failure + duration_ms = int((time.time() - start) * 1000) + return { + "call": call, + "arguments": item["arguments"], + "tool_call_id": item["tool_call_id"], + "tool_result": { + "server_name": call["server_name"], + "tool_name": call["tool_name"], + "error": str(exc), + }, + "duration_ms": duration_ms, + "error": str(exc), + } + + return list(await asyncio.gather(*[_one(item) for item in prepared])) def _verification_requirements_met(self) -> bool: if not self.verification_enabled: return True + if self.verification_search_rounds < self.verification_min_search_rounds: + return False + # 工具侧 confidence 已达标:检索质量由 search 门控背书,不再追加检索 + if self.retrieval_confidence_passed: + return True return ( - self.verification_search_rounds >= self.verification_min_search_rounds - and len(self.verification_high_conf_source_domains) + len(self.verification_high_conf_source_domains) >= self.verification_min_high_conf_sources ) @@ -441,6 +810,55 @@ def _mark_verification_guidance_issued(self) -> None: self.verification_high_conf_source_domains ) + async def _inject_lead_followup( + self, + message_history: List[Dict[str, Any]], + turn_count: int, + reason: str = "", + ) -> bool: + """Inject a lead follow-up user message if tracking is active. + + Returns True when a follow-up was injected (caller should ``continue``). + """ + if not self.lead_tracker.enabled or not self.lead_tracker.trail: + return False + if not self.lead_tracker.trail.should_continue_following(): + return False + + # Round 6 early-stop: enough independent sources → skip extra clue chases + if self._should_early_stop_clue_chase(): + self._note_deep_early_stop(turn_count, reason=reason or "lead-followup") + return False + + top_leads = self.lead_tracker.trail.get_top_unfollowed_leads(k=1) + if not top_leads: + return False + + lead = top_leads[0] + lead_followup_prompt = ( + f"继续深入研究以下线索:\n\n{lead.question}\n\n" + f"请执行检索工具查找相关信息,并在发现后记录结果。" + f"优先使用搜索摘要;仅对冲突关键 URL 做全文抓取。" + f"若同一观点已有≥2个独立来源支撑且冲突点已可成文,可停止追线索并开始写报告。" + ) + message_history.append({"role": "user", "content": lead_followup_prompt}) + self.lead_tracker.trail.mark_followed_up( + lead, turn=turn_count, findings="正在追踪中..." + ) + self.task_log.run_metrics.record_follow_up_search() + self.task_log.log_step( + "info", + f"Main Agent | Turn: {turn_count} | Lead Follow-up", + f"追踪线索({reason}):{lead.question[:100]}", + ) + await self._emit_stage_heartbeat( + "线索追踪", + turn=turn_count, + detail="追踪研究线索", + agent_name="main", + ) + return True + def _should_issue_verification_guidance( self, turn_count: int, max_turns: int ) -> bool: @@ -519,11 +937,17 @@ def _build_verification_status_note(self) -> str: if self.verification_high_conf_source_domains else "无" ) + confidence_note = ( + "- 检索质量门控:已通过(工具侧 confidence passed=yes,不再追加检索)\n" + if self.retrieval_confidence_passed + else "" + ) return ( "交叉校验状态汇总:\n" f"- 检索轮次:{self.verification_search_rounds}/{self.verification_min_search_rounds}\n" f"- 高置信来源数:{len(self.verification_high_conf_source_domains)}/{self.verification_min_high_conf_sources}\n" f"- 已命中高置信域名:{domains}\n" + f"{confidence_note}" "请在最终答案中明确写出时间锚点、关键指标口径、对象边界与分组规则,并处理数字冲突。\n" "最终答案必须包含“关键数字速览”:至少 3 条“数字+时间+来源”;若不足,明确缺失项与已尝试检索口径。" ) @@ -1221,6 +1645,15 @@ async def run_main_agent( agent_name="main", ) + # Initialize lead tracking for this task + self.task_log.log_step( + "info", + "Orchestrator | Lead Tracking Config", + f"enabled={self.lead_tracker.enabled}, " + f"max_follow_ups={self.lead_tracker.max_follow_ups}", + ) + self.lead_tracker.initialize(task_description) + while turn_count < max_turns and total_attempts < max_attempts: turn_count += 1 total_attempts += 1 @@ -1274,6 +1707,18 @@ async def run_main_agent( "show_text", {"text": text_response} ) + # Extract leads from assistant response (Phase 4) + if self.lead_tracker.enabled: + lead_questions = self.lead_tracker.process_turn_response( + assistant_response_text, turn=turn_count + ) + if lead_questions: + self.task_log.log_step( + "info", + f"Main Agent | Turn: {turn_count} | Lead Tracking", + f"Extracted {len(lead_questions)} lead(s) for follow-up", + ) + # Extract boxed content if assistant_response_text: boxed_content = self.output_formatter._extract_boxed_content( @@ -1283,6 +1728,11 @@ async def run_main_agent( self.intermediate_boxed_answers.append(boxed_content) if should_break and tool_calls: + # Before ending, allow lead follow-up injection when enabled + if await self._inject_lead_followup( + message_history, turn_count, reason="pre-break-with-tools" + ): + continue self.task_log.log_step( "info", f"Main Agent | Turn: {turn_count} | LLM Call", @@ -1300,6 +1750,15 @@ async def run_main_agent( "max_consecutive_llm_failures": self.max_consecutive_llm_failures, }, ) + # Round 7: if early-stop nudge already fired, do not burn more + # identical timeout turns — exit to final summary immediately. + if self._deep_convergence_nudge_sent: + self.task_log.log_step( + "info", + f"Main Agent | Turn: {turn_count} | Deep Convergence Exit", + "LLM failed after convergence nudge; exiting to summary.", + ) + break if consecutive_llm_failures >= self.max_consecutive_llm_failures: # 尝试激活 failback 模型;成功则重置计数器继续,否则终止 if self.llm_client.activate_fallback(): @@ -1349,6 +1808,24 @@ async def run_main_agent( ) continue + # Check for lead-based follow-ups (Phase 4) + if await self._inject_lead_followup( + message_history, turn_count, reason="no-tool-calls" + ): + continue + + force_exit = await self._maybe_nudge_and_force_summary( + message_history, turn_count + ) + if force_exit: + break + if ( + self._deep_convergence_nudge_sent + and self._should_force_summary_after_early_stop(turn_count) + ): + # Nudge just appended; give the model one more turn. + continue + if should_break: self.task_log.log_step( "info", @@ -1394,196 +1871,271 @@ async def run_main_agent( should_rollback_turn = False main_agent_last_call_tokens = self.llm_client.last_call_tokens - for call in tool_calls: - server_name = call["server_name"] - tool_name = call["tool_name"] - arguments = call["arguments"] - call_id = call["id"] - await self._emit_stage_heartbeat( - "检索" if tool_name in SEARCH_TOOL_NAMES else "工具调用", - turn=turn_count, - detail=f"执行工具 {tool_name}", - agent_name="main", - tool_name=tool_name, + # Round 6: parallel multi-search / multi-scrape in one turn when safe + can_parallel = ( + self.parallel_tool_calls + and len(tool_calls) > 1 + and all( + not str(c.get("server_name", "")).startswith("agent-") + for c in tool_calls ) - - # Fix common parameter name mistakes - arguments = self.tool_executor.fix_tool_call_arguments( - tool_name, arguments + ) + if can_parallel: + parallel_results = await self._parallel_execute_regular_main_tools( + tool_calls, turn_count ) + for item in parallel_results: + call = item["call"] + tool_name = call["tool_name"] + arguments = item["arguments"] + tool_result = item["tool_result"] + call_id = call["id"] + cache_name = "main_" + tool_name - call_start_time = time.time() - try: - if server_name.startswith("agent-") and self.cfg.agent.sub_agents: - # Sub-agent execution - cache_name = "main_" + tool_name - ( - is_duplicate, - should_rollback, - turn_count, - consecutive_rollbacks, - message_history, - ) = await self._check_duplicate_query( - tool_name, - arguments, - cache_name, - consecutive_rollbacks, - turn_count, - total_attempts, - max_attempts, - message_history, - "Main Agent", - ) - if should_rollback: - should_rollback_turn = True - break - - # Stream events - await self.stream.end_llm("main") - await self.stream.end_agent("main", self.current_agent_id) - - # Execute sub-agent - sub_agent_result = await self.run_sub_agent( - server_name, - arguments["subtask"], - ) - - # Update query count + if "error" not in tool_result: await self._record_query(cache_name, tool_name, arguments) - tool_result = { - "server_name": server_name, - "tool_name": tool_name, - "result": sub_agent_result, - } - self.current_agent_id = await self.stream.start_agent( - "main", display_name="Summarizing" - ) - await self.stream.start_llm("main", display_name="Summarizing") - else: - # Regular tool execution - cache_name = "main_" + tool_name - ( - is_duplicate, - should_rollback, - turn_count, - consecutive_rollbacks, - message_history, - ) = await self._check_duplicate_query( - tool_name, - arguments, - cache_name, - consecutive_rollbacks, - turn_count, - total_attempts, - max_attempts, - message_history, - "Main Agent", - ) - if should_rollback: - should_rollback_turn = True - break - - # Send stream event - tool_call_id = await self.stream.tool_call(tool_name, arguments) - - # Execute tool call - tool_result = ( - await self.main_agent_tool_manager.execute_tool_call( - server_name=server_name, - tool_name=tool_name, - arguments=arguments, - ) - ) - - # Update query count if successful - if "error" not in tool_result: - await self._record_query(cache_name, tool_name, arguments) - - # Post-process result - tool_result = self.tool_executor.post_process_tool_call_result( - tool_name, tool_result - ) - self._record_search_evidence(tool_name, tool_result) - result = ( - tool_result.get("result") - if tool_result.get("result") - else tool_result.get("error") - ) - - # Check for errors that should trigger rollback - if self.tool_executor.should_rollback_result( - tool_name, result, tool_result - ): - if ( - consecutive_rollbacks - < self.MAX_CONSECUTIVE_ROLLBACKS - 1 - ): - message_history.pop() - turn_count -= 1 - consecutive_rollbacks += 1 - should_rollback_turn = True - self.task_log.log_step( - "warning", - f"Main Agent | Turn: {turn_count} | Rollback", - f"Tool result error - tool: {tool_name}, result: '{str(result)[:200]}'", - ) - break - - await self.stream.tool_call( - tool_name, {"result": result}, tool_call_id=tool_call_id - ) - - call_end_time = time.time() - call_duration_ms = int((call_end_time - call_start_time) * 1000) + tool_result = self.tool_executor.post_process_tool_call_result( + tool_name, tool_result + ) + self._record_search_evidence(tool_name, tool_result) + self._record_scrape_metric(tool_name, tool_result, turn_count) + result = ( + tool_result.get("result") + if tool_result.get("result") + else tool_result.get("error") + ) + await self.stream.tool_call( + tool_name, + {"result": result}, + tool_call_id=item["tool_call_id"], + ) tool_calls_data.append( { - "server_name": server_name, + "server_name": call["server_name"], "tool_name": tool_name, "arguments": arguments, "result": tool_result, - "duration_ms": call_duration_ms, + "duration_ms": item["duration_ms"], "call_time": get_utc_plus_8_time(), + "parallel": True, } ) self.task_log.log_step( "info", f"Main Agent | Turn: {turn_count} | Tool Call", - f"Tool {tool_name} completed in {call_duration_ms}ms", + f"Tool {tool_name} completed in {item['duration_ms']}ms (parallel)", + ) + tool_result_for_llm = ( + self.output_formatter.format_tool_result_for_user(tool_result) + ) + all_tool_results_content_with_id.append( + (call_id, tool_result_for_llm) + ) + else: + for call in tool_calls: + server_name = call["server_name"] + tool_name = call["tool_name"] + arguments = call["arguments"] + call_id = call["id"] + await self._emit_stage_heartbeat( + "检索" if tool_name in SEARCH_TOOL_NAMES else "工具调用", + turn=turn_count, + detail=f"执行工具 {tool_name}", + agent_name="main", + tool_name=tool_name, ) - except Exception as e: - call_end_time = time.time() - call_duration_ms = int((call_end_time - call_start_time) * 1000) + # Fix common parameter name mistakes + arguments = self.tool_executor.fix_tool_call_arguments( + tool_name, arguments + ) - tool_calls_data.append( - { + call_start_time = time.time() + try: + if ( + server_name.startswith("agent-") + and self.cfg.agent.sub_agents + ): + # Sub-agent execution + cache_name = "main_" + tool_name + ( + is_duplicate, + should_rollback, + turn_count, + consecutive_rollbacks, + message_history, + ) = await self._check_duplicate_query( + tool_name, + arguments, + cache_name, + consecutive_rollbacks, + turn_count, + total_attempts, + max_attempts, + message_history, + "Main Agent", + ) + if should_rollback: + should_rollback_turn = True + break + + # Stream events + await self.stream.end_llm("main") + await self.stream.end_agent("main", self.current_agent_id) + + # Execute sub-agent + sub_agent_result = await self.run_sub_agent( + server_name, + arguments["subtask"], + ) + + # Update query count + await self._record_query(cache_name, tool_name, arguments) + + tool_result = { + "server_name": server_name, + "tool_name": tool_name, + "result": sub_agent_result, + } + self.current_agent_id = await self.stream.start_agent( + "main", display_name="Summarizing" + ) + await self.stream.start_llm( + "main", display_name="Summarizing" + ) + else: + # Regular tool execution + cache_name = "main_" + tool_name + ( + is_duplicate, + should_rollback, + turn_count, + consecutive_rollbacks, + message_history, + ) = await self._check_duplicate_query( + tool_name, + arguments, + cache_name, + consecutive_rollbacks, + turn_count, + total_attempts, + max_attempts, + message_history, + "Main Agent", + ) + if should_rollback: + should_rollback_turn = True + break + + # Send stream event + tool_call_id = await self.stream.tool_call( + tool_name, arguments + ) + + tool_result = await self._execute_regular_tool_call( + server_name, tool_name, arguments, turn_count + ) + + # Update query count if successful + if "error" not in tool_result: + await self._record_query( + cache_name, tool_name, arguments + ) + + # Post-process result + tool_result = ( + self.tool_executor.post_process_tool_call_result( + tool_name, tool_result + ) + ) + self._record_search_evidence(tool_name, tool_result) + self._record_scrape_metric( + tool_name, tool_result, turn_count + ) + + result = ( + tool_result.get("result") + if tool_result.get("result") + else tool_result.get("error") + ) + + # Check for errors that should trigger rollback + if self.tool_executor.should_rollback_result( + tool_name, result, tool_result + ): + if ( + consecutive_rollbacks + < self.MAX_CONSECUTIVE_ROLLBACKS - 1 + ): + message_history.pop() + turn_count -= 1 + consecutive_rollbacks += 1 + should_rollback_turn = True + self.task_log.log_step( + "warning", + f"Main Agent | Turn: {turn_count} | Rollback", + f"Tool result error - tool: {tool_name}, result: '{str(result)[:200]}'", + ) + break + + await self.stream.tool_call( + tool_name, {"result": result}, tool_call_id=tool_call_id + ) + + call_end_time = time.time() + call_duration_ms = int((call_end_time - call_start_time) * 1000) + + tool_calls_data.append( + { + "server_name": server_name, + "tool_name": tool_name, + "arguments": arguments, + "result": tool_result, + "duration_ms": call_duration_ms, + "call_time": get_utc_plus_8_time(), + } + ) + self.task_log.log_step( + "info", + f"Main Agent | Turn: {turn_count} | Tool Call", + f"Tool {tool_name} completed in {call_duration_ms}ms", + ) + + except Exception as e: + call_end_time = time.time() + call_duration_ms = int((call_end_time - call_start_time) * 1000) + + tool_calls_data.append( + { + "server_name": server_name, + "tool_name": tool_name, + "arguments": arguments, + "error": str(e), + "duration_ms": call_duration_ms, + "call_time": get_utc_plus_8_time(), + } + ) + tool_result = { "server_name": server_name, "tool_name": tool_name, - "arguments": arguments, "error": str(e), - "duration_ms": call_duration_ms, - "call_time": get_utc_plus_8_time(), } + self.task_log.log_step( + "error", + f"Main Agent | Turn: {turn_count} | Tool Call", + f"Tool {tool_name} failed to execute: {str(e)}", + ) + + # Format results for LLM + tool_result_for_llm = ( + self.output_formatter.format_tool_result_for_user(tool_result) ) - tool_result = { - "server_name": server_name, - "tool_name": tool_name, - "error": str(e), - } - self.task_log.log_step( - "error", - f"Main Agent | Turn: {turn_count} | Tool Call", - f"Tool {tool_name} failed to execute: {str(e)}", + all_tool_results_content_with_id.append( + (call_id, tool_result_for_llm) ) - # Format results for LLM - tool_result_for_llm = self.output_formatter.format_tool_result_for_user( - tool_result - ) - all_tool_results_content_with_id.append((call_id, tool_result_for_llm)) - if should_rollback_turn: continue @@ -1610,6 +2162,19 @@ async def run_main_agent( } self.task_log.save() + # Round 7: after tools, exit once early-stop turn budget is spent + force_exit = await self._maybe_nudge_and_force_summary( + message_history, turn_count + ) + if force_exit: + break + if ( + self._deep_convergence_nudge_sent + and self._should_force_summary_after_early_stop(turn_count) + ): + # Nudge just appended after tools; next loop iteration runs LLM. + continue + # Check context length temp_summary_prompt = generate_agent_summarize_prompt( task_description, @@ -1703,6 +2268,41 @@ async def run_main_agent( save_callback=self._save_message_history, ) + # Append lead trail section to final summary (Phase 4) + if self.lead_tracker.enabled: + lead_trail_section = self.lead_tracker.get_trail_section() + if lead_trail_section: + final_summary += "\n\n" + lead_trail_section + self.task_log.log_step( + "info", + "Main Agent | Lead Trail", + f"Appended lead trail section ({len(lead_trail_section)} chars)", + ) + # Log lead tracking stats + lead_stats = self.lead_tracker.get_stats() + if lead_stats.get("enabled"): + self.task_log.log_step( + "info", + "Main Agent | Lead Tracking Stats", + f"Total leads: {lead_stats['total_leads']}, " + f"Followed up: {lead_stats['followed_up']}, " + f"Unfollowed: {lead_stats['unfollowed']}", + ) + + # User-facing cleanup: strip diagnostics, fix truncated refs, compact + # pending leads, and add analysis/topology when useful. + detail = getattr(self.answer_generator, "output_detail_level", "detailed") + try: + final_summary = prepare_user_facing_report( + final_summary, detail_level=str(detail) + ) + except Exception as exc: # never block final emit on presentation + self.task_log.log_step( + "warning", + "Main Agent | Report Presentation", + f"prepare_user_facing_report failed: {exc}", + ) + final_output_emitted = await self._emit_final_output( final_summary, result_quality, diff --git a/apps/miroflow-agent/src/core/pipeline.py b/apps/miroflow-agent/src/core/pipeline.py index 360c22d..e35952f 100644 --- a/apps/miroflow-agent/src/core/pipeline.py +++ b/apps/miroflow-agent/src/core/pipeline.py @@ -19,7 +19,7 @@ from typing import Any, Dict, List, Optional from miroflow_tools.manager import ToolManager -from omegaconf import DictConfig +from omegaconf import DictConfig, open_dict from ..config.settings import ( create_mcp_server_parameters, @@ -31,6 +31,8 @@ TaskLog, get_utc_plus_8_time, ) +from .deep_efficiency import resolve_agent_value +from .lead_tracker import resolve_lead_tracking_config from .orchestrator import Orchestrator FINAL_ANSWER_UNAVAILABLE_ERROR = "Final summary produced no usable answer." @@ -130,6 +132,7 @@ async def execute_task_pipeline( tool_definitions: Optional[List[Dict[str, Any]]] = None, sub_agent_tool_definitions: Optional[Dict[str, List[Dict[str, Any]]]] = None, is_final_retry: bool = False, + effective_config: Optional[Dict[str, Any]] = None, ): """ Executes the full pipeline for a single task. @@ -147,6 +150,8 @@ async def execute_task_pipeline( stream_queue: A queue for streaming the task execution (optional). tool_definitions: The definitions of the tools for the main agent (optional). sub_agent_tool_definitions: The definitions of the tools for the sub-agents (optional). + is_final_retry: Whether this is the final retry attempt (optional). + effective_config: The effective research configuration (Phase 1, optional). Returns: 包含以下字段的 Pipeline 结果映射: @@ -184,6 +189,66 @@ async def execute_task_pipeline( for sub_agent_tool_manager in sub_agent_tool_managers.values(): sub_agent_tool_manager.set_task_log(task_log) + # Store effective_config in RunMetrics (Phase 1) + if effective_config: + task_log.run_metrics.set_effective_config( + mode=effective_config.get("mode", "balanced"), + search_profile=effective_config.get( + "search_profile", "parallel-trusted" + ), + search_result_num=effective_config.get("search_result_num", 20), + verification_min_search_rounds=effective_config.get( + "verification_min_search_rounds", 3 + ), + output_detail_level=effective_config.get( + "output_detail_level", "balanced" + ), + research_intensity=effective_config.get( + "research_intensity", "standard" + ), + ) + task_log.log_step( + "info", + "Pipeline | Effective Config", + f"Research intensity: {effective_config.get('research_intensity')}, " + f"Mode: {effective_config.get('mode')}, " + f"Detail level: {effective_config.get('output_detail_level')}", + ) + + # Sync report detail / research-report flags into cfg.agent so + # AnswerGenerator prompts match API/harness effective_config + # (Hydra often only sets output_formatter.detail_level). + detail = ( + str(effective_config.get("output_detail_level") or "").strip().lower() + ) + if detail in {"compact", "balanced", "detailed"}: + with open_dict(cfg.agent): + cfg.agent.output_detail_level = detail + intensity = ( + str(effective_config.get("research_intensity") or "").strip().lower() + ) + # Deep+detailed hotspot reports need research_report_mode headings + if detail == "detailed" or intensity == "deep": + with open_dict(cfg.agent): + if not cfg.agent.get("research_report_mode"): + cfg.agent.research_report_mode = True + if intensity: + cfg.agent.research_intensity = intensity + + # Enable lead tracking for deep intensity (Phase 4). Orchestrator resolves + # the same flag via resolve_lead_tracking_config; this keeps cfg.agent in + # sync for logging and downstream readers on both API and CLI paths. + should_enable, _max_fu = resolve_lead_tracking_config(cfg) + if should_enable and not cfg.agent.get("enable_lead_tracking"): + with open_dict(cfg.agent): + cfg.agent.enable_lead_tracking = True + task_log.log_step( + "info", + "Pipeline | Lead Tracking", + "Enabled lead tracking (intensity=deep or explicit flag; " + f"research_intensity={resolve_agent_value(cfg, 'research_intensity')})", + ) + # Initialize LLM client llm_init_start_time = time.perf_counter() random_uuid = str(uuid.uuid4()) diff --git a/apps/miroflow-agent/src/logging/task_logger.py b/apps/miroflow-agent/src/logging/task_logger.py index 7a29b50..54785ba 100644 --- a/apps/miroflow-agent/src/logging/task_logger.py +++ b/apps/miroflow-agent/src/logging/task_logger.py @@ -119,16 +119,31 @@ class RunMetrics: # LLM 层 rate_limit_429_count: int = 0 timeout_count: int = 0 + http_timeout_count: int = 0 + wall_timeout_count: int = 0 + llm_retry_count: int = 0 + summary_passes: int = 0 key_switch_count: int = 0 model_route_hits: Dict[str, Dict[str, int]] = field(default_factory=dict) # 检索层 search_rounds: int = 0 + search_attempts: int = 0 + search_provider_hits: Dict[str, int] = field(default_factory=dict) + scrape_count: int = 0 + follow_up_searches: int = 0 + # 工具侧检索质量门控(passed 后不再追加补检) + retrieval_confidence_passed: bool = False + # Deep early-stop / LLM-path efficiency (Round 7) + early_stop_triggered: bool = False + early_stop_turn: int = 0 # 耗时(毫秒) total_duration_ms: int = 0 stage_durations: Dict[str, int] = field(default_factory=dict) # 模型 failback failback_activated: bool = False failback_model: str = "" + # 生效配置(记录实际使用的参数) + effective_config: Dict[str, Any] = field(default_factory=dict) # ---- 便捷方法 ---- @@ -136,6 +151,65 @@ def record_model_route(self, requested: str, responded: str) -> None: inner = self.model_route_hits.setdefault(requested, {}) inner[responded] = inner.get(responded, 0) + 1 + def record_search_round(self) -> None: + """记录一次检索轮次""" + self.search_rounds += 1 + + def record_search_attempt(self) -> None: + """记录一次检索工具调用(无论是否返回 organic 结果)。""" + self.search_attempts += 1 + + def record_search_provider_hit(self, provider: str, count: int = 1) -> None: + """记录某个搜索源被命中/尝试的次数。""" + name = (provider or "").strip().lower() + if not name: + return + self.search_provider_hits[name] = self.search_provider_hits.get(name, 0) + max( + 1, int(count) + ) + + def record_scrape(self, count: int = 1) -> None: + """记录页面抓取次数""" + self.scrape_count += count + + def record_follow_up_search(self) -> None: + """记录一次追踪式检索""" + self.follow_up_searches += 1 + + def record_llm_retry(self, count: int = 1) -> None: + """记录一次 LLM 应用层重试(含 timeout degrade)。""" + self.llm_retry_count += max(1, int(count)) + + def record_summary_pass(self) -> None: + """记录一次最终总结 LLM 调用。""" + self.summary_passes += 1 + + def record_early_stop(self, turn: int) -> None: + """记录 deep early-stop 首次触发回合。""" + if self.early_stop_triggered: + return + self.early_stop_triggered = True + self.early_stop_turn = max(0, int(turn)) + + def set_effective_config( + self, + mode: str, + search_profile: str, + search_result_num: int, + verification_min_search_rounds: int, + output_detail_level: str, + research_intensity: str = "standard", + ) -> None: + """设置生效配置""" + self.effective_config = { + "mode": mode, + "search_profile": search_profile, + "search_result_num": search_result_num, + "verification_min_search_rounds": verification_min_search_rounds, + "output_detail_level": output_detail_level, + "research_intensity": research_intensity, + } + def to_dict(self) -> Dict[str, Any]: return asdict(self) diff --git a/apps/miroflow-agent/tests/test_deep_early_stop_exit.py b/apps/miroflow-agent/tests/test_deep_early_stop_exit.py new file mode 100644 index 0000000..ac51719 --- /dev/null +++ b/apps/miroflow-agent/tests/test_deep_early_stop_exit.py @@ -0,0 +1,64 @@ +# Copyright (c) 2025 MiroMind +"""Round 7: early-stop turn cap forces summary exit.""" + +from __future__ import annotations + +import sys +from pathlib import Path +from unittest.mock import MagicMock + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from src.core.orchestrator import Orchestrator # noqa: E402 +from src.logging.task_logger import RunMetrics # noqa: E402 + + +def _bare_orchestrator(**kwargs) -> Orchestrator: + """Build a partially-initialized orchestrator for helper unit tests.""" + obj = Orchestrator.__new__(Orchestrator) + obj.deep_early_stop_enabled = True + obj.deep_early_stop_min_sources = 2 + obj.deep_exit_on_early_stop = True + obj.deep_post_early_stop_turns = 2 + obj.deep_early_stop_triggered = False + obj.deep_early_stop_turn = 0 + obj._deep_convergence_nudge_sent = False + obj.independent_source_domains = {"a.com", "b.com"} + obj.verification_min_search_rounds = 3 + obj.task_log = MagicMock() + obj.task_log.run_metrics = RunMetrics() + obj.task_log.run_metrics.search_rounds = 3 + for key, value in kwargs.items(): + setattr(obj, key, value) + return obj + + +def test_force_summary_after_post_early_stop_turns(): + orch = _bare_orchestrator() + assert orch._should_force_summary_after_early_stop(3) is False + assert orch.deep_early_stop_triggered is True + assert orch.deep_early_stop_turn == 3 + assert orch.task_log.run_metrics.early_stop_triggered is True + assert orch.task_log.run_metrics.early_stop_turn == 3 + # post_turns=2 → force at turn >= 5 + assert orch._should_force_summary_after_early_stop(4) is False + assert orch._should_force_summary_after_early_stop(5) is True + + +def test_force_summary_disabled_when_exit_flag_off(): + orch = _bare_orchestrator(deep_exit_on_early_stop=False) + assert orch._should_force_summary_after_early_stop(20) is False + assert orch.deep_early_stop_triggered is False + + +def test_nudge_sent_flag_allows_failure_path_exit_semantics(): + """After nudge, orchestrator should prefer summary over more empty retries.""" + orch = _bare_orchestrator() + orch._deep_convergence_nudge_sent = True + assert orch._deep_convergence_nudge_sent is True + # force condition still true once past budget + orch.deep_early_stop_triggered = True + orch.deep_early_stop_turn = 7 + assert orch._should_force_summary_after_early_stop(10) is True diff --git a/apps/miroflow-agent/tests/test_deep_efficiency.py b/apps/miroflow-agent/tests/test_deep_efficiency.py new file mode 100644 index 0000000..332e2c9 --- /dev/null +++ b/apps/miroflow-agent/tests/test_deep_efficiency.py @@ -0,0 +1,111 @@ +# Copyright (c) 2025 MiroMind +"""Unit tests for Round-6/7 deep efficiency knobs.""" + +from src.core.deep_efficiency import ( + DEFAULT_DEEP_MAX_FINAL_ANSWER_RETRIES, + DEFAULT_DEEP_MAX_LEAD_FOLLOW_UPS, + DEFAULT_DEEP_MAX_SCRAPE_PER_TASK, + DEFAULT_DEEP_POST_EARLY_STOP_TURNS, + default_max_lead_follow_ups_for_intensity, + resolve_early_stop_config, + resolve_exit_on_early_stop, + resolve_max_final_answer_retries, + resolve_max_scrape_per_task, + resolve_oneshot_final_report, + resolve_parallel_tool_calls, + resolve_summary_keep_tool_result, + scrape_budget_exceeded, +) +from src.core.lead_tracker import resolve_lead_tracking_config + + +class _CfgDict(dict): + def __getattr__(self, item): + try: + return self[item] + except KeyError as exc: + raise AttributeError(item) from exc + + +def test_deep_default_clue_top_k_is_two(): + assert default_max_lead_follow_ups_for_intensity("deep") == 2 + assert DEFAULT_DEEP_MAX_LEAD_FOLLOW_UPS == 2 + + +def test_resolve_lead_tracking_deep_defaults_to_top_k_two(): + agent = _CfgDict(research_intensity="deep") + cfg = _CfgDict(agent=agent) + enabled, max_fu = resolve_lead_tracking_config(cfg) + assert enabled is True + assert max_fu == 2 + + +def test_resolve_max_scrape_per_task_deep_default(): + agent = _CfgDict(research_intensity="deep") + cfg = _CfgDict(agent=agent) + assert resolve_max_scrape_per_task(cfg) == DEFAULT_DEEP_MAX_SCRAPE_PER_TASK + + +def test_scrape_budget_exceeded(): + assert scrape_budget_exceeded(8, 8) is True + assert scrape_budget_exceeded(7, 8) is False + assert scrape_budget_exceeded(100, 0) is False # 0 = unlimited + + +def test_early_stop_defaults_on_for_deep(): + agent = _CfgDict(research_intensity="deep") + cfg = _CfgDict(agent=agent) + enabled, min_src = resolve_early_stop_config(cfg) + assert enabled is True + assert min_src >= 2 + + +def test_parallel_tool_calls_default_true(): + agent = _CfgDict(research_intensity="deep") + cfg = _CfgDict(agent=agent) + assert resolve_parallel_tool_calls(cfg) is True + + +def test_exit_on_early_stop_defaults_for_deep(): + agent = _CfgDict(research_intensity="deep") + cfg = _CfgDict(agent=agent) + enabled, post = resolve_exit_on_early_stop(cfg) + assert enabled is True + assert post == DEFAULT_DEEP_POST_EARLY_STOP_TURNS + + +def test_exit_on_early_stop_off_for_standard(): + agent = _CfgDict(research_intensity="standard") + cfg = _CfgDict(agent=agent) + enabled, _post = resolve_exit_on_early_stop(cfg) + assert enabled is False + + +def test_max_final_answer_retries_deep_defaults_to_one(): + agent = _CfgDict(research_intensity="deep") + cfg = _CfgDict(agent=agent) + assert ( + resolve_max_final_answer_retries(cfg) == DEFAULT_DEEP_MAX_FINAL_ANSWER_RETRIES + ) + assert DEFAULT_DEEP_MAX_FINAL_ANSWER_RETRIES == 1 + + +def test_max_final_answer_retries_explicit_wins(): + agent = _CfgDict(research_intensity="deep", max_final_answer_retries=3) + cfg = _CfgDict(agent=agent) + assert resolve_max_final_answer_retries(cfg) == 3 + + +def test_oneshot_skeleton_defaults_on_for_standard_not_light(): + standard = _CfgDict(agent=_CfgDict(research_intensity="standard")) + light = _CfgDict(agent=_CfgDict(research_intensity="light")) + assert resolve_oneshot_final_report(standard) is True + assert resolve_oneshot_final_report(light) is False + + +def test_round8_oneshot_and_summary_keep_defaults(): + agent = _CfgDict(research_intensity="deep") + cfg = _CfgDict(agent=agent) + assert resolve_oneshot_final_report(cfg) is True + assert resolve_summary_keep_tool_result(cfg) == 2 + assert DEFAULT_DEEP_POST_EARLY_STOP_TURNS == 1 diff --git a/apps/miroflow-agent/tests/test_lead_tracker.py b/apps/miroflow-agent/tests/test_lead_tracker.py new file mode 100644 index 0000000..d0df9b3 --- /dev/null +++ b/apps/miroflow-agent/tests/test_lead_tracker.py @@ -0,0 +1,83 @@ +# Copyright (c) 2025 MiroMind +# This source code is licensed under the Apache 2.0 License. + +"""Unit tests for lead tracking enable resolution and extraction.""" + +from types import SimpleNamespace + +from src.core.lead_tracker import ( + LeadTrackingManager, + LeadTrail, + resolve_lead_tracking_config, +) + + +class _CfgDict(dict): + """Dict that also supports OmegaConf-like .get usage.""" + + pass + + +def test_resolve_enable_from_main_agent_path(): + agent = _CfgDict() + agent["main_agent"] = _CfgDict(enable_lead_tracking=True, max_lead_follow_ups=2) + cfg = SimpleNamespace(agent=agent) + enabled, max_fu = resolve_lead_tracking_config(cfg) + assert enabled is True + assert max_fu == 2 + + +def test_resolve_auto_enable_on_deep_intensity(): + agent = _CfgDict(research_intensity="deep") + agent["main_agent"] = _CfgDict() + cfg = SimpleNamespace(agent=agent) + enabled, _ = resolve_lead_tracking_config(cfg) + assert enabled is True + + +def test_resolve_light_disabled_without_explicit_flag(): + agent = _CfgDict(research_intensity="light") + agent["main_agent"] = _CfgDict() + cfg = SimpleNamespace(agent=agent) + enabled, _ = resolve_lead_tracking_config(cfg) + assert enabled is False + + +def test_english_extraction_need_to_investigate(): + text = ( + "Based on initial research, we found quantum computing developments.\n" + "However, we need to investigate: what are the specific breakthroughs by IBM?\n" + "There is also uncertainty regarding Google's claimed quantum supremacy metrics.\n" + ) + leads = LeadTrail.extract_leads_from_text(text, turn=1) + assert len(leads) >= 1 + + +def test_query_seed_for_chained_how_did(): + seeds = LeadTrail.seed_leads_from_query( + "How did the discovery of DNA structure lead to CRISPR technology?" + ) + assert len(seeds) >= 1 + assert any("CRISPR" in q or "intermediate" in q.lower() for q, _ in seeds) + + +def test_trail_includes_pending_leads(): + mgr = LeadTrackingManager(enabled=True, max_follow_ups=2) + mgr.initialize("How did A lead to B?") + assert mgr.get_stats()["total_leads"] >= 1 + trail = mgr.get_trail_section() + assert "Lead Trail" in trail + assert ("未跟进" in trail) or ("pending" in trail) or ("followed" in trail) + + +def test_follow_up_marks_trail_and_stats(): + mgr = LeadTrackingManager(enabled=True, max_follow_ups=2) + mgr.initialize("How did A lead to B?") + leads = mgr.process_turn_response( + "We need to investigate: what tools bridged A to B?", turn=1 + ) + assert leads + mgr.record_follow_up(leads[0], turn=2, findings="Found restriction enzymes.") + trail = mgr.get_trail_section() + assert "followed" in trail + assert mgr.trail.follow_up_count >= 1 diff --git a/apps/miroflow-agent/tests/test_verification_gate_efficiency.py b/apps/miroflow-agent/tests/test_verification_gate_efficiency.py index e026942..1f24d0d 100644 --- a/apps/miroflow-agent/tests/test_verification_gate_efficiency.py +++ b/apps/miroflow-agent/tests/test_verification_gate_efficiency.py @@ -7,8 +7,8 @@ if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) -from src.core.orchestrator import Orchestrator -from src.logging.task_logger import TaskLog +from src.core.orchestrator import Orchestrator # noqa: E402 +from src.logging.task_logger import TaskLog # noqa: E402 def _build_orchestrator_stub() -> Orchestrator: @@ -20,6 +20,7 @@ def _build_orchestrator_stub() -> Orchestrator: orchestrator.verification_guidance_attempts = 1 orchestrator.verification_search_rounds = 1 orchestrator.verification_high_conf_source_domains = set() + orchestrator.retrieval_confidence_passed = False orchestrator.verification_guidance_anchor_search_rounds = 1 orchestrator.verification_guidance_anchor_high_conf_sources = 0 orchestrator.verification_stagnant_guidance_attempts = 0 @@ -54,3 +55,16 @@ def test_verification_gate_allows_retry_when_new_search_evidence_exists(): assert should_issue is True assert orchestrator.verification_stagnant_guidance_attempts == 0 + + +def test_verification_requirements_met_when_retrieval_confidence_passed(): + orchestrator = _build_orchestrator_stub() + orchestrator.verification_search_rounds = 3 + orchestrator.retrieval_confidence_passed = True + + # 高置信来源计数不足,但工具侧 confidence 已通过 + assert orchestrator._verification_requirements_met() is True + + # 检索轮次下限仍然必须满足 + orchestrator.verification_search_rounds = 2 + assert orchestrator._verification_requirements_met() is False From dce6d3ee366de0f67f3b6d73aa29614b81b8551d Mon Sep 17 00:00:00 2001 From: rainwalker Date: Tue, 22 Sep 2026 11:30:48 +0800 Subject: [PATCH 02/26] =?UTF-8?q?feat(search):=20=E2=9C=A8=20=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=E7=BD=AE=E4=BF=A1=E5=BA=A6=E7=AD=96=E7=95=A5=E4=B8=8E?= =?UTF-8?q?=E6=A3=80=E7=B4=A2=E5=8D=8F=E8=AE=AE=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/config/search_policy.py | 85 +++++++++++++ .../tests/test_search_policy.py | 57 +++++++++ .../dev_mcp_servers/providers/registry.py | 10 +- .../search_and_scrape_webpage.py | 77 +++++++++++- .../src/test/test_search_confidence_policy.py | 112 ++++++++++++++++++ .../src/test/test_search_provider_protocol.py | 18 ++- 6 files changed, 350 insertions(+), 9 deletions(-) create mode 100644 apps/miroflow-agent/src/config/search_policy.py create mode 100644 apps/miroflow-agent/tests/test_search_policy.py create mode 100644 libs/miroflow-tools/src/test/test_search_confidence_policy.py diff --git a/apps/miroflow-agent/src/config/search_policy.py b/apps/miroflow-agent/src/config/search_policy.py new file mode 100644 index 0000000..e272328 --- /dev/null +++ b/apps/miroflow-agent/src/config/search_policy.py @@ -0,0 +1,85 @@ +"""检索/预算策略与用户显式配置之间的优先级规则。 + +gradio-demo 与 api-server 两个入口共用本模块,避免同一问题因入口不同而走出 +不同的路由或预算参数。 +""" + +from __future__ import annotations + +import os +from typing import Dict, List, Optional, Tuple + +# 置信度阈值属于调优参数:用户在 .env 里显式设置时应优先于策略预设。 +# 其余键(provider order / mode)定义策略本身,由策略覆盖才有意义。 +USER_TUNABLE_SEARCH_ENV_PREFIXES: Tuple[str, ...] = ("SEARCH_CONFIDENCE_",) + +# 环境变量 → Hydra 键、类型与取值范围。 +BudgetSpec = Tuple[str, str, Optional[float], Optional[float]] +EXPLICIT_BUDGET_ENV_MAP: Tuple[BudgetSpec, ...] = ( + ("LLM_MAX_TOKENS", "llm.max_tokens", 256, None), + ("KEEP_TOOL_RESULT", "agent.keep_tool_result", -1, None), + ("MAIN_AGENT_MAX_TURNS", "agent.main_agent.max_turns", 1, None), + ("CONTEXT_COMPRESS_LIMIT", "agent.context_compress_limit", 0, None), + ("LLM_TEMPERATURE", "llm.temperature", 0.0, 2.0), +) + +_BOOL_ENV_KEYS: Tuple[Tuple[str, str], ...] = ( + ("RETRY_WITH_SUMMARY", "agent.retry_with_summary"), +) + +_TRUE_TOKENS = {"1", "true", "yes", "on"} + + +def _hydra_override_key(override: str) -> str: + normalized = override.lstrip("+") + key, separator, _ = normalized.partition("=") + return key if separator else normalized + + +def apply_user_search_env_precedence(profile_env: Dict[str, str]) -> Dict[str, str]: + """剔除策略预设中用户已显式设定的调优键,让 .env 的值真正生效。""" + return { + key: value + for key, value in profile_env.items() + if not ( + key.startswith(USER_TUNABLE_SEARCH_ENV_PREFIXES) + and os.environ.get(key, "").strip() + ) + } + + +def apply_explicit_budget_overrides(overrides: List[str]) -> List[str]: + """把用户显式设定的预算旋钮提到最高优先级。 + + 模式预设排在 overrides 末尾(detail < mode 是刻意设计),但它会连带覆盖 + ``LLM_MAX_TOKENS`` 这类 .env 设置,使这些变量变成静默失效的死旋钮。 + """ + replacements: Dict[str, str] = {} + + for env_name, override_key, minimum, maximum in EXPLICIT_BUDGET_ENV_MAP: + raw = os.environ.get(env_name, "").strip() + if not raw: + continue + try: + value = float(raw) + except ValueError: + continue + if minimum is not None: + value = max(minimum, value) + if maximum is not None: + value = min(maximum, value) + rendered = str(int(value)) if override_key != "llm.temperature" else str(value) + replacements[override_key] = rendered + + for env_name, override_key in _BOOL_ENV_KEYS: + raw = os.environ.get(env_name, "").strip() + if raw: + replacements[override_key] = str(raw.lower() in _TRUE_TOKENS) + + if not replacements: + return overrides + + keys = set(replacements) + pruned = [o for o in overrides if _hydra_override_key(o) not in keys] + pruned.extend(f"{key}={value}" for key, value in replacements.items()) + return pruned diff --git a/apps/miroflow-agent/tests/test_search_policy.py b/apps/miroflow-agent/tests/test_search_policy.py new file mode 100644 index 0000000..85f7d91 --- /dev/null +++ b/apps/miroflow-agent/tests/test_search_policy.py @@ -0,0 +1,57 @@ +"""入口共享的检索/预算优先级规则。 + +api-server 与 gradio-demo 都靠这两个 helper 让 .env 里的用户设置在 +profile/mode 预设面前真正生效,因此规则本身需要独立回归测试。 +""" + +from src.config.search_policy import ( + apply_explicit_budget_overrides, + apply_user_search_env_precedence, +) + + +def test_user_threshold_envs_survive_profile_preset(monkeypatch): + """用户显式设置的 SEARCH_CONFIDENCE_* 阈值不再被 profile 预设覆盖。""" + profile_env = { + "SEARCH_CONFIDENCE_MIN_SCORE": "0.5", + "SEARCH_CONFIDENCE_MIN_PROVIDER_COVERAGE": "2", + "SEARCH_PROVIDER_ORDER": "serpapi,tavily,serper", + } + for env_key in profile_env: + monkeypatch.delenv(env_key, raising=False) + monkeypatch.setenv("SEARCH_CONFIDENCE_MIN_SCORE", "0.95") + + resolved = apply_user_search_env_precedence(profile_env) + + # 剔除预设值后,.env 里的 0.95 才会生效;其余键不受影响 + assert "SEARCH_CONFIDENCE_MIN_SCORE" not in resolved + assert resolved["SEARCH_CONFIDENCE_MIN_PROVIDER_COVERAGE"] == "2" + assert resolved["SEARCH_PROVIDER_ORDER"] == "serpapi,tavily,serper" + + +def test_profile_values_still_apply_when_user_silent(monkeypatch): + """用户未显式设置(或只写了空白)时,profile 预设继续生效。""" + profile_env = {"SEARCH_CONFIDENCE_MIN_SCORE": "0.5"} + monkeypatch.delenv("SEARCH_CONFIDENCE_MIN_SCORE", raising=False) + + assert apply_user_search_env_precedence(dict(profile_env)) == profile_env + + monkeypatch.setenv("SEARCH_CONFIDENCE_MIN_SCORE", " ") + assert apply_user_search_env_precedence(dict(profile_env)) == profile_env + + +def test_explicit_budget_env_outranks_mode_preset(monkeypatch): + """用户显式 LLM_MAX_TOKENS 必须排在模式预设之后(Hydra 后者胜出)。""" + monkeypatch.setenv("LLM_MAX_TOKENS", "16384") + overrides = [ + "agent.output_detail_level=detailed", + "llm.max_tokens=3072", # 模式预设位置 + ] + + resolved = apply_explicit_budget_overrides(overrides) + + assert "llm.max_tokens=3072" not in resolved + assert "agent.output_detail_level=detailed" in resolved + assert resolved.index("llm.max_tokens=16384") > resolved.index( + "agent.output_detail_level=detailed" + ) diff --git a/libs/miroflow-tools/src/miroflow_tools/dev_mcp_servers/providers/registry.py b/libs/miroflow-tools/src/miroflow_tools/dev_mcp_servers/providers/registry.py index b0a338c..2076aae 100644 --- a/libs/miroflow-tools/src/miroflow_tools/dev_mcp_servers/providers/registry.py +++ b/libs/miroflow-tools/src/miroflow_tools/dev_mcp_servers/providers/registry.py @@ -31,11 +31,15 @@ def available_names(self) -> list[str]: """返回所有当前可用的搜索源名称列表。""" return [name for name, p in self._providers.items() if p.is_available()] - def resolve_order(self, order_config: str) -> list[str]: + def resolve_order( + self, order_config: str, *, strict: bool = False + ) -> list[str]: """ 按配置字符串解析可用 provider 顺序。 - 不可用的自动过滤;未显式配置但可用的追加到末尾。 + 不可用的自动过滤。默认会把未显式配置但可用的 provider 追加到末尾; + ``strict=True`` 时仅返回配置中明确列出且当前可用的 provider(用于 + ``searxng-only`` 等硬约束路由,避免 SERPER 等密钥存在时静默扩容)。 """ seen: set[str] = set() result: list[str] = [] @@ -46,6 +50,8 @@ def resolve_order(self, order_config: str) -> list[str]: if name not in seen and self.get(name) and self.get(name).is_available(): result.append(name) seen.add(name) + if strict: + return result # 追加未显式配置但可用的 provider for name in self._providers: if name not in seen and self._providers[name].is_available(): diff --git a/libs/miroflow-tools/src/miroflow_tools/dev_mcp_servers/search_and_scrape_webpage.py b/libs/miroflow-tools/src/miroflow_tools/dev_mcp_servers/search_and_scrape_webpage.py index dd9ecff..c2f7e81 100644 --- a/libs/miroflow-tools/src/miroflow_tools/dev_mcp_servers/search_and_scrape_webpage.py +++ b/libs/miroflow-tools/src/miroflow_tools/dev_mcp_servers/search_and_scrape_webpage.py @@ -206,6 +206,11 @@ def _read_env_ip_networks(name: str) -> Tuple[Any, ...]: "SEARCH_SEARXNG_ONLY_DOWNGRADE_ORDER", DEFAULT_SEARCH_SEARXNG_ONLY_DOWNGRADE_ORDER, ).strip() +# searxng-only 等硬约束路由:禁止把未配置的可用 provider 追加进顺序表 +SEARCH_PROVIDER_ORDER_STRICT = _read_env_bool( + "SEARCH_PROVIDER_ORDER_STRICT", + False, +) def _build_searxng_only_downgrade_providers( @@ -308,6 +313,15 @@ def _evaluate_confidence( ) } + # 只配了 1 个检索源时,要求 2 路覆盖的门控永远不可能满足,会白白耗尽补检轮次。 + min_provider_coverage = max( + 1, + min( + SEARCH_CONFIDENCE_MIN_PROVIDER_COVERAGE, + len(_registry.available_names()), + ), + ) + result_ratio = min( len(organic_results) / max(1, SEARCH_CONFIDENCE_MIN_RESULTS), 1.0, @@ -317,7 +331,7 @@ def _evaluate_confidence( 1.0, ) provider_ratio = min( - len(providers_with_results) / max(1, SEARCH_CONFIDENCE_MIN_PROVIDER_COVERAGE), + len(providers_with_results) / max(1, min_provider_coverage), 1.0, ) high_conf_ratio = min( @@ -335,7 +349,7 @@ def _evaluate_confidence( hard_constraints_passed = ( len(organic_results) >= SEARCH_CONFIDENCE_MIN_RESULTS and len(unique_domains) >= SEARCH_CONFIDENCE_MIN_UNIQUE_DOMAINS - and len(providers_with_results) >= SEARCH_CONFIDENCE_MIN_PROVIDER_COVERAGE + and len(providers_with_results) >= min_provider_coverage and len(high_conf_domains_hit) >= SEARCH_CONFIDENCE_MIN_HIGH_CONF_HITS ) passed = hard_constraints_passed and score >= SEARCH_CONFIDENCE_SCORE_THRESHOLD @@ -354,13 +368,48 @@ def _evaluate_confidence( "constraints": { "min_results": SEARCH_CONFIDENCE_MIN_RESULTS, "min_unique_domains": SEARCH_CONFIDENCE_MIN_UNIQUE_DOMAINS, - "min_provider_coverage": SEARCH_CONFIDENCE_MIN_PROVIDER_COVERAGE, + "min_provider_coverage": min_provider_coverage, "min_high_conf_hits": SEARCH_CONFIDENCE_MIN_HIGH_CONF_HITS, }, "high_conf_domains_hit": sorted(high_conf_domains_hit), } +def _ensure_confidence_evaluated( + organic_results: list[dict], + search_params: dict[str, Any], + providers_with_results: Optional[set[str]] = None, +) -> None: + """串行回退/合并模式同样产出置信度,否则该门控只在并发路由下生效。""" + if search_params.get("confidence") is not None: + return + covered = providers_with_results or { + str(search_params.get("provider", "")).strip() + } + search_params["confidence"] = _evaluate_confidence( + organic_results, {name for name in covered if name} + ) + + +def _confidence_verdict_line(search_params: dict[str, Any]) -> str: + """一行质量结论。工具结果按字符数截断,JSON 尾部的 confidence 模型看不到,故前置。""" + confidence = search_params.get("confidence") + if not isinstance(confidence, dict): + return "" + metrics = confidence.get("metrics", {}) + constraints = confidence.get("constraints", {}) + return ( + f"confidence={confidence.get('score')} " + f"passed={'yes' if confidence.get('passed') else 'no'} " + f"results={metrics.get('results')}/{constraints.get('min_results')} " + f"domains={metrics.get('unique_domains')}/{constraints.get('min_unique_domains')} " + f"providers={metrics.get('provider_coverage')}/" + f"{constraints.get('min_provider_coverage')} " + f"high_conf={metrics.get('high_conf_domain_hits')}/" + f"{constraints.get('min_high_conf_hits')}" + ) + + @mcp.tool() async def google_search( q: str, @@ -440,7 +489,10 @@ async def perform_search(search_query: str) -> tuple[list, dict, list[str]]: if configured_mode not in VALID_SEARCH_PROVIDER_MODES: configured_mode = DEFAULT_SEARCH_PROVIDER_MODE - providers = _registry.resolve_order(SEARCH_PROVIDER_ORDER) + providers = _registry.resolve_order( + SEARCH_PROVIDER_ORDER, + strict=SEARCH_PROVIDER_ORDER_STRICT, + ) ( providers, searxng_only_downgraded, @@ -626,8 +678,19 @@ async def perform_search(search_query: str) -> tuple[list, dict, list[str]]: str(exc), ) + # 补检源可能不在 SEARCH_PROVIDER_ORDER 里,合并时按结果实际来源补齐, + # 否则刚取回的结果会因为不在 order 列表而被丢掉。 merged_results = _merge_provider_results( - providers, provider_results_map, result_num + [ + *providers, + *[ + name + for name in provider_results_map + if name not in providers + ], + ], + provider_results_map, + result_num, ) confidence = _evaluate_confidence( merged_results, providers_with_results @@ -764,6 +827,8 @@ async def perform_search(search_query: str) -> tuple[list, dict, list[str]]: # Build comprehensive response response_provider = search_params.get("provider", search_provider) + _ensure_confidence_evaluated(organic_results, search_params) + retrieval_quality = _confidence_verdict_line(search_params) if not organic_results: error_message = ( "; ".join(provider_errors) @@ -773,6 +838,7 @@ async def perform_search(search_query: str) -> tuple[list, dict, list[str]]: response_data = { "success": False, "error": error_message, + "retrieval_quality": retrieval_quality, "organic": [], "results": [], "searchParameters": search_params, @@ -790,6 +856,7 @@ async def perform_search(search_query: str) -> tuple[list, dict, list[str]]: return json.dumps(response_data, ensure_ascii=False) response_data = { + "retrieval_quality": retrieval_quality, "organic": organic_results, "searchParameters": search_params, "provider": response_provider, diff --git a/libs/miroflow-tools/src/test/test_search_confidence_policy.py b/libs/miroflow-tools/src/test/test_search_confidence_policy.py new file mode 100644 index 0000000..330497a --- /dev/null +++ b/libs/miroflow-tools/src/test/test_search_confidence_policy.py @@ -0,0 +1,112 @@ +"""检索质量门控:单 provider 部署下 confidence 硬约束必须可达。 + +回归目标: +- 只配 1 个检索源时,``MIN_PROVIDER_COVERAGE=2`` 的门控永远达不到,会白白耗尽 + 补检轮次;门槛钳制到实际 provider 数后 verdict 才可用。 +- confidence 只看 provider 实际返回的结果,响应级 flag 不能替代结果。 +""" + +import importlib +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "src")) + +_SEARCH_MODULE = "miroflow_tools.dev_mcp_servers.search_and_scrape_webpage" + + +def _reset_search_env(monkeypatch) -> None: + """把 confidence 相关 env 清零到默认值,使测试对 .env/系统环境不可见。""" + for env_key in ( + "SEARCH_PROVIDER_ORDER", + "SEARCH_PROVIDER_TRUSTED_ORDER", + "SEARCH_CONFIDENCE_ENABLED", + "SEARCH_CONFIDENCE_SCORE_THRESHOLD", + "SEARCH_CONFIDENCE_MIN_RESULTS", + "SEARCH_CONFIDENCE_MIN_UNIQUE_DOMAINS", + "SEARCH_CONFIDENCE_MIN_PROVIDER_COVERAGE", + "SEARCH_CONFIDENCE_MIN_HIGH_CONF_HITS", + "SEARCH_CONFIDENCE_HIGH_CONF_DOMAINS", + "SEARXNG_BASE_URL", + "SERPER_API_KEY", + "SERPAPI_API_KEY", + "TAVILY_API_KEY", + ): + monkeypatch.delenv(env_key, raising=False) + + +def _reload_search_module(): + """重建模块级常量(env 只在 import 时读取)。""" + sys.modules.pop(_SEARCH_MODULE, None) + return importlib.import_module(_SEARCH_MODULE) + + +def _set_single_provider_env(monkeypatch) -> None: + _reset_search_env(monkeypatch) + monkeypatch.setenv("SEARCH_PROVIDER_ORDER", "serper") + monkeypatch.setenv("SERPER_API_KEY", "test-key") + # 门槛压到单条结果可达,聚焦"钳制后的 provider 覆盖数不再挡住门控" + monkeypatch.setenv("SEARCH_CONFIDENCE_MIN_RESULTS", "1") + monkeypatch.setenv("SEARCH_CONFIDENCE_MIN_UNIQUE_DOMAINS", "1") + monkeypatch.setenv("SEARCH_CONFIDENCE_MIN_HIGH_CONF_HITS", "1") + + +@pytest.mark.unit +def test_single_provider_confidence_gate_reachable(monkeypatch): + """单 provider 环境:MIN_PROVIDER_COVERAGE 钳制到实际 provider 数, + confidence 校验可用且 verdict 正确。""" + _set_single_provider_env(monkeypatch) + module = _reload_search_module() + + organic_results = [ + { + "title": "Reuters 报道", + "link": "https://www.reuters.com/world/example", + "snippet": "示例摘要。", + "source": "serper", + } + ] + search_params: dict = {"provider": "serper"} + + module._ensure_confidence_evaluated(organic_results, search_params) + verdict = module._confidence_verdict_line(search_params) + + assert search_params["confidence"]["constraints"]["min_provider_coverage"] == 1 + assert search_params["confidence"]["passed"] is True + assert "passed=yes" in verdict + assert "providers=1/1" in verdict + + +@pytest.mark.unit +def test_single_provider_confidence_fail_reports_no(monkeypatch): + """结果为空时报 passed=no,而不是因 provider_coverage 硬约束不可达 + 而永远 passed=yes。""" + _set_single_provider_env(monkeypatch) + module = _reload_search_module() + + search_params: dict = {"provider": "serper"} + + module._ensure_confidence_evaluated([], search_params) + verdict = module._confidence_verdict_line(search_params) + + assert search_params["confidence"]["passed"] is False + assert "passed=no" in verdict + + +@pytest.mark.unit +def test_confidence_follows_actual_results(monkeypatch): + """响应级 flag 乐观也不能让空结果通过门控。""" + _set_single_provider_env(monkeypatch) + module = _reload_search_module() + + search_params: dict = { + "provider": "serper", + "low_confidence": False, + "provider_ok": True, + } + + module._ensure_confidence_evaluated([], search_params) + + assert search_params["confidence"]["passed"] is False diff --git a/libs/miroflow-tools/src/test/test_search_provider_protocol.py b/libs/miroflow-tools/src/test/test_search_provider_protocol.py index efda5b2..635441b 100644 --- a/libs/miroflow-tools/src/test/test_search_provider_protocol.py +++ b/libs/miroflow-tools/src/test/test_search_provider_protocol.py @@ -1,7 +1,5 @@ """SearchProvider 协议接口、数据结构和 Registry 测试。""" -import pytest - from miroflow_tools.dev_mcp_servers.providers.base import ( SearchParams, SearchProvider, @@ -181,6 +179,22 @@ def test_resolve_order_appends_unconfigured(self): resolved = reg.resolve_order("serper") assert resolved == ["serper", "serpapi"] + def test_resolve_order_strict_does_not_append(self): + reg = ProviderRegistry() + reg.register(_FakeProvider("serper", available=True)) + reg.register(_FakeProvider("serpapi", available=True)) + reg.register(_FakeProvider("searxng", available=True)) + # searxng-only: 严禁把 serper/serpapi 追加进来 + resolved = reg.resolve_order("searxng", strict=True) + assert resolved == ["searxng"] + + def test_resolve_order_strict_filters_unavailable(self): + reg = ProviderRegistry() + reg.register(_FakeProvider("serper", available=True)) + reg.register(_FakeProvider("searxng", available=False)) + resolved = reg.resolve_order("searxng", strict=True) + assert resolved == [] + def test_duplicate_register_overwrites(self): reg = ProviderRegistry() p1 = _FakeProvider("x") From b8a58a5ef43211370ea50f43099fb62083c872ce Mon Sep 17 00:00:00 2001 From: rainwalker Date: Tue, 22 Sep 2026 11:30:56 +0800 Subject: [PATCH 03/26] =?UTF-8?q?feat(llm):=20=E2=9C=A8=20=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E8=B6=85=E6=97=B6=E5=BF=AB=E9=80=9F=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E4=B8=8E=E9=99=8D=E7=BA=A7=E9=87=8D=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/llm/providers/openai_client.py | 178 +++++++++++++++--- .../tests/test_llm_timeout_fail_fast.py | 128 +++++++++++++ 2 files changed, 278 insertions(+), 28 deletions(-) create mode 100644 apps/miroflow-agent/tests/test_llm_timeout_fail_fast.py diff --git a/apps/miroflow-agent/src/llm/providers/openai_client.py b/apps/miroflow-agent/src/llm/providers/openai_client.py index f3024d0..8cc8bc8 100644 --- a/apps/miroflow-agent/src/llm/providers/openai_client.py +++ b/apps/miroflow-agent/src/llm/providers/openai_client.py @@ -53,6 +53,20 @@ DEFAULT_OPENAI_HTTP_TIMEOUT_SECONDS = 90.0 DEFAULT_OPENAI_SDK_MAX_RETRIES = 0 DEFAULT_TOOL_RESULT_MAX_CHARS = 4000 +# Round 7: on timeout, one degrade retry then fail-fast (no identical retries) +DEFAULT_TIMEOUT_FAIL_FAST = True +DEFAULT_TIMEOUT_DEGRADE_KEEP_TOOL_RESULTS = 2 +TIMEOUT_ERROR_TYPE_NAMES = frozenset( + { + "APITimeoutError", + "TimeoutException", + "ReadTimeout", + "ConnectTimeout", + "WriteTimeout", + "PoolTimeout", + "TimeoutError", + } +) @dataclasses.dataclass @@ -87,6 +101,29 @@ def __post_init__(self): "LLM_TOOL_RESULT_MAX_CHARS", DEFAULT_TOOL_RESULT_MAX_CHARS ) ) + cfg_timeout_fail_fast = self.cfg.llm.get("timeout_fail_fast") + if cfg_timeout_fail_fast is None: + env_ff = os.getenv("LLM_TIMEOUT_FAIL_FAST") + if env_ff is None: + self.timeout_fail_fast = DEFAULT_TIMEOUT_FAIL_FAST + else: + self.timeout_fail_fast = env_ff.strip().lower() in { + "1", + "true", + "yes", + "on", + } + else: + self.timeout_fail_fast = bool(cfg_timeout_fail_fast) + cfg_degrade_keep = self.cfg.llm.get("timeout_degrade_keep_tool_results") + self.timeout_degrade_keep_tool_results = ( + int(cfg_degrade_keep) + if cfg_degrade_keep is not None + else self._read_env_int( + "LLM_TIMEOUT_DEGRADE_KEEP_TOOL_RESULTS", + DEFAULT_TIMEOUT_DEGRADE_KEEP_TOOL_RESULTS, + ) + ) # Key 池轮转:优先从 OPENAI_API_KEYS 读取多 Key,回退到单 Key try: self._key_pool = KeyPool.from_env( @@ -149,6 +186,51 @@ def _sanitize_tool_result_text(self, text: str) -> str: return normalized_text return normalized_text[: self.tool_result_max_chars] + "...(truncated)" + @staticmethod + def _is_timeout_error(exc: BaseException) -> bool: + """Detect asyncio / OpenAI / httpx timeout errors (Round 7 metrics + fail-fast).""" + if isinstance(exc, (asyncio.TimeoutError, TimeoutError)): + return True + if type(exc).__name__ in TIMEOUT_ERROR_TYPE_NAMES: + return True + # Some SDKs wrap timeouts under APIConnectionError / RequestException + message = str(exc).lower() + return "timeout" in message or "timed out" in message + + def _record_timeout_metric(self, *, http: bool = True) -> None: + try: + self.task_log.run_metrics.timeout_count += 1 + if http: + self.task_log.run_metrics.http_timeout_count += 1 + except Exception: + pass + + def _record_llm_retry(self) -> None: + try: + self.task_log.run_metrics.record_llm_retry() + except Exception: + pass + + def _degrade_messages_for_timeout_retry( + self, messages: List[Dict[str, Any]] + ) -> List[Dict[str, Any]]: + """Shorter context after timeout: keep fewer tool results, truncate bodies.""" + keep = max(1, int(self.timeout_degrade_keep_tool_results)) + degraded = self._remove_tool_result_from_messages(messages, keep) + max_chars = max(800, int(self.tool_result_max_chars // 2)) + for message in degraded: + if not isinstance(message, dict): + continue + role = message.get("role") + content = message.get("content") + if ( + role in {"tool", "user"} + and isinstance(content, str) + and len(content) > max_chars + ): + message["content"] = content[:max_chars] + "...(timeout-degrade)" + return degraded + @staticmethod def _stringify_message_field(value: Any) -> str: if value is None: @@ -329,6 +411,7 @@ async def _create_message( attempt = 0 _429_streak = 0 # 连续 429 计数,达到 pool.size 时视为一轮完整失败 + timeout_degrade_used = False while attempt < max_retries: params = { "model": request_model_name, @@ -486,24 +569,6 @@ async def _create_message( # This ensures that the complete conversation history is preserved in logs return response, messages_history - except asyncio.TimeoutError as e: - self.task_log.run_metrics.timeout_count += 1 - if attempt < max_retries - 1: - self.task_log.log_step( - "warning", - "LLM | Timeout Error", - f"Timeout error (attempt {attempt + 1}/{max_retries}): {str(e)}, retrying...", - ) - await asyncio.sleep(base_wait_time) - attempt += 1 - continue - else: - self.task_log.log_step( - "error", - "LLM | Timeout Error", - f"Timeout error after {max_retries} attempts: {str(e)}", - ) - raise e except asyncio.CancelledError as e: self.task_log.log_step( "error", @@ -537,9 +602,17 @@ async def _create_message( # 一轮完整遍历后所有 Key 均 429,视为一次失败 _429_streak = 0 attempt += 1 + self._record_llm_retry() wait_time = self._key_pool.min_cooldown_remaining() - if wait_time > 0 and attempt < max_retries: - capped_wait = min(wait_time, 120.0) + if attempt >= max_retries: + self.task_log.log_step( + "error", + "LLM | Rate Limit Exhausted", + f"All keys exhausted after {max_retries} attempts: {str(e)}", + ) + raise e + capped_wait = min(max(wait_time, 0.0), 120.0) + if capped_wait > 0: self.task_log.log_step( "warning", "LLM | All Keys Rate Limited", @@ -547,18 +620,66 @@ async def _create_message( f"waiting {capped_wait:.1f}s for cooldown", ) await asyncio.sleep(capped_wait) - recovered_key = self._key_pool.next_available_key() - if recovered_key: - await self._rotate_client() - continue - if attempt >= max_retries: + recovered_key = self._key_pool.next_available_key() + if recovered_key: + await self._rotate_client() + continue + except Exception as e: + if self._is_timeout_error(e): + self._record_timeout_metric(http=True) + # Round 7 fail-fast: one shorter-context degrade, then stop + # identical timeout retries that burned ~385s on H3 Round 6. + if self.timeout_fail_fast: + if ( + not timeout_degrade_used + and agent_type not in SUMMARY_AGENT_TYPES + and agent_type not in VERIFICATION_AGENT_TYPES + ): + timeout_degrade_used = True + self._record_llm_retry() + messages_for_llm = self._degrade_messages_for_timeout_retry( + messages_for_llm + ) + self.task_log.log_step( + "warning", + "LLM | Timeout Degrade Retry", + ( + f"Timeout ({type(e).__name__}: {e}); " + "retrying once with shorter tool context " + f"(keep≤{self.timeout_degrade_keep_tool_results}), " + "then fail-fast." + ), + ) + await asyncio.sleep(min(base_wait_time, 2.0)) + attempt += 1 + continue + self.task_log.log_step( + "error", + "LLM | Timeout Error", + ( + f"Timeout after fail-fast path " + f"(attempt {attempt + 1}/{max_retries}, " + f"degrade_used={timeout_degrade_used}): {e}" + ), + ) + raise e + if attempt < max_retries - 1: + self._record_llm_retry() + self.task_log.log_step( + "warning", + "LLM | Timeout Error", + f"Timeout error (attempt {attempt + 1}/{max_retries}): {e}, retrying...", + ) + await asyncio.sleep(base_wait_time) + attempt += 1 + continue self.task_log.log_step( "error", - "LLM | Rate Limit Exhausted", - f"All keys exhausted after {max_retries} attempts: {str(e)}", + "LLM | Timeout Error", + f"Timeout error after {max_retries} attempts: {e}", ) raise e - except Exception as e: + if "Error code: 400" in str(e) and "longer than the model" in str(e): self.task_log.log_step( "error", @@ -568,6 +689,7 @@ async def _create_message( raise e else: if attempt < max_retries - 1: + self._record_llm_retry() self.task_log.log_step( "warning", "LLM | API Error", diff --git a/apps/miroflow-agent/tests/test_llm_timeout_fail_fast.py b/apps/miroflow-agent/tests/test_llm_timeout_fail_fast.py new file mode 100644 index 0000000..adde2a0 --- /dev/null +++ b/apps/miroflow-agent/tests/test_llm_timeout_fail_fast.py @@ -0,0 +1,128 @@ +# Copyright (c) 2025 MiroMind +"""Round 7: timeout fail-fast + degrade-once + metrics.""" + +from __future__ import annotations + +import sys +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest +from omegaconf import OmegaConf + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from src.llm.providers.openai_client import OpenAIClient # noqa: E402 +from src.logging.task_logger import RunMetrics # noqa: E402 + + +def _make_client(**llm_overrides) -> OpenAIClient: + llm = { + "provider": "openai", + "model_name": "glm-5.3-flash", + "api_key": "test-key", + "base_url": "https://example.invalid/v1", + "async_client": True, + "temperature": 0.3, + "top_p": 1.0, + "min_p": 0.0, + "top_k": 50, + "max_tokens": 1024, + "max_context_length": 128000, + "max_retries": 4, + "retry_wait_seconds": 0.01, + "timeout_fail_fast": True, + "timeout_degrade_keep_tool_results": 2, + } + llm.update(llm_overrides) + cfg = OmegaConf.create( + { + "llm": llm, + "agent": {"keep_tool_result": 5}, + } + ) + task_log = MagicMock() + task_log.run_metrics = RunMetrics() + task_log.log_step = MagicMock() + task_log.record_stage_timing = MagicMock() + client = OpenAIClient(task_id="t-timeout", cfg=cfg, task_log=task_log) + client._remove_tool_result_from_messages = MagicMock( + side_effect=lambda msgs, keep: [dict(m) for m in msgs] + ) + return client + + +def test_is_timeout_error_detects_openai_and_httpx_names(): + assert OpenAIClient._is_timeout_error(TimeoutError("boom")) + assert OpenAIClient._is_timeout_error( + type("APITimeoutError", (Exception,), {})("timed out") + ) + assert OpenAIClient._is_timeout_error(Exception("Request timed out")) + assert not OpenAIClient._is_timeout_error(Exception("rate limited")) + + +@pytest.mark.asyncio +async def test_timeout_fail_fast_degrades_once_then_raises(): + client = _make_client() + calls = {"n": 0} + + class _FakeAPITimeout(Exception): + pass + + async def _boom(**_kwargs): + calls["n"] += 1 + raise _FakeAPITimeout("Request timed out after 90s") + + client.async_client = True + client.client = SimpleNamespace( + chat=SimpleNamespace(completions=SimpleNamespace(create=_boom)) + ) + client.convert_tool_definition_to_tool_call = MagicMock(return_value=[]) + + with pytest.raises(_FakeAPITimeout): + await client._create_message( + system_prompt="sys", + messages_history=[ + {"role": "user", "content": "q"}, + {"role": "tool", "content": "x" * 5000, "tool_call_id": "1"}, + ], + tools_definitions=[], + keep_tool_result=5, + agent_type="main", + ) + + assert calls["n"] == 2 # original + one degrade retry + assert client.task_log.run_metrics.timeout_count == 2 + assert client.task_log.run_metrics.http_timeout_count == 2 + assert client.task_log.run_metrics.llm_retry_count == 1 + client._remove_tool_result_from_messages.assert_called() + + +@pytest.mark.asyncio +async def test_timeout_fail_fast_disabled_uses_normal_retries(): + client = _make_client(timeout_fail_fast=False, max_retries=3) + calls = {"n": 0} + + async def _boom(**_kwargs): + calls["n"] += 1 + raise TimeoutError("asyncio timeout") + + client.async_client = True + client.client = SimpleNamespace( + chat=SimpleNamespace(completions=SimpleNamespace(create=_boom)) + ) + + with pytest.raises(TimeoutError): + await client._create_message( + system_prompt="sys", + messages_history=[{"role": "user", "content": "q"}], + tools_definitions=[], + keep_tool_result=5, + agent_type="main", + ) + + assert calls["n"] == 3 + assert client.task_log.run_metrics.timeout_count == 3 From 50166d75694ee5bd66b0feae0ae256f311ecd073 Mon Sep 17 00:00:00 2001 From: rainwalker Date: Tue, 22 Sep 2026 11:31:03 +0800 Subject: [PATCH 04/26] =?UTF-8?q?feat(report):=20=E2=9C=A8=20=E7=BB=93?= =?UTF-8?q?=E6=9E=84=E9=97=A8=E4=B8=8E=E4=BA=BA=E7=B1=BB=E5=8F=AF=E8=AF=BB?= =?UTF-8?q?=E5=91=88=E7=8E=B0=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/core/answer_generator.py | 225 ++++- .../miroflow-agent/src/io/output_formatter.py | 51 +- .../src/io/report_presentation.py | 769 ++++++++++++++++++ .../miroflow-agent/src/io/report_structure.py | 636 +++++++++++++++ .../test_answer_generator_summary_passes.py | 85 ++ .../tests/test_oneshot_final_report.py | 184 +++++ .../tests/test_report_presentation.py | 216 +++++ .../tests/test_report_structure_conflicts.py | 287 +++++++ 8 files changed, 2428 insertions(+), 25 deletions(-) create mode 100644 apps/miroflow-agent/src/io/report_presentation.py create mode 100644 apps/miroflow-agent/src/io/report_structure.py create mode 100644 apps/miroflow-agent/tests/test_answer_generator_summary_passes.py create mode 100644 apps/miroflow-agent/tests/test_oneshot_final_report.py create mode 100644 apps/miroflow-agent/tests/test_report_presentation.py create mode 100644 apps/miroflow-agent/tests/test_report_structure_conflicts.py diff --git a/apps/miroflow-agent/src/core/answer_generator.py b/apps/miroflow-agent/src/core/answer_generator.py index 7216cdd..62b77da 100644 --- a/apps/miroflow-agent/src/core/answer_generator.py +++ b/apps/miroflow-agent/src/core/answer_generator.py @@ -20,7 +20,12 @@ from omegaconf import DictConfig from ..io.output_formatter import OutputFormatter -from ..llm.base_client import BaseClient +from ..io.report_structure import ReportStructureValidator +from ..llm.base_client import ( + SUMMARY_AGENT_TYPES, + OMITTED_TOOL_RESULT_TEXT, + BaseClient, +) from ..logging.task_logger import TaskLog from ..utils.parsing_utils import ( extract_failure_experience_summary, @@ -34,6 +39,13 @@ generate_agent_summarize_prompt, ) from ..utils.wrapper_utils import ErrorBox, ResponseBox +from .deep_efficiency import ( + resolve_max_final_answer_retries, + resolve_oneshot_final_report, + resolve_research_intensity, + resolve_summary_keep_tool_result, + resolve_summary_max_tokens_cap, +) from .stream_handler import StreamHandler logger = logging.getLogger(__name__) @@ -158,13 +170,13 @@ def __init__( # Context management settings self.context_compress_limit = cfg.agent.get("context_compress_limit", 0) + # Round 7: deep/research defaults to 1 summary pass; explicit cfg still wins try: - configured_final_answer_retries = int( - cfg.agent.get( - "max_final_answer_retries", - DEFAULT_MAX_FINAL_ANSWER_RETRIES, - ) - ) + explicit_retries = cfg.agent.get("max_final_answer_retries", None) + if explicit_retries is not None: + configured_final_answer_retries = int(explicit_retries) + else: + configured_final_answer_retries = resolve_max_final_answer_retries(cfg) except (TypeError, ValueError): configured_final_answer_retries = DEFAULT_MAX_FINAL_ANSWER_RETRIES self.max_final_answer_retries = max(1, configured_final_answer_retries) @@ -201,6 +213,35 @@ def __init__( str(domain).strip().lower() for domain in raw_domains if str(domain).strip() ] + # Round 8: oneshot skeleton final report + summary-stage context cap + self.oneshot_final_report = resolve_oneshot_final_report(cfg) + # 只有 deep 档把核验折叠进骨架提示词:standard 档保留高模型核验轮与 + # 交叉校验说明,避免默认档为省一轮调用而降质 + self.fold_verification_into_summary = ( + self.oneshot_final_report + and resolve_research_intensity(cfg) == "deep" + ) + # 骨架提示词只服务详细报告;其他档位不得因此丢掉“过短扩写”重试 + self.oneshot_skeleton_active = ( + self.oneshot_final_report + and self.research_report_mode + and self.output_detail_level == "detailed" + ) + try: + research_keep = int(cfg.agent.get("keep_tool_result", -1)) + except (TypeError, ValueError): + research_keep = -1 + self.summary_keep_tool_result = resolve_summary_keep_tool_result( + cfg, research_keep=research_keep + ) + summary_cap = resolve_summary_max_tokens_cap(cfg) + if summary_cap is not None: + try: + current = int(self.llm_client.summary_max_tokens) + except (TypeError, ValueError): + current = summary_cap + self.llm_client.summary_max_tokens = min(current, summary_cap) + # 阶段心跳同名去重,避免高频心跳刷 stderr self._last_stage_log_key: Optional[Tuple[Any, ...]] = None @@ -245,7 +286,35 @@ def _build_main_summary_prompt(self, task_description: str) -> str: 根据输出档位和运行模式构建最终总结提示词。 research_report_mode=true 时,覆盖短答案模板约束,强制按研究报告输出。 + Round 8 oneshot: fill a strict skeleton once; local enforce_structure patches gaps. """ + if self.oneshot_skeleton_active: + template = ReportStructureValidator.get_structure_template("detailed") + summary_prompt = ( + f"Task:\n{task_description}\n\n" + "ONE-SHOT structured research report (最高优先级,Round 8):\n" + "1) 仅用一轮输出填满下方骨架;禁止多轮重写/扩写;结构缺口由本地补丁修复。\n" + "2) 只依据对话中仍保留的完整工具结果(较早的已省略,勿假装读过)。" + "禁止编造来源、数字或共识。\n" + "3) 正文字数目标约 2500–6000 中文字符:优先覆盖硬性章节与可核验事实," + "而非堆砌全文转写。\n" + "4) 必须保留独立一级标题:" + "TL;DR(含置信度)、Conflicts & Uncertainties / 冲突与不确定、" + "Timeline / 时间线、Evidence / 证据、Confirmed vs Unconfirmed / 已确认 vs 未确认、" + "References。\n" + "5) Conflicts 硬性必填:并列各方说法;信息不足时写明“无法核实/单方宣称”。\n" + "6) Evidence 中每个数字带来源与日期;无来源数字不得写出。\n" + "7) 结尾额外给出一条 \\boxed{一句话核心结论}。\n\n" + "骨架(按此填空,可微调标题中英别名但勿删节):\n" + f"{template}" + ) + if self.verification_enabled: + summary_prompt += ( + "\n\n核验约束:绝对时间锚点;冲突给区间而非伪造单值;" + "优先高置信来源并标明等级。" + ) + return summary_prompt + summary_prompt = generate_agent_summarize_prompt( task_description, agent_type="main", @@ -262,10 +331,23 @@ def _build_main_summary_prompt(self, task_description: str) -> str: " - 绝对禁止为了控制篇幅而省略、压缩或概括检索到的具体信息。\n" f"3) 正文字数目标:在信息充分时不少于 {RESEARCH_DETAILED_TARGET_MIN_CHARS} 个中文字符。最终报告必须比任何单轮检索输出都更长、更完整。\n" f"4) 至少包含 {RESEARCH_DETAILED_MIN_SECTIONS} 个一级小节,且每节提供可核验事实、时间锚点与来源线索。\n" - "5) 必须包含:关键结论速览、详细时间线、关键数字表(含来源)、已知/不确定/冲突信息对照、深度背景分析、风险与后续观察。\n" + "5) 必须使用以下一级标题(中英均可,不得省略):\n" + " - `## TL;DR / 结论(标明置信度)`\n" + " - `## 冲突与不确定 / Conflicts & Uncertainties`" + "(**硬性必填**:列出各方说法冲突、口径差异;" + "即使信息不足也要写明“无法核实/单方宣称”,禁止消抹分歧或伪造共识)\n" + " - `## 时间线 / Timeline`(绝对日期)\n" + " - `## Evidence` / `## 证据` / `## 临床证据` / " + "`## 证据与来源`(证据含来源与日期;数字无来源不得写出)\n" + " - `## 已确认 vs 未确认 / Confirmed vs Unconfirmed`\n" + " - 另含关键数字表、深度背景、风险与后续观察、References\n" "6) 每个要点必须展开论述,包含具体描述、原因背景、数据证据、来源引用,而非一句话带过。\n" "7) 禁止空泛总结;若数据不足,明确缺口、已尝试口径和下一步补证方案。\n" - "8) 结尾额外给出一条 \\boxed{一句话核心结论},用于结构化提取;但正文必须完整保留。" + "8) 热点事件交叉核验:对相互矛盾的宣称并列呈现,不要偏信单方;置信度必须显式标注。\n" + "9) 效率提示:同一回合可并行发起多个互不依赖的搜索;优先用搜索摘要," + "仅对冲突关键 URL 全文抓取;若同一争议点已有≥2个独立来源且 Conflicts 可成文," + "停止追线索并直接写报告。\n" + "10) 结尾额外给出一条 \\boxed{一句话核心结论},用于结构化提取;但正文必须完整保留。" ) elif self.output_detail_level == "balanced": summary_prompt += ( @@ -288,11 +370,14 @@ def _build_main_summary_prompt(self, task_description: str) -> str: summary_prompt += ( "\n\nDetailed Output Requirements:\n" "1) Provide a complete, structured report with clear section headings.\n" - "2) Include a timeline/time anchor section using absolute dates.\n" - "3) Include a 'Key Figures' section with as many verifiable numbers as available.\n" - "4) Include 'What is known / uncertain / conflicting' sections.\n" - "5) Include actionable next steps or monitoring points when applicable.\n" - "6) Do not be overly concise; prioritize completeness and traceable detail." + "2) REQUIRED heading: `## TL;DR` with explicit confidence (high/medium/low).\n" + "3) REQUIRED heading: `## Conflicts & Uncertainties` — surface multi-source " + "disagreements explicitly; never invent consensus.\n" + "4) REQUIRED heading: `## Timeline` with absolute dates.\n" + "5) REQUIRED heading: `## Evidence` — every number needs a source+date.\n" + "6) REQUIRED heading: `## Confirmed vs Unconfirmed`.\n" + "7) Include actionable next steps or monitoring points when applicable.\n" + "8) Do not be overly concise; prioritize completeness and traceable detail." ) elif self.output_detail_level == "compact": summary_prompt += ( @@ -310,6 +395,47 @@ def _build_main_summary_prompt(self, task_description: str) -> str: ) return summary_prompt + @staticmethod + def _message_text_content(message: Dict[str, Any]) -> str: + """Flatten message content to plain text for omission checks.""" + content = message.get("content") + if isinstance(content, str): + return content + if isinstance(content, list): + parts: List[str] = [] + for item in content: + if isinstance(item, dict) and item.get("type") == "text": + parts.append(str(item.get("text") or "")) + elif isinstance(item, str): + parts.append(item) + return "\n".join(parts) + return "" + + def _strip_omitted_tool_stubs( + self, message_history: List[Dict[str, Any]] + ) -> List[Dict[str, Any]]: + """Remove omitted-tool placeholders so final summary does not re-digest stubs. + + Round 8: once a dump was condensed away, do not ask the model to + summarize the omission marker again. + """ + cleaned: List[Dict[str, Any]] = [] + for msg in message_history: + text = self._message_text_content(msg).strip() + if text == OMITTED_TOOL_RESULT_TEXT: + continue + cleaned.append(msg) + return cleaned + + def _resolve_keep_tool_result_for_call(self, agent_type: str) -> int: + """Research turns use agent.keep_tool_result; summary uses Round-8 cap.""" + if agent_type in SUMMARY_AGENT_TYPES: + return self.summary_keep_tool_result + try: + return int(self.cfg.agent.keep_tool_result) + except (TypeError, ValueError, AttributeError): + return -1 + def _get_summary_retry_min_chars(self) -> int: """ 返回当前档位触发“总结过短重试”的最小字符阈值。 @@ -325,12 +451,40 @@ def _get_summary_retry_min_chars(self) -> int: def _is_summary_too_short(self, final_answer_text: str) -> bool: """ 判断最终总结是否短于当前档位阈值。 + + Round 7: if structure already has Conflicts/Evidence/Confirmed cues, + skip length-driven LLM re-summarization (local enforce_structure is enough). + Round 8 oneshot: never expand-rewrite when the skeleton prompt is in use; + surgical patch only. """ + if self.oneshot_skeleton_active: + return False if self.summary_retry_min_chars <= 0: return False display_text = self.output_formatter.clean_final_answer_text(final_answer_text) normalized_text = "".join(display_text.split()) - return len(normalized_text) < self.summary_retry_min_chars + if len(normalized_text) >= self.summary_retry_min_chars: + return False + # Prefer single-pass + local repair when hard sections are already present + lower = display_text.lower() + has_conflicts = ( + "conflicts" in lower or "冲突" in display_text or "不确定" in display_text + ) + has_evidence = "evidence" in lower or "证据" in display_text + has_confirmed = ( + "confirmed" in lower or "已确认" in display_text or "可确认" in display_text + ) + if has_conflicts and has_evidence and has_confirmed: + self.task_log.log_step( + "info", + "Main Agent | Final Answer", + ( + "Summary under length threshold but required sections present; " + "accepting without expand retry (Round 7 single-pass)." + ), + ) + return False + return True def _build_expand_summary_prompt(self) -> str: """ @@ -342,10 +496,12 @@ def _build_expand_summary_prompt(self) -> str: "1) 逐一检查每轮检索结果,确保每轮中的每个独立事实、数据、引述都在报告中体现。\n" "2) 全量保留所有信息,通过去重整合(而非压缩精简)来组织内容。\n" "3) 多轮重复信息合并为最完整版本,不同角度的互补信息全部保留。\n" - "4) 明确区分:核心结论、关键数字(含来源)、来源分歧、不确定项、深度背景。\n" - "5) 每个要点展开论述,包含具体描述、原因背景、数据证据,而非一句话概括。\n" - "6) 最终报告必须比任何单轮检索输出都更长、更完整。\n" - "7) 结尾保留 \\boxed{一句话核心结论}。" + "4) 必须包含独立章节:`冲突与不确定 / Conflicts & Uncertainties`、" + "`时间线 / Timeline`、`已确认 vs 未确认`;禁止消抹多方冲突。\n" + "5) 明确区分:核心结论(含置信度)、关键数字(含来源)、来源分歧、不确定项、深度背景。\n" + "6) 每个要点展开论述,包含具体描述、原因背景、数据证据,而非一句话概括。\n" + "7) 最终报告必须比任何单轮检索输出都更长、更完整。\n" + "8) 结尾保留 \\boxed{一句话核心结论}。" ) return ( "你的上一版总结偏短,请在保持结构清晰的前提下补充关键信息:\n" @@ -485,7 +641,9 @@ async def handle_llm_call( system_prompt=system_prompt, message_history=message_history, tool_definitions=tool_definitions, - keep_tool_result=self.cfg.agent.keep_tool_result, + keep_tool_result=self._resolve_keep_tool_result_for_call( + agent_type + ), step_id=step_id, task_log=self.task_log, agent_type=agent_type, @@ -560,6 +718,8 @@ async def handle_llm_call( except asyncio.TimeoutError: elapsed_ms = int((time.perf_counter() - llm_call_start_time) * 1000) + self.task_log.run_metrics.timeout_count += 1 + self.task_log.run_metrics.wall_timeout_count += 1 self.task_log.record_stage_timing( f"answer_generator.llm_call.{agent_type}", elapsed_ms, @@ -719,13 +879,24 @@ async def generate_final_answer_with_retries( usage_log, message_history, result_quality) """ # Generate summary prompt - if self.verification_enabled: + if self.verification_enabled and not self.fold_verification_into_summary: message_history = await self.generate_cross_verification_note( system_prompt=system_prompt, message_history=message_history, turn_count=turn_count, task_description=task_description, ) + elif self.verification_enabled and self.fold_verification_into_summary: + self.task_log.log_step( + "info", + "Main Agent | Final Answer", + "Skipping separate verification LLM pass (Round 8 deep oneshot; " + "constraints folded into summary prompt).", + ) + + # Round 8: drop omitted stubs before oneshot summary so we never + # re-summarize condensed placeholders. + message_history = self._strip_omitted_tool_stubs(message_history) summary_prompt = self._build_main_summary_prompt(task_description) @@ -752,7 +923,11 @@ async def generate_final_answer_with_retries( } final_summary_agent_types = ( ["verification", "final_summary"] - if self.verification_enabled and self.verification_use_high_model + if ( + self.verification_enabled + and self.verification_use_high_model + and not self.fold_verification_into_summary + ) else ["final_summary"] ) @@ -781,10 +956,14 @@ async def generate_final_answer_with_retries( f"Main agent | Final Summary (attempt {retry_idx + 1}/{self.max_final_answer_retries})", agent_type=current_agent_type, ) + self.task_log.run_metrics.record_summary_pass() if final_answer_text: payload = self.output_formatter.format_final_summary_payload( - final_answer_text, self.llm_client + final_answer_text, + self.llm_client, + detail_level=self.output_detail_level, + validate_structure=True, # Always validate structure (Phase 2) ) final_summary = payload["summary"] final_boxed_answer = payload["boxed_answer"] diff --git a/apps/miroflow-agent/src/io/output_formatter.py b/apps/miroflow-agent/src/io/output_formatter.py index e63c5c3..3520c62 100644 --- a/apps/miroflow-agent/src/io/output_formatter.py +++ b/apps/miroflow-agent/src/io/output_formatter.py @@ -7,6 +7,7 @@ from typing import Tuple from ..utils.prompt_utils import FORMAT_ERROR_MESSAGE +from .report_structure import ReportStructureValidator # Maximum length for tool results before truncation (100k chars ≈ 25k tokens) TOOL_RESULT_MAX_LENGTH = 100_000 @@ -223,7 +224,13 @@ def format_final_summary_and_log( return "\n".join(summary_lines), boxed_result, log_string - def format_final_summary_payload(self, final_answer_text: str, client=None) -> dict: + def format_final_summary_payload( + self, + final_answer_text: str, + client=None, + detail_level: str = "balanced", + validate_structure: bool = True, + ) -> dict: """格式化最终摘要并返回结构化质量信息。 与 format_final_summary_and_log() 的区别:返回值包含 quality 字典, @@ -232,10 +239,13 @@ def format_final_summary_payload(self, final_answer_text: str, client=None) -> d Args: final_answer_text: 模型的最终回答文本 client: 可选的 LLM 客户端(用于 token 统计) + detail_level: 输出篇幅档位 (compact/balanced/detailed) + validate_structure: 是否验证报告结构 Returns: dict with keys: summary, boxed_answer, usage_log, quality - quality: {"format_valid": bool, "fallback_used": bool, "issues": list} + quality: {"format_valid": bool, "fallback_used": bool, "issues": list, + "structure_valid": bool, "structure_issues": list} """ display_text = self.clean_final_answer_text(final_answer_text) @@ -275,11 +285,48 @@ def format_final_summary_payload(self, final_answer_text: str, client=None) -> d if not quality["format_valid"] and not quality["fallback_used"]: quality["issues"].append("no_answer_available") + # Structure validation (Phase 2) + quality["structure_valid"] = False + quality["structure_issues"] = [] + quality["structure_metadata"] = {} + + if validate_structure and display_text: + struct_valid, struct_issues, struct_meta = ( + ReportStructureValidator.validate_structure(display_text, detail_level) + ) + quality["structure_valid"] = struct_valid + quality["structure_issues"] = struct_issues + quality["structure_metadata"] = struct_meta + + # Attempt to fix structure if invalid + if not struct_valid and boxed_result: + fixed_text = ReportStructureValidator.enforce_structure( + display_text, detail_level + ) + if fixed_text != display_text: + # Re-validate after fix + struct_valid_fixed, struct_issues_fixed, struct_meta_fixed = ( + ReportStructureValidator.validate_structure( + fixed_text, detail_level + ) + ) + if struct_valid_fixed or len(struct_issues_fixed) < len( + struct_issues + ): + display_text = fixed_text + quality["structure_valid"] = struct_valid_fixed + quality["structure_issues"] = struct_issues_fixed + quality["structure_metadata"] = struct_meta_fixed + quality["issues"].append("structure_auto_fixed") + # Token usage statistics if client and hasattr(client, "format_token_usage_summary"): token_summary_lines, _ = client.format_token_usage_summary() summary_lines.extend(token_summary_lines) + # Update summary_lines with potentially fixed text + summary_lines[1] = display_text + return { "summary": "\n".join(summary_lines), "boxed_answer": boxed_result, diff --git a/apps/miroflow-agent/src/io/report_presentation.py b/apps/miroflow-agent/src/io/report_presentation.py new file mode 100644 index 0000000..55a948a --- /dev/null +++ b/apps/miroflow-agent/src/io/report_presentation.py @@ -0,0 +1,769 @@ +"""User-facing report presentation: strip diagnostics, fix refs, compact leads, optional topology.""" + +from __future__ import annotations + +import re +from typing import List + +# Final Answer header only — keep the body text that follows. +_FINAL_ANSWER_HEADER_RE = re.compile(r"(?m)^\s*={5,}\s*Final Answer\s*={5,}\s*\n?") +# Truncate from Extracted Result / Token Usage section headers through EOF +# (those sections duplicate or are CLI/billing noise). +_DIAGNOSTIC_TAIL_RE = re.compile( + r"\n?[=\-]{5,}\s*(?:Extracted Result|Token Usage(?:\s*&\s*Cost)?)\s*[=\-]{5,}.*\Z", + re.DOTALL, +) +_PRICING_NOISE_RE = re.compile( + r"(?ms)" r"(?:^|\n)-{5,}.*?\b(?:Pricing is disabled|Total Input Tokens)\b.*" +) +_EXTRACTED_NOTE_RE = re.compile( + r"(?m)^\(Note: model did not use " + re.escape(r"\boxed{}") + r" format;.*$\n?" +) +_BARE_INCOMPLETE_REF_LINE_RE = re.compile(r"(?m)^(?:\d+\.\s*)?(?:https?://(?:www\.)?)$") +# reshape_report_for_consumer always emits these two markers; their presence +# means the text already went through this pipeline (see prepare_*). +_PREPARED_CONCLUSION_RE = re.compile(r"(?m)^##\s*结论\s*$") +_PREPARED_CONFIDENCE_RE = re.compile( + r"(?m)^\s*$" +) +# 参考文献章节标题(正文引用号是否可压缩取决于该章节的条目写法) +_REFERENCES_HEADING_ANY_RE = re.compile( + r"(?im)^#{1,6}\s*(?:参考文献|参考资料|参考来源|引用|references?|sources?)\s*$" +) + + +def strip_diagnostic_noise(text: str) -> str: + """Remove CLI/eval diagnostic wrappers from a final summary. + + Keeps the Final Answer *body* (the actual report). Only strips the + ``===== Final Answer =====`` banner, then truncates from + Extracted Result / Token Usage tails (duplicates + billing noise). + """ + if not text: + return text + cleaned = _FINAL_ANSWER_HEADER_RE.sub("", text, count=1) + cleaned = _DIAGNOSTIC_TAIL_RE.sub("", cleaned) + cleaned = _PRICING_NOISE_RE.sub("\n", cleaned) + cleaned = _EXTRACTED_NOTE_RE.sub("", cleaned) + # Leftover bare headers if tail regex missed a variant + cleaned = re.sub(r"(?m)^\s*={5,}\s*Final Answer\s*={5,}\s*$", "", cleaned) + cleaned = re.sub(r"(?m)^\s*-{5,}\s*Extracted Result\s*-{5,}\s*$", "", cleaned) + return cleaned.strip() + ("\n" if cleaned.strip() else "") + + +def drop_incomplete_reference_lines(text: str) -> str: + """Drop reference lines that clearly truncated mid-URL.""" + if not text: + return text + lines = text.splitlines() + kept: List[str] = [] + for line in lines: + stripped = line.strip() + # dangling "https://www" / "http://a." style stubs + if re.search(r"https?://\S+$", stripped): + url = re.search(r"https?://\S+$", stripped) + u = url.group(0) if url else "" + # incomplete if host missing TLD or ends with bare www. + if re.fullmatch(r"https?://(?:www\.)?", u) or re.fullmatch( + r"https?://[\w\-]+", u + ): + continue + if u.rstrip("/").count(".") == 0 and "://" in u: + continue + if _BARE_INCOMPLETE_REF_LINE_RE.match(stripped): + continue + # 路径被省略号截断(``…/ztzl/.../post_1.html``)的条目点开即 404,按残缺链接丢弃 + if re.search(r"https?://\S*(?:\.\.\.|…)", stripped): + continue + kept.append(line) + # Also drop a truncated URL glued to text at end of line (seen in R8) + out = "\n".join(kept) + out = re.sub(r"(?m)https?://(?:www\.)?[ \t]*$", "", out) + return out + + +def compact_pending_lead_trail(text: str) -> str: + """Rewrite pending-only lead trails into a short Chinese summary.""" + if not text: + return text + if "线索追踪" not in text and "Lead Trail" not in text: + return text + + parts = re.split(r"(?m)(?=^##\s*(?:线索追踪|Lead Trail))", text, maxsplit=1) + if len(parts) < 2: + return text + head, trail = parts[0], parts[1] + + pending = list( + re.finditer( + r"(?ms)^### Lead \d+:\s*(.+?)$.*?^\*\*状态\*\*:\s*pending\s*$", + trail, + ) + ) + followed = list( + re.finditer( + r"(?ms)^### Lead \d+:\s*(.+?)$.*?^\*\*状态\*\*:\s*followed\s*$", + trail, + ) + ) + + # If there are followed leads, keep followed detail and compact pending + if followed: + # rebuild: keep followed blocks, summarize pending + followed_blocks = [] + for m in re.finditer( + r"(?ms)^### Lead \d+:.*?^\*\*状态\*\*:\s*followed\s*(?:\n\*\*追踪轮次\*\*:.*$)?(?:\n\n\*\*发现\*\*:.*?)?(?=\n### Lead |\Z)", + trail, + ): + followed_blocks.append(m.group(0).strip()) + lines = ["## 线索追踪 / Lead Trail\n", "以下是已跟进线索;未跟进项已折叠。\n"] + for b in followed_blocks: + lines.append(b) + lines.append("") + if pending: + lines.append("### 未跟进线索(摘要)") + for i, m in enumerate(pending, 1): + q = m.group(1).strip() + if len(q) > 80: + q = q[:77] + "…" + lines.append(f"{i}. {q} — **未跟进**") + lines.append("") + return head.rstrip() + "\n\n" + "\n".join(lines).rstrip() + "\n" + + # pending-only: replace whole trail + if pending: + lines = [ + "## 线索追踪 / Lead Trail\n", + "本轮未实际跟进额外线索(种子线索仍为 pending,不视为报告未写完)。\n", + "### 未跟进线索(摘要)", + ] + for i, m in enumerate(pending, 1): + q = m.group(1).strip() + if len(q) > 80: + q = q[:77] + "…" + lines.append(f"{i}. {q} — **未跟进**") + lines.append("") + return head.rstrip() + "\n\n" + "\n".join(lines).rstrip() + "\n" + + return text + + +def _extract_conflict_bullets(text: str, limit: int = 4) -> List[str]: + m = re.search( + r"(?ims)^##[^\n]*(?:冲突|Conflicts)[^\n]*\n(.*?)(?=^##\s|\Z)", + text, + ) + if not m: + return [] + bullets = [] + for line in m.group(1).splitlines(): + s = line.strip() + if s.startswith(("-", "*", "•")): + bullets.append(re.sub(r"^[-*•]\s*", "", s)) + if len(bullets) >= limit: + break + return bullets + + +def ensure_content_analysis_and_topology( + text: str, *, detail_level: str = "detailed" +) -> str: + """Append Content Analysis + Mermaid topology when conflicts exist and sections missing.""" + if not text or detail_level == "compact": + return text + lower = text.lower() + has_analysis = ( + "内容分析" in text + or "content analysis" in lower + or re.search(r"(?im)^##\s*内容分析", text) is not None + ) + has_topo = "关系拓扑" in text or "relationship map" in lower or "```mermaid" in text + bullets = _extract_conflict_bullets(text) + extras: List[str] = [] + + if not has_analysis and bullets: + extras.append("## 内容分析 / Content Analysis\n") + extras.append( + "围绕争议点拆解:各方主张、可核对证据、仍不确定处。" + "下表为冲突要点摘要(由结构后处理生成,需结合正文证据阅读)。\n" + ) + for i, b in enumerate(bullets, 1): + extras.append(f"{i}. {b}") + extras.append("") + + if not has_topo and bullets: + # Build a small mermaid graph; sanitize node labels + def nid(i: int) -> str: + return f"C{i}" + + def lab(s: str) -> str: + s = re.sub(r"[\"\[\]]", "", s) + s = s.replace("\n", " ") + return (s[:36] + "…") if len(s) > 36 else s + + extras.append("## 关系拓扑 / Relationship Map\n") + extras.append("```mermaid") + extras.append("flowchart TB") + extras.append(' Q["议题争议"]') + for i, b in enumerate(bullets, 1): + extras.append(f' {nid(i)}["{lab(b)}"]') + extras.append(f" Q --> {nid(i)}") + extras.append(' G["证据缺口 / 待核实"]') + if bullets: + extras.append(f" {nid(1)} -.-> G") + extras.append("```") + extras.append("") + + if not extras: + return text + # Insert before References / Lead Trail if present + insert_at = None + for marker in ( + r"(?im)^##\s*References\b", + r"(?im)^##\s*参考文献\b", + r"(?im)^##\s*线索追踪\b", + r"(?im)^##\s*Lead Trail\b", + ): + m = re.search(marker, text) + if m: + insert_at = m.start() + break + block = "\n".join(extras).rstrip() + "\n\n" + if insert_at is None: + return text.rstrip() + "\n\n" + block + return text[:insert_at].rstrip() + "\n\n" + block + text[insert_at:].lstrip() + + +def strip_duplicate_trailing_conclusion(text: str) -> str: + """Remove a second Conclusion/结论 block that appears after References. + + Live Gradio runs sometimes append another ## Conclusion that mostly + repeats the citation list (and may truncate the last URL). Keep the + first conclusion body; drop trailing duplicates after References. + """ + if not text: + return text + # Find References / 参考文献 heading + ref_m = re.search(r"(?im)^##\s*(References|参考文献)\b.*$", text) + if not ref_m: + return text + after = text[ref_m.end() :] + # Any Conclusion after References is treated as duplicate trailer + dup = re.search(r"(?im)^##\s*(Conclusion|结论|总结)\b.*$", after) + if not dup: + return text + # Keep References section up to (not including) the duplicate conclusion + kept = text[: ref_m.end() + dup.start()].rstrip() + "\n" + return kept + + +def renumber_citations(text: str) -> str: + """Compact citation numbers to 1..N by first-appearance order. + + Keeps links usable when the model skips ids (e.g. missing [4]/[8]). + Rewrites both body markers like [3] and matching References list markers. + """ + if not text: + return text + # References 为有序列表(``1. 标题``)时条目编号即正文引用号:压缩正文编号会 + # 让正文与条目错位(正文 [2] 指向第 3 条),此时保持模型原编号。 + ref_m = _REFERENCES_HEADING_ANY_RE.search(text) + if ref_m and re.search(r"(?m)^\s*\d{1,4}[.)]\s+\S", text[ref_m.end() :]): + return text + # Collect ids in order of first appearance (body + refs) + found: list[int] = [] + seen: set[int] = set() + for m in re.finditer(r"\[(\d+)\]", text): + n = int(m.group(1)) + if n not in seen: + seen.add(n) + found.append(n) + if not found or found == list(range(1, len(found) + 1)): + return text + mapping = {old: i for i, old in enumerate(found, 1)} + + def _sub(m: re.Match[str]) -> str: + old = int(m.group(1)) + return f"[{mapping.get(old, old)}]" + + return re.sub(r"\[(\d+)\]", _sub, text) + + +def _split_markdown_sections(text: str) -> List[tuple[str, str]]: + """Split markdown into [(heading_without_hashes_or_empty, body), ...].""" + if not text: + return [] + parts = re.split(r"(?m)^(##\s+.+)$", text) + sections: List[tuple[str, str]] = [] + # parts[0] is preamble before first ## + if parts and parts[0].strip(): + sections.append(("", parts[0].strip())) + i = 1 + while i < len(parts): + heading = parts[i].strip() + body = parts[i + 1].strip() if i + 1 < len(parts) else "" + sections.append((heading, body)) + i += 2 + return sections + + +def _section_kind(heading: str) -> str: + h = heading.lower() + # Prefer explicit direct-answer headings as glance (before broader rules). + if re.search(r"直接答案|direct\s*answer|答案", h, re.I): + return "glance" + if re.search(r"tl;?\s*dr|结论|总览|executive summary|一句话", h, re.I): + return "glance" + if re.search(r"冲突|不确定|conflict|uncertaint", h, re.I): + return "conflict" + if re.search(r"references|参考文献|参考资料|来源|引用", h, re.I): + return "sources" + if re.search( + r"内容分析|content analysis|关系拓扑|relationship|mermaid|拓扑", h, re.I + ): + return "analysis" + if re.search(r"线索|lead trail|pending", h, re.I): + return "leads" + if re.search(r"证据|evidence|事实|timeline|时间线", h, re.I): + return "evidence" + if re.search(r"conclusion|总结|详述|分析", h, re.I): + return "detail" + return "other" + + +def _guess_confidence(text: str) -> tuple[str, str]: + """Return (level, label) level in high|mid|low.""" + # 「置信度:高」是骨架提示词自己要求的写法,必须与「高置信」同等识别 + if re.search( + r"(高\s*置信|置信度\s*[::=]\s*高|confidence\s*[:=]?\s*high|\bhigh confidence\b)", + text, + re.I, + ): + return "high", "置信度:高" + if re.search( + r"(低\s*置信|置信度\s*[::=]\s*低|confidence\s*[:=]?\s*low|\blow confidence\b)", + text, + re.I, + ): + return "low", "置信度:低" + # mid is also the fallback: explicit "中置信" and conflict density both land here + return "mid", "置信度:中" + + +def _is_list_item(line: str) -> bool: + """True for markdown list items; False for bold/italic that starts with *.""" + s = (line or "").strip() + if not s: + return False + if s.startswith("**"): + return False + return bool(re.match(r"^([-•]|\*(?!\*)|\d+\.)\s+", s)) + + +# 行内置信度标签(``**置信度:中。**``):夹在句子中间时按标点切句会留下孤立的 ``**``, +# 因此在切句前整体剥掉标签本体。 +_INLINE_CONFIDENCE_RE = re.compile( + r"\*{0,2}[ \t]*置信度[ \t]*[::][ \t]*[高中低][。.]?[ \t]*\*{0,2}[ \t]*" +) + + +def _strip_inline_confidence(s: str) -> str: + return _INLINE_CONFIDENCE_RE.sub("", s or "") + + +def _is_meta_confidence_line(s: str) -> bool: + """True for confidence badges / HTML confidence markers — never the answer.""" + t = (s or "").strip() + if not t: + return False + if re.match(r"", t, re.I): + return True + if re.match(r"^\*{0,2}置信度\s*[::]\s*[高中低]\*{0,2}[。.]*$", t): + return True + if re.match(r"^\*{0,2}confidence\s*[:=]\s*(high|mid|medium|low)\*{0,2}$", t, re.I): + return True + return False + + +_BOXED_OPEN_RE = re.compile(r"\\boxed\s*\{") +# 整份报告被 \boxed{} 包裹时(含章节/列表结构),内容不再是"一句话结论" +_STRUCTURED_BOXED_RE = re.compile(r"(?m)^#{1,6}\s|\\section|\\begin\b|\\item\b") + + +def _boxed_spans(text: str) -> List[tuple[int, int, int]]: + """``\\boxed{...}`` 的 (起点, 内容起点, 结束点);花括号配平,跳过 ``\\{``/``\\}``。""" + spans: List[tuple[int, int, int]] = [] + for match in _BOXED_OPEN_RE.finditer(text): + if any(start < match.start() < end for start, _, end in spans): + continue + depth = 1 + cursor = match.end() + while cursor < len(text) and depth: + char = text[cursor] + if char == "\\": + cursor += 2 + continue + if char == "{": + depth += 1 + elif char == "}": + depth -= 1 + cursor += 1 + if depth == 0: + spans.append((match.start(), match.end(), cursor)) + return spans + + +def _strip_boxed_answer_lines(text: str) -> str: + """移除独立成行的 ``\\boxed{...}`` 答案行(已提升为结论卡片,正文不再重复)。""" + if not text or "\\boxed" not in text: + return text + kept: List[str] = [] + cursor = 0 + for start, content_start, end in _boxed_spans(text): + if _STRUCTURED_BOXED_RE.search(text[content_start : end - 1]): + continue + line_start = text.rfind("\n", 0, start) + 1 + line_end = text.find("\n", end) + if line_end < 0: + line_end = len(text) + # 仅处理整行只由 $\boxed{...}$ 组成的形态;行内引用保持原样 + if text[line_start:start].strip(" \t$") or text[end:line_end].strip(" \t$"): + continue + kept.append(text[cursor:line_start]) + cursor = line_end + 1 + kept.append(text[cursor:]) + return "".join(kept) + + +def _extract_direct_answer(text: str) -> str: + """Prefer boxed / explicit 答案 lines over first-paragraph heuristics.""" + if not text: + return "" + # \boxed{...},含 $\boxed{\text{...}}$ 形态(花括号可嵌套) + for _start, content_start, end in _boxed_spans(text): + inner = text[content_start : end - 1].strip() + if _STRUCTURED_BOXED_RE.search(inner): + continue + inner = re.sub( + r"^\\text(?:bf|it)?\s*\{(.*)\}$", r"\1", inner, flags=re.DOTALL + ).strip() + if inner: + return inner + # **答案:...** / 答案:... + m = re.search( + r"(?:\*\*)?答案\s*[::]\s*(.+?)(?:\*\*)?\s*$", + text, + re.M, + ) + if m: + ans = m.group(1).strip().strip("*").strip() + if ans and not _is_meta_confidence_line(ans) and not ans.startswith("置信度"): + return ans + # leading **...** one-liner that looks like a verdict (skip confidence badges) + for m in re.finditer(r"^\*\*([^*]{2,120})\*\*\s*$", text, re.M): + cand = m.group(1).strip() + if _is_meta_confidence_line(cand) or cand.startswith("置信度"): + continue + if re.match(r"^confidence\s*[:=]", cand, re.I): + continue + return cand + return "" + + +def _first_paragraph(body: str, max_chars: int = 160) -> str: + direct = _extract_direct_answer(body) + if direct: + if len(direct) > max_chars: + return direct[: max_chars - 1].rstrip() + "…" + return direct + lines = [] + heading_fallback = "" + for line in (body or "").splitlines(): + s = line.strip() + if not s or s.startswith("```"): + if lines: + break + continue + if _is_meta_confidence_line(s) or s.startswith("") + out.append(f"**{conf_label}**") + out.append("") + + # Drop bullets that merely repeat the one-line conclusion + bullets = [b for b in bullets if b.strip() and b.strip() != answer.strip()] + if bullets: + out.append("## 要点\n") + for b in bullets: + out.append(f"- {b}") + out.append("") + + if conflict_bodies: + # keep at most conflict_limit bullets total + collected: List[str] = [] + for body in conflict_bodies: + for b in _bullet_points(body, limit=conflict_limit): + collected.append(b) + if len(collected) >= conflict_limit: + break + if len(collected) >= conflict_limit: + break + if collected: + conflict_body = "\n".join(f"- {b}" for b in collected) + else: + # 表格形态的冲突清单提不出要点时整段保留,避免只剩一个空标题 + conflict_body = ( + _first_paragraph(conflict_bodies[0], max_chars=120) + or conflict_bodies[0].strip() + ) + if conflict_body: + out.append("## 争议与不确定\n") + out.append(conflict_body) + out.append("") + + # Evidence + sources fold target + fold_evidence: List[str] = [] + fold_evidence.extend(evidence_parts) + fold_evidence.extend(source_parts) + + def _demote_headings(block: str) -> str: + # Keep only one H2 per fold region so Gradio can wrap it cleanly. + return re.sub(r"(?m)^##\s+", "### ", block) + + if fold_evidence: + cleaned_bits: List[str] = [] + for block in fold_evidence: + # 引用列表自带标题时降级保留:删掉标题会让编号条目并进上一段证据列表 + cleaned = _demote_headings(block).strip() + if cleaned: + cleaned_bits.append(cleaned) + if cleaned_bits: + out.append("## 证据与来源\n") + out.append("\n\n".join(cleaned_bits)) + out.append("") + + # Deep dive + deep: List[str] = [] + if level == "balanced": + deep.extend(analysis_parts) + # keep other detail shorter in balanced + for block in other_parts[:2]: + body = re.sub(r"^##\s+.+", "", block).strip() + if len(body) > 600: + body = body[:600].rstrip() + "…" + # reattach demoted heading if any + hm = re.match(r"(##\s+.+)", block) + deep.append((hm.group(1) + "\n\n" + body) if hm else body) + else: + deep.append(block) + else: + # 详细档与精简档:正文完整保留;精简档正文受提示词长度约束,本就很短, + # 静默丢弃会让「快速了解」只剩结论与参考来源。 + deep.extend(analysis_parts) + deep.extend(other_parts) + if deep: + deep_bits = [_demote_headings(b) for b in deep if str(b).strip()] + if deep_bits: + out.append("## 深入了解\n") + out.append("\n\n".join(deep_bits)) + out.append("") + + result = "\n".join(out).strip() + "\n" + result = re.sub(r"\n{3,}", "\n\n", result) + return result + + +def is_prepared_report(text: str) -> bool: + """True when ``text`` already came out of :func:`prepare_user_facing_report`. + + Callers may run on orchestrator output that was already presented + (Gradio re-render, exports); re-running the pipeline would renumber + citations again and nest the reshaped headings. + """ + return bool( + text + and _PREPARED_CONCLUSION_RE.search(text) + and _PREPARED_CONFIDENCE_RE.search(text) + ) + + +def prepare_user_facing_report(text: str, *, detail_level: str = "detailed") -> str: + """Pipeline for Gradio/export/acceptance human-readable final report.""" + if not text: + return text + if is_prepared_report(text): + return text + out = strip_diagnostic_noise(text) + out = drop_incomplete_reference_lines(out) + out = compact_pending_lead_trail(out) + out = ensure_content_analysis_and_topology(out, detail_level=detail_level) + out = strip_duplicate_trailing_conclusion(out) + out = renumber_citations(out) + out = reshape_report_for_consumer(out, detail_level=detail_level) + # collapse excessive blank lines + out = re.sub(r"\n{3,}", "\n\n", out).strip() + "\n" + return out diff --git a/apps/miroflow-agent/src/io/report_structure.py b/apps/miroflow-agent/src/io/report_structure.py new file mode 100644 index 0000000..0472222 --- /dev/null +++ b/apps/miroflow-agent/src/io/report_structure.py @@ -0,0 +1,636 @@ +# Copyright (c) 2025 MiroMind +# This source code is licensed under the Apache 2.0 License. + +"""Report structure validation and enforcement for research outputs.""" + +import re +from dataclasses import dataclass +from typing import Any, Dict, List, Optional, Tuple + + +@dataclass +class StructureSection: + """Represents a required section in the report structure.""" + + name: str + pattern: str + required: bool = True + min_length: int = 50 + + +# Shared patterns (Chinese + English aliases for hotspot cross-verification reports) +_TLDR = ( + r"(?i)##?\s*(?:tl;?dr(?:\s*/\s*结论[((]标明置信度[))])?|" + r"摘要|核心结论|" + r"结论\s*[((]标明置信度[))]|" + r"结论(含置信度))" +) +_CONCLUSION = ( + r"(?i)##?\s*(?:conclusions?\b|主要发现|关键结论(?:速览)?|" + r"结论(?!\s*[((]?标明置信度))" +) +# Bilingual Evidence headings (Round 6): EN + CN thematic aliases. +# Accepts e.g. ## Evidence, ## 证据, ## 临床证据, ## 证据与来源, ## 四、临床证据… +_EVIDENCE = ( + r"(?i)##?\s*(?:" + r"evidence(?:\s*[((][^))\n]*[))])?|" + r"supporting\s+evidence|" + r"证据(?:与来源|链|汇总|清单)?|" + r"来源与证据|证据来源|" + r"临床证据(?:与安全性争议)?|关键证据|核心证据|" + r"(?:[一二三四五六七八九十\d]+[、..]\s*)[^\n]{0,48}证据" + r")" +) +_CONFLICTS = ( + r"(?i)##?\s*(?:conflicts?\s*(?:&|and|/)?\s*uncertaint(?:y|ies)?|" + r"disagreements?\s*(?:/|,|&)?\s*uncertaint(?:y|ies)?|" + r"冲突与不确定|已知[//]?不确定[//]?冲突|" + r"(?:争议|冲突)[与和]不确定|来源分歧|口径冲突|" + r"what\s+is\s+known\s*/\s*uncertain\s*/\s*conflicting)" +) +_TIMELINE = r"(?i)##?\s*(?:timeline|时间线|chronolog|大事记|时间轴)" +_CONFIRMED = ( + r"(?i)##?\s*(?:confirmed\s+vs\.?\s+unconfirmed|" + r"what\s+is\s+confirmed|" + r"已确认\s*(?:vs\.?|versus|与|/)\s*未确认|" + r"能确认[与和]不能确认|确认与未确认|" + r"已知与未知)" +) +_REFERENCES = r"(?i)##?\s*(?:references|参考文献|参考资料|来源列表|引用)" + + +class ReportStructureValidator: + """Validates and enforces readable report structure.""" + + # Expected sections for a well-structured research report. + # detailed/deep hotspot reports MUST surface Conflicts explicitly (Round 5). + REQUIRED_SECTIONS = { + "compact": [ + StructureSection("tldr", _TLDR, required=True, min_length=30), + StructureSection("conclusion", _CONCLUSION, required=True, min_length=50), + ], + "balanced": [ + StructureSection("tldr", _TLDR, required=True, min_length=30), + StructureSection("conclusion", _CONCLUSION, required=True, min_length=100), + StructureSection("evidence", _EVIDENCE, required=True, min_length=100), + StructureSection("conflicts", _CONFLICTS, required=False, min_length=80), + ], + "detailed": [ + StructureSection("tldr", _TLDR, required=True, min_length=30), + StructureSection("conclusion", _CONCLUSION, required=True, min_length=150), + StructureSection("conflicts", _CONFLICTS, required=True, min_length=120), + StructureSection("timeline", _TIMELINE, required=True, min_length=80), + StructureSection("evidence", _EVIDENCE, required=True, min_length=200), + StructureSection("confirmed", _CONFIRMED, required=True, min_length=80), + StructureSection("references", _REFERENCES, required=False, min_length=50), + ], + } + + @classmethod + def validate_structure( + cls, markdown_text: str, detail_level: str = "balanced" + ) -> Tuple[bool, List[str], Dict[str, Any]]: + """Validate report structure against expected sections. + + Args: + markdown_text: The markdown report text to validate + detail_level: Expected detail level (compact/balanced/detailed) + + Returns: + Tuple of (is_valid, issues, metadata) + - is_valid: Whether the structure meets minimum requirements + - issues: List of validation issue descriptions + - metadata: Additional structure metadata + """ + if not markdown_text or not markdown_text.strip(): + return False, ["empty_report"], {} + + sections = cls.REQUIRED_SECTIONS.get( + detail_level, cls.REQUIRED_SECTIONS["balanced"] + ) + issues: List[str] = [] + metadata: Dict[str, Any] = { + "found_sections": [], + "missing_required": [], + "section_lengths": {}, + "has_citations": False, + "citation_count": 0, + "has_conflicts_section": False, + "unsourced_number_warnings": [], + } + + # Check for citations ([1] style or URL/domain mentions) + citation_pattern = r"\[\d+\]" + citations = re.findall(citation_pattern, markdown_text) + metadata["has_citations"] = len(citations) > 0 + metadata["citation_count"] = len(citations) + + # Validate each required section + for section in sections: + match = re.search(section.pattern, markdown_text, re.MULTILINE) + if match: + metadata["found_sections"].append(section.name) + if section.name == "conflicts": + metadata["has_conflicts_section"] = True + section_start = match.end() + next_section = re.search(r"\n##?\s+", markdown_text[section_start:]) + section_end = ( + section_start + next_section.start() + if next_section + else len(markdown_text) + ) + section_content = markdown_text[section_start:section_end].strip() + section_length = len(section_content) + metadata["section_lengths"][section.name] = section_length + + if section.required and section_length < section.min_length: + issues.append( + f"{section.name}_too_short:" + f"{section_length}<{section.min_length}" + ) + elif section.required: + metadata["missing_required"].append(section.name) + issues.append(f"missing_{section.name}") + + # Additional quality checks + if detail_level in ["balanced", "detailed"] and metadata["citation_count"] < 3: + # Also accept bare URLs as weak citations for live Chinese sources + url_hits = re.findall(r"https?://[^\s\)]+", markdown_text) + if len(url_hits) + metadata["citation_count"] < 3: + issues.append("insufficient_citations") + + if detail_level == "detailed": + # heading followed by content (not just an alias mention in prose) + if not re.search(_REFERENCES.rstrip() + r"\s*\n", markdown_text): + issues.append("missing_references_section") + if "conflicts" not in metadata["found_sections"]: + # Already covered by missing_required when required=True; + # keep explicit flag for Round-5 gate reporting. + issues.append("missing_conflicts_section") + + # Soft warning: numbers that look like claims without nearby source cue + metadata["unsourced_number_warnings"] = cls._find_unsourced_numbers( + markdown_text + ) + + is_valid = len(metadata["missing_required"]) == 0 + return is_valid, issues, metadata + + UNSOURCED_NUMBER_WARNING_CAP = 8 + # 诊断行/代码块/日期与列表序号都不是“待核数据”,先剔除再扫描 + _NON_CLAIM_LINE_RE = re.compile( + r"Tokens?\b|Extracted Result|Pricing is disabled", re.IGNORECASE + ) + _TIME_UNIT_RE = re.compile(r"^\s*(?:[年月日号点时秒岁]|世纪|周年|届|年代|[-/.]\d)") + + @classmethod + def _claim_scan_text(cls, text: str) -> str: + """剥离代码块(mermaid 标签)与线索追踪附录后的正文扫描文本。""" + scan = re.sub(r"```.*?```", " ", text, flags=re.DOTALL) + return re.split(r"(?m)^##\s*(?:线索追踪|Lead Trail)", scan, maxsplit=1)[0] + + @classmethod + def _is_non_claim_number(cls, text: str, match: re.Match) -> bool: + """列表序号、日期/年龄与诊断行中的数字不计入待核清单。""" + line_start = text.rfind("\n", 0, match.start()) + 1 + line_end = text.find("\n", match.end()) + line = text[line_start : len(text) if line_end == -1 else line_end] + if cls._NON_CLAIM_LINE_RE.search(line): + return True + if not text[line_start : match.start()].strip() and re.match( + r"[.、))]", text[match.end() : match.end() + 1] + ): + return True + return bool(cls._TIME_UNIT_RE.match(text[match.end() : match.end() + 1])) + + @classmethod + def _find_unsourced_numbers(cls, text: str) -> List[str]: + """Heuristic: flag standalone figures lacking nearby source cues. + + Does not fail validation by itself — soft signal for manual review. + Figures are matched with an ±80-char cue window: reports legitimately + attribute sources at paragraph level, so this list is a review aid, + not a gate. + """ + warnings: List[str] = [] + scan_text = cls._claim_scan_text(text) + source_cue = re.compile( + r"(来源|据|报道|称|官方|http|www\.|\[\d+\]|根据)", + re.IGNORECASE, + ) + for match in re.finditer( + r"(?= cls.UNSOURCED_NUMBER_WARNING_CAP: + break + return warnings + + @classmethod + def _normalize_near_miss_headings(cls, text: str) -> str: + """Rename near-miss headings to canonical bilingual forms (Round 8). + + Surgical rename — no LLM rewrite. Only touches lines that look like + section headings but miss the full Conflicts/Evidence/Timeline aliases. + """ + renames = [ + ( + r"(?im)^(##?)\s*冲突\s*$", + r"\1 冲突与不确定 / Conflicts & Uncertainties", + ), + ( + r"(?im)^(##?)\s*冲突与分歧\s*$", + r"\1 冲突与不确定 / Conflicts & Uncertainties", + ), + ( + r"(?im)^(##?)\s*不确定(?:性|项)?\s*$", + r"\1 冲突与不确定 / Conflicts & Uncertainties", + ), + ( + r"(?im)^(##?)\s*disagreements?\s*$", + r"\1 Conflicts & Uncertainties", + ), + ( + r"(?im)^(##?)\s*时间(?:线)?\s*$", + r"\1 时间线 / Timeline", + ), + ( + r"(?im)^(##?)\s*chronology\s*$", + r"\1 Timeline", + ), + ( + r"(?im)^(##?)\s*证据清单\s*$", + r"\1 Evidence", + ), + ( + r"(?im)^(##?)\s*supporting\s+materials?\s*$", + r"\1 Evidence", + ), + ( + r"(?im)^(##?)\s*确认事项\s*$", + r"\1 已确认 vs 未确认 / Confirmed vs Unconfirmed", + ), + ( + r"(?im)^(##?)\s*known\s+vs\.?\s+unknown\s*$", + r"\1 Confirmed vs Unconfirmed", + ), + ] + updated = text + for pattern, repl in renames: + updated = re.sub(pattern, repl, updated) + return updated + + @classmethod + def _inject_section_before_cues( + cls, + text: str, + heading: str, + body: str, + cue_pattern: re.Pattern, + *, + heading_only: bool = False, + ) -> str: + """Insert ``heading`` (optionally + body) above the first cue paragraph.""" + parts = text.split("\n\n") + for idx, para in enumerate(parts): + p = para.strip() + if not p or p.startswith("#"): + continue + if cue_pattern.search(p): + block = heading if heading_only else f"{heading}\n\n{body}".strip() + parts.insert(idx, block) + return "\n\n".join(parts) + return text.rstrip() + f"\n\n{heading}\n\n{body}\n" + + @classmethod + def enforce_structure( + cls, + markdown_text: str, + detail_level: str = "balanced", + citations: Optional[List[str]] = None, + ) -> str: + """Attempt to fix structure issues by surgical heading inject/rename. + + Round 8: prefer rename near-miss headings and inject missing headings + above existing body blocks over full regenerate or stub-at-end dumps. + """ + if not markdown_text or not markdown_text.strip(): + return markdown_text + + text = cls._normalize_near_miss_headings(markdown_text.strip()) + sections_to_prepend: List[str] = [] + sections_to_append: List[str] = [] + + is_valid, issues, metadata = cls.validate_structure(text, detail_level) + + if is_valid: + return text + + if "tldr" in metadata["missing_required"]: + first_para = cls._extract_first_paragraph(text) + if first_para: + sections_to_prepend.append(f"## TL;DR\n\n{first_para}\n\n") + + if "conclusion" in metadata["missing_required"]: + conclusion_text = cls._extract_conclusion_text(text) + if conclusion_text: + sections_to_append.append(f"\n## Conclusion\n\n{conclusion_text}\n\n") + + # Round-5 hard gate: Conflicts must exist for detailed reports. + if detail_level == "detailed" and ( + "conflicts" in metadata["missing_required"] + or "missing_conflicts_section" in issues + ): + extracted = cls._extract_conflict_text(text) + body = extracted or ( + "(结构自动补全)原文未单独列出冲突与不确定项;" + "以下为从正文抽取的分歧线索,置信度低,需人工复核," + "不得视为已核实结论。\n\n" + + (cls._extract_first_paragraph(text) or "暂无可用冲突摘录。") + ) + heading = "## 冲突与不确定 / Conflicts & Uncertainties" + conflict_cues = re.compile( + r"(冲突|分歧|争议|不一致|相互矛盾|说法不一|" + r"官方否认|尚未证实|无法确认|conflict|disagre|" + r"unconfirm|disput)", + re.IGNORECASE, + ) + if extracted: + text = cls._inject_section_before_cues( + text, + heading, + body, + conflict_cues, + heading_only=True, + ) + else: + sections_to_append.append(f"\n{heading}\n\n{body}\n\n") + + if detail_level == "detailed" and "timeline" in metadata["missing_required"]: + date_cues = re.compile( + r"\b20\d{2}[-/年.]\d{1,2}|timeline|时间线", + re.IGNORECASE, + ) + has_dated = any( + date_cues.search(p) + for p in text.split("\n\n") + if p.strip() and not p.strip().startswith("#") + ) + if has_dated: + text = cls._inject_section_before_cues( + text, + "## 时间线 / Timeline", + "", + date_cues, + heading_only=True, + ) + else: + sections_to_append.append( + "\n## 时间线 / Timeline\n\n" + "(结构自动补全)原文缺少独立时间线章节;" + "请勿将此占位视为已核实时间表。\n\n" + ) + + if detail_level == "detailed" and "confirmed" in metadata["missing_required"]: + sections_to_append.append( + "\n## 已确认 vs 未确认 / Confirmed vs Unconfirmed\n\n" + "- **已确认**:见正文有明确多源支持的陈述(若无则标注“无”)。\n" + "- **未确认**:单方宣称、无法交叉核验或来源冲突的陈述。\n" + "(结构自动补全占位;禁止据此伪造确定性。)\n\n" + ) + + # Round-6/8: Evidence auto-repair — append dedicated section (do not + # inject mid-Conflicts; source cues often live inside other sections). + if detail_level in ("balanced", "detailed") and ( + "evidence" in metadata["missing_required"] + ): + evidence_body = cls._extract_evidence_text(text, citations) + if evidence_body: + sections_to_append.append( + f"\n## Evidence(证据含来源与日期)\n\n{evidence_body}\n\n" + ) + + # references is required=False, so it never lands in missing_required; + # only the "no References heading at all" case needs repairing. + if ( + detail_level == "detailed" + and "missing_references_section" in issues + and not re.search(_REFERENCES, text) + ): + if citations: + refs_body = "\n".join( + f"{i + 1}. {cite}" for i, cite in enumerate(citations) + ) + else: + refs_body = "(结构自动补全)请参见正文内联来源与链接。" + sections_to_append.append(f"\n## References\n\n{refs_body}\n\n") + + if sections_to_prepend or sections_to_append: + text = "".join(sections_to_prepend) + text + "".join(sections_to_append) + + return text + + @staticmethod + def _extract_first_paragraph(text: str) -> str: + """Extract first substantial paragraph as TL;DR candidate.""" + lines = text.strip().split("\n") + for line in lines: + line = line.strip() + if line and not line.startswith("#") and len(line) > 50: + return line + return "" + + @staticmethod + def _extract_conclusion_text(text: str) -> str: + """Extract conclusion-like content from text.""" + conclusion_patterns = [ + r"(?i)in conclusion[,:\s]+(.*?)(?:\n\n|\Z)", + r"(?i)to sum(marize|mary)[,:\s]+(.*?)(?:\n\n|\Z)", + r"(?i)therefore[,:\s]+(.*?)(?:\n\n|\Z)", + r"(?i)综上[,,:\s]+(.*?)(?:\n\n|\Z)", + ] + + for pattern in conclusion_patterns: + match = re.search(pattern, text, re.DOTALL) + if match: + conclusion = match.group(1).strip() + if len(conclusion) > 50: + return conclusion[:500] + + paras = [p.strip() for p in text.split("\n\n") if p.strip()] + for para in reversed(paras): + if not para.startswith("#") and len(para) > 100: + return para[:500] + + return "" + + @staticmethod + def _extract_conflict_text(text: str) -> str: + """Pull disagreement-like sentences for Conflicts auto-fix.""" + cues = re.compile( + r"(冲突|分歧|争议|不一致|相互矛盾|说法不一|" + r"官方否认|尚未证实|无法确认|conflict|disagre|" + r"unconfirm|disput)", + re.IGNORECASE, + ) + hits: List[str] = [] + for para in text.split("\n\n"): + p = para.strip() + if p and not p.startswith("#") and cues.search(p): + hits.append(p[:400]) + if len(hits) >= 3: + break + return "\n\n".join(hits) + + @staticmethod + def _extract_evidence_text(text: str, citations: Optional[List[str]] = None) -> str: + """Build an Evidence body from inlined sources / URLs / citations. + + Returns empty string when there is nothing source-like to promote — + avoids inventing evidence just to pass the heading gate. + """ + source_cue = re.compile( + r"(https?://|www\.|\[\d+\]|" + r"来源|据.{0,24}(报道|称|声明|通报|披露)|" + r"路透|美联社|法新|新华|半岛|BBC|Reuters|AP\b|" + r"官方[声声]?明?|通讯社)", + re.IGNORECASE, + ) + hits: List[str] = [] + for para in text.split("\n\n"): + p = para.strip() + if not p or p.startswith("#"): + continue + # Skip lead-trail / metrics appendix that are not report evidence + if "线索追踪" in p or "Lead Trail" in p: + continue + if source_cue.search(p): + hits.append(p[:500]) + if len(hits) >= 5: + break + + if citations: + cite_lines = "\n".join(f"- {c}" for c in citations[:8] if str(c).strip()) + if cite_lines: + hits.append("引用列表:\n" + cite_lines) + + if not hits: + # Weak fallback: collect bare URLs scattered in the report + urls = re.findall(r"https?://[^\s\)\]\>\"']+", text) + uniq: List[str] = [] + for u in urls: + if u not in uniq: + uniq.append(u) + if len(uniq) >= 5: + break + if uniq: + hits.append( + "(结构自动补全)正文未设独立 Evidence 标题;" + "以下从来源链接整理,需人工复核:\n" + + "\n".join(f"- {u}" for u in uniq) + ) + + if not hits: + return "" + + header = ( + "(结构自动补全)原文缺少独立 Evidence / 证据 标题;" + "以下为从正文抽取的来源线索,禁止据此伪造确定性。\n\n" + ) + body = header + "\n\n".join(hits) + # Ensure detailed min_length (200) can be satisfied when content exists + return body[:2500] + + @classmethod + def get_structure_template(cls, detail_level: str = "balanced") -> str: + """Get a template for the expected report structure. + + Args: + detail_level: The detail level (compact/balanced/detailed) + + Returns: + Markdown template string + """ + if detail_level == "compact": + return """## TL;DR / 结论(标明置信度) + +[核心结论,2-3句话;写明置信度:高/中/低] + +## Conclusion + +[简要总结主要发现和结论;不确定处显式标注] +""" + elif detail_level == "balanced": + return """## TL;DR / 结论(标明置信度) + +[核心结论,2-3句话;写明置信度] + +## Conclusions + +[详细结论和主要发现] + +## 冲突与不确定 / Conflicts & Uncertainties + +[各方说法冲突、口径差异、未核实点 — 若无冲突也需写明“未发现重大冲突”] + +## Evidence + +[支撑证据,引用来源与日期 [1], [2] 等] + +## References + +1. [来源1] +2. [来源2] +""" + else: # detailed + return """## TL;DR / 结论(标明置信度) + +[核心结论,2-3句话;必须标明置信度:高/中/低及理由] + +## Conclusions + +[详细结论和主要发现,包含背景和 context] + +## 冲突与不确定 / Conflicts & Uncertainties + +[必填] 多方说法对照表:谁说了什么、冲突点、可能的口径原因。禁止消抹分歧。 + +## 时间线 / Timeline + +[按绝对日期排列的事件时间线] + +## Evidence + +[详细支撑证据,含来源与日期 [1], [2] 等;数字必须带来源] +(中文等价标题亦可:`## 证据` / `## 证据与来源` / `## 临床证据`) + +## 已确认 vs 未确认 / Confirmed vs Unconfirmed + +- **已确认**:… +- **未确认 / 无法确认**:… + +## Gaps in Knowledge + +[研究中发现的知识空白] + +## 内容分析 / Content Analysis + +[可选] 角色/主张/时间线要点;有助于舆情与谣言交叉验证阅读。 + +## 关系拓扑 / Relationship Map + +[可选] 用 mermaid flowchart 表达 主张→证据/反驳→缺口(无结构时可不生成)。 + +## References + +1. [来源1 - 完整引用信息与日期] +2. [来源2 - 完整引用信息与日期] +""" diff --git a/apps/miroflow-agent/tests/test_answer_generator_summary_passes.py b/apps/miroflow-agent/tests/test_answer_generator_summary_passes.py new file mode 100644 index 0000000..db6cc37 --- /dev/null +++ b/apps/miroflow-agent/tests/test_answer_generator_summary_passes.py @@ -0,0 +1,85 @@ +# Copyright (c) 2025 MiroMind +"""Round 7: summary_passes metric + deep default single summary pass.""" + +from __future__ import annotations + +import sys +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import pytest +from omegaconf import OmegaConf + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from src.core.answer_generator import AnswerGenerator # noqa: E402 +from src.io.output_formatter import OutputFormatter # noqa: E402 +from src.logging.task_logger import RunMetrics # noqa: E402 + + +def _make_generator(**agent_overrides) -> AnswerGenerator: + agent = { + "keep_tool_result": 5, + "context_compress_limit": 0, + "retry_with_summary": True, + "output_detail_level": "detailed", + "research_report_mode": True, + "research_intensity": "deep", + "verification": {"enabled": False}, + } + agent.update(agent_overrides) + cfg = OmegaConf.create({"agent": agent}) + llm_client = MagicMock() + llm_client.format_token_usage_summary.return_value = ([], "") + llm_client.ensure_summary_context.side_effect = lambda hist, _p: (True, hist) + stream = MagicMock() + stream.update = AsyncMock() + task_log = MagicMock() + task_log.run_metrics = RunMetrics() + return AnswerGenerator( + llm_client=llm_client, + output_formatter=OutputFormatter(), + task_log=task_log, + stream_handler=stream, + cfg=cfg, + intermediate_boxed_answers=[], + ) + + +def test_deep_research_defaults_to_single_summary_pass(): + gen = _make_generator() + assert gen.max_final_answer_retries == 1 + + +def test_explicit_retries_override_deep_default(): + gen = _make_generator(max_final_answer_retries=3) + assert gen.max_final_answer_retries == 3 + + +@pytest.mark.asyncio +async def test_summary_passes_metric_increments_once(): + gen = _make_generator() + boxed = ( + "\\boxed{\n" + "## TL;DR\nok\n" + "## Conflicts & Uncertainties\n双方说法冲突\n" + "## Timeline\n- 2026-09-19 event\n" + "## Evidence(证据)\n[1] source\n" + "## Confirmed vs Unconfirmed\n可确认:拦截;不可确认:延布受损\n" + "}" + ) + + async def _fake_llm(*_a, **_k): + return boxed, True, None, [{"role": "user", "content": "q"}] + + gen.handle_llm_call = _fake_llm # type: ignore[method-assign] + await gen.generate_final_answer_with_retries( + system_prompt="sys", + message_history=[{"role": "user", "content": "q"}], + tool_definitions=[], + turn_count=3, + task_description="test query", + ) + assert gen.task_log.run_metrics.summary_passes == 1 diff --git a/apps/miroflow-agent/tests/test_oneshot_final_report.py b/apps/miroflow-agent/tests/test_oneshot_final_report.py new file mode 100644 index 0000000..40f123a --- /dev/null +++ b/apps/miroflow-agent/tests/test_oneshot_final_report.py @@ -0,0 +1,184 @@ +# Copyright (c) 2025 MiroMind +"""Round 8: oneshot final report + summary-stage tool retention.""" + +from __future__ import annotations + +import sys +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import pytest +from omegaconf import OmegaConf + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from src.core.answer_generator import AnswerGenerator # noqa: E402 +from src.core.deep_efficiency import ( # noqa: E402 + DEFAULT_DEEP_POST_EARLY_STOP_TURNS, + DEFAULT_DEEP_SUMMARY_KEEP_TOOL_RESULT, + DEFAULT_DEEP_SUMMARY_MAX_TOKENS, + resolve_exit_on_early_stop, + resolve_oneshot_final_report, + resolve_summary_keep_tool_result, + resolve_summary_max_tokens_cap, +) +from src.io.output_formatter import OutputFormatter # noqa: E402 +from src.llm.base_client import OMITTED_TOOL_RESULT_TEXT # noqa: E402 +from src.logging.task_logger import RunMetrics # noqa: E402 + + +class _CfgDict(dict): + def __getattr__(self, item): + try: + return self[item] + except KeyError as exc: + raise AttributeError(item) from exc + + +def _make_generator(**agent_overrides) -> AnswerGenerator: + agent = { + "keep_tool_result": 5, + "context_compress_limit": 0, + "retry_with_summary": True, + "output_detail_level": "detailed", + "research_report_mode": True, + "research_intensity": "deep", + "verification": {"enabled": False}, + } + agent.update(agent_overrides) + cfg = OmegaConf.create({"agent": agent}) + llm_client = MagicMock() + llm_client.format_token_usage_summary.return_value = ([], "") + llm_client.ensure_summary_context.side_effect = lambda hist, _p: (True, hist) + llm_client.summary_max_tokens = 8192 + stream = MagicMock() + stream.update = AsyncMock() + task_log = MagicMock() + task_log.run_metrics = RunMetrics() + return AnswerGenerator( + llm_client=llm_client, + output_formatter=OutputFormatter(), + task_log=task_log, + stream_handler=stream, + cfg=cfg, + intermediate_boxed_answers=[], + ) + + +def test_deep_post_early_stop_turns_default_is_one(): + assert DEFAULT_DEEP_POST_EARLY_STOP_TURNS == 1 + agent = _CfgDict(research_intensity="deep") + cfg = _CfgDict(agent=agent) + enabled, post = resolve_exit_on_early_stop(cfg) + assert enabled is True + assert post == 1 + + +def test_oneshot_final_report_defaults_on_for_deep(): + agent = _CfgDict(research_intensity="deep") + cfg = _CfgDict(agent=agent) + assert resolve_oneshot_final_report(cfg) is True + + +def test_summary_keep_tool_result_deep_oneshot_defaults_to_two(): + agent = _CfgDict(research_intensity="deep") + cfg = _CfgDict(agent=agent) + assert ( + resolve_summary_keep_tool_result(cfg, research_keep=5) + == DEFAULT_DEEP_SUMMARY_KEEP_TOOL_RESULT + ) + assert DEFAULT_DEEP_SUMMARY_KEEP_TOOL_RESULT == 2 + + +def test_summary_max_tokens_cap_deep_default(): + agent = _CfgDict(research_intensity="deep") + cfg = _CfgDict(agent=agent) + assert resolve_summary_max_tokens_cap(cfg) == DEFAULT_DEEP_SUMMARY_MAX_TOKENS + + +def test_oneshot_prompt_uses_skeleton_not_no_compress(): + gen = _make_generator() + assert gen.oneshot_final_report is True + assert gen.summary_keep_tool_result == 2 + prompt = gen._build_main_summary_prompt("测试任务:胡塞与沙特分歧") + assert "ONE-SHOT" in prompt + assert "禁止压缩" not in prompt + assert "Conflicts" in prompt or "冲突" in prompt + assert "\\boxed{" in prompt or "boxed" in prompt.lower() + + +def test_oneshot_skips_length_expand(): + gen = _make_generator() + short = "## TL;DR\nok\n## Conflicts\n分歧\n## Evidence\nx\n## Confirmed\ny" + assert gen._is_summary_too_short(short) is False + + +def test_strip_omitted_tool_stubs(): + gen = _make_generator() + history = [ + {"role": "user", "content": "q"}, + {"role": "user", "content": OMITTED_TOOL_RESULT_TEXT}, + {"role": "user", "content": "real dump"}, + ] + cleaned = gen._strip_omitted_tool_stubs(history) + assert len(cleaned) == 2 + assert cleaned[1]["content"] == "real dump" + + +def test_resolve_keep_tool_result_for_summary_vs_main(): + gen = _make_generator() + assert gen._resolve_keep_tool_result_for_call("final_summary") == 2 + assert gen._resolve_keep_tool_result_for_call("main") == 5 + + +@pytest.mark.asyncio +async def test_oneshot_skips_verification_llm_pass(): + gen = _make_generator( + verification={ + "enabled": True, + "use_high_model_for_verification": True, + "min_search_rounds": 3, + "min_high_conf_sources": 2, + "high_conf_domains": [], + } + ) + assert gen.oneshot_final_report is True + assert gen.verification_enabled is True + + called_agent_types: list[str] = [] + boxed = ( + "\\boxed{\n## TL;DR\nok confidence medium\n" + "## Conflicts & Uncertainties\n双方冲突说法足够长以通过结构校验门槛字符数要求\n" + "## Timeline\n- 2026-09-19 event happened here with enough chars for min length\n" + "## Evidence\n[1] https://example.com/a source dated 2026-09-19 and more\n" + "## Confirmed vs Unconfirmed\n可确认:声明发出;不可确认:是否命中目标设施\n" + "## References\n1. https://example.com/a\n" + "}" + ) + + async def _capture( + system_prompt, + message_history, + tool_definitions, + step_id, + purpose, + agent_type="main", + ): + called_agent_types.append(agent_type) + return boxed, True, None, message_history + + gen.handle_llm_call = _capture # type: ignore[method-assign] + gen.generate_cross_verification_note = AsyncMock( # type: ignore[method-assign] + side_effect=AssertionError("verification note should be skipped") + ) + + await gen.generate_final_answer_with_retries( + system_prompt="sys", + message_history=[{"role": "user", "content": "q"}], + tool_definitions=[], + turn_count=3, + task_description="task", + ) + assert called_agent_types == ["final_summary"] diff --git a/apps/miroflow-agent/tests/test_report_presentation.py b/apps/miroflow-agent/tests/test_report_presentation.py new file mode 100644 index 0000000..b972d8b --- /dev/null +++ b/apps/miroflow-agent/tests/test_report_presentation.py @@ -0,0 +1,216 @@ +"""Tests for user-facing report presentation cleanup.""" + +from src.io.report_presentation import ( + compact_pending_lead_trail, + drop_incomplete_reference_lines, + ensure_content_analysis_and_topology, + prepare_user_facing_report, + reshape_report_for_consumer, + strip_diagnostic_noise, +) + + +MESSY = """ +============================== Final Answer ============================== +## TL;DR / 结论(标明置信度) + +双方说法冲突;置信度:中。 + +## Conclusions + +综合公开报道,袭击是否造成实质破坏仍有争议,需要更多来源交叉核验。 + +## 冲突与不确定 / Conflicts & Uncertainties + +- 甲方宣称命中能源设施并引发火灾。 +- 乙方称拦截成功、设施运转正常。 +- 伤亡数字各方不一,尚无独立核实。 + +## Evidence + +据路透社报道 [1]。 + +## References + +1. https://example.com/reuters-2026-09-18 +2. https://www + +## 线索追踪 / Lead Trail + +### Lead 1: What are the most important unresolved aspects of the attack? +**来源**: query_seed (Turn 0) +**优先级**: 0.65 +**状态**: pending + +--------------------- Extracted Result --------------------- +boxed text + +--------------------- Token Usage & Cost --------------------- +Total Input Tokens: 123 +Pricing is disabled - no cost information available +----------------------------------------------------- +""" + + +def test_strip_diagnostic_noise_removes_token_blocks(): + cleaned = strip_diagnostic_noise(MESSY) + assert "Token Usage" not in cleaned + assert "Pricing is disabled" not in cleaned + assert "Extracted Result" not in cleaned + assert "TL;DR" in cleaned + + +def test_drop_incomplete_reference_lines(): + text = "## References\n\n1. https://example.com/ok\n2. https://www\n" + out = drop_incomplete_reference_lines(text) + assert "example.com/ok" in out + assert "https://www\n" not in out + "\n" or out.strip().endswith("ok") + + +def test_compact_pending_only_trail(): + trail = """## body + +## 线索追踪 / Lead Trail + +### Lead 1: Long English seed question about unresolved conflicts? +**来源**: query_seed (Turn 0) +**优先级**: 0.65 +**状态**: pending +""" + out = compact_pending_lead_trail(trail) + assert "未跟进" in out + assert "**状态**: pending" not in out + + +def test_topology_added_when_conflicts_present(): + body = """## TL;DR + +结论足够长用于测试。 + +## 冲突与不确定 / Conflicts & Uncertainties + +- 甲方宣称命中。 +- 乙方称拦截成功。 + +## References + +1. https://example.com/a +""" + out = ensure_content_analysis_and_topology(body, detail_level="detailed") + assert "内容分析" in out + assert "```mermaid" in out + assert "flowchart" in out + + +def test_prepare_user_facing_report_end_to_end(): + out = prepare_user_facing_report(MESSY, detail_level="detailed") + assert "Token Usage" not in out + assert "Pricing is disabled" not in out + assert "https://www\n" not in out + assert "## 结论" in out + assert "证据与来源" in out + assert "未跟进" in out # lead trail compacted into deep dive + assert "内容分析" in out + assert "```mermaid" in out + assert out.index("## 结论") < out.index("证据与来源") + + +def test_prepare_consumer_layout_glance_first(): + out = prepare_user_facing_report(MESSY, detail_level="detailed") + assert "## 结论" in out + assert "证据与来源" in out + assert out.index("## 结论") < out.index("证据与来源") + assert "深入了解" in out + # nested analysis demoted under deep dive + assert "### 内容分析" in out or "内容分析" in out + + +def test_prepare_compact_skips_deep_dive(): + out = prepare_user_facing_report(MESSY, detail_level="compact") + assert "## 结论" in out + assert "深入了解" not in out + assert "```mermaid" not in out + + +def test_reshape_keeps_conflict_short(): + body = """## TL;DR +短结论。 + +## 冲突与不确定 +- a啊 +- b吧 +- c从 +- d的 + +## References +1. https://example.com/x +""" + out = reshape_report_for_consumer(body, detail_level="balanced") + assert "## 争议与不确定" in out + # at most 3 bullets under conflict + conflict = out.split("## 争议与不确定", 1)[1].split("## ", 1)[0] + bullets = [ln for ln in conflict.splitlines() if ln.strip().startswith("-")] + assert len(bullets) <= 3 + + +def test_reshape_prefers_bold_answer_over_fallback(): + """**答案:** lines must not be mistaken for bullets (empty glance bug).""" + raw = """# 1+1 等于几? + +**答案:1+1 = 2** + +## 基本解释 + +- 算术上 1+1=2 +""" + out = prepare_user_facing_report(raw, detail_level="compact") + assert "暂无法" not in out + conclusion = out.split("## 结论", 1)[1].split("##", 1)[0] + assert "1+1" in conclusion and "2" in conclusion + + +def test_section_kind_direct_answer_is_glance(): + from src.io.report_presentation import _section_kind, reshape_report_for_consumer + + assert _section_kind("## 直接答案") == "glance" + assert _section_kind("Direct Answer") == "glance" + assert _section_kind("答案") == "glance" + raw = """## 直接答案 + +1+1=2 + +## References +1. https://example.com/x +""" + out = reshape_report_for_consumer(raw, detail_level="compact") + assert "## 结论" in out + assert "1+1=2" in out.split("## 结论", 1)[1].split("##", 1)[0] + assert "深入了解" not in out + + +def test_section_kind_reference_headings_are_sources(): + from src.io.report_presentation import _section_kind + + for heading in ("## References", "## 参考文献", "## 参考资料", "## 来源列表"): + assert _section_kind(heading) == "sources", heading + + +def test_prepare_does_not_use_confidence_as_answer(): + """Re-preparing a consumer report must keep 1+1=2, not promote 置信度.""" + raw = """## 结论 + +1+1=2 + + +**置信度:高** +""" + out = prepare_user_facing_report(raw, detail_level="compact") + conclusion = out.split("## 结论", 1)[1].split("##", 1)[0] + assert "1+1=2" in conclusion + # answer line itself must not be only the confidence label + body_lines = [ + ln.strip() + for ln in conclusion.splitlines() + if ln.strip() and not ln.strip().startswith("", body) + level = conf_m.group(1) if conf_m else "mid" + body = re.sub(r"\s*", "", body) + label_m = re.search(r"\*\*?置信度:([高中低])\*\*?", body) + if label_m: + label = f"置信度:{label_m.group(1)}" + else: + label = { + "high": "置信度:高", + "mid": "置信度:中", + "low": "置信度:低", + }.get(level, "置信度:中") + conf = ( + f'' + f"{html.escape(label, quote=False)}" + ) + # Strip every leftover confidence label (bold/plain, whole line or inline), + # then keep the answer in ONE escaped
: Gradio Markdown drops bare + # text /

siblings inside HTML blocks. + body = re.sub( + r"(?m)^[ \t]*\*{0,2}置信度:[高中低]\*{0,2}[ \t]*$" + r"|\*{0,2}置信度:[高中低]\*{0,2}", + "", + body, + ).strip() + answer = body.strip() if body.strip() else "(结论正文缺失,请展开过程或重试)" + # Collapse excessive blank lines but keep readable line breaks as
. + answer_html = html.escape(answer, quote=False).replace("\n", "
") + return ( + f'

' + f'
' + f'结论' + f"{conf}" + f"
" + f'

{answer_html}

' + f"
\n\n" + ) + + text = re.sub( + r"(?ms)^##\s*结论\s*\n+(.*?)(?=^##\s|\Z)", + _wrap_glance, + text, + count=1, + ) + + # 2) Legacy TL;DR callout (skip if already glance-wrapped; exclude bare 结论) + def _wrap_tldr(match: "re.Match[str]") -> str: + title = html.escape(match.group(1).strip(), quote=False) + body = (match.group(2) or "").strip() + conf = "" + conf_m = re.search( + r"(高|中|低)\s*置信|confidence\s*[:=]?\s*(high|medium|low|\d+%?)", + match.group(1) + "\n" + body, + re.I, + ) + if conf_m: + label = html.escape(conf_m.group(0), quote=False) + level = "mid" + if re.search(r"高|high", label, re.I): + level = "high" + elif re.search(r"低|low", label, re.I): + level = "low" + conf = f'{label}' + return ( + f'
\n\n' + f'
{title}{conf}
\n\n' + f"{body}\n\n" + f"
\n\n" + ) + + if 'class="report-glance"' not in text: + text = re.sub( + r"(?ms)^##\s*([^\n]*(?:TL;?DR|总览|Executive Summary)[^\n]*)\n+(.*?)(?=^##\s|\Z)", + _wrap_tldr, + text, + count=1, + ) + + # 3) Conflict tag on heading (keep open, short) + text = re.sub( + r"(?m)^(##\s*[^\n]*(?:争议与不确定|冲突|不确定|Conflicts?|Uncertainties?)[^\n]*)$", + r'\1 冲突/不确定', + text, + count=1, + ) + + # 4) Fold heavy sections (evidence / deep dive). Nested headings are ###. + def _fold_section(match: "re.Match[str]") -> str: + heading = match.group(1).strip() + body = (match.group(2) or "").strip() + if not body: + return "" # never show an empty fold + n_links = len(re.findall(r"https?://", body)) + n_items = len(re.findall(r"(?m)^\s*(?:\d+\.|[-*•])\s+", body)) + n = n_links or n_items + count_hint = f"({n})" if n else "" + title = html.escape(re.sub(r"^##\s*", "", heading), quote=False) + # Mermaid card inside fold body + body = re.sub( + r"(?ms)(###\s*[^\n]*(?:关系拓扑|Relationship Map|拓扑)[^\n]*\n+)(```mermaid\n.*?```)", + r'
\n\n\1\2\n\n
\n\n', + body, + count=1, + ) + return ( + f'
\n' + f"{title}{count_hint}\n\n" + f"{body}\n\n" + f"
\n\n" + ) + + text = re.sub( + r"(?ms)^(##\s*[^\n]*(?:证据与来源|证据和来源|深入了解|深入分析)[^\n]*)\n+(.*?)(?=^##\s|\Z)", + _fold_section, + text, + ) + + text = text.replace('class="ref-citation"', 'class="ref-citation ref-chip"') + return text + + +def _keep_last_report_glance(decorated_blocks: List[str]) -> List[str]: + """Keep a single consumer glance card when streamed summaries differ only by confidence. + + Balanced/detailed merge uses ``all_unique``, so an early mid-confidence reshape and a + later high-confidence reshape can both survive. Rendering both produces duplicate + 「结论 / 置信度」 chrome in the a11y tree. Prefer the latest glance-bearing block. + """ + if len(decorated_blocks) <= 1: + return decorated_blocks + glance_indexes = [ + idx + for idx, block in enumerate(decorated_blocks) + if 'class="report-glance"' in str(block or "") + ] + if len(glance_indexes) <= 1: + return decorated_blocks + drop = set(glance_indexes[:-1]) + return [block for idx, block in enumerate(decorated_blocks) if idx not in drop] + + +_REPORT_SOURCE_TOOL_NAMES = {"google_search", "sogou_search"} +_REPORT_SCRAPE_TOOL_NAMES = { + "scrape", + "scrape_website", + "scrape_webpage", + "scrape_and_extract_info", +} +# 参考来源上限:论文式文末列表,太多反而淹没阅读 +MAX_REPORT_SOURCES = 30 + + +def _normalize_source_url(url: Any) -> str: + candidate = str(url or "").strip() + if not candidate: + return "" + if not candidate.lower().startswith(("http://", "https://")): + return "" + return candidate + + +def _source_display_title(title: Any, url: str) -> str: + # 搜索结果标题常带 等高亮标签,剥掉避免破坏 Markdown 链接文本 + text = re.sub(r"<[^>]+>", "", str(title or "")) + text = re.sub(r"\s+", " ", text).strip() + if text: + return text[:120] + host = urlparse(url).netloc or url + return host + + +def _collect_report_sources(state: dict) -> List[Dict[str, str]]: + """汇总本轮研究实际命中/访问过的来源,按首次出现顺序去重。""" + sources: List[Dict[str, str]] = [] + seen_urls = set() + + def _add(url: Any, title: Any = "") -> None: + normalized = _normalize_source_url(url) + if not normalized or normalized in seen_urls: + return + seen_urls.add(normalized) + sources.append( + {"url": normalized, "title": _source_display_title(title, normalized)} + ) + + for agent_id in (state or {}).get("agent_order", []): + agent = (state or {}).get("agents", {}).get(agent_id, {}) + for call_id in agent.get("tool_call_order", []): + call = agent.get("tools", {}).get(call_id, {}) + tool_name = call.get("tool_name", "") + tool_input = call.get("input", {}) + tool_output = call.get("output", {}) + if tool_name in _REPORT_SOURCE_TOOL_NAMES: + result_data: Dict[str, Any] = {} + result_payload = ( + tool_output.get("result", "") + if isinstance(tool_output, dict) + else "" + ) + if isinstance(result_payload, str) and result_payload.strip(): + try: + parsed = json.loads(result_payload) + if isinstance(parsed, dict): + result_data = parsed + except json.JSONDecodeError: + result_data = {} + elif isinstance(result_payload, dict): + result_data = result_payload + if not result_data and isinstance(tool_output, dict): + result_data = tool_output + organic = result_data.get("organic") + if not isinstance(organic, list): + organic = result_data.get("Pages") + if isinstance(organic, list): + for item in organic: + if not isinstance(item, dict): + continue + _add( + item.get("link") or item.get("url"), + item.get("title") or item.get("siteName"), + ) + elif tool_name in _REPORT_SCRAPE_TOOL_NAMES and isinstance( + tool_input, dict + ): + _add(tool_input.get("url") or tool_input.get("link")) + if len(sources) >= MAX_REPORT_SOURCES: + return sources + return sources + + +def _build_references_section(sources: List[Dict[str, str]]) -> List[str]: + if not sources: + return [] + heading = _progress_copy("references_heading") + lines = ["", "\n---\n", f"### {heading}\n"] + for idx, src in enumerate(sources, 1): + title = str(src.get("title") or src["url"]).replace("[", "(").replace("]", ")") + lines.append(f"[{idx}] [{title}]({src['url']})") + return lines + + +def _build_summary_section( + final_summary_blocks: List[str], + output_detail_level: Optional[str] = None, +) -> List[str]: if not final_summary_blocks: return [] # 前置空字符串项:确保和上一个 HTML block(如 search-step-board 的
)之间 # 有一个空行,否则 CommonMark 会把 `## 📋 研究总结` 视为 HTML block 的延续, # 导致 `## ` 字面显示而非作为标题渲染。 - lines = ["", "## 📋 研究总结\n\n"] + # Glance card already leads with 结论; keep a quieter label. + lines = ["", "### 研究报告\n\n"] + resolved_detail = _normalize_output_detail_level(output_detail_level) # 先剥离 OutputFormatter 注入的调试分段标记,再做 LaTeX → Markdown 规范化 - sanitized = (_strip_diagnostic_markers(block) for block in final_summary_blocks) + sanitized = ( + _prepare_user_facing_report_safe(block, detail_level=resolved_detail) + for block in final_summary_blocks + ) normalized = (_normalize_latex_like_markup(block) for block in sanitized) rewritten = (_humanize_pipeline_fallback(block) for block in normalized) - lines.extend(_linkify_reference_citations(block) for block in rewritten) + # linkify first, then decorate so ref-chip class lands on citation anchors + linkified = [_linkify_reference_citations(block) for block in rewritten] + decorated = [ + _decorate_report_for_web(block, detail_level=resolved_detail) + for block in linkified + ] + lines.extend(_keep_last_report_glance(decorated)) return lines -def _build_process_details_section(process_lines: List[str]) -> List[str]: +def _set_thought_cards_expanded( + process_lines: List[str], *, expanded: bool +) -> List[str]: + """Streaming: keep thoughts open. Finished: fold them shut inside process panel.""" + out: List[str] = [] + for line in process_lines: + text = str(line or "") + if expanded: + text = text.replace( + '
', + '
', + ) + else: + text = text.replace( + '
', + '
', + ) + out.append(text) + return out + + +def _build_process_details_section( + process_lines: List[str], + *, + step_count: Optional[int] = None, +) -> List[str]: if not process_lines: return [] + label = _progress_copy("progress_process_summary") + if step_count and step_count > 0: + label = f"{label} · {step_count}" + # data-collapsed + fingerprint: Gradio markdown morph can preserve a stale + # [open] from an earlier stream tick; fingerprint remounts and JS force-closes once. + fingerprint = ( + abs(hash((label, len(process_lines), step_count or 0))) % 1_000_000_007 + ) lines = [ - "\n\n---\n\n", - '
\n🧭 查看检索过程(中间步骤)\n\n', + "\n\n", + ( + f'
\n' + f"🧭 {html.escape(label, quote=False)}\n\n" + ), ] - lines.extend(process_lines) + lines.extend(_set_thought_cards_expanded(process_lines, expanded=False)) lines.append("\n
\n") return lines @@ -2685,6 +3329,12 @@ def _build_search_steps_section(search_step_lines: List[str]) -> List[str]: normalized_line = str(step_line or "").strip() if not normalized_line: continue + # Builders already html.escape query/provider fragments; still neutralize + # any raw angle brackets that slipped through without double-escaping entities. + if "<" in normalized_line or ">" in normalized_line: + # Only escape if it looks like raw tags (not already entity-encoded) + if "<" not in normalized_line and ">" not in normalized_line: + normalized_line = html.escape(normalized_line, quote=False) lines.append(f'
{normalized_line}
') lines.append("") return lines @@ -2711,8 +3361,125 @@ def _build_export_filename(export_format: str, task_id: Optional[str] = None) -> return f"{EXPORT_FILENAME_PREFIX}{suffix}-{timestamp}-{unique_suffix}{extension}" +def _find_details_block_end(text: str, block_start: int) -> int: + open_tag_len = len(" 0: + next_open = text.find(" Optional[Tuple[str, str, str, str]]: + """Return (before, summary, inner, after) for the first details block of a class.""" + opening = f'
", block_start) + body_start = ( + text.find("\n", tag_end) + 1 if tag_end != -1 else block_start + len(opening) + ) + summary_match = re.match( + r"\s*(.*?)\s*", text[body_start:], re.DOTALL + ) + summary = summary_match.group(1) if summary_match else "" + inner_start = body_start + (summary_match.end() if summary_match else 0) + block_end = _find_details_block_end(text, block_start) + inner = text[inner_start : block_end - len("
")].strip() + return text[:block_start], summary.strip(), inner, text[block_end:] + + +def _strip_details_blocks(text: str, class_name: str) -> str: + while True: + parts = _split_details_block(text, class_name) + if parts is None: + return text + before, _summary, _inner, after = parts + text = f"{before.rstrip()}\n\n{after.lstrip()}" + + +def _unwrap_report_folds(text: str) -> str: + while True: + parts = _split_details_block(text, "report-fold") + if parts is None: + return text + before, summary, inner, after = parts + title = html.unescape(re.sub(r"(\d+)\s*$", "", summary)).strip() + text = f"{before.rstrip()}\n\n## {title}\n\n{inner}\n\n{after.lstrip()}" + + +def _plainify_report_html(text: str) -> str: + """Turn web-only report chrome back into plain Markdown for exported files.""" + + # Glance card body is HTML-escaped for the browser; restore it as the 结论 section. + def _restore_glance(glance_match: "re.Match[str]") -> str: + block = glance_match.group(0) + body = html.unescape(glance_match.group(1) or "") + body = re.sub(r"(?i)", "\n", body).strip() + confidence_match = re.search( + r'class="confidence-badge[^"]*">([^<]*)', block + ) + confidence = confidence_match.group(1).strip() if confidence_match else "" + head = f"## 结论\n\n**{confidence}**\n\n" if confidence else "## 结论\n\n" + return f"{head}{body}\n\n" + + text = re.sub( + r'(?ms)
.*?' + r'

(.*?)

\s*
', + _restore_glance, + text, + ) + text = re.sub( + r'(?ms)
\s*' + r'
(.*?)(.*?)
\s*(.*?)\n
', + lambda m: ( + f"## {html.unescape(m.group(1).strip())}\n\n" + f"{html.unescape(re.sub(r'(?s)<[^>]*>', '', m.group(2)).strip())}\n\n" + f"{m.group(3).strip()}\n\n" + ), + text, + ) + text = _unwrap_report_folds(text) + text = re.sub( + r'(?ms)
(.*?)
', + lambda m: f"{m.group(1).strip()}\n\n", + text, + ) + text = re.sub(r'(?ms)\s*.*?', "", text) + text = re.sub( + r'(?ms)]*class="ref-citation[^"]*"[^>]*>(.*?)', + lambda m: f"{m.group(2)}({html.unescape(m.group(1))})", + text, + ) + return re.sub(r"(?m)]*>\s*", "", text) + + +def _extract_conclusion_markdown(rendered_markdown: str) -> str: + """Keep only the research report: the process panel and live progress are UI-only.""" + text = str(rendered_markdown or "") + heading_match = re.search(r"(?m)^###\s*研究报告[^\n]*\n", text) + if heading_match: + text = text[heading_match.start() :] + text = _strip_details_blocks(text, "process-details") + return _plainify_report_html(text) + + def _prepare_export_markdown(markdown_text: str) -> str: - normalized_markdown = _strip_diagnostic_markers(str(markdown_text or "")).strip() + conclusion = _extract_conclusion_markdown(markdown_text) + normalized_markdown = _strip_diagnostic_markers(conclusion).strip() if not normalized_markdown: normalized_markdown = "当前没有可导出的研究结论。" return normalized_markdown + "\n" @@ -2894,8 +3661,40 @@ def _render_markdown( state: dict, render_mode: Optional[str] = None, final_summary_merge_strategy: Optional[str] = None, + output_detail_level: Optional[str] = None, + ui_lang: Optional[str] = None, +) -> str: + resolved_lang = ( + ui_lang + if ui_lang in I18N + else ( + (state or {}).get("ui_lang") + if (state or {}).get("ui_lang") in I18N + else DEFAULT_LANG + ) + ) + _lang_token = _UI_LANG.set(resolved_lang) + try: + return _render_markdown_inner( + state, + render_mode=render_mode, + final_summary_merge_strategy=final_summary_merge_strategy, + output_detail_level=output_detail_level, + ui_lang=resolved_lang, + ) + finally: + _UI_LANG.reset(_lang_token) + + +def _render_markdown_inner( + state: dict, + render_mode: Optional[str] = None, + final_summary_merge_strategy: Optional[str] = None, + output_detail_level: Optional[str] = None, + ui_lang: Optional[str] = None, ) -> str: resolved_render_mode = _normalize_render_mode(render_mode, DEFAULT_UI_RENDER_MODE) + resolved_output_detail_level = _normalize_output_detail_level(output_detail_level) if resolved_render_mode == "full": search_display_limit = SEARCH_RESULT_DISPLAY_MAX scrape_preview_chars = 2200 @@ -2913,7 +3712,9 @@ def _render_markdown( # Render errors first if any if state.get("errors"): for err in state["errors"]: - error_lines.append(f'
❌ {err}
') + error_lines.append( + f'
❌ {html.escape(str(err), quote=False)}
' + ) # Render all agents' content for agent_id in state.get("agent_order", []): @@ -2940,10 +3741,14 @@ def _render_markdown( elif "Search" in display_name: display_name = "检索智能体 (Search Agent)" + safe_name = html.escape(str(display_name), quote=False) + # Escape HTML so agent text cannot break the card shell; + # Markdown emphasis/links still render after entity decode. + safe_content = html.escape(str(content), quote=False) formatted_thought = ( - f'
\n' - f" 💭 {display_name} 思考与规划\n" - f'
\n\n{content}\n\n
\n' + f'
\n' + f" 💭 {safe_name} 思考与规划\n" + f'
\n\n{safe_content}\n\n
\n' f"
\n" ) process_lines.append(formatted_thought) @@ -3002,7 +3807,9 @@ def _render_markdown( if tool_name in ("python", "run_python_code") and (has_input or has_output): # Use pure Markdown to avoid HTML wrapper blocking Markdown rendering process_lines.append("\n---\n") - process_lines.append("#### 💻 Code Execution\n") + process_lines.append( + f"#### 💻 {_progress_copy('progress_code_exec')}\n" + ) # Show code input - try multiple possible keys code = "" if isinstance(tool_input, dict): @@ -3024,25 +3831,30 @@ def _render_markdown( elif isinstance(tool_output, str): output = tool_output if isinstance(output, str) and output.strip(): - process_lines.append("\n**Output:**\n") + process_lines.append( + f"\n**{_progress_copy('progress_output')}:**\n" + ) process_lines.append( f'\n```text\n{output[:1000]}{"..." if len(output) > 1000 else ""}\n```\n' ) - process_lines.append("\n✅ Executed\n") + process_lines.append(f"\n✅ {_progress_copy('progress_executed')}\n") continue # Other tools - show as compact card if has_input or has_output: + safe_tool = html.escape(_tool_display_name(str(tool_name)), quote=False) process_lines.append('
') - process_lines.append(f'
🔧 {tool_name}
') + process_lines.append(f'
🔧 {safe_tool}
') if has_input and isinstance(tool_input, dict): brief = ", ".join( f"{k}: {str(v)[:30]}..." if len(str(v)) > 30 else f"{k}: {v}" for k, v in list(tool_input.items())[:2] ) - process_lines.append(f'
{brief}
') + process_lines.append( + f'
{html.escape(brief, quote=False)}
' + ) if has_output: - process_lines.append('
✓ Done
') + process_lines.append('
✓ 完成
') process_lines.append("
") merged_final_summary_blocks = _merge_final_summary_blocks( @@ -3053,26 +3865,75 @@ def _render_markdown( has_final_summary = bool(merged_final_summary_blocks) if has_final_summary and COLLAPSE_PROCESS_AFTER_SUMMARY: - lines.extend(_build_search_steps_section(search_step_lines)) - lines.extend(_build_summary_section(merged_final_summary_blocks)) - lines.extend(_build_process_details_section(process_lines)) + # Answer-first (ChatGPT/Claude style): final report on top, process folded below. + lines.extend( + _build_summary_section( + merged_final_summary_blocks, + output_detail_level=resolved_output_detail_level, + ) + ) + folded_process: List[str] = [] + folded_process.extend(_build_search_steps_section(search_step_lines)) + folded_process.extend(process_lines) + lines.extend( + _build_process_details_section( + folded_process, + step_count=len(search_step_lines) or None, + ) + ) elif resolved_render_mode == "full": - lines.extend(process_lines) + # Live / no-collapse: expand thoughts while work is streaming. + live_process = ( + _set_thought_cards_expanded(process_lines, expanded=True) + if not has_final_summary + else process_lines + ) + lines.extend(live_process) if has_final_summary: lines.append("\n\n---\n\n") - lines.extend(_build_summary_section(merged_final_summary_blocks)) + lines.extend( + _build_summary_section( + merged_final_summary_blocks, + output_detail_level=resolved_output_detail_level, + ) + ) elif resolved_render_mode == "summary_only": if has_final_summary: - lines.extend(_build_summary_section(merged_final_summary_blocks)) + lines.extend( + _build_summary_section( + merged_final_summary_blocks, + output_detail_level=resolved_output_detail_level, + ) + ) else: - lines.extend(process_lines) + lines.extend(_set_thought_cards_expanded(process_lines, expanded=True)) else: if has_final_summary: - lines.extend(_build_search_steps_section(search_step_lines)) - lines.extend(_build_summary_section(merged_final_summary_blocks)) - lines.extend(_build_process_details_section(process_lines)) + lines.extend( + _build_summary_section( + merged_final_summary_blocks, + output_detail_level=resolved_output_detail_level, + ) + ) + folded_process = [] + folded_process.extend(_build_search_steps_section(search_step_lines)) + folded_process.extend(process_lines) + lines.extend( + _build_process_details_section( + folded_process, + step_count=len(search_step_lines) or None, + ) + ) else: - lines.extend(process_lines) + lines.extend(_set_thought_cards_expanded(process_lines, expanded=True)) + + if has_final_summary: + # 论文式文末来源:LLM 没自己给出参考文献时,用实际命中/访问的来源补一节 + joined_summary = "\n".join(merged_final_summary_blocks) + if not _REFERENCES_HEADING_RE.search(joined_summary): + sources = _collect_report_sources(state) + if sources: + lines.extend(_build_references_section(sources)) if lines: return "\n".join(lines) @@ -3354,14 +4215,15 @@ async def _disconnect_check_for_task(task_id: str): return _CANCEL_FLAGS.get(task_id, False) -def _spinner_markup(running: bool, status_text: str = "生成中...") -> str: +def _spinner_markup(running: bool, status_text: str = "研究进行中…") -> str: if not running: return "" + safe = html.escape(str(status_text or "研究进行中…"), quote=False) return ( - '\n\n
' - '
' - f"{status_text}" - "
\n\n" + '\n\n
' + '' + f'{safe}' + "
\n" ) @@ -3386,9 +4248,11 @@ def _build_initial_ui_state( output_detail_level: str, render_mode: str, summary_merge_strategy: str, + ui_lang: str = DEFAULT_LANG, ) -> dict: return { "task_id": task_id, + "ui_lang": ui_lang if ui_lang in I18N else DEFAULT_LANG, "mode": mode, "search_profile": search_profile, "search_result_num": search_result_num, @@ -3415,6 +4279,15 @@ def _build_launch_kwargs(host: str, port: int) -> dict: allowed_paths = _collect_gradio_allowed_paths() if allowed_paths: launch_kwargs["allowed_paths"] = allowed_paths + # Optional temporary public share (set GRADIO_SHARE=1). Prefer pairing with auth. + if _read_env_bool("GRADIO_SHARE", False): + launch_kwargs["share"] = True + auth_user = (os.getenv("GRADIO_AUTH_USER") or "").strip() + auth_pass = (os.getenv("GRADIO_AUTH_PASS") or "").strip() + if auth_user and auth_pass: + launch_kwargs["auth"] = (auth_user, auth_pass) + # Keep the share link from being casually browsed without credentials. + launch_kwargs["auth_message"] = "Private demo — sign in required." return launch_kwargs @@ -3467,6 +4340,48 @@ def _build_reconnect_initial_render_state(snapshot: dict) -> dict: return state +def _is_waiting_output_markdown(markdown: Optional[str]) -> bool: + """True when markdown is the idle waiting placeholder (EN/CN).""" + text = str(markdown or "").strip() + if not text: + return True + for lang_pack in I18N.values(): + waiting = str(lang_pack.get("output_waiting") or "").strip() + if waiting and text == waiting: + return True + return False + + +def _pack_ui_stream( + markdown, + run_btn_update, + stop_btn_update, + ui_state, + *, + show_output: Optional[bool] = None, +): + """Pack Gradio stream outputs including output-section visibility. + + Returns: + (markdown, run_btn, stop_btn, ui_state, task_id_bridge, + output_section_update, export_bar_update) + """ + if show_output is None: + show_output = not _is_waiting_output_markdown(markdown) + # 导出条只在「有正文且无运行中 spinner」时可见:流式过程中隐藏, + # 终态(完成/失败/重连快照)出现。 + export_visible = show_output and 'class="runtime-spinner"' not in str(markdown) + return ( + markdown, + run_btn_update, + stop_btn_update, + ui_state, + _task_id_bridge_value(ui_state), + gr.update(visible=bool(show_output)), + gr.update(visible=export_visible), + ) + + async def _render_stream_via_api( task_id: str, *, @@ -3478,20 +4393,23 @@ async def _render_stream_via_api( """订阅 api-server SSE 流并按既有渲染管线产出 Gradio 输出元组。 Yields: - (markdown, run_btn_update, stop_btn_update, ui_state) + (markdown, run_btn_update, stop_btn_update, ui_state, task_id_bridge, + output_section, export_bar) """ state = initial_state or _init_render_state() initial_markdown = _render_markdown( state, render_mode=resolved_ui_render_mode, final_summary_merge_strategy=resolved_summary_merge_strategy, + output_detail_level=(ui_state or {}).get("output_detail_level"), + ui_lang=(ui_state or {}).get("ui_lang"), ) - yield ( + yield _pack_ui_stream( initial_markdown + _spinner_markup(True, _format_runtime_status_label(state)), gr.update(interactive=False), gr.update(interactive=True), ui_state, - _task_id_bridge_value(ui_state), + show_output=True, ) # 取消检查复用本地 _CANCEL_FLAGS:stop 按钮按下后会 set 标志 @@ -3517,13 +4435,15 @@ async def _cancel_check() -> bool: state, render_mode=resolved_ui_render_mode, final_summary_merge_strategy=resolved_summary_merge_strategy, + output_detail_level=(ui_state or {}).get("output_detail_level"), + ui_lang=(ui_state or {}).get("ui_lang"), ) - yield ( + yield _pack_ui_stream( heartbeat_md + _spinner_markup(True, heartbeat_label), gr.update(interactive=False), gr.update(interactive=True), ui_state, - _task_id_bridge_value(ui_state), + show_output=True, ) continue state = _update_state_with_event(state, message) @@ -3531,43 +4451,45 @@ async def _cancel_check() -> bool: state, render_mode=resolved_ui_render_mode, final_summary_merge_strategy=resolved_summary_merge_strategy, + output_detail_level=(ui_state or {}).get("output_detail_level"), + ui_lang=(ui_state or {}).get("ui_lang"), ) - yield ( + yield _pack_ui_stream( md + _spinner_markup(True, _format_runtime_status_label(state)), gr.update(interactive=False), gr.update(interactive=True), ui_state, - _task_id_bridge_value(ui_state), + show_output=True, ) await asyncio.sleep(0.01) except api_client.TaskNotFoundError: cleared_ui_state = {**ui_state, "task_id": None} - yield ( + yield _pack_ui_stream( f"任务 `{task_id}` 不存在或已过期,请重新发起检索。", gr.update(interactive=True), gr.update(interactive=False), cleared_ui_state, - _task_id_bridge_value(cleared_ui_state), + show_output=True, ) return except api_client.ApiClientError as exc: logger.error("API SSE 订阅失败: %s", exc) - yield ( + yield _pack_ui_stream( f"连接 api-server 失败:{exc}", gr.update(interactive=True), gr.update(interactive=False), ui_state, - _task_id_bridge_value(ui_state), + show_output=True, ) return except Exception as exc: logger.exception("API 流处理异常") - yield ( + yield _pack_ui_stream( f"流处理异常:{exc}", gr.update(interactive=True), gr.update(interactive=False), ui_state, - _task_id_bridge_value(ui_state), + show_output=True, ) return @@ -3575,13 +4497,15 @@ async def _cancel_check() -> bool: state, render_mode=resolved_ui_render_mode, final_summary_merge_strategy=resolved_summary_merge_strategy, + output_detail_level=(ui_state or {}).get("output_detail_level"), + ui_lang=(ui_state or {}).get("ui_lang"), ) - yield ( + yield _pack_ui_stream( final_md, gr.update(interactive=True), gr.update(interactive=False), ui_state, - _task_id_bridge_value(ui_state), + show_output=True, ) @@ -3608,23 +4532,23 @@ async def _gradio_run_via_api( ) except api_client.ApiClientError as exc: logger.error("api-server 创建任务失败: %s", exc) - yield ( + yield _pack_ui_stream( f"提交任务到 api-server 失败:{exc}", gr.update(interactive=True), gr.update(interactive=False), ui_state, - _task_id_bridge_value(ui_state), + show_output=True, ) return task_id = created.get("task_id") if not task_id: - yield ( + yield _pack_ui_stream( "api-server 未返回 task_id,请检查后端日志。", gr.update(interactive=True), gr.update(interactive=False), ui_state, - _task_id_bridge_value(ui_state), + show_output=True, ) return @@ -3651,6 +4575,7 @@ async def gradio_run( search_result_num: int = DEFAULT_SEARCH_RESULT_NUM, verification_min_search_rounds: int = DEFAULT_VERIFICATION_MIN_SEARCH_ROUNDS, output_detail_level: str = DEFAULT_OUTPUT_DETAIL_LEVEL, + lang: str = DEFAULT_LANG, ui_state: Optional[dict] = None, ): query = replace_chinese_punctuation(query or "") @@ -3671,6 +4596,7 @@ async def gradio_run( resolved_summary_merge_strategy = _normalize_final_summary_merge_strategy( _get_summary_merge_for_output_detail(resolved_output_detail_level) ) + resolved_ui_lang = lang if lang in I18N else DEFAULT_LANG # ===== API 后端模式:把任务交给 api-server,刷新页面可由 task_id 重连 ===== if api_client.is_api_mode_enabled(): @@ -3684,6 +4610,7 @@ async def gradio_run( output_detail_level=resolved_output_detail_level, render_mode=resolved_ui_render_mode, summary_merge_strategy=resolved_summary_merge_strategy, + ui_lang=resolved_ui_lang, ) merged_state = {**base_state, **new_ui_state} async for tup in _gradio_run_via_api( @@ -3712,6 +4639,7 @@ async def gradio_run( "verification_min_search_rounds": resolved_verification_min_rounds, "render_mode": resolved_ui_render_mode, "output_detail_level": resolved_output_detail_level, + "ui_lang": resolved_ui_lang, "final_summary_merge_strategy": resolved_summary_merge_strategy, } else: @@ -3724,6 +4652,7 @@ async def gradio_run( "verification_min_search_rounds": resolved_verification_min_rounds, "render_mode": resolved_ui_render_mode, "output_detail_level": resolved_output_detail_level, + "ui_lang": resolved_ui_lang, "final_summary_merge_strategy": resolved_summary_merge_strategy, } state = _init_render_state() @@ -3732,15 +4661,16 @@ async def gradio_run( state, render_mode=resolved_ui_render_mode, final_summary_merge_strategy=resolved_summary_merge_strategy, + output_detail_level=resolved_output_detail_level, ) # Initial: disable Run, enable Stop, and show spinner at bottom of text - yield ( + yield _pack_ui_stream( initial_markdown + _spinner_markup(True, _format_runtime_status_label(state)), gr.update(interactive=False), gr.update(interactive=True), ui_state, - _task_id_bridge_value(ui_state), + show_output=True, ) async for message in stream_events_optimized( task_id, @@ -3761,13 +4691,14 @@ async def gradio_run( state, render_mode=resolved_ui_render_mode, final_summary_merge_strategy=resolved_summary_merge_strategy, + output_detail_level=resolved_output_detail_level, ) - yield ( + yield _pack_ui_stream( heartbeat_markdown + _spinner_markup(True, heartbeat_label), gr.update(interactive=False), gr.update(interactive=True), ui_state, - _task_id_bridge_value(ui_state), + show_output=True, ) continue @@ -3776,27 +4707,29 @@ async def gradio_run( state, render_mode=resolved_ui_render_mode, final_summary_merge_strategy=resolved_summary_merge_strategy, + output_detail_level=resolved_output_detail_level, ) - yield ( + yield _pack_ui_stream( md + _spinner_markup(True, _format_runtime_status_label(state)), gr.update(interactive=False), gr.update(interactive=True), ui_state, - _task_id_bridge_value(ui_state), + show_output=True, ) # Small delay to allow Gradio to process the update await asyncio.sleep(0.01) # End: enable Run, disable Stop, remove spinner - yield ( + yield _pack_ui_stream( _render_markdown( state, render_mode=resolved_ui_render_mode, final_summary_merge_strategy=resolved_summary_merge_strategy, + output_detail_level=resolved_output_detail_level, ), gr.update(interactive=True), gr.update(interactive=False), ui_state, - _task_id_bridge_value(ui_state), + show_output=True, ) finally: _unregister_active_task(task_id) @@ -3891,6 +4824,7 @@ async def run_research_once( state, render_mode=resolved_api_render_mode, final_summary_merge_strategy=resolved_summary_merge_strategy, + output_detail_level=resolved_output_detail_level, ) # 只有明确成功且收到最终总结时才写缓存;失败、取消或流异常不能污染后续请求。 if ( @@ -4001,6 +4935,7 @@ async def _run_research_once_via_api( state, render_mode=resolved_api_render_mode, final_summary_merge_strategy=resolved_summary_merge_strategy, + output_detail_level=resolved_output_detail_level, ) if done_status in {"completed", "cached"} and "final-output" not in state["agents"]: return "api-server 已完成任务,但未返回可展示的最终结果。" @@ -4074,20 +5009,21 @@ async def reconnect_or_init( ): """页面加载时根据 URL `?task_id=...` 决定是否重连任务。 - Yields 与 gradio_run 相同的 4 元组: - (markdown, run_btn_update, stop_btn_update, ui_state) + Yields 与 gradio_run 相同的 7 元组: + (markdown, run_btn_update, stop_btn_update, ui_state, task_id_bridge, + output_section, export_bar) 当 URL 没有 task_id,或不在 API 模式时,仅恢复初始空闲态。 """ base_state = ui_state or {} # 首屏空闲态:等待用户输入 - idle_tuple = ( + idle_tuple = _pack_ui_stream( I18N[DEFAULT_LANG]["output_waiting"], gr.update(interactive=True), gr.update(interactive=False), base_state, - _task_id_bridge_value(base_state), + show_output=False, ) if not api_client.is_api_mode_enabled(): @@ -4116,23 +5052,23 @@ async def reconnect_or_init( except api_client.ApiClientError as exc: cleared_ui_state = {**base_state, "task_id": None} logger.warning("get_task 失败 task_id=%s err=%s", task_id, exc) - yield ( + yield _pack_ui_stream( f"无法连接 api-server:{exc}", gr.update(interactive=True), gr.update(interactive=False), cleared_ui_state, - _task_id_bridge_value(cleared_ui_state), + show_output=True, ) return if snapshot is None: cleared_ui_state = {**base_state, "task_id": None} - yield ( + yield _pack_ui_stream( f"任务 `{task_id}` 不存在或已过期,请重新发起检索。", gr.update(interactive=True), gr.update(interactive=False), cleared_ui_state, - _task_id_bridge_value(cleared_ui_state), + show_output=True, ) return @@ -4164,6 +5100,7 @@ async def reconnect_or_init( output_detail_level=resolved_output_detail_level, render_mode=resolved_ui_render_mode, summary_merge_strategy=resolved_summary_merge_strategy, + ui_lang=(base_state or {}).get("ui_lang") or DEFAULT_LANG, ) new_ui_state = {**base_state, **new_ui_state} @@ -4377,32 +5314,105 @@ def _update_verification_rounds_visibility(mode: str): return gr.update(visible=_is_verified_mode(mode)) +# Unified outline icon set (24x24, stroke 1.75, round caps/joins). +# Used by top-right Skills button (inline SVG) and action buttons (CSS masks). +_ICON_SVG_ATTRS = ( + 'xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" ' + 'fill="none" stroke="currentColor" stroke-width="1.75" ' + 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"' +) +_ICON_PATHS = { + "download": '', + "settings": ( + '' + '' + '' + '' + '' + ), + "export": ('' ''), + "stop": '', + "run": '', + "close": '', +} + + +def _icon_svg(name: str) -> str: + paths = _ICON_PATHS[name] + return f"{paths}" + + +def _icon_mask_data_uri(name: str) -> str: + """CSS mask-friendly SVG data URI (black strokes for mask luminance).""" + paths = _ICON_PATHS[name] + svg = ( + '' + f"{paths}" + ) + # compact encode for CSS url() + encoded = ( + svg.replace("%", "%25") + .replace("#", "%23") + .replace('"', "%22") + .replace("<", "%3C") + .replace(">", "%3E") + .replace(" ", "%20") + ) + return f"url(%22data:image/svg+xml,{encoded}%22)" + + def build_demo(): api_client.get_backend_mode() logo_data_uri = _load_logo_data_uri() fallback_favicon_data_uri = _build_fallback_favicon_data_uri() custom_css = """ - /* ========== MiroThinker - Clean Emerald Design ========== */ + /* ========== MiroThinker - Tech / Sci-Fi Dark ========== */ - /* Base */ - .gradio-container { - --app-bg: #f9fafb; - --panel-bg: #ffffff; - --panel-border: rgba(0, 0, 0, 0.06); - --panel-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.03); - --ink-strong: #111827; - --ink-body: #374151; - --ink-soft: #6b7280; - --accent: #10b981; - --accent-strong: #059669; - --accent-soft: #d1fae5; + /* Base tokens — also on :root so html/body wash can resolve vars */ + :root, .gradio-container { + --app-bg: #1a2332; + --app-bg-elevated: #1e293b; + --panel-bg: rgba(30, 41, 59, 0.88); + --panel-bg-solid: #243044; + --panel-border: rgba(100, 180, 200, 0.18); + --panel-shadow: 0 8px 28px rgba(15, 23, 42, 0.28); + --glass-bg: rgba(30, 41, 59, 0.72); + --glass-border: rgba(148, 163, 184, 0.22); + --ink-strong: #e2e8f0; + --ink-body: #cbd5e1; + --ink-soft: #94a3b8; + --accent: #38bdf8; + --accent-strong: #0ea5e9; + --accent-soft: rgba(56, 189, 248, 0.12); + --accent-green: #34d399; + --accent-emerald: #34d399; + --btn-gray: rgba(36, 48, 68, 0.92); + --btn-gray-hover: rgba(51, 65, 85, 0.95); + --icon-muted: #94a3b8; + --icon-accent: #38bdf8; + --focus-ring: 0 0 0 2px rgba(56, 189, 248, 0.32); + /* Soft theme still ships light checkbox-label fills — pin dark tokens */ + --checkbox-label-background-fill: rgba(15, 23, 42, 0.88); + --checkbox-label-background-fill-hover: rgba(30, 41, 59, 0.95); + --checkbox-label-background-fill-selected: rgba(14, 165, 233, 0.78); + --checkbox-label-text-color: #cbd5e1; + --checkbox-label-text-color-selected: #f8fafc; + --checkbox-label-border-color: rgba(148, 163, 184, 0.22); + --checkbox-label-border-color-hover: rgba(148, 163, 184, 0.35); + --checkbox-label-border-color-selected: rgba(56, 189, 248, 0.55); + --block-info-text-color: #e2e8f0; + --block-label-text-color: #e2e8f0; max-width: 100% !important; margin: 0 !important; padding: 0 !important; font-family: __LOCAL_FONT_FAMILY_STACK__ !important; - background: #ffffff !important; - color: var(--ink-strong); + background: + radial-gradient(1200px 600px at 50% -10%, rgba(56, 189, 248, 0.07), transparent 55%), + radial-gradient(900px 500px at 90% 10%, rgba(52, 211, 153, 0.05), transparent 50%), + var(--app-bg) !important; + color: var(--ink-body); min-height: 100vh; position: relative; } @@ -4411,13 +5421,34 @@ def build_demo(): display: none; } - /* 强力清除 Gradio 默认包装盒的丑陋背景与边框 */ - .gradio-container .form, - .gradio-container fieldset, + /* Force full-page soft dark wash — never transparent (avoids white canvas) */ + html, body { + background: #1a2332 !important; + color: #cbd5e1 !important; + } + html, body, .gradio-container, .main, .wrap, .app, + #layout-shell, .hero-section-parent, .hero-wrap, + #main-content-column, #top-utility-bar { + background: var(--app-bg, #1a2332) !important; + color: var(--ink-body, #cbd5e1) !important; + } + .gradio-container { + background: + radial-gradient(1200px 600px at 50% -10%, rgba(56, 189, 248, 0.07), transparent 55%), + radial-gradient(900px 500px at 90% 10%, rgba(52, 211, 153, 0.05), transparent 50%), + var(--app-bg) !important; + } + + /* 强力清除 Gradio 默认包装盒的丑陋背景与边框 */ + .gradio-container .form, + .gradio-container fieldset, #main-content-column .form, #right-options-column .form, + #settings-modal .form, #input-section .block, #input-section .solid, + #settings-modal .block, + #settings-modal .solid, #options-panel .block, #options-panel .solid { background: transparent !important; @@ -4425,7 +5456,681 @@ def build_demo(): box-shadow: none !important; } + /* ===== Google-like Search Home (visual polish) ===== */ + #input-section { + background: transparent !important; + border: none !important; + border-radius: 0 !important; + box-shadow: none !important; + padding: 0 !important; + margin: 0 auto 12px !important; + max-width: 584px !important; + width: 100% !important; + gap: 0 !important; + } + + #input-section #question-input, + #input-section #question-input .block, + #input-section #question-input .solid { + background: transparent !important; + border: none !important; + box-shadow: none !important; + } + + #input-section #question-input textarea, + #input-section textarea { + background: rgba(30, 42, 61, 0.88) !important; + border: 1px solid rgba(148, 163, 184, 0.26) !important; + border-radius: 16px !important; + min-height: 56px !important; + max-height: 140px !important; + padding: 15px 20px !important; + font-size: 16px !important; + line-height: 1.5 !important; + color: var(--ink-strong) !important; + box-shadow: 0 10px 28px rgba(2, 6, 23, 0.35), inset 0 1px 0 rgba(255,255,255,0.05) !important; + backdrop-filter: blur(12px); + resize: none !important; + text-align: left !important; + transition: box-shadow 0.18s ease, border-color 0.18s ease, background 0.18s ease !important; + } + + #input-section #question-input textarea::placeholder, + #input-section textarea::placeholder { + color: #64748b !important; + opacity: 1 !important; + } + + #input-section #question-input textarea:hover, + #input-section textarea:hover { + border-color: rgba(56, 189, 248, 0.35) !important; + background: rgba(36, 50, 72, 0.94) !important; + box-shadow: 0 12px 30px rgba(2, 6, 23, 0.4) !important; + } + + #input-section #question-input textarea:focus, + #input-section textarea:focus { + border-color: rgba(56, 189, 248, 0.55) !important; + box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.16), 0 12px 32px rgba(2, 6, 23, 0.42) !important; + background: rgba(36, 50, 72, 0.98) !important; + outline: none !important; + } + + #btn-row { + display: flex !important; + align-items: center !important; + justify-content: center !important; + flex-wrap: wrap !important; + gap: 10px !important; + padding: 18px 4px 6px !important; + border-top: none !important; + background: transparent !important; + } + + #btn-row > * { + flex: 0 0 auto !important; + } + + #btn-row button, + #settings-open-btn, + #stop-btn, + #run-btn { + height: 38px !important; + min-height: 38px !important; + padding: 0 18px !important; + font-size: 14px !important; + font-weight: 500 !important; + letter-spacing: 0.01em !important; + border-radius: 12px !important; + line-height: 38px !important; + box-shadow: none !important; + transform: none !important; + } + + #settings-open-btn, + #stop-btn { + min-width: auto !important; + max-width: none !important; + border: 1px solid rgba(148, 163, 184, 0.2) !important; + background: rgba(36, 48, 68, 0.85) !important; + color: var(--ink-soft) !important; + } + + #settings-open-btn:hover, + #stop-btn:hover { + background: rgba(51, 65, 85, 0.9) !important; + border-color: rgba(148, 163, 184, 0.32) !important; + color: var(--ink-body) !important; + box-shadow: 0 2px 8px rgba(15, 23, 42, 0.2) !important; + } + + #run-btn { + min-width: auto !important; + max-width: none !important; + border: 1px solid rgba(45, 180, 170, 0.35) !important; + background: linear-gradient(135deg, rgba(14, 165, 180, 0.88), rgba(45, 180, 140, 0.82)) !important; + color: #0b1a1c !important; + font-weight: 600 !important; + box-shadow: 0 4px 14px rgba(14, 165, 180, 0.18) !important; + } + + #run-btn:hover { + background: linear-gradient(135deg, rgba(20, 180, 190, 0.95), rgba(52, 190, 150, 0.9)) !important; + box-shadow: 0 6px 18px rgba(14, 165, 180, 0.22) !important; + transform: none !important; + } + + #stop-btn[disabled], + #stop-btn:disabled { + opacity: 0.82 !important; + filter: none !important; + color: #94a3b8 !important; + background: rgba(30, 41, 59, 0.55) !important; + border-color: rgba(148, 163, 184, 0.28) !important; + } + + /* Hide legacy right options column if present */ + #right-options-column { + display: none !important; + } + + /* ===== Modals: settings + export ====================================== + Single source of truth for both overlays. Gradio's Column already + renders display:flex and hides itself with display:none for + visible=False, so never override `display` on the overlay itself. */ + #settings-modal{ + position: fixed !important; + inset: 0 !important; + z-index: 1000 !important; + flex-direction: column !important; + align-items: flex-start !important; + justify-content: flex-start !important; + gap: 0 !important; + padding: 24px !important; + box-sizing: border-box !important; + overflow: auto !important; + cursor: pointer !important; + background: rgba(15, 23, 42, 0.62) !important; + } + + /* Set by the dialog script while a modal is mounted. */ + html.miro-modal-open, + html.miro-modal-open body { + overflow: hidden !important; + } + /* The card is the dialog's focus target; it has no visible edge to ring. */ + #settings-modal .modal-card:focus{ + outline: none !important; + } + + #settings-modal .modal-card{ + /* Gradio gives every Column flex:1 1 auto. In a full-viewport overlay + that stretched the card to its max-height and left a blank bottom. */ + flex: 0 0 auto !important; + display: flex !important; + flex-direction: column !important; + gap: 10px !important; + /* Auto margins centre the card, yet collapse to 0 when the card is + taller than the overlay — so the top stays reachable while scrolling. */ + margin: auto !important; + width: min(420px, 100%) !important; + max-width: 420px !important; + min-width: 0 !important; + padding: 18px 22px 20px !important; + box-sizing: border-box !important; + position: relative !important; + z-index: 1 !important; + cursor: default !important; + color: var(--ink-strong) !important; + background: rgba(30, 41, 59, 0.97) !important; + border: 1px solid var(--panel-border) !important; + border-radius: 16px !important; + box-shadow: 0 16px 40px rgba(15, 23, 42, 0.35), 0 0 0 1px rgba(148, 163, 184, 0.12) !important; + /* Option lists are absolutely positioned inside their own block, so no + ancestor may clip. The overlay scrolls instead of the card. */ + overflow: visible !important; + } + + /* Keep fields in one column and never wider than the card. */ + #settings-modal .modal-card > *, + #settings-modal .modal-card .form, + #settings-modal .modal-card .block, + #settings-modal .modal-card .wrap, + #settings-modal .modal-card .container, + #settings-modal .modal-card label{ + min-width: 0 !important; + max-width: 100% !important; + box-sizing: border-box !important; + } + #settings-modal .modal-card .form{ + display: flex !important; + flex-direction: column !important; + flex-wrap: nowrap !important; + align-items: stretch !important; + width: 100% !important; + overflow: visible !important; + } + #settings-modal .modal-card .form > *{ + flex: 0 0 auto !important; + width: 100% !important; + } + #settings-modal .modal-card .svelte-select, + #settings-modal .modal-card [data-testid="dropdown"]{ + width: 100% !important; + max-width: 100% !important; + } + + /* Gradio writes overflow:hidden inline on blocks; the option list must + be able to escape the trigger it is anchored to. */ + #mode-selector, + #search-profile-selector, + #search-result-num-selector, + #verification-rounds-selector, + #output-detail-level-selector { + overflow: visible !important; + } + + /* Option lists keep Gradio's absolute anchoring so their width and offset + follow the trigger; only the palette is overridden here. */ + #settings-modal ul[role="listbox"]{ + z-index: 5000 !important; + max-height: 240px !important; + overflow-y: auto !important; + background: #1e293b !important; + border: 1px solid rgba(100, 180, 200, 0.25) !important; + border-radius: 10px !important; + box-shadow: 0 12px 32px rgba(15, 23, 42, 0.45) !important; + color: #e2e8f0 !important; + } + #settings-modal ul[role="listbox"] li{ + color: #e2e8f0 !important; + background: transparent !important; + padding: 8px 12px !important; + white-space: normal !important; + } + #settings-modal ul[role="listbox"] li:hover, + #settings-modal ul[role="listbox"] li[aria-selected="true"]{ + background: rgba(34, 211, 238, 0.15) !important; + color: #a5f3fc !important; + } + + /* Dark form controls inside modals */ + #settings-modal label, + #settings-modal .wrap{ + color: var(--ink-strong) !important; + } + #settings-modal input, + #settings-modal select, + #settings-modal textarea, + #settings-modal .secondary-wrap{ + background: rgba(15, 23, 42, 0.85) !important; + border-color: var(--glass-border) !important; + color: var(--ink-strong) !important; + } + + /* Footer: status line + restore/apply actions */ + #settings-modal .modal-footer{ + display: flex !important; + flex-direction: row !important; + flex-wrap: wrap !important; + align-items: center !important; + justify-content: flex-end !important; + gap: 8px !important; + width: 100% !important; + margin-top: 4px !important; + } + /* Status sits on its own full-width row above the action buttons. The + direct flex child is Gradio's block wrapper (#settings-status), not + the inner .modal-status div. */ + #settings-modal .modal-footer > #settings-status{ + flex: 1 1 100% !important; + min-width: 0 !important; + } + #settings-modal .modal-footer .modal-status{ + margin: 0 !important; + color: #7dd3fc; + font-size: 0.76em; + line-height: 1.4; + text-align: left; + } + .modal-status:empty { + display: none; + } + #settings-reset-btn, + #settings-apply-btn { + flex: 0 0 auto !important; + width: auto !important; + height: 34px !important; + min-height: 34px !important; + padding: 0 12px !important; + border-radius: 10px !important; + font-size: 13px !important; + } + #settings-reset-btn { + background: rgba(15, 23, 42, 0.72) !important; + border: 1px solid rgba(148, 163, 184, 0.22) !important; + color: #cbd5e1 !important; + } + #settings-reset-btn:hover { + border-color: rgba(34, 211, 238, 0.45) !important; + color: #22d3ee !important; + } + #settings-apply-btn { + padding: 0 14px !important; + font-weight: 600 !important; + } + + .modal-title { + font-size: 18px; + font-weight: 600; + color: var(--ink-strong); + margin: 0 44px 2px 0; + letter-spacing: 0.02em; + line-height: 36px; + min-height: 36px; + } + + /* ===== Settings modal polish ====================================== */ + #settings-modal { + background: rgba(2, 6, 23, 0.66) !important; + backdrop-filter: blur(6px) !important; + -webkit-backdrop-filter: blur(6px) !important; + } + #settings-modal .modal-card { + width: min(460px, 100%) !important; + max-width: 460px !important; + gap: 16px !important; + padding: 28px 30px 26px !important; + background: linear-gradient(180deg, rgba(32, 44, 64, 0.98), rgba(22, 31, 47, 0.98)) !important; + border-color: rgba(148, 163, 184, 0.16) !important; + box-shadow: 0 24px 64px rgba(2, 6, 23, 0.55), 0 0 0 1px rgba(148, 163, 184, 0.10) !important; + animation: miro-modal-pop 0.16s ease-out !important; + } + #settings-modal .modal-card::before { + content: "" !important; + position: absolute !important; + top: 0 !important; + left: 30px !important; + right: 30px !important; + height: 3px !important; + border-radius: 0 0 4px 4px !important; + background: linear-gradient(90deg, #22d3ee, #818cf8) !important; + } + @keyframes miro-modal-pop { + from { opacity: 0; transform: translateY(10px) scale(0.985); } + to { opacity: 1; transform: none; } + } + @media (prefers-reduced-motion: reduce) { + #settings-modal .modal-card { + animation: none !important; + } + } + #settings-modal .modal-title { + font-size: 20px !important; + font-weight: 700 !important; + } + #settings-modal .modal-title::after { + content: "" !important; + display: block !important; + width: 30px !important; + height: 3px !important; + margin-top: 6px !important; + border-radius: 2px !important; + background: linear-gradient(90deg, #22d3ee, #818cf8) !important; + } + #settings-close-btn { + top: 16px !important; + right: 16px !important; + width: 32px !important; + min-width: 32px !important; + max-width: 32px !important; + height: 32px !important; + min-height: 32px !important; + border-radius: 50% !important; + transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease !important; + } + /* Divider rhythm: one faint line above each field after the title. + Option hints hug the selector they describe — no divider, pulled up. */ + #settings-modal .modal-card .form { + gap: 16px !important; + } + #settings-modal .modal-card .form > *:not(:first-child) { + border-top: 1px solid rgba(148, 163, 184, 0.08) !important; + padding-top: 14px !important; + } + #settings-modal .modal-card .form > #mode-option-hint, + #settings-modal .modal-card .form > #search-profile-option-hint { + border-top: none !important; + padding-top: 0 !important; + margin-top: -10px !important; + } + /* Gradio 默认给每个表单块较大内边距,与分隔线叠加后会显得松散 */ + #settings-modal .modal-card .form > .block.padded { + padding: 4px 0 !important; + } + #settings-modal input[type="range"], + #settings-modal input[type="radio"] { + accent-color: #22d3ee !important; + } + #settings-apply-btn { + background: linear-gradient(135deg, #0ea5e9, #22d3ee) !important; + border: none !important; + color: #06222c !important; + box-shadow: 0 4px 14px rgba(34, 211, 238, 0.28) !important; + transition: filter 0.15s ease, box-shadow 0.15s ease !important; + } + #settings-apply-btn:hover { + filter: brightness(1.08) !important; + box-shadow: 0 6px 18px rgba(34, 211, 238, 0.38) !important; + } + #settings-modal button:focus-visible, + #settings-modal [role="listbox"]:focus-visible { + outline: 2px solid rgba(34, 211, 238, 0.6) !important; + outline-offset: 2px !important; + } + #settings-modal .settings-hint { + margin: -4px 0 0 !important; + font-size: 12px !important; + line-height: 1.55 !important; + color: #94a3b8 !important; + } + #settings-modal .option-hint { + margin: 0 !important; + padding: 6px 10px !important; + border-left: 2px solid rgba(56, 189, 248, 0.45) !important; + border-radius: 0 6px 6px 0 !important; + background: rgba(56, 189, 248, 0.07) !important; + font-size: 12px !important; + line-height: 1.55 !important; + color: #a5c3d9 !important; + } + #settings-modal .preset-row { + display: flex !important; + gap: 8px !important; + width: 100% !important; + } + #settings-modal .preset-row > * { + flex: 1 1 0 !important; + min-width: 0 !important; + } + #settings-modal .preset-btn { + height: 32px !important; + min-height: 32px !important; + padding: 0 6px !important; + font-size: 12.5px !important; + font-weight: 500 !important; + border-radius: 999px !important; + background: rgba(15, 23, 42, 0.55) !important; + border: 1px solid rgba(148, 163, 184, 0.26) !important; + color: var(--ink-body) !important; + box-shadow: none !important; + transition: border-color 0.15s ease, color 0.15s ease, background 0.15s ease !important; + } + #settings-modal .preset-btn:hover { + border-color: rgba(34, 211, 238, 0.5) !important; + color: #22d3ee !important; + background: rgba(34, 211, 238, 0.08) !important; + } + #settings-modal .preset-btn-primary { + border-color: rgba(34, 211, 238, 0.4) !important; + color: #22d3ee !important; + background: rgba(34, 211, 238, 0.1) !important; + } + + /* ===== Export icon bar (result footer) ============================ */ + #export-bar { + display: flex !important; + flex-direction: row !important; + align-items: center !important; + flex-wrap: wrap !important; + justify-content: flex-start !important; + gap: 8px !important; + margin-top: 14px !important; + padding: 0 !important; + border: none !important; + border-radius: 0 !important; + background: transparent !important; + } + #export-bar > * { + min-width: 0 !important; + } + #export-bar .block { + flex: 0 0 auto !important; + } + .export-icon-btn { + /* scale=0 pins Gradio's flex-basis to 0% — restore content sizing or + the label text overflows and overlaps the neighbouring buttons. */ + flex: 0 0 auto !important; + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; + gap: 6px !important; + height: 30px !important; + min-height: 30px !important; + padding: 0 12px !important; + border-radius: 8px !important; + font-size: 12.5px !important; + font-weight: 500 !important; + letter-spacing: 0.01em !important; + background: transparent !important; + border: 1px solid rgba(148, 163, 184, 0.22) !important; + color: var(--ink-soft) !important; + box-shadow: none !important; + transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease !important; + } + .export-icon-btn::before { + content: "" !important; + display: inline-block !important; + font-weight: 700 !important; + font-size: 11px !important; + line-height: 1 !important; + letter-spacing: 0.02em !important; + } + /* 每格式语义化徽标:Markdown 通用 M↓,PDF/Word 用格式缩写 */ + .export-md-btn::before { + content: "M\\2193" !important; + font-size: 12px !important; + } + .export-pdf-btn::before { + content: "PDF" !important; + font-size: 9px !important; + letter-spacing: 0.06em !important; + } + .export-docx-btn::before { + content: "W" !important; + font-size: 12px !important; + } + .export-icon-btn:hover { + color: #22d3ee !important; + border-color: rgba(34, 211, 238, 0.5) !important; + background: rgba(34, 211, 238, 0.06) !important; + } + #export-file { + flex: 1 1 220px !important; + max-width: 420px !important; + } + + #lang-toggle-btn { + height: 36px !important; + min-height: 36px !important; + font-size: 14px !important; + font-weight: 500 !important; + border-radius: 10px !important; + } + + /* Corner icon close — full-width footer "关闭" was the wrong pattern */ + #settings-close-btn, + #export-close-btn { + position: absolute !important; + top: 12px !important; + right: 12px !important; + z-index: 3 !important; + width: 36px !important; + min-width: 36px !important; + max-width: 36px !important; + height: 36px !important; + min-height: 36px !important; + margin: 0 !important; + padding: 0 !important; + border-radius: 10px !important; + border: 1px solid rgba(34, 211, 238, 0.18) !important; + background: rgba(15, 23, 42, 0.85) !important; + color: #94a3b8 !important; + box-shadow: none !important; + font-size: 0 !important; + line-height: 0 !important; + overflow: hidden !important; + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; + } + #settings-close-btn:hover, + #export-close-btn:hover { + color: #22d3ee !important; + border-color: rgba(34, 211, 238, 0.45) !important; + background: rgba(34, 211, 238, 0.08) !important; + } + #settings-close-btn::before, + #export-close-btn::before { + content: "" !important; + display: block !important; + width: 18px !important; + height: 18px !important; + background-color: currentColor !important; + -webkit-mask-repeat: no-repeat !important; + mask-repeat: no-repeat !important; + -webkit-mask-position: center !important; + mask-position: center !important; + -webkit-mask-size: contain !important; + mask-size: contain !important; + -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M18 6 6 18'/%3E%3Cpath d='m6 6 12 12'/%3E%3C/svg%3E") !important; + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M18 6 6 18'/%3E%3Cpath d='m6 6 12 12'/%3E%3C/svg%3E") !important; + } + #settings-close-btn *, + #export-close-btn * { + font-size: 0 !important; + opacity: 0 !important; + pointer-events: none !important; + } + + /* Options helper text under each field (block-info is the TITLE). */ + #settings-modal .md p{ + color: #94a3b8 !important; + font-size: 0.72em !important; + line-height: 1.4 !important; + margin: 2px 0 6px !important; + } + + #options-accordion, + #options-panel .gr-accordion, + #options-panel details { + border: none !important; + background: transparent !important; + } + + #options-accordion > summary, + #options-panel .label-wrap { + font-size: 0.78em !important; + font-weight: 700 !important; + letter-spacing: 0.08em !important; + text-transform: uppercase !important; + color: #64748b !important; + } + + /* Empty progress state */ + #log-view.progress-empty, + #log-view:has(h3), + #output-section #log-view { + display: flex; + flex-direction: column; + } + + #log-view h3 { + color: #e2e8f0 !important; + font-size: 1.15em !important; + border-bottom: none !important; + margin: 8px 0 8px !important; + padding-bottom: 0 !important; + } + + #log-view p { + color: #94a3b8 !important; + font-size: 0.95em !important; + max-width: 36em; + } + .progress-empty-wrap { + min-height: 220px; + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + padding: 12px 8px 24px; + background: + radial-gradient(1200px 240px at 10% 0%, rgba(56,189,248,0.06), transparent 60%), + var(--panel-bg-solid); + border-radius: 16px; + } /* ===== Options Panel ===== */ #right-options-column { @@ -4434,7 +6139,7 @@ def build_demo(): #options-panel { width: 100% !important; - background: #ffffff !important; + background: var(--panel-bg) !important; border: 1px solid rgba(0, 0, 0, 0.03) !important; border-radius: 16px !important; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.02) !important; @@ -4509,7 +6214,7 @@ def build_demo(): #search-result-num-selector .wrap, #verification-rounds-selector .wrap, #output-detail-level-selector .wrap { - background: #ffffff !important; + background: var(--panel-bg) !important; border: 1px solid rgba(0, 0, 0, 0.1) !important; border-radius: 10px !important; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02) !important; @@ -4541,55 +6246,37 @@ def build_demo(): fill: #475569 !important; } + /* btn-row layout owned by P0 search-card rules above */ #btn-row { - padding: 12px 24px 16px !important; - border-top: 1px solid rgba(0, 0, 0, 0.04); - gap: 12px !important; - background: #ffffff; + padding: 14px 4px 4px !important; + border-top: none !important; + gap: 10px !important; + background: transparent !important; } + /* Primary CTA styles owned by Google polish block above */ #run-btn { - background: #10b981 !important; - color: #ffffff !important; - border: none !important; - border-radius: 12px !important; - padding: 12px 20px !important; - font-size: 0.92em !important; - font-weight: 600 !important; - letter-spacing: 0.05em !important; cursor: pointer !important; - transition: all 0.2s ease !important; - box-shadow: 0 2px 4px rgba(16, 185, 129, 0.15) !important; + transition: background 0.15s ease, box-shadow 0.15s ease !important; } #run-btn:hover { - background: #059669 !important; - transform: translateY(-1px) !important; - box-shadow: 0 4px 8px rgba(16, 185, 129, 0.25) !important; + /* keep Google blue hover from polish block */ } #stop-btn { - background: #ffffff !important; - color: #64748b !important; - border: 1px solid rgba(0, 0, 0, 0.06) !important; - border-radius: 12px !important; - padding: 12px 20px !important; - font-size: 0.92em !important; - font-weight: 500 !important; cursor: pointer !important; - transition: all 0.2s ease !important; + transition: background 0.15s ease, color 0.15s ease !important; } #stop-btn:hover { - color: #dc2626 !important; - border-color: rgba(220, 38, 38, 0.2) !important; - background: #fef2f2 !important; + /* gray hover from polish block */ } /* ===== Output Section ===== */ #output-section { width: 100% !important; - max-width: 980px !important; + max-width: 800px !important; margin: 0 auto !important; padding: 0 0 60px !important; } @@ -4605,14 +6292,14 @@ def build_demo(): } #log-view { - padding: 40px 48px !important; - min-height: 420px; + padding: 8px 4px 28px !important; + min-height: 120px; height: auto !important; overflow: visible !important; - background: #ffffff !important; + background: transparent !important; border: none !important; - border-radius: 28px !important; - box-shadow: 0 4px 20px -4px rgba(0, 0, 0, 0.04), 0 0 0 1px rgba(0, 0, 0, 0.03) !important; + border-radius: 0 !important; + box-shadow: none !important; } #export-row { @@ -4621,21 +6308,6 @@ def build_demo(): align-items: flex-end !important; } - #export-btn { - border-radius: 12px !important; - border: 1px solid rgba(16, 185, 129, 0.18) !important; - color: #047857 !important; - background: #ecfdf5 !important; - font-weight: 700 !important; - } - - .export-hint { - margin: 8px 6px 0; - color: #64748b; - font-size: 0.82em; - line-height: 1.6; - } - #log-view h3 { font-size: 1.02em; font-weight: 700; @@ -4662,13 +6334,13 @@ def build_demo(): /* Thought details card */ .thought-card { - background: linear-gradient(180deg, rgba(248, 250, 252, 0.94), rgba(255, 255, 255, 0.98)); - border: 1px solid rgba(15, 23, 42, 0.06); - border-left: 3px solid #10b981; - border-radius: 14px; - padding: 12px 16px; - margin: 12px 0; - box-shadow: 0 4px 12px rgba(15, 23, 42, 0.015); + background: transparent; + border: none; + border-left: 2px solid rgba(52, 211, 153, 0.45); + border-radius: 0; + padding: 4px 0 4px 12px; + margin: 10px 0; + box-shadow: none; } .thought-card > summary { @@ -4694,15 +6366,16 @@ def build_demo(): /* Tool card */ .tool-card { - background: linear-gradient(180deg, rgba(246, 248, 250, 0.94), rgba(255, 255, 255, 0.98)); - border: 1px solid rgba(15, 23, 42, 0.07); - border-radius: 18px; - padding: 14px 16px; - margin: 14px 0; - box-shadow: 0 8px 20px rgba(15, 23, 42, 0.04); - } - - .tool-header { + background: transparent; + border: none; + border-left: 2px solid rgba(148, 163, 184, 0.28); + border-radius: 0; + padding: 4px 0 4px 12px; + margin: 10px 0; + box-shadow: none; + } + + .tool-header { font-size: 0.9em; font-weight: 500; color: var(--ink-strong); @@ -4771,6 +6444,33 @@ def build_demo(): margin: 0 0 14px; } + /* Finished-run process bar: quiet, clickable, answer stays above */ + #log-view .process-details { + margin: 14px 0 8px !important; + border-radius: 12px !important; + border: 1px solid rgba(148, 163, 184, 0.22) !important; + background: rgba(15, 23, 42, 0.55) !important; + padding: 0 !important; + } + #log-view .process-details > summary { + cursor: pointer !important; + list-style: none !important; + padding: 10px 14px !important; + color: #94a3b8 !important; + font-size: 13px !important; + font-weight: 500 !important; + user-select: none !important; + } + #log-view .process-details > summary::-webkit-details-marker { display: none !important; } + #log-view .process-details[open] > summary { + color: #e2e8f0 !important; + border-bottom: 1px solid rgba(148, 163, 184, 0.14) !important; + } + #log-view .process-details .search-step-board, + #log-view .process-details .thought-card, + #log-view .process-details .tool-card { + margin: 8px 12px !important; + } #log-view .process-details { margin-top: 14px; border: 1px solid rgba(15, 23, 42, 0.08); @@ -4795,21 +6495,30 @@ def build_demo(): border: 1px solid #e5e7eb; border-radius: 14px; overflow: hidden; - background: #ffffff; + background: var(--panel-bg-solid); } #log-view .search-step-item { padding: 12px 16px; font-size: 0.88em; line-height: 1.6; - color: #334155; - border-bottom: 1px solid #eef2f7; - background: #f9fafb; + color: #e2e8f0; + border-bottom: 1px solid rgba(148, 163, 184, 0.16); + background: rgba(15, 23, 42, 0.55); } #log-view .search-step-item:last-child { border-bottom: none; } + + #log-view .search-step-item a, + #log-view .tool-card a, + #log-view .thought-card a { + color: #67e8f9 !important; + text-decoration: underline !important; + text-underline-offset: 2px; + word-break: break-all; + } #log-view::-webkit-scrollbar { width: 6px; @@ -4828,13 +6537,60 @@ def build_demo(): background: #d4d4d8; } + + /* Force readable search-step text on dark board (overrides earlier light theme) */ + #log-view .search-step-board .search-step-item { + color: #e2e8f0 !important; + background: transparent !important; + border-bottom-color: rgba(148, 163, 184, 0.14) !important; + } + #log-view .search-step-board .search-step-item a { + color: #67e8f9 !important; + } + #log-view .tool-card, #log-view .thought-card { + color: #e2e8f0 !important; + } + #log-view .tool-card a, #log-view .thought-card a { + color: #67e8f9 !important; + } + + /* Search result cards nested in dark #log-view — force high contrast */ + #log-view .search-card { + background: rgba(15, 23, 42, 0.92) !important; + border: 1px solid rgba(34, 211, 238, 0.2) !important; + color: #e2e8f0 !important; + } + #log-view .search-header, + #log-view .search-count { + background: rgba(8, 47, 73, 0.45) !important; + border-bottom-color: rgba(34, 211, 238, 0.12) !important; + color: #cbd5e1 !important; + } + #log-view .search-query { + color: #f1f5f9 !important; + } + #log-view .search-result-item, + #log-view a.search-result-item { + color: #e2e8f0 !important; + background: transparent !important; + } + #log-view .search-result-item:hover, + #log-view a.search-result-item:hover { + background: rgba(34, 211, 238, 0.08) !important; + border-left-color: #22d3ee !important; + color: #f8fafc !important; + } + #log-view .result-title, + #log-view .search-result-item .result-title { + color: #f1f5f9 !important; + } + #log-view .search-result-item .result-icon { + opacity: 0.9 !important; + } + /* ===== Footer ===== */ .app-footer { - text-align: center; - padding: 24px; - color: #a1a1aa; - font-size: 0.85em; - border-top: 1px solid #f0f0f0; + display: none !important; } /* ===== Loading Spinner ===== */ @@ -4863,8 +6619,8 @@ def build_demo(): /* ===== Search Results Card ===== */ .search-card { - background: #ffffff; - border: 1px solid #e5e5e5; + background: var(--panel-bg-solid); + border: 1px solid var(--glass-border); border-radius: 12px; margin: 16px 0; overflow: hidden; @@ -4934,8 +6690,8 @@ def build_demo(): /* ===== Scrape Card ===== */ .scrape-card { - background: #ffffff; - border: 1px solid #e5e5e5; + background: var(--panel-bg-solid); + border: 1px solid var(--glass-border); border-radius: 10px; margin: 12px 0; padding: 12px 16px; @@ -5202,7 +6958,6 @@ def build_demo(): border-color: rgba(16, 185, 129, 0.3) !important; color: #10b981 !important; } - } .nav-brand { display: flex; @@ -5235,12 +6990,23 @@ def build_demo(): gap: 12px; } - /* ===== Hero Section ===== */ + /* ===== Hero Section (Google-like typography) ===== */ + .hero-section { + position: relative !important; + isolation: isolate; + } + /* Gradio HTML wrappers must not stack old+new text during updates */ + #top-utility-bar, + #main-content-column > .html-container, + .hero-wrap { + overflow: visible; + } + .hero-section { text-align: center; - padding: 16px 16px 32px; - max-width: 1040px; - margin: 0 auto 8px; + padding: 56px 16px 22px; + max-width: 584px; + margin: 0 auto 0; position: relative; } @@ -5248,61 +7014,138 @@ def build_demo(): display: flex; align-items: center; justify-content: center; - gap: 10px; - margin-bottom: 16px; + gap: 8px; + margin-bottom: 8px; } .hero-logo { - width: min(160px, 36vw); - max-height: 64px; + width: min(180px, 42vw); + max-height: 72px; height: auto; object-fit: contain; box-shadow: none; border-radius: 0; flex-shrink: 0; - opacity: 0.9; + opacity: 1; } .hero-brand-name { - font-size: 0.96em; - font-weight: 700; - color: #0f172a; - letter-spacing: 0.01em; + display: none; } .hero-title { - font-size: clamp(1.8rem, 3.2vw, 3rem); - font-weight: 900; - background: linear-gradient(135deg, #065f46 0%, #10b981 40%, #059669 100%); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; - margin: 0 0 10px 0; - letter-spacing: -0.04em; - line-height: 1.15; + font-size: 22px; + font-weight: 500; + color: #e2e8f0; + background: none; + -webkit-text-fill-color: #e2e8f0; + margin: 4px 0 6px 0; + letter-spacing: 0.01em; + line-height: 1.3; } .hero-subtitle { display: flex; align-items: center; justify-content: center; - gap: 16px; - color: #94a3b8; - font-size: 0.92em; - font-weight: 500; + gap: 8px; + color: #b8c5d6; + font-size: 13px; + font-weight: 400; letter-spacing: 0.02em; } .hero-line { - width: 40px; - height: 1px; - background: linear-gradient(90deg, transparent, rgba(16, 185, 129, 0.3), transparent); + display: none; + } + + #layout-shell { + width: 100% !important; + max-width: 680px !important; + margin: 0 auto !important; + padding: 0 16px 48px !important; + gap: 0 !important; + } + + #output-section { + max-width: 680px !important; + margin: 36px auto 0 !important; + padding-bottom: 48px !important; + } + + .output-label { + font-size: 11px !important; + font-weight: 500 !important; + color: #94a3b8 !important; + letter-spacing: 0.06em !important; + } + + #log-view { + padding: 8px 4px 24px !important; + min-height: 120px !important; + border-radius: 0 !important; + border: none !important; + background: transparent !important; + box-shadow: none !important; + backdrop-filter: none; + font-size: 15px !important; + color: var(--ink-body) !important; + line-height: 1.7 !important; + } + + #log-view h3 { + font-size: 16px !important; + font-weight: 600 !important; + color: var(--accent) !important; + margin: 0 0 6px !important; + border-bottom: none !important; + padding-bottom: 0 !important; + } + + #log-view p { + font-size: 14px !important; + color: var(--ink-soft) !important; + line-height: 1.55 !important; + } + + .top-nav { + max-width: 980px !important; + width: calc(100% - 32px) !important; + margin: 8px auto 0 !important; + padding: 8px 4px !important; + } + + .nav-brand-text { + font-size: 14px !important; + font-weight: 500 !important; + color: #94a3b8 !important; + } + + .skills-top-link { + font-size: 13px !important; + color: #1a73e8 !important; + font-weight: 500 !important; + } + + .app-footer { + font-size: 12px !important; + color: #94a3b8 !important; + } + + #main-content-column { + width: 100% !important; + padding: 0 !important; + gap: 0 !important; } /* ===== Responsive ===== */ @media (max-width: 768px) { .hero-title { - font-size: 2.2em; + font-size: 18px; + } + + .hero-section { + padding-top: 28px; } .brand-logo { @@ -5332,56 +7175,933 @@ def build_demo(): padding: 8px 16px 16px; } - .hero-brand { - margin-bottom: 14px; - gap: 8px; - } + .hero-brand { + margin-bottom: 14px; + gap: 8px; + } + + .hero-logo { + width: min(188px, 68vw); + max-height: 72px; + } + + .hero-brand-name { + font-size: 0.9em; + } + + .hero-subtitle { + gap: 10px; + font-size: 0.92em; + } + + #layout-shell { + padding: 0 16px 28px !important; + gap: 16px !important; + } + + #main-content-column { + order: 1; + padding: 0 !important; + } + + #right-options-column { + order: 2; + position: static; + } + + #input-section, + #options-panel, + #log-view { + border-radius: 22px !important; + } + + + #log-view { + min-height: 260px; + padding: 22px 20px !important; + } + } + + /* task_id <-> URL 同步桥的隐藏样式: + Gradio 5 中 visible=False 的组件不进入 DOM,JS 无法找到, + 因此用 CSS 隐藏一个 visible=True 的 textbox。 */ + + /* ===== Unified icon system (outline, 20px in 36px hit target) ===== */ + .ui-icon-btn, + .skills-icon-btn { + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; + width: 36px !important; + height: 36px !important; + min-width: 36px !important; + padding: 0 !important; + border-radius: 10px !important; + border: 1px solid var(--glass-border) !important; + background: var(--glass-bg) !important; + color: var(--icon-muted) !important; + text-decoration: none !important; + box-sizing: border-box !important; + transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease !important; + backdrop-filter: blur(10px); + cursor: pointer; + } + + .ui-icon-btn svg, + .skills-icon-btn svg { + width: 20px !important; + height: 20px !important; + stroke-width: 1.75 !important; + display: block !important; + } + + .ui-icon-btn:hover, + .skills-icon-btn:hover { + color: var(--icon-accent) !important; + border-color: rgba(34, 211, 238, 0.4) !important; + background: rgba(34, 211, 238, 0.08) !important; + box-shadow: 0 0 0 1px rgba(34, 211, 238, 0.12) !important; + } + + .skills-icon-btn.is-copied { + color: var(--accent-emerald) !important; + border-color: rgba(52, 211, 153, 0.45) !important; + } + + .skills-icon-btn-disabled { + opacity: 0.45 !important; + cursor: not-allowed !important; + pointer-events: none !important; + } + + + /* hide-gradio-textbox-label + NOTE: In Gradio 5 the