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

import os
from pathlib import Path
from typing import Optional, List, Dict, Any
from slack_sdk import WebClient

from health.utils.logging_config import setup_logger
from slack_bot.obsidian.indexer import ObsidianIndexer
from slack_bot.obsidian.generators import WritingAssistant, ReplyGenerator, DecisionSupport, SearchAnalyzer, DeAIReviser, ZhihuGenerator
from slack_bot.context.storage import ContextStorage as BaseStorage

logger = setup_logger(__name__)

class ObsidianContextStorage(BaseStorage):
    """Specific storage for Obsidian bot to avoid mixing with Health bot."""
    def __init__(self, channel_id: str):
        super().__init__(channel_id)
        from health import config
        self.context_dir = config.DATA_DIR / "obsidian_context"
        self.context_dir.mkdir(parents=True, exist_ok=True)
        self.file_path = self.context_dir / f"{channel_id}.json"
        self.state_file = self.context_dir / f"{channel_id}_state.json"

    def save_state(self, state: Dict[str, Any]):
        import json
        with open(self.state_file, "w") as f:
            json.dump(state, f)

    def load_state(self) -> Dict[str, Any]:
        import json
        if not self.state_file.exists():
            return {"mode": "none"}
        try:
            with open(self.state_file, "r") as f:
                return json.load(f)
        except:
            return {"mode": "none"}

class ObsidianDispatcher:
    """Routes Obsidian Slack Bot messages."""

    MODE_WRITE = "write"
    MODE_REPLY = "reply"
    MODE_DECIDE = "decide"
    MODE_SEARCH = "search"
    MODE_DEAI = "deai"
    MODE_ZHIHU = "zhihu"
    MODE_NOTE = "note"
    MODE_ZHIHU_HUNTER = "zhihu-hunter"

    VALID_MODES = {MODE_WRITE, MODE_REPLY, MODE_DECIDE, MODE_SEARCH, MODE_DEAI, MODE_ZHIHU, MODE_NOTE, MODE_ZHIHU_HUNTER}

    def __init__(self, vault_path: str):
        self.indexer = ObsidianIndexer(vault_path)
        self.indexer.scan_vault()

        # Initialize RAG vector store and note ingester
        from slack_bot.obsidian.embeddings import get_embedding_provider
        from slack_bot.obsidian.vector_store import ChromaVectorStore
        from slack_bot.obsidian.note_ingester import NoteIngester

        self.vector_store = ChromaVectorStore(embedding_provider=get_embedding_provider())
        self.note_ingester = NoteIngester(
            vault_path=Path(vault_path),
            vector_store=self.vector_store,
        )

        self.generators = {
            self.MODE_WRITE: WritingAssistant(self.indexer, self.vector_store),
            self.MODE_REPLY: ReplyGenerator(self.indexer, self.vector_store),
            self.MODE_DECIDE: DecisionSupport(self.indexer),
            self.MODE_SEARCH: SearchAnalyzer(self.indexer),
            self.MODE_DEAI: DeAIReviser(self.indexer),
            self.MODE_ZHIHU: ZhihuGenerator(self.indexer, self.vector_store),
        }

        token = os.environ.get("OBSIDIAN_SLACK_BOT_TOKEN")
        self.client = WebClient(token=token)

        # Initialize Zhihu Hunter components
        from slack_bot.zhihu.zhihu_hunter import ZhihuHunter
        from slack_bot.zhihu.zhihu_playwright_engine import ZhihuPlaywrightEngine
        from slack_bot.zhihu.slack_interactive_gateway import SlackInteractiveGateway

        zhihu_notify_channel = os.environ.get("ZHIHU_NOTIFY_CHANNEL", "")
        self.zhihu_hunter = ZhihuHunter(
            vault_path=Path(vault_path),
            vector_store=self.vector_store,
            indexer=self.indexer,
        )
        _zhihu_engine = ZhihuPlaywrightEngine(
            slack_client=self.client,
            notify_channel=zhihu_notify_channel,
        )
        self.zhihu_gateway = SlackInteractiveGateway(
            slack_client=self.client,
            hunter=self.zhihu_hunter,
            engine=_zhihu_engine,
            notify_channel=zhihu_notify_channel,
        )
        logger.info("ObsidianDispatcher initialized")

    def dispatch(self, text: str, channel_id: str, user_id: str, response_ts: Optional[str] = None):
        storage = ObsidianContextStorage(channel_id)
        state = storage.load_state()
        current_mode = state.get("mode", "none")

        cmd = text.strip().lower()

        # 1. Handle Commands
        if cmd.startswith("mode "):
            new_mode = cmd.split(" ", 1)[1].strip()
            if new_mode in self.VALID_MODES:
                storage.clear()
                storage.save_state({"mode": new_mode})
                self._reply(channel_id, f"✅ Switched to mode: *{new_mode}* (History cleared)", response_ts)
                return
            else:
                self._reply(channel_id, f"❌ Invalid mode. Available: {', '.join(sorted(self.VALID_MODES))}", response_ts)
                return

        if cmd == "clear":
            storage.clear()
            self._reply(channel_id, "🧹 Context cleared.", response_ts)
            return

        if cmd == "reload":
            self.indexer.scan_vault()
            self._reply(channel_id, "🔄 Vault reloaded.", response_ts)
            return

        # 2. Handle Content based on mode
        if current_mode == "none":
            self._reply(channel_id, "⚠ No mode selected. Use `mode write/reply/decide/search/deai/zhihu/note/zhihu-hunter` to start.", response_ts)
            return

        # MODE_NOTE: ingest text or URL into vault + vector store
        if current_mode == self.MODE_NOTE:
            try:
                result = self.note_ingester.ingest(text)
                self._reply(channel_id, result, response_ts)
            except Exception as e:
                logger.error(f"Note ingestion failed: {e}", exc_info=True)
                self._reply(channel_id, f"🐛 Ingestion error: {str(e)}", response_ts)
            return

        # MODE_ZHIHU_HUNTER: scan vault for keywords, surface Zhihu question candidates
        #
        # Command format:
        #   开始                               → full scan, all dirs, auto keywords
        #   article                            → scan Article only (Revised first, no time filter)
        #   article agent开发                  → scan Article, focus on "agent开发"
        #   notes agent开发                    → scan notes, focus on "agent开发"
        #   关键词1 关键词2                     → skip scan, search directly with keywords
        #   Article/8-xxx-Revised.md           → single file: grounded in that article
        #   Article/*openclaw*-Revised.md      → glob: combine matching files
        #   https://www.zhihu.com/question/... → direct URL: answer this specific question
        #
        # Dir shortcuts: notes / article / clippings / claw / deepfact / deep-dive
        if current_mode == self.MODE_ZHIHU_HUNTER:
            from slack_bot.zhihu.zhihu_hunter import DIR_SHORTCUTS
            try:
                trigger_words = {"开始", "hunt", "扫描", "start", "go"}
                words = [w for w in text.strip().split() if w.lower() not in trigger_words]

                if not words:
                    # Bare trigger → full auto scan
                    self._reply(channel_id, "🔍 正在扫描知乎候选问题（所有目录）...", response_ts)
                    questions = self.zhihu_hunter.scan_and_hunt(since_days=7)

                elif words and 'zhihu.com/question/' in words[0]:
                    # Direct URL paste — user was invited or found a question to answer.
                    # Any text after the URL is treated as an optional answer outline.
                    # Strip trailing Slack/HTML junk chars (>, ", etc.) from the URL.
                    import re as _re
                    url = _re.sub(r'[>\'")\].,;]+$', '', words[0])
                    outline = " ".join(words[1:])[:500] if len(words) > 1 else ""
                    hint = f"（已记录回答思路）" if outline else ""
                    self._reply(channel_id, f"🔍 获取问题标题，准备生成草稿{hint}...", response_ts)
                    questions = self.zhihu_hunter.hunt_direct_url(url)
                    if outline and questions:
                        questions[0].outline = outline
                    logger.info(f"ZhihuHunter: direct URL {url!r} → {questions[0].title!r}" +
                                (f" outline={outline[:40]!r}" if outline else ""))

                elif words and ('*' in words[0] or '?' in words[0]):
                    # Glob pattern → combine all matched files for keyword extraction
                    pattern = words[0]
                    self._reply(
                        channel_id,
                        f"🔍 匹配 `{pattern}` 中的文件，提取观点搜索知乎问题...",
                        response_ts,
                    )
                    questions = self.zhihu_hunter.scan_glob_files(pattern)
                    logger.info(f"ZhihuHunter: glob '{pattern}' → {len(questions)} questions")

                elif words and ('/' in words[0] or words[0].endswith('.md')):
                    # Single-file mode: extract questions from one specific file,
                    # and pass source_file so answers stay grounded in that article.
                    rel_path = words[0]
                    self._reply(
                        channel_id,
                        f"🔍 从 `{rel_path}` 提取观点，搜索匹配知乎问题...",
                        response_ts,
                    )
                    questions = self.zhihu_hunter.scan_single_file(rel_path)
                    logger.info(f"ZhihuHunter: single-file scan '{rel_path}' → {len(questions)} questions")

                elif words[0].lower() in DIR_SHORTCUTS:
                    # Dir-scoped scan with optional topic hint
                    rel_dir = DIR_SHORTCUTS[words[0].lower()]
                    topic_hint = " ".join(words[1:])
                    hint_desc = f"，聚焦：{topic_hint}" if topic_hint else ""
                    self._reply(
                        channel_id,
                        f"🔍 扫描 {rel_dir} 目录{hint_desc}，请稍候...",
                        response_ts,
                    )
                    questions = self.zhihu_hunter.scan_and_hunt(
                        since_days=7, dirs=[rel_dir], topic_hint=topic_hint
                    )

                else:
                    # Direct keywords → skip vault scan entirely
                    self._reply(channel_id, f"🔍 使用指定关键词搜索：{' · '.join(words)}", response_ts)
                    questions = self.zhihu_hunter._find_questions(words)
                    logger.info(f"ZhihuHunter: manual keywords {words} → {len(questions)} questions")

                self.zhihu_gateway.post_question_cards(questions, channel=channel_id)
            except Exception as e:
                logger.error(f"Zhihu hunt failed: {e}", exc_info=True)
                self._reply(channel_id, f"❌ 扫描失败：{e}", response_ts)
            return

        # Get history
        history = storage.get_context()
        generator = self.generators[current_mode]

        try:
            logger.info(f"Generating Obsidian response in mode: {current_mode}")
            # The chat method handles first turn logic and returns updated history
            # But wait, our generators' chat method appends to history internally.
            # In our Slack setup, we want to append the result to ContextStorage.

            # Note: generator.chat(text, history) returns (response, new_history_list)
            # new_history_list contains [...history, {user: rich_prompt}, {assistant: response}]
            response_text, updated_history = generator.chat(text, history)

            # Since generator.chat already did the LLM call and returned history,
            # we just need to save the ONLY NEW messages to our storage.
            # However, during FIRST TURN, text is replaced by rich_prompt.

            # For simplicity: completely overwrite storage with updated_history
            # ContextStorage doesn't support overwrite easily, but we can clear and re-add.
            # Better: Modify ObsidianContextStorage to support set_context.

            # Actually, let's just use the updated_history and manually add to storage
            # Warning: first turn 'user' message in storage will be the RICH PROMPT
            # This is actually better for context continuity in Slack too.

            storage.clear()
            for msg in updated_history:
                storage.add_message(msg["role"], msg["content"])

            self._reply(channel_id, response_text, response_ts)

        except Exception as e:
            logger.error(f"Obsidian generation failed: {e}", exc_info=True)
            self._reply(channel_id, f"🐛 Error: {str(e)}", response_ts)

    def _reply(self, channel_id: str, text: str, ts: Optional[str] = None):
        from slack_bot.utils.mrkdwn import SlackFormatter

        # Slack API limits (official documentation):
        # - chat.postMessage: 40,000 chars
        # - chat.update: 4,000 chars
        # Slack markdown formatting can expand text by 50-100% (bold, links, code blocks, etc.)
        TRUNCATE_WARNING = "\n\n⚠ (Response truncated, full text uploaded as file)"
        FILE_UPLOAD_HINT = "📄 Full response uploaded as file above ☝"

        # Calculate safe pre-formatting limit with aggressive overhead buffer
        if ts:
            # For updates: 4000 limit - 60% overhead for safety = ~1600 chars
            max_raw_length = 1500
        else:
            # For new messages: 40000 limit - 40% overhead = ~24000 chars
            max_raw_length = 24000

        original_length = len(text)
        should_upload_file = original_length > max_raw_length

        if should_upload_file:
            # Upload full text as file first
            self._upload_as_file(channel_id, text, ts)

            # Then send a truncated preview
            truncate_at = text.rfind('\n\n', 0, 800)
            if truncate_at == -1 or truncate_at < 400:
                truncate_at = text.rfind('\n', 0, 800)
            if truncate_at == -1 or truncate_at < 400:
                truncate_at = 800

            preview_text = text[:truncate_at] + f"\n\n...\n\n{FILE_UPLOAD_HINT}"
            formatted_text = SlackFormatter.convert(preview_text)
            logger.info(f"Uploaded full response ({original_length} chars) as file, sending preview ({len(preview_text)} chars)")
        else:
            # Text is short enough, just format and send
            formatted_text = SlackFormatter.convert(text)

            # Final safety check after formatting
            final_limit = 3900 if ts else 39900
            if len(formatted_text) > final_limit:
                logger.warning(f"Formatted text ({len(formatted_text)} chars) exceeds limit after formatting. Uploading as file.")
                self._upload_as_file(channel_id, text, ts)
                # Send simple notification
                formatted_text = FILE_UPLOAD_HINT

        # Send the message
        try:
            if ts:
                self.client.chat_update(channel=channel_id, ts=ts, text=formatted_text)
            else:
                self.client.chat_postMessage(channel=channel_id, text=formatted_text)
        except Exception as e:
            if "msg_too_long" in str(e):
                logger.error(f"Still got msg_too_long error even after truncation. Text length: {len(formatted_text)}")
                # Ultimate fallback: send error message only
                try:
                    error_msg = "❌ Response formatting error. Please check the uploaded file above."
                    if ts:
                        self.client.chat_update(channel=channel_id, ts=ts, text=error_msg)
                    else:
                        self.client.chat_postMessage(channel=channel_id, text=error_msg)
                except:
                    logger.error("Failed to send even error message", exc_info=True)
            else:
                raise

    def _upload_as_file(self, channel_id: str, content: str, ts: Optional[str] = None):
        """Fallback: upload long responses as text files."""
        try:
            import tempfile
            from datetime import datetime

            filename = f"response_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt"

            with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False, encoding='utf-8') as tmp:
                tmp.write(content)
                tmp_path = tmp.name

            self.client.files_upload_v2(
                channel=channel_id,
                file=tmp_path,
                filename=filename,
                initial_comment="📄 Response too long for Slack message, uploaded as file:",
                thread_ts=ts
            )

            os.unlink(tmp_path)
            logger.info(f"Uploaded long response as file: {filename}")
        except Exception as e:
            logger.error(f"Failed to upload file: {e}", exc_info=True)
            # Ultimate fallback: send error message
            error_msg = "❌ Response too long to display. Please try a more specific query."
            if ts:
                self.client.chat_update(channel=channel_id, ts=ts, text=error_msg)
            else:
                self.client.chat_postMessage(channel=channel_id, text=error_msg)


