        # === Second pass: de-AI review (first turn only) ===
        if is_first_turn:
            review_prompt = f"""You are a strict editor. A draft article has been written. Your ONLY job is to remove AI tone. Do NOT change facts or add new content.

=== TONE TARGET (最高优先级) ===
目标语感：像一个懂行的人在跟朋友聊天时随口说的。不端着，也不刻意耍帅。
两种AI味都要杀：
- 正式AI味：过渡词堆砌、排比对仗、空洞修饰、鸡汤结尾
- 油腻AI味：刻意装随意（"折腾去吧"）、强行造金句（"搞坏了不心疼，跑通了就是资产"）、给普通概念起酷名字（"隔离舱"、"暴力碰撞"、"最干净的契约"）、用一句俏皮话收尾装潇洒
正确的语感：平铺直叙，该说什么说什么，说完就停。不需要让读者觉得你很酷。

=== VOCABULARY KILL LIST (逐词扫描，命中即删或换大白话) ===
1. 夸张修饰: 极度脆弱、极其脆弱、毁灭性的、灾难级的、压倒性的、远超想象、不止一个量级、颠覆性的、革命性的、折磨人、天翻地覆、风险极高、瞬间崩断、精疲力竭、真金白银、本质上
2. 大词空转: 范式转移、范式突变、开发范式、数字化容器、数字雇员、温和池架构、非确定性、阅后即焚、毫秒级就绪、毫秒级响应、赋能、底层逻辑、仪式性努力、深刻变革、暴力碰撞、最干净的契约、服务化的潜力、可扩展的执行信用、终极归宿、壁垒、分水岭、沉淀（动词修饰数据/经验时）、云端办公室、命门
3. 鸡汤/口号: 正途、值得跨越、安置灵魂、掌控感、才是王道、苦活累活交给、未来已来、危机就是转机、从玩具变成生产力、把环境变成了资产、安全底线、这才是成熟的做法、才是X的关键、不是建议是必须
4. 概念包装: 裸奔（=没防护）、修路/飙车（=基础工作/高级用法）、提款机（=攻击目标）、隔离舱（=容器）、数字雇员（=脚本）、执行信用（=资源）、云端办公室（=云服务器）、牢笼（=限制）、独立房间（=隔离环境）
5. 油腻收尾: "X去吧"式潇洒收尾、"搞坏了不心疼，跑通了就是Y"式金句、俏皮话总结全文、"把X关在Y里...把Y放在Z上"式排比收尾
6. 伪口语: 说白了、说实话、折腾这玩意儿

=== SENTENCE PATTERN KILL LIST (命中即改写) ===
1. 防御性写法："很多人觉得X，实际上Y"、"有人X，有人已经Y了"、"一部分人在X，另一部分人在Y" → 直接说判断，删掉铺垫
2. 对比式定义："不是X，是Y"、"不是X而是Y"、"别把X当Y"、"这不对，它的本质是…" → 直接陈述后半句
3. 换皮夸张：用另一个夸张词替换夸张词不算修好 → 换成具体后果
4. 概念包装：给普通事物起酷名字 → 用最朴素的说法
5. 油腻金句：短句对仗、押韵、刻意工整的收尾句 → 删掉或拆成普通句
6. 比喻堆砌：全篇最多1个比喻，多余全删换直说
7. 鸡汤/口号结尾：删除励志或耍帅收尾，给具体建议或直接停
8. 首尾呼应：禁止结尾重复开头
9. 编号分段：线性论述删除序号改自然段落；并列对比可保留
10. 强行总结：删除"综上"/"一句话"式收尾
11. 关键词重复：同一个修辞性词汇全篇只能出现一次
12. 过渡词换皮："第一个要说的是"="首先"，"另一个问题是"="其次" → 直接删掉过渡，开门见山
13. 伪分类二分法："一部分人X，另一部分人Y"、"修路派/飙车派" → 删除分类框架，直接说事实

=== DRAFT TO REVIEW ===
{draft}

=== OUTPUT ===
Return ONLY the corrected text. No explanation, no meta-commentary.
"""
            logger.info("Running second-pass de-AI review on writing draft...")

import os
from pathlib import Path
from typing import List, Dict, Tuple, Optional, TYPE_CHECKING
from slack_bot.llm.gemini import GeminiLLM
from slack_bot.obsidian.indexer import ObsidianIndexer
from slack_bot.tools.web import WebSearchTool
from health.utils.logging_config import setup_logger
from health.utils.time_utils import get_current_time_str

if TYPE_CHECKING:
    from slack_bot.obsidian.vector_store import ChromaVectorStore

logger = setup_logger(__name__)

OBSIDIAN_SYSTEM_PROMPT = """You are Butler (Obsidian Edition), an intelligent knowledge assistant connected to the user's second brain.
Current Time: {current_time}

Your Role:
You are NOT a health assistant. You are a Knowledge Partner designed to help the user think, write, and communicate. You draw directly from the user's local Obsidian notes, values, and methodology.

Core Capabilities:
1. Writing: You mimic the user's unique writing style (as defined in `writing_style.md`).
2. Communication: You draft high-EQ, logically rigorous replies for professional contexts (as defined in `REPLY-SAMPLE.md`).
3. Decision: You act as a "Devil's Advocate" and strategic advisor using the user's decision frameworks (GPA, IPO).

Guidelines:
- **Style Alignment**: Strictly adhere to the tone and sentence structures found in the provided samples.
- **Data Source**: Rely primarily on the provided context (RAG) and loaded markdown files.
- **Identity**: You are pragmatic, rational, and value "technological optimism" and "intellectual honesty".
"""

class BaseGenerator:
    def __init__(
        self,
        indexer: ObsidianIndexer,
        vector_store: Optional["ChromaVectorStore"] = None,
    ):
        formatted_prompt = OBSIDIAN_SYSTEM_PROMPT.format(current_time=get_current_time_str())
        self.llm = GeminiLLM(system_instruction=formatted_prompt)
        self.indexer = indexer
        self.vector_store = vector_store
        self.workspace_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))

    def _read_file(self, filename: str) -> str:
        """Reads a file from the workspace root."""
        path = os.path.join(self.workspace_root, filename)
        try:
            with open(path, "r", encoding="utf-8") as f:
                return f.read()
        except Exception as e:
            logger.error(f"Failed to read {filename}: {e}")
            return ""

    def _semantic_search(self, query: str, top_k: int = 3) -> str:
        """Run semantic search against the vector store and format results.

        Args:
            query: Search query text.
            top_k: Maximum number of chunks to retrieve.

        Returns:
            Formatted string block, or empty string if no results.
        """
        if self.vector_store is None:
            return ""
        try:
            results = self.vector_store.search_knowledge(query, top_k=top_k)
        except Exception as e:
            logger.warning(f"_semantic_search failed: {e}")
            return ""
        if not results:
            return ""
        lines = ["=== SEMANTIC KNOWLEDGE FROM VAULT ==="]
        for r in results:
            lines.append(f"[{Path(r.source_path).name} / {r.header_hierarchy}]")
            # Truncate individual chunks to avoid prompt bloat (~500 tokens ≈ 2000 chars)
            chunk_text = r.text[:2000]
            lines.append(chunk_text)
            lines.append("")
        return "\n".join(lines)

