I need to understand the Zhihu answer generation pipeline in this project. Specifically:

1. How does `ZhihuHunter.hunt_direct_url()` fetch a Zhihu question (in `/root/projects/butler/slack_bot/zhihu/zhihu_hunter.py`)?
2. How does `SlackInteractiveGateway` handle user interaction to trigger answer generation (in `/root/projects/butler/slack_bot/zhihu/slack_interactive_gateway.py`)?
3. How is the Zhihu answer actually generated - what prompt is sent to the LLM and does it include the question's detail/description text or just the title?
4. Where does the `outline` field get used in the answer generation process?

Be thorough - read the full relevant methods in these files. I need to understand the complete flow from URL paste → question fetch → user clicks "generate" → LLM generates answer.


I'll help you understand the Zhihu answer generation pipeline. Let me start by exploring the relevant files.


     1→"""Slack Interactive Gateway — Block Kit control panel for zhihu-hunter.
     2→
     3→Manages two human-in-the-loop approval gates:
     4→
     5→    Gate 1  [✍ 生成草稿] / [🚫 忽略]
     6→            Triggered when ZhihuHunter surfaces candidate questions.
     7→
     8→    Gate 2  [✅ 确认发布] / [🔄 重新生成] / [❌ 取消]
     9→            Triggered after an answer draft has been generated.
    10→
    11→Usage:
    12→    gateway = SlackInteractiveGateway(slack_client, hunter, engine, channel)
    13→    gateway.register(app)   # call once at bot startup
    14→    gateway.post_question_cards(questions)  # call from dispatcher
    15→"""
    16→
    17→import json
    18→import uuid
    19→from pathlib import Path
    20→from typing import Optional
    21→
    22→from slack_bolt import App
    23→from slack_sdk import WebClient
    24→
    25→from health.utils.logging_config import setup_logger
    26→from slack_bot.zhihu.zhihu_hunter import AnswerDraft, ZhihuHunter, ZhihuQuestion
    27→from slack_bot.zhihu.zhihu_playwright_engine import ZhihuPlaywrightEngine
    28→
    29→logger = setup_logger(__name__)
    30→
    31→
    32→# ── Action IDs ─────────────────────────────────────────────────────────────────
    33→_ACT_GENERATE = "zhihu_generate_draft"
    34→_ACT_IGNORE   = "zhihu_ignore"
    35→_ACT_PUBLISH  = "zhihu_publish"
    36→_ACT_REGEN    = "zhihu_regenerate"
    37→_ACT_CANCEL   = "zhihu_cancel"
    38→_VIEW_REGEN   = "zhihu_regen_modal"
    39→
    40→# Characters shown in the draft preview section (Slack block limit: 3000)
    41→_PREVIEW_CHARS = 800
    42→
    43→
    44→class SlackInteractiveGateway:
    45→    """Block Kit gateway providing two approval gates for the zhihu-hunter workflow.
    46→
    47→    Args:
    48→        slack_client: Authenticated WebClient (same token as the Bolt App).
    49→        hunter: ZhihuHunter instance for draft generation.
    50→        engine: ZhihuPlaywrightEngine instance for publishing.
    51→        notify_channel: Default Slack channel/DM for outbound cards.
    52→        state_dir: Directory for storing draft JSON files between interactions.
    53→            Defaults to <project_root>/data/zhihu_drafts.
    54→    """
    55→
    56→    def __init__(
    57→        self,
    58→        slack_client: WebClient,
    59→        hunter: ZhihuHunter,
    60→        engine: ZhihuPlaywrightEngine,
    61→        notify_channel: str,
    62→        state_dir: Optional[Path] = None,
    63→    ) -> None:
    64→        self.client        = slack_client
    65→        self.hunter        = hunter
    66→        self.engine        = engine
    67→        self.notify_channel = notify_channel
    68→        self.state_dir     = state_dir or (
    69→            Path(__file__).resolve().parents[3] / "data" / "zhihu_drafts"
    70→        )
    71→        self.state_dir.mkdir(parents=True, exist_ok=True)
    72→
    73→    # ── Registration ───────────────────────────────────────────────────────────
    74→
    75→    def register(self, app: App) -> None:
    76→        """Attach all action handlers to a Slack Bolt App instance.
    77→
    78→        Call this once during bot startup, after the App object is created.
    79→
    80→        Args:
    81→            app: The Slack Bolt App that receives Socket Mode events.
    82→        """
    83→        app.action(_ACT_GENERATE)(self._handle_generate_draft)
    84→        app.action(_ACT_IGNORE)(self._handle_ignore)
    85→        app.action(_ACT_PUBLISH)(self._handle_publish)
    86→        app.action(_ACT_REGEN)(self._handle_regenerate)
    87→        app.action(_ACT_CANCEL)(self._handle_cancel)
    88→        app.view(_VIEW_REGEN)(self._handle_regenerate_submit)
    89→        logger.info("ZhihuHunter: Slack action handlers registered")
    90→
    91→    # ── Outbound: Gate 1 ───────────────────────────────────────────────────────
    92→
    93→    def post_question_cards(
    94→        self,
    95→        questions: list[ZhihuQuestion],
    96→        channel: Optional[str] = None,
    97→    ) -> None:
    98→        """Post one Block Kit card per discovered question (Gate 1).
    99→
   100→        Each card includes [✍ 生成草稿] and [🚫 忽略] buttons.
   101→
   102→        Args:
   103→            questions: Candidate questions returned by ZhihuHunter.scan_and_hunt().
   104→            channel: Override channel. Defaults to self.notify_channel.
   105→        """
   106→        ch = channel or self.notify_channel
   107→        if not questions:
   108→            self.client.chat_postMessage(
   109→                channel=ch,
   110→                text="🔍 本次未发现符合条件的知乎问题，下次再试。",
   111→            )
   112→            return
   113→
   114→        self.client.chat_postMessage(
   115→            channel=ch,
   116→            text=f"🔍 发现 *{len(questions)}* 个知乎相关问题，请逐一确认：",
   117→        )
   118→        for q in questions:
   119→            try:
   120→                self.client.chat_postMessage(
   121→                    channel=ch,
   122→                    blocks=self._question_blocks(q),
   123→                    text=f"🔍 知乎问题: {q.title}",
   124→                )
   125→            except Exception as e:
   126→                logger.error(f"Failed to post question card for '{q.title}': {e}")
   127→
   128→    # ── Outbound: Gate 2 ───────────────────────────────────────────────────────
   129→
   130→    def post_draft_card(
   131→        self,
   132→        draft: AnswerDraft,
   133→        channel: str,
   134→        thread_ts: str,
   135→    ) -> None:
   136→        """Post an answer draft card as a thread reply (Gate 2).
   137→
   138→        Includes [✅ 确认发布], [🔄 重新生成], and [❌ 取消] buttons.
   139→
   140→        Args:
   141→            draft: Generated AnswerDraft.
   142→            channel: Channel containing the original question card.
   143→            thread_ts: ts of the question card message (reply target).
   144→        """
   145→        draft_id = self._save_draft(draft)
   146→        try:
   147→            self.client.chat_postMessage(
   148→                channel=channel,
   149→                thread_ts=thread_ts,
   150→                blocks=self._draft_blocks(draft, draft_id),
   151→                text=f"📝 草稿已生成，等待确认发布: {draft.question.title}",
   152→            )
   153→        except Exception as e:
   154→            logger.error(f"Failed to post draft card: {e}")
   155→
   156→    # ── Action Handlers ────────────────────────────────────────────────────────
   157→
   158→    def _handle_generate_draft(self, ack, body, client) -> None:
   159→        """Gate 1 → [✍ 生成草稿]: generate draft and post as thread reply."""
   160→        ack()
   161→        channel  = body["channel"]["id"]
   162→        msg_ts   = body["message"]["ts"]
   163→        raw_val  = body["actions"][0]["value"]
   164→
   165→        # Immediately show loading state
   166→        client.chat_update(
   167→            channel=channel,
   168→            ts=msg_ts,
   169→            blocks=self._loading_block("✍ 草稿生成中，请稍候..."),
   170→            text="草稿生成中...",
   171→        )
   172→
   173→        try:
   174→            question = ZhihuQuestion.model_validate_json(raw_val)
   175→            draft    = self.hunter.draft_answer(question)
   176→
   177→            # Persist to vault for manual fallback publishing
   178→            vault_file = self._save_to_vault(draft)
   179→            if vault_file:
   180→                draft = draft.model_copy(update={"vault_file": str(vault_file)})
   181→
   182→            draft_id = self._save_draft(draft)
   183→
   184→            # Post draft as thread reply
   185→            client.chat_postMessage(
   186→                channel=channel,
   187→                thread_ts=msg_ts,
   188→                blocks=self._draft_blocks(draft, draft_id),
   189→                text=f"📝 草稿已生成: {question.title}",
   190→            )
   191→
   192→            # Update original question card to show "done" state
   193→            client.chat_update(
   194→                channel=channel,
   195→                ts=msg_ts,
   196→                blocks=self._done_block(
   197→                    f"📩 草稿已生成，请在上方回复中查看并确认。\n*<{question.url}|{question.title}>*"
   198→                ),
   199→                text="草稿已生成",
   200→            )
   201→
   202→        except Exception as e:
   203→            logger.error(f"Draft generation failed: {e}", exc_info=True)
   204→            client.chat_update(
   205→                channel=channel,
   206→                ts=msg_ts,
   207→                blocks=self._error_block(f"草稿生成失败：{e}"),
   208→                text="草稿生成失败",
   209→            )
   210→
   211→    def _handle_ignore(self, ack, respond) -> None:
   212→        """Gate 1 → [🚫 忽略]: dismiss the question card."""
   213→        ack()
   214→        respond(
   215→            replace_original=True,
   216→            text="🚫 已忽略",
   217→            blocks=self._done_block("🚫 已忽略此问题"),
   218→        )
   219→
   220→    def _handle_publish(self, ack, body, client) -> None:
   221→        """Gate 2 → [✅ 确认发布]: login to Zhihu and publish the answer."""
   222→        ack()
   223→        channel  = body["channel"]["id"]
   224→        msg_ts   = body["message"]["ts"]
   225→        draft_id = body["actions"][0]["value"]
   226→
   227→        client.chat_update(
   228→            channel=channel,
   229→            ts=msg_ts,
   230→            blocks=self._loading_block("⏳ 正在登录知乎并发布，请稍候..."),
   231→            text="发布中...",
   232→        )
   233→
   234→        try:
   235→            draft = self._load_draft(draft_id)
   236→            if draft is None:
   237→                raise RuntimeError(f"草稿文件不存在（id={draft_id}），可能已过期")
   238→
   239→            # Ensure valid Zhihu session (may send QR to Slack)
   240→            if not self.engine.ensure_logged_in():
   241→                raise RuntimeError("知乎登录失败，请扫码后重试")
   242→
   243→            answer_url = self.engine.publish_answer(
   244→                question_url=draft.question.url,
   245→                answer_text=draft.content,
   246→            )
   247→
   248→            client.chat_update(
   249→                channel=channel,
   250→                ts=msg_ts,
   251→                blocks=self._done_block(
   252→                    f"✅ *发布成功！*\n"
   253→                    f"问题: *<{draft.question.url}|{draft.question.title}>*\n"
   254→                    f"回答: <{answer_url}|查看已发布回答>"
   255→                ),
   256→                text=f"✅ 已发布: {answer_url}",
   257→            )
   258→            # Update vault file status to published
   259→            if draft.vault_file:
   260→                self._update_vault_published(draft.vault_file, answer_url)
   261→            # Clean up draft file
   262→            self._delete_draft(draft_id)
   263→
   264→        except Exception as e:
   265→            logger.error(f"Publish failed: {e}", exc_info=True)
   266→            client.chat_update(
   267→                channel=channel,
   268→                ts=msg_ts,
   269→                blocks=self._error_block(
   270→                    f"发布失败：{e}\n你可以重新点击发布，或联系排查日志。"
   271→                ),
   272→                text="发布失败",
   273→            )
   274→
   275→    def _handle_regenerate(self, ack, body, client) -> None:
   276→        """Gate 2 → [🔄 重新生成]: open a modal for user to enter revision feedback."""
   277→        ack()
   278→        channel  = body["channel"]["id"]
   279→        msg_ts   = body["message"]["ts"]
   280→        raw_val  = body["actions"][0]["value"]
   281→
   282→        try:
   283→            question = ZhihuQuestion.model_validate_json(raw_val)
   284→        except Exception:
   285→            question_title = "（问题信息丢失）"
   286→        else:
   287→            question_title = question.title
   288→
   289→        private_metadata = json.dumps({
   290→            "channel":    channel,
   291→            "msg_ts":     msg_ts,
   292→            "regen_value": raw_val,
   293→        })
   294→
   295→        client.views_open(
   296→            trigger_id=body["trigger_id"],
   297→            view={
   298→                "type": "modal",
   299→                "callback_id": _VIEW_REGEN,
   300→                "title": {"type": "plain_text", "text": "重新生成草稿"},
   301→                "submit": {"type": "plain_text", "text": "生成"},
   302→                "close":  {"type": "plain_text", "text": "取消"},
   303→                "private_metadata": private_metadata,
   304→                "blocks": [
   305→                    {
   306→                        "type": "section",
   307→                        "text": {
   308→                            "type": "mrkdwn",
   309→                            "text": f"*{question_title}*\n\n请输入修改意见，Bot 会据此重新生成草稿。",
   310→                        },
   311→                    },
   312→                    {
   313→                        "type": "input",
   314→                        "block_id": "feedback_block",
   315→                        "optional": True,
   316→                        "label": {"type": "plain_text", "text": "修改意见"},
   317→                        "element": {
   318→                            "type": "plain_text_input",
   319→                            "action_id": "feedback_input",
   320→                            "multiline": True,
   321→                            "placeholder": {
   322→                                "type": "plain_text",
   323→                                "text": "例：内容太技术了，偏向大众一些；字数控制在500字以内；结尾加一句诗",
   324→                            },
   325→                        },
   326→                    },
   327→                ],
   328→            },
   329→        )
   330→
   331→    def _handle_regenerate_submit(self, ack, body, client) -> None:
   332→        """Modal submit → regenerate draft with user feedback."""
   333→        ack()
   334→        metadata  = json.loads(body["view"]["private_metadata"])
   335→        channel   = metadata["channel"]
   336→        msg_ts    = metadata["msg_ts"]
   337→        raw_val   = metadata["regen_value"]
   338→        feedback  = (
   339→            body["view"]["state"]["values"]
   340→            .get("feedback_block", {})
   341→            .get("feedback_input", {})
   342→            .get("value") or ""
   343→        )
   344→
   345→        client.chat_update(
   346→            channel=channel,
   347→            ts=msg_ts,
   348→            blocks=self._loading_block("🔄 重新生成中，请稍候..."),
   349→            text="重新生成中...",
   350→        )
   351→
   352→        try:
   353→            question = ZhihuQuestion.model_validate_json(raw_val)
   354→            draft    = self.hunter.draft_answer(question, feedback=feedback)
   355→
   356→            # Persist to vault (overwrites previous draft file for same question)
   357→            vault_file = self._save_to_vault(draft)
   358→            if vault_file:
   359→                draft = draft.model_copy(update={"vault_file": str(vault_file)})
   360→
   361→            draft_id = self._save_draft(draft)
   362→
   363→            client.chat_update(
   364→                channel=channel,
   365→                ts=msg_ts,
   366→                blocks=self._draft_blocks(draft, draft_id),
   367→                text=f"📝 草稿已重新生成: {question.title}",
   368→            )
   369→
   370→        except Exception as e:
   371→            logger.error(f"Regenerate submit failed: {e}", exc_info=True)
   372→            client.chat_update(
   373→                channel=channel,
   374→                ts=msg_ts,
   375→                blocks=self._error_block(f"重新生成失败：{e}"),
   376→                text="重新生成失败",
   377→            )
   378→
   379→    def _handle_cancel(self, ack, respond) -> None:
   380→        """Gate 2 → [❌ 取消]: dismiss the draft card."""
   381→        ack()
   382→        respond(
   383→            replace_original=True,
   384→            text="❌ 已取消",
   385→            blocks=self._done_block("❌ 已取消本次发布"),
   386→        )
   387→
   388→    # ── Block Kit Builders ─────────────────────────────────────────────────────
   389→
   390→    def _question_blocks(self, question: ZhihuQuestion) -> list[dict]:
   391→        """Build Gate 1 Block Kit blocks for a single question.
   392→
   393→        Args:
   394→            question: ZhihuQuestion to display.
   395→
   396→        Returns:
   397→            List of Slack Block Kit block dicts.
   398→        """
   399→        # Truncate snippet so the button value stays < 2000 chars
   400→        q_for_button = question.model_copy(
   401→            update={"snippet": (question.snippet or "")[:100]}
   402→        )
   403→        btn_value = q_for_button.model_dump_json()
   404→
   405→        body_lines = [f"*<{question.url}|{question.title}>*"]
   406→        if question.snippet:
   407→            body_lines.append(f"> {question.snippet[:200]}")
   408→        if question.keywords:
   409→            body_lines.append(f"_关键词：{' · '.join(question.keywords)}_")
   410→
   411→        return [
   412→            {
   413→                "type": "section",
   414→                "text": {"type": "mrkdwn", "text": "\n".join(body_lines)},
   415→            },
   416→            {
   417→                "type": "actions",
   418→                "elements": [
   419→                    {
   420→                        "type": "button",
   421→                        "text": {"type": "plain_text", "text": "✍ 生成草稿", "emoji": True},
   422→                        "style": "primary",
   423→                        "action_id": _ACT_GENERATE,
   424→                        "value": btn_value,
   425→                    },
   426→                    {
   427→                        "type": "button",
   428→                        "text": {"type": "plain_text", "text": "🚫 忽略", "emoji": True},
   429→                        "action_id": _ACT_IGNORE,
   430→                        "value": "ignore",
   431→                    },
   432→                ],
   433→            },
   434→        ]
   435→
   436→    def _draft_blocks(self, draft: AnswerDraft, draft_id: str) -> list[dict]:
   437→        """Build Gate 2 Block Kit blocks for a draft answer.
   438→
   439→        Args:
   440→            draft: AnswerDraft to display.
   441→            draft_id: UUID key used as the publish button value.
   442→
   443→        Returns:
   444→            List of Slack Block Kit block dicts.
   445→        """
   446→        preview = draft.content[:_PREVIEW_CHARS]
   447→        if len(draft.content) > _PREVIEW_CHARS:
   448→            preview += f"\n_…（共 {len(draft.content)} 字，发布时使用全文）_"
   449→
   450→        # Truncate question for regenerate button value
   451→        q_for_btn = draft.question.model_copy(
   452→            update={"snippet": (draft.question.snippet or "")[:100]}
   453→        )
   454→        regen_value = q_for_btn.model_dump_json()
   455→
   456→        blocks: list[dict] = [
   457→            {
   458→                "type": "section",
   459→                "text": {
   460→                    "type": "mrkdwn",
   461→                    "text": (
   462→                        f"📝 *草稿已生成* | "
   463→                        f"<{draft.question.url}|{draft.question.title}>"
   464→                    ),
   465→                },
   466→            },
   467→            {"type": "divider"},
   468→            {
   469→                "type": "section",
   470→                "text": {"type": "mrkdwn", "text": preview},
   471→            },
   472→        ]
   473→
   474→        if draft.rag_sources:
   475→            blocks.append({
   476→                "type": "context",
   477→                "elements": [{
   478→                    "type": "mrkdwn",
   479→                    "text": f"📚 RAG 来源：{' · '.join(draft.rag_sources[:5])}",
   480→                }],
   481→            })
   482→
   483→        blocks += [
   484→            {"type": "divider"},
   485→            {
   486→                "type": "actions",
   487→                "elements": [
   488→                    {
   489→                        "type": "button",
   490→                        "text": {"type": "plain_text", "text": "✅ 确认发布", "emoji": True},
   491→                        "style": "primary",
   492→                        "action_id": _ACT_PUBLISH,
   493→                        "value": draft_id,
   494→                    },
   495→                    {
   496→                        "type": "button",
   497→                        "text": {"type": "plain_text", "text": "🔄 重新生成", "emoji": True},
   498→                        "action_id": _ACT_REGEN,
   499→                        "value": regen_value,
   500→                    },
   501→                    {
   502→                        "type": "button",
   503→                        "text": {"type": "plain_text", "text": "❌ 取消", "emoji": True},
   504→                        "style": "danger",
   505→                        "action_id": _ACT_CANCEL,
   506→                        "value": "cancel",
   507→                    },
   508→                ],
   509→            },
   510→        ]
   511→        return blocks
   512→
   513→    def _loading_block(self, message: str) -> list[dict]:
   514→        """Simple single-section block for loading state.
   515→
   516→        Args:
   517→            message: Text to display (supports mrkdwn).
   518→
   519→        Returns:
   520→            List with one section block.
   521→        """
   522→        return [{"type": "section", "text": {"type": "mrkdwn", "text": message}}]
   523→
   524→    def _done_block(self, message: str) -> list[dict]:
   525→        """Single-section block for a completed/dismissed state.
   526→
   527→        Args:
   528→            message: Text to display (supports mrkdwn).
   529→
   530→        Returns:
   531→            List with one section block (no action buttons).
   532→        """
   533→        return [{"type": "section", "text": {"type": "mrkdwn", "text": message}}]
   534→
   535→    def _error_block(self, message: str) -> list[dict]:
   536→        """Single-section block for error display.
   537→
   538→        Args:
   539→            message: Error description (supports mrkdwn).
   540→
   541→        Returns:
   542→            List with one section block prefixed with ⚠.
   543→        """
   544→        return [{
   545→            "type": "section",
   546→            "text": {"type": "mrkdwn", "text": f"⚠ {message}"},
   547→        }]
   548→
   549→    # ── Draft State ────────────────────────────────────────────────────────────
   550→
   551→    def _save_to_vault(self, draft: AnswerDraft) -> Optional[Path]:
   552→        """Persist a draft as a Markdown file in <vault>/zhihu/.
   553→
   554→        Args:
   555→            draft: The generated AnswerDraft to save.
   556→
   557→        Returns:
   558→            Absolute path of the written file, or None on failure.
   559→        """
   560→        import re
   561→        from datetime import date as _date
   562→
   563→        try:
   564→            zhihu_dir = self.hunter.vault_path / "zhihu"
   565→            zhihu_dir.mkdir(parents=True, exist_ok=True)
   566→
   567→            date_str = _date.today().strftime("%Y-%m-%d")
   568→            slug = re.sub(r"[^\w\u4e00-\u9fff]+", "-", draft.question.title)[:40].strip("-")
   569→            file_path = zhihu_dir / f"{date_str}-{slug}.md"
   570→
   571→            file_path.write_text(
   572→                f"---\n"
   573→                f"title: \"{draft.question.title}\"\n"
   574→                f"url: {draft.question.url}\n"
   575→                f"date: {date_str}\n"
   576→                f"status: draft\n"
   577→                f"tags: [zhihu]\n"
   578→                f"---\n\n"
   579→                f"# {draft.question.title}\n\n"
   580→                f"{draft.content}\n",
   581→                encoding="utf-8",
   582→            )
   583→            logger.info(f"Draft saved to vault: {file_path}")
   584→            return file_path
   585→        except Exception as e:
   586→            logger.warning(f"Failed to save draft to vault: {e}")
   587→            return None
   588→
   589→    def _update_vault_published(self, vault_file: str, answer_url: str) -> None:
   590→        """Update the vault file's frontmatter status to published.
   591→
   592→        Args:
   593→            vault_file: Absolute path string stored in AnswerDraft.vault_file.
   594→            answer_url: Published answer URL to record.
   595→        """
   596→        try:
   597→            fp = Path(vault_file)
   598→            if not fp.exists():
   599→                return
   600→            content = fp.read_text(encoding="utf-8")
   601→            content = content.replace(
   602→                "status: draft",
   603→                f"status: published\nanswer_url: {answer_url}",
   604→                1,
   605→            )
   606→            fp.write_text(content, encoding="utf-8")
   607→            logger.info(f"Vault file marked published: {fp}")
   608→        except Exception as e:
   609→            logger.warning(f"Failed to update vault file: {e}")
   610→
   611→    def _save_draft(self, draft: AnswerDraft) -> str:
   612→        """Persist a draft to disk and return its UUID key.
   613→
   614→        Args:
   615→            draft: AnswerDraft to save.
   616→
   617→        Returns:
   618→            UUID string key (used as publish button value).
   619→        """
   620→        draft_id  = uuid.uuid4().hex[:12]
   621→        file_path = self.state_dir / f"{draft_id}.json"
   622→        file_path.write_text(draft.model_dump_json(), encoding="utf-8")
   623→        logger.debug(f"Draft saved: {file_path}")
   624→        return draft_id
   625→
   626→    def _load_draft(self, draft_id: str) -> Optional[AnswerDraft]:
   627→        """Load a draft by its UUID key.
   628→
   629→        Args:
   630→            draft_id: Key returned by _save_draft().
   631→
   632→        Returns:
   633→            AnswerDraft if found, None if the file does not exist.
   634→        """
   635→        file_path = self.state_dir / f"{draft_id}.json"
   636→        if not file_path.exists():
   637→            return None
   638→        try:
   639→            return AnswerDraft.model_validate_json(file_path.read_text(encoding="utf-8"))
   640→        except Exception as e:
   641→            logger.error(f"Failed to load draft {draft_id}: {e}")
   642→            return None
   643→
   644→    def _delete_draft(self, draft_id: str) -> None:
   645→        """Remove a draft file after successful publish.
   646→
   647→        Args:
   648→            draft_id: Key of the draft to delete.
   649→        """
   650→        file_path = self.state_dir / f"{draft_id}.json"
   651→        try:
   652→            file_path.unlink(missing_ok=True)
   653→        except Exception as e:
   654→            logger.warning(f"Could not delete draft file {draft_id}: {e}")
   655→


     1→"""Zhihu Hunter — vault scanning, question discovery, and answer drafting.
     2→
     3→Workflow:
     4→    1. scan_and_hunt()  →  scans recent vault files, extracts keywords,
     5→                           searches Tavily for matching Zhihu questions.
     6→    2. draft_answer()   →  uses existing RAG + ZhihuGenerator to produce
     7→                           a Zhihu-style answer draft ready for review.
     8→"""
     9→
    10→import os
    11→import re
    12→import requests
    13→from datetime import datetime, timezone, timedelta
    14→from pathlib import Path
    15→from typing import List, Optional
    16→
    17→from pydantic import BaseModel, Field
    18→
    19→from health.utils.logging_config import setup_logger
    20→from slack_bot.llm.gemini import GeminiLLM
    21→from slack_bot.obsidian.generators import ZhihuGenerator
    22→from slack_bot.obsidian.indexer import ObsidianIndexer
    23→from slack_bot.obsidian.vector_store import ChromaVectorStore
    24→
    25→logger = setup_logger(__name__)
    26→
    27→
    28→# ── Vault directories to watch for new/updated content ────────────────────────
    29→# Paths are relative to vault_path root.
    30→WATCH_DIRS: List[str] = [
    31→    "notes",
    32→    "Article",
    33→    "auto_report/deep-dive",
    34→    "CLAW/deepfact",
    35→    "Clippings",
    36→]
    37→
    38→# Article is always included regardless of time; Revised files are prioritized.
    39→_ARTICLE_DIR = "Article"
    40→
    41→# Short names users can type to target a specific directory.
    42→DIR_SHORTCUTS: dict[str, str] = {
    43→    "notes":      "notes",
    44→    "article":    "Article",
    45→    "clippings":  "Clippings",
    46→    "claw":       "CLAW/deepfact",
    47→    "deepfact":   "CLAW/deepfact",
    48→    "deep-dive":  "auto_report/deep-dive",
    49→}
    50→
    51→_MAX_FILES_PER_DIR   = 4    # most-recent files per directory (keeps dir diversity)
    52→_MAX_FILES_ARTICLE   = 8    # Article gets more slots (higher priority)
    53→_MAX_KEYWORDS        = 10   # keywords extracted from vault scan
    54→_MAX_QUESTIONS_TOTAL = 12   # total Zhihu questions surfaced per hunt
    55→# Must contain /question/<id>; answer/comment subpages are excluded in _find_questions
    56→_MIN_QUESTION_URL_RE = re.compile(r'zhihu\.com/question/\d+')
    57→
    58→
    59→# ── Data Models ────────────────────────────────────────────────────────────────
    60→
    61→class ZhihuQuestion(BaseModel):
    62→    """A Zhihu question candidate surfaced by the hunter."""
    63→
    64→    title: str
    65→    url: str
    66→    snippet: Optional[str] = None
    67→    keywords: List[str] = Field(default_factory=list)
    68→    found_at: str = Field(
    69→        default_factory=lambda: datetime.now(tz=timezone.utc).isoformat()
    70→    )
    71→    source_file: Optional[str] = None  # vault-relative path, set by scan_single_file()
    72→    outline: Optional[str] = None       # user-provided answer logic/structure (direct URL mode)
    73→
    74→
    75→class AnswerDraft(BaseModel):
    76→    """A generated answer draft awaiting human review."""
    77→
    78→    question: ZhihuQuestion
    79→    content: str
    80→    generated_at: str = Field(
    81→        default_factory=lambda: datetime.now(tz=timezone.utc).isoformat()
    82→    )
    83→    rag_sources: List[str] = Field(default_factory=list)
    84→    vault_file: Optional[str] = None  # absolute path set after saving to vault
    85→
    86→
    87→# ── Main Class ─────────────────────────────────────────────────────────────────
    88→
    89→class ZhihuHunter:
    90→    """Discovers Zhihu questions from vault content and drafts answers.
    91→
    92→    Example:
    93→        hunter = ZhihuHunter(vault_path, vector_store, indexer)
    94→        questions = hunter.scan_and_hunt(since_days=3)
    95→        draft = hunter.draft_answer(questions[0])
    96→    """
    97→
    98→    def __init__(
    99→        self,
   100→        vault_path: Path,
   101→        vector_store: ChromaVectorStore,
   102→        indexer: ObsidianIndexer,
   103→    ) -> None:
   104→        """Initialize the hunter.
   105→
   106→        Args:
   107→            vault_path: Root path of the Obsidian vault.
   108→            vector_store: ChromaDB vector store for semantic retrieval.
   109→            indexer: ObsidianIndexer for keyword-based search and writing samples.
   110→        """
   111→        self.vault_path = vault_path
   112→        self.vector_store = vector_store
   113→        self.indexer = indexer
   114→        self.llm = GeminiLLM()
   115→
   116→    # ── Public API ─────────────────────────────────────────────────────────────
   117→
   118→    def scan_and_hunt(
   119→        self,
   120→        since_days: int = 7,
   121→        dirs: Optional[List[str]] = None,
   122→        topic_hint: str = "",
   123→    ) -> List[ZhihuQuestion]:
   124→        """Scan vault files and surface matching Zhihu questions.
   125→
   126→        Args:
   127→            since_days: Age threshold for non-Article directories.
   128→            dirs: Restrict scan to these vault dirs (relative paths). None = all WATCH_DIRS.
   129→            topic_hint: Optional focus hint injected into keyword extraction prompt.
   130→
   131→        Returns:
   132→            List of ZhihuQuestion candidates (up to _MAX_QUESTIONS_TOTAL).
   133→        """
   134→        scan_desc = f"dirs={dirs or 'all'}" + (f", topic='{topic_hint}'" if topic_hint else "")
   135→        logger.info(f"ZhihuHunter: scanning vault ({scan_desc})")
   136→
   137→        keywords = self._scan_vault_for_keywords(since_days, dirs=dirs, topic_hint=topic_hint)
   138→        if not keywords:
   139→            logger.info("No keywords extracted from vault files")
   140→            return []
   141→
   142→        logger.info(f"Extracted keywords: {keywords}")
   143→        questions = self._find_questions(keywords)
   144→        logger.info(f"Found {len(questions)} Zhihu questions")
   145→        return questions
   146→
   147→    def scan_single_file(self, rel_path: str) -> List[ZhihuQuestion]:
   148→        """Extract keywords from one vault file and find matching Zhihu questions.
   149→
   150→        The returned questions carry ``source_file`` set to ``rel_path`` so that
   151→        ``draft_answer()`` will ground the generated answer in that article.
   152→
   153→        Args:
   154→            rel_path: Path relative to vault_path (e.g. "Article/8-xxx-Revised.md").
   155→
   156→        Returns:
   157→            List of ZhihuQuestion candidates with source_file populated.
   158→
   159→        Raises:
   160→            FileNotFoundError: If the file does not exist under the vault root.
   161→        """
   162→        file_path = self.vault_path / rel_path
   163→        if not file_path.exists():
   164→            raise FileNotFoundError(f"文件不存在：{file_path}")
   165→
   166→        text = file_path.read_text(encoding="utf-8", errors="ignore")
   167→        text = re.sub(r'^---\n.*?\n---\n', '', text, flags=re.DOTALL).strip()
   168→        logger.info(f"scan_single_file: {rel_path} ({len(text)} chars)")
   169→
   170→        keywords = self._extract_keywords_with_llm(text[:5000])
   171→        if not keywords:
   172→            logger.info("No keywords extracted from single file")
   173→            return []
   174→
   175→        logger.info(f"Keywords from file: {keywords}")
   176→        questions = self._find_questions(keywords)
   177→
   178→        for q in questions:
   179→            q.source_file = rel_path
   180→
   181→        return questions
   182→
   183→    def scan_glob_files(self, pattern: str) -> List[ZhihuQuestion]:
   184→        """Find vault files matching a glob pattern and hunt Zhihu questions.
   185→
   186→        All matched files are combined for keyword extraction, so the questions
   187→        reflect the full set of content.  The most-recently-modified matched file
   188→        is stored as ``source_file`` to ground the answer draft.
   189→
   190→        Args:
   191→            pattern: Glob pattern relative to vault_path
   192→                     (e.g. "Article/*openclaw*-Revised.md").
   193→
   194→        Returns:
   195→            List of ZhihuQuestion candidates with source_file set.
   196→
   197→        Raises:
   198→            FileNotFoundError: If no files match the pattern.
   199→        """
   200→        matched = sorted(
   201→            self.vault_path.glob(pattern),
   202→            key=lambda p: p.stat().st_mtime,
   203→            reverse=True,
   204→        )
   205→        if not matched:
   206→            raise FileNotFoundError(f"没有文件匹配：{pattern}")
   207→
   208→        names = [p.name for p in matched]
   209→        logger.info(f"scan_glob_files: '{pattern}' → {len(matched)} files: {names}")
   210→
   211→        # Combine content from all matched files (per-file budget so no one file
   212→        # dominates) and extract keywords from the aggregate.
   213→        parts: List[str] = []
   214→        per_file_budget = 1500 if len(matched) == 1 else 800
   215→        for fp in matched:
   216→            try:
   217→                text = fp.read_text(encoding="utf-8", errors="ignore")
   218→                text = re.sub(r'^---\n.*?\n---\n', '', text, flags=re.DOTALL).strip()
   219→                parts.append(f"[{fp.name}]\n{text[:per_file_budget]}")
   220→            except OSError as e:
   221→                logger.debug(f"Could not read {fp}: {e}")
   222→
   223→        combined = "\n\n---\n\n".join(parts)
   224→        keywords = self._extract_keywords_with_llm(combined[:8000])
   225→        if not keywords:
   226→            logger.info("No keywords extracted from glob-matched files")
   227→            return []
   228→
   229→        logger.info(f"Keywords from glob: {keywords}")
   230→        questions = self._find_questions(keywords)
   231→
   232→        # Primary source = most-recently-modified matched file
   233→        primary = str(matched[0].relative_to(self.vault_path))
   234→        for q in questions:
   235→            q.source_file = primary
   236→
   237→        return questions
   238→
   239→    def hunt_direct_url(self, url: str) -> List[ZhihuQuestion]:
   240→        """Create a question card directly from a Zhihu question URL.
   241→
   242→        Used when the user pastes a URL they were invited to answer.
   243→        Fetches the page title so the card displays a readable name.
   244→
   245→        Args:
   246→            url: A zhihu.com/question/<id> URL.
   247→
   248→        Returns:
   249→            Single-element list containing the ZhihuQuestion.
   250→        """
   251→        clean_url = re.sub(r'[>\'")\].,;]+$', '', url).split("?")[0].split("#")[0].rstrip("/")
   252→        title = self._fetch_question_title(clean_url)
   253→        logger.info(f"Direct URL hunt: {clean_url!r} → title={title!r}")
   254→        return [ZhihuQuestion(title=title, url=clean_url)]
   255→
   256→    def _fetch_question_title(self, url: str) -> str:
   257→        """Fetch the question title via Playwright (with saved session cookies).
   258→
   259→        Falls back to a URL-derived label if Playwright is unavailable or fails.
   260→
   261→        Args:
   262→            url: Cleaned question URL.
   263→
   264→        Returns:
   265→            Question title string, or a fallback derived from the URL.
   266→        """
   267→        state_file = Path(__file__).resolve().parents[3] / "data" / "zhihu_state.json"
   268→        try:
   269→            from playwright.sync_api import sync_playwright
   270→            from playwright_stealth import Stealth
   271→            import json as _json
   272→
   273→            with sync_playwright() as p:
   274→                browser = p.chromium.launch(headless=True)
   275→                context = browser.new_context(
   276→                    user_agent=(
   277→                        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
   278→                        "AppleWebKit/537.36 (KHTML, like Gecko) "
   279→                        "Chrome/122.0.0.0 Safari/537.36"
   280→                    )
   281→                )
   282→                # Load saved cookies if available
   283→                if state_file.exists():
   284→                    data = _json.loads(state_file.read_text(encoding="utf-8"))
   285→                    cookies = data.get("cookies", [])
   286→                    if cookies:
   287→                        context.add_cookies(cookies)
   288→
   289→                page = context.new_page()
   290→                Stealth().apply_stealth_sync(page)
   291→                page.goto(url, wait_until="domcontentloaded", timeout=15000)
   292→                page.wait_for_timeout(2000)
   293→
   294→                title = page.title()
   295→                browser.close()
   296→
   297→                if title:
   298→                    # Page title format: "问题标题 - 知乎" or just "知乎"
   299→                    title = title.split(" - 知乎")[0].strip()
   300→                    if title and title != "知乎":
   301→                        return title
   302→        except Exception as e:
   303→            logger.debug(f"Playwright title fetch failed for {url}: {e}")
   304→
   305→        # Fallback: use the question ID
   306→        qid = url.rstrip("/").split("/")[-1]
   307→        return f"知乎问题 #{qid}"
   308→
   309→    def _fetch_recent_web_context(self, query: str) -> str:
   310→        """Search the web for recent articles/news about the query topic.
   311→
   312→        Results are injected into the answer prompt so it reads like it
   313→        comes from someone who's actively following the latest developments.
   314→
   315→        Args:
   316→            query: Topic or question title to search.
   317→
   318→        Returns:
   319→            Formatted string of recent snippets, or empty string on failure.
   320→        """
   321→        api_key = os.environ.get("TAVILY_API_KEY")
   322→        if not api_key:
   323→            return ""
   324→        try:
   325→            from tavily import TavilyClient
   326→            client = TavilyClient(api_key=api_key)
   327→            results = client.search(
   328→                query=query,
   329→                search_depth="basic",
   330→                max_results=4,
   331→                days=90,
   332→                exclude_domains=["zhihu.com"],
   333→            )
   334→            items = results.get("results", [])
   335→            if not items:
   336→                return ""
   337→            parts = [
   338→                f"- {r['title']} ({r['url'].split('/')[2]}): {r.get('content', '')[:250]}"
   339→                for r in items
   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→
   440→        Args:
   441→            since_days: Age threshold for non-Article directories.
   442→            dirs: Dirs to scan; None = all WATCH_DIRS.
   443→            topic_hint: Optional focus instruction for keyword extraction.
   444→
   445→        Returns:
   446→            Deduplicated list of topic keywords (up to _MAX_KEYWORDS).
   447→        """
   448→        cutoff = datetime.now(tz=timezone.utc) - timedelta(days=since_days)
   449→        files = self._collect_recent_files(cutoff, dirs=dirs)
   450→
   451→        if not files:
   452→            return []
   453→
   454→        # Article-only scans get a larger per-file budget (higher priority content)
   455→        scan_dirs = dirs or WATCH_DIRS
   456→        per_file_budget = 800 if scan_dirs == [_ARTICLE_DIR] else 500
   457→        combined = self._read_files_summary(files, max_chars_per_file=per_file_budget)
   458→        return self._extract_keywords_with_llm(combined, topic_hint=topic_hint)
   459→
   460→    def _collect_recent_files(
   461→        self,
   462→        cutoff: datetime,
   463→        dirs: Optional[List[str]] = None,
   464→    ) -> List[Path]:
   465→        """Walk vault directories and return candidate files.
   466→
   467→        Article directory:
   468→          - No time filter (always included regardless of cutoff)
   469→          - Revised files sorted first, then by mtime descending
   470→          - Up to _MAX_FILES_ARTICLE slots
   471→        Other directories:
   472→          - Only files modified after cutoff
   473→          - Sorted by mtime descending
   474→          - Up to _MAX_FILES_PER_DIR slots
   475→
   476→        Args:
   477→            cutoff: Freshness threshold for non-Article directories.
   478→            dirs: Directories to scan (relative paths). None = all WATCH_DIRS.
   479→
   480→        Returns:
   481→            List of Path objects.
   482→        """
   483→        scan_dirs = dirs if dirs is not None else WATCH_DIRS
   484→        collected: List[Path] = []
   485→
   486→        for rel_dir in scan_dirs:
   487→            watch_dir = self.vault_path / rel_dir
   488→            if not watch_dir.is_dir():
   489→                logger.debug(f"Watch dir not found, skipping: {watch_dir}")
   490→                continue
   491→
   492→            # (mtime, is_revised, path)
   493→            candidates: List[tuple[float, bool, Path]] = []
   494→            for md_file in watch_dir.rglob("*.md"):
   495→                try:
   496→                    mtime = md_file.stat().st_mtime
   497→                    if rel_dir != _ARTICLE_DIR:
   498→                        file_dt = datetime.fromtimestamp(mtime, tz=timezone.utc)
   499→                        if file_dt < cutoff:
   500→                            continue
   501→                    is_revised = md_file.name.lower().endswith("-revised.md")
   502→                    candidates.append((mtime, is_revised, md_file))
   503→                except OSError:
   504→                    continue
   505→
   506→            if rel_dir == _ARTICLE_DIR:
   507→                # Revised files first, then newest; give double slots
   508→                candidates.sort(key=lambda t: (0 if t[1] else 1, -t[0]))
   509→                max_files = _MAX_FILES_ARTICLE
   510→            else:
   511→                candidates.sort(key=lambda t: -t[0])
   512→                max_files = _MAX_FILES_PER_DIR
   513→
   514→            collected.extend(p for _, _, p in candidates[:max_files])
   515→
   516→        logger.debug(f"Collected {len(collected)} files from vault (dirs={scan_dirs})")
   517→        return collected
   518→
   519→    def _read_files_summary(
   520→        self,
   521→        files: List[Path],
   522→        max_chars: int = 8000,
   523→        max_chars_per_file: int = 500,
   524→    ) -> str:
   525→        """Read and concatenate file contents up to a character budget.
   526→
   527→        Each file is capped at max_chars_per_file so that no single large file
   528→        exhausts the total budget and crowds out other directories.
   529→
   530→        Args:
   531→            files: List of markdown file paths.
   532→            max_chars: Total character budget across all files.
   533→            max_chars_per_file: Per-file cap to ensure directory diversity.
   534→
   535→        Returns:
   536→            Concatenated content string with file-name headers.
   537→        """
   538→        parts: List[str] = []
   539→        remaining = max_chars
   540→
   541→        for f in files:
   542→            if remaining <= 0:
   543→                break
   544→            try:
   545→                text = f.read_text(encoding="utf-8", errors="ignore")
   546→                # Strip YAML frontmatter
   547→                text = re.sub(r'^---\n.*?\n---\n', '', text, flags=re.DOTALL).strip()
   548→                chunk = text[:min(max_chars_per_file, remaining)]
   549→                parts.append(f"[{f.name}]\n{chunk}")
   550→                remaining -= len(chunk)
   551→            except OSError as e:
   552→                logger.debug(f"Could not read {f}: {e}")
   553→
   554→        return "\n\n---\n\n".join(parts)
   555→
   556→    def _extract_keywords_with_llm(
   557→        self,
   558→        combined_text: str,
   559→        topic_hint: str = "",
   560→    ) -> List[str]:
   561→        """Use LLM to distill topic keywords from aggregated vault content.
   562→
   563→        Args:
   564→            combined_text: Concatenated recent vault content.
   565→            topic_hint: Optional focus instruction (e.g. "agent开发").
   566→
   567→        Returns:
   568→            List of keyword strings (up to _MAX_KEYWORDS).
   569→        """
   570→        topic_instruction = (
   571→            f"\n- 重点关注与「{topic_hint}」相关的话题，优先提取这个方向的关键词"
   572→            if topic_hint else ""
   573→        )
   574→        prompt = f"""你是一位知乎内容运营专家。请从以下笔记内容中提炼出适合在知乎搜索问题的关键词。
   575→
   576→要求：
   577→- 输出 {_MAX_KEYWORDS} 个以内的关键词，每行一个，无序号无符号
   578→- 关键词要在知乎上有真实讨论热度，即普通中国用户会关心并提问的话题领域
   579→- 避免过于偏门的专有名词（如具体项目代号、小众产品名），要用它所属的上一级概念
   580→  例：「OpenClaw」→「AI智能体安全」；「BadJs」→「微信监控机制」；「Moon Monitor」→「健康监测设备」
   581→- 不要太泛（如"AI""技术""健康"），也不要太窄（如只有一个工具代码名称）
   582→- 优先选择最近讨论最多、最有深度的话题{topic_instruction}
   583→- 只输出关键词列表，不要解释
   584→
   585→=== 笔记内容 ===
   586→{combined_text[:5000]}
   587→"""
   588→        try:
   589→            response, _ = self.llm.generate_response(prompt, [])
   590→            keywords = [
   591→                line.strip()
   592→                for line in response.strip().splitlines()
   593→                if line.strip() and not line.strip().startswith("#")
   594→            ]
   595→            return keywords[:_MAX_KEYWORDS]
   596→        except Exception as e:
   597→            logger.error(f"Keyword extraction failed: {e}")
   598→            return []
   599→
   600→    # ── Question Discovery ─────────────────────────────────────────────────────
   601→
   602→    def _find_questions(self, keywords: List[str]) -> List[ZhihuQuestion]:
   603→        """Search Tavily for Zhihu questions matching each keyword.
   604→
   605→        Collects candidates from ALL keywords before truncating so that later
   606→        keywords (which can cover different facets) are not silently dropped.
   607→
   608→        Args:
   609→            keywords: List of topic keywords to search.
   610→
   611→        Returns:
   612→            Deduplicated ZhihuQuestion list (up to _MAX_QUESTIONS_TOTAL).
   613→        """
   614→        from tavily import TavilyClient
   615→
   616→        api_key = os.environ.get("TAVILY_API_KEY")
   617→        if not api_key:
   618→            raise RuntimeError("TAVILY_API_KEY not set")
   619→
   620→        client = TavilyClient(api_key=api_key)
   621→        seen_urls: set[str] = set()
   622→        questions: List[ZhihuQuestion] = []
   623→
   624→        for kw in keywords:
   625→            try:
   626→                results = self._tavily_search(client, kw)
   627→                for item in results:
   628→                    url = item.get("url", "")
   629→                    if not _MIN_QUESTION_URL_RE.search(url):
   630→                        continue  # skip non-question URLs (e.g. zhihu.com/people/...)
   631→                    # Normalize: extract bare URL to strip invisible chars, then remove params
   632→                    _m = re.search(r'https?://\S+', url)
   633→                    if not _m:
   634→                        continue
   635→                    clean_url = re.sub(r'[>\'")\].,;]+$', '', _m.group(0)).split("?")[0].split("#")[0].rstrip("/")
   636→                    # Skip answer/comment subpages — we want the question itself
   637→                    if re.search(r'/answer/|/comment/', clean_url):
   638→                        continue
   639→                    if clean_url in seen_urls:
   640→                        continue
   641→                    seen_urls.add(clean_url)
   642→                    questions.append(
   643→                        ZhihuQuestion(
   644→                            title=item.get("title", kw),
   645→                            url=clean_url,
   646→                            snippet=item.get("content", "")[:200] or None,
   647→                            keywords=[kw],
   648→                        )
   649→                    )
   650→            except Exception as e:
   651→                logger.warning(f"Tavily search failed for keyword '{kw}': {e}")
   652→
   653→        logger.info(f"Found {len(questions)} Zhihu questions (capped at {_MAX_QUESTIONS_TOTAL})")
   654→        return questions[:_MAX_QUESTIONS_TOTAL]
   655→
   656→    def _tavily_search(self, client, keyword: str) -> List[dict]:
   657→        """Run a single Tavily search scoped to zhihu.com/question.
   658→
   659→        Args:
   660→            client: Authenticated TavilyClient instance.
   661→            keyword: Search keyword.
   662→
   663→        Returns:
   664→            List of result dicts from Tavily (url, title, content, score).
   665→        """
   666→        query = f"site:zhihu.com/question {keyword}"
   667→        logger.debug(f"Tavily search: {query}")
   668→        response = client.search(
   669→            query=query,
   670→            search_depth="basic",
   671→            max_results=8,
   672→            include_domains=["zhihu.com"],
   673→            days=365,   # prefer questions with recent activity (higher chance of top rank)
   674→        )
   675→        return response.get("results", [])
   676→


Now let me look at the ZhihuGenerator to understand how the LLM prompt is constructed:


     1→import os
     2→from pathlib import Path
     3→from typing import List, Dict, Tuple, Optional, TYPE_CHECKING
     4→from slack_bot.llm.gemini import GeminiLLM
     5→from slack_bot.obsidian.indexer import ObsidianIndexer
     6→from slack_bot.tools.web import WebSearchTool
     7→from health.utils.logging_config import setup_logger
     8→from health.utils.time_utils import get_current_time_str
     9→
    10→if TYPE_CHECKING:
    11→    from slack_bot.obsidian.vector_store import ChromaVectorStore
    12→
    13→logger = setup_logger(__name__)
    14→
    15→
    16→OBSIDIAN_SYSTEM_PROMPT = """You are Butler (Obsidian Edition), an intelligent knowledge assistant connected to the user's second brain.
    17→Current Time: {current_time}
    18→
    19→Your Role:
    20→You are NOT a health assistant. You are a Knowledge Partner designed to help the user think, write, and communicate. You draw directly from the user's local Obsidian notes, values, and methodology.
    21→
    22→Core Capabilities:
    23→1. Writing: You mimic the user's unique writing style (as defined in `writing_style.md`).
    24→2. Communication: You draft high-EQ, logically rigorous replies for professional contexts (as defined in `REPLY-SAMPLE.md`).
    25→3. Decision: You act as a "Devil's Advocate" and strategic advisor using the user's decision frameworks (GPA, IPO).
    26→
    27→Guidelines:
    28→- **Style Alignment**: Strictly adhere to the tone and sentence structures found in the provided samples.
    29→- **Data Source**: Rely primarily on the provided context (RAG) and loaded markdown files.
    30→- **Identity**: You are pragmatic, rational, and value "technological optimism" and "intellectual honesty".
    31→"""
    32→
    33→class BaseGenerator:
    34→    def __init__(
    35→        self,
    36→        indexer: ObsidianIndexer,
    37→        vector_store: Optional["ChromaVectorStore"] = None,
    38→    ):
    39→        formatted_prompt = OBSIDIAN_SYSTEM_PROMPT.format(current_time=get_current_time_str())
    40→        self.llm = GeminiLLM(system_instruction=formatted_prompt)
    41→        self.indexer = indexer
    42→        self.vector_store = vector_store
    43→        self.workspace_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
    44→
    45→    def _read_file(self, filename: str) -> str:
    46→        """Reads a file from the workspace root."""
    47→        path = os.path.join(self.workspace_root, filename)
    48→        try:
    49→            with open(path, "r", encoding="utf-8") as f:
    50→                return f.read()
    51→        except Exception as e:
    52→            logger.error(f"Failed to read {filename}: {e}")
    53→            return ""
    54→
    55→    def _semantic_search(self, query: str, top_k: int = 3) -> str:
    56→        """Run semantic search against the vector store and format results.
    57→
    58→        Args:
    59→            query: Search query text.
    60→            top_k: Maximum number of chunks to retrieve.
    61→
    62→        Returns:
    63→            Formatted string block, or empty string if no results.
    64→        """
    65→        if self.vector_store is None:
    66→            return ""
    67→        try:
    68→            results = self.vector_store.search_knowledge(query, top_k=top_k)
    69→        except Exception as e:
    70→            logger.warning(f"_semantic_search failed: {e}")
    71→            return ""
    72→        if not results:
    73→            return ""
    74→        lines = ["=== SEMANTIC KNOWLEDGE FROM VAULT ==="]
    75→        for r in results:
    76→            lines.append(f"[{Path(r.source_path).name} / {r.header_hierarchy}]")
    77→            # Truncate individual chunks to avoid prompt bloat (~500 tokens ≈ 2000 chars)
    78→            chunk_text = r.text[:2000]
    79→            lines.append(chunk_text)
    80→            lines.append("")
    81→        return "\n".join(lines)
    82→
    83→class WritingAssistant(BaseGenerator):
    84→    def chat(self, user_input: str, history: List[Dict]) -> Tuple[str, List[Dict]]:
    85→
    86→        is_first_turn = (len(history) == 0)
    87→
    88→        if is_first_turn:
    89→            # === First Turn: Build Rich Prompt ===
    90→            # user_input is treated as "Topic"
    91→
    92→            # Naive splitting for context if formatted as "Topic | Context"
    93→            parts = user_input.split("|", 1)
    94→            topic = parts[0].strip()
    95→            extra_context = parts[1].strip() if len(parts) > 1 else ""
    96→
    97→            # 1. RAG & Samples
    98→            style_samples = self.indexer.get_writing_samples(count=3)
    99→            style_text = "\n\n".join(style_samples)
   100→
   101→            rag_notes = self.indexer.search(topic, limit=5)
   102→            rag_text = "\n\n".join(rag_notes)
   103→
   104→            writing_style_guide = self._read_file("writing_style.md")
   105→            methodology = self._read_file("methodology.md")
   106→            powell_revise_guide = self._read_file("POWELL_REVISE.md")
   107→
   108→            semantic_context = self._semantic_search(topic, top_k=3)
   109→
   110→            prompt = f"""You are a ghostwriter for the user. Your goal is to write an article on the TOPIC provided.
   111→
   112→            === YOUR IDENTITY & METHODOLOGY ===
   113→            {methodology}
   114→
   115→            === YOUR WRITING STYLE GUIDE ===
   116→            {writing_style_guide}
   117→
   118→            === ANTI-AI-TONE GUIDE (APPLY WHILE WRITING, NOT JUST AT THE END) ===
   119→            {powell_revise_guide}
   120→
   121→            === YOUR WRITING SAMPLES (MIMIC THIS TONE) ===
   122→            {style_text}
   123→
   124→            === RELEVANT NOTES FROM OBSIDIAN VAULT ===
   125→            {rag_text}
   126→
   127→            {semantic_context}
   128→
   129→            === TASK ===
   130→            Topic: {topic}
   131→            Extra Context: {extra_context}
   132→
   133→            Write the article in Chinese (unless the topic implies English).
   134→            Adhere strictly to the "Identity" and "Style Guide".
   135→            """
   136→
   137→            logger.info(f"Generating article for topic: {topic}")
   138→            actual_user_message = prompt # We inject the big prompt
   139→
   140→        else:
   141→            # === Follow-up Turn ===
   142→            # user_input is just instructions like "Make it shorter"
   143→            logger.info(f"Follow-up instruction: {user_input}")
   144→            actual_user_message = user_input
   145→            prompt = user_input
   146→
   147→        # Call LLM — first pass: content generation
   148→        draft, _ = self.llm.generate_response(prompt, history)
   149→
   150→        # === Second pass: de-AI review (first turn only) ===
   151→        if is_first_turn:
   152→            powell_revise_guide = self._read_file("POWELL_REVISE.md")
   153→            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.
   154→
   155→=== TONE TARGET (最高优先级) ===
   156→目标语感：像一个懂行的人在跟朋友聊天时随口说的。不端着，也不刻意耍帅。
   157→两种AI味都要杀：
   158→- 正式AI味：过渡词堆砌、排比对仗、空洞修饰、鸡汤结尾
   159→- 油腻AI味：刻意装随意（"折腾去吧"）、强行造金句（"搞坏了不心疼，跑通了就是资产"）、给普通概念起酷名字（"隔离舱"、"暴力碰撞"、"最干净的契约"）、用一句俏皮话收尾装潇洒
   160→正确的语感：平铺直叙，该说什么说什么，说完就停。不需要让读者觉得你很酷。
   161→
   162→=== PRIORITY 1: VOCABULARY KILL LIST (逐词扫描，命中即删或换大白话) ===
   163→夸张修饰: 极度脆弱、毁灭性的、灾难级的、压倒性的、远超想象、不止一个量级、颠覆性的、革命性的、折磨人、天翻地覆、风险极高
   164→大词空转: 范式转移、范式突变、开发范式、数字化容器、数字雇员、温和池架构、非确定性、阅后即焚、毫秒级就绪、毫秒级响应、赋能、底层逻辑、仪式性努力、深刻变革、暴力碰撞、最干净的契约、服务化的潜力、可扩展的执行信用、终极归宿、壁垒、分水岭、沉淀（用作动词修饰数据/经验时）、云端办公室
   165→鸡汤/口号: 正途、值得跨越、安置灵魂、掌控感、才是王道、苦活累活交给、未来已来、危机就是转机、从玩具变成生产力、把环境变成了资产、安全底线、这才是成熟的做法、才是X的关键、不是建议是必须
   166→油腻收尾: "X去吧"式潇洒收尾、"搞坏了不心疼，跑通了就是Y"式金句、任何试图用一句俏皮话总结全文的结尾、"把X关在Y里...把Y放在Z上"式排比收尾
   167→伪口语: 说白了、说实话、折腾这玩意儿
   168→
   169→=== PRIORITY 2: SENTENCE PATTERN KILL LIST (命中即改写) ===
   170→1. 防御性写法："很多人觉得X，实际上Y"、"有些人认为X，但其实Y"、"很多人没意识到"、"有人X，有人已经Y了" → 直接说判断，删掉"很多人/有人"铺垫
   171→2. 对比式定义："不是X，是Y"、"不是X而是Y"、"那不叫X那叫Y"、"别把X当Y" → 删除对比句式，直接陈述后半句即可
   172→3. 换皮夸张：用另一个夸张词替换夸张词不算修好。"灾难级"→"毁灭性"→"风险极高"都是换皮。正确做法：换成具体后果（如"可能误删整个home目录"）
   173→4. 概念包装：给普通事物起酷名字（"隔离舱"=容器、"暴力碰撞"=冲突、"数字雇员"=自动化脚本、"执行信用"=服务器资源、"云端办公室"=云服务器、"牢笼"=限制）→ 用最朴素的说法
   174→5. 油腻金句：短句对仗、押韵、刻意工整的收尾句 → 要么删掉，要么拆成普通陈述句
   175→6. 比喻堆砌：全篇最多保留1个比喻，多余的全部删除换成直说
   176→7. 鸡汤/口号结尾：删除所有励志或耍帅收尾，改为具体行动建议或直接停住
   177→8. 首尾呼应：禁止结尾重复开头观点
   178→9. 语气漂移：全文语气必须统一，不要中途切换
   179→10. 编号标题：如果是线性论述（论点1→论点2→论点3），删除"一、二、三"序号改为自然段落过渡；如果是并列对比不同方案（如方案A vs 方案B vs 方案C），可以保留编号
   180→11. 过渡词堆积：删除"首先...其次...最后..."
   181→12. 排比对仗：打破字数相近的并列句
   182→13. 关键词重复：同一个修辞性词汇（如"分水岭"、"命门"、"杀手锏"）全篇只能出现一次，重复出现时删除后续的或换成普通说法
   183→
   184→=== PRIORITY 3: FULL REVISION GUIDE ===
   185→{powell_revise_guide}
   186→
   187→=== DRAFT TO REVIEW ===
   188→{draft}
   189→
   190→=== OUTPUT ===
   191→Return ONLY the corrected text. No explanation, no meta-commentary.
   192→"""
   193→            logger.info("Running second-pass de-AI review on writing draft...")
   194→            response, _ = self.llm.generate_response(review_prompt, [])
   195→        else:
   196→            response = draft
   197→
   198→        # Update History
   199→        new_history = history.copy()
   200→        new_history.append({"role": "user", "content": actual_user_message})
   201→        new_history.append({"role": "assistant", "content": response})
   202→
   203→        return response, new_history
   204→
   205→class ReplyGenerator(BaseGenerator):
   206→    def chat(self, user_input: str, history: List[Dict]) -> Tuple[str, List[Dict]]:
   207→
   208→        is_first_turn = (len(history) == 0)
   209→
   210→        if is_first_turn:
   211→             # === First Turn ===
   212→            static_samples = self._read_file("REPLY-SAMPLE.md")
   213→            dynamic_samples = self.indexer.get_reply_samples(count=3)
   214→            dynamic_samples_text = "\n\n".join(dynamic_samples)
   215→            methodology = self._read_file("methodology.md")
   216→
   217→            semantic_context = self._semantic_search(user_input, top_k=3)
   218→
   219→            prompt = f"""You are an experienced Product/R&D Manager.
   220→            Your task is to draft a reply to a challenging situation.
   221→
   222→            === CORE PHILOSOPHY ===
   223→            {methodology}
   224→
   225→            === REPLY GUIDELINES ===
   226→            1. Logic: Be clear and structured (e.g., numbered lists).
   227→            2. Responsibility: Don't take unnecessary blame, but don't shirk core duties. Explain objective causes.
   228→            3. Emotion: Be empathetic but professional.
   229→            4. Conciseness: No fluff.
   230→
   231→            === REFERENCE SAMPLES (FEW-SHOT) ===
   232→            {static_samples}
   233→
   234→            {dynamic_samples_text}
   235→
   236→            {semantic_context}
   237→
   238→            === THE SITUATION ===
   239→            {user_input}
   240→
   241→            Draft a reply. If the context implies a specific role (PM, Dev Lead), assume that role.
   242→            """
   243→            logger.info(f"Generating reply for query: {user_input}")
   244→            actual_user_message = prompt
   245→
   246→        else:
   247→             # === Follow-up Turn ===
   248→            logger.info(f"Follow-up reply instruction: {user_input}")
   249→            actual_user_message = user_input
   250→            prompt = user_input
   251→
   252→        response, _ = self.llm.generate_response(prompt, history)
   253→
   254→        new_history = history.copy()
   255→        new_history.append({"role": "user", "content": actual_user_message})
   256→        new_history.append({"role": "assistant", "content": response})
   257→
   258→        return response, new_history
   259→
   260→class DecisionSupport(BaseGenerator):
   261→    def chat(self, user_input: str, history: List[Dict]) -> Tuple[str, List[Dict]]:
   262→
   263→        is_first_turn = (len(history) == 0)
   264→
   265→        if is_first_turn:
   266→            decision_guide = self._read_file("decision.md")
   267→            methodology = self._read_file("methodology.md")
   268→
   269→            rag_notes = self.indexer.search(user_input, limit=3)
   270→            rag_text = "\n\n".join(rag_notes)
   271→
   272→            prompt = f"""You are a Decision Support Assistant.
   273→            Your goal is to help the user think through a complex decision.
   274→
   275→            === DECISION FRAMEWORK ===
   276→            {decision_guide}
   277→
   278→            === CORE PHILOSOPHY ===
   279→            {methodology}
   280→
   281→            === RELEVANT CONTEXT FROM NOTES ===
   282→            {rag_text}
   283→
   284→            === THE DECISION / ISSUE ===
   285→            {user_input}
   286→
   287→            === TASK ===
   288→            1. Challenge the premise: Is this the right problem to solve?
   289→            2. Apply the Framework: Use GPA (Goal, Priority, Alternatives) and IPO (Information, People, Objective reasoning) models.
   290→            3. Pre-mortem: What is the worst that could happen?
   291→            4. Provide a recommendation or a set of questions to clarify.
   292→            """
   293→
   294→            logger.info(f"Analyzing decision: {user_input}")
   295→            actual_user_message = prompt
   296→        else:
   297→            logger.info(f"Follow-up decision instruction: {user_input}")
   298→            actual_user_message = user_input
   299→            prompt = user_input
   300→
   301→        response, _ = self.llm.generate_response(prompt, history)
   302→
   303→        new_history = history.copy()
   304→        new_history.append({"role": "user", "content": actual_user_message})
   305→        new_history.append({"role": "assistant", "content": response})
   306→
   307→        return response, new_history
   308→
   309→class SearchAnalyzer(BaseGenerator):
   310→    def chat(self, user_input: str, history: List[Dict]) -> Tuple[str, List[Dict]]:
   311→
   312→        # Tools definition for the LLM
   313→        tools = [
   314→            {
   315→                "type": "function",
   316→                "function": {
   317→                    "name": "search_web",
   318→                    "description": "Search the public web using DuckDuckGo. Use this when the internal notes are insufficient or when you need up-to-date external information.",
   319→                    "parameters": {
   320→                        "type": "object",
   321→                        "properties": {
   322→                            "query": {
   323→                                "type": "string",
   324→                                "description": "The search query"
   325→                            }
   326→                        },
   327→                        "required": ["query"]
   328→                    }
   329→                }
   330→            },
   331→            {
   332→                "type": "function",
   333→                "function": {
   334→                    "name": "list_recent_files",
   335→                    "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.",
   336→                    "parameters": {
   337→                        "type": "object",
   338→                        "properties": {
   339→                            "days": {
   340→                                "type": "integer",
   341→                                "description": "Number of days to check (default 5)",
   342→                                "default": 5
   343→                            }
   344→                        },
   345→                        "required": []
   346→                    }
   347→                }
   348→            }
   349→        ]
   350→
   351→        is_first_turn = (len(history) == 0)
   352→
   353→        # Current Turn Message
   354→        if is_first_turn:
   355→            rag_notes = self.indexer.search(user_input, limit=5)
   356→            rag_text = "\n\n".join(rag_notes)
   357→
   358→            logger.info(f"SearchAnalyzer retrieved {len(rag_notes)} local notes.")
   359→            for note in rag_notes:
   360→                # Extract source line for logging
   361→                first_line = note.split('\n')[0]
   362→                logger.debug(f" - Retrieved: {first_line}")
   363→
   364→            prompt = f"""You are an Analyst using Butler (Obsidian Edition).
   365→
   366→            === TASK ===
   367→            Answer the user's question.
   368→            CRITICAL: You MUST explicitly cite your sources in the output.
   369→
   370→            === STRATEGY ===
   371→            1. first: Check "RELEVANT LOCAL NOTES" provided below.
   372→            2. second: If the notes answer the question, answer directly.
   373→            3. third: If the notes are missing info, ambiguos, or outdated, USE THE `search_web` TOOL to find external info.
   374→
   375→            === OUTPUT FORMAT ===
   376→            - Begin with a summary.
   377→            - 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]".
   378→            - If data comes from BOTH, mention both.
   379→
   380→            === RELEVANT LOCAL NOTES ===
   381→            {rag_text}
   382→
   383→            === USER QUESTION ===
   384→            {user_input}
   385→            """
   386→            actual_user_message = prompt
   387→        else:
   388→            actual_user_message = user_input
   389→
   390→        # Prepare execution context
   391→        # We don't want to mutate the input history directly until the end
   392→        exec_history = history.copy()
   393→        exec_history.append({"role": "user", "content": actual_user_message})
   394→
   395→        logger.info(f"Analyzer thinking on: {user_input}")
   396→
   397→        # === ReAct Loop (Max 2 turns to prevent infinite loops) ===
   398→        for i in range(2):
   399→            response_text, tool_calls = self.llm.generate_response(
   400→                message="", # Message is already in history
   401→                context=exec_history,
   402→                tools=tools
   403→            )
   404→
   405→            if tool_calls:
   406→                # LLM wants to use tools
   407→                logger.info(f"LLM requested tool calls: {tool_calls}")
   408→
   409→                # Append the assistant's request (with tool calls) to history
   410→                exec_history.append({
   411→                    "role": "assistant",
   412→                    "content": f"[Tool Call Request: {tool_calls}]"
   413→                })
   414→
   415→                for tc in tool_calls:
   416→                    if tc["name"] == "search_web":
   417→                        query = tc["args"].get("query")
   418→                        search_result = WebSearchTool.search_web(query)
   419→
   420→                        # Feed result back
   421→                        exec_history.append({
   422→                            "role": "user", # Using 'function' role would be better if our LLM class supports it fully, but 'user' works as system injection
   423→                            "content": f"--- TOOL OUTPUT (search_web) ---\n{search_result}"
   424→                        })
   425→
   426→                    elif tc["name"] == "list_recent_files":
   427→                        days = tc["args"].get("days", 5)
   428→                        # Call indexer method
   429→                        recent_list = self.indexer.get_recent_files(days=days)
   430→
   431→                        exec_history.append({
   432→                            "role": "user",
   433→                            "content": f"--- TOOL OUTPUT (list_recent_files) ---\n{recent_list}"
   434→                        })
   435→
   436→                # Loop again to get the final answer using tool outputs
   437→                try:
   438→                     # Second pass with tool outputs
   439→                    response_text, _ = self.llm.generate_response(
   440→                        message="",
   441→                        context=exec_history,
   442→                        tools=tools
   443→                    )
   444→                    return response_text, exec_history
   445→                except Exception as e:
   446→                    logger.error(f"Error in second ReAct loop: {e}")
   447→                    return response_text, exec_history
   448→
   449→            else:
   450→                # Final answer (no tools)
   451→                return response_text, exec_history
   452→
   453→        # Fallback if loop exhausted
   454→        return response_text, exec_history
   455→
   456→
   457→class DeAIReviser(BaseGenerator):
   458→    """Remove AI tone from articles - text-in, text-out mode."""
   459→
   460→    def chat(self, user_input: str, history: List[Dict]) -> Tuple[str, List[Dict]]:
   461→
   462→        is_first_turn = (len(history) == 0)
   463→
   464→        if is_first_turn:
   465→            # === First Turn: User pastes the AI-generated article ===
   466→            # Load the POWELL_REVISE.md guide
   467→            powell_revise_guide = self._read_file("POWELL_REVISE.md")
   468→
   469→            # Load user's writing samples for style reference
   470→            style_samples = self.indexer.get_writing_samples(count=3)
   471→            style_text = "\n\n".join(style_samples)
   472→
   473→            prompt = f"""You are an expert editor specializing in removing AI-generated tone from articles.
   474→
   475→Your task is to revise the article provided by the user to make it sound more human and authentic.
   476→
   477→=== REVISION GUIDE ===
   478→{powell_revise_guide}
   479→
   480→=== USER'S AUTHENTIC WRITING SAMPLES (for style reference) ===
   481→{style_text}
   482→
   483→=== ARTICLE TO REVISE ===
   484→{user_input}
   485→
   486→=== TASK ===
   487→1. Analyze the article and identify AI characteristics (过渡词堆积, 排比对仗, 空洞修饰词等)
   488→2. Rewrite the article following the POWELL_REVISE.md principles
   489→3. Preserve:
   490→   - Original meaning and key information
   491→   - Technical terms and proper nouns
   492→   - Code blocks, lists, and formatting
   493→   - Original language (Chinese/English)
   494→4. Output the revised article directly (no meta-commentary like "Here's the revised version...")
   495→
   496→CRITICAL: Output ONLY the revised article text. Do NOT add explanations before or after.
   497→"""
   498→
   499→            logger.info(f"DeAI Reviser: Processing article ({len(user_input)} chars)")
   500→            actual_user_message = prompt
   501→
   502→        else:
   503→            # === Follow-up Turn: User gives refinement instructions ===
   504→            logger.info(f"DeAI Reviser: Follow-up instruction - {user_input}")
   505→            actual_user_message = user_input
   506→            prompt = user_input
   507→
   508→        # Call LLM
   509→        response, _ = self.llm.generate_response(prompt, history)
   510→
   511→        # Update History
   512→        new_history = history.copy()
   513→        new_history.append({"role": "user", "content": actual_user_message})
   514→        new_history.append({"role": "assistant", "content": response})
   515→
   516→        return response, new_history
   517→
   518→
   519→class ZhihuGenerator(BaseGenerator):
   520→    """Generate Zhihu-style answers based on style guide and user's knowledge base."""
   521→
   522→    def chat(self, user_input: str, history: List[Dict]) -> Tuple[str, List[Dict]]:
   523→
   524→        is_first_turn = (len(history) == 0)
   525→        built_rich_prompt = False  # tracks whether we ran the full question->draft pipeline
   526→
   527→        # Pre-load rule files (needed for both generation and second-pass review)
   528→        zhihu_bot_style = self._read_file("zhihu-bot-style.md")
   529→        powell_revise_guide = self._read_file("POWELL_REVISE.md")
   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")
   550→            # Bot-specific overrides (plain-text output, no bracket explanations, etc.)
   551→            if zhihu_bot_style:
   552→                zhihu_style_guide = zhihu_style_guide + "\n\n" + zhihu_bot_style
   553→
   554→            # 2. RAG from Obsidian vault for actual content material
   555→            rag_notes = self.indexer.search(question, limit=5)
   556→            rag_text = "\n\n".join(rag_notes)
   557→
   558→            # 3. Load methodology
   559→            methodology = self._read_file("methodology.md")
   560→
   561→            semantic_context = self._semantic_search(question, top_k=3)
   562→
   563→            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.
   564→
   565→=== YOUR IDENTITY & METHODOLOGY ===
   566→{methodology}
   567→
   568→=== ANTI-AI-TONE GUIDE (APPLY WHILE WRITING, NOT JUST AT THE END) ===
   569→{powell_revise_guide}
   570→
   571→=== ZHIHU STYLE GUIDE (FOLLOW THESE WRITING PRINCIPLES) ===
   572→{zhihu_style_guide}
   573→
   574→CRITICAL: The style guide above defines HOW to write (tone, structure, language patterns).
   575→Do NOT treat zhihu-sample content as factual material. It's ONLY for style reference.
   576→
   577→=== RELEVANT KNOWLEDGE FROM YOUR OBSIDIAN VAULT (USE AS ACTUAL MATERIAL) ===
   578→{rag_text}
   579→
   580→{semantic_context}
   581→
   582→=== THE QUESTION ===
   583→{question}
   584→
   585→=== YOUR CORE IDEAS ===
   586→{core_ideas}
   587→
   588→=== TASK ===
   589→1. Answer the question based on YOUR knowledge (from Obsidian vault) and core ideas
   590→2. FOLLOW THE ANTI-AI-TONE GUIDE FIRST, then the Zhihu style guide
   591→3. Share real experiences, examples, and insights from the vault material
   592→4. Write in Chinese (unless question implies English)
   593→5. Minimize first person "我": use it sparingly, not as filler. Observations and judgments can stand without "我认为" or "我觉得" as prefix.
   594→6. Be direct and opinionated. Attach a concrete evaluation to every fact or feature you mention (e.g. "暴力但有效", "极度脆弱"). Pure feature listing is forbidden.
   595→7. AVOID AT ALL COSTS: AI tone, empty buzzwords, hollow phrases, and 宏大叙事 (不可逆的浪潮, 范式转移, 时代大背景).
   596→8. If recent web context is provided, weave it in naturally — like someone who reads a lot online
   597→9. STRICT: Never add bracket explanations for any term. Write "大语言模型" not "大语言模型（LLM）". If a term needs explaining, use a sentence, not parentheses.
   598→10. No public account names, no self-promotion, no引流 of any kind — this is the highest priority rule.
   599→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.
   600→12. STRICT: Never reference past projects, articles, or talks by name.
   601→13. No poetry, quotes, or "升华" endings. No首尾呼应 (repeating the opening point at the end). Stop when the point is made.
   602→14. LENGTH: 500–1000 Chinese characters. Cut a point, not quality.
   603→
   604→=== BEFORE SUBMITTING, CHECK THESE ===
   605→- Is there any AI-generated tone or phrasing?
   606→- Are there any buzzwords or jargon that need to be replaced with plain language?
   607→- Is the answer direct and opinionated, not neutral or "balanced"?
   608→- Does it sound like a real human wrote it, not a machine?
   609→
   610→Begin writing the answer:
   611→"""
   612→
   613→            logger.info(f"Generating Zhihu answer for: {question[:50]}...")
   614→            actual_user_message = prompt
   615→            built_rich_prompt = True
   616→
   617→        else:
   618→            # === Follow-up Turn: User gives refinement instructions (e.g. "make it shorter") ===
   619→            logger.info(f"Follow-up Zhihu instruction: {user_input}")
   620→            actual_user_message = user_input
   621→            prompt = user_input
   622→
   623→        # Call LLM — first pass: content generation
   624→        # For new questions, use empty history to avoid contamination from previous conversations
   625→        llm_history = [] if is_new_question else history
   626→        draft, _ = self.llm.generate_response(prompt, llm_history)
   627→
   628→        # === Second pass: rule enforcement ===
   629→        # Runs whenever a full draft was generated (built_rich_prompt=True),
   630→        # regardless of whether history was empty or not.
   631→        # Skipped on follow-up refinement turns (e.g. "make it shorter").
   632→        run_review = built_rich_prompt
   633→        if run_review:
   634→            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.
   635→
   636→=== TONE TARGET (最高优先级) ===
   637→目标语感：像一个懂行的人在知乎随手打字回答。不端着，也不刻意耍帅。
   638→两种AI味都要杀：
   639→- 正式AI味：过渡词堆砌、排比对仗、空洞修饰、鸡汤结尾
   640→- 油腻AI味：刻意装随意（"折腾去吧"）、强行造金句（"搞坏了不心疼，跑通了就是资产"）、给普通概念起酷名字（"隔离舱"、"暴力碰撞"、"最干净的契约"）、用一句俏皮话收尾装潇洒
   641→正确的语感：平铺直叙，该说什么说什么，说完就停。不需要让读者觉得你很酷。
   642→
   643→=== PRIORITY 1: VOCABULARY KILL LIST (逐词扫描，命中即删或换大白话) ===
   644→夸张修饰: 极度脆弱、毁灭性的、灾难级的、压倒性的、远超想象、不止一个量级、颠覆性的、革命性的、折磨人、天翻地覆、风险极高、本质上
   645→大词空转: 范式转移、范式突变、开发范式、数字化容器、数字雇员、温和池架构、非确定性、阅后即焚、毫秒级就绪、毫秒级响应、赋能、底层逻辑、仪式性努力、深刻变革、暴力碰撞、最干净的契约、服务化的潜力、不可逆的浪潮、时代大背景、可扩展的执行信用、终极归宿、壁垒、分水岭、沉淀（用作动词修饰数据/经验时）、云端办公室
   646→鸡汤/口号: 正途、值得跨越、安置灵魂、掌控感、才是王道、苦活累活交给、未来已来、危机就是转机、挑战与机遇并存、从玩具变成生产力、把环境变成了资产、安全底线、这才是成熟的做法、才是X的关键、不是建议是必须
   647→油腻收尾: "X去吧"式潇洒收尾、"搞坏了不心疼，跑通了就是Y"式金句、任何试图用一句俏皮话总结全文的结尾、"把X关在Y里...把Y放在Z上"式排比收尾
   648→伪口语: 说白了、说实话、折腾这玩意儿
   649→
   650→=== PRIORITY 2: SENTENCE PATTERN KILL LIST (命中即改写) ===
   651→1. 防御性写法："很多人觉得X，实际上Y"、"有些人认为X，但其实Y"、"很多人没意识到"、"不少人觉得X"、"有人X，有人已经Y了" → 直接说判断，删掉"很多人/有人"铺垫
   652→2. 对比式定义："不是X，是Y"、"不是X而是Y"、"那不叫X那叫Y"、"别把X当Y" → 删除对比句式，直接陈述后半句即可
   653→3. 换皮夸张：用另一个夸张词替换夸张词不算修好。"灾难级"→"毁灭性"→"风险极高"都是换皮。正确做法：换成具体后果（如"可能误删整个home目录"）
   654→4. 概念包装：给普通事物起酷名字（"隔离舱"=容器、"暴力碰撞"=冲突、"数字雇员"=自动化脚本、"执行信用"=服务器资源、"云端办公室"=云服务器、"牢笼"=限制）→ 用最朴素的说法
   655→5. 油腻金句：短句对仗、押韵、刻意工整的收尾句 → 要么删掉，要么拆成普通陈述句
   656→6. 比喻堆砌：全篇最多1个比喻，多余全删换直说
   657→7. 鸡汤/口号结尾：删除励志或耍帅收尾，给具体建议或直接停
   658→8. 首尾呼应：禁止结尾重复开头
   659→9. 编号分段：如果是线性论述（论点1→论点2→论点3），删除"一、二、三"序号改为自然段落；如果是并列对比不同方案（方案A vs 方案B vs 方案C），可以保留编号
   660→10. 防御性引号：删除套在归纳性词汇上的引号
   661→11. 强行总结：删除"一句话建议"、"综上"式收尾
   662→12. 嵌套数字列表：删除正文中"1. 2. 3."子列表，改为连续段落
   663→13. 关键词重复：同一个修辞性词汇（如"分水岭"、"命门"、"杀手锏"）全篇只能出现一次，重复出现时删除后续的或换成普通说法
   664→
   665→=== PRIORITY 3: ZHIHU-SPECIFIC RULES ===
   666→{zhihu_bot_style}
   667→
   668→=== PRIORITY 4: FULL REVISION GUIDE ===
   669→{powell_revise_guide}
   670→
   671→=== DRAFT TO REVIEW ===
   672→{draft}
   673→
   674→=== OUTPUT ===
   675→Return ONLY the corrected Chinese text. No explanation, no meta-commentary, no English.
   676→"""
   677→            logger.info("Running second-pass rule enforcement on Zhihu draft...")
   678→            response, _ = self.llm.generate_response(review_prompt, [])
   679→        else:
   680→            response = draft
   681→
   682→        # Update History
   683→        new_history = history.copy()
   684→        new_history.append({"role": "user", "content": actual_user_message})
   685→        new_history.append({"role": "assistant", "content": response})
   686→
   687→        return response, new_history


Perfect! Now let me check the dispatcher to understand how `hunt_direct_url()` is triggered:


No files found


Found 2 files
slack_bot/zhihu/zhihu_hunter.py
slack_bot/obsidian/dispatcher.py