The file /root/projects/butler/slack_bot/zhihu/zhihu_hunter.py has been updated successfully.

"""Zhihu Hunter — vault scanning, question discovery, and answer drafting.

Workflow:
    1. scan_and_hunt()  →  scans recent vault files, extracts keywords,
                           searches Tavily for matching Zhihu questions.
    2. draft_answer()   →  uses existing RAG + ZhihuGenerator to produce
                           a Zhihu-style answer draft ready for review.
"""

import os
import re
import requests
from datetime import datetime, timezone, timedelta
from pathlib import Path
from typing import List, Optional

from pydantic import BaseModel, Field

from health.utils.logging_config import setup_logger
from slack_bot.llm.gemini import GeminiLLM
from slack_bot.obsidian.generators import ZhihuGenerator
from slack_bot.obsidian.indexer import ObsidianIndexer
from slack_bot.obsidian.vector_store import ChromaVectorStore

logger = setup_logger(__name__)

# ── Vault directories to watch for new/updated content ────────────────────────
# Paths are relative to vault_path root.
WATCH_DIRS: List[str] = [
    "notes",
    "Article",
    "auto_report/deep-dive",
    "CLAW/deepfact",
    "Clippings",
]

# Article is always included regardless of time; Revised files are prioritized.
_ARTICLE_DIR = "Article"

# Short names users can type to target a specific directory.
DIR_SHORTCUTS: dict[str, str] = {
    "notes":      "notes",
    "article":    "Article",
    "clippings":  "Clippings",
    "claw":       "CLAW/deepfact",
    "deepfact":   "CLAW/deepfact",
    "deep-dive":  "auto_report/deep-dive",
}

_MAX_FILES_PER_DIR   = 4    # most-recent files per directory (keeps dir diversity)
_MAX_FILES_ARTICLE   = 8    # Article gets more slots (higher priority)
_MAX_KEYWORDS        = 10   # keywords extracted from vault scan
_MAX_QUESTIONS_TOTAL = 12   # total Zhihu questions surfaced per hunt
# Must contain /question/<id>; answer/comment subpages are excluded in _find_questions
_MIN_QUESTION_URL_RE = re.compile(r'zhihu\.com/question/\d+')

# ── Data Models ────────────────────────────────────────────────────────────────

class ZhihuQuestion(BaseModel):
    """A Zhihu question candidate surfaced by the hunter."""

    title: str
    url: str
    snippet: Optional[str] = None
    keywords: List[str] = Field(default_factory=list)
    found_at: str = Field(
        default_factory=lambda: datetime.now(tz=timezone.utc).isoformat()
    )
    source_file: Optional[str] = None  # vault-relative path, set by scan_single_file()
    outline: Optional[str] = None       # user-provided answer logic/structure (direct URL mode)

class AnswerDraft(BaseModel):
    """A generated answer draft awaiting human review."""

    question: ZhihuQuestion
    content: str
    generated_at: str = Field(
        default_factory=lambda: datetime.now(tz=timezone.utc).isoformat()
    )
    rag_sources: List[str] = Field(default_factory=list)
    vault_file: Optional[str] = None  # absolute path set after saving to vault

# ── Main Class ─────────────────────────────────────────────────────────────────

