     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→        built_rich_prompt = False  # tracks whether we ran the full question->draft pipeline
   478→
   479→        # Pre-load rule files (needed for both generation and second-pass review)
   480→        zhihu_bot_style = self._read_file("zhihu-bot-style.md")
   481→        powell_revise_guide = self._read_file("POWELL_REVISE.md")
   482→
   483→        # Treat as a new question if:
   484→        # - history is empty (genuine first turn), OR
   485→        # - user input contains "|" (explicit "Question | Core Ideas" format), OR
   486→        # - user input starts with known question triggers like "回答这个问题"
   487→        is_new_question = (
   488→            is_first_turn
   489→            or "|" in user_input
   490→            or user_input.strip().startswith("回答这个问题")
   491→        )
   492→
   493→        if is_new_question:
   494→            # === First Turn: Build Rich Prompt ===
   495→            # Expected input format: "Question | Core Ideas"
   496→            parts = user_input.split("|", 1)
   497→            question = parts[0].strip()
   498→            core_ideas = parts[1].strip() if len(parts) > 1 else ""
   499→
   500→            # 1. Load Style Guide (zhihu-style.md serves as writing standard, NOT as material)
   501→            zhihu_style_guide = self._read_file("zhihu-style.md")
   502→            # Bot-specific overrides (plain-text output, no bracket explanations, etc.)
   503→            if zhihu_bot_style:
   504→                zhihu_style_guide = zhihu_style_guide + "\n\n" + zhihu_bot_style
   505→
   506→            # 2. RAG from Obsidian vault for actual content material
   507→            rag_notes = self.indexer.search(question, limit=5)
   508→            rag_text = "\n\n".join(rag_notes)
   509→
   510→            # 3. Load methodology
   511→            methodology = self._read_file("methodology.md")
   512→
   513→            semantic_context = self._semantic_search(question, top_k=3)
   514→
   515→            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.
   516→
   517→=== YOUR IDENTITY & METHODOLOGY ===
   518→{methodology}
   519→
   520→=== ANTI-AI-TONE GUIDE (APPLY WHILE WRITING, NOT JUST AT THE END) ===
   521→{powell_revise_guide}
   522→
   523→=== ZHIHU STYLE GUIDE (FOLLOW THESE WRITING PRINCIPLES) ===
   524→{zhihu_style_guide}
   525→
   526→CRITICAL: The style guide above defines HOW to write (tone, structure, language patterns).
   527→Do NOT treat zhihu-sample content as factual material. It's ONLY for style reference.
   528→
   529→=== RELEVANT KNOWLEDGE FROM YOUR OBSIDIAN VAULT (USE AS ACTUAL MATERIAL) ===
   530→{rag_text}
   531→
   532→{semantic_context}
   533→
   534→=== THE QUESTION ===
   535→{question}
   536→
   537→=== YOUR CORE IDEAS ===
   538→{core_ideas}
   539→
   540→=== TASK ===
   541→1. Answer the question based on YOUR knowledge (from Obsidian vault) and core ideas
   542→2. FOLLOW THE ANTI-AI-TONE GUIDE FIRST, then the Zhihu style guide
   543→3. Share real experiences, examples, and insights from the vault material
   544→4. Write in Chinese (unless question implies English)
   545→5. Minimize first person "我": use it sparingly, not as filler. Observations and judgments can stand without "我认为" or "我觉得" as prefix.
   546→6. Be direct and opinionated. Attach a concrete evaluation to every fact or feature you mention (e.g. "暴力但有效", "极度脆弱"). Pure feature listing is forbidden.
   547→7. AVOID AT ALL COSTS: AI tone, empty buzzwords, hollow phrases, and 宏大叙事 (不可逆的浪潮, 范式转移, 时代大背景).
   548→8. If recent web context is provided, weave it in naturally — like someone who reads a lot online
   549→9. STRICT: Never add bracket explanations for any term. Write "大语言模型" not "大语言模型（LLM）". If a term needs explaining, use a sentence, not parentheses.
   550→10. No public account names, no self-promotion, no引流 of any kind — this is the highest priority rule.
   551→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.
   552→12. STRICT: Never reference past projects, articles, or talks by name.
   553→13. No poetry, quotes, or "升华" endings. No首尾呼应 (repeating the opening point at the end). Stop when the point is made.
   554→14. LENGTH: 500–1000 Chinese characters. Cut a point, not quality.
   555→
   556→=== BEFORE SUBMITTING, CHECK THESE ===
   557→- Is there any AI-generated tone or phrasing?
   558→- Are there any buzzwords or jargon that need to be replaced with plain language?
   559→- Is the answer direct and opinionated, not neutral or "balanced"?
   560→- Does it sound like a real human wrote it, not a machine?
   561→
   562→Begin writing the answer:
   563→"""
   564→
   565→            logger.info(f"Generating Zhihu answer for: {question[:50]}...")
   566→            actual_user_message = prompt
   567→            built_rich_prompt = True
   568→
   569→        else:
   570→            # === Follow-up Turn: User gives refinement instructions (e.g. "make it shorter") ===
   571→            logger.info(f"Follow-up Zhihu instruction: {user_input}")
   572→            actual_user_message = user_input
   573→            prompt = user_input
   574→
   575→        # Call LLM — first pass: content generation
   576→        # For new questions, use empty history to avoid contamination from previous conversations
   577→        llm_history = [] if is_new_question else history
   578→        draft, _ = self.llm.generate_response(prompt, llm_history)
   579→
   580→        # === Second pass: rule enforcement ===
   581→        # Runs whenever a full draft was generated (built_rich_prompt=True),
   582→        # regardless of whether history was empty or not.
   583→        # Skipped on follow-up refinement turns (e.g. "make it shorter").
   584→        run_review = built_rich_prompt
   585→        if run_review:
   586→            review_prompt = f"""You are a strict editor. A draft Zhihu answer has been written. Your only job is to fix rule violations and return the corrected text. Do NOT change the content, facts, or structure unless they directly violate a rule below.
   587→
   588→=== RULES TO ENFORCE ===
   589→
   590→{zhihu_bot_style}
   591→
   592→{powell_revise_guide}
   593→
   594→=== SPECIFIC VIOLATIONS TO SCAN AND FIX ===
   595→1. 对比式定义句式：删除或改写所有"不是X而是Y"、"不能当X用得当Y用"、"那不叫X那叫Y"、"别把X当Y要把X当Z"句式。直接陈述即可。
   596→2. 编号分段：删除所有"一、二、三、四"序号标题，改为无标题段落自然过渡。
   597→3. 防御性引号：删除所有套在归纳性词汇上的引号（如"数字替身"、"包工头模式"、"统一入口"、"调教"等）。
   598→4. 伪口语：删除"说白了"、"说实话"、"折腾这玩意儿"。
   599→5. 强行总结：删除任何"一句话建议"、"总结一句话"、"综上"式收尾段。
   600→6. 句长均匀：如果连续三句以上长度相近，拆短其中至少一句，制造落差。
   601→7. 嵌套数字列表：删除正文中"1. 2. 3."的子列表，改为连续段落。
   602→
   603→=== DRAFT TO REVIEW ===
   604→{draft}
   605→
   606→=== OUTPUT ===
   607→Return ONLY the corrected Chinese text. No explanation, no meta-commentary, no English.
   608→"""
   609→            logger.info("Running second-pass rule enforcement on Zhihu draft...")
   610→            response, _ = self.llm.generate_response(review_prompt, [])
   611→        else:
   612→            response = draft
   613→
   614→        # Update History
   615→        new_history = history.copy()
   616→        new_history.append({"role": "user", "content": actual_user_message})
   617→        new_history.append({"role": "assistant", "content": response})
   618→
   619→        return response, new_history

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

if TYPE_CHECKING:
    from slack_bot.obsidian.vector_store import ChromaVectorStore

logger = setup_logger(__name__)

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

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

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

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

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

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

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

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

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

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

        is_first_turn = (len(history) == 0)

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

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

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

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

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

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

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

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

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

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

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

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

            {semantic_context}

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

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

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

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

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

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

        return response, new_history

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

        is_first_turn = (len(history) == 0)

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

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

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

            === CORE PHILOSOPHY ===
            {methodology}

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

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

            {dynamic_samples_text}

            {semantic_context}

            === THE SITUATION ===
            {user_input}

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

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

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

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

        return response, new_history

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

        is_first_turn = (len(history) == 0)

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

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

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

            === DECISION FRAMEWORK ===
            {decision_guide}

            === CORE PHILOSOPHY ===
            {methodology}

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

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

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

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

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

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

        return response, new_history

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

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

        is_first_turn = (len(history) == 0)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

        # Fallback if loop exhausted
        return response_text, exec_history

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

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

        is_first_turn = (len(history) == 0)

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

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

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

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

=== REVISION GUIDE ===
{powell_revise_guide}

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

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

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

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

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

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

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

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

        return response, new_history

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

        # === Second pass: rule enforcement ===
        # Runs whenever a full draft was generated (built_rich_prompt=True),
        # regardless of whether history was empty or not.
        # Skipped on follow-up refinement turns (e.g. "make it shorter").
        run_review = built_rich_prompt
        if run_review:
            review_prompt = f"""You are a strict editor. A draft Zhihu answer has been written. Your only job is to fix rule violations and return the corrected text. Do NOT change the content, facts, or structure unless they directly violate a rule below.

