     1→import os
     2→from pathlib import Path
     3→from typing import List, Dict, Tuple, Optional, TYPE_CHECKING
     4→from slack_bot.llm.gemini import GeminiLLM
     5→from slack_bot.obsidian.indexer import ObsidianIndexer
     6→from slack_bot.tools.web import WebSearchTool
     7→from health.utils.logging_config import setup_logger
     8→from health.utils.time_utils import get_current_time_str
     9→
    10→if TYPE_CHECKING:
    11→    from slack_bot.obsidian.vector_store import ChromaVectorStore
    12→
    13→logger = setup_logger(__name__)
    14→
    15→
    16→OBSIDIAN_SYSTEM_PROMPT = """You are Butler (Obsidian Edition), an intelligent knowledge assistant connected to the user's second brain.
    17→Current Time: {current_time}
    18→
    19→Your Role:
    20→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.
    21→
    22→Core Capabilities:
    23→1. Writing: You mimic the user's unique writing style (as defined in `writing_style.md`).
    24→2. Communication: You draft high-EQ, logically rigorous replies for professional contexts (as defined in `REPLY-SAMPLE.md`).
    25→3. Decision: You act as a "Devil's Advocate" and strategic advisor using the user's decision frameworks (GPA, IPO).
    26→
    27→Guidelines:
    28→- **Style Alignment**: Strictly adhere to the tone and sentence structures found in the provided samples.
    29→- **Data Source**: Rely primarily on the provided context (RAG) and loaded markdown files.
    30→- **Identity**: You are pragmatic, rational, and value "technological optimism" and "intellectual honesty".
    31→"""
    32→
    33→class BaseGenerator:
    34→    def __init__(
    35→        self,
    36→        indexer: ObsidianIndexer,
    37→        vector_store: Optional["ChromaVectorStore"] = None,
    38→    ):
    39→        formatted_prompt = OBSIDIAN_SYSTEM_PROMPT.format(current_time=get_current_time_str())
    40→        self.llm = GeminiLLM(system_instruction=formatted_prompt)
    41→        self.indexer = indexer
    42→        self.vector_store = vector_store
    43→        self.workspace_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
    44→
    45→    def _read_file(self, filename: str) -> str:
    46→        """Reads a file from the workspace root."""
    47→        path = os.path.join(self.workspace_root, filename)
    48→        try:
    49→            with open(path, "r", encoding="utf-8") as f:
    50→                return f.read()
    51→        except Exception as e:
    52→            logger.error(f"Failed to read {filename}: {e}")
    53→            return ""
    54→
    55→    def _semantic_search(self, query: str, top_k: int = 3) -> str:
    56→        """Run semantic search against the vector store and format results.
    57→
    58→        Args:
    59→            query: Search query text.
    60→            top_k: Maximum number of chunks to retrieve.
    61→
    62→        Returns:
    63→            Formatted string block, or empty string if no results.
    64→        """
    65→        if self.vector_store is None:
    66→            return ""
    67→        try:
    68→            results = self.vector_store.search_knowledge(query, top_k=top_k)
    69→        except Exception as e:
    70→            logger.warning(f"_semantic_search failed: {e}")
    71→            return ""
    72→        if not results:
    73→            return ""
    74→        lines = ["=== SEMANTIC KNOWLEDGE FROM VAULT ==="]
    75→        for r in results:
    76→            lines.append(f"[{Path(r.source_path).name} / {r.header_hierarchy}]")
    77→            # Truncate individual chunks to avoid prompt bloat (~500 tokens ≈ 2000 chars)
    78→            chunk_text = r.text[:2000]
    79→            lines.append(chunk_text)
    80→            lines.append("")
    81→        return "\n".join(lines)
    82→
    83→class WritingAssistant(BaseGenerator):
    84→    def chat(self, user_input: str, history: List[Dict]) -> Tuple[str, List[Dict]]:
    85→
    86→        is_first_turn = (len(history) == 0)
    87→
    88→        if is_first_turn:
    89→            # === First Turn: Build Rich Prompt ===
    90→            # user_input is treated as "Topic"
    91→
    92→            # Naive splitting for context if formatted as "Topic | Context"
    93→            parts = user_input.split("|", 1)
    94→            topic = parts[0].strip()
    95→            extra_context = parts[1].strip() if len(parts) > 1 else ""
    96→
    97→            # 1. RAG & Samples
    98→            style_samples = self.indexer.get_writing_samples(count=3)
    99→            style_text = "\n\n".join(style_samples)
   100→
   101→            rag_notes = self.indexer.search(topic, limit=5)
   102→            rag_text = "\n\n".join(rag_notes)
   103→
   104→            writing_style_guide = self._read_file("writing_style.md")
   105→            methodology = self._read_file("methodology.md")
   106→            powell_revise_guide = self._read_file("POWELL_REVISE.md")
   107→
   108→            semantic_context = self._semantic_search(topic, top_k=3)
   109→
   110→            prompt = f"""You are a ghostwriter for the user. Your goal is to write an article on the TOPIC provided.
   111→
   112→            === YOUR IDENTITY & METHODOLOGY ===
   113→            {methodology}
   114→
   115→            === YOUR WRITING STYLE GUIDE ===
   116→            {writing_style_guide}
   117→
   118→            === ANTI-AI-TONE GUIDE (APPLY WHILE WRITING, NOT JUST AT THE END) ===
   119→            {powell_revise_guide}
   120→
   121→            === YOUR WRITING SAMPLES (MIMIC THIS TONE) ===
   122→            {style_text}
   123→
   124→            === RELEVANT NOTES FROM OBSIDIAN VAULT ===
   125→            {rag_text}
   126→
   127→            {semantic_context}
   128→
   129→            === TASK ===
   130→            Topic: {topic}
   131→            Extra Context: {extra_context}
   132→
   133→            Write the article in Chinese (unless the topic implies English).
   134→            Adhere strictly to the "Identity" and "Style Guide".
   135→            """
   136→
   137→            logger.info(f"Generating article for topic: {topic}")
   138→            actual_user_message = prompt # We inject the big prompt
   139→
   140→        else:
   141→            # === Follow-up Turn ===
   142→            # user_input is just instructions like "Make it shorter"
   143→            logger.info(f"Follow-up instruction: {user_input}")
   144→            actual_user_message = user_input
   145→            prompt = user_input
   146→
   147→        # Call LLM — first pass: content generation
   148→        draft, _ = self.llm.generate_response(prompt, history)
   149→
   150→        # === Second pass: de-AI review (first turn only) ===
   151→        if is_first_turn:
   152→            powell_revise_guide = self._read_file("POWELL_REVISE.md")
   153→            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.
   154→
   155→=== TONE TARGET (最高优先级) ===
   156→目标语感：像一个懂行的人在跟朋友聊天时随口说的。不端着，也不刻意耍帅。
   157→两种AI味都要杀：
   158→- 正式AI味：过渡词堆砌、排比对仗、空洞修饰、鸡汤结尾
   159→- 油腻AI味：刻意装随意（"折腾去吧"）、强行造金句（"搞坏了不心疼，跑通了就是资产"）、给普通概念起酷名字（"隔离舱"、"暴力碰撞"、"最干净的契约"）、用一句俏皮话收尾装潇洒
   160→正确的语感：平铺直叙，该说什么说什么，说完就停。不需要让读者觉得你很酷。
   161→
   162→=== PRIORITY 1: VOCABULARY KILL LIST (逐词扫描，命中即删或换大白话) ===
   163→夸张修饰: 极度脆弱、毁灭性的、灾难级的、压倒性的、远超想象、不止一个量级、颠覆性的、革命性的、折磨人、天翻地覆、风险极高
   164→大词空转: 范式转移、范式突变、开发范式、数字化容器、数字雇员、温和池架构、非确定性、阅后即焚、毫秒级就绪、毫秒级响应、赋能、底层逻辑、仪式性努力、深刻变革、暴力碰撞、最干净的契约、服务化的潜力、可扩展的执行信用、终极归宿、壁垒、分水岭、沉淀（用作动词修饰数据/经验时）、云端办公室
   165→鸡汤/口号: 正途、值得跨越、安置灵魂、掌控感、才是王道、苦活累活交给、未来已来、危机就是转机、从玩具变成生产力、把环境变成了资产、安全底线、这才是成熟的做法、才是X的关键、不是建议是必须
   166→油腻收尾: "X去吧"式潇洒收尾、"搞坏了不心疼，跑通了就是Y"式金句、任何试图用一句俏皮话总结全文的结尾、"把X关在Y里...把Y放在Z上"式排比收尾
   167→伪口语: 说白了、说实话、折腾这玩意儿
   168→
   169→=== PRIORITY 2: SENTENCE PATTERN KILL LIST (命中即改写) ===
   170→1. 防御性写法："很多人觉得X，实际上Y"、"有些人认为X，但其实Y"、"很多人没意识到"、"有人X，有人已经Y了" → 直接说判断，删掉"很多人/有人"铺垫
   171→2. 对比式定义："不是X，是Y"、"不是X而是Y"、"那不叫X那叫Y"、"别把X当Y" → 删除对比句式，直接陈述后半句即可
   172→3. 换皮夸张：用另一个夸张词替换夸张词不算修好。"灾难级"→"毁灭性"→"风险极高"都是换皮。正确做法：换成具体后果（如"可能误删整个home目录"）
   173→4. 概念包装：给普通事物起酷名字（"隔离舱"=容器、"暴力碰撞"=冲突、"数字雇员"=自动化脚本、"执行信用"=服务器资源、"云端办公室"=云服务器、"牢笼"=限制）→ 用最朴素的说法
   174→5. 油腻金句：短句对仗、押韵、刻意工整的收尾句 → 要么删掉，要么拆成普通陈述句
   175→6. 比喻堆砌：全篇最多保留1个比喻，多余的全部删除换成直说
   176→7. 鸡汤/口号结尾：删除所有励志或耍帅收尾，改为具体行动建议或直接停住
   177→8. 首尾呼应：禁止结尾重复开头观点
   178→9. 语气漂移：全文语气必须统一，不要中途切换
   179→10. 编号标题：如果是线性论述（论点1→论点2→论点3），删除"一、二、三"序号改为自然段落过渡；如果是并列对比不同方案（如方案A vs 方案B vs 方案C），可以保留编号
   180→11. 过渡词堆积：删除"首先...其次...最后..."
   181→12. 排比对仗：打破字数相近的并列句
   182→13. 关键词重复：同一个修辞性词汇（如"分水岭"、"命门"、"杀手锏"）全篇只能出现一次，重复出现时删除后续的或换成普通说法
   183→
   184→=== PRIORITY 3: FULL REVISION GUIDE ===
   185→{powell_revise_guide}
   186→
   187→=== DRAFT TO REVIEW ===
   188→{draft}
   189→
   190→=== OUTPUT ===
   191→Return ONLY the corrected text. No explanation, no meta-commentary.
   192→"""
   193→            logger.info("Running second-pass de-AI review on writing draft...")
   194→            response, _ = self.llm.generate_response(review_prompt, [])
   195→        else:
   196→            response = draft
   197→
   198→        # Update History
   199→        new_history = history.copy()
   200→        new_history.append({"role": "user", "content": actual_user_message})
   201→        new_history.append({"role": "assistant", "content": response})
   202→
   203→        return response, new_history
   204→
   205→class ReplyGenerator(BaseGenerator):
   206→    def chat(self, user_input: str, history: List[Dict]) -> Tuple[str, List[Dict]]:
   207→
   208→        is_first_turn = (len(history) == 0)
   209→
   210→        if is_first_turn:
   211→             # === First Turn ===
   212→            static_samples = self._read_file("REPLY-SAMPLE.md")
   213→            dynamic_samples = self.indexer.get_reply_samples(count=3)
   214→            dynamic_samples_text = "\n\n".join(dynamic_samples)
   215→            methodology = self._read_file("methodology.md")
   216→
   217→            semantic_context = self._semantic_search(user_input, top_k=3)
   218→
   219→            prompt = f"""You are an experienced Product/R&D Manager.
   220→            Your task is to draft a reply to a challenging situation.
   221→
   222→            === CORE PHILOSOPHY ===
   223→            {methodology}
   224→
   225→            === REPLY GUIDELINES ===
   226→            1. Logic: Be clear and structured (e.g., numbered lists).
   227→            2. Responsibility: Don't take unnecessary blame, but don't shirk core duties. Explain objective causes.
   228→            3. Emotion: Be empathetic but professional.
   229→            4. Conciseness: No fluff.
   230→
   231→            === REFERENCE SAMPLES (FEW-SHOT) ===
   232→            {static_samples}
   233→
   234→            {dynamic_samples_text}
   235→
   236→            {semantic_context}
   237→
   238→            === THE SITUATION ===
   239→            {user_input}
   240→
   241→            Draft a reply. If the context implies a specific role (PM, Dev Lead), assume that role.
   242→            """
   243→            logger.info(f"Generating reply for query: {user_input}")
   244→            actual_user_message = prompt
   245→
   246→        else:
   247→             # === Follow-up Turn ===
   248→            logger.info(f"Follow-up reply instruction: {user_input}")
   249→            actual_user_message = user_input
   250→            prompt = user_input
   251→
   252→        response, _ = self.llm.generate_response(prompt, history)
   253→
   254→        new_history = history.copy()
   255→        new_history.append({"role": "user", "content": actual_user_message})
   256→        new_history.append({"role": "assistant", "content": response})
   257→
   258→        return response, new_history
   259→
   260→class DecisionSupport(BaseGenerator):
   261→    def chat(self, user_input: str, history: List[Dict]) -> Tuple[str, List[Dict]]:
   262→
   263→        is_first_turn = (len(history) == 0)
   264→
   265→        if is_first_turn:
   266→            decision_guide = self._read_file("decision.md")
   267→            methodology = self._read_file("methodology.md")
   268→
   269→            rag_notes = self.indexer.search(user_input, limit=3)
   270→            rag_text = "\n\n".join(rag_notes)
   271→
   272→            prompt = f"""You are a Decision Support Assistant.
   273→            Your goal is to help the user think through a complex decision.
   274→
   275→            === DECISION FRAMEWORK ===
   276→            {decision_guide}
   277→
   278→            === CORE PHILOSOPHY ===
   279→            {methodology}
   280→
   281→            === RELEVANT CONTEXT FROM NOTES ===
   282→            {rag_text}
   283→
   284→            === THE DECISION / ISSUE ===
   285→            {user_input}
   286→
   287→            === TASK ===
   288→            1. Challenge the premise: Is this the right problem to solve?
   289→            2. Apply the Framework: Use GPA (Goal, Priority, Alternatives) and IPO (Information, People, Objective reasoning) models.
   290→            3. Pre-mortem: What is the worst that could happen?
   291→            4. Provide a recommendation or a set of questions to clarify.
   292→            """
   293→
   294→            logger.info(f"Analyzing decision: {user_input}")
   295→            actual_user_message = prompt
   296→        else:
   297→            logger.info(f"Follow-up decision instruction: {user_input}")
   298→            actual_user_message = user_input
   299→            prompt = user_input
   300→
   301→        response, _ = self.llm.generate_response(prompt, history)
   302→
   303→        new_history = history.copy()
   304→        new_history.append({"role": "user", "content": actual_user_message})
   305→        new_history.append({"role": "assistant", "content": response})
   306→
   307→        return response, new_history
   308→
   309→class SearchAnalyzer(BaseGenerator):
   310→    def chat(self, user_input: str, history: List[Dict]) -> Tuple[str, List[Dict]]:
   311→
   312→        # Tools definition for the LLM
   313→        tools = [
   314→            {
   315→                "type": "function",
   316→                "function": {
   317→                    "name": "search_web",
   318→                    "description": "Search the public web using DuckDuckGo. Use this when the internal notes are insufficient or when you need up-to-date external information.",
   319→                    "parameters": {
   320→                        "type": "object",
   321→                        "properties": {
   322→                            "query": {
   323→                                "type": "string",
   324→                                "description": "The search query"
   325→                            }
   326→                        },
   327→                        "required": ["query"]
   328→                    }
   329→                }
   330→            },
   331→            {
   332→                "type": "function",
   333→                "function": {
   334→                    "name": "list_recent_files",
   335→                    "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.",
   336→                    "parameters": {
   337→                        "type": "object",
   338→                        "properties": {
   339→                            "days": {
   340→                                "type": "integer",
   341→                                "description": "Number of days to check (default 5)",
   342→                                "default": 5
   343→                            }
   344→                        },
   345→                        "required": []
   346→                    }
   347→                }
   348→            }
   349→        ]
   350→
   351→        is_first_turn = (len(history) == 0)
   352→
   353→        # Current Turn Message
   354→        if is_first_turn:
   355→            rag_notes = self.indexer.search(user_input, limit=5)
   356→            rag_text = "\n\n".join(rag_notes)
   357→
   358→            logger.info(f"SearchAnalyzer retrieved {len(rag_notes)} local notes.")
   359→            for note in rag_notes:
   360→                # Extract source line for logging
   361→                first_line = note.split('\n')[0]
   362→                logger.debug(f" - Retrieved: {first_line}")
   363→
   364→            prompt = f"""You are an Analyst using Butler (Obsidian Edition).
   365→
   366→            === TASK ===
   367→            Answer the user's question.
   368→            CRITICAL: You MUST explicitly cite your sources in the output.
   369→
   370→            === STRATEGY ===
   371→            1. first: Check "RELEVANT LOCAL NOTES" provided below.
   372→            2. second: If the notes answer the question, answer directly.
   373→            3. third: If the notes are missing info, ambiguos, or outdated, USE THE `search_web` TOOL to find external info.
   374→
   375→            === OUTPUT FORMAT ===
   376→            - Begin with a summary.
   377→            - 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]".
   378→            - If data comes from BOTH, mention both.
   379→
   380→            === RELEVANT LOCAL NOTES ===
   381→            {rag_text}
   382→
   383→            === USER QUESTION ===
   384→            {user_input}
   385→            """
   386→            actual_user_message = prompt
   387→        else:
   388→            actual_user_message = user_input
   389→
   390→        # Prepare execution context
   391→        # We don't want to mutate the input history directly until the end
   392→        exec_history = history.copy()
   393→        exec_history.append({"role": "user", "content": actual_user_message})
   394→
   395→        logger.info(f"Analyzer thinking on: {user_input}")
   396→
   397→        # === ReAct Loop (Max 2 turns to prevent infinite loops) ===
   398→        for i in range(2):
   399→            response_text, tool_calls = self.llm.generate_response(
   400→                message="", # Message is already in history
   401→                context=exec_history,
   402→                tools=tools
   403→            )
   404→
   405→            if tool_calls:
   406→                # LLM wants to use tools
   407→                logger.info(f"LLM requested tool calls: {tool_calls}")
   408→
   409→                # Append the assistant's request (with tool calls) to history
   410→                exec_history.append({
   411→                    "role": "assistant",
   412→                    "content": f"[Tool Call Request: {tool_calls}]"
   413→                })
   414→
   415→                for tc in tool_calls:
   416→                    if tc["name"] == "search_web":
   417→                        query = tc["args"].get("query")
   418→                        search_result = WebSearchTool.search_web(query)
   419→
   420→                        # Feed result back
   421→                        exec_history.append({
   422→                            "role": "user", # Using 'function' role would be better if our LLM class supports it fully, but 'user' works as system injection
   423→                            "content": f"--- TOOL OUTPUT (search_web) ---\n{search_result}"
   424→                        })
   425→
   426→                    elif tc["name"] == "list_recent_files":
   427→                        days = tc["args"].get("days", 5)
   428→                        # Call indexer method
   429→                        recent_list = self.indexer.get_recent_files(days=days)
   430→
   431→                        exec_history.append({
   432→                            "role": "user",
   433→                            "content": f"--- TOOL OUTPUT (list_recent_files) ---\n{recent_list}"
   434→                        })
   435→
   436→                # Loop again to get the final answer using tool outputs
   437→                try:
   438→                     # Second pass with tool outputs
   439→                    response_text, _ = self.llm.generate_response(
   440→                        message="",
   441→                        context=exec_history,
   442→                        tools=tools
   443→                    )
   444→                    return response_text, exec_history
   445→                except Exception as e:
   446→                    logger.error(f"Error in second ReAct loop: {e}")
   447→                    return response_text, exec_history
   448→
   449→            else:
   450→                # Final answer (no tools)
   451→                return response_text, exec_history
   452→
   453→        # Fallback if loop exhausted
   454→        return response_text, exec_history
   455→
   456→
   457→class DeAIReviser(BaseGenerator):
   458→    """Remove AI tone from articles - text-in, text-out mode."""
   459→
   460→    def chat(self, user_input: str, history: List[Dict]) -> Tuple[str, List[Dict]]:
   461→
   462→        is_first_turn = (len(history) == 0)
   463→
   464→        if is_first_turn:
   465→            # === First Turn: User pastes the AI-generated article ===
   466→            # Load the POWELL_REVISE.md guide
   467→            powell_revise_guide = self._read_file("POWELL_REVISE.md")
   468→
   469→            # Load user's writing samples for style reference
   470→            style_samples = self.indexer.get_writing_samples(count=3)
   471→            style_text = "\n\n".join(style_samples)
   472→
   473→            prompt = f"""You are an expert editor specializing in removing AI-generated tone from articles.
   474→
   475→Your task is to revise the article provided by the user to make it sound more human and authentic.
   476→
   477→=== REVISION GUIDE ===
   478→{powell_revise_guide}
   479→
   480→=== USER'S AUTHENTIC WRITING SAMPLES (for style reference) ===
   481→{style_text}
   482→
   483→=== ARTICLE TO REVISE ===
   484→{user_input}
   485→
   486→=== TASK ===
   487→1. Analyze the article and identify AI characteristics (过渡词堆积, 排比对仗, 空洞修饰词等)
   488→2. Rewrite the article following the POWELL_REVISE.md principles
   489→3. Preserve:
   490→   - Original meaning and key information
   491→   - Technical terms and proper nouns
   492→   - Code blocks, lists, and formatting
   493→   - Original language (Chinese/English)
   494→4. Output the revised article directly (no meta-commentary like "Here's the revised version...")
   495→
   496→CRITICAL: Output ONLY the revised article text. Do NOT add explanations before or after.
   497→"""
   498→
   499→            logger.info(f"DeAI Reviser: Processing article ({len(user_input)} chars)")
   500→            actual_user_message = prompt
   501→
   502→        else:
   503→            # === Follow-up Turn: User gives refinement instructions ===
   504→            logger.info(f"DeAI Reviser: Follow-up instruction - {user_input}")
   505→            actual_user_message = user_input
   506→            prompt = user_input
   507→
   508→        # Call LLM
   509→        response, _ = self.llm.generate_response(prompt, history)
   510→
   511→        # Update History
   512→        new_history = history.copy()
   513→        new_history.append({"role": "user", "content": actual_user_message})
   514→        new_history.append({"role": "assistant", "content": response})
   515→
   516→        return response, new_history
   517→
   518→
   519→class ZhihuGenerator(BaseGenerator):
   520→    """Generate Zhihu-style answers based on style guide and user's knowledge base."""
   521→
   522→    def chat(self, user_input: str, history: List[Dict]) -> Tuple[str, List[Dict]]:
   523→
   524→        is_first_turn = (len(history) == 0)
   525→        built_rich_prompt = False  # tracks whether we ran the full question->draft pipeline
   526→
   527→        # Pre-load rule files (needed for both generation and second-pass review)
   528→        zhihu_bot_style = self._read_file("zhihu-bot-style.md")
   529→        powell_revise_guide = self._read_file("POWELL_REVISE.md")
   530→
   531→        # Treat as a new question if:
   532→        # - history is empty (genuine first turn), OR
   533→        # - user input contains "|" (explicit "Question | Core Ideas" format), OR
   534→        # - user input starts with known question triggers like "回答这个问题"
   535→        is_new_question = (
   536→            is_first_turn
   537→            or "|" in user_input
   538→            or user_input.strip().startswith("回答这个问题")
   539→        )
   540→
   541→        if is_new_question:
   542→            # === First Turn: Build Rich Prompt ===
   543→            # Expected input format: "Question | Core Ideas"
   544→            parts = user_input.split("|", 1)
   545→            question = parts[0].strip()
   546→            core_ideas = parts[1].strip() if len(parts) > 1 else ""
   547→
   548→            # 1. Load Style Guide (zhihu-style.md serves as writing standard, NOT as material)
   549→            zhihu_style_guide = self._read_file("zhihu-style.md")
   550→            # Bot-specific overrides (plain-text output, no bracket explanations, etc.)
   551→            if zhihu_bot_style:
   552→                zhihu_style_guide = zhihu_style_guide + "\n\n" + zhihu_bot_style
   553→
   554→            # 2. RAG from Obsidian vault for actual content material
   555→            rag_notes = self.indexer.search(question, limit=5)
   556→            rag_text = "\n\n".join(rag_notes)
   557→
   558→            # 3. Load methodology
   559→            methodology = self._read_file("methodology.md")
   560→
   561→            semantic_context = self._semantic_search(question, top_k=3)
   562→
   563→            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.
   564→
   565→=== YOUR IDENTITY & METHODOLOGY ===
   566→{methodology}
   567→
   568→=== ANTI-AI-TONE GUIDE (APPLY WHILE WRITING, NOT JUST AT THE END) ===
   569→{powell_revise_guide}
   570→
   571→=== ZHIHU STYLE GUIDE (FOLLOW THESE WRITING PRINCIPLES) ===
   572→{zhihu_style_guide}
   573→
   574→CRITICAL: The style guide above defines HOW to write (tone, structure, language patterns).
   575→Do NOT treat zhihu-sample content as factual material. It's ONLY for style reference.
   576→
   577→=== RELEVANT KNOWLEDGE FROM YOUR OBSIDIAN VAULT (USE AS ACTUAL MATERIAL) ===
   578→{rag_text}
   579→
   580→{semantic_context}
   581→
   582→=== THE QUESTION ===
   583→{question}
   584→
   585→=== YOUR CORE IDEAS ===
   586→{core_ideas}
   587→
   588→=== TASK ===
   589→1. Answer the question based on YOUR knowledge (from Obsidian vault) and core ideas
   590→2. FOLLOW THE ANTI-AI-TONE GUIDE FIRST, then the Zhihu style guide
   591→3. Share real experiences, examples, and insights from the vault material
   592→4. Write in Chinese (unless question implies English)
   593→5. Minimize first person "我": use it sparingly, not as filler. Observations and judgments can stand without "我认为" or "我觉得" as prefix.
   594→6. Be direct and opinionated. Attach a concrete evaluation to every fact or feature you mention (e.g. "暴力但有效", "极度脆弱"). Pure feature listing is forbidden.
   595→7. AVOID AT ALL COSTS: AI tone, empty buzzwords, hollow phrases, and 宏大叙事 (不可逆的浪潮, 范式转移, 时代大背景).
   596→8. If recent web context is provided, weave it in naturally — like someone who reads a lot online
   597→9. STRICT: Never add bracket explanations for any term. Write "大语言模型" not "大语言模型（LLM）". If a term needs explaining, use a sentence, not parentheses.
   598→10. No public account names, no self-promotion, no引流 of any kind — this is the highest priority rule.
   599→11. STRICT: Never reveal personal identity or professional background. Do not mention employer, team, job title, product name, or any detail that could identify who you are.
   600→12. STRICT: Never reference past projects, articles, or talks by name.
   601→13. No poetry, quotes, or "升华" endings. No首尾呼应 (repeating the opening point at the end). Stop when the point is made.
   602→14. LENGTH: 500–1000 Chinese characters. Cut a point, not quality.
   603→
   604→=== BEFORE SUBMITTING, CHECK THESE ===
   605→- Is there any AI-generated tone or phrasing?
   606→- Are there any buzzwords or jargon that need to be replaced with plain language?
   607→- Is the answer direct and opinionated, not neutral or "balanced"?
   608→- Does it sound like a real human wrote it, not a machine?
   609→
   610→Begin writing the answer:
   611→"""
   612→
   613→            logger.info(f"Generating Zhihu answer for: {question[:50]}...")
   614→            actual_user_message = prompt
   615→            built_rich_prompt = True
   616→
   617→        else:
   618→            # === Follow-up Turn: User gives refinement instructions (e.g. "make it shorter") ===
   619→            logger.info(f"Follow-up Zhihu instruction: {user_input}")
   620→            actual_user_message = user_input
   621→            prompt = user_input
   622→
   623→        # Call LLM — first pass: content generation
   624→        # For new questions, use empty history to avoid contamination from previous conversations
   625→        llm_history = [] if is_new_question else history
   626→        draft, _ = self.llm.generate_response(prompt, llm_history)
   627→
   628→        # === Second pass: rule enforcement ===
   629→        # Runs whenever a full draft was generated (built_rich_prompt=True),
   630→        # regardless of whether history was empty or not.
   631→        # Skipped on follow-up refinement turns (e.g. "make it shorter").
   632→        run_review = built_rich_prompt
   633→        if run_review:
   634→            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.
   635→
   636→=== TONE TARGET (最高优先级) ===
   637→目标语感：像一个懂行的人在知乎随手打字回答。不端着，也不刻意耍帅。
   638→两种AI味都要杀：
   639→- 正式AI味：过渡词堆砌、排比对仗、空洞修饰、鸡汤结尾
   640→- 油腻AI味：刻意装随意（"折腾去吧"）、强行造金句（"搞坏了不心疼，跑通了就是资产"）、给普通概念起酷名字（"隔离舱"、"暴力碰撞"、"最干净的契约"）、用一句俏皮话收尾装潇洒
   641→正确的语感：平铺直叙，该说什么说什么，说完就停。不需要让读者觉得你很酷。
   642→
   643→=== PRIORITY 1: VOCABULARY KILL LIST (逐词扫描，命中即删或换大白话) ===
   644→夸张修饰: 极度脆弱、毁灭性的、灾难级的、压倒性的、远超想象、不止一个量级、颠覆性的、革命性的、折磨人、天翻地覆、风险极高、本质上
   645→大词空转: 范式转移、范式突变、开发范式、数字化容器、数字雇员、温和池架构、非确定性、阅后即焚、毫秒级就绪、毫秒级响应、赋能、底层逻辑、仪式性努力、深刻变革、暴力碰撞、最干净的契约、服务化的潜力、不可逆的浪潮、时代大背景、可扩展的执行信用、终极归宿、壁垒、分水岭、沉淀（用作动词修饰数据/经验时）、云端办公室
   646→鸡汤/口号: 正途、值得跨越、安置灵魂、掌控感、才是王道、苦活累活交给、未来已来、危机就是转机、挑战与机遇并存、从玩具变成生产力、把环境变成了资产、安全底线、这才是成熟的做法、才是X的关键、不是建议是必须
   647→油腻收尾: "X去吧"式潇洒收尾、"搞坏了不心疼，跑通了就是Y"式金句、任何试图用一句俏皮话总结全文的结尾、"把X关在Y里...把Y放在Z上"式排比收尾
   648→伪口语: 说白了、说实话、折腾这玩意儿
   649→
   650→=== PRIORITY 2: SENTENCE PATTERN KILL LIST (命中即改写) ===
   651→1. 防御性写法："很多人觉得X，实际上Y"、"有些人认为X，但其实Y"、"很多人没意识到"、"不少人觉得X"、"有人X，有人已经Y了" → 直接说判断，删掉"很多人/有人"铺垫
   652→2. 对比式定义："不是X，是Y"、"不是X而是Y"、"那不叫X那叫Y"、"别把X当Y" → 删除对比句式，直接陈述后半句即可
   653→3. 换皮夸张：用另一个夸张词替换夸张词不算修好。"灾难级"→"毁灭性"→"风险极高"都是换皮。正确做法：换成具体后果（如"可能误删整个home目录"）
   654→4. 概念包装：给普通事物起酷名字（"隔离舱"=容器、"暴力碰撞"=冲突、"数字雇员"=自动化脚本、"执行信用"=服务器资源、"云端办公室"=云服务器、"牢笼"=限制）→ 用最朴素的说法
   655→5. 油腻金句：短句对仗、押韵、刻意工整的收尾句 → 要么删掉，要么拆成普通陈述句
   656→6. 比喻堆砌：全篇最多1个比喻，多余全删换直说
   657→7. 鸡汤/口号结尾：删除励志或耍帅收尾，给具体建议或直接停
   658→8. 首尾呼应：禁止结尾重复开头
   659→9. 编号分段：如果是线性论述（论点1→论点2→论点3），删除"一、二、三"序号改为自然段落；如果是并列对比不同方案（方案A vs 方案B vs 方案C），可以保留编号
   660→10. 防御性引号：删除套在归纳性词汇上的引号
   661→11. 强行总结：删除"一句话建议"、"综上"式收尾
   662→12. 嵌套数字列表：删除正文中"1. 2. 3."子列表，改为连续段落
   663→13. 关键词重复：同一个修辞性词汇（如"分水岭"、"命门"、"杀手锏"）全篇只能出现一次，重复出现时删除后续的或换成普通说法
   664→
   665→=== PRIORITY 3: ZHIHU-SPECIFIC RULES ===
   666→{zhihu_bot_style}
   667→
   668→=== PRIORITY 4: FULL REVISION GUIDE ===
   669→{powell_revise_guide}
   670→
   671→=== DRAFT TO REVIEW ===
   672→{draft}
   673→
   674→=== OUTPUT ===
   675→Return ONLY the corrected Chinese text. No explanation, no meta-commentary, no English.
   676→"""
   677→            logger.info("Running second-pass rule enforcement on Zhihu draft...")
   678→            response, _ = self.llm.generate_response(review_prompt, [])
   679→        else:
   680→            response = draft
   681→
   682→        # Update History
   683→        new_history = history.copy()
   684→        new_history.append({"role": "user", "content": actual_user_message})
   685→        new_history.append({"role": "assistant", "content": response})
   686→
   687→        return response, new_history