class ZhihuHunter:
    """Discovers Zhihu questions from vault content and drafts answers.

    Example:
        hunter = ZhihuHunter(vault_path, vector_store, indexer)
        questions = hunter.scan_and_hunt(since_days=3)
        draft = hunter.draft_answer(questions[0])
    """

    def __init__(
        self,
        vault_path: Path,
        vector_store: ChromaVectorStore,
        indexer: ObsidianIndexer,
    ) -> None:
        """Initialize the hunter.

        Args:
            vault_path: Root path of the Obsidian vault.
            vector_store: ChromaDB vector store for semantic retrieval.
            indexer: ObsidianIndexer for keyword-based search and writing samples.
        """
        self.vault_path = vault_path
        self.vector_store = vector_store
        self.indexer = indexer
        self.llm = GeminiLLM()

    # ── Public API ─────────────────────────────────────────────────────────────

    def scan_and_hunt(
        self,
        since_days: int = 7,
        dirs: Optional[List[str]] = None,
        topic_hint: str = "",
    ) -> List[ZhihuQuestion]:
        """Scan vault files and surface matching Zhihu questions.

        Args:
            since_days: Age threshold for non-Article directories.
            dirs: Restrict scan to these vault dirs (relative paths). None = all WATCH_DIRS.
            topic_hint: Optional focus hint injected into keyword extraction prompt.

        Returns:
            List of ZhihuQuestion candidates (up to _MAX_QUESTIONS_TOTAL).
        """
        scan_desc = f"dirs={dirs or 'all'}" + (f", topic='{topic_hint}'" if topic_hint else "")
        logger.info(f"ZhihuHunter: scanning vault ({scan_desc})")

        keywords = self._scan_vault_for_keywords(since_days, dirs=dirs, topic_hint=topic_hint)
        if not keywords:
            logger.info("No keywords extracted from vault files")
            return []

        logger.info(f"Extracted keywords: {keywords}")
        questions = self._find_questions(keywords)
        logger.info(f"Found {len(questions)} Zhihu questions")
        return questions

    def scan_single_file(self, rel_path: str) -> List[ZhihuQuestion]:
        """Extract keywords from one vault file and find matching Zhihu questions.

        The returned questions carry ``source_file`` set to ``rel_path`` so that
        ``draft_answer()`` will ground the generated answer in that article.

        Args:
            rel_path: Path relative to vault_path (e.g. "Article/8-xxx-Revised.md").

        Returns:
            List of ZhihuQuestion candidates with source_file populated.

        Raises:
            FileNotFoundError: If the file does not exist under the vault root.
        """
        file_path = self.vault_path / rel_path
        if not file_path.exists():
            raise FileNotFoundError(f"文件不存在：{file_path}")

        text = file_path.read_text(encoding="utf-8", errors="ignore")
        text = re.sub(r'^---\n.*?\n---\n', '', text, flags=re.DOTALL).strip()
        logger.info(f"scan_single_file: {rel_path} ({len(text)} chars)")

        keywords = self._extract_keywords_with_llm(text[:5000])
        if not keywords:
            logger.info("No keywords extracted from single file")
            return []

        logger.info(f"Keywords from file: {keywords}")
        questions = self._find_questions(keywords)

        for q in questions:
            q.source_file = rel_path

        return questions

    def scan_glob_files(self, pattern: str) -> List[ZhihuQuestion]:
        """Find vault files matching a glob pattern and hunt Zhihu questions.

        All matched files are combined for keyword extraction, so the questions
        reflect the full set of content.  The most-recently-modified matched file
        is stored as ``source_file`` to ground the answer draft.

        Args:
            pattern: Glob pattern relative to vault_path
                     (e.g. "Article/*openclaw*-Revised.md").

        Returns:
            List of ZhihuQuestion candidates with source_file set.

        Raises:
            FileNotFoundError: If no files match the pattern.
        """
        matched = sorted(
            self.vault_path.glob(pattern),
            key=lambda p: p.stat().st_mtime,
            reverse=True,
        )
        if not matched:
            raise FileNotFoundError(f"没有文件匹配：{pattern}")

        names = [p.name for p in matched]
        logger.info(f"scan_glob_files: '{pattern}' → {len(matched)} files: {names}")

        # Combine content from all matched files (per-file budget so no one file
        # dominates) and extract keywords from the aggregate.
        parts: List[str] = []
        per_file_budget = 1500 if len(matched) == 1 else 800
        for fp in matched:
            try:
                text = fp.read_text(encoding="utf-8", errors="ignore")
                text = re.sub(r'^---\n.*?\n---\n', '', text, flags=re.DOTALL).strip()
                parts.append(f"[{fp.name}]\n{text[:per_file_budget]}")
            except OSError as e:
                logger.debug(f"Could not read {fp}: {e}")

        combined = "\n\n---\n\n".join(parts)
        keywords = self._extract_keywords_with_llm(combined[:8000])
        if not keywords:
            logger.info("No keywords extracted from glob-matched files")
            return []

        logger.info(f"Keywords from glob: {keywords}")
        questions = self._find_questions(keywords)

        # Primary source = most-recently-modified matched file
        primary = str(matched[0].relative_to(self.vault_path))
        for q in questions:
            q.source_file = primary

        return questions

    def hunt_direct_url(self, url: str) -> List[ZhihuQuestion]:
        """Create a question card directly from a Zhihu question URL.

        Used when the user pastes a URL they were invited to answer.
        Fetches the page title so the card displays a readable name.

        Args:
            url: A zhihu.com/question/<id> URL.

        Returns:
            Single-element list containing the ZhihuQuestion.
        """
        clean_url = re.sub(r'[>\'")\].,;]+$', '', url).split("?")[0].split("#")[0].rstrip("/")
        title = self._fetch_question_title(clean_url)
        logger.info(f"Direct URL hunt: {clean_url!r} → title={title!r}")
        return [ZhihuQuestion(title=title, url=clean_url)]

    def _fetch_question_title(self, url: str) -> str:
        """Fetch the question title via Playwright (with saved session cookies).

        Falls back to a URL-derived label if Playwright is unavailable or fails.

        Args:
            url: Cleaned question URL.

        Returns:
            Question title string, or a fallback derived from the URL.
        """
        state_file = Path(__file__).resolve().parents[3] / "data" / "zhihu_state.json"
        try:
            from playwright.sync_api import sync_playwright
            from playwright_stealth import Stealth
            import json as _json

            with sync_playwright() as p:
                browser = p.chromium.launch(headless=True)
                context = browser.new_context(
                    user_agent=(
                        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                        "AppleWebKit/537.36 (KHTML, like Gecko) "
                        "Chrome/122.0.0.0 Safari/537.36"
                    )
                )
                # Load saved cookies if available
                if state_file.exists():
                    data = _json.loads(state_file.read_text(encoding="utf-8"))
                    cookies = data.get("cookies", [])
                    if cookies:
                        context.add_cookies(cookies)

                page = context.new_page()
                Stealth().apply_stealth_sync(page)
                page.goto(url, wait_until="domcontentloaded", timeout=15000)
                page.wait_for_timeout(2000)

                title = page.title()
                browser.close()

                if title:
                    # Page title format: "问题标题 - 知乎" or just "知乎"
                    title = title.split(" - 知乎")[0].strip()
                    if title and title != "知乎":
                        return title
        except Exception as e:
            logger.debug(f"Playwright title fetch failed for {url}: {e}")

        # Fallback: use the question ID
        qid = url.rstrip("/").split("/")[-1]
        return f"知乎问题 #{qid}"

    def _fetch_recent_web_context(self, query: str) -> str:
        """Search the web for recent articles/news about the query topic.

        Results are injected into the answer prompt so it reads like it
        comes from someone who's actively following the latest developments.

        Args:
            query: Topic or question title to search.

        Returns:
            Formatted string of recent snippets, or empty string on failure.
        """
        api_key = os.environ.get("TAVILY_API_KEY")
        if not api_key:
            return ""
        try:
            from tavily import TavilyClient
            client = TavilyClient(api_key=api_key)
            results = client.search(
                query=query,
                search_depth="basic",
                max_results=4,
                days=90,
                exclude_domains=["zhihu.com"],
            )
            items = results.get("results", [])
            if not items:
                return ""
            parts = [
                f"- {r['title']} ({r['url'].split('/')[2]}): {r.get('content', '')[:250]}"
                for r in items
            ]
            return "\n".join(parts)
        except Exception as e:
            logger.debug(f"Recent web context fetch failed: {e}")
            return ""

    def draft_answer(self, question: ZhihuQuestion, feedback: str = "") -> AnswerDraft:
        """Generate a Zhihu-style answer draft for a question.

        Delegates to the existing ZhihuGenerator (reuses prompt + style guide).
        The RAG context is enriched by both semantic (ChromaDB) and
        keyword-based (ObsidianIndexer) retrieval.

        Args:
            question: ZhihuQuestion to answer.
            feedback: Optional revision instructions from the user (e.g. "更口语化一些").

        Returns:
            AnswerDraft with generated content and source list.
        """
        logger.info(f"Drafting answer for: {question.title}" + (f" [feedback: {feedback[:40]}]" if feedback else ""))

        # Collect RAG sources for transparency
        rag_sources: List[str] = []

        semantic_results = self.vector_store.search_knowledge(question.title, top_k=3)
        for r in semantic_results:
            rag_sources.append(Path(r.source_path).name)

        keyword_results = self.indexer.search(question.title, limit=5)
        for note in keyword_results:
            first_line = note.split("\n")[0].strip("# ").strip()
            if first_line:
                rag_sources.append(first_line)

        # If the question was pinned to a specific source file, load it as the
        # primary article so the answer stays grounded in its viewpoints.
        primary_article = ""
        if question.source_file:
            fp = self.vault_path / question.source_file
            try:
                raw = fp.read_text(encoding="utf-8", errors="ignore")
                raw = re.sub(r'^---\n.*?\n---\n', '', raw, flags=re.DOTALL).strip()
                primary_article = raw[:4000]
                rag_sources.insert(0, Path(question.source_file).name)
                logger.info(f"Loaded primary article: {question.source_file} ({len(primary_article)} chars)")
            except OSError as e:
                logger.warning(f"Could not read source file {question.source_file}: {e}")

        # Delegate to existing ZhihuGenerator for consistent style
        generator = ZhihuGenerator(self.indexer, self.vector_store)
        prompt_input = question.title
        if question.snippet:
            prompt_input += f" | 背景：{question.snippet}"

        # User-provided outline takes highest priority — structure the answer strictly
        # around it, pulling matching content from the knowledge base for each point.
        if question.outline:
            prompt_input += (
                f"\n\n用户提供的回答思路（必须严格按照这个逻辑框架展开，"
                f"从知识库中找到对应的内容来支撑每个论点，不要自由发挥框架结构）：\n{question.outline}"
            )
            logger.info(f"Injected user outline: {question.outline[:60]!r}")

        if primary_article:
            prompt_input += (
                f"\n\n主要参考文章（请基于此文的观点和论据来回答，"
                f"可直接引用其中的分析和结论）：\n{primary_article}"
            )

        # Inject recent web context so the answer feels up-to-date
        recent_context = self._fetch_recent_web_context(question.title)
        if recent_context:
            prompt_input += (
                f"\n\n近期相关资讯（最近90天内的动态，用来补充最新工具/发布/讨论，"
                f"让回答更有时效感，自然地融入即可，不要刻意罗列）：\n{recent_context}"
            )
            logger.info(f"Injected recent web context ({len(recent_context)} chars)")

        if feedback:
            prompt_input += f"\n\n用户修改意见：{feedback}"

        content, _ = generator.chat(prompt_input, history=[])

        return AnswerDraft(
            question=question,
            content=content,
            rag_sources=list(dict.fromkeys(rag_sources)),  # deduplicate, keep order
        )

    # ── Vault Scanning ─────────────────────────────────────────────────────────

    def _scan_vault_for_keywords(
        self,
        since_days: int,
        dirs: Optional[List[str]] = None,
        topic_hint: str = "",
    ) -> List[str]:
        """Collect vault files and extract topic keywords via LLM.

        Args:
            since_days: Age threshold for non-Article directories.
            dirs: Dirs to scan; None = all WATCH_DIRS.
            topic_hint: Optional focus instruction for keyword extraction.

        Returns:
            Deduplicated list of topic keywords (up to _MAX_KEYWORDS).
        """
        cutoff = datetime.now(tz=timezone.utc) - timedelta(days=since_days)
        files = self._collect_recent_files(cutoff, dirs=dirs)

        if not files:
            return []

        # Article-only scans get a larger per-file budget (higher priority content)
        scan_dirs = dirs or WATCH_DIRS
        per_file_budget = 800 if scan_dirs == [_ARTICLE_DIR] else 500
        combined = self._read_files_summary(files, max_chars_per_file=per_file_budget)
        return self._extract_keywords_with_llm(combined, topic_hint=topic_hint)

    def _collect_recent_files(
        self,
        cutoff: datetime,
        dirs: Optional[List[str]] = None,
    ) -> List[Path]:
        """Walk vault directories and return candidate files.

        Article directory:
          - No time filter (always included regardless of cutoff)
          - Revised files sorted first, then by mtime descending
          - Up to _MAX_FILES_ARTICLE slots
        Other directories:
          - Only files modified after cutoff
          - Sorted by mtime descending
          - Up to _MAX_FILES_PER_DIR slots

        Args:
            cutoff: Freshness threshold for non-Article directories.
            dirs: Directories to scan (relative paths). None = all WATCH_DIRS.

        Returns:
            List of Path objects.
        """
        scan_dirs = dirs if dirs is not None else WATCH_DIRS
        collected: List[Path] = []

        for rel_dir in scan_dirs:
            watch_dir = self.vault_path / rel_dir
            if not watch_dir.is_dir():
                logger.debug(f"Watch dir not found, skipping: {watch_dir}")
                continue

            # (mtime, is_revised, path)
            candidates: List[tuple[float, bool, Path]] = []
            for md_file in watch_dir.rglob("*.md"):
                try:
                    mtime = md_file.stat().st_mtime
                    if rel_dir != _ARTICLE_DIR:
                        file_dt = datetime.fromtimestamp(mtime, tz=timezone.utc)
                        if file_dt < cutoff:
                            continue
                    is_revised = md_file.name.lower().endswith("-revised.md")
                    candidates.append((mtime, is_revised, md_file))
                except OSError:
                    continue

            if rel_dir == _ARTICLE_DIR:
                # Revised files first, then newest; give double slots
                candidates.sort(key=lambda t: (0 if t[1] else 1, -t[0]))
                max_files = _MAX_FILES_ARTICLE
            else:
                candidates.sort(key=lambda t: -t[0])
                max_files = _MAX_FILES_PER_DIR

            collected.extend(p for _, _, p in candidates[:max_files])

        logger.debug(f"Collected {len(collected)} files from vault (dirs={scan_dirs})")
        return collected

    def _read_files_summary(
        self,
        files: List[Path],
        max_chars: int = 8000,
        max_chars_per_file: int = 500,
    ) -> str:
        """Read and concatenate file contents up to a character budget.

        Each file is capped at max_chars_per_file so that no single large file
        exhausts the total budget and crowds out other directories.

        Args:
            files: List of markdown file paths.
            max_chars: Total character budget across all files.
            max_chars_per_file: Per-file cap to ensure directory diversity.

        Returns:
            Concatenated content string with file-name headers.
        """
        parts: List[str] = []
        remaining = max_chars

        for f in files:
            if remaining <= 0:
                break
            try:
                text = f.read_text(encoding="utf-8", errors="ignore")
                # Strip YAML frontmatter
                text = re.sub(r'^---\n.*?\n---\n', '', text, flags=re.DOTALL).strip()
                chunk = text[:min(max_chars_per_file, remaining)]
                parts.append(f"[{f.name}]\n{chunk}")
                remaining -= len(chunk)
            except OSError as e:
                logger.debug(f"Could not read {f}: {e}")

        return "\n\n---\n\n".join(parts)

    def _extract_keywords_with_llm(
        self,
        combined_text: str,
        topic_hint: str = "",
    ) -> List[str]:
        """Use LLM to distill topic keywords from aggregated vault content.

        Args:
            combined_text: Concatenated recent vault content.
            topic_hint: Optional focus instruction (e.g. "agent开发").

        Returns:
            List of keyword strings (up to _MAX_KEYWORDS).
        """
        topic_instruction = (
            f"\n- 重点关注与「{topic_hint}」相关的话题，优先提取这个方向的关键词"
            if topic_hint else ""
        )
        prompt = f"""你是一位知乎内容运营专家。请从以下笔记内容中提炼出适合在知乎搜索问题的关键词。

要求：
- 输出 {_MAX_KEYWORDS} 个以内的关键词，每行一个，无序号无符号
- 关键词要在知乎上有真实讨论热度，即普通中国用户会关心并提问的话题领域
- 避免过于偏门的专有名词（如具体项目代号、小众产品名），要用它所属的上一级概念
  例：「OpenClaw」→「AI智能体安全」；「BadJs」→「微信监控机制」；「Moon Monitor」→「健康监测设备」
- 不要太泛（如"AI""技术""健康"），也不要太窄（如只有一个工具代码名称）
- 优先选择最近讨论最多、最有深度的话题{topic_instruction}
- 只输出关键词列表，不要解释

=== 笔记内容 ===
{combined_text[:5000]}
"""
        try:
            response, _ = self.llm.generate_response(prompt, [])
            keywords = [
                line.strip()
                for line in response.strip().splitlines()
                if line.strip() and not line.strip().startswith("#")
            ]
            return keywords[:_MAX_KEYWORDS]
        except Exception as e:
            logger.error(f"Keyword extraction failed: {e}")
            return []

    # ── Question Discovery ─────────────────────────────────────────────────────

    def _find_questions(self, keywords: List[str]) -> List[ZhihuQuestion]:
        """Search Tavily for Zhihu questions matching each keyword.

        Collects candidates from ALL keywords before truncating so that later
        keywords (which can cover different facets) are not silently dropped.

        Args:
            keywords: List of topic keywords to search.

        Returns:
            Deduplicated ZhihuQuestion list (up to _MAX_QUESTIONS_TOTAL).
        """
        from tavily import TavilyClient

        api_key = os.environ.get("TAVILY_API_KEY")
        if not api_key:
            raise RuntimeError("TAVILY_API_KEY not set")

        client = TavilyClient(api_key=api_key)
        seen_urls: set[str] = set()
        questions: List[ZhihuQuestion] = []

        for kw in keywords:
            try:
                results = self._tavily_search(client, kw)
                for item in results:
                    url = item.get("url", "")
                    if not _MIN_QUESTION_URL_RE.search(url):
                        continue  # skip non-question URLs (e.g. zhihu.com/people/...)
                    # Normalize: extract bare URL to strip invisible chars, then remove params
                    _m = re.search(r'https?://\S+', url)
                    if not _m:
                        continue
                    clean_url = re.sub(r'[>\'")\].,;]+$', '', _m.group(0)).split("?")[0].split("#")[0].rstrip("/")
                    # Skip answer/comment subpages — we want the question itself
                    if re.search(r'/answer/|/comment/', clean_url):
                        continue
                    if clean_url in seen_urls:
                        continue
                    seen_urls.add(clean_url)
                    questions.append(
                        ZhihuQuestion(
                            title=item.get("title", kw),
                            url=clean_url,
                            snippet=item.get("content", "")[:200] or None,
                            keywords=[kw],
                        )
                    )
            except Exception as e:
                logger.warning(f"Tavily search failed for keyword '{kw}': {e}")

        logger.info(f"Found {len(questions)} Zhihu questions (capped at {_MAX_QUESTIONS_TOTAL})")
        return questions[:_MAX_QUESTIONS_TOTAL]

    def _tavily_search(self, client, keyword: str) -> List[dict]:
        """Run a single Tavily search scoped to zhihu.com/question.

        Args:
            client: Authenticated TavilyClient instance.
            keyword: Search keyword.

        Returns:
            List of result dicts from Tavily (url, title, content, score).
        """
        query = f"site:zhihu.com/question {keyword}"
        logger.debug(f"Tavily search: {query}")
        response = client.search(
            query=query,
            search_depth="basic",
            max_results=8,
            include_domains=["zhihu.com"],
            days=365,   # prefer questions with recent activity (higher chance of top rank)
        )
        return response.get("results", [])