=== RULES TO ENFORCE ===

{zhihu_bot_style}

{powell_revise_guide}

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

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

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

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

        return response, new_history


Now I have a clear picture. Let me analyze the situation:

## Current Pipeline

### Generation step (WritingAssistant):
- Line 104-106: Loads `writing_style.md`, `methodology.md`, AND `POWELL_REVISE.md`
- Line 119: Explicitly labels it as `ANTI-AI-TONE GUIDE (APPLY WHILE WRITING, NOT JUST AT THE END)`
- So the generation step already has all the guides loaded

### DeAI Reviser (DeAIReviser class in generators.py):
- Line 419: Loads `POWELL_REVISE.md`

- Line 423: Also pulls in writing samples for style reference, which is solid

### /deai Claude Code command (`.claude/commands/deai.md`):
- This is a SEPARATE thing - it's a Claude Code slash command
- It does NOT reference `POWELL_REVISE.md` at all
- It has its own simplified, generic list of AI characteristics

### ZhihuGenerator:
- Has a two-pass system: generation + review
- Loads `POWELL_REVISE.md` for both passes
- Very thorough

## Analysis

The **bot's code** (generators.py) already properly integrates the anti-AI tone guide throughout the pipeline, so the core system is working as intended.

The real bottleneck is that **Gemini** (the LLM being used) doesn't follow instructions as strictly as needed, even with comprehensive guides. The guide files are already quite lengthy at 432 lines, and LLMs tend to lose focus on instructions as prompts get longer.

