Write

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


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

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

+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

+    from slack_bot.obsidian.vector_store import ChromaVectorStore

+OBSIDIAN_SYSTEM_PROMPT = """You are Butler (Obsidian Edition), an intelligent knowledge assistant connected to the user's second brain.

+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.

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

+- **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".

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

+            with open(path, "r", encoding="utf-8") as f:

+            logger.error(f"Failed to read {filename}: {e}")

+    def _semantic_search(self, query: str, top_k: int = 3) -> str:

+        """Run semantic search against the vector store and format results.

+            top_k: Maximum number of chunks to retrieve.

+            Formatted string block, or empty string if no results.

+            results = self.vector_store.search_knowledge(query, top_k=top_k)

+            logger.warning(f"_semantic_search failed: {e}")

+        lines = ["=== SEMANTIC KNOWLEDGE FROM VAULT ==="]

+            lines.append(f"[{Path(r.source_path).name} / {r.header_hierarchy}]")

+            # Truncate individual chunks to avoid prompt bloat (~500 tokens ≈ 2000 chars)

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

+        is_first_turn = (len(history) == 0)

+            # === 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)

+            extra_context = parts[1].strip() if len(parts) > 1 else ""

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

+            === YOUR WRITING STYLE GUIDE ===

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

+            === YOUR WRITING SAMPLES (MIMIC THIS TONE) ===

+            === RELEVANT NOTES FROM OBSIDIAN VAULT ===

+            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

+            # user_input is just instructions like "Make it shorter"

+            logger.info(f"Follow-up instruction: {user_input}")

+            actual_user_message = user_input

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

+        new_history.append({"role": "user", "content": actual_user_message})

+        new_history.append({"role": "assistant", "content": response})

+        is_first_turn = (len(history) == 0)

+            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.

+            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.

+            === REFERENCE SAMPLES (FEW-SHOT) ===

+            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

+            logger.info(f"Follow-up reply instruction: {user_input}")

+            actual_user_message = user_input

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

+        is_first_turn = (len(history) == 0)

+            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.

+            === RELEVANT CONTEXT FROM NOTES ===

+            === THE DECISION / ISSUE ===

+            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

+            logger.info(f"Follow-up decision instruction: {user_input}")

+            actual_user_message = user_input

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

+                    "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.",

+                        "type": "object",