Updated task #4 status


Updated task #3 status


现在修第二个 bug — `is_new_question` 检测。


   530→
   531→        # Treat as a new question if:
   532→        # - history is empty (genuine first turn), OR
   533→        # - user input contains "|" (explicit "Question | Core Ideas" format), OR
   534→        # - user input starts with known question triggers like "回答这个问题"
   535→        is_new_question = (
   536→            is_first_turn
   537→            or "|" in user_input
   538→            or user_input.strip().startswith("回答这个问题")
   539→        )
   540→
   541→        if is_new_question:
   542→            # === First Turn: Build Rich Prompt ===
   543→            # Expected input format: "Question | Core Ideas"
   544→            parts = user_input.split("|", 1)
   545→            question = parts[0].strip()
   546→            core_ideas = parts[1].strip() if len(parts) > 1 else ""
   547→
   548→            # 1. Load Style Guide (zhihu-style.md serves as writing standard, NOT as material)
   549→            zhihu_style_guide = self._read_file("zhihu-style.md")

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


Edit

        # 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("回答这个问题")
        )

        # 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 "回答这个问题", OR
        # - user input contains a Zhihu question URL (pasting a new question link)
        is_new_question = (
            is_first_turn
            or "|" in user_input
            or user_input.strip().startswith("回答这个问题")
            or "zhihu.com/question/" in user_input
        )


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

        # 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 "回答这个问题", OR
        # - user input contains a Zhihu question URL (pasting a new question link)
        is_new_question = (
            is_first_turn
            or "|" in user_input
            or user_input.strip().startswith("回答这个问题")
            or "zhihu.com/question/" in user_input
        )

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 — 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 remove AI tone. Do NOT change facts or add new content.

