The file /root/projects/butler/slack_bot/obsidian/generators.py has been updated successfully.

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

        else:
            # === Follow-up Turn: User gives refinement instructions ===
            logger.info(f"Follow-up Zhihu 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: rule enforcement (first turn only) ===
        # Re-scan the draft against zhihu-bot-style.md and POWELL_REVISE.md.
        # This runs as a fresh call so the checklist gets full attention,
        # not diluted by the long generation context.
        if is_first_turn:
            review_prompt = f"""You are a strict editor. A draft Zhihu answer has been written. Your only job is to fix rule violations and return the corrected text. Do NOT change the content, facts, or structure unless they directly violate a rule below.

=== RULES TO ENFORCE ===

{zhihu_bot_style}

{powell_revise_guide}

=== SPECIFIC VIOLATIONS TO SCAN AND FIX ===
1. 对比式定义句式：删除或改写所有"不是X而是Y"、"不能当X用得当Y用"、"那不叫X那叫Y"、"别把X当Y要把X当Z"句式。直接陈述即可。
2. 编号分段：删除所有"一、二、三、四"序号标题，改为无标题段落自然过渡。
3. 防御性引号：删除所有套在归纳性词汇上的引号（如"数字替身"、"包工头模式"、"统一入口"、"调教"等）。
4. 伪口语：删除"说白了"、"说实话"、"折腾这玩意儿"。
5. 强行总结：删除任何"一句话建议"、"总结一句话"、"综上"式收尾段。
6. 句长均匀：如果连续三句以上长度相近，拆短其中至少一句，制造落差。
7. 嵌套数字列表：删除正文中"1. 2. 3."的子列表，改为连续段落。

=== 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

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
        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 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)

        if is_first_turn:
            # === 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.)
            zhihu_bot_style = self._read_file("zhihu-bot-style.md")
            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 and anti-AI-tone guide
            methodology = self._read_file("methodology.md")
            powell_revise_guide = self._read_file("POWELL_REVISE.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}

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

=== 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. FOLLOW THE ANTI-AI-TONE GUIDE FIRST, then the Zhihu style guide
3. Share real experiences, examples, and insights from the vault material
4. Write in Chinese (unless question implies English)
5. Minimize first person "我": use it sparingly, not as filler. Observations and judgments can stand without "我认为" or "我觉得" as prefix.
6. Be direct and opinionated. Attach a concrete evaluation to every fact or feature you mention (e.g. "暴力但有效", "极度脆弱"). Pure feature listing is forbidden.
7. AVOID AT ALL COSTS: AI tone, empty buzzwords, hollow phrases, and 宏大叙事 (不可逆的浪潮, 范式转移, 时代大背景).
8. If recent web context is provided, weave it in naturally — like someone who reads a lot online
9. STRICT: Never add bracket explanations for any term. Write "大语言模型" not "大语言模型（LLM）". If a term needs explaining, use a sentence, not parentheses.
10. No public account names, no self-promotion, no引流 of any kind — this is the highest priority rule.
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.
12. STRICT: Never reference past projects, articles, or talks by name.
13. No poetry, quotes, or "升华" endings. No首尾呼应 (repeating the opening point at the end). Stop when the point is made.
14. LENGTH: 500–1000 Chinese characters. Cut a point, not quality.

=== BEFORE SUBMITTING, CHECK THESE ===
- Is there any AI-generated tone or phrasing?
- Are there any buzzwords or jargon that need to be replaced with plain language?
- Is the answer direct and opinionated, not neutral or "balanced"?
- Does it sound like a real human wrote it, not a machine?

Begin writing the answer:
"""

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

        else:
            # === Follow-up Turn: User gives refinement instructions ===
            logger.info(f"Follow-up Zhihu 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

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

+        # Call LLM — first pass: content generation

+        draft, _ = self.llm.generate_response(prompt, history)

+        # === Second pass: rule enforcement (first turn only) ===

+        # Re-scan the draft against zhihu-bot-style.md and POWELL_REVISE.md.

+        # This runs as a fresh call so the checklist gets full attention,

+        # not diluted by the long generation context.

+            review_prompt = f"""You are a strict editor. A draft Zhihu answer has been written. Your only job is to fix rule violations and return the corrected text. Do NOT change the content, facts, or structure unless they directly violate a rule below.

+=== SPECIFIC VIOLATIONS TO SCAN AND FIX ===

+1. 对比式定义句式：删除或改写所有"不是X而是Y"、"不能当X用得当Y用"、"那不叫X那叫Y"、"别把X当Y要把X当Z"句式。直接陈述即可。

+2. 编号分段：删除所有"一、二、三、四"序号标题，改为无标题段落自然过渡。

+3. 防御性引号：删除所有套在归纳性词汇上的引号（如"数字替身"、"包工头模式"、"统一入口"、"调教"等）。

+4. 伪口语：删除"说白了"、"说实话"、"折腾这玩意儿"。

+5. 强行总结：删除任何"一句话建议"、"总结一句话"、"综上"式收尾段。

+6. 句长均匀：如果连续三句以上长度相近，拆短其中至少一句，制造落差。

+7. 嵌套数字列表：删除正文中"1. 2. 3."的子列表，改为连续段落。

+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, [])


Bash

source venv/bin/activate && python -c "from slack_bot.obsidian.generators import ZhihuGenerator; print('import ok')"


import ok


systemctl restart butler-obsidian && sleep 3 && systemctl status butler-obsidian --no-pager | head -4


● butler-obsidian.service - Butler Obsidian Bot
     Loaded: loaded (/etc/systemd/system/butler-obsidian.service; disabled; preset: disabled)
     Active: active (running) since Fri 2026-03-06 10:33:57 CST; 3s ago
   Main PID: 3609634 (python)


服务正常。

**改了什么：**

`ZhihuGenerator.chat()` 现在在首轮生成后自动跑第二遍 LLM 调用，专门做规则扫描。两次调用各自独立上下文，避免审校注意力被原始 prompt 的几千字稀释。

第二遍 prompt 的要点：
- 只传 `zhihu_bot_style` 和 `powell_revise_guide`，不带 RAG 和风格指南，保持专注
- 列出了7条具体的违规模式，直接点名要改什么（对比式句式、编号、防御性引号、伪口语、强行总结、句长均匀、嵌套列表）
- 明确要求只返回修正后的中文正文，不加任何说明

后续 follow-up 轮次跳过第二遍，避免用户在修改时被重复审校。


但是现在生成的内容还是不行：OpenClaw 这类工具的出现，本质上是将 AI 从网页里的对话框，变成了你电脑里一个 24 小时待命的“数字替身”。它不是用来聊天的，它是用来接管那些机械、重复、需要跨应用操作的脏活累活。

如果真想靠它提升办公效率，不能把它当大语言模型用，得把它当成一个有手有脚、能看文档、能改代码、能守着邮箱的代工。

一、 极客的暴力美学：从对话到接管

OpenClaw 的办公效率提升，源于它敢于把 Shell 访问权限、文件操作权限和 24/7 的常驻能力交给 AI。这和普通对话框有本质区别。

1. 主动代办流水线：很多人用 AI 还是学生思维，问一句答一句，再手动复制。这太低效。极客的玩法是“包工头模式”。比如你要重构一个几万行代码的遗留项目，你只需要在通讯软件里发一条指令。OpenClaw 可以在后台并行唤起几十个子代理，有的分析依赖，有的改写组件，有的跑测试。你睡一觉起来，它已经提交了几十次代码记录。这种暴力但有效的产出，只有这种常驻后台的架构能做到。

2. 跨平台的统一入口：办公效率最大的敌人是频繁切换窗口。OpenClaw 能把你的所有办公通道打通。它可以变成一个智能邮箱管家，在后台盯着收件箱，过滤掉垃圾，把紧急邮件提取摘要发到你的即时通讯工具。你只需要在手机上回一句指令，它就自动生成草稿回复。这种统一入口的设计，能帮你把细碎的干扰心智的动作全部屏蔽。

3. 从代理到代码固化：OpenClaw 最强的能力是它能操作本地脚本。高效的用法是固化。当你在本地用 AI 跑通了一个流程，比如每天抓取竞品数据并生成报表，不要每天去喊它。你要让 AI 把这个逻辑写成一段独立的 Python 脚本，扔到你的服务器上挂定时任务。从此以后，这个功能就不再依赖任何框架，变成了你私有的生产力插件。

二、 硬核门槛与安全悖论：这把牛刀很利，但也容易伤己

必须承认，OpenClaw 具有极强的极客属性，这意味着它绝不是给普通用户准备的“开箱即用”产品。

首先是配置地狱。哪怕是资深开发者，面对复杂的容器环境、网络穿透、不同平台的接口鉴权以及各种依赖冲突，折腾个两三天跑不起来是常态。它在工程上是一座精密的大厦，但通往大厦的安检门，有时比直接走进房间还费劲。如果你的需求只是写个周报问个天气，折腾这玩意儿就是在浪费生命。

其次是极高的安全风险。这也是最需要警惕的地方：

1. 权限失控的恐怖：OpenClaw 的价值核心在于它可以执行系统命令。如果你在没设防的情况下给了它最高权限，这相当于把家里的钥匙交给了一个容易产生幻觉的临时工。一旦 AI 误解了指令，执行了一个错误的删除操作，那不是提升效率，那是灾难。

2. 脆弱的补丁协议：OpenClaw 发明了一套补丁协议来节省 Token。这在技术上很聪明，但在工程上极度脆弱。大语言模型是概率模型，它不能保证 100% 精确。如果它输错一个标点或者行号对不上，整个文件修改就会崩溃。

3. 隐私暴露：如果你用的是第三方平台做中转，理论上你的所有隐私办公数据都会流经这些平台的服务器。对于敏感的商业秘密，这种架构存在天然的泄露风险。

一句话建议：别把 OpenClaw 当成一个聪明的聊天机器人，要把它当成一个需要你不断调优、在沙箱里严密监管、最终能独立运行的自动化流水线。如果你没有能力控制这把牛刀，那就别轻易挥动它。 有这么几个明显的问题：1. 仍然有编号；2.仍然有很多引号；3.仍然有太多 暴力美学，数字替身，精密的大厦等含义较浓重的ai风格的比喻


说明第二遍 LLM 调用没有真正生效。先验证一下日志，看二次审校是否被执行：


grep -E "second-pass|Generating Zhihu" /root/projects/butler/logs/*.log 2>/dev/null | tail -20


/root/projects/butler/logs/obsidian.log:2026-03-03 21:21:53 - slack_bot.obsidian.generators - INFO - Generating Zhihu answer for: Claude与Cursor有什么区别？ - 知乎...
/root/projects/butler/logs/obsidian.log:2026-03-03 21:22:11 - slack_bot.obsidian.generators - INFO - Generating Zhihu answer for: 为什么要开源？ - 醉卧沙场的回答- 知乎...
/root/projects/butler/logs/obsidian.log:2026-03-03 21:36:51 - slack_bot.obsidian.generators - INFO - Generating Zhihu answer for: 为什么Unix如此默默无闻而又长盛不衰？ - 知乎...
/root/projects/butler/logs/obsidian.log:2026-03-03 21:37:02 - slack_bot.obsidian.generators - INFO - Generating Zhihu answer for: Claude与Cursor有什么区别？ - 知乎...
/root/projects/butler/logs/obsidian.log:2026-03-03 21:44:54 - slack_bot.obsidian.generators - INFO - Generating Zhihu answer for: OpenClaw 有哪些进阶的玩法？ - 知乎...
/root/projects/butler/logs/obsidian.log:2026-03-03 21:49:45 - slack_bot.obsidian.generators - INFO - Generating Zhihu answer for: 人工智能算法对社会的影响是什么？...
/root/projects/butler/logs/obsidian.log:2026-03-03 21:58:35 - slack_bot.obsidian.generators - INFO - Generating Zhihu answer for: 来聊聊最近大火的预测市场Polymarket，用户真能靠信息差赚钱吗？...
/root/projects/butler/logs/obsidian.log:2026-03-03 22:20:20 - slack_bot.obsidian.generators - INFO - Generating Zhihu answer for: 有哪些自动化办公软件值得推荐？ - 知乎...
/root/projects/butler/logs/obsidian.log:2026-03-03 22:45:50 - slack_bot.obsidian.generators - INFO - Generating Zhihu answer for: 开源项目OpenClaw 仅用3 个月登顶GitHub Star 榜首，对此你怎么看？...
/root/projects/butler/logs/obsidian.log:2026-03-03 23:35:33 - slack_bot.obsidian.generators - INFO - Generating Zhihu answer for: 知乎问题 #1976694170476360490>...
/root/projects/butler/logs/obsidian.log:2026-03-03 23:49:34 - slack_bot.obsidian.generators - INFO - Generating Zhihu answer for: 知乎问题 #1976694170476360490>...
/root/projects/butler/logs/obsidian.log:2026-03-03 23:57:10 - slack_bot.obsidian.generators - INFO - Generating Zhihu answer for: 知乎问题 #1976694170476360490>...
/root/projects/butler/logs/obsidian.log:2026-03-04 10:42:59 - slack_bot.obsidian.generators - INFO - Generating Zhihu answer for: 普通人搭建个人服务器，真的划算吗？有哪些潜在成本？ - 知乎...
/root/projects/butler/logs/obsidian.log:2026-03-04 10:44:32 - slack_bot.obsidian.generators - INFO - Generating Zhihu answer for: 普通人搭建个人服务器，真的划算吗？有哪些潜在成本？ - 知乎...
/root/projects/butler/logs/obsidian.log:2026-03-04 12:21:49 - slack_bot.obsidian.generators - INFO - Generating Zhihu answer for: 知乎问题 #2012492934491108892>
/root/projects/butler/logs/obsidian.log:2026-03-04 12:32:49 - slack_bot.obsidian.generators - INFO - Generating Zhihu answer for: 知乎问题 #2012492934491108892>
/root/projects/butler/logs/obsidian.log:2026-03-04 12:52:52 - slack_bot.obsidian.generators - INFO - Generating Zhihu answer for: 知乎问题 #2012492934491108892>
/root/projects/butler/logs/obsidian.log:2026-03-05 18:08:18 - slack_bot.obsidian.generators - INFO - Generating Zhihu answer for: Deepseek的出现让很多人陷入了AI焦虑，普通人应该如何应对？ - 知乎...
/root/projects/butler/logs/obsidian.log:2026-03-05 18:08:42 - slack_bot.obsidian.generators - INFO - Generating Zhihu answer for: 什么是超级个体？如何让自己成为超级个体？ - 知乎...
/root/projects/butler/logs/obsidian.log:2026-03-05 19:12:48 - slack_bot.obsidian.generators - INFO - Generating Zhihu answer for: 回答这个问题：


确认了：日志里完全没有 `second-pass` 的记录，说明今天的代码改动还没有被实际运行到——这些生成记录都是今天之前的。用户发来的这条内容很可能是用旧版本生成的，或者重启后缓存了旧代码。

先确认当前运行的代码：


grep -n "second-pass\|Running second" /root/projects/butler/slack_bot/obsidian/generators.py


592:            logger.info("Running second-pass rule enforcement on Zhihu draft...")


grep -n "zhihu_bot_style" /root/projects/butler/slack_bot/obsidian/generators.py


488:            zhihu_bot_style = self._read_file("zhihu-bot-style.md")
489:            if zhihu_bot_style:
490:                zhihu_style_guide = zhihu_style_guide + "\n\n" + zhihu_bot_style
573:{zhihu_bot_style}


问题找到了。`zhihu_bot_style` 在 `if is_first_turn:` 块内定义，但 review_prompt 在块外引用它，如果 `is_first_turn=False` 时变量不存在是 NameError；更关键的是 `if is_first_turn:` 块结束后 `zhihu_bot_style` 在 review 里能用，但要确认变量作用域没问题。看一下完整的逻辑：


   554→
   555→        else:
   556→            # === Follow-up Turn: User gives refinement instructions ===
   557→            logger.info(f"Follow-up Zhihu instruction: {user_input}")
   558→            actual_user_message = user_input
   559→            prompt = user_input
   560→
   561→        # Call LLM — first pass: content generation
   562→        draft, _ = self.llm.generate_response(prompt, history)
   563→
   564→        # === Second pass: rule enforcement (first turn only) ===
   565→        # Re-scan the draft against zhihu-bot-style.md and POWELL_REVISE.md.
   566→        # This runs as a fresh call so the checklist gets full attention,
   567→        # not diluted by the long generation context.
   568→        if is_first_turn:
   569→            review_prompt = f"""You are a strict editor. A draft Zhihu answer has been written. Your only job is to fix rule violations and return the corrected text. Do NOT change the content, facts, or structure unless they directly violate a rule below.
   570→
   571→=== RULES TO ENFORCE ===
   572→
   573→{zhihu_bot_style}
   574→
   575→{powell_revise_guide}
   576→
   577→=== SPECIFIC VIOLATIONS TO SCAN AND FIX ===
   578→1. 对比式定义句式：删除或改写所有"不是X而是Y"、"不能当X用得当Y用"、"那不叫X那叫Y"、"别把X当Y要把X当Z"句式。直接陈述即可。
   579→2. 编号分段：删除所有"一、二、三、四"序号标题，改为无标题段落自然过渡。
   580→3. 防御性引号：删除所有套在归纳性词汇上的引号（如"数字替身"、"包工头模式"、"统一入口"、"调教"等）。
   581→4. 伪口语：删除"说白了"、"说实话"、"折腾这玩意儿"。
   582→5. 强行总结：删除任何"一句话建议"、"总结一句话"、"综上"式收尾段。
   583→6. 句长均匀：如果连续三句以上长度相近，拆短其中至少一句，制造落差。
   584→7. 嵌套数字列表：删除正文中"1. 2. 3."的子列表，改为连续段落。
   585→
   586→=== DRAFT TO REVIEW ===
   587→{draft}
   588→
   589→=== OUTPUT ===
   590→Return ONLY the corrected Chinese text. No explanation, no meta-commentary, no English.
   591→"""
   592→            logger.info("Running second-pass rule enforcement on Zhihu draft...")
   593→            response, _ = self.llm.generate_response(review_prompt, [])
   594→        else:
   595→            response = draft
   596→
   597→        # Update History
   598→        new_history = history.copy()
   599→        new_history.append({"role": "user", "content": actual_user_message})
   600→        new_history.append({"role": "assistant", "content": response})
   601→
   602→        return response, new_history

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

        else:
            # === Follow-up Turn: User gives refinement instructions ===
            logger.info(f"Follow-up Zhihu 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: rule enforcement (first turn only) ===
        # Re-scan the draft against zhihu-bot-style.md and POWELL_REVISE.md.
        # This runs as a fresh call so the checklist gets full attention,
        # not diluted by the long generation context.
        if is_first_turn:
            review_prompt = f"""You are a strict editor. A draft Zhihu answer has been written. Your only job is to fix rule violations and return the corrected text. Do NOT change the content, facts, or structure unless they directly violate a rule below.

=== RULES TO ENFORCE ===

{zhihu_bot_style}

{powell_revise_guide}

=== SPECIFIC VIOLATIONS TO SCAN AND FIX ===
1. 对比式定义句式：删除或改写所有"不是X而是Y"、"不能当X用得当Y用"、"那不叫X那叫Y"、"别把X当Y要把X当Z"句式。直接陈述即可。
2. 编号分段：删除所有"一、二、三、四"序号标题，改为无标题段落自然过渡。
3. 防御性引号：删除所有套在归纳性词汇上的引号（如"数字替身"、"包工头模式"、"统一入口"、"调教"等）。
4. 伪口语：删除"说白了"、"说实话"、"折腾这玩意儿"。
5. 强行总结：删除任何"一句话建议"、"总结一句话"、"综上"式收尾段。
6. 句长均匀：如果连续三句以上长度相近，拆短其中至少一句，制造落差。
7. 嵌套数字列表：删除正文中"1. 2. 3."的子列表，改为连续段落。

=== 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


代码本身没问题，`zhihu_bot_style` 在 `is_first_turn` 块内定义，review 也在同一个 `if is_first_turn:` 块内，作用域正确。

问题在于：你说的这条"还是不行"的内容，日志里最新的记录是 `19:12:48`，而今天的改动是 `10:33:57` 重启的。**这条内容是用今天重启后的新版本生成的吗？** 还是之前的记录？

直接测一下 second-pass 有没有在跑：


2026-03-06 10:21:02 - slack_bot.zhihu.slack_interactive_gateway - INFO - ZhihuHunter: Slack action handlers registered
2026-03-06 10:21:02 - __main__ - INFO - ⚡ Obsidian Bot is connected to Slack!
⚡ Bolt app is running!
2026-03-06 10:26:31 - __main__ - INFO - Obsidian Bot received from U0A8ZCK7RJM: 回答这个问题：

OpenClaw已成为一款极具实用价值的开源个人AI助手平台，如何使用它来提供办公...
2026-03-06 10:26:31 - slack_bot.obsidian.dispatcher - INFO - Generating Obsidian response in mode: zhihu
2026-03-06 10:26:31 - slack_bot.obsidian.generators - INFO - Follow-up Zhihu instruction: 回答这个问题：

OpenClaw已成为一款极具实用价值的开源个人AI助手平台，如何使用它来提供办公效率呢

需要重点强调它的极客属性和安全风险
2026-03-06 10:26:40 - slack_bot.context.storage - INFO - Cleared context for D0AACF9G4J0
2026-03-06 10:33:58 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-06 10:33:58 - slack_bot.obsidian.indexer - INFO - Scanning Obsidian Vault at: /root/vault/obsidian_vault/obsidian/obsidian/
2026-03-06 10:33:58 - slack_bot.obsidian.indexer - INFO - Scan complete. Scanned 495 files.
2026-03-06 10:33:58 - slack_bot.obsidian.indexer - INFO - Found 33 writing samples.
2026-03-06 10:33:58 - slack_bot.obsidian.indexer - INFO - Found 2 reply samples.
2026-03-06 10:33:58 - slack_bot.obsidian.embeddings - INFO - Using cloud (Qwen) embedding provider
2026-03-06 10:33:59 - slack_bot.obsidian.vector_store - INFO - ChromaVectorStore initialized at /root/projects/butler/data/health/vector_db (collection: obsidian_notes)
2026-03-06 10:33:59 - slack_bot.llm.gemini - INFO - Using OpenRouter: https://openrouter.ai/api
2026-03-06 10:33:59 - slack_bot.llm.gemini - INFO - Initialized Gemini model: google/gemini-3-flash-preview
2026-03-06 10:33:59 - slack_bot.llm.gemini - INFO - Using OpenRouter: https://openrouter.ai/api
2026-03-06 10:33:59 - slack_bot.llm.gemini - INFO - Initialized Gemini model: google/gemini-3-flash-preview
2026-03-06 10:33:59 - slack_bot.llm.gemini - INFO - Using OpenRouter: https://openrouter.ai/api
2026-03-06 10:33:59 - slack_bot.llm.gemini - INFO - Initialized Gemini model: google/gemini-3-flash-preview
2026-03-06 10:33:59 - slack_bot.llm.gemini - INFO - Using OpenRouter: https://openrouter.ai/api
2026-03-06 10:33:59 - slack_bot.llm.gemini - INFO - Initialized Gemini model: google/gemini-3-flash-preview
2026-03-06 10:33:59 - slack_bot.llm.gemini - INFO - Using OpenRouter: https://openrouter.ai/api
2026-03-06 10:33:59 - slack_bot.llm.gemini - INFO - Initialized Gemini model: google/gemini-3-flash-preview
2026-03-06 10:33:59 - slack_bot.llm.gemini - INFO - Using OpenRouter: https://openrouter.ai/api
2026-03-06 10:33:59 - slack_bot.llm.gemini - INFO - Initialized Gemini model: google/gemini-3-flash-preview
2026-03-06 10:33:59 - slack_bot.llm.gemini - INFO - Using OpenRouter: https://openrouter.ai/api
2026-03-06 10:33:59 - slack_bot.llm.gemini - INFO - Initialized Gemini model: google/gemini-3-flash-preview
2026-03-06 10:33:59 - slack_bot.llm.gemini - INFO - Using OpenRouter: https://openrouter.ai/api
2026-03-06 10:33:59 - slack_bot.llm.gemini - INFO - Initialized Gemini model: google/gemini-3-flash-preview
2026-03-06 10:33:59 - slack_bot.obsidian.dispatcher - INFO - ObsidianDispatcher initialized
2026-03-06 10:33:59 - slack_bot.zhihu.slack_interactive_gateway - INFO - ZhihuHunter: Slack action handlers registered
2026-03-06 10:33:59 - __main__ - INFO - ⚡ Obsidian Bot is connected to Slack!
⚡ Bolt app is running!
2026-03-06 10:57:58 - __main__ - INFO - Obsidian Bot received from U0A8ZCK7RJM: 回答这个问题：

OpenClaw已成为一款极具实用价值的开源个人AI助手平台，如何使用它来提供办公...
2026-03-06 10:57:58 - slack_bot.obsidian.dispatcher - INFO - Generating Obsidian response in mode: zhihu
2026-03-06 10:57:58 - slack_bot.obsidian.generators - INFO - Follow-up Zhihu instruction: 回答这个问题：

OpenClaw已成为一款极具实用价值的开源个人AI助手平台，如何使用它来提供办公效率呢

需要重点强调它的极客属性和安全风险
2026-03-06 10:58:06 - slack_bot.context.storage - INFO - Cleared context for D0AACF9G4J0


问题找到了。日志清楚显示：

```
INFO - Follow-up Zhihu instruction: 回答这个问题...
```

每次都是 **Follow-up turn**，不是 First turn，所以 second-pass 根本没触发。原因是 context 里有历史记录——你之前用过 `mode zhihu` 没有 `clear`，导致 `history` 非空，`is_first_turn = False`。

两个问题需要同时修：

1. **`clear` 要在 `mode zhihu` 切换时自动清掉 context**（dispatcher 已经做了，但你可能是在同一个 mode 下重复发问）
2. **second-pass 的触发条件改掉**：不应该依赖 `is_first_turn`，应该每次收到新问题都触发


Edit

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

        # === Second pass: rule enforcement (first turn only) ===
        # Re-scan the draft against zhihu-bot-style.md and POWELL_REVISE.md.
        # This runs as a fresh call so the checklist gets full attention,
        # not diluted by the long generation context.
        if is_first_turn:

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

        # === Second pass: rule enforcement ===
        # Only runs when this turn built a new rich prompt (i.e. the user sent a question,
        # not a follow-up refinement instruction like "make it shorter").
        # Detect by checking whether we built a review-able draft: always true on first turn,
        # and also triggered when follow-up looks like a new question (contains the question text).
        run_review = is_first_turn
        if run_review:


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

        # === Second pass: rule enforcement ===
        # Only runs when this turn built a new rich prompt (i.e. the user sent a question,
        # not a follow-up refinement instruction like "make it shorter").
        # Detect by checking whether we built a review-able draft: always true on first turn,
        # and also triggered when follow-up looks like a new question (contains the question text).
        run_review = is_first_turn
        if run_review:

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
        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 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)

        if is_first_turn:
            # === 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.)
            zhihu_bot_style = self._read_file("zhihu-bot-style.md")
            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 and anti-AI-tone guide
            methodology = self._read_file("methodology.md")
            powell_revise_guide = self._read_file("POWELL_REVISE.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}

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

=== 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. FOLLOW THE ANTI-AI-TONE GUIDE FIRST, then the Zhihu style guide
3. Share real experiences, examples, and insights from the vault material
4. Write in Chinese (unless question implies English)
5. Minimize first person "我": use it sparingly, not as filler. Observations and judgments can stand without "我认为" or "我觉得" as prefix.
6. Be direct and opinionated. Attach a concrete evaluation to every fact or feature you mention (e.g. "暴力但有效", "极度脆弱"). Pure feature listing is forbidden.
7. AVOID AT ALL COSTS: AI tone, empty buzzwords, hollow phrases, and 宏大叙事 (不可逆的浪潮, 范式转移, 时代大背景).
8. If recent web context is provided, weave it in naturally — like someone who reads a lot online
9. STRICT: Never add bracket explanations for any term. Write "大语言模型" not "大语言模型（LLM）". If a term needs explaining, use a sentence, not parentheses.
10. No public account names, no self-promotion, no引流 of any kind — this is the highest priority rule.
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.
12. STRICT: Never reference past projects, articles, or talks by name.
13. No poetry, quotes, or "升华" endings. No首尾呼应 (repeating the opening point at the end). Stop when the point is made.
14. LENGTH: 500–1000 Chinese characters. Cut a point, not quality.

=== BEFORE SUBMITTING, CHECK THESE ===
- Is there any AI-generated tone or phrasing?
- Are there any buzzwords or jargon that need to be replaced with plain language?
- Is the answer direct and opinionated, not neutral or "balanced"?
- Does it sound like a real human wrote it, not a machine?

Begin writing the answer:
"""

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

        else:
            # === Follow-up Turn: User gives refinement instructions ===
            logger.info(f"Follow-up Zhihu 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: rule enforcement (first turn only) ===
        # Re-scan the draft against zhihu-bot-style.md and POWELL_REVISE.md.
        # This runs as a fresh call so the checklist gets full attention,
        # not diluted by the long generation context.
        if is_first_turn:
            review_prompt = f"""You are a strict editor. A draft Zhihu answer has been written. Your only job is to fix rule violations and return the corrected text. Do NOT change the content, facts, or structure unless they directly violate a rule below.

=== RULES TO ENFORCE ===

{zhihu_bot_style}

{powell_revise_guide}

=== SPECIFIC VIOLATIONS TO SCAN AND FIX ===
1. 对比式定义句式：删除或改写所有"不是X而是Y"、"不能当X用得当Y用"、"那不叫X那叫Y"、"别把X当Y要把X当Z"句式。直接陈述即可。
2. 编号分段：删除所有"一、二、三、四"序号标题，改为无标题段落自然过渡。
3. 防御性引号：删除所有套在归纳性词汇上的引号（如"数字替身"、"包工头模式"、"统一入口"、"调教"等）。
4. 伪口语：删除"说白了"、"说实话"、"折腾这玩意儿"。
5. 强行总结：删除任何"一句话建议"、"总结一句话"、"综上"式收尾段。
6. 句长均匀：如果连续三句以上长度相近，拆短其中至少一句，制造落差。
7. 嵌套数字列表：删除正文中"1. 2. 3."的子列表，改为连续段落。

=== 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

-        # === Second pass: rule enforcement (first turn only) ===

-        # Re-scan the draft against zhihu-bot-style.md and POWELL_REVISE.md.

-        # This runs as a fresh call so the checklist gets full attention,

-        # not diluted by the long generation context.

+        # === Second pass: rule enforcement ===

+        # Only runs when this turn built a new rich prompt (i.e. the user sent a question,

+        # not a follow-up refinement instruction like "make it shorter").

+        # Detect by checking whether we built a review-able draft: always true on first turn,

+        # and also triggered when follow-up looks like a new question (contains the question text).


`is_first_turn` 依赖 history 是否为空，但问题在 dispatcher 里 history 被持久化了。更根本的修法：把触发条件改为"是否刚刚执行了 rich prompt 构建"，用一个局部标记：


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


<tool_use_error>Found 6 matches of the string to replace, but replace_all is false. To replace all occurrences, set replace_all to true. To replace only one occurrence, please provide more context to uniquely identify the instance.
String:         is_first_turn = (len(history) == 0)</tool_use_error>

Error: Found 6 matches of the string to replace, but replace_all is false. To replace all occurrences, set replace_all to true. To replace only one occurrence, please provide more context to uniquely identify the instance.
String:         is_first_turn = (len(history) == 0)


   474→    def chat(self, user_input: str, history: List[Dict]) -> Tuple[str, List[Dict]]:
   475→
   476→        is_first_turn = (len(history) == 0)
   477→
   478→        if is_first_turn:
   479→            # === First Turn: Build Rich Prompt ===
   480→            # Expected input format: "Question | Core Ideas"
   481→            parts = user_input.split("|", 1)
   482→            question = parts[0].strip()
   483→            core_ideas = parts[1].strip() if len(parts) > 1 else ""

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

    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 ===
            # 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 ""


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

        is_first_turn = (len(history) == 0)

        if is_first_turn:

    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

        if is_first_turn:


<tool_use_error>Found 5 matches of the string to replace, but replace_all is false. To replace all occurrences, set replace_all to true. To replace only one occurrence, please provide more context to uniquely identify the instance.
String:     def chat(self, user_input: str, history: List[Dict]) -> Tuple[str, List[Dict]]:

        is_first_turn = (len(history) == 0)

        if is_first_turn:</tool_use_error>

Error: Found 5 matches of the string to replace, but replace_all is false. To replace all occurrences, set replace_all to true. To replace only one occurrence, please provide more context to uniquely identify the instance.
String:     def chat(self, user_input: str, history: List[Dict]) -> Tuple[str, List[Dict]]:

        is_first_turn = (len(history) == 0)

        if is_first_turn:


   470→
   471→class ZhihuGenerator(BaseGenerator):
   472→    """Generate Zhihu-style answers based on style guide and user's knowledge base."""
   473→
   474→    def chat(self, user_input: str, history: List[Dict]) -> Tuple[str, List[Dict]]:
   475→
   476→        is_first_turn = (len(history) == 0)
   477→
   478→        if is_first_turn:
   479→            # === First Turn: Build Rich Prompt ===
   480→            # Expected input format: "Question | Core Ideas"
   481→            parts = user_input.split("|", 1)
   482→            question = parts[0].strip()
   483→            core_ideas = parts[1].strip() if len(parts) > 1 else ""
   484→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

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)

        if is_first_turn:
            # === 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 ""


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)

        if is_first_turn:
            # === 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 ""

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

        if is_first_turn:
            # === 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 ""


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

        if is_first_turn:
            # === 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 ""

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
        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 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)

        if is_first_turn:
            # === 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.)
            zhihu_bot_style = self._read_file("zhihu-bot-style.md")
            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 and anti-AI-tone guide
            methodology = self._read_file("methodology.md")
            powell_revise_guide = self._read_file("POWELL_REVISE.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}

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

=== 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. FOLLOW THE ANTI-AI-TONE GUIDE FIRST, then the Zhihu style guide
3. Share real experiences, examples, and insights from the vault material
4. Write in Chinese (unless question implies English)
5. Minimize first person "我": use it sparingly, not as filler. Observations and judgments can stand without "我认为" or "我觉得" as prefix.
6. Be direct and opinionated. Attach a concrete evaluation to every fact or feature you mention (e.g. "暴力但有效", "极度脆弱"). Pure feature listing is forbidden.
7. AVOID AT ALL COSTS: AI tone, empty buzzwords, hollow phrases, and 宏大叙事 (不可逆的浪潮, 范式转移, 时代大背景).
8. If recent web context is provided, weave it in naturally — like someone who reads a lot online
9. STRICT: Never add bracket explanations for any term. Write "大语言模型" not "大语言模型（LLM）". If a term needs explaining, use a sentence, not parentheses.
10. No public account names, no self-promotion, no引流 of any kind — this is the highest priority rule.
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.
12. STRICT: Never reference past projects, articles, or talks by name.
13. No poetry, quotes, or "升华" endings. No首尾呼应 (repeating the opening point at the end). Stop when the point is made.
14. LENGTH: 500–1000 Chinese characters. Cut a point, not quality.

=== BEFORE SUBMITTING, CHECK THESE ===
- Is there any AI-generated tone or phrasing?
- Are there any buzzwords or jargon that need to be replaced with plain language?
- Is the answer direct and opinionated, not neutral or "balanced"?
- Does it sound like a real human wrote it, not a machine?

Begin writing the answer:
"""

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

        else:
            # === Follow-up Turn: User gives refinement instructions ===
            logger.info(f"Follow-up Zhihu 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: rule enforcement ===
        # Only runs when this turn built a new rich prompt (i.e. the user sent a question,
        # not a follow-up refinement instruction like "make it shorter").
        # Detect by checking whether we built a review-able draft: always true on first turn,
        # and also triggered when follow-up looks like a new question (contains the question text).
        run_review = is_first_turn
        if run_review:
            review_prompt = f"""You are a strict editor. A draft Zhihu answer has been written. Your only job is to fix rule violations and return the corrected text. Do NOT change the content, facts, or structure unless they directly violate a rule below.

=== RULES TO ENFORCE ===

{zhihu_bot_style}

{powell_revise_guide}

=== SPECIFIC VIOLATIONS TO SCAN AND FIX ===
1. 对比式定义句式：删除或改写所有"不是X而是Y"、"不能当X用得当Y用"、"那不叫X那叫Y"、"别把X当Y要把X当Z"句式。直接陈述即可。
2. 编号分段：删除所有"一、二、三、四"序号标题，改为无标题段落自然过渡。
3. 防御性引号：删除所有套在归纳性词汇上的引号（如"数字替身"、"包工头模式"、"统一入口"、"调教"等）。
4. 伪口语：删除"说白了"、"说实话"、"折腾这玩意儿"。
5. 强行总结：删除任何"一句话建议"、"总结一句话"、"综上"式收尾段。
6. 句长均匀：如果连续三句以上长度相近，拆短其中至少一句，制造落差。
7. 嵌套数字列表：删除正文中"1. 2. 3."的子列表，改为连续段落。

=== 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

+        built_rich_prompt = False  # tracks whether we ran the full question->draft pipeline


现在在 `is_first_turn` 块末尾标记 `built_rich_prompt = True`，并更新触发条件：