     1→import os
     2→from pathlib import Path
     3→from typing import Optional, List, Dict, Any
     4→from slack_sdk import WebClient
     5→
     6→from health.utils.logging_config import setup_logger
     7→from slack_bot.obsidian.indexer import ObsidianIndexer
     8→from slack_bot.obsidian.generators import WritingAssistant, ReplyGenerator, DecisionSupport, SearchAnalyzer, DeAIReviser, ZhihuGenerator
     9→from slack_bot.context.storage import ContextStorage as BaseStorage
    10→
    11→logger = setup_logger(__name__)
    12→
    13→class ObsidianContextStorage(BaseStorage):
    14→    """Specific storage for Obsidian bot to avoid mixing with Health bot."""
    15→    def __init__(self, channel_id: str):
    16→        super().__init__(channel_id)
    17→        from health import config
    18→        self.context_dir = config.DATA_DIR / "obsidian_context"
    19→        self.context_dir.mkdir(parents=True, exist_ok=True)
    20→        self.file_path = self.context_dir / f"{channel_id}.json"
    21→        self.state_file = self.context_dir / f"{channel_id}_state.json"
    22→
    23→    def save_state(self, state: Dict[str, Any]):
    24→        import json
    25→        with open(self.state_file, "w") as f:
    26→            json.dump(state, f)
    27→
    28→    def load_state(self) -> Dict[str, Any]:
    29→        import json
    30→        if not self.state_file.exists():
    31→            return {"mode": "none"}
    32→        try:
    33→            with open(self.state_file, "r") as f:
    34→                return json.load(f)
    35→        except:
    36→            return {"mode": "none"}
    37→
    38→class ObsidianDispatcher:
    39→    """Routes Obsidian Slack Bot messages."""
    40→
    41→    MODE_WRITE = "write"
    42→    MODE_REPLY = "reply"
    43→    MODE_DECIDE = "decide"
    44→    MODE_SEARCH = "search"
    45→    MODE_DEAI = "deai"
    46→    MODE_ZHIHU = "zhihu"
    47→    MODE_NOTE = "note"
    48→    MODE_ZHIHU_HUNTER = "zhihu-hunter"
    49→
    50→    VALID_MODES = {MODE_WRITE, MODE_REPLY, MODE_DECIDE, MODE_SEARCH, MODE_DEAI, MODE_ZHIHU, MODE_NOTE, MODE_ZHIHU_HUNTER}
    51→
    52→    def __init__(self, vault_path: str):
    53→        self.indexer = ObsidianIndexer(vault_path)
    54→        self.indexer.scan_vault()
    55→
    56→        # Initialize RAG vector store and note ingester
    57→        from slack_bot.obsidian.embeddings import get_embedding_provider
    58→        from slack_bot.obsidian.vector_store import ChromaVectorStore
    59→        from slack_bot.obsidian.note_ingester import NoteIngester
    60→
    61→        self.vector_store = ChromaVectorStore(embedding_provider=get_embedding_provider())
    62→        self.note_ingester = NoteIngester(
    63→            vault_path=Path(vault_path),
    64→            vector_store=self.vector_store,
    65→        )
    66→
    67→        self.generators = {
    68→            self.MODE_WRITE: WritingAssistant(self.indexer, self.vector_store),
    69→            self.MODE_REPLY: ReplyGenerator(self.indexer, self.vector_store),
    70→            self.MODE_DECIDE: DecisionSupport(self.indexer),
    71→            self.MODE_SEARCH: SearchAnalyzer(self.indexer),
    72→            self.MODE_DEAI: DeAIReviser(self.indexer),
    73→            self.MODE_ZHIHU: ZhihuGenerator(self.indexer, self.vector_store),
    74→        }
    75→
    76→        token = os.environ.get("OBSIDIAN_SLACK_BOT_TOKEN")
    77→        self.client = WebClient(token=token)
    78→
    79→        # Initialize Zhihu Hunter components
    80→        from slack_bot.zhihu.zhihu_hunter import ZhihuHunter
    81→        from slack_bot.zhihu.zhihu_playwright_engine import ZhihuPlaywrightEngine
    82→        from slack_bot.zhihu.slack_interactive_gateway import SlackInteractiveGateway
    83→
    84→        zhihu_notify_channel = os.environ.get("ZHIHU_NOTIFY_CHANNEL", "")
    85→        self.zhihu_hunter = ZhihuHunter(
    86→            vault_path=Path(vault_path),
    87→            vector_store=self.vector_store,
    88→            indexer=self.indexer,
    89→        )
    90→        _zhihu_engine = ZhihuPlaywrightEngine(
    91→            slack_client=self.client,
    92→            notify_channel=zhihu_notify_channel,
    93→        )
    94→        self.zhihu_gateway = SlackInteractiveGateway(
    95→            slack_client=self.client,
    96→            hunter=self.zhihu_hunter,
    97→            engine=_zhihu_engine,
    98→            notify_channel=zhihu_notify_channel,
    99→        )
   100→        logger.info("ObsidianDispatcher initialized")
   101→
   102→    def dispatch(self, text: str, channel_id: str, user_id: str, response_ts: Optional[str] = None):
   103→        storage = ObsidianContextStorage(channel_id)
   104→        state = storage.load_state()
   105→        current_mode = state.get("mode", "none")
   106→
   107→        cmd = text.strip().lower()
   108→
   109→        # 1. Handle Commands
   110→        if cmd.startswith("mode "):
   111→            new_mode = cmd.split(" ", 1)[1].strip()
   112→            if new_mode in self.VALID_MODES:
   113→                storage.clear()
   114→                storage.save_state({"mode": new_mode})
   115→                self._reply(channel_id, f"✅ Switched to mode: *{new_mode}* (History cleared)", response_ts)
   116→                return
   117→            else:
   118→                self._reply(channel_id, f"❌ Invalid mode. Available: {', '.join(sorted(self.VALID_MODES))}", response_ts)
   119→                return
   120→
   121→        if cmd == "clear":
   122→            storage.clear()
   123→            self._reply(channel_id, "🧹 Context cleared.", response_ts)
   124→            return
   125→
   126→        if cmd == "reload":
   127→            self.indexer.scan_vault()
   128→            self._reply(channel_id, "🔄 Vault reloaded.", response_ts)
   129→            return
   130→
   131→        # 2. Handle Content based on mode
   132→        if current_mode == "none":
   133→            self._reply(channel_id, "⚠ No mode selected. Use `mode write/reply/decide/search/deai/zhihu/note/zhihu-hunter` to start.", response_ts)
   134→            return
   135→
   136→        # MODE_NOTE: ingest text or URL into vault + vector store
   137→        if current_mode == self.MODE_NOTE:
   138→            try:
   139→                result = self.note_ingester.ingest(text)
   140→                self._reply(channel_id, result, response_ts)
   141→            except Exception as e:
   142→                logger.error(f"Note ingestion failed: {e}", exc_info=True)
   143→                self._reply(channel_id, f"🐛 Ingestion error: {str(e)}", response_ts)
   144→            return
   145→
   146→        # MODE_ZHIHU_HUNTER: scan vault for keywords, surface Zhihu question candidates
   147→        #
   148→        # Command format:
   149→        #   开始                               → full scan, all dirs, auto keywords
   150→        #   article                            → scan Article only (Revised first, no time filter)
   151→        #   article agent开发                  → scan Article, focus on "agent开发"
   152→        #   notes agent开发                    → scan notes, focus on "agent开发"
   153→        #   关键词1 关键词2                     → skip scan, search directly with keywords
   154→        #   Article/8-xxx-Revised.md           → single file: grounded in that article
   155→        #   Article/*openclaw*-Revised.md      → glob: combine matching files
   156→        #   https://www.zhihu.com/question/... → direct URL: answer this specific question
   157→        #
   158→        # Dir shortcuts: notes / article / clippings / claw / deepfact / deep-dive
   159→        if current_mode == self.MODE_ZHIHU_HUNTER:
   160→            from slack_bot.zhihu.zhihu_hunter import DIR_SHORTCUTS
   161→            try:
   162→                trigger_words = {"开始", "hunt", "扫描", "start", "go"}
   163→                words = [w for w in text.strip().split() if w.lower() not in trigger_words]
   164→
   165→                if not words:
   166→                    # Bare trigger → full auto scan
   167→                    self._reply(channel_id, "🔍 正在扫描知乎候选问题（所有目录）...", response_ts)
   168→                    questions = self.zhihu_hunter.scan_and_hunt(since_days=7)
   169→
   170→                elif words and 'zhihu.com/question/' in words[0]:
   171→                    # Direct URL paste — user was invited or found a question to answer.
   172→                    # Any text after the URL is treated as an optional answer outline.
   173→                    # Strip trailing Slack/HTML junk chars (>, ", etc.) from the URL.
   174→                    import re as _re
   175→                    url = _re.sub(r'[>\'")\].,;]+$', '', words[0])
   176→                    outline = " ".join(words[1:])[:500] if len(words) > 1 else ""
   177→                    hint = f"（已记录回答思路）" if outline else ""
   178→                    self._reply(channel_id, f"🔍 获取问题标题，准备生成草稿{hint}...", response_ts)
   179→                    questions = self.zhihu_hunter.hunt_direct_url(url)
   180→                    if outline and questions:
   181→                        questions[0].outline = outline
   182→                    logger.info(f"ZhihuHunter: direct URL {url!r} → {questions[0].title!r}" +
   183→                                (f" outline={outline[:40]!r}" if outline else ""))
   184→
   185→                elif words and ('*' in words[0] or '?' in words[0]):
   186→                    # Glob pattern → combine all matched files for keyword extraction
   187→                    pattern = words[0]
   188→                    self._reply(
   189→                        channel_id,
   190→                        f"🔍 匹配 `{pattern}` 中的文件，提取观点搜索知乎问题...",
   191→                        response_ts,
   192→                    )
   193→                    questions = self.zhihu_hunter.scan_glob_files(pattern)
   194→                    logger.info(f"ZhihuHunter: glob '{pattern}' → {len(questions)} questions")
   195→
   196→                elif words and ('/' in words[0] or words[0].endswith('.md')):
   197→                    # Single-file mode: extract questions from one specific file,
   198→                    # and pass source_file so answers stay grounded in that article.
   199→                    rel_path = words[0]
   200→                    self._reply(
   201→                        channel_id,
   202→                        f"🔍 从 `{rel_path}` 提取观点，搜索匹配知乎问题...",
   203→                        response_ts,
   204→                    )
   205→                    questions = self.zhihu_hunter.scan_single_file(rel_path)
   206→                    logger.info(f"ZhihuHunter: single-file scan '{rel_path}' → {len(questions)} questions")
   207→
   208→                elif words[0].lower() in DIR_SHORTCUTS:
   209→                    # Dir-scoped scan with optional topic hint
   210→                    rel_dir = DIR_SHORTCUTS[words[0].lower()]
   211→                    topic_hint = " ".join(words[1:])
   212→                    hint_desc = f"，聚焦：{topic_hint}" if topic_hint else ""
   213→                    self._reply(
   214→                        channel_id,
   215→                        f"🔍 扫描 {rel_dir} 目录{hint_desc}，请稍候...",
   216→                        response_ts,
   217→                    )
   218→                    questions = self.zhihu_hunter.scan_and_hunt(
   219→                        since_days=7, dirs=[rel_dir], topic_hint=topic_hint
   220→                    )
   221→
   222→                else:
   223→                    # Direct keywords → skip vault scan entirely
   224→                    self._reply(channel_id, f"🔍 使用指定关键词搜索：{' · '.join(words)}", response_ts)
   225→                    questions = self.zhihu_hunter._find_questions(words)
   226→                    logger.info(f"ZhihuHunter: manual keywords {words} → {len(questions)} questions")
   227→
   228→                self.zhihu_gateway.post_question_cards(questions, channel=channel_id)
   229→            except Exception as e:
   230→                logger.error(f"Zhihu hunt failed: {e}", exc_info=True)
   231→                self._reply(channel_id, f"❌ 扫描失败：{e}", response_ts)
   232→            return
   233→
   234→        # Get history
   235→        history = storage.get_context()
   236→        generator = self.generators[current_mode]
   237→
   238→        try:
   239→            logger.info(f"Generating Obsidian response in mode: {current_mode}")
   240→            # The chat method handles first turn logic and returns updated history
   241→            # But wait, our generators' chat method appends to history internally.
   242→            # In our Slack setup, we want to append the result to ContextStorage.
   243→
   244→            # Note: generator.chat(text, history) returns (response, new_history_list)
   245→            # new_history_list contains [...history, {user: rich_prompt}, {assistant: response}]
   246→            response_text, updated_history = generator.chat(text, history)
   247→
   248→            # Since generator.chat already did the LLM call and returned history,
   249→            # we just need to save the ONLY NEW messages to our storage.
   250→            # However, during FIRST TURN, text is replaced by rich_prompt.
   251→
   252→            # For simplicity: completely overwrite storage with updated_history
   253→            # ContextStorage doesn't support overwrite easily, but we can clear and re-add.
   254→            # Better: Modify ObsidianContextStorage to support set_context.
   255→
   256→            # Actually, let's just use the updated_history and manually add to storage
   257→            # Warning: first turn 'user' message in storage will be the RICH PROMPT
   258→            # This is actually better for context continuity in Slack too.
   259→
   260→            storage.clear()
   261→            for msg in updated_history:
   262→                storage.add_message(msg["role"], msg["content"])
   263→
   264→            self._reply(channel_id, response_text, response_ts)
   265→
   266→        except Exception as e:
   267→            logger.error(f"Obsidian generation failed: {e}", exc_info=True)
   268→            self._reply(channel_id, f"🐛 Error: {str(e)}", response_ts)
   269→
   270→    def _reply(self, channel_id: str, text: str, ts: Optional[str] = None):
   271→        from slack_bot.utils.mrkdwn import SlackFormatter
   272→
   273→        # Slack API limits (official documentation):
   274→        # - chat.postMessage: 40,000 chars
   275→        # - chat.update: 4,000 chars
   276→        # Slack markdown formatting can expand text by 50-100% (bold, links, code blocks, etc.)
   277→        TRUNCATE_WARNING = "\n\n⚠ (Response truncated, full text uploaded as file)"
   278→        FILE_UPLOAD_HINT = "📄 Full response uploaded as file above ☝"
   279→
   280→        # Calculate safe pre-formatting limit with aggressive overhead buffer
   281→        if ts:
   282→            # For updates: 4000 limit - 60% overhead for safety = ~1600 chars
   283→            max_raw_length = 1500
   284→        else:
   285→            # For new messages: 40000 limit - 40% overhead = ~24000 chars
   286→            max_raw_length = 24000
   287→
   288→        original_length = len(text)
   289→        should_upload_file = original_length > max_raw_length
   290→
   291→        if should_upload_file:
   292→            # Upload full text as file first
   293→            self._upload_as_file(channel_id, text, ts)
   294→
   295→            # Then send a truncated preview
   296→            truncate_at = text.rfind('\n\n', 0, 800)
   297→            if truncate_at == -1 or truncate_at < 400:
   298→                truncate_at = text.rfind('\n', 0, 800)
   299→            if truncate_at == -1 or truncate_at < 400:
   300→                truncate_at = 800
   301→
   302→            preview_text = text[:truncate_at] + f"\n\n...\n\n{FILE_UPLOAD_HINT}"
   303→            formatted_text = SlackFormatter.convert(preview_text)
   304→            logger.info(f"Uploaded full response ({original_length} chars) as file, sending preview ({len(preview_text)} chars)")
   305→        else:
   306→            # Text is short enough, just format and send
   307→            formatted_text = SlackFormatter.convert(text)
   308→
   309→            # Final safety check after formatting
   310→            final_limit = 3900 if ts else 39900
   311→            if len(formatted_text) > final_limit:
   312→                logger.warning(f"Formatted text ({len(formatted_text)} chars) exceeds limit after formatting. Uploading as file.")
   313→                self._upload_as_file(channel_id, text, ts)
   314→                # Send simple notification
   315→                formatted_text = FILE_UPLOAD_HINT
   316→
   317→        # Send the message
   318→        try:
   319→            if ts:
   320→                self.client.chat_update(channel=channel_id, ts=ts, text=formatted_text)
   321→            else:
   322→                self.client.chat_postMessage(channel=channel_id, text=formatted_text)
   323→        except Exception as e:
   324→            if "msg_too_long" in str(e):
   325→                logger.error(f"Still got msg_too_long error even after truncation. Text length: {len(formatted_text)}")
   326→                # Ultimate fallback: send error message only
   327→                try:
   328→                    error_msg = "❌ Response formatting error. Please check the uploaded file above."
   329→                    if ts:
   330→                        self.client.chat_update(channel=channel_id, ts=ts, text=error_msg)
   331→                    else:
   332→                        self.client.chat_postMessage(channel=channel_id, text=error_msg)
   333→                except:
   334→                    logger.error("Failed to send even error message", exc_info=True)
   335→            else:
   336→                raise
   337→
   338→    def _upload_as_file(self, channel_id: str, content: str, ts: Optional[str] = None):
   339→        """Fallback: upload long responses as text files."""
   340→        try:
   341→            import tempfile
   342→            from datetime import datetime
   343→
   344→            filename = f"response_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt"
   345→
   346→            with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False, encoding='utf-8') as tmp:
   347→                tmp.write(content)
   348→                tmp_path = tmp.name
   349→
   350→            self.client.files_upload_v2(
   351→                channel=channel_id,
   352→                file=tmp_path,
   353→                filename=filename,
   354→                initial_comment="📄 Response too long for Slack message, uploaded as file:",
   355→                thread_ts=ts
   356→            )
   357→
   358→            os.unlink(tmp_path)
   359→            logger.info(f"Uploaded long response as file: {filename}")
   360→        except Exception as e:
   361→            logger.error(f"Failed to upload file: {e}", exc_info=True)
   362→            # Ultimate fallback: send error message
   363→            error_msg = "❌ Response too long to display. Please try a more specific query."
   364→            if ts:
   365→                self.client.chat_update(channel=channel_id, ts=ts, text=error_msg)
   366→            else:
   367→                self.client.chat_postMessage(channel=channel_id, text=error_msg)
   368→
   369→