=== TONE TARGET (最高优先级) ===
目标语感：像一个懂行的人在跟朋友聊天时随口说的。不端着，也不刻意耍帅。
两种AI味都要杀：
- 正式AI味：过渡词堆砌、排比对仗、空洞修饰、鸡汤结尾
- 油腻AI味：刻意装随意（"折腾去吧"）、强行造金句（"搞坏了不心疼，跑通了就是资产"）、给普通概念起酷名字（"隔离舱"、"暴力碰撞"、"最干净的契约"）、用一句俏皮话收尾装潇洒
正确的语感：平铺直叙，该说什么说什么，说完就停。不需要让读者觉得你很酷。

=== PRIORITY 1: VOCABULARY KILL LIST (逐词扫描，命中即删或换大白话) ===
夸张修饰: 极度脆弱、毁灭性的、灾难级的、压倒性的、远超想象、不止一个量级、颠覆性的、革命性的、折磨人、天翻地覆、风险极高
大词空转: 范式转移、范式突变、开发范式、数字化容器、数字雇员、温和池架构、非确定性、阅后即焚、毫秒级就绪、毫秒级响应、赋能、底层逻辑、仪式性努力、深刻变革、暴力碰撞、最干净的契约、服务化的潜力、可扩展的执行信用、终极归宿、壁垒、分水岭、沉淀（用作动词修饰数据/经验时）、云端办公室
鸡汤/口号: 正途、值得跨越、安置灵魂、掌控感、才是王道、苦活累活交给、未来已来、危机就是转机、从玩具变成生产力、把环境变成了资产、安全底线、这才是成熟的做法、才是X的关键、不是建议是必须
油腻收尾: "X去吧"式潇洒收尾、"搞坏了不心疼，跑通了就是Y"式金句、任何试图用一句俏皮话总结全文的结尾、"把X关在Y里...把Y放在Z上"式排比收尾
伪口语: 说白了、说实话、折腾这玩意儿

=== PRIORITY 2: SENTENCE PATTERN KILL LIST (命中即改写) ===
1. 防御性写法："很多人觉得X，实际上Y"、"有些人认为X，但其实Y"、"很多人没意识到"、"有人X，有人已经Y了" → 直接说判断，删掉"很多人/有人"铺垫
2. 对比式定义："不是X，是Y"、"不是X而是Y"、"那不叫X那叫Y"、"别把X当Y" → 删除对比句式，直接陈述后半句即可
3. 换皮夸张：用另一个夸张词替换夸张词不算修好。"灾难级"→"毁灭性"→"风险极高"都是换皮。正确做法：换成具体后果（如"可能误删整个home目录"）
4. 概念包装：给普通事物起酷名字（"隔离舱"=容器、"暴力碰撞"=冲突、"数字雇员"=自动化脚本、"执行信用"=服务器资源、"云端办公室"=云服务器、"牢笼"=限制）→ 用最朴素的说法
5. 油腻金句：短句对仗、押韵、刻意工整的收尾句 → 要么删掉，要么拆成普通陈述句
6. 比喻堆砌：全篇最多保留1个比喻，多余的全部删除换成直说
7. 鸡汤/口号结尾：删除所有励志或耍帅收尾，改为具体行动建议或直接停住
8. 首尾呼应：禁止结尾重复开头观点
9. 语气漂移：全文语气必须统一，不要中途切换
10. 编号标题：如果是线性论述（论点1→论点2→论点3），删除"一、二、三"序号改为自然段落过渡；如果是并列对比不同方案（如方案A vs 方案B vs 方案C），可以保留编号
11. 过渡词堆积：删除"首先...其次...最后..."
12. 排比对仗：打破字数相近的并列句
13. 关键词重复：同一个修辞性词汇（如"分水岭"、"命门"、"杀手锏"）全篇只能出现一次，重复出现时删除后续的或换成普通说法

=== PRIORITY 3: FULL REVISION GUIDE ===
{powell_revise_guide}

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

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 remove AI tone. Do NOT change facts or add new content.

=== TONE TARGET (最高优先级) ===
目标语感：像一个懂行的人在知乎随手打字回答。不端着，也不刻意耍帅。
两种AI味都要杀：
- 正式AI味：过渡词堆砌、排比对仗、空洞修饰、鸡汤结尾
- 油腻AI味：刻意装随意（"折腾去吧"）、强行造金句（"搞坏了不心疼，跑通了就是资产"）、给普通概念起酷名字（"隔离舱"、"暴力碰撞"、"最干净的契约"）、用一句俏皮话收尾装潇洒
正确的语感：平铺直叙，该说什么说什么，说完就停。不需要让读者觉得你很酷。

=== PRIORITY 1: VOCABULARY KILL LIST (逐词扫描，命中即删或换大白话) ===
夸张修饰: 极度脆弱、毁灭性的、灾难级的、压倒性的、远超想象、不止一个量级、颠覆性的、革命性的、折磨人、天翻地覆、风险极高、本质上
大词空转: 范式转移、范式突变、开发范式、数字化容器、数字雇员、温和池架构、非确定性、阅后即焚、毫秒级就绪、毫秒级响应、赋能、底层逻辑、仪式性努力、深刻变革、暴力碰撞、最干净的契约、服务化的潜力、不可逆的浪潮、时代大背景、可扩展的执行信用、终极归宿、壁垒、分水岭、沉淀（用作动词修饰数据/经验时）、云端办公室
鸡汤/口号: 正途、值得跨越、安置灵魂、掌控感、才是王道、苦活累活交给、未来已来、危机就是转机、挑战与机遇并存、从玩具变成生产力、把环境变成了资产、安全底线、这才是成熟的做法、才是X的关键、不是建议是必须
油腻收尾: "X去吧"式潇洒收尾、"搞坏了不心疼，跑通了就是Y"式金句、任何试图用一句俏皮话总结全文的结尾、"把X关在Y里...把Y放在Z上"式排比收尾
伪口语: 说白了、说实话、折腾这玩意儿

=== PRIORITY 2: SENTENCE PATTERN KILL LIST (命中即改写) ===
1. 防御性写法："很多人觉得X，实际上Y"、"有些人认为X，但其实Y"、"很多人没意识到"、"不少人觉得X"、"有人X，有人已经Y了" → 直接说判断，删掉"很多人/有人"铺垫
2. 对比式定义："不是X，是Y"、"不是X而是Y"、"那不叫X那叫Y"、"别把X当Y" → 删除对比句式，直接陈述后半句即可
3. 换皮夸张：用另一个夸张词替换夸张词不算修好。"灾难级"→"毁灭性"→"风险极高"都是换皮。正确做法：换成具体后果（如"可能误删整个home目录"）
4. 概念包装：给普通事物起酷名字（"隔离舱"=容器、"暴力碰撞"=冲突、"数字雇员"=自动化脚本、"执行信用"=服务器资源、"云端办公室"=云服务器、"牢笼"=限制）→ 用最朴素的说法
5. 油腻金句：短句对仗、押韵、刻意工整的收尾句 → 要么删掉，要么拆成普通陈述句
6. 比喻堆砌：全篇最多1个比喻，多余全删换直说
7. 鸡汤/口号结尾：删除励志或耍帅收尾，给具体建议或直接停
8. 首尾呼应：禁止结尾重复开头
9. 编号分段：如果是线性论述（论点1→论点2→论点3），删除"一、二、三"序号改为自然段落；如果是并列对比不同方案（方案A vs 方案B vs 方案C），可以保留编号
10. 防御性引号：删除套在归纳性词汇上的引号
11. 强行总结：删除"一句话建议"、"综上"式收尾
12. 嵌套数字列表：删除正文中"1. 2. 3."子列表，改为连续段落
13. 关键词重复：同一个修辞性词汇（如"分水岭"、"命门"、"杀手锏"）全篇只能出现一次，重复出现时删除后续的或换成普通说法

=== PRIORITY 3: ZHIHU-SPECIFIC RULES ===
{zhihu_bot_style}

=== PRIORITY 4: FULL REVISION GUIDE ===
{powell_revise_guide}

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

-        # - user input starts with known question triggers like "回答这个问题"

+        # - user input starts with known question triggers like "回答这个问题", OR

+        # - user input contains a Zhihu question URL (pasting a new question link)

+            or "zhihu.com/question/" in user_input


