tt-help-cli-ycl 1.4.2 → 1.4.4
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/tag.js +131 -68
- package/src/lib/args.js +0 -4
- package/src/lib/constants.js +2 -1
- package/src/lib/tag-discover.js +218 -10
- package/src/watch/data-store.js +42 -0
- package/src/watch/db-schema.js +19 -0
- package/src/watch/db-tags.js +749 -0
- package/src/watch/public/app.js +27 -0
- package/src/watch/public/index.html +1 -1
- package/src/watch/server.js +255 -2
- package/src/watch/tag-service.js +509 -23
package/src/watch/tag-service.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Tag 发现与打分服务
|
|
3
3
|
* - LLM 调用与 prompt 组装
|
|
4
4
|
* - 单国家标签发现(discoverTagsForCountry)
|
|
5
|
+
* - 自闭环:健康检查 → 数据清理 → niche 切换 → 生成 → 记录
|
|
5
6
|
* - 标签打分(scoreTag / scoreTagBatch)
|
|
6
7
|
*/
|
|
7
8
|
|
|
@@ -31,7 +32,7 @@ export function getLang(country) {
|
|
|
31
32
|
|
|
32
33
|
// ====== LLM 调用 ======
|
|
33
34
|
|
|
34
|
-
export async function callLLM(prompt) {
|
|
35
|
+
export async function callLLM(prompt, temperature = 0.7) {
|
|
35
36
|
const apiKey = process.env.APIKEY || "";
|
|
36
37
|
const { fetch } = await import("undici");
|
|
37
38
|
|
|
@@ -45,7 +46,7 @@ export async function callLLM(prompt) {
|
|
|
45
46
|
model: LLM_MODEL,
|
|
46
47
|
messages: [{ role: "user", content: prompt }],
|
|
47
48
|
max_tokens: 1024,
|
|
48
|
-
temperature
|
|
49
|
+
temperature,
|
|
49
50
|
}),
|
|
50
51
|
});
|
|
51
52
|
|
|
@@ -86,28 +87,30 @@ export function parseTagsFromResponse(content) {
|
|
|
86
87
|
*/
|
|
87
88
|
export function parseDiscoverResponse(content) {
|
|
88
89
|
let strategy = null;
|
|
90
|
+
let niche = null;
|
|
89
91
|
let tags = [];
|
|
90
92
|
|
|
91
93
|
try {
|
|
92
94
|
const parsed = JSON.parse(content);
|
|
93
95
|
|
|
94
|
-
// 新格式: { strategy, tags }
|
|
96
|
+
// 新格式: { strategy, niche, tags }
|
|
95
97
|
if (parsed.strategy && Array.isArray(parsed.tags)) {
|
|
96
98
|
strategy = parsed.strategy;
|
|
99
|
+
niche = parsed.niche || null;
|
|
97
100
|
tags = parsed.tags.map(normalizeTag).filter((t) => t && t.length >= 2);
|
|
98
|
-
return { strategy, tags };
|
|
101
|
+
return { strategy, niche, tags };
|
|
99
102
|
}
|
|
100
103
|
|
|
101
104
|
// 旧格式: 纯数组
|
|
102
105
|
if (Array.isArray(parsed)) {
|
|
103
106
|
tags = parsed.map(normalizeTag).filter((t) => t && t.length >= 2);
|
|
104
|
-
return { strategy, tags };
|
|
107
|
+
return { strategy, niche, tags };
|
|
105
108
|
}
|
|
106
109
|
|
|
107
110
|
// 旧格式: { tags: [...] }
|
|
108
111
|
if (Array.isArray(parsed.tags)) {
|
|
109
112
|
tags = parsed.tags.map(normalizeTag).filter((t) => t && t.length >= 2);
|
|
110
|
-
return { strategy, tags };
|
|
113
|
+
return { strategy, niche, tags };
|
|
111
114
|
}
|
|
112
115
|
} catch {
|
|
113
116
|
// JSON 解析失败,尝试从文本中提取
|
|
@@ -116,6 +119,10 @@ export function parseDiscoverResponse(content) {
|
|
|
116
119
|
if (strategyMatch) {
|
|
117
120
|
strategy = strategyMatch[1];
|
|
118
121
|
}
|
|
122
|
+
const nicheMatch = content.match(/"niche"\s*:\s*"([^"]+)"/);
|
|
123
|
+
if (nicheMatch) {
|
|
124
|
+
niche = nicheMatch[1];
|
|
125
|
+
}
|
|
119
126
|
}
|
|
120
127
|
|
|
121
128
|
// 降级:从文本行中提取 tags
|
|
@@ -127,7 +134,7 @@ export function parseDiscoverResponse(content) {
|
|
|
127
134
|
}
|
|
128
135
|
}
|
|
129
136
|
|
|
130
|
-
return { strategy, tags };
|
|
137
|
+
return { strategy, niche, tags };
|
|
131
138
|
}
|
|
132
139
|
|
|
133
140
|
// ====== Prompt 组装 ======
|
|
@@ -138,6 +145,9 @@ export function buildDiscoverPrompt(
|
|
|
138
145
|
history,
|
|
139
146
|
userPrompt,
|
|
140
147
|
pastStrategies,
|
|
148
|
+
health,
|
|
149
|
+
nicheRecommendation,
|
|
150
|
+
hardRules = [],
|
|
141
151
|
) {
|
|
142
152
|
const lang = getLang(country);
|
|
143
153
|
const langNames = {
|
|
@@ -166,13 +176,36 @@ export function buildDiscoverPrompt(
|
|
|
166
176
|
)}. Use these as reference for the STYLE and TYPE of tag that works — prefer commonly used words like these.`
|
|
167
177
|
: "";
|
|
168
178
|
|
|
169
|
-
// 负样本:该国 dead tag
|
|
179
|
+
// 负样本:该国 dead tag(只传最近50个,避免 prompt 过长)
|
|
170
180
|
const dead = history.dead || [];
|
|
181
|
+
const recentDead = dead.slice(0, 50);
|
|
171
182
|
const deadHint =
|
|
172
|
-
|
|
173
|
-
? `\nTags that failed for ${country} (found no matching users): ${
|
|
183
|
+
recentDead.length > 0
|
|
184
|
+
? `\nTags that RECENTLY failed for ${country} (found no matching users): ${recentDead.map((t) => t.tag).join(", ")}. Avoid these EXACT tags and similar patterns.`
|
|
185
|
+
: "";
|
|
186
|
+
|
|
187
|
+
// 高频重复 dead 标签警告(这些是反复生成又反复失败的)
|
|
188
|
+
const deadTagNames = new Set(dead.map((t) => t.tag));
|
|
189
|
+
const recentGenerated = (history.recentGenerated || []).filter((t) =>
|
|
190
|
+
deadTagNames.has(t),
|
|
191
|
+
);
|
|
192
|
+
const repeatedDeadHint =
|
|
193
|
+
recentGenerated.length > 0
|
|
194
|
+
? `\n⚠️ These tags have been generated MULTIPLE TIMES and always fail — NEVER generate them again: ${recentGenerated.join(", ")}`
|
|
174
195
|
: "";
|
|
175
196
|
|
|
197
|
+
// 语言模式分析:统计 dead 标签的语言分布
|
|
198
|
+
const langPatternHint = (() => {
|
|
199
|
+
if (dead.length < 20) return "";
|
|
200
|
+
const englishDead = dead.filter((t) => /^[a-zA-Z]+$/.test(t.tag)).length;
|
|
201
|
+
const localDead = dead.filter((t) => !/^[a-zA-Z]+$/.test(t.tag)).length;
|
|
202
|
+
const englishRatio = Math.round((englishDead / dead.length) * 100);
|
|
203
|
+
if (englishRatio > 60 && lang !== "en") {
|
|
204
|
+
return `\n⚠️ LANGUAGE WARNING: ${englishRatio}% of dead tags for ${country} are English-only words — they DON'T work in a ${langName}-speaking country. You MUST generate tags in ${langName} or bilingual (like greekshop, ελληνικαπροιοντα).`;
|
|
205
|
+
}
|
|
206
|
+
return "";
|
|
207
|
+
})();
|
|
208
|
+
|
|
176
209
|
// 死因分析
|
|
177
210
|
const errorPatterns = [
|
|
178
211
|
...new Set(dead.filter((t) => t.last_error).map((t) => t.last_error)),
|
|
@@ -216,6 +249,45 @@ Use this analysis to decide your strategy for this round.`;
|
|
|
216
249
|
? `\n⚠️ WARNING: Currently ${deadRatio}% of our generated tags fail (no real TikTok posts). This is critically high. You MUST be more conservative — only suggest hashtags you have HIGH CONFIDENCE actually exist on TikTok.`
|
|
217
250
|
: "";
|
|
218
251
|
|
|
252
|
+
// ====== 自闭环:健康指标 & niche 推荐 ======
|
|
253
|
+
let healthHint = "";
|
|
254
|
+
let nicheHint = "";
|
|
255
|
+
|
|
256
|
+
if (health && health.healthStatus !== "healthy") {
|
|
257
|
+
const issuesText = health.issues.map((i) => ` - ${i}`).join("\n");
|
|
258
|
+
healthHint = `
|
|
259
|
+
=== 🏥 Country Health Analysis (${health.healthStatus}) ===
|
|
260
|
+
Country: ${health.country}
|
|
261
|
+
Total tags: ${health.total} | Productive: ${health.productive} (${health.productiveRatio}%) | Dead: ${health.dead} (${health.deadRatio}%) | Scored: ${health.scored}
|
|
262
|
+
Recent effectiveness: productive ${health.recentProductiveRatio}% | dead ${health.recentDeadRatio}% | empty rounds ${health.recentEmptyRounds}
|
|
263
|
+
Saturation: ${health.saturation}%
|
|
264
|
+
|
|
265
|
+
Issues detected:
|
|
266
|
+
${issuesText}
|
|
267
|
+
|
|
268
|
+
⚠️ Based on this health analysis, you MUST adjust your strategy:
|
|
269
|
+
${health.healthStatus === "critical" ? "- Be EXTREMELY conservative — only suggest the most common, well-established hashtags\n- Focus on niches that have proven to work (see productive tags above)\n- Avoid experimental or creative combinations" : "- Be more conservative than usual — prioritize proven tag patterns\n- Try different niche directions that haven't been explored yet"}
|
|
270
|
+
`;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (nicheRecommendation) {
|
|
274
|
+
const uncoveredText = nicheRecommendation.uncovered
|
|
275
|
+
.slice(0, 5)
|
|
276
|
+
.map((n) => `${n.niche} (current: ${n.currentCoverage} tags)`)
|
|
277
|
+
.join(", ");
|
|
278
|
+
const saturatedText = nicheRecommendation.saturated
|
|
279
|
+
.map((n) => `${n.niche} (${n.currentCoverage} tags)`)
|
|
280
|
+
.join(", ");
|
|
281
|
+
|
|
282
|
+
nicheHint = `
|
|
283
|
+
=== 🎯 Niche Direction Recommendation ===
|
|
284
|
+
Uncovered niches (EXPLORE these — few existing productive tags): ${uncoveredText || "none remaining"}
|
|
285
|
+
Saturated niches (AVOID these — already well-covered): ${saturatedText || "none saturated"}
|
|
286
|
+
|
|
287
|
+
${nicheRecommendation.uncovered.length > 0 ? `Focus your tags on the UNCOVERED niches above. Generate hashtags related to those product/service categories in ${langName}.` : "All major niches are covered. Try hyper-local or seasonal trends, or very specific sub-categories."}
|
|
288
|
+
`;
|
|
289
|
+
}
|
|
290
|
+
|
|
219
291
|
return `You are discovering TikTok hashtags used by people who sell things in ${country}.
|
|
220
292
|
|
|
221
293
|
⛔ CRITICAL RULE: ONLY suggest hashtags that you are CONFIDENT actually exist on TikTok with real posts.
|
|
@@ -237,7 +309,19 @@ Think about the MOST COMMON hashtags that real sellers in ${country} use on TikT
|
|
|
237
309
|
- Common seller identities (shop, boutique, small business, creator...)
|
|
238
310
|
- Well-known community tags (support local, marketplace, second round...)
|
|
239
311
|
|
|
240
|
-
${qualityWarning}
|
|
312
|
+
${qualityWarning}
|
|
313
|
+
|
|
314
|
+
${
|
|
315
|
+
hardRules.length > 0
|
|
316
|
+
? `
|
|
317
|
+
=== ⛔ HARD RULES (from self-reflection — MUST follow these) ===
|
|
318
|
+
These rules were generated by analyzing past failures and successes. Violating them will produce dead tags.
|
|
319
|
+
${hardRules.map((r) => ` ${r.ruleType === "hard" ? "⛔" : "💡"} ${r.ruleText}`).join("\n")}
|
|
320
|
+
`
|
|
321
|
+
: ""
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
${productiveHint}${deadHint}${repeatedDeadHint}${langPatternHint}${errorHint}${userHint}${strategyReview}${healthHint}${nicheHint}
|
|
241
325
|
|
|
242
326
|
## Quality check before responding
|
|
243
327
|
|
|
@@ -245,9 +329,10 @@ For each tag you consider, ask yourself: "Have I actually seen this hashtag used
|
|
|
245
329
|
|
|
246
330
|
## Output format
|
|
247
331
|
|
|
248
|
-
Return ONLY a JSON object with
|
|
332
|
+
Return ONLY a JSON object with three fields:
|
|
249
333
|
{
|
|
250
|
-
"strategy": "Your analysis (2-3 sentences). Acknowledge what worked/failed before
|
|
334
|
+
"strategy": "Your analysis (2-3 sentences). Acknowledge what worked/failed before, explain why your chosen tags are likely real and commonly used, and state which niche direction you're targeting.",
|
|
335
|
+
"niche": "The primary niche category you focused on (e.g. 'fashion', 'food', 'handmade_crafts', 'electronics_tech', 'pets_animals', 'services_professional', 'marketplace_local')",
|
|
251
336
|
"tags": ["tag1", "tag2", "tag3", "tag4", "..."]
|
|
252
337
|
}
|
|
253
338
|
|
|
@@ -257,6 +342,7 @@ Rules:
|
|
|
257
342
|
- QUALITY over creativity — 3 real, commonly used tags beat 4 invented ones
|
|
258
343
|
- NEVER invent compound words or translate concepts into hashtags
|
|
259
344
|
- Prefer simpler, more common tags over hyper-specific ones
|
|
345
|
+
- If health analysis shows saturation in certain niches, EXPLICITLY pivot to uncovered niches
|
|
260
346
|
- Look for tags with broad appeal (many potential posters)`;
|
|
261
347
|
}
|
|
262
348
|
|
|
@@ -272,35 +358,140 @@ export async function discoverTagsForCountry(
|
|
|
272
358
|
return { country, error: `不支持的国家代码: ${country}` };
|
|
273
359
|
}
|
|
274
360
|
|
|
361
|
+
// ====== 自闭环 Step 1: 健康检查 & 数据清理 ======
|
|
362
|
+
const {
|
|
363
|
+
getDiscoverRound,
|
|
364
|
+
getDiscoverEffectiveness,
|
|
365
|
+
getCountryHealth,
|
|
366
|
+
cleanupLowScoredTags,
|
|
367
|
+
cleanupStaleScoringTags,
|
|
368
|
+
cleanupErrorTags,
|
|
369
|
+
getActiveRules,
|
|
370
|
+
} = await import("./db-tags.js");
|
|
371
|
+
|
|
372
|
+
const health = getCountryHealth(country);
|
|
373
|
+
console.error(
|
|
374
|
+
`[tag-service] 🏥 ${country} health: ${health?.healthStatus || "unknown"} | deadRatio=${health?.deadRatio || "?"}% | productiveRatio=${health?.productiveRatio || "?"}% | saturation=${health?.saturation || "?"}%`,
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
// 根据健康状态执行数据清理
|
|
378
|
+
const cleanupActions = {};
|
|
379
|
+
if (health) {
|
|
380
|
+
// 清理超时 scoring tag(始终执行)
|
|
381
|
+
const staleResult = cleanupStaleScoringTags();
|
|
382
|
+
cleanupActions.staleScoringReset = staleResult.reset;
|
|
383
|
+
|
|
384
|
+
// 清理 error tag(始终执行)
|
|
385
|
+
const errorResult = cleanupErrorTags();
|
|
386
|
+
cleanupActions.errorReset = errorResult.reset;
|
|
387
|
+
|
|
388
|
+
// 低分 scored tag 清理:当 lowScored > 2x productive 时执行
|
|
389
|
+
if (health.lowScored > health.productive * 2) {
|
|
390
|
+
const lowResult = cleanupLowScoredTags(20);
|
|
391
|
+
cleanupActions.lowScoredCleanup = lowResult.cleaned;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ====== 自闭环 Step 2: 自动 niche 切换 ======
|
|
396
|
+
let autoNichePrompt = null;
|
|
397
|
+
let nicheRecommendation = null;
|
|
398
|
+
|
|
399
|
+
if (
|
|
400
|
+
health &&
|
|
401
|
+
health.healthStatus !== "healthy" &&
|
|
402
|
+
health.nicheRecommendation
|
|
403
|
+
) {
|
|
404
|
+
nicheRecommendation = health.nicheRecommendation;
|
|
405
|
+
|
|
406
|
+
// 当健康状态为 warning/critical 且有未覆盖 niche 时,自动切换
|
|
407
|
+
if (nicheRecommendation.uncovered.length > 0) {
|
|
408
|
+
const targetNiche = nicheRecommendation.uncovered[0];
|
|
409
|
+
const nicheDescriptions = {
|
|
410
|
+
fashion:
|
|
411
|
+
"clothing, shoes, accessories, vintage fashion, secondhand clothes",
|
|
412
|
+
food: "bakery, food delivery, local food, restaurant, cooking supplies",
|
|
413
|
+
handmade_crafts:
|
|
414
|
+
"handmade items, DIY crafts, ceramics, candles, resin art",
|
|
415
|
+
home_living:
|
|
416
|
+
"home decoration, furniture, interior design, cleaning services",
|
|
417
|
+
electronics_tech:
|
|
418
|
+
"electronics, gadgets, phones, computers, tech accessories",
|
|
419
|
+
pets_animals: "pet supplies, dog accessories, cat food, animal care",
|
|
420
|
+
beauty_health:
|
|
421
|
+
"beauty products, cosmetics, skincare, hair care, wellness",
|
|
422
|
+
books_collectibles:
|
|
423
|
+
"books, collectibles, vinyl records, trading cards, antiques",
|
|
424
|
+
services_professional:
|
|
425
|
+
"professional services, repair, cleaning, photography, florist",
|
|
426
|
+
marketplace_local:
|
|
427
|
+
"local market, marketplace, shop, boutique, online store",
|
|
428
|
+
};
|
|
429
|
+
autoNichePrompt = `Focus on the "${targetNiche.niche}" niche: ${nicheDescriptions[targetNiche.niche] || targetNiche.niche}. We have only ${targetNiche.currentCoverage} productive tags in this category — explore more hashtags in this direction.`;
|
|
430
|
+
console.error(
|
|
431
|
+
`[tag-service] 🎯 ${country} auto niche switch → ${targetNiche.niche} (coverage: ${targetNiche.currentCoverage})`,
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// 合并用户 prompt 和自动 niche prompt
|
|
437
|
+
const effectiveUserPrompt = autoNichePrompt || userPrompt;
|
|
438
|
+
|
|
439
|
+
// ====== 自闭环 Step 3: 动态温度调整 ======
|
|
440
|
+
let temperature = 0.7; // 默认
|
|
441
|
+
if (health) {
|
|
442
|
+
if (health.healthStatus === "critical") {
|
|
443
|
+
temperature = 0.3; // 极度保守
|
|
444
|
+
} else if (health.healthStatus === "warning") {
|
|
445
|
+
temperature = 0.5; // 较保守
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// ====== 原有 discover 流程 ======
|
|
275
450
|
// 读取历史打分记录
|
|
276
451
|
const productive = store.getTagsByCountry(country, 50);
|
|
277
452
|
const dead = store.getDeadTags(country);
|
|
278
453
|
// 获取该国所有已存在的 tag 名(应用层去重用)
|
|
279
454
|
const allTags = store.getTagsByCountry(country, 0);
|
|
280
455
|
const allExisting = new Set(allTags.map((t) => t.tag));
|
|
281
|
-
|
|
456
|
+
|
|
457
|
+
// 获取最近生成的标签(用于检测重复生成)
|
|
458
|
+
const { getRecentGeneratedTags } = await import("./db-tags.js");
|
|
459
|
+
const recentGenerated = getRecentGeneratedTags(country, 10);
|
|
460
|
+
|
|
461
|
+
const history = { productive, dead, recentGenerated };
|
|
282
462
|
|
|
283
463
|
// 读取历史策略效果(自学习)
|
|
284
|
-
const { getDiscoverRound, getDiscoverEffectiveness } =
|
|
285
|
-
await import("./db-tags.js");
|
|
286
464
|
const round = getDiscoverRound(country);
|
|
287
465
|
const pastStrategies = getDiscoverEffectiveness(country);
|
|
288
466
|
|
|
289
|
-
//
|
|
467
|
+
// ====== 自闭环 Step 4: 注入硬规则(反思产出) ======
|
|
468
|
+
const hardRules = getActiveRules(country);
|
|
469
|
+
if (hardRules.length > 0) {
|
|
470
|
+
console.error(
|
|
471
|
+
`[tag-service] ⛔ ${country} 注入 ${hardRules.length} 条硬规则到 prompt`,
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// 组装 prompt(加入健康指标、niche 推荐、硬规则)
|
|
290
476
|
const prompt = buildDiscoverPrompt(
|
|
291
477
|
country,
|
|
292
478
|
count,
|
|
293
479
|
history,
|
|
294
|
-
|
|
480
|
+
effectiveUserPrompt,
|
|
295
481
|
pastStrategies,
|
|
482
|
+
health,
|
|
483
|
+
nicheRecommendation,
|
|
484
|
+
hardRules,
|
|
296
485
|
);
|
|
297
486
|
console.error(
|
|
298
|
-
`[tag-service] LLM discover ${country} round=${round} (lang=${getLang(country)}, count=${count})`,
|
|
487
|
+
`[tag-service] LLM discover ${country} round=${round} (lang=${getLang(country)}, count=${count}, temp=${temperature}, health=${health?.healthStatus || "unknown"})`,
|
|
299
488
|
);
|
|
300
|
-
const content = await callLLM(prompt);
|
|
489
|
+
const content = await callLLM(prompt, temperature);
|
|
301
490
|
|
|
302
|
-
// 解析响应:提取 strategy
|
|
303
|
-
const
|
|
491
|
+
// 解析响应:提取 strategy, niche, tags
|
|
492
|
+
const parsed = parseDiscoverResponse(content);
|
|
493
|
+
const { strategy, tags } = parsed;
|
|
494
|
+
const niche = parsed.niche || null;
|
|
304
495
|
const unique = [...new Set(tags)]
|
|
305
496
|
.filter((t) => !allExisting.has(t))
|
|
306
497
|
.slice(0, count);
|
|
@@ -308,6 +499,9 @@ export async function discoverTagsForCountry(
|
|
|
308
499
|
console.error(
|
|
309
500
|
`[tag-service] LLM strategy: ${strategy?.substring(0, 150) || "(none)"}`,
|
|
310
501
|
);
|
|
502
|
+
if (niche) {
|
|
503
|
+
console.error(`[tag-service] LLM niche focus: ${niche}`);
|
|
504
|
+
}
|
|
311
505
|
console.error(
|
|
312
506
|
`[tag-service] LLM generated ${unique.length} tags: ${unique.join(", ")}`,
|
|
313
507
|
);
|
|
@@ -319,7 +513,7 @@ export async function discoverTagsForCountry(
|
|
|
319
513
|
if (result.inserted) inserted.push(result.tag);
|
|
320
514
|
}
|
|
321
515
|
|
|
322
|
-
// 记录 discover log(策略 +
|
|
516
|
+
// 记录 discover log(策略 + 结果 + 健康状态 + niche)
|
|
323
517
|
const { insertDiscoverLog } = await import("./db-tags.js");
|
|
324
518
|
insertDiscoverLog({
|
|
325
519
|
country,
|
|
@@ -337,13 +531,41 @@ export async function discoverTagsForCountry(
|
|
|
337
531
|
dead.length > 0 ? dead.reduce((s, t) => s + t.score, 0) / dead.length : 0,
|
|
338
532
|
});
|
|
339
533
|
|
|
534
|
+
// ====== 自闭环 Step 5: 反思触发(本轮结束后检查是否需要反思) ======
|
|
535
|
+
let reflectionResult = null;
|
|
536
|
+
try {
|
|
537
|
+
reflectionResult = await reflectAndLearn(store, country);
|
|
538
|
+
if (reflectionResult.reflected) {
|
|
539
|
+
console.error(
|
|
540
|
+
`[tag-service] 🔄 ${country} 反思完成: 新增 ${reflectionResult.rulesAdded} 条规则, 分析: ${reflectionResult.analysis?.substring(0, 100)}`,
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
} catch (e) {
|
|
544
|
+
console.error(`[tag-service] 反思异常: ${e.message}`);
|
|
545
|
+
reflectionResult = { reflected: false, error: e.message };
|
|
546
|
+
}
|
|
547
|
+
|
|
340
548
|
return {
|
|
341
549
|
country,
|
|
342
550
|
round,
|
|
343
551
|
strategy,
|
|
552
|
+
niche,
|
|
344
553
|
added: inserted.length,
|
|
345
554
|
tags: inserted,
|
|
346
555
|
total: unique.length,
|
|
556
|
+
// 自闭环信息
|
|
557
|
+
health: {
|
|
558
|
+
status: health?.healthStatus || "unknown",
|
|
559
|
+
deadRatio: health?.deadRatio || "?",
|
|
560
|
+
productiveRatio: health?.productiveRatio || "?",
|
|
561
|
+
saturation: health?.saturation || "?",
|
|
562
|
+
issues: health?.issues || [],
|
|
563
|
+
},
|
|
564
|
+
cleanupActions,
|
|
565
|
+
autoNiche: autoNichePrompt ? nicheRecommendation.uncovered[0].niche : null,
|
|
566
|
+
temperature,
|
|
567
|
+
hardRulesInjected: hardRules.length,
|
|
568
|
+
reflection: reflectionResult,
|
|
347
569
|
};
|
|
348
570
|
}
|
|
349
571
|
|
|
@@ -503,3 +725,267 @@ export async function scoreTag(store, { tag, targetCountries, serverUrl }) {
|
|
|
503
725
|
|
|
504
726
|
return result;
|
|
505
727
|
}
|
|
728
|
+
|
|
729
|
+
// ====== 反思闭环:自我学习与改进 ======
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* 组装反思 prompt
|
|
733
|
+
* 输入:最近轮次数据、成功/失败模式、当前规则、健康指标
|
|
734
|
+
* 输出期望:JSON 格式的规则列表
|
|
735
|
+
*/
|
|
736
|
+
export function buildReflectionPrompt(data) {
|
|
737
|
+
const {
|
|
738
|
+
country,
|
|
739
|
+
langName,
|
|
740
|
+
recentLogs,
|
|
741
|
+
productiveTop,
|
|
742
|
+
deadFrequency,
|
|
743
|
+
errorStats,
|
|
744
|
+
activeRules,
|
|
745
|
+
health,
|
|
746
|
+
} = data;
|
|
747
|
+
|
|
748
|
+
// 最近轮次效果摘要
|
|
749
|
+
const roundsSummary = recentLogs
|
|
750
|
+
.slice(0, 10)
|
|
751
|
+
.map((log) => {
|
|
752
|
+
const tagsGen = JSON.parse(log.tags_generated || "[]");
|
|
753
|
+
return `Round ${log.round}: strategy="${log.strategy?.substring(0, 100) || "(none)"}", generated ${tagsGen.length} tags: ${tagsGen.slice(0, 8).join(", ")}${tagsGen.length > 8 ? "..." : ""}`;
|
|
754
|
+
})
|
|
755
|
+
.join("\n");
|
|
756
|
+
|
|
757
|
+
// 成功模式:productive 标签的特征
|
|
758
|
+
const successPatterns = productiveTop
|
|
759
|
+
.slice(0, 20)
|
|
760
|
+
.map(
|
|
761
|
+
(t) =>
|
|
762
|
+
`${t.tag} (score=${t.score}, authors=${t.matched_authors}, pushed=${t.pushed_users})`,
|
|
763
|
+
)
|
|
764
|
+
.join(", ");
|
|
765
|
+
|
|
766
|
+
// 失败模式:dead 标签的特征
|
|
767
|
+
const failurePatterns = deadFrequency
|
|
768
|
+
.slice(0, 30)
|
|
769
|
+
.map((t) => `${t.tag} (error: ${t.last_error || "no_match"})`)
|
|
770
|
+
.join(", ");
|
|
771
|
+
|
|
772
|
+
// Error 类型统计
|
|
773
|
+
const errorSummary = errorStats
|
|
774
|
+
.map((e) => `${e.error_type}: ${e.count} tags`)
|
|
775
|
+
.join(", ");
|
|
776
|
+
|
|
777
|
+
// 当前活跃规则
|
|
778
|
+
const currentRules =
|
|
779
|
+
activeRules.length > 0
|
|
780
|
+
? activeRules.map((r) => ` - ${r.rule_text}`).join("\n")
|
|
781
|
+
: " (none yet)";
|
|
782
|
+
|
|
783
|
+
// 健康指标
|
|
784
|
+
const healthInfo = health
|
|
785
|
+
? `Health: ${health.healthStatus} | deadRatio=${health.deadRatio}% | productiveRatio=${health.productiveRatio}% | saturation=${health.saturation}%`
|
|
786
|
+
: "Health: unknown";
|
|
787
|
+
|
|
788
|
+
return `You are a self-reflection agent analyzing why TikTok hashtag discovery has been failing for ${country} (${langName}).
|
|
789
|
+
|
|
790
|
+
Your goal: identify FAILURE PATTERNS and SUCCESS PATTERNS, then produce HARD RULES that will guide future tag generation to be more effective.
|
|
791
|
+
|
|
792
|
+
## Recent discovery rounds (all failing)
|
|
793
|
+
${roundsSummary}
|
|
794
|
+
|
|
795
|
+
## Success patterns (productive tags that actually work)
|
|
796
|
+
${successPatterns}
|
|
797
|
+
|
|
798
|
+
## Failure patterns (dead tags that don't work)
|
|
799
|
+
${failurePatterns}
|
|
800
|
+
|
|
801
|
+
## Error type distribution
|
|
802
|
+
${errorSummary}
|
|
803
|
+
|
|
804
|
+
## Current health status
|
|
805
|
+
${healthInfo}
|
|
806
|
+
|
|
807
|
+
## Current hard rules already in effect
|
|
808
|
+
${currentRules}
|
|
809
|
+
|
|
810
|
+
## Analysis task
|
|
811
|
+
|
|
812
|
+
Analyze the data above and answer:
|
|
813
|
+
|
|
814
|
+
1. **What patterns do productive tags share?** (language, length, word type, structure)
|
|
815
|
+
2. **What patterns do dead tags share?** (language, length, word type, structure)
|
|
816
|
+
3. **What specific tags should NEVER be generated again?** (list the most frequently repeated dead tags)
|
|
817
|
+
4. **What language strategy should be used?** (e.g. "only use ${langName} words", "avoid English-only tags")
|
|
818
|
+
5. **What word structure works?** (e.g. "single industry words", "city+category combos", "avoid compound words")
|
|
819
|
+
|
|
820
|
+
## Output format
|
|
821
|
+
|
|
822
|
+
Return ONLY a JSON object:
|
|
823
|
+
{
|
|
824
|
+
"analysis_summary": "2-3 sentence summary of what you learned",
|
|
825
|
+
"success_pattern": "description of what productive tags look like",
|
|
826
|
+
"failure_pattern": "description of what dead tags look like",
|
|
827
|
+
"rules": [
|
|
828
|
+
{
|
|
829
|
+
"rule_type": "hard",
|
|
830
|
+
"rule_text": "NEVER use English-only tags for ${country} — they have 0% match rate",
|
|
831
|
+
"trigger_reason": "dead_ratio > 60% with English tags"
|
|
832
|
+
},
|
|
833
|
+
{
|
|
834
|
+
"rule_type": "hard",
|
|
835
|
+
"rule_text": "NEVER generate these specific tags: hautpflege, bodycare, serum, creme, wellness",
|
|
836
|
+
"trigger_reason": "these tags confirmed dead 5+ times"
|
|
837
|
+
},
|
|
838
|
+
{
|
|
839
|
+
"rule_type": "soft",
|
|
840
|
+
"rule_text": "Prefer ${langName} industry/profession words (like handwerker, schreinerei pattern)",
|
|
841
|
+
"trigger_reason": "productive tags are mostly local-language industry words"
|
|
842
|
+
}
|
|
843
|
+
]
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
Be specific and actionable. Each rule should be something that can be directly applied to future tag generation prompts.`;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* 解析反思 LLM 的响应
|
|
851
|
+
*/
|
|
852
|
+
export function parseReflectionResponse(content) {
|
|
853
|
+
try {
|
|
854
|
+
const parsed = JSON.parse(content);
|
|
855
|
+
return {
|
|
856
|
+
analysisSummary: parsed.analysis_summary || "",
|
|
857
|
+
successPattern: parsed.success_pattern || "",
|
|
858
|
+
failurePattern: parsed.failure_pattern || "",
|
|
859
|
+
rules: (parsed.rules || [])
|
|
860
|
+
.map((r) => ({
|
|
861
|
+
ruleType: r.rule_type || "hard",
|
|
862
|
+
ruleText: r.rule_text || "",
|
|
863
|
+
triggerReason: r.trigger_reason || "",
|
|
864
|
+
}))
|
|
865
|
+
.filter((r) => r.ruleText.length > 10),
|
|
866
|
+
};
|
|
867
|
+
} catch {
|
|
868
|
+
// JSON 解析失败,尝试从文本提取
|
|
869
|
+
const rules = [];
|
|
870
|
+
const lines = content.split("\n");
|
|
871
|
+
for (const line of lines) {
|
|
872
|
+
const match = line.match(/"rule_text"\s*:\s*"([^"]+)"/);
|
|
873
|
+
if (match && match[1].length > 10) {
|
|
874
|
+
rules.push({
|
|
875
|
+
ruleType: line.includes("soft") ? "soft" : "hard",
|
|
876
|
+
ruleText: match[1],
|
|
877
|
+
triggerReason: "",
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
return {
|
|
882
|
+
analysisSummary: content.substring(0, 200),
|
|
883
|
+
successPattern: "",
|
|
884
|
+
failurePattern: "",
|
|
885
|
+
rules,
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/**
|
|
891
|
+
* 执行一次反思:分析数据 → 调用 LLM → 写入规则
|
|
892
|
+
* @param {object} store - data-store 实例
|
|
893
|
+
* @param {string} country - 国家代码
|
|
894
|
+
* @returns {{ reflected: boolean, rulesAdded: number, analysis: string }}
|
|
895
|
+
*/
|
|
896
|
+
export async function reflectAndLearn(store, country) {
|
|
897
|
+
if (!COUNTRY_LANG[country]) {
|
|
898
|
+
return { reflected: false, error: `不支持的国家代码: ${country}` };
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
const {
|
|
902
|
+
shouldTriggerReflection,
|
|
903
|
+
getReflectionData,
|
|
904
|
+
insertReflectionRules,
|
|
905
|
+
supersedeRules,
|
|
906
|
+
getActiveRules,
|
|
907
|
+
} = await import("./db-tags.js");
|
|
908
|
+
|
|
909
|
+
// Step 1: 检查是否需要反思
|
|
910
|
+
const triggerCheck = shouldTriggerReflection(country);
|
|
911
|
+
if (!triggerCheck.shouldReflect) {
|
|
912
|
+
console.error(`[reflection] ${country} 不需要反思: ${triggerCheck.reason}`);
|
|
913
|
+
return { reflected: false, reason: triggerCheck.reason };
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
console.error(`[reflection] 🔄 ${country} 触发反思: ${triggerCheck.reason}`);
|
|
917
|
+
|
|
918
|
+
// Step 2: 收集反思数据
|
|
919
|
+
const data = getReflectionData(country);
|
|
920
|
+
if (!data) {
|
|
921
|
+
return { reflected: false, error: "无法获取反思数据" };
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// Step 3: 组装反思 prompt
|
|
925
|
+
const prompt = buildReflectionPrompt(data);
|
|
926
|
+
|
|
927
|
+
// Step 4: 调用 LLM 进行反思(温度较低,确保分析质量)
|
|
928
|
+
console.error(`[reflection] 🧠 ${country} 正在调用 LLM 进行反思分析...`);
|
|
929
|
+
const content = await callLLM(prompt, 0.3);
|
|
930
|
+
|
|
931
|
+
// Step 5: 解析反思结果
|
|
932
|
+
const reflection = parseReflectionResponse(content);
|
|
933
|
+
if (reflection.rules.length === 0) {
|
|
934
|
+
console.error(
|
|
935
|
+
`[reflection] ${country} LLM 反思未产出有效规则,原始响应: ${content.substring(0, 200)}`,
|
|
936
|
+
);
|
|
937
|
+
return {
|
|
938
|
+
reflected: true,
|
|
939
|
+
rulesAdded: 0,
|
|
940
|
+
analysis: reflection.analysisSummary,
|
|
941
|
+
rawContent: content.substring(0, 500),
|
|
942
|
+
};
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
console.error(
|
|
946
|
+
`[reflection] ${country} LLM 分析: ${reflection.analysisSummary}`,
|
|
947
|
+
);
|
|
948
|
+
console.error(
|
|
949
|
+
`[reflection] ${country} 成功模式: ${reflection.successPattern}`,
|
|
950
|
+
);
|
|
951
|
+
console.error(
|
|
952
|
+
`[reflection] ${country} 失败模式: ${reflection.failurePattern}`,
|
|
953
|
+
);
|
|
954
|
+
console.error(
|
|
955
|
+
`[reflection] ${country} 产出 ${reflection.rules.length} 条规则:`,
|
|
956
|
+
);
|
|
957
|
+
for (const rule of reflection.rules) {
|
|
958
|
+
console.error(` [${rule.ruleType}] ${rule.ruleText}`);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// Step 6: 废弃旧规则,写入新规则
|
|
962
|
+
const supersedeResult = supersedeRules(country, "new reflection round");
|
|
963
|
+
console.error(
|
|
964
|
+
`[reflection] ${country} 废弃 ${supersedeResult.superseded} 条旧规则`,
|
|
965
|
+
);
|
|
966
|
+
|
|
967
|
+
const rulesToInsert = reflection.rules.map((r) => ({
|
|
968
|
+
country,
|
|
969
|
+
ruleType: r.ruleType,
|
|
970
|
+
ruleText: r.ruleText,
|
|
971
|
+
triggerReason: r.triggerReason,
|
|
972
|
+
productiveSample: data.productiveTop.slice(0, 10).map((t) => t.tag),
|
|
973
|
+
deadSample: data.deadFrequency.slice(0, 20).map((t) => t.tag),
|
|
974
|
+
analysisSummary: reflection.analysisSummary,
|
|
975
|
+
}));
|
|
976
|
+
|
|
977
|
+
const insertResult = insertReflectionRules(rulesToInsert);
|
|
978
|
+
console.error(
|
|
979
|
+
`[reflection] ${country} 写入 ${insertResult.inserted} 条新规则`,
|
|
980
|
+
);
|
|
981
|
+
|
|
982
|
+
return {
|
|
983
|
+
reflected: true,
|
|
984
|
+
rulesAdded: insertResult.inserted,
|
|
985
|
+
analysis: reflection.analysisSummary,
|
|
986
|
+
successPattern: reflection.successPattern,
|
|
987
|
+
failurePattern: reflection.failurePattern,
|
|
988
|
+
rules: reflection.rules,
|
|
989
|
+
superseded: supersedeResult.superseded,
|
|
990
|
+
};
|
|
991
|
+
}
|