class WritingAssistant(BaseGenerator):
    def chat(self, user_input: str, history: List[Dict]) -> Tuple[str, List[Dict]]:

        is_first_turn = (len(history) == 0)

        if is_first_turn:
            # === First Turn: Build Rich Prompt ===
            # user_input is treated as "Topic"

            # Naive splitting for context if formatted as "Topic | Context"
            parts = user_input.split("|", 1)
            topic = parts[0].strip()
            extra_context = parts[1].strip() if len(parts) > 1 else ""

            # 1. RAG & Samples
            style_samples = self.indexer.get_writing_samples(count=3)
            style_text = "\n\n".join(style_samples)

            rag_notes = self.indexer.search(topic, limit=5)
            rag_text = "\n\n".join(rag_notes)

            writing_style_guide = self._read_file("writing_style.md")
            methodology = self._read_file("methodology.md")
            powell_revise_guide = self._read_file("POWELL_REVISE.md")

            semantic_context = self._semantic_search(topic, top_k=3)

            prompt = f"""You are a ghostwriter for the user. Your goal is to write an article on the TOPIC provided.

            === YOUR IDENTITY & METHODOLOGY ===
            {methodology}

            === YOUR WRITING STYLE GUIDE ===
            {writing_style_guide}

            === ANTI-AI-TONE GUIDE (APPLY WHILE WRITING, NOT JUST AT THE END) ===
            {powell_revise_guide}

            === YOUR WRITING SAMPLES (MIMIC THIS TONE) ===
            {style_text}

            === RELEVANT NOTES FROM OBSIDIAN VAULT ===
            {rag_text}

            {semantic_context}

            === TASK ===
            Topic: {topic}
            Extra Context: {extra_context}

            Write the article in Chinese (unless the topic implies English).
            Adhere strictly to the "Identity" and "Style Guide".
            """

            logger.info(f"Generating article for topic: {topic}")
            actual_user_message = prompt # We inject the big prompt

        else:
            # === Follow-up Turn ===
            # user_input is just instructions like "Make it shorter"
            logger.info(f"Follow-up instruction: {user_input}")
            actual_user_message = user_input
            prompt = user_input

        # Call LLM — first pass: content generation
        draft, _ = self.llm.generate_response(prompt, history)

        # === Second pass: de-AI review (first turn only) ===
        if is_first_turn:
            powell_revise_guide = self._read_file("POWELL_REVISE.md")
            review_prompt = f"""You are a strict editor. A draft article has been written. Your ONLY job is to remove AI tone. Do NOT change facts or add new content.

=== TONE TARGET (最高优先级) ===
目标语感：像一个懂行的人在跟朋友聊天时随口说的。不端着，也不刻意耍帅。
两种AI味都要杀：
- 正式AI味：过渡词堆砌、排比对仗、空洞修饰、鸡汤结尾
- 油腻AI味：刻意装随意（"折腾去吧"）、强行造金句（"搞坏了不心疼，跑通了就是资产"）、给普通概念起酷名字（"隔离舱"、"暴力碰撞"、"最干净的契约"）、用一句俏皮话收尾装潇洒
正确的语感：平铺直叙，该说什么说什么，说完就停。不需要让读者觉得你很酷。

=== PRIORITY 1: VOCABULARY KILL LIST (逐词扫描，命中即删或换大白话) ===
夸张修饰: 极度脆弱、毁灭性的、灾难级的、压倒性的、远超想象、不止一个量级、颠覆性的、革命性的、折磨人、天翻地覆、风险极高
大词空转: 范式转移、范式突变、开发范式、数字化容器、数字雇员、温和池架构、非确定性、阅后即焚、毫秒级就绪、毫秒级响应、赋能、底层逻辑、仪式性努力、深刻变革、暴力碰撞、最干净的契约、服务化的潜力、可扩展的执行信用、终极归宿、壁垒、分水岭、沉淀（用作动词修饰数据/经验时）、云端办公室
鸡汤/口号: 正途、值得跨越、安置灵魂、掌控感、才是王道、苦活累活交给、未来已来、危机就是转机、从玩具变成生产力、把环境变成了资产、安全底线、这才是成熟的做法、才是X的关键、不是建议是必须
油腻收尾: "X去吧"式潇洒收尾、"搞坏了不心疼，跑通了就是Y"式金句、任何试图用一句俏皮话总结全文的结尾、"把X关在Y里...把Y放在Z上"式排比收尾
伪口语: 说白了、说实话、折腾这玩意儿

=== PRIORITY 2: SENTENCE PATTERN KILL LIST (命中即改写) ===
1. 防御性写法："很多人觉得X，实际上Y"、"有些人认为X，但其实Y"、"很多人没意识到"、"有人X，有人已经Y了" → 直接说判断，删掉"很多人/有人"铺垫
2. 对比式定义："不是X，是Y"、"不是X而是Y"、"那不叫X那叫Y"、"别把X当Y" → 删除对比句式，直接陈述后半句即可
3. 换皮夸张：用另一个夸张词替换夸张词不算修好。"灾难级"→"毁灭性"→"风险极高"都是换皮。正确做法：换成具体后果（如"可能误删整个home目录"）
4. 概念包装：给普通事物起酷名字（"隔离舱"=容器、"暴力碰撞"=冲突、"数字雇员"=自动化脚本、"执行信用"=服务器资源、"云端办公室"=云服务器、"牢笼"=限制）→ 用最朴素的说法
5. 油腻金句：短句对仗、押韵、刻意工整的收尾句 → 要么删掉，要么拆成普通陈述句
6. 比喻堆砌：全篇最多保留1个比喻，多余的全部删除换成直说
7. 鸡汤/口号结尾：删除所有励志或耍帅收尾，改为具体行动建议或直接停住
8. 首尾呼应：禁止结尾重复开头观点
9. 语气漂移：全文语气必须统一，不要中途切换
10. 编号标题：如果是线性论述（论点1→论点2→论点3），删除"一、二、三"序号改为自然段落过渡；如果是并列对比不同方案（如方案A vs 方案B vs 方案C），可以保留编号
11. 过渡词堆积：删除"首先...其次...最后..."
12. 排比对仗：打破字数相近的并列句
13. 关键词重复：同一个修辞性词汇（如"分水岭"、"命门"、"杀手锏"）全篇只能出现一次，重复出现时删除后续的或换成普通说法

=== PRIORITY 3: FULL REVISION GUIDE ===
{powell_revise_guide}

=== DRAFT TO REVIEW ===
{draft}

=== OUTPUT ===
Return ONLY the corrected text. No explanation, no meta-commentary.
"""
            logger.info("Running second-pass de-AI review on writing draft...")
            response, _ = self.llm.generate_response(review_prompt, [])
        else:
            response = draft

        # Update History
        new_history = history.copy()
        new_history.append({"role": "user", "content": actual_user_message})
        new_history.append({"role": "assistant", "content": response})

        return response, new_history

class ReplyGenerator(BaseGenerator):
    def chat(self, user_input: str, history: List[Dict]) -> Tuple[str, List[Dict]]:

        is_first_turn = (len(history) == 0)

        if is_first_turn:
             # === First Turn ===
            static_samples = self._read_file("REPLY-SAMPLE.md")
            dynamic_samples = self.indexer.get_reply_samples(count=3)
            dynamic_samples_text = "\n\n".join(dynamic_samples)
            methodology = self._read_file("methodology.md")

            semantic_context = self._semantic_search(user_input, top_k=3)

            prompt = f"""You are an experienced Product/R&D Manager.
            Your task is to draft a reply to a challenging situation.

            === CORE PHILOSOPHY ===
            {methodology}

            === REPLY GUIDELINES ===
            1. Logic: Be clear and structured (e.g., numbered lists).
            2. Responsibility: Don't take unnecessary blame, but don't shirk core duties. Explain objective causes.
            3. Emotion: Be empathetic but professional.
            4. Conciseness: No fluff.

            === REFERENCE SAMPLES (FEW-SHOT) ===
            {static_samples}

            {dynamic_samples_text}

            {semantic_context}

            === THE SITUATION ===
            {user_input}

            Draft a reply. If the context implies a specific role (PM, Dev Lead), assume that role.
            """
            logger.info(f"Generating reply for query: {user_input}")
            actual_user_message = prompt

        else:
             # === Follow-up Turn ===
            logger.info(f"Follow-up reply instruction: {user_input}")
            actual_user_message = user_input
            prompt = user_input

        response, _ = self.llm.generate_response(prompt, history)

        new_history = history.copy()
        new_history.append({"role": "user", "content": actual_user_message})
        new_history.append({"role": "assistant", "content": response})

        return response, new_history

class DecisionSupport(BaseGenerator):
    def chat(self, user_input: str, history: List[Dict]) -> Tuple[str, List[Dict]]:

        is_first_turn = (len(history) == 0)

        if is_first_turn:
            decision_guide = self._read_file("decision.md")
            methodology = self._read_file("methodology.md")

            rag_notes = self.indexer.search(user_input, limit=3)
            rag_text = "\n\n".join(rag_notes)

            prompt = f"""You are a Decision Support Assistant.
            Your goal is to help the user think through a complex decision.

            === DECISION FRAMEWORK ===
            {decision_guide}

            === CORE PHILOSOPHY ===
            {methodology}

            === RELEVANT CONTEXT FROM NOTES ===
            {rag_text}

            === THE DECISION / ISSUE ===
            {user_input}

            === TASK ===
            1. Challenge the premise: Is this the right problem to solve?
            2. Apply the Framework: Use GPA (Goal, Priority, Alternatives) and IPO (Information, People, Objective reasoning) models.
            3. Pre-mortem: What is the worst that could happen?
            4. Provide a recommendation or a set of questions to clarify.
            """

            logger.info(f"Analyzing decision: {user_input}")
            actual_user_message = prompt
        else:
            logger.info(f"Follow-up decision instruction: {user_input}")
            actual_user_message = user_input
            prompt = user_input

        response, _ = self.llm.generate_response(prompt, history)

        new_history = history.copy()
        new_history.append({"role": "user", "content": actual_user_message})
        new_history.append({"role": "assistant", "content": response})

        return response, new_history

class SearchAnalyzer(BaseGenerator):
    def chat(self, user_input: str, history: List[Dict]) -> Tuple[str, List[Dict]]:

        # Tools definition for the LLM
        tools = [
            {
                "type": "function",
                "function": {
                    "name": "search_web",
                    "description": "Search the public web using DuckDuckGo. Use this when the internal notes are insufficient or when you need up-to-date external information.",
                    "parameters": {
                        "type": "object",
                        "properties": {
                            "query": {
                                "type": "string",
                                "description": "The search query"
                            }
                        },
                        "required": ["query"]
                    }
                }
            },
            {
                "type": "function",
                "function": {
                    "name": "list_recent_files",
                    "description": "List files in the Obsidian vault that have been modified recently. Use this when the user asks for 'recent updates', 'what's new', or changes in the last X days.",
                    "parameters": {
                        "type": "object",
                        "properties": {
                            "days": {
                                "type": "integer",
                                "description": "Number of days to check (default 5)",
                                "default": 5
                            }
                        },
                        "required": []
                    }
                }
            }
        ]

        is_first_turn = (len(history) == 0)

        # Current Turn Message
        if is_first_turn:
            rag_notes = self.indexer.search(user_input, limit=5)
            rag_text = "\n\n".join(rag_notes)

            logger.info(f"SearchAnalyzer retrieved {len(rag_notes)} local notes.")
            for note in rag_notes:
                # Extract source line for logging
                first_line = note.split('\n')[0]
                logger.debug(f" - Retrieved: {first_line}")

            prompt = f"""You are an Analyst using Butler (Obsidian Edition).

            === TASK ===
            Answer the user's question.
            CRITICAL: You MUST explicitly cite your sources in the output.

            === STRATEGY ===
            1. first: Check "RELEVANT LOCAL NOTES" provided below.
            2. second: If the notes answer the question, answer directly.
            3. third: If the notes are missing info, ambiguos, or outdated, USE THE `search_web` TOOL to find external info.

            === OUTPUT FORMAT ===
            - Begin with a summary.
            - When stating facts, append the source in brackets, e.g., "The server failed [Source: XLSmart 2025-12-25.md]" or "Market share is 20% [Source: Web Search]".
            - If data comes from BOTH, mention both.

            === RELEVANT LOCAL NOTES ===
            {rag_text}

            === USER QUESTION ===
            {user_input}
            """
            actual_user_message = prompt
        else:
            actual_user_message = user_input

        # Prepare execution context
        # We don't want to mutate the input history directly until the end
        exec_history = history.copy()
        exec_history.append({"role": "user", "content": actual_user_message})

        logger.info(f"Analyzer thinking on: {user_input}")

        # === ReAct Loop (Max 2 turns to prevent infinite loops) ===
        for i in range(2):
            response_text, tool_calls = self.llm.generate_response(
                message="", # Message is already in history
                context=exec_history,
                tools=tools
            )

            if tool_calls:
                # LLM wants to use tools
                logger.info(f"LLM requested tool calls: {tool_calls}")

                # Append the assistant's request (with tool calls) to history
                exec_history.append({
                    "role": "assistant",
                    "content": f"[Tool Call Request: {tool_calls}]"
                })

                for tc in tool_calls:
                    if tc["name"] == "search_web":
                        query = tc["args"].get("query")
                        search_result = WebSearchTool.search_web(query)

                        # Feed result back
                        exec_history.append({
                            "role": "user", # Using 'function' role would be better if our LLM class supports it fully, but 'user' works as system injection
                            "content": f"--- TOOL OUTPUT (search_web) ---\n{search_result}"
                        })

                    elif tc["name"] == "list_recent_files":
                        days = tc["args"].get("days", 5)
                        # Call indexer method
                        recent_list = self.indexer.get_recent_files(days=days)

                        exec_history.append({
                            "role": "user",
                            "content": f"--- TOOL OUTPUT (list_recent_files) ---\n{recent_list}"
                        })

                # Loop again to get the final answer using tool outputs
                try:
                     # Second pass with tool outputs
                    response_text, _ = self.llm.generate_response(
                        message="",
                        context=exec_history,
                        tools=tools
                    )
                    return response_text, exec_history
                except Exception as e:
                    logger.error(f"Error in second ReAct loop: {e}")
                    return response_text, exec_history

            else:
                # Final answer (no tools)
                return response_text, exec_history

        # Fallback if loop exhausted
        return response_text, exec_history