两个 bug 都修好了。总结一下：

**改动 1：URL `<>` 清理** — 2 个文件
- `dispatcher.py:175` — 加了 `.lstrip("<")`
- `zhihu_hunter.py:251` — 加了 `.lstrip("<")`

**改动 2：新问题识别** — 1 个文件
- `generators.py:535-540` — `is_new_question` 条件加了 `"zhihu.com/question/" in user_input`

**结构检查：**
- 无新依赖
- 无函数签名变更

**风险评估：**
- `lstrip("<")` 很保守，只在 URL 头部有 `<` 时才生效，对正常 URL 无影响
- `is_new_question` 新增的 URL 检测不会误触发（只有包含 `zhihu.com/question/` 的输入才匹配），且命中后会清空 history 重新生成

**验证：** 重启 bot 后，在 Slack 用 `mode zhihu-hunter` 贴那个 MaaS 问题的 URL，应该能正确获取标题 "什么是云计算平台 MaaS？..."


我用mode zhihu-hunter生成的内容给你看看，为什么感觉ai味还是很重：把权限交给AI，现阶段的风险不是理论上的，而是真金白银的账单和系统崩溃。很多人把 OpenClaw 当成更好用的聊天机器人，这不对。它的本质是一个个人 AI 操作系统，是一个有手有脚、一直在线的代理运行环境。它能主动干活，比如半夜扫描邮件、监控价格、操作服务器。但这把刀现在还很钝，而且危险。第一个要说的是安全性。OpenClaw 给了 AI 执行 Shell 脚本、读写文件和访问浏览器的权限，这是它的价值，也是命门。2026年3月爆发的安全危机已经证明了这一点，4万个暴露在公网的实例成了黑客的提款机。如果没有内网穿透防护意识直接把这玩意挂在公网，就是在裸奔。把权外交给 AI，底线是必须在物理或逻辑隔离的环境中运行。不要指望大语言模型在那层脆弱的逻辑上搞什么防护。AI 是概率模型，会幻觉，会误解指令。我见过有人让它重构项目，结果模型陷入死循环，不断生成、反思、再生成。2小时后，用户收到的是 200 多美元的 API 账单。这种死循环在 Node.js 守护进程模式下太容易发生了。所以，把 OpenClaw 关进 Docker 容器里跑是必须的，哪怕环境配置很麻烦。另外，OpenClaw 的补丁协议非常暴力。它通过输出文件差异来节省 Token，但这套协议极其脆弱。一旦模型输错一个标点或者行号对不上，整个任务链条会瞬间崩断。这种不确定性决定了它目前只能处理一些容错率极高的边缘任务。对于大多数人，我不建议安装 OpenClaw。现在的社区分化很厉害。一部分人在修路，被 Node.js 版本、环境依赖和内网穿透折腾得精疲力竭，连门都没进去。另一部分人在飙车，展示各种自动化的场景。但实际上，如果你只是想在本地写代码，Claude Code 的原生集成比这种三层转发的架构快得多，也稳得多。如果你还没想好具体要用它解决什么高频、重复且必须远程处理的痛点


The user is showing me a generated Zhihu answer from the bot (via zhihu-hunter mode) and saying the AI tone is still heavy. Let me analyze this text against the kill lists and POWELL_REVISE rules.

AI tone issues I can spot:

1. **"真金白银的账单和系统崩溃"** - 夸张修饰/概念包装
2. **"这把刀现在还很钝，而且危险"** - 比喻（刀）
3. **"命门"** - kill list vocabulary

4. **"裸奔"** - 概念包装/油腻
5. **"那层脆弱的逻辑"** - 夸张修饰
6. **"瞬间崩断"** - 夸张修饰
7. **"精疲力竭"** - 夸张修饰
8. **"修路...飙车"** - 比喻堆砌，exceeds the one-metaphor limit
9. **"第一个要说的是"** - 过度冗余的开场

继续检查后面的表述，发现"极其脆弱"重复了之前的极度脆弱变体，"暴力"与暴力碰撞相关，"必须"这个表述又出现了强制性的语气问题。"一部分人修路另一部分人飙车"这个比喻再次堆砌，而"这不对。它的本质是"采用了对比式定义的套路。关于权限和隔离的表述似乎被截断了，但核心问题是在强调必须性和绝对化的表达。

后面的"脆弱的逻辑"和"连门都没进去"继续用比喻，最后的句子确实没有完成，看起来是在描述某个具体的使用场景但没有结束。

我现在意识到问题所在——第二轮审查应该捕捉到这些违规项，但没有。我在检查杀伤列表中的词汇和模式，发现"极度脆弱"、"安全底线"、"不是建议是必须"这些都明确在列表里，还有多个隐喻比如"修路飙车"、"这把刀"、"提款机"，这超过了允许的最多一个比喻的限制，所以第二轮审查失效了。 The review process is actually running through the two-pass system, so the issue isn't that it's being skipped—it's that Gemini simply isn't following the instructions reliably enough. Adding more words to the kill list keeps hitting diminishing returns since Gemini misses variants anyway. The real problem is that word-level scanning needs a more robust approach than just expanding the lists.


逐条分析这篇的 AI 味问题：

## 1. 比喻严重超标（至少 4 个，上限 1 个）

- "这把刀现在还很钝" — 刀的比喻
- "黑客的提款机" — 提款机比喻
- "裸奔" — 比喻
- "一部分人在修路…另一部分人在飙车" — 两个比喻一起上

## 2. Kill list 词命中但没被杀

| 出现的词 | Kill list 里的对应项 |
|----------|-------------------|
| 极其脆弱 | 极度脆弱（变体）|
| 安全底线 | 安全底线（原词）|
| 是必须的 | 不是建议是必须 |
| 命门 | 关键词重复类（规则13）|
| 暴力 | 暴力碰撞 |
| 瞬间崩断 | 夸张修饰类 |

## 3. 需要新增到 kill list 的词

- **夸张修饰**: 瞬间崩断、精疲力竭、真金白银
- **概念包装**: 裸奔（=没防护）、修路/飙车（=基础工作/高级用法）、提款机（=攻击目标）、命门（=关键风险点）
- **伪分类**: "一部分人在X，另一部分人在Y" — 典型的伪分类二分法

## 4. 句式问题

- "这不对。它的本质是…" — 对比式定义（kill list 规则 2）
- "第一个要说的是" — "首先"的换皮
- 结尾不完整，句子断了

要把这些加进 kill list 吗？


需要增加kill list，另一个问题是，为什么生成质量如此之差，能否系统性排查并解决


The user wants two things:
1. Add new items to the kill list
2. Systematically investigate why the generation quality is so poor and fix it

For #2, I need to think about why the quality is bad despite having a two-pass review system. Let me think about possible root causes:

1. **The zhihu-hunter path might not be using the same review prompt** - Let me check if `draft_answer()` → `ZhihuGenerator.chat()` actually triggers the two-pass review
2. **The question title wasn't fetched properly** - We just fixed the `<` URL issue, so the title was "知乎问题 #2016845353517806681" instead of the actual question. The LLM had NO idea what the question was actually about!

3. **RAG context might be poor** - The semantic search might not return useful content
4. **The first-pass prompt might be too long** - Too many instructions might cause Gemini to ignore them
5. **The review pass might be overwhelmed** - Too many rules for Gemini to follow

Actually, looking back at the logs, the biggest culprit is definitely #2 — the title extraction was completely broken, so the model was working blind the whole time.

The URL fetch gave us just "知乎问题 #2016845353517806681" as the title, which is completely meaningless. Then when the draft answer flow ran, it had this useless title plus a vague outline about definitions and advantages, and the semantic search with that garbage query probably returned nothing relevant. Even the keyword search would've failed. So the LLM somehow managed to figure out the topic was OpenClaw despite having almost no actual context to work with — that's actually impressive in a way, but it explains why the quality tanked.

Now I'm looking at the timeline more carefully. The user mentioned using zhihu-hunter mode and getting this OpenClaw output, but the original question was about MaaS, not OpenClaw. Either this is from a completely different question, or there's something else going on with how the content got mixed up.

The core issue the user is asking about is why the generation quality is so poor and whether I can systematically diagnose and fix it. Let me think through the quality problems systematically.

The first major issue is that when the title fetch fails or the URL cleaning bug isn't applied, the bot only gets a generic title like "知乎问题 #xxx" instead of the actual question. Even when the title does come through, I'm only fetching the title itself, not the full question description—which often contains critical context that shapes the entire response.

The second problem is that the kill list in the review prompt isn't being reliably enforced by Gemini. I've got a comprehensive list of words and phrases to filter out, but Gemini doesn't consistently catch them at the word level, so prohibited content keeps slipping through the final output.

The third issue is prompt bloat. The generation prompt is packed with the methodology, the full POWELL_REVISE guide which is 430 lines alone, the Zhihu style guidelines, RAG context, and more. The review prompt also includes the complete POWELL_REVISE guide. Gemini might be getting overwhelmed by the sheer volume of instructions competing for attention.

The review prompt asks the LLM to be a "strict editor" but doesn't give it a structured way to actually do that—no step-by-step checklist, and the kill list items use vague categories instead of exact phrase matching. The RAG quality is also questionable since a bad title leads to irrelevant semantic search results, and even with a good title, the vault might not have the material I need. Plus, a single review pass probably isn't enough to catch everything, especially when dealing with long kill lists that can overwhelm the LLM's attention.

I need to trace through the actual flow from zhihu-hunter through draft_answer() to ZhihuGenerator, see what the prompts actually look like when they hit the LLM, verify the web context fetch is working, and check whether the review pass is even running for Chinese content.

Let me start by examining the draft_answer() method to understand how the prompt gets constructed, then check if the Tavily web context fetch is functioning properly, see what RAG results come back for typical queries, look at the actual prompt size to see if it's becoming unwieldy, and think about structural improvements to the whole generation pipeline.


