weflow-cli 1.13.1 → 1.14.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.
- package/CHANGELOG.md +22 -0
- package/bin/weflow-cli.ts +25 -2
- package/dist/bin/weflow-cli.js +28 -2
- package/dist/bin/weflow-cli.js.map +1 -1
- package/dist/src/services/configService.d.ts +3 -0
- package/dist/src/services/configService.d.ts.map +1 -1
- package/dist/src/services/configService.js +3 -2
- package/dist/src/services/configService.js.map +1 -1
- package/docs/DECISIONS.md +30 -0
- package/package.json +1 -1
- package/scripts/_utils.py +21 -2
- package/scripts/daily_mail.py +467 -0
- package/scripts/daily_task.ps1 +20 -0
- package/scripts/jev_client.py +136 -2
- package/src/services/configService.ts +8 -3
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
"""把当天日报按主题整理成一封邮件(AI / 文学 / 学术),SMTP 发出。
|
|
2
|
+
|
|
3
|
+
为什么不发 HTML 阅读器:`output/biz-daily/<日期>/index.html` 的图片是**本地相对路径**,
|
|
4
|
+
发到邮箱里会全部裂掉;内联 base64 又会让邮件变成好几 MB。所以正文走「文本 + 原文链接」,
|
|
5
|
+
要带图的完整版仍在电脑上开阅读器。
|
|
6
|
+
|
|
7
|
+
"每篇都由 AI 读过"不是这里做的:逐篇摘要(`【核心观点】/【关键细节】`)是 `biz_daily.py`
|
|
8
|
+
在抓取阶段用决策模型生成的,本脚本只负责**按主题聚合 + 写每个主题的综述**(每个主题一次调用)。
|
|
9
|
+
|
|
10
|
+
用法:
|
|
11
|
+
python scripts/daily_mail.py --date 2026-10-18
|
|
12
|
+
python scripts/daily_mail.py --date 2026-10-18 --to someone@example.com --dry-run
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import html
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import pathlib
|
|
21
|
+
import re
|
|
22
|
+
import smtplib
|
|
23
|
+
import sys
|
|
24
|
+
from email.header import Header
|
|
25
|
+
from email.mime.multipart import MIMEMultipart
|
|
26
|
+
from email.mime.text import MIMEText
|
|
27
|
+
from email.utils import formataddr, formatdate
|
|
28
|
+
|
|
29
|
+
SCRIPT_DIR = pathlib.Path(__file__).resolve().parent
|
|
30
|
+
sys.path.insert(0, str(SCRIPT_DIR))
|
|
31
|
+
|
|
32
|
+
from _utils import get_api_key, load_config # noqa: E402
|
|
33
|
+
|
|
34
|
+
ROOT = SCRIPT_DIR.parent
|
|
35
|
+
DEFAULT_TOPICS = ('AI', '学术')
|
|
36
|
+
SMTP_CONFIG = pathlib.Path(os.path.expanduser('~/.weflow-issue-watch.json'))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def articles_path(date: str) -> pathlib.Path:
|
|
40
|
+
return ROOT / 'output' / 'biz-daily' / date / '.articles.json'
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def load_articles(date: str, topics):
|
|
44
|
+
path = articles_path(date)
|
|
45
|
+
if not path.is_file():
|
|
46
|
+
raise SystemExit(f'没有这一天的数据:{path}')
|
|
47
|
+
data = json.loads(path.read_text(encoding='utf-8'))
|
|
48
|
+
arts = [a for a in (data.get('articles') or []) if a.get('topic') in topics and a.get('title')]
|
|
49
|
+
return data, arts
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def group_by_topic(articles, topics):
|
|
53
|
+
groups = {t: [] for t in topics}
|
|
54
|
+
for a in articles:
|
|
55
|
+
groups.setdefault(a.get('topic'), []).append(a)
|
|
56
|
+
for t in groups:
|
|
57
|
+
groups[t].sort(key=lambda a: (a.get('time') or '', a.get('relevanceScore') or 0), reverse=True)
|
|
58
|
+
return groups
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
GITHUB_RE = re.compile(r'https?://github\.com/[\w\-./]+', re.I)
|
|
62
|
+
#: 主题内分类的小圆点配色。同一条新闻流里给不同聚类换色,读起来不容易串行。
|
|
63
|
+
PALETTE = ('#6366f1', '#8b5cf6', '#0ea5e9', '#f59e0b', '#ec4899', '#14b8a6')
|
|
64
|
+
_TAG_RE = re.compile(r'【[^】]{1,12}】')
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def clean_summary(summary: str, limit=170) -> str:
|
|
68
|
+
"""把 `【核心观点】…【关键细节】1. …;2. …` 压成**一句人话**。
|
|
69
|
+
|
|
70
|
+
邮件里那串标签是给机器分段的,对读者只是噪音:谁在手机上读日报,都不会去分辨
|
|
71
|
+
「核心观点」和「关键细节」的区别。所以只取【核心观点】那一段,标签一律去掉;
|
|
72
|
+
没有标签的老格式就整段压平。
|
|
73
|
+
"""
|
|
74
|
+
text = summary or ''
|
|
75
|
+
m = re.search(r'【核心观点】(.+?)(?=【|$)', text, re.S)
|
|
76
|
+
if m:
|
|
77
|
+
text = m.group(1)
|
|
78
|
+
text = _TAG_RE.sub(' ', text)
|
|
79
|
+
text = ' '.join(text.split())
|
|
80
|
+
if len(text) <= limit:
|
|
81
|
+
return text
|
|
82
|
+
return text[:limit].rstrip(',;、,. ') + '…'
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def body_index(date: str) -> dict:
|
|
86
|
+
"""url -> 那篇文章的 md 全文(用来从正文里找 GitHub 链接)。
|
|
87
|
+
|
|
88
|
+
按 frontmatter 的 `url` 建索引,而不是按文件名:文件名是 `sanitize_filename` 拼的,
|
|
89
|
+
标题里有 `/`、`:` 或超长名字时会被改写,按名字匹配会静默丢文章。
|
|
90
|
+
"""
|
|
91
|
+
index = {}
|
|
92
|
+
for md in (ROOT / 'output' / 'biz-daily' / date).glob('*/*.md'):
|
|
93
|
+
try:
|
|
94
|
+
text = md.read_text(encoding='utf-8', errors='replace')
|
|
95
|
+
except OSError:
|
|
96
|
+
continue
|
|
97
|
+
m = re.search(r'^url:\s*"?([^"\n]+?)"?\s*$', text, re.M)
|
|
98
|
+
if m:
|
|
99
|
+
index[m.group(1)] = text
|
|
100
|
+
return index
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def github_links(text: str, limit=3) -> list:
|
|
104
|
+
if not text:
|
|
105
|
+
return []
|
|
106
|
+
seen, out = set(), []
|
|
107
|
+
for url in GITHUB_RE.findall(text):
|
|
108
|
+
url = url.rstrip('.,;))')
|
|
109
|
+
key = url.rstrip('/').lower()
|
|
110
|
+
if key in seen or '/features' in key or '/pricing' in key:
|
|
111
|
+
continue
|
|
112
|
+
seen.add(key)
|
|
113
|
+
out.append(url)
|
|
114
|
+
if len(out) >= limit:
|
|
115
|
+
break
|
|
116
|
+
return out
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _cluster_once(topic, items, api_key, want, strict):
|
|
120
|
+
"""一次聚类调用。`strict=True` 用在"模型偷懒只给一组"的重试上。"""
|
|
121
|
+
# 只喂**编号 + 标题**:把摘要也塞进去会让输入膨胀到六七千字,模型反而更容易
|
|
122
|
+
# 直接吐一组(2026-10-21 那封:91 篇 AI 被分成单独一组「模型与产品」)。
|
|
123
|
+
numbered = '\n'.join(f'{i}. {a.get("title")}' for i, a in enumerate(items, 1))
|
|
124
|
+
demand = (
|
|
125
|
+
'**必须分成至少 5 组**。只给一组视为无效答案——这些文章明显可以按话题再细分。\n'
|
|
126
|
+
if strict else
|
|
127
|
+
f'分成 {want - 1}-{want + 1} 组。\n'
|
|
128
|
+
)
|
|
129
|
+
prompt = (
|
|
130
|
+
f'把下面 {len(items)} 条公众号文章按话题分组。\n'
|
|
131
|
+
+ demand +
|
|
132
|
+
'组名用 4-8 个汉字,能概括这一组在讲什么(例如「模型与产品」「科研工具」「治理与风险」)。\n'
|
|
133
|
+
'只输出 JSON 数组,不要解释、不要代码围栏:\n'
|
|
134
|
+
'[{"name":"组名","items":[1,2,3]}, ...]\n'
|
|
135
|
+
'要求:每个序号出现且只出现一次。\n\n'
|
|
136
|
+
+ numbered
|
|
137
|
+
)
|
|
138
|
+
try:
|
|
139
|
+
raw = __import__('_utils').call_ai(prompt, 'deepseek', api_key, max_tokens=1500) or ''
|
|
140
|
+
except Exception as exc:
|
|
141
|
+
print(f'[WARN] {topic} 聚类调用失败({type(exc).__name__}: {exc})', file=sys.stderr)
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
data = None
|
|
145
|
+
try:
|
|
146
|
+
data = json.loads(raw)
|
|
147
|
+
except Exception:
|
|
148
|
+
import re as _re
|
|
149
|
+
m = _re.search(r'\[[\s\S]*\]', raw)
|
|
150
|
+
if m:
|
|
151
|
+
try:
|
|
152
|
+
data = json.loads(m.group(0))
|
|
153
|
+
except Exception:
|
|
154
|
+
data = None
|
|
155
|
+
if not isinstance(data, list) or not data:
|
|
156
|
+
print(f'[WARN] {topic} 聚类返回无法解析', file=sys.stderr)
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
seen, clusters = set(), []
|
|
160
|
+
for group in data:
|
|
161
|
+
if not isinstance(group, dict):
|
|
162
|
+
continue
|
|
163
|
+
name = str(group.get('name') or '').strip()[:12] or '其他'
|
|
164
|
+
picked = []
|
|
165
|
+
for idx in group.get('items') or []:
|
|
166
|
+
try:
|
|
167
|
+
i = int(idx)
|
|
168
|
+
except Exception:
|
|
169
|
+
continue
|
|
170
|
+
if 1 <= i <= len(items) and i not in seen:
|
|
171
|
+
seen.add(i)
|
|
172
|
+
picked.append(items[i - 1])
|
|
173
|
+
if picked:
|
|
174
|
+
clusters.append((name, picked))
|
|
175
|
+
leftover = [a for i, a in enumerate(items, 1) if i not in seen]
|
|
176
|
+
if leftover:
|
|
177
|
+
if clusters and len(leftover) <= max(3, len(items) // 4):
|
|
178
|
+
clusters[-1][1].extend(leftover)
|
|
179
|
+
else:
|
|
180
|
+
clusters.append(('其他', leftover))
|
|
181
|
+
return clusters or None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def cluster_items(topic: str, items, api_key: str, want=5):
|
|
185
|
+
"""把同一主题下的文章再分一次类(让几十篇的段落不再是一根长肠子)。
|
|
186
|
+
|
|
187
|
+
为什么不用现成的 `tags`:实测 68 篇 AI 文章里有 **382 个不同标签**,频次最高的才 6 次——
|
|
188
|
+
按标签聚类会碎成一地。所以交给模型分一次。
|
|
189
|
+
|
|
190
|
+
**退化要重试**:2026-10-21 那天模型把 91 篇 AI 全塞进一组「模型与产品」,
|
|
191
|
+
版式当场退回上线前的样子(一根长肠子),而且完全不报错。现在"只给一组但篇数很多"
|
|
192
|
+
会被当成失败、用更硬的指令再要一次。
|
|
193
|
+
|
|
194
|
+
返回 `[(类名, [文章...])]`;任何一步失败都退回"一组=全部",**绝不因此不发信**。
|
|
195
|
+
"""
|
|
196
|
+
if len(items) < 8 or not api_key:
|
|
197
|
+
return [(topic, list(items))]
|
|
198
|
+
|
|
199
|
+
result = None
|
|
200
|
+
for strict in (False, True):
|
|
201
|
+
result = _cluster_once(topic, items, api_key, want, strict)
|
|
202
|
+
degenerate = result and len(result) == 1 and len(items) > 20
|
|
203
|
+
if result and not degenerate:
|
|
204
|
+
return result
|
|
205
|
+
if degenerate:
|
|
206
|
+
print(f'[WARN] {topic} 聚类退化成 1 组({len(items)} 篇),{"再要求一次" if not strict else "仍失败"}',
|
|
207
|
+
file=sys.stderr)
|
|
208
|
+
return result or [(topic, list(items))]
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def topic_digest(topic: str, items, api_key: str) -> str:
|
|
212
|
+
"""每个主题一次调用:让它读完全部标题+摘要,写一段综述。失败就返回空串(不影响发信)。"""
|
|
213
|
+
if not api_key:
|
|
214
|
+
return ''
|
|
215
|
+
lines = [f'- {a.get("title")}|{a.get("source")}|{clean_summary(a.get("summary"), 90)}' for a in items]
|
|
216
|
+
prompt = (
|
|
217
|
+
f'下面是今天「{topic}」主题下 {len(items)} 篇公众号文章的标题与要点。\n'
|
|
218
|
+
'请用中文写一段 150-250 字的综述:今天这个主题主要在讨论什么、有哪些值得注意的进展或分歧、'
|
|
219
|
+
'其中哪一两篇值得优先精读(给出标题)。不要罗列全部篇目,不要空话,不要用列表。\n\n'
|
|
220
|
+
+ '\n'.join(lines)
|
|
221
|
+
)
|
|
222
|
+
try:
|
|
223
|
+
return api_key and __import__('_utils').call_ai(prompt, 'deepseek', api_key, max_tokens=600) or ''
|
|
224
|
+
except Exception as exc:
|
|
225
|
+
print(f'[WARN] {topic} 综述生成失败({type(exc).__name__}: {exc}),邮件里跳过这一段', file=sys.stderr)
|
|
226
|
+
return ''
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def render(date: str, groups, digests, bodies=None, clusters=None) -> tuple[str, str, int]:
|
|
230
|
+
bodies = bodies or {}
|
|
231
|
+
clusters = clusters or {}
|
|
232
|
+
total = sum(len(v) for v in groups.values())
|
|
233
|
+
# 主题配色:AI 靛蓝、学术 青绿。用 table + inline style —— 邮件客户端会剥离
|
|
234
|
+
# `<style>` 与 class,class 选择器在 QQ / Gmail / 企业微信里表现不一致。
|
|
235
|
+
accents = {'AI': ('#4f46e5', '#eef2ff'), '学术': ('#0d9488', '#ecfdf5')}
|
|
236
|
+
|
|
237
|
+
text_lines = [f'公众号日报 {date}', f'共 {total} 篇', '']
|
|
238
|
+
blocks: list[str] = []
|
|
239
|
+
|
|
240
|
+
for topic, items in groups.items():
|
|
241
|
+
if not items:
|
|
242
|
+
continue
|
|
243
|
+
accent, tint = accents.get(topic, ('#334155', '#f1f5f9'))
|
|
244
|
+
text_lines.append(f'=== {topic}({len(items)} 篇)===')
|
|
245
|
+
digest = (digests or {}).get(topic) or ''
|
|
246
|
+
if digest:
|
|
247
|
+
text_lines.append(digest)
|
|
248
|
+
text_lines.append('')
|
|
249
|
+
|
|
250
|
+
# 主题分区:一条通栏色条 + 主题名,比一个小圆角标签更能"切开"版面。
|
|
251
|
+
# 档位名取自 `_utils.RELEVANCE_NAMES`:写死 `('高','中','低')` 会与判据层漂移,
|
|
252
|
+
# 而 `topic_taxonomy_test` 就是专门拦这件事的(它当场红过一次)。
|
|
253
|
+
from _utils import RELEVANCE_NAMES as _REL
|
|
254
|
+
hi, mid, lo = (sum(1 for a in items if a.get('relevance') == k) for k in (_REL[2], _REL[1], _REL[0]))
|
|
255
|
+
def pct(n):
|
|
256
|
+
return max(1, round(n * 100 / max(1, len(items))))
|
|
257
|
+
blocks.append(
|
|
258
|
+
'<tr><td style="padding:30px 28px 0;">'
|
|
259
|
+
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr>'
|
|
260
|
+
f'<td width="4" style="background:{accent};border-radius:2px;"></td>'
|
|
261
|
+
'<td style="padding-left:10px;">'
|
|
262
|
+
f'<span style="font-size:18px;font-weight:700;color:#111827;">{html.escape(topic)}</span>'
|
|
263
|
+
f'<span style="font-size:12px;color:#9aa3af;margin-left:8px;">{len(items)} 篇</span>'
|
|
264
|
+
'</td></tr></table>'
|
|
265
|
+
# 相关度比例条:不用看数字就知道今天"值得读的占多少"
|
|
266
|
+
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin-top:10px;">'
|
|
267
|
+
'<tr>'
|
|
268
|
+
f'<td width="{pct(hi)}%" height="7" style="background:{accent};border-radius:4px 0 0 4px;"></td>'
|
|
269
|
+
f'<td width="{pct(mid)}%" height="7" style="background:{tint};"></td>'
|
|
270
|
+
f'<td width="{pct(lo)}%" height="7" style="background:#eef1f4;border-radius:0 4px 4px 0;"></td>'
|
|
271
|
+
'</tr></table>'
|
|
272
|
+
f'<div style="font-size:11.5px;color:#9aa3af;margin-top:6px;">'
|
|
273
|
+
f'<span style="color:{accent};font-weight:600;">高 {hi}</span> · 中 {mid} · 低 {lo}</div>'
|
|
274
|
+
'</td></tr>'
|
|
275
|
+
)
|
|
276
|
+
if digest:
|
|
277
|
+
blocks.append(
|
|
278
|
+
'<tr><td style="padding:14px 28px 0;">'
|
|
279
|
+
'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" '
|
|
280
|
+
f'style="background:#f8fafc;border-left:3px solid {accent};border-radius:8px;">'
|
|
281
|
+
'<tr><td style="padding:14px 16px;font-size:14px;line-height:1.8;color:#3f4652;">'
|
|
282
|
+
f'{html.escape(digest)}</td></tr></table></td></tr>'
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
# 主题内再分一次类:68 篇挤成一排没人看,分成 4-6 组后每一段都有名字。
|
|
286
|
+
section_clusters = (clusters or {}).get(topic) or [(topic, list(items))]
|
|
287
|
+
for ci, (cname, citems) in enumerate(section_clusters):
|
|
288
|
+
dot = PALETTE[ci % len(PALETTE)]
|
|
289
|
+
if len(section_clusters) > 1:
|
|
290
|
+
blocks.append(
|
|
291
|
+
'<tr><td style="padding:22px 28px 0;">'
|
|
292
|
+
f'<span style="display:inline-block;width:7px;height:7px;border-radius:50%;'
|
|
293
|
+
f'background:{dot};vertical-align:middle;"></span>'
|
|
294
|
+
f'<span style="font-size:13.5px;font-weight:700;color:#374151;margin-left:7px;">'
|
|
295
|
+
f'{html.escape(cname)}</span>'
|
|
296
|
+
f'<span style="font-size:11.5px;color:#b3bac4;margin-left:6px;">{len(citems)}</span>'
|
|
297
|
+
'</td></tr>'
|
|
298
|
+
)
|
|
299
|
+
text_lines.append(f'-- {cname}({len(citems)})--')
|
|
300
|
+
|
|
301
|
+
for a in citems:
|
|
302
|
+
i = items.index(a) + 1
|
|
303
|
+
title = a.get('title') or ''
|
|
304
|
+
source = a.get('source') or ''
|
|
305
|
+
when = (a.get('time') or '')[:5]
|
|
306
|
+
rel = a.get('relevance') or ''
|
|
307
|
+
url = a.get('url') or ''
|
|
308
|
+
repos = github_links(bodies.get(url, ''))
|
|
309
|
+
meta = ' · '.join(x for x in (source, when, rel) if x)
|
|
310
|
+
num = f'{i:02d}'
|
|
311
|
+
title_html = (f'<a href="{html.escape(url)}" style="color:#111827;text-decoration:none;">'
|
|
312
|
+
f'{html.escape(title)}</a>' if url else html.escape(title))
|
|
313
|
+
|
|
314
|
+
# **颜色必须自己说明自己。** 上一版把序号徽章染成主题色来表示"高相关",
|
|
315
|
+
# 但没有任何图例——用户看到截图的第一反应是"这边为什么有的是绿的"(2026-10-20)。
|
|
316
|
+
# 现在徽章统一成灰色,相关度改成 meta 行里一个**写着"高"的小色块**:
|
|
317
|
+
# 颜色解释自己,不需要图例。
|
|
318
|
+
if rel == _REL[2]:
|
|
319
|
+
summary = clean_summary(a.get('summary'), 190)
|
|
320
|
+
text_lines.append(f'{num}. ★ {title} — {meta}')
|
|
321
|
+
elif rel == _REL[1]:
|
|
322
|
+
summary = clean_summary(a.get('summary'), 120)
|
|
323
|
+
text_lines.append(f'{num}. {title} — {meta}')
|
|
324
|
+
else:
|
|
325
|
+
summary = ''
|
|
326
|
+
text_lines.append(f'{num}. {title} — {meta}')
|
|
327
|
+
if rel == _REL[2]:
|
|
328
|
+
rel_html = (f'<span style="display:inline-block;background:{accent};color:#ffffff;'
|
|
329
|
+
f'font-size:10.5px;font-weight:600;padding:1px 6px;border-radius:4px;">'
|
|
330
|
+
f'{html.escape(rel)}</span>')
|
|
331
|
+
else:
|
|
332
|
+
rel_html = f'<span style="color:#b3bac4;">{html.escape(rel)}</span>' if rel else ''
|
|
333
|
+
meta_html = ' · '.join(x for x in (
|
|
334
|
+
html.escape(source), html.escape(when), rel_html) if x)
|
|
335
|
+
if summary:
|
|
336
|
+
text_lines.append(f' {summary}')
|
|
337
|
+
for repo in repos:
|
|
338
|
+
text_lines.append(f' GitHub: {repo}')
|
|
339
|
+
if url:
|
|
340
|
+
text_lines.append(f' {url}')
|
|
341
|
+
blocks.append(
|
|
342
|
+
'<tr><td style="padding:10px 28px 0;">'
|
|
343
|
+
'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" '
|
|
344
|
+
'style="background:#ffffff;border:1px solid #eef0f3;border-radius:12px;">'
|
|
345
|
+
'<tr><td style="padding:14px 16px;">'
|
|
346
|
+
'<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr>'
|
|
347
|
+
f'<td width="28" valign="top"><span style="display:inline-block;width:24px;height:24px;'
|
|
348
|
+
'line-height:24px;text-align:center;border-radius:7px;background:#f1f5f9;'
|
|
349
|
+
f'color:#94a3b8;font-size:11.5px;font-weight:700;">{num}</span></td>'
|
|
350
|
+
'<td valign="top">'
|
|
351
|
+
f'<div style="font-size:14.5px;font-weight:600;color:#111827;line-height:1.55;">{title_html}</div>'
|
|
352
|
+
f'<div style="font-size:11.5px;color:#9aa3af;margin-top:4px;">{meta_html}</div>'
|
|
353
|
+
+ (f'<div style="font-size:13.5px;line-height:1.75;color:#4b5563;margin-top:8px;">'
|
|
354
|
+
f'{html.escape(summary)}</div>' if summary else '')
|
|
355
|
+
+ ''.join(
|
|
356
|
+
f'<div style="margin-top:8px;font-size:12.5px;">'
|
|
357
|
+
f'<a href="{html.escape(repo)}" style="color:{accent};text-decoration:none;">'
|
|
358
|
+
f'● {html.escape(repo.replace("https://github.com/", "").rstrip("/"))}</a></div>'
|
|
359
|
+
for repo in repos
|
|
360
|
+
)
|
|
361
|
+
+ '</td></tr></table></td></tr></table></td></tr>'
|
|
362
|
+
)
|
|
363
|
+
text_lines.append('')
|
|
364
|
+
|
|
365
|
+
text_lines.append('完整版(含配图)在电脑上打开:')
|
|
366
|
+
text_lines.append(f' weflow-cli daily-server --date {date} --open')
|
|
367
|
+
|
|
368
|
+
stats = ' · '.join(f'{t} {len(v)} 篇' for t, v in groups.items() if v)
|
|
369
|
+
page = (
|
|
370
|
+
'<!doctype html><html><head><meta charset="utf-8">'
|
|
371
|
+
'<meta name="viewport" content="width=device-width,initial-scale=1">'
|
|
372
|
+
'<title>' + html.escape(f'公众号日报 {date}') + '</title></head>'
|
|
373
|
+
'<body style="margin:0;padding:0;background:#f4f5f7;">'
|
|
374
|
+
'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" '
|
|
375
|
+
'style="background:#f4f5f7;"><tr><td align="center" style="padding:24px 12px;">'
|
|
376
|
+
'<table role="presentation" width="640" cellpadding="0" cellspacing="0" '
|
|
377
|
+
'style="width:100%;max-width:640px;background:#ffffff;border-radius:14px;'
|
|
378
|
+
'overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.06);">'
|
|
379
|
+
'<tr><td style="background:#111827;padding:26px 28px;">'
|
|
380
|
+
'<div style="font-size:12px;letter-spacing:.12em;color:#9ca3af;font-weight:600;">'
|
|
381
|
+
'WEFLOW · 公众号日报</div>'
|
|
382
|
+
f'<div style="font-size:26px;font-weight:700;color:#ffffff;margin-top:8px;">{html.escape(date)}</div>'
|
|
383
|
+
f'<div style="font-size:13px;color:#c7cbd1;margin-top:8px;">{html.escape(stats)}</div>'
|
|
384
|
+
'</td></tr>'
|
|
385
|
+
+ ''.join(blocks)
|
|
386
|
+
+ '<tr><td style="padding:22px 28px 28px;">'
|
|
387
|
+
'<div style="font-size:12px;color:#9aa3af;line-height:1.8;">'
|
|
388
|
+
'完整版(含配图)在电脑上打开:<br>'
|
|
389
|
+
'<span style="font-family:ui-monospace,Consolas,Menlo,monospace;color:#6b7280;">'
|
|
390
|
+
f'weflow-cli daily-server --date {html.escape(date)} --open</span>'
|
|
391
|
+
'</div></td></tr>'
|
|
392
|
+
'</table></td></tr></table></body></html>'
|
|
393
|
+
)
|
|
394
|
+
return '\n'.join(text_lines), page, total
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def smtp_settings():
|
|
398
|
+
if not SMTP_CONFIG.is_file():
|
|
399
|
+
raise SystemExit(f'找不到发信凭据 {SMTP_CONFIG}(需要 smtp_user 与 smtp_auth)')
|
|
400
|
+
cfg = json.loads(SMTP_CONFIG.read_text(encoding='utf-8'))
|
|
401
|
+
if not cfg.get('smtp_user') or not cfg.get('smtp_auth'):
|
|
402
|
+
raise SystemExit(f'{SMTP_CONFIG} 里缺 smtp_user / smtp_auth')
|
|
403
|
+
return cfg['smtp_user'], cfg['smtp_auth']
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def send(user, auth, to, subject, text_body, html_body):
|
|
407
|
+
msg = MIMEMultipart('alternative')
|
|
408
|
+
msg['From'] = formataddr((str(Header('WeFlow CLI 日报', 'utf-8')), user))
|
|
409
|
+
msg['To'] = to
|
|
410
|
+
msg['Subject'] = Header(subject, 'utf-8')
|
|
411
|
+
msg['Date'] = formatdate(localtime=True)
|
|
412
|
+
msg.attach(MIMEText(text_body, 'plain', 'utf-8'))
|
|
413
|
+
msg.attach(MIMEText(html_body, 'html', 'utf-8'))
|
|
414
|
+
with smtplib.SMTP_SSL('smtp.qq.com', 465, timeout=45) as smtp:
|
|
415
|
+
smtp.login(user, auth)
|
|
416
|
+
refused = smtp.sendmail(user, [to], msg.as_string())
|
|
417
|
+
if refused:
|
|
418
|
+
raise SystemExit(f'被拒收:{refused}')
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def main():
|
|
422
|
+
ap = argparse.ArgumentParser(description='把某天的日报按主题整理成邮件并发送')
|
|
423
|
+
ap.add_argument('--date', required=True, help='YYYY-MM-DD')
|
|
424
|
+
ap.add_argument('--to', default='', help='收件人;缺省用配置 dailyMailTo,再缺省发给自己')
|
|
425
|
+
ap.add_argument('--topics', default=','.join(DEFAULT_TOPICS), help='逗号分隔的主题')
|
|
426
|
+
ap.add_argument('--no-digest', action='store_true', help='不生成每主题的 AI 综述(省钱、更快)')
|
|
427
|
+
ap.add_argument('--dry-run', action='store_true', help='只渲染,不发送')
|
|
428
|
+
args = ap.parse_args()
|
|
429
|
+
|
|
430
|
+
topics = [t.strip() for t in args.topics.split(',') if t.strip()]
|
|
431
|
+
data, articles = load_articles(args.date, set(topics))
|
|
432
|
+
groups = group_by_topic(articles, topics)
|
|
433
|
+
total = sum(len(v) for v in groups.values())
|
|
434
|
+
print(f'{args.date}: 命中 {total} 篇({", ".join(f"{t}={len(v)}" for t, v in groups.items())})')
|
|
435
|
+
if not total:
|
|
436
|
+
raise SystemExit('没有可发送的文章')
|
|
437
|
+
|
|
438
|
+
cfg = load_config()
|
|
439
|
+
# `--no-digest` 只关"每主题一段综述";聚类是版式结构,仍要做(两件事分开开关,
|
|
440
|
+
# 否则想看新版式的人一加 `--no-digest` 就退回一根长肠子)。
|
|
441
|
+
api_key = get_api_key(cfg)
|
|
442
|
+
digests = {} if args.no_digest else {t: topic_digest(t, items, api_key) for t, items in groups.items() if items}
|
|
443
|
+
clusters = {t: cluster_items(t, items, api_key, want=5) for t, items in groups.items() if len(items) >= 8}
|
|
444
|
+
for t, cs in clusters.items():
|
|
445
|
+
print(f' {t} 分了 {len(cs)} 组: ' + ' / '.join(f'{n}({len(v)})' for n, v in cs))
|
|
446
|
+
|
|
447
|
+
bodies = body_index(args.date)
|
|
448
|
+
with_repo = sum(1 for a in articles if github_links(bodies.get(a.get('url'), '')))
|
|
449
|
+
print(f'正文含 GitHub 链接: {with_repo} 篇')
|
|
450
|
+
|
|
451
|
+
text_body, html_body, total = render(args.date, groups, digests, bodies, clusters)
|
|
452
|
+
subject = f'公众号日报 {args.date}:{" / ".join(t for t in topics if groups.get(t))} {total} 篇'
|
|
453
|
+
|
|
454
|
+
user, auth = smtp_settings()
|
|
455
|
+
to = args.to or str(cfg.get('dailyMailTo') or '') or user
|
|
456
|
+
print(f'收件人: {to} 主题: {subject} 正文字符: {len(text_body)}')
|
|
457
|
+
if args.dry_run:
|
|
458
|
+
out = ROOT / 'output' / f'daily-mail-{args.date}.html'
|
|
459
|
+
out.write_text(html_body, encoding='utf-8')
|
|
460
|
+
print(f'[dry-run] 已渲染到 {out}')
|
|
461
|
+
return
|
|
462
|
+
send(user, auth, to, subject, text_body, html_body)
|
|
463
|
+
print(f'已发送到 {to}')
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
if __name__ == '__main__':
|
|
467
|
+
main()
|
package/scripts/daily_task.ps1
CHANGED
|
@@ -127,6 +127,26 @@ if ($proc.ExitCode -eq 0 -and $rest.Count -eq 0) {
|
|
|
127
127
|
Set-Content -Path (Join-Path $dayDir '.noon_done') -Value (Get-Date -Format 'yyyy-MM-dd HH:mm:ss') -Encoding UTF8
|
|
128
128
|
}
|
|
129
129
|
}
|
|
130
|
+
|
|
131
|
+
# Mail is a separate step on purpose: the day's product is already on disk, so a mail
|
|
132
|
+
# failure must not turn a successful run into a failed one (and must not stop the
|
|
133
|
+
# noon marker from being written). It has its own log pair.
|
|
134
|
+
#
|
|
135
|
+
# **Only the day pass mails.** The noon pass is a half day, so mailing it too means two
|
|
136
|
+
# emails a day where the second one repeats the first - the user asked for one email,
|
|
137
|
+
# and the complete day is the one worth reading (2026-10-20).
|
|
138
|
+
$mailScript = Join-Path $PSScriptRoot 'daily_mail.py'
|
|
139
|
+
$mailOut = Join-Path $logDir ('daily-mail-{0}{1}.log' -f $stamp, $suffix)
|
|
140
|
+
$mailErr = Join-Path $logDir ('daily-mail-{0}{1}.err.log' -f $stamp, $suffix)
|
|
141
|
+
if ($mode -eq 'day' -and (Test-Path $mailScript)) {
|
|
142
|
+
$mailProc = Start-Process -FilePath $python -ArgumentList @($mailScript, '--date', $targetDay) `
|
|
143
|
+
-NoNewWindow -Wait -PassThru -RedirectStandardOutput $mailOut -RedirectStandardError $mailErr
|
|
144
|
+
if ($mailProc.ExitCode -ne 0) {
|
|
145
|
+
Add-Content -Path (Join-Path $logDir 'daily-task.log') -Value `
|
|
146
|
+
('===== {0} [{1}] {2} mail failed (exit {3}), see {4} =====' -f `
|
|
147
|
+
(Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $mode, $targetDay, $mailProc.ExitCode, $mailErr) -Encoding UTF8
|
|
148
|
+
}
|
|
149
|
+
}
|
|
130
150
|
}
|
|
131
151
|
|
|
132
152
|
exit $proc.ExitCode
|
package/scripts/jev_client.py
CHANGED
|
@@ -38,7 +38,7 @@ INPUT_USD_PER_TOKEN = 0.042 / 1_000_000 # 输出 token 目前不计费
|
|
|
38
38
|
|
|
39
39
|
# 分类法(列表 + 每类的判据)只有一份,在 `_utils` 里。这里直接引用它,
|
|
40
40
|
# 所以提示词那条路和决策模型这条路**判的是同一套定义**——回退时不会换标准。
|
|
41
|
-
from _utils import TOPIC_CRITERIA, RELEVANCE_NAMES # noqa: E402 (scripts/ 在 sys.path 上)
|
|
41
|
+
from _utils import TOPIC_CRITERIA, RELEVANCE_NAMES, DEFAULT_JUDGE_LOCAL_URL # noqa: E402 (scripts/ 在 sys.path 上)
|
|
42
42
|
|
|
43
43
|
# **顺序即语义**:score.criteria 零索引,第一个元素是 0 分。
|
|
44
44
|
# 两件事必须分开:判据文本是**给模型看**的(照抄 TOPIC_PROMPT 里对高/中/低的定义,
|
|
@@ -543,6 +543,131 @@ class ChatJevClient(JevClient):
|
|
|
543
543
|
return message.get('content') or '', usage
|
|
544
544
|
|
|
545
545
|
|
|
546
|
+
class LocalJevClient(JevClient):
|
|
547
|
+
"""**用本机决策模型当判断器**(D-127):`decide.py serve` 的 `/decide`,不出网、无 key。
|
|
548
|
+
|
|
549
|
+
与 `JevClient` 同一个鸭子类型(`decide` / `decide_article` / `last_model`),差别有三处:
|
|
550
|
+
|
|
551
|
+
· **一个问题一次请求**:本机服务的 `/decide` 只答一个 primitive,而原生 `systemone`
|
|
552
|
+
一次答一组问题。日报是 3 个问题 ⇒ 3 次调用(本机 0.2–0.6 s/次,整篇仍在 2 s 内);
|
|
553
|
+
· `score` **没有连续分**:用 `choice` 在有序档位上选,把选中档位的下标当分数。
|
|
554
|
+
下游 `score_to_relevance` 本来就是四舍五入取整,所以语义等价;
|
|
555
|
+
· 概率是**候选集上的相对 softmax、未校准**(APUS 契约原文),`confidence` 只作参考,
|
|
556
|
+
不要直接拿来做自动放行阈值。
|
|
557
|
+
|
|
558
|
+
契约不成立时仍由 `_check_choice` / `_check_score` / `_check_noul` 当场抛 ——
|
|
559
|
+
换通路不降低校验标准。
|
|
560
|
+
"""
|
|
561
|
+
|
|
562
|
+
#: noul / score_level 必须使用固定的 yes/no 候选(本机服务的契约要求)。
|
|
563
|
+
BINARY = [{'id': 'yes', 'description': 'The stated proposition is true.'},
|
|
564
|
+
{'id': 'no', 'description': 'The stated proposition is false.'}]
|
|
565
|
+
|
|
566
|
+
#: 主题 → 相关度下限(见 `_calibrate` 的实测依据)。空表就是不做校准。
|
|
567
|
+
TOPIC_RELEVANCE_FLOOR = {'AI': 1}
|
|
568
|
+
|
|
569
|
+
def __init__(self, base_url=DEFAULT_JUDGE_LOCAL_URL, model='apus-reflex-4b', timeout=120):
|
|
570
|
+
super().__init__('local', model=model, timeout=timeout)
|
|
571
|
+
self.base_url = (base_url or DEFAULT_JUDGE_LOCAL_URL).rstrip('/')
|
|
572
|
+
self.provider = 'local'
|
|
573
|
+
|
|
574
|
+
def models(self):
|
|
575
|
+
"""本机服务没有模型清单接口;调用方(`jev_probe`)只拿它做"名字写错了吗"的自检。"""
|
|
576
|
+
return []
|
|
577
|
+
|
|
578
|
+
def _decide_one(self, state, question):
|
|
579
|
+
qtype = (question or {}).get('type')
|
|
580
|
+
instructions = (question or {}).get('instructions') or ''
|
|
581
|
+
if isinstance(state, (dict, list)):
|
|
582
|
+
state = json.dumps(state, ensure_ascii=False)
|
|
583
|
+
if qtype == 'choice':
|
|
584
|
+
criteria = question.get('criteria') or {}
|
|
585
|
+
body = self._post(self.base_url + '/decide', {
|
|
586
|
+
'state': state, 'instructions': instructions, 'primitive': 'choice',
|
|
587
|
+
'criteria': [{'id': str(k), 'description': str(v)} for k, v in criteria.items()]})
|
|
588
|
+
return {'choice': body.get('choice'),
|
|
589
|
+
'probabilities': body.get('probabilities') or {},
|
|
590
|
+
'confidence': body.get('confidence')}
|
|
591
|
+
if qtype == 'score':
|
|
592
|
+
levels = question.get('criteria') or []
|
|
593
|
+
if len(levels) < 2:
|
|
594
|
+
raise JevError('score:有序档位至少要有 2 档')
|
|
595
|
+
# 把原问题("…的实用价值有多高?")改写成陈述式命题:
|
|
596
|
+
# "…的实用价值,达到「中」或更高吗?(中:…;高:…)"。
|
|
597
|
+
# 实测三种写法(同一批 44 篇):三选一 45.5%、纯判据句 59.1%、
|
|
598
|
+
# 这种"保留主语 + 阈值 + 判据" 70.5%。**保留主语**是关键——
|
|
599
|
+
# 只给判据时模型不知道在判什么。
|
|
600
|
+
stem = instructions.strip().rstrip('??').strip()
|
|
601
|
+
if stem.endswith('有多高'):
|
|
602
|
+
stem = stem[:-3].rstrip(',, ')
|
|
603
|
+
hits = 0
|
|
604
|
+
last_name = str(levels[-1]).split(':')[0].split(':')[0]
|
|
605
|
+
for index in range(1, len(levels)):
|
|
606
|
+
name = str(levels[index]).split(':')[0].split(':')[0]
|
|
607
|
+
criteria_text = ';'.join(str(x) for x in levels[index:])
|
|
608
|
+
body = self._post(self.base_url + '/decide', {
|
|
609
|
+
'state': state,
|
|
610
|
+
'instructions': '这篇文章%s,达到「%s」或「%s」吗?(%s)'
|
|
611
|
+
% (stem, name, last_name, criteria_text),
|
|
612
|
+
'primitive': 'noul', 'criteria': self.BINARY})
|
|
613
|
+
probability = body.get('yes_probability')
|
|
614
|
+
if probability is None:
|
|
615
|
+
raise JevError('score:第 %d 档阈值没给 yes_probability(%s)'
|
|
616
|
+
% (index + 1, json.dumps(body, ensure_ascii=False)[:200]))
|
|
617
|
+
hits += float(probability) >= 0.5
|
|
618
|
+
return {'score': float(hits)}
|
|
619
|
+
if qtype == 'noul':
|
|
620
|
+
body = self._post(self.base_url + '/decide', {
|
|
621
|
+
'state': state, 'instructions': instructions, 'primitive': 'noul',
|
|
622
|
+
'criteria': self.BINARY})
|
|
623
|
+
if body.get('yes_probability') is None:
|
|
624
|
+
raise JevError('noul:本机服务没给 yes_probability(%s)'
|
|
625
|
+
% json.dumps(body, ensure_ascii=False)[:200])
|
|
626
|
+
return {'noul': float(body['yes_probability'])}
|
|
627
|
+
raise JevError('本机判断器不支持的问题类型:%r' % (qtype,))
|
|
628
|
+
|
|
629
|
+
def decide(self, state, questions):
|
|
630
|
+
"""返回 `(answers, usage)`,形状与另外两条通路一致;每个答案都过一遍校验。"""
|
|
631
|
+
answers = {}
|
|
632
|
+
started = time.time()
|
|
633
|
+
for key, question in (questions or {}).items():
|
|
634
|
+
if not isinstance(question, dict):
|
|
635
|
+
continue
|
|
636
|
+
answer = self._decide_one(state, question)
|
|
637
|
+
_check_choice(key, question, answer)
|
|
638
|
+
_check_score(key, question, answer)
|
|
639
|
+
_check_noul(key, question, answer)
|
|
640
|
+
answers[key] = answer
|
|
641
|
+
self._calibrate(answers)
|
|
642
|
+
usage = {'model': self.model, 'provider': 'local',
|
|
643
|
+
'elapsed_ms': round((time.time() - started) * 1000),
|
|
644
|
+
'calibration': dict(self.TOPIC_RELEVANCE_FLOOR)}
|
|
645
|
+
self.last_model = self.model
|
|
646
|
+
return answers, usage
|
|
647
|
+
|
|
648
|
+
def _calibrate(self, answers):
|
|
649
|
+
"""按主题给相关度加下限 —— **这是实测出来的,不是拍脑袋**。
|
|
650
|
+
|
|
651
|
+
211 篇同题对比(2026-10-16~21,Jev 实判):本机模型对 `AI` 类文章的相关度
|
|
652
|
+
系统性低估一档——`低→中` 33 次、`中→中` 仅 5 次,该类一致率 20.0%,
|
|
653
|
+
而政治/文学/新闻都在 90% 以上。加下限后 AI 类 77.5%、总体 73.9% → 84.4%。
|
|
654
|
+
|
|
655
|
+
为什么是「下限」而不是「整体 +1」:统一 +1 只有 82.9%,且会把本来判 `高`
|
|
656
|
+
的压成越界值;只把 `低` 抬到 `中` 才对得上 Jev 的分布。
|
|
657
|
+
"""
|
|
658
|
+
topic_answer = answers.get('topic') or {}
|
|
659
|
+
score_answer = answers.get('relevance') or {}
|
|
660
|
+
topic = topic_answer.get('choice')
|
|
661
|
+
if topic is None or 'score' not in score_answer:
|
|
662
|
+
return
|
|
663
|
+
floor = self.TOPIC_RELEVANCE_FLOOR.get(topic)
|
|
664
|
+
if floor is None:
|
|
665
|
+
return
|
|
666
|
+
if float(score_answer['score']) < floor:
|
|
667
|
+
score_answer['score'] = float(floor)
|
|
668
|
+
score_answer['calibrated'] = True
|
|
669
|
+
|
|
670
|
+
|
|
546
671
|
def create_client(api_key='', model='', timeout=120, config=None):
|
|
547
672
|
"""拿到 key 就返回客户端,没有就返回 None(调用方据此走老路)。
|
|
548
673
|
|
|
@@ -553,7 +678,9 @@ def create_client(api_key='', model='', timeout=120, config=None):
|
|
|
553
678
|
`aiModel` / `deepseekApiKey`)——一句话就能打开,不需要新厂商、新 key。
|
|
554
679
|
实测 `deepseek-chat` 1.0 秒/篇且契约通过,成本量级 ¥1/天;
|
|
555
680
|
· `openrouter` → OpenRouter 的 chat/completions,模型取 `openrouterJudgeModel`
|
|
556
|
-
(默认 `typesafe/jev-router`),key 取 `openrouterApiKey
|
|
681
|
+
(默认 `typesafe/jev-router`),key 取 `openrouterApiKey`;
|
|
682
|
+
· `local` → **本机决策模型服务**(`judgeLocalUrl`,默认 `http://127.0.0.1:8090`,
|
|
683
|
+
即 `decide.py serve`),不需要 key、不出网(D-127)。
|
|
557
684
|
"""
|
|
558
685
|
judge_provider = ''
|
|
559
686
|
openrouter_key = openrouter_model = ''
|
|
@@ -620,6 +747,13 @@ def create_client(api_key='', model='', timeout=120, config=None):
|
|
|
620
747
|
timeout=timeout, provider='openrouter',
|
|
621
748
|
extra_headers={'HTTP-Referer': OPENROUTER_REFERER,
|
|
622
749
|
'X-Title': OPENROUTER_TITLE})
|
|
750
|
+
if judge_provider == 'local':
|
|
751
|
+
local_url = DEFAULT_JUDGE_LOCAL_URL
|
|
752
|
+
try:
|
|
753
|
+
local_url = getattr(_utils, 'get_judge_local_url')(config) or DEFAULT_JUDGE_LOCAL_URL
|
|
754
|
+
except Exception:
|
|
755
|
+
pass
|
|
756
|
+
return LocalJevClient(local_url, timeout=timeout)
|
|
623
757
|
key = resolve_key(api_key, config)
|
|
624
758
|
if not key:
|
|
625
759
|
return None
|