tt-help-cli-ycl 1.4.3 → 1.4.5
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 +128 -116
- package/src/lib/args.js +0 -8
- 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 +790 -0
- package/src/watch/public/app.js +27 -0
- package/src/watch/public/index.html +1 -1
- package/src/watch/server.js +244 -0
- package/src/watch/tag-service.js +595 -31
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,11 @@ export function buildDiscoverPrompt(
|
|
|
138
145
|
history,
|
|
139
146
|
userPrompt,
|
|
140
147
|
pastStrategies,
|
|
148
|
+
health,
|
|
149
|
+
nicheRecommendation,
|
|
150
|
+
hardRules = [],
|
|
151
|
+
existingCount = 0,
|
|
152
|
+
forceNicheSwitch = null,
|
|
141
153
|
) {
|
|
142
154
|
const lang = getLang(country);
|
|
143
155
|
const langNames = {
|
|
@@ -166,13 +178,36 @@ export function buildDiscoverPrompt(
|
|
|
166
178
|
)}. Use these as reference for the STYLE and TYPE of tag that works — prefer commonly used words like these.`
|
|
167
179
|
: "";
|
|
168
180
|
|
|
169
|
-
// 负样本:该国 dead tag
|
|
181
|
+
// 负样本:该国 dead tag(只传最近50个,避免 prompt 过长)
|
|
170
182
|
const dead = history.dead || [];
|
|
183
|
+
const recentDead = dead.slice(0, 50);
|
|
171
184
|
const deadHint =
|
|
172
|
-
|
|
173
|
-
? `\nTags that failed for ${country} (found no matching users): ${
|
|
185
|
+
recentDead.length > 0
|
|
186
|
+
? `\nTags that RECENTLY failed for ${country} (found no matching users): ${recentDead.map((t) => t.tag).join(", ")}. Avoid these EXACT tags and similar patterns.`
|
|
187
|
+
: "";
|
|
188
|
+
|
|
189
|
+
// 高频重复 dead 标签警告(这些是反复生成又反复失败的)
|
|
190
|
+
const deadTagNames = new Set(dead.map((t) => t.tag));
|
|
191
|
+
const recentGenerated = (history.recentGenerated || []).filter((t) =>
|
|
192
|
+
deadTagNames.has(t),
|
|
193
|
+
);
|
|
194
|
+
const repeatedDeadHint =
|
|
195
|
+
recentGenerated.length > 0
|
|
196
|
+
? `\n⚠️ These tags have been generated MULTIPLE TIMES and always fail — NEVER generate them again: ${recentGenerated.join(", ")}`
|
|
174
197
|
: "";
|
|
175
198
|
|
|
199
|
+
// 语言模式分析:统计 dead 标签的语言分布
|
|
200
|
+
const langPatternHint = (() => {
|
|
201
|
+
if (dead.length < 20) return "";
|
|
202
|
+
const englishDead = dead.filter((t) => /^[a-zA-Z]+$/.test(t.tag)).length;
|
|
203
|
+
const localDead = dead.filter((t) => !/^[a-zA-Z]+$/.test(t.tag)).length;
|
|
204
|
+
const englishRatio = Math.round((englishDead / dead.length) * 100);
|
|
205
|
+
if (englishRatio > 60 && lang !== "en") {
|
|
206
|
+
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, ελληνικαπροιοντα).`;
|
|
207
|
+
}
|
|
208
|
+
return "";
|
|
209
|
+
})();
|
|
210
|
+
|
|
176
211
|
// 死因分析
|
|
177
212
|
const errorPatterns = [
|
|
178
213
|
...new Set(dead.filter((t) => t.last_error).map((t) => t.last_error)),
|
|
@@ -216,6 +251,57 @@ Use this analysis to decide your strategy for this round.`;
|
|
|
216
251
|
? `\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
252
|
: "";
|
|
218
253
|
|
|
254
|
+
// ====== 自闭环:健康指标 & niche 推荐 ======
|
|
255
|
+
let healthHint = "";
|
|
256
|
+
let nicheHint = "";
|
|
257
|
+
|
|
258
|
+
if (health && health.healthStatus !== "healthy") {
|
|
259
|
+
const issuesText = health.issues.map((i) => ` - ${i}`).join("\n");
|
|
260
|
+
healthHint = `
|
|
261
|
+
=== 🏥 Country Health Analysis (${health.healthStatus}) ===
|
|
262
|
+
Country: ${health.country}
|
|
263
|
+
Total tags: ${health.total} | Productive: ${health.productive} (${health.productiveRatio}%) | Dead: ${health.dead} (${health.deadRatio}%) | Scored: ${health.scored}
|
|
264
|
+
Recent effectiveness: productive ${health.recentProductiveRatio}% | dead ${health.recentDeadRatio}% | empty rounds ${health.recentEmptyRounds}
|
|
265
|
+
Saturation: ${health.saturation}%
|
|
266
|
+
|
|
267
|
+
Issues detected:
|
|
268
|
+
${issuesText}
|
|
269
|
+
|
|
270
|
+
⚠️ Based on this health analysis, you MUST adjust your strategy:
|
|
271
|
+
${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"}
|
|
272
|
+
`;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (nicheRecommendation) {
|
|
276
|
+
const uncoveredText = nicheRecommendation.uncovered
|
|
277
|
+
.slice(0, 5)
|
|
278
|
+
.map((n) => `${n.niche} (current: ${n.currentCoverage} tags)`)
|
|
279
|
+
.join(", ");
|
|
280
|
+
const saturatedText = nicheRecommendation.saturated
|
|
281
|
+
.map((n) => `${n.niche} (${n.currentCoverage} tags)`)
|
|
282
|
+
.join(", ");
|
|
283
|
+
|
|
284
|
+
nicheHint = `
|
|
285
|
+
=== 🎯 Niche Direction Recommendation ===
|
|
286
|
+
Uncovered niches (EXPLORE these — few existing productive tags): ${uncoveredText || "none remaining"}
|
|
287
|
+
Saturated niches (AVOID these — already well-covered): ${saturatedText || "none saturated"}
|
|
288
|
+
|
|
289
|
+
${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."}
|
|
290
|
+
`;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ====== 自闭环:强制方向切换提示 ======
|
|
294
|
+
let forceNicheSwitchHint = "";
|
|
295
|
+
if (forceNicheSwitch) {
|
|
296
|
+
forceNicheSwitchHint = `
|
|
297
|
+
=== 🔄 MANDATORY DIRECTION SWITCH ===
|
|
298
|
+
⚠️ CRITICAL: The previous direction "${forceNicheSwitch.from}" has been exhausted — ${forceNicheSwitch.reason}.
|
|
299
|
+
You MUST NOT generate tags in the "${forceNicheSwitch.from}" direction anymore.
|
|
300
|
+
You MUST switch to the "${forceNicheSwitch.to}" direction: ${forceNicheSwitch.description}.
|
|
301
|
+
Generate ONLY tags related to this new niche. Do NOT fall back to old patterns.
|
|
302
|
+
`;
|
|
303
|
+
}
|
|
304
|
+
|
|
219
305
|
return `You are discovering TikTok hashtags used by people who sell things in ${country}.
|
|
220
306
|
|
|
221
307
|
⛔ CRITICAL RULE: ONLY suggest hashtags that you are CONFIDENT actually exist on TikTok with real posts.
|
|
@@ -237,7 +323,19 @@ Think about the MOST COMMON hashtags that real sellers in ${country} use on TikT
|
|
|
237
323
|
- Common seller identities (shop, boutique, small business, creator...)
|
|
238
324
|
- Well-known community tags (support local, marketplace, second round...)
|
|
239
325
|
|
|
240
|
-
${qualityWarning}
|
|
326
|
+
${qualityWarning}
|
|
327
|
+
|
|
328
|
+
${
|
|
329
|
+
hardRules.length > 0
|
|
330
|
+
? `
|
|
331
|
+
=== ⛔ HARD RULES (from self-reflection — MUST follow these) ===
|
|
332
|
+
These rules were generated by analyzing past failures and successes. Violating them will produce dead tags.
|
|
333
|
+
${hardRules.map((r) => ` ${r.ruleType === "hard" ? "⛔" : "💡"} ${r.ruleText}`).join("\n")}
|
|
334
|
+
`
|
|
335
|
+
: ""
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
${productiveHint}${deadHint}${repeatedDeadHint}${langPatternHint}${errorHint}${userHint}${strategyReview}${healthHint}${nicheHint}${forceNicheSwitchHint}
|
|
241
339
|
|
|
242
340
|
## Quality check before responding
|
|
243
341
|
|
|
@@ -245,18 +343,20 @@ For each tag you consider, ask yourself: "Have I actually seen this hashtag used
|
|
|
245
343
|
|
|
246
344
|
## Output format
|
|
247
345
|
|
|
248
|
-
Return ONLY a JSON object with
|
|
346
|
+
Return ONLY a JSON object with three fields:
|
|
249
347
|
{
|
|
250
|
-
"strategy": "Your analysis (2-3 sentences). Acknowledge what worked/failed before
|
|
348
|
+
"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.",
|
|
349
|
+
"niche": "The primary niche category you focused on (e.g. 'fashion', 'food', 'handmade_crafts', 'electronics_tech', 'pets_animals', 'services_professional', 'marketplace_local')",
|
|
251
350
|
"tags": ["tag1", "tag2", "tag3", "tag4", "..."]
|
|
252
351
|
}
|
|
253
352
|
|
|
254
353
|
Rules:
|
|
255
|
-
- Brainstorm broadly — generate
|
|
354
|
+
- Brainstorm broadly — generate at least ${Math.max(count * 3, 20)} distinct, diverse tag ideas covering different categories (selling verbs, product types, community tags, marketplace terms, seasonal trends). We already have ${existingCount} existing tags in our database, so many of your suggestions will be filtered out as duplicates — generate MORE than you think we need to ensure enough survive filtering.
|
|
256
355
|
- We will filter duplicates and existing tags on our end, so explore freely
|
|
257
356
|
- QUALITY over creativity — 3 real, commonly used tags beat 4 invented ones
|
|
258
357
|
- NEVER invent compound words or translate concepts into hashtags
|
|
259
358
|
- Prefer simpler, more common tags over hyper-specific ones
|
|
359
|
+
- If health analysis shows saturation in certain niches, EXPLICITLY pivot to uncovered niches
|
|
260
360
|
- Look for tags with broad appeal (many potential posters)`;
|
|
261
361
|
}
|
|
262
362
|
|
|
@@ -272,45 +372,217 @@ export async function discoverTagsForCountry(
|
|
|
272
372
|
return { country, error: `不支持的国家代码: ${country}` };
|
|
273
373
|
}
|
|
274
374
|
|
|
375
|
+
// ====== 自闭环 Step 1: 健康检查 & 数据清理 ======
|
|
376
|
+
const {
|
|
377
|
+
getDiscoverRound,
|
|
378
|
+
getDiscoverEffectiveness,
|
|
379
|
+
getCountryHealth,
|
|
380
|
+
cleanupLowScoredTags,
|
|
381
|
+
cleanupStaleScoringTags,
|
|
382
|
+
cleanupErrorTags,
|
|
383
|
+
getActiveRules,
|
|
384
|
+
} = await import("./db-tags.js");
|
|
385
|
+
|
|
386
|
+
const health = getCountryHealth(country);
|
|
387
|
+
console.error(
|
|
388
|
+
`[tag-service] 🏥 ${country} health: ${health?.healthStatus || "unknown"} | deadRatio=${health?.deadRatio || "?"}% | productiveRatio=${health?.productiveRatio || "?"}% | saturation=${health?.saturation || "?"}%`,
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
// 根据健康状态执行数据清理
|
|
392
|
+
const cleanupActions = {};
|
|
393
|
+
if (health) {
|
|
394
|
+
// 清理超时 scoring tag(始终执行)
|
|
395
|
+
const staleResult = cleanupStaleScoringTags();
|
|
396
|
+
cleanupActions.staleScoringReset = staleResult.reset;
|
|
397
|
+
|
|
398
|
+
// 清理 error tag(始终执行)
|
|
399
|
+
const errorResult = cleanupErrorTags();
|
|
400
|
+
cleanupActions.errorReset = errorResult.reset;
|
|
401
|
+
|
|
402
|
+
// 低分 scored tag 清理:当 lowScored > 2x productive 时执行
|
|
403
|
+
if (health.lowScored > health.productive * 2) {
|
|
404
|
+
const lowResult = cleanupLowScoredTags(20);
|
|
405
|
+
cleanupActions.lowScoredCleanup = lowResult.cleaned;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ====== 自闭环 Step 2: 自动 niche 切换 ======
|
|
410
|
+
let autoNichePrompt = null;
|
|
411
|
+
let nicheRecommendation = null;
|
|
412
|
+
|
|
413
|
+
if (
|
|
414
|
+
health &&
|
|
415
|
+
health.healthStatus !== "healthy" &&
|
|
416
|
+
health.nicheRecommendation
|
|
417
|
+
) {
|
|
418
|
+
nicheRecommendation = health.nicheRecommendation;
|
|
419
|
+
|
|
420
|
+
// 当健康状态为 warning/critical 且有未覆盖 niche 时,自动切换
|
|
421
|
+
if (nicheRecommendation.uncovered.length > 0) {
|
|
422
|
+
const targetNiche = nicheRecommendation.uncovered[0];
|
|
423
|
+
const nicheDescriptions = {
|
|
424
|
+
fashion:
|
|
425
|
+
"clothing, shoes, accessories, vintage fashion, secondhand clothes",
|
|
426
|
+
food: "bakery, food delivery, local food, restaurant, cooking supplies",
|
|
427
|
+
handmade_crafts:
|
|
428
|
+
"handmade items, DIY crafts, ceramics, candles, resin art",
|
|
429
|
+
home_living:
|
|
430
|
+
"home decoration, furniture, interior design, cleaning services",
|
|
431
|
+
electronics_tech:
|
|
432
|
+
"electronics, gadgets, phones, computers, tech accessories",
|
|
433
|
+
pets_animals: "pet supplies, dog accessories, cat food, animal care",
|
|
434
|
+
beauty_health:
|
|
435
|
+
"beauty products, cosmetics, skincare, hair care, wellness",
|
|
436
|
+
books_collectibles:
|
|
437
|
+
"books, collectibles, vinyl records, trading cards, antiques",
|
|
438
|
+
services_professional:
|
|
439
|
+
"professional services, repair, cleaning, photography, florist",
|
|
440
|
+
marketplace_local:
|
|
441
|
+
"local market, marketplace, shop, boutique, online store",
|
|
442
|
+
};
|
|
443
|
+
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.`;
|
|
444
|
+
console.error(
|
|
445
|
+
`[tag-service] 🎯 ${country} auto niche switch → ${targetNiche.niche} (coverage: ${targetNiche.currentCoverage})`,
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// ====== 自闭环 Step 2b: 连续低效轮强制方向切换 ======
|
|
451
|
+
// 商品分类方向定义(用于强制切换)
|
|
452
|
+
const NICHE_CATEGORIES = {
|
|
453
|
+
fashion:
|
|
454
|
+
"clothing, shoes, accessories, vintage, secondhand, thrift, sneakers, jewelry",
|
|
455
|
+
food: "bakery, food delivery, restaurant, cooking, local food, cafe, patisserie, boulangerie",
|
|
456
|
+
handmade_crafts:
|
|
457
|
+
"handmade, DIY, crafts, ceramics, candles, resin art, macrame, pottery, weaving",
|
|
458
|
+
home_living:
|
|
459
|
+
"home decoration, furniture, interior design, cleaning, moving, renovation",
|
|
460
|
+
electronics_tech:
|
|
461
|
+
"electronics, gadgets, phones, computers, audio, gaming, chargers, accessories",
|
|
462
|
+
pets_animals:
|
|
463
|
+
"pet supplies, dog accessories, cat food, animal care, grooming, veterinary",
|
|
464
|
+
beauty_health:
|
|
465
|
+
"beauty, cosmetics, skincare, hair care, wellness, perfume, makeup, spa",
|
|
466
|
+
books_collectibles:
|
|
467
|
+
"books, collectibles, vinyl records, trading cards, antiques, coins, stamps",
|
|
468
|
+
services_professional:
|
|
469
|
+
"repair, cleaning, photography, florist, tutoring, legal, consulting, moving",
|
|
470
|
+
marketplace_local:
|
|
471
|
+
"local market, marketplace, shop, boutique, online store, flea market, bazaar",
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
let forceNicheSwitch = null;
|
|
475
|
+
if (health && health.consecutiveLowProductiveRounds >= 2) {
|
|
476
|
+
// 连续 ≥2 轮 productive=0,强制切换方向
|
|
477
|
+
// 找出当前 productive tag 覆盖最多的 niche(即当前方向)
|
|
478
|
+
const currentNiche =
|
|
479
|
+
nicheRecommendation?.saturated?.[0]?.niche || "marketplace_local";
|
|
480
|
+
// 找出覆盖最少的 niche 作为新方向
|
|
481
|
+
const targetNiche = nicheRecommendation?.uncovered?.[0]?.niche || "food";
|
|
482
|
+
forceNicheSwitch = {
|
|
483
|
+
from: currentNiche,
|
|
484
|
+
to: targetNiche,
|
|
485
|
+
reason: `连续 ${health.consecutiveLowProductiveRounds} 轮无有效产出`,
|
|
486
|
+
description: NICHE_CATEGORIES[targetNiche] || targetNiche,
|
|
487
|
+
};
|
|
488
|
+
console.error(
|
|
489
|
+
`[tag-service] 🔄 ${country} 强制方向切换: ${currentNiche} → ${targetNiche} (原因: 连续${health.consecutiveLowProductiveRounds}轮低效)`,
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// 合并用户 prompt、自动 niche prompt 和强制方向切换
|
|
494
|
+
let effectiveUserPrompt = userPrompt;
|
|
495
|
+
if (forceNicheSwitch) {
|
|
496
|
+
// 强制方向切换优先级最高,覆盖 autoNichePrompt
|
|
497
|
+
effectiveUserPrompt = `MANDATORY DIRECTION CHANGE: You MUST switch from "${forceNicheSwitch.from}" to "${forceNicheSwitch.to}" niche. ${forceNicheSwitch.reason}. Focus on: ${forceNicheSwitch.description}. Do NOT generate tags in the "${forceNicheSwitch.from}" direction anymore — it has been exhausted with no productive results.`;
|
|
498
|
+
} else if (autoNichePrompt) {
|
|
499
|
+
effectiveUserPrompt = autoNichePrompt;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// ====== 自闭环 Step 3: 动态温度调整 ======
|
|
503
|
+
let temperature = 0.7; // 默认
|
|
504
|
+
if (health) {
|
|
505
|
+
if (health.healthStatus === "critical") {
|
|
506
|
+
temperature = 0.3; // 极度保守
|
|
507
|
+
} else if (health.healthStatus === "warning") {
|
|
508
|
+
temperature = 0.5; // 较保守
|
|
509
|
+
}
|
|
510
|
+
// 连续低效轮时进一步降温,让 LLM 更保守
|
|
511
|
+
if (health.consecutiveLowProductiveRounds >= 2) {
|
|
512
|
+
temperature = Math.min(temperature, 0.3);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// ====== 原有 discover 流程 ======
|
|
275
517
|
// 读取历史打分记录
|
|
276
518
|
const productive = store.getTagsByCountry(country, 50);
|
|
277
519
|
const dead = store.getDeadTags(country);
|
|
278
|
-
//
|
|
279
|
-
const
|
|
280
|
-
const allExisting =
|
|
281
|
-
|
|
520
|
+
// 全局去重:获取所有已存在的 tag 名(不限国家、不限状态),避免跨国家重复入库
|
|
521
|
+
const { getAllExistingTagNames } = await import("./db-tags.js");
|
|
522
|
+
const allExisting = getAllExistingTagNames();
|
|
523
|
+
console.error(
|
|
524
|
+
`[tag-service] ${country} 全局去重集合: ${allExisting.size} 个已存在 tag`,
|
|
525
|
+
);
|
|
526
|
+
|
|
527
|
+
// 获取最近生成的标签(用于检测重复生成)
|
|
528
|
+
const { getRecentGeneratedTags } = await import("./db-tags.js");
|
|
529
|
+
const recentGenerated = getRecentGeneratedTags(country, 10);
|
|
530
|
+
|
|
531
|
+
const history = { productive, dead, recentGenerated };
|
|
282
532
|
|
|
283
533
|
// 读取历史策略效果(自学习)
|
|
284
|
-
const { getDiscoverRound, getDiscoverEffectiveness } =
|
|
285
|
-
await import("./db-tags.js");
|
|
286
534
|
const round = getDiscoverRound(country);
|
|
287
535
|
const pastStrategies = getDiscoverEffectiveness(country);
|
|
288
536
|
|
|
289
|
-
//
|
|
537
|
+
// ====== 自闭环 Step 4: 注入硬规则(反思产出) ======
|
|
538
|
+
const hardRules = getActiveRules(country);
|
|
539
|
+
if (hardRules.length > 0) {
|
|
540
|
+
console.error(
|
|
541
|
+
`[tag-service] ⛔ ${country} 注入 ${hardRules.length} 条硬规则到 prompt`,
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// 组装 prompt(加入健康指标、niche 推荐、硬规则)
|
|
290
546
|
const prompt = buildDiscoverPrompt(
|
|
291
547
|
country,
|
|
292
548
|
count,
|
|
293
549
|
history,
|
|
294
|
-
|
|
550
|
+
effectiveUserPrompt,
|
|
295
551
|
pastStrategies,
|
|
552
|
+
health,
|
|
553
|
+
nicheRecommendation,
|
|
554
|
+
hardRules,
|
|
555
|
+
allExisting.size,
|
|
556
|
+
forceNicheSwitch,
|
|
296
557
|
);
|
|
297
558
|
console.error(
|
|
298
|
-
`[tag-service] LLM discover ${country} round=${round} (lang=${getLang(country)}, count=${count})`,
|
|
559
|
+
`[tag-service] LLM discover ${country} round=${round} (lang=${getLang(country)}, count=${count}, temp=${temperature}, health=${health?.healthStatus || "unknown"})`,
|
|
299
560
|
);
|
|
300
|
-
const content = await callLLM(prompt);
|
|
561
|
+
const content = await callLLM(prompt, temperature);
|
|
301
562
|
|
|
302
|
-
// 解析响应:提取 strategy
|
|
303
|
-
const
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
|
|
563
|
+
// 解析响应:提取 strategy, niche, tags
|
|
564
|
+
const parsed = parseDiscoverResponse(content);
|
|
565
|
+
const { strategy, tags } = parsed;
|
|
566
|
+
const niche = parsed.niche || null;
|
|
567
|
+
const deduped = [...new Set(tags)]; // LLM 自身去重
|
|
568
|
+
const filtered = deduped.filter((t) => !allExisting.has(t)); // 全局去重
|
|
569
|
+
const unique = filtered.slice(0, count);
|
|
307
570
|
|
|
308
571
|
console.error(
|
|
309
572
|
`[tag-service] LLM strategy: ${strategy?.substring(0, 150) || "(none)"}`,
|
|
310
573
|
);
|
|
574
|
+
if (niche) {
|
|
575
|
+
console.error(`[tag-service] LLM niche focus: ${niche}`);
|
|
576
|
+
}
|
|
311
577
|
console.error(
|
|
312
|
-
`[tag-service] LLM
|
|
578
|
+
`[tag-service] ${country} 生成链路: LLM原始=${tags.length} → 自去重=${deduped.length} → 全局去重=${filtered.length} → 截取=${unique.length}`,
|
|
313
579
|
);
|
|
580
|
+
if (filtered.length < count) {
|
|
581
|
+
const dupCount = deduped.length - filtered.length;
|
|
582
|
+
console.error(
|
|
583
|
+
`[tag-service] ⚠️ ${country} 去重后仅剩 ${filtered.length} 个(${dupCount} 个与已有 ${allExisting.size} 个 tag 重复),不足目标 ${count} 个`,
|
|
584
|
+
);
|
|
585
|
+
}
|
|
314
586
|
|
|
315
587
|
// 去重写入数据库
|
|
316
588
|
const inserted = [];
|
|
@@ -319,7 +591,7 @@ export async function discoverTagsForCountry(
|
|
|
319
591
|
if (result.inserted) inserted.push(result.tag);
|
|
320
592
|
}
|
|
321
593
|
|
|
322
|
-
// 记录 discover log(策略 +
|
|
594
|
+
// 记录 discover log(策略 + 结果 + 健康状态 + niche)
|
|
323
595
|
const { insertDiscoverLog } = await import("./db-tags.js");
|
|
324
596
|
insertDiscoverLog({
|
|
325
597
|
country,
|
|
@@ -337,13 +609,41 @@ export async function discoverTagsForCountry(
|
|
|
337
609
|
dead.length > 0 ? dead.reduce((s, t) => s + t.score, 0) / dead.length : 0,
|
|
338
610
|
});
|
|
339
611
|
|
|
612
|
+
// ====== 自闭环 Step 5: 反思触发(本轮结束后检查是否需要反思) ======
|
|
613
|
+
let reflectionResult = null;
|
|
614
|
+
try {
|
|
615
|
+
reflectionResult = await reflectAndLearn(store, country);
|
|
616
|
+
if (reflectionResult.reflected) {
|
|
617
|
+
console.error(
|
|
618
|
+
`[tag-service] 🔄 ${country} 反思完成: 新增 ${reflectionResult.rulesAdded} 条规则, 分析: ${reflectionResult.analysis?.substring(0, 100)}`,
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
} catch (e) {
|
|
622
|
+
console.error(`[tag-service] 反思异常: ${e.message}`);
|
|
623
|
+
reflectionResult = { reflected: false, error: e.message };
|
|
624
|
+
}
|
|
625
|
+
|
|
340
626
|
return {
|
|
341
627
|
country,
|
|
342
628
|
round,
|
|
343
629
|
strategy,
|
|
630
|
+
niche,
|
|
344
631
|
added: inserted.length,
|
|
345
632
|
tags: inserted,
|
|
346
633
|
total: unique.length,
|
|
634
|
+
// 自闭环信息
|
|
635
|
+
health: {
|
|
636
|
+
status: health?.healthStatus || "unknown",
|
|
637
|
+
deadRatio: health?.deadRatio || "?",
|
|
638
|
+
productiveRatio: health?.productiveRatio || "?",
|
|
639
|
+
saturation: health?.saturation || "?",
|
|
640
|
+
issues: health?.issues || [],
|
|
641
|
+
},
|
|
642
|
+
cleanupActions,
|
|
643
|
+
autoNiche: autoNichePrompt ? nicheRecommendation.uncovered[0].niche : null,
|
|
644
|
+
temperature,
|
|
645
|
+
hardRulesInjected: hardRules.length,
|
|
646
|
+
reflection: reflectionResult,
|
|
347
647
|
};
|
|
348
648
|
}
|
|
349
649
|
|
|
@@ -503,3 +803,267 @@ export async function scoreTag(store, { tag, targetCountries, serverUrl }) {
|
|
|
503
803
|
|
|
504
804
|
return result;
|
|
505
805
|
}
|
|
806
|
+
|
|
807
|
+
// ====== 反思闭环:自我学习与改进 ======
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* 组装反思 prompt
|
|
811
|
+
* 输入:最近轮次数据、成功/失败模式、当前规则、健康指标
|
|
812
|
+
* 输出期望:JSON 格式的规则列表
|
|
813
|
+
*/
|
|
814
|
+
export function buildReflectionPrompt(data) {
|
|
815
|
+
const {
|
|
816
|
+
country,
|
|
817
|
+
langName,
|
|
818
|
+
recentLogs,
|
|
819
|
+
productiveTop,
|
|
820
|
+
deadFrequency,
|
|
821
|
+
errorStats,
|
|
822
|
+
activeRules,
|
|
823
|
+
health,
|
|
824
|
+
} = data;
|
|
825
|
+
|
|
826
|
+
// 最近轮次效果摘要
|
|
827
|
+
const roundsSummary = recentLogs
|
|
828
|
+
.slice(0, 10)
|
|
829
|
+
.map((log) => {
|
|
830
|
+
const tagsGen = JSON.parse(log.tags_generated || "[]");
|
|
831
|
+
return `Round ${log.round}: strategy="${log.strategy?.substring(0, 100) || "(none)"}", generated ${tagsGen.length} tags: ${tagsGen.slice(0, 8).join(", ")}${tagsGen.length > 8 ? "..." : ""}`;
|
|
832
|
+
})
|
|
833
|
+
.join("\n");
|
|
834
|
+
|
|
835
|
+
// 成功模式:productive 标签的特征
|
|
836
|
+
const successPatterns = productiveTop
|
|
837
|
+
.slice(0, 20)
|
|
838
|
+
.map(
|
|
839
|
+
(t) =>
|
|
840
|
+
`${t.tag} (score=${t.score}, authors=${t.matched_authors}, pushed=${t.pushed_users})`,
|
|
841
|
+
)
|
|
842
|
+
.join(", ");
|
|
843
|
+
|
|
844
|
+
// 失败模式:dead 标签的特征
|
|
845
|
+
const failurePatterns = deadFrequency
|
|
846
|
+
.slice(0, 30)
|
|
847
|
+
.map((t) => `${t.tag} (error: ${t.last_error || "no_match"})`)
|
|
848
|
+
.join(", ");
|
|
849
|
+
|
|
850
|
+
// Error 类型统计
|
|
851
|
+
const errorSummary = errorStats
|
|
852
|
+
.map((e) => `${e.error_type}: ${e.count} tags`)
|
|
853
|
+
.join(", ");
|
|
854
|
+
|
|
855
|
+
// 当前活跃规则
|
|
856
|
+
const currentRules =
|
|
857
|
+
activeRules.length > 0
|
|
858
|
+
? activeRules.map((r) => ` - ${r.rule_text}`).join("\n")
|
|
859
|
+
: " (none yet)";
|
|
860
|
+
|
|
861
|
+
// 健康指标
|
|
862
|
+
const healthInfo = health
|
|
863
|
+
? `Health: ${health.healthStatus} | deadRatio=${health.deadRatio}% | productiveRatio=${health.productiveRatio}% | saturation=${health.saturation}%`
|
|
864
|
+
: "Health: unknown";
|
|
865
|
+
|
|
866
|
+
return `You are a self-reflection agent analyzing why TikTok hashtag discovery has been failing for ${country} (${langName}).
|
|
867
|
+
|
|
868
|
+
Your goal: identify FAILURE PATTERNS and SUCCESS PATTERNS, then produce HARD RULES that will guide future tag generation to be more effective.
|
|
869
|
+
|
|
870
|
+
## Recent discovery rounds (all failing)
|
|
871
|
+
${roundsSummary}
|
|
872
|
+
|
|
873
|
+
## Success patterns (productive tags that actually work)
|
|
874
|
+
${successPatterns}
|
|
875
|
+
|
|
876
|
+
## Failure patterns (dead tags that don't work)
|
|
877
|
+
${failurePatterns}
|
|
878
|
+
|
|
879
|
+
## Error type distribution
|
|
880
|
+
${errorSummary}
|
|
881
|
+
|
|
882
|
+
## Current health status
|
|
883
|
+
${healthInfo}
|
|
884
|
+
|
|
885
|
+
## Current hard rules already in effect
|
|
886
|
+
${currentRules}
|
|
887
|
+
|
|
888
|
+
## Analysis task
|
|
889
|
+
|
|
890
|
+
Analyze the data above and answer:
|
|
891
|
+
|
|
892
|
+
1. **What patterns do productive tags share?** (language, length, word type, structure)
|
|
893
|
+
2. **What patterns do dead tags share?** (language, length, word type, structure)
|
|
894
|
+
3. **What specific tags should NEVER be generated again?** (list the most frequently repeated dead tags)
|
|
895
|
+
4. **What language strategy should be used?** (e.g. "only use ${langName} words", "avoid English-only tags")
|
|
896
|
+
5. **What word structure works?** (e.g. "single industry words", "city+category combos", "avoid compound words")
|
|
897
|
+
|
|
898
|
+
## Output format
|
|
899
|
+
|
|
900
|
+
Return ONLY a JSON object:
|
|
901
|
+
{
|
|
902
|
+
"analysis_summary": "2-3 sentence summary of what you learned",
|
|
903
|
+
"success_pattern": "description of what productive tags look like",
|
|
904
|
+
"failure_pattern": "description of what dead tags look like",
|
|
905
|
+
"rules": [
|
|
906
|
+
{
|
|
907
|
+
"rule_type": "hard",
|
|
908
|
+
"rule_text": "NEVER use English-only tags for ${country} — they have 0% match rate",
|
|
909
|
+
"trigger_reason": "dead_ratio > 60% with English tags"
|
|
910
|
+
},
|
|
911
|
+
{
|
|
912
|
+
"rule_type": "hard",
|
|
913
|
+
"rule_text": "NEVER generate these specific tags: hautpflege, bodycare, serum, creme, wellness",
|
|
914
|
+
"trigger_reason": "these tags confirmed dead 5+ times"
|
|
915
|
+
},
|
|
916
|
+
{
|
|
917
|
+
"rule_type": "soft",
|
|
918
|
+
"rule_text": "Prefer ${langName} industry/profession words (like handwerker, schreinerei pattern)",
|
|
919
|
+
"trigger_reason": "productive tags are mostly local-language industry words"
|
|
920
|
+
}
|
|
921
|
+
]
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
Be specific and actionable. Each rule should be something that can be directly applied to future tag generation prompts.`;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* 解析反思 LLM 的响应
|
|
929
|
+
*/
|
|
930
|
+
export function parseReflectionResponse(content) {
|
|
931
|
+
try {
|
|
932
|
+
const parsed = JSON.parse(content);
|
|
933
|
+
return {
|
|
934
|
+
analysisSummary: parsed.analysis_summary || "",
|
|
935
|
+
successPattern: parsed.success_pattern || "",
|
|
936
|
+
failurePattern: parsed.failure_pattern || "",
|
|
937
|
+
rules: (parsed.rules || [])
|
|
938
|
+
.map((r) => ({
|
|
939
|
+
ruleType: r.rule_type || "hard",
|
|
940
|
+
ruleText: r.rule_text || "",
|
|
941
|
+
triggerReason: r.trigger_reason || "",
|
|
942
|
+
}))
|
|
943
|
+
.filter((r) => r.ruleText.length > 10),
|
|
944
|
+
};
|
|
945
|
+
} catch {
|
|
946
|
+
// JSON 解析失败,尝试从文本提取
|
|
947
|
+
const rules = [];
|
|
948
|
+
const lines = content.split("\n");
|
|
949
|
+
for (const line of lines) {
|
|
950
|
+
const match = line.match(/"rule_text"\s*:\s*"([^"]+)"/);
|
|
951
|
+
if (match && match[1].length > 10) {
|
|
952
|
+
rules.push({
|
|
953
|
+
ruleType: line.includes("soft") ? "soft" : "hard",
|
|
954
|
+
ruleText: match[1],
|
|
955
|
+
triggerReason: "",
|
|
956
|
+
});
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
return {
|
|
960
|
+
analysisSummary: content.substring(0, 200),
|
|
961
|
+
successPattern: "",
|
|
962
|
+
failurePattern: "",
|
|
963
|
+
rules,
|
|
964
|
+
};
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
/**
|
|
969
|
+
* 执行一次反思:分析数据 → 调用 LLM → 写入规则
|
|
970
|
+
* @param {object} store - data-store 实例
|
|
971
|
+
* @param {string} country - 国家代码
|
|
972
|
+
* @returns {{ reflected: boolean, rulesAdded: number, analysis: string }}
|
|
973
|
+
*/
|
|
974
|
+
export async function reflectAndLearn(store, country) {
|
|
975
|
+
if (!COUNTRY_LANG[country]) {
|
|
976
|
+
return { reflected: false, error: `不支持的国家代码: ${country}` };
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
const {
|
|
980
|
+
shouldTriggerReflection,
|
|
981
|
+
getReflectionData,
|
|
982
|
+
insertReflectionRules,
|
|
983
|
+
supersedeRules,
|
|
984
|
+
getActiveRules,
|
|
985
|
+
} = await import("./db-tags.js");
|
|
986
|
+
|
|
987
|
+
// Step 1: 检查是否需要反思
|
|
988
|
+
const triggerCheck = shouldTriggerReflection(country);
|
|
989
|
+
if (!triggerCheck.shouldReflect) {
|
|
990
|
+
console.error(`[reflection] ${country} 不需要反思: ${triggerCheck.reason}`);
|
|
991
|
+
return { reflected: false, reason: triggerCheck.reason };
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
console.error(`[reflection] 🔄 ${country} 触发反思: ${triggerCheck.reason}`);
|
|
995
|
+
|
|
996
|
+
// Step 2: 收集反思数据
|
|
997
|
+
const data = getReflectionData(country);
|
|
998
|
+
if (!data) {
|
|
999
|
+
return { reflected: false, error: "无法获取反思数据" };
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
// Step 3: 组装反思 prompt
|
|
1003
|
+
const prompt = buildReflectionPrompt(data);
|
|
1004
|
+
|
|
1005
|
+
// Step 4: 调用 LLM 进行反思(温度较低,确保分析质量)
|
|
1006
|
+
console.error(`[reflection] 🧠 ${country} 正在调用 LLM 进行反思分析...`);
|
|
1007
|
+
const content = await callLLM(prompt, 0.3);
|
|
1008
|
+
|
|
1009
|
+
// Step 5: 解析反思结果
|
|
1010
|
+
const reflection = parseReflectionResponse(content);
|
|
1011
|
+
if (reflection.rules.length === 0) {
|
|
1012
|
+
console.error(
|
|
1013
|
+
`[reflection] ${country} LLM 反思未产出有效规则,原始响应: ${content.substring(0, 200)}`,
|
|
1014
|
+
);
|
|
1015
|
+
return {
|
|
1016
|
+
reflected: true,
|
|
1017
|
+
rulesAdded: 0,
|
|
1018
|
+
analysis: reflection.analysisSummary,
|
|
1019
|
+
rawContent: content.substring(0, 500),
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
console.error(
|
|
1024
|
+
`[reflection] ${country} LLM 分析: ${reflection.analysisSummary}`,
|
|
1025
|
+
);
|
|
1026
|
+
console.error(
|
|
1027
|
+
`[reflection] ${country} 成功模式: ${reflection.successPattern}`,
|
|
1028
|
+
);
|
|
1029
|
+
console.error(
|
|
1030
|
+
`[reflection] ${country} 失败模式: ${reflection.failurePattern}`,
|
|
1031
|
+
);
|
|
1032
|
+
console.error(
|
|
1033
|
+
`[reflection] ${country} 产出 ${reflection.rules.length} 条规则:`,
|
|
1034
|
+
);
|
|
1035
|
+
for (const rule of reflection.rules) {
|
|
1036
|
+
console.error(` [${rule.ruleType}] ${rule.ruleText}`);
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// Step 6: 废弃旧规则,写入新规则
|
|
1040
|
+
const supersedeResult = supersedeRules(country, "new reflection round");
|
|
1041
|
+
console.error(
|
|
1042
|
+
`[reflection] ${country} 废弃 ${supersedeResult.superseded} 条旧规则`,
|
|
1043
|
+
);
|
|
1044
|
+
|
|
1045
|
+
const rulesToInsert = reflection.rules.map((r) => ({
|
|
1046
|
+
country,
|
|
1047
|
+
ruleType: r.ruleType,
|
|
1048
|
+
ruleText: r.ruleText,
|
|
1049
|
+
triggerReason: r.triggerReason,
|
|
1050
|
+
productiveSample: data.productiveTop.slice(0, 10).map((t) => t.tag),
|
|
1051
|
+
deadSample: data.deadFrequency.slice(0, 20).map((t) => t.tag),
|
|
1052
|
+
analysisSummary: reflection.analysisSummary,
|
|
1053
|
+
}));
|
|
1054
|
+
|
|
1055
|
+
const insertResult = insertReflectionRules(rulesToInsert);
|
|
1056
|
+
console.error(
|
|
1057
|
+
`[reflection] ${country} 写入 ${insertResult.inserted} 条新规则`,
|
|
1058
|
+
);
|
|
1059
|
+
|
|
1060
|
+
return {
|
|
1061
|
+
reflected: true,
|
|
1062
|
+
rulesAdded: insertResult.inserted,
|
|
1063
|
+
analysis: reflection.analysisSummary,
|
|
1064
|
+
successPattern: reflection.successPattern,
|
|
1065
|
+
failurePattern: reflection.failurePattern,
|
|
1066
|
+
rules: reflection.rules,
|
|
1067
|
+
superseded: supersedeResult.superseded,
|
|
1068
|
+
};
|
|
1069
|
+
}
|