weflow-cli 1.9.0 → 1.11.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 (134) hide show
  1. package/ARCHITECTURE.md +62 -26
  2. package/CHANGELOG.md +1051 -0
  3. package/OPERATIONS.md +99 -13
  4. package/README.en.md +14 -5
  5. package/README.md +23 -8
  6. package/bin/weflow-cli.ts +364 -40
  7. package/dist/bin/weflow-cli.js +381 -69
  8. package/dist/bin/weflow-cli.js.map +1 -1
  9. package/dist/mcp-server/index.d.ts.map +1 -1
  10. package/dist/mcp-server/index.js +19 -1
  11. package/dist/mcp-server/index.js.map +1 -1
  12. package/dist/src/core/dbPathService.d.ts +12 -0
  13. package/dist/src/core/dbPathService.d.ts.map +1 -1
  14. package/dist/src/core/dbPathService.js +20 -3
  15. package/dist/src/core/dbPathService.js.map +1 -1
  16. package/dist/src/panel/server.d.ts.map +1 -1
  17. package/dist/src/panel/server.js +35 -0
  18. package/dist/src/panel/server.js.map +1 -1
  19. package/dist/src/services/assistantService.d.ts +19 -0
  20. package/dist/src/services/assistantService.d.ts.map +1 -1
  21. package/dist/src/services/assistantService.js +20 -0
  22. package/dist/src/services/assistantService.js.map +1 -1
  23. package/dist/src/services/assistantTools.d.ts +27 -1
  24. package/dist/src/services/assistantTools.d.ts.map +1 -1
  25. package/dist/src/services/assistantTools.js +169 -35
  26. package/dist/src/services/assistantTools.js.map +1 -1
  27. package/dist/src/services/configService.d.ts +2 -0
  28. package/dist/src/services/configService.d.ts.map +1 -1
  29. package/dist/src/services/configService.js +3 -2
  30. package/dist/src/services/configService.js.map +1 -1
  31. package/dist/src/services/exportService.d.ts +43 -0
  32. package/dist/src/services/exportService.d.ts.map +1 -1
  33. package/dist/src/services/exportService.js +116 -11
  34. package/dist/src/services/exportService.js.map +1 -1
  35. package/dist/src/services/messageQuery.d.ts +18 -0
  36. package/dist/src/services/messageQuery.d.ts.map +1 -1
  37. package/dist/src/services/messageQuery.js +29 -0
  38. package/dist/src/services/messageQuery.js.map +1 -1
  39. package/dist/src/services/wechatClient.d.ts +125 -0
  40. package/dist/src/services/wechatClient.d.ts.map +1 -0
  41. package/dist/src/services/wechatClient.js +344 -0
  42. package/dist/src/services/wechatClient.js.map +1 -0
  43. package/dist/src/services/wechatMedia.d.ts +44 -0
  44. package/dist/src/services/wechatMedia.d.ts.map +1 -0
  45. package/dist/src/services/wechatMedia.js +155 -0
  46. package/dist/src/services/wechatMedia.js.map +1 -0
  47. package/dist/src/services/wechatMessageService.d.ts +11 -0
  48. package/dist/src/services/wechatMessageService.d.ts.map +1 -1
  49. package/dist/src/services/wechatMessageService.js +55 -11
  50. package/dist/src/services/wechatMessageService.js.map +1 -1
  51. package/dist/src/types.d.ts +4 -0
  52. package/dist/src/types.d.ts.map +1 -1
  53. package/docs/CONTACT_DB_SCHEMA.md +1085 -0
  54. package/docs/DECISIONS.md +1274 -0
  55. package/docs/EXTENDING.md +69 -7
  56. package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +27 -0
  57. package/docs/HEALTH-CHECK.md +12 -7
  58. package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +2 -2
  59. package/docs/MCP.md +2 -2
  60. package/docs/PROJECT_STATE.md +64 -5
  61. package/docs/RELEASING.md +128 -1
  62. package/docs/images/weflow-architecture-gpt-image-2.png +0 -0
  63. package/logo.png +0 -0
  64. package/mcp-server/index.ts +18 -3
  65. package/package.json +5 -3
  66. package/resources/js/graph3d/NOTICE.txt +35 -0
  67. package/resources/js/graph3d/d3-binarytree.min.js +2 -0
  68. package/resources/js/graph3d/d3-dispatch.min.js +2 -0
  69. package/resources/js/graph3d/d3-force-3d.min.js +2 -0
  70. package/resources/js/graph3d/d3-octree.min.js +2 -0
  71. package/resources/js/graph3d/d3-quadtree.min.js +2 -0
  72. package/resources/js/graph3d/d3-timer.min.js +2 -0
  73. package/resources/js/graph3d/layout.mjs +63 -0
  74. package/resources/js/graph3d/three.min.js +6 -0
  75. package/resources/js/graph3d/viewer2d.js +448 -0
  76. package/resources/panel/index.html +12 -0
  77. package/resources/panel/mascot-blink-1.png +0 -0
  78. package/resources/panel/mascot-blink-2.png +0 -0
  79. package/resources/panel/mascot-lift-down-1.png +0 -0
  80. package/resources/panel/mascot-lift-down-2.png +0 -0
  81. package/resources/panel/mascot-lift-down-3.png +0 -0
  82. package/resources/panel/mascot-lift-down-4.png +0 -0
  83. package/resources/panel/mascot-lift-down-5.png +0 -0
  84. package/resources/panel/mascot-lift-down-6.png +0 -0
  85. package/resources/panel/mascot-lift-up-1.png +0 -0
  86. package/resources/panel/mascot-lift-up-2.png +0 -0
  87. package/resources/panel/mascot-lift-up-3.png +0 -0
  88. package/resources/panel/mascot-lift-up-4.png +0 -0
  89. package/resources/panel/mascot-lift-up-5.png +0 -0
  90. package/resources/panel/mascot-lift-up-6.png +0 -0
  91. package/resources/panel/mascot-tickle-1.png +0 -0
  92. package/resources/panel/mascot-tickle-2.png +0 -0
  93. package/resources/panel/mascot-tickle-3.png +0 -0
  94. package/resources/panel/mascot-tickle-4.png +0 -0
  95. package/resources/panel/panel.css +111 -0
  96. package/resources/panel/renderer.js +480 -8
  97. package/scripts/_utils.py +1203 -977
  98. package/scripts/biz_daily.py +25 -10
  99. package/scripts/chat_notes.py +15 -5
  100. package/scripts/check_readme_commands.py +426 -0
  101. package/scripts/compile_wiki.py +163 -57
  102. package/scripts/contact_schema.py +339 -0
  103. package/scripts/data/wechat_emoji.json +225 -0
  104. package/scripts/export_chat_html.py +346 -6
  105. package/scripts/fav_notes.py +4 -1
  106. package/scripts/graph_2d.py +151 -0
  107. package/scripts/graph_3d.py +646 -0
  108. package/scripts/health_check.py +163 -26
  109. package/scripts/nt_decrypt.py +3 -3
  110. package/scripts/panel_frames.py +683 -0
  111. package/scripts/pipeline.py +21 -10
  112. package/scripts/quality_eval.py +5 -0
  113. package/scripts/rag_chat.py +33 -16
  114. package/scripts/reply_debt.py +26 -6
  115. package/scripts/semantic_search.py +212 -58
  116. package/scripts/vault_rag.py +42 -16
  117. package/scripts/vault_search.py +44 -8
  118. package/scripts/wechat_emoticon.py +6 -0
  119. package/scripts/wiki_lint.py +94 -17
  120. package/src/core/dbPathService.ts +22 -4
  121. package/src/panel/server.ts +33 -0
  122. package/src/services/assistantService.ts +26 -0
  123. package/src/services/assistantTools.ts +2452 -2319
  124. package/src/services/configService.ts +5 -2
  125. package/src/services/exportService.ts +129 -12
  126. package/src/services/messageQuery.ts +29 -0
  127. package/src/services/wechatClient.ts +380 -0
  128. package/src/services/wechatMedia.ts +166 -0
  129. package/src/services/wechatMessageService.ts +62 -11
  130. package/src/types.ts +4 -0
  131. package/dist/src/utils/wechatEmoji.d.ts +0 -5
  132. package/dist/src/utils/wechatEmoji.d.ts.map +0 -1
  133. package/dist/src/utils/wechatEmoji.js +0 -35
  134. 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)