tt-help-cli-ycl 1.4.11 → 1.4.13

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.
@@ -0,0 +1,986 @@
1
+ /**
2
+ * Tag 自学习系统 —— 分层探索、方向评分、跨国家学习、LLM 分析师
3
+ *
4
+ * 核心设计:
5
+ * 1. 三层探索:通用词(L1) → 产品分类(L2) → 具体产品(L3)
6
+ * 2. Dead 三分类:not_exist / no_match / wrong_country
7
+ * 3. 方向级评分(非单个 tag 评分)
8
+ * 4. 跨国家经验复用
9
+ * 5. LLM 分析师周期性体检
10
+ *
11
+ * 本模块不修改现有 tag-service.js,而是在其基础上叠加新能力。
12
+ */
13
+
14
+ import { getDb } from "./db-schema.js";
15
+ import { callLLM, LLM_MODEL, getLang, COUNTRY_LANG } from "./tag-service.js";
16
+
17
+ // ====== 通用词根表(L1 探索起点) ======
18
+
19
+ const UNIVERSAL_SELL_KEYWORDS = {
20
+ de: [
21
+ "verkaufen",
22
+ "kaufen",
23
+ "shop",
24
+ "laden",
25
+ "markt",
26
+ "flohmarkt",
27
+ "kleinanzeigen",
28
+ "boutique",
29
+ "handel",
30
+ "verkauf",
31
+ "bestellen",
32
+ "gratis",
33
+ "angebot",
34
+ "sale",
35
+ "rabatt",
36
+ ],
37
+ fr: [
38
+ "vendre",
39
+ "acheter",
40
+ "boutique",
41
+ "magasin",
42
+ "marché",
43
+ "brocante",
44
+ "videgrenier",
45
+ "occasion",
46
+ "commerce",
47
+ "vente",
48
+ "commander",
49
+ "gratuit",
50
+ "promo",
51
+ "soldes",
52
+ ],
53
+ it: [
54
+ "vendere",
55
+ "comprare",
56
+ "negozio",
57
+ "mercato",
58
+ "mercatino",
59
+ "usato",
60
+ "occasioni",
61
+ "commercio",
62
+ "vendita",
63
+ "ordinare",
64
+ "gratis",
65
+ "offerta",
66
+ "saldi",
67
+ "sconto",
68
+ ],
69
+ es: [
70
+ "vender",
71
+ "comprar",
72
+ "tienda",
73
+ "mercado",
74
+ "mercadillo",
75
+ "segundamano",
76
+ "ocasión",
77
+ "comercio",
78
+ "venta",
79
+ "pedir",
80
+ "gratis",
81
+ "oferta",
82
+ "rebajas",
83
+ "descuento",
84
+ ],
85
+ nl: [
86
+ "verkopen",
87
+ "kopen",
88
+ "winkel",
89
+ "markt",
90
+ "rommelmarkt",
91
+ "tweedehands",
92
+ "kringloop",
93
+ "handel",
94
+ "verkoop",
95
+ "bestellen",
96
+ "gratis",
97
+ "aanbieding",
98
+ "uitverkoop",
99
+ "korting",
100
+ ],
101
+ pt: [
102
+ "vender",
103
+ "comprar",
104
+ "loja",
105
+ "mercado",
106
+ "feira",
107
+ "usados",
108
+ "segundamão",
109
+ "comércio",
110
+ "venda",
111
+ "encomendar",
112
+ "grátis",
113
+ "oferta",
114
+ "promoção",
115
+ "desconto",
116
+ ],
117
+ pl: [
118
+ "sprzedam",
119
+ "kupię",
120
+ "sklep",
121
+ "rynek",
122
+ "targ",
123
+ "używane",
124
+ "ogłoszenia",
125
+ "handel",
126
+ "sprzedaż",
127
+ "zamów",
128
+ "gratis",
129
+ "oferta",
130
+ "promocja",
131
+ "przecena",
132
+ ],
133
+ cs: [
134
+ "prodat",
135
+ "koupit",
136
+ "obchod",
137
+ "trh",
138
+ "blešítrh",
139
+ "bazar",
140
+ "inzerce",
141
+ "obchodování",
142
+ "prodej",
143
+ "objednat",
144
+ "zdarma",
145
+ "nabídka",
146
+ "sleva",
147
+ "výprodej",
148
+ ],
149
+ el: [
150
+ "πωλώ",
151
+ "αγοράζω",
152
+ "κατάστημα",
153
+ "αγορά",
154
+ "παζάρι",
155
+ "μεταχειρισμένα",
156
+ "αγγελίες",
157
+ "εμπόριο",
158
+ "πώληση",
159
+ "παραγγελία",
160
+ "δωρεάν",
161
+ "προσφορά",
162
+ "εκπτώσεις",
163
+ ],
164
+ hu: [
165
+ "eladó",
166
+ "veszek",
167
+ "bolt",
168
+ "piac",
169
+ "bolhapiac",
170
+ "használt",
171
+ "apróhirdetés",
172
+ "kereskedés",
173
+ "értékesítés",
174
+ "rendelés",
175
+ "ingyen",
176
+ "ajánlat",
177
+ "akció",
178
+ "leárazás",
179
+ ],
180
+ };
181
+
182
+ // ====== Dead 原因分类关键词 ======
183
+
184
+ const TAG_NOT_EXIST_PATTERNS = [
185
+ "no videos found",
186
+ "tag_not_found",
187
+ "找不到此话题标签",
188
+ ];
189
+
190
+ const TAG_NO_LOCATION_PATTERNS = ["no matching users", "matched_authors=0"];
191
+
192
+ // ====== 语言分组(用于跨国家学习) ======
193
+
194
+ const LANG_GROUPS = {
195
+ germanic: ["DE", "AT"],
196
+ romance: ["FR", "IT", "ES", "PT"],
197
+ west_slavic: ["PL", "CZ"],
198
+ germanic_west: ["NL", "BE"],
199
+ other: ["GR", "HU", "IE"],
200
+ };
201
+
202
+ function getLangGroup(country) {
203
+ for (const [group, countries] of Object.entries(LANG_GROUPS)) {
204
+ if (countries.includes(country)) return group;
205
+ }
206
+ return "other";
207
+ }
208
+
209
+ // ====== Dead Tag 清理与分类 ======
210
+
211
+ /**
212
+ * 清理真正不存在的 dead tag(释放去重空间)
213
+ * 规则:多次打分且每次都是 "no videos found" 或 "tag_not_found"
214
+ * @returns {{ deleted: number, kept: number }}
215
+ */
216
+ export function cleanupTrulyDeadTags(minFailures = 2) {
217
+ const db = getDb();
218
+ if (!db) return { deleted: 0, kept: 0, error: "db not ready" };
219
+
220
+ // 查找多次失败的死标签
221
+ const deadTags = db
222
+ .prepare(
223
+ `SELECT id, tag, last_error, score_count, status FROM tags WHERE status = 'dead'`,
224
+ )
225
+ .all();
226
+
227
+ let deleted = 0;
228
+ let kept = 0;
229
+ const trulyDeadIds = [];
230
+
231
+ for (const t of deadTags) {
232
+ if (!t.last_error) {
233
+ kept++;
234
+ continue;
235
+ }
236
+
237
+ const isNotExist = TAG_NOT_EXIST_PATTERNS.some((p) =>
238
+ t.last_error.toLowerCase().includes(p.toLowerCase()),
239
+ );
240
+
241
+ if (isNotExist && t.score_count >= minFailures) {
242
+ trulyDeadIds.push(t.id);
243
+ deleted++;
244
+ } else {
245
+ kept++;
246
+ }
247
+ }
248
+
249
+ // 批量删除
250
+ if (trulyDeadIds.length > 0) {
251
+ const stmt = db.prepare("DELETE FROM tags WHERE id = ?");
252
+ const deleteTxn = db.transaction(() => {
253
+ for (const id of trulyDeadIds) stmt.run(id);
254
+ });
255
+ deleteTxn();
256
+ }
257
+
258
+ return { deleted, kept };
259
+ }
260
+
261
+ /**
262
+ * 分类 dead tag 并更新 dead_reason 字段
263
+ * @returns {{ classified: number, not_exist: number, no_match: number, wrong_country: number }}
264
+ */
265
+ export function classifyDeadTags(country = null) {
266
+ const db = getDb();
267
+ if (!db)
268
+ return { classified: 0, not_exist: 0, no_match: 0, wrong_country: 0 };
269
+
270
+ let sql = `SELECT id, tag, last_error, matched_authors, score, countries FROM tags WHERE status = 'dead' AND dead_reason IS NULL`;
271
+ const params = [];
272
+ if (country) {
273
+ sql += ` AND countries LIKE ?`;
274
+ params.push(`%"${country}"%`);
275
+ }
276
+ sql += ` LIMIT 500`;
277
+
278
+ const deadTags = db.prepare(sql).all(...params);
279
+
280
+ let notExist = 0;
281
+ let noMatch = 0;
282
+ let wrongCountry = 0;
283
+
284
+ const updateStmt = db.prepare(`UPDATE tags SET dead_reason = ? WHERE id = ?`);
285
+
286
+ const classifyTxn = db.transaction(() => {
287
+ for (const t of deadTags) {
288
+ let reason = "unknown";
289
+
290
+ if (t.last_error) {
291
+ const isNotExist = TAG_NOT_EXIST_PATTERNS.some((p) =>
292
+ t.last_error.toLowerCase().includes(p.toLowerCase()),
293
+ );
294
+ if (isNotExist) {
295
+ reason = "not_exist";
296
+ notExist++;
297
+ }
298
+ }
299
+
300
+ if (reason === "unknown" && t.matched_authors === 0) {
301
+ reason = "no_match";
302
+ noMatch++;
303
+ }
304
+
305
+ if (reason === "unknown") {
306
+ // 有匹配作者但分数低 → 可能在错误的国家
307
+ reason = "wrong_country";
308
+ wrongCountry++;
309
+ }
310
+
311
+ updateStmt.run(reason, t.id);
312
+ }
313
+ });
314
+ classifyTxn();
315
+
316
+ return {
317
+ classified: deadTags.length,
318
+ not_exist: notExist,
319
+ no_match: noMatch,
320
+ wrong_country: wrongCountry,
321
+ };
322
+ }
323
+
324
+ /**
325
+ * 获取按 dead_reason 分类的统计
326
+ */
327
+ export function getDeadTagStats(country = null) {
328
+ const db = getDb();
329
+ if (!db) return null;
330
+
331
+ let sql = `SELECT
332
+ COUNT(*) as total,
333
+ SUM(CASE WHEN dead_reason = 'not_exist' THEN 1 ELSE 0 END) as not_exist,
334
+ SUM(CASE WHEN dead_reason = 'no_match' THEN 1 ELSE 0 END) as no_match,
335
+ SUM(CASE WHEN dead_reason = 'wrong_country' THEN 1 ELSE 0 END) as wrong_country,
336
+ SUM(CASE WHEN dead_reason IS NULL THEN 1 ELSE 0 END) as unclassified
337
+ FROM tags WHERE status = 'dead'`;
338
+
339
+ const params = [];
340
+ if (country) {
341
+ sql += ` AND countries LIKE ?`;
342
+ params.push(`%"${country}"%`);
343
+ }
344
+
345
+ return db.prepare(sql).get(...params);
346
+ }
347
+
348
+ // ====== 方向管理 ======
349
+
350
+ /**
351
+ * 初始化/获取默认方向(L1: 通用卖货词)
352
+ */
353
+ export function initDefaultDirections(country) {
354
+ const db = getDb();
355
+ if (!db) return [];
356
+
357
+ const lang = getLang(country);
358
+ const keywords =
359
+ UNIVERSAL_SELL_KEYWORDS[lang] || UNIVERSAL_SELL_KEYWORDS["en"] || [];
360
+
361
+ const insertStmt = db.prepare(
362
+ `INSERT OR IGNORE INTO tag_directions (name, level, parent_id, country, lang_group)
363
+ VALUES (?, 1, NULL, ?, ?)`,
364
+ );
365
+
366
+ const langGroup = getLangGroup(country);
367
+
368
+ const insertTxn = db.transaction(() => {
369
+ for (const kw of keywords) {
370
+ insertStmt.run(kw, country, langGroup);
371
+ }
372
+ });
373
+ insertTxn();
374
+
375
+ return db
376
+ .prepare(`SELECT * FROM tag_directions WHERE country = ? AND level = 1`)
377
+ .all(country);
378
+ }
379
+
380
+ /**
381
+ * 获取某国家某层级的方向列表
382
+ */
383
+ export function getDirections(country, level = 1, status = "active") {
384
+ const db = getDb();
385
+ if (!db) return [];
386
+
387
+ return db
388
+ .prepare(
389
+ `SELECT * FROM tag_directions
390
+ WHERE country = ? AND level = ? AND status = ?
391
+ ORDER BY best_score DESC, productive_experiments DESC`,
392
+ )
393
+ .all(country, level, status);
394
+ }
395
+
396
+ /**
397
+ * 记录方向的实验结果
398
+ */
399
+ export function recordDirectionResult(directionId, country, result) {
400
+ const db = getDb();
401
+ if (!db) return;
402
+
403
+ const {
404
+ productive_count = 0,
405
+ dead_count = 0,
406
+ best_score = 0,
407
+ notes = null,
408
+ } = result;
409
+
410
+ db.prepare(
411
+ `UPDATE tag_directions SET
412
+ total_experiments = total_experiments + 1,
413
+ productive_experiments = productive_experiments + CASE WHEN ? > 0 THEN 1 ELSE 0 END,
414
+ best_score = MAX(COALESCE(best_score, 0), ?),
415
+ notes = COALESCE(?, notes),
416
+ updated_at = datetime('now')
417
+ WHERE id = ?`,
418
+ ).run(productive_count, best_score, notes, directionId);
419
+ }
420
+
421
+ /**
422
+ * 获取跨国家的方向推荐
423
+ * 原理:同一语言组内,一个国家有效的方向推荐给同组的其他国家
424
+ */
425
+ export function getCrossCountryRecommendations(targetCountry, limit = 10) {
426
+ const db = getDb();
427
+ if (!db) return [];
428
+
429
+ const targetGroup = getLangGroup(targetCountry);
430
+
431
+ // 找同语言组的其他国家
432
+ const siblingCountries =
433
+ LANG_GROUPS[targetGroup]?.filter((c) => c !== targetCountry) || [];
434
+
435
+ if (siblingCountries.length === 0) return [];
436
+
437
+ const results = [];
438
+
439
+ for (const sourceCountry of siblingCountries) {
440
+ // 查源国家的 productive tag
441
+ const productiveTags = db
442
+ .prepare(
443
+ `SELECT tag, score, countries, matched_countries FROM tags
444
+ WHERE status = 'productive' AND countries LIKE ?
445
+ ORDER BY score DESC LIMIT 20`,
446
+ )
447
+ .all(`%"${sourceCountry}"%`);
448
+
449
+ for (const t of productiveTags) {
450
+ // 检查目标国家是否已有此 tag
451
+ const existing = db
452
+ .prepare(`SELECT id, status FROM tags WHERE tag = ?`)
453
+ .get(t.tag);
454
+
455
+ let recommendationStatus = "new";
456
+ if (existing) {
457
+ if (existing.status === "productive")
458
+ recommendationStatus = "exists_productive";
459
+ else if (existing.status === "dead")
460
+ recommendationStatus = "exists_dead";
461
+ else recommendationStatus = "exists_other";
462
+ }
463
+
464
+ results.push({
465
+ source_country: sourceCountry,
466
+ source_tag: t.tag,
467
+ source_score: t.score,
468
+ target_country: targetCountry,
469
+ recommendation_status: recommendationStatus,
470
+ });
471
+ }
472
+ }
473
+
474
+ // 去重并排序:优先推荐不存在于目标国家的 tag
475
+ const seen = new Set();
476
+ const filtered = results.filter((r) => {
477
+ const key = `${r.source_tag}`;
478
+ if (seen.has(key)) return false;
479
+ seen.add(key);
480
+ return true;
481
+ });
482
+
483
+ // 排序:new 优先,然后按 source_score 降序
484
+ filtered.sort((a, b) => {
485
+ if (a.recommendation_status === "new" && b.recommendation_status !== "new")
486
+ return -1;
487
+ if (b.recommendation_status === "new" && a.recommendation_status !== "new")
488
+ return 1;
489
+ return b.source_score - a.source_score;
490
+ });
491
+
492
+ return filtered.slice(0, limit);
493
+ }
494
+
495
+ /**
496
+ * 记录跨国家学习结果
497
+ */
498
+ export function recordCrossCountryLearning(
499
+ sourceCountry,
500
+ sourceTag,
501
+ targetCountry,
502
+ targetTag,
503
+ resultStatus,
504
+ score,
505
+ ) {
506
+ const db = getDb();
507
+ if (!db) return;
508
+
509
+ db.prepare(
510
+ `INSERT INTO cross_country_learnings
511
+ (source_country, source_tag, target_country, target_tag, transferred_at, result_status, score)
512
+ VALUES (?, ?, ?, ?, datetime('now'), ?, ?)`,
513
+ ).run(
514
+ sourceCountry,
515
+ sourceTag,
516
+ targetCountry,
517
+ targetTag,
518
+ resultStatus,
519
+ score,
520
+ );
521
+ }
522
+
523
+ // ====== LLM 分析师 ======
524
+
525
+ /**
526
+ * LLM 分析师:定期体检,分析数据,给出方向建议
527
+ * @param {string[]} countries - 要分析的国家列表
528
+ * @returns {{ summary, topDirections, worstDirections, crossCountrySuggestions, cleanupSuggestions, nextStrategy }}
529
+ */
530
+ export async function runLLMAnalyst(countries = null) {
531
+ const db = getDb();
532
+ if (!db) return { error: "db not ready" };
533
+
534
+ // 收集所有需要的数据
535
+ const allCountries = countries || [
536
+ ...new Set(
537
+ db
538
+ .prepare(`SELECT DISTINCT country FROM tag_directions`)
539
+ .all()
540
+ .map((r) => r.country),
541
+ ),
542
+ ];
543
+
544
+ if (allCountries.length === 0) {
545
+ // fallback: 从 tags 表获取国家列表
546
+ const allTags = db.prepare(`SELECT countries FROM tags`).all();
547
+ const countrySet = new Set();
548
+ for (const t of allTags) {
549
+ try {
550
+ for (const c of JSON.parse(t.countries || "[]")) countrySet.add(c);
551
+ } catch {}
552
+ }
553
+ allCountries.push(...countrySet);
554
+ }
555
+
556
+ // 收集各国家数据
557
+ const countryData = [];
558
+ for (const country of allCountries) {
559
+ // 基础统计
560
+ const stats = db
561
+ .prepare(
562
+ `SELECT
563
+ COUNT(*) as total,
564
+ SUM(CASE WHEN status = 'productive' THEN 1 ELSE 0 END) as productive,
565
+ SUM(CASE WHEN status = 'dead' THEN 1 ELSE 0 END) as dead,
566
+ SUM(CASE WHEN status = 'scored' THEN 1 ELSE 0 END) as scored
567
+ FROM tags WHERE countries LIKE ?`,
568
+ )
569
+ .get(`%"${country}"%`);
570
+
571
+ // Dead 分类
572
+ const deadStats = db
573
+ .prepare(
574
+ `SELECT
575
+ SUM(CASE WHEN dead_reason = 'not_exist' THEN 1 ELSE 0 END) as not_exist,
576
+ SUM(CASE WHEN dead_reason = 'no_match' THEN 1 ELSE 0 END) as no_match,
577
+ SUM(CASE WHEN dead_reason = 'wrong_country' THEN 1 ELSE 0 END) as wrong_country
578
+ FROM tags WHERE status = 'dead' AND countries LIKE ?`,
579
+ )
580
+ .get(`%"${country}"%`);
581
+
582
+ // Top productive tags
583
+ const topTags = db
584
+ .prepare(
585
+ `SELECT tag, score, matched_authors, pushed_users, dead_reason
586
+ FROM tags WHERE status = 'productive' AND countries LIKE ?
587
+ ORDER BY score DESC LIMIT 10`,
588
+ )
589
+ .all(`%"${country}"%`);
590
+
591
+ // 方向统计
592
+ const directions = db
593
+ .prepare(
594
+ `SELECT name, level, productive_experiments, total_experiments, best_score
595
+ FROM tag_directions WHERE country = ? ORDER BY best_score DESC LIMIT 10`,
596
+ )
597
+ .all(country);
598
+
599
+ // 最近实验
600
+ const recentExperiments = db
601
+ .prepare(
602
+ `SELECT hypothesis, strategy, productive_count, dead_count, conclusion
603
+ FROM tag_experiments WHERE country = ? ORDER BY created_at DESC LIMIT 5`,
604
+ )
605
+ .all(country);
606
+
607
+ countryData.push({
608
+ country,
609
+ stats,
610
+ deadStats,
611
+ topTags,
612
+ directions,
613
+ recentExperiments,
614
+ });
615
+ }
616
+
617
+ // 构建 LLM prompt
618
+ const dataSummary = countryData
619
+ .map((cd) => {
620
+ const topTagNames = cd.topTags
621
+ .map((t) => `${t.tag}(score:${t.score})`)
622
+ .join(", ");
623
+ const dirNames = cd.directions
624
+ .map(
625
+ (d) =>
626
+ `${d.name}(L${d.level}, eff:${d.productive_experiments}/${d.total_experiments})`,
627
+ )
628
+ .join(", ");
629
+ const prodRatio =
630
+ cd.stats.total > 0
631
+ ? ((cd.stats.productive / cd.stats.total) * 100).toFixed(1)
632
+ : "0";
633
+ const deadRatio =
634
+ cd.stats.total > 0
635
+ ? ((cd.stats.dead / cd.stats.total) * 100).toFixed(1)
636
+ : "0";
637
+
638
+ return `${cd.country}: ${cd.stats.total} tags, ${prodRatio}% productive, ${deadRatio}% dead | dead分类: not_exist=${cd.deadStats.not_exist}, no_match=${cd.deadStats.no_match}, wrong_country=${cd.deadStats.wrong_country} | Top: ${topTagNames} | Directions: ${dirNames}`;
639
+ })
640
+ .join("\n");
641
+
642
+ const prompt = `You are an analyst reviewing a TikTok hashtag discovery system's performance.
643
+
644
+ The system tries to find TikTok hashtags used by local sellers in European countries. Each hashtag is scored by scraping TikTok's tag page and checking if video authors are from the target country.
645
+
646
+ === CURRENT STATE ===
647
+ ${dataSummary}
648
+
649
+ === YOUR TASK ===
650
+ Analyze the data and return a JSON with:
651
+ 1. "summary": Overall assessment (2-3 sentences). What's working, what's failing?
652
+ 2. "topDirections": Array of {country, direction, why} — the best performing exploration directions
653
+ 3. "worstDirections": Array of {country, direction, why} — directions that should be abandoned
654
+ 4. "crossCountrySuggestions": Array of {fromCountry, tag, toCountry, translatedTag, reason} — tags that worked in one country and should be tried in a similar-language country
655
+ 5. "cleanupSuggestions": Array of {action, count, reason} — what data should be cleaned up
656
+ 6. "nextStrategy": Suggested strategy for the next round (2-3 sentences)
657
+
658
+ Return ONLY valid JSON, no other text.`;
659
+
660
+ try {
661
+ const content = await callLLM(prompt, 0.5);
662
+ const parsed = JSON.parse(
663
+ content
664
+ .replace(/```json\s*/g, "")
665
+ .replace(/```\s*/g, "")
666
+ .trim(),
667
+ );
668
+
669
+ // 保存报告
670
+ db.prepare(
671
+ `INSERT INTO tag_analyst_reports
672
+ (report_type, countries, summary, top_directions, worst_directions,
673
+ cross_country_suggestions, cleanup_suggestions, next_strategy)
674
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
675
+ ).run(
676
+ "daily",
677
+ JSON.stringify(allCountries),
678
+ parsed.summary || "",
679
+ JSON.stringify(parsed.topDirections || []),
680
+ JSON.stringify(parsed.worstDirections || []),
681
+ JSON.stringify(parsed.crossCountrySuggestions || []),
682
+ JSON.stringify(parsed.cleanupSuggestions || []),
683
+ parsed.nextStrategy || "",
684
+ );
685
+
686
+ return {
687
+ ...parsed,
688
+ reportId: db.prepare("SELECT last_insert_rowid()").get()?.[
689
+ "last_insert_rowid()"
690
+ ],
691
+ };
692
+ } catch (e) {
693
+ return { error: e.message, raw: null };
694
+ }
695
+ }
696
+
697
+ /**
698
+ * 获取最近的分析师报告
699
+ */
700
+ export function getLatestAnalystReport() {
701
+ const db = getDb();
702
+ if (!db) return null;
703
+
704
+ const row = db
705
+ .prepare(
706
+ `SELECT * FROM tag_analyst_reports ORDER BY created_at DESC LIMIT 1`,
707
+ )
708
+ .get();
709
+
710
+ if (!row) return null;
711
+
712
+ return {
713
+ ...row,
714
+ countries: JSON.parse(row.countries || "[]"),
715
+ top_directions: JSON.parse(row.top_directions || "[]"),
716
+ worst_directions: JSON.parse(row.worst_directions || "[]"),
717
+ cross_country_suggestions: JSON.parse(
718
+ row.cross_country_suggestions || "[]",
719
+ ),
720
+ cleanup_suggestions: JSON.parse(row.cleanup_suggestions || "[]"),
721
+ };
722
+ }
723
+
724
+ /**
725
+ * 执行分析师建议的清理操作
726
+ */
727
+ export function executeAnalystCleanup() {
728
+ const db = getDb();
729
+ if (!db) return { ok: false, error: "db not ready" };
730
+
731
+ const report = getLatestAnalystReport();
732
+ if (!report?.cleanup_suggestions) return { ok: false, error: "no report" };
733
+
734
+ let deletedNotExist = 0;
735
+ let resetWrongCountry = 0;
736
+
737
+ for (const suggestion of report.cleanup_suggestions) {
738
+ if (suggestion.action === "delete_not_exist_dead_tags") {
739
+ const result = db
740
+ .prepare(
741
+ `DELETE FROM tags WHERE status = 'dead' AND dead_reason = 'not_exist'`,
742
+ )
743
+ .run();
744
+ deletedNotExist = result.changes;
745
+ }
746
+ if (suggestion.action === "reset_wrong_country_to_new") {
747
+ const result = db
748
+ .prepare(
749
+ `UPDATE tags SET status = 'new', dead_reason = NULL, scored_at = NULL WHERE status = 'dead' AND dead_reason = 'wrong_country'`,
750
+ )
751
+ .run();
752
+ resetWrongCountry = result.changes;
753
+ }
754
+ }
755
+
756
+ return { ok: true, deletedNotExist, resetWrongCountry };
757
+ }
758
+
759
+ // ====== 实验记录 ======
760
+
761
+ /**
762
+ * 记录一次方向探索实验
763
+ */
764
+ export function recordExperiment({
765
+ directionId,
766
+ country,
767
+ hypothesis,
768
+ strategy,
769
+ tagsTried,
770
+ tagsProductive,
771
+ tagsDeadNotExist,
772
+ tagsDeadNoMatch,
773
+ conclusion,
774
+ }) {
775
+ const db = getDb();
776
+ if (!db) return { ok: false };
777
+
778
+ const result = db
779
+ .prepare(
780
+ `INSERT INTO tag_experiments
781
+ (direction_id, country, hypothesis, strategy, tags_tried, tags_productive,
782
+ tags_dead_not_exist, tags_dead_no_match, conclusion,
783
+ productive_count, dead_count)
784
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
785
+ )
786
+ .run(
787
+ directionId,
788
+ country,
789
+ hypothesis,
790
+ strategy,
791
+ JSON.stringify(tagsTried || []),
792
+ JSON.stringify(tagsProductive || []),
793
+ JSON.stringify(tagsDeadNotExist || []),
794
+ JSON.stringify(tagsDeadNoMatch || []),
795
+ conclusion,
796
+ (tagsProductive || []).length,
797
+ (tagsDeadNotExist || []).length + (tagsDeadNoMatch || []).length,
798
+ );
799
+
800
+ return { ok: true, id: result.lastInsertRowid };
801
+ }
802
+
803
+ /**
804
+ * 获取某国的实验历史(用于 LLM 学习)
805
+ */
806
+ export function getExperimentHistory(country, limit = 10) {
807
+ const db = getDb();
808
+ if (!db) return [];
809
+
810
+ const rows = db
811
+ .prepare(
812
+ `SELECT * FROM tag_experiments WHERE country = ? ORDER BY created_at DESC LIMIT ?`,
813
+ )
814
+ .all(country, limit);
815
+
816
+ return rows.map((r) => ({
817
+ ...r,
818
+ tags_tried: JSON.parse(r.tags_tried || "[]"),
819
+ tags_productive: JSON.parse(r.tags_productive || "[]"),
820
+ tags_dead_not_exist: JSON.parse(r.tags_dead_not_exist || "[]"),
821
+ tags_dead_no_match: JSON.parse(r.tags_dead_no_match || "[]"),
822
+ }));
823
+ }
824
+
825
+ // ====== 分层探索 Prompt 生成 ======
826
+
827
+ /**
828
+ * 生成分层探索的 prompt(替代原有 buildDiscoverPrompt)
829
+ * L1: 基于通用卖货词扩展
830
+ * L2: 基于已验证的产品分类深挖
831
+ * L3: 基于具体产品寻找变体
832
+ */
833
+ export function buildLayeredDiscoverPrompt(country, levelData) {
834
+ const lang = getLang(country);
835
+ const langName =
836
+ {
837
+ cs: "Czech",
838
+ el: "Greek",
839
+ hu: "Hungarian",
840
+ pt: "Portuguese",
841
+ es: "Spanish",
842
+ pl: "Polish",
843
+ nl: "Dutch",
844
+ de: "German",
845
+ fr: "French",
846
+ it: "Italian",
847
+ en: "English",
848
+ }[lang] || lang;
849
+
850
+ const {
851
+ level = 1,
852
+ existingProductive = [],
853
+ existingDead = [],
854
+ directionName = null,
855
+ experimentHistory = [],
856
+ crossCountryTips = [],
857
+ } = levelData;
858
+
859
+ // 根据层级调整提示
860
+ let levelGuide = "";
861
+ if (level === 1) {
862
+ levelGuide = `You are exploring BROAD seller keywords in ${langName}. Focus on the most common words local small sellers use in hashtags. Think: how would a ${langName}-speaking person tag their post when selling something? Use common, everyday words.`;
863
+ } else if (level === 2) {
864
+ levelGuide = `You are exploring PRODUCT CATEGORIES in ${langName}. Focus on specific types of products that local sellers commonly offer. Use real category names in ${langName}.`;
865
+ } else {
866
+ levelGuide = `You are exploring SPECIFIC PRODUCTS in ${langName}. Generate very specific product names that are commonly sold by individuals (not big brands). Think of items people sell on marketplace apps.`;
867
+ }
868
+
869
+ const productiveHint =
870
+ existingProductive.length > 0
871
+ ? `\nTags that already worked in ${country}: ${existingProductive.map((t) => t.tag).join(", ")}. These are GOOD references for the TYPE of tag to generate.`
872
+ : "";
873
+
874
+ const deadHint =
875
+ existingDead.length > 0
876
+ ? `\nTags that failed (mostly don't exist on TikTok): ${existingDead
877
+ .slice(0, 30)
878
+ .map((t) => t.tag)
879
+ .join(", ")}. AVOID these and similar patterns.`
880
+ : "";
881
+
882
+ const directionHint = directionName
883
+ ? `\nFocus direction: "${directionName}" — explore hashtags related to this theme.`
884
+ : "";
885
+
886
+ // 实验历史反馈
887
+ let experimentFeedback = "";
888
+ if (experimentHistory.length > 0) {
889
+ const lines = experimentHistory.slice(0, 3).map((e) => {
890
+ const productive = e.tags_productive?.join(", ") || "none";
891
+ const dead =
892
+ (e.tags_dead_not_exist || [])
893
+ .concat(e.tags_dead_no_match || [])
894
+ .join(", ") || "none";
895
+ return `Experiment: hypothesis="${e.hypothesis}", worked=[${productive}], failed=[${dead}]`;
896
+ });
897
+ experimentFeedback = `\n=== Previous Experiments in ${country} ===\n${lines.join("\n")}\nLearn from these: what patterns worked? What patterns failed?`;
898
+ }
899
+
900
+ // 跨国家建议
901
+ let crossCountryFeedback = "";
902
+ if (crossCountryTips.length > 0) {
903
+ const tips = crossCountryTips
904
+ .map(
905
+ (t) =>
906
+ `${t.source_tag} (worked in ${t.source_country}, score ${t.source_score}) → try similar in ${country}`,
907
+ )
908
+ .join("\n");
909
+ crossCountryFeedback = `\n=== Cross-Country Tips ===\nThese tags worked in similar-language countries — consider generating variations for ${country}:\n${tips}`;
910
+ }
911
+
912
+ return `You are discovering TikTok hashtags used by people who SELL things in ${country}.
913
+
914
+ ${levelGuide}
915
+
916
+ ⛔ CRITICAL RULE: ONLY suggest hashtags that you are CONFIDENT actually exist on TikTok with real posts. Never invent compound words. If you're not sure — don't include it.
917
+
918
+ ✅ GOOD: Simple common words, real category names, established community tags
919
+ ❌ BAD: Invented compound words, hyper-specific combinations, rare technical terms
920
+
921
+ ${productiveHint}${deadHint}${directionHint}${experimentFeedback}${crossCountryFeedback}
922
+
923
+ ## Output format
924
+ Return ONLY a JSON:
925
+ {
926
+ "strategy": "2-3 sentences: what direction you chose and why",
927
+ "tags": ["tag1", "tag2", "tag3", "tag4", "tag5", "tag6", "tag7", "tag8", "tag9", "tag10"]
928
+ }
929
+
930
+ Rules:
931
+ - Generate at least 15 distinct tag ideas — we filter duplicates on our end
932
+ - Prefer simpler, more common words over complex ones
933
+ - Think like a ${langName} marketplace seller: what hashtags do they actually use?`;
934
+ }
935
+
936
+ // ====== 综合指标 ======
937
+
938
+ /**
939
+ * 获取完整的自学习系统状态
940
+ */
941
+ export function getSelfLearnStatus() {
942
+ const db = getDb();
943
+ if (!db) return { error: "db not ready" };
944
+
945
+ const directionCount = db
946
+ .prepare(`SELECT COUNT(*) as c FROM tag_directions`)
947
+ .get().c;
948
+ const experimentCount = db
949
+ .prepare(`SELECT COUNT(*) as c FROM tag_experiments`)
950
+ .get().c;
951
+ const reportCount = db
952
+ .prepare(`SELECT COUNT(*) as c FROM tag_analyst_reports`)
953
+ .get().c;
954
+ const crossCountryCount = db
955
+ .prepare(`SELECT COUNT(*) as c FROM cross_country_learnings`)
956
+ .get().c;
957
+
958
+ const deadStats = db
959
+ .prepare(
960
+ `SELECT
961
+ SUM(CASE WHEN dead_reason = 'not_exist' THEN 1 ELSE 0 END) as not_exist,
962
+ SUM(CASE WHEN dead_reason = 'no_match' THEN 1 ELSE 0 END) as no_match,
963
+ SUM(CASE WHEN dead_reason = 'wrong_country' THEN 1 ELSE 0 END) as wrong_country,
964
+ SUM(CASE WHEN dead_reason IS NULL THEN 1 ELSE 0 END) as unclassified
965
+ FROM tags WHERE status = 'dead'`,
966
+ )
967
+ .get();
968
+
969
+ const latestReport = getLatestAnalystReport();
970
+
971
+ return {
972
+ directions: directionCount,
973
+ experiments: experimentCount,
974
+ reports: reportCount,
975
+ crossCountryLearnings: crossCountryCount,
976
+ deadClassification: deadStats,
977
+ latestReport: latestReport
978
+ ? {
979
+ id: latestReport.id,
980
+ created_at: latestReport.created_at,
981
+ summary: latestReport.summary,
982
+ nextStrategy: latestReport.next_strategy,
983
+ }
984
+ : null,
985
+ };
986
+ }