tt-help-cli-ycl 1.4.2 → 1.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli/tag.js +131 -68
- package/src/lib/args.js +0 -4
- package/src/lib/constants.js +2 -1
- package/src/lib/tag-discover.js +218 -10
- package/src/watch/data-store.js +42 -0
- package/src/watch/db-schema.js +19 -0
- package/src/watch/db-tags.js +749 -0
- package/src/watch/public/app.js +27 -0
- package/src/watch/public/index.html +1 -1
- package/src/watch/server.js +255 -2
- package/src/watch/tag-service.js +509 -23
package/src/watch/db-tags.js
CHANGED
|
@@ -357,6 +357,30 @@ export function getRecentDiscoverLogs(country, limit = 10) {
|
|
|
357
357
|
}));
|
|
358
358
|
}
|
|
359
359
|
|
|
360
|
+
/**
|
|
361
|
+
* 获取最近 N 轮生成的所有标签(去重),用于检测重复生成
|
|
362
|
+
*/
|
|
363
|
+
export function getRecentGeneratedTags(country, lookback = 10) {
|
|
364
|
+
const db = getDb();
|
|
365
|
+
if (!db) return [];
|
|
366
|
+
|
|
367
|
+
const logs = db
|
|
368
|
+
.prepare(
|
|
369
|
+
`SELECT tags_generated FROM discover_log
|
|
370
|
+
WHERE country = ?
|
|
371
|
+
ORDER BY round DESC
|
|
372
|
+
LIMIT ?`,
|
|
373
|
+
)
|
|
374
|
+
.all(country, lookback);
|
|
375
|
+
|
|
376
|
+
const allTags = new Set();
|
|
377
|
+
for (const log of logs) {
|
|
378
|
+
const tags = JSON.parse(log.tags_generated || "[]");
|
|
379
|
+
for (const t of tags) allTags.add(t);
|
|
380
|
+
}
|
|
381
|
+
return [...allTags];
|
|
382
|
+
}
|
|
383
|
+
|
|
360
384
|
/**
|
|
361
385
|
* 获取某国家的 discover 轮次(用于递增)
|
|
362
386
|
*/
|
|
@@ -425,3 +449,728 @@ export function getDiscoverEffectiveness(country) {
|
|
|
425
449
|
};
|
|
426
450
|
});
|
|
427
451
|
}
|
|
452
|
+
|
|
453
|
+
// ====== 自闭环:健康指标 & 数据清理 ======
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* 获取某国家的 tag 健康指标
|
|
457
|
+
* 返回:总数、各状态数、dead比率、productive比率、饱和度、最近轮次效果
|
|
458
|
+
*/
|
|
459
|
+
export function getCountryHealth(country) {
|
|
460
|
+
const db = getDb();
|
|
461
|
+
if (!db) return null;
|
|
462
|
+
|
|
463
|
+
// 基础统计
|
|
464
|
+
const allTags = db
|
|
465
|
+
.prepare(`SELECT tag, status, score FROM tags WHERE countries LIKE ?`)
|
|
466
|
+
.all(`%"${country}"%`);
|
|
467
|
+
|
|
468
|
+
const total = allTags.length;
|
|
469
|
+
const productive = allTags.filter((t) => t.status === "productive");
|
|
470
|
+
const dead = allTags.filter((t) => t.status === "dead");
|
|
471
|
+
const scored = allTags.filter((t) => t.status === "scored");
|
|
472
|
+
const lowScored = allTags.filter(
|
|
473
|
+
(t) => t.status === "scored" && t.score >= 10 && t.score < 20,
|
|
474
|
+
);
|
|
475
|
+
|
|
476
|
+
const deadRatio = total > 0 ? (dead.length / total) * 100 : 0;
|
|
477
|
+
const productiveRatio = total > 0 ? (productive.length / total) * 100 : 0;
|
|
478
|
+
const avgProductiveScore =
|
|
479
|
+
productive.length > 0
|
|
480
|
+
? productive.reduce((s, t) => s + t.score, 0) / productive.length
|
|
481
|
+
: 0;
|
|
482
|
+
|
|
483
|
+
// 最近5轮 discover 效果
|
|
484
|
+
const recentLogs = db
|
|
485
|
+
.prepare(
|
|
486
|
+
`SELECT round, tags_added, tags_generated FROM discover_log
|
|
487
|
+
WHERE country = ? ORDER BY round DESC LIMIT 5`,
|
|
488
|
+
)
|
|
489
|
+
.all(country);
|
|
490
|
+
|
|
491
|
+
// 计算最近5轮的 productive 和 dead 比例
|
|
492
|
+
let recentProductiveCount = 0;
|
|
493
|
+
let recentDeadCount = 0;
|
|
494
|
+
let recentTotalGenerated = 0;
|
|
495
|
+
let recentEmptyRounds = 0;
|
|
496
|
+
|
|
497
|
+
for (const log of recentLogs) {
|
|
498
|
+
const tagsGen = JSON.parse(log.tags_generated || "[]");
|
|
499
|
+
recentTotalGenerated += tagsGen.length;
|
|
500
|
+
if (tagsGen.length === 0) recentEmptyRounds++;
|
|
501
|
+
|
|
502
|
+
if (tagsGen.length > 0) {
|
|
503
|
+
const placeholders = tagsGen.map(() => "?").join(",");
|
|
504
|
+
const tagResults = db
|
|
505
|
+
.prepare(`SELECT status FROM tags WHERE tag IN (${placeholders})`)
|
|
506
|
+
.all(...tagsGen);
|
|
507
|
+
recentProductiveCount += tagResults.filter(
|
|
508
|
+
(t) => t.status === "productive",
|
|
509
|
+
).length;
|
|
510
|
+
recentDeadCount += tagResults.filter((t) => t.status === "dead").length;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const recentProductiveRatio =
|
|
515
|
+
recentTotalGenerated > 0
|
|
516
|
+
? (recentProductiveCount / recentTotalGenerated) * 100
|
|
517
|
+
: 0;
|
|
518
|
+
const recentDeadRatio =
|
|
519
|
+
recentTotalGenerated > 0
|
|
520
|
+
? (recentDeadCount / recentTotalGenerated) * 100
|
|
521
|
+
: 0;
|
|
522
|
+
|
|
523
|
+
// 饱和度:最近轮次生成空或极少 tag 的比例
|
|
524
|
+
const saturation =
|
|
525
|
+
recentLogs.length > 0 ? (recentEmptyRounds / recentLogs.length) * 100 : 0;
|
|
526
|
+
|
|
527
|
+
// 判断健康状态
|
|
528
|
+
let healthStatus = "healthy";
|
|
529
|
+
const issues = [];
|
|
530
|
+
|
|
531
|
+
if (deadRatio > 60) {
|
|
532
|
+
healthStatus = "critical";
|
|
533
|
+
issues.push(
|
|
534
|
+
`dead_ratio=${deadRatio.toFixed(1)}% (>60%): 太多无效tag,需要降低生成激进度`,
|
|
535
|
+
);
|
|
536
|
+
} else if (deadRatio > 40) {
|
|
537
|
+
healthStatus = "warning";
|
|
538
|
+
issues.push(
|
|
539
|
+
`dead_ratio=${deadRatio.toFixed(1)}% (>40%): 无效tag偏多,需要更保守`,
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (productiveRatio < 8) {
|
|
544
|
+
healthStatus = healthStatus === "critical" ? "critical" : "warning";
|
|
545
|
+
issues.push(
|
|
546
|
+
`productive_ratio=${productiveRatio.toFixed(1)}% (<8%): 有效tag太少,可能需要切换策略方向`,
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (saturation >= 40) {
|
|
551
|
+
healthStatus = healthStatus === "critical" ? "critical" : "warning";
|
|
552
|
+
issues.push(
|
|
553
|
+
`saturation=${saturation.toFixed(0)}%: tag空间接近饱和,需要切换到新niche`,
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (recentEmptyRounds > 0) {
|
|
558
|
+
issues.push(
|
|
559
|
+
`recent_empty_rounds=${recentEmptyRounds}: LLM无法生成新不重复tag`,
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if (lowScored.length > productive.length * 2) {
|
|
564
|
+
issues.push(
|
|
565
|
+
`low_scored=${lowScored.length} vs productive=${productive.length}: 低分tag远多于有效tag,应清理`,
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// 推荐的 niche 方向(基于现有 productive tag 的分类分析)
|
|
570
|
+
const nicheRecommendation = recommendNiche(country, productive);
|
|
571
|
+
|
|
572
|
+
return {
|
|
573
|
+
country,
|
|
574
|
+
total,
|
|
575
|
+
productive: productive.length,
|
|
576
|
+
dead: dead.length,
|
|
577
|
+
scored: scored.length,
|
|
578
|
+
lowScored: lowScored.length,
|
|
579
|
+
deadRatio: deadRatio.toFixed(1),
|
|
580
|
+
productiveRatio: productiveRatio.toFixed(1),
|
|
581
|
+
avgProductiveScore: avgProductiveScore.toFixed(1),
|
|
582
|
+
recentProductiveRatio: recentProductiveRatio.toFixed(1),
|
|
583
|
+
recentDeadRatio: recentDeadRatio.toFixed(1),
|
|
584
|
+
recentEmptyRounds,
|
|
585
|
+
saturation: saturation.toFixed(0),
|
|
586
|
+
healthStatus,
|
|
587
|
+
issues,
|
|
588
|
+
nicheRecommendation,
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* 基于现有 productive tag 推荐新的 niche 方向
|
|
594
|
+
* 分析已有 tag 的类别分布,找出未覆盖或覆盖不足的方向
|
|
595
|
+
*/
|
|
596
|
+
function recommendNiche(country, productiveTags) {
|
|
597
|
+
// 定义 niche 类别及其关键词模式
|
|
598
|
+
const niches = {
|
|
599
|
+
fashion: [
|
|
600
|
+
"clothes",
|
|
601
|
+
"kleidung",
|
|
602
|
+
"kleding",
|
|
603
|
+
"vestiti",
|
|
604
|
+
"ropa",
|
|
605
|
+
"outfit",
|
|
606
|
+
"mode",
|
|
607
|
+
"shoes",
|
|
608
|
+
"sneakers",
|
|
609
|
+
"jurken",
|
|
610
|
+
"obleceni",
|
|
611
|
+
"ruha",
|
|
612
|
+
"zapatos",
|
|
613
|
+
"chaussures",
|
|
614
|
+
"vintage",
|
|
615
|
+
"secondhand",
|
|
616
|
+
"thrift",
|
|
617
|
+
"friperie",
|
|
618
|
+
"tweedehands",
|
|
619
|
+
"hasznalt",
|
|
620
|
+
"segundamano",
|
|
621
|
+
"secondamano",
|
|
622
|
+
],
|
|
623
|
+
food: [
|
|
624
|
+
"food",
|
|
625
|
+
"bakery",
|
|
626
|
+
"brot",
|
|
627
|
+
"brood",
|
|
628
|
+
"pane",
|
|
629
|
+
"comida",
|
|
630
|
+
"restaurant",
|
|
631
|
+
"cafe",
|
|
632
|
+
"kitchen",
|
|
633
|
+
"konyha",
|
|
634
|
+
"cukrar",
|
|
635
|
+
"patisserie",
|
|
636
|
+
"boulangerie",
|
|
637
|
+
"wypieki",
|
|
638
|
+
"sauerteig",
|
|
639
|
+
"etterm",
|
|
640
|
+
"kaviarnia",
|
|
641
|
+
],
|
|
642
|
+
handmade_crafts: [
|
|
643
|
+
"handmade",
|
|
644
|
+
"handwerk",
|
|
645
|
+
"handgemaakt",
|
|
646
|
+
"artigianato",
|
|
647
|
+
"hechoamano",
|
|
648
|
+
"kézműves",
|
|
649
|
+
"kreativ",
|
|
650
|
+
"craft",
|
|
651
|
+
"diy",
|
|
652
|
+
"knutselen",
|
|
653
|
+
"ceramic",
|
|
654
|
+
"keramiek",
|
|
655
|
+
"kerzen",
|
|
656
|
+
"gyertya",
|
|
657
|
+
"harskunst",
|
|
658
|
+
"resin",
|
|
659
|
+
"macrame",
|
|
660
|
+
"pottery",
|
|
661
|
+
"weaving",
|
|
662
|
+
],
|
|
663
|
+
home_living: [
|
|
664
|
+
"home",
|
|
665
|
+
"wohnung",
|
|
666
|
+
"huis",
|
|
667
|
+
"casa",
|
|
668
|
+
"casa",
|
|
669
|
+
"lakberendezes",
|
|
670
|
+
"interior",
|
|
671
|
+
"deco",
|
|
672
|
+
"decoration",
|
|
673
|
+
"einrichten",
|
|
674
|
+
"furniture",
|
|
675
|
+
"möbel",
|
|
676
|
+
"meubilair",
|
|
677
|
+
"epipla",
|
|
678
|
+
"butor",
|
|
679
|
+
"nabytek",
|
|
680
|
+
"tapic",
|
|
681
|
+
"reinigung",
|
|
682
|
+
"cleaning",
|
|
683
|
+
],
|
|
684
|
+
electronics_tech: [
|
|
685
|
+
"tech",
|
|
686
|
+
"elektronik",
|
|
687
|
+
"electronica",
|
|
688
|
+
"gadget",
|
|
689
|
+
"phone",
|
|
690
|
+
"smartphone",
|
|
691
|
+
"laptop",
|
|
692
|
+
"computer",
|
|
693
|
+
"audio",
|
|
694
|
+
"cuffie",
|
|
695
|
+
"auricolari",
|
|
696
|
+
"headphones",
|
|
697
|
+
"caricatore",
|
|
698
|
+
"charger",
|
|
699
|
+
"usb",
|
|
700
|
+
"playstation",
|
|
701
|
+
"gaming",
|
|
702
|
+
],
|
|
703
|
+
pets_animals: [
|
|
704
|
+
"pet",
|
|
705
|
+
"hund",
|
|
706
|
+
"dog",
|
|
707
|
+
"cat",
|
|
708
|
+
"kat",
|
|
709
|
+
"kuty",
|
|
710
|
+
"konia",
|
|
711
|
+
"pets",
|
|
712
|
+
"tier",
|
|
713
|
+
"dieren",
|
|
714
|
+
"animale",
|
|
715
|
+
"animal",
|
|
716
|
+
"kutyakonzerv",
|
|
717
|
+
"akcesoriadlapsow",
|
|
718
|
+
],
|
|
719
|
+
beauty_health: [
|
|
720
|
+
"beauty",
|
|
721
|
+
"kosmetik",
|
|
722
|
+
"cosmetica",
|
|
723
|
+
"beaute",
|
|
724
|
+
"belleza",
|
|
725
|
+
"szepseg",
|
|
726
|
+
"kapper",
|
|
727
|
+
"hair",
|
|
728
|
+
"haj",
|
|
729
|
+
"skincare",
|
|
730
|
+
"makeup",
|
|
731
|
+
"parfum",
|
|
732
|
+
"perfume",
|
|
733
|
+
"wellness",
|
|
734
|
+
"gesundheit",
|
|
735
|
+
"salud",
|
|
736
|
+
"sante",
|
|
737
|
+
],
|
|
738
|
+
books_collectibles: [
|
|
739
|
+
"book",
|
|
740
|
+
"buch",
|
|
741
|
+
"boek",
|
|
742
|
+
"libro",
|
|
743
|
+
"knihy",
|
|
744
|
+
"konyv",
|
|
745
|
+
"collectible",
|
|
746
|
+
"sammlung",
|
|
747
|
+
"verzameling",
|
|
748
|
+
"collezionismo",
|
|
749
|
+
"coleccion",
|
|
750
|
+
"antique",
|
|
751
|
+
"antiquariat",
|
|
752
|
+
"vintage",
|
|
753
|
+
"vinyl",
|
|
754
|
+
"schallplatten",
|
|
755
|
+
"pokemon",
|
|
756
|
+
"kaarten",
|
|
757
|
+
"moedas",
|
|
758
|
+
"mince",
|
|
759
|
+
],
|
|
760
|
+
services_professional: [
|
|
761
|
+
"service",
|
|
762
|
+
"dienst",
|
|
763
|
+
"servizio",
|
|
764
|
+
"servicio",
|
|
765
|
+
"szolgaltatas",
|
|
766
|
+
"handwerker",
|
|
767
|
+
"craftsman",
|
|
768
|
+
"repair",
|
|
769
|
+
"reparatur",
|
|
770
|
+
"reparatie",
|
|
771
|
+
"riparazione",
|
|
772
|
+
"reparacion",
|
|
773
|
+
"cleaning",
|
|
774
|
+
"reinigung",
|
|
775
|
+
"moving",
|
|
776
|
+
"umzug",
|
|
777
|
+
"verhuur",
|
|
778
|
+
"mudanca",
|
|
779
|
+
"florist",
|
|
780
|
+
"photography",
|
|
781
|
+
"fotograf",
|
|
782
|
+
],
|
|
783
|
+
marketplace_local: [
|
|
784
|
+
"market",
|
|
785
|
+
"markt",
|
|
786
|
+
"mercato",
|
|
787
|
+
"mercado",
|
|
788
|
+
"piac",
|
|
789
|
+
"trh",
|
|
790
|
+
"flohmarkt",
|
|
791
|
+
"rommelmarkt",
|
|
792
|
+
"brocante",
|
|
793
|
+
"bazar",
|
|
794
|
+
"mercadillo",
|
|
795
|
+
"vlooienmarkt",
|
|
796
|
+
"braderie",
|
|
797
|
+
"feira",
|
|
798
|
+
"local",
|
|
799
|
+
"lokal",
|
|
800
|
+
"locale",
|
|
801
|
+
"winkel",
|
|
802
|
+
"boutique",
|
|
803
|
+
"shop",
|
|
804
|
+
"tienda",
|
|
805
|
+
"negozio",
|
|
806
|
+
"bol",
|
|
807
|
+
"webshop",
|
|
808
|
+
"eshop",
|
|
809
|
+
],
|
|
810
|
+
};
|
|
811
|
+
|
|
812
|
+
// 统计每个 niche 在 productive tag 中的覆盖数
|
|
813
|
+
const nicheCoverage = {};
|
|
814
|
+
for (const [niche, keywords] of Object.entries(niches)) {
|
|
815
|
+
const matched = productiveTags.filter((t) =>
|
|
816
|
+
keywords.some((kw) => t.tag.includes(kw)),
|
|
817
|
+
).length;
|
|
818
|
+
nicheCoverage[niche] = matched;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// 找出覆盖不足的 niche(productive < 5)作为推荐方向
|
|
822
|
+
const uncovered = Object.entries(nicheCoverage)
|
|
823
|
+
.filter(([, count]) => count < 5)
|
|
824
|
+
.sort(([, a], [, b]) => a - b)
|
|
825
|
+
.map(([niche, count]) => ({ niche, currentCoverage: count }));
|
|
826
|
+
|
|
827
|
+
// 找出覆盖良好的 niche(productive >= 10)作为已饱和方向
|
|
828
|
+
const saturated = Object.entries(nicheCoverage)
|
|
829
|
+
.filter(([, count]) => count >= 10)
|
|
830
|
+
.sort(([, a], [, b]) => b - a)
|
|
831
|
+
.map(([niche, count]) => ({ niche, currentCoverage: count }));
|
|
832
|
+
|
|
833
|
+
return { uncovered, saturated, nicheCoverage };
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* 清理低效 tag:将 score < 20 的 scored tag 降级为 dead
|
|
838
|
+
* @param {number} minScore - 低于此分数的 scored tag 降级为 dead(默认 20)
|
|
839
|
+
* @returns {object} { cleaned: number }
|
|
840
|
+
*/
|
|
841
|
+
export function cleanupLowScoredTags(minScore = 20) {
|
|
842
|
+
const db = getDb();
|
|
843
|
+
if (!db) return { cleaned: 0, error: "db not ready" };
|
|
844
|
+
|
|
845
|
+
const result = db
|
|
846
|
+
.prepare(
|
|
847
|
+
`UPDATE tags SET status = 'dead', last_error = 'auto_cleanup: scored<20'
|
|
848
|
+
WHERE status = 'scored' AND score < ?`,
|
|
849
|
+
)
|
|
850
|
+
.run(minScore);
|
|
851
|
+
|
|
852
|
+
if (result.changes > 0) {
|
|
853
|
+
console.error(
|
|
854
|
+
`[tags] 自闭环清理: ${result.changes} 个低分scored tag降级为dead (score<${minScore})`,
|
|
855
|
+
);
|
|
856
|
+
}
|
|
857
|
+
return { cleaned: result.changes };
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* 清理长时间卡在 scoring 状态的 tag(超时未打分)
|
|
862
|
+
* @param {number} minutes - 超时分钟数(默认 60)
|
|
863
|
+
* @returns {object} { reset: number }
|
|
864
|
+
*/
|
|
865
|
+
export function cleanupStaleScoringTags(minutes = 60) {
|
|
866
|
+
const db = getDb();
|
|
867
|
+
if (!db) return { reset: 0, error: "db not ready" };
|
|
868
|
+
|
|
869
|
+
const result = db
|
|
870
|
+
.prepare(
|
|
871
|
+
`UPDATE tags SET status = 'new', scored_at = NULL
|
|
872
|
+
WHERE status = 'scoring' AND scored_at IS NOT NULL
|
|
873
|
+
AND scored_at < datetime('now', '-${minutes} minutes')`,
|
|
874
|
+
)
|
|
875
|
+
.run();
|
|
876
|
+
|
|
877
|
+
if (result.changes > 0) {
|
|
878
|
+
console.error(
|
|
879
|
+
`[tags] 自闭环清理: ${result.changes} 个超时scoring tag重置为new (>${minutes}min)`,
|
|
880
|
+
);
|
|
881
|
+
}
|
|
882
|
+
return { reset: result.changes };
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* 批量清理 error 状态的 tag(重置为 new 以重新打分)
|
|
887
|
+
* @returns {object} { reset: number }
|
|
888
|
+
*/
|
|
889
|
+
export function cleanupErrorTags() {
|
|
890
|
+
const db = getDb();
|
|
891
|
+
if (!db) return { reset: 0, error: "db not ready" };
|
|
892
|
+
|
|
893
|
+
const result = db
|
|
894
|
+
.prepare(
|
|
895
|
+
`UPDATE tags SET status = 'new', scored_at = NULL, last_error = NULL
|
|
896
|
+
WHERE status = 'error'`,
|
|
897
|
+
)
|
|
898
|
+
.run();
|
|
899
|
+
|
|
900
|
+
if (result.changes > 0) {
|
|
901
|
+
console.error(`[tags] 自闭环清理: ${result.changes} 个error tag重置为new`);
|
|
902
|
+
}
|
|
903
|
+
return { reset: result.changes };
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// ====== 反思闭环:reflection_rules CRUD ======
|
|
907
|
+
|
|
908
|
+
/**
|
|
909
|
+
* 获取某国家的所有活跃硬规则
|
|
910
|
+
*/
|
|
911
|
+
export function getActiveRules(country) {
|
|
912
|
+
const db = getDb();
|
|
913
|
+
if (!db) return [];
|
|
914
|
+
|
|
915
|
+
return db
|
|
916
|
+
.prepare(
|
|
917
|
+
`SELECT id, country, rule_type, rule_text, trigger_reason, created_at
|
|
918
|
+
FROM reflection_rules
|
|
919
|
+
WHERE country = ? AND is_active = 1
|
|
920
|
+
ORDER BY created_at DESC`,
|
|
921
|
+
)
|
|
922
|
+
.all(country);
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/**
|
|
926
|
+
* 插入一条反思规则
|
|
927
|
+
*/
|
|
928
|
+
export function insertReflectionRule({
|
|
929
|
+
country,
|
|
930
|
+
ruleType = "hard",
|
|
931
|
+
ruleText,
|
|
932
|
+
triggerReason,
|
|
933
|
+
productiveSample,
|
|
934
|
+
deadSample,
|
|
935
|
+
analysisSummary,
|
|
936
|
+
}) {
|
|
937
|
+
const db = getDb();
|
|
938
|
+
if (!db) return { ok: false, error: "db not ready" };
|
|
939
|
+
|
|
940
|
+
try {
|
|
941
|
+
const result = db
|
|
942
|
+
.prepare(
|
|
943
|
+
`INSERT INTO reflection_rules
|
|
944
|
+
(country, rule_type, rule_text, source, trigger_reason,
|
|
945
|
+
productive_sample, dead_sample, analysis_summary)
|
|
946
|
+
VALUES (?, ?, ?, 'reflection', ?, ?, ?, ?)`,
|
|
947
|
+
)
|
|
948
|
+
.run(
|
|
949
|
+
country,
|
|
950
|
+
ruleType,
|
|
951
|
+
ruleText,
|
|
952
|
+
triggerReason || null,
|
|
953
|
+
JSON.stringify(productiveSample || []),
|
|
954
|
+
JSON.stringify(deadSample || []),
|
|
955
|
+
analysisSummary || null,
|
|
956
|
+
);
|
|
957
|
+
return { ok: true, id: result.lastInsertRowid };
|
|
958
|
+
} catch (e) {
|
|
959
|
+
return { ok: false, error: e.message };
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* 批量插入反思规则(一次反思产出多条规则)
|
|
965
|
+
*/
|
|
966
|
+
export function insertReflectionRules(rules) {
|
|
967
|
+
const db = getDb();
|
|
968
|
+
if (!db) return { ok: false, error: "db not ready" };
|
|
969
|
+
|
|
970
|
+
const inserted = [];
|
|
971
|
+
for (const rule of rules) {
|
|
972
|
+
const result = insertReflectionRule(rule);
|
|
973
|
+
if (result.ok) inserted.push(result.id);
|
|
974
|
+
}
|
|
975
|
+
return { ok: true, inserted: inserted.length };
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* 废弃旧规则(当新反思覆盖旧规则时)
|
|
980
|
+
*/
|
|
981
|
+
export function supersedeRules(country, reason = "new reflection") {
|
|
982
|
+
const db = getDb();
|
|
983
|
+
if (!db) return { ok: false, error: "db not ready" };
|
|
984
|
+
|
|
985
|
+
const result = db
|
|
986
|
+
.prepare(
|
|
987
|
+
`UPDATE reflection_rules
|
|
988
|
+
SET is_active = 0, superseded_at = datetime('now')
|
|
989
|
+
WHERE country = ? AND is_active = 1`,
|
|
990
|
+
)
|
|
991
|
+
.run(country);
|
|
992
|
+
|
|
993
|
+
return { ok: true, superseded: result.changes };
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/**
|
|
997
|
+
* 检查是否需要触发反思
|
|
998
|
+
* 条件:最近 N 轮 productive_cnt ≤ threshold
|
|
999
|
+
* @returns {{ shouldReflect: boolean, reason: string, recentRounds: array }}
|
|
1000
|
+
*/
|
|
1001
|
+
export function shouldTriggerReflection(country, lookback = 5, threshold = 1) {
|
|
1002
|
+
const db = getDb();
|
|
1003
|
+
if (!db) return { shouldReflect: false, reason: "db not ready" };
|
|
1004
|
+
|
|
1005
|
+
// 获取最近 lookback 轮的 discover_log
|
|
1006
|
+
const recentLogs = db
|
|
1007
|
+
.prepare(
|
|
1008
|
+
`SELECT round, tags_generated, tags_added, strategy, created_at
|
|
1009
|
+
FROM discover_log
|
|
1010
|
+
WHERE country = ?
|
|
1011
|
+
ORDER BY round DESC
|
|
1012
|
+
LIMIT ?`,
|
|
1013
|
+
)
|
|
1014
|
+
.all(country, lookback);
|
|
1015
|
+
|
|
1016
|
+
if (recentLogs.length < lookback) {
|
|
1017
|
+
return {
|
|
1018
|
+
shouldReflect: false,
|
|
1019
|
+
reason: `only ${recentLogs.length} rounds, need ${lookback}`,
|
|
1020
|
+
recentRounds: recentLogs,
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// 计算每轮的 productive 数量
|
|
1025
|
+
let totalProductive = 0;
|
|
1026
|
+
let totalDead = 0;
|
|
1027
|
+
let emptyRounds = 0;
|
|
1028
|
+
|
|
1029
|
+
for (const log of recentLogs) {
|
|
1030
|
+
const tagsGen = JSON.parse(log.tags_generated || "[]");
|
|
1031
|
+
if (tagsGen.length === 0) {
|
|
1032
|
+
emptyRounds++;
|
|
1033
|
+
continue;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
const placeholders = tagsGen.map(() => "?").join(",");
|
|
1037
|
+
const tagResults = db
|
|
1038
|
+
.prepare(`SELECT status FROM tags WHERE tag IN (${placeholders})`)
|
|
1039
|
+
.all(...tagsGen);
|
|
1040
|
+
|
|
1041
|
+
const productive = tagResults.filter(
|
|
1042
|
+
(t) => t.status === "productive",
|
|
1043
|
+
).length;
|
|
1044
|
+
const dead = tagResults.filter((t) => t.status === "dead").length;
|
|
1045
|
+
totalProductive += productive;
|
|
1046
|
+
totalDead += dead;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
const avgProductive = totalProductive / lookback;
|
|
1050
|
+
const shouldReflect = avgProductive <= threshold;
|
|
1051
|
+
|
|
1052
|
+
return {
|
|
1053
|
+
shouldReflect,
|
|
1054
|
+
reason: shouldReflect
|
|
1055
|
+
? `最近${lookback}轮平均productive=${avgProductive.toFixed(1)} ≤ ${threshold}`
|
|
1056
|
+
: `最近${lookback}轮平均productive=${avgProductive.toFixed(1)} > ${threshold}`,
|
|
1057
|
+
recentRounds: recentLogs,
|
|
1058
|
+
totalProductive,
|
|
1059
|
+
totalDead,
|
|
1060
|
+
emptyRounds,
|
|
1061
|
+
avgProductive,
|
|
1062
|
+
};
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
/**
|
|
1066
|
+
* 获取反思所需的数据摘要(供 LLM 分析)
|
|
1067
|
+
*/
|
|
1068
|
+
export function getReflectionData(country) {
|
|
1069
|
+
const db = getDb();
|
|
1070
|
+
if (!db) return null;
|
|
1071
|
+
|
|
1072
|
+
// 1. 最近 10 轮 discover_log
|
|
1073
|
+
const recentLogs = db
|
|
1074
|
+
.prepare(
|
|
1075
|
+
`SELECT round, strategy, tags_generated, tags_added,
|
|
1076
|
+
avg_productive_score, avg_dead_score, created_at
|
|
1077
|
+
FROM discover_log
|
|
1078
|
+
WHERE country = ?
|
|
1079
|
+
ORDER BY round DESC
|
|
1080
|
+
LIMIT 10`,
|
|
1081
|
+
)
|
|
1082
|
+
.all(country);
|
|
1083
|
+
|
|
1084
|
+
// 2. productive top 20(成功模式)
|
|
1085
|
+
const productiveTop = db
|
|
1086
|
+
.prepare(
|
|
1087
|
+
`SELECT tag, score, matched_authors, pushed_users
|
|
1088
|
+
FROM tags
|
|
1089
|
+
WHERE status = 'productive' AND countries LIKE ?
|
|
1090
|
+
ORDER BY pushed_users DESC
|
|
1091
|
+
LIMIT 20`,
|
|
1092
|
+
)
|
|
1093
|
+
.all(`%"${country}"%`);
|
|
1094
|
+
|
|
1095
|
+
// 3. dead 标签频次统计(哪些标签被反复生成且 dead)
|
|
1096
|
+
const deadFrequency = db
|
|
1097
|
+
.prepare(
|
|
1098
|
+
`SELECT tag, score, last_error, created_at
|
|
1099
|
+
FROM tags
|
|
1100
|
+
WHERE status = 'dead' AND countries LIKE ?
|
|
1101
|
+
ORDER BY created_at DESC
|
|
1102
|
+
LIMIT 50`,
|
|
1103
|
+
)
|
|
1104
|
+
.all(`%"${country}"%`);
|
|
1105
|
+
|
|
1106
|
+
// 4. dead 标签的 error 类型统计
|
|
1107
|
+
const errorStats = db
|
|
1108
|
+
.prepare(
|
|
1109
|
+
`SELECT
|
|
1110
|
+
COALESCE(last_error, 'no_match') as error_type,
|
|
1111
|
+
COUNT(*) as count
|
|
1112
|
+
FROM tags
|
|
1113
|
+
WHERE status = 'dead' AND countries LIKE ?
|
|
1114
|
+
GROUP BY COALESCE(last_error, 'no_match')
|
|
1115
|
+
ORDER BY count DESC`,
|
|
1116
|
+
)
|
|
1117
|
+
.all(`%"${country}"%`);
|
|
1118
|
+
|
|
1119
|
+
// 5. 语言分布:dead 标签中英语 vs 本地语言的比例
|
|
1120
|
+
const lang = getLang(country);
|
|
1121
|
+
const langNames = {
|
|
1122
|
+
cs: "Czech",
|
|
1123
|
+
el: "Greek",
|
|
1124
|
+
hu: "Hungarian",
|
|
1125
|
+
pt: "Portuguese",
|
|
1126
|
+
es: "Spanish",
|
|
1127
|
+
pl: "Polish",
|
|
1128
|
+
nl: "Dutch",
|
|
1129
|
+
de: "German",
|
|
1130
|
+
fr: "French",
|
|
1131
|
+
it: "Italian",
|
|
1132
|
+
en: "English",
|
|
1133
|
+
};
|
|
1134
|
+
const langName = langNames[lang] || lang;
|
|
1135
|
+
|
|
1136
|
+
// 6. 当前活跃的硬规则
|
|
1137
|
+
const activeRules = getActiveRules(country);
|
|
1138
|
+
|
|
1139
|
+
// 7. 健康指标
|
|
1140
|
+
const health = getCountryHealth(country);
|
|
1141
|
+
|
|
1142
|
+
return {
|
|
1143
|
+
country,
|
|
1144
|
+
lang,
|
|
1145
|
+
langName,
|
|
1146
|
+
recentLogs,
|
|
1147
|
+
productiveTop,
|
|
1148
|
+
deadFrequency,
|
|
1149
|
+
errorStats,
|
|
1150
|
+
activeRules,
|
|
1151
|
+
health,
|
|
1152
|
+
};
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
/**
|
|
1156
|
+
* 获取某语言判断函数(用于分析 dead 标签的语言)
|
|
1157
|
+
* 简单判断:是否包含该国本地语言的特殊字符
|
|
1158
|
+
*/
|
|
1159
|
+
function getLang(country) {
|
|
1160
|
+
const COUNTRY_LANG = {
|
|
1161
|
+
CZ: "cs",
|
|
1162
|
+
GR: "el",
|
|
1163
|
+
HU: "hu",
|
|
1164
|
+
PT: "pt",
|
|
1165
|
+
ES: "es",
|
|
1166
|
+
PL: "pl",
|
|
1167
|
+
NL: "nl",
|
|
1168
|
+
DE: "de",
|
|
1169
|
+
FR: "fr",
|
|
1170
|
+
IT: "it",
|
|
1171
|
+
IE: "en",
|
|
1172
|
+
AT: "de",
|
|
1173
|
+
BE: "nl",
|
|
1174
|
+
};
|
|
1175
|
+
return COUNTRY_LANG[country] || "en";
|
|
1176
|
+
}
|