+                        "properties": {

+                                "type": "string",

+                                "description": "The search query"

+                        "required": ["query"]

+                    "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.",

+                                "type": "integer",

+                                "description": "Number of days to check (default 5)",

+                                "default": 5

+        is_first_turn = (len(history) == 0)

+            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.")

+                # 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).

+            Answer the user's question.

+            CRITICAL: You MUST explicitly cite your sources in the output.

+            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.

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

+            actual_user_message = prompt

+            actual_user_message = user_input

+        # We don't want to mutate the input history directly until the end

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

+            response_text, tool_calls = self.llm.generate_response(

+                message="", # Message is already in history

+                # LLM wants to use tools

+                logger.info(f"LLM requested tool calls: {tool_calls}")

+                # Append the assistant's request (with tool calls) to history

+                    "role": "assistant",

+                    "content": f"[Tool Call Request: {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)

+                            "role": "user",

+                            "content": f"--- TOOL OUTPUT (list_recent_files) ---\n{recent_list}"

+                # Loop again to get the final answer using tool outputs

+                     # Second pass with tool outputs

+                    response_text, _ = self.llm.generate_response(

+                        context=exec_history,

+                    return response_text, exec_history

+                    logger.error(f"Error in second ReAct loop: {e}")

+                # Final answer (no tools)

+                return response_text, exec_history

+        return response_text, exec_history

+    """Remove AI tone from articles - text-in, text-out mode."""

+        is_first_turn = (len(history) == 0)

+            # === 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.

+=== USER'S AUTHENTIC WRITING SAMPLES (for style reference) ===

+1. Analyze the article and identify AI characteristics (过渡词堆积, 排比对仗, 空洞修饰词等)

+2. Rewrite the article following the POWELL_REVISE.md principles

+   - Original meaning and key information

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

+            # === Follow-up Turn: User gives refinement instructions ===

+            logger.info(f"DeAI Reviser: Follow-up instruction - {user_input}")

+            actual_user_message = user_input

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


很好，我已经重新添加了完整的 `slack_bot/obsidian/generators.py` 文件，包括所有缺失的类定义。现在我应该检查服务是否能正常启动了。


Bash

systemctl restart butler-obsidian && sleep 5 && systemctl status butler-obsidian


● butler-obsidian.service - Butler Obsidian Bot
     Loaded: loaded (/etc/systemd/system/butler-obsidian.service; disabled; preset: disabled)
     Active: active (running) since Thu 2026-03-05 18:59:42 CST; 5s ago
   Main PID: 3382168 (python)
      Tasks: 17 (limit: 9171)
     Memory: 93.3M (peak: 93.7M)
        CPU: 1.891s
     CGroup: /system.slice/butler-obsidian.service
             └─3382168 /root/projects/butler/venv/bin/python scripts/obsidian_bot.py

Mar 05 18:59:42 VM-8-2-opencloudos systemd[1]: Started butler-obsidian.service - Butler Obsidian Bot.


太好了！butler-obsidian 服务现在已经成功启动并正在运行。从 systemctl status 输出可以看到：

- 服务状态是 active (running)
- Main PID 是 3382168
- 任务数为 17
- 内存使用量约为 93MB
- CPU 使用时间约为 1.89 秒

这表明服务已经正常运行，并且没有立即失败。现在我们已经修复了问题，服务可以正常启动了。


journalctl -u butler-obsidian --no-pager -n 20


Mar 05 18:58:29 VM-8-2-opencloudos systemd[1]: butler-obsidian.service: Failed with result 'exit-code'.
Mar 05 18:58:44 VM-8-2-opencloudos systemd[1]: butler-obsidian.service: Scheduled restart job, restart counter is at 78.
Mar 05 18:58:44 VM-8-2-opencloudos systemd[1]: Started butler-obsidian.service - Butler Obsidian Bot.
Mar 05 18:58:45 VM-8-2-opencloudos systemd[1]: butler-obsidian.service: Main process exited, code=exited, status=1/FAILURE
Mar 05 18:58:45 VM-8-2-opencloudos systemd[1]: butler-obsidian.service: Failed with result 'exit-code'.
Mar 05 18:59:00 VM-8-2-opencloudos systemd[1]: butler-obsidian.service: Scheduled restart job, restart counter is at 79.
Mar 05 18:59:00 VM-8-2-opencloudos systemd[1]: Started butler-obsidian.service - Butler Obsidian Bot.
Mar 05 18:59:00 VM-8-2-opencloudos systemd[1]: butler-obsidian.service: Main process exited, code=exited, status=1/FAILURE
Mar 05 18:59:00 VM-8-2-opencloudos systemd[1]: butler-obsidian.service: Failed with result 'exit-code'.
Mar 05 18:59:15 VM-8-2-opencloudos systemd[1]: butler-obsidian.service: Scheduled restart job, restart counter is at 80.
Mar 05 18:59:15 VM-8-2-opencloudos systemd[1]: Started butler-obsidian.service - Butler Obsidian Bot.
Mar 05 18:59:15 VM-8-2-opencloudos systemd[1]: butler-obsidian.service: Main process exited, code=exited, status=1/FAILURE
Mar 05 18:59:15 VM-8-2-opencloudos systemd[1]: butler-obsidian.service: Failed with result 'exit-code'.
Mar 05 18:59:30 VM-8-2-opencloudos systemd[1]: butler-obsidian.service: Scheduled restart job, restart counter is at 81.
Mar 05 18:59:30 VM-8-2-opencloudos systemd[1]: Started butler-obsidian.service - Butler Obsidian Bot.
Mar 05 18:59:42 VM-8-2-opencloudos systemd[1]: Stopping butler-obsidian.service - Butler Obsidian Bot...
Mar 05 18:59:42 VM-8-2-opencloudos systemd[1]: butler-obsidian.service: Deactivated successfully.
Mar 05 18:59:42 VM-8-2-opencloudos systemd[1]: Stopped butler-obsidian.service - Butler Obsidian Bot.
Mar 05 18:59:42 VM-8-2-opencloudos systemd[1]: butler-obsidian.service: Consumed 1.981s CPU time.
Mar 05 18:59:42 VM-8-2-opencloudos systemd[1]: Started butler-obsidian.service - Butler Obsidian Bot.


很好，服务现在正在正常运行。从最后几行日志可以看到：

1. 服务成功停止了之前的实例
2. 成功启动了新实例
3. 当前状态是 active (running)

现在让我验证一下 Bot 是否正常响应。


ps aux | grep -E "python.*obsidian_bot.py"


root     3382168  1.7  1.8 1353436 142176 ?      Ssl  18:59   0:01 /root/projects/butler/venv/bin/python scripts/obsidian_bot.py
root     3383029 17.0  0.0  10916  6272 ?        Ss   19:01   0:00 /bin/bash -c source /root/.claude/shell-snapshots/snapshot-bash-1772706104093-bbvd1y.sh && shopt -u extglob 2>/dev/null || true && eval 'ps aux < /dev/null | grep -E python.\*obsidian_bot.py' && pwd -P >| /tmp/claude-8fbb-cwd
root     3383422  0.0  0.0   7540  2156 ?        S    19:01   0:00 grep --color=auto -E python.*obsidian_bot.py


很好，我看到了运行中的 obsidian_bot.py 进程。现在我应该使用 finish 工具来总结问题的解决过程。


已成功修复了 butler-obsidian 服务无法启动的问题。

## 问题根因
在优化 `ZhihuGenerator` 类时，错误地删除了 `BaseGenerator` 类的定义，导致 `NameError: name 'BaseGenerator' is not defined` 错误，使得系统服务无法正常启动。

## 解决方案
1. **重新构建 `slack_bot/obsidian/generators.py` 文件**，完整包含所有生成器类的定义
2. **恢复 `BaseGenerator` 基类**，该类提供了所有生成器的核心功能
3. **确保其他生成器类**（WritingAssistant、ReplyGenerator、DecisionSupport、SearchAnalyzer、DeAIReviser 和 ZhihuGenerator）能够正确继承 BaseGenerator

## 验证结果
- 服务已成功启动：`systemctl status butler-obsidian` 显示 active (running)
- 进程正在运行：PID 3382168 正在执行 `/root/projects/butler/venv/bin/python scripts/obsidian_bot.py`
- 内存使用正常：约 93MB，在合理范围内
- CPU 使用合理：1.89 秒，说明服务启动过程顺利

现在 butler-obsidian 服务已恢复正常运行，可以继续使用其提供的各种功能，包括 mode zhihu 模式的知乎文案生成功能。


这次的进步非常大！你已经成功把最讨厌的“爹味说教”和泛滥的“我”给剔除了。文本里的颗粒度极高（25个子Agent、120个文件重构、Git Commit、Docker/Node.js），主观锐度也很清晰（“那是监工”、“暴力之处”）。这已经具备了硬核技术分享的血肉。

但是，如果我们用那套最严苛的 writing-style.markdown 准则来扫描，它依然在骨架和标点上触发了警报。

以下是仅存的几个“病灶”：

1. 经典的八股文骨架与强行总结（严重违规）

病灶： “一、... 二、... 三、... 四、... 总结一句话：...”

诊断： 这是极其典型的 AI/咨询报告体。完全违背了“砸碎八股文骨架”和“绝对禁止结尾总结/升华”的原则。真正有技术含量的随笔或复盘，思维是流动的、网状的，想到哪打到哪，说完事实直接掐断（EOF），绝对不会在最后还要强行给你“总结一句话”。

2. 防御性引号的泛滥（违规）

病灶： “调戏”、“数字替身”、“你问我答”、“主动交办”、“包工头模式”、“固化”、“统一入口”。

诊断： AI 的注意力机制又“侧漏”了。它极度害怕你抓不住重点，所以把稍微带点修辞或归纳性质的词全部套上了双引号。这在人类阅读时会显得极其细碎且故弄玄虚。全砍掉。

3. 伪造的口语润滑剂（轻微违规）

病灶： “说白了”、“说实话”。

诊断： 这是试图伪装成人类在聊天的废话连词。根据“剃刀原则”，删掉它们不仅不影响句意，反而会让文本更干脆。看看怎么解决这个问题


我需要根据用户提供的三个"病灶"修改相应的代码。首先，我需要找到问题所在的文件。用户提到这是在 mode zhihu 生成的文本，所以问题可能出在 ZhihuGenerator 类的 prompt 或者相关的风格指南文件中。

让我检查一下可能影响的文件：
1. slack_bot/obsidian/generators.py - 包含 ZhihuGenerator 类
2. zhihu-style.md - 知乎风格指南
3. POWELL_REVISE.md - AI 味去除指南

我需要先查看当前的 prompt 配置，然后根据问题进行修改。


<tool_use_error>InputValidationError: Read failed due to the following issues:
An unexpected parameter `length` was provided
The parameter `offset` type is expected as `number` but provided as `string`</tool_use_error>

InputValidationError: [
  {
    "expected": "number",
    "code": "invalid_type",
    "path": [
      "offset"
    ],
    "message": "Invalid input: expected number, received string"
  },
  {
    "code": "unrecognized_keys",
    "keys": [
      "length"
    ],
    "path": [],
    "message": "Unrecognized key: \"length\""
  }
]


我需要查看整个 ZhihuGenerator 类的内容，特别是 prompt 部分，来找到用户提到的"病灶"。


     1→import os
     2→from pathlib import Path
     3→from typing import List, Dict, Tuple, Optional, TYPE_CHECKING
     4→from slack_bot.llm.gemini import GeminiLLM
     5→from slack_bot.obsidian.indexer import ObsidianIndexer
     6→from slack_bot.tools.web import WebSearchTool
     7→from health.utils.logging_config import setup_logger
     8→from health.utils.time_utils import get_current_time_str
     9→
    10→if TYPE_CHECKING:
    11→    from slack_bot.obsidian.vector_store import ChromaVectorStore
    12→
    13→logger = setup_logger(__name__)
    14→
    15→
    16→OBSIDIAN_SYSTEM_PROMPT = """You are Butler (Obsidian Edition), an intelligent knowledge assistant connected to the user's second brain.
    17→Current Time: {current_time}
    18→
    19→Your Role:
    20→You are NOT a health assistant. You are a Knowledge Partner designed to help the user think, write, and communicate. You draw directly from the user's local Obsidian notes, values, and methodology.
    21→
    22→Core Capabilities:
    23→1. Writing: You mimic the user's unique writing style (as defined in `writing_style.md`).
    24→2. Communication: You draft high-EQ, logically rigorous replies for professional contexts (as defined in `REPLY-SAMPLE.md`).
    25→3. Decision: You act as a "Devil's Advocate" and strategic advisor using the user's decision frameworks (GPA, IPO).
    26→
    27→Guidelines:
    28→- **Style Alignment**: Strictly adhere to the tone and sentence structures found in the provided samples.
    29→- **Data Source**: Rely primarily on the provided context (RAG) and loaded markdown files.
    30→- **Identity**: You are pragmatic, rational, and value "technological optimism" and "intellectual honesty".
    31→"""
    32→
    33→class BaseGenerator:
    34→    def __init__(
    35→        self,
    36→        indexer: ObsidianIndexer,
    37→        vector_store: Optional["ChromaVectorStore"] = None,
    38→    ):
    39→        formatted_prompt = OBSIDIAN_SYSTEM_PROMPT.format(current_time=get_current_time_str())
    40→        self.llm = GeminiLLM(system_instruction=formatted_prompt)
    41→        self.indexer = indexer
    42→        self.vector_store = vector_store
    43→        self.workspace_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
    44→
    45→    def _read_file(self, filename: str) -> str:
    46→        """Reads a file from the workspace root."""
    47→        path = os.path.join(self.workspace_root, filename)
    48→        try:
    49→            with open(path, "r", encoding="utf-8") as f:
    50→                return f.read()
    51→        except Exception as e:
    52→            logger.error(f"Failed to read {filename}: {e}")
    53→            return ""
    54→
    55→    def _semantic_search(self, query: str, top_k: int = 3) -> str:
    56→        """Run semantic search against the vector store and format results.
    57→
    58→        Args:
    59→            query: Search query text.
    60→            top_k: Maximum number of chunks to retrieve.
    61→
    62→        Returns:
    63→            Formatted string block, or empty string if no results.
    64→        """
    65→        if self.vector_store is None:
    66→            return ""
    67→        try:
    68→            results = self.vector_store.search_knowledge(query, top_k=top_k)
    69→        except Exception as e:
    70→            logger.warning(f"_semantic_search failed: {e}")
    71→            return ""
    72→        if not results:
    73→            return ""
    74→        lines = ["=== SEMANTIC KNOWLEDGE FROM VAULT ==="]
    75→        for r in results:
    76→            lines.append(f"[{Path(r.source_path).name} / {r.header_hierarchy}]")
    77→            # Truncate individual chunks to avoid prompt bloat (~500 tokens ≈ 2000 chars)
    78→            chunk_text = r.text[:2000]
    79→            lines.append(chunk_text)
    80→            lines.append("")
    81→        return "\n".join(lines)
    82→
    83→class WritingAssistant(BaseGenerator):
    84→    def chat(self, user_input: str, history: List[Dict]) -> Tuple[str, List[Dict]]:
    85→
    86→        is_first_turn = (len(history) == 0)
    87→
    88→        if is_first_turn:
    89→            # === First Turn: Build Rich Prompt ===
    90→            # user_input is treated as "Topic"
    91→
    92→            # Naive splitting for context if formatted as "Topic | Context"
    93→            parts = user_input.split("|", 1)
    94→            topic = parts[0].strip()
    95→            extra_context = parts[1].strip() if len(parts) > 1 else ""
    96→
    97→            # 1. RAG & Samples
    98→            style_samples = self.indexer.get_writing_samples(count=3)
    99→            style_text = "\n\n".join(style_samples)
   100→
   101→            rag_notes = self.indexer.search(topic, limit=5)
   102→            rag_text = "\n\n".join(rag_notes)
   103→
   104→            writing_style_guide = self._read_file("writing_style.md")
   105→            methodology = self._read_file("methodology.md")
   106→            powell_revise_guide = self._read_file("POWELL_REVISE.md")
   107→
   108→            semantic_context = self._semantic_search(topic, top_k=3)
   109→
   110→            prompt = f"""You are a ghostwriter for the user. Your goal is to write an article on the TOPIC provided.
   111→
   112→            === YOUR IDENTITY & METHODOLOGY ===
   113→            {methodology}
   114→
   115→            === YOUR WRITING STYLE GUIDE ===
   116→            {writing_style_guide}
   117→
   118→            === ANTI-AI-TONE GUIDE (APPLY WHILE WRITING, NOT JUST AT THE END) ===
   119→            {powell_revise_guide}
   120→
   121→            === YOUR WRITING SAMPLES (MIMIC THIS TONE) ===
   122→            {style_text}
   123→
   124→            === RELEVANT NOTES FROM OBSIDIAN VAULT ===
   125→            {rag_text}
   126→
   127→            {semantic_context}
   128→
   129→            === TASK ===
   130→            Topic: {topic}
   131→            Extra Context: {extra_context}
   132→
   133→            Write the article in Chinese (unless the topic implies English).
   134→            Adhere strictly to the "Identity" and "Style Guide".
   135→            """
   136→
   137→            logger.info(f"Generating article for topic: {topic}")
   138→            actual_user_message = prompt # We inject the big prompt
   139→
   140→        else:
   141→            # === Follow-up Turn ===
   142→            # user_input is just instructions like "Make it shorter"
   143→            logger.info(f"Follow-up instruction: {user_input}")
   144→            actual_user_message = user_input
   145→            prompt = user_input
   146→
   147→        # Call LLM
   148→        response, _ = self.llm.generate_response(prompt, history)
   149→
   150→        # Update History
   151→        new_history = history.copy()
   152→        new_history.append({"role": "user", "content": actual_user_message})
   153→        new_history.append({"role": "assistant", "content": response})
   154→
   155→        return response, new_history
   156→
   157→class ReplyGenerator(BaseGenerator):
   158→    def chat(self, user_input: str, history: List[Dict]) -> Tuple[str, List[Dict]]:
   159→
   160→        is_first_turn = (len(history) == 0)
   161→
   162→        if is_first_turn:
   163→             # === First Turn ===
   164→            static_samples = self._read_file("REPLY-SAMPLE.md")
   165→            dynamic_samples = self.indexer.get_reply_samples(count=3)
   166→            dynamic_samples_text = "\n\n".join(dynamic_samples)
   167→            methodology = self._read_file("methodology.md")
   168→
   169→            semantic_context = self._semantic_search(user_input, top_k=3)
   170→
   171→            prompt = f"""You are an experienced Product/R&D Manager.
   172→            Your task is to draft a reply to a challenging situation.
   173→
   174→            === CORE PHILOSOPHY ===
   175→            {methodology}
   176→
   177→            === REPLY GUIDELINES ===
   178→            1. Logic: Be clear and structured (e.g., numbered lists).
   179→            2. Responsibility: Don't take unnecessary blame, but don't shirk core duties. Explain objective causes.
   180→            3. Emotion: Be empathetic but professional.
   181→            4. Conciseness: No fluff.
   182→
   183→            === REFERENCE SAMPLES (FEW-SHOT) ===
   184→            {static_samples}
   185→
   186→            {dynamic_samples_text}
   187→
   188→            {semantic_context}
   189→
   190→            === THE SITUATION ===
   191→            {user_input}
   192→
   193→            Draft a reply. If the context implies a specific role (PM, Dev Lead), assume that role.
   194→            """
   195→            logger.info(f"Generating reply for query: {user_input}")
   196→            actual_user_message = prompt
   197→
   198→        else:
   199→             # === Follow-up Turn ===
   200→            logger.info(f"Follow-up reply instruction: {user_input}")
   201→            actual_user_message = user_input
   202→            prompt = user_input
   203→
   204→        response, _ = self.llm.generate_response(prompt, history)
   205→
   206→        new_history = history.copy()
   207→        new_history.append({"role": "user", "content": actual_user_message})
   208→        new_history.append({"role": "assistant", "content": response})
   209→
   210→        return response, new_history
   211→
   212→class DecisionSupport(BaseGenerator):
   213→    def chat(self, user_input: str, history: List[Dict]) -> Tuple[str, List[Dict]]:
   214→
   215→        is_first_turn = (len(history) == 0)
   216→
   217→        if is_first_turn:
   218→            decision_guide = self._read_file("decision.md")
   219→            methodology = self._read_file("methodology.md")
   220→
   221→            rag_notes = self.indexer.search(user_input, limit=3)
   222→            rag_text = "\n\n".join(rag_notes)
   223→
   224→            prompt = f"""You are a Decision Support Assistant.
   225→            Your goal is to help the user think through a complex decision.
   226→
   227→            === DECISION FRAMEWORK ===
   228→            {decision_guide}
   229→
   230→            === CORE PHILOSOPHY ===
   231→            {methodology}
   232→
   233→            === RELEVANT CONTEXT FROM NOTES ===
   234→            {rag_text}
   235→
   236→            === THE DECISION / ISSUE ===
   237→            {user_input}
   238→
   239→            === TASK ===
   240→            1. Challenge the premise: Is this the right problem to solve?
   241→            2. Apply the Framework: Use GPA (Goal, Priority, Alternatives) and IPO (Information, People, Objective reasoning) models.
   242→            3. Pre-mortem: What is the worst that could happen?
   243→            4. Provide a recommendation or a set of questions to clarify.
   244→            """
   245→
   246→            logger.info(f"Analyzing decision: {user_input}")
   247→            actual_user_message = prompt
   248→        else:
   249→            logger.info(f"Follow-up decision instruction: {user_input}")
   250→            actual_user_message = user_input
   251→            prompt = user_input
   252→
   253→        response, _ = self.llm.generate_response(prompt, history)
   254→
   255→        new_history = history.copy()
   256→        new_history.append({"role": "user", "content": actual_user_message})
   257→        new_history.append({"role": "assistant", "content": response})
   258→
   259→        return response, new_history
   260→
   261→class SearchAnalyzer(BaseGenerator):
   262→    def chat(self, user_input: str, history: List[Dict]) -> Tuple[str, List[Dict]]:
   263→
   264→        # Tools definition for the LLM
   265→        tools = [
   266→            {
   267→                "type": "function",
   268→                "function": {
   269→                    "name": "search_web",
   270→                    "description": "Search the public web using DuckDuckGo. Use this when the internal notes are insufficient or when you need up-to-date external information.",
   271→                    "parameters": {
   272→                        "type": "object",
   273→                        "properties": {
   274→                            "query": {
   275→                                "type": "string",
   276→                                "description": "The search query"
   277→                            }
   278→                        },
   279→                        "required": ["query"]
   280→                    }
   281→                }
   282→            },
   283→            {
   284→                "type": "function",
   285→                "function": {
   286→                    "name": "list_recent_files",
   287→                    "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.",
   288→                    "parameters": {
   289→                        "type": "object",
   290→                        "properties": {
   291→                            "days": {
   292→                                "type": "integer",
   293→                                "description": "Number of days to check (default 5)",
   294→                                "default": 5
   295→                            }
   296→                        },
   297→                        "required": []
   298→                    }
   299→                }
   300→            }
   301→        ]
   302→
   303→        is_first_turn = (len(history) == 0)
   304→
   305→        # Current Turn Message
   306→        if is_first_turn:
   307→            rag_notes = self.indexer.search(user_input, limit=5)
   308→            rag_text = "\n\n".join(rag_notes)
   309→
   310→            logger.info(f"SearchAnalyzer retrieved {len(rag_notes)} local notes.")
   311→            for note in rag_notes:
   312→                # Extract source line for logging
   313→                first_line = note.split('\n')[0]
   314→                logger.debug(f" - Retrieved: {first_line}")
   315→
   316→            prompt = f"""You are an Analyst using Butler (Obsidian Edition).
   317→
   318→            === TASK ===
   319→            Answer the user's question.
   320→            CRITICAL: You MUST explicitly cite your sources in the output.
   321→
   322→            === STRATEGY ===
   323→            1. first: Check "RELEVANT LOCAL NOTES" provided below.
   324→            2. second: If the notes answer the question, answer directly.
   325→            3. third: If the notes are missing info, ambiguos, or outdated, USE THE `search_web` TOOL to find external info.
   326→
   327→            === OUTPUT FORMAT ===
   328→            - Begin with a summary.
   329→            - 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]".
   330→            - If data comes from BOTH, mention both.
   331→
   332→            === RELEVANT LOCAL NOTES ===
   333→            {rag_text}
   334→
   335→            === USER QUESTION ===
   336→            {user_input}
   337→            """
   338→            actual_user_message = prompt
   339→        else:
   340→            actual_user_message = user_input
   341→
   342→        # Prepare execution context
   343→        # We don't want to mutate the input history directly until the end
   344→        exec_history = history.copy()
   345→        exec_history.append({"role": "user", "content": actual_user_message})
   346→
   347→        logger.info(f"Analyzer thinking on: {user_input}")
   348→
   349→        # === ReAct Loop (Max 2 turns to prevent infinite loops) ===
   350→        for i in range(2):
   351→            response_text, tool_calls = self.llm.generate_response(
   352→                message="", # Message is already in history
   353→                context=exec_history,
   354→                tools=tools
   355→            )
   356→
   357→            if tool_calls:
   358→                # LLM wants to use tools
   359→                logger.info(f"LLM requested tool calls: {tool_calls}")
   360→
   361→                # Append the assistant's request (with tool calls) to history
   362→                exec_history.append({
   363→                    "role": "assistant",
   364→                    "content": f"[Tool Call Request: {tool_calls}]"
   365→                })
   366→
   367→                for tc in tool_calls:
   368→                    if tc["name"] == "search_web":
   369→                        query = tc["args"].get("query")
   370→                        search_result = WebSearchTool.search_web(query)
   371→
   372→                        # Feed result back
   373→                        exec_history.append({
   374→                            "role": "user", # Using 'function' role would be better if our LLM class supports it fully, but 'user' works as system injection
   375→                            "content": f"--- TOOL OUTPUT (search_web) ---\n{search_result}"
   376→                        })
   377→
   378→                    elif tc["name"] == "list_recent_files":
   379→                        days = tc["args"].get("days", 5)
   380→                        # Call indexer method
   381→                        recent_list = self.indexer.get_recent_files(days=days)
   382→
   383→                        exec_history.append({
   384→                            "role": "user",
   385→                            "content": f"--- TOOL OUTPUT (list_recent_files) ---\n{recent_list}"
   386→                        })
   387→
   388→                # Loop again to get the final answer using tool outputs
   389→                try:
   390→                     # Second pass with tool outputs
   391→                    response_text, _ = self.llm.generate_response(
   392→                        message="",
   393→                        context=exec_history,
   394→                        tools=tools
   395→                    )
   396→                    return response_text, exec_history
   397→                except Exception as e:
   398→                    logger.error(f"Error in second ReAct loop: {e}")
   399→                    return response_text, exec_history
   400→
   401→            else:
   402→                # Final answer (no tools)
   403→                return response_text, exec_history
   404→
   405→        # Fallback if loop exhausted
   406→        return response_text, exec_history
   407→
   408→
   409→class DeAIReviser(BaseGenerator):
   410→    """Remove AI tone from articles - text-in, text-out mode."""
   411→
   412→    def chat(self, user_input: str, history: List[Dict]) -> Tuple[str, List[Dict]]:
   413→
   414→        is_first_turn = (len(history) == 0)
   415→
   416→        if is_first_turn:
   417→            # === First Turn: User pastes the AI-generated article ===
   418→            # Load the POWELL_REVISE.md guide
   419→            powell_revise_guide = self._read_file("POWELL_REVISE.md")
   420→
   421→            # Load user's writing samples for style reference
   422→            style_samples = self.indexer.get_writing_samples(count=3)
   423→            style_text = "\n\n".join(style_samples)
   424→
   425→            prompt = f"""You are an expert editor specializing in removing AI-generated tone from articles.
   426→
   427→Your task is to revise the article provided by the user to make it sound more human and authentic.
   428→
   429→=== REVISION GUIDE ===
   430→{powell_revise_guide}
   431→
   432→=== USER'S AUTHENTIC WRITING SAMPLES (for style reference) ===
   433→{style_text}
   434→
   435→=== ARTICLE TO REVISE ===
   436→{user_input}
   437→
   438→=== TASK ===
   439→1. Analyze the article and identify AI characteristics (过渡词堆积, 排比对仗, 空洞修饰词等)
   440→2. Rewrite the article following the POWELL_REVISE.md principles
   441→3. Preserve:
   442→   - Original meaning and key information
   443→   - Technical terms and proper nouns
   444→   - Code blocks, lists, and formatting
   445→   - Original language (Chinese/English)
   446→4. Output the revised article directly (no meta-commentary like "Here's the revised version...")
   447→
   448→CRITICAL: Output ONLY the revised article text. Do NOT add explanations before or after.
   449→"""
   450→
   451→            logger.info(f"DeAI Reviser: Processing article ({len(user_input)} chars)")
   452→            actual_user_message = prompt
   453→
   454→        else:
   455→            # === Follow-up Turn: User gives refinement instructions ===
   456→            logger.info(f"DeAI Reviser: Follow-up instruction - {user_input}")
   457→            actual_user_message = user_input
   458→            prompt = user_input
   459→
   460→        # Call LLM
   461→        response, _ = self.llm.generate_response(prompt, history)
   462→
   463→        # Update History
   464→        new_history = history.copy()
   465→        new_history.append({"role": "user", "content": actual_user_message})
   466→        new_history.append({"role": "assistant", "content": response})
   467→
   468→        return response, new_history
   469→
   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→
   485→            # 1. Load Style Guide (zhihu-style.md serves as writing standard, NOT as material)
   486→            zhihu_style_guide = self._read_file("zhihu-style.md")
   487→            # Bot-specific overrides (plain-text output, no bracket explanations, etc.)
   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
   491→
   492→            # 2. RAG from Obsidian vault for actual content material
   493→            rag_notes = self.indexer.search(question, limit=5)
   494→            rag_text = "\n\n".join(rag_notes)
   495→
   496→            # 3. Load methodology and anti-AI-tone guide
   497→            methodology = self._read_file("methodology.md")
   498→            powell_revise_guide = self._read_file("POWELL_REVISE.md")
   499→
   500→            semantic_context = self._semantic_search(question, top_k=3)
   501→
   502→            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.
   503→
   504→=== YOUR IDENTITY & METHODOLOGY ===
   505→{methodology}
   506→
   507→=== ANTI-AI-TONE GUIDE (APPLY WHILE WRITING, NOT JUST AT THE END) ===
   508→{powell_revise_guide}
   509→
   510→=== ZHIHU STYLE GUIDE (FOLLOW THESE WRITING PRINCIPLES) ===
   511→{zhihu_style_guide}
   512→
   513→CRITICAL: The style guide above defines HOW to write (tone, structure, language patterns).
   514→Do NOT treat zhihu-sample content as factual material. It's ONLY for style reference.
   515→
   516→=== RELEVANT KNOWLEDGE FROM YOUR OBSIDIAN VAULT (USE AS ACTUAL MATERIAL) ===
   517→{rag_text}
   518→
   519→{semantic_context}
   520→
   521→=== THE QUESTION ===
   522→{question}
   523→
   524→=== YOUR CORE IDEAS ===
   525→{core_ideas}
   526→
   527→=== TASK ===
   528→1. Answer the question based on YOUR knowledge (from Obsidian vault) and core ideas
   529→2. FOLLOW THE ANTI-AI-TONE GUIDE FIRST, then the Zhihu style guide
   530→3. Share real experiences, examples, and insights from the vault material
   531→4. Write in Chinese (unless question implies English)
   532→5. Minimize first person "我": use it sparingly, not as filler. Observations and judgments can stand without "我认为" or "我觉得" as prefix.
   533→6. Be direct and opinionated. Attach a concrete evaluation to every fact or feature you mention (e.g. "暴力但有效", "极度脆弱"). Pure feature listing is forbidden.
   534→7. AVOID AT ALL COSTS: AI tone, empty buzzwords, hollow phrases, and 宏大叙事 (不可逆的浪潮, 范式转移, 时代大背景).
   535→8. If recent web context is provided, weave it in naturally — like someone who reads a lot online
   536→9. STRICT: Never add bracket explanations for any term. Write "大语言模型" not "大语言模型（LLM）". If a term needs explaining, use a sentence, not parentheses.
   537→10. No public account names, no self-promotion, no引流 of any kind — this is the highest priority rule.
   538→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.
   539→12. STRICT: Never reference past projects, articles, or talks by name.
   540→13. No poetry, quotes, or "升华" endings. No首尾呼应 (repeating the opening point at the end). Stop when the point is made.
   541→14. LENGTH: 500–1000 Chinese characters. Cut a point, not quality.
   542→
   543→=== BEFORE SUBMITTING, CHECK THESE ===
   544→- Is there any AI-generated tone or phrasing?
   545→- Are there any buzzwords or jargon that need to be replaced with plain language?
   546→- Is the answer direct and opinionated, not neutral or "balanced"?
   547→- Does it sound like a real human wrote it, not a machine?
   548→
   549→Begin writing the answer:
   550→"""
   551→
   552→            logger.info(f"Generating Zhihu answer for: {question[:50]}...")
   553→            actual_user_message = prompt
   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
   562→        response, _ = self.llm.generate_response(prompt, history)
   563→
   564→        # Update History
   565→        new_history = history.copy()
   566→        new_history.append({"role": "user", "content": actual_user_message})
   567→        new_history.append({"role": "assistant", "content": response})
   568→
   569→        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>


我需要查看 POWELL_REVISE.md 文件，看看是否需要更新 anti-AI-tone 规则，以更严格地禁止用户提到的"病灶"：

1. 八股文骨架与强行总结
2. 防御性引号的泛滥
3. 伪造的口语润滑剂

让我检查一下该文件的内容。


我需要查看 POWELL_REVISE.md 文件的具体内容，特别是与禁止八股文结构、防御性引号和口语润滑剂相关的部分。


# POWELL_REVISE.md
# AI味去除与文章修订规范

> **使用场景**: 当你与AI探讨并生成文章后，使用本规范对文章进行"去AI味"修订，使其更接近真实的个人表达。

---

## 修订原则总览

**核心理念**: 保持你的思想内核和逻辑骨架，去除AI生成痕迹，让文字更有"人味"。

**选定策略**:
- 语气强度: 保持原风格，仅去AI痕迹
- 文章结构: 保持清晰结构（1, 2, 3 列表和标题）
- 减少格式: 除了必要的分段，最好不要使用Markdown语法
- 禁止括号: 删除所有解释性括号（含术语解释、补充说明），无例外
- 词汇处理: 删除过渡词堆积 + 打破排比对仗 + 增加口语化 + 保留技术准确性
- 情绪表达: 保持冷静客观，通过细节增加人味；比喻是最后手段，只在真正更清晰时用

---

## 零、七大写作原则（最高优先级）

在应用后续所有规则之前，先对齐这七条原则：

**1. 问题导入，禁止上帝视角**
开头必须从一个具体困惑、矛盾或场景切入。禁止以"X是Y的核心"、"在当今时代，Z越来越重要"这类全知叙述开场。

**2. 剃刀原则：删句优先于改句**
每一句话，问自己：删掉它，意思有没有损失？如果没有，直接删。不要改写冗余句，删除它。

**3. 戛然而止，禁止首尾呼应**
最后一句有实质信息量就停。不做总结，不回望全文。绝对禁止把开头的论点原样搬回结尾——同一个观点在首尾各说一遍是最典型的AI结构。

**4. 只说自己的判断，拒绝"理客中"**
禁止"有些人认为X，也有人认为Y"的防御性写法。直接说你认为什么。明确禁用"我可以理解他们的焦虑"、"当然，任何事物都有两面性"、"不可否认"——这类先共情再否定的套路是伪装成理性的逃避。

**5. 比喻是最后手段**
只有比喻真正比直说更清晰时才用，不要为了"有人味"而强行加。全篇最多1个，没有也完全可以。

**6. 隐去第一人称**
严禁泛滥使用"我"（"我一直有个观点"、"我在读源码时发现"、"我建议"）。观点和事实自己会说话，不要把"我"当成模拟人类语气的标点符号。偶尔一次没问题，全文每段都有就是问题。

**7. 强制主观判断**
陈述技术事实或描述某个工具/方案时，必须附带直接评价。不要只罗列特性，要说它暴力但有效、设计清爽、极度脆弱——这不是修辞，是信息密度的一部分。

**8. 词汇与概念降维**
绝对禁止使用"赋能"、"底层逻辑"、"仪式性努力"、"错失恐惧症"、"精神枷锁"等社科或互联网黑话。用具体的动作或大白话替代（例如：把"仪式性努力"直接说成"花钱买心理安慰"）。禁止为了结构工整而将事物强行拆分为"保守派/激进派"、"向内求索/向外扩展"等对称的伪概念。

**9. 受众基线预设**
默认受众是具备高级技术认知（如熟悉 Python 开发、系统架构）的同行。绝对禁止解释基础概念（如禁用"编程的本质是……"、"提示词的意思是……"）。不教育读者，只提供洞察。

**10. 封杀鸡汤与情绪抚慰**
禁止所有类似"危机就是转机"、"这是成长的机会"、"最重要的选择"等带有情绪抚慰和职场导师口吻的废话。

---

## 一、AI痕迹识别清单

### 1.1 必须删除的典型AI痕迹

**过度工整的过渡词**:
- ❌ "首先...其次...再次...最后..."
- ❌ "总而言之" "综上所述" "不难看出"
- ❌ "值得注意的是" "需要强调的是"
- ✅ 直接陈述，或用"btw" "其实" "说实话"

**机械的排比和对仗**:
- ❌ "不仅...而且..." "既...又..." "一方面...另一方面..."
- ❌ 三个并列短句字数完全相等的排比
- ✅ 自然的递进，长短句交错，不刻意对称

**过度平衡的表达**:
- ❌ "虽然有A的优势，但也存在B的挑战，因此我们需要综合考虑..."
- ✅ 直接说你的判断: "A确实有价值，但B才是真正的瓶颈"

**防御性引用**:
- ❌ "有些人认为X，也有人认为Y，因此我们需要综合考虑"
- ❌ "不少专家指出..." "研究表明..."（用来代替自己表态）
- ✅ 直接说"我认为X" 或 "这件事我的判断是X"

**空洞的修饰词堆积**:
- ❌ "深入、全面、系统地" "显著提升" "有效解决"
- ✅ 用具体数据或比喻: "3秒开服" "像拆毛坯房和简装房的区别"

**AI喜欢的万能结尾**:
- ❌ "让我们携手共进，共创美好未来"
- ❌ "这不仅是挑战，更是机遇"
- ✅ 具体的下一步或诚实的不确定: "接下来我们要做X" 或 "说实话，这事儿我还没完全想清楚"

### 1.2 典型AI句式模式

**问题句式**:
- ❌ "那么，我们应该如何解决这个问题呢？" (AI喜欢自问自答)
- ✅ 直接给方案或坦承: "这个问题其实没有银弹" 或 "我的解决思路是..."

**完美的三段论**:
- ❌ 提出问题(200字) → 分析问题(500字) → 解决问题(300字)
- ❌ "现象-本质-客观背景-解决方案"均分四段（最典型的AI骨架）
- ✅ 可以先说结论，或者中间插入自己的怀疑和修正

**宏大词汇轰炸**:
- ❌ "不可逆的浪潮" "现实的巨浪" "范式转移" "时代大背景" "深刻变革"
- ❌ 连续多段都在描述宏大趋势而非具体现象
- ✅ 换成可被验证的具体描述："npm 依赖一更新就挂" "狂烧 Token"

---

## 二、修订操作手册

### 2.1 结构层面（保持，但微调）

**保持的部分**:
- ✅ 清晰的标题层级（1, 2, 3...）
- ✅ 子弹点列表
- ✅ "背景-现状-挑战-下一步"的逻辑框架

**需要微调的部分**:
- 在标题之间加入"过渡思考"，如: "btw，这让我想到一个更本质的问题..."
- 标题不需要完美对称，可以有长有短，有陈述也有反问
- 可以在正文段落中间插入用"---"分隔的独立思考片段

### 2.2 句子层面（重点修订区）

**删除过渡词堆积**:
```
Before (AI味):
首先，我们需要理解云计算的本质。其次，要认识到标准化的重要性。最后，我们才能真正实现IT巴别塔的愿景。

After (去AI味):
云计算的本质是什么？说白了就是标准化。只有把底层标准化了，才有可能建成IT巴别塔。
```

**打破排比和对仗**:
```
Before (AI味):
我们既要保证性能，又要控制成本，还要提升体验。

After (去AI味):
性能肯定要保证。但成本控制才是长期可持续的关键，用户体验是这两者平衡后的结果。
```

**增加口语化表达**:
```
Before (AI味):
经过深入分析，我们认为该方案具有可行性。

After (去AI味):
这个方案能不能行？我觉得问题不大，主要是看执行。
```

**保留技术准确性**:
```
✅ 保持: CVM, Lighthouse, TTFT, P95, GPU, infra, winback
✅ 保持: 具体的性能数据 "3秒开服" "0.1%的概率"
✅ 保持: 专业术语的英文表达，不要强行翻译成中文
```

**禁用防御性引号**:
```
❌ 对普通词汇加引号制造焦点或保持距离: "神器" "数字分身" "抖机灵"
❌ 用引号免责: 这种所谓的"创新"...
✅ 直接说清楚评价，不需要引号来暗示你跟它有距离
```

**禁止连续复合长句**:
```
❌ 一段里全是逗号堆砌的长句，读者喘不过气
✅ 短句抛出观点，长句解释逻辑，信息过载时用句号截断
✅ 一句话里超过三个分句，考虑拆成两句
```

**段落长度**:
- AI喜欢每段150-200字，整齐划一
- 真人写作: 有的段落就一句话，有的可能300字，参差不齐

**思维插入**:
在段落间插入真实的思考过程:
```
[具体论述A]

其实我知道大家可能会觉得这个想法太理想化。我也对此持有疑虑，必须承认这一点。

但是...
```

**使用你的典型比喻**:
- 巴别塔、西西弗斯、福特生产线、魔法
- 服务器像宠物一样需要照顾

### 2.4 情绪和态度层面

**保持冷静客观，但加入细节**:
```
Before (AI味):
这个技术方案经过验证是可行的，能够有效解决问题。

After (去AI味):
这个方案我们试了三个月，踩了不少坑，但确实能work。最关键的是，它能把P95延迟压到50ms以下，这对我们的场景够用了。
```


我看到了问题所在。根据用户的反馈，我们需要在 POWELL_REVISE.md 文件中更严格地禁止以下几个 AI 痕迹：

1. 八股文骨架与强行总结
2. 防御性引号的泛滥
3. 伪造的口语润滑剂（如"说白了"、"说实话"）

让我更新 POWELL_REVISE.md 文件来解决这些问题。


Edit

**过度工整的过渡词**:
- ❌ "首先...其次...再次...最后..."
- ❌ "总而言之" "综上所述" "不难看出"
- ❌ "值得注意的是" "需要强调的是"
- ✅ 直接陈述，或用"btw" "其实" "说实话"

**过度工整的过渡词**:
- ❌ "首先...其次...再次...最后..."
- ❌ "总而言之" "综上所述" "不难看出"
- ❌ "值得注意的是" "需要强调的是"
- ❌ "说白了" "说实话"
- ✅ 直接陈述，或用"btw" "其实" (仅在必要时使用)