No files found


Found 2 files
slack_bot/zhihu/zhihu_hunter.py
slack_bot/obsidian/dispatcher.py


     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

agentId: addfd377395170735 (for resuming to continue this agent's work if needed)
<usage>total_tokens: 55829
tool_uses: 8
duration_ms: 200862</usage>

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

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

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


Now I understand the full pipeline. The key finding is:

**The bot only fetches the question TITLE from Zhihu, not the question's full description/detail text.**

So when the user pastes `https://www.zhihu.com/question/2016845353517806681 回答定义，优势和建议`:

1. The bot fetches only the question title via Playwright
2. "回答定义，优势和建议" becomes the `outline` (answer structure hint)
3. The LLM generates an answer based on: question title + outline + RAG from vault + style guides

This creates a real risk: if the actual question has important context or constraints buried in its description, the LLM could misinterpret the topic entirely or miss critical details that would shape the answer. Looking at the generated response about intermittent fasting, I'm wondering whether the question title alone was enough to guide the LLM correctly, or if missing the full description led to hallucinations or factual inaccuracies.

What's striking is that the output contains multiple patterns that should have been filtered out—"仪式性努力" appears despite being on the kill list, along with the numbered advantage structure and the "不是X，是Y" construction. There's also what looks like incorrect terminology around mTOR that suggests the answer wasn't properly grounded. The user flagged it as wrong, which points to a deeper issue: without access to the actual question content, the LLM generated plausible-sounding but ultimately incorrect information. The user is asking me to identify what's wrong, and since I can't access the URL they referenced, I need to focus on what I can actually analyze: the answer has AI-like patterns despite attempts to avoid them, contains potentially inaccurate medical specifics, and the prompt structure itself ("回答定义，优势和建议") is pushing the LLM to sound authoritative about medical topics it doesn't have reliable grounding for.

Looking at the length, this answer is way over the 500-1000 character limit that Zhihu's style guide specifies. Beyond that, I'm noticing the answer is packed with terms from the kill list—"仪式性努力" appears directly, the "首先...其次...最后" transition pattern is there, and it uses the "不是X，是Y" structure multiple times. These are exactly the artificial patterns the kill list was designed to eliminate.

The bigger issue though is I'm not confident about the factual accuracy here. I don't actually have access to the original Zhihu question, so I can't verify whether the answer is even addressing the right topic or if the medical claims are correct. I should check the bot logs or ask the user directly what the question actually is before I can properly evaluate whether the content is accurate. Looking at the specific medical claims more closely, I see the issue with the mTORC1 terminology - it should use the proper Chinese biomedical term "哺乳动物雷帕霉素靶蛋白复合物1". The 16-hour autophagy threshold is also oversimplified since autophagy is continuous and increases gradually with fasting rather than triggering at a specific cutoff. The 30% blood sugar reduction claim does reference real research by Alpana Shukla, but presenting it as a definitive fact without caveats is misleading.

What's becoming clear is that this entire response contains confident-sounding medical and biochemical claims that aren't actually grounded in reliable sources - the LLM is essentially generating plausible-sounding health information without proper verification. Even though the user might have health-related notes in their vault, these specific claims about mTORC1 pathways and autophagy thresholds likely aren't coming from any actual source material.

The real issue is structural: the bot needs the full question context, not just the title, and it needs web search specifically for medical fact-checking rather than just recent context. I'm also wondering if there's a problem with how the question title was fetched in the first place.

If Playwright failed to scrape the page due to anti-scraping protections, the bot would fall back to generating a meaningless label from the question ID. But the answer clearly addresses intermittent fasting, so either the fetch succeeded or something else provided the topic. Looking at the user's Slack input, they provided a Zhihu link with instructions to answer about definitions, advantages, and recommendations—so the topic was actually specified by the user, not derived from the page.

The real issues I should focus on are the factual errors in the answer itself, the kill list terms that slipped through the two-pass review, the excessive length over 1000 characters, and the pipeline's fundamental limitations: it only fetches the title without description, creating hallucination risk for medical topics. The user is asking me to identify why the answer is wrong, so I need to pinpoint those specific problems. Looking at the actual content of the answer, I'm spotting specific factual issues—the definition of intermittent fasting is framed too narrowly through a "biohacking" lens when the medical definition is more straightforward, and there's a translation error where "mechanical" is used instead of "mechanistic" for mTOR, which fundamentally misrepresents what the acronym means.

The claim about autophagy triggering after 16 hours oversimplifies the science—it actually begins increasing around 12 hours in various models, not at a sharp 16-hour threshold. The blood sugar reduction claim of "at least 30%" from eating fiber-protein-carbs in that order is overstated given it's based on specific study conditions, and recommending the 14:10 fasting pattern as a starting point is less standard than the more commonly suggested 16:8 approach.

I'm also second-guessing myself on some of these critiques—the explanation about mTORC1 inhibition triggering autophagy and cellular cleanup is somewhat oversimplified, but I'm not a medical expert either, so I should be careful about what I'm calling definitively wrong versus just debatable. Let me refocus and ask the user directly what's incorrect rather than making assumptions.

Looking back at their message, they're pointing me to a Zhihu link and saying the answer is wrong, but I can't actually access external links due to anti-scraping restrictions. I should acknowledge this limitation and instead ask them to specify what's incorrect—whether it's factual errors, the wrong topic entirely, or issues with the tone and language used. I also notice the generated answer contains some problematic phrasing that violates content guidelines, which could be part of the problem. The user clarified they meant the response has factual errors, not just an AI-like tone, so I should address both issues in my feedback. Let me put together a response that covers the accuracy problems alongside the stylistic concerns.


我无法直接访问知乎页面（反爬保护），但通过分析代码和这篇回答，发现了几个问题：

## 1. 管线根因：Bot 只拿到问题标题，没拿到问题描述

`hunt_direct_url()` 通过 Playwright 只抓取了 `page.title()`，没有获取问题的详细描述。如果知乎问题的描述里有重要限定条件或背景信息，Bot 完全看不到，LLM 只能靠标题和 outline 猜测。

## 2. 医学/健康类内容纯靠 LLM 编造

这篇回答中有大量具体的生化声明，Vault 里大概率没有这些数据，LLM 在"自信地胡说"：

- "机械靶向雷帕霉素复合物 1" — mTORC1 的中文不叫"机械靶向"，应该是"机制性雷帕霉素靶蛋白复合物1"
- "断食时间超过 16 小时"触发自噬 — 过度简化，自噬是个渐进过程
- "血糖峰值下降至少 30%" — 引用特定实验条件下的数据当通用结论
- "14:10 模式"作为起步推荐 — 通常推荐的是 16:8

## 3. Kill list 大面积失效

尽管有 two-pass de-AI review，这篇回答里的 kill list 违规极多：

- **"仪式性努力"** — 明确在 kill list 里
- **"首先…其次…再者…最后…"** — pattern kill list 第 11 条
- **"不是X，是Y"** — pattern kill list 第 2 条（"核心价值不在于…而在于…"、"是…不是…"）
- **"第一个优势…第二个优势…"** — 线性论述编号，应删
- **"本质定义"、"深度清洁"、"核心价值"、"最硬核的"** — 大词空转 / 夸张修饰

---

你能告诉我这个知乎问题实际问的是什么吗？我无法访问页面，想确认是"问题理解错了"还是"回答的医学事实有误"。


我用mode zhihu-hunter，同样获取不了帖子内容，请帮我看下，是不是知乎增强了反爬虫系统了


当前模型 claude-opus-4-6 负载已经达到上限，请稍后重试 (request id: 20260316145105141781490jCakgeGz)


当前模型 claude-opus-4-6 负载已经达到上限，请稍后重试 (request id: 20260316145105756951393ciKPaKj1)


当前模型 claude-opus-4-6 负载已经达到上限，请稍后重试 (request id: 20260316145106952660496qm2KDpJe)


当前模型 claude-opus-4-6 负载已经达到上限，请稍后重试 (request id: 20260316145109497711573C1VY61ln)


The user is saying that even the bot's zhihu-hunter mode can't fetch the post content, and they want me to check if Zhihu has enhanced their anti-scraping system. Let me look at the relevant code that handles fetching Zhihu question titles/content, specifically the Playwright-based fetching logic.

Let me look at:
1. `_fetch_question_title()` in zhihu_hunter.py
2. The Playwright engine code
3. Any cookie/auth handling

I should also check the logs to see what's happening when the bot tries to fetch.


让我看看知乎抓取相关的代码。


Agent

Explore Zhihu scraping code

Prompt:
I need to understand how this project fetches Zhihu question content. Please find and read:

1. The `_fetch_question_title()` method in `/root/projects/butler/slack_bot/zhihu/zhihu_hunter.py` - read the full method
2. Any Playwright-related code for Zhihu page scraping (check `zhihu_playwright_engine.py`)
3. Any cookie handling or authentication logic for Zhihu
4. Check if there's any method that fetches the FULL question description (not just title)

Also check for any config related to Zhihu cookies (look for cookie files, env vars like ZHIHU_COOKIE, etc.)

Be thorough - I need to understand exactly how the bot interacts with Zhihu's website and what might be failing.


Bash

List Zhihu module files


I need to understand how this project fetches Zhihu question content. Please find and read:

1. The `_fetch_question_title()` method in `/root/projects/butler/slack_bot/zhihu/zhihu_hunter.py` - read the full method
2. Any Playwright-related code for Zhihu page scraping (check `zhihu_playwright_engine.py`)
3. Any cookie handling or authentication logic for Zhihu
4. Check if there's any method that fetches the FULL question description (not just title)

Also check for any config related to Zhihu cookies (look for cookie files, env vars like ZHIHU_COOKIE, etc.)

Be thorough - I need to understand exactly how the bot interacts with Zhihu's website and what might be failing.

Prompt:
I need to understand how this project fetches Zhihu question content. Please find and read:

1. The `_fetch_question_title()` method in `/root/projects/butler/slack_bot/zhihu/zhihu_hunter.py` - read the full method
2. Any Playwright-related code for Zhihu page scraping (check `zhihu_playwright_engine.py`)
3. Any cookie handling or authentication logic for Zhihu
4. Check if there's any method that fetches the FULL question description (not just title)

Also check for any config related to Zhihu cookies (look for cookie files, env vars like ZHIHU_COOKIE, etc.)

Be thorough - I need to understand exactly how the bot interacts with Zhihu's website and what might be failing.


total 84
drwxr-xr-x  3 root root    137 Mar  4 13:22 .
drwxr-xr-x 10  501 games  4096 Mar 10 13:42 ..
-rw-r--r--  1 root root      0 Mar  3 19:35 __init__.py
drwxr-xr-x  2 root root    170 Mar  4 13:22 __pycache__
-rw-r--r--  1 root root  24490 Mar  4 12:29 slack_interactive_gateway.py
-rw-r--r--  1 root root  27331 Mar  4 13:22 zhihu_hunter.py
-rw-r--r--  1 root root  28214 Mar  4 12:59 zhihu_playwright_engine.py