So the actual issues are: the guides themselves are already comprehensive and don't need expansion, the `/deai` command doesn't use the existing guides (though this is minor since the Obsidian bot has its own `DeAIReviser` class), and the LLM itself isn't adhering to the instructions as reliably as it should.

For the user's question, I don't need to modify the guidance documents—the rules already cover everything. The real problem is that `POWELL_REVISE.md` is too long at 430 lines, which causes the LLM's attention to degrade and many rules get overlooked. I could add a second-pass review system to `WritingAssistant` similar to what `ZhihuGenerator` does, and the `/deai` Claude Code command should reference `POWELL_REVISE.md` directly.


看完整个流水线后，结论是：**指导文档不需要改，问题不在规则缺失**。

你之前给我看的那段文字里的问题（比喻堆砌、大词空转、鸡汤结尾、语气不统一），`POWELL_REVISE.md` 里**全部已经覆盖**了——而且写得很详细。

真正的问题在**执行层**，有两个具体的点：

---

**1. `WritingAssistant` 缺少 second-pass review**

`ZhihuGenerator`（`generators.py:585-610`）有一个两阶段流程：先生成 draft，再跑一遍 rule enforcement review。但 `WritingAssistant`（`generators.py:83-155`）只有单次生成，没有 review pass。

这意味着 Obsidian bot 写文章时，虽然 prompt 里塞了 `POWELL_REVISE.md`（line 106），但 Gemini 在一次 pass 里同时要写内容+遵守一份 430 行的反 AI 规则，注意力必然衰减。

**建议**：给 `WritingAssistant` 也加一个类似知乎生成器的 second-pass review——先写，再检查修。这不是新增文档，是新增一个步骤。

**2. `/deai` Claude Code 命令没对齐**

`.claude/commands/deai.md` 用的是自己的一套简化规则（"Overly formal language", "Repetitive sentence structures" 这些泛泛的英文描述），完全没引用 `POWELL_REVISE.md`。而 bot 端的 `DeAIReviser` class 是正确引用了的（line 419）。

这导致你用 `/deai` 命令手动去 AI 味时，规则覆盖远不如 bot 端完整。