class DeAIReviser(BaseGenerator):
    """Remove AI tone from articles - text-in, text-out mode."""

    def chat(self, user_input: str, history: List[Dict]) -> Tuple[str, List[Dict]]:

        is_first_turn = (len(history) == 0)

        if is_first_turn:
            # === First Turn: User pastes the AI-generated article ===
            # Load the POWELL_REVISE.md guide
            powell_revise_guide = self._read_file("POWELL_REVISE.md")

            # Load user's writing samples for style reference
            style_samples = self.indexer.get_writing_samples(count=3)
            style_text = "\n\n".join(style_samples)

            prompt = f"""You are an expert editor specializing in removing AI-generated tone from articles.

Your task is to revise the article provided by the user to make it sound more human and authentic.

=== REVISION GUIDE ===
{powell_revise_guide}

=== USER'S AUTHENTIC WRITING SAMPLES (for style reference) ===
{style_text}

=== ARTICLE TO REVISE ===
{user_input}

=== TASK ===
1. Analyze the article and identify AI characteristics (过渡词堆积, 排比对仗, 空洞修饰词等)
2. Rewrite the article following the POWELL_REVISE.md principles
3. Preserve:
   - Original meaning and key information
   - Technical terms and proper nouns
   - Code blocks, lists, and formatting
   - Original language (Chinese/English)
4. Output the revised article directly (no meta-commentary like "Here's the revised version...")

CRITICAL: Output ONLY the revised article text. Do NOT add explanations before or after.
"""

            logger.info(f"DeAI Reviser: Processing article ({len(user_input)} chars)")
            actual_user_message = prompt

        else:
            # === Follow-up Turn: User gives refinement instructions ===
            logger.info(f"DeAI Reviser: Follow-up instruction - {user_input}")
            actual_user_message = user_input
            prompt = user_input

        # Call LLM
        response, _ = self.llm.generate_response(prompt, history)

        # Update History
        new_history = history.copy()
        new_history.append({"role": "user", "content": actual_user_message})
        new_history.append({"role": "assistant", "content": response})

        return response, new_history

class ZhihuGenerator(BaseGenerator):
    """Generate Zhihu-style answers based on style guide and user's knowledge base."""

    def chat(self, user_input: str, history: List[Dict]) -> Tuple[str, List[Dict]]:

        is_first_turn = (len(history) == 0)
        built_rich_prompt = False  # tracks whether we ran the full question->draft pipeline

        # Pre-load rule files (needed for second-pass review)
        zhihu_bot_style = self._read_file("zhihu-bot-style.md")

        # Treat as a new question if:
        # - history is empty (genuine first turn), OR
        # - user input contains "|" (explicit "Question | Core Ideas" format), OR
        # - user input starts with known question triggers like "回答这个问题", OR
        # - user input contains a Zhihu question URL (pasting a new question link)
        is_new_question = (
            is_first_turn
            or "|" in user_input
            or user_input.strip().startswith("回答这个问题")
            or "zhihu.com/question/" in user_input
        )

        if is_new_question:
            # === First Turn: Build Rich Prompt ===
            # Expected input format: "Question | Core Ideas"
            parts = user_input.split("|", 1)
            question = parts[0].strip()
            core_ideas = parts[1].strip() if len(parts) > 1 else ""

            # 1. Load Style Guide (zhihu-style.md serves as writing standard, NOT as material)
            zhihu_style_guide = self._read_file("zhihu-style.md")
            # Bot-specific overrides (plain-text output, no bracket explanations, etc.)
            if zhihu_bot_style:
                zhihu_style_guide = zhihu_style_guide + "\n\n" + zhihu_bot_style

            # 2. RAG from Obsidian vault for actual content material
            rag_notes = self.indexer.search(question, limit=5)
            rag_text = "\n\n".join(rag_notes)

            # 3. Load methodology
            methodology = self._read_file("methodology.md")

            semantic_context = self._semantic_search(question, top_k=3)

            prompt = f"""You are answering a Zhihu question. Your goal is to write an authentic, experience-driven answer that sounds like a real human, not AI-generated.

=== YOUR IDENTITY & METHODOLOGY ===
{methodology}

=== ZHIHU STYLE GUIDE (FOLLOW THESE WRITING PRINCIPLES) ===
{zhihu_style_guide}

CRITICAL: The style guide above defines HOW to write (tone, structure, language patterns).
Do NOT treat zhihu-sample content as factual material. It's ONLY for style reference.

=== RELEVANT KNOWLEDGE FROM YOUR OBSIDIAN VAULT (USE AS ACTUAL MATERIAL) ===
{rag_text}

{semantic_context}

=== THE QUESTION ===
{question}

=== YOUR CORE IDEAS ===
{core_ideas}

=== TASK ===
1. Answer the question based on YOUR knowledge (from Obsidian vault) and core ideas
2. Write in Chinese (unless question implies English)
3. Be direct and opinionated — attach concrete evaluation to every fact (not feature listing)
4. If recent web context is provided, weave it in naturally
5. STRICT: Never add bracket explanations. Write "大语言模型" not "大语言模型（LLM）"
6. No self-promotion, no identity reveal, no past project references
7. No poetry, quotes, or "升华" endings. Stop when the point is made.
8. LENGTH: 500–1000 Chinese characters. Cut a point, not quality.

=== ANTI-AI CORE RULES (apply while writing) ===
- 语感目标：像懂行的人随手打字，不端着，不耍帅
- 禁止：排比对仗、过渡词堆砌（首先/其次/最后）、空洞修饰（深入/全面/显著）
- 禁止：比喻超过1个、概念包装（给普通事物起酷名字）、鸡汤结尾
- 禁止：防御性写法（很多人觉得X但其实Y）、对比式定义（不是X是Y）
- 陈述事实时必须附带主观判断，不要纯客观罗列

Begin writing the answer:
"""

            logger.info(f"Generating Zhihu answer for: {question[:50]}...")
            actual_user_message = prompt
            built_rich_prompt = True

        else:
            # === Follow-up Turn: User gives refinement instructions (e.g. "make it shorter") ===
            logger.info(f"Follow-up Zhihu instruction: {user_input}")
            actual_user_message = user_input
            prompt = user_input

        # Call LLM — first pass: content generation
        # For new questions, use empty history to avoid contamination from previous conversations
        llm_history = [] if is_new_question else history
        draft, _ = self.llm.generate_response(prompt, llm_history)

        # === Second pass: rule enforcement ===
        # Runs whenever a full draft was generated (built_rich_prompt=True),
        # regardless of whether history was empty or not.
        # Skipped on follow-up refinement turns (e.g. "make it shorter").
        run_review = built_rich_prompt
        if run_review:
            review_prompt = f"""You are a strict editor. A draft Zhihu answer has been written. Your ONLY job is to remove AI tone. Do NOT change facts or add new content.

=== TONE TARGET (最高优先级) ===
目标语感：像一个懂行的人在知乎随手打字回答。不端着，也不刻意耍帅。
两种AI味都要杀：
- 正式AI味：过渡词堆砌、排比对仗、空洞修饰、鸡汤结尾
- 油腻AI味：刻意装随意（"折腾去吧"）、强行造金句（"搞坏了不心疼，跑通了就是资产"）、给普通概念起酷名字（"隔离舱"、"暴力碰撞"、"最干净的契约"）、用一句俏皮话收尾装潇洒
正确的语感：平铺直叙，该说什么说什么，说完就停。不需要让读者觉得你很酷。

=== VOCABULARY KILL LIST (逐词扫描，命中即删或换大白话) ===
1. 夸张修饰: 极度脆弱、极其脆弱、毁灭性的、灾难级的、压倒性的、远超想象、不止一个量级、颠覆性的、革命性的、折磨人、天翻地覆、风险极高、瞬间崩断、精疲力竭、真金白银、本质上
2. 大词空转: 范式转移、范式突变、开发范式、数字化容器、数字雇员、温和池架构、非确定性、阅后即焚、毫秒级就绪、毫秒级响应、赋能、底层逻辑、仪式性努力、深刻变革、暴力碰撞、最干净的契约、服务化的潜力、不可逆的浪潮、时代大背景、可扩展的执行信用、终极归宿、壁垒、分水岭、沉淀（动词修饰数据/经验时）、云端办公室、命门
3. 鸡汤/口号: 正途、值得跨越、安置灵魂、掌控感、才是王道、苦活累活交给、未来已来、危机就是转机、挑战与机遇并存、从玩具变成生产力、把环境变成了资产、安全底线、这才是成熟的做法、才是X的关键、不是建议是必须
4. 概念包装: 裸奔（=没防护）、修路/飙车（=基础工作/高级用法）、提款机（=攻击目标）、隔离舱（=容器）、数字雇员（=脚本）、执行信用（=资源）、云端办公室（=云服务器）、牢笼（=限制）、独立房间（=隔离环境）
5. 油腻收尾: "X去吧"式潇洒收尾、"搞坏了不心疼，跑通了就是Y"式金句、俏皮话总结全文、"把X关在Y里...把Y放在Z上"式排比收尾
6. 伪口语: 说白了、说实话、折腾这玩意儿

=== SENTENCE PATTERN KILL LIST (命中即改写) ===
1. 防御性写法："很多人觉得X，实际上Y"、"有人X，有人已经Y了"、"一部分人在X，另一部分人在Y" → 直接说判断，删掉铺垫
2. 对比式定义："不是X，是Y"、"不是X而是Y"、"别把X当Y"、"这不对，它的本质是…" → 直接陈述后半句
3. 换皮夸张：用另一个夸张词替换夸张词不算修好 → 换成具体后果
4. 概念包装：给普通事物起酷名字 → 用最朴素的说法
5. 油腻金句：短句对仗、押韵、刻意工整的收尾句 → 删掉或拆成普通句
6. 比喻堆砌：全篇最多1个比喻，多余全删换直说
7. 鸡汤/口号结尾：删除励志或耍帅收尾，给具体建议或直接停
8. 首尾呼应：禁止结尾重复开头
9. 编号分段：线性论述删除序号改自然段落；并列对比可保留
10. 强行总结：删除"综上"/"一句话"式收尾
11. 关键词重复：同一个修辞性词汇全篇只能出现一次
12. 过渡词换皮："第一个要说的是"="首先"，"另一个问题是"="其次" → 直接删掉过渡，开门见山
13. 伪分类二分法："一部分人X，另一部分人Y"、"修路派/飙车派" → 删除分类框架，直接说事实

=== DRAFT TO REVIEW ===
{draft}

=== OUTPUT ===
Return ONLY the corrected Chinese text. No explanation, no meta-commentary, no English.
"""
            logger.info("Running second-pass rule enforcement on Zhihu draft...")
            response, _ = self.llm.generate_response(review_prompt, [])
        else:
            response = draft

        # Update History
        new_history = history.copy()
        new_history.append({"role": "user", "content": actual_user_message})
        new_history.append({"role": "assistant", "content": response})

        return response, new_history

