weflow-cli 1.8.2 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/ARCHITECTURE.md +62 -26
  2. package/CHANGELOG.md +1290 -0
  3. package/OPERATIONS.md +144 -11
  4. package/README.en.md +20 -4
  5. package/README.md +30 -5
  6. package/bin/weflow-cli-electron.cjs +2 -0
  7. package/bin/weflow-cli.ts +996 -118
  8. package/dist/bin/weflow-cli.js +1038 -146
  9. package/dist/bin/weflow-cli.js.map +1 -1
  10. package/dist/mcp-server/http.d.ts +14 -0
  11. package/dist/mcp-server/http.d.ts.map +1 -0
  12. package/dist/mcp-server/http.js +84 -0
  13. package/dist/mcp-server/http.js.map +1 -0
  14. package/dist/mcp-server/httpConfig.d.ts +50 -0
  15. package/dist/mcp-server/httpConfig.d.ts.map +1 -0
  16. package/dist/mcp-server/httpConfig.js +104 -0
  17. package/dist/mcp-server/httpConfig.js.map +1 -0
  18. package/dist/mcp-server/index.d.ts +13 -1
  19. package/dist/mcp-server/index.d.ts.map +1 -1
  20. package/dist/mcp-server/index.js +58 -374
  21. package/dist/mcp-server/index.js.map +1 -1
  22. package/dist/src/core/dbPathService.d.ts +12 -0
  23. package/dist/src/core/dbPathService.d.ts.map +1 -1
  24. package/dist/src/core/dbPathService.js +20 -3
  25. package/dist/src/core/dbPathService.js.map +1 -1
  26. package/dist/src/panel/server.d.ts.map +1 -1
  27. package/dist/src/panel/server.js +61 -1
  28. package/dist/src/panel/server.js.map +1 -1
  29. package/dist/src/services/assistantEval.d.ts +69 -0
  30. package/dist/src/services/assistantEval.d.ts.map +1 -1
  31. package/dist/src/services/assistantEval.js +270 -8
  32. package/dist/src/services/assistantEval.js.map +1 -1
  33. package/dist/src/services/assistantScenes.d.ts +81 -0
  34. package/dist/src/services/assistantScenes.d.ts.map +1 -0
  35. package/dist/src/services/assistantScenes.js +322 -0
  36. package/dist/src/services/assistantScenes.js.map +1 -0
  37. package/dist/src/services/assistantService.d.ts +33 -4
  38. package/dist/src/services/assistantService.d.ts.map +1 -1
  39. package/dist/src/services/assistantService.js +102 -6
  40. package/dist/src/services/assistantService.js.map +1 -1
  41. package/dist/src/services/assistantSkills.d.ts +94 -0
  42. package/dist/src/services/assistantSkills.d.ts.map +1 -0
  43. package/dist/src/services/assistantSkills.js +330 -0
  44. package/dist/src/services/assistantSkills.js.map +1 -0
  45. package/dist/src/services/assistantTools.d.ts +34 -1
  46. package/dist/src/services/assistantTools.d.ts.map +1 -1
  47. package/dist/src/services/assistantTools.js +931 -88
  48. package/dist/src/services/assistantTools.js.map +1 -1
  49. package/dist/src/services/assistantTrace.d.ts +4 -0
  50. package/dist/src/services/assistantTrace.d.ts.map +1 -1
  51. package/dist/src/services/assistantTrace.js +9 -0
  52. package/dist/src/services/assistantTrace.js.map +1 -1
  53. package/dist/src/services/configService.d.ts +9 -0
  54. package/dist/src/services/configService.d.ts.map +1 -1
  55. package/dist/src/services/configService.js +5 -2
  56. package/dist/src/services/configService.js.map +1 -1
  57. package/dist/src/services/messageQuery.d.ts +18 -0
  58. package/dist/src/services/messageQuery.d.ts.map +1 -1
  59. package/dist/src/services/messageQuery.js +29 -0
  60. package/dist/src/services/messageQuery.js.map +1 -1
  61. package/dist/src/services/outboundSend.d.ts +53 -0
  62. package/dist/src/services/outboundSend.d.ts.map +1 -0
  63. package/dist/src/services/outboundSend.js +90 -0
  64. package/dist/src/services/outboundSend.js.map +1 -0
  65. package/dist/src/services/pythonBridge.d.ts.map +1 -1
  66. package/dist/src/services/pythonBridge.js +1 -0
  67. package/dist/src/services/pythonBridge.js.map +1 -1
  68. package/dist/src/services/wechatClient.d.ts +125 -0
  69. package/dist/src/services/wechatClient.d.ts.map +1 -0
  70. package/dist/src/services/wechatClient.js +344 -0
  71. package/dist/src/services/wechatClient.js.map +1 -0
  72. package/dist/src/services/wechatMedia.d.ts +44 -0
  73. package/dist/src/services/wechatMedia.d.ts.map +1 -0
  74. package/dist/src/services/wechatMedia.js +155 -0
  75. package/dist/src/services/wechatMedia.js.map +1 -0
  76. package/dist/src/services/wechatMessageService.d.ts +11 -0
  77. package/dist/src/services/wechatMessageService.d.ts.map +1 -1
  78. package/dist/src/services/wechatMessageService.js +55 -11
  79. package/dist/src/services/wechatMessageService.js.map +1 -1
  80. package/dist/src/types.d.ts +4 -0
  81. package/dist/src/types.d.ts.map +1 -1
  82. package/dist/src/utils/python.js +2 -2
  83. package/dist/src/utils/python.js.map +1 -1
  84. package/docs/BRANCHES.md +37 -0
  85. package/docs/CONTACT_DB_SCHEMA.md +1085 -0
  86. package/docs/DECISIONS.md +1624 -0
  87. package/docs/EXTENDING.md +98 -10
  88. package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +6 -0
  89. package/docs/HEALTH-CHECK.md +12 -7
  90. package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +2 -2
  91. package/docs/MCP.md +58 -4
  92. package/docs/PROJECT_STATE.md +180 -11
  93. package/docs/RELEASING.md +115 -1
  94. package/docs/images/weflow-architecture-gpt-image-2.png +0 -0
  95. package/logo.png +0 -0
  96. package/mcp-server/http.ts +108 -0
  97. package/mcp-server/httpConfig.ts +126 -0
  98. package/mcp-server/index.ts +359 -681
  99. package/package.json +5 -3
  100. package/resources/js/graph3d/NOTICE.txt +35 -0
  101. package/resources/js/graph3d/d3-binarytree.min.js +2 -0
  102. package/resources/js/graph3d/d3-dispatch.min.js +2 -0
  103. package/resources/js/graph3d/d3-force-3d.min.js +2 -0
  104. package/resources/js/graph3d/d3-octree.min.js +2 -0
  105. package/resources/js/graph3d/d3-quadtree.min.js +2 -0
  106. package/resources/js/graph3d/d3-timer.min.js +2 -0
  107. package/resources/js/graph3d/layout.mjs +63 -0
  108. package/resources/js/graph3d/three.min.js +6 -0
  109. package/resources/js/graph3d/viewer2d.js +448 -0
  110. package/resources/panel/ball-position.cjs +48 -2
  111. package/resources/panel/index.html +18 -4
  112. package/resources/panel/main.cjs +137 -5
  113. package/resources/panel/mascot-base.png +0 -0
  114. package/resources/panel/mascot-blink-1.png +0 -0
  115. package/resources/panel/mascot-blink-2.png +0 -0
  116. package/resources/panel/mascot-iris.png +0 -0
  117. package/resources/panel/mascot-lift-down-1.png +0 -0
  118. package/resources/panel/mascot-lift-down-2.png +0 -0
  119. package/resources/panel/mascot-lift-down-3.png +0 -0
  120. package/resources/panel/mascot-lift-down-4.png +0 -0
  121. package/resources/panel/mascot-lift-down-5.png +0 -0
  122. package/resources/panel/mascot-lift-down-6.png +0 -0
  123. package/resources/panel/mascot-lift-up-1.png +0 -0
  124. package/resources/panel/mascot-lift-up-2.png +0 -0
  125. package/resources/panel/mascot-lift-up-3.png +0 -0
  126. package/resources/panel/mascot-lift-up-4.png +0 -0
  127. package/resources/panel/mascot-lift-up-5.png +0 -0
  128. package/resources/panel/mascot-lift-up-6.png +0 -0
  129. package/resources/panel/mascot-peek.png +0 -0
  130. package/resources/panel/mascot-tickle-1.png +0 -0
  131. package/resources/panel/mascot-tickle-2.png +0 -0
  132. package/resources/panel/mascot-tickle-3.png +0 -0
  133. package/resources/panel/mascot-tickle-4.png +0 -0
  134. package/resources/panel/panel.css +205 -59
  135. package/resources/panel/preload.cjs +26 -0
  136. package/resources/panel/renderer.js +646 -17
  137. package/scripts/_utils.py +1203 -931
  138. package/scripts/backfill_articles.py +202 -21
  139. package/scripts/biz_daily.py +26 -34
  140. package/scripts/bonds.py +331 -0
  141. package/scripts/chat_notes.py +561 -470
  142. package/scripts/chat_stats.py +8 -7
  143. package/scripts/check_readme_commands.py +426 -0
  144. package/scripts/compile_wiki.py +187 -59
  145. package/scripts/contact_schema.py +339 -0
  146. package/scripts/data/wechat_emoji.json +225 -0
  147. package/scripts/export_chat_html.py +3 -1
  148. package/scripts/fav_notes.py +4 -1
  149. package/scripts/graph_2d.py +151 -0
  150. package/scripts/graph_3d.py +646 -0
  151. package/scripts/health_check.py +163 -26
  152. package/scripts/mcp_bridge.py +6 -7
  153. package/scripts/nt_decrypt.py +3 -3
  154. package/scripts/panel_frames.py +683 -0
  155. package/scripts/pipeline.py +21 -10
  156. package/scripts/quality_eval.py +5 -0
  157. package/scripts/rag_chat.py +33 -16
  158. package/scripts/reply_debt.py +26 -6
  159. package/scripts/semantic_search.py +130 -22
  160. package/scripts/vault_rag.py +42 -16
  161. package/scripts/vault_search.py +44 -8
  162. package/scripts/wechat_emoticon.py +6 -0
  163. package/scripts/wechat_voice.py +9 -0
  164. package/scripts/wiki_lint.py +124 -24
  165. package/src/core/dbPathService.ts +22 -4
  166. package/src/panel/server.ts +58 -1
  167. package/src/services/assistantEval.ts +323 -12
  168. package/src/services/assistantScenes.ts +342 -0
  169. package/src/services/assistantService.ts +107 -6
  170. package/src/services/assistantSkills.ts +364 -0
  171. package/src/services/assistantTools.ts +2452 -1640
  172. package/src/services/assistantTrace.ts +9 -0
  173. package/src/services/configService.ts +415 -403
  174. package/src/services/messageQuery.ts +29 -0
  175. package/src/services/outboundSend.ts +126 -0
  176. package/src/services/pythonBridge.ts +1 -0
  177. package/src/services/wechatClient.ts +380 -0
  178. package/src/services/wechatMedia.ts +166 -0
  179. package/src/services/wechatMessageService.ts +62 -11
  180. package/src/types.ts +4 -0
  181. package/src/utils/python.ts +2 -2
  182. package/dist/src/utils/wechatEmoji.d.ts +0 -5
  183. package/dist/src/utils/wechatEmoji.d.ts.map +0 -1
  184. package/dist/src/utils/wechatEmoji.js +0 -35
  185. package/dist/src/utils/wechatEmoji.js.map +0 -1