import os
from pathlib import Path
from typing import Optional, List, Dict, Any
from slack_sdk import WebClient

from health.utils.logging_config import setup_logger
from slack_bot.obsidian.indexer import ObsidianIndexer
from slack_bot.obsidian.generators import WritingAssistant, ReplyGenerator, DecisionSupport, SearchAnalyzer, DeAIReviser, ZhihuGenerator
from slack_bot.context.storage import ContextStorage as BaseStorage

logger = setup_logger(__name__)

class ObsidianContextStorage(BaseStorage):
    """Specific storage for Obsidian bot to avoid mixing with Health bot."""
    def __init__(self, channel_id: str):
        super().__init__(channel_id)
        from health import config
        self.context_dir = config.DATA_DIR / "obsidian_context"
        self.context_dir.mkdir(parents=True, exist_ok=True)
        self.file_path = self.context_dir / f"{channel_id}.json"
        self.state_file = self.context_dir / f"{channel_id}_state.json"

    def save_state(self, state: Dict[str, Any]):
        import json
        with open(self.state_file, "w") as f:
            json.dump(state, f)

    def load_state(self) -> Dict[str, Any]:
        import json
        if not self.state_file.exists():
            return {"mode": "none"}
        try:
            with open(self.state_file, "r") as f:
                return json.load(f)
        except:
            return {"mode": "none"}