**建议**：`deai.md` 里加一行让它读取 `POWELL_REVISE.md` 作为主要参考，而不是用自己那套简化版。

---

**总结**：不加新文档，不改现有规则。做两件事——给 `WritingAssistant` 加 review pass，把 `/deai` 命令指向已有的 `POWELL_REVISE.md`。要不要做？


好的，就按你的建议来修改吧


The user wants me to implement the two changes:

1. Add a second-pass review to `WritingAssistant` (similar to `ZhihuGenerator`)
2. Update `.claude/commands/deai.md` to reference `POWELL_REVISE.md`

Let me plan this out:

### Change 1: WritingAssistant second-pass review
I need to add a review step after the initial draft generation in `WritingAssistant.chat()`. Looking at how `ZhihuGenerator` does it:
- It generates a draft first
- Then runs a review prompt that checks for rule violations

- The review prompt includes the powell_revise guide and specific violations to scan

For `WritingAssistant`, I should:
- After generating the draft (line 148), add a second pass
- Load `POWELL_REVISE.md` (already loaded at line 106)
- Create a review prompt similar to `ZhihuGenerator`'s but adapted for general articles

### Change 2: Update deai.md
Replace the generic English rules with a reference to read `POWELL_REVISE.md` and `writing_style.md`.

Let me implement both changes now.


TaskCreate

给 WritingAssistant 加 second-pass review

在 generators.py 的 WritingAssistant.chat() 中，生成 draft 后增加一个 review pass，参考 ZhihuGenerator 的实现方式，用 POWELL_REVISE.md 做规则检查。


更新 /deai 命令引用 POWELL_REVISE.md

修改 .claude/commands/deai.md，去掉自带的简化英文规则，改为读取 POWELL_REVISE.md 和 writing_style.md 作为主要修订参考。


Task #1 created successfully: 更新 /deai 命令引用 POWELL_REVISE.md

更新 /deai 命令引用 POWELL_REVISE.md


Task #2 created successfully: 给 WritingAssistant 加 second-pass review

给 WritingAssistant 加 second-pass review


Updated task #2 status


先改 `WritingAssistant`，参考 `ZhihuGenerator` 的 review pass 逻辑。


    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

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


Edit

        # 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

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

        # === Second pass: de-AI review (first turn only) ===
        if is_first_turn:
            powell_revise_guide = self._read_file("POWELL_REVISE.md")
            review_prompt = f"""You are a strict editor. A draft article has been written. Your only job is to fix AI-tone violations and return the corrected text. Do NOT change the content, facts, or structure unless they directly violate a rule below.

=== REVISION RULES ===
{powell_revise_guide}

=== SPECIFIC VIOLATIONS TO SCAN AND FIX ===
1. 比喻堆砌：全篇比喻不超过1个，多余的直接删除，用大白话替代。
2. 用力过猛的形容词：删除"灾难级的"、"折磨人"、"不止一个量级"等夸张修饰，换成具体描述。
3. 造概念/大词空转：删除"数字化容器"、"温和池架构"等抽象包装，用读者能直接理解的说法。
4. 鸡汤结尾：删除所有"值得跨越"、"安置灵魂"等励志收尾，改为具体行动建议或直接停住。
5. 语气不统一：如果开头是口语风格，全文保持口语；不要中途切换成书面语或抒情体。
6. 首尾呼应：禁止把开头观点在结尾重复一遍。
7. 过渡词堆积：删除"首先...其次...最后..."，直接陈述。
8. 排比对仗：打破字数相近的并列句，制造长短落差。

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

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

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

        return response, new_history


<tool_use_error>Found 2 matches of the string to replace, but replace_all is false. To replace all occurrences, set replace_all to true. To replace only one occurrence, please provide more context to uniquely identify the instance.
String:         # 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</tool_use_error>

Error: Found 2 matches of the string to replace, but replace_all is false. To replace all occurrences, set replace_all to true. To replace only one occurrence, please provide more context to uniquely identify the instance.
String:         # 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


            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

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

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

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

        # === Second pass: de-AI review (first turn only) ===
        if is_first_turn:
            powell_revise_guide = self._read_file("POWELL_REVISE.md")
            review_prompt = f"""You are a strict editor. A draft article has been written. Your only job is to fix AI-tone violations and return the corrected text. Do NOT change the content, facts, or structure unless they directly violate a rule below.

