     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→


Perfect! Now I have all the information I need. Let me create a comprehensive summary of the pipeline.

## Complete Zhihu Answer Generation Pipeline

Based on my thorough analysis of the codebase, here's the full flow:

### 1. **URL Paste → Question Fetch** (`hunt_direct_url()`)

**File:** `/root/projects/butler/slack_bot/zhihu/zhihu_hunter.py` (lines 239-307)

When a user pastes a Zhihu URL in Slack with `mode zhihu-hunter`:

```python
# In dispatcher.py (lines 170-183), detected by:
elif words and 'zhihu.com/question/' in words[0]:
    url = _re.sub(r'[>\'")\].,;]+$', '', words[0])
    outline = " ".join(words[1:])[:500] if len(words) > 1 else ""
    questions = self.zhihu_hunter.hunt_direct_url(url)
    if outline and questions:
        questions[0].outline = outline
```

The `hunt_direct_url()` method:
1. **Cleans the URL** (line 251): Strips trailing Slack characters and removes query params/fragments
2. **Fetches the page title** (line 252): Calls `_fetch_question_title()` which:
   - Uses Playwright with saved cookies (if available) to headlessly navigate to the URL
   - Extracts the page title via `page.title()` and removes the " - 知乎" suffix
   - Falls back to generating a label from the question ID if Playwright fails
3. **Returns a ZhihuQuestion** object with:
   - `title`: The fetched question title
   - `url`: The cleaned URL
   - `snippet`: None (only set by Tavily search)
   - `outline`: Optional user-provided answer structure (set from remaining text after URL)

### 2. **Slack Interactive Gate 1** (`post_question_cards()`)

**File:** `/root/projects/butler/slack_bot/zhihu/slack_interactive_gateway.py` (lines 93-127)

After `hunt_direct_url()` returns, the dispatcher calls:
```python
self.zhihu_gateway.post_question_cards(questions, channel=channel_id)
```

This posts a Block Kit card with:
- Question title as a clickable link
- Optional snippet (if available)
- Keywords (if from Tavily search)
- **[✍ 生成草稿]** button (action: `_ACT_GENERATE`) → triggers draft generation
- **[🚫 忽略]** button → dismisses the question

### 3. **User Clicks "Generate Draft"** (`_handle_generate_draft()`)

**File:** `/root/projects/butler/slack_bot/zhihu/slack_interactive_gateway.py` (lines 158-209)

When user clicks the generate button:

1. **Shows loading state** (line 166-171): Updates the question card to "✍ 草稿生成中，请稍候..."
2. **Calls `hunter.draft_answer(question)`** (line 175): See step 4 below
3. **Saves draft** to vault and state file
4. **Posts draft card as thread reply** (lines 185-189): Shows the generated answer with buttons for approval/rejection/regeneration

### 4. **LLM Answer Generation** (`draft_answer()`)

**File:** `/root/projects/butler/slack_bot/zhihu/zhihu_hunter.py` (lines 346-428)

This is the core generation method. It constructs the prompt by:

**a) Gathering RAG context** (lines 365-387):
- **Semantic search** (line 365): Searches ChromaDB vector store with the question title for 3 top results
- **Keyword search** (line 369): Searches Obsidian vault via indexer for 5 matches
- **Primary article** (lines 377-387): If `question.source_file` is set (from single-file or glob scan), loads that file (up to 4000 chars)

**b) Constructing the prompt input** (lines 391-420):

```python
prompt_input = question.title  # The question itself

if question.snippet:
    prompt_input += f" | 背景：{question.snippet}"

# CRITICAL: User outline takes highest priority
if question.outline:
    prompt_input += (
        f"\n\n用户提供的回答思路（必须严格按照这个逻辑框架展开，"
        f"从知识库中找到对应的内容来支撑每个论点，不要自由发挥框架结构）：\n{question.outline}"
    )

if primary_article:
    prompt_input += (
        f"\n\n主要参考文章（请基于此文的观点和论据来回答，"
        f"可直接引用其中的分析和结论）：\n{primary_article}"
    )

recent_context = self._fetch_recent_web_context(question.title)  # Tavily search for recent web info
if recent_context:
    prompt_input += (
        f"\n\n近期相关资讯（最近90天内的动态...）：\n{recent_context}"
    )

if feedback:
    prompt_input += f"\n\n用户修改意见：{feedback}"
```

**c) Delegates to ZhihuGenerator** (line 422):
```python
content, _ = generator.chat(prompt_input, history=[])
```

