/root/projects/butler/health/utils/__init__.py
/root/projects/butler/health/utils/date_utils.py
/root/projects/butler/health/utils/exceptions.py
/root/projects/butler/health/__init__.py
/root/projects/butler/tag20260215/health/utils/__init__.py
/root/projects/butler/tag20260215/health/utils/date_utils.py
/root/projects/butler/tag20260215/health/utils/exceptions.py
/root/projects/butler/tag20260215/health/__init__.py
/root/projects/butler/tag20260302/health/utils/__init__.py
/root/projects/butler/tag20260302/health/utils/date_utils.py
/root/projects/butler/tag20260302/health/utils/exceptions.py
/root/projects/butler/tag20260302/health/__init__.py
/root/projects/butler/health/db/__init__.py
/root/projects/butler/health/db/schema.py
/root/projects/butler/tag20260215/health/db/__init__.py
/root/projects/butler/tag20260215/health/db/schema.py
/root/projects/butler/tag20260302/health/db/__init__.py
/root/projects/butler/tag20260302/health/db/schema.py
/root/projects/butler/health/db/repository.py
/root/projects/butler/tag20260215/health/db/repository.py
/root/projects/butler/tag20260302/health/db/repository.py
/root/projects/butler/health/models/__init__.py
/root/projects/butler/health/models/sync_metadata.py
/root/projects/butler/tag20260215/health/models/__init__.py
/root/projects/butler/tag20260215/health/models/sync_metadata.py
/root/projects/butler/tag20260302/health/models/__init__.py
/root/projects/butler/tag20260302/health/models/sync_metadata.py
/root/projects/butler/health/services/__init__.py
/root/projects/butler/tag20260215/health/services/__init__.py
/root/projects/butler/tag20260302/health/services/__init__.py
/root/projects/butler/tag20260215/health/services/storage.py
/root/projects/butler/tag20260302/health/services/storage.py
/root/projects/butler/health/cli/__init__.py
/root/projects/butler/health/cli/sync.py
/root/projects/butler/tag20260215/health/cli/__init__.py
/root/projects/butler/tag20260215/health/cli/sync.py
/root/projects/butler/tag20260302/health/cli/__init__.py
/root/projects/butler/tag20260302/health/cli/sync.py
/root/projects/butler/scripts/setup_health.py
/root/projects/butler/tag20260215/scripts/setup_health.py
/root/projects/butler/tag20260302/scripts/setup_health.py
/root/projects/butler/skills/health_sync.py
/root/projects/butler/tag20260215/skills/health_sync.py
/root/projects/butler/tag20260302/skills/health_sync.py
/root/projects/butler/health/models/body_metrics.py
/root/projects/butler/tag20260215/health/models/body_metrics.py
/root/projects/butler/tag20260302/health/models/body_metrics.py
/root/projects/butler/scripts/verify_system.py
/root/projects/butler/tag20260215/scripts/verify_system.py
/root/projects/butler/tag20260302/scripts/verify_system.py
/root/projects/butler/health/models/activity.py
/root/projects/butler/tag20260215/health/models/activity.py
/root/projects/butler/tag20260302/health/models/activity.py
/root/projects/butler/tag20260215/health/models/daily_metrics.py
/root/projects/butler/tag20260302/health/models/daily_metrics.py
/root/projects/butler/tag20260215/tests/__init__.py
/root/projects/butler/tests/__init__.py
/root/projects/butler/tag20260302/tests/__init__.py
/root/projects/butler/tag20260215/tests/test_garmin_client_new_features.py
/root/projects/butler/tests/test_garmin_client_new_features.py
/root/projects/butler/tag20260302/tests/test_garmin_client_new_features.py
/root/projects/butler/health/services/query.py
/root/projects/butler/tag20260215/health/services/query.py
/root/projects/butler/tag20260302/health/services/query.py
/root/projects/butler/scripts/query_examples.py
/root/projects/butler/tag20260215/scripts/query_examples.py
/root/projects/butler/tag20260302/scripts/query_examples.py
/root/projects/butler/scripts/resync_steps.py
/root/projects/butler/tag20260215/scripts/resync_steps.py
/root/projects/butler/tag20260302/scripts/resync_steps.py
/root/projects/butler/scripts/daily_sync.py
/root/projects/butler/tag20260215/scripts/daily_sync.py
/root/projects/butler/tag20260302/scripts/daily_sync.py
/root/projects/butler/tag20260215/health/services/garmin_client.py
/root/projects/butler/tag20260302/health/services/garmin_client.py
/root/projects/butler/health/cli/query.py
/root/projects/butler/tag20260215/health/cli/query.py
/root/projects/butler/tag20260302/health/cli/query.py
/root/projects/butler/health/services/data_sync.py
/root/projects/butler/tag20260215/health/services/data_sync.py
/root/projects/butler/tag20260302/health/services/data_sync.py
/root/projects/butler/health/models/manual_log.py
/root/projects/butler/tag20260215/health/models/manual_log.py
/root/projects/butler/tag20260302/health/models/manual_log.py
/root/projects/butler/health/services/manual_log_storage.py
/root/projects/butler/tag20260215/health/services/manual_log_storage.py
/root/projects/butler/tag20260302/health/services/manual_log_storage.py
/root/projects/butler/health/cli/manual.py
/root/projects/butler/tag20260215/health/cli/manual.py
/root/projects/butler/tag20260302/health/cli/manual.py
/root/projects/butler/health/services/obsidian.py
/root/projects/butler/tag20260215/health/services/obsidian.py
/root/projects/butler/tag20260302/health/services/obsidian.py
/root/projects/butler/health/cli/obsidian.py
/root/projects/butler/tag20260215/health/cli/obsidian.py
/root/projects/butler/tag20260302/health/cli/obsidian.py
/root/projects/butler/skills/obsidian_sync.py
/root/projects/butler/tag20260215/skills/obsidian_sync.py
/root/projects/butler/tag20260302/skills/obsidian_sync.py
/root/projects/butler/scripts/analyze_recent_health.py
(Results are truncated. Consider using a more specific path or pattern.)


