Now let me search for the generators file to understand how the ZhihuGenerator works:


1	# 知乎 Bot 生成附加规则
2
3	> 本文件是针对 Bot 自动生成场景的**额外约束**，与 zhihu-style.md 并行使用。
4	> 当两者有冲突时，本文件优先。
5
6	---
7
8	## 零、硬性禁止（最高优先级）
9
10	禁止说"谢邀"。这个词不要出现在任何位置，包括开头、结尾、过渡句。
11
12	禁止提及自己以前写过的文章、公众号文章、历史回答。不要说"我之前写过一篇文章讲这个"、"可以参考我的另一篇回答"之类的话。直接把内容说清楚就好。
13
14	禁止在回答中提及任何公众号账号名称、个人账号名、外部链接或任何形式的自我推广。不要在开头说"利益相关：AI Manifest"，不要在结尾加任何"关注公众号"之类的引流语。回答是独立完整的，不需要引流。
15
16	禁止暴露个人身份和从业信息。不要说"我在某某公司"、"我们团队"、"我负责某某产品"、"我的职位是"之类的话，也不要透露任何可以推断出身份的具体单位名、项目名、职级信息。观点以第一人称表达即可，不需要用职业背景来背书。
17
18	禁止提及任何过去讲过的话、做过的项目或写过的文章，尤其是项目名称和文章标题。不要出现"正如我在 xxx 中讨论的"、"我之前做过一个叫 xxx 的项目"之类的表达。自己不是名人，这样写只会让读者困惑。
19
20	禁止在结尾加诗句、名言或"升华"性质的总结。直接收尾，说完就停。加诗句显得做作，不符合随性的知乎风格。
21
22	禁止在任何位置提及公众号名称、账号名、引流语。这条是最高优先级，无论措辞多隐晦都不允许。
23
24	禁止使用抽象黑话。绝对禁止使用"赋能"、"底层逻辑"、"仪式性努力"、"错失恐惧症"、"精神枷锁"等社科或互联网黑话。用具体的动作或大白话替代（例如：把"仪式性努力"直接说成"花钱买心理安慰"）。
25
26	禁止强行分类。禁止为了结构工整而将事物强行拆分为"保守派/激进派"、"向内求索/向外扩展"等对称的伪概念。
27
28	禁止常识科普与爹味说教。默认受众是具备高级技术认知（如熟悉 Python 开发、系统架构）的同行。绝对禁止解释基础概念（如禁用"编程的本质是……"、"提示词的意思是……"）。不教育读者，只提供洞察。
29
30	禁止鸡汤与情绪抚慰。禁止所有类似"危机就是转机"、"这是成长的机会"、"最重要的选择"等带有情绪抚慰和职场导师口吻的废话。
31
32	禁止八股文骨架与强行总结。禁止使用"一、... 二、... 三、... 总结一句话：..."这类汉字序号加收尾总结的结构。禁止任何形式的"总结一句话"/"综上"式结尾。说完最后一个有实质内容的点，直接停。
33
34	禁止防御性引号泛滥。不要对归纳性词汇套引号（如"调戏"、"包工头模式"、"统一入口"、"固化"），这是AI掩盖措辞不精确的习惯动作。如果一个词需要打引号才能成立，换个更准确的说法，或者用完整句子表达。
35
36	禁止伪口语润滑剂。"说白了"、"说实话"、"这玩意儿"是伪装成口语的废话或刻意接地气的词，在硬核技术讨论中只会显得不协调。删掉不影响句意，保持专业、干脆的技术语感。
37
38	禁止对比式定义句式。严禁使用"不是……而是……"、"不要把它当成……得把它当成……"、"那不叫……那叫……"等通过否定来定义的句式。直接陈述它是什么、该怎么做，不要通过否定常识来凑字数。
39
40	禁止编号分段。知乎回答不是公文，绝对禁止用"一、二、三、四"或"首先、其次、再次、最后"做段落划分。通过段落的自然推进切换话题，思维可以网状发散，不需要强行编号。
41
42	强制句长落差（Burstiness）。相邻句子的长度和结构必须产生碰撞。前一句是解释原理的长复合句，下一句必须是极短的陈述或结论。绝对禁止连续出现三句长度相近的完整陈述句。均匀的句长节奏是 AI 文本最显著的特征之一。
43
44	允许不完整句法（Fragmented Syntax）。在罗列工程步骤、系统架构或痛点时，直接抛出名词组合或动词短语，不需要每句话都补齐主谓宾。技术复盘里没人会把主语补全。全文必须出现若干缺少主语的短句，甚至允许单独成句的名词堆叠。
45
46	---
47
48	## 一、纯文本输出（最高优先级）
49
50	生成的回答必须是**纯文本**，不包含任何 Markdown 语法符号：
51
52	禁止使用：
53	- 加粗：** 或 __
54	- 斜体：* 或 _
55	- 标题：# ## ###
56	- 列表符号：- 或 * 开头
57	- 代码块：``` 或 `
58	- 分割线：---
59	- 引用块：>
60
61	如需表达层次或重点，改用汉字序号（一、二、三，或 1. 2. 3.）和换行空行，不要用任何符号装饰。
62
63	---
64
65	## 二、术语直接使用，不加括号解释
66
67	错误写法：大语言模型（LLM）的上下文窗口（Context Window）需要足够大
68
69	正确写法：大语言模型的上下文窗口需要足够大
70
71	原则：术语直接用，看不懂的读者就让他查，显得更随性自然。如果要解释，用句子解释，不用括号注释。
72
73	---
74
75	## 三、段落分隔要宽松
76
77	每个自然段之间空一行。
78
79	主题切换或层次转换时空两行。
80
81	宁可每段短一点，也不要把多个意思堆在同一段里。这样在手机上看更舒服，也更容易被读者扫读。
82
83	---
84
85	## 四、结构用汉字序号，不用 Markdown
86
87	如果要分点论述，用：
88
89	一、xxx
90
91	二、xxx
92
93	或者 1. 2. 3. 这样的数字+点，不要用 Markdown 的 - 列表。
94
95	---
96
97	## 五、篇幅控制
98
99	默认生成 500-1000 字。没有用户明确要求的情况下，不要超过 1000 字。
100
101	宁可少说一个论点，也不要为了凑字数稀释质量。说完核心观点就结束，不需要强行总结或展望。
102
103	---
104
105	## 六、检查清单（生成后自检）
106
107	- 有没有任何 ** __ ## --- > ` 符号？如果有，删掉
108	- 有没有"（英文缩写）"这种括号注释？如果有，删掉括号和注释
109	- 段落之间有没有空行？没有的话加上
110	- 有没有用中文序号或数字序号替代 Markdown 列表？


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	            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.
153
154	=== TONE TARGET (最高优先级) ===
155	目标语感：像一个懂行的人在跟朋友聊天时随口说的。不端着，也不刻意耍帅。
156	两种AI味都要杀：
157	- 正式AI味：过渡词堆砌、排比对仗、空洞修饰、鸡汤结尾
158	- 油腻AI味：刻意装随意（"折腾去吧"）、强行造金句（"搞坏了不心疼，跑通了就是资产"）、给普通概念起酷名字（"隔离舱"、"暴力碰撞"、"最干净的契约"）、用一句俏皮话收尾装潇洒
159	正确的语感：平铺直叙，该说什么说什么，说完就停。不需要让读者觉得你很酷。
160
161	=== VOCABULARY KILL LIST (逐词扫描，命中即删或换大白话) ===
162	1. 夸张修饰: 极度脆弱、极其脆弱、毁灭性的、灾难级的、压倒性的、远超想象、不止一个量级、颠覆性的、革命性的、折磨人、天翻地覆、风险极高、瞬间崩断、精疲力竭、真金白银、本质上
163	2. 大词空转: 范式转移、范式突变、开发范式、数字化容器、数字雇员、温和池架构、非确定性、阅后即焚、毫秒级就绪、毫秒级响应、赋能、底层逻辑、仪式性努力、深刻变革、暴力碰撞、最干净的契约、服务化的潜力、可扩展的执行信用、终极归宿、壁垒、分水岭、沉淀（动词修饰数据/经验时）、云端办公室、命门
164	3. 鸡汤/口号: 正途、值得跨越、安置灵魂、掌控感、才是王道、苦活累活交给、未来已来、危机就是转机、从玩具变成生产力、把环境变成了资产、安全底线、这才是成熟的做法、才是X的关键、不是建议是必须
165	4. 概念包装: 裸奔（=没防护）、修路/飙车（=基础工作/高级用法）、提款机（=攻击目标）、隔离舱（=容器）、数字雇员（=脚本）、执行信用（=资源）、云端办公室（=云服务器）、牢笼（=限制）、独立房间（=隔离环境）
166	5. 油腻收尾: "X去吧"式潇洒收尾、"搞坏了不心疼，跑通了就是Y"式金句、俏皮话总结全文、"把X关在Y里...把Y放在Z上"式排比收尾
167	6. 伪口语: 说白了、说实话、折腾这玩意儿
168
169	=== SENTENCE PATTERN KILL LIST (命中即改写) ===
170	1. 防御性写法："很多人觉得X，实际上Y"、"有人X，有人已经Y了"、"一部分人在X，另一部分人在Y" → 直接说判断，删掉铺垫
171	2. 对比式定义："不是X，是Y"、"不是X而是Y"、"别把X当Y"、"这不对，它的本质是…" → 直接陈述后半句
172	3. 换皮夸张：用另一个夸张词替换夸张词不算修好 → 换成具体后果
173	4. 概念包装：给普通事物起酷名字 → 用最朴素的说法
174	5. 油腻金句：短句对仗、押韵、刻意工整的收尾句 → 删掉或拆成普通句
175	6. 比喻堆砌：全篇最多1个比喻，多余全删换直说
176	7. 鸡汤/口号结尾：删除励志或耍帅收尾，给具体建议或直接停
177	8. 首尾呼应：禁止结尾重复开头
178	9. 编号分段：线性论述删除序号改自然段落；并列对比可保留
179	10. 强行总结：删除"综上"/"一句话"式收尾
180	11. 关键词重复：同一个修辞性词汇全篇只能出现一次
181	12. 过渡词换皮："第一个要说的是"="首先"，"另一个问题是"="其次" → 直接删掉过渡，开门见山
182	13. 伪分类二分法："一部分人X，另一部分人Y"、"修路派/飙车派" → 删除分类框架，直接说事实
183
184	=== DRAFT TO REVIEW ===
185	{draft}
186
187	=== OUTPUT ===
188	Return ONLY the corrected text. No explanation, no meta-commentary.
189	"""
190	            logger.info("Running second-pass de-AI review on writing draft...")
191	            response, _ = self.llm.generate_response(review_prompt, [])
192	        else:
193	            response = draft
194
195	        # Update History
196	        new_history = history.copy()
197	        new_history.append({"role": "user", "content": actual_user_message})
198	        new_history.append({"role": "assistant", "content": response})
199
200	        return response, new_history
201
202	class ReplyGenerator(BaseGenerator):
203	    def chat(self, user_input: str, history: List[Dict]) -> Tuple[str, List[Dict]]:
204
205	        is_first_turn = (len(history) == 0)
206
207	        if is_first_turn:
208	             # === First Turn ===
209	            static_samples = self._read_file("REPLY-SAMPLE.md")
210	            dynamic_samples = self.indexer.get_reply_samples(count=3)
211	            dynamic_samples_text = "\n\n".join(dynamic_samples)
212	            methodology = self._read_file("methodology.md")
213
214	            semantic_context = self._semantic_search(user_input, top_k=3)
215
216	            prompt = f"""You are an experienced Product/R&D Manager.
217	            Your task is to draft a reply to a challenging situation.
218
219	            === CORE PHILOSOPHY ===
220	            {methodology}
221
222	            === REPLY GUIDELINES ===
223	            1. Logic: Be clear and structured (e.g., numbered lists).
224	            2. Responsibility: Don't take unnecessary blame, but don't shirk core duties. Explain objective causes.
225	            3. Emotion: Be empathetic but professional.
226	            4. Conciseness: No fluff.
227
228	            === REFERENCE SAMPLES (FEW-SHOT) ===
229	            {static_samples}
230
231	            {dynamic_samples_text}
232
233	            {semantic_context}
234
235	            === THE SITUATION ===
236	            {user_input}
237
238	            Draft a reply. If the context implies a specific role (PM, Dev Lead), assume that role.
239	            """
240	            logger.info(f"Generating reply for query: {user_input}")
241	            actual_user_message = prompt
242
243	        else:
244	             # === Follow-up Turn ===
245	            logger.info(f"Follow-up reply instruction: {user_input}")
246	            actual_user_message = user_input
247	            prompt = user_input
248
249	        response, _ = self.llm.generate_response(prompt, history)
250
251	        new_history = history.copy()
252	        new_history.append({"role": "user", "content": actual_user_message})
253	        new_history.append({"role": "assistant", "content": response})
254
255	        return response, new_history
256
257	class DecisionSupport(BaseGenerator):
258	    def chat(self, user_input: str, history: List[Dict]) -> Tuple[str, List[Dict]]:
259
260	        is_first_turn = (len(history) == 0)
261
262	        if is_first_turn:
263	            decision_guide = self._read_file("decision.md")
264	            methodology = self._read_file("methodology.md")
265
266	            rag_notes = self.indexer.search(user_input, limit=3)
267	            rag_text = "\n\n".join(rag_notes)
268
269	            prompt = f"""You are a Decision Support Assistant.
270	            Your goal is to help the user think through a complex decision.
271
272	            === DECISION FRAMEWORK ===
273	            {decision_guide}
274
275	            === CORE PHILOSOPHY ===
276	            {methodology}
277
278	            === RELEVANT CONTEXT FROM NOTES ===
279	            {rag_text}
280
281	            === THE DECISION / ISSUE ===
282	            {user_input}
283
284	            === TASK ===
285	            1. Challenge the premise: Is this the right problem to solve?
286	            2. Apply the Framework: Use GPA (Goal, Priority, Alternatives) and IPO (Information, People, Objective reasoning) models.
287	            3. Pre-mortem: What is the worst that could happen?
288	            4. Provide a recommendation or a set of questions to clarify.
289	            """
290
291	            logger.info(f"Analyzing decision: {user_input}")
292	            actual_user_message = prompt
293	        else:
294	            logger.info(f"Follow-up decision instruction: {user_input}")
295	            actual_user_message = user_input
296	            prompt = user_input
297
298	        response, _ = self.llm.generate_response(prompt, history)
299
300	        new_history = history.copy()
301	        new_history.append({"role": "user", "content": actual_user_message})
302	        new_history.append({"role": "assistant", "content": response})
303
304	        return response, new_history
305
306	class SearchAnalyzer(BaseGenerator):
307	    def chat(self, user_input: str, history: List[Dict]) -> Tuple[str, List[Dict]]:
308
309	        # Tools definition for the LLM
310	        tools = [
311	            {
312	                "type": "function",
313	                "function": {
314	                    "name": "search_web",
315	                    "description": "Search the public web using DuckDuckGo. Use this when the internal notes are insufficient or when you need up-to-date external information.",
316	                    "parameters": {
317	                        "type": "object",
318	                        "properties": {
319	                            "query": {
320	                                "type": "string",
321	                                "description": "The search query"
322	                            }
323	                        },
324	                        "required": ["query"]
325	                    }
326	                }
327	            },
328	            {
329	                "type": "function",
330	                "function": {
331	                    "name": "list_recent_files",
332	                    "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.",
333	                    "parameters": {
334	                        "type": "object",
335	                        "properties": {
336	                            "days": {
337	                                "type": "integer",
338	                                "description": "Number of days to check (default 5)",
339	                                "default": 5
340	                            }
341	                        },
342	                        "required": []
343	                    }
344	                }
345	            }
346	        ]
347
348	        is_first_turn = (len(history) == 0)
349
350	        # Current Turn Message
351	        if is_first_turn:
352	            rag_notes = self.indexer.search(user_input, limit=5)
353	            rag_text = "\n\n".join(rag_notes)
354
355	            logger.info(f"SearchAnalyzer retrieved {len(rag_notes)} local notes.")
356	            for note in rag_notes:
357	                # Extract source line for logging
358	                first_line = note.split('\n')[0]
359	                logger.debug(f" - Retrieved: {first_line}")
360
361	            prompt = f"""You are an Analyst using Butler (Obsidian Edition).
362
363	            === TASK ===
364	            Answer the user's question.
365	            CRITICAL: You MUST explicitly cite your sources in the output.
366
367	            === STRATEGY ===
368	            1. first: Check "RELEVANT LOCAL NOTES" provided below.
369	            2. second: If the notes answer the question, answer directly.
370	            3. third: If the notes are missing info, ambiguos, or outdated, USE THE `search_web` TOOL to find external info.
371
372	            === OUTPUT FORMAT ===
373	            - Begin with a summary.
374	            - 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]".
375	            - If data comes from BOTH, mention both.
376
377	            === RELEVANT LOCAL NOTES ===
378	            {rag_text}
379
380	            === USER QUESTION ===
381	            {user_input}
382	            """
383	            actual_user_message = prompt
384	        else:
385	            actual_user_message = user_input
386
387	        # Prepare execution context
388	        # We don't want to mutate the input history directly until the end
389	        exec_history = history.copy()
390	        exec_history.append({"role": "user", "content": actual_user_message})
391
392	        logger.info(f"Analyzer thinking on: {user_input}")
393
394	        # === ReAct Loop (Max 2 turns to prevent infinite loops) ===
395	        for i in range(2):
396	            response_text, tool_calls = self.llm.generate_response(
397	                message="", # Message is already in history
398	                context=exec_history,
399	                tools=tools
400	            )


Let me continue reading the generators file to see the full ZhihuGenerator class:


Bash

grep -n "class ZhihuGenerator" /root/projects/butler/slack_bot/obsidian/generators.py


400	            )
401
402	            if tool_calls:
403	                # LLM wants to use tools
404	                logger.info(f"LLM requested tool calls: {tool_calls}")
405
406	                # Append the assistant's request (with tool calls) to history
407	                exec_history.append({
408	                    "role": "assistant",
409	                    "content": f"[Tool Call Request: {tool_calls}]"
410	                })
411
412	                for tc in tool_calls:
413	                    if tc["name"] == "search_web":
414	                        query = tc["args"].get("query")
415	                        search_result = WebSearchTool.search_web(query)
416
417	                        # Feed result back
418	                        exec_history.append({
419	                            "role": "user", # Using 'function' role would be better if our LLM class supports it fully, but 'user' works as system injection
420	                            "content": f"--- TOOL OUTPUT (search_web) ---\n{search_result}"
421	                        })
422
423	                    elif tc["name"] == "list_recent_files":
424	                        days = tc["args"].get("days", 5)
425	                        # Call indexer method
426	                        recent_list = self.indexer.get_recent_files(days=days)
427
428	                        exec_history.append({
429	                            "role": "user",
430	                            "content": f"--- TOOL OUTPUT (list_recent_files) ---\n{recent_list}"
431	                        })
432
433	                # Loop again to get the final answer using tool outputs
434	                try:
435	                     # Second pass with tool outputs
436	                    response_text, _ = self.llm.generate_response(
437	                        message="",
438	                        context=exec_history,
439	                        tools=tools
440	                    )
441	                    return response_text, exec_history
442	                except Exception as e:
443	                    logger.error(f"Error in second ReAct loop: {e}")
444	                    return response_text, exec_history
445
446	            else:
447	                # Final answer (no tools)
448	                return response_text, exec_history
449
450	        # Fallback if loop exhausted
451	        return response_text, exec_history
452
453
454	class DeAIReviser(BaseGenerator):
455	    """Remove AI tone from articles - text-in, text-out mode."""
456
457	    def chat(self, user_input: str, history: List[Dict]) -> Tuple[str, List[Dict]]:
458
459	        is_first_turn = (len(history) == 0)
460
461	        if is_first_turn:
462	            # === First Turn: User pastes the AI-generated article ===
463	            # Load the POWELL_REVISE.md guide
464	            powell_revise_guide = self._read_file("POWELL_REVISE.md")
465
466	            # Load user's writing samples for style reference
467	            style_samples = self.indexer.get_writing_samples(count=3)
468	            style_text = "\n\n".join(style_samples)
469
470	            prompt = f"""You are an expert editor specializing in removing AI-generated tone from articles.
471
472	Your task is to revise the article provided by the user to make it sound more human and authentic.
473
474	=== REVISION GUIDE ===
475	{powell_revise_guide}
476
477	=== USER'S AUTHENTIC WRITING SAMPLES (for style reference) ===
478	{style_text}
479
480	=== ARTICLE TO REVISE ===
481	{user_input}
482
483	=== TASK ===
484	1. Analyze the article and identify AI characteristics (过渡词堆积, 排比对仗, 空洞修饰词等)
485	2. Rewrite the article following the POWELL_REVISE.md principles
486	3. Preserve:
487	   - Original meaning and key information
488	   - Technical terms and proper nouns
489	   - Code blocks, lists, and formatting
490	   - Original language (Chinese/English)
491	4. Output the revised article directly (no meta-commentary like "Here's the revised version...")
492
493	CRITICAL: Output ONLY the revised article text. Do NOT add explanations before or after.
494	"""
495
496	            logger.info(f"DeAI Reviser: Processing article ({len(user_input)} chars)")
497	            actual_user_message = prompt
498
499	        else:
500	            # === Follow-up Turn: User gives refinement instructions ===
501	            logger.info(f"DeAI Reviser: Follow-up instruction - {user_input}")
502	            actual_user_message = user_input
503	            prompt = user_input
504
505	        # Call LLM
506	        response, _ = self.llm.generate_response(prompt, history)
507
508	        # Update History
509	        new_history = history.copy()
510	        new_history.append({"role": "user", "content": actual_user_message})
511	        new_history.append({"role": "assistant", "content": response})
512
513	        return response, new_history
514
515
516	class ZhihuGenerator(BaseGenerator):
517	    """Generate Zhihu-style answers based on style guide and user's knowledge base."""
518
519	    def chat(self, user_input: str, history: List[Dict]) -> Tuple[str, List[Dict]]:
520
521	        is_first_turn = (len(history) == 0)
522	        built_rich_prompt = False  # tracks whether we ran the full question->draft pipeline
523
524	        # Pre-load rule files (needed for second-pass review)
525	        zhihu_bot_style = self._read_file("zhihu-bot-style.md")
526
527	        # Treat as a new question if:
528	        # - history is empty (genuine first turn), OR
529	        # - user input contains "|" (explicit "Question | Core Ideas" format), OR
530	        # - user input starts with known question triggers like "回答这个问题", OR
531	        # - user input contains a Zhihu question URL (pasting a new question link)
532	        is_new_question = (
533	            is_first_turn
534	            or "|" in user_input
535	            or user_input.strip().startswith("回答这个问题")
536	            or "zhihu.com/question/" in user_input
537	        )
538
539	        if is_new_question:
540	            # === First Turn: Build Rich Prompt ===
541	            # Expected input format: "Question | Core Ideas"
542	            parts = user_input.split("|", 1)
543	            question = parts[0].strip()
544	            core_ideas = parts[1].strip() if len(parts) > 1 else ""
545
546	            # 1. Load Style Guide (zhihu-style.md serves as writing standard, NOT as material)
547	            zhihu_style_guide = self._read_file("zhihu-style.md")
548	            # Bot-specific overrides (plain-text output, no bracket explanations, etc.)
549	            if zhihu_bot_style:
550	                zhihu_style_guide = zhihu_style_guide + "\n\n" + zhihu_bot_style
551
552	            # 2. RAG from Obsidian vault for actual content material
553	            rag_notes = self.indexer.search(question, limit=5)
554	            rag_text = "\n\n".join(rag_notes)
555
556	            # 3. Load methodology
557	            methodology = self._read_file("methodology.md")
558
559	            semantic_context = self._semantic_search(question, top_k=3)
560
561	            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.
562
563	=== YOUR IDENTITY & METHODOLOGY ===
564	{methodology}
565
566	=== ZHIHU STYLE GUIDE (FOLLOW THESE WRITING PRINCIPLES) ===
567	{zhihu_style_guide}
568
569	CRITICAL: The style guide above defines HOW to write (tone, structure, language patterns).
570	Do NOT treat zhihu-sample content as factual material. It's ONLY for style reference.
571
572	=== RELEVANT KNOWLEDGE FROM YOUR OBSIDIAN VAULT (USE AS ACTUAL MATERIAL) ===
573	{rag_text}
574
575	{semantic_context}
576
577	=== THE QUESTION ===
578	{question}
579
580	=== YOUR CORE IDEAS ===
581	{core_ideas}
582
583	=== TASK ===
584	1. Answer the question based on YOUR knowledge (from Obsidian vault) and core ideas
585	2. Write in Chinese (unless question implies English)
586	3. Be direct and opinionated — attach concrete evaluation to every fact (not feature listing)
587	4. If recent web context is provided, weave it in naturally
588	5. STRICT: Never add bracket explanations. Write "大语言模型" not "大语言模型（LLM）"
589	6. No self-promotion, no identity reveal, no past project references
590	7. No poetry, quotes, or "升华" endings. Stop when the point is made.
591	8. LENGTH: 500–1000 Chinese characters. Cut a point, not quality.
592
593	=== ANTI-AI CORE RULES (apply while writing) ===
594	- 语感目标：像懂行的人随手打字，不端着，不耍帅
595	- 禁止：排比对仗、过渡词堆砌（首先/其次/最后）、空洞修饰（深入/全面/显著）
596	- 禁止：比喻超过1个、概念包装（给普通事物起酷名字）、鸡汤结尾
597	- 禁止：防御性写法（很多人觉得X但其实Y）、对比式定义（不是X是Y）
598	- 陈述事实时必须附带主观判断，不要纯客观罗列
599
600	Begin writing the answer:
601	"""
602
603	            logger.info(f"Generating Zhihu answer for: {question[:50]}...")
604	            actual_user_message = prompt
605	            built_rich_prompt = True
606
607	        else:
608	            # === Follow-up Turn: User gives refinement instructions (e.g. "make it shorter") ===
609	            logger.info(f"Follow-up Zhihu instruction: {user_input}")
610	            actual_user_message = user_input
611	            prompt = user_input
612
613	        # Call LLM — first pass: content generation
614	        # For new questions, use empty history to avoid contamination from previous conversations
615	        llm_history = [] if is_new_question else history
616	        draft, _ = self.llm.generate_response(prompt, llm_history)
617
618	        # === Second pass: rule enforcement ===
619	        # Runs whenever a full draft was generated (built_rich_prompt=True),
620	        # regardless of whether history was empty or not.
621	        # Skipped on follow-up refinement turns (e.g. "make it shorter").
622	        run_review = built_rich_prompt
623	        if run_review:
624	            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.
625
626	=== TONE TARGET (最高优先级) ===
627	目标语感：像一个懂行的人在知乎随手打字回答。不端着，也不刻意耍帅。
628	两种AI味都要杀：
629	- 正式AI味：过渡词堆砌、排比对仗、空洞修饰、鸡汤结尾
630	- 油腻AI味：刻意装随意（"折腾去吧"）、强行造金句（"搞坏了不心疼，跑通了就是资产"）、给普通概念起酷名字（"隔离舱"、"暴力碰撞"、"最干净的契约"）、用一句俏皮话收尾装潇洒
631	正确的语感：平铺直叙，该说什么说什么，说完就停。不需要让读者觉得你很酷。
632
633	=== VOCABULARY KILL LIST (逐词扫描，命中即删或换大白话) ===
634	1. 夸张修饰: 极度脆弱、极其脆弱、毁灭性的、灾难级的、压倒性的、远超想象、不止一个量级、颠覆性的、革命性的、折磨人、天翻地覆、风险极高、瞬间崩断、精疲力竭、真金白银、本质上
635	2. 大词空转: 范式转移、范式突变、开发范式、数字化容器、数字雇员、温和池架构、非确定性、阅后即焚、毫秒级就绪、毫秒级响应、赋能、底层逻辑、仪式性努力、深刻变革、暴力碰撞、最干净的契约、服务化的潜力、不可逆的浪潮、时代大背景、可扩展的执行信用、终极归宿、壁垒、分水岭、沉淀（动词修饰数据/经验时）、云端办公室、命门
636	3. 鸡汤/口号: 正途、值得跨越、安置灵魂、掌控感、才是王道、苦活累活交给、未来已来、危机就是转机、挑战与机遇并存、从玩具变成生产力、把环境变成了资产、安全底线、这才是成熟的做法、才是X的关键、不是建议是必须
637	4. 概念包装: 裸奔（=没防护）、修路/飙车（=基础工作/高级用法）、提款机（=攻击目标）、隔离舱（=容器）、数字雇员（=脚本）、执行信用（=资源）、云端办公室（=云服务器）、牢笼（=限制）、独立房间（=隔离环境）
638	5. 油腻收尾: "X去吧"式潇洒收尾、"搞坏了不心疼，跑通了就是Y"式金句、俏皮话总结全文、"把X关在Y里...把Y放在Z上"式排比收尾
639	6. 伪口语: 说白了、说实话、折腾这玩意儿
640
641	=== SENTENCE PATTERN KILL LIST (命中即改写) ===
642	1. 防御性写法："很多人觉得X，实际上Y"、"有人X，有人已经Y了"、"一部分人在X，另一部分人在Y" → 直接说判断，删掉铺垫
643	2. 对比式定义："不是X，是Y"、"不是X而是Y"、"别把X当Y"、"这不对，它的本质是…" → 直接陈述后半句
644	3. 换皮夸张：用另一个夸张词替换夸张词不算修好 → 换成具体后果
645	4. 概念包装：给普通事物起酷名字 → 用最朴素的说法
646	5. 油腻金句：短句对仗、押韵、刻意工整的收尾句 → 删掉或拆成普通句
647	6. 比喻堆砌：全篇最多1个比喻，多余全删换直说
648	7. 鸡汤/口号结尾：删除励志或耍帅收尾，给具体建议或直接停
649	8. 首尾呼应：禁止结尾重复开头
650	9. 编号分段：线性论述删除序号改自然段落；并列对比可保留
651	10. 强行总结：删除"综上"/"一句话"式收尾
652	11. 关键词重复：同一个修辞性词汇全篇只能出现一次
653	12. 过渡词换皮："第一个要说的是"="首先"，"另一个问题是"="其次" → 直接删掉过渡，开门见山
654	13. 伪分类二分法："一部分人X，另一部分人Y"、"修路派/飙车派" → 删除分类框架，直接说事实
655
656	=== DRAFT TO REVIEW ===
657	{draft}
658
659	=== OUTPUT ===
660	Return ONLY the corrected Chinese text. No explanation, no meta-commentary, no English.
661	"""
662	            logger.info("Running second-pass rule enforcement on Zhihu draft...")
663	            response, _ = self.llm.generate_response(review_prompt, [])
664	        else:
665	            response = draft
666
667	        # Update History
668	        new_history = history.copy()
669	        new_history.append({"role": "user", "content": actual_user_message})
670	        new_history.append({"role": "assistant", "content": response})
671
672	        return response, new_history