### 5. **ZhihuGenerator LLM Prompt** (The Actual Generation)

**File:** `/root/projects/butler/slack_bot/obsidian/generators.py` (lines 519-687)

The `ZhihuGenerator.chat()` method builds a comprehensive prompt:

**What the prompt INCLUDES:**
- **methodology.md**: User's core philosophy and approach
- **POWELL_REVISE.md**: Anti-AI-tone guidelines (vocabulary kill list, sentence patterns to avoid)
- **zhihu-style.md + zhihu-bot-style.md**: Zhihu-specific writing conventions
- **RAG context**: Results from semantic + keyword search of the vault
- **The question title**
- **User's core ideas** (if provided via prompt_input)
- **Style guides**: How to write for Zhihu (600-1000 chars, no AI tone, etc.)

**What the prompt DOES NOT include:**
- The question's detail/description text is NOT explicitly fetched
- Only the question **title** is sent to the LLM
- If description exists, it must come from:
  - The `snippet` field (from Tavily search) if the question was found via hunt
  - The `outline` field (user-provided answer structure)
  - The `primary_article` (source file when grounded in vault content)

**CRITICAL FLOW - How outline gets used:**

1. **User pastes URL + outline text**:
   ```
   https://www.zhihu.com/question/123456 我想从三个角度回答：1. 技术方面 2. 业务影响 3. 团队协作
   ```

2. **In dispatcher.py (line 181)**:
   ```python
   questions[0].outline = outline  # Sets the outline field
   ```

3. **In draft_answer() (lines 397-401)**:
   ```python
   if question.outline:
       prompt_input += (
           f"\n\n用户提供的回答思路（必须严格按照这个逻辑框架展开，"
           f"从知识库中找到对应的内容来支撑每个论点，不要自由发挥框架结构）：\n{question.outline}"
       )
   ```

4. **In ZhihuGenerator (lines 582-586)**:
   The prompt_input (which now includes the outline) is injected as:
   ```python
   prompt = f"""...[methodology, style guide, RAG context]...
   === THE QUESTION ===
   {question}

   === YOUR CORE IDEAS ===
   {core_ideas}  # This is the outline!
   ```

### 6. **Two-Pass Generation (De-AI Review)**

**File:** `/root/projects/butler/slack_bot/obsidian/generators.py` (lines 628-678)

After initial draft generation:

1. **First pass** (line 626): Generate draft from rich prompt
2. **Second pass** (lines 628-678): **Rule enforcement** - strict editor removes AI tone:
   - Scans for vocabulary kill list items
   - Checks sentence patterns
   - Removes buzzwords, hollow phrases
   - Ensures sound like authentic human experience

The review prompt emphasizes:
```
目标语感：像一个懂行的人在知乎随手打字回答。不端着，也不刻意耍帅。
```

### 7. **Slack Interactive Gate 2** (`post_draft_card()`)

**File:** `/root/projects/butler/slack_bot/zhihu/slack_interactive_gateway.py` (lines 130-154)

Posts the draft as a thread reply with:
- **Draft preview** (first 800 chars)
- **RAG sources** (list of vault files used)
- **[✅ 确认发布]** button → publish to Zhihu
- **[🔄 重新生成]** button → open modal for feedback
- **[❌ 取消]** button → dismiss draft

### Summary Table

| Component | Role | Key Detail |
|-----------|------|-----------|
| `hunt_direct_url()` | Fetch question title | Uses Playwright headless browser; returns ZhihuQuestion with title only |
| `outline` field | User-provided answer structure | Set by dispatcher from text after URL; passed to ZhihuGenerator as "YOUR CORE IDEAS" |
| `draft_answer()` | RAG + prompt assembly | Gathers semantic+keyword search; fetches recent web context; constructs multi-part prompt |
| `ZhihuGenerator.chat()` | LLM generation | Sends title + outline + RAG + style guides to LLM; **does NOT include full question description** |
| Two-pass review | De-AI enforcement | First pass: content generation; Second pass: strict tone review and buzzword removal |
| `SlackInteractiveGateway` | Human approval gates | Gate 1: Question discovery; Gate 2: Draft review before publishing |

**Key Insight on "Question Detail"**: The prompt sent to the LLM contains the **question title** and **user's outline** (if provided), but does NOT fetch the full question description from Zhihu. The description text only appears if:
- User includes it in the outline (manual input)
- It was captured as a snippet by Tavily search
- It's mentioned in the primary article source file