让我查看一下项目中与知乎模式（zhihu mode）相关的代码。我需要找到处理这个模式的相关文件。


我需要查看项目中与知乎模式（zhihu mode）相关的代码，以确定 `zhihu-bot-style.md` 是否被使用。首先检查 `slack_bot` 目录，因为通常这种模式会在聊天机器人代码中。


./scripts/obsidian_bot.py:    dispatcher.zhihu_gateway.register(app)
./scripts/zhihu_debug_publish.py:    python scripts/zhihu_debug_publish.py \
./scripts/zhihu_debug_publish.py:        --url "https://www.zhihu.com/question/1992659642908702603"
./scripts/zhihu_debug_publish.py:STATE_FILE = Path(__file__).resolve().parents[1].parent / "data" / "zhihu_state.json"
./scripts/zhihu_debug_publish.py:SCREENSHOT_DIR = Path("/tmp/zhihu_debug")
./scripts/zhihu_e2e_test.py:    python scripts/zhihu_e2e_test.py \
./scripts/zhihu_e2e_test.py:        --url "https://www.zhihu.com/question/2012492934491108892" \
./scripts/zhihu_e2e_test.py:logger = setup_logger("zhihu_e2e_test")
./scripts/zhihu_e2e_test.py:    from slack_bot.zhihu.zhihu_hunter import ZhihuHunter, ZhihuQuestion
./scripts/zhihu_e2e_test.py:    from slack_bot.zhihu.zhihu_playwright_engine import ZhihuPlaywrightEngine
./scripts/zhihu_e2e_test.py:    vault_zhihu_dir = vault_path / "zhihu"
./scripts/zhihu_e2e_test.py:    vault_zhihu_dir.mkdir(parents=True, exist_ok=True)
./scripts/zhihu_e2e_test.py:    vault_file = vault_zhihu_dir / f"{date_str}-{slug}.md"
./scripts/zhihu_e2e_test.py:        f"status: draft\ntags: [zhihu]\n---\n\n# {question.title}\n\n{draft.content}\n",
./slack_bot/obsidian/dispatcher.py:    MODE_ZHIHU = "zhihu"
./slack_bot/obsidian/dispatcher.py:    MODE_ZHIHU_HUNTER = "zhihu-hunter"
./slack_bot/obsidian/dispatcher.py:        from slack_bot.zhihu.zhihu_hunter import ZhihuHunter
./slack_bot/obsidian/dispatcher.py:        from slack_bot.zhihu.zhihu_playwright_engine import ZhihuPlaywrightEngine
./slack_bot/obsidian/dispatcher.py:        from slack_bot.zhihu.slack_interactive_gateway import SlackInteractiveGateway
./slack_bot/obsidian/dispatcher.py:        zhihu_notify_channel = os.environ.get("ZHIHU_NOTIFY_CHANNEL", "")
./slack_bot/obsidian/dispatcher.py:        self.zhihu_hunter = ZhihuHunter(
./slack_bot/obsidian/dispatcher.py:        _zhihu_engine = ZhihuPlaywrightEngine(
./slack_bot/obsidian/dispatcher.py:            notify_channel=zhihu_notify_channel,
./slack_bot/obsidian/dispatcher.py:        self.zhihu_gateway = SlackInteractiveGateway(
./slack_bot/obsidian/dispatcher.py:            hunter=self.zhihu_hunter,
./slack_bot/obsidian/dispatcher.py:            engine=_zhihu_engine,
./slack_bot/obsidian/dispatcher.py:            notify_channel=zhihu_notify_channel,
./slack_bot/obsidian/dispatcher.py:            self._reply(channel_id, "⚠ No mode selected. Use `mode write/reply/decide/search/deai/zhihu/note/zhihu-hunter` to start.", response_ts)
./slack_bot/obsidian/dispatcher.py:        #   https://www.zhihu.com/question/... → direct URL: answer this specific question
./slack_bot/obsidian/dispatcher.py:            from slack_bot.zhihu.zhihu_hunter import DIR_SHORTCUTS
./slack_bot/obsidian/dispatcher.py:                    questions = self.zhihu_hunter.scan_and_hunt(since_days=7)
./slack_bot/obsidian/dispatcher.py:                elif words and 'zhihu.com/question/' in words[0]:
./slack_bot/obsidian/dispatcher.py:                    questions = self.zhihu_hunter.hunt_direct_url(url)
./slack_bot/obsidian/dispatcher.py:                    questions = self.zhihu_hunter.scan_glob_files(pattern)
./slack_bot/obsidian/dispatcher.py:                    questions = self.zhihu_hunter.scan_single_file(rel_path)
./slack_bot/obsidian/dispatcher.py:                    questions = self.zhihu_hunter.scan_and_hunt(
./slack_bot/obsidian/dispatcher.py:                    questions = self.zhihu_hunter._find_questions(words)
./slack_bot/obsidian/dispatcher.py:                self.zhihu_gateway.post_question_cards(questions, channel=channel_id)
./slack_bot/obsidian/generators.py:            # 1. Load Style Guide (zhihu-style.md serves as writing standard, NOT as material)
./slack_bot/obsidian/generators.py:            zhihu_style_guide = self._read_file("zhihu-style.md")
./slack_bot/obsidian/generators.py:            zhihu_bot_style = self._read_file("zhihu-bot-style.md")
./slack_bot/obsidian/generators.py:            if zhihu_bot_style:
./slack_bot/obsidian/generators.py:                zhihu_style_guide = zhihu_style_guide + "\n\n" + zhihu_bot_style
./slack_bot/obsidian/generators.py:{zhihu_style_guide}
./slack_bot/obsidian/generators.py:Do NOT treat zhihu-sample content as factual material. It's ONLY for style reference.
./slack_bot/zhihu/zhihu_playwright_engine.py:- Persistent session via zhihu_state.json (cookies)
./slack_bot/zhihu/zhihu_playwright_engine.py:ZHIHU_SIGNIN_URL = "https://www.zhihu.com/signin"
./slack_bot/zhihu/zhihu_playwright_engine.py:ZHIHU_HOME_URL   = "https://www.zhihu.com/"
./slack_bot/zhihu/zhihu_playwright_engine.py:_QR_TMP_PATH      = Path("/tmp/zhihu_qr.png")
./slack_bot/zhihu/zhihu_playwright_engine.py:                Defaults to <project_root>/data/zhihu_state.json.
./slack_bot/zhihu/zhihu_playwright_engine.py:            Path(__file__).resolve().parents[3] / "data" / "zhihu_state.json"
./slack_bot/zhihu/zhihu_playwright_engine.py:            "请稍后重新发起 `mode zhihu-hunter` 流程。"
./slack_bot/zhihu/zhihu_playwright_engine.py:                    filename="zhihu_qr.png",
./slack_bot/zhihu/zhihu_playwright_engine.py:                    lambda url: "zhihu.com" in url and "signin" not in url,
./slack_bot/zhihu/zhihu_playwright_engine.py:                filename="zhihu_debug.png",
./slack_bot/zhihu/zhihu_playwright_engine.py:            "update _WRITE_ANSWER_SELECTORS in zhihu_playwright_engine.py"
./slack_bot/zhihu/zhihu_playwright_engine.py:                "Update _EDITOR_SELECTORS in zhihu_playwright_engine.py"
./slack_bot/zhihu/zhihu_playwright_engine.py:            "Update _PUBLISH_SELECTORS in zhihu_playwright_engine.py"
./slack_bot/zhihu/zhihu_hunter.py:_MIN_QUESTION_URL_RE = re.compile(r'zhihu\.com/question/\d+')
./slack_bot/zhihu/zhihu_hunter.py:            url: A zhihu.com/question/<id> URL.
./slack_bot/zhihu/zhihu_hunter.py:        state_file = Path(__file__).resolve().parents[3] / "data" / "zhihu_state.json"
./slack_bot/zhihu/zhihu_hunter.py:                exclude_domains=["zhihu.com"],
./slack_bot/zhihu/zhihu_hunter.py:                        continue  # skip non-question URLs (e.g. zhihu.com/people/...)
./slack_bot/zhihu/zhihu_hunter.py:        """Run a single Tavily search scoped to zhihu.com/question.
./slack_bot/zhihu/zhihu_hunter.py:        query = f"site:zhihu.com/question {keyword}"
./slack_bot/zhihu/zhihu_hunter.py:            include_domains=["zhihu.com"],
./slack_bot/zhihu/slack_interactive_gateway.py:"""Slack Interactive Gateway — Block Kit control panel for zhihu-hunter.
./slack_bot/zhihu/slack_interactive_gateway.py:from slack_bot.zhihu.zhihu_hunter import AnswerDraft, ZhihuHunter, ZhihuQuestion
./slack_bot/zhihu/slack_interactive_gateway.py:from slack_bot.zhihu.zhihu_playwright_engine import ZhihuPlaywrightEngine
./slack_bot/zhihu/slack_interactive_gateway.py:_ACT_GENERATE = "zhihu_generate_draft"
./slack_bot/zhihu/slack_interactive_gateway.py:_ACT_IGNORE   = "zhihu_ignore"
./slack_bot/zhihu/slack_interactive_gateway.py:_ACT_PUBLISH  = "zhihu_publish"
./slack_bot/zhihu/slack_interactive_gateway.py:_ACT_REGEN    = "zhihu_regenerate"
./slack_bot/zhihu/slack_interactive_gateway.py:_ACT_CANCEL   = "zhihu_cancel"
./slack_bot/zhihu/slack_interactive_gateway.py:_VIEW_REGEN   = "zhihu_regen_modal"
./slack_bot/zhihu/slack_interactive_gateway.py:    """Block Kit gateway providing two approval gates for the zhihu-hunter workflow.
./slack_bot/zhihu/slack_interactive_gateway.py:            Defaults to <project_root>/data/zhihu_drafts.
./slack_bot/zhihu/slack_interactive_gateway.py:            Path(__file__).resolve().parents[3] / "data" / "zhihu_drafts"
./slack_bot/zhihu/slack_interactive_gateway.py:        """Persist a draft as a Markdown file in <vault>/zhihu/.
./slack_bot/zhihu/slack_interactive_gateway.py:            zhihu_dir = self.hunter.vault_path / "zhihu"
./slack_bot/zhihu/slack_interactive_gateway.py:            zhihu_dir.mkdir(parents=True, exist_ok=True)
./slack_bot/zhihu/slack_interactive_gateway.py:            file_path = zhihu_dir / f"{date_str}-{slug}.md"
./slack_bot/zhihu/slack_interactive_gateway.py:                f"tags: [zhihu]\n"
./tag20260215/slack_bot/obsidian/dispatcher.py:    MODE_ZHIHU = "zhihu"
./tag20260215/slack_bot/obsidian/dispatcher.py:            self._reply(channel_id, "⚠ No mode selected. Use `mode write/reply/decide/search/deai/zhihu` to start.", response_ts)
./tag20260215/slack_bot/obsidian/generators.py:            # 1. Load Style Guide (zhihu-style.md serves as writing standard, NOT as material)
./tag20260215/slack_bot/obsidian/generators.py:            zhihu_style_guide = self._read_file("zhihu-style.md")
./tag20260215/slack_bot/obsidian/generators.py:{zhihu_style_guide}
./tag20260215/slack_bot/obsidian/generators.py:Do NOT treat zhihu-sample content as factual material. It's ONLY for style reference.
./tag20260302/slack_bot/obsidian/dispatcher.py:    MODE_ZHIHU = "zhihu"
./tag20260302/slack_bot/obsidian/dispatcher.py:            self._reply(channel_id, "⚠ No mode selected. Use `mode write/reply/decide/search/deai/zhihu` to start.", response_ts)
./tag20260302/slack_bot/obsidian/generators.py:            # 1. Load Style Guide (zhihu-style.md serves as writing standard, NOT as material)
./tag20260302/slack_bot/obsidian/generators.py:            zhihu_style_guide = self._read_file("zhihu-style.md")
./tag20260302/slack_bot/obsidian/generators.py:{zhihu_style_guide}
./tag20260302/slack_bot/obsidian/generators.py:Do NOT treat zhihu-sample content as factual material. It's ONLY for style reference.


很好，我找到了关键信息！在 `./slack_bot/obsidian/generators.py` 文件中，我可以看到代码已经在使用 `zhihu-bot-style.md` 文件了。让我查看具体的代码实现。


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

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

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 chat(self, user_input: str, history: List[Dict]) -> Tuple[str, List[Dict]]:
        """
        Processes chat input.
        If history is empty, treats input as a Topic/Query and builds a Rich Prompt.
        If history exists, treats input as follow-up instruction.
        Returns (response_text, updated_history)
        """
        raise NotImplementedError

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

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

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

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

        is_first_turn = (len(history) == 0)

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

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

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

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

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

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

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

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

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

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

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

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

            {semantic_context}

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

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

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

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

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

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

        return response, new_history

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

        is_first_turn = (len(history) == 0)

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

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

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

            === CORE PHILOSOPHY ===
            {methodology}

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

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

            {dynamic_samples_text}

            {semantic_context}

            === THE SITUATION ===
            {user_input}

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

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

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

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

        return response, new_history

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

        is_first_turn = (len(history) == 0)

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

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

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

            === DECISION FRAMEWORK ===
            {decision_guide}

            === CORE PHILOSOPHY ===
            {methodology}

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

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

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

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

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

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

        return response, new_history

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

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

        is_first_turn = (len(history) == 0)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

        # Fallback if loop exhausted
        return response_text, exec_history

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

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

        is_first_turn = (len(history) == 0)

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

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

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

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

=== REVISION GUIDE ===
{powell_revise_guide}

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

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

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

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

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

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

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

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

        return response, new_history

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

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

        is_first_turn = (len(history) == 0)

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

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

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

            # 3. Load methodology and anti-AI-tone guide
            methodology = self._read_file("methodology.md")
            powell_revise_guide = self._read_file("POWELL_REVISE.md")

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

            prompt = f"""You are answering a Zhihu question. Your goal is to write an authentic, experience-driven answer.

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

=== ANTI-AI-TONE GUIDE (APPLY WHILE WRITING) ===
{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 Zhihu style guide and ANTI-AI-TONE GUIDE strictly
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 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.

Begin writing the answer:
"""

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

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

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

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

        return response, new_history


很好，我找到了问题所在。在 `slack_bot/obsidian/generators.py` 文件中的 `ZhihuGenerator` 类中，代码确实已经在使用 `zhihu-bot-style.md` 文件了。

具体来说，在第 495-499 行：
```python
# 1. Load Style Guide (zhihu-style.md serves as writing standard, NOT as material)
zhihu_style_guide = self._read_file("zhihu-style.md")
# Bot-specific overrides (plain-text output, no bracket explanations, etc.)
zhihu_bot_style = self._read_file("zhihu-bot-style.md")
if zhihu_bot_style:
    zhihu_style_guide = zhihu_style_guide + "\n\n" + zhihu_bot_style
```

这意味着当使用 `mode zhihu` 时，代码会同时加载 `zhihu-style.md` 和 `zhihu-bot-style.md`，并将它们合并在一起作为风格指南。

但是，我注意到在 `ZhihuGenerator` 类的 prompt 中，虽然加载了 `powell_revise_guide`，但它被放在了 "ANTI-AI-TONE GUIDE" 部分（第 516-517 行），而不是与风格指南合并。

我还注意到，在 `WritingAssistant` 类中，`powell_revise_guide` 被明确标记为 "APPLY WHILE WRITING, NOT JUST AT THE END"，但在 `ZhihuGenerator` 类中，这个注释被简化成了 "APPLY WHILE WRITING"。

为了进一步改善 AI 味的问题，我建议：
1. 确保 `powell_revise_guide` 的内容在生成过程中被充分应用
2. 可能需要调整 prompt 中各部分的顺序或强调程度
3. 确保 `zhihu-bot-style.md` 中的规则被严格执行

让我检查一下 `ZhihuGenerator` 类的完整实现，看看是否有其他可以优化的地方。


我查看了代码，确认在 `mode zhihu` 中已经在使用 `zhihu-bot-style.md` 文件。在 `slack_bot/obsidian/generators.py` 的 `ZhihuGenerator` 类中，代码会同时加载 `zhihu-style.md` 和 `zhihu-bot-style.md` 并合并使用。

为了进一步改善 AI 味问题，我建议优化 `ZhihuGenerator` 类的 prompt 结构，让 anti-AI-tone 规则更加突出和明确：


Edit

            prompt = f"""You are answering a Zhihu question. Your goal is to write an authentic, experience-driven answer.

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

=== ANTI-AI-TONE GUIDE (APPLY WHILE WRITING) ===
{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 Zhihu style guide and ANTI-AI-TONE GUIDE strictly
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 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.

Begin writing the answer:
"""

            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:
"""


<tool_use_error>InputValidationError: Edit failed due to the following issue:
The parameter `replace_all` type is expected as `boolean` but provided as `string`</tool_use_error>

InputValidationError: [
  {
    "expected": "boolean",
    "code": "invalid_type",
    "path": [
      "replace_all"
    ],
    "message": "Invalid input: expected boolean, received string"
  }
]


我需要使用 Write 工具重写整个 ZhihuGenerator 类的 prompt 部分，因为 Edit 工具的参数类型有问题。


Write

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

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

        is_first_turn = (len(history) == 0)

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

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

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

            # 3. Load methodology and anti-AI-tone guide
            methodology = self._read_file("methodology.md")
            powell_revise_guide = self._read_file("POWELL_REVISE.md")

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

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

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

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

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

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

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

{semantic_context}

=== THE QUESTION ===
{question}

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

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

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

Begin writing the answer:
"""

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

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

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

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

        return response, new_history