class ObsidianDispatcher:
    """Routes Obsidian Slack Bot messages."""

    MODE_WRITE = "write"
    MODE_REPLY = "reply"
    MODE_DECIDE = "decide"
    MODE_SEARCH = "search"
    MODE_DEAI = "deai"
    MODE_ZHIHU = "zhihu"
    MODE_NOTE = "note"
    MODE_ZHIHU_HUNTER = "zhihu-hunter"

    VALID_MODES = {MODE_WRITE, MODE_REPLY, MODE_DECIDE, MODE_SEARCH, MODE_DEAI, MODE_ZHIHU, MODE_NOTE, MODE_ZHIHU_HUNTER}

    def __init__(self, vault_path: str):
        self.indexer = ObsidianIndexer(vault_path)
        self.indexer.scan_vault()

        # Initialize RAG vector store and note ingester
        from slack_bot.obsidian.embeddings import get_embedding_provider
        from slack_bot.obsidian.vector_store import ChromaVectorStore
        from slack_bot.obsidian.note_ingester import NoteIngester

        self.vector_store = ChromaVectorStore(embedding_provider=get_embedding_provider())
        self.note_ingester = NoteIngester(
            vault_path=Path(vault_path),
            vector_store=self.vector_store,
        )

        self.generators = {
            self.MODE_WRITE: WritingAssistant(self.indexer, self.vector_store),
            self.MODE_REPLY: ReplyGenerator(self.indexer, self.vector_store),
            self.MODE_DECIDE: DecisionSupport(self.indexer),
            self.MODE_SEARCH: SearchAnalyzer(self.indexer),
            self.MODE_DEAI: DeAIReviser(self.indexer),
            self.MODE_ZHIHU: ZhihuGenerator(self.indexer, self.vector_store),
        }

        token = os.environ.get("OBSIDIAN_SLACK_BOT_TOKEN")
        self.client = WebClient(token=token)

        # Initialize Zhihu Hunter components
        from slack_bot.zhihu.zhihu_hunter import ZhihuHunter
        from slack_bot.zhihu.zhihu_playwright_engine import ZhihuPlaywrightEngine
        from slack_bot.zhihu.slack_interactive_gateway import SlackInteractiveGateway

        zhihu_notify_channel = os.environ.get("ZHIHU_NOTIFY_CHANNEL", "")
        self.zhihu_hunter = ZhihuHunter(
            vault_path=Path(vault_path),
            vector_store=self.vector_store,
            indexer=self.indexer,
        )
        _zhihu_engine = ZhihuPlaywrightEngine(
            slack_client=self.client,
            notify_channel=zhihu_notify_channel,
        )
        self.zhihu_gateway = SlackInteractiveGateway(
            slack_client=self.client,
            hunter=self.zhihu_hunter,
            engine=_zhihu_engine,
            notify_channel=zhihu_notify_channel,
        )
        logger.info("ObsidianDispatcher initialized")

    def dispatch(self, text: str, channel_id: str, user_id: str, response_ts: Optional[str] = None):
        storage = ObsidianContextStorage(channel_id)
        state = storage.load_state()
        current_mode = state.get("mode", "none")

        cmd = text.strip().lower()

        # 1. Handle Commands
        if cmd.startswith("mode "):
            new_mode = cmd.split(" ", 1)[1].strip()
            if new_mode in self.VALID_MODES:
                storage.clear()
                storage.save_state({"mode": new_mode})
                self._reply(channel_id, f"✅ Switched to mode: *{new_mode}* (History cleared)", response_ts)
                return
            else:
                self._reply(channel_id, f"❌ Invalid mode. Available: {', '.join(sorted(self.VALID_MODES))}", response_ts)
                return

        if cmd == "clear":
            storage.clear()
            self._reply(channel_id, "🧹 Context cleared.", response_ts)
            return

        if cmd == "reload":
            self.indexer.scan_vault()
            self._reply(channel_id, "🔄 Vault reloaded.", response_ts)
            return

        # 2. Handle Content based on mode
        if current_mode == "none":
            self._reply(channel_id, "⚠ No mode selected. Use `mode write/reply/decide/search/deai/zhihu/note/zhihu-hunter` to start.", response_ts)
            return

        # MODE_NOTE: ingest text or URL into vault + vector store
        if current_mode == self.MODE_NOTE:
            try:
                result = self.note_ingester.ingest(text)
                self._reply(channel_id, result, response_ts)
            except Exception as e:
                logger.error(f"Note ingestion failed: {e}", exc_info=True)
                self._reply(channel_id, f"🐛 Ingestion error: {str(e)}", response_ts)
            return

        # MODE_ZHIHU_HUNTER: scan vault for keywords, surface Zhihu question candidates
        #
        # Command format:
        #   开始                               → full scan, all dirs, auto keywords
        #   article                            → scan Article only (Revised first, no time filter)
        #   article agent开发                  → scan Article, focus on "agent开发"
        #   notes agent开发                    → scan notes, focus on "agent开发"
        #   关键词1 关键词2                     → skip scan, search directly with keywords
        #   Article/8-xxx-Revised.md           → single file: grounded in that article
        #   Article/*openclaw*-Revised.md      → glob: combine matching files
        #   https://www.zhihu.com/question/... → direct URL: answer this specific question
        #
        # Dir shortcuts: notes / article / clippings / claw / deepfact / deep-dive
        if current_mode == self.MODE_ZHIHU_HUNTER:
            from slack_bot.zhihu.zhihu_hunter import DIR_SHORTCUTS
            try:
                trigger_words = {"开始", "hunt", "扫描", "start", "go"}
                words = [w for w in text.strip().split() if w.lower() not in trigger_words]

                if not words:
                    # Bare trigger → full auto scan
                    self._reply(channel_id, "🔍 正在扫描知乎候选问题（所有目录）...", response_ts)
                    questions = self.zhihu_hunter.scan_and_hunt(since_days=7)

                elif words and 'zhihu.com/question/' in words[0]:
                    # Direct URL paste — user was invited or found a question to answer.
                    # Any text after the URL is treated as an optional answer outline.
                    # Strip trailing Slack/HTML junk chars (>, ", etc.) from the URL.
                    import re as _re
                    url = _re.sub(r'[>\'")\].,;]+$', '', words[0])
                    outline = " ".join(words[1:])[:500] if len(words) > 1 else ""
                    hint = f"（已记录回答思路）" if outline else ""
                    self._reply(channel_id, f"🔍 获取问题标题，准备生成草稿{hint}...", response_ts)
                    questions = self.zhihu_hunter.hunt_direct_url(url)
                    if outline and questions:
                        questions[0].outline = outline
                    logger.info(f"ZhihuHunter: direct URL {url!r} → {questions[0].title!r}" +
                                (f" outline={outline[:40]!r}" if outline else ""))

                elif words and ('*' in words[0] or '?' in words[0]):
                    # Glob pattern → combine all matched files for keyword extraction
                    pattern = words[0]
                    self._reply(
                        channel_id,
                        f"🔍 匹配 `{pattern}` 中的文件，提取观点搜索知乎问题...",
                        response_ts,
                    )
                    questions = self.zhihu_hunter.scan_glob_files(pattern)
                    logger.info(f"ZhihuHunter: glob '{pattern}' → {len(questions)} questions")

                elif words and ('/' in words[0] or words[0].endswith('.md')):
                    # Single-file mode: extract questions from one specific file,
                    # and pass source_file so answers stay grounded in that article.
                    rel_path = words[0]
                    self._reply(
                        channel_id,
                        f"🔍 从 `{rel_path}` 提取观点，搜索匹配知乎问题...",
                        response_ts,
                    )
                    questions = self.zhihu_hunter.scan_single_file(rel_path)
                    logger.info(f"ZhihuHunter: single-file scan '{rel_path}' → {len(questions)} questions")

                elif words[0].lower() in DIR_SHORTCUTS:
                    # Dir-scoped scan with optional topic hint
                    rel_dir = DIR_SHORTCUTS[words[0].lower()]
                    topic_hint = " ".join(words[1:])
                    hint_desc = f"，聚焦：{topic_hint}" if topic_hint else ""
                    self._reply(
                        channel_id,
                        f"🔍 扫描 {rel_dir} 目录{hint_desc}，请稍候...",
                        response_ts,
                    )
                    questions = self.zhihu_hunter.scan_and_hunt(
                        since_days=7, dirs=[rel_dir], topic_hint=topic_hint
                    )

                else:
                    # Direct keywords → skip vault scan entirely
                    self._reply(channel_id, f"🔍 使用指定关键词搜索：{' · '.join(words)}", response_ts)
                    questions = self.zhihu_hunter._find_questions(words)
                    logger.info(f"ZhihuHunter: manual keywords {words} → {len(questions)} questions")

                self.zhihu_gateway.post_question_cards(questions, channel=channel_id)
            except Exception as e:
                logger.error(f"Zhihu hunt failed: {e}", exc_info=True)
                self._reply(channel_id, f"❌ 扫描失败：{e}", response_ts)
            return

        # Get history
        history = storage.get_context()
        generator = self.generators[current_mode]

        try:
            logger.info(f"Generating Obsidian response in mode: {current_mode}")
            # The chat method handles first turn logic and returns updated history
            # But wait, our generators' chat method appends to history internally.
            # In our Slack setup, we want to append the result to ContextStorage.

            # Note: generator.chat(text, history) returns (response, new_history_list)
            # new_history_list contains [...history, {user: rich_prompt}, {assistant: response}]
            response_text, updated_history = generator.chat(text, history)

            # Since generator.chat already did the LLM call and returned history,
            # we just need to save the ONLY NEW messages to our storage.
            # However, during FIRST TURN, text is replaced by rich_prompt.

            # For simplicity: completely overwrite storage with updated_history
            # ContextStorage doesn't support overwrite easily, but we can clear and re-add.
            # Better: Modify ObsidianContextStorage to support set_context.

            # Actually, let's just use the updated_history and manually add to storage
            # Warning: first turn 'user' message in storage will be the RICH PROMPT
            # This is actually better for context continuity in Slack too.

            storage.clear()
            for msg in updated_history:
                storage.add_message(msg["role"], msg["content"])

            self._reply(channel_id, response_text, response_ts)

        except Exception as e:
            logger.error(f"Obsidian generation failed: {e}", exc_info=True)
            self._reply(channel_id, f"🐛 Error: {str(e)}", response_ts)

    def _reply(self, channel_id: str, text: str, ts: Optional[str] = None):
        from slack_bot.utils.mrkdwn import SlackFormatter

        # Slack API limits (official documentation):
        # - chat.postMessage: 40,000 chars
        # - chat.update: 4,000 chars
        # Slack markdown formatting can expand text by 50-100% (bold, links, code blocks, etc.)
        TRUNCATE_WARNING = "\n\n⚠ (Response truncated, full text uploaded as file)"
        FILE_UPLOAD_HINT = "📄 Full response uploaded as file above ☝"

        # Calculate safe pre-formatting limit with aggressive overhead buffer
        if ts:
            # For updates: 4000 limit - 60% overhead for safety = ~1600 chars
            max_raw_length = 1500
        else:
            # For new messages: 40000 limit - 40% overhead = ~24000 chars
            max_raw_length = 24000

        original_length = len(text)
        should_upload_file = original_length > max_raw_length

        if should_upload_file:
            # Upload full text as file first
            self._upload_as_file(channel_id, text, ts)

            # Then send a truncated preview
            truncate_at = text.rfind('\n\n', 0, 800)
            if truncate_at == -1 or truncate_at < 400:
                truncate_at = text.rfind('\n', 0, 800)
            if truncate_at == -1 or truncate_at < 400:
                truncate_at = 800

            preview_text = text[:truncate_at] + f"\n\n...\n\n{FILE_UPLOAD_HINT}"
            formatted_text = SlackFormatter.convert(preview_text)
            logger.info(f"Uploaded full response ({original_length} chars) as file, sending preview ({len(preview_text)} chars)")
        else:
            # Text is short enough, just format and send
            formatted_text = SlackFormatter.convert(text)

            # Final safety check after formatting
            final_limit = 3900 if ts else 39900
            if len(formatted_text) > final_limit:
                logger.warning(f"Formatted text ({len(formatted_text)} chars) exceeds limit after formatting. Uploading as file.")
                self._upload_as_file(channel_id, text, ts)
                # Send simple notification
                formatted_text = FILE_UPLOAD_HINT

        # Send the message
        try:
            if ts:
                self.client.chat_update(channel=channel_id, ts=ts, text=formatted_text)
            else:
                self.client.chat_postMessage(channel=channel_id, text=formatted_text)
        except Exception as e:
            if "msg_too_long" in str(e):
                logger.error(f"Still got msg_too_long error even after truncation. Text length: {len(formatted_text)}")
                # Ultimate fallback: send error message only
                try:
                    error_msg = "❌ Response formatting error. Please check the uploaded file above."
                    if ts:
                        self.client.chat_update(channel=channel_id, ts=ts, text=error_msg)
                    else:
                        self.client.chat_postMessage(channel=channel_id, text=error_msg)
                except:
                    logger.error("Failed to send even error message", exc_info=True)
            else:
                raise

    def _upload_as_file(self, channel_id: str, content: str, ts: Optional[str] = None):
        """Fallback: upload long responses as text files."""
        try:
            import tempfile
            from datetime import datetime

            filename = f"response_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt"

            with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False, encoding='utf-8') as tmp:
                tmp.write(content)
                tmp_path = tmp.name

            self.client.files_upload_v2(
                channel=channel_id,
                file=tmp_path,
                filename=filename,
                initial_comment="📄 Response too long for Slack message, uploaded as file:",
                thread_ts=ts
            )

            os.unlink(tmp_path)
            logger.info(f"Uploaded long response as file: {filename}")
        except Exception as e:
            logger.error(f"Failed to upload file: {e}", exc_info=True)
            # Ultimate fallback: send error message
            error_msg = "❌ Response too long to display. Please try a more specific query."
            if ts:
                self.client.chat_update(channel=channel_id, ts=ts, text=error_msg)
            else:
                self.client.chat_postMessage(channel=channel_id, text=error_msg)


Now let me look at the Zhihu Hunter flow. The user pastes a URL `https://www.zhihu.com/question/2016845353517806681` and adds "回答定义，优势和建议" as the outline. Let me trace through the dispatcher code:

Looking at the `MODE_ZHIHU_HUNTER` section:
1. When a URL containing `zhihu.com/question/` is detected, it calls `self.zhihu_hunter.hunt_direct_url(url)`
2. Any text after the URL is treated as an optional answer outline

But wait - the user's message format is: `https://www.zhihu.com/question/2016845353517806681 回答定义，优势和建议`