@@ -43,8 +43,11 @@ def main():
43
43
  parser.add_argument('--interest', default='AI', help='兴趣主题(默认 AI)')
44
44
  parser.add_argument('--wiki-limit', type=int, default=20, help='概念编译数量(默认 20)')
45
45
  parser.add_argument('--skip-classify', action='store_true', help='跳过后处理')
46
- parser.add_argument('--skip-wiki', action='store_true', help='跳过概念编译')
47
- parser.add_argument('--skip-vault', action='store_true', help='跳过 Vault 同步')
46
+ parser.add_argument('--with-wiki', action='store_true',
47
+ help='把概念页写进 Obsidian Vault。**默认不做** —— Obsidian 自带的图谱\n'
48
+ '视图是实时的,写一次 vault 图谱就重画一次,所以只有你明确要它重建时才写')
49
+ parser.add_argument('--with-vault', action='store_true',
50
+ help='把当天的内容副本同步进 Obsidian Vault(Sources/WeChat/<日期>/)。**默认不做**')
48
51
  parser.add_argument('--skip-html', action='store_true', help='跳过 HTML 生成')
49
52
  parser.add_argument('--skip-ai-report', action='store_true', help='跳过 AI 深度阅读报告')
50
53
  parser.add_argument('--no-ai', action='store_true', help='关闭所有 AI 调用,但保留抓取和本地输出')
