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.
- package/ARCHITECTURE.md +62 -26
- package/CHANGELOG.md +1290 -0
- package/OPERATIONS.md +144 -11
- package/README.en.md +20 -4
- package/README.md +30 -5
- package/bin/weflow-cli-electron.cjs +2 -0
- package/bin/weflow-cli.ts +996 -118
- package/dist/bin/weflow-cli.js +1038 -146
- package/dist/bin/weflow-cli.js.map +1 -1
- package/dist/mcp-server/http.d.ts +14 -0
- package/dist/mcp-server/http.d.ts.map +1 -0
- package/dist/mcp-server/http.js +84 -0
- package/dist/mcp-server/http.js.map +1 -0
- package/dist/mcp-server/httpConfig.d.ts +50 -0
- package/dist/mcp-server/httpConfig.d.ts.map +1 -0
- package/dist/mcp-server/httpConfig.js +104 -0
- package/dist/mcp-server/httpConfig.js.map +1 -0
- package/dist/mcp-server/index.d.ts +13 -1
- package/dist/mcp-server/index.d.ts.map +1 -1
- package/dist/mcp-server/index.js +58 -374
- package/dist/mcp-server/index.js.map +1 -1
- package/dist/src/core/dbPathService.d.ts +12 -0
- package/dist/src/core/dbPathService.d.ts.map +1 -1
- package/dist/src/core/dbPathService.js +20 -3
- package/dist/src/core/dbPathService.js.map +1 -1
- package/dist/src/panel/server.d.ts.map +1 -1
- package/dist/src/panel/server.js +61 -1
- package/dist/src/panel/server.js.map +1 -1
- package/dist/src/services/assistantEval.d.ts +69 -0
- package/dist/src/services/assistantEval.d.ts.map +1 -1
- package/dist/src/services/assistantEval.js +270 -8
- package/dist/src/services/assistantEval.js.map +1 -1
- package/dist/src/services/assistantScenes.d.ts +81 -0
- package/dist/src/services/assistantScenes.d.ts.map +1 -0
- package/dist/src/services/assistantScenes.js +322 -0
- package/dist/src/services/assistantScenes.js.map +1 -0
- package/dist/src/services/assistantService.d.ts +33 -4
- package/dist/src/services/assistantService.d.ts.map +1 -1
- package/dist/src/services/assistantService.js +102 -6
- package/dist/src/services/assistantService.js.map +1 -1
- package/dist/src/services/assistantSkills.d.ts +94 -0
- package/dist/src/services/assistantSkills.d.ts.map +1 -0
- package/dist/src/services/assistantSkills.js +330 -0
- package/dist/src/services/assistantSkills.js.map +1 -0
- package/dist/src/services/assistantTools.d.ts +34 -1
- package/dist/src/services/assistantTools.d.ts.map +1 -1
- package/dist/src/services/assistantTools.js +931 -88
- package/dist/src/services/assistantTools.js.map +1 -1
- package/dist/src/services/assistantTrace.d.ts +4 -0
- package/dist/src/services/assistantTrace.d.ts.map +1 -1
- package/dist/src/services/assistantTrace.js +9 -0
- package/dist/src/services/assistantTrace.js.map +1 -1
- package/dist/src/services/configService.d.ts +9 -0
- package/dist/src/services/configService.d.ts.map +1 -1
- package/dist/src/services/configService.js +5 -2
- package/dist/src/services/configService.js.map +1 -1
- package/dist/src/services/messageQuery.d.ts +18 -0
- package/dist/src/services/messageQuery.d.ts.map +1 -1
- package/dist/src/services/messageQuery.js +29 -0
- package/dist/src/services/messageQuery.js.map +1 -1
- package/dist/src/services/outboundSend.d.ts +53 -0
- package/dist/src/services/outboundSend.d.ts.map +1 -0
- package/dist/src/services/outboundSend.js +90 -0
- package/dist/src/services/outboundSend.js.map +1 -0
- package/dist/src/services/pythonBridge.d.ts.map +1 -1
- package/dist/src/services/pythonBridge.js +1 -0
- package/dist/src/services/pythonBridge.js.map +1 -1
- package/dist/src/services/wechatClient.d.ts +125 -0
- package/dist/src/services/wechatClient.d.ts.map +1 -0
- package/dist/src/services/wechatClient.js +344 -0
- package/dist/src/services/wechatClient.js.map +1 -0
- package/dist/src/services/wechatMedia.d.ts +44 -0
- package/dist/src/services/wechatMedia.d.ts.map +1 -0
- package/dist/src/services/wechatMedia.js +155 -0
- package/dist/src/services/wechatMedia.js.map +1 -0
- package/dist/src/services/wechatMessageService.d.ts +11 -0
- package/dist/src/services/wechatMessageService.d.ts.map +1 -1
- package/dist/src/services/wechatMessageService.js +55 -11
- package/dist/src/services/wechatMessageService.js.map +1 -1
- package/dist/src/types.d.ts +4 -0
- package/dist/src/types.d.ts.map +1 -1
- package/dist/src/utils/python.js +2 -2
- package/dist/src/utils/python.js.map +1 -1
- package/docs/BRANCHES.md +37 -0
- package/docs/CONTACT_DB_SCHEMA.md +1085 -0
- package/docs/DECISIONS.md +1624 -0
- package/docs/EXTENDING.md +98 -10
- package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +6 -0
- package/docs/HEALTH-CHECK.md +12 -7
- package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +2 -2
- package/docs/MCP.md +58 -4
- package/docs/PROJECT_STATE.md +180 -11
- package/docs/RELEASING.md +115 -1
- package/docs/images/weflow-architecture-gpt-image-2.png +0 -0
- package/logo.png +0 -0
- package/mcp-server/http.ts +108 -0
- package/mcp-server/httpConfig.ts +126 -0
- package/mcp-server/index.ts +359 -681
- package/package.json +5 -3
- package/resources/js/graph3d/NOTICE.txt +35 -0
- package/resources/js/graph3d/d3-binarytree.min.js +2 -0
- package/resources/js/graph3d/d3-dispatch.min.js +2 -0
- package/resources/js/graph3d/d3-force-3d.min.js +2 -0
- package/resources/js/graph3d/d3-octree.min.js +2 -0
- package/resources/js/graph3d/d3-quadtree.min.js +2 -0
- package/resources/js/graph3d/d3-timer.min.js +2 -0
- package/resources/js/graph3d/layout.mjs +63 -0
- package/resources/js/graph3d/three.min.js +6 -0
- package/resources/js/graph3d/viewer2d.js +448 -0
- package/resources/panel/ball-position.cjs +48 -2
- package/resources/panel/index.html +18 -4
- package/resources/panel/main.cjs +137 -5
- package/resources/panel/mascot-base.png +0 -0
- package/resources/panel/mascot-blink-1.png +0 -0
- package/resources/panel/mascot-blink-2.png +0 -0
- package/resources/panel/mascot-iris.png +0 -0
- package/resources/panel/mascot-lift-down-1.png +0 -0
- package/resources/panel/mascot-lift-down-2.png +0 -0
- package/resources/panel/mascot-lift-down-3.png +0 -0
- package/resources/panel/mascot-lift-down-4.png +0 -0
- package/resources/panel/mascot-lift-down-5.png +0 -0
- package/resources/panel/mascot-lift-down-6.png +0 -0
- package/resources/panel/mascot-lift-up-1.png +0 -0
- package/resources/panel/mascot-lift-up-2.png +0 -0
- package/resources/panel/mascot-lift-up-3.png +0 -0
- package/resources/panel/mascot-lift-up-4.png +0 -0
- package/resources/panel/mascot-lift-up-5.png +0 -0
- package/resources/panel/mascot-lift-up-6.png +0 -0
- package/resources/panel/mascot-peek.png +0 -0
- package/resources/panel/mascot-tickle-1.png +0 -0
- package/resources/panel/mascot-tickle-2.png +0 -0
- package/resources/panel/mascot-tickle-3.png +0 -0
- package/resources/panel/mascot-tickle-4.png +0 -0
- package/resources/panel/panel.css +205 -59
- package/resources/panel/preload.cjs +26 -0
- package/resources/panel/renderer.js +646 -17
- package/scripts/_utils.py +1203 -931
- package/scripts/backfill_articles.py +202 -21
- package/scripts/biz_daily.py +26 -34
- package/scripts/bonds.py +331 -0
- package/scripts/chat_notes.py +561 -470
- package/scripts/chat_stats.py +8 -7
- package/scripts/check_readme_commands.py +426 -0
- package/scripts/compile_wiki.py +187 -59
- package/scripts/contact_schema.py +339 -0
- package/scripts/data/wechat_emoji.json +225 -0
- package/scripts/export_chat_html.py +3 -1
- package/scripts/fav_notes.py +4 -1
- package/scripts/graph_2d.py +151 -0
- package/scripts/graph_3d.py +646 -0
- package/scripts/health_check.py +163 -26
- package/scripts/mcp_bridge.py +6 -7
- package/scripts/nt_decrypt.py +3 -3
- package/scripts/panel_frames.py +683 -0
- package/scripts/pipeline.py +21 -10
- package/scripts/quality_eval.py +5 -0
- package/scripts/rag_chat.py +33 -16
- package/scripts/reply_debt.py +26 -6
- package/scripts/semantic_search.py +130 -22
- package/scripts/vault_rag.py +42 -16
- package/scripts/vault_search.py +44 -8
- package/scripts/wechat_emoticon.py +6 -0
- package/scripts/wechat_voice.py +9 -0
- package/scripts/wiki_lint.py +124 -24
- package/src/core/dbPathService.ts +22 -4
- package/src/panel/server.ts +58 -1
- package/src/services/assistantEval.ts +323 -12
- package/src/services/assistantScenes.ts +342 -0
- package/src/services/assistantService.ts +107 -6
- package/src/services/assistantSkills.ts +364 -0
- package/src/services/assistantTools.ts +2452 -1640
- package/src/services/assistantTrace.ts +9 -0
- package/src/services/configService.ts +415 -403
- package/src/services/messageQuery.ts +29 -0
- package/src/services/outboundSend.ts +126 -0
- package/src/services/pythonBridge.ts +1 -0
- package/src/services/wechatClient.ts +380 -0
- package/src/services/wechatMedia.ts +166 -0
- package/src/services/wechatMessageService.ts +62 -11
- package/src/types.ts +4 -0
- package/src/utils/python.ts +2 -2
- package/dist/src/utils/wechatEmoji.d.ts +0 -5
- package/dist/src/utils/wechatEmoji.d.ts.map +0 -1
- package/dist/src/utils/wechatEmoji.js +0 -35
- package/dist/src/utils/wechatEmoji.js.map +0 -1
package/scripts/pipeline.py
CHANGED
|
@@ -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('--
|
|
47
|
-
|
|
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-
|
|
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
|
|
77
|
+
# `--no-summary --skip-classify` 仍然索要 DeepSeek key——那正是这条
|
|
75
78
|
# 路要摆脱的东西。
|
|
76
79
|
api_key = args.api_key or ''
|
|
77
|
-
downstream_ai = not
|
|
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.
|
|
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
|
|
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
|
|
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')
|
package/scripts/quality_eval.py
CHANGED
|
@@ -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')
|
package/scripts/rag_chat.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
package/scripts/reply_debt.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
431
|
-
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
|
|
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
|
-
|
|
586
|
-
|
|
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
|
|