=== REVISION RULES ===
{powell_revise_guide}

=== SPECIFIC VIOLATIONS TO SCAN AND FIX ===
1. 比喻堆砌：全篇比喻不超过1个，多余的直接删除，用大白话替代。
2. 用力过猛的形容词：删除"灾难级的"、"折磨人"、"不止一个量级"等夸张修饰，换成具体描述。
3. 造概念/大词空转：删除"数字化容器"、"温和池架构"等抽象包装，用读者能直接理解的说法。
4. 鸡汤结尾：删除所有"值得跨越"、"安置灵魂"等励志收尾，改为具体行动建议或直接停住。
5. 语气不统一：如果开头是口语风格，全文保持口语；不要中途切换成书面语或抒情体。
6. 首尾呼应：禁止把开头观点在结尾重复一遍。
7. 过渡词堆积：删除"首先...其次...最后..."，直接陈述。
8. 排比对仗：打破字数相近的并列句，制造长短落差。

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

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

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

        return response, new_history


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

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

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

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

        # === Second pass: de-AI review (first turn only) ===
        if is_first_turn:
            powell_revise_guide = self._read_file("POWELL_REVISE.md")
            review_prompt = f"""You are a strict editor. A draft article has been written. Your only job is to fix AI-tone violations and return the corrected text. Do NOT change the content, facts, or structure unless they directly violate a rule below.

=== REVISION RULES ===
{powell_revise_guide}

=== SPECIFIC VIOLATIONS TO SCAN AND FIX ===
1. 比喻堆砌：全篇比喻不超过1个，多余的直接删除，用大白话替代。
2. 用力过猛的形容词：删除"灾难级的"、"折磨人"、"不止一个量级"等夸张修饰，换成具体描述。
3. 造概念/大词空转：删除"数字化容器"、"温和池架构"等抽象包装，用读者能直接理解的说法。
4. 鸡汤结尾：删除所有"值得跨越"、"安置灵魂"等励志收尾，改为具体行动建议或直接停住。
5. 语气不统一：如果开头是口语风格，全文保持口语；不要中途切换成书面语或抒情体。
6. 首尾呼应：禁止把开头观点在结尾重复一遍。
7. 过渡词堆积：删除"首先...其次...最后..."，直接陈述。
8. 排比对仗：打破字数相近的并列句，制造长短落差。

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

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

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

        return response, new_history

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)
        built_rich_prompt = False  # tracks whether we ran the full question->draft pipeline

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

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

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

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

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

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

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

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

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

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

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

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

        # === Second pass: rule enforcement ===
        # Runs whenever a full draft was generated (built_rich_prompt=True),
        # regardless of whether history was empty or not.
        # Skipped on follow-up refinement turns (e.g. "make it shorter").
        run_review = built_rich_prompt
        if run_review:
            review_prompt = f"""You are a strict editor. A draft Zhihu answer has been written. Your only job is to fix rule violations and return the corrected text. Do NOT change the content, facts, or structure unless they directly violate a rule below.

=== RULES TO ENFORCE ===

{zhihu_bot_style}

{powell_revise_guide}

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

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

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

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

        return response, new_history

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

+        # Call LLM — first pass: content generation

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

+        # === Second pass: de-AI review (first turn only) ===

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

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

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

+1. 比喻堆砌：全篇比喻不超过1个，多余的直接删除，用大白话替代。

+2. 用力过猛的形容词：删除"灾难级的"、"折磨人"、"不止一个量级"等夸张修饰，换成具体描述。

+3. 造概念/大词空转：删除"数字化容器"、"温和池架构"等抽象包装，用读者能直接理解的说法。

+4. 鸡汤结尾：删除所有"值得跨越"、"安置灵魂"等励志收尾，改为具体行动建议或直接停住。

+5. 语气不统一：如果开头是口语风格，全文保持口语；不要中途切换成书面语或抒情体。

+6. 首尾呼应：禁止把开头观点在结尾重复一遍。

+7. 过渡词堆积：删除"首先...其次...最后..."，直接陈述。

+8. 排比对仗：打破字数相近的并列句，制造长短落差。

+Return ONLY the corrected text. No explanation, no meta-commentary.

+            logger.info("Running second-pass de-AI review on writing draft...")

+            response, _ = self.llm.generate_response(review_prompt, [])


Updated task #1 status


现在改 `/deai` 命令。