好，先做系统性排查，再一起修。


TaskCreate

系统排查 zhihu-hunter 生成质量差的根因

调查完整的 zhihu-hunter 生成管线，找出质量差的根因：
1. draft_answer() 拼装的 prompt 内容是什么？题目/RAG/web context 是否有效？
2. 提示词总长度是否过大导致 Gemini 忽略指令？
3. kill list review pass 是否有效执行？
4. 问题描述（不只是标题）是否被获取？


更新 kill list 词汇

新增：夸张修饰(瞬间崩断/精疲力竭/真金白银)、概念包装(裸奔/修路/飙车/提款机/命门)、伪分类("一部分人X另一部分人Y")、过渡词换皮("第一个要说的是")


Task #5 created successfully: 系统排查 zhihu-hunter 生成质量差的根因

系统排查 zhihu-hunter 生成质量差的根因


Task #6 created successfully: 更新 kill list 词汇

更新 kill list 词汇


Updated task #5 status


先深入排查 `draft_answer()` 的完整流程。


   340→            ]
   341→            return "\n".join(parts)
   342→        except Exception as e:
   343→            logger.debug(f"Recent web context fetch failed: {e}")
   344→            return ""
   345→
   346→    def draft_answer(self, question: ZhihuQuestion, feedback: str = "") -> AnswerDraft:
   347→        """Generate a Zhihu-style answer draft for a question.
   348→
   349→        Delegates to the existing ZhihuGenerator (reuses prompt + style guide).
   350→        The RAG context is enriched by both semantic (ChromaDB) and
   351→        keyword-based (ObsidianIndexer) retrieval.
   352→
   353→        Args:
   354→            question: ZhihuQuestion to answer.
   355→            feedback: Optional revision instructions from the user (e.g. "更口语化一些").
   356→
   357→        Returns:
   358→            AnswerDraft with generated content and source list.
   359→        """
   360→        logger.info(f"Drafting answer for: {question.title}" + (f" [feedback: {feedback[:40]}]" if feedback else ""))
   361→
   362→        # Collect RAG sources for transparency
   363→        rag_sources: List[str] = []
   364→
   365→        semantic_results = self.vector_store.search_knowledge(question.title, top_k=3)
   366→        for r in semantic_results:
   367→            rag_sources.append(Path(r.source_path).name)
   368→
   369→        keyword_results = self.indexer.search(question.title, limit=5)
   370→        for note in keyword_results:
   371→            first_line = note.split("\n")[0].strip("# ").strip()
   372→            if first_line:
   373→                rag_sources.append(first_line)
   374→
   375→        # If the question was pinned to a specific source file, load it as the
   376→        # primary article so the answer stays grounded in its viewpoints.
   377→        primary_article = ""
   378→        if question.source_file:
   379→            fp = self.vault_path / question.source_file
   380→            try:
   381→                raw = fp.read_text(encoding="utf-8", errors="ignore")
   382→                raw = re.sub(r'^---\n.*?\n---\n', '', raw, flags=re.DOTALL).strip()
   383→                primary_article = raw[:4000]
   384→                rag_sources.insert(0, Path(question.source_file).name)
   385→                logger.info(f"Loaded primary article: {question.source_file} ({len(primary_article)} chars)")
   386→            except OSError as e:
   387→                logger.warning(f"Could not read source file {question.source_file}: {e}")
   388→
   389→        # Delegate to existing ZhihuGenerator for consistent style
   390→        generator = ZhihuGenerator(self.indexer, self.vector_store)
   391→        prompt_input = question.title
   392→        if question.snippet:
   393→            prompt_input += f" | 背景：{question.snippet}"
   394→
   395→        # User-provided outline takes highest priority — structure the answer strictly
   396→        # around it, pulling matching content from the knowledge base for each point.
   397→        if question.outline:
   398→            prompt_input += (
   399→                f"\n\n用户提供的回答思路（必须严格按照这个逻辑框架展开，"
   400→                f"从知识库中找到对应的内容来支撑每个论点，不要自由发挥框架结构）：\n{question.outline}"
   401→            )
   402→            logger.info(f"Injected user outline: {question.outline[:60]!r}")
   403→
   404→        if primary_article:
   405→            prompt_input += (
   406→                f"\n\n主要参考文章（请基于此文的观点和论据来回答，"
   407→                f"可直接引用其中的分析和结论）：\n{primary_article}"
   408→            )
   409→
   410→        # Inject recent web context so the answer feels up-to-date
   411→        recent_context = self._fetch_recent_web_context(question.title)
   412→        if recent_context:
   413→            prompt_input += (
   414→                f"\n\n近期相关资讯（最近90天内的动态，用来补充最新工具/发布/讨论，"
   415→                f"让回答更有时效感，自然地融入即可，不要刻意罗列）：\n{recent_context}"
   416→            )
   417→            logger.info(f"Injected recent web context ({len(recent_context)} chars)")
   418→
   419→        if feedback:
   420→            prompt_input += f"\n\n用户修改意见：{feedback}"
   421→
   422→        content, _ = generator.chat(prompt_input, history=[])
   423→
   424→        return AnswerDraft(
   425→            question=question,
   426→            content=content,
   427→            rag_sources=list(dict.fromkeys(rag_sources)),  # deduplicate, keep order
   428→        )
   429→
   430→    # ── Vault Scanning ─────────────────────────────────────────────────────────
   431→
   432→    def _scan_vault_for_keywords(
   433→        self,
   434→        since_days: int,
   435→        dirs: Optional[List[str]] = None,
   436→        topic_hint: str = "",
   437→    ) -> List[str]:
   438→        """Collect vault files and extract topic keywords via LLM.
   439→

            ]
            return "\n".join(parts)
        except Exception as e:
            logger.debug(f"Recent web context fetch failed: {e}")
            return ""

    def draft_answer(self, question: ZhihuQuestion, feedback: str = "") -> AnswerDraft:
        """Generate a Zhihu-style answer draft for a question.

        Delegates to the existing ZhihuGenerator (reuses prompt + style guide).
        The RAG context is enriched by both semantic (ChromaDB) and
        keyword-based (ObsidianIndexer) retrieval.

        Args:
            question: ZhihuQuestion to answer.
            feedback: Optional revision instructions from the user (e.g. "更口语化一些").

        Returns:
            AnswerDraft with generated content and source list.
        """
        logger.info(f"Drafting answer for: {question.title}" + (f" [feedback: {feedback[:40]}]" if feedback else ""))

        # Collect RAG sources for transparency
        rag_sources: List[str] = []

        semantic_results = self.vector_store.search_knowledge(question.title, top_k=3)
        for r in semantic_results:
            rag_sources.append(Path(r.source_path).name)

        keyword_results = self.indexer.search(question.title, limit=5)
        for note in keyword_results:
            first_line = note.split("\n")[0].strip("# ").strip()
            if first_line:
                rag_sources.append(first_line)

        # If the question was pinned to a specific source file, load it as the
        # primary article so the answer stays grounded in its viewpoints.
        primary_article = ""
        if question.source_file:
            fp = self.vault_path / question.source_file
            try:
                raw = fp.read_text(encoding="utf-8", errors="ignore")
                raw = re.sub(r'^---\n.*?\n---\n', '', raw, flags=re.DOTALL).strip()
                primary_article = raw[:4000]
                rag_sources.insert(0, Path(question.source_file).name)
                logger.info(f"Loaded primary article: {question.source_file} ({len(primary_article)} chars)")
            except OSError as e:
                logger.warning(f"Could not read source file {question.source_file}: {e}")

        # Delegate to existing ZhihuGenerator for consistent style
        generator = ZhihuGenerator(self.indexer, self.vector_store)
        prompt_input = question.title
        if question.snippet:
            prompt_input += f" | 背景：{question.snippet}"

        # User-provided outline takes highest priority — structure the answer strictly
        # around it, pulling matching content from the knowledge base for each point.
        if question.outline:
            prompt_input += (
                f"\n\n用户提供的回答思路（必须严格按照这个逻辑框架展开，"
                f"从知识库中找到对应的内容来支撑每个论点，不要自由发挥框架结构）：\n{question.outline}"
            )
            logger.info(f"Injected user outline: {question.outline[:60]!r}")

        if primary_article:
            prompt_input += (
                f"\n\n主要参考文章（请基于此文的观点和论据来回答，"
                f"可直接引用其中的分析和结论）：\n{primary_article}"
            )

        # Inject recent web context so the answer feels up-to-date
        recent_context = self._fetch_recent_web_context(question.title)
        if recent_context:
            prompt_input += (
                f"\n\n近期相关资讯（最近90天内的动态，用来补充最新工具/发布/讨论，"
                f"让回答更有时效感，自然地融入即可，不要刻意罗列）：\n{recent_context}"
            )
            logger.info(f"Injected recent web context ({len(recent_context)} chars)")

        if feedback:
            prompt_input += f"\n\n用户修改意见：{feedback}"

        content, _ = generator.chat(prompt_input, history=[])

        return AnswerDraft(
            question=question,
            content=content,
            rag_sources=list(dict.fromkeys(rag_sources)),  # deduplicate, keep order
        )

    # ── Vault Scanning ─────────────────────────────────────────────────────────

    def _scan_vault_for_keywords(
        self,
        since_days: int,
        dirs: Optional[List[str]] = None,
        topic_hint: str = "",
    ) -> List[str]:
        """Collect vault files and extract topic keywords via LLM.


Now let me check the logs more carefully to understand the OpenClaw question generation. I want to see:
1. What title was fetched
2. What web context was injected
3. What RAG sources were used
4. The actual prompt sent to ZhihuGenerator

Let me look at the most recent logs for the OpenClaw answer generation.


Bash