516:class ZhihuGenerator(BaseGenerator):


Perfect! Now let me look at a dispatcher to see how the workflow is triggered:


grep -l "zhihu" /root/projects/butler/slack_bot/obsidian/*.py


grep -n "zhihu\|Zhihu" /root/projects/butler/slack_bot/obsidian/dispatcher.py | head -40


/root/projects/butler/slack_bot/obsidian/dispatcher.py
/root/projects/butler/slack_bot/obsidian/generators.py


8:from slack_bot.obsidian.generators import WritingAssistant, ReplyGenerator, DecisionSupport, SearchAnalyzer, DeAIReviser, ZhihuGenerator
47:    MODE_ZHIHU = "zhihu"
49:    MODE_ZHIHU_HUNTER = "zhihu-hunter"
75:            self.MODE_ZHIHU: ZhihuGenerator(self.indexer, self.vector_store),
83:        # Initialize Zhihu Hunter components
84:        from slack_bot.zhihu.zhihu_hunter import ZhihuHunter
85:        from slack_bot.zhihu.zhihu_playwright_engine import ZhihuPlaywrightEngine
86:        from slack_bot.zhihu.slack_interactive_gateway import SlackInteractiveGateway
88:        zhihu_notify_channel = os.environ.get("ZHIHU_NOTIFY_CHANNEL", "")
89:        self.zhihu_hunter = ZhihuHunter(
94:        _zhihu_engine = ZhihuPlaywrightEngine(
96:            notify_channel=zhihu_notify_channel,
98:        self.zhihu_gateway = SlackInteractiveGateway(
100:            hunter=self.zhihu_hunter,
101:            engine=_zhihu_engine,
102:            notify_channel=zhihu_notify_channel,
150:            self._reply(channel_id, "⚠ No mode selected. Use `mode write/reply/decide/search/deai/zhihu/note/info/zhihu-hunter` to start.", response_ts)
172:        # MODE_ZHIHU_HUNTER: scan vault for keywords, surface Zhihu question candidates
182:        #   https://www.zhihu.com/question/... → direct URL: answer this specific question
186:            from slack_bot.zhihu.zhihu_hunter import DIR_SHORTCUTS
194:                    questions = self.zhihu_hunter.scan_and_hunt(since_days=7)
196:                elif words and 'zhihu.com/question/' in words[0]:
205:                    questions = self.zhihu_hunter.hunt_direct_url(url)
208:                    logger.info(f"ZhihuHunter: direct URL {url!r} → {questions[0].title!r}" +
219:                    questions = self.zhihu_hunter.scan_glob_files(pattern)
220:                    logger.info(f"ZhihuHunter: glob '{pattern}' → {len(questions)} questions")
231:                    questions = self.zhihu_hunter.scan_single_file(rel_path)
232:                    logger.info(f"ZhihuHunter: single-file scan '{rel_path}' → {len(questions)} questions")
244:                    questions = self.zhihu_hunter.scan_and_hunt(
251:                    questions = self.zhihu_hunter._find_questions(words)
252:                    logger.info(f"ZhihuHunter: manual keywords {words} → {len(questions)} questions")
254:                self.zhihu_gateway.post_question_cards(questions, channel=channel_id)
256:                logger.error(f"Zhihu hunt failed: {e}", exc_info=True)


Now let me examine the complete dispatcher flow for zhihu mode:


grep -A 50 "elif mode == self.MODE_ZHIHU:" /root/projects/butler/slack_bot/obsidian/dispatcher.py


1	import os
2	from pathlib import Path
3	from typing import Optional, List, Dict, Any
4	from slack_sdk import WebClient
5
6	from health.utils.logging_config import setup_logger
7	from slack_bot.obsidian.indexer import ObsidianIndexer
8	from slack_bot.obsidian.generators import WritingAssistant, ReplyGenerator, DecisionSupport, SearchAnalyzer, DeAIReviser, ZhihuGenerator
9	from slack_bot.obsidian.info_manager import InfoManager
10	from slack_bot.context.storage import ContextStorage as BaseStorage
11
12	logger = setup_logger(__name__)
13
14	class ObsidianContextStorage(BaseStorage):
15	    """Specific storage for Obsidian bot to avoid mixing with Health bot."""
16	    def __init__(self, channel_id: str):
17	        super().__init__(channel_id)
18	        from health import config
19	        self.context_dir = config.DATA_DIR / "obsidian_context"
20	        self.context_dir.mkdir(parents=True, exist_ok=True)
21	        self.file_path = self.context_dir / f"{channel_id}.json"
22	        self.state_file = self.context_dir / f"{channel_id}_state.json"
23
24	    def save_state(self, state: Dict[str, Any]):
25	        import json
26	        with open(self.state_file, "w") as f:
27	            json.dump(state, f)
28
29	    def load_state(self) -> Dict[str, Any]:
30	        import json
31	        if not self.state_file.exists():
32	            return {"mode": "none"}
33	        try:
34	            with open(self.state_file, "r") as f:
35	                return json.load(f)
36	        except:
37	            return {"mode": "none"}
38
39	class ObsidianDispatcher:
40	    """Routes Obsidian Slack Bot messages."""
41
42	    MODE_WRITE = "write"
43	    MODE_REPLY = "reply"
44	    MODE_DECIDE = "decide"
45	    MODE_SEARCH = "search"
46	    MODE_DEAI = "deai"
47	    MODE_ZHIHU = "zhihu"
48	    MODE_NOTE = "note"
49	    MODE_ZHIHU_HUNTER = "zhihu-hunter"
50	    MODE_INFO = "info"
51
52	    VALID_MODES = {MODE_WRITE, MODE_REPLY, MODE_DECIDE, MODE_SEARCH, MODE_DEAI, MODE_ZHIHU, MODE_NOTE, MODE_ZHIHU_HUNTER, MODE_INFO}
53
54	    def __init__(self, vault_path: str):
55	        self.indexer = ObsidianIndexer(vault_path)
56	        self.indexer.scan_vault()
57
58	        # Initialize RAG vector store and note ingester
59	        from slack_bot.obsidian.embeddings import get_embedding_provider
60	        from slack_bot.obsidian.vector_store import ChromaVectorStore
61	        from slack_bot.obsidian.note_ingester import NoteIngester
62
63	        self.vector_store = ChromaVectorStore(embedding_provider=get_embedding_provider())
64	        self.note_ingester = NoteIngester(
65	            vault_path=Path(vault_path),
66	            vector_store=self.vector_store,
67	        )
68
69	        self.generators = {
70	            self.MODE_WRITE: WritingAssistant(self.indexer, self.vector_store),
71	            self.MODE_REPLY: ReplyGenerator(self.indexer, self.vector_store),
72	            self.MODE_DECIDE: DecisionSupport(self.indexer),
73	            self.MODE_SEARCH: SearchAnalyzer(self.indexer),
74	            self.MODE_DEAI: DeAIReviser(self.indexer),
75	            self.MODE_ZHIHU: ZhihuGenerator(self.indexer, self.vector_store),
76	        }
77
78	        self.info_manager = InfoManager(vault_path=Path(vault_path))
79
80	        token = os.environ.get("OBSIDIAN_SLACK_BOT_TOKEN")
81	        self.client = WebClient(token=token)
82
83	        # Initialize Zhihu Hunter components
84	        from slack_bot.zhihu.zhihu_hunter import ZhihuHunter
85	        from slack_bot.zhihu.zhihu_playwright_engine import ZhihuPlaywrightEngine
86	        from slack_bot.zhihu.slack_interactive_gateway import SlackInteractiveGateway
87
88	        zhihu_notify_channel = os.environ.get("ZHIHU_NOTIFY_CHANNEL", "")
89	        self.zhihu_hunter = ZhihuHunter(
90	            vault_path=Path(vault_path),
91	            vector_store=self.vector_store,
92	            indexer=self.indexer,
93	        )
94	        _zhihu_engine = ZhihuPlaywrightEngine(
95	            slack_client=self.client,
96	            notify_channel=zhihu_notify_channel,
97	        )
98	        self.zhihu_gateway = SlackInteractiveGateway(
99	            slack_client=self.client,
100	            hunter=self.zhihu_hunter,
101	            engine=_zhihu_engine,
102	            notify_channel=zhihu_notify_channel,
103	        )
104	        logger.info("ObsidianDispatcher initialized")
105
106	    def dispatch(self, text: str, channel_id: str, user_id: str, response_ts: Optional[str] = None):
107	        storage = ObsidianContextStorage(channel_id)
108	        state = storage.load_state()
109	        current_mode = state.get("mode", "none")
110
111	        cmd = text.strip().lower()
112
113	        # 1. Handle Commands
114	        if cmd.startswith("mode "):
115	            new_mode = cmd.split(" ", 1)[1].strip()
116	            if new_mode in self.VALID_MODES:
117	                storage.clear()
118	                storage.save_state({"mode": new_mode})
119	                msg = f"✅ Switched to mode: *{new_mode}* (History cleared)"
120	                if new_mode == self.MODE_INFO:
121	                    msg += (
122	                        "\n\n📌 *使用说明:*\n"
123	                        "• `project <名称>` — 切换/创建项目\n"
124	                        "• 直接发送文字 — 新增条目，末尾加 `(From 来源)` 标注出处\n"
125	                        "• `update` — 查看最近条目，选序号追加更新\n"
126	                        "• `ask <问题>` — 基于项目信息回答问题（附带日期和来源）\n"
127	                        "• `summary` — 汇总项目进展\n"
128	                        "• `summary + <vault路径>` — 汇总 + 融合外部笔记\n"
129	                        "• `search <关键词>` — 搜索当前项目\n"
130	                        "• `list` — 查看所有项目"
131	                    )
132	                self._reply(channel_id, msg, response_ts)
133	                return
134	            else:
135	                self._reply(channel_id, f"❌ Invalid mode. Available: {', '.join(sorted(self.VALID_MODES))}", response_ts)
136	                return
137
138	        if cmd == "clear":
139	            storage.clear()
140	            self._reply(channel_id, "🧹 Context cleared.", response_ts)
141	            return
142
143	        if cmd == "reload":
144	            self.indexer.scan_vault()
145	            self._reply(channel_id, "🔄 Vault reloaded.", response_ts)
146	            return
147
148	        # 2. Handle Content based on mode
149	        if current_mode == "none":
150	            self._reply(channel_id, "⚠ No mode selected. Use `mode write/reply/decide/search/deai/zhihu/note/info/zhihu-hunter` to start.", response_ts)
151	            return
152
153	        # MODE_NOTE: ingest text or URL into vault + vector store
154	        if current_mode == self.MODE_NOTE:
155	            try:
156	                result = self.note_ingester.ingest(text)
157	                self._reply(channel_id, result, response_ts)
158	            except Exception as e:
159	                logger.error(f"Note ingestion failed: {e}", exc_info=True)
160	                self._reply(channel_id, f"🐛 Ingestion error: {str(e)}", response_ts)
161	            return
162
163	        # MODE_INFO: project info fragment tracker
164	        if current_mode == self.MODE_INFO:
165	            try:
166	                self._handle_info_mode(text, channel_id, storage, state, response_ts)
167	            except Exception as e:
168	                logger.error(f"Info mode failed: {e}", exc_info=True)
169	                self._reply(channel_id, f"🐛 Error: {str(e)}", response_ts)
170	            return
171
172	        # MODE_ZHIHU_HUNTER: scan vault for keywords, surface Zhihu question candidates
173	        #
174	        # Command format:
175	        #   开始                               → full scan, all dirs, auto keywords
176	        #   article                            → scan Article only (Revised first, no time filter)
177	        #   article agent开发                  → scan Article, focus on "agent开发"
178	        #   notes agent开发                    → scan notes, focus on "agent开发"
179	        #   关键词1 关键词2                     → skip scan, search directly with keywords
180	        #   Article/8-xxx-Revised.md           → single file: grounded in that article
181	        #   Article/*openclaw*-Revised.md      → glob: combine matching files
182	        #   https://www.zhihu.com/question/... → direct URL: answer this specific question
183	        #
184	        # Dir shortcuts: notes / article / clippings / claw / deepfact / deep-dive
185	        if current_mode == self.MODE_ZHIHU_HUNTER:
186	            from slack_bot.zhihu.zhihu_hunter import DIR_SHORTCUTS
187	            try:
188	                trigger_words = {"开始", "hunt", "扫描", "start", "go"}
189	                words = [w for w in text.strip().split() if w.lower() not in trigger_words]
190
191	                if not words:
192	                    # Bare trigger → full auto scan
193	                    self._reply(channel_id, "🔍 正在扫描知乎候选问题（所有目录）...", response_ts)
194	                    questions = self.zhihu_hunter.scan_and_hunt(since_days=7)
195
196	                elif words and 'zhihu.com/question/' in words[0]:
197	                    # Direct URL paste — user was invited or found a question to answer.
198	                    # Any text after the URL is treated as an optional answer outline.
199	                    # Strip trailing Slack/HTML junk chars (>, ", etc.) from the URL.
200	                    import re as _re


(Bash completed with no output)


Let me search for the zhihu mode handling in the dispatcher:


grep -A 30 "elif current_mode == self.MODE_ZHIHU:" /root/projects/butler/slack_bot/obsidian/dispatcher.py


Let me search more broadly in the dispatcher file:


grep -n "MODE_ZHIHU" /root/projects/butler/slack_bot/obsidian/dispatcher.py | tail -20


250	                    self._reply(channel_id, f"🔍 使用指定关键词搜索：{' · '.join(words)}", response_ts)
251	                    questions = self.zhihu_hunter._find_questions(words)
252	                    logger.info(f"ZhihuHunter: manual keywords {words} → {len(questions)} questions")
253
254	                self.zhihu_gateway.post_question_cards(questions, channel=channel_id)
255	            except Exception as e:
256	                logger.error(f"Zhihu hunt failed: {e}", exc_info=True)
257	                self._reply(channel_id, f"❌ 扫描失败：{e}", response_ts)
258	            return
259
260	        # Get history
261	        history = storage.get_context()
262	        generator = self.generators[current_mode]
263
264	        try:
265	            logger.info(f"Generating Obsidian response in mode: {current_mode}")
266	            # The chat method handles first turn logic and returns updated history
267	            # But wait, our generators' chat method appends to history internally.
268	            # In our Slack setup, we want to append the result to ContextStorage.
269
270	            # Note: generator.chat(text, history) returns (response, new_history_list)
271	            # new_history_list contains [...history, {user: rich_prompt}, {assistant: response}]
272	            response_text, updated_history = generator.chat(text, history)
273
274	            # Since generator.chat already did the LLM call and returned history,
275	            # we just need to save the ONLY NEW messages to our storage.
276	            # However, during FIRST TURN, text is replaced by rich_prompt.
277
278	            # For simplicity: completely overwrite storage with updated_history
279	            # ContextStorage doesn't support overwrite easily, but we can clear and re-add.
280	            # Better: Modify ObsidianContextStorage to support set_context.
281
282	            # Actually, let's just use the updated_history and manually add to storage
283	            # Warning: first turn 'user' message in storage will be the RICH PROMPT
284	            # This is actually better for context continuity in Slack too.
285
286	            storage.clear()
287	            for msg in updated_history:
288	                storage.add_message(msg["role"], msg["content"])
289
290	            self._reply(channel_id, response_text, response_ts)
291
292	        except Exception as e:
293	            logger.error(f"Obsidian generation failed: {e}", exc_info=True)
294	            self._reply(channel_id, f"🐛 Error: {str(e)}", response_ts)
295
296	    def _handle_info_mode(
297	        self,
298	        text: str,
299	        channel_id: str,
300	        storage: ObsidianContextStorage,
301	        state: dict,
302	        response_ts: Optional[str],
303	    ) -> None:
304	        """Route commands within MODE_INFO."""
305	        cmd = text.strip()
306	        cmd_lower = cmd.lower()
307	        active_project = state.get("active_project", "")
308	        pending_idx = state.get("pending_update_entry_index")
309
310	        # --- Command: project <name> ---
311	        if cmd_lower.startswith("project "):
312	            project_name = cmd[8:].strip()
313	            if not project_name or "/" in project_name or ".." in project_name:
314	                self._reply(channel_id, "⚠ 无效项目名。", response_ts)
315	                return
316	            state["active_project"] = project_name
317	            state["pending_update_entry_index"] = None
318	            storage.save_state(state)
319	            self._reply(channel_id, f"📂 已切换到项目: *{project_name}*", response_ts)
320	            return
321
322	        # --- Command: list ---
323	        if cmd_lower == "list":
324	            result = self.info_manager.list_projects()
325	            self._reply(channel_id, result, response_ts)
326	            return
327
328	        # Guard: require active project for all other commands
329	        if not active_project:
330	            self._reply(
331	                channel_id,
332	                "⚠ 请先选择项目。用 `project <名称>` 切换或创建项目。\n用 `list` 查看已有项目。",
333	                response_ts,
334	            )
335	            return
336
337	        # --- Command: update ---
338	        if cmd_lower == "update":
339	            msg, entries = self.info_manager.list_recent(active_project)
340	            if entries:
341	                # Store entries in state for later reference
342	                state["pending_update_entries"] = [e.model_dump() for e in entries]
343	                state["pending_update_entry_index"] = -1  # waiting for index
344	                storage.save_state(state)
345	            self._reply(channel_id, msg, response_ts)
346	            return
347
348	        # --- State: waiting for update index selection ---
349	        if pending_idx == -1:
350	            try:
351	                idx = int(cmd.strip()) - 1  # user sees 1-based
352	                entries_data = state.get("pending_update_entries", [])
353	                if 0 <= idx < len(entries_data):
354	                    state["pending_update_entry_index"] = idx
355	                    storage.save_state(state)
356	                    self._reply(
357	                        channel_id,
358	                        f"✏ 已选择第 {idx + 1} 条。请发送更新内容：",
359	                        response_ts,
360	                    )
361	                    return
362	                else:
363	                    state["pending_update_entry_index"] = None
364	                    storage.save_state(state)
365	                    self._reply(channel_id, "⚠ 无效序号，已退出更新模式。", response_ts)
366	                    return
367	            except ValueError:
368	                # Not a number — treat as cancel, fall through to add_entry
369	                state["pending_update_entry_index"] = None
370	                state.pop("pending_update_entries", None)
371	                storage.save_state(state)
372
373	        # --- State: waiting for update content ---
374	        if isinstance(pending_idx, int) and pending_idx >= 0:
375	            from slack_bot.obsidian.info_manager import InfoEntry
376
377	            entries_data = state.get("pending_update_entries", [])
378	            entries = [InfoEntry(**d) for d in entries_data]
379	            result = self.info_manager.update_entry(
380	                active_project, entries, pending_idx, cmd
381	            )
382	            state["pending_update_entry_index"] = None
383	            state.pop("pending_update_entries", None)
384	            storage.save_state(state)
385	            self._reply(channel_id, result, response_ts)
386	            return
387
388	        # --- Command: summary ---
389	        if cmd_lower.startswith("summary"):
390	            extra_paths = None
391	            if "+" in cmd:
392	                _, paths_str = cmd.split("+", 1)
393	                extra_paths = [p.strip() for p in paths_str.split(",") if p.strip()]
394	            result = self.info_manager.summarize(active_project, extra_paths)
395	            self._reply(channel_id, result, response_ts)
396	            return
397
398	        # --- Command: ask <question> ---
399	        if cmd_lower.startswith("ask "):


47:    MODE_ZHIHU = "zhihu"
49:    MODE_ZHIHU_HUNTER = "zhihu-hunter"
52:    VALID_MODES = {MODE_WRITE, MODE_REPLY, MODE_DECIDE, MODE_SEARCH, MODE_DEAI, MODE_ZHIHU, MODE_NOTE, MODE_ZHIHU_HUNTER, MODE_INFO}
75:            self.MODE_ZHIHU: ZhihuGenerator(self.indexer, self.vector_store),
172:        # MODE_ZHIHU_HUNTER: scan vault for keywords, surface Zhihu question candidates
185:        if current_mode == self.MODE_ZHIHU_HUNTER:


Perfect! Now I have a good understanding. Let me create a comprehensive summary document by examining the final section of the dispatcher to understand the complete flow:


521 /root/projects/butler/slack_bot/obsidian/dispatcher.py


Now let me check for any additional script files that might show the complete flow:


find /root/projects/butler -name "*zhihu*" -type f | grep -v __pycache__ | grep -v htmlcov


/root/projects/butler/scripts/zhihu_debug_publish.py
/root/projects/butler/scripts/zhihu_e2e_test.py
/root/projects/butler/slack_bot/zhihu/zhihu_playwright_engine.py
/root/projects/butler/slack_bot/zhihu/zhihu_hunter.py
/root/projects/butler/tag20260215/zhihu-style.md
/root/projects/butler/zhihu-style.md
/root/projects/butler/tag20260302/zhihu-style.md
/root/projects/butler/zhihu-bot-style.md
/root/projects/butler/zhihu-hunter-guide.md


Let me read the hunter guide and check the sample:


1	# Zhihu Hunter 使用说明
2
3	在 Slack 中先发送 `mode zhihu-hunter` 切换到该模式，然后发送以下任意指令。
4
5	---
6
7	## 一、找问题的六种方式
8
9	### 1. 全自动扫描（默认）
10
11	```
12	开始
13	```
14
15	扫描近 7 天改动的所有目录（notes / Article / Clippings / CLAW / deep-dive），用 LLM 提炼关键词，再去知乎搜匹配的问题。返回最多 12 个候选。
16
17	---
18
19	### 2. 指定目录扫描
20
21	```
22	article
23	notes
24	clippings
25	claw
26	deep-dive
27	```
28
29	只扫这个目录。Article 目录比较特殊：不限制时间，优先读 `-Revised.md` 文件。
30
31	可以加主题词聚焦：
32
33	```
34	article agent开发
35	notes AI安全
36	claw 预测市场
37	```
38
39	主题词会注入到关键词提取的 prompt，让搜出来的问题更集中。
40
41	---
42
43	### 3. 指定单个文件
44
45	```
46	Article/8-你的文章名-Revised.md
47	```
48
49	只从这一篇文章里提取关键词找问题。生成草稿时，回答会以这篇文章的观点为主要依据。
50
51	---
52
53	### 4. Glob 模糊匹配多文件
54
55	```
56	Article/*openclaw*-Revised.md
57	Article/*agent*-Revised.md
58	```
59
60	用通配符匹配多个文件，合并内容后提炼关键词。最新改动的文件作为生成草稿时的主要参考。
61
62	---
63
64	### 5. 直接指定关键词
65
66	```
67	AI智能体安全 大模型应用
68	预测市场 信息差
69	```
70
71	跳过 vault 扫描，直接拿这些词去知乎搜问题。适合自己已经想好要聊什么方向的情况。
72
73	---
74
75	### 6. 直接粘贴知乎问题 URL
76
77	```
78	https://www.zhihu.com/question/123456789
79	```
80
81	被邀请回答或者自己看到了一道想回答的问题，直接粘贴 URL。Bot 会自动获取问题标题，生成一张草稿卡片。
82
83	**可选：在 URL 后面加上回答思路**
84
85	```
86	https://www.zhihu.com/question/123456789 先讲信息差的本质，再分析 Polymarket 的机制，最后说普通用户能做什么
87	```
88
89	思路会跟着问题卡片保存。点"生成草稿"时，Bot 会严格按照这个逻辑框架展开，从知识库中找匹配的内容填充每个论点。不加思路就和现在一样自动生成。
90
91	---
92
93	## 二、拿到候选问题后的操作
94
95	Bot 会返回多张问题卡片，每张卡片有三个按钮：
96
97	| 按钮 | 说明 |
98	|------|------|
99	| 生成草稿 | 用知识库 + 近期网络资讯生成知乎风格回答 |
100	| 重新生成 | 弹出输入框，填写修改意见后重新生成 |
101	| 确认发布 | 用 Playwright 自动登录知乎并发布回答 |
102
103	---
104
105	## 三、草稿生成说明
106
107	- 回答基于 Obsidian 知识库（语义搜索 + 关键词检索）
108	- 如果指定了文件（方式 3 / 4），该文件内容作为首要参考
109	- 自动抓取近 90 天相关网络资讯注入 prompt，让回答有时效感
110	- 风格：纯文本、无 Markdown、不加括号解释术语、不说谢邀、不提公众号
111
112	---
113
114	## 四、目录简写对照表
115
116	| 输入 | 实际目录 |
117	|------|----------|
118	| `notes` | notes/ |
119	| `article` | Article/ |
120	| `clippings` | Clippings/ |
121	| `claw` / `deepfact` | CLAW/deepfact/ |
122	| `deep-dive` | auto_report/deep-dive/ |
123


1	# 全球领先的公有云aws近几日召开的reinvent大会给腾讯云带来了哪些启发？
2
3	说的我也挺有感触的，aws作为云计算行业的龙头，在发展多年之后仍然保持着极其旺盛的生命力，并通过事实和结果证明了aws这种云计算模式似乎代表了基础设施变革的正确之路。抛开本次reinvent具体发布的新功能，作为一名长期关注云计算业界和aws发展的云从业老兵，我认为aws的精神内核无非就是：认真做产品，坚持做产品。
4
5	“认真做产品，坚持做产品”，这句话我想了好几年，主要是觉得太土气而想用一句更有逼格的话来评价aws的整体设计原则，但始终找不到更贴切的。而实际上一直以来，aws本身也是这么一个土味十足的产品集合，从一开始它被硅谷技术更强的大公司嘲笑其产品能力低端并没有什么先进理念，到现在仍然不断在各种场合被不同的同行吊打它的某些能力，就连它的名字本身“Amazon Web Services”也一直没有改过，让人以为是国内三线城市的网站代理商。
6
7	而这正是我认为aws之所以成功的原因，aws只以最笨的方法解决现实存在的问题，并把这变成标准的产品能力。包括本次的reinvent，aws推出了一项Image Builder(https://aws.amazon.com/cn/blogs/china/automate-os-image-build-pipelines-with-ec2-image-builder/)的功能，操作复杂，解决的场景有限，预期客户也多不到哪里去，但是它就是在aws的产品体系中恰到好处的“有用”。
8
9	稍微展开一点开看，aws的“认真做产品，坚持做产品”大致可以分为这么几个核心原则：
10
11	1. 强调产品化大过定制化/项目化/服务化。
12
13	ToB产品本身的服务化属性非常强，而aws宁愿以在早期吃力不讨好的方式坚持产品化，甚至有很多用户的核心诉求，只要是产品化条件不满足，aws就不以任何形式提供。比如“ec2在线修改密码”，aws大约在被骂了十几年之后，才通过ec2之上的另一个产品ssm(https://docs.aws.amazon.com/zh_cn/systems-manager/latest/userguide/ssm-agent.html) 做了有限支持。
14
15	我觉得这个和我司说的“C2B”的理念有共通之处，产品化意味着标准化、生态化和边际成本较低的可复制，产品化的道路更加漫长和艰难，但是它每一次的进步都是可复制的进步。ZStack的创始人张鑫发表过ZStack的设计理念的文章，有一篇专门探讨“是做产品还是做项目”，在我看来也是一定程度上受到aws的启发，（然后就被阿里云收购了）。当然这里并不是否认针对性服务的重要性，对产品不完善性的服务不仅困难而且难以创造附加价值。
16
17	在现实世界中，即便是云计算已经发展了十几年，基础设施的能力和用户的需求之间还是存在很多鸿沟，并不能一刀切的认为产品化的原教旨主义总是唯一正确的，标准和定制总是会有所取舍，但是aws对产品化的坚持算得上是对革命理想乐观奋进的光辉。
18
19	我们团队有一个产品弹性伸缩as（https://cloud.tencent.com/product/as）近两年一直在优化产品本身的能力而从未做过推广，却得到了120倍的规模增长，我想这也是产品化完善之后，“桃李不言，下自成蹊”的效应。
20
21	2. 模块化程度高。
22
23	这也算得上是产品化理念在落地上的实践原则，aws的产品矩阵继承amazon的内部架构，产品之间和产品内部模块之间的调用都是高度抽象和模块化的，模块之间边界清晰低耦合，模块内部架构完整高内聚。比如aws sns（https://aws.amazon.com/cn/sns/?whats-new-cards.sort-by=item.additionalFields.postDateTime&whats-new-cards.sort-order=desc）只是一个简单的消息收发服务，除了发布和订阅以外其它的事都不做，既看不到技术的深度也带不来多少收入，但是它在持续优化，做好消息的收发管道（甚至具体消息处理都是依赖其它产品），现在几乎所有的基于aws的“积木搭建”的解决方案都需要集成sns产品。
24
25	Image Builder和ssm也是相同的思路，面对具体问题，aws大概把最多的时间花在了分层和建模上。Image Builder不在Image中实现，而是另起一个产品，给原有的镜像产品在产品设计上一分为二，一个专门做镜像的管理和跟ec2相关的生产，另外一个专门做镜像的标准化生成，aws的产品经理面对Image Builder这样一个新产品，他考虑的是如何实现最稳妥的模块划分，pipeline、recipe、component、image充分解耦而又相互配合形成一个小而完整的可扩展架构，并且引入的概念都是开发人员易于理解的，然后整个Image Builder再与License Manager、Organizations、Inspector、ssm等服务协同成为完整的资源管理生态体系。
26
27	同样的，ssm与ec2的关系在于，ssm从ec2中分化出devops层，让iaas与devops互相依赖而又互不影响。aws的产品发展和裂变大都遵循着相同的路径，一般在孵化初期会显得产品建模复杂和臃肿，但它通过这种方式来很好地应对了长时间需求变化的考验。当然了，我觉得有些产品比如batch(https://aws.amazon.com/cn/batch/)还是太过度设计了，它引入的概念过于繁琐，导致使用时很难与业务场景的真实问题匹配上。
28
29	ec2不去做devops，它所关注的在于ec2本身的优化，在ec2早就一骑绝尘的时候，ec2仍然在其核心能力——性能和稳定性上持续并深度优化，然后这变成了aws的核心之一：nitro。从产品上看，nitro并无任何巧思，它只是在长时间踏实的解决实际问题，类似于曾国藩的“结硬寨，打呆仗”，步步为营，让时间站在自己这一方。这也是我不太认同题主所说我们缺乏顶层设计的地方，云计算，尤其是iaas层和paas层，产品的核心能力本身就是大家都认同并且追求的。正如题主而言，aws有一种不怕被抄袭的自信，我觉得那是因为aws选择了hardest way, 坚持在走hardest way，并且相信自己是hardest。我云的情况也是类似，虚拟化领域已经连续三年登上kvm贡献排行榜，这就是坚持和时间的馈赠，而我相信未来这也会变成我云最具竞争力的能力之一。
30
31	除了模块内部建模的有板有眼和优化的迭代深入，模块化程度高还有另外一个体现在于aws的api体系极其工整，工整到让人觉得严肃而死板。cvm早期，我们曾嘲笑aws api的道貌岸然，我们在关机接口中用1表示软关机，2表示硬关机，3表示先软关机再硬关机，4表示一个和关机没有关系的逻辑，我们为这种“quick and dirty”的设计理念欢呼，但是这给产品间的配合和用户的api使用带来灾难，我曾在某个大客户的现场看到其开发人员贴在显示器旁边的“腾讯云API魔法数字速查表”……于是在2017年cvm重新设计api时，深度调研才发现，即使不管ec2 api的事实标准这一点，ec2 api仍然几乎是最完美的设计。
32
33	aws api的权限控制体系iam（https://aws.amazon.com/cn/iam/details/manage-users/）也曾经让我头疼不已，但是我花了很长时间仍然想不出一个能比它更好的标准化权限控制功能，我们可以为不同的场景定制化权限控制功能，但随着需求变化和场景变多，这些都将变成不可维护的技术负债。如今腾讯云已经完整支持了更好的api框架，即参照aws实现的api 3.0，并且可以在api 3.0上集成权限控制cam和资源标签功能，这会是接入产品标准化的一个很重要保证。
34
35	这很像是手工艺产品和工业化产品的区别，工业化产品看起来千篇一律，没有情怀，但是它可控、可复制、可集成，让智慧和巧思体现在更上层的组合设计上，aws的工业化坚持反倒有了一种“天下有道，丘不与易也”的逼格。
36
37	3. 做尊重用户的产品创新。
38
39	尊重用户不仅是要解决用户的实际问题，也要让用户能够感知和理解产品的设计思路。aws极少做pr型的产品功能，也从来不提供silver bullet欺诈用户。比如aws的竞价实例（https://aws.amazon.com/cn/ec2/spot/），aws给ec2实例提供了一个“可能被中断回收”的选项后，实例的价格可以大幅降低。用户很容易理解这些资源其实来自于aws的母机池碎片，当aws需要时可回收以供正常的实例使用，这样用户也就能理解竞价实例低价格的原因以及在哪种场景下适用。aws竞价实例的精巧还在于，你越是去逆向分析它，就越能感受到aws确实只用一条“可能被中断回收”的不承诺而做到了其它所有的承诺（https://zhuanlan.zhihu.com/p/27606489）
40
41	计划预留实例（https://docs.aws.amazon.com/zh_cn/AWSEC2/latest/UserGuide/ec2-scheduled-instances.html）是另外一个尊重用户的产品创新，它摆明了以一种类似于滴滴“拼车出行”的方式诱导用户之间做好资源复用以降低价格。用户只要愿意接受“拼车”就能享受到低价。
42
43	尊重用户才能获得用户的尊重和信任，这是一笔巨大的财富，这也许是每次aws出重大问题时，用户宁愿去从自己身上找原因的原因吧。
44
45	这次reinvent发布的local zones（https://aws.amazon.com/cn/about-aws/global-infrastructure/localzones/?hp=r），也是相同的产品思路。aws扩展了zone的概念，引入了local zone，用户就能理解这个zone是从属于某个region并且离他较近，而且也能猜测local zone应该是接入到其所在region的骨干网，于是在不需要更多文档解释的情况下，用户很容易设计出合理的容灾架构。
46
47	4. 计算能力自下而上的产品化。
48
49	“aws只以最笨的方法解决现实存在的问题，并把这变成标准的产品能力”，aws最早只提供ec2和s3两个最底层产品，而不像当时流行的app engine提供的是完善且贴近用户的服务框架，因此曾倍受质疑。现在来看，aws已经发展出了几百个产品，而高屋建瓴的革命性产品已经销声匿迹，我认为原因还是在于aws的产品发展和裂变是一种自下而上的产品化过程。所谓自下而上，即在解决实际问题的过程中发现新的问题，然后再解决，而不是一蹴而就给出颠覆性的解决方案，后者有点树个靶子再打的嫌疑，并耗费了大量的精力在解决可能本不应该有的问题。
50
51	原有的基础设施即使再不好，也有几十年的积累和存量业务，基础设施的变革对于用户来说，至少要具备可迁移、可回退、可追溯这几个特征，计算能力自下而上的产品化的好处在于，它很少强迫用户一定要接受新的东西，但是它靠产品本身的有序演进让越来越多的用户享受到了平滑迁移到新功能带来的利益。前面说的ssm, image builder, as, 竞价实例, iam等都是这样演化过来的。
52
53	这次reinvent，aws发布了一系列arm实例和Inf1实例，可以看做是自下而上产品化，aws在服务用户时遇到了相应场景的问题，然后通过严格模块化的思路去解决，最后变成实际可用的产品，经过多年这样迭代，aws的发展越来越壮大，解决的问题越来越多，适用的场景也就越来越丰富，逐渐就有了颠覆旧基础设施的样子（然而它还是叫web services）。甚至aws发布的量子计算产品braket，都马上能在jupyter notebook中调用来编写实际的代码。
54
55	计算能力自下而上的产品化其实就是模块化边界清晰之后逐渐深度优化的过程，我最近两次参加T通道评审惊讶的发现，已经有了很多的内部业务在使用腾讯云的cvm和tke来搭建，通过标准的文档、接口和对产品的直观理解，就可以将业务部署到云，这在过去几年是不敢期望的。越来越多自发使用云产品组合的架构，正是代表了产品化成熟的标志。
56
57	————————结尾—————————
58
59	认识aws这么多年，如果把aws人格化，我觉得aws就像是郭靖，他说的话并不多，但每一句都真诚而有份量，不论是对阵威震江湖的名门正派，还是杀伐决断的漠北天骄，他总是恭恭敬敬地唱一个诺，然后左边画个龙，右边画一道彩虹，大喝一声：
60
61	亢龙有悔
62


Perfect! Now I have all the information I need. Let me create a comprehensive report on the zhihu mode and pipeline.

## Summary

I've found all files related to "zhihu" mode in the Butler project. Here's a comprehensive overview of the system architecture, files, and generation pipeline:

### File Locations

**Core Implementation Files:**
- `/root/projects/butler/slack_bot/zhihu/zhihu_hunter.py` - Main question discovery and answer drafting logic
- `/root/projects/butler/slack_bot/zhihu/zhihu_playwright_engine.py` - Browser automation for login and publishing
- `/root/projects/butler/slack_bot/zhihu/slack_interactive_gateway.py` - Slack UI controls and approval gates
- `/root/projects/butler/slack_bot/obsidian/generators.py` - Contains `ZhihuGenerator` class (lines 516-672)
- `/root/projects/butler/slack_bot/obsidian/dispatcher.py` - Routes "zhihu" and "zhihu-hunter" modes

**Style & Guide Files:**
- `/root/projects/butler/zhihu-style.md` - Comprehensive style guide (610 lines) with 10 sections
- `/root/projects/butler/zhihu-bot-style.md` - Bot-specific hard constraints and overrides
- `/root/projects/butler/zhihu-hunter-guide.md` - User documentation for the hunter workflow
- `/root/projects/butler/zhihu-sample/12.md` - Example Zhihu answer (AWS reinvent analysis)

---

## Two Separate Modes

### 1. MODE_ZHIHU: Direct Answer Generation
**Dispatcher:** Lines 262-294 in dispatcher.py

Takes a question (optionally with context) and generates a Zhihu-style answer.

**Input Format:** `Question | Core Ideas` (the `|` separator is optional for follow-up refinement)

**Pipeline:**
```
User Input (in Slack)
        ↓
[First Turn - Full Rich Prompt Build]
        ↓
Load zhihu-style.md + zhihu-bot-style.md (style rules)
Load methodology.md (core philosophy)
Search Obsidian vault via ObsidianIndexer (keyword-based RAG)
Search vector store semantically (ChromaDB, top 3 results)
        ↓
Create combined prompt with:
  - Style guide
  - RAG context from vault
  - User's core ideas
  - Question text
        ↓
LLM Call 1: Generate draft answer
        ↓
LLM Call 2: De-AI Review (enforce zhihu-bot-style.md rules)
        ↓
Return pure plain-text answer
        ↓
[Follow-up Turns]
        ↓
User can refine with "make it shorter", "more formal", etc.
LLM applies refinement to existing answer
```

**Key Code:** `ZhihuGenerator.chat()` in generators.py (lines 519-672)

---

### 2. MODE_ZHIHU_HUNTER: Question Discovery + Draft Pipeline
**Dispatcher:** Lines 172-258 in dispatcher.py

Discovers Zhihu questions from vault content, then generates answers for approval.

**Six Ways to Find Questions:**

| Method | Command | Behavior |
|--------|---------|----------|
| Auto scan | `开始` | Scan all dirs (7 days), extract keywords via LLM, search Zhihu |
| Dir-specific | `article`, `notes`, `claw` | Scan one directory with optional topic focus |
| Single file | `Article/8-xxx-Revised.md` | Ground answer in this specific file |
| Glob pattern | `Article/*agent*-Revised.md` | Combine matched files for keyword extraction |
| Keywords | `AI智能体 大模型` | Skip vault scan, search Zhihu directly |
| Direct URL | `https://www.zhihu.com/question/123456` | Fetch title/description, create question card |

**Complete Hunter Pipeline:**

```
[Phase 1: Question Discovery]
User triggers hunt (6 methods above)
        ↓
Vault Scanning (if applicable):
  - Collect recent files from watched dirs:
    • notes/ (7 days old)
    • Article/ (no time filter, -Revised.md prioritized)
    • Clippings/ (7 days old)
    • CLAW/deepfact/ (7 days old)
    • auto_report/deep-dive/ (7 days old)
  - Read file headers (strip YAML frontmatter)
  - Combine with per-file budget (500-800 chars)
  - Total budget: 8000 chars
        ↓
LLM Keyword Extraction:
  - Prompt: Extract 10 topic keywords from aggregated vault text
  - Avoid: overly niche terms, too broad concepts, project codenames
  - Use: upper-level concepts users would search for
  - Example: "OpenClaw" → "AI智能体安全"
        ↓
Tavily Search (via TavilyClient):
  - Search for: site:zhihu.com/question <keyword>
  - Filter: exclude non-question URLs (no /answer/, /comment/)
  - Normalize URLs (strip params, trailing chars)
  - Return up to 12 questions (deduplicated)
        ↓
Return: List[ZhihuQuestion]
  - title, url, snippet, keywords, found_at
  - Optional: source_file (set if from single file/glob)

[Phase 2: Slack Approval Gate 1]
Post question cards with buttons:
  [✍ 生成草稿] [🚫 忽略]
        ↓
User clicks one button

[Phase 3: Draft Generation on Approval]
When user clicks "生成草稿":
  - Load ZhihuQuestion
  - Call zhihu_hunter.draft_answer(question)
        ↓
draft_answer() pipeline:
  - Semantic search (vector_store): top 3 chunks from vault
  - Keyword search (indexer): top 5 vault notes
  - Load source file (if pinned to one):
    • Read up to 4000 chars from <vault>/source_file
    • Use as primary reference for answer grounding
  - Fetch recent web context (Tavily, 90 days):
    • Search for question title
    • Get 4 results from non-Zhihu sources
    • Format as snippets
        ↓
Delegate to ZhihuGenerator.chat():
  - Build rich prompt with:
    • Question title + description
    • User outline (if provided)
    • Primary article (source file)
    • Recent web context
  - Generate answer using zhihu-style.md rules
  - Apply de-AI second pass
        ↓
Return: AnswerDraft
  - question, content, generated_at
  - rag_sources (deduplicated list)
  - vault_file (saved to <vault>/zhihu/ if successful)

[Phase 4: Slack Approval Gate 2]
Post draft card with buttons:
  [✅ 确认发布] [🔄 重新生成] [❌ 取消]
        ↓
If "🔄 重新生成":
  - Modal dialog opens for user feedback
  - Re-call draft_answer(question, feedback=user_feedback)
        ↓
If "✅ 确认发布":
  - Call engine.ensure_logged_in():
    • Check saved cookies in data/zhihu_state.json
    • If expired: trigger QR code login (2 min timeout, up to 2 attempts)
    • Save/refresh cookies
        ↓
  - Call engine.publish_answer(question_url, answer_text):
    • Navigate to home page (warm up session)
    • Navigate to question page
    • Click "写回答" button (tries 10+ selectors)
    • Fill editor via Draft.js (3 strategies: execCommand → ClipboardEvent → keyboard.type)
    • Click "发布回答" button
    • Extract answer URL from DOM
    • Return answer URL
        ↓
  - Update vault file: status: draft → published + answer_url
  - Delete draft JSON file
  - Post success card to Slack
```

---

## Style Guide System

The zhihu mode uses a **two-layer style system:**

### Layer 1: `zhihu-style.md` (610 lines)
Comprehensive style guide covering:
- **Core positioning**: "Professional yet interesting, rigorous yet warm"
- **Tone**: Mix of formal + casual, first-person dominant
- **Sentence patterns**: Long/short contrast, rhetorical questions
- **Rhetoric**: Metaphors (max 1-2 per article), self-deprecation, classical references
- **Content structure**:
  - Opening: "谢邀", direct statement, acknowledgment, interest disclosure
  - Body: Total-分-总 with numbered points
  - Ending: Poetry/wisdom, summary, forward-looking
- **Language**: Professional terms + colloquial; must explain jargon with full sentences, not parentheses
- **Layout**: Bold only for core terms (3-4 max); space paragraphs
- **Depth**: Real cases, theory + practice balance, tools/methods, critical thinking
- **Avoids**: AI tone, fearmongering, empty praise, buzzwords, excessive metaphors, Markdown decoration

### Layer 2: `zhihu-bot-style.md` (110 lines)
Bot-specific hard constraints:

**Hard Bans:**
- No "谢邀" (thank-you phrase)
- No self-promotion, past articles, identity reveals
- No poetry/wisdom endings (direct stop)
- No abstract jargon: "赋能", "底层逻辑", "仪式性努力", "精神枷锁"
- No fake binary categories: "conservative/radical", "inward/outward"
- No educational tone (assume expert audience)
- No chicken soup endings: "crisis is opportunity", "growth mindset"
- No formulaic structure with Chinese numeral summaries
- No defensive phrasing: "many think X but actually Y"
- No contrastive definitions: "not X, but Y"
- No fragmented syntax unless listing steps

**Forced Style Patterns:**
- Pure plain text (zero Markdown symbols)
- Direct terminology use (no parenthetical explanations)
- Wide paragraph spacing
- Natural sentence length variation (burstiness)
- Allow fragmented syntax in technical lists
- 500-1000 characters target length
- Stop when point is made (no padding)

---

## Generation Pipeline in Detail

### ZhihuGenerator.chat() Flow

```python
# Input: question text, history
# First turn: "Should I invest in crypto? | I want to discuss systemic risks"
# First turn detected by: empty history, "|" in input, "zhihu.com/question/" in input

if is_new_question:
    # 1. Load style guides
    zhihu_style = read("zhihu-style.md")  # 610 lines
    zhihu_bot = read("zhihu-bot-style.md")  # Overrides
    combined_style = zhihu_style + "\n\n" + zhihu_bot

    # 2. RAG retrieval (dual search)
    rag_notes = indexer.search(question, limit=5)  # Keyword-based
    semantic = vector_store.search_knowledge(question, top_k=3)  # Vector-based

    # 3. Load context
    methodology = read("methodology.md")

    # 4. Build mega-prompt
    prompt = f"""
    You are answering a Zhihu question. Write authentic, experience-driven.

    === STYLE GUIDE ===
    {combined_style}

    === OBSIDIAN VAULT MATERIAL ===
    {rag_notes}
    {semantic}

    === THE QUESTION ===
    {question}

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

    === TASK ===
    - Answer based on vault knowledge + your ideas
    - 500-1000 chars
    - Pure text, no Markdown
    - No "谢邀", no self-promotion, no poetry
    - Direct & opinionated (attach judgment to every fact)
    - Weave in recent web context if provided
    """

    # 5. LLM Draft Generation
    draft = llm.generate(prompt, history=[])  # Empty history for first turn

    # 6. De-AI Second Pass (rule enforcement)
    review_prompt = f"""
    You are a strict editor. Remove AI tone. Do NOT change facts.

    === TONE TARGET ===
    Like a knowledgeable person typing casually on Zhihu.
    Not putting on airs. Not showing off.

    === VOCABULARY KILL LIST ===
    1. Exaggeration: 极度脆弱, 颠覆性, 天翻地覆, 远超想象
    2. Empty abstractions: 范式转移, 赋能, 底层逻辑, 仪式性努力
    3. Chicken soup: 危机就是转机, 这是成长机会, 最重要的选择
    4. Concept packaging: 隔离舱, 数字雇员, 云端办公室
    5. Oily endings: "折腾去吧"式潇洒, "搞坏了不心疼"式金句
    6. Faux-casual: 说白了, 说实话, 这玩意儿

    === SENTENCE PATTERN KILL LIST ===
    1. Defensive: "很多人觉得X，实际上Y" → 直接说判断
    2. Contrastive: "不是X是Y" → 直接陈述后半句
    3. Metaphor overkill: 最多1个比喻，多余全删
    4. Numbered paragraphs: 一、二、三 → 自然段落
    5. Forced summary: "综上所述"/"一句话" → 直接停

    === DRAFT ===
    {draft}
    """

    response = llm.generate(review_prompt, history=[])

else:
    # Follow-up: User says "make it shorter" / "add more examples"
    response = llm.generate(user_input, history)

return response, updated_history
```

---

## Key Data Models

### ZhihuQuestion
```python
title: str              # "应该投资加密货币吗？"
url: str               # "https://www.zhihu.com/question/123456"
snippet: Optional[str] # "近年来虚拟货币风险..." (from web search)
keywords: List[str]    # ["加密货币", "投资风险"]
found_at: str          # ISO timestamp
description: Optional[str]  # Full question body (from Playwright)
source_file: Optional[str]  # "Article/8-crypto-Revised.md" (if pinned)
outline: Optional[str]      # User-provided answer structure
```

### AnswerDraft
```python
question: ZhihuQuestion
content: str           # Generated answer body
generated_at: str      # ISO timestamp
rag_sources: List[str] # ["article-name.md", "note-title"]
vault_file: Optional[str]  # Absolute path to saved draft in vault
```

---

## Watched Directories (Vault Scanning)

```python
WATCH_DIRS = [
    "notes",                  # General notes, 7 days
    "Article",               # Blog articles (no time filter, Revised prioritized)
    "auto_report/deep-dive", # Analysis reports, 7 days
    "CLAW/deepfact",         # Deep research, 7 days
    "Clippings",             # Web clippings, 7 days
]

_MAX_FILES_PER_DIR = 4      # Most recent 4 per dir
_MAX_FILES_ARTICLE = 8      # Double slots for Article
_MAX_KEYWORDS = 10          # Extracted keywords limit
_MAX_QUESTIONS_TOTAL = 12   # Final questions capped at 12
```

---

## External Dependencies

- **Tavily API** (`TavilyClient`): Web search for Zhihu questions
- **Playwright + playwright-stealth**: Browser automation for login/publishing
- **GeminiLLM**: Text generation via Google Gemini API
- **ChromaDB** (vector_store): Semantic search of vault notes
- **Slack SDK**: Interactive gateways and UI
- **Obsidian Indexer**: Keyword-based vault search

---

## Example Flow: Complete End-to-End

```
User: "mode zhihu-hunter"
Bot: "✅ Switched to zhihu-hunter"

User: "article agent开发"
Bot: "🔍 正在扫描..."
  → Scans Article/ dir only
  → Extracts keywords: ["AI智能体", "Agent框架", "智能体安全"]
  → Searches Zhihu with each keyword
  → Returns 12 questions like:
    • "怎样才能系统地学习AI智能体开发？"
    • "大模型在智能体中的安全隐患有哪些？"

Bot posts cards:
  [✍ 生成草稿] [🚫 忽略]
  [✍ 生成草稿] [🚫 忽略]
  ...

User: Clicks "✍ 生成草稿" on question 1
Bot: "⏳ 草稿生成中，请稍候..."
  → Calls zhihu_hunter.draft_answer(question)
  → Semantic search: gets 3 relevant vault chunks
  → Keyword search: gets 5 vault notes
  → Loads most-recent Article file from scan
  → Fetches recent web articles on "AI智能体开发"
  → Builds rich prompt to ZhihuGenerator
  → LLM generates draft (500-1000 chars)
  → Second pass removes AI tone
  → Saves to vault: zhihu/2026-03-30-AI智能体学习.md

Bot posts draft card:
  📝 "怎样才能系统地学习AI智能体开发？"

  生成的草稿文本...（前800字预览）

  📚 RAG来源：article1.md · vault-note-2 · ...

  [✅ 确认发布] [🔄 重新生成] [❌ 取消]

User: Clicks "🔄 重新生成"
Modal: "请输入修改意见："
User: "可以加点实操的例子吗"
Bot: "🔄 重新生成中..."
  → Calls draft_answer(question, feedback="可以加点实操的例子吗")
  → Re-adds feedback to LLM prompt
  → Regenerates draft
  → Updates card in place

User: Clicks "✅ 确认发布"
Bot: "⏳ 正在登录知乎并发布..."
  → Calls engine.ensure_logged_in()
    → Loads cookies from data/zhihu_state.json
    → If valid: continue
    → If expired: Send QR code to Slack, wait 2 min for scan
    → Save refreshed cookies
  → Calls engine.publish_answer(question_url, answer_text)
    → Navigate to Zhihu home + question page
    → Click "写回答"
    → Fill editor (Draft.js aware)
    → Click "发布"
    → Extract published answer URL

Bot: "✅ 已发布！
问题: 怎样才能系统地学习AI智能体开发？
回答: https://www.zhihu.com/question/123456/answer/789..."
  → Updates vault file: status published, answer_url added
  → Deletes draft JSON
```

This comprehensive pipeline balances:
- **Discovery**: Vault content → keywords → Zhihu questions
- **Drafting**: Multi-source RAG + style enforcement
- **Approval**: Two-gate human review (question + draft)
- **Publishing**: Secure headless browser automation
- **Persistence**: Vault storage + cookie management