So the URL is the first word, and "回答定义，优势和建议" is the outline/hint.

The flow is:
1. `hunt_direct_url(url)` fetches the question from Zhihu
2. The outline "回答定义，优势和建议" gets saved
3. Then `self.zhihu_gateway.post_question_cards(questions, channel=channel_id)` posts the card

When the user clicks to generate the answer, it eventually calls `ZhihuGenerator.chat()`, but the user is reporting the answer is incorrect.

I need to trace through the zhihu_hunter code to see how it's extracting the question content, and then check the interactive gateway to understand where the answer generation is going wrong.


让我看看 zhihu_hunter 如何获取知乎问题内容，以及答案是如何生成的。


Agent

Explore zhihu hunter code

Prompt:
I need to understand the Zhihu answer generation pipeline in this project. Specifically:

1. How does `ZhihuHunter.hunt_direct_url()` fetch a Zhihu question (in `/root/projects/butler/slack_bot/zhihu/zhihu_hunter.py`)?
2. How does `SlackInteractiveGateway` handle user interaction to trigger answer generation (in `/root/projects/butler/slack_bot/zhihu/slack_interactive_gateway.py`)?
3. How is the Zhihu answer actually generated - what prompt is sent to the LLM and does it include the question's detail/description text or just the title?
4. Where does the `outline` field get used in the answer generation process?

Be thorough - read the full relevant methods in these files. I need to understand the complete flow from URL paste → question fetch → user clicks "generate" → LLM generates answer.


I need to understand the Zhihu answer generation pipeline in this project. Specifically:

1. How does `ZhihuHunter.hunt_direct_url()` fetch a Zhihu question (in `/root/projects/butler/slack_bot/zhihu/zhihu_hunter.py`)?
2. How does `SlackInteractiveGateway` handle user interaction to trigger answer generation (in `/root/projects/butler/slack_bot/zhihu/slack_interactive_gateway.py`)?
3. How is the Zhihu answer actually generated - what prompt is sent to the LLM and does it include the question's detail/description text or just the title?
4. Where does the `outline` field get used in the answer generation process?

Be thorough - read the full relevant methods in these files. I need to understand the complete flow from URL paste → question fetch → user clicks "generate" → LLM generates answer.

Prompt:
I need to understand the Zhihu answer generation pipeline in this project. Specifically:

1. How does `ZhihuHunter.hunt_direct_url()` fetch a Zhihu question (in `/root/projects/butler/slack_bot/zhihu/zhihu_hunter.py`)?
2. How does `SlackInteractiveGateway` handle user interaction to trigger answer generation (in `/root/projects/butler/slack_bot/zhihu/slack_interactive_gateway.py`)?
3. How is the Zhihu answer actually generated - what prompt is sent to the LLM and does it include the question's detail/description text or just the title?
4. Where does the `outline` field get used in the answer generation process?