-            powell_revise_guide = self._read_file("POWELL_REVISE.md")

正确的语感：平铺直叙，该说什么说什么，说完就停。不需要让读者觉得你很酷。

-=== PRIORITY 1: VOCABULARY KILL LIST (逐词扫描，命中即删或换大白话) ===

-夸张修饰: 极度脆弱、毁灭性的、灾难级的、压倒性的、远超想象、不止一个量级、颠覆性的、革命性的、折磨人、天翻地覆、风险极高

-大词空转: 范式转移、范式突变、开发范式、数字化容器、数字雇员、温和池架构、非确定性、阅后即焚、毫秒级就绪、毫秒级响应、赋能、底层逻辑、仪式性努力、深刻变革、暴力碰撞、最干净的契约、服务化的潜力、可扩展的执行信用、终极归宿、壁垒、分水岭、沉淀（用作动词修饰数据/经验时）、云端办公室

-鸡汤/口号: 正途、值得跨越、安置灵魂、掌控感、才是王道、苦活累活交给、未来已来、危机就是转机、从玩具变成生产力、把环境变成了资产、安全底线、这才是成熟的做法、才是X的关键、不是建议是必须

-油腻收尾: "X去吧"式潇洒收尾、"搞坏了不心疼，跑通了就是Y"式金句、任何试图用一句俏皮话总结全文的结尾、"把X关在Y里...把Y放在Z上"式排比收尾

-伪口语: 说白了、说实话、折腾这玩意儿

+=== VOCABULARY KILL LIST (逐词扫描，命中即删或换大白话) ===

+1. 夸张修饰: 极度脆弱、极其脆弱、毁灭性的、灾难级的、压倒性的、远超想象、不止一个量级、颠覆性的、革命性的、折磨人、天翻地覆、风险极高、瞬间崩断、精疲力竭、真金白银、本质上

+2. 大词空转: 范式转移、范式突变、开发范式、数字化容器、数字雇员、温和池架构、非确定性、阅后即焚、毫秒级就绪、毫秒级响应、赋能、底层逻辑、仪式性努力、深刻变革、暴力碰撞、最干净的契约、服务化的潜力、可扩展的执行信用、终极归宿、壁垒、分水岭、沉淀（动词修饰数据/经验时）、云端办公室、命门

+3. 鸡汤/口号: 正途、值得跨越、安置灵魂、掌控感、才是王道、苦活累活交给、未来已来、危机就是转机、从玩具变成生产力、把环境变成了资产、安全底线、这才是成熟的做法、才是X的关键、不是建议是必须

+4. 概念包装: 裸奔（=没防护）、修路/飙车（=基础工作/高级用法）、提款机（=攻击目标）、隔离舱（=容器）、数字雇员（=脚本）、执行信用（=资源）、云端办公室（=云服务器）、牢笼（=限制）、独立房间（=隔离环境）

+5. 油腻收尾: "X去吧"式潇洒收尾、"搞坏了不心疼，跑通了就是Y"式金句、俏皮话总结全文、"把X关在Y里...把Y放在Z上"式排比收尾

+6. 伪口语: 说白了、说实话、折腾这玩意儿

-=== PRIORITY 2: SENTENCE PATTERN KILL LIST (命中即改写) ===

-1. 防御性写法："很多人觉得X，实际上Y"、"有些人认为X，但其实Y"、"很多人没意识到"、"有人X，有人已经Y了" → 直接说判断，删掉"很多人/有人"铺垫

-2. 对比式定义："不是X，是Y"、"不是X而是Y"、"那不叫X那叫Y"、"别把X当Y" → 删除对比句式，直接陈述后半句即可

-3. 换皮夸张：用另一个夸张词替换夸张词不算修好。"灾难级"→"毁灭性"→"风险极高"都是换皮。正确做法：换成具体后果（如"可能误删整个home目录"）

-4. 概念包装：给普通事物起酷名字（"隔离舱"=容器、"暴力碰撞"=冲突、"数字雇员"=自动化脚本、"执行信用"=服务器资源、"云端办公室"=云服务器、"牢笼"=限制）→ 用最朴素的说法

-5. 油腻金句：短句对仗、押韵、刻意工整的收尾句 → 要么删掉，要么拆成普通陈述句

-6. 比喻堆砌：全篇最多保留1个比喻，多余的全部删除换成直说

-7. 鸡汤/口号结尾：删除所有励志或耍帅收尾，改为具体行动建议或直接停住

-8. 首尾呼应：禁止结尾重复开头观点

-9. 语气漂移：全文语气必须统一，不要中途切换

-10. 编号标题：如果是线性论述（论点1→论点2→论点3），删除"一、二、三"序号改为自然段落过渡；如果是并列对比不同方案（如方案A vs 方案B vs 方案C），可以保留编号

-12. 排比对仗：打破字数相近的并列句

-13. 关键词重复：同一个修辞性词汇（如"分水岭"、"命门"、"杀手锏"）全篇只能出现一次，重复出现时删除后续的或换成普通说法

+=== SENTENCE PATTERN KILL LIST (命中即改写) ===

+1. 防御性写法："很多人觉得X，实际上Y"、"有人X，有人已经Y了"、"一部分人在X，另一部分人在Y" → 直接说判断，删掉铺垫

+2. 对比式定义："不是X，是Y"、"不是X而是Y"、"别把X当Y"、"这不对，它的本质是…" → 直接陈述后半句

+3. 换皮夸张：用另一个夸张词替换夸张词不算修好 → 换成具体后果

+4. 概念包装：给普通事物起酷名字 → 用最朴素的说法

+5. 油腻金句：短句对仗、押韵、刻意工整的收尾句 → 删掉或拆成普通句

+6. 比喻堆砌：全篇最多1个比喻，多余全删换直说

+7. 鸡汤/口号结尾：删除励志或耍帅收尾，给具体建议或直接停

+8. 首尾呼应：禁止结尾重复开头

+9. 编号分段：线性论述删除序号改自然段落；并列对比可保留

+10. 强行总结：删除"综上"/"一句话"式收尾

+11. 关键词重复：同一个修辞性词汇全篇只能出现一次

+12. 过渡词换皮："第一个要说的是"="首先"，"另一个问题是"="其次" → 直接删掉过渡，开门见山

+13. 伪分类二分法："一部分人X，另一部分人Y"、"修路派/飙车派" → 删除分类框架，直接说事实

-=== PRIORITY 3: FULL REVISION GUIDE ===


验证一下改动后的文件能正常导入：


Bash

source /root/projects/butler/venv/bin/activate && python3 -c "
from slack_bot.obsidian.generators import WritingAssistant, ZhihuGenerator
from slack_bot.zhihu.zhihu_hunter import ZhihuQuestion
print('Import OK')
print('ZhihuQuestion fields:', list(ZhihuQuestion.model_fields.keys()))
" 2>&1

Verify imports work after changes


Import OK
ZhihuQuestion fields: ['title', 'url', 'snippet', 'keywords', 'found_at', 'description', 'source_file', 'outline']


验证 Playwright 能抓到问题描述：


source /root/projects/butler/venv/bin/activate && python3 -c "
import sys, os
os.chdir('/root/projects/butler')
sys.path.insert(0, '.')
from slack_bot.zhihu.zhihu_hunter import ZhihuHunter
from slack_bot.obsidian.indexer import ObsidianIndexer
from slack_bot.obsidian.embeddings import get_embedding_provider
from slack_bot.obsidian.vector_store import ChromaVectorStore
from pathlib import Path

vault = os.environ.get('OBSIDIAN_VAULT_PATH', '/root/vault/obsidian_vault/obsidian/obsidian/')
indexer = ObsidianIndexer(vault)
indexer.scan_vault()
vs = ChromaVectorStore(embedding_provider=get_embedding_provider())

hunter = ZhihuHunter(vault_path=Path(vault), vector_store=vs, indexer=indexer)

# Test with the OpenClaw question
qs = hunter.hunt_direct_url('https://www.zhihu.com/question/2015396999915139338')
q = qs[0]
print(f'Title: {q.title!r}')
print(f'Description: {q.description!r}')
print(f'URL: {q.url}')
" 2>&1

Test question info fetch with new code