@@ -52,7 +55,7 @@ def main():
52
55
  help='biz_daily 只做判断不做生成(不调 LLM 写摘要/标签/简报,'
53
56
  '主题与相关度仍由 Jev 判断),因此**不需要 DeepSeek key**。'
54
57
  '注意:下游步骤(行动建议/概念编译/AI 报告)仍会用 LLM,'
55
- '要全关请再加 --skip-classify --skip-wiki --skip-ai-report')
58
+ '要全关请再加 --skip-classify --skip-ai-report')
56
59
  parser.add_argument('--ai-report-range', type=int, default=1, help='AI 报告覆盖最近 N 天(默认 1=仅当天)')
57
60
  parser.add_argument('--source', action='append', default=[], metavar='NAME',
58
61
  help='仅处理指定公众号,可重复或用逗号分隔')
@@ -71,10 +74,10 @@ def main():
71
74
  #
72
75
  # **只算真的会调 LLM 的那两步**(行动建议、概念编译):`generate_ai_report.py` 虽然
73
76
  # 叫 AI 报告,但一次 LLM 都不调(它读已落盘的概率与判断),把它算进来会让
74
- # `--no-summary --skip-classify --skip-wiki` 仍然索要 DeepSeek key——那正是这条
77
+ # `--no-summary --skip-classify` 仍然索要 DeepSeek key——那正是这条
75
78
  # 路要摆脱的东西。
76
79
  api_key = args.api_key or ''
77
- downstream_ai = not (args.skip_classify and args.skip_wiki)
80
+ downstream_ai = (not args.skip_classify) or args.with_wiki
78
81
  needs_key = args.engine in ('deepseek', 'claude') and not args.no_ai and (
79
82
  not args.no_summary or downstream_ai)
80
83
  if needs_key and not api_key:
@@ -104,7 +107,7 @@ def main():
104
107
  print(' [提示] --no-summary 只关掉本步骤的 LLM 生成;下游仍会调 LLM 的步骤:'
105
108
  '%s。要全关请补上对应 --skip-*'
106
109
  % '、'.join(n for n, skipped in (('classify_daily(行动建议)', args.skip_classify),
107
- ('compile_wiki(概念编译)', args.skip_wiki))
110
+ ('compile_wiki(概念编译)', not args.with_wiki))
108
111
  if not skipped))
109
112
  if not run_step('biz_daily — 抓取+摘要', step1_args):
110
113
  sys.exit(1)
@@ -121,8 +124,8 @@ def main():
121
124
  if not run_step('classify_daily — 后处理', step2_args):
122
125
  print('[WARN] classify_daily 失败,继续后续步骤')
123
126
 
124
- # Step 3: Vault sync(可选)
125
- if not args.skip_vault:
127
+ # Step 3: Vault sync(**默认不做**,见 --help)
128
+ if args.with_vault:
126
129
  date_str = args.date or time.strftime('%Y-%m-%d')
127
130
  source_dir = os.path.join(SOURCE_ROOT, date_str)