grep -n "OpenClaw\|openclaw\|Drafting answer\|Direct URL hunt\|Injected\|semantic\|keyword\|recent web\|Generating Zhihu\|second-pass\|Bot received\|mode zhihu" /root/projects/butler/logs/obsidian.log | tail -60

Find OpenClaw-related log entries


3881:2026-03-05 19:12:48 - slack_bot.obsidian.generators - INFO - Generating Zhihu answer for: 回答这个问题：
3883:OpenClaw已成为一款极具实用价值的开源个人AI助手平台，如何使用它来提供办公...
3938:2026-03-06 09:44:09 - __main__ - INFO - Obsidian Bot received from U0A8ZCK7RJM: 回答这个问题：
3940:OpenClaw已成为一款极具实用价值的开源个人AI助手平台，如何使用它来提供办公...
3944:OpenClaw已成为一款极具实用价值的开源个人AI助手平台，如何使用它来提供办公效率呢
4025:2026-03-06 10:26:31 - __main__ - INFO - Obsidian Bot received from U0A8ZCK7RJM: 回答这个问题：
4027:OpenClaw已成为一款极具实用价值的开源个人AI助手平台，如何使用它来提供办公...
4031:OpenClaw已成为一款极具实用价值的开源个人AI助手平台，如何使用它来提供办公效率呢
4062:2026-03-06 10:57:58 - __main__ - INFO - Obsidian Bot received from U0A8ZCK7RJM: 回答这个问题：
4064:OpenClaw已成为一款极具实用价值的开源个人AI助手平台，如何使用它来提供办公...
4068:OpenClaw已成为一款极具实用价值的开源个人AI助手平台，如何使用它来提供办公效率呢
4103:2026-03-14 22:00:09 - __main__ - INFO - Obsidian Bot received from U0A8ZCK7RJM: mode note...
4105:2026-03-14 22:00:15 - __main__ - INFO - Obsidian Bot received from U0A8ZCK7RJM: <https://www.uipath.com/blog/ai/adopting-agentic-a...
4116:2026-03-14 22:07:53 - __main__ - INFO - Obsidian Bot received from U0A8ZCK7RJM: <https://www.exabeam.com/explainers/ai-cyber-secur...
4127:2026-03-16 13:08:54 - __main__ - INFO - Obsidian Bot received from U0A8ZCK7RJM: mode zhihu...
4129:2026-03-16 13:10:20 - __main__ - INFO - Obsidian Bot received from U0A8ZCK7RJM: <https://www.zhihu.com/question/201647704514890581...
4131:2026-03-16 13:10:21 - slack_bot.obsidian.generators - INFO - Generating Zhihu answer for: <https://www.zhihu.com/question/201647704514890581...
4132:2026-03-16 13:10:34 - slack_bot.obsidian.generators - INFO - Running second-pass rule enforcement on Zhihu draft...
4161:2026-03-16 13:31:12 - __main__ - INFO - Obsidian Bot received from U0A8ZCK7RJM: <https://www.zhihu.com/question/201647704514890581...
4165:2026-03-16 13:32:53 - __main__ - INFO - Obsidian Bot received from U0A8ZCK7RJM: mode zhihu...
4167:2026-03-16 13:33:10 - __main__ - INFO - Obsidian Bot received from U0A8ZCK7RJM: <https://www.zhihu.com/question/201647704514890581...
4169:2026-03-16 13:33:14 - slack_bot.obsidian.generators - INFO - Generating Zhihu answer for: <https://www.zhihu.com/question/201647704514890581...
4170:2026-03-16 13:33:25 - slack_bot.obsidian.generators - INFO - Running second-pass rule enforcement on Zhihu draft...
4199:2026-03-16 13:43:21 - __main__ - INFO - Obsidian Bot received from U0A8ZCK7RJM: mode zhihu...
4201:2026-03-16 13:44:18 - __main__ - INFO - Obsidian Bot received from U0A8ZCK7RJM: <https://www.zhihu.com/question/201647704514890581...
4203:2026-03-16 13:44:23 - slack_bot.obsidian.generators - INFO - Generating Zhihu answer for: <https://www.zhihu.com/question/201647704514890581...
4204:2026-03-16 13:44:33 - slack_bot.obsidian.generators - INFO - Running second-pass rule enforcement on Zhihu draft...
4233:2026-03-16 13:50:20 - __main__ - INFO - Obsidian Bot received from U0A8ZCK7RJM: mode zhihu...
4235:2026-03-16 13:50:31 - __main__ - INFO - Obsidian Bot received from U0A8ZCK7RJM: <https://www.zhihu.com/question/201647704514890581...
4237:2026-03-16 13:50:32 - slack_bot.obsidian.generators - INFO - Generating Zhihu answer for: <https://www.zhihu.com/question/201647704514890581...
4238:2026-03-16 13:50:45 - slack_bot.obsidian.generators - INFO - Running second-pass rule enforcement on Zhihu draft...
4267:2026-03-16 14:00:09 - __main__ - INFO - Obsidian Bot received from U0A8ZCK7RJM: mode zhihu...
4269:2026-03-16 14:00:18 - __main__ - INFO - Obsidian Bot received from U0A8ZCK7RJM: <https://www.zhihu.com/question/201647704514890581...
4271:2026-03-16 14:00:22 - slack_bot.obsidian.generators - INFO - Generating Zhihu answer for: <https://www.zhihu.com/question/201647704514890581...
4272:2026-03-16 14:00:34 - slack_bot.obsidian.generators - INFO - Running second-pass rule enforcement on Zhihu draft...
4301:2026-03-16 14:12:52 - __main__ - INFO - Obsidian Bot received from U0A8ZCK7RJM: mode zhihu...
4303:2026-03-16 14:13:05 - __main__ - INFO - Obsidian Bot received from U0A8ZCK7RJM: <https://www.zhihu.com/question/201647704514890581...
4305:2026-03-16 14:13:16 - slack_bot.obsidian.generators - INFO - Generating Zhihu answer for: <https://www.zhihu.com/question/201647704514890581...
4306:2026-03-16 14:13:27 - slack_bot.obsidian.generators - INFO - Running second-pass rule enforcement on Zhihu draft...
4308:2026-03-16 14:22:32 - __main__ - INFO - Obsidian Bot received from U0A8ZCK7RJM: <https://www.zhihu.com/question/201684535351780668...
4312:2026-03-16 14:42:49 - __main__ - INFO - Obsidian Bot received from U0A8ZCK7RJM: mode...
4316:2026-03-16 14:43:07 - __main__ - INFO - Obsidian Bot received from U0A8ZCK7RJM: mode help...
4317:2026-03-16 14:43:14 - __main__ - INFO - Obsidian Bot received from U0A8ZCK7RJM: mode zhihu-hunter...
4319:2026-03-16 14:43:26 - __main__ - INFO - Obsidian Bot received from U0A8ZCK7RJM: <https://www.zhihu.com/question/201684535351780668...
4320:2026-03-16 14:43:28 - slack_bot.zhihu.zhihu_hunter - INFO - Direct URL hunt: '<https://www.zhihu.com/question/2016845353517806681' → title='知乎问题 #2016845353517806681'
4322:2026-03-16 14:44:44 - slack_bot.zhihu.zhihu_hunter - INFO - Drafting answer for: 知乎问题 #2016845353517806681
4325:2026-03-16 14:44:46 - slack_bot.zhihu.zhihu_hunter - INFO - Injected user outline: '回答定义，优势和建议'
4326:2026-03-16 14:44:49 - slack_bot.zhihu.zhihu_hunter - INFO - Injected recent web context (1140 chars)
4327:2026-03-16 14:44:52 - slack_bot.obsidian.generators - INFO - Generating Zhihu answer for: 知乎问题 #2016845353517806681
4330:2026-03-16 14:45:03 - slack_bot.obsidian.generators - INFO - Running second-pass rule enforcement on Zhihu draft...
4359:2026-03-17 11:50:44 - __main__ - INFO - Obsidian Bot received from U0A8ZCK7RJM: mode zhihu-hunter...
4361:2026-03-17 11:51:25 - __main__ - INFO - Obsidian Bot received from U0A8ZCK7RJM: <https://www.zhihu.com/question/201539699991513933...
4362:2026-03-17 11:51:32 - slack_bot.zhihu.zhihu_hunter - INFO - Direct URL hunt: 'https://www.zhihu.com/question/2015396999915139338' → title='(11 条消息) 你会使用OpenClaw吗，风险大于获得？'
4363:2026-03-17 11:51:32 - slack_bot.obsidian.dispatcher - INFO - ZhihuHunter: direct URL 'https://www.zhihu.com/question/2015396999915139338' → '(11 条消息) 你会使用OpenClaw吗，风险大于获得？' outline='权限交给ai在现阶段确实存在很大的风险，所以应该在一个隔离的环境中运行'
4364:2026-03-17 11:51:39 - slack_bot.zhihu.zhihu_hunter - INFO - Drafting answer for: (11 条消息) 你会使用OpenClaw吗，风险大于获得？
4367:2026-03-17 11:51:44 - slack_bot.zhihu.zhihu_hunter - INFO - Injected user outline: '权限交给ai在现阶段确实存在很大的风险，所以应该在一个隔离的环境中运行'
4368:2026-03-17 11:51:46 - slack_bot.zhihu.zhihu_hunter - INFO - Injected recent web context (890 chars)
4369:2026-03-17 11:51:52 - slack_bot.obsidian.generators - INFO - Generating Zhihu answer for: (11 条消息) 你会使用OpenClaw吗，风险大于获得？
4372:2026-03-17 11:52:02 - slack_bot.obsidian.generators - INFO - Running second-pass rule enforcement on Zhihu draft...
4373:2026-03-17 11:52:07 - slack_bot.zhihu.slack_interactive_gateway - INFO - Draft saved to vault: /root/vault/obsidian_vault/obsidian/obsidian/zhihu/2026-03-17-11-条消息-你会使用OpenClaw吗-风险大于获得.md