2026-03-17 14:39:12 - slack_bot.obsidian.indexer - INFO - Scanning Obsidian Vault at: /root/vault/obsidian_vault/obsidian/obsidian/
2026-03-17 14:39:12 - slack_bot.obsidian.indexer - INFO - Scan complete. Scanned 528 files.
2026-03-17 14:39:12 - slack_bot.obsidian.indexer - INFO - Found 33 writing samples.
2026-03-17 14:39:12 - slack_bot.obsidian.indexer - INFO - Found 2 reply samples.
2026-03-17 14:39:12 - slack_bot.obsidian.embeddings - INFO - Using cloud (Qwen) embedding provider
2026-03-17 14:39:13 - slack_bot.obsidian.vector_store - INFO - ChromaVectorStore initialized at /root/projects/butler/data/health/vector_db (collection: obsidian_notes)
2026-03-17 14:39:13 - slack_bot.llm.gemini - INFO - Using OpenRouter: https://openrouter.ai/api
2026-03-17 14:39:13 - slack_bot.llm.gemini - INFO - Initialized Gemini model: google/gemini-3-flash-preview
2026-03-17 14:39:19 - slack_bot.zhihu.zhihu_hunter - INFO - Direct URL hunt: 'https://www.zhihu.com/question/2015396999915139338' → title='你会使用OpenClaw吗，风险大于获得？', desc=217 chars
Title: '你会使用OpenClaw吗，风险大于获得？'
Description: '知乎，中文互联网高质量的问答社区和创作者聚集的原创内容平台，于 2011 年 1 月正式上线，以「让人们更好的分享知识、经验和见解，找到自己的解答」为品牌使命。知乎凭借认真、专业、友善的社区氛围、独特的产品机制以及结构化和易获得的优质内容，聚集了中文互联网科技、商业、影视、时尚、文化等领域最具创造力的人群，已成为综合性、全品类、在诸多领域具有关键影响力的知识分享社区和创作者聚集的原创内容平台，建立起了以社区驱动的内容变现商业模式。'
URL: https://www.zhihu.com/question/2015396999915139338


The title is clean now ("你会使用OpenClaw吗，风险大于获得？" without "(11 条消息)" prefix), but the description is not the actual question description — it's the Zhihu meta description tag content (a generic description of Zhihu itself). This is because the `meta[name='description']` selector returns the site-wide description, not the question-specific description.

The issue is that without login, the question description is not rendered in the page body for questions without many views/answers. Let me check what the page structure looks like more carefully.

Actually, looking at the selectors I used:

1. `div.QuestionRichText` - this might not exist for this question (maybe it's hidden behind "显示全部" / "show more")
2. `div.QuestionDetail-main .RichText` - same issue
3. `meta[name='description']` - this returns the Zhihu site description, not the question description

I need to fix the selector to get the actual question description, or extract it from the page content more carefully. In my earlier test with the MaaS question, the body text included the beginning of the description, so it seems like the content is there somewhere in the DOM.

Looking at the MaaS example, the description text appears to be present on the page but might be in a different element than expected. Let me try checking with that question instead, since the OpenClaw question might just not have a description at all—some questions only have titles.


Description 抓到的是知乎的 meta 标签（通用站点描述），不是问题描述。让我找到正确的 DOM 选择器。


source /root/projects/butler/venv/bin/activate && python3 -c "
from playwright.sync_api import sync_playwright
from playwright_stealth import Stealth

# Test with MaaS question which has a description
url = 'https://www.zhihu.com/question/2016845353517806681'
with sync_playwright() as p:
    browser = p.chromium.launch(headless=True)
    context = browser.new_context(user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/122.0.0.0 Safari/537.36')
    page = context.new_page()
    Stealth().apply_stealth_sync(page)
    page.goto(url, wait_until='domcontentloaded', timeout=15000)
    page.wait_for_timeout(3000)

    # Try various selectors for question description
    selectors = [
        'div.QuestionRichText',
        'div.QuestionDetail-main .RichText',
        '.QuestionHeader-detail .RichText',
        'div[class*=\"QuestionDetail\"]',
        'span[class*=\"RichText\"]',
        'div.QuestionDetail',
        '.QuestionHeader-detail',
    ]
    for sel in selectors:
        el = page.query_selector(sel)
        if el:
            text = el.inner_text().strip()[:200]
            print(f'FOUND {sel}: {text!r}')
        else:
            print(f'MISS  {sel}')

    # Also try getting text after the title element
    print('---')
    # Look at the question detail area
    detail = page.query_selector('.QuestionHeader-detail')
    if detail:
        print(f'Detail HTML snippet: {detail.inner_html()[:500]!r}')

    # Try meta og:description
    og_desc = page.query_selector('meta[property=\"og:description\"]')
    if og_desc:
        print(f'og:description: {og_desc.get_attribute(\"content\")[:200]!r}')

    browser.close()
" 2>&1

Find correct CSS selector for question description


FOUND div.QuestionRichText: '在人工智能技术高速发展的今天，模型即服务（MaaS）作为连接AI模型与用户的关键环节，本应借助技术的优势为用户提供便捷的AI服务，从而获取丰厚的收益。…显示全部 \u200b'
MISS  div.QuestionDetail-main .RichText
MISS  .QuestionHeader-detail .RichText
MISS  div[class*="QuestionDetail"]
FOUND span[class*="RichText"]: '唉，写股票最终写到了基本面。我通常不怎么喜欢写行业细化的东西。因为本质上这是我的领域知识，我还指望用这个吃饭呢。但是AI时代，人人皆为行业专家。好像曾经的护城河不复存在。SaaS公司也是如此。过去两个月，美股SaaS板块遭到了屠版式的抛盘。最后形成的行业经验是：\n\nAnthropic 瞪谁谁怀孕。\n\n出了个CVE漏洞检测工具，网安股崩盘。出了个COBOL编写能力，IBM一天能干崩13个点。出了个股'
MISS  div.QuestionDetail
MISS  .QuestionHeader-detail
---
og:description: '在人工智能技术高速发展的今天，模型即服务（MaaS）作为连接AI模型与用户的关键环节，本应借助技术的优势…'


Good findings:
1. `div.QuestionRichText` works for the MaaS question - but the text is truncated with "…显示全部" (show more)
2. `meta[property="og:description"]` also works but is even more truncated
3. The first selector `div.QuestionRichText` gets the most content

But the content has "…显示全部" which means the full text is hidden behind a "show more" button. For most questions, the truncated text should be enough context.

Let me also try clicking the "显示全部" button to get full description:Actually, for the purpose of providing context to the LLM, even a truncated description is better than nothing.

Now I need to refine the selectors to handle the truncation properly and check whether the OpenClaw question has any description available.


`div.QuestionRichText` 有效但会被截断（"…显示全部"）。`og:description` 也能用。让我试试点开"显示全部"以及检查 OpenClaw 问题是否有描述。


source /root/projects/butler/venv/bin/activate && python3 -c "
from playwright.sync_api import sync_playwright
from playwright_stealth import Stealth

# Test both questions
for url, label in [
    ('https://www.zhihu.com/question/2016845353517806681', 'MaaS'),
    ('https://www.zhihu.com/question/2015396999915139338', 'OpenClaw'),
]:
    print(f'=== {label} ===')
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        context = browser.new_context(user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/122.0.0.0 Safari/537.36')
        page = context.new_page()
        Stealth().apply_stealth_sync(page)
        page.goto(url, wait_until='domcontentloaded', timeout=15000)
        page.wait_for_timeout(2000)

        # Try clicking 'show more' button
        try:
            show_more = page.query_selector('button:has-text(\"显示全部\")')
            if show_more:
                show_more.click()
                page.wait_for_timeout(500)
                print('Clicked 显示全部')
        except:
            pass

        # Get description
        desc_el = page.query_selector('div.QuestionRichText')
        if desc_el:
            text = desc_el.inner_text().strip()
            text = text.replace('显示全部', '').replace('\\u200b', '').strip()
            print(f'QuestionRichText ({len(text)} chars): {text[:400]!r}')
        else:
            print('No QuestionRichText found')

        browser.close()
    print()
" 2>&1

Test description fetch with show-more click


=== MaaS ===
QuestionRichText (76 chars): '在人工智能技术高速发展的今天，模型即服务（MaaS）作为连接AI模型与用户的关键环节，本应借助技术的优势为用户提供便捷的AI服务，从而获取丰厚的收益。…'

=== OpenClaw ===
No QuestionRichText found


OK so:
1. MaaS question has a description but it's truncated (76 chars visible, full text hidden behind "显示全部" button which didn't expand, likely needs login)
2. OpenClaw question has NO description at all

So for the OpenClaw question, there's no extra context to fetch. The description being empty is actually fine - many Zhihu questions only have a title.

For the MaaS question, even the truncated description gives some useful context. The "显示全部" (show more) button didn't expand - this could be because:
1. It requires login
2. The JS didn't fire properly in headless mode

Now I'm refining the `_fetch_question_info` method to prioritize the rich text description first, then clean up the truncation markers and zero-width characters, and fall back to the og:description meta tag if needed. I should also strip the "…" to keep the text clean for the LLM.


OK，OpenClaw 问题没有描述正文（只有标题），MaaS 有但被截断。让我更新选择器逻辑，优先用 `QuestionRichText`，fallback 到 `og:description`：


Edit

                # Extract question description
                description = None
                for selector in [
                    "div.QuestionRichText",
                    "div.QuestionDetail-main .RichText",
                    "meta[name='description']",
                ]:
                    desc_el = page.query_selector(selector)
                    if desc_el:
                        if selector.startswith("meta"):
                            description = desc_el.get_attribute("content") or ""
                        else:
                            description = desc_el.inner_text().strip()
                        if description:
                            description = description[:2000]
                            break

                # Extract question description
                description = None
                desc_el = page.query_selector("div.QuestionRichText")
                if desc_el:
                    description = desc_el.inner_text().strip()
                    # Clean up truncation artifacts and zero-width chars
                    description = description.replace("\u200b", "").strip()
                    description = re.sub(r'…?\s*显示全部\s*$', '', description).strip()
                if not description:
                    og_el = page.query_selector('meta[property="og:description"]')
                    if og_el:
                        description = (og_el.get_attribute("content") or "").strip()
                        description = re.sub(r'…$', '', description).strip()
                if description:
                    description = description[:2000]
                else:
                    description = None


The file /root/projects/butler/slack_bot/zhihu/zhihu_hunter.py has been updated successfully.

                # Extract question description
                description = None
                desc_el = page.query_selector("div.QuestionRichText")
                if desc_el:
                    description = desc_el.inner_text().strip()
                    # Clean up truncation artifacts and zero-width chars
                    description = description.replace("\u200b", "").strip()
                    description = re.sub(r'…?\s*显示全部\s*$', '', description).strip()
                if not description:
                    og_el = page.query_selector('meta[property="og:description"]')
                    if og_el:
                        description = (og_el.get_attribute("content") or "").strip()
                        description = re.sub(r'…$', '', description).strip()
                if description:
                    description = description[:2000]
                else:
                    description = None

"""Zhihu Hunter — vault scanning, question discovery, and answer drafting.

Workflow:
    1. scan_and_hunt()  →  scans recent vault files, extracts keywords,
                           searches Tavily for matching Zhihu questions.
    2. draft_answer()   →  uses existing RAG + ZhihuGenerator to produce
                           a Zhihu-style answer draft ready for review.
"""

import os
import re
import requests
from datetime import datetime, timezone, timedelta
from pathlib import Path
from typing import List, Optional

from pydantic import BaseModel, Field

from health.utils.logging_config import setup_logger
from slack_bot.llm.gemini import GeminiLLM
from slack_bot.obsidian.generators import ZhihuGenerator
from slack_bot.obsidian.indexer import ObsidianIndexer
from slack_bot.obsidian.vector_store import ChromaVectorStore

logger = setup_logger(__name__)

# ── Vault directories to watch for new/updated content ────────────────────────
# Paths are relative to vault_path root.
WATCH_DIRS: List[str] = [
    "notes",
    "Article",
    "auto_report/deep-dive",
    "CLAW/deepfact",
    "Clippings",
]

# Article is always included regardless of time; Revised files are prioritized.
_ARTICLE_DIR = "Article"

# Short names users can type to target a specific directory.
DIR_SHORTCUTS: dict[str, str] = {
    "notes":      "notes",
    "article":    "Article",
    "clippings":  "Clippings",
    "claw":       "CLAW/deepfact",
    "deepfact":   "CLAW/deepfact",
    "deep-dive":  "auto_report/deep-dive",
}

_MAX_FILES_PER_DIR   = 4    # most-recent files per directory (keeps dir diversity)
_MAX_FILES_ARTICLE   = 8    # Article gets more slots (higher priority)
_MAX_KEYWORDS        = 10   # keywords extracted from vault scan
_MAX_QUESTIONS_TOTAL = 12   # total Zhihu questions surfaced per hunt
# Must contain /question/<id>; answer/comment subpages are excluded in _find_questions
_MIN_QUESTION_URL_RE = re.compile(r'zhihu\.com/question/\d+')

# ── Data Models ────────────────────────────────────────────────────────────────

class ZhihuQuestion(BaseModel):
    """A Zhihu question candidate surfaced by the hunter."""

    title: str
    url: str
    snippet: Optional[str] = None
    keywords: List[str] = Field(default_factory=list)
    found_at: str = Field(
        default_factory=lambda: datetime.now(tz=timezone.utc).isoformat()
    )
    description: Optional[str] = None   # question body/detail text from Zhihu page
    source_file: Optional[str] = None  # vault-relative path, set by scan_single_file()
    outline: Optional[str] = None       # user-provided answer logic/structure (direct URL mode)

class AnswerDraft(BaseModel):
    """A generated answer draft awaiting human review."""

    question: ZhihuQuestion
    content: str
    generated_at: str = Field(
        default_factory=lambda: datetime.now(tz=timezone.utc).isoformat()
    )
    rag_sources: List[str] = Field(default_factory=list)
    vault_file: Optional[str] = None  # absolute path set after saving to vault

# ── Main Class ─────────────────────────────────────────────────────────────────

class ZhihuHunter:
    """Discovers Zhihu questions from vault content and drafts answers.

    Example:
        hunter = ZhihuHunter(vault_path, vector_store, indexer)
        questions = hunter.scan_and_hunt(since_days=3)
        draft = hunter.draft_answer(questions[0])
    """

    def __init__(
        self,
        vault_path: Path,
        vector_store: ChromaVectorStore,
        indexer: ObsidianIndexer,
    ) -> None:
        """Initialize the hunter.

        Args:
            vault_path: Root path of the Obsidian vault.
            vector_store: ChromaDB vector store for semantic retrieval.
            indexer: ObsidianIndexer for keyword-based search and writing samples.
        """
        self.vault_path = vault_path
        self.vector_store = vector_store
        self.indexer = indexer
        self.llm = GeminiLLM()

    # ── Public API ─────────────────────────────────────────────────────────────

    def scan_and_hunt(
        self,
        since_days: int = 7,
        dirs: Optional[List[str]] = None,
        topic_hint: str = "",
    ) -> List[ZhihuQuestion]:
        """Scan vault files and surface matching Zhihu questions.

        Args:
            since_days: Age threshold for non-Article directories.
            dirs: Restrict scan to these vault dirs (relative paths). None = all WATCH_DIRS.
            topic_hint: Optional focus hint injected into keyword extraction prompt.

        Returns:
            List of ZhihuQuestion candidates (up to _MAX_QUESTIONS_TOTAL).
        """
        scan_desc = f"dirs={dirs or 'all'}" + (f", topic='{topic_hint}'" if topic_hint else "")
        logger.info(f"ZhihuHunter: scanning vault ({scan_desc})")

        keywords = self._scan_vault_for_keywords(since_days, dirs=dirs, topic_hint=topic_hint)
        if not keywords:
            logger.info("No keywords extracted from vault files")
            return []

        logger.info(f"Extracted keywords: {keywords}")
        questions = self._find_questions(keywords)
        logger.info(f"Found {len(questions)} Zhihu questions")
        return questions

    def scan_single_file(self, rel_path: str) -> List[ZhihuQuestion]:
        """Extract keywords from one vault file and find matching Zhihu questions.

        The returned questions carry ``source_file`` set to ``rel_path`` so that
        ``draft_answer()`` will ground the generated answer in that article.

        Args:
            rel_path: Path relative to vault_path (e.g. "Article/8-xxx-Revised.md").

        Returns:
            List of ZhihuQuestion candidates with source_file populated.

        Raises:
            FileNotFoundError: If the file does not exist under the vault root.
        """
        file_path = self.vault_path / rel_path
        if not file_path.exists():
            raise FileNotFoundError(f"文件不存在：{file_path}")

        text = file_path.read_text(encoding="utf-8", errors="ignore")
        text = re.sub(r'^---\n.*?\n---\n', '', text, flags=re.DOTALL).strip()
        logger.info(f"scan_single_file: {rel_path} ({len(text)} chars)")

        keywords = self._extract_keywords_with_llm(text[:5000])
        if not keywords:
            logger.info("No keywords extracted from single file")
            return []

        logger.info(f"Keywords from file: {keywords}")
        questions = self._find_questions(keywords)

        for q in questions:
            q.source_file = rel_path

        return questions

    def scan_glob_files(self, pattern: str) -> List[ZhihuQuestion]:
        """Find vault files matching a glob pattern and hunt Zhihu questions.

        All matched files are combined for keyword extraction, so the questions
        reflect the full set of content.  The most-recently-modified matched file
        is stored as ``source_file`` to ground the answer draft.

        Args:
            pattern: Glob pattern relative to vault_path
                     (e.g. "Article/*openclaw*-Revised.md").

        Returns:
            List of ZhihuQuestion candidates with source_file set.

        Raises:
            FileNotFoundError: If no files match the pattern.
        """
        matched = sorted(
            self.vault_path.glob(pattern),
            key=lambda p: p.stat().st_mtime,
            reverse=True,
        )
        if not matched:
            raise FileNotFoundError(f"没有文件匹配：{pattern}")

        names = [p.name for p in matched]
        logger.info(f"scan_glob_files: '{pattern}' → {len(matched)} files: {names}")

        # Combine content from all matched files (per-file budget so no one file
        # dominates) and extract keywords from the aggregate.
        parts: List[str] = []
        per_file_budget = 1500 if len(matched) == 1 else 800
        for fp in matched:
            try:
                text = fp.read_text(encoding="utf-8", errors="ignore")
                text = re.sub(r'^---\n.*?\n---\n', '', text, flags=re.DOTALL).strip()
                parts.append(f"[{fp.name}]\n{text[:per_file_budget]}")
            except OSError as e:
                logger.debug(f"Could not read {fp}: {e}")

        combined = "\n\n---\n\n".join(parts)
        keywords = self._extract_keywords_with_llm(combined[:8000])
        if not keywords:
            logger.info("No keywords extracted from glob-matched files")
            return []

        logger.info(f"Keywords from glob: {keywords}")
        questions = self._find_questions(keywords)

        # Primary source = most-recently-modified matched file
        primary = str(matched[0].relative_to(self.vault_path))
        for q in questions:
            q.source_file = primary

        return questions

    def hunt_direct_url(self, url: str) -> List[ZhihuQuestion]:
        """Create a question card directly from a Zhihu question URL.

        Used when the user pastes a URL they were invited to answer.
        Fetches the page title and description so the card displays readable info.

        Args:
            url: A zhihu.com/question/<id> URL.

        Returns:
            Single-element list containing the ZhihuQuestion.
        """
        clean_url = re.sub(r'[>\'")\].,;]+$', '', url).lstrip("<").split("?")[0].split("#")[0].rstrip("/")
        title, description = self._fetch_question_info(clean_url)
        logger.info(f"Direct URL hunt: {clean_url!r} → title={title!r}, desc={len(description or '')} chars")
        return [ZhihuQuestion(title=title, url=clean_url, description=description)]

    def _fetch_question_info(self, url: str) -> tuple[str, Optional[str]]:
        """Fetch the question title and description via Playwright.

        Falls back to a URL-derived label if Playwright is unavailable or fails.

        Args:
            url: Cleaned question URL.

        Returns:
            Tuple of (title, description). Description may be None.
        """
        state_file = Path(__file__).resolve().parents[3] / "data" / "zhihu_state.json"
        try:
            from playwright.sync_api import sync_playwright
            from playwright_stealth import Stealth
            import json as _json

            with sync_playwright() as p:
                browser = p.chromium.launch(headless=True)
                context = browser.new_context(
                    user_agent=(
                        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                        "AppleWebKit/537.36 (KHTML, like Gecko) "
                        "Chrome/122.0.0.0 Safari/537.36"
                    )
                )
                # Load saved cookies if available
                if state_file.exists():
                    data = _json.loads(state_file.read_text(encoding="utf-8"))
                    cookies = data.get("cookies", [])
                    if cookies:
                        context.add_cookies(cookies)

                page = context.new_page()
                Stealth().apply_stealth_sync(page)
                page.goto(url, wait_until="domcontentloaded", timeout=15000)
                page.wait_for_timeout(2000)

                # Extract title
                title = ""
                title_el = page.query_selector("h1.QuestionHeader-title")
                if title_el:
                    title = title_el.inner_text().strip()
                if not title:
                    title = page.title() or ""
                    title = title.split(" - 知乎")[0].strip()
                # Strip "(XX 条消息)" notification prefix from tab title
                title = re.sub(r'^\(\d+ 条消息\)\s*', '', title)

                # Extract question description
                description = None
                for selector in [
                    "div.QuestionRichText",
                    "div.QuestionDetail-main .RichText",
                    "meta[name='description']",
                ]:
                    desc_el = page.query_selector(selector)
                    if desc_el:
                        if selector.startswith("meta"):
                            description = desc_el.get_attribute("content") or ""
                        else:
                            description = desc_el.inner_text().strip()
                        if description:
                            description = description[:2000]
                            break

                browser.close()

                if title and title != "知乎":
                    return title, description
        except Exception as e:
            logger.debug(f"Playwright question fetch failed for {url}: {e}")

        # Fallback: use the question ID
        qid = url.rstrip("/").split("/")[-1]
        return f"知乎问题 #{qid}", None

    def _fetch_recent_web_context(self, query: str) -> str:
        """Search the web for recent articles/news about the query topic.

        Results are injected into the answer prompt so it reads like it
        comes from someone who's actively following the latest developments.

        Args:
            query: Topic or question title to search.

        Returns:
            Formatted string of recent snippets, or empty string on failure.
        """
        api_key = os.environ.get("TAVILY_API_KEY")
        if not api_key:
            return ""
        try:
            from tavily import TavilyClient
            client = TavilyClient(api_key=api_key)
            results = client.search(
                query=query,
                search_depth="basic",
                max_results=4,
                days=90,
                exclude_domains=["zhihu.com"],
            )
            items = results.get("results", [])
            if not items:
                return ""
            parts = [
                f"- {r['title']} ({r['url'].split('/')[2]}): {r.get('content', '')[:250]}"
                for r in items
            ]
            return "\n".join(parts)
        except Exception as e:
            logger.debug(f"Recent web context fetch failed: {e}")
            return ""

    def draft_answer(self, question: ZhihuQuestion, feedback: str = "") -> AnswerDraft:
        """Generate a Zhihu-style answer draft for a question.

        Delegates to the existing ZhihuGenerator (reuses prompt + style guide).
        The RAG context is enriched by both semantic (ChromaDB) and
        keyword-based (ObsidianIndexer) retrieval.

        Args:
            question: ZhihuQuestion to answer.
            feedback: Optional revision instructions from the user (e.g. "更口语化一些").

        Returns:
            AnswerDraft with generated content and source list.
        """
        logger.info(f"Drafting answer for: {question.title}" + (f" [feedback: {feedback[:40]}]" if feedback else ""))

        # Collect RAG sources for transparency
        rag_sources: List[str] = []

        semantic_results = self.vector_store.search_knowledge(question.title, top_k=3)
        for r in semantic_results:
            rag_sources.append(Path(r.source_path).name)

        keyword_results = self.indexer.search(question.title, limit=5)
        for note in keyword_results:
            first_line = note.split("\n")[0].strip("# ").strip()
            if first_line:
                rag_sources.append(first_line)

        # If the question was pinned to a specific source file, load it as the
        # primary article so the answer stays grounded in its viewpoints.
        primary_article = ""
        if question.source_file:
            fp = self.vault_path / question.source_file
            try:
                raw = fp.read_text(encoding="utf-8", errors="ignore")
                raw = re.sub(r'^---\n.*?\n---\n', '', raw, flags=re.DOTALL).strip()
                primary_article = raw[:4000]
                rag_sources.insert(0, Path(question.source_file).name)
                logger.info(f"Loaded primary article: {question.source_file} ({len(primary_article)} chars)")
            except OSError as e:
                logger.warning(f"Could not read source file {question.source_file}: {e}")

        # Delegate to existing ZhihuGenerator for consistent style
        generator = ZhihuGenerator(self.indexer, self.vector_store)
        prompt_input = question.title
        if question.description:
            prompt_input += f"\n\n问题详细描述：{question.description}"
        if question.snippet:
            prompt_input += f" | 背景：{question.snippet}"

        # User-provided outline takes highest priority — structure the answer strictly
        # around it, pulling matching content from the knowledge base for each point.
        if question.outline:
            prompt_input += (
                f"\n\n用户提供的回答思路（必须严格按照这个逻辑框架展开，"
                f"从知识库中找到对应的内容来支撑每个论点，不要自由发挥框架结构）：\n{question.outline}"
            )
            logger.info(f"Injected user outline: {question.outline[:60]!r}")

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

        # Inject recent web context so the answer feels up-to-date
        recent_context = self._fetch_recent_web_context(question.title)
        if recent_context:
            prompt_input += (
                f"\n\n近期相关资讯（最近90天内的动态，用来补充最新工具/发布/讨论，"
                f"让回答更有时效感，自然地融入即可，不要刻意罗列）：\n{recent_context}"
            )
            logger.info(f"Injected recent web context ({len(recent_context)} chars)")

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

        content, _ = generator.chat(prompt_input, history=[])

        return AnswerDraft(
            question=question,
            content=content,
            rag_sources=list(dict.fromkeys(rag_sources)),  # deduplicate, keep order
        )

    # ── Vault Scanning ─────────────────────────────────────────────────────────

    def _scan_vault_for_keywords(
        self,
        since_days: int,
        dirs: Optional[List[str]] = None,
        topic_hint: str = "",
    ) -> List[str]:
        """Collect vault files and extract topic keywords via LLM.

        Args:
            since_days: Age threshold for non-Article directories.
            dirs: Dirs to scan; None = all WATCH_DIRS.
            topic_hint: Optional focus instruction for keyword extraction.

        Returns:
            Deduplicated list of topic keywords (up to _MAX_KEYWORDS).
        """
        cutoff = datetime.now(tz=timezone.utc) - timedelta(days=since_days)
        files = self._collect_recent_files(cutoff, dirs=dirs)

        if not files:
            return []

        # Article-only scans get a larger per-file budget (higher priority content)
        scan_dirs = dirs or WATCH_DIRS
        per_file_budget = 800 if scan_dirs == [_ARTICLE_DIR] else 500
        combined = self._read_files_summary(files, max_chars_per_file=per_file_budget)
        return self._extract_keywords_with_llm(combined, topic_hint=topic_hint)

    def _collect_recent_files(
        self,
        cutoff: datetime,
        dirs: Optional[List[str]] = None,
    ) -> List[Path]:
        """Walk vault directories and return candidate files.

        Article directory:
          - No time filter (always included regardless of cutoff)
          - Revised files sorted first, then by mtime descending
          - Up to _MAX_FILES_ARTICLE slots
        Other directories:
          - Only files modified after cutoff
          - Sorted by mtime descending
          - Up to _MAX_FILES_PER_DIR slots

        Args:
            cutoff: Freshness threshold for non-Article directories.
            dirs: Directories to scan (relative paths). None = all WATCH_DIRS.

        Returns:
            List of Path objects.
        """
        scan_dirs = dirs if dirs is not None else WATCH_DIRS
        collected: List[Path] = []

        for rel_dir in scan_dirs:
            watch_dir = self.vault_path / rel_dir
            if not watch_dir.is_dir():
                logger.debug(f"Watch dir not found, skipping: {watch_dir}")
                continue

            # (mtime, is_revised, path)
            candidates: List[tuple[float, bool, Path]] = []
            for md_file in watch_dir.rglob("*.md"):
                try:
                    mtime = md_file.stat().st_mtime
                    if rel_dir != _ARTICLE_DIR:
                        file_dt = datetime.fromtimestamp(mtime, tz=timezone.utc)
                        if file_dt < cutoff:
                            continue
                    is_revised = md_file.name.lower().endswith("-revised.md")
                    candidates.append((mtime, is_revised, md_file))
                except OSError:
                    continue

            if rel_dir == _ARTICLE_DIR:
                # Revised files first, then newest; give double slots
                candidates.sort(key=lambda t: (0 if t[1] else 1, -t[0]))
                max_files = _MAX_FILES_ARTICLE
            else:
                candidates.sort(key=lambda t: -t[0])
                max_files = _MAX_FILES_PER_DIR

            collected.extend(p for _, _, p in candidates[:max_files])

        logger.debug(f"Collected {len(collected)} files from vault (dirs={scan_dirs})")
        return collected

    def _read_files_summary(
        self,
        files: List[Path],
        max_chars: int = 8000,
        max_chars_per_file: int = 500,
    ) -> str:
        """Read and concatenate file contents up to a character budget.

        Each file is capped at max_chars_per_file so that no single large file
        exhausts the total budget and crowds out other directories.

        Args:
            files: List of markdown file paths.
            max_chars: Total character budget across all files.
            max_chars_per_file: Per-file cap to ensure directory diversity.

        Returns:
            Concatenated content string with file-name headers.
        """
        parts: List[str] = []
        remaining = max_chars

        for f in files:
            if remaining <= 0:
                break
            try:
                text = f.read_text(encoding="utf-8", errors="ignore")
                # Strip YAML frontmatter
                text = re.sub(r'^---\n.*?\n---\n', '', text, flags=re.DOTALL).strip()
                chunk = text[:min(max_chars_per_file, remaining)]
                parts.append(f"[{f.name}]\n{chunk}")
                remaining -= len(chunk)
            except OSError as e:
                logger.debug(f"Could not read {f}: {e}")

        return "\n\n---\n\n".join(parts)

    def _extract_keywords_with_llm(
        self,
        combined_text: str,
        topic_hint: str = "",
    ) -> List[str]:
        """Use LLM to distill topic keywords from aggregated vault content.

        Args:
            combined_text: Concatenated recent vault content.
            topic_hint: Optional focus instruction (e.g. "agent开发").

        Returns:
            List of keyword strings (up to _MAX_KEYWORDS).
        """
        topic_instruction = (
            f"\n- 重点关注与「{topic_hint}」相关的话题，优先提取这个方向的关键词"
            if topic_hint else ""
        )
        prompt = f"""你是一位知乎内容运营专家。请从以下笔记内容中提炼出适合在知乎搜索问题的关键词。

要求：
- 输出 {_MAX_KEYWORDS} 个以内的关键词，每行一个，无序号无符号
- 关键词要在知乎上有真实讨论热度，即普通中国用户会关心并提问的话题领域
- 避免过于偏门的专有名词（如具体项目代号、小众产品名），要用它所属的上一级概念
  例：「OpenClaw」→「AI智能体安全」；「BadJs」→「微信监控机制」；「Moon Monitor」→「健康监测设备」
- 不要太泛（如"AI""技术""健康"），也不要太窄（如只有一个工具代码名称）
- 优先选择最近讨论最多、最有深度的话题{topic_instruction}
- 只输出关键词列表，不要解释

=== 笔记内容 ===
{combined_text[:5000]}
"""
        try:
            response, _ = self.llm.generate_response(prompt, [])
            keywords = [
                line.strip()
                for line in response.strip().splitlines()
                if line.strip() and not line.strip().startswith("#")
            ]
            return keywords[:_MAX_KEYWORDS]
        except Exception as e:
            logger.error(f"Keyword extraction failed: {e}")
            return []

    # ── Question Discovery ─────────────────────────────────────────────────────

    def _find_questions(self, keywords: List[str]) -> List[ZhihuQuestion]:
        """Search Tavily for Zhihu questions matching each keyword.

        Collects candidates from ALL keywords before truncating so that later
        keywords (which can cover different facets) are not silently dropped.

        Args:
            keywords: List of topic keywords to search.

        Returns:
            Deduplicated ZhihuQuestion list (up to _MAX_QUESTIONS_TOTAL).
        """
        from tavily import TavilyClient

        api_key = os.environ.get("TAVILY_API_KEY")
        if not api_key:
            raise RuntimeError("TAVILY_API_KEY not set")

        client = TavilyClient(api_key=api_key)
        seen_urls: set[str] = set()
        questions: List[ZhihuQuestion] = []

        for kw in keywords:
            try:
                results = self._tavily_search(client, kw)
                for item in results:
                    url = item.get("url", "")
                    if not _MIN_QUESTION_URL_RE.search(url):
                        continue  # skip non-question URLs (e.g. zhihu.com/people/...)
                    # Normalize: extract bare URL to strip invisible chars, then remove params
                    _m = re.search(r'https?://\S+', url)
                    if not _m:
                        continue
                    clean_url = re.sub(r'[>\'")\].,;]+$', '', _m.group(0)).split("?")[0].split("#")[0].rstrip("/")
                    # Skip answer/comment subpages — we want the question itself
                    if re.search(r'/answer/|/comment/', clean_url):
                        continue
                    if clean_url in seen_urls:
                        continue
                    seen_urls.add(clean_url)
                    questions.append(
                        ZhihuQuestion(
                            title=item.get("title", kw),
                            url=clean_url,
                            snippet=item.get("content", "")[:200] or None,
                            keywords=[kw],
                        )
                    )
            except Exception as e:
                logger.warning(f"Tavily search failed for keyword '{kw}': {e}")

        logger.info(f"Found {len(questions)} Zhihu questions (capped at {_MAX_QUESTIONS_TOTAL})")
        return questions[:_MAX_QUESTIONS_TOTAL]

    def _tavily_search(self, client, keyword: str) -> List[dict]:
        """Run a single Tavily search scoped to zhihu.com/question.

        Args:
            client: Authenticated TavilyClient instance.
            keyword: Search keyword.

        Returns:
            List of result dicts from Tavily (url, title, content, score).
        """
        query = f"site:zhihu.com/question {keyword}"
        logger.debug(f"Tavily search: {query}")
        response = client.search(
            query=query,
            search_depth="basic",
            max_results=8,
            include_domains=["zhihu.com"],
            days=365,   # prefer questions with recent activity (higher chance of top rank)
        )
        return response.get("results", [])

-                    "div.QuestionRichText",

-                    "div.QuestionDetail-main .RichText",

-                    "meta[name='description']",

-                    desc_el = page.query_selector(selector)

-                        if selector.startswith("meta"):

-                            description = desc_el.get_attribute("content") or ""

-                            description = desc_el.inner_text().strip()

-                        if description:

-                            description = description[:2000]

+                desc_el = page.query_selector("div.QuestionRichText")

+                    description = desc_el.inner_text().strip()

+                    # Clean up truncation artifacts and zero-width chars

+                    description = description.replace("\u200b", "").strip()

+                    description = re.sub(r'…?\s*显示全部\s*$', '', description).strip()

+                    og_el = page.query_selector('meta[property="og:description"]')

+                        description = (og_el.get_attribute("content") or "").strip()

+                        description = re.sub(r'…$', '', description).strip()

+                    description = description[:2000]


最后验证完整导入和抓取：


Bash

source /root/projects/butler/venv/bin/activate && python3 -c "
import os, sys
os.chdir('/root/projects/butler')
sys.path.insert(0, '.')

# Test with both questions
from slack_bot.zhihu.zhihu_hunter import ZhihuHunter
from slack_bot.obsidian.indexer import ObsidianIndexer
from slack_bot.obsidian.embeddings import get_embedding_provider
from slack_bot.obsidian.vector_store import ChromaVectorStore
from pathlib import Path

vault = os.environ.get('OBSIDIAN_VAULT_PATH', '/root/vault/obsidian_vault/obsidian/obsidian/')
indexer = ObsidianIndexer(vault)
indexer.scan_vault()
vs = ChromaVectorStore(embedding_provider=get_embedding_provider())
hunter = ZhihuHunter(vault_path=Path(vault), vector_store=vs, indexer=indexer)

for url, label in [
    ('https://www.zhihu.com/question/2016845353517806681', 'MaaS'),
    ('https://www.zhihu.com/question/2015396999915139338', 'OpenClaw'),
]:
    qs = hunter.hunt_direct_url(url)
    q = qs[0]
    print(f'=== {label} ===')
    print(f'  Title: {q.title!r}')
    print(f'  Desc:  {q.description!r}')
    print()
" 2>&1

Full integration test of question info fetch


2026-03-17 14:48:53 - slack_bot.obsidian.indexer - INFO - Scanning Obsidian Vault at: /root/vault/obsidian_vault/obsidian/obsidian/
2026-03-17 14:48:53 - slack_bot.obsidian.indexer - INFO - Scan complete. Scanned 528 files.
2026-03-17 14:48:53 - slack_bot.obsidian.indexer - INFO - Found 33 writing samples.
2026-03-17 14:48:53 - slack_bot.obsidian.indexer - INFO - Found 2 reply samples.
2026-03-17 14:48:53 - slack_bot.obsidian.embeddings - INFO - Using cloud (Qwen) embedding provider
2026-03-17 14:48:54 - slack_bot.obsidian.vector_store - INFO - ChromaVectorStore initialized at /root/projects/butler/data/health/vector_db (collection: obsidian_notes)
2026-03-17 14:48:54 - slack_bot.llm.gemini - INFO - Using OpenRouter: https://openrouter.ai/api
2026-03-17 14:48:54 - slack_bot.llm.gemini - INFO - Initialized Gemini model: google/gemini-3-flash-preview
2026-03-17 14:48:59 - slack_bot.zhihu.zhihu_hunter - INFO - Direct URL hunt: 'https://www.zhihu.com/question/2016845353517806681' → title='什么是云计算平台 MaaS？云大厂的护城河究竟是模型能力，还是其生态的高粘性？', desc=75 chars
=== MaaS ===
  Title: '什么是云计算平台 MaaS？云大厂的护城河究竟是模型能力，还是其生态的高粘性？'
  Desc:  '在人工智能技术高速发展的今天，模型即服务（MaaS）作为连接AI模型与用户的关键环节，本应借助技术的优势为用户提供便捷的AI服务，从而获取丰厚的收益。'

2026-03-17 14:49:06 - slack_bot.zhihu.zhihu_hunter - INFO - Direct URL hunt: 'https://www.zhihu.com/question/2015396999915139338' → title='你会使用OpenClaw吗，风险大于获得？', desc=217 chars
=== OpenClaw ===
  Title: '你会使用OpenClaw吗，风险大于获得？'
  Desc:  '知乎，中文互联网高质量的问答社区和创作者聚集的原创内容平台，于 2011 年 1 月正式上线，以「让人们更好的分享知识、经验和见解，找到自己的解答」为品牌使命。知乎凭借认真、专业、友善的社区氛围、独特的产品机制以及结构化和易获得的优质内容，聚集了中文互联网科技、商业、影视、时尚、文化等领域最具创造力的人群，已成为综合性、全品类、在诸多领域具有关键影响力的知识分享社区和创作者聚集的原创内容平台，建立起了以社区驱动的内容变现商业模式。'


The OpenClaw question's `og:description` returns the generic Zhihu site description (not question-specific). I need to filter this out. The MaaS question returns the actual question description properly.

For the OpenClaw case, the `div.QuestionRichText` is not found (the question has no description body), and `og:description` returns the generic Zhihu boilerplate. I should detect and filter this boilerplate text.

Let me add a check: if the description contains the Zhihu site boilerplate ("知乎，中文互联网高质量的问答社区"), set it to None.


OpenClaw 的 `og:description` 返回了知乎站点通用描述，需要过滤掉。


Edit

                if not description:
                    og_el = page.query_selector('meta[property="og:description"]')
                    if og_el:
                        description = (og_el.get_attribute("content") or "").strip()
                        description = re.sub(r'…$', '', description).strip()

                if not description:
                    og_el = page.query_selector('meta[property="og:description"]')
                    if og_el:
                        description = (og_el.get_attribute("content") or "").strip()
                        description = re.sub(r'…$', '', description).strip()
                # Filter out Zhihu generic site description (not question-specific)
                if description and "知乎，中文互联网" in description:
                    description = None