128
131
  vault_dir = os.path.join(os.path.dirname(SCRIPTS_DIR), 'output', 'wechat-vault',
@@ -134,14 +137,22 @@ def main():
134
137
  file_count = sum(1 for _ in Path(vault_dir).rglob('*.md'))
135
138
  print(f'\n Vault 同步: {file_count} 个文件 → {vault_dir}')
136
139
 
137
- # Step 4: wiki compile(可选)
138
- if not args.skip_wiki and not args.no_ai:
140
+ # Step 4: wiki compile(**默认不做**,见 --help)
141
+ if args.with_wiki and not args.no_ai:
139
142
  step3_args = [
140
143
  os.path.join(SCRIPTS_DIR, 'compile_wiki.py'),
141
144
  '--limit', str(args.wiki_limit),
142
145
  ]
143
146
  run_step('wiki compile — 概念编译', step3_args)
144
147
 
148
+ # 默认不写 Vault:**Obsidian 自带的图谱视图是实时的** —— 它盯着 vault 里的文件,文件一变
149
+ # 就重画,所以"内容一更新图谱就重建"只在"自动改 vault"时发生。要它变,显式加那两个开关。
150
+ if not args.with_vault or not args.with_wiki:
151
+ missing = [name for name, on in (('--with-vault(当天内容副本)', args.with_vault),
152
+ ('--with-wiki(概念页)', args.with_wiki)) if not on]
153
+ print('\n Obsidian 那边没有动:%s。要写进去就显式加上 —— 图谱只在你要它重建时才变。'
154
+ % '、'.join(missing))
155
+
145
156
  # Step 5: HTML 生成(可选)
146
157
  if not args.skip_html:
147
158
  date_str = args.date or time.strftime('%Y-%m-%d')
@@ -44,6 +44,11 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
44
44
  from _utils import TOPICS, RELEVANCE_NAMES # noqa: E402
45
45
  from jev_client import create_client # noqa: E402
46
46
 
47
+ # 手跑或被重定向时 stdout 走本机编码(Windows 是 GBK):非 GBK 字符(本文件里的 `⚠️`)会
48
+ # UnicodeEncodeError、整次输出没了;桥接跑有 PYTHONIOENCODING=utf-8 所以看不出来。
49
+ # 这条让两条路的输出一致(同 docs/EXTENDING.md Recipe E,守它的测试是 test/script_stdout_encoding_test.py)。
50
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
51
+
47
52
  TZ = timezone(timedelta(hours=8))
48
53
  ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
49
54
  DAILY_ROOT = os.path.join(ROOT, 'output', 'biz-daily')
@@ -19,7 +19,8 @@ import sys, os, json
19
19
 
20
20
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
21
21
  from _utils import load_config, create_engine, get_api_key, get_dashscope_key
22
- from semantic_search import search as semantic_search, build_index, INDEX_DIR, VECTORS_FILE
22
+ from semantic_search import (search as semantic_search, build_index, INDEX_DIR, VECTORS_FILE,
23
+ LINE_CHOICES, line_label)
23
24
 
24
25
  PROMPT_TEMPLATE = """你是一个个人知识助手,可以访问用户的微信聊天记录和公众号文章。
25
26
 
@@ -36,8 +37,12 @@ PROMPT_TEMPLATE = """你是一个个人知识助手,可以访问用户的微
36
37
  请用中文简洁回答,在关键信息处引用来源编号(如 [1]、[2])。"""
37
38
 
38
39
 
39
- def format_context(results: list[dict]) -> str:
40
- """将搜索结果格式化为 RAG context。"""
40
+ def format_context(results: list[dict], line: str = 'all') -> str:
41
+ """将搜索结果格式化为 RAG context。
42
+
43
+ 没有内容时**说清是"这条线里没有"**:`(未找到相关信息)` 会被模型读成"用户的知识库里
44
+ 没有",而它其实只是"这一条线里没有"——答案的事实性就错在这里。
45
+ """
41
46
  chunks = []
42
47
  for i, item in enumerate(results, 1):
43
48
  if item.get('type') == 'article':
@@ -55,12 +60,13 @@ def format_context(results: list[dict]) -> str:
55
60
  chunks.append(
56
61
  f"[{i}] 内容: {item.get('text', str(item)[:500])}"
57
62
  )
58
- return '\n\n'.join(chunks) if chunks else '(未找到相关信息)'
63
+ return '\n\n'.join(chunks) if chunks else '(**%s**里没找到相关信息)' % line_label(line)
59
64
 
60
65
 
61
- def build_prompt(question: str, results: list[dict], history: list[dict] = None) -> str:
66
+ def build_prompt(question: str, results: list[dict], history: list[dict] = None,
67
+ line: str = 'all') -> str:
62
68
  """构建 RAG prompt。"""
63
- context = format_context(results)
69
+ context = format_context(results, line)
64
70
 
65
71
  if history and len(history) > 0:
66
72
  history_text = '\n'.join(
@@ -73,10 +79,14 @@ def build_prompt(question: str, results: list[dict], history: list[dict] = None)
73
79
 
74
80
 
75
81
  def query_rag(question: str, embed_key: str, chat_key: str, top_k: int = 10,
76
- talker: str = None, history: list[dict] = None) -> dict:
77
- """执行一次 RAG 查询。"""
82
+ talker: str = None, history: list[dict] = None, line: str = 'all') -> dict:
83
+ """执行一次 RAG 查询。
84
+
85
+ `line` 一路透到语义检索。**答案里要写清搜的是哪条线**:RAG 的坏法不是报错,是
86
+ "这条线里没有"被说成"你的知识库里没有"。函数默认 `all`,用户看到的默认在 CLI 那一层。
87
+ """
78
88
  # 1. 语义检索
79
- results = semantic_search(question, embed_key, top_k=top_k)
89
+ results = semantic_search(question, embed_key, top_k=top_k, line=line)
80
90
 
81
91
  # 2. 过滤 talker(如果指定)
82
92
  if talker:
@@ -88,7 +98,7 @@ def query_rag(question: str, embed_key: str, chat_key: str, top_k: int = 10,
88
98
  }
89
99
 
90
100
  # 3. 构建 prompt
91
- prompt = build_prompt(question, results, history)
101
+ prompt = build_prompt(question, results, history, line)
92
102
 
93
103
  # 4. 调用 AI
94
104
  config = load_config()
@@ -104,15 +114,17 @@ def query_rag(question: str, embed_key: str, chat_key: str, top_k: int = 10,
104
114
  else:
105
115
  sources.append(f"[{i}] {item.get('title', item.get('id', '?'))[:60]}")
106
116
 
107
- return {'answer': answer, 'sources': sources}
117
+ return {'answer': answer, 'sources': sources, 'line': line, 'lineLabel': line_label(line)}
108
118
 
109
119
 
110
- def interactive_mode(embed_key: str, chat_key: str):
120
+ def interactive_mode(embed_key: str, chat_key: str, line: str = 'all'):
111
121
  """交互式多轮对话。"""
112
122
  config = load_config()
113
123
  engine_type = config.get('aiEngine', 'deepseek')
114
124
 
115
125
  print(f'\n🤖 RAG 智能助手 ({engine_type})')
126
+ # **把范围写在脸上**:交互模式一开就是十几轮,用户中途改了 `--line` 是看不到的
127
+ print(f' 检索范围: {line_label(line)}(`all` 才两条线一起搜)')
116
128
  print(' 输入问题开始对话,输入 /help 查看帮助,输入 /exit 退出\n')
117
129
 
118
130
  history: list[dict] = []
@@ -143,7 +155,7 @@ def interactive_mode(embed_key: str, chat_key: str):
143
155
  print(f'✓ {result}\n')
144
156
  continue
145
157
 
146
- result = query_rag(question, embed_key, chat_key, top_k=10, history=history)
158
+ result = query_rag(question, embed_key, chat_key, top_k=10, history=history, line=line)
147
159
  history.append({'question': question, 'answer': result['answer']})
148
160
 
149
161
  print(f'\n🤖 助手:\n{result["answer"]}')
@@ -161,6 +173,8 @@ def main():
161
173
  parser.add_argument('--top-k', type=int, default=10, help='检索数量(默认 10)')
162
174
  parser.add_argument('--talker', help='限定联系人/群聊')
163
175
  parser.add_argument('--api-key', help='DeepSeek API key(优先从 config 读取)')
176
+ parser.add_argument('--line', choices=list(LINE_CHOICES), default='wiki',
177
+ help='只看一条知识库:wiki=文章线(默认)、chat=聊天线、all=两条都看')
164
178
  parser.add_argument('--json', action='store_true', help='JSON 输出')
165
179
  args = parser.parse_args()
166
180
 
@@ -179,13 +193,16 @@ def main():
179
193
  sys.exit(1)
180
194
 
181
195
  if args.interactive or not question:
182
- interactive_mode(embed_key, chat_key)
196
+ interactive_mode(embed_key, chat_key, line=args.line)
183
197
  else:
184
- result = query_rag(question, embed_key, chat_key, top_k=args.top_k, talker=talker)
198
+ result = query_rag(question, embed_key, chat_key, top_k=args.top_k, talker=talker,
199
+ line=args.line)
185
200
  if args.json:
186
201
  print(json.dumps(result, ensure_ascii=False, indent=2))
187
202
  else:
188
- print(f'\n{result["answer"]}')
203
+ # 范围写在这句话里:`--line chat` 没答上来时,读的人要能一眼看出
204
+ # 是"聊天线里没有"还是"整个知识库里没有"
205
+ print(f'\n(检索范围:{result["lineLabel"]})\n{result["answer"]}')
189
206
  if result.get('sources'):
190
207
  print(f"\n📎 来源: {' | '.join(result['sources'])}")
191
208
 
@@ -327,9 +327,20 @@ def main():
327
327
  try:
328
328
  picked = collect_conversations(conns, name_map, own_wxid, args.days, args.limit)
329
329
  if not picked:
330
- print('最近 %d 天没有活跃会话' % args.days)
330
+ # `--json` 时 stdout 必须是一整份 JSON。这条早退原来只打散文 ⇒ 机器消费者**拿不到任何
331
+ # 可解析的东西**(空目录/闲置机器上就会撞到)。形状与下面的 dry-run 预览一致,只是计数为 0。
332
+ if args.json:
333
+ print(json.dumps({'success': True, 'dryRun': args.dry_run,
334
+ 'action': 'reply-debt.scan', 'days': args.days,
335
+ 'conversations': 0, 'stateChars': 0,
336
+ 'readsLocalChat': True, 'invokesAI': True,
337
+ 'writesNothing': True}, ensure_ascii=False))
338
+ else:
339
+ print('最近 %d 天没有活跃会话' % args.days)
331
340
  return 0
332
- print('扫描 %d 个最近 %d 天有动静的会话…\n' % (len(picked), args.days))
341
+ # 进度走 stderr(同文件下面两处 `file=sys.stderr` 的写法):`--json` 时 stdout 只放结果,
342
+ # 这一行原来无条件打 stdout ⇒ `awaiting --dry-run --json | jq` 会断在第一个字符上。
343
+ print('扫描 %d 个最近 %d 天有动静的会话…\n' % (len(picked), args.days), file=sys.stderr)
333
344
 
334
345
  # 每个会话的最近若干条消息,一次读完(连接只开一次)
335
346
  transcripts = {}
@@ -389,7 +400,8 @@ def main():
389
400
  verdict, transcripts.get(item['talker'], []))
390
401
  verdict['evidence'] = proofs.get(item['talker'], {})
391
402
  rows.append(verdict)
392
- print('判断完成 %d/%d,耗时 %.1fs\n' % (len(rows), len(futures), time.time() - started))
403
+ print('判断完成 %d/%d,耗时 %.1fs\n' % (len(rows), len(futures), time.time() - started),
404
+ file=sys.stderr) # 进度:`--json` 时不许进 stdout(本机跑不到这条路径,按读码改的)
393
405
  finally:
394
406
  for conn in conns:
395
407
  conn.close()
@@ -413,6 +425,8 @@ def main():
413
425
  noise = [r for r in rows if r['kind'] == '服务']
414
426
  uncertain = [r for r in rows if 0.3 <= (r['waiting'] or 0) < args.min_prob]
415
427
 
428
+ report_path = None # 只有真的写了 HTML 才有值;json 模式下它会进输出
429
+
416
430
  if args.html:
417
431
  # 名字是外部数据,转义由 render_html 负责;这里只保证目录存在。
418
432
  target = os.path.abspath(args.html)
@@ -424,11 +438,17 @@ def main():
424
438
  'days': args.days, 'minProb': args.min_prob,
425
439
  'generatedAt': now.strftime('%Y-%m-%d %H:%M'),
426
440
  }))
427
- print('已写出:%s(不含聊天内容,可直接分享)' % target)
441
+ report_path = target
442
+ # 人在看时打这一行;机器在看时**同一件事进 JSON**(`report` 那格)—— 只把它挪到 stderr 会
443
+ # 让机器消费者再也拿不到"写到哪了",那不是修,是换一种丢。
444
+ if not args.json:
445
+ print('已写出:%s(不含聊天内容,可直接分享)' % target)
428
446
 
429
447
  if args.json:
430
- print(json.dumps({'debts': debts, 'excluded_service': len(noise),
431
- 'uncertain': uncertain}, ensure_ascii=False, indent=2))
448
+ payload = {'debts': debts, 'excluded_service': len(noise), 'uncertain': uncertain}
449
+ if report_path:
450
+ payload['report'] = report_path
451
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
432
452
  return 0
433
453
 
434
454
  print('=' * 62)
@@ -12,10 +12,13 @@
12
12
  # 搜索(有索引用向量,否则关键词 fallback)
13
13
  python scripts/semantic_search.py search "有人推荐过遥感的工具吗" --top-k 10
14
14
 
15
+ # 只看一条知识库(默认 wiki=文章线;`all` 才两条都看)
16
+ python scripts/semantic_search.py search "项目排期" --line chat
17
+
15
18
  # 增量更新(只处理新数据)
16
19
  python scripts/semantic_search.py update
17
20
 
18
- 输出: JSON 格式
21
+ 输出: JSON 格式(`search` 这一支是一个对象:`line` / `results` / `indexCounts` / 有时 `note`)
19
22
  """
20
23
 
21
24
  import sys, os, json, hashlib, re, argparse
@@ -59,7 +62,7 @@ except:
59
62
  pass
60
63
 
61
64
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
62
- from _utils import load_config, decrypt_lock, get_dashscope_key
65
+ from _utils import load_config, decrypt_lock, get_dashscope_key, LINE_LABELS # noqa: E402
63
66
  from jev_client import create_client
64
67
 
65
68
  OUTPUT_ROOT = 'output'
@@ -70,6 +73,39 @@ EMBEDDING_DIM = 1024 # 阿里云 text-embedding-v4
70
73
  BATCH_SIZE = 10 # 阿里云 text-embedding-v4 单次最多 10 条
71
74
  TZ = timezone(timedelta(hours=8))
72
75
 
76
+ # 两条知识库线在**索引里**的 `type` 值。索引是一份,这是**故意的**:两条线在同一次嵌入里
77
+ # 一起算,线靠记录上的 `type` 分(`article` / `chat`)——所以"按线检索"就是给相似度加一个
78
+ # 掩码,不需要第二份索引、也不用重新嵌入。
79
+ #
80
+ # 对外(`--line`、图谱、体检)一律说 `wiki` / `chat`,索引内部叫 `article` / `chat`,
81
+ # **翻译只在这里做一次**。散着写的话改词只会改到一半,而两条线的名字漂了不会报错。
82
+ LINE_TYPES = {'wiki': ('article',), 'chat': ('chat',)}
83
+ LINE_CHOICES = ('all', 'wiki', 'chat')
84
+
85
+
86
+ def line_types(line: str):
87
+ """线 → 索引里允许的 `type` 集合;`all` 返回 None(不过滤)。
88
+
89
+ 认不出的线**报错**,不悄悄当 all:`--line wik` 静默搜两条线的话,读的人会以为
90
+ 自己限定住了,而那正是"分开"没生效的样子。
91
+ """
92
+ if line == 'all':
93
+ return None
94
+ if line not in LINE_TYPES:
95
+ raise ValueError('未知的线:%s(可选 %s)' % (line, '、'.join(LINE_CHOICES)))
96
+ return LINE_TYPES[line]
97
+
98
+
99
+ def line_label(line: str) -> str:
100
+ return LINE_LABELS.get(line, '两条线') if line != 'all' else '两条线'
101
+
102
+
103
+ def add_line_arg(parser):
104
+ """三条线共用的 `--line`(默认**文章线**:两条线分开之后,不传就是只看文章那条,
105
+ 要合起来看显式给 `all`)。"""
106
+ parser.add_argument('--line', choices=list(LINE_CHOICES), default='wiki',
107
+ help='只看一条知识库:wiki=文章线(默认)、chat=聊天线、all=两条都看')
108
+
73
109
 
74
110
  def json_output(data):
75
111
  print(json.dumps(data, ensure_ascii=False, indent=2))
@@ -348,19 +384,25 @@ def build_index(api_key: str, full: bool = False, days: int = 90, article_days:
348
384
  }
349
385
 
350
386
 
351
- def keyword_search(query: str, top_k: int = 10, root=None):
387
+ def keyword_search(query: str, top_k: int = 10, root=None, line: str = 'all'):
352
388
  """Simple keyword fallback: scan articles + messages for keyword matches.
353
389
 
354
390
  `root` 只为可测而参数化(默认就是日报目录)。它原先硬编码成相对路径,
355
391
  于是这条路径只能靠 chdir 才能测,也就一直没被测过——而它正是检索不可用时
356
392
  真正跑着的那条(本机索引从未构建过)。
393
+
394
+ `line` 在这里**必须真的把两条路分开**:这一段的两个来源正好一人一条线
395
+ (日报文章 = 文章线、`mcp_bridge` 的聊天消息 = 聊天线)。
357
396
  """
358
397
  results = []
359
398
  keywords = query.lower().split()
399
+ types = line_types(line)
400
+ want_article = types is None or 'article' in types
401
+ want_chat = types is None or 'chat' in types
360
402
 
361
403
  # Scan biz-daily articles
362
404
  biz_dir = Path(root or 'output/biz-daily')
363
- if biz_dir.exists():
405
+ if want_article and biz_dir.exists():
364
406
  for md_file in sorted(biz_dir.rglob('*.md'), reverse=True):
365
407
  # **只收真文章。** 日报目录里还躺着 <天>/README.md、<天>/行动建议.md
366
408
  # 这类**生成产物**,它们会被当成检索结果返回(实测搜到过
@@ -387,19 +429,20 @@ def keyword_search(query: str, top_k: int = 10, root=None):
387
429
  })
