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.
@@ -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: 0.7,
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
- dead.length > 0
173
- ? `\nTags that failed for ${country} (found no matching users): ${dead.map((t) => t.tag).join(", ")}. Avoid these and similar patterns.`
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}${productiveHint}${deadHint}${errorHint}${userHint}${strategyReview}
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 two fields:
346
+ Return ONLY a JSON object with three fields:
249
347
  {
250
- "strategy": "Your analysis (2-3 sentences). Acknowledge what worked/failed before and explain why your chosen tags are likely real and commonly used.",
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 12-15 distinct, diverse tag ideas covering different categories (selling verbs, product types, community tags, marketplace terms, seasonal trends)
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
- // 获取该国所有已存在的 tag 名(应用层去重用)
279
- const allTags = store.getTagsByCountry(country, 0);
280
- const allExisting = new Set(allTags.map((t) => t.tag));
281
- const history = { productive, dead };
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
- // 组装 prompt 并调用 LLM(两阶段:先思考策略,再生成 tag)
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
- userPrompt,
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 和 tags,筛选掉数据库中已存在的
303
- const { strategy, tags } = parseDiscoverResponse(content);
304
- const unique = [...new Set(tags)]
305
- .filter((t) => !allExisting.has(t))
306
- .slice(0, count);
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 generated ${unique.length} tags: ${unique.join(", ")}`,
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
+ }