tt-help-cli-ycl 1.4.20 → 1.4.22
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/package.json +1 -1
- package/src/cli/comments.js +63 -1
- package/src/lib/parse-ssr.mjs +6 -0
- package/src/lib/tag-discover.js +8 -4
- package/src/lib/tag-fetcher.js +5 -0
- package/src/npm-main.js +3 -0
- package/src/watch/data-store.js +2 -0
- package/src/watch/db-tags.js +237 -17
- package/src/watch/llm-config.js +269 -0
- package/src/watch/llm-scoring.js +103 -63
- package/src/watch/public/app.js +198 -8
- package/src/watch/public/index.html +39 -0
- package/src/watch/public/style.css +154 -0
- package/src/watch/server.js +104 -6
- package/src/watch/tag-service.js +119 -41
package/package.json
CHANGED
package/src/cli/comments.js
CHANGED
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
} from "../scraper/modules/captcha-handler.js";
|
|
36
36
|
import { detectModals, closeAllModals } from "../lib/modal-handler.js";
|
|
37
37
|
import { getMacOrUuid } from "../lib/mac-or-uuid.js";
|
|
38
|
+
import { parseVideoInfo } from "../lib/parse-ssr.mjs";
|
|
38
39
|
|
|
39
40
|
// ────────────────────────────────────────
|
|
40
41
|
// Section 1: 常量 & 配置默认值
|
|
@@ -492,6 +493,49 @@ function extractVideoInfo() {
|
|
|
492
493
|
return result;
|
|
493
494
|
}
|
|
494
495
|
|
|
496
|
+
/**
|
|
497
|
+
* 从视频页 SSR 数据中提取 challenges(标签/hashtags)
|
|
498
|
+
* @param {Page} page - Playwright page
|
|
499
|
+
* @returns {Promise<{ tagNames: string[], challenges: Array }>}
|
|
500
|
+
*/
|
|
501
|
+
async function extractVideoTags(page) {
|
|
502
|
+
try {
|
|
503
|
+
const html = await page.evaluate(() => document.documentElement.outerHTML);
|
|
504
|
+
const videoInfo = parseVideoInfo(html);
|
|
505
|
+
if (
|
|
506
|
+
!videoInfo ||
|
|
507
|
+
!videoInfo.challenges ||
|
|
508
|
+
videoInfo.challenges.length === 0
|
|
509
|
+
) {
|
|
510
|
+
return { tagNames: [], challenges: [] };
|
|
511
|
+
}
|
|
512
|
+
const tagNames = videoInfo.challenges.map((c) => c.title).filter(Boolean);
|
|
513
|
+
return { tagNames, challenges: videoInfo.challenges };
|
|
514
|
+
} catch (e) {
|
|
515
|
+
console.error(` [tags] 提取失败: ${e.message}`);
|
|
516
|
+
return { tagNames: [], challenges: [] };
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* 将视频 tag 保存到服务端(只插入不存在的,不覆盖已有)
|
|
522
|
+
* @returns {Promise<{ inserted: number }>}
|
|
523
|
+
*/
|
|
524
|
+
async function saveVideoTags(apiPost, serverUrl, tagNames, country) {
|
|
525
|
+
if (!tagNames || tagNames.length === 0) return { inserted: 0 };
|
|
526
|
+
try {
|
|
527
|
+
const result = await apiPost(`${serverUrl}/api/tags/batch-add-if-new`, {
|
|
528
|
+
tags: tagNames,
|
|
529
|
+
countries: [country],
|
|
530
|
+
source: "video",
|
|
531
|
+
});
|
|
532
|
+
return { inserted: result.inserted || 0 };
|
|
533
|
+
} catch (e) {
|
|
534
|
+
console.error(` [tags] 保存失败: ${e.message}`);
|
|
535
|
+
return { inserted: 0 };
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
495
539
|
function extractUniqueAuthors(comments) {
|
|
496
540
|
return [
|
|
497
541
|
...new Set(
|
|
@@ -668,7 +712,7 @@ async function runAutoMode(options) {
|
|
|
668
712
|
if (shuttingDown) break;
|
|
669
713
|
|
|
670
714
|
try {
|
|
671
|
-
const { id, href, locationCreated } = task;
|
|
715
|
+
const { id, href, locationCreated, ttSeller } = task;
|
|
672
716
|
const loc = normalizeLocation(locationCreated);
|
|
673
717
|
const taskNum = totalTasks() + 1;
|
|
674
718
|
|
|
@@ -691,6 +735,24 @@ async function runAutoMode(options) {
|
|
|
691
735
|
// 导航到视频页
|
|
692
736
|
await prepareVideoPage(session.page, href);
|
|
693
737
|
|
|
738
|
+
// ── 提取视频 tag(仅 ttSeller 视频)──
|
|
739
|
+
if (ttSeller && loc) {
|
|
740
|
+
const { tagNames } = await extractVideoTags(session.page);
|
|
741
|
+
if (tagNames.length > 0) {
|
|
742
|
+
const { inserted } = await saveVideoTags(
|
|
743
|
+
apiPost,
|
|
744
|
+
serverUrl,
|
|
745
|
+
tagNames,
|
|
746
|
+
loc,
|
|
747
|
+
);
|
|
748
|
+
if (inserted > 0) {
|
|
749
|
+
console.error(
|
|
750
|
+
` [tags] 提取到 ${tagNames.length} 个: ${tagNames.join(", ")} → 新增 ${inserted} 个`,
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
694
756
|
// 获取评论
|
|
695
757
|
const result = await fetchUserCommentsAPI(session.page, {
|
|
696
758
|
maxComments: actualMaxComments,
|
package/src/lib/parse-ssr.mjs
CHANGED
|
@@ -138,11 +138,17 @@ export function parseVideoInfo(rawHtml) {
|
|
|
138
138
|
const item = vd.itemInfo.itemStruct;
|
|
139
139
|
const author = item.author || {};
|
|
140
140
|
const stats = item.stats || {};
|
|
141
|
+
const challenges = (item.challenges || []).map((c) => ({
|
|
142
|
+
id: c.id,
|
|
143
|
+
title: c.title,
|
|
144
|
+
desc: c.desc || "",
|
|
145
|
+
}));
|
|
141
146
|
return {
|
|
142
147
|
id: item.id,
|
|
143
148
|
desc: item.desc || "",
|
|
144
149
|
createTime: item.createTime || null,
|
|
145
150
|
locationCreated: item.locationCreated || null,
|
|
151
|
+
challenges,
|
|
146
152
|
author: {
|
|
147
153
|
uniqueId: author.uniqueId,
|
|
148
154
|
nickname: author.nickname,
|
package/src/lib/tag-discover.js
CHANGED
|
@@ -158,11 +158,15 @@ async function discoverTagsForCountryCli(
|
|
|
158
158
|
` 🏥 ${country} health: ${health.healthStatus} | deadRatio=${health.deadRatio}% | productiveRatio=${health.productiveRatio}% | saturation=${health.saturation}%\n`,
|
|
159
159
|
);
|
|
160
160
|
|
|
161
|
-
//
|
|
162
|
-
if (health.
|
|
163
|
-
temperature = 0
|
|
164
|
-
} else if (health.
|
|
161
|
+
// 动态温度调整:卡死时升温打破 LLM 确定性重复
|
|
162
|
+
if (health.consecutiveLowProductiveRounds >= 3) {
|
|
163
|
+
temperature = 1.0;
|
|
164
|
+
} else if (health.consecutiveLowProductiveRounds >= 2) {
|
|
165
|
+
temperature = 0.9;
|
|
166
|
+
} else if (health.healthStatus === "critical") {
|
|
165
167
|
temperature = 0.5;
|
|
168
|
+
} else if (health.healthStatus === "warning") {
|
|
169
|
+
temperature = 0.6;
|
|
166
170
|
}
|
|
167
171
|
|
|
168
172
|
// 自动 niche 切换
|
package/src/lib/tag-fetcher.js
CHANGED
|
@@ -166,6 +166,11 @@ export async function fetchTagData(tag, options = {}) {
|
|
|
166
166
|
throw new Error("标签页加载失败,TikTok 返回了错误页面");
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
+
// 检测 CDN/网络拦截:15 秒后仍无任何 API 数据到达,说明被拦截而非真没数据
|
|
170
|
+
if (seenVideoIds.size === 0 && challengeInfo === null) {
|
|
171
|
+
throw new Error("无法获取标签页 API 数据(可能被 CDN 拦截或网络异常)");
|
|
172
|
+
}
|
|
173
|
+
|
|
169
174
|
let lastCount = 0;
|
|
170
175
|
let staleRounds = 0;
|
|
171
176
|
const startTime = Date.now();
|
package/src/npm-main.js
CHANGED
|
@@ -3,6 +3,7 @@ import { PUBLIC_HELP_TEXT } from "./lib/constants.js";
|
|
|
3
3
|
import { handleInfo } from "./cli/info.js";
|
|
4
4
|
import { handleExplore } from "./cli/explore.js";
|
|
5
5
|
import { handleAttach } from "./cli/attach.js";
|
|
6
|
+
import { handleRecheck } from "./cli/recheck.js";
|
|
6
7
|
import { handleConfig, showConfig, showUsage, version } from "./cli/config.js";
|
|
7
8
|
import { handleOpen } from "./cli/open.js";
|
|
8
9
|
import { handleComments } from "./cli/comments.js";
|
|
@@ -32,6 +33,8 @@ async function main() {
|
|
|
32
33
|
return handleInfo(parsed);
|
|
33
34
|
case "attach":
|
|
34
35
|
return handleAttach(parsed);
|
|
36
|
+
case "recheck":
|
|
37
|
+
return handleRecheck(parsed);
|
|
35
38
|
case "watch":
|
|
36
39
|
case "webserver":
|
|
37
40
|
case "videostats":
|
package/src/watch/data-store.js
CHANGED
|
@@ -86,6 +86,7 @@ import {
|
|
|
86
86
|
// Tag CRUD
|
|
87
87
|
import {
|
|
88
88
|
insertTag,
|
|
89
|
+
insertTagsIfNew,
|
|
89
90
|
getTagsByStatus,
|
|
90
91
|
getTagsByCountry,
|
|
91
92
|
getDeadTags,
|
|
@@ -3601,6 +3602,7 @@ export function createStore(filePath, options = {}) {
|
|
|
3601
3602
|
getLlmSampleOffsets, // 获取 LLM 采样偏移量状态
|
|
3602
3603
|
// Tag 发现与打分
|
|
3603
3604
|
insertTag,
|
|
3605
|
+
insertTagsIfNew,
|
|
3604
3606
|
getTagsByStatus,
|
|
3605
3607
|
getTagsByCountry,
|
|
3606
3608
|
getDeadTags,
|
package/src/watch/db-tags.js
CHANGED
|
@@ -50,11 +50,59 @@ export function insertTag(tag, countries, source = "llm") {
|
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/**
|
|
54
|
+
* 批量插入 tag,只插入不存在的(INSERT OR IGNORE)。
|
|
55
|
+
* 用于非 LLM 来源(如视频页采集),不覆盖已有 tag 的任何字段。
|
|
56
|
+
*
|
|
57
|
+
* @param {string[]} tags - 原始 tag 名列表(可带 # 前缀)
|
|
58
|
+
* @param {string[]} countries - 国家列表
|
|
59
|
+
* @param {string} source - 来源标识,默认 'video'
|
|
60
|
+
* @returns {{ inserted: number, tags: string[] }}
|
|
61
|
+
*/
|
|
62
|
+
export function insertTagsIfNew(tags, countries, source = "video") {
|
|
63
|
+
const db = getDb();
|
|
64
|
+
if (!db) return { inserted: 0, tags: [] };
|
|
65
|
+
if (!Array.isArray(tags) || tags.length === 0)
|
|
66
|
+
return { inserted: 0, tags: [] };
|
|
67
|
+
if (!Array.isArray(countries) || countries.length === 0)
|
|
68
|
+
return { inserted: 0, tags: [] };
|
|
69
|
+
|
|
70
|
+
const normalizedList = tags
|
|
71
|
+
.map((t) => t.replace(/^#+/, "").trim().toLowerCase())
|
|
72
|
+
.filter((t) => t.length >= 2);
|
|
73
|
+
|
|
74
|
+
if (normalizedList.length === 0) return { inserted: 0, tags: [] };
|
|
75
|
+
|
|
76
|
+
const countriesJson = JSON.stringify(countries);
|
|
77
|
+
let inserted = 0;
|
|
78
|
+
const insertedTags = [];
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const stmt = db.prepare(
|
|
82
|
+
`INSERT OR IGNORE INTO tags (tag, countries, source) VALUES (?, ?, ?)`,
|
|
83
|
+
);
|
|
84
|
+
const txn = db.transaction((items) => {
|
|
85
|
+
for (const tag of items) {
|
|
86
|
+
const result = stmt.run(tag, countriesJson, source);
|
|
87
|
+
if (result.changes > 0) {
|
|
88
|
+
inserted++;
|
|
89
|
+
insertedTags.push(tag);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
txn(normalizedList);
|
|
94
|
+
return { inserted, tags: insertedTags };
|
|
95
|
+
} catch (e) {
|
|
96
|
+
return { inserted, error: e.message };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
53
100
|
export function getTagsByStatus(
|
|
54
101
|
status,
|
|
55
102
|
limit = 100,
|
|
56
103
|
offset = 0,
|
|
57
104
|
country = null,
|
|
105
|
+
search = null,
|
|
58
106
|
) {
|
|
59
107
|
const db = getDb();
|
|
60
108
|
if (!db) return [];
|
|
@@ -64,6 +112,10 @@ export function getTagsByStatus(
|
|
|
64
112
|
sql += " AND countries LIKE ?";
|
|
65
113
|
params.push(`%"${country}"%`);
|
|
66
114
|
}
|
|
115
|
+
if (search) {
|
|
116
|
+
sql += " AND tag LIKE ?";
|
|
117
|
+
params.push(`%${search}%`);
|
|
118
|
+
}
|
|
67
119
|
sql +=
|
|
68
120
|
" ORDER BY refreshed_at DESC NULLS LAST, score ASC, created_at ASC LIMIT ? OFFSET ?";
|
|
69
121
|
params.push(limit, offset);
|
|
@@ -71,21 +123,29 @@ export function getTagsByStatus(
|
|
|
71
123
|
return rows.map(parseTagRow);
|
|
72
124
|
}
|
|
73
125
|
|
|
74
|
-
export function getTagStats(country = null) {
|
|
126
|
+
export function getTagStats(country = null, search = null) {
|
|
75
127
|
const db = getDb();
|
|
76
128
|
if (!db) return null;
|
|
129
|
+
const conditions = [];
|
|
130
|
+
const params = [];
|
|
131
|
+
if (country) {
|
|
132
|
+
conditions.push("countries LIKE ?");
|
|
133
|
+
params.push(`%"${country}"%`);
|
|
134
|
+
}
|
|
135
|
+
if (search) {
|
|
136
|
+
conditions.push("tag LIKE ?");
|
|
137
|
+
params.push(`%${search}%`);
|
|
138
|
+
}
|
|
139
|
+
const whereClause =
|
|
140
|
+
conditions.length > 0 ? " WHERE " + conditions.join(" AND ") : "";
|
|
141
|
+
|
|
77
142
|
let sql = `SELECT
|
|
78
143
|
COUNT(*) as total,
|
|
79
144
|
SUM(CASE WHEN status = 'productive' THEN 1 ELSE 0 END) as productive,
|
|
80
145
|
SUM(CASE WHEN status = 'dead' THEN 1 ELSE 0 END) as dead,
|
|
81
146
|
SUM(CASE WHEN status = 'new' THEN 1 ELSE 0 END) as newCount,
|
|
82
147
|
SUM(CASE WHEN status = 'scoring' THEN 1 ELSE 0 END) as scoring
|
|
83
|
-
FROM tags`;
|
|
84
|
-
const params = [];
|
|
85
|
-
if (country) {
|
|
86
|
-
sql += " WHERE countries LIKE ?";
|
|
87
|
-
params.push(`%"${country}"%`);
|
|
88
|
-
}
|
|
148
|
+
FROM tags${whereClause}`;
|
|
89
149
|
const row = db.prepare(sql).get(...params);
|
|
90
150
|
// 获取所有出现过的国家
|
|
91
151
|
const allRows = db.prepare("SELECT countries FROM tags").all();
|
|
@@ -276,15 +336,28 @@ export function reportTagScore(tag, fields) {
|
|
|
276
336
|
}
|
|
277
337
|
}
|
|
278
338
|
|
|
279
|
-
export function getAllTags(
|
|
339
|
+
export function getAllTags(
|
|
340
|
+
limit = 200,
|
|
341
|
+
offset = 0,
|
|
342
|
+
country = null,
|
|
343
|
+
search = null,
|
|
344
|
+
) {
|
|
280
345
|
const db = getDb();
|
|
281
346
|
if (!db) return [];
|
|
282
347
|
let sql = "SELECT * FROM tags";
|
|
283
348
|
const params = [];
|
|
349
|
+
const conditions = [];
|
|
284
350
|
if (country) {
|
|
285
|
-
|
|
351
|
+
conditions.push("countries LIKE ?");
|
|
286
352
|
params.push(`%"${country}"%`);
|
|
287
353
|
}
|
|
354
|
+
if (search) {
|
|
355
|
+
conditions.push("tag LIKE ?");
|
|
356
|
+
params.push(`%${search}%`);
|
|
357
|
+
}
|
|
358
|
+
if (conditions.length > 0) {
|
|
359
|
+
sql += " WHERE " + conditions.join(" AND ");
|
|
360
|
+
}
|
|
288
361
|
sql +=
|
|
289
362
|
" ORDER BY refreshed_at DESC NULLS LAST, score DESC, created_at DESC LIMIT ? OFFSET ?";
|
|
290
363
|
params.push(limit, offset);
|
|
@@ -371,6 +444,7 @@ export function insertDiscoverLog({
|
|
|
371
444
|
country,
|
|
372
445
|
round,
|
|
373
446
|
strategy,
|
|
447
|
+
niche,
|
|
374
448
|
tagsGenerated,
|
|
375
449
|
tagsRaw,
|
|
376
450
|
tagsAdded,
|
|
@@ -382,18 +456,26 @@ export function insertDiscoverLog({
|
|
|
382
456
|
const db = getDb();
|
|
383
457
|
if (!db) return { ok: false, error: "db not ready" };
|
|
384
458
|
|
|
459
|
+
// 确保 niche 列存在(兼容旧表)
|
|
460
|
+
try {
|
|
461
|
+
db.prepare("ALTER TABLE discover_log ADD COLUMN niche TEXT").run();
|
|
462
|
+
} catch {
|
|
463
|
+
// 列已存在则忽略
|
|
464
|
+
}
|
|
465
|
+
|
|
385
466
|
try {
|
|
386
467
|
const result = db
|
|
387
468
|
.prepare(
|
|
388
469
|
`INSERT INTO discover_log
|
|
389
|
-
(country, round, strategy, tags_generated, tags_raw, tags_added,
|
|
470
|
+
(country, round, strategy, niche, tags_generated, tags_raw, tags_added,
|
|
390
471
|
productive_sample, dead_sample, avg_productive_score, avg_dead_score)
|
|
391
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
472
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
392
473
|
)
|
|
393
474
|
.run(
|
|
394
475
|
country,
|
|
395
476
|
round,
|
|
396
477
|
strategy || null,
|
|
478
|
+
niche || null,
|
|
397
479
|
JSON.stringify(tagsGenerated || []),
|
|
398
480
|
tagsRaw ? JSON.stringify(tagsRaw) : null,
|
|
399
481
|
tagsAdded || 0,
|
|
@@ -714,6 +796,9 @@ export function getCountryHealth(country) {
|
|
|
714
796
|
// 推荐的 niche 方向(基于现有 productive tag 的分类分析)
|
|
715
797
|
const nicheRecommendation = recommendNiche(country, productive);
|
|
716
798
|
|
|
799
|
+
// 追踪 LLM 当前在攻击哪个 niche(取最近3轮 discover_log 的 niche 字段,众数)
|
|
800
|
+
const currentNiche = getCurrentNiche(country);
|
|
801
|
+
|
|
717
802
|
return {
|
|
718
803
|
country,
|
|
719
804
|
total,
|
|
@@ -732,9 +817,43 @@ export function getCountryHealth(country) {
|
|
|
732
817
|
healthStatus,
|
|
733
818
|
issues,
|
|
734
819
|
nicheRecommendation,
|
|
820
|
+
currentNiche,
|
|
735
821
|
};
|
|
736
822
|
}
|
|
737
823
|
|
|
824
|
+
/**
|
|
825
|
+
* 获取 LLM 当前正在攻击的 niche(最近3轮 discover_log 的 niche 众数)
|
|
826
|
+
*/
|
|
827
|
+
function getCurrentNiche(country) {
|
|
828
|
+
const db = getDb();
|
|
829
|
+
if (!db) return null;
|
|
830
|
+
|
|
831
|
+
try {
|
|
832
|
+
const rows = db
|
|
833
|
+
.prepare(
|
|
834
|
+
`SELECT niche FROM discover_log
|
|
835
|
+
WHERE country = ? AND niche IS NOT NULL
|
|
836
|
+
ORDER BY round DESC
|
|
837
|
+
LIMIT 3`,
|
|
838
|
+
)
|
|
839
|
+
.all(country);
|
|
840
|
+
|
|
841
|
+
if (rows.length === 0) return null;
|
|
842
|
+
|
|
843
|
+
// 取出现次数最多的 niche
|
|
844
|
+
const counts = {};
|
|
845
|
+
for (const r of rows) {
|
|
846
|
+
const n = r.niche;
|
|
847
|
+
if (n) counts[n] = (counts[n] || 0) + 1;
|
|
848
|
+
}
|
|
849
|
+
const sorted = Object.entries(counts).sort((a, b) => b[1] - a[1]);
|
|
850
|
+
return sorted.length > 0 ? sorted[0][0] : null;
|
|
851
|
+
} catch {
|
|
852
|
+
// niche 列尚不存在(旧表),返回 null
|
|
853
|
+
return null;
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
738
857
|
/**
|
|
739
858
|
* 基于现有 productive tag 推荐新的 niche 方向
|
|
740
859
|
* 分析已有 tag 的类别分布,找出未覆盖或覆盖不足的方向
|
|
@@ -1121,13 +1240,68 @@ export function insertReflectionRules(rules) {
|
|
|
1121
1240
|
return { ok: true, inserted: inserted.length };
|
|
1122
1241
|
}
|
|
1123
1242
|
|
|
1243
|
+
/**
|
|
1244
|
+
* 计算两段规则文本的相似度(简单的词重叠率)
|
|
1245
|
+
* 返回 0-1,越高越相似
|
|
1246
|
+
*/
|
|
1247
|
+
function ruleTextSimilarity(a, b) {
|
|
1248
|
+
const wordsA = new Set(
|
|
1249
|
+
a
|
|
1250
|
+
.toLowerCase()
|
|
1251
|
+
.split(/\s+/)
|
|
1252
|
+
.filter((w) => w.length > 2),
|
|
1253
|
+
);
|
|
1254
|
+
const wordsB = new Set(
|
|
1255
|
+
b
|
|
1256
|
+
.toLowerCase()
|
|
1257
|
+
.split(/\s+/)
|
|
1258
|
+
.filter((w) => w.length > 2),
|
|
1259
|
+
);
|
|
1260
|
+
if (wordsA.size === 0 || wordsB.size === 0) return 0;
|
|
1261
|
+
const intersection = [...wordsA].filter((w) => wordsB.has(w)).length;
|
|
1262
|
+
return intersection / Math.max(wordsA.size, wordsB.size);
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1124
1265
|
/**
|
|
1125
1266
|
* 废弃旧规则(当新反思覆盖旧规则时)
|
|
1267
|
+
* 加入去重:如果新规则与旧规则语义相似(>70% 词重叠),跳过 supersede
|
|
1126
1268
|
*/
|
|
1127
|
-
export function supersedeRules(
|
|
1269
|
+
export function supersedeRules(
|
|
1270
|
+
country,
|
|
1271
|
+
newRules = [],
|
|
1272
|
+
reason = "new reflection",
|
|
1273
|
+
) {
|
|
1128
1274
|
const db = getDb();
|
|
1129
1275
|
if (!db) return { ok: false, error: "db not ready" };
|
|
1130
1276
|
|
|
1277
|
+
// 获取当前活跃规则
|
|
1278
|
+
const oldRules = db
|
|
1279
|
+
.prepare(
|
|
1280
|
+
"SELECT id, rule_text FROM reflection_rules WHERE country = ? AND is_active = 1",
|
|
1281
|
+
)
|
|
1282
|
+
.all(country);
|
|
1283
|
+
|
|
1284
|
+
// 规则去重:如果所有新规则与所有旧规则高度相似,跳过
|
|
1285
|
+
if (oldRules.length > 0 && newRules.length > 0) {
|
|
1286
|
+
let allSimilar = true;
|
|
1287
|
+
for (const nr of newRules) {
|
|
1288
|
+
const newText = typeof nr === "string" ? nr : nr.ruleText || "";
|
|
1289
|
+
const hasSimilar = oldRules.some(
|
|
1290
|
+
(or) => ruleTextSimilarity(newText, or.rule_text) > 0.7,
|
|
1291
|
+
);
|
|
1292
|
+
if (!hasSimilar) {
|
|
1293
|
+
allSimilar = false;
|
|
1294
|
+
break;
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
if (allSimilar) {
|
|
1298
|
+
console.error(
|
|
1299
|
+
`[reflection] 跳过 supersede: ${newRules.length} 条新规则与 ${oldRules.length} 条旧规则高度相似`,
|
|
1300
|
+
);
|
|
1301
|
+
return { ok: true, superseded: 0, skipped: "rules too similar" };
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1131
1305
|
const result = db
|
|
1132
1306
|
.prepare(
|
|
1133
1307
|
`UPDATE reflection_rules
|
|
@@ -1141,13 +1315,45 @@ export function supersedeRules(country, reason = "new reflection") {
|
|
|
1141
1315
|
|
|
1142
1316
|
/**
|
|
1143
1317
|
* 检查是否需要触发反思
|
|
1144
|
-
* 条件:最近 N 轮 productive_cnt ≤ threshold
|
|
1318
|
+
* 条件:最近 N 轮 productive_cnt ≤ threshold,且距上次反思 ≥ cooldownRounds 轮
|
|
1145
1319
|
* @returns {{ shouldReflect: boolean, reason: string, recentRounds: array }}
|
|
1146
1320
|
*/
|
|
1147
|
-
export function shouldTriggerReflection(
|
|
1321
|
+
export function shouldTriggerReflection(
|
|
1322
|
+
country,
|
|
1323
|
+
lookback = 5,
|
|
1324
|
+
threshold = 1,
|
|
1325
|
+
cooldownRounds = 10,
|
|
1326
|
+
) {
|
|
1148
1327
|
const db = getDb();
|
|
1149
1328
|
if (!db) return { shouldReflect: false, reason: "db not ready" };
|
|
1150
1329
|
|
|
1330
|
+
// 冷却期检查:距上次反思不足 cooldownRounds 轮则跳过
|
|
1331
|
+
const lastReflection = db
|
|
1332
|
+
.prepare(
|
|
1333
|
+
`SELECT MAX(rr.created_at) as lastReflectAt
|
|
1334
|
+
FROM reflection_rules rr
|
|
1335
|
+
WHERE rr.country = ? AND rr.is_active = 0 AND rr.superseded_at IS NOT NULL
|
|
1336
|
+
ORDER BY rr.superseded_at DESC
|
|
1337
|
+
LIMIT 1`,
|
|
1338
|
+
)
|
|
1339
|
+
.get(country);
|
|
1340
|
+
|
|
1341
|
+
if (lastReflection?.lastReflectAt) {
|
|
1342
|
+
const roundsSince = db
|
|
1343
|
+
.prepare(
|
|
1344
|
+
`SELECT COUNT(*) as cnt FROM discover_log
|
|
1345
|
+
WHERE country = ? AND created_at > ?`,
|
|
1346
|
+
)
|
|
1347
|
+
.get(country, lastReflection.lastReflectAt);
|
|
1348
|
+
if ((roundsSince?.cnt || 0) < cooldownRounds) {
|
|
1349
|
+
return {
|
|
1350
|
+
shouldReflect: false,
|
|
1351
|
+
reason: `冷却期: 距上次反思仅${roundsSince?.cnt || 0}轮,需≥${cooldownRounds}轮`,
|
|
1352
|
+
recentRounds: [],
|
|
1353
|
+
};
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1151
1357
|
// 获取最近 lookback 轮的 discover_log
|
|
1152
1358
|
const recentLogs = db
|
|
1153
1359
|
.prepare(
|
|
@@ -1202,14 +1408,14 @@ export function shouldTriggerReflection(country, lookback = 5, threshold = 1) {
|
|
|
1202
1408
|
}
|
|
1203
1409
|
|
|
1204
1410
|
const avgProductive = totalProductive / lookback;
|
|
1205
|
-
// 触发条件:平均 productive ≤ threshold,或连续 ≥
|
|
1411
|
+
// 触发条件:平均 productive ≤ threshold,或连续 ≥3 轮 productive ≤ 1
|
|
1206
1412
|
const shouldReflect =
|
|
1207
|
-
avgProductive <= threshold || consecutiveLowProductiveRounds >=
|
|
1413
|
+
avgProductive <= threshold || consecutiveLowProductiveRounds >= 3;
|
|
1208
1414
|
|
|
1209
1415
|
return {
|
|
1210
1416
|
shouldReflect,
|
|
1211
1417
|
reason: shouldReflect
|
|
1212
|
-
? consecutiveLowProductiveRounds >=
|
|
1418
|
+
? consecutiveLowProductiveRounds >= 3
|
|
1213
1419
|
? `连续${consecutiveLowProductiveRounds}轮productive≤1,需要反思换方向`
|
|
1214
1420
|
: `最近${lookback}轮平均productive=${avgProductive.toFixed(1)} ≤ ${threshold}`
|
|
1215
1421
|
: `最近${lookback}轮平均productive=${avgProductive.toFixed(1)} > ${threshold}`,
|
|
@@ -1222,6 +1428,20 @@ export function shouldTriggerReflection(country, lookback = 5, threshold = 1) {
|
|
|
1222
1428
|
};
|
|
1223
1429
|
}
|
|
1224
1430
|
|
|
1431
|
+
/**
|
|
1432
|
+
* 清理所有已废弃的反思规则(is_active=0)
|
|
1433
|
+
*/
|
|
1434
|
+
export function cleanupInactiveReflectionRules() {
|
|
1435
|
+
const db = getDb();
|
|
1436
|
+
if (!db) return { ok: false, error: "db not ready" };
|
|
1437
|
+
|
|
1438
|
+
const result = db
|
|
1439
|
+
.prepare("DELETE FROM reflection_rules WHERE is_active = 0")
|
|
1440
|
+
.run();
|
|
1441
|
+
console.error(`[reflection] 🧹 清理了 ${result.changes} 条废弃规则`);
|
|
1442
|
+
return { ok: true, deleted: result.changes };
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1225
1445
|
/**
|
|
1226
1446
|
* 获取反思所需的数据摘要(供 LLM 分析)
|
|
1227
1447
|
*/
|