388
430
 
389
431
  # Scan chat messages from mcp_bridge
390
- try:
391
- sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
392
- from mcp_bridge import search_messages as _search_msg
393
- msg_results = _search_msg(query, 50)
394
- for r in msg_results.get('results', [])[:top_k]:
395
- results.append({
396
- 'title': f"[{r.get('time','')}] {r.get('talker','')} > {r.get('sender','')}",
397
- 'source': '微信聊天',
398
- 'score': len(query),
399
- 'text': r.get('content', '')[:200],
400
- })
401
- except:
402
- pass
432
+ if want_chat:
433
+ try:
434
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
435
+ from mcp_bridge import search_messages as _search_msg
436
+ msg_results = _search_msg(query, 50)
437
+ for r in msg_results.get('results', [])[:top_k]:
438
+ results.append({
439
+ 'title': f"[{r.get('time','')}] {r.get('talker','')} > {r.get('sender','')}",
440
+ 'source': '微信聊天',
441
+ 'score': len(query),
442
+ 'text': r.get('content', '')[:200],
443
+ })
444
+ except:
445
+ pass
403
446
 
404
447
  results.sort(key=lambda x: -x['score'])
405
448
  return results[:top_k]
@@ -492,8 +535,19 @@ def rerank(query, results, client=None, pool_size=RERANK_CANDIDATES):
492
535
  return ordered