Be thorough - read the full relevant methods in these files. I need to understand the complete flow from URL paste → question fetch → user clicks "generate" → LLM generates answer.


     1→"""Slack Interactive Gateway — Block Kit control panel for zhihu-hunter.
     2→
     3→Manages two human-in-the-loop approval gates:
     4→
     5→    Gate 1  [✍ 生成草稿] / [🚫 忽略]
     6→            Triggered when ZhihuHunter surfaces candidate questions.
     7→
     8→    Gate 2  [✅ 确认发布] / [🔄 重新生成] / [❌ 取消]
     9→            Triggered after an answer draft has been generated.
    10→
    11→Usage:
    12→    gateway = SlackInteractiveGateway(slack_client, hunter, engine, channel)
    13→    gateway.register(app)   # call once at bot startup
    14→    gateway.post_question_cards(questions)  # call from dispatcher
    15→"""
    16→
    17→import json
    18→import uuid
    19→from pathlib import Path
    20→from typing import Optional
    21→
    22→from slack_bolt import App
    23→from slack_sdk import WebClient
    24→
    25→from health.utils.logging_config import setup_logger
    26→from slack_bot.zhihu.zhihu_hunter import AnswerDraft, ZhihuHunter, ZhihuQuestion
    27→from slack_bot.zhihu.zhihu_playwright_engine import ZhihuPlaywrightEngine
    28→
    29→logger = setup_logger(__name__)
    30→
    31→
    32→# ── Action IDs ─────────────────────────────────────────────────────────────────
    33→_ACT_GENERATE = "zhihu_generate_draft"
    34→_ACT_IGNORE   = "zhihu_ignore"
    35→_ACT_PUBLISH  = "zhihu_publish"
    36→_ACT_REGEN    = "zhihu_regenerate"
    37→_ACT_CANCEL   = "zhihu_cancel"
    38→_VIEW_REGEN   = "zhihu_regen_modal"
    39→
    40→# Characters shown in the draft preview section (Slack block limit: 3000)
    41→_PREVIEW_CHARS = 800
    42→
    43→
    44→class SlackInteractiveGateway:
    45→    """Block Kit gateway providing two approval gates for the zhihu-hunter workflow.
    46→
    47→    Args:
    48→        slack_client: Authenticated WebClient (same token as the Bolt App).
    49→        hunter: ZhihuHunter instance for draft generation.
    50→        engine: ZhihuPlaywrightEngine instance for publishing.
    51→        notify_channel: Default Slack channel/DM for outbound cards.
    52→        state_dir: Directory for storing draft JSON files between interactions.
    53→            Defaults to <project_root>/data/zhihu_drafts.
    54→    """
    55→
    56→    def __init__(
    57→        self,
    58→        slack_client: WebClient,
    59→        hunter: ZhihuHunter,
    60→        engine: ZhihuPlaywrightEngine,
    61→        notify_channel: str,
    62→        state_dir: Optional[Path] = None,
    63→    ) -> None:
    64→        self.client        = slack_client
    65→        self.hunter        = hunter
    66→        self.engine        = engine
    67→        self.notify_channel = notify_channel
    68→        self.state_dir     = state_dir or (
    69→            Path(__file__).resolve().parents[3] / "data" / "zhihu_drafts"
    70→        )
    71→        self.state_dir.mkdir(parents=True, exist_ok=True)
    72→
    73→    # ── Registration ───────────────────────────────────────────────────────────
    74→
    75→    def register(self, app: App) -> None:
    76→        """Attach all action handlers to a Slack Bolt App instance.
    77→
    78→        Call this once during bot startup, after the App object is created.
    79→
    80→        Args:
    81→            app: The Slack Bolt App that receives Socket Mode events.
    82→        """
    83→        app.action(_ACT_GENERATE)(self._handle_generate_draft)
    84→        app.action(_ACT_IGNORE)(self._handle_ignore)
    85→        app.action(_ACT_PUBLISH)(self._handle_publish)
    86→        app.action(_ACT_REGEN)(self._handle_regenerate)
    87→        app.action(_ACT_CANCEL)(self._handle_cancel)
    88→        app.view(_VIEW_REGEN)(self._handle_regenerate_submit)
    89→        logger.info("ZhihuHunter: Slack action handlers registered")
    90→
    91→    # ── Outbound: Gate 1 ───────────────────────────────────────────────────────
    92→
    93→    def post_question_cards(
    94→        self,
    95→        questions: list[ZhihuQuestion],
    96→        channel: Optional[str] = None,
    97→    ) -> None:
    98→        """Post one Block Kit card per discovered question (Gate 1).
    99→
   100→        Each card includes [✍ 生成草稿] and [🚫 忽略] buttons.
   101→
   102→        Args:
   103→            questions: Candidate questions returned by ZhihuHunter.scan_and_hunt().
   104→            channel: Override channel. Defaults to self.notify_channel.
   105→        """
   106→        ch = channel or self.notify_channel
   107→        if not questions:
   108→            self.client.chat_postMessage(
   109→                channel=ch,
   110→                text="🔍 本次未发现符合条件的知乎问题，下次再试。",
   111→            )
   112→            return
   113→
   114→        self.client.chat_postMessage(
   115→            channel=ch,
   116→            text=f"🔍 发现 *{len(questions)}* 个知乎相关问题，请逐一确认：",
   117→        )
   118→        for q in questions:
   119→            try:
   120→                self.client.chat_postMessage(
   121→                    channel=ch,
   122→                    blocks=self._question_blocks(q),
   123→                    text=f"🔍 知乎问题: {q.title}",
   124→                )
   125→            except Exception as e:
   126→                logger.error(f"Failed to post question card for '{q.title}': {e}")
   127→
   128→    # ── Outbound: Gate 2 ───────────────────────────────────────────────────────
   129→
   130→    def post_draft_card(
   131→        self,
   132→        draft: AnswerDraft,
   133→        channel: str,
   134→        thread_ts: str,
   135→    ) -> None:
   136→        """Post an answer draft card as a thread reply (Gate 2).
   137→
   138→        Includes [✅ 确认发布], [🔄 重新生成], and [❌ 取消] buttons.
   139→
   140→        Args:
   141→            draft: Generated AnswerDraft.
   142→            channel: Channel containing the original question card.
   143→            thread_ts: ts of the question card message (reply target).
   144→        """
   145→        draft_id = self._save_draft(draft)
   146→        try:
   147→            self.client.chat_postMessage(
   148→                channel=channel,
   149→                thread_ts=thread_ts,
   150→                blocks=self._draft_blocks(draft, draft_id),
   151→                text=f"📝 草稿已生成，等待确认发布: {draft.question.title}",
   152→            )
   153→        except Exception as e:
   154→            logger.error(f"Failed to post draft card: {e}")
   155→
   156→    # ── Action Handlers ────────────────────────────────────────────────────────
   157→
   158→    def _handle_generate_draft(self, ack, body, client) -> None:
   159→        """Gate 1 → [✍ 生成草稿]: generate draft and post as thread reply."""
   160→        ack()
   161→        channel  = body["channel"]["id"]
   162→        msg_ts   = body["message"]["ts"]
   163→        raw_val  = body["actions"][0]["value"]
   164→
   165→        # Immediately show loading state
   166→        client.chat_update(
   167→            channel=channel,
   168→            ts=msg_ts,
   169→            blocks=self._loading_block("✍ 草稿生成中，请稍候..."),
   170→            text="草稿生成中...",
   171→        )
   172→
   173→        try:
   174→            question = ZhihuQuestion.model_validate_json(raw_val)
   175→            draft    = self.hunter.draft_answer(question)
   176→
   177→            # Persist to vault for manual fallback publishing
   178→            vault_file = self._save_to_vault(draft)
   179→            if vault_file:
   180→                draft = draft.model_copy(update={"vault_file": str(vault_file)})
   181→
   182→            draft_id = self._save_draft(draft)
   183→
   184→            # Post draft as thread reply
   185→            client.chat_postMessage(
   186→                channel=channel,
   187→                thread_ts=msg_ts,
   188→                blocks=self._draft_blocks(draft, draft_id),
   189→                text=f"📝 草稿已生成: {question.title}",
   190→            )
   191→
   192→            # Update original question card to show "done" state
   193→            client.chat_update(
   194→                channel=channel,
   195→                ts=msg_ts,
   196→                blocks=self._done_block(
   197→                    f"📩 草稿已生成，请在上方回复中查看并确认。\n*<{question.url}|{question.title}>*"
   198→                ),
   199→                text="草稿已生成",
   200→            )
   201→
   202→        except Exception as e:
   203→            logger.error(f"Draft generation failed: {e}", exc_info=True)
   204→            client.chat_update(
   205→                channel=channel,
   206→                ts=msg_ts,
   207→                blocks=self._error_block(f"草稿生成失败：{e}"),
   208→                text="草稿生成失败",
   209→            )
   210→
   211→    def _handle_ignore(self, ack, respond) -> None:
   212→        """Gate 1 → [🚫 忽略]: dismiss the question card."""
   213→        ack()
   214→        respond(
   215→            replace_original=True,
   216→            text="🚫 已忽略",
   217→            blocks=self._done_block("🚫 已忽略此问题"),
   218→        )
   219→
   220→    def _handle_publish(self, ack, body, client) -> None:
   221→        """Gate 2 → [✅ 确认发布]: login to Zhihu and publish the answer."""
   222→        ack()
   223→        channel  = body["channel"]["id"]
   224→        msg_ts   = body["message"]["ts"]
   225→        draft_id = body["actions"][0]["value"]
   226→
   227→        client.chat_update(
   228→            channel=channel,
   229→            ts=msg_ts,
   230→            blocks=self._loading_block("⏳ 正在登录知乎并发布，请稍候..."),
   231→            text="发布中...",
   232→        )
   233→
   234→        try:
   235→            draft = self._load_draft(draft_id)
   236→            if draft is None:
   237→                raise RuntimeError(f"草稿文件不存在（id={draft_id}），可能已过期")
   238→
   239→            # Ensure valid Zhihu session (may send QR to Slack)
   240→            if not self.engine.ensure_logged_in():
   241→                raise RuntimeError("知乎登录失败，请扫码后重试")
   242→
   243→            answer_url = self.engine.publish_answer(
   244→                question_url=draft.question.url,
   245→                answer_text=draft.content,
   246→            )
   247→
   248→            client.chat_update(
   249→                channel=channel,
   250→                ts=msg_ts,
   251→                blocks=self._done_block(
   252→                    f"✅ *发布成功！*\n"
   253→                    f"问题: *<{draft.question.url}|{draft.question.title}>*\n"
   254→                    f"回答: <{answer_url}|查看已发布回答>"
   255→                ),
   256→                text=f"✅ 已发布: {answer_url}",
   257→            )
   258→            # Update vault file status to published
   259→            if draft.vault_file:
   260→                self._update_vault_published(draft.vault_file, answer_url)
   261→            # Clean up draft file
   262→            self._delete_draft(draft_id)
   263→
   264→        except Exception as e:
   265→            logger.error(f"Publish failed: {e}", exc_info=True)
   266→            client.chat_update(
   267→                channel=channel,
   268→                ts=msg_ts,
   269→                blocks=self._error_block(
   270→                    f"发布失败：{e}\n你可以重新点击发布，或联系排查日志。"
   271→                ),
   272→                text="发布失败",
   273→            )
   274→
   275→    def _handle_regenerate(self, ack, body, client) -> None:
   276→        """Gate 2 → [🔄 重新生成]: open a modal for user to enter revision feedback."""
   277→        ack()
   278→        channel  = body["channel"]["id"]
   279→        msg_ts   = body["message"]["ts"]
   280→        raw_val  = body["actions"][0]["value"]
   281→
   282→        try:
   283→            question = ZhihuQuestion.model_validate_json(raw_val)
   284→        except Exception:
   285→            question_title = "（问题信息丢失）"
   286→        else:
   287→            question_title = question.title
   288→
   289→        private_metadata = json.dumps({
   290→            "channel":    channel,
   291→            "msg_ts":     msg_ts,
   292→            "regen_value": raw_val,
   293→        })
   294→
   295→        client.views_open(
   296→            trigger_id=body["trigger_id"],
   297→            view={
   298→                "type": "modal",
   299→                "callback_id": _VIEW_REGEN,
   300→                "title": {"type": "plain_text", "text": "重新生成草稿"},
   301→                "submit": {"type": "plain_text", "text": "生成"},
   302→                "close":  {"type": "plain_text", "text": "取消"},
   303→                "private_metadata": private_metadata,
   304→                "blocks": [
   305→                    {
   306→                        "type": "section",
   307→                        "text": {
   308→                            "type": "mrkdwn",
   309→                            "text": f"*{question_title}*\n\n请输入修改意见，Bot 会据此重新生成草稿。",
   310→                        },
   311→                    },
   312→                    {
   313→                        "type": "input",
   314→                        "block_id": "feedback_block",
   315→                        "optional": True,
   316→                        "label": {"type": "plain_text", "text": "修改意见"},
   317→                        "element": {
   318→                            "type": "plain_text_input",
   319→                            "action_id": "feedback_input",
   320→                            "multiline": True,
   321→                            "placeholder": {
   322→                                "type": "plain_text",
   323→                                "text": "例：内容太技术了，偏向大众一些；字数控制在500字以内；结尾加一句诗",
   324→                            },
   325→                        },
   326→                    },
   327→                ],
   328→            },
   329→        )
   330→
   331→    def _handle_regenerate_submit(self, ack, body, client) -> None:
   332→        """Modal submit → regenerate draft with user feedback."""
   333→        ack()
   334→        metadata  = json.loads(body["view"]["private_metadata"])
   335→        channel   = metadata["channel"]
   336→        msg_ts    = metadata["msg_ts"]
   337→        raw_val   = metadata["regen_value"]
   338→        feedback  = (
   339→            body["view"]["state"]["values"]
   340→            .get("feedback_block", {})
   341→            .get("feedback_input", {})
   342→            .get("value") or ""
   343→        )
   344→
   345→        client.chat_update(
   346→            channel=channel,
   347→            ts=msg_ts,
   348→            blocks=self._loading_block("🔄 重新生成中，请稍候..."),
   349→            text="重新生成中...",
   350→        )
   351→
   352→        try:
   353→            question = ZhihuQuestion.model_validate_json(raw_val)
   354→            draft    = self.hunter.draft_answer(question, feedback=feedback)
   355→
   356→            # Persist to vault (overwrites previous draft file for same question)
   357→            vault_file = self._save_to_vault(draft)
   358→            if vault_file:
   359→                draft = draft.model_copy(update={"vault_file": str(vault_file)})
   360→
   361→            draft_id = self._save_draft(draft)
   362→
   363→            client.chat_update(
   364→                channel=channel,
   365→                ts=msg_ts,
   366→                blocks=self._draft_blocks(draft, draft_id),
   367→                text=f"📝 草稿已重新生成: {question.title}",
   368→            )
   369→
   370→        except Exception as e:
   371→            logger.error(f"Regenerate submit failed: {e}", exc_info=True)
   372→            client.chat_update(
   373→                channel=channel,
   374→                ts=msg_ts,
   375→                blocks=self._error_block(f"重新生成失败：{e}"),
   376→                text="重新生成失败",
   377→            )
   378→
   379→    def _handle_cancel(self, ack, respond) -> None:
   380→        """Gate 2 → [❌ 取消]: dismiss the draft card."""
   381→        ack()
   382→        respond(
   383→            replace_original=True,
   384→            text="❌ 已取消",
   385→            blocks=self._done_block("❌ 已取消本次发布"),
   386→        )
   387→
   388→    # ── Block Kit Builders ─────────────────────────────────────────────────────
   389→
   390→    def _question_blocks(self, question: ZhihuQuestion) -> list[dict]:
   391→        """Build Gate 1 Block Kit blocks for a single question.
   392→
   393→        Args:
   394→            question: ZhihuQuestion to display.
   395→
   396→        Returns:
   397→            List of Slack Block Kit block dicts.
   398→        """
   399→        # Truncate snippet so the button value stays < 2000 chars
   400→        q_for_button = question.model_copy(
   401→            update={"snippet": (question.snippet or "")[:100]}
   402→        )
   403→        btn_value = q_for_button.model_dump_json()
   404→
   405→        body_lines = [f"*<{question.url}|{question.title}>*"]
   406→        if question.snippet:
   407→            body_lines.append(f"> {question.snippet[:200]}")
   408→        if question.keywords:
   409→            body_lines.append(f"_关键词：{' · '.join(question.keywords)}_")
   410→
   411→        return [
   412→            {
   413→                "type": "section",
   414→                "text": {"type": "mrkdwn", "text": "\n".join(body_lines)},
   415→            },
   416→            {
   417→                "type": "actions",
   418→                "elements": [
   419→                    {
   420→                        "type": "button",
   421→                        "text": {"type": "plain_text", "text": "✍ 生成草稿", "emoji": True},
   422→                        "style": "primary",
   423→                        "action_id": _ACT_GENERATE,
   424→                        "value": btn_value,
   425→                    },
   426→                    {
   427→                        "type": "button",
   428→                        "text": {"type": "plain_text", "text": "🚫 忽略", "emoji": True},
   429→                        "action_id": _ACT_IGNORE,
   430→                        "value": "ignore",
   431→                    },
   432→                ],
   433→            },
   434→        ]
   435→
   436→    def _draft_blocks(self, draft: AnswerDraft, draft_id: str) -> list[dict]:
   437→        """Build Gate 2 Block Kit blocks for a draft answer.
   438→
   439→        Args:
   440→            draft: AnswerDraft to display.
   441→            draft_id: UUID key used as the publish button value.
   442→
   443→        Returns:
   444→            List of Slack Block Kit block dicts.
   445→        """
   446→        preview = draft.content[:_PREVIEW_CHARS]
   447→        if len(draft.content) > _PREVIEW_CHARS:
   448→            preview += f"\n_…（共 {len(draft.content)} 字，发布时使用全文）_"
   449→
   450→        # Truncate question for regenerate button value
   451→        q_for_btn = draft.question.model_copy(
   452→            update={"snippet": (draft.question.snippet or "")[:100]}
   453→        )
   454→        regen_value = q_for_btn.model_dump_json()
   455→
   456→        blocks: list[dict] = [
   457→            {
   458→                "type": "section",
   459→                "text": {
   460→                    "type": "mrkdwn",
   461→                    "text": (
   462→                        f"📝 *草稿已生成* | "
   463→                        f"<{draft.question.url}|{draft.question.title}>"
   464→                    ),
   465→                },
   466→            },
   467→            {"type": "divider"},
   468→            {
   469→                "type": "section",
   470→                "text": {"type": "mrkdwn", "text": preview},
   471→            },
   472→        ]
   473→
   474→        if draft.rag_sources:
   475→            blocks.append({
   476→                "type": "context",
   477→                "elements": [{
   478→                    "type": "mrkdwn",
   479→                    "text": f"📚 RAG 来源：{' · '.join(draft.rag_sources[:5])}",
   480→                }],
   481→            })
   482→
   483→        blocks += [
   484→            {"type": "divider"},
   485→            {
   486→                "type": "actions",
   487→                "elements": [
   488→                    {
   489→                        "type": "button",
   490→                        "text": {"type": "plain_text", "text": "✅ 确认发布", "emoji": True},
   491→                        "style": "primary",
   492→                        "action_id": _ACT_PUBLISH,
   493→                        "value": draft_id,
   494→                    },
   495→                    {
   496→                        "type": "button",
   497→                        "text": {"type": "plain_text", "text": "🔄 重新生成", "emoji": True},
   498→                        "action_id": _ACT_REGEN,
   499→                        "value": regen_value,
   500→                    },
   501→                    {
   502→                        "type": "button",
   503→                        "text": {"type": "plain_text", "text": "❌ 取消", "emoji": True},
   504→                        "style": "danger",
   505→                        "action_id": _ACT_CANCEL,
   506→                        "value": "cancel",
   507→                    },
   508→                ],
   509→            },
   510→        ]
   511→        return blocks
   512→
   513→    def _loading_block(self, message: str) -> list[dict]:
   514→        """Simple single-section block for loading state.
   515→
   516→        Args:
   517→            message: Text to display (supports mrkdwn).
   518→
   519→        Returns:
   520→            List with one section block.
   521→        """
   522→        return [{"type": "section", "text": {"type": "mrkdwn", "text": message}}]
   523→
   524→    def _done_block(self, message: str) -> list[dict]:
   525→        """Single-section block for a completed/dismissed state.
   526→
   527→        Args:
   528→            message: Text to display (supports mrkdwn).
   529→
   530→        Returns:
   531→            List with one section block (no action buttons).
   532→        """
   533→        return [{"type": "section", "text": {"type": "mrkdwn", "text": message}}]
   534→
   535→    def _error_block(self, message: str) -> list[dict]:
   536→        """Single-section block for error display.
   537→
   538→        Args:
   539→            message: Error description (supports mrkdwn).
   540→
   541→        Returns:
   542→            List with one section block prefixed with ⚠.
   543→        """
   544→        return [{
   545→            "type": "section",
   546→            "text": {"type": "mrkdwn", "text": f"⚠ {message}"},
   547→        }]
   548→
   549→    # ── Draft State ────────────────────────────────────────────────────────────
   550→
   551→    def _save_to_vault(self, draft: AnswerDraft) -> Optional[Path]:
   552→        """Persist a draft as a Markdown file in <vault>/zhihu/.
   553→
   554→        Args:
   555→            draft: The generated AnswerDraft to save.
   556→
   557→        Returns:
   558→            Absolute path of the written file, or None on failure.
   559→        """
   560→        import re
   561→        from datetime import date as _date
   562→
   563→        try:
   564→            zhihu_dir = self.hunter.vault_path / "zhihu"
   565→            zhihu_dir.mkdir(parents=True, exist_ok=True)
   566→
   567→            date_str = _date.today().strftime("%Y-%m-%d")
   568→            slug = re.sub(r"[^\w\u4e00-\u9fff]+", "-", draft.question.title)[:40].strip("-")
   569→            file_path = zhihu_dir / f"{date_str}-{slug}.md"
   570→
   571→            file_path.write_text(
   572→                f"---\n"
   573→                f"title: \"{draft.question.title}\"\n"
   574→                f"url: {draft.question.url}\n"
   575→                f"date: {date_str}\n"
   576→                f"status: draft\n"
   577→                f"tags: [zhihu]\n"
   578→                f"---\n\n"
   579→                f"# {draft.question.title}\n\n"
   580→                f"{draft.content}\n",
   581→                encoding="utf-8",
   582→            )
   583→            logger.info(f"Draft saved to vault: {file_path}")
   584→            return file_path
   585→        except Exception as e:
   586→            logger.warning(f"Failed to save draft to vault: {e}")
   587→            return None
   588→
   589→    def _update_vault_published(self, vault_file: str, answer_url: str) -> None:
   590→        """Update the vault file's frontmatter status to published.
   591→
   592→        Args:
   593→            vault_file: Absolute path string stored in AnswerDraft.vault_file.
   594→            answer_url: Published answer URL to record.
   595→        """
   596→        try:
   597→            fp = Path(vault_file)
   598→            if not fp.exists():
   599→                return
   600→            content = fp.read_text(encoding="utf-8")
   601→            content = content.replace(
   602→                "status: draft",
   603→                f"status: published\nanswer_url: {answer_url}",
   604→                1,
   605→            )
   606→            fp.write_text(content, encoding="utf-8")
   607→            logger.info(f"Vault file marked published: {fp}")
   608→        except Exception as e:
   609→            logger.warning(f"Failed to update vault file: {e}")
   610→
   611→    def _save_draft(self, draft: AnswerDraft) -> str:
   612→        """Persist a draft to disk and return its UUID key.
   613→
   614→        Args:
   615→            draft: AnswerDraft to save.
   616→
   617→        Returns:
   618→            UUID string key (used as publish button value).
   619→        """
   620→        draft_id  = uuid.uuid4().hex[:12]
   621→        file_path = self.state_dir / f"{draft_id}.json"
   622→        file_path.write_text(draft.model_dump_json(), encoding="utf-8")
   623→        logger.debug(f"Draft saved: {file_path}")
   624→        return draft_id
   625→
   626→    def _load_draft(self, draft_id: str) -> Optional[AnswerDraft]:
   627→        """Load a draft by its UUID key.
   628→
   629→        Args:
   630→            draft_id: Key returned by _save_draft().
   631→
   632→        Returns:
   633→            AnswerDraft if found, None if the file does not exist.
   634→        """
   635→        file_path = self.state_dir / f"{draft_id}.json"
   636→        if not file_path.exists():
   637→            return None
   638→        try:
   639→            return AnswerDraft.model_validate_json(file_path.read_text(encoding="utf-8"))
   640→        except Exception as e:
   641→            logger.error(f"Failed to load draft {draft_id}: {e}")
   642→            return None
   643→
   644→    def _delete_draft(self, draft_id: str) -> None:
   645→        """Remove a draft file after successful publish.
   646→
   647→        Args:
   648→            draft_id: Key of the draft to delete.
   649→        """
   650→        file_path = self.state_dir / f"{draft_id}.json"
   651→        try:
   652→            file_path.unlink(missing_ok=True)
   653→        except Exception as e:
   654→            logger.warning(f"Could not delete draft file {draft_id}: {e}")
   655→


     1→"""Zhihu Hunter — vault scanning, question discovery, and answer drafting.
     2→
     3→Workflow:
     4→    1. scan_and_hunt()  →  scans recent vault files, extracts keywords,
     5→                           searches Tavily for matching Zhihu questions.
     6→    2. draft_answer()   →  uses existing RAG + ZhihuGenerator to produce
     7→                           a Zhihu-style answer draft ready for review.
     8→"""
     9→
    10→import os
    11→import re
    12→import requests
    13→from datetime import datetime, timezone, timedelta
    14→from pathlib import Path
    15→from typing import List, Optional
    16→
    17→from pydantic import BaseModel, Field
    18→
    19→from health.utils.logging_config import setup_logger
    20→from slack_bot.llm.gemini import GeminiLLM
    21→from slack_bot.obsidian.generators import ZhihuGenerator
    22→from slack_bot.obsidian.indexer import ObsidianIndexer
    23→from slack_bot.obsidian.vector_store import ChromaVectorStore
    24→
    25→logger = setup_logger(__name__)
    26→
    27→
    28→# ── Vault directories to watch for new/updated content ────────────────────────
    29→# Paths are relative to vault_path root.
    30→WATCH_DIRS: List[str] = [
    31→    "notes",
    32→    "Article",
    33→    "auto_report/deep-dive",
    34→    "CLAW/deepfact",
    35→    "Clippings",
    36→]
    37→
    38→# Article is always included regardless of time; Revised files are prioritized.
    39→_ARTICLE_DIR = "Article"
    40→
    41→# Short names users can type to target a specific directory.
    42→DIR_SHORTCUTS: dict[str, str] = {
    43→    "notes":      "notes",
    44→    "article":    "Article",
    45→    "clippings":  "Clippings",
    46→    "claw":       "CLAW/deepfact",
    47→    "deepfact":   "CLAW/deepfact",
    48→    "deep-dive":  "auto_report/deep-dive",
    49→}
    50→
    51→_MAX_FILES_PER_DIR   = 4    # most-recent files per directory (keeps dir diversity)
    52→_MAX_FILES_ARTICLE   = 8    # Article gets more slots (higher priority)
    53→_MAX_KEYWORDS        = 10   # keywords extracted from vault scan
    54→_MAX_QUESTIONS_TOTAL = 12   # total Zhihu questions surfaced per hunt
    55→# Must contain /question/<id>; answer/comment subpages are excluded in _find_questions
    56→_MIN_QUESTION_URL_RE = re.compile(r'zhihu\.com/question/\d+')
    57→
    58→
    59→# ── Data Models ────────────────────────────────────────────────────────────────
    60→
    61→class ZhihuQuestion(BaseModel):
    62→    """A Zhihu question candidate surfaced by the hunter."""
    63→
    64→    title: str
    65→    url: str
    66→    snippet: Optional[str] = None
    67→    keywords: List[str] = Field(default_factory=list)
    68→    found_at: str = Field(
    69→        default_factory=lambda: datetime.now(tz=timezone.utc).isoformat()
    70→    )
    71→    source_file: Optional[str] = None  # vault-relative path, set by scan_single_file()
    72→    outline: Optional[str] = None       # user-provided answer logic/structure (direct URL mode)
    73→
    74→
    75→class AnswerDraft(BaseModel):
    76→    """A generated answer draft awaiting human review."""
    77→
    78→    question: ZhihuQuestion
    79→    content: str
    80→    generated_at: str = Field(
    81→        default_factory=lambda: datetime.now(tz=timezone.utc).isoformat()
    82→    )
    83→    rag_sources: List[str] = Field(default_factory=list)
    84→    vault_file: Optional[str] = None  # absolute path set after saving to vault
    85→
    86→
    87→# ── Main Class ─────────────────────────────────────────────────────────────────
    88→
    89→class ZhihuHunter:
    90→    """Discovers Zhihu questions from vault content and drafts answers.
    91→
    92→    Example:
    93→        hunter = ZhihuHunter(vault_path, vector_store, indexer)
    94→        questions = hunter.scan_and_hunt(since_days=3)
    95→        draft = hunter.draft_answer(questions[0])
    96→    """
    97→
    98→    def __init__(
    99→        self,
   100→        vault_path: Path,
   101→        vector_store: ChromaVectorStore,
   102→        indexer: ObsidianIndexer,
   103→    ) -> None:
   104→        """Initialize the hunter.
   105→
   106→        Args:
   107→            vault_path: Root path of the Obsidian vault.
   108→            vector_store: ChromaDB vector store for semantic retrieval.
   109→            indexer: ObsidianIndexer for keyword-based search and writing samples.
   110→        """
   111→        self.vault_path = vault_path
   112→        self.vector_store = vector_store
   113→        self.indexer = indexer
   114→        self.llm = GeminiLLM()
   115→
   116→    # ── Public API ─────────────────────────────────────────────────────────────
   117→
   118→    def scan_and_hunt(
   119→        self,
   120→        since_days: int = 7,
   121→        dirs: Optional[List[str]] = None,
   122→        topic_hint: str = "",
   123→    ) -> List[ZhihuQuestion]:
   124→        """Scan vault files and surface matching Zhihu questions.
   125→
   126→        Args:
   127→            since_days: Age threshold for non-Article directories.
   128→            dirs: Restrict scan to these vault dirs (relative paths). None = all WATCH_DIRS.
   129→            topic_hint: Optional focus hint injected into keyword extraction prompt.
   130→
   131→        Returns:
   132→            List of ZhihuQuestion candidates (up to _MAX_QUESTIONS_TOTAL).
   133→        """
   134→        scan_desc = f"dirs={dirs or 'all'}" + (f", topic='{topic_hint}'" if topic_hint else "")
   135→        logger.info(f"ZhihuHunter: scanning vault ({scan_desc})")
   136→
   137→        keywords = self._scan_vault_for_keywords(since_days, dirs=dirs, topic_hint=topic_hint)
   138→        if not keywords:
   139→            logger.info("No keywords extracted from vault files")
   140→            return []
   141→
   142→        logger.info(f"Extracted keywords: {keywords}")
   143→        questions = self._find_questions(keywords)
   144→        logger.info(f"Found {len(questions)} Zhihu questions")
   145→        return questions
   146→
   147→    def scan_single_file(self, rel_path: str) -> List[ZhihuQuestion]:
   148→        """Extract keywords from one vault file and find matching Zhihu questions.
   149→
   150→        The returned questions carry ``source_file`` set to ``rel_path`` so that
   151→        ``draft_answer()`` will ground the generated answer in that article.
   152→
   153→        Args:
   154→            rel_path: Path relative to vault_path (e.g. "Article/8-xxx-Revised.md").
   155→
   156→        Returns:
   157→            List of ZhihuQuestion candidates with source_file populated.
   158→
   159→        Raises:
   160→            FileNotFoundError: If the file does not exist under the vault root.
   161→        """
   162→        file_path = self.vault_path / rel_path
   163→        if not file_path.exists():
   164→            raise FileNotFoundError(f"文件不存在：{file_path}")
   165→
   166→        text = file_path.read_text(encoding="utf-8", errors="ignore")
   167→        text = re.sub(r'^---\n.*?\n---\n', '', text, flags=re.DOTALL).strip()
   168→        logger.info(f"scan_single_file: {rel_path} ({len(text)} chars)")
   169→
   170→        keywords = self._extract_keywords_with_llm(text[:5000])
   171→        if not keywords:
   172→            logger.info("No keywords extracted from single file")
   173→            return []
   174→
   175→        logger.info(f"Keywords from file: {keywords}")
   176→        questions = self._find_questions(keywords)
   177→
   178→        for q in questions:
   179→            q.source_file = rel_path
   180→
   181→        return questions
   182→
   183→    def scan_glob_files(self, pattern: str) -> List[ZhihuQuestion]:
   184→        """Find vault files matching a glob pattern and hunt Zhihu questions.
   185→
   186→        All matched files are combined for keyword extraction, so the questions
   187→        reflect the full set of content.  The most-recently-modified matched file
   188→        is stored as ``source_file`` to ground the answer draft.
   189→
   190→        Args:
   191→            pattern: Glob pattern relative to vault_path
   192→                     (e.g. "Article/*openclaw*-Revised.md").
   193→
   194→        Returns:
   195→            List of ZhihuQuestion candidates with source_file set.
   196→
   197→        Raises:
   198→            FileNotFoundError: If no files match the pattern.
   199→        """
   200→        matched = sorted(
   201→            self.vault_path.glob(pattern),
   202→            key=lambda p: p.stat().st_mtime,
   203→            reverse=True,
   204→        )
   205→        if not matched:
   206→            raise FileNotFoundError(f"没有文件匹配：{pattern}")
   207→
   208→        names = [p.name for p in matched]
   209→        logger.info(f"scan_glob_files: '{pattern}' → {len(matched)} files: {names}")
   210→
   211→        # Combine content from all matched files (per-file budget so no one file
   212→        # dominates) and extract keywords from the aggregate.
   213→        parts: List[str] = []
   214→        per_file_budget = 1500 if len(matched) == 1 else 800
   215→        for fp in matched:
   216→            try:
   217→                text = fp.read_text(encoding="utf-8", errors="ignore")
   218→                text = re.sub(r'^---\n.*?\n---\n', '', text, flags=re.DOTALL).strip()
   219→                parts.append(f"[{fp.name}]\n{text[:per_file_budget]}")
   220→            except OSError as e:
   221→                logger.debug(f"Could not read {fp}: {e}")
   222→
   223→        combined = "\n\n---\n\n".join(parts)
   224→        keywords = self._extract_keywords_with_llm(combined[:8000])
   225→        if not keywords:
   226→            logger.info("No keywords extracted from glob-matched files")
   227→            return []
   228→
   229→        logger.info(f"Keywords from glob: {keywords}")
   230→        questions = self._find_questions(keywords)
   231→
   232→        # Primary source = most-recently-modified matched file
   233→        primary = str(matched[0].relative_to(self.vault_path))
   234→        for q in questions:
   235→            q.source_file = primary
   236→
   237→        return questions
   238→
   239→    def hunt_direct_url(self, url: str) -> List[ZhihuQuestion]:
   240→        """Create a question card directly from a Zhihu question URL.
   241→
   242→        Used when the user pastes a URL they were invited to answer.
   243→        Fetches the page title so the card displays a readable name.
   244→
   245→        Args:
   246→            url: A zhihu.com/question/<id> URL.
   247→
   248→        Returns:
   249→            Single-element list containing the ZhihuQuestion.
   250→        """
   251→        clean_url = re.sub(r'[>\'")\].,;]+$', '', url).split("?")[0].split("#")[0].rstrip("/")
   252→        title = self._fetch_question_title(clean_url)
   253→        logger.info(f"Direct URL hunt: {clean_url!r} → title={title!r}")
   254→        return [ZhihuQuestion(title=title, url=clean_url)]
   255→
   256→    def _fetch_question_title(self, url: str) -> str:
   257→        """Fetch the question title via Playwright (with saved session cookies).
   258→
   259→        Falls back to a URL-derived label if Playwright is unavailable or fails.
   260→
   261→        Args:
   262→            url: Cleaned question URL.
   263→
   264→        Returns:
   265→            Question title string, or a fallback derived from the URL.
   266→        """
   267→        state_file = Path(__file__).resolve().parents[3] / "data" / "zhihu_state.json"
   268→        try:
   269→            from playwright.sync_api import sync_playwright
   270→            from playwright_stealth import Stealth
   271→            import json as _json
   272→
   273→            with sync_playwright() as p:
   274→                browser = p.chromium.launch(headless=True)
   275→                context = browser.new_context(
   276→                    user_agent=(
   277→                        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
   278→                        "AppleWebKit/537.36 (KHTML, like Gecko) "
   279→                        "Chrome/122.0.0.0 Safari/537.36"
   280→                    )
   281→                )
   282→                # Load saved cookies if available
   283→                if state_file.exists():
   284→                    data = _json.loads(state_file.read_text(encoding="utf-8"))
   285→                    cookies = data.get("cookies", [])
   286→                    if cookies:
   287→                        context.add_cookies(cookies)
   288→
   289→                page = context.new_page()
   290→                Stealth().apply_stealth_sync(page)
   291→                page.goto(url, wait_until="domcontentloaded", timeout=15000)
   292→                page.wait_for_timeout(2000)
   293→
   294→                title = page.title()
   295→                browser.close()
   296→
   297→                if title:
   298→                    # Page title format: "问题标题 - 知乎" or just "知乎"
   299→                    title = title.split(" - 知乎")[0].strip()
   300→                    if title and title != "知乎":
   301→                        return title
   302→        except Exception as e:
   303→            logger.debug(f"Playwright title fetch failed for {url}: {e}")
   304→
   305→        # Fallback: use the question ID
   306→        qid = url.rstrip("/").split("/")[-1]
   307→        return f"知乎问题 #{qid}"
   308→
   309→    def _fetch_recent_web_context(self, query: str) -> str:
   310→        """Search the web for recent articles/news about the query topic.
   311→
   312→        Results are injected into the answer prompt so it reads like it
   313→        comes from someone who's actively following the latest developments.
   314→
   315→        Args:
   316→            query: Topic or question title to search.
   317→
   318→        Returns:
   319→            Formatted string of recent snippets, or empty string on failure.
   320→        """
   321→        api_key = os.environ.get("TAVILY_API_KEY")
   322→        if not api_key:
   323→            return ""
   324→        try:
   325→            from tavily import TavilyClient
   326→            client = TavilyClient(api_key=api_key)
   327→            results = client.search(
   328→                query=query,
   329→                search_depth="basic",
   330→                max_results=4,
   331→                days=90,
   332→                exclude_domains=["zhihu.com"],
   333→            )
   334→            items = results.get("results", [])
   335→            if not items:
   336→                return ""
   337→            parts = [
   338→                f"- {r['title']} ({r['url'].split('/')[2]}): {r.get('content', '')[:250]}"
   339→                for r in items
   340→            ]
   341→            return "\n".join(parts)
   342→        except Exception as e:
   343→            logger.debug(f"Recent web context fetch failed: {e}")
   344→            return ""
   345→
   346→    def draft_answer(self, question: ZhihuQuestion, feedback: str = "") -> AnswerDraft:
   347→        """Generate a Zhihu-style answer draft for a question.
   348→
   349→        Delegates to the existing ZhihuGenerator (reuses prompt + style guide).
   350→        The RAG context is enriched by both semantic (ChromaDB) and
   351→        keyword-based (ObsidianIndexer) retrieval.
   352→
   353→        Args:
   354→            question: ZhihuQuestion to answer.
   355→            feedback: Optional revision instructions from the user (e.g. "更口语化一些").
   356→
   357→        Returns:
   358→            AnswerDraft with generated content and source list.
   359→        """
   360→        logger.info(f"Drafting answer for: {question.title}" + (f" [feedback: {feedback[:40]}]" if feedback else ""))
   361→
   362→        # Collect RAG sources for transparency
   363→        rag_sources: List[str] = []
   364→
   365→        semantic_results = self.vector_store.search_knowledge(question.title, top_k=3)
   366→        for r in semantic_results:
   367→            rag_sources.append(Path(r.source_path).name)
   368→
   369→        keyword_results = self.indexer.search(question.title, limit=5)
   370→        for note in keyword_results:
   371→            first_line = note.split("\n")[0].strip("# ").strip()
   372→            if first_line:
   373→                rag_sources.append(first_line)
   374→
   375→        # If the question was pinned to a specific source file, load it as the
   376→        # primary article so the answer stays grounded in its viewpoints.
   377→        primary_article = ""
   378→        if question.source_file:
   379→            fp = self.vault_path / question.source_file
   380→            try:
   381→                raw = fp.read_text(encoding="utf-8", errors="ignore")
   382→                raw = re.sub(r'^---\n.*?\n---\n', '', raw, flags=re.DOTALL).strip()
   383→                primary_article = raw[:4000]
   384→                rag_sources.insert(0, Path(question.source_file).name)
   385→                logger.info(f"Loaded primary article: {question.source_file} ({len(primary_article)} chars)")
   386→            except OSError as e:
   387→                logger.warning(f"Could not read source file {question.source_file}: {e}")
   388→
   389→        # Delegate to existing ZhihuGenerator for consistent style
   390→        generator = ZhihuGenerator(self.indexer, self.vector_store)
   391→        prompt_input = question.title
   392→        if question.snippet:
   393→            prompt_input += f" | 背景：{question.snippet}"
   394→
   395→        # User-provided outline takes highest priority — structure the answer strictly
   396→        # around it, pulling matching content from the knowledge base for each point.
   397→        if question.outline:
   398→            prompt_input += (
   399→                f"\n\n用户提供的回答思路（必须严格按照这个逻辑框架展开，"
   400→                f"从知识库中找到对应的内容来支撑每个论点，不要自由发挥框架结构）：\n{question.outline}"
   401→            )
   402→            logger.info(f"Injected user outline: {question.outline[:60]!r}")
   403→
   404→        if primary_article:
   405→            prompt_input += (
   406→                f"\n\n主要参考文章（请基于此文的观点和论据来回答，"
   407→                f"可直接引用其中的分析和结论）：\n{primary_article}"
   408→            )
   409→
   410→        # Inject recent web context so the answer feels up-to-date
   411→        recent_context = self._fetch_recent_web_context(question.title)
   412→        if recent_context:
   413→            prompt_input += (
   414→                f"\n\n近期相关资讯（最近90天内的动态，用来补充最新工具/发布/讨论，"
   415→                f"让回答更有时效感，自然地融入即可，不要刻意罗列）：\n{recent_context}"
   416→            )
   417→            logger.info(f"Injected recent web context ({len(recent_context)} chars)")
   418→
   419→        if feedback:
   420→            prompt_input += f"\n\n用户修改意见：{feedback}"
   421→
   422→        content, _ = generator.chat(prompt_input, history=[])
   423→
   424→        return AnswerDraft(
   425→            question=question,
   426→            content=content,
   427→            rag_sources=list(dict.fromkeys(rag_sources)),  # deduplicate, keep order
   428→        )
   429→
   430→    # ── Vault Scanning ─────────────────────────────────────────────────────────
   431→
   432→    def _scan_vault_for_keywords(
   433→        self,
   434→        since_days: int,
   435→        dirs: Optional[List[str]] = None,
   436→        topic_hint: str = "",
   437→    ) -> List[str]:
   438→        """Collect vault files and extract topic keywords via LLM.
   439→
   440→        Args:
   441→            since_days: Age threshold for non-Article directories.
   442→            dirs: Dirs to scan; None = all WATCH_DIRS.
   443→            topic_hint: Optional focus instruction for keyword extraction.
   444→
   445→        Returns:
   446→            Deduplicated list of topic keywords (up to _MAX_KEYWORDS).
   447→        """
   448→        cutoff = datetime.now(tz=timezone.utc) - timedelta(days=since_days)
   449→        files = self._collect_recent_files(cutoff, dirs=dirs)
   450→
   451→        if not files:
   452→            return []
   453→
   454→        # Article-only scans get a larger per-file budget (higher priority content)
   455→        scan_dirs = dirs or WATCH_DIRS
   456→        per_file_budget = 800 if scan_dirs == [_ARTICLE_DIR] else 500
   457→        combined = self._read_files_summary(files, max_chars_per_file=per_file_budget)
   458→        return self._extract_keywords_with_llm(combined, topic_hint=topic_hint)
   459→
   460→    def _collect_recent_files(
   461→        self,
   462→        cutoff: datetime,
   463→        dirs: Optional[List[str]] = None,
   464→    ) -> List[Path]:
   465→        """Walk vault directories and return candidate files.
   466→
   467→        Article directory:
   468→          - No time filter (always included regardless of cutoff)
   469→          - Revised files sorted first, then by mtime descending
   470→          - Up to _MAX_FILES_ARTICLE slots
   471→        Other directories:
   472→          - Only files modified after cutoff
   473→          - Sorted by mtime descending
   474→          - Up to _MAX_FILES_PER_DIR slots
   475→
   476→        Args:
   477→            cutoff: Freshness threshold for non-Article directories.
   478→            dirs: Directories to scan (relative paths). None = all WATCH_DIRS.
   479→
   480→        Returns:
   481→            List of Path objects.
   482→        """
   483→        scan_dirs = dirs if dirs is not None else WATCH_DIRS
   484→        collected: List[Path] = []
   485→
   486→        for rel_dir in scan_dirs:
   487→            watch_dir = self.vault_path / rel_dir
   488→            if not watch_dir.is_dir():
   489→                logger.debug(f"Watch dir not found, skipping: {watch_dir}")
   490→                continue
   491→
   492→            # (mtime, is_revised, path)
   493→            candidates: List[tuple[float, bool, Path]] = []
   494→            for md_file in watch_dir.rglob("*.md"):
   495→                try:
   496→                    mtime = md_file.stat().st_mtime
   497→                    if rel_dir != _ARTICLE_DIR:
   498→                        file_dt = datetime.fromtimestamp(mtime, tz=timezone.utc)
   499→                        if file_dt < cutoff:
   500→                            continue
   501→                    is_revised = md_file.name.lower().endswith("-revised.md")
   502→                    candidates.append((mtime, is_revised, md_file))
   503→                except OSError:
   504→                    continue
   505→
   506→            if rel_dir == _ARTICLE_DIR:
   507→                # Revised files first, then newest; give double slots
   508→                candidates.sort(key=lambda t: (0 if t[1] else 1, -t[0]))
   509→                max_files = _MAX_FILES_ARTICLE
   510→            else:
   511→                candidates.sort(key=lambda t: -t[0])
   512→                max_files = _MAX_FILES_PER_DIR
   513→
   514→            collected.extend(p for _, _, p in candidates[:max_files])
   515→
   516→        logger.debug(f"Collected {len(collected)} files from vault (dirs={scan_dirs})")
   517→        return collected
   518→
   519→    def _read_files_summary(
   520→        self,
   521→        files: List[Path],
   522→        max_chars: int = 8000,
   523→        max_chars_per_file: int = 500,
   524→    ) -> str:
   525→        """Read and concatenate file contents up to a character budget.
   526→
   527→        Each file is capped at max_chars_per_file so that no single large file
   528→        exhausts the total budget and crowds out other directories.
   529→
   530→        Args:
   531→            files: List of markdown file paths.
   532→            max_chars: Total character budget across all files.
   533→            max_chars_per_file: Per-file cap to ensure directory diversity.
   534→
   535→        Returns:
   536→            Concatenated content string with file-name headers.
   537→        """
   538→        parts: List[str] = []
   539→        remaining = max_chars
   540→
   541→        for f in files:
   542→            if remaining <= 0:
   543→                break
   544→            try:
   545→                text = f.read_text(encoding="utf-8", errors="ignore")
   546→                # Strip YAML frontmatter
   547→                text = re.sub(r'^---\n.*?\n---\n', '', text, flags=re.DOTALL).strip()
   548→                chunk = text[:min(max_chars_per_file, remaining)]
   549→                parts.append(f"[{f.name}]\n{chunk}")
   550→                remaining -= len(chunk)
   551→            except OSError as e:
   552→                logger.debug(f"Could not read {f}: {e}")
   553→
   554→        return "\n\n---\n\n".join(parts)
   555→
   556→    def _extract_keywords_with_llm(
   557→        self,
   558→        combined_text: str,
   559→        topic_hint: str = "",
   560→    ) -> List[str]:
   561→        """Use LLM to distill topic keywords from aggregated vault content.
   562→
   563→        Args:
   564→            combined_text: Concatenated recent vault content.
   565→            topic_hint: Optional focus instruction (e.g. "agent开发").
   566→
   567→        Returns:
   568→            List of keyword strings (up to _MAX_KEYWORDS).
   569→        """
   570→        topic_instruction = (
   571→            f"\n- 重点关注与「{topic_hint}」相关的话题，优先提取这个方向的关键词"
   572→            if topic_hint else ""
   573→        )
   574→        prompt = f"""你是一位知乎内容运营专家。请从以下笔记内容中提炼出适合在知乎搜索问题的关键词。
   575→
   576→要求：
   577→- 输出 {_MAX_KEYWORDS} 个以内的关键词，每行一个，无序号无符号
   578→- 关键词要在知乎上有真实讨论热度，即普通中国用户会关心并提问的话题领域
   579→- 避免过于偏门的专有名词（如具体项目代号、小众产品名），要用它所属的上一级概念
   580→  例：「OpenClaw」→「AI智能体安全」；「BadJs」→「微信监控机制」；「Moon Monitor」→「健康监测设备」
   581→- 不要太泛（如"AI""技术""健康"），也不要太窄（如只有一个工具代码名称）
   582→- 优先选择最近讨论最多、最有深度的话题{topic_instruction}
   583→- 只输出关键词列表，不要解释
   584→
   585→=== 笔记内容 ===
   586→{combined_text[:5000]}
   587→"""
   588→        try:
   589→            response, _ = self.llm.generate_response(prompt, [])
   590→            keywords = [
   591→                line.strip()
   592→                for line in response.strip().splitlines()
   593→                if line.strip() and not line.strip().startswith("#")
   594→            ]
   595→            return keywords[:_MAX_KEYWORDS]
   596→        except Exception as e:
   597→            logger.error(f"Keyword extraction failed: {e}")
   598→            return []
   599→
   600→    # ── Question Discovery ─────────────────────────────────────────────────────
   601→
   602→    def _find_questions(self, keywords: List[str]) -> List[ZhihuQuestion]:
   603→        """Search Tavily for Zhihu questions matching each keyword.
   604→
   605→        Collects candidates from ALL keywords before truncating so that later
   606→        keywords (which can cover different facets) are not silently dropped.
   607→
   608→        Args:
   609→            keywords: List of topic keywords to search.
   610→
   611→        Returns:
   612→            Deduplicated ZhihuQuestion list (up to _MAX_QUESTIONS_TOTAL).
   613→        """
   614→        from tavily import TavilyClient
   615→
   616→        api_key = os.environ.get("TAVILY_API_KEY")
   617→        if not api_key:
   618→            raise RuntimeError("TAVILY_API_KEY not set")
   619→
   620→        client = TavilyClient(api_key=api_key)
   621→        seen_urls: set[str] = set()
   622→        questions: List[ZhihuQuestion] = []
   623→
   624→        for kw in keywords:
   625→            try:
   626→                results = self._tavily_search(client, kw)
   627→                for item in results:
   628→                    url = item.get("url", "")
   629→                    if not _MIN_QUESTION_URL_RE.search(url):
   630→                        continue  # skip non-question URLs (e.g. zhihu.com/people/...)
   631→                    # Normalize: extract bare URL to strip invisible chars, then remove params
   632→                    _m = re.search(r'https?://\S+', url)
   633→                    if not _m:
   634→                        continue
   635→                    clean_url = re.sub(r'[>\'")\].,;]+$', '', _m.group(0)).split("?")[0].split("#")[0].rstrip("/")
   636→                    # Skip answer/comment subpages — we want the question itself
   637→                    if re.search(r'/answer/|/comment/', clean_url):
   638→                        continue
   639→                    if clean_url in seen_urls:
   640→                        continue
   641→                    seen_urls.add(clean_url)
   642→                    questions.append(
   643→                        ZhihuQuestion(
   644→                            title=item.get("title", kw),
   645→                            url=clean_url,
   646→                            snippet=item.get("content", "")[:200] or None,
   647→                            keywords=[kw],
   648→                        )
   649→                    )
   650→            except Exception as e:
   651→                logger.warning(f"Tavily search failed for keyword '{kw}': {e}")
   652→
   653→        logger.info(f"Found {len(questions)} Zhihu questions (capped at {_MAX_QUESTIONS_TOTAL})")
   654→        return questions[:_MAX_QUESTIONS_TOTAL]
   655→
   656→    def _tavily_search(self, client, keyword: str) -> List[dict]:
   657→        """Run a single Tavily search scoped to zhihu.com/question.
   658→
   659→        Args:
   660→            client: Authenticated TavilyClient instance.
   661→            keyword: Search keyword.
   662→
   663→        Returns:
   664→            List of result dicts from Tavily (url, title, content, score).
   665→        """
   666→        query = f"site:zhihu.com/question {keyword}"
   667→        logger.debug(f"Tavily search: {query}")
   668→        response = client.search(
   669→            query=query,
   670→            search_depth="basic",
   671→            max_results=8,
   672→            include_domains=["zhihu.com"],
   673→            days=365,   # prefer questions with recent activity (higher chance of top rank)
   674→        )
   675→        return response.get("results", [])
   676→