493
536
 
494
537
 
495
- def search(query: str, api_key: str, top_k: int = 10, rerank_results: bool = True):
496
- """Semantic search with keyword fallback."""
538
+ def search(query: str, api_key: str, top_k: int = 10, rerank_results: bool = True,
539
+ line: str = 'all'):
540
+ """Semantic search with keyword fallback.
541
+
542
+ `line` 在**取候选之前**就把不属于这条线的记录挡掉(把它们的相似度压成 -inf 再
543
+ argsort)。反过来的做法——先取 top-k 再过滤——看着也行,但那会把条数算错:
544
+ `--top-k 10 --line chat` 可能只回 3 条,而"为什么只有 3 条"没有任何地方会说。
545
+
546
+ **函数这一层的默认是 `all`(不缩小范围)**,用户看到的默认(文章线)在 CLI 那一层
547
+ (`add_line_arg`)。这样某个调用方忘了传 `line` 时结果是"多搜了一条线",而不是
548
+ "静默少搜一半"——后者在这个仓库里是最贵的那类 bug。
549
+ """
550
+ types = line_types(line) # 认不出的线在这里就报错,不静默当 all
497
551
  # Try embedding-based search first
498
552
  if VECTORS_FILE.exists() and META_FILE.exists():
499
553
  try:
@@ -505,11 +559,21 @@ def search(query: str, api_key: str, top_k: int = 10, rerank_results: bool = Tru
505
559
  query_vec = np.array(query_embeddings[0], dtype=np.float32)
506
560
  query_vec = query_vec / np.linalg.norm(query_vec)
507
561
  similarities = np.dot(vectors, query_vec)
562
+ if types is not None:
563
+ in_scope = np.array([str(m.get('type', '')) in types for m in meta], dtype=bool)
564
+ if not in_scope.any():
565
+ # 一条都没有:**说出来**,别让它长成"搜了但没有结果"
566
+ return []
567
+ similarities = np.where(in_scope, similarities, -np.inf)
508
568
  # 先取一个比重排池更大的候选集,重排才有可以换的空间。
509
569
  pool_size = max(top_k, RERANK_CANDIDATES) if rerank_results else top_k
570
+ if types is not None:
571
+ pool_size = min(pool_size, int(in_scope.sum()))
510
572
  top_indices = np.argsort(similarities)[::-1][:pool_size]
511
573
  results = []
512
574
  for idx in top_indices:
575
+ if not np.isfinite(similarities[idx]):
576
+ continue # 掩码挡掉的(-inf)不许混进结果
513
577
  item = meta[idx]
514
578
  results.append({**item, 'score': float(similarities[idx])})
515
579
  if rerank_results:
@@ -519,13 +583,39 @@ def search(query: str, api_key: str, top_k: int = 10, rerank_results: bool = Tru
519
583
  pass
520
584
 
521
585
  # Fallback: keyword search
522
- results = keyword_search(query, max(top_k, RERANK_CANDIDATES) if rerank_results else top_k)
586
+ results = keyword_search(query, max(top_k, RERANK_CANDIDATES) if rerank_results else top_k,
587
+ line=line)
523
588
  if rerank_results:
524
589
  # 关键词打分比余弦更粗,重排在这里的收益反而更大。
525
590
  results = rerank(query, results, create_client())
526
591
  return results[:top_k]
527
592
 
528
593
 
594
+ def line_counts() -> dict:
595
+ """索引里每条线各有多少条记录(`all` 也算一份总数)。
596
+
597
+ **只读 meta**(不解锁向量文件、不调任何服务、不花一分钱),所以"这一趟为什么是空的"
598
+ 可以在返回之前就答上来:`--line chat` 空成一片时,先得能分清是"没命中"还是
599
+ "这条线在索引里根本没有记录"——后者要重新建索引,前者不用。
600
+ """
601
+ counts = {line: 0 for line in LINE_CHOICES}
602
+ if not META_FILE.exists():
603
+ return counts
604
+ try:
605
+ meta = json.loads(META_FILE.read_text(encoding='utf-8'))
606
+ except Exception:
607
+ return counts
608
+ for item in meta:
609
+ t = str(item.get('type', ''))
610
+ for line, types in LINE_TYPES.items():
611
+ if t in types:
612
+ counts[line] += 1
613
+ # `all` 是**索引里的总条数**,不是两条线相加:以后要是多出第三种 type,
614
+ # 它会被 `all` 带上、而不属于任何一条线,两条线相加就少算了。
615
+ counts['all'] = len(meta)
616
+ return counts
617
+
618
+
529
619
  # ====== Main ======
530
620
 
531
621
  def add_window_args(parser):
@@ -563,6 +653,7 @@ def main():
563
653
  p.add_argument('--top-k', type=int, default=10)
564
654
  p.add_argument('--no-rerank', action='store_true',
565
655
  help='不调用决策模型重排,只按向量/关键词相似度返回(回退到引入重排之前)')
656
+ add_line_arg(p)
566
657
 
567
658
  args = parser.parse_args()
568
659
  # 手动跑时的第二道闸门(CLI 那条路已经按 1-36500 校验过):0 天等于建一个空库,
@@ -582,8 +673,25 @@ def main():
582
673
  if not query:
583
674
  result = {"error": "missing search query"}
584
675
  else:
585
- result = search(query, api_key, top_k=args.top_k,
586
- rerank_results=not args.no_rerank)
676
+ # **输出带上是哪条线**(`--line` 默认文章线)。少了这一层,`--line chat`
677
+ # 空成一片时读的人分不清"没命中"和"这条线根本没进索引"——而两者的下一步
678
+ # (换个词 / 重建索引)是两回事。
679
+ rows = search(query, api_key, top_k=args.top_k,
680
+ rerank_results=not args.no_rerank, line=args.line)
681
+ counts = line_counts()
682
+ result = {
683
+ 'success': True,
684
+ 'line': args.line,
685
+ 'lineLabel': line_label(args.line),
686
+ 'results': rows,
687
+ 'indexCounts': counts,
688
+ }
689
+ if not rows and counts['all'] == 0:
690
+ result['note'] = ('语义索引还没建(output/.semantic_index 不存在)——'
691
+ '先跑 weflow-cli search-index')
692
+ elif not rows and args.line != 'all' and counts[args.line] == 0:
693
+ result['note'] = ('索引里一条「%s」的记录都没有(建索引那天的窗口可能没覆盖到),'
694
+ '先看 --line all' % line_label(args.line))
587
695
  else:
588
696
  result = {"error": f"未知命令: {args.command}"}
589
697