全项目扫描修复: Docker数据卷修复+安全requireAdmin+12页SEO+API白名单+脚本超时+常量提取+假数据删除
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
# scripts/ 目录指南
|
||||
|
||||
追光AI 数字人 Bot 系统的脚本库。本目录的 `.mjs` 脚本都是用 Node.js(>=18)跑,不依赖 Next.js 构建。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
scripts/
|
||||
├── lib/ # 核心库(被 CLI 引用)
|
||||
│ ├── bot-adversarial-learning.mjs # Bot 对抗学习(每周学 1 篇真人高赞)
|
||||
│ ├── bot-avatar-generator.mjs # Bot 头像生成(Lunaris + SVG 兜底)
|
||||
│ ├── bot-persona.mjs # Bot persona 加载/刷新
|
||||
│ ├── bot-persona-experiment.mjs # A/B 测试分流
|
||||
│ └── retry.mjs # DeepSeek/OpenAI 重试封装
|
||||
│
|
||||
├── bot-activity.mjs # 主活动引擎:发 topic / reply
|
||||
├── bot-adversarial-learning-run.mjs # 每周日 23:00 跑
|
||||
├── bot-avatar-generate.mjs # 数字人头像批量预生成
|
||||
├── bot-affinity-update.mjs # bot 板块亲和度更新
|
||||
├── bot-feedback-loop.mjs # bot 自反馈循环
|
||||
├── bot-persona-experiment-run.mjs # A/B 指标采集 + 显著性分析
|
||||
├── bot-skill-crystallize.mjs # bot 技能结晶化
|
||||
└── bot-weekly-review.mjs # bot 周报
|
||||
```
|
||||
|
||||
## 常用命令
|
||||
|
||||
### 数字人头像生成(新)
|
||||
|
||||
```bash
|
||||
# 全量:112 个 bot 一次性预生成 + 落盘 + 写库
|
||||
node scripts/bot-avatar-generate.mjs
|
||||
|
||||
# 单个 bot
|
||||
node scripts/bot-avatar-generate.mjs --bot=laochen
|
||||
|
||||
# 强制重新生成(覆盖已存在)
|
||||
node scripts/bot-avatar-generate.mjs --force
|
||||
|
||||
# 只落盘不写 User.avatarUrl
|
||||
node scripts/bot-avatar-generate.mjs --no-update-db
|
||||
|
||||
# 试运行
|
||||
node scripts/bot-avatar-generate.mjs --dry-run
|
||||
```
|
||||
|
||||
产物:
|
||||
- `public/bot-avatars/{key}.svg` — 唯一程序化头像(~2.5KB/个)
|
||||
- `public/bot-avatars/lunaris/{key}.jpg` — Lunaris 拉取的原图(占位时也是这个)
|
||||
- `public/bot-avatars/manifest.json` — 生成清单
|
||||
|
||||
> 落盘的 SVG 会通过 Next.js 的 `public/` 静态服务直接挂在 `/bot-avatars/{key}.svg`,前端 `<BotAvatar>` 组件会优先用 `user.avatarUrl`,找不到时回退到动态 Lunaris URL。
|
||||
|
||||
### Bot 对抗学习
|
||||
|
||||
```bash
|
||||
# 全量:每个 bot 学一篇本周真人高赞帖
|
||||
node scripts/bot-adversarial-learning-run.mjs
|
||||
|
||||
# 单个 bot
|
||||
node scripts/bot-adversarial-learning-run.mjs --bot=laochen
|
||||
|
||||
# 重跑(先清掉本周已有 learning)
|
||||
node scripts/bot-adversarial-learning-run.mjs --force
|
||||
|
||||
# 追溯更长时间窗
|
||||
node scripts/bot-adversarial-learning-run.mjs --lookback=14
|
||||
|
||||
# 调度入口(生产 cron:`0 23 * * 0`)
|
||||
```
|
||||
|
||||
### Bot A/B 测试
|
||||
|
||||
```bash
|
||||
# 采指标 + 跑显著性分析
|
||||
node scripts/bot-persona-experiment-run.mjs
|
||||
|
||||
# 只采指标,不切 winner
|
||||
node scripts/bot-persona-experiment-run.mjs --no-switch
|
||||
|
||||
# 只跑分析(用已有指标)
|
||||
node scripts/bot-persona-experiment-run.mjs --analyze-only
|
||||
|
||||
# 调度入口(生产 cron:`45 */4 * * *`)
|
||||
```
|
||||
|
||||
## 数据模型要点
|
||||
|
||||
| 表 | 用途 | 关键字段 |
|
||||
|------|------|----------|
|
||||
| `bot_persona_variants` | A/B 测试变体 | `weight` / `is_control` / `sample_count` / `engagement_score` |
|
||||
| `bot_persona_experiments` | 实验 | `started_at` / `ended_at` / `status` |
|
||||
| `bot_persona_assignments` | 内容创建时的分流记录 | `variant_id` / `topic_id` / `post_id` |
|
||||
| `bot_persona_metrics` | 每日聚合指标 | `variant_id` / `date` / `reply_count` / `human_reply_count` |
|
||||
| `bot_adversarial_learnings` | 每周学习 | `week_key` / `source_ref_id` / `learned_insight` / `status` |
|
||||
|
||||
## 添加新 bot
|
||||
|
||||
1. 在 `data/bot-characters.json` 加 character(必填 `key` / `displayName` / `email` / `avatarPrompt` / `personality` / `primaryForums` / `activeHours` / `activityLevel` / `replyChance`)
|
||||
2. 用 `seed-bot-forum.mjs` 把 bot 跑进 DB(已有 seed)
|
||||
3. 跑 `node scripts/bot-avatar-generate.mjs --bot={newKey}` 单独生成头像
|
||||
4. 跑 `node scripts/seed-persona-variants.mjs` 派生 A/B 变体
|
||||
|
||||
## 故障排查
|
||||
|
||||
| 症状 | 排查 |
|
||||
|------|------|
|
||||
| `DATABASE_URL 未配置` | 检查 `.env` 是否存在并 `mysql://` 协议 |
|
||||
| `Lunaris 拉取失败` | 网络问题;脚本会自动 fallback 到 SVG |
|
||||
| `Lunaris 全部返回占位` | trae-api 当前所有 prompt 都返回同一张 default.jpeg;用 `isLunarisDefault` 检测后走 SVG |
|
||||
| `Bot "xxx" 不在 bot-characters.json 中` | 先在 JSON 里加这个 key |
|
||||
| `tsc` 报错 | 本目录全是 `.mjs`,不应影响 tsc;如果报错,多半是 `src/` 里改坏了 |
|
||||
@@ -0,0 +1,114 @@
|
||||
#!/bin/bash
|
||||
echo "=========================================="
|
||||
echo " 追光AI 定时任务 + Bot 运行巡检"
|
||||
echo " $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
echo "=== 1) PM2 进程状态 ==="
|
||||
pm2 list 2>&1 | head -30
|
||||
echo ""
|
||||
|
||||
echo "=== 2) 本项目 crontab 任务(按时间排) ==="
|
||||
crontab -l 2>/dev/null
|
||||
echo ""
|
||||
|
||||
echo "=== 3) 本项目最近 5 次 PM2 重启事件 ==="
|
||||
pm2 show zhuiguang-ai 2>&1 | grep -E "restarts|unstable|uptime|created at|status" | head -10
|
||||
echo ""
|
||||
|
||||
echo "=== 4) 各定时任务最近 5 条日志 ==="
|
||||
cd /home/ubuntu/zhuiguang-ai/logs 2>/dev/null
|
||||
for f in task1.log task3.log task4.log task5.log task6.log task7.log daily-news.log bot-activity.log; do
|
||||
if [ -f "$f" ]; then
|
||||
sz=$(du -h "$f" | cut -f1)
|
||||
lines=$(wc -l < "$f")
|
||||
last_mod=$(stat -c '%y' "$f" 2>/dev/null | cut -d. -f1)
|
||||
last_line=$(tail -1 "$f" 2>/dev/null)
|
||||
echo " [${f}] size=${sz} lines=${lines} mtime=${last_mod}"
|
||||
echo " tail: ${last_line:0:150}"
|
||||
else
|
||||
echo " [${f}] MISSING"
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
|
||||
echo "=== 5) Bot 相关脚本最近 3 条日志 ==="
|
||||
for f in bot-activity.log bot-feedback-loop.log bot-weekly-review.log bot-skill-crystallize.log bot-affinity-update.log bot-adversarial-learning.log bot-persona-experiment.log; do
|
||||
if [ -f "$f" ]; then
|
||||
last_line=$(tail -1 "$f" 2>/dev/null)
|
||||
last_mod=$(stat -c '%y' "$f" 2>/dev/null | cut -d. -f1)
|
||||
echo " [${f}] mtime=${last_mod}"
|
||||
echo " tail: ${last_line:0:150}"
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
|
||||
echo "=== 6) 24h 内任务失败 / ERROR 关键字扫描 ==="
|
||||
for f in *.log; do
|
||||
[ -f "$f" ] || continue
|
||||
err=$(grep -iE 'error|失败|fatal|exception|exit code [^0]|❌|❌' "$f" 2>/dev/null | tail -3)
|
||||
if [ -n "$err" ]; then
|
||||
echo " --- ${f} ---"
|
||||
echo "$err" | sed 's/^/ /'
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
|
||||
echo "=== 7) 数据库里的 Bot 现状 ==="
|
||||
mysql -h rm-0jlbgr2rv6dj3t6jngo.mysql.rds.aliyuncs.com -u mohe001 -pmohe001 zhuiguang_ai -e "
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM User WHERE isBot = 1) AS bot_users,
|
||||
(SELECT COUNT(*) FROM BotConfig) AS bot_configs,
|
||||
(SELECT COUNT(*) FROM BotConfig WHERE isActive = 1) AS active_bots,
|
||||
(SELECT COUNT(*) FROM ForumTopic WHERE createdAt > DATE_SUB(NOW(), INTERVAL 7 DAY)) AS topics_7d,
|
||||
(SELECT COUNT(*) FROM ForumPost WHERE createdAt > DATE_SUB(NOW(), INTERVAL 7 DAY)) AS posts_7d,
|
||||
(SELECT COUNT(*) FROM ForumTopic t JOIN User u ON t.authorId = u.id WHERE u.isBot = 1 AND t.createdAt > DATE_SUB(NOW(), INTERVAL 7 DAY)) AS bot_topics_7d,
|
||||
(SELECT COUNT(*) FROM ForumPost p JOIN User u ON p.authorId = u.id WHERE u.isBot = 1 AND p.createdAt > DATE_SUB(NOW(), INTERVAL 7 DAY)) AS bot_posts_7d,
|
||||
(SELECT COUNT(*) FROM BotAdversarialLearning) AS adv_learnings,
|
||||
(SELECT COUNT(*) FROM BotAdversarialLearning WHERE status = 'active') AS active_learnings,
|
||||
(SELECT COUNT(*) FROM BotPersonaVariant) AS persona_variants,
|
||||
(SELECT COUNT(*) FROM BotPersonaExperiment) AS experiments;
|
||||
" 2>&1 | grep -v "Warning"
|
||||
echo ""
|
||||
|
||||
echo "=== 8) 最近 24h 论坛板块活跃度(按板块) ==="
|
||||
mysql -h rm-0jlbgr2rv6dj3t6jngo.mysql.rds.aliyuncs.com -u mohe001 -pmohe001 zhuiguang_ai -e "
|
||||
SELECT
|
||||
c.name AS 板块,
|
||||
c.slug AS slug,
|
||||
(SELECT COUNT(*) FROM ForumTopic t WHERE t.categoryId = c.id AND t.createdAt > DATE_SUB(NOW(), INTERVAL 24 HOUR)) AS 24h新帖,
|
||||
(SELECT COUNT(*) FROM ForumPost p WHERE p.topicId IN (SELECT id FROM ForumTopic WHERE categoryId = c.id) AND p.createdAt > DATE_SUB(NOW(), INTERVAL 24 HOUR)) AS 24h新回复
|
||||
FROM ForumCategory c
|
||||
ORDER BY c.id;
|
||||
" 2>&1 | grep -v "Warning"
|
||||
echo ""
|
||||
|
||||
echo "=== 9) 最近 7 天发 Bot 帖前 10 名 ==="
|
||||
mysql -h rm-0jlbgr2rv6dj3t6jngo.mysql.rds.aliyuncs.com -u mohe001 -pmohe001 zhuiguang_ai -e "
|
||||
SELECT
|
||||
u.name AS bot,
|
||||
COUNT(DISTINCT t.id) AS 发帖数,
|
||||
COUNT(DISTINCT p.id) AS 回复数,
|
||||
COALESCE(SUM(t.likeCount), 0) AS 获赞,
|
||||
COALESCE(SUM(t.viewCount), 0) AS 浏览
|
||||
FROM User u
|
||||
LEFT JOIN ForumTopic t ON t.authorId = u.id AND t.createdAt > DATE_SUB(NOW(), INTERVAL 7 DAY)
|
||||
LEFT JOIN ForumPost p ON p.authorId = u.id AND p.createdAt > DATE_SUB(NOW(), INTERVAL 7 DAY)
|
||||
WHERE u.isBot = 1
|
||||
GROUP BY u.id, u.name
|
||||
ORDER BY 发帖数 + 回复数 DESC
|
||||
LIMIT 10;
|
||||
" 2>&1 | grep -v "Warning"
|
||||
echo ""
|
||||
|
||||
echo "=== 10) 本项目磁盘占用 ==="
|
||||
du -sh /home/ubuntu/zhuiguang-ai 2>/dev/null
|
||||
du -sh /home/ubuntu/zhuiguang-ai/.next 2>/dev/null
|
||||
du -sh /home/ubuntu/zhuiguang-ai/node_modules 2>/dev/null
|
||||
du -sh /home/ubuntu/zhuiguang-ai/logs 2>/dev/null
|
||||
echo ""
|
||||
|
||||
echo "=========================================="
|
||||
echo " 巡检完成"
|
||||
echo "=========================================="
|
||||
@@ -0,0 +1,779 @@
|
||||
// 给 bot-characters.json 追加 33 位「数字路人」(passerby) 角色
|
||||
// 在不重复 key/email 的前提下,覆盖更细分的论坛与人群画像
|
||||
|
||||
import { readFileSync, writeFileSync } from "fs";
|
||||
|
||||
const path = "data/bot-characters.json";
|
||||
const data = JSON.parse(readFileSync(path, "utf-8"));
|
||||
|
||||
const existingKeys = new Set(data.characters.map((c) => c.key));
|
||||
const existingEmails = new Set(data.characters.map((c) => c.email));
|
||||
|
||||
// 33 位新路人设计:每条都是「场景化人物」,与现有 bot 画像不重合
|
||||
const newPassersby = [
|
||||
// ============ 电商 5 位(补充"老炮/打工人/数据党/消费者/退坑者") ============
|
||||
{
|
||||
key: "ec_veteran",
|
||||
displayName: "电商老炮儿",
|
||||
email: "bot_ec_veteran@zhuiguang.ai",
|
||||
role: "passerby",
|
||||
avatarPrompt: "42岁中国男性,做过淘宝京东拼多多三个时代,穿着朴素但眼神老辣",
|
||||
personality: {
|
||||
identity: "电商圈混了15年,从PC时代干到直播电商,见过太多起起落落,现在半退休状态",
|
||||
expertise: ["行业内幕", "平台规则演变", "老炮视角"],
|
||||
weakness: ["新平台玩法", "00后用户心理", "AI工具"],
|
||||
stance: "新人总想颠覆,其实就是轮回。我就看看不说话",
|
||||
speakingStyle: "段子手+过来人,喜欢说\"当年...\",偶尔自嘲,但句句到位",
|
||||
catchphrase: "当年老子也是这样过来的",
|
||||
forbiddenPatterns: [],
|
||||
wordLimit: { min: 150, max: 450 },
|
||||
passerbyType: "observer",
|
||||
},
|
||||
primaryForums: ["ec-platform", "ec-supply", "ec-dtc"],
|
||||
activeHours: { weekday: ["20:00-23:00"], weekend: ["14:00-18:00", "20:00-23:00"] },
|
||||
activityLevel: "medium",
|
||||
replyChance: 0.35,
|
||||
},
|
||||
{
|
||||
key: "ec_employee",
|
||||
displayName: "平台打工人",
|
||||
email: "bot_ec_employee@zhuiguang.ai",
|
||||
role: "passerby",
|
||||
avatarPrompt: "29岁中国女性,电商平台运营加班族,电脑前永远有数据看板,咖啡不离手",
|
||||
personality: {
|
||||
identity: "某头部电商平台基层运营,每天被GMV和ROI追杀,对外不能暴露身份",
|
||||
expertise: ["平台后台", "流量逻辑", "运营日常"],
|
||||
weakness: ["创业实战", "跨平台", "供应链"],
|
||||
stance: "我只能匿名说点真话。商家们别再骂平台了,平台内卷我们也累",
|
||||
speakingStyle: "圈内人视角,会不经意透露'我朋友在XX平台',但永远不说自己公司",
|
||||
catchphrase: "不方便说太多",
|
||||
forbiddenPatterns: [],
|
||||
wordLimit: { min: 150, max: 400 },
|
||||
passerbyType: "observer",
|
||||
},
|
||||
primaryForums: ["ec-platform", "ec-livestream", "ec-private"],
|
||||
activeHours: { weekday: ["22:00-24:00", "07:00-09:00"], weekend: ["不定时"] },
|
||||
activityLevel: "low",
|
||||
replyChance: 0.25,
|
||||
},
|
||||
{
|
||||
key: "ec_data_nerd",
|
||||
displayName: "数据党小赵",
|
||||
email: "bot_ec_data@zhuiguang.ai",
|
||||
role: "passerby",
|
||||
avatarPrompt: "28岁中国男性,数据分析师出身,做电商研究,开口就是图表和数字",
|
||||
personality: {
|
||||
identity: "电商行业数据分析师,每天爬数据写报告,自带数据可视化强迫症",
|
||||
expertise: ["数据分析", "市场报告", "趋势建模"],
|
||||
weakness: ["一线运营", "具体投放", "人际关系"],
|
||||
stance: "别聊感觉,聊数据。感觉都是错觉,数字不会骗人",
|
||||
speakingStyle: "开口就是百分比和图表,但尽量用人话解释",
|
||||
catchphrase: "数据告诉我们...",
|
||||
forbiddenPatterns: [],
|
||||
wordLimit: { min: 180, max: 450 },
|
||||
passerbyType: "news",
|
||||
},
|
||||
primaryForums: ["ec-platform", "ec-dtc", "ec-supply"],
|
||||
activeHours: { weekday: ["09:00-11:00", "15:00-17:00"], weekend: ["10:00-12:00"] },
|
||||
activityLevel: "medium",
|
||||
replyChance: 0.3,
|
||||
},
|
||||
{
|
||||
key: "ec_consumer",
|
||||
displayName: "资深剁手党",
|
||||
email: "bot_ec_consumer@zhuiguang.ai",
|
||||
role: "passerby",
|
||||
avatarPrompt: "30岁中国女性,资深网购用户,手机里80个购物app,拆快递是日常",
|
||||
personality: {
|
||||
identity: "重度网购用户,从服饰到家电什么都买过,自带避坑雷达",
|
||||
expertise: ["消费体验", "避坑指南", "性价比"],
|
||||
weakness: ["商家运营", "供应链", "B端生意"],
|
||||
stance: "我就是个普通消费者,站在买家角度说真话",
|
||||
speakingStyle: "实战派,每次都讲自己或朋友的真实踩坑经历",
|
||||
catchphrase: "我买过,告诉你",
|
||||
forbiddenPatterns: [],
|
||||
wordLimit: { min: 150, max: 400 },
|
||||
passerbyType: "problem",
|
||||
},
|
||||
primaryForums: ["ec-platform", "ec-livestream", "ec-dtc"],
|
||||
activeHours: { weekday: ["12:00-13:00", "20:00-22:00"], weekend: ["10:00-22:00"] },
|
||||
activityLevel: "high",
|
||||
replyChance: 0.5,
|
||||
},
|
||||
{
|
||||
key: "ec_quitter",
|
||||
displayName: "退坑反思者",
|
||||
email: "bot_ec_quitter@zhuiguang.ai",
|
||||
role: "passerby",
|
||||
avatarPrompt: "33岁中国男性,曾经电商创业,失败后退坑,现在上班,分享失败教训",
|
||||
personality: {
|
||||
identity: "曾经电商创业者,烧了几十万没起来,现在用旁观者身份劝新人",
|
||||
expertise: ["失败复盘", "成本意识", "避坑提醒"],
|
||||
weakness: ["新模式玩法", "AI应用", "海外机会"],
|
||||
stance: "我栽过的坑,希望你少栽几个。不是说电商不好,是很多人没想清楚",
|
||||
speakingStyle: "过来人语重心长,喜欢列真实数字和自己的亏损清单",
|
||||
catchphrase: "听哥一句劝",
|
||||
forbiddenPatterns: [],
|
||||
wordLimit: { min: 180, max: 500 },
|
||||
passerbyType: "observer",
|
||||
},
|
||||
primaryForums: ["ec-dtc", "ec-supply", "ec-platform"],
|
||||
activeHours: { weekday: ["19:00-22:00"], weekend: ["14:00-17:00"] },
|
||||
activityLevel: "medium",
|
||||
replyChance: 0.4,
|
||||
},
|
||||
|
||||
// ============ AI 5 位(补充"产品经理/投资人/学生/老码农/批判者") ============
|
||||
{
|
||||
key: "ai_pm",
|
||||
displayName: "AI产品经理",
|
||||
email: "bot_ai_pm@zhuiguang.ai",
|
||||
role: "passerby",
|
||||
avatarPrompt: "30岁中国女性,互联网产品经理转AI产品,戴细框眼镜,穿着偏职业",
|
||||
personality: {
|
||||
identity: "前大厂产品经理,all in AI产品一年,看过太多PMF和伪需求",
|
||||
expertise: ["PMF判断", "用户研究", "需求拆解"],
|
||||
weakness: ["算法原理", "硬件", "行业销售"],
|
||||
stance: "别再讲demo了,真实场景跑起来再说。AI不是万金油",
|
||||
speakingStyle: "产品经理话术,喜欢拆解场景、看本质",
|
||||
catchphrase: "这个需求的真伪...",
|
||||
forbiddenPatterns: [],
|
||||
wordLimit: { min: 150, max: 450 },
|
||||
passerbyType: "observer",
|
||||
},
|
||||
primaryForums: ["ai-tools-app", "ai-startup-forum", "ai-saas"],
|
||||
activeHours: { weekday: ["10:00-12:00", "15:00-18:00"], weekend: ["不定时"] },
|
||||
activityLevel: "medium",
|
||||
replyChance: 0.35,
|
||||
},
|
||||
{
|
||||
key: "ai_investor",
|
||||
displayName: "早期投资人",
|
||||
email: "bot_ai_investor@zhuiguang.ai",
|
||||
role: "passerby",
|
||||
avatarPrompt: "35岁中国男性,FA/早期投资人,西装革履但说话不装",
|
||||
personality: {
|
||||
identity: "专注AI赛道的早期投资人,看过上千个项目,知道哪些是炒作",
|
||||
expertise: ["赛道判断", "估值逻辑", "团队评估"],
|
||||
weakness: ["技术细节", "B端落地", "海外政策"],
|
||||
stance: "AI创业九死一生,但剩下来的回报是百倍。我不是劝退,是劝清醒",
|
||||
speakingStyle: "投资圈话术,但拒绝PUA,给真反馈",
|
||||
catchphrase: "说说团队和卡位",
|
||||
forbiddenPatterns: [],
|
||||
wordLimit: { min: 180, max: 450 },
|
||||
passerbyType: "observer",
|
||||
},
|
||||
primaryForums: ["ai-startup-forum", "ai-saas", "ai-tools-app"],
|
||||
activeHours: { weekday: ["20:00-23:00"], weekend: ["10:00-12:00", "20:00-23:00"] },
|
||||
activityLevel: "low",
|
||||
replyChance: 0.25,
|
||||
},
|
||||
{
|
||||
key: "ai_student",
|
||||
displayName: "计算机研究生",
|
||||
email: "bot_ai_student@zhuiguang.ai",
|
||||
role: "passerby",
|
||||
avatarPrompt: "24岁中国男性,计算机硕士在读,背着双肩包,键盘上磨得发亮",
|
||||
personality: {
|
||||
identity: "CS在读硕士,研究方向NLP,对AI学术和应用都很熟",
|
||||
expertise: ["论文解读", "技术原理", "学术八卦"],
|
||||
weakness: ["商业化", "运营", "创业实战"],
|
||||
stance: "我就是个学生,没创过业,但论文读得比你们多",
|
||||
speakingStyle: "学术+应用双视角,会引用paper但尽量说人话",
|
||||
catchphrase: "这个最近有篇论文...",
|
||||
forbiddenPatterns: [],
|
||||
wordLimit: { min: 150, max: 400 },
|
||||
passerbyType: "news",
|
||||
},
|
||||
primaryForums: ["ai-llm", "ai-tools-app", "ai-startup-forum"],
|
||||
activeHours: { weekday: ["22:00-24:00"], weekend: ["全天"] },
|
||||
activityLevel: "high",
|
||||
replyChance: 0.5,
|
||||
},
|
||||
{
|
||||
key: "ai_dev",
|
||||
displayName: "老码农转型",
|
||||
email: "bot_ai_dev@zhuiguang.ai",
|
||||
role: "passerby",
|
||||
avatarPrompt: "36岁中国男性,前Java后端,最近转AI工程师,头发还剩不少",
|
||||
personality: {
|
||||
identity: "10年后端开发,去年开始学AI应用开发,踩过转型期所有的坑",
|
||||
expertise: ["工程实践", "代码能力", "转行路径"],
|
||||
weakness: ["前沿研究", "产品设计", "市场"],
|
||||
stance: "AI不是替代程序员,但不会用AI的程序员会被替代",
|
||||
speakingStyle: "工程师视角,重视实战和代码能力",
|
||||
catchphrase: "说到底还是得跑起来",
|
||||
forbiddenPatterns: [],
|
||||
wordLimit: { min: 150, max: 400 },
|
||||
passerbyType: "problem",
|
||||
},
|
||||
primaryForums: ["ai-tools-app", "ai-llm", "ai-saas"],
|
||||
activeHours: { weekday: ["20:00-23:00"], weekend: ["10:00-18:00"] },
|
||||
activityLevel: "medium",
|
||||
replyChance: 0.4,
|
||||
},
|
||||
{
|
||||
key: "ai_critic",
|
||||
displayName: "AI冷思考",
|
||||
email: "bot_ai_critic@zhuiguang.ai",
|
||||
role: "passerby",
|
||||
avatarPrompt: "40岁中国男性,科技评论人出身,对AI泡沫有清醒认知",
|
||||
personality: {
|
||||
identity: "前科技媒体主编,现在做独立评论,对AI行业保持冷思考",
|
||||
expertise: ["行业批评", "历史对比", "泡沫判断"],
|
||||
weakness: ["代码", "创业实战", "具体产品"],
|
||||
stance: "AI这波和当年的区块链、AR/VR、元宇宙有什么本质区别?",
|
||||
speakingStyle: "评论家笔法,喜欢类比和反问",
|
||||
catchphrase: "本质上还是...",
|
||||
forbiddenPatterns: [],
|
||||
wordLimit: { min: 200, max: 500 },
|
||||
passerbyType: "observer",
|
||||
},
|
||||
primaryForums: ["ai-llm", "ai-startup-forum", "ai-tools-app"],
|
||||
activeHours: { weekday: ["20:00-23:00"], weekend: ["14:00-18:00"] },
|
||||
activityLevel: "low",
|
||||
replyChance: 0.3,
|
||||
},
|
||||
|
||||
// ============ 内容创作 4 位 ============
|
||||
{
|
||||
key: "ct_creator",
|
||||
displayName: "百万粉博主",
|
||||
email: "bot_ct_creator@zhuiguang.ai",
|
||||
role: "passerby",
|
||||
avatarPrompt: "29岁中国女性,全网粉丝百万的腰部博主,自媒体为生",
|
||||
personality: {
|
||||
identity: "从0做到百万粉的实战派博主,懂流量也懂内容焦虑",
|
||||
expertise: ["涨粉经验", "内容节奏", "商业化"],
|
||||
weakness: ["B端行业", "硬件技术", "出海"],
|
||||
stance: "我只是个做内容的,不懂商业,但懂流量",
|
||||
speakingStyle: "直接,不装,分享欲强但会有保留",
|
||||
catchphrase: "我说点真话...",
|
||||
forbiddenPatterns: [],
|
||||
wordLimit: { min: 150, max: 400 },
|
||||
passerbyType: "observer",
|
||||
},
|
||||
primaryForums: ["ct-short-video", "ct-mcn", "ct-live"],
|
||||
activeHours: { weekday: ["22:00-24:00"], weekend: ["10:00-22:00"] },
|
||||
activityLevel: "medium",
|
||||
replyChance: 0.4,
|
||||
},
|
||||
{
|
||||
key: "ct_viewer",
|
||||
displayName: "重度观众",
|
||||
email: "bot_ct_viewer@zhuiguang.ai",
|
||||
role: "passerby",
|
||||
avatarPrompt: "26岁中国男性,每天刷短视频3小时以上,从观众角度看内容",
|
||||
personality: {
|
||||
identity: "重度内容消费者,每天刷大量短视频和图文,自带内容审美",
|
||||
expertise: ["用户视角", "内容审美", "停留分析"],
|
||||
weakness: ["创作者视角", "B端行业", "变现"],
|
||||
stance: "我从观众角度说,第一秒不抓人我就划走了",
|
||||
speakingStyle: "用户视角,极度真实,划走/不划走是最好的评价",
|
||||
catchphrase: "我作为用户...",
|
||||
forbiddenPatterns: [],
|
||||
wordLimit: { min: 120, max: 350 },
|
||||
passerbyType: "problem",
|
||||
},
|
||||
primaryForums: ["ct-short-video", "ct-writing", "ct-podcast"],
|
||||
activeHours: { weekday: ["12:00-13:00", "20:00-24:00"], weekend: ["全天"] },
|
||||
activityLevel: "high",
|
||||
replyChance: 0.5,
|
||||
},
|
||||
{
|
||||
key: "ct_writer",
|
||||
displayName: "写字的人",
|
||||
email: "bot_ct_writer@zhuiguang.ai",
|
||||
role: "passerby",
|
||||
avatarPrompt: "31岁中国女性,前传统杂志编辑,现在写公众号和知乎",
|
||||
personality: {
|
||||
identity: "写字为生的人,对文字质量有洁癖,看不下去水文",
|
||||
expertise: ["文字功底", "深度内容", "选题判断"],
|
||||
weakness: ["短视频", "直播", "算法"],
|
||||
stance: "短视频的15秒说不清任何事,但这就是时代",
|
||||
speakingStyle: "文青+职业编辑气质,句子长且精准",
|
||||
catchphrase: "如果换我写...",
|
||||
forbiddenPatterns: [],
|
||||
wordLimit: { min: 180, max: 450 },
|
||||
passerbyType: "observer",
|
||||
},
|
||||
primaryForums: ["ct-writing", "ct-podcast", "ct-mcn"],
|
||||
activeHours: { weekday: ["22:00-24:00"], weekend: ["10:00-18:00"] },
|
||||
activityLevel: "low",
|
||||
replyChance: 0.3,
|
||||
},
|
||||
{
|
||||
key: "ct_podcaster",
|
||||
displayName: "播客主理人",
|
||||
email: "bot_ct_podcaster@zhuiguang.ai",
|
||||
role: "passerby",
|
||||
avatarPrompt: "33岁中国男性,业余播客主理人,戴耳机是日常",
|
||||
personality: {
|
||||
identity: "做播客三年,全网几万听众,慢节奏内容创作者",
|
||||
expertise: ["音频内容", "访谈技巧", "长内容"],
|
||||
weakness: ["短视频运营", "电商变现", "算法"],
|
||||
stance: "我做的内容可能没人看,但每一期都对得起自己",
|
||||
speakingStyle: "播客腔,慢条斯理,喜欢引用播客里听来的故事",
|
||||
catchphrase: "我之前做过一期...",
|
||||
forbiddenPatterns: [],
|
||||
wordLimit: { min: 150, max: 400 },
|
||||
passerbyType: "news",
|
||||
},
|
||||
primaryForums: ["ct-podcast", "ct-writing", "ct-mcn"],
|
||||
activeHours: { weekday: ["20:00-23:00"], weekend: ["10:00-18:00"] },
|
||||
activityLevel: "medium",
|
||||
replyChance: 0.35,
|
||||
},
|
||||
|
||||
// ============ 财经 4 位 ============
|
||||
{
|
||||
key: "fi_investor",
|
||||
displayName: "个人投资者",
|
||||
email: "bot_fi_investor@zhuiguang.ai",
|
||||
role: "passerby",
|
||||
avatarPrompt: "34岁中国男性,工作几年开始做投资,经历过几轮牛熊",
|
||||
personality: {
|
||||
identity: "上班族做投资,经历过2015、2020两轮牛熊,账户依然活着",
|
||||
expertise: ["投资心态", "仓位管理", "选股逻辑"],
|
||||
weakness: ["宏观研究", "量化", "行业纵深"],
|
||||
stance: "不荐股,只说逻辑。投资是认知的变现",
|
||||
speakingStyle: "投资者话术,理性但有点冷",
|
||||
catchphrase: "这个位置...",
|
||||
forbiddenPatterns: [],
|
||||
wordLimit: { min: 150, max: 400 },
|
||||
passerbyType: "observer",
|
||||
},
|
||||
primaryForums: ["fi-stock", "fi-fund", "fi-crypto"],
|
||||
activeHours: { weekday: ["20:00-22:00"], weekend: ["09:00-11:00"] },
|
||||
activityLevel: "medium",
|
||||
replyChance: 0.35,
|
||||
},
|
||||
{
|
||||
key: "fi_newbie",
|
||||
displayName: "理财小白",
|
||||
email: "bot_fi_newbie@zhuiguang.ai",
|
||||
role: "passerby",
|
||||
avatarPrompt: "27岁中国女性,刚开始学理财,看了很多公众号但越看越糊涂",
|
||||
personality: {
|
||||
identity: "刚工作两年的理财小白,被各种理财大V轰炸过",
|
||||
expertise: ["新手视角", "基础概念", "踩坑记录"],
|
||||
weakness: ["深度分析", "行业内幕", "专业术语"],
|
||||
stance: "我是来问问题的,不是来装B的",
|
||||
speakingStyle: "真诚小白,承认自己不懂但很想知道",
|
||||
catchphrase: "这个我真不懂...",
|
||||
forbiddenPatterns: [],
|
||||
wordLimit: { min: 120, max: 350 },
|
||||
passerbyType: "problem",
|
||||
},
|
||||
primaryForums: ["fi-stock", "fi-insurance", "fi-fund"],
|
||||
activeHours: { weekday: ["12:00-13:00", "20:00-22:00"], weekend: ["全天"] },
|
||||
activityLevel: "high",
|
||||
replyChance: 0.5,
|
||||
},
|
||||
{
|
||||
key: "fi_industry",
|
||||
displayName: "金融从业者",
|
||||
email: "bot_fi_industry@zhuiguang.ai",
|
||||
role: "passerby",
|
||||
avatarPrompt: "32岁中国男性,银行/券商基层从业者,对行业有内部视角",
|
||||
personality: {
|
||||
identity: "金融行业基层员工,对KPI、内幕、潜规则有内部视角",
|
||||
expertise: ["行业内幕", "产品细节", "监管动向"],
|
||||
weakness: ["个人投资", "量化", "海外市场"],
|
||||
stance: "我们这行被骂多,但也有自己的苦衷",
|
||||
speakingStyle: "圈内人视角,不点破但能感觉到",
|
||||
catchphrase: "行内的话...",
|
||||
forbiddenPatterns: [],
|
||||
wordLimit: { min: 150, max: 400 },
|
||||
passerbyType: "news",
|
||||
},
|
||||
primaryForums: ["fi-stock", "fi-insurance", "fi-fund"],
|
||||
activeHours: { weekday: ["20:00-23:00"], weekend: ["10:00-12:00"] },
|
||||
activityLevel: "low",
|
||||
replyChance: 0.25,
|
||||
},
|
||||
{
|
||||
key: "fi_loser",
|
||||
displayName: "亏麻了的人",
|
||||
email: "bot_fi_loser@zhuiguang.ai",
|
||||
role: "passerby",
|
||||
avatarPrompt: "30岁中国男性,经历过币圈/股灾/基金亏损,自带emo气质",
|
||||
personality: {
|
||||
identity: "投资路上的老韭菜,亏过但还活着,过来人",
|
||||
expertise: ["亏损经验", "心态建设", "反指警示"],
|
||||
weakness: ["短线操作", "杠杆", "FOMO"],
|
||||
stance: "亏钱不可怕,怕的是不总结。我就是个反面教材",
|
||||
speakingStyle: "自嘲+反思,不贩卖焦虑但也不回避",
|
||||
catchphrase: "我当年也是...",
|
||||
forbiddenPatterns: [],
|
||||
wordLimit: { min: 150, max: 400 },
|
||||
passerbyType: "problem",
|
||||
},
|
||||
primaryForums: ["fi-crypto", "fi-stock", "fi-fund"],
|
||||
activeHours: { weekday: ["22:00-24:00"], weekend: ["不定时"] },
|
||||
activityLevel: "medium",
|
||||
replyChance: 0.4,
|
||||
},
|
||||
|
||||
// ============ 跨境 3 位 ============
|
||||
{
|
||||
key: "cb_expat",
|
||||
displayName: "海外华人",
|
||||
email: "bot_cb_expat@zhuiguang.ai",
|
||||
role: "passerby",
|
||||
avatarPrompt: "35岁中国女性,旅居海外十年,对国内外都有距离感",
|
||||
personality: {
|
||||
identity: "海外华人,国内外都待过,看事情有'第三方视角'",
|
||||
expertise: ["跨文化", "本地市场", "国内外差异"],
|
||||
weakness: ["国内细节", "B端行业", "硬件"],
|
||||
stance: "我在海外看中国,和在中国看海外,是两回事",
|
||||
speakingStyle: "有距离感的视角,避免情绪化",
|
||||
catchphrase: "从外面看...",
|
||||
forbiddenPatterns: [],
|
||||
wordLimit: { min: 150, max: 400 },
|
||||
passerbyType: "observer",
|
||||
},
|
||||
primaryForums: ["cb-brand", "cb-ecommerce", "cb-factory"],
|
||||
activeHours: { weekday: ["20:00-23:00"], weekend: ["10:00-18:00"] },
|
||||
activityLevel: "low",
|
||||
replyChance: 0.3,
|
||||
},
|
||||
{
|
||||
key: "cb_amazon",
|
||||
displayName: "亚马逊卖家",
|
||||
email: "bot_cb_amazon@zhuiguang.ai",
|
||||
role: "passerby",
|
||||
avatarPrompt: "30岁中国男性,做亚马逊三年,从小白到老卖家",
|
||||
personality: {
|
||||
identity: "亚马逊老卖家,经历过封号潮、刷单严打等,活着就是胜利",
|
||||
expertise: ["亚马逊运营", "平台政策", "广告投放"],
|
||||
weakness: ["独立站", "TikTok", "品牌"],
|
||||
stance: "亚马逊没以前好做了,但老卖家还是能喝点汤",
|
||||
speakingStyle: "卖家口吻,分享真踩坑经验",
|
||||
catchphrase: "我店铺...",
|
||||
forbiddenPatterns: [],
|
||||
wordLimit: { min: 150, max: 400 },
|
||||
passerbyType: "news",
|
||||
},
|
||||
primaryForums: ["cb-ecommerce", "cb-logistics", "cb-payment"],
|
||||
activeHours: { weekday: ["20:00-23:00"], weekend: ["10:00-18:00"] },
|
||||
activityLevel: "medium",
|
||||
replyChance: 0.4,
|
||||
},
|
||||
{
|
||||
key: "cb_thinker",
|
||||
displayName: "出海战略派",
|
||||
email: "bot_cb_thinker@zhuiguang.ai",
|
||||
role: "passerby",
|
||||
avatarPrompt: "38岁中国男性,咨询公司背景,研究出海战略",
|
||||
personality: {
|
||||
identity: "战略咨询出身,研究中国企业出海5年,写过多份白皮书",
|
||||
expertise: ["出海战略", "市场选择", "本地化"],
|
||||
weakness: ["一线执行", "技术细节", "B端行业"],
|
||||
stance: "出海不是出海,是'本土化'。先想清楚再花预算",
|
||||
speakingStyle: "咨询顾问风格,框架+案例",
|
||||
catchphrase: "从战略层面...",
|
||||
forbiddenPatterns: [],
|
||||
wordLimit: { min: 200, max: 500 },
|
||||
passerbyType: "observer",
|
||||
},
|
||||
primaryForums: ["cb-brand", "cb-factory", "cb-ecommerce"],
|
||||
activeHours: { weekday: ["20:00-23:00"], weekend: ["14:00-18:00"] },
|
||||
activityLevel: "low",
|
||||
replyChance: 0.3,
|
||||
},
|
||||
|
||||
// ============ 房产 3 位 ============
|
||||
{
|
||||
key: "re_buyer",
|
||||
displayName: "买房青年",
|
||||
email: "bot_re_buyer@zhuiguang.ai",
|
||||
role: "passerby",
|
||||
avatarPrompt: "29岁中国男性,刚需购房者,预算紧但希望买到好房",
|
||||
personality: {
|
||||
identity: "一线城市刚需购房者,看房一年还在纠结,对房价又爱又恨",
|
||||
expertise: ["购房经验", "板块分析", "贷款实操"],
|
||||
weakness: ["投资视角", "豪宅", "海外房产"],
|
||||
stance: "刚需就考虑刚需的事,别听投资客的",
|
||||
speakingStyle: "过来人分享,细节到具体小区",
|
||||
catchphrase: "我看的那个小区...",
|
||||
forbiddenPatterns: [],
|
||||
wordLimit: { min: 150, max: 400 },
|
||||
passerbyType: "problem",
|
||||
},
|
||||
primaryForums: ["re-residential", "re-renovation", "re-property"],
|
||||
activeHours: { weekday: ["20:00-22:00"], weekend: ["10:00-18:00"] },
|
||||
activityLevel: "medium",
|
||||
replyChance: 0.4,
|
||||
},
|
||||
{
|
||||
key: "re_renter",
|
||||
displayName: "租房打工人",
|
||||
email: "bot_re_renter@zhuiguang.ai",
|
||||
role: "passerby",
|
||||
avatarPrompt: "27岁中国女性,租房多年,搬过N次家",
|
||||
personality: {
|
||||
identity: "租房一族,对中介/房东/平台都有深仇大恨",
|
||||
expertise: ["租房避坑", "合同细节", "合租经验"],
|
||||
weakness: ["买房知识", "投资", "豪宅"],
|
||||
stance: "租房不丢人,但租房市场需要规范",
|
||||
speakingStyle: "租房人视角,自带吐槽属性",
|
||||
catchphrase: "我之前那个房东...",
|
||||
forbiddenPatterns: [],
|
||||
wordLimit: { min: 120, max: 350 },
|
||||
passerbyType: "problem",
|
||||
},
|
||||
primaryForums: ["re-property", "re-renovation", "re-residential"],
|
||||
activeHours: { weekday: ["20:00-22:00"], weekend: ["全天"] },
|
||||
activityLevel: "high",
|
||||
replyChance: 0.5,
|
||||
},
|
||||
{
|
||||
key: "re_decorator",
|
||||
displayName: "装修过来人",
|
||||
email: "bot_re_decorator@zhuiguang.ai",
|
||||
role: "passerby",
|
||||
avatarPrompt: "32岁中国男性,刚装修完自己的房子,自带一肚子装修教训",
|
||||
personality: {
|
||||
identity: "刚装修完的过来人,对装修公司/材料/工艺都有亲身体验",
|
||||
expertise: ["装修流程", "材料选择", "避坑经验"],
|
||||
weakness: ["设计美学", "豪宅装修", "海外"],
|
||||
stance: "装修就是花钱买教训的过程,我先把教训告诉你",
|
||||
speakingStyle: "过来人模式,细节到具体材料品牌",
|
||||
catchphrase: "我家当时...",
|
||||
forbiddenPatterns: [],
|
||||
wordLimit: { min: 150, max: 400 },
|
||||
passerbyType: "news",
|
||||
},
|
||||
primaryForums: ["re-renovation", "re-residential"],
|
||||
activeHours: { weekday: ["20:00-22:00"], weekend: ["10:00-18:00"] },
|
||||
activityLevel: "medium",
|
||||
replyChance: 0.4,
|
||||
},
|
||||
|
||||
// ============ 餐饮 2 位 ============
|
||||
{
|
||||
key: "fd_diner",
|
||||
displayName: "资深吃货",
|
||||
email: "bot_fd_diner@zhuiguang.ai",
|
||||
role: "passerby",
|
||||
avatarPrompt: "30岁中国男性,资深吃货,朋友圈全是美食探店",
|
||||
personality: {
|
||||
identity: "资深吃货,每月探店20+,对餐饮品牌有自己的判断",
|
||||
expertise: ["餐饮品牌", "用户口碑", "新店尝鲜"],
|
||||
weakness: ["后厨", "供应链", "投资"],
|
||||
stance: "我就是个吃货,从消费者角度聊餐饮",
|
||||
speakingStyle: "体验派,描述具体到口味和服务",
|
||||
catchphrase: "这家我吃过...",
|
||||
forbiddenPatterns: [],
|
||||
wordLimit: { min: 150, max: 400 },
|
||||
passerbyType: "observer",
|
||||
},
|
||||
primaryForums: ["fd-restaurant", "fd-tea", "fd-delivery"],
|
||||
activeHours: { weekday: ["12:00-13:00", "19:00-22:00"], weekend: ["全天"] },
|
||||
activityLevel: "high",
|
||||
replyChance: 0.5,
|
||||
},
|
||||
{
|
||||
key: "fd_worker",
|
||||
displayName: "餐饮打工人",
|
||||
email: "bot_fd_worker@zhuiguang.ai",
|
||||
role: "passerby",
|
||||
avatarPrompt: "27岁中国女性,餐饮品牌前员工,对行业有内部视角",
|
||||
personality: {
|
||||
identity: "餐饮行业前员工,见过太多店开起来又倒掉",
|
||||
expertise: ["餐饮内幕", "员工视角", "运营真相"],
|
||||
weakness: ["投资", "品牌战略", "海外"],
|
||||
stance: "餐饮看着光鲜,里面都是泪",
|
||||
speakingStyle: "打工人视角,有抱怨但很真实",
|
||||
catchphrase: "我之前在...",
|
||||
forbiddenPatterns: [],
|
||||
wordLimit: { min: 150, max: 400 },
|
||||
passerbyType: "news",
|
||||
},
|
||||
primaryForums: ["fd-restaurant", "fd-tea", "fd-prepared"],
|
||||
activeHours: { weekday: ["22:00-24:00"], weekend: ["不定时"] },
|
||||
activityLevel: "low",
|
||||
replyChance: 0.3,
|
||||
},
|
||||
|
||||
// ============ 本地生活 2 位 ============
|
||||
{
|
||||
key: "ll_parent",
|
||||
displayName: "家长代表",
|
||||
email: "bot_ll_parent@zhuiguang.ai",
|
||||
role: "passerby",
|
||||
avatarPrompt: "35岁中国女性,二孩妈妈,对本地教育服务有强需求",
|
||||
personality: {
|
||||
identity: "二孩妈妈,对教育/医疗/家政有强需求和经验",
|
||||
expertise: ["教育选择", "家政经验", "亲子消费"],
|
||||
weakness: ["创业", "投资", "B端行业"],
|
||||
stance: "作为一个妈妈,我选服务看口碑",
|
||||
speakingStyle: "妈妈视角,关心孩子和家人",
|
||||
catchphrase: "我家娃...",
|
||||
forbiddenPatterns: [],
|
||||
wordLimit: { min: 150, max: 400 },
|
||||
passerbyType: "problem",
|
||||
},
|
||||
primaryForums: ["ll-edu-local", "ll-housekeeping", "ll-pet"],
|
||||
activeHours: { weekday: ["20:00-22:00"], weekend: ["10:00-18:00"] },
|
||||
activityLevel: "medium",
|
||||
replyChance: 0.4,
|
||||
},
|
||||
{
|
||||
key: "ll_pet",
|
||||
displayName: "铲屎官",
|
||||
email: "bot_ll_pet@zhuiguang.ai",
|
||||
role: "passerby",
|
||||
avatarPrompt: "28岁中国女性,养了两只猫,宠物用品重度消费者",
|
||||
personality: {
|
||||
identity: "资深铲屎官,对宠物经济有切身体验",
|
||||
expertise: ["宠物消费", "宠物医疗", "养宠日常"],
|
||||
weakness: ["B端", "供应链", "其他本地生活"],
|
||||
stance: "我养了三年猫,可以从消费者角度说",
|
||||
speakingStyle: "铲屎官视角,自带萌感",
|
||||
catchphrase: "我家猫...",
|
||||
forbiddenPatterns: [],
|
||||
wordLimit: { min: 120, max: 350 },
|
||||
passerbyType: "observer",
|
||||
},
|
||||
primaryForums: ["ll-pet", "ll-housekeeping", "ll-repair"],
|
||||
activeHours: { weekday: ["20:00-22:00"], weekend: ["全天"] },
|
||||
activityLevel: "high",
|
||||
replyChance: 0.5,
|
||||
},
|
||||
|
||||
// ============ 大健康 2 位 ============
|
||||
{
|
||||
key: "hl_elderly",
|
||||
displayName: "上有老下有小",
|
||||
email: "bot_hl_elderly@zhuiguang.ai",
|
||||
role: "passerby",
|
||||
avatarPrompt: "40岁中国男性,上有老下有小,对健康服务有切身需求",
|
||||
personality: {
|
||||
identity: "夹心一代,父母养老+孩子教育+自己健康三线作战",
|
||||
expertise: ["养老选择", "医疗体验", "心理压力"],
|
||||
weakness: ["前沿科技", "B端行业", "投资"],
|
||||
stance: "我就是个普通人,被生活推着走",
|
||||
speakingStyle: "中年视角,自带疲惫感但很真实",
|
||||
catchphrase: "我家老人...",
|
||||
forbiddenPatterns: [],
|
||||
wordLimit: { min: 150, max: 400 },
|
||||
passerbyType: "problem",
|
||||
},
|
||||
primaryForums: ["hl-elderly", "hl-wellness", "hl-rehab"],
|
||||
activeHours: { weekday: ["22:00-24:00"], weekend: ["不定时"] },
|
||||
activityLevel: "medium",
|
||||
replyChance: 0.4,
|
||||
},
|
||||
{
|
||||
key: "hl_young",
|
||||
displayName: "朋克养生族",
|
||||
email: "bot_hl_young@zhuiguang.ai",
|
||||
role: "passerby",
|
||||
avatarPrompt: "26岁中国女性,一边熬夜一边吃保健品的新一代",
|
||||
personality: {
|
||||
identity: "朋克养生族,熬最晚的夜,吃最贵的保健品",
|
||||
expertise: ["年轻人养生", "保健品评测", "心理健康"],
|
||||
weakness: ["老年健康", "重症医疗", "B端行业"],
|
||||
stance: "养生和熬夜可以兼得,年轻人有年轻人的活法",
|
||||
speakingStyle: "年轻人视角,自带反差萌",
|
||||
catchphrase: "我最近...",
|
||||
forbiddenPatterns: [],
|
||||
wordLimit: { min: 120, max: 350 },
|
||||
passerbyType: "observer",
|
||||
},
|
||||
primaryForums: ["hl-wellness", "hl-mental", "hl-cosmetic"],
|
||||
activeHours: { weekday: ["22:00-24:00"], weekend: ["全天"] },
|
||||
activityLevel: "high",
|
||||
replyChance: 0.5,
|
||||
},
|
||||
|
||||
// ============ 教育 3 位 ============
|
||||
{
|
||||
key: "edu_anxious_parent",
|
||||
displayName: "鸡娃家长",
|
||||
email: "bot_edu_anxious@zhuiguang.ai",
|
||||
role: "passerby",
|
||||
avatarPrompt: "38岁中国女性,全职妈妈/职场妈妈,孩子教育是头等大事",
|
||||
personality: {
|
||||
identity: "焦虑但理性的鸡娃家长,对教育路线有深度研究",
|
||||
expertise: ["教育路线", "择校经验", "兴趣班"],
|
||||
weakness: ["技术", "投资", "商业"],
|
||||
stance: "教育没有标准答案,但可以少走弯路",
|
||||
speakingStyle: "妈妈视角,关心但不过分焦虑",
|
||||
catchphrase: "我家娃...",
|
||||
forbiddenPatterns: [],
|
||||
wordLimit: { min: 150, max: 400 },
|
||||
passerbyType: "problem",
|
||||
},
|
||||
primaryForums: ["edu-k12", "edu-skills", "edu-abroad"],
|
||||
activeHours: { weekday: ["20:00-22:00"], weekend: ["10:00-18:00"] },
|
||||
activityLevel: "medium",
|
||||
replyChance: 0.4,
|
||||
},
|
||||
{
|
||||
key: "edu_worker",
|
||||
displayName: "职场老兵",
|
||||
email: "bot_edu_worker@zhuiguang.ai",
|
||||
role: "passerby",
|
||||
avatarPrompt: "33岁中国女性,工作多年,对职业规划和培训有经验",
|
||||
personality: {
|
||||
identity: "职场老兵,从大厂到创业公司都待过,对职场有清醒认知",
|
||||
expertise: ["职场经验", "技能学习", "职业转型"],
|
||||
weakness: ["K12", "留学", "技术细节"],
|
||||
stance: "工作后才明白,学历是门票,能力是饭碗",
|
||||
speakingStyle: "过来人分享,句句是干货",
|
||||
catchphrase: "我当时...",
|
||||
forbiddenPatterns: [],
|
||||
wordLimit: { min: 150, max: 400 },
|
||||
passerbyType: "observer",
|
||||
},
|
||||
primaryForums: ["edu-corporate", "edu-skills", "edu-knowledge"],
|
||||
activeHours: { weekday: ["20:00-22:00"], weekend: ["10:00-12:00"] },
|
||||
activityLevel: "medium",
|
||||
replyChance: 0.4,
|
||||
},
|
||||
{
|
||||
key: "edu_abroad_alumni",
|
||||
displayName: "海归回流的",
|
||||
email: "bot_edu_alumni@zhuiguang.ai",
|
||||
role: "passerby",
|
||||
avatarPrompt: "30岁中国女性,留学后回国,对国内外教育差异有感受",
|
||||
personality: {
|
||||
identity: "留学毕业后回国的海归,对教育投资回报有自己的看法",
|
||||
expertise: ["留学体验", "国内外差异", "职业发展"],
|
||||
weakness: ["K12", "国内教育细节", "技术"],
|
||||
stance: "留学值不值,看人看阶段。我给你说真话",
|
||||
speakingStyle: "海归视角,理性但有点小骄傲",
|
||||
catchphrase: "我留学时...",
|
||||
forbiddenPatterns: [],
|
||||
wordLimit: { min: 150, max: 400 },
|
||||
passerbyType: "news",
|
||||
},
|
||||
primaryForums: ["edu-abroad", "edu-corporate", "edu-skills"],
|
||||
activeHours: { weekday: ["20:00-22:00"], weekend: ["10:00-18:00"] },
|
||||
activityLevel: "low",
|
||||
replyChance: 0.3,
|
||||
},
|
||||
];
|
||||
|
||||
// 校验:跳过 key / email 重复的条目
|
||||
const toAdd = newPassersby.filter((b) => {
|
||||
if (existingKeys.has(b.key)) {
|
||||
console.warn("跳过重复 key:", b.key);
|
||||
return false;
|
||||
}
|
||||
if (existingEmails.has(b.email)) {
|
||||
console.warn("跳过重复 email:", b.email);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
console.log("已新增路人:", toAdd.length);
|
||||
data.characters.push(...toAdd);
|
||||
|
||||
writeFileSync(path, JSON.stringify(data, null, 2), "utf-8");
|
||||
console.log("总 Bot 数:", data.characters.length);
|
||||
console.log("观察者数:", data.characters.filter((c) => c.role === "passerby").length);
|
||||
@@ -0,0 +1,224 @@
|
||||
// 回填历史 BotDailyStat 数据
|
||||
// 从 forumTopic / forumPost / 点赞表中,按本地日期(UTC+8)聚合每个 bot 的每日指标
|
||||
// 用法:node scripts/backfill-bot-daily-stats.mjs [--days=14] [--dry-run]
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||
import "dotenv/config";
|
||||
|
||||
const base = process.env.DATABASE_URL.replace("mysql://", "mariadb://");
|
||||
const sep = base.includes("?") ? "&" : "?";
|
||||
const connectionString = `${base}${sep}connection_limit=5&pool_timeout=15`;
|
||||
const adapter = new PrismaMariaDb(connectionString);
|
||||
const prisma = new PrismaClient({ adapter });
|
||||
|
||||
// 把任意 Date 转成"本地日"的 key(YYYY-MM-DD)
|
||||
// 因为 Prisma @db.Date 在中国时区会被 UTC 截到前一天,所以聚合也按本地日
|
||||
function toLocalDateKey(d) {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
function toUTCDateOfLocalDay(key) {
|
||||
// "YYYY-MM-DD" -> UTC 午夜 Date 对象
|
||||
const [y, m, d] = key.split("-").map(Number);
|
||||
return new Date(Date.UTC(y, m - 1, d));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const dryRun = args.includes("--dry-run");
|
||||
const daysArg = args.find((a) => a.startsWith("--days="));
|
||||
const days = daysArg ? parseInt(daysArg.split("=")[1], 10) : 30;
|
||||
|
||||
console.log(`========== 回填 BotDailyStat(${days} 天, ${dryRun ? "DRY-RUN" : "实际写入"})==========\n`);
|
||||
|
||||
// 1. 拿到所有 bot 用户
|
||||
const botUsers = await prisma.user.findMany({
|
||||
where: { isBot: true },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
if (botUsers.length === 0) {
|
||||
console.log("没有 bot 用户,退出。");
|
||||
await prisma.$disconnect();
|
||||
return;
|
||||
}
|
||||
console.log(`共 ${botUsers.length} 个 bot`);
|
||||
|
||||
// 2. BotConfig 映射(userId -> configId)
|
||||
const configs = await prisma.botConfig.findMany({
|
||||
where: { userId: { in: botUsers.map((u) => u.id) } },
|
||||
select: { id: true, userId: true },
|
||||
});
|
||||
const configIdByUserId = new Map(configs.map((c) => [c.userId, c.id]));
|
||||
const botUserIds = botUsers.map((u) => u.id).filter((uid) => configIdByUserId.has(uid));
|
||||
console.log(`共 ${configs.length} 个 BotConfig(${botUsers.length - configs.length} 个 bot 还没创建 BotConfig)`);
|
||||
|
||||
// 3. 时间窗
|
||||
const since = new Date(Date.now() - days * 24 * 3600 * 1000);
|
||||
console.log(`时间窗: ${toLocalDateKey(since)} ~ 至今`);
|
||||
|
||||
// 4. 拉 bot 的所有话题(只取 createdAt)
|
||||
const topics = await prisma.forumTopic.findMany({
|
||||
where: { userId: { in: botUserIds }, createdAt: { gte: since } },
|
||||
select: { id: true, userId: true, createdAt: true, categoryId: true, likeCount: true },
|
||||
});
|
||||
console.log(`拉取到 ${topics.length} 个 bot 话题`);
|
||||
|
||||
// 5. 拉 bot 的所有回复
|
||||
const posts = await prisma.forumPost.findMany({
|
||||
where: { userId: { in: botUserIds }, createdAt: { gte: since } },
|
||||
select: { id: true, userId: true, createdAt: true, topicId: true, likeCount: true },
|
||||
});
|
||||
console.log(`拉取到 ${posts.length} 个 bot 回复`);
|
||||
|
||||
// 6. 拉 bot 话题的点赞(区分是不是 bot 自己)
|
||||
const topicIds = topics.map((t) => t.id);
|
||||
const topicLikes = topicIds.length > 0 ? await prisma.forumTopicLike.findMany({
|
||||
where: { topicId: { in: topicIds } },
|
||||
select: { topicId: true, userId: true, createdAt: true },
|
||||
}) : [];
|
||||
console.log(`拉取到 ${topicLikes.length} 条话题点赞`);
|
||||
|
||||
// 7. 拉 bot 回复的点赞
|
||||
const postIds = posts.map((p) => p.id);
|
||||
const postLikes = postIds.length > 0 ? await prisma.forumPostLike.findMany({
|
||||
where: { postId: { in: postIds } },
|
||||
select: { postId: true, userId: true, createdAt: true },
|
||||
}) : [];
|
||||
console.log(`拉取到 ${postLikes.length} 条回复点赞`);
|
||||
|
||||
// 8. 拉 bot 话题/回复下的所有回复(用于 human interactions:bot 收到真人回复)
|
||||
const topicsSet = new Set(topics.map((t) => t.id));
|
||||
const postsForBotTopics = topicsSet.size > 0 ? await prisma.forumPost.findMany({
|
||||
where: { topicId: { in: [...topicsSet] }, createdAt: { gte: since } },
|
||||
select: { id: true, userId: true, createdAt: true, topicId: true },
|
||||
}) : [];
|
||||
const botSet = new Set(botUserIds);
|
||||
const allUserIds = Array.from(new Set([
|
||||
...topicLikes.map((l) => l.userId),
|
||||
...postLikes.map((l) => l.userId),
|
||||
...postsForBotTopics.map((p) => p.userId),
|
||||
]));
|
||||
const humanIds = new Set();
|
||||
if (allUserIds.length > 0) {
|
||||
const humans = await prisma.user.findMany({
|
||||
where: { id: { in: allUserIds }, isBot: false },
|
||||
select: { id: true },
|
||||
});
|
||||
humans.forEach((h) => humanIds.add(h.id));
|
||||
}
|
||||
console.log(`识别出 ${humanIds.size} 个真人用户`);
|
||||
|
||||
// 9. 按 botConfigId + 本地日期 key 聚合
|
||||
// row: { topicCreated, replySent, repliesReceived, likesReceived, humanInteractions }
|
||||
const agg = new Map(); // key: `${cfgId}|${dateKey}` -> stats
|
||||
function bump(cfgId, dateKey, field, by = 1) {
|
||||
const key = `${cfgId}|${dateKey}`;
|
||||
if (!agg.has(key)) {
|
||||
agg.set(key, { botId: cfgId, dateKey, topicCreated: 0, replySent: 0, repliesReceived: 0, likesReceived: 0, humanInteractions: 0, followUpSent: 0, feedbackProcessed: 0 });
|
||||
}
|
||||
agg.get(key)[field] += by;
|
||||
}
|
||||
|
||||
// 9a. 话题创建
|
||||
for (const t of topics) {
|
||||
const cfgId = configIdByUserId.get(t.userId);
|
||||
if (!cfgId) continue;
|
||||
bump(cfgId, toLocalDateKey(t.createdAt), "topicCreated", 1);
|
||||
}
|
||||
|
||||
// 9b. 回复
|
||||
for (const p of posts) {
|
||||
const cfgId = configIdByUserId.get(p.userId);
|
||||
if (!cfgId) continue;
|
||||
bump(cfgId, toLocalDateKey(p.createdAt), "replySent", 1);
|
||||
}
|
||||
|
||||
// 9c. bot 话题收到的回复(repliesReceived = 该 bot 的所有话题下其他人的回复数)
|
||||
for (const p of postsForBotTopics) {
|
||||
// 找这个 topic 是哪个 bot 的
|
||||
const topic = topics.find((t) => t.id === p.topicId);
|
||||
if (!topic) continue;
|
||||
const ownerCfgId = configIdByUserId.get(topic.userId);
|
||||
if (!ownerCfgId) continue;
|
||||
// 跳过 bot 自己的回复
|
||||
if (botSet.has(p.userId)) continue;
|
||||
bump(ownerCfgId, toLocalDateKey(p.createdAt), "repliesReceived", 1);
|
||||
// 真人互动:bot 收到真人回复
|
||||
if (humanIds.has(p.userId)) {
|
||||
bump(ownerCfgId, toLocalDateKey(p.createdAt), "humanInteractions", 1);
|
||||
}
|
||||
}
|
||||
|
||||
// 9d. 话题点赞
|
||||
for (const l of topicLikes) {
|
||||
const topic = topics.find((t) => t.id === l.topicId);
|
||||
if (!topic) continue;
|
||||
const ownerCfgId = configIdByUserId.get(topic.userId);
|
||||
if (!ownerCfgId) continue;
|
||||
bump(ownerCfgId, toLocalDateKey(l.createdAt), "likesReceived", 1);
|
||||
if (humanIds.has(l.userId)) {
|
||||
bump(ownerCfgId, toLocalDateKey(l.createdAt), "humanInteractions", 1);
|
||||
}
|
||||
}
|
||||
|
||||
// 9e. 回复点赞
|
||||
for (const l of postLikes) {
|
||||
const post = posts.find((p) => p.id === l.postId);
|
||||
if (!post) continue;
|
||||
const ownerCfgId = configIdByUserId.get(post.userId);
|
||||
if (!ownerCfgId) continue;
|
||||
bump(ownerCfgId, toLocalDateKey(l.createdAt), "likesReceived", 1);
|
||||
if (humanIds.has(l.userId)) {
|
||||
bump(ownerCfgId, toLocalDateKey(l.createdAt), "humanInteractions", 1);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n聚合出 ${agg.size} 条记录\n`);
|
||||
|
||||
// 10. 写入
|
||||
let writeCount = 0;
|
||||
for (const row of agg.values()) {
|
||||
const dateObj = toUTCDateOfLocalDay(row.dateKey);
|
||||
if (dryRun) {
|
||||
if (writeCount < 5) {
|
||||
console.log(` [DRY] ${row.botId} ${row.dateKey} T=${row.topicCreated} R=${row.replySent} RR=${row.repliesReceived} L=${row.likesReceived} H=${row.humanInteractions}`);
|
||||
}
|
||||
writeCount++;
|
||||
continue;
|
||||
}
|
||||
await prisma.botDailyStat.upsert({
|
||||
where: { botId_date: { botId: row.botId, date: dateObj } },
|
||||
create: {
|
||||
botId: row.botId,
|
||||
date: dateObj,
|
||||
topicCreated: row.topicCreated,
|
||||
replySent: row.replySent,
|
||||
followUpSent: row.followUpSent,
|
||||
repliesReceived: row.repliesReceived,
|
||||
likesReceived: row.likesReceived,
|
||||
humanInteractions: row.humanInteractions,
|
||||
feedbackProcessed: 0,
|
||||
},
|
||||
update: {
|
||||
topicCreated: { increment: row.topicCreated },
|
||||
replySent: { increment: row.replySent },
|
||||
repliesReceived: { increment: row.repliesReceived },
|
||||
likesReceived: { increment: row.likesReceived },
|
||||
humanInteractions: { increment: row.humanInteractions },
|
||||
},
|
||||
});
|
||||
writeCount++;
|
||||
}
|
||||
|
||||
console.log(`${dryRun ? "[DRY-RUN] 即将写入" : "已写入"} ${writeCount} 条 BotDailyStat 记录`);
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch(async (e) => {
|
||||
console.error(e);
|
||||
await prisma.$disconnect();
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# 追光AI 容器卷备份脚本 (在宿主机上跑)
|
||||
# - 把 4 个 docker 卷打包到 /home/ubuntu/zhuiguang-ai-backup/dockers/
|
||||
# - 默认保留最近 7 天快照
|
||||
# - 用法: bash scripts/backup-volumes.sh
|
||||
# =============================================================================
|
||||
set -e
|
||||
|
||||
PROJECT="/home/ubuntu/zhuiguang-ai"
|
||||
BACKUP_ROOT="/home/ubuntu/zhuiguang-ai-backup/dockers"
|
||||
STAMP=$(date +%Y%m%d-%H%M%S)
|
||||
BACKUP_DIR="${BACKUP_ROOT}/${STAMP}"
|
||||
KEEP_DAYS=7
|
||||
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
echo "[backup] $(date) 开始备份容器卷到 $BACKUP_DIR"
|
||||
|
||||
# 备份 4 个卷
|
||||
for vol in zhuiguang_ai_bot_data zhuiguang_ai_bot_public zhuiguang_ai_bot_logs zhuiguang_ai_bot_prisma; do
|
||||
if ! docker volume inspect "$vol" >/dev/null 2>&1; then
|
||||
echo "[backup] WARN: 卷 $vol 不存在,跳过"
|
||||
continue
|
||||
fi
|
||||
echo "[backup] 备份卷 $vol ..."
|
||||
docker run --rm \
|
||||
-v "${vol}:/src:ro" \
|
||||
-v "${BACKUP_DIR}:/dst" \
|
||||
alpine:3.19 \
|
||||
tar czf "/dst/${vol}.tgz" -C /src . 2>&1 | tail -3
|
||||
echo "[backup] -> ${vol}.tgz"
|
||||
done
|
||||
|
||||
# 同时备份 .env(敏感文件单独加密存)
|
||||
if [ -f "$PROJECT/env/.env.production" ]; then
|
||||
cp "$PROJECT/env/.env.production" "${BACKUP_DIR}/.env.production"
|
||||
echo "[backup] .env.production 已备份"
|
||||
fi
|
||||
|
||||
# 备份清单
|
||||
{
|
||||
echo "# 备份清单 - $(date)"
|
||||
echo "STAMP: $STAMP"
|
||||
ls -lh "$BACKUP_DIR"
|
||||
} > "$BACKUP_DIR/INDEX.txt"
|
||||
|
||||
echo "[backup] 完成。清单:"
|
||||
ls -lh "$BACKUP_DIR"
|
||||
|
||||
# 清理 7 天前的旧备份
|
||||
echo "[backup] 清理 $KEEP_DAYS 天前的旧备份 ..."
|
||||
find "$BACKUP_ROOT" -maxdepth 1 -type d -mtime +$KEEP_DAYS -exec rm -rf {} + 2>/dev/null || true
|
||||
|
||||
echo "[backup] DONE"
|
||||
Binary file not shown.
+878
-26
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,147 @@
|
||||
// 数字人 vs 真人"对抗学习" 调度入口
|
||||
// 每周给所有专家 bot 各选 1 篇真实高赞真人帖,提取"为什么火"的洞察
|
||||
// 用法:
|
||||
// node scripts/bot-adversarial-learning-run.mjs # 默认本周
|
||||
// node scripts/bot-adversarial-learning-run.mjs --week=2026-W23 # 指定周
|
||||
// node scripts/bot-adversarial-learning-run.mjs --force # 强制重学(覆盖已有)
|
||||
// node scripts/bot-adversarial-learning-run.mjs --bot=laochen # 单 bot
|
||||
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||
import { fileURLToPath } from "url";
|
||||
import { dirname } from "path";
|
||||
import "dotenv/config";
|
||||
import {
|
||||
runWeeklyAdversarialLearning,
|
||||
getISOWeekKey,
|
||||
disconnectAdversarialLearning,
|
||||
} from "./lib/bot-adversarial-learning.mjs";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const base = process.env.DATABASE_URL.replace("mysql://", "mariadb://");
|
||||
const sep = base.includes("?") ? "&" : "?";
|
||||
const connectionString = `${base}${sep}connection_limit=3&pool_timeout=10`;
|
||||
const adapter = new PrismaMariaDb(connectionString);
|
||||
const prisma = new PrismaClient({ adapter });
|
||||
|
||||
function ts() {
|
||||
return `[${new Date().toISOString()}]`;
|
||||
}
|
||||
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
const out = { week: null, force: false, bot: null, lookback: 7 };
|
||||
for (const a of args) {
|
||||
if (a.startsWith("--week=")) out.week = a.split("=")[1];
|
||||
else if (a === "--force") out.force = true;
|
||||
else if (a.startsWith("--bot=")) out.bot = a.split("=")[1];
|
||||
else if (a.startsWith("--lookback=")) out.lookback = parseInt(a.split("=")[1], 10);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs();
|
||||
const weekKey = args.week || getISOWeekKey();
|
||||
console.log(
|
||||
`${ts()} Adversarial Learning run starting (week=${weekKey}, force=${args.force}, bot=${args.bot || "all"}, lookback=${args.lookback}d)`
|
||||
);
|
||||
|
||||
if (!process.env.DATABASE_URL) {
|
||||
throw new Error("DATABASE_URL 未配置");
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
// --force 时:先把本周已有 learning 删掉(针对限定 bot)
|
||||
if (args.force) {
|
||||
const where = args.bot
|
||||
? { bot: { user: { email: `bot_${args.bot}@zhuiguang.ai` } }, weekKey }
|
||||
: { weekKey };
|
||||
const del = await prisma.botAdversarialLearning.deleteMany({ where });
|
||||
console.log(`${ts()} --force: 删除了 ${del.count} 条已有 learning`);
|
||||
}
|
||||
|
||||
// 单 bot 模式
|
||||
let botConfigIds = null;
|
||||
if (args.bot) {
|
||||
const u = await prisma.user.findFirst({ where: { email: `bot_${args.bot}@zhuiguang.ai` } });
|
||||
if (!u) {
|
||||
console.error(`${ts()} Bot "${args.bot}" 未找到`);
|
||||
process.exit(1);
|
||||
}
|
||||
const c = await prisma.botConfig.findUnique({ where: { userId: u.id } });
|
||||
if (!c) {
|
||||
console.error(`${ts()} Bot "${args.bot}" 没有 BotConfig`);
|
||||
process.exit(1);
|
||||
}
|
||||
botConfigIds = [c.id];
|
||||
}
|
||||
|
||||
const summary = await runWeeklyAdversarialLearning({
|
||||
weekKey,
|
||||
lookbackDays: args.lookback,
|
||||
botConfigIds,
|
||||
});
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
console.log(
|
||||
`${ts()} Done: bots=${summary.botCount} learned=${summary.learned} skipped=${summary.skipped} errors=${summary.errors} (${duration}ms)`
|
||||
);
|
||||
for (const r of summary.results.slice(0, 10)) {
|
||||
if (r.error) {
|
||||
console.log(`${ts()} ✗ ${r.botName} ${r.error}`);
|
||||
} else if (r.skipped) {
|
||||
console.log(`${ts()} ⤼ ${r.botName} skip=${r.reason}`);
|
||||
} else {
|
||||
const rec = r.record;
|
||||
console.log(
|
||||
`${ts()} ✓ ${r.botName} ← topic#${rec.sourceRefId} "${rec.sourceTitle?.slice(0, 30)}" rel=${rec.relevanceScore} cat=${JSON.stringify(rec.learnCategories)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.taskLog.create({
|
||||
data: {
|
||||
taskKey: "bot-adversarial-learning-run",
|
||||
taskName: "Bot 对抗学习(真人高赞帖)",
|
||||
status: summary.errors > 0 ? "partial" : "success",
|
||||
startedAt: new Date(startTime),
|
||||
finishedAt: new Date(),
|
||||
triggerBy: "cron",
|
||||
result: {
|
||||
weekKey,
|
||||
botCount: summary.botCount,
|
||||
learned: summary.learned,
|
||||
skipped: summary.skipped,
|
||||
errors: summary.errors,
|
||||
force: args.force,
|
||||
lookbackDays: args.lookback,
|
||||
durationMs: duration,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(async (e) => {
|
||||
console.error(`${ts()} Fatal:`, e);
|
||||
try {
|
||||
await prisma.taskLog.create({
|
||||
data: {
|
||||
taskKey: "bot-adversarial-learning-run",
|
||||
taskName: "Bot 对抗学习(真人高赞帖)",
|
||||
status: "failed",
|
||||
startedAt: new Date(),
|
||||
finishedAt: new Date(),
|
||||
triggerBy: "cron",
|
||||
error: (e && e.message ? e.message : String(e)).slice(0, 1000),
|
||||
},
|
||||
});
|
||||
} catch {}
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
await disconnectAdversarialLearning();
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||
import { readFileSync } from "fs";
|
||||
import { resolve, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import "dotenv/config";
|
||||
import { updatePersona as sharedUpdatePersona, computePersona } from "./lib/bot-persona.mjs";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const base = process.env.DATABASE_URL.replace("mysql://", "mariadb://");
|
||||
const sep = base.includes("?") ? "&" : "?";
|
||||
const connectionString = `${base}${sep}connection_limit=5&pool_timeout=30`;
|
||||
const adapter = new PrismaMariaDb(connectionString);
|
||||
const prisma = new PrismaClient({ adapter });
|
||||
|
||||
const BOT_DATA_PATH = resolve(__dirname, "..", "data", "bot-characters.json");
|
||||
|
||||
const LOOKBACK_DAYS = 14;
|
||||
const MIN_AFFINITY_SAMPLES = 1;
|
||||
|
||||
function ts() {
|
||||
return `[${new Date().toISOString()}]`;
|
||||
}
|
||||
|
||||
async function loadBotCharacters() {
|
||||
const raw = readFileSync(BOT_DATA_PATH, "utf-8");
|
||||
const { characters } = JSON.parse(raw);
|
||||
const map = {};
|
||||
for (const c of characters) map[c.key] = c;
|
||||
return { characters, map };
|
||||
}
|
||||
|
||||
async function computeAffinity(botUser, botConfigId) {
|
||||
const since = new Date(Date.now() - LOOKBACK_DAYS * 24 * 60 * 60 * 1000);
|
||||
const myTopics = await prisma.forumTopic.findMany({
|
||||
where: { userId: botUser.id, createdAt: { gt: since } },
|
||||
select: { id: true, category: { select: { slug: true } } },
|
||||
});
|
||||
|
||||
if (myTopics.length === 0) return { homeForums: new Map(), crossCount: 0 };
|
||||
|
||||
const topicIds = myTopics.map((t) => t.id);
|
||||
const myForumSlugs = new Set(myTopics.map((t) => t.category.slug));
|
||||
|
||||
const humanRepliers = await prisma.forumPost.findMany({
|
||||
where: {
|
||||
topicId: { in: topicIds },
|
||||
user: { isBot: false },
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
isBot: true,
|
||||
forumTopics: {
|
||||
where: { createdAt: { gt: since } },
|
||||
select: { category: { select: { slug: true } } },
|
||||
take: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
topic: { select: { category: { select: { slug: true, name: true } } } },
|
||||
},
|
||||
});
|
||||
|
||||
const homeForumCount = new Map();
|
||||
for (const p of humanRepliers) {
|
||||
const homeForums = p.user.forumTopics.map((t) => t.category.slug);
|
||||
if (homeForums.length === 0) continue;
|
||||
const counts = new Map();
|
||||
for (const f of homeForums) counts.set(f, (counts.get(f) || 0) + 1);
|
||||
const sorted = Array.from(counts.entries()).sort((a, b) => b[1] - a[1]);
|
||||
const homeForum = sorted[0][0];
|
||||
if (myForumSlugs.has(homeForum)) continue;
|
||||
homeForumCount.set(homeForum, (homeForumCount.get(homeForum) || 0) + 1);
|
||||
}
|
||||
|
||||
return { homeForums: homeForumCount, crossCount: humanRepliers.length };
|
||||
}
|
||||
|
||||
async function updatePersona(botChar) {
|
||||
const botUser = await prisma.user.findFirst({
|
||||
where: { email: `bot_${botChar.key}@zhuiguang.ai` },
|
||||
});
|
||||
if (!botUser) return false;
|
||||
|
||||
const botConfig = await prisma.botConfig.findUnique({
|
||||
where: { userId: botUser.id },
|
||||
});
|
||||
if (!botConfig) return false;
|
||||
|
||||
// 调用 lib/bot-persona.mjs 的共享实现(同步)
|
||||
const result = await sharedUpdatePersona(botUser, botConfig.id);
|
||||
if (!result.success) return false;
|
||||
|
||||
// 再读一份用于日志输出
|
||||
const persona = await computePersona(botUser, botConfig.id);
|
||||
console.log(
|
||||
`${ts()} 📐 ${botChar.displayName} 画像: 话题${persona.postSampleCount}条 / 平均话题${persona.avgTopicLength}字 / 平均回复${persona.avgReplyLength}字 / 问号率${persona.questionRatio}`
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function updateAffinity(botChar) {
|
||||
const botUser = await prisma.user.findFirst({
|
||||
where: { email: `bot_${botChar.key}@zhuiguang.ai` },
|
||||
});
|
||||
if (!botUser) return;
|
||||
|
||||
const botConfig = await prisma.botConfig.findUnique({
|
||||
where: { userId: botUser.id },
|
||||
});
|
||||
if (!botConfig) return;
|
||||
|
||||
const { homeForums, crossCount } = await computeAffinity(botUser, botConfig.id);
|
||||
|
||||
const primaryForumSlugs = new Set(
|
||||
(botChar.personality?.primaryForums || []).map((s) => s)
|
||||
);
|
||||
|
||||
await prisma.botCrossForumAffinity.deleteMany({
|
||||
where: {
|
||||
botId: botConfig.id,
|
||||
forumSlug: { in: Array.from(primaryForumSlugs) },
|
||||
},
|
||||
});
|
||||
|
||||
if (homeForums.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const totalCrossSamples = Array.from(homeForums.values()).reduce((s, n) => s + n, 0);
|
||||
|
||||
for (const [slug, count] of homeForums.entries()) {
|
||||
if (count < MIN_AFFINITY_SAMPLES) continue;
|
||||
const affinity = Math.min(1, count / Math.max(crossCount / 4, 5));
|
||||
const reason = `${count}/${totalCrossSamples} 跨板块互动`;
|
||||
await prisma.botCrossForumAffinity.upsert({
|
||||
where: { botId_forumSlug: { botId: botConfig.id, forumSlug: slug } },
|
||||
create: {
|
||||
botId: botConfig.id,
|
||||
forumSlug: slug,
|
||||
affinity,
|
||||
sampleCount: count,
|
||||
reason,
|
||||
},
|
||||
update: {
|
||||
affinity,
|
||||
sampleCount: count,
|
||||
reason,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (homeForums.size > 0) {
|
||||
const top = Array.from(homeForums.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 3)
|
||||
.map(([s, c]) => `${s}(${c})`)
|
||||
.join(", ");
|
||||
console.log(`${ts()} 🌍 ${botChar.displayName} 跨板块亲和度: ${top}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`${ts()} Bot Affinity & Persona Update starting...`);
|
||||
|
||||
if (!process.env.DATABASE_URL) {
|
||||
throw new Error("DATABASE_URL 未配置");
|
||||
}
|
||||
|
||||
const { characters } = await loadBotCharacters();
|
||||
const botChars = characters.filter((c) => !c.role || c.role !== "passerby");
|
||||
console.log(`${ts()} Loaded ${botChars.length} expert bots.`);
|
||||
|
||||
let personaCount = 0;
|
||||
let affinityCount = 0;
|
||||
|
||||
for (const botChar of botChars) {
|
||||
try {
|
||||
if (await updatePersona(botChar)) personaCount++;
|
||||
await updateAffinity(botChar);
|
||||
affinityCount++;
|
||||
} catch (err) {
|
||||
console.error(`${ts()} Error processing ${botChar.key}:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.taskLog.create({
|
||||
data: {
|
||||
taskKey: "bot-affinity-update",
|
||||
taskName: "Bot亲和度与画像",
|
||||
status: "success",
|
||||
startedAt: new Date(),
|
||||
finishedAt: new Date(),
|
||||
triggerBy: "cron",
|
||||
result: { personaCount, affinityCount, totalBots: botChars.length },
|
||||
},
|
||||
});
|
||||
|
||||
console.log(
|
||||
`${ts()} Bot Affinity & Persona Update done. persona=${personaCount} affinity=${affinityCount}`
|
||||
);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(async (e) => {
|
||||
console.error(`${ts()} Fatal error:`, e);
|
||||
try {
|
||||
await prisma.taskLog.create({
|
||||
data: {
|
||||
taskKey: "bot-affinity-update",
|
||||
taskName: "Bot亲和度与画像",
|
||||
status: "failed",
|
||||
startedAt: new Date(),
|
||||
finishedAt: new Date(),
|
||||
triggerBy: "cron",
|
||||
error: (e && e.message ? e.message : String(e)).slice(0, 1000),
|
||||
},
|
||||
});
|
||||
} catch {}
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
const { disconnectBotPersona } = await import("./lib/bot-persona.mjs");
|
||||
await disconnectBotPersona();
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env node
|
||||
// scripts/bot-avatar-generate.mjs
|
||||
// 数字人头像批量预生成 + 落盘 + 回写 User.avatarUrl
|
||||
//
|
||||
// 用法:
|
||||
// node scripts/bot-avatar-generate.mjs # 生成所有 bot 头像
|
||||
// node scripts/bot-avatar-generate.mjs --bot=laochen # 只生成单个 bot
|
||||
// node scripts/bot-avatar-generate.mjs --force # 强制重新生成(已存在也覆盖)
|
||||
// node scripts/bot-avatar-generate.mjs --use-lunaris-only # 只用 Lunaris,不生成 SVG(当前 Lunaris 全是 default,不推荐)
|
||||
// node scripts/bot-avatar-generate.mjs --no-update-db # 只落盘,不写 User.avatarUrl
|
||||
// node scripts/bot-avatar-generate.mjs --dry-run # 试运行,不落盘不写库
|
||||
|
||||
import { disconnect, generateForAll } from "./lib/bot-avatar-generator.mjs";
|
||||
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
const out = { force: false, useLunarisOnly: false, updateDb: true, dryRun: false, bot: null, delay: 200 };
|
||||
for (const a of args) {
|
||||
if (a === "--force") out.force = true;
|
||||
else if (a === "--use-lunaris-only") out.useLunarisOnly = true;
|
||||
else if (a === "--no-update-db") out.updateDb = false;
|
||||
else if (a === "--dry-run") out.dryRun = true;
|
||||
else if (a.startsWith("--bot=")) out.bot = a.slice(6);
|
||||
else if (a.startsWith("--delay=")) out.delay = parseInt(a.slice(8), 10);
|
||||
else if (a === "--help" || a === "-h") {
|
||||
console.log("用法: node scripts/bot-avatar-generate.mjs [选项]");
|
||||
console.log(" --bot=<key> 只生成指定 bot");
|
||||
console.log(" --force 覆盖已存在的头像");
|
||||
console.log(" --use-lunaris-only 禁用 SVG 兜底(当前不推荐)");
|
||||
console.log(" --no-update-db 只落盘不写 User.avatarUrl");
|
||||
console.log(" --dry-run 试运行,不落盘不写库");
|
||||
console.log(" --delay=<ms> bot 间隔延迟(默认 200ms)");
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function ts() {
|
||||
return new Date().toISOString().replace("T", " ").slice(0, 19);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs();
|
||||
console.log(`${ts()} 数字人头像批量预生成开始`);
|
||||
console.log(
|
||||
` bot=${args.bot || "all"} force=${args.force} lunarisOnly=${args.useLunarisOnly} updateDb=${args.updateDb} dryRun=${args.dryRun} delay=${args.delay}ms`
|
||||
);
|
||||
|
||||
if (!process.env.DATABASE_URL) {
|
||||
throw new Error("DATABASE_URL 未配置");
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const { summary, results } = await generateForAll({
|
||||
force: args.force,
|
||||
botKey: args.bot,
|
||||
useLunarisOnly: args.useLunarisOnly,
|
||||
delayMs: args.dryRun ? 0 : args.delay,
|
||||
});
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
console.log("");
|
||||
console.log(`${ts()} 完成: total=${summary.total} ok=${summary.ok} failed=${summary.failed} (${duration}ms)`);
|
||||
console.log(`${ts()} 落盘位置: public/bot-avatars/{key}.svg + public/bot-avatars/lunaris/{key}.jpg`);
|
||||
console.log(`${ts()} 清单: public/bot-avatars/manifest.json`);
|
||||
|
||||
if (summary.failed > 0) {
|
||||
console.log("");
|
||||
console.log("失败列表:");
|
||||
for (const r of results) {
|
||||
if (r.error) console.log(` - ${r.key}: ${r.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
await disconnect();
|
||||
process.exit(summary.failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch(async (err) => {
|
||||
console.error(`${ts()} ❌ 致命错误: ${err.message}`);
|
||||
console.error(err.stack);
|
||||
await disconnect().catch(() => {});
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,421 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||
import { readFileSync } from "fs";
|
||||
import { resolve, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import OpenAI from "openai";
|
||||
import "dotenv/config";
|
||||
import { withRetry } from "./lib/retry.mjs";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const base = process.env.DATABASE_URL.replace("mysql://", "mariadb://");
|
||||
const sep = base.includes("?") ? "&" : "?";
|
||||
const connectionString = `${base}${sep}connection_limit=5&pool_timeout=30`;
|
||||
const adapter = new PrismaMariaDb(connectionString);
|
||||
const prisma = new PrismaClient({ adapter });
|
||||
|
||||
const openai = new OpenAI({
|
||||
apiKey: process.env.DEEPSEEK_API_KEY,
|
||||
baseURL: "https://api.deepseek.com/v1",
|
||||
});
|
||||
|
||||
const BOT_DATA_PATH = resolve(__dirname, "..", "data", "bot-characters.json");
|
||||
const MODEL = process.env.DEEPSEEK_MODEL || "deepseek-chat";
|
||||
|
||||
const LAYER = {
|
||||
SESSION: "SESSION",
|
||||
WORKING: "WORKING",
|
||||
LONGTERM: "LONGTERM",
|
||||
};
|
||||
|
||||
const MEMORY_TYPE = {
|
||||
EXPERIENCE: "experience",
|
||||
INTERACTION: "interaction",
|
||||
FEEDBACK: "feedback",
|
||||
SKILL_USAGE: "skill_usage",
|
||||
REFLECTION: "reflection",
|
||||
USER_MODEL: "user_model",
|
||||
};
|
||||
|
||||
const QUOTA = {
|
||||
high: { followUpPerDay: 6 },
|
||||
medium: { followUpPerDay: 3 },
|
||||
low: { followUpPerDay: 1 },
|
||||
};
|
||||
|
||||
const FEEDBACK_LOOKBACK_HOURS = 24;
|
||||
const FOLLOW_UP_PROBABILITY = 0.6;
|
||||
const MIN_FEEDBACK_TO_FOLLOWUP = 1;
|
||||
const MAX_FOLLOWUP_PER_RUN_PER_BOT = 3;
|
||||
|
||||
function ts() {
|
||||
return `[${new Date().toISOString()}]`;
|
||||
}
|
||||
|
||||
function pick(arr) {
|
||||
return arr[Math.floor(Math.random() * arr.length)];
|
||||
}
|
||||
|
||||
function startOfToday() {
|
||||
// 用 UTC 午夜避开 Prisma @db.Date 写入时的时区截断
|
||||
const d = new Date();
|
||||
return new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
|
||||
}
|
||||
|
||||
async function loadBotCharacters() {
|
||||
const raw = readFileSync(BOT_DATA_PATH, "utf-8");
|
||||
const { characters } = JSON.parse(raw);
|
||||
const map = {};
|
||||
for (const c of characters) {
|
||||
map[c.key] = c;
|
||||
}
|
||||
return { characters, map };
|
||||
}
|
||||
|
||||
async function getOrCreateTodayStat(botUserId) {
|
||||
const today = startOfToday();
|
||||
return prisma.botDailyStat.upsert({
|
||||
where: { botId_date: { botId: botUserId, date: today } },
|
||||
create: { botId: botUserId, date: today },
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
|
||||
async function incrementStat(botUserId, field, by = 1) {
|
||||
const today = startOfToday();
|
||||
await prisma.botDailyStat.upsert({
|
||||
where: { botId_date: { botId: botUserId, date: today } },
|
||||
create: { botId: botUserId, date: today, [field]: by },
|
||||
update: { [field]: { increment: by } },
|
||||
});
|
||||
}
|
||||
|
||||
async function getProcessedReplyIds(botUserId) {
|
||||
const memories = await prisma.botMemory.findMany({
|
||||
where: {
|
||||
botId: botUserId,
|
||||
memoryType: MEMORY_TYPE.FEEDBACK,
|
||||
},
|
||||
select: { contextTags: true },
|
||||
});
|
||||
const ids = new Set();
|
||||
for (const m of memories) {
|
||||
const tags = m.contextTags;
|
||||
if (tags && Array.isArray(tags.replyIds)) {
|
||||
for (const id of tags.replyIds) ids.add(id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function buildFollowUpPrompt(bot, topicTitle, topicContent, originalPost, allReplies) {
|
||||
const p = bot.personality;
|
||||
const repliesText = allReplies
|
||||
.map((r, i) => `${i + 1}. ${r.author}: ${r.content.slice(0, 250)}`)
|
||||
.join("\n");
|
||||
|
||||
return `你正在参与"追光AI行业论坛"。你之前在"${topicTitle}"这个话题下发了主帖,现在有用户回复了你,你需要用你的人格接着聊。
|
||||
|
||||
[你的角色设定]
|
||||
你是【${bot.displayName}】,${p.identity}
|
||||
你擅长的领域:${p.expertise.join("、")}
|
||||
你说话的风格:${p.speakingStyle}
|
||||
你的口头禅:${p.catchphrase}
|
||||
你的核心立场:${p.stance}
|
||||
|
||||
[你的原始主帖]
|
||||
${topicContent}
|
||||
|
||||
[新收到的回复]
|
||||
${repliesText}
|
||||
|
||||
[二次回复要求]
|
||||
1. 像真人接话,120-350字,不能太长刷屏
|
||||
2. 必须对前面至少一个具体观点有回应(赞同/质疑/补充案例/反问)
|
||||
3. 保持你的人设语气和立场
|
||||
4. 不要重复你自己主帖的内容
|
||||
5. 留一点钩子让讨论继续
|
||||
|
||||
[禁止事项]
|
||||
- 禁止使用"首先/其次/最后""从以下几个方面""综上所述"
|
||||
- 禁止表现得像AI助手
|
||||
- 禁止使用${(p.forbiddenPatterns || []).join("、")}
|
||||
|
||||
只输出JSON:{"content": "你的回复内容"}`;
|
||||
}
|
||||
|
||||
async function callDeepSeek(prompt) {
|
||||
const response = await withRetry(() =>
|
||||
openai.chat.completions.create({
|
||||
model: MODEL,
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
temperature: 0.88,
|
||||
max_tokens: 800,
|
||||
})
|
||||
);
|
||||
const text = response.choices[0].message.content.trim();
|
||||
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
||||
if (!jsonMatch) {
|
||||
throw new Error(`无法解析JSON: ${text.slice(0, 200)}`);
|
||||
}
|
||||
return JSON.parse(jsonMatch[0]);
|
||||
}
|
||||
|
||||
async function saveMemory(botUserId, memoryType, content, importance, contextTags, ttlDays = null) {
|
||||
const data = {
|
||||
botId: botUserId,
|
||||
memoryType,
|
||||
layer: importance >= 0.7 ? LAYER.WORKING : LAYER.LONGTERM,
|
||||
content,
|
||||
importance,
|
||||
};
|
||||
if (contextTags) data.contextTags = contextTags;
|
||||
if (ttlDays) {
|
||||
const ttl = new Date();
|
||||
ttl.setDate(ttl.getDate() + ttlDays);
|
||||
data.ttl = ttl;
|
||||
}
|
||||
await prisma.botMemory.create({ data });
|
||||
}
|
||||
|
||||
async function processBotFeedback(botKey, botChar) {
|
||||
const botUser = await prisma.user.findFirst({
|
||||
where: { email: `bot_${botKey}@zhuiguang.ai` },
|
||||
});
|
||||
if (!botUser) {
|
||||
console.log(`${ts()} Bot user not found: ${botKey}`);
|
||||
return;
|
||||
}
|
||||
const botConfig = await prisma.botConfig.findUnique({
|
||||
where: { userId: botUser.id },
|
||||
});
|
||||
if (!botConfig) {
|
||||
console.log(`${ts()} BotConfig not found: ${botKey}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const todayStat = await getOrCreateTodayStat(botConfig.id);
|
||||
const activityLevel = botChar.activityLevel || "medium";
|
||||
const quota = QUOTA[activityLevel] || QUOTA.medium;
|
||||
if (todayStat.followUpSent >= quota.followUpPerDay) {
|
||||
console.log(`${ts()} ${botChar.displayName} 今日跟帖配额已用完 (${todayStat.followUpSent}/${quota.followUpPerDay})`);
|
||||
return;
|
||||
}
|
||||
|
||||
const since = new Date(Date.now() - FEEDBACK_LOOKBACK_HOURS * 60 * 60 * 1000);
|
||||
const processedIds = await getProcessedReplyIds(botConfig.id);
|
||||
|
||||
const myTopics = await prisma.forumTopic.findMany({
|
||||
where: {
|
||||
userId: botUser.id,
|
||||
createdAt: { gt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) },
|
||||
isLocked: false,
|
||||
},
|
||||
include: {
|
||||
category: { select: { name: true, slug: true } },
|
||||
posts: {
|
||||
where: { createdAt: { gt: since } },
|
||||
include: { user: { select: { id: true, name: true, isBot: true } } },
|
||||
orderBy: { createdAt: "asc" },
|
||||
},
|
||||
},
|
||||
orderBy: { lastReplyAt: "desc" },
|
||||
take: 10,
|
||||
});
|
||||
|
||||
let totalNewReplies = 0;
|
||||
let humanReplies = 0;
|
||||
let followUpsSent = 0;
|
||||
|
||||
for (const topic of myTopics) {
|
||||
const newReplies = topic.posts.filter(p => p.userId !== botUser.id && !processedIds.has(p.id));
|
||||
if (newReplies.length === 0) continue;
|
||||
|
||||
const replyIds = newReplies.map(p => p.id);
|
||||
const humanCount = newReplies.filter(p => !p.user.isBot).length;
|
||||
const humanRepliers = newReplies.filter(p => !p.user.isBot).map(p => ({
|
||||
id: p.userId,
|
||||
name: p.user.name || "匿名",
|
||||
}));
|
||||
|
||||
await saveMemory(
|
||||
botConfig.id,
|
||||
MEMORY_TYPE.FEEDBACK,
|
||||
{
|
||||
topicTitle: topic.title,
|
||||
topicId: topic.id,
|
||||
forum: topic.category.name,
|
||||
replyCount: newReplies.length,
|
||||
humanCount,
|
||||
repliers: newReplies.map(p => ({
|
||||
name: p.user.name || "匿名",
|
||||
isBot: p.user.isBot,
|
||||
excerpt: p.content.slice(0, 120),
|
||||
})),
|
||||
},
|
||||
humanCount > 0 ? 0.8 : 0.4,
|
||||
{
|
||||
topicId: topic.id,
|
||||
forumSlug: topic.category.slug,
|
||||
replyIds,
|
||||
humanRepliers,
|
||||
source: "feedback_loop",
|
||||
},
|
||||
30
|
||||
);
|
||||
|
||||
totalNewReplies += newReplies.length;
|
||||
humanReplies += humanCount;
|
||||
await incrementStat(botConfig.id, "feedbackProcessed", 1);
|
||||
await incrementStat(botConfig.id, "repliesReceived", newReplies.length);
|
||||
if (humanCount > 0) {
|
||||
await incrementStat(botConfig.id, "humanInteractions", humanCount);
|
||||
}
|
||||
|
||||
if (
|
||||
newReplies.length >= MIN_FEEDBACK_TO_FOLLOWUP &&
|
||||
Math.random() < FOLLOW_UP_PROBABILITY &&
|
||||
followUpsSent < MAX_FOLLOWUP_PER_RUN_PER_BOT &&
|
||||
todayStat.followUpSent + followUpsSent < quota.followUpPerDay
|
||||
) {
|
||||
try {
|
||||
const repliesForPrompt = newReplies.map(p => ({
|
||||
author: p.user.name || "匿名",
|
||||
content: p.content,
|
||||
}));
|
||||
const prompt = buildFollowUpPrompt(
|
||||
botChar,
|
||||
topic.title,
|
||||
topic.content,
|
||||
null,
|
||||
repliesForPrompt
|
||||
);
|
||||
const result = await callDeepSeek(prompt);
|
||||
const followUpContent = (result.content || "").trim();
|
||||
if (!followUpContent || followUpContent.length < 30) {
|
||||
console.log(`${ts()} ${botChar.displayName} 二次回复内容过短,跳过`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const post = await prisma.forumPost.create({
|
||||
data: {
|
||||
topicId: topic.id,
|
||||
userId: botUser.id,
|
||||
content: followUpContent.slice(0, 5000),
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.forumTopic.update({
|
||||
where: { id: topic.id },
|
||||
data: {
|
||||
replyCount: { increment: 1 },
|
||||
lastReplyAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await saveMemory(
|
||||
botConfig.id,
|
||||
MEMORY_TYPE.INTERACTION,
|
||||
{
|
||||
action: "follow_up_reply",
|
||||
forum: topic.category.name,
|
||||
topicTitle: topic.title,
|
||||
topicId: topic.id,
|
||||
triggeredBy: "feedback_loop",
|
||||
summary: followUpContent.slice(0, 100),
|
||||
},
|
||||
0.6,
|
||||
{
|
||||
topicId: topic.id,
|
||||
forumSlug: topic.category.slug,
|
||||
postId: post.id,
|
||||
source: "feedback_loop",
|
||||
},
|
||||
14
|
||||
);
|
||||
|
||||
followUpsSent++;
|
||||
await incrementStat(botConfig.id, "followUpSent", 1);
|
||||
await incrementStat(botConfig.id, "replySent", 1);
|
||||
|
||||
console.log(`${ts()} ${botChar.displayName} 二次回复: "${followUpContent.slice(0, 40)}..."`);
|
||||
} catch (err) {
|
||||
console.error(`${ts()} ${botChar.displayName} 二次回复失败:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (totalNewReplies > 0) {
|
||||
console.log(`${ts()} ${botChar.displayName}: 处理 ${totalNewReplies} 条新回复 (${humanReplies} 真人) → 跟帖 ${followUpsSent} 条`);
|
||||
}
|
||||
}
|
||||
|
||||
async function logTask(action, status, detail) {
|
||||
try {
|
||||
await prisma.taskLog.create({
|
||||
data: {
|
||||
taskKey: "bot-feedback-loop",
|
||||
taskName: "Bot反馈循环",
|
||||
status,
|
||||
startedAt: new Date(),
|
||||
finishedAt: new Date(),
|
||||
triggerBy: "cron",
|
||||
result: { action, detail },
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(`${ts()} TaskLog error:`, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`${ts()} Bot Feedback Loop starting...`);
|
||||
|
||||
if (!process.env.DEEPSEEK_API_KEY) {
|
||||
throw new Error("DEEPSEEK_API_KEY 未配置");
|
||||
}
|
||||
if (!process.env.DATABASE_URL) {
|
||||
throw new Error("DATABASE_URL 未配置");
|
||||
}
|
||||
|
||||
const { characters } = await loadBotCharacters();
|
||||
const botChars = characters.filter(c => !c.role || c.role !== "passerby");
|
||||
console.log(`${ts()} Loaded ${botChars.length} expert bots.`);
|
||||
|
||||
let processed = 0;
|
||||
for (const botChar of botChars) {
|
||||
try {
|
||||
await processBotFeedback(botChar.key, botChar);
|
||||
processed++;
|
||||
} catch (err) {
|
||||
console.error(`${ts()} Error processing ${botChar.key}:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
await logTask("feedback_loop", "success", `处理 ${processed} 个数字人`);
|
||||
console.log(`${ts()} Bot Feedback Loop done.`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(async (e) => {
|
||||
console.error(`${ts()} Fatal error:`, e);
|
||||
// 写一条失败日志方便后台追溯
|
||||
try {
|
||||
await prisma.taskLog.create({
|
||||
data: {
|
||||
taskKey: "bot-feedback-loop",
|
||||
taskName: "Bot反馈循环",
|
||||
status: "failed",
|
||||
startedAt: new Date(),
|
||||
finishedAt: new Date(),
|
||||
triggerBy: "cron",
|
||||
error: (e && e.message ? e.message : String(e)).slice(0, 1000),
|
||||
},
|
||||
});
|
||||
} catch {}
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
// Bot Persona A/B 测试 调度入口
|
||||
// 1) 采集最近 N 天 assignment 的真实互动数据
|
||||
// 2) 回填 bot_persona_metrics(按变体按天聚合)
|
||||
// 3) 更新变体的 sampleCount/replyCount/likeCount/humanReplyCount/engagementScore
|
||||
// 4) 对所有 bot 的实验做显著性分析,winner 自动提升权重
|
||||
//
|
||||
// 用法:
|
||||
// node scripts/bot-persona-experiment-run.mjs # 默认跑 1+2+3+4
|
||||
// node scripts/bot-persona-experiment-run.mjs --no-switch # 只跑 1+2+3
|
||||
// node scripts/bot-persona-experiment-run.mjs --lookback=14 # 自定义回看天数
|
||||
// node scripts/bot-persona-experiment-run.mjs --bot=laochen # 只为指定 bot 跑
|
||||
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||
import { readFileSync } from "fs";
|
||||
import { resolve, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import "dotenv/config";
|
||||
import {
|
||||
recordAssignmentMetrics,
|
||||
analyzeAndSwitchAllBots,
|
||||
disconnectBotExperiment,
|
||||
} from "./lib/bot-persona-experiment.mjs";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const base = process.env.DATABASE_URL.replace("mysql://", "mariadb://");
|
||||
const sep = base.includes("?") ? "&" : "?";
|
||||
const connectionString = `${base}${sep}connection_limit=3&pool_timeout=10`;
|
||||
const adapter = new PrismaMariaDb(connectionString);
|
||||
const prisma = new PrismaClient({ adapter });
|
||||
|
||||
const BOT_DATA_PATH = resolve(__dirname, "..", "data", "bot-characters.json");
|
||||
|
||||
function ts() {
|
||||
return `[${new Date().toISOString()}]`;
|
||||
}
|
||||
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
const out = { lookback: 7, noSwitch: false, bot: null, mode: "all" };
|
||||
for (const a of args) {
|
||||
if (a.startsWith("--lookback=")) out.lookback = parseInt(a.split("=")[1], 10);
|
||||
else if (a === "--no-switch") out.noSwitch = true;
|
||||
else if (a === "--metrics-only") out.mode = "metrics";
|
||||
else if (a === "--analyze-only") out.mode = "analyze";
|
||||
else if (a.startsWith("--bot=")) out.bot = a.split("=")[1];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs();
|
||||
console.log(`${ts()} Bot Persona A/B run starting (lookback=${args.lookback}d, mode=${args.mode}, bot=${args.bot || "all"}, noSwitch=${args.noSwitch})`);
|
||||
|
||||
if (!process.env.DATABASE_URL) {
|
||||
throw new Error("DATABASE_URL 未配置");
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
let metricsResult = null;
|
||||
let analysisResult = null;
|
||||
|
||||
// 1) 采集指标
|
||||
if (args.mode === "all" || args.mode === "metrics") {
|
||||
console.log(`${ts()} [Step 1] 采集 assignment 真实互动数据 ...`);
|
||||
metricsResult = await recordAssignmentMetrics({ lookbackDays: args.lookback });
|
||||
console.log(
|
||||
`${ts()} ✓ updated=${metricsResult.updated} assignments=${metricsResult.assignments} dayBuckets=${metricsResult.dayBuckets}`
|
||||
);
|
||||
}
|
||||
|
||||
// 2) 分析 + 切换
|
||||
if (!args.noSwitch && (args.mode === "all" || args.mode === "analyze")) {
|
||||
console.log(`${ts()} [Step 2] 显著性分析 + winner 权重切换 ...`);
|
||||
analysisResult = await analyzeAndSwitchAllBots();
|
||||
console.log(`${ts()} ✓ botCount=${analysisResult.botCount}`);
|
||||
for (const r of analysisResult.results.slice(0, 10)) {
|
||||
console.log(
|
||||
`${ts()} bot#${r.botId} winner=${r.winner || "∅"} action=${r.action} (${r.reason || ""})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
console.log(`${ts()} Done in ${duration}ms.`);
|
||||
|
||||
await prisma.taskLog.create({
|
||||
data: {
|
||||
taskKey: "bot-persona-experiment-run",
|
||||
taskName: "Bot Persona A/B 调度",
|
||||
status: "success",
|
||||
startedAt: new Date(startTime),
|
||||
finishedAt: new Date(),
|
||||
triggerBy: "cron",
|
||||
result: {
|
||||
lookbackDays: args.lookback,
|
||||
mode: args.mode,
|
||||
noSwitch: args.noSwitch,
|
||||
bot: args.bot,
|
||||
metricsResult,
|
||||
analysisResult,
|
||||
durationMs: duration,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(async (e) => {
|
||||
console.error(`${ts()} Fatal:`, e);
|
||||
try {
|
||||
await prisma.taskLog.create({
|
||||
data: {
|
||||
taskKey: "bot-persona-experiment-run",
|
||||
taskName: "Bot Persona A/B 调度",
|
||||
status: "failed",
|
||||
startedAt: new Date(),
|
||||
finishedAt: new Date(),
|
||||
triggerBy: "cron",
|
||||
error: (e && e.message ? e.message : String(e)).slice(0, 1000),
|
||||
},
|
||||
});
|
||||
} catch {}
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
await disconnectBotExperiment();
|
||||
});
|
||||
@@ -0,0 +1,411 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||
import { readFileSync } from "fs";
|
||||
import { resolve, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import OpenAI from "openai";
|
||||
import "dotenv/config";
|
||||
import { withRetry } from "./lib/retry.mjs";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const base = process.env.DATABASE_URL.replace("mysql://", "mariadb://");
|
||||
const sep = base.includes("?") ? "&" : "?";
|
||||
const connectionString = `${base}${sep}connection_limit=5&pool_timeout=30`;
|
||||
const adapter = new PrismaMariaDb(connectionString);
|
||||
const prisma = new PrismaClient({ adapter });
|
||||
|
||||
const openai = new OpenAI({
|
||||
apiKey: process.env.DEEPSEEK_API_KEY,
|
||||
baseURL: "https://api.deepseek.com/v1",
|
||||
});
|
||||
const MODEL = process.env.DEEPSEEK_MODEL || "deepseek-chat";
|
||||
|
||||
const BOT_DATA_PATH = resolve(__dirname, "..", "data", "bot-characters.json");
|
||||
|
||||
const CRYSTALLIZE_TRIGGERS = {
|
||||
minReplies: 5,
|
||||
minHumanReplies: 1,
|
||||
minLikes: 3,
|
||||
lookbackDays: 14,
|
||||
maxTopicsPerBot: 8,
|
||||
minTopicsForCrystallize: 2,
|
||||
};
|
||||
|
||||
function ts() {
|
||||
return `[${new Date().toISOString()}]`;
|
||||
}
|
||||
|
||||
function pick(arr) {
|
||||
return arr[Math.floor(Math.random() * arr.length)];
|
||||
}
|
||||
|
||||
async function loadBotCharacters() {
|
||||
const raw = readFileSync(BOT_DATA_PATH, "utf-8");
|
||||
const { characters } = JSON.parse(raw);
|
||||
const map = {};
|
||||
for (const c of characters) {
|
||||
map[c.key] = c;
|
||||
}
|
||||
return { characters, map };
|
||||
}
|
||||
|
||||
function buildCrystallizePrompt(botChar, topicSummaries) {
|
||||
const p = botChar.personality;
|
||||
return `你是一个"成功帖子解构师"。你负责把一个数字人最近发的高互动帖子提炼成可复用的发帖技能。
|
||||
|
||||
[数字人角色]
|
||||
- 名字:【${botChar.displayName}】
|
||||
- 身份:${p.identity}
|
||||
- 立场:${p.stance}
|
||||
- 风格:${p.speakingStyle}
|
||||
- 口头禅:${p.catchphrase}
|
||||
|
||||
[高互动帖子列表]
|
||||
${topicSummaries
|
||||
.map(
|
||||
(t, i) =>
|
||||
`[${i + 1}] 标题:${t.title}\n板块:${t.forumName}\n收到回复:${t.replyCount}(真人${t.humanCount})\n开头:${t.opening}\n要点:${t.bulletPoints}`
|
||||
)
|
||||
.join("\n\n")}
|
||||
|
||||
[任务]
|
||||
请从以上${topicSummaries.length}个高互动帖子中提取共性,输出一个可复用的发帖技能。
|
||||
|
||||
要求:
|
||||
1. 技能名 6-12字,简明扼要
|
||||
2. 钩子模板(开头如何吸引人)100-200字,可以含{{topic}}占位符
|
||||
3. 正文骨架(段落结构+要点清单)150-300字
|
||||
4. 适用场景 30-80字,描述什么类型的话题/事件适合套用
|
||||
5. 必须保持【${botChar.displayName}】的说话风格和立场,不能泛化
|
||||
6. 如果发现明显"反模式"(如"使用了禁止的句式"),在适用场景里反向说明
|
||||
|
||||
[输出格式]
|
||||
只输出严格JSON,不要其他任何内容:
|
||||
{
|
||||
"name": "技能名",
|
||||
"hookPattern": "钩子模板",
|
||||
"bodyTemplate": "正文骨架",
|
||||
"applicability": "适用场景"
|
||||
}`;
|
||||
}
|
||||
|
||||
async function callDeepSeek(prompt) {
|
||||
const response = await withRetry(() =>
|
||||
openai.chat.completions.create({
|
||||
model: MODEL,
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
temperature: 0.7,
|
||||
max_tokens: 1500,
|
||||
})
|
||||
);
|
||||
const text = response.choices[0]?.message?.content?.trim() || "";
|
||||
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
||||
if (!jsonMatch) throw new Error("无法解析JSON: " + text.slice(0, 200));
|
||||
return JSON.parse(jsonMatch[0]);
|
||||
}
|
||||
|
||||
async function getCrystallizedTopicIds(botConfigId) {
|
||||
const skills = await prisma.botSkill.findMany({
|
||||
where: { botId: botConfigId },
|
||||
select: { sourceTopicIds: true },
|
||||
});
|
||||
const ids = new Set();
|
||||
for (const s of skills) {
|
||||
const arr = s.sourceTopicIds;
|
||||
if (Array.isArray(arr)) for (const id of arr) ids.add(id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
async function findQualifiedTopics(botUser, botConfigId) {
|
||||
const since = new Date(Date.now() - CRYSTALLIZE_TRIGGERS.lookbackDays * 24 * 60 * 60 * 1000);
|
||||
const topics = await prisma.forumTopic.findMany({
|
||||
where: {
|
||||
userId: botUser.id,
|
||||
createdAt: { gt: since },
|
||||
},
|
||||
include: {
|
||||
category: { select: { name: true, slug: true } },
|
||||
posts: {
|
||||
include: { user: { select: { isBot: true } } },
|
||||
},
|
||||
},
|
||||
orderBy: { replyCount: "desc" },
|
||||
take: 30,
|
||||
});
|
||||
|
||||
// 拉 topic 下所有回复的真实 likeCount,与 topic.likeCount 合并作为真实点赞
|
||||
const postIds = topics.flatMap((t) => t.posts.map((p) => p.id));
|
||||
const postLikeAgg = postIds.length > 0
|
||||
? await prisma.forumPost.groupBy({
|
||||
by: ["id"],
|
||||
where: { id: { in: postIds } },
|
||||
_sum: { likeCount: true },
|
||||
})
|
||||
: [];
|
||||
const postLikeMap = new Map(postLikeAgg.map((r) => [r.id, r._sum.likeCount || 0]));
|
||||
|
||||
return topics
|
||||
.map((t) => {
|
||||
const humanReplies = t.posts.filter((p) => !p.user.isBot).length;
|
||||
// 真实点赞:topic 自身的 likeCount + 所有回复的 likeCount 之和
|
||||
const postLikes = t.posts.reduce((s, p) => s + (postLikeMap.get(p.id) ?? 0), 0);
|
||||
const realLikeCount = (t.likeCount || 0) + postLikes;
|
||||
return {
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
forumName: t.category.name,
|
||||
forumSlug: t.category.slug,
|
||||
content: t.content,
|
||||
replyCount: t._count?.posts ?? t.posts.length,
|
||||
humanCount: humanReplies,
|
||||
likeCount: realLikeCount,
|
||||
opening: t.content.slice(0, 200),
|
||||
bulletPoints: t.content
|
||||
.split(/[。\n]/)
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 5 && s.length < 60)
|
||||
.slice(0, 5)
|
||||
.join(" / "),
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(t) =>
|
||||
t.replyCount >= CRYSTALLIZE_TRIGGERS.minReplies ||
|
||||
t.humanCount >= CRYSTALLIZE_TRIGGERS.minHumanReplies ||
|
||||
t.likeCount >= CRYSTALLIZE_TRIGGERS.minLikes
|
||||
);
|
||||
}
|
||||
|
||||
async function processBotCrystallize(botChar) {
|
||||
const botUser = await prisma.user.findFirst({
|
||||
where: { email: `bot_${botChar.key}@zhuiguang.ai` },
|
||||
});
|
||||
if (!botUser) return;
|
||||
|
||||
const botConfig = await prisma.botConfig.findUnique({
|
||||
where: { userId: botUser.id },
|
||||
});
|
||||
if (!botConfig) return;
|
||||
|
||||
const alreadyCrystallized = await getCrystallizedTopicIds(botConfig.id);
|
||||
|
||||
const allQualified = await findQualifiedTopics(botUser, botConfig.id);
|
||||
const newQualified = allQualified.filter((t) => !alreadyCrystallized.has(t.id));
|
||||
|
||||
if (newQualified.length < CRYSTALLIZE_TRIGGERS.minTopicsForCrystallize) {
|
||||
if (newQualified.length === 0) return;
|
||||
console.log(
|
||||
`${ts()} ${botChar.displayName}: ${newQualified.length} 个新合格话题 (< ${CRYSTALLIZE_TRIGGERS.minTopicsForCrystallize}),跳过`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const topicsForCrystallize = newQualified.slice(0, CRYSTALLIZE_TRIGGERS.maxTopicsPerBot);
|
||||
console.log(
|
||||
`${ts()} ${botChar.displayName}: ${topicsForCrystallize.length} 个高互动话题待解构`
|
||||
);
|
||||
|
||||
const forumGroups = {};
|
||||
for (const t of topicsForCrystallize) {
|
||||
if (!forumGroups[t.forumSlug]) forumGroups[t.forumSlug] = [];
|
||||
forumGroups[t.forumSlug].push(t);
|
||||
}
|
||||
|
||||
for (const [forumSlug, topics] of Object.entries(forumGroups)) {
|
||||
if (topics.length < CRYSTALLIZE_TRIGGERS.minTopicsForCrystallize) continue;
|
||||
|
||||
try {
|
||||
const prompt = buildCrystallizePrompt(botChar, topics);
|
||||
const result = await callDeepSeek(prompt);
|
||||
|
||||
if (!result.name || !result.hookPattern || !result.bodyTemplate) {
|
||||
console.warn(`${ts()} ${forumSlug} 解构结果字段不全,跳过`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const newSkill = await prisma.botSkill.create({
|
||||
data: {
|
||||
botId: botConfig.id,
|
||||
name: result.name.slice(0, 50),
|
||||
category: forumSlug,
|
||||
hookPattern: result.hookPattern,
|
||||
bodyTemplate: result.bodyTemplate,
|
||||
applicability: result.applicability || null,
|
||||
sourceTopicIds: topics.map((t) => t.id),
|
||||
successRate: 0.5,
|
||||
usageCount: 0,
|
||||
avgReplies: topics.reduce((s, t) => s + t.replyCount, 0) / topics.length,
|
||||
avgLikes: topics.reduce((s, t) => s + t.likeCount, 0) / topics.length,
|
||||
version: 1,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.botMemory.create({
|
||||
data: {
|
||||
botId: botConfig.id,
|
||||
memoryType: "skill_usage",
|
||||
layer: "LONGTERM",
|
||||
content: {
|
||||
action: "crystallize_skill",
|
||||
skillId: newSkill.id,
|
||||
skillName: newSkill.name,
|
||||
category: forumSlug,
|
||||
sourceTopicCount: topics.length,
|
||||
sourceTopicIds: topics.map((t) => t.id),
|
||||
},
|
||||
importance: 0.9,
|
||||
contextTags: {
|
||||
skillId: newSkill.id,
|
||||
forumSlug,
|
||||
source: "skill_crystallize",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.taskLog.create({
|
||||
data: {
|
||||
taskKey: "bot-skill-crystallize",
|
||||
taskName: "Bot技能结晶",
|
||||
status: "success",
|
||||
startedAt: new Date(),
|
||||
finishedAt: new Date(),
|
||||
triggerBy: "cron",
|
||||
result: {
|
||||
botKey: botChar.key,
|
||||
forumSlug,
|
||||
skillId: newSkill.id,
|
||||
skillName: newSkill.name,
|
||||
sourceCount: topics.length,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
console.log(
|
||||
`${ts()} ✅ 结晶 [${forumSlug}] "${newSkill.name}" (来源 ${topics.length} 帖)`
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(`${ts()} ${forumSlug} 结晶失败:`, err.message);
|
||||
await prisma.taskLog.create({
|
||||
data: {
|
||||
taskKey: "bot-skill-crystallize",
|
||||
taskName: "Bot技能结晶",
|
||||
status: "error",
|
||||
startedAt: new Date(),
|
||||
finishedAt: new Date(),
|
||||
triggerBy: "cron",
|
||||
error: err.message,
|
||||
result: { botKey: botChar.key, forumSlug },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 把已经"沉淀"过的技能(基于 sourceTopicIds)回查最近表现:
|
||||
// 1) 找到 sourceTopicIds 中每个话题的最新点赞/回复数
|
||||
// 2) 与技能里记录的 avgReplies/avgLikes 对比,更新 successRate
|
||||
// 3) 对 successRate < 0.2 且 usageCount >= 5 的技能,标记 isActive=false(暂时停用)
|
||||
// 返回被回收/调整的技能数
|
||||
async function recycleSkillOutcomes() {
|
||||
const skills = await prisma.botSkill.findMany({
|
||||
where: { isActive: true },
|
||||
select: { id: true, sourceTopicIds: true, avgReplies: true, avgLikes: true, usageCount: true, successRate: true },
|
||||
});
|
||||
if (skills.length === 0) return 0;
|
||||
|
||||
let adjusted = 0;
|
||||
for (const skill of skills) {
|
||||
const ids = Array.isArray(skill.sourceTopicIds) ? skill.sourceTopicIds : [];
|
||||
if (ids.length === 0) continue;
|
||||
const topics = await prisma.forumTopic.findMany({
|
||||
where: { id: { in: ids } },
|
||||
select: { replyCount: true, likeCount: true },
|
||||
});
|
||||
if (topics.length === 0) continue;
|
||||
const newAvgReplies = topics.reduce((s, t) => s + t.replyCount, 0) / topics.length;
|
||||
const newAvgLikes = topics.reduce((s, t) => s + t.likeCount, 0) / topics.length;
|
||||
// 简单 successRate = 当前平均点赞 / 5,上限 1
|
||||
const newSuccessRate = Math.min(1, newAvgLikes / 5);
|
||||
const update = {
|
||||
avgReplies: newAvgReplies,
|
||||
avgLikes: newAvgLikes,
|
||||
successRate: newSuccessRate,
|
||||
};
|
||||
if (skill.usageCount >= 5 && newSuccessRate < 0.2) {
|
||||
update.isActive = false;
|
||||
}
|
||||
await prisma.botSkill.update({
|
||||
where: { id: skill.id },
|
||||
data: update,
|
||||
});
|
||||
adjusted++;
|
||||
}
|
||||
return adjusted;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`${ts()} Bot Skill Crystallize starting...`);
|
||||
|
||||
if (!process.env.DEEPSEEK_API_KEY) {
|
||||
throw new Error("DEEPSEEK_API_KEY 未配置");
|
||||
}
|
||||
if (!process.env.DATABASE_URL) {
|
||||
throw new Error("DATABASE_URL 未配置");
|
||||
}
|
||||
|
||||
const { characters } = await loadBotCharacters();
|
||||
const botChars = characters.filter((c) => !c.role || c.role !== "passerby");
|
||||
console.log(`${ts()} Loaded ${botChars.length} expert bots.`);
|
||||
|
||||
let processed = 0;
|
||||
for (const botChar of botChars) {
|
||||
try {
|
||||
await processBotCrystallize(botChar);
|
||||
processed++;
|
||||
} catch (err) {
|
||||
console.error(`${ts()} Error processing ${botChar.key}:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`${ts()} Recycle step: 回收成熟技能的实际表现...`);
|
||||
const recycled = await recycleSkillOutcomes();
|
||||
console.log(`${ts()} Recycle done: ${recycled} 技能已回收`);
|
||||
|
||||
await prisma.taskLog.create({
|
||||
data: {
|
||||
taskKey: "bot-skill-crystallize",
|
||||
taskName: "Bot技能结晶",
|
||||
status: "success",
|
||||
startedAt: new Date(),
|
||||
finishedAt: new Date(),
|
||||
triggerBy: "cron",
|
||||
result: { processedBots: processed, recycledSkills: recycled },
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`${ts()} Bot Skill Crystallize done. Processed ${processed} bots, recycled ${recycled} skills.`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(async (e) => {
|
||||
console.error(`${ts()} Fatal error:`, e);
|
||||
try {
|
||||
await prisma.taskLog.create({
|
||||
data: {
|
||||
taskKey: "bot-skill-crystallize",
|
||||
taskName: "Bot技能沉淀",
|
||||
status: "failed",
|
||||
startedAt: new Date(),
|
||||
finishedAt: new Date(),
|
||||
triggerBy: "cron",
|
||||
error: (e && e.message ? e.message : String(e)).slice(0, 1000),
|
||||
},
|
||||
});
|
||||
} catch {}
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
@@ -0,0 +1,311 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||
import { readFileSync } from "fs";
|
||||
import { resolve, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import OpenAI from "openai";
|
||||
import "dotenv/config";
|
||||
import { withRetry } from "./lib/retry.mjs";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const base = process.env.DATABASE_URL.replace("mysql://", "mariadb://");
|
||||
const sep = base.includes("?") ? "&" : "?";
|
||||
const connectionString = `${base}${sep}connection_limit=5&pool_timeout=30`;
|
||||
const adapter = new PrismaMariaDb(connectionString);
|
||||
const prisma = new PrismaClient({ adapter });
|
||||
|
||||
const openai = new OpenAI({
|
||||
apiKey: process.env.DEEPSEEK_API_KEY,
|
||||
baseURL: "https://api.deepseek.com/v1",
|
||||
});
|
||||
const MODEL = process.env.DEEPSEEK_MODEL || "deepseek-chat";
|
||||
|
||||
const BOT_DATA_PATH = resolve(__dirname, "..", "data", "bot-characters.json");
|
||||
|
||||
const ENGAGEMENT_WEIGHTS = {
|
||||
REPLY_PER_TOPIC: 0.4,
|
||||
HUMAN_RATIO: 0.3,
|
||||
LIKES_LOG: 0.3,
|
||||
};
|
||||
|
||||
const TIER_THRESHOLDS = {
|
||||
high: 1.0,
|
||||
medium: 0.5,
|
||||
};
|
||||
|
||||
function ts() {
|
||||
return `[${new Date().toISOString()}]`;
|
||||
}
|
||||
|
||||
function startOfToday() {
|
||||
// 用 UTC 午夜避开 Prisma @db.Date 写入时的时区截断
|
||||
const d = new Date();
|
||||
return new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
|
||||
}
|
||||
|
||||
async function loadBotCharacters() {
|
||||
const raw = readFileSync(BOT_DATA_PATH, "utf-8");
|
||||
const { characters } = JSON.parse(raw);
|
||||
const map = {};
|
||||
for (const c of characters) {
|
||||
map[c.key] = c;
|
||||
}
|
||||
return { characters, map };
|
||||
}
|
||||
|
||||
function calcEngagementScore(stats) {
|
||||
const { topicsCreated, repliesReceived, humanInteractions, likesReceived, repliesSent } = stats;
|
||||
const replyPerTopic = topicsCreated > 0 ? repliesReceived / topicsCreated : 0;
|
||||
const humanRatio =
|
||||
repliesSent + topicsCreated > 0 ? humanInteractions / (repliesSent + topicsCreated) : 0;
|
||||
const likesLog = Math.log(1 + likesReceived);
|
||||
|
||||
return (
|
||||
ENGAGEMENT_WEIGHTS.REPLY_PER_TOPIC * Math.min(replyPerTopic, 5) +
|
||||
ENGAGEMENT_WEIGHTS.HUMAN_RATIO * Math.min(humanRatio, 1) * 5 +
|
||||
ENGAGEMENT_WEIGHTS.LIKES_LOG * Math.min(likesLog, 5)
|
||||
);
|
||||
}
|
||||
|
||||
function decideTier(score, baseTier) {
|
||||
const baseRank = { low: 1, medium: 2, high: 3 }[baseTier] || 2;
|
||||
if (score >= TIER_THRESHOLDS.high) return "high";
|
||||
if (score >= TIER_THRESHOLDS.medium) return baseRank >= 2 ? "medium" : "high";
|
||||
if (score >= TIER_THRESHOLDS.medium * 0.6) return baseRank >= 2 ? "low" : "medium";
|
||||
return "low";
|
||||
}
|
||||
|
||||
function buildReflectionPrompt(botChar, stats, score, nextTier) {
|
||||
const p = botChar.personality;
|
||||
return `你是【${botChar.displayName}】。本周你的论坛表现总结:
|
||||
|
||||
[本周数据]
|
||||
- 发帖 ${stats.topicsCreated} 个
|
||||
- 回复 ${stats.repliesSent} 条
|
||||
- 收到回复 ${stats.repliesReceived} 条
|
||||
- 真人互动 ${stats.humanInteractions} 次
|
||||
- 收到点赞 ${stats.likesReceived} 个
|
||||
- 互动得分 ${score.toFixed(2)}
|
||||
|
||||
[下周配额] ${nextTier}
|
||||
|
||||
[你的角色]
|
||||
${p.identity}
|
||||
风格:${p.speakingStyle}
|
||||
|
||||
[任务]
|
||||
请以第一人称写一段50-120字的本周复盘:
|
||||
1. 数据怎么解读(不要数字罗列,要有你的判断)
|
||||
2. 哪类话题效果好、哪类效果差
|
||||
3. 下周你最想尝试的一个调整是什么
|
||||
|
||||
要求:保持你【${botChar.displayName}】的说话风格,简短口语化。
|
||||
|
||||
只输出JSON:{"reflection": "你的复盘内容"}`;
|
||||
}
|
||||
|
||||
async function callDeepSeek(prompt) {
|
||||
const response = await withRetry(() =>
|
||||
openai.chat.completions.create({
|
||||
model: MODEL,
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
temperature: 0.85,
|
||||
max_tokens: 400,
|
||||
})
|
||||
);
|
||||
const text = response.choices[0]?.message?.content?.trim() || "";
|
||||
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
||||
if (!jsonMatch) throw new Error("无法解析JSON");
|
||||
return JSON.parse(jsonMatch[0]);
|
||||
}
|
||||
|
||||
async function getWeekRange() {
|
||||
const today = startOfToday();
|
||||
const dayOfWeek = today.getUTCDay();
|
||||
// weekStart = today - 6 天(统一用 UTC 午夜)
|
||||
const weekStart = new Date(today);
|
||||
weekStart.setUTCDate(weekStart.getUTCDate() - 6);
|
||||
const weekEnd = today;
|
||||
return { weekStart, weekEnd };
|
||||
}
|
||||
|
||||
async function reviewBot(botChar) {
|
||||
const botUser = await prisma.user.findFirst({
|
||||
where: { email: `bot_${botChar.key}@zhuiguang.ai` },
|
||||
});
|
||||
if (!botUser) return;
|
||||
|
||||
const botConfig = await prisma.botConfig.findUnique({
|
||||
where: { userId: botUser.id },
|
||||
});
|
||||
if (!botConfig) return;
|
||||
|
||||
const { weekStart, weekEnd } = await getWeekRange();
|
||||
|
||||
// weekStart / weekEnd 已经是 UTC 午夜了,无需再 setHours
|
||||
const weekStartDate = new Date(weekStart);
|
||||
const weekEndDate = new Date(weekEnd);
|
||||
|
||||
const stats = await prisma.botDailyStat.aggregate({
|
||||
where: {
|
||||
botId: botConfig.id,
|
||||
date: { gte: weekStartDate, lte: weekEndDate },
|
||||
},
|
||||
_sum: {
|
||||
topicCreated: true,
|
||||
replySent: true,
|
||||
followUpSent: true,
|
||||
repliesReceived: true,
|
||||
likesReceived: true,
|
||||
humanInteractions: true,
|
||||
feedbackProcessed: true,
|
||||
},
|
||||
});
|
||||
|
||||
const sum = stats._sum;
|
||||
const flatStats = {
|
||||
topicsCreated: sum.topicCreated || 0,
|
||||
repliesSent: sum.replySent || 0,
|
||||
repliesReceived: sum.repliesReceived || 0,
|
||||
humanInteractions: sum.humanInteractions || 0,
|
||||
likesReceived: sum.likesReceived || 0,
|
||||
};
|
||||
|
||||
if (flatStats.topicsCreated === 0 && flatStats.repliesSent === 0) {
|
||||
console.log(`${ts()} ${botChar.displayName}: 本周无活动,跳过`);
|
||||
return;
|
||||
}
|
||||
|
||||
const score = calcEngagementScore(flatStats);
|
||||
const baseTier = botChar.activityLevel || "medium";
|
||||
const nextTier = decideTier(score, baseTier);
|
||||
|
||||
let reflection = null;
|
||||
try {
|
||||
const prompt = buildReflectionPrompt(botChar, flatStats, score, nextTier);
|
||||
const result = await callDeepSeek(prompt);
|
||||
reflection = (result.reflection || "").trim().slice(0, 500) || null;
|
||||
} catch (err) {
|
||||
console.warn(`${ts()} ${botChar.displayName} 反思生成失败:`, err.message);
|
||||
}
|
||||
|
||||
const review = await prisma.botWeeklyReview.upsert({
|
||||
where: { botId_weekStart: { botId: botConfig.id, weekStart: weekStartDate } },
|
||||
create: {
|
||||
botId: botConfig.id,
|
||||
weekStart: weekStartDate,
|
||||
weekEnd: weekEndDate,
|
||||
topicsCreated: flatStats.topicsCreated,
|
||||
repliesSent: flatStats.repliesSent,
|
||||
repliesReceived: flatStats.repliesReceived,
|
||||
humanRatio:
|
||||
flatStats.repliesSent + flatStats.topicsCreated > 0
|
||||
? flatStats.humanInteractions / (flatStats.repliesSent + flatStats.topicsCreated)
|
||||
: 0,
|
||||
likesReceived: flatStats.likesReceived,
|
||||
engagementScore: score,
|
||||
nextWeekQuota: nextTier,
|
||||
reflection,
|
||||
},
|
||||
update: {
|
||||
weekEnd: weekEndDate,
|
||||
topicsCreated: flatStats.topicsCreated,
|
||||
repliesSent: flatStats.repliesSent,
|
||||
repliesReceived: flatStats.repliesReceived,
|
||||
humanRatio:
|
||||
flatStats.repliesSent + flatStats.topicsCreated > 0
|
||||
? flatStats.humanInteractions / (flatStats.repliesSent + flatStats.topicsCreated)
|
||||
: 0,
|
||||
likesReceived: flatStats.likesReceived,
|
||||
engagementScore: score,
|
||||
nextWeekQuota: nextTier,
|
||||
reflection,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.botMemory.create({
|
||||
data: {
|
||||
botId: botConfig.id,
|
||||
memoryType: "reflection",
|
||||
layer: "WORKING",
|
||||
content: {
|
||||
action: "weekly_review",
|
||||
score,
|
||||
topicsCreated: flatStats.topicsCreated,
|
||||
repliesReceived: flatStats.repliesReceived,
|
||||
humanInteractions: flatStats.humanInteractions,
|
||||
likesReceived: flatStats.likesReceived,
|
||||
nextTier,
|
||||
reflection,
|
||||
},
|
||||
importance: 0.7,
|
||||
contextTags: {
|
||||
reviewId: review.id,
|
||||
weekStart: weekStartDate.toISOString().slice(0, 10),
|
||||
source: "weekly_review",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
console.log(
|
||||
`${ts()} ${botChar.displayName}: 得分 ${score.toFixed(2)} | ${baseTier} → ${nextTier} | 帖${flatStats.topicsCreated}/收${flatStats.repliesReceived}/真人${flatStats.humanInteractions}`
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`${ts()} Bot Weekly Review starting...`);
|
||||
|
||||
if (!process.env.DEEPSEEK_API_KEY) {
|
||||
throw new Error("DEEPSEEK_API_KEY 未配置");
|
||||
}
|
||||
|
||||
const { characters } = await loadBotCharacters();
|
||||
const botChars = characters.filter((c) => !c.role || c.role !== "passerby");
|
||||
console.log(`${ts()} Loaded ${botChars.length} expert bots.`);
|
||||
|
||||
let processed = 0;
|
||||
for (const botChar of botChars) {
|
||||
try {
|
||||
await reviewBot(botChar);
|
||||
processed++;
|
||||
} catch (err) {
|
||||
console.error(`${ts()} Error processing ${botChar.key}:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.taskLog.create({
|
||||
data: {
|
||||
taskKey: "bot-weekly-review",
|
||||
taskName: "Bot周度复盘",
|
||||
status: "success",
|
||||
startedAt: new Date(),
|
||||
finishedAt: new Date(),
|
||||
triggerBy: "cron",
|
||||
result: { processedBots: processed },
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`${ts()} Bot Weekly Review done. Processed ${processed} bots.`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(async (e) => {
|
||||
console.error(`${ts()} Fatal error:`, e);
|
||||
try {
|
||||
await prisma.taskLog.create({
|
||||
data: {
|
||||
taskKey: "bot-weekly-review",
|
||||
taskName: "Bot周复盘",
|
||||
status: "failed",
|
||||
startedAt: new Date(),
|
||||
finishedAt: new Date(),
|
||||
triggerBy: "cron",
|
||||
error: (e && e.message ? e.message : String(e)).slice(0, 1000),
|
||||
},
|
||||
});
|
||||
} catch {}
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||
import "dotenv/config";
|
||||
|
||||
const base = process.env.DATABASE_URL.replace("mysql://", "mariadb://");
|
||||
const cs = base + (base.includes("?") ? "&" : "?") + "connection_limit=5";
|
||||
const prisma = new PrismaClient({ adapter: new PrismaMariaDb(cs) });
|
||||
|
||||
// 模拟API的查询
|
||||
const allTopLevel = await prisma.forumCategory.findMany({
|
||||
where: { parentId: null },
|
||||
include: {
|
||||
children: {
|
||||
orderBy: [{ sortOrder: "asc" }],
|
||||
},
|
||||
},
|
||||
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
|
||||
});
|
||||
|
||||
console.log("=== API查询结果 (parentId=null + children) ===");
|
||||
for (const cat of allTopLevel) {
|
||||
console.log(`\n[${cat.slug}] ${cat.name} topicCount=${cat.topicCount}`);
|
||||
for (const child of cat.children) {
|
||||
console.log(` └─ [${child.slug}] ${child.name} topicCount=${child.topicCount} (子板块数: ?)`);
|
||||
}
|
||||
}
|
||||
|
||||
// 看看community页面查询的industry
|
||||
console.log("\n\n=== industry板块 children话题统计 ===");
|
||||
const industry = await prisma.forumCategory.findUnique({
|
||||
where: { slug: "industry" },
|
||||
include: { children: true }
|
||||
});
|
||||
if (industry) {
|
||||
for (const c of industry.children) {
|
||||
const subCount = await prisma.forumCategory.count({ where: { parentId: c.id } });
|
||||
const subWithTopic = await prisma.forumCategory.findMany({ where: { parentId: c.id }, select: { name: true, topicCount: true } });
|
||||
console.log(` ${c.name} (${c.slug}) 子板块数=${subCount}:`);
|
||||
for (const s of subWithTopic) {
|
||||
console.log(` - ${s.name}: ${s.topicCount}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.$disconnect();
|
||||
@@ -0,0 +1,48 @@
|
||||
import urllib.request
|
||||
import re
|
||||
import json
|
||||
|
||||
req = urllib.request.Request('https://www.zhuig.com/community', headers={'User-Agent': 'Mozilla/5.0', 'Cache-Control': 'no-cache'})
|
||||
html = urllib.request.urlopen(req, timeout=15).read().decode('utf-8')
|
||||
|
||||
# 提取所有<a>标签里的href和text: <a href="/community/xxx" ... >板块名</a>
|
||||
# 但HTML结构可能嵌套。用BeautifulSoup风格的解析
|
||||
# 找所有板块链接
|
||||
pattern = re.compile(r'<a[^>]*href="/community/([a-zA-Z0-9_-]+)"[^>]*>(.*?)</a>', re.DOTALL)
|
||||
matches = pattern.findall(html)
|
||||
print(f'找到 {len(matches)} 个链接')
|
||||
|
||||
# 提取每个slug对应的板块名(去HTML标签)
|
||||
slug_to_names = {}
|
||||
for slug, text in matches:
|
||||
# 去掉HTML标签
|
||||
clean = re.sub(r'<[^>]+>', '', text).strip()
|
||||
if clean and len(clean) < 20:
|
||||
slug_to_names.setdefault(slug, []).append(clean)
|
||||
|
||||
print('\n=== 全部板块slug与显示名 ===')
|
||||
for slug in sorted(slug_to_names.keys()):
|
||||
names = slug_to_names[slug]
|
||||
print(f' {slug:30s} → {names}')
|
||||
|
||||
# 对比数据库/API
|
||||
api_req = urllib.request.Request('https://www.zhuig.com/api/forum/categories', headers={'Cache-Control': 'no-cache'})
|
||||
api_data = json.loads(urllib.request.urlopen(api_req, timeout=10).read().decode('utf-8'))
|
||||
api_slugs = []
|
||||
for c in api_data['categories']:
|
||||
api_slugs.append((c['slug'], c['name'], '顶层'))
|
||||
for child in c.get('children', []):
|
||||
api_slugs.append((child['slug'], child['name'], ' ├─'))
|
||||
for gc in child.get('children', []):
|
||||
api_slugs.append((gc['slug'], gc['name'], ' └─'))
|
||||
|
||||
print(f'\n=== API板块数: {len(api_slugs)} ===')
|
||||
# 看哪些是新版slug
|
||||
new_design_slugs = ['ec-platform', 'ec-livestream', 'ec-supply', 'ec-dtc', 'ec-private', 'ec-group', 'ai-tools-app', 'ai-llm', 'ai-startup-forum', 'ai-saas', 'ai-hardware', 'fd-restaurant', 'fd-tea', 'fd-prepared', 'fd-supply', 'fd-delivery', 're-residential', 're-commercial', 're-renovation', 're-property', 'fi-stock', 'fi-insurance', 'fi-pe', 'fi-forex', 'fi-crypto', 'ct-writing', 'ct-short-video', 'ct-live', 'ct-mcn', 'ct-podcast', 'll-beauty', 'll-fitness', 'll-pet', 'll-housekeeping', 'll-repair', 'll-edu-local', 'hl-cosmetic', 'hl-wellness', 'hl-elderly', 'hl-mental', 'hl-rehab', 'edu-knowledge', 'edu-skills', 'edu-abroad', 'edu-corporate', 'cb-ecommerce', 'cb-factory', 'cb-logistics', 'cb-payment', 'cb-brand']
|
||||
|
||||
api_slug_list = [s[0] for s in api_slugs]
|
||||
in_api = [s for s in new_design_slugs if s in api_slug_list]
|
||||
not_in_api = [s for s in new_design_slugs if s not in api_slug_list]
|
||||
print(f'\n新设计子板块: 总{len(new_design_slugs)}, API中有{len(in_api)}, 缺失{len(not_in_api)}')
|
||||
if not_in_api:
|
||||
print(f'缺失: {not_in_api}')
|
||||
@@ -0,0 +1,18 @@
|
||||
import { readFileSync } from "fs";
|
||||
|
||||
const scripts = [
|
||||
"task3-check-tools.mjs",
|
||||
"task5-update-stars.mjs",
|
||||
"task6-review-hot.mjs",
|
||||
"daily-news.mjs",
|
||||
"task7-news-to-community.mjs",
|
||||
"daily-discover.mjs",
|
||||
];
|
||||
|
||||
for (const s of scripts) {
|
||||
const c = readFileSync("scripts/" + s, "utf-8");
|
||||
const hasFail = c.includes('"failed"');
|
||||
const hasCatch = c.includes(".catch(");
|
||||
const hasStartLog = c.includes("taskLog.create") || c.includes("logTask(");
|
||||
console.log(`${s.padEnd(35)} catch=${hasCatch} failLog=${hasFail} startLog=${hasStartLog}`);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||
import "dotenv/config";
|
||||
|
||||
const base = process.env.DATABASE_URL.replace("mysql://", "mariadb://");
|
||||
const cs = base + (base.includes("?") ? "&" : "?") + "connection_limit=5";
|
||||
const prisma = new PrismaClient({ adapter: new PrismaMariaDb(cs) });
|
||||
|
||||
const total = await prisma.forumTopic.count();
|
||||
const last10 = await prisma.forumTopic.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 10,
|
||||
include: { category: { select: { name: true, slug: true } } }
|
||||
});
|
||||
const catCount = await prisma.forumCategory.count();
|
||||
console.log("总话题数:", total);
|
||||
console.log("总板块数:", catCount);
|
||||
console.log("\n最近10个话题:");
|
||||
for (const t of last10) {
|
||||
console.log(" [" + t.createdAt.toISOString() + "]", t.category?.name || "无板块", "|", t.title.substring(0, 50));
|
||||
}
|
||||
const oneDayAgo = new Date(Date.now() - 24 * 3600 * 1000);
|
||||
const recent = await prisma.forumTopic.count({ where: { createdAt: { gt: oneDayAgo } } });
|
||||
console.log("\n过去24小时新话题:", recent);
|
||||
|
||||
const byCat = await prisma.forumCategory.findMany({
|
||||
include: { _count: { select: { topics: true } } },
|
||||
orderBy: { sortOrder: "asc" }
|
||||
});
|
||||
console.log("\n各板块话题数:");
|
||||
for (const c of byCat) {
|
||||
if (c._count.topics > 0) console.log(" " + c.name + ": " + c._count.topics);
|
||||
}
|
||||
const empty = byCat.filter(c => c._count.topics === 0);
|
||||
console.log("\n空板块数:", empty.length);
|
||||
if (empty.length > 0 && empty.length < 10) {
|
||||
console.log("空板块列表:", empty.map(c => c.name).join(", "));
|
||||
}
|
||||
|
||||
await prisma.$disconnect();
|
||||
@@ -0,0 +1,33 @@
|
||||
import urllib.request
|
||||
|
||||
req = urllib.request.Request('https://www.zhuig.com/community', headers={'User-Agent': 'Mozilla/5.0 (iPhone)', 'Cache-Control': 'no-cache', 'Pragma': 'no-cache'})
|
||||
html = urllib.request.urlopen(req, timeout=15).read().decode('utf-8')
|
||||
|
||||
# 保存完整HTML
|
||||
with open('/tmp/comm-fresh.html', 'w', encoding='utf-8') as f:
|
||||
f.write(html)
|
||||
|
||||
# 提取构建ID
|
||||
import re
|
||||
build_id = re.search(r'BUILD_ID["\s:]+["\']([^"\']+)', html)
|
||||
print(f'HTML size: {len(html)}')
|
||||
print(f'BUILD_ID: {build_id.group(1) if build_id else "N/A"}')
|
||||
|
||||
# 提取所有 _next/static 资源链接
|
||||
static_links = sorted(set(re.findall(r'/_next/static/([^"\']+)', html)))
|
||||
print(f'\n静态资源: {len(static_links)}个')
|
||||
for s in static_links[:15]:
|
||||
print(f' /_next/static/{s}')
|
||||
|
||||
# 检查"子板块是否在HTML里"
|
||||
new_subcats = ['ec-platform', 'ec-livestream', 'ec-supply', 'ec-dtc', 'ec-private', 'ec-group', 'ai-tools-app', 'ai-llm', 'ai-startup-forum', 'ai-saas', 'ai-hardware']
|
||||
for s in new_subcats:
|
||||
if s in html:
|
||||
# 找包含这个slug的整段
|
||||
idx = html.find(s)
|
||||
ctx = html[max(0, idx-50):idx+150]
|
||||
ctx_clean = re.sub(r'<[^>]+>', ' ', ctx)
|
||||
ctx_clean = re.sub(r'\s+', ' ', ctx_clean)
|
||||
print(f'\n [{s}] 出现: ...{ctx_clean[:200]}...')
|
||||
else:
|
||||
print(f'\n [{s}] NOT FOUND')
|
||||
@@ -0,0 +1,10 @@
|
||||
import re
|
||||
with open('/tmp/comm.html', encoding='utf-8') as f:
|
||||
html = f.read()
|
||||
|
||||
# 查找板块名
|
||||
names = re.findall(r'>([^<>]{2,15})</a>', html)
|
||||
keywords = ['赚钱', '副业', '电商', 'AI', '金融', '房产', '餐饮', '健康', '教育', '内容', '本地', '跨境', '求职']
|
||||
for n in names:
|
||||
if any(k in n for k in keywords) and len(n) < 20:
|
||||
print(repr(n))
|
||||
@@ -0,0 +1,27 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||
import "dotenv/config";
|
||||
|
||||
const base = process.env.DATABASE_URL.replace("mysql://", "mariadb://");
|
||||
const cs = base + (base.includes("?") ? "&" : "?") + "connection_limit=5";
|
||||
const prisma = new PrismaClient({ adapter: new PrismaMariaDb(cs) });
|
||||
|
||||
// 看industry板块
|
||||
const ind = await prisma.forumCategory.findUnique({ where: { slug: "industry" } });
|
||||
console.log('industry板块:', ind ? `id=${ind.id}, name=${ind.name}, parentId=${ind.parentId}` : 'NULL');
|
||||
|
||||
// 用page.tsx同样的查询
|
||||
const children = await prisma.forumCategory.findMany({
|
||||
where: { parentId: ind?.id ?? undefined },
|
||||
include: { children: { orderBy: [{ sortOrder: "asc" }] } },
|
||||
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
|
||||
});
|
||||
console.log(`\n父板块数: ${children.length}`);
|
||||
let total = 0;
|
||||
for (const c of children) {
|
||||
console.log(` [${c.slug}] ${c.name} (子板块: ${c.children.length})`);
|
||||
total += c.children.length;
|
||||
}
|
||||
console.log(`\n子板块总数: ${total}`);
|
||||
console.log(`page.tsx期待: categories.length + totalSubCategories = ${children.length} + ${total} = ${children.length + total}`);
|
||||
await prisma.$disconnect();
|
||||
@@ -0,0 +1,33 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||
import "dotenv/config";
|
||||
|
||||
const base = process.env.DATABASE_URL.replace("mysql://", "mariadb://");
|
||||
const cs = base + (base.includes("?") ? "&" : "?") + "connection_limit=5";
|
||||
const prisma = new PrismaClient({ adapter: new PrismaMariaDb(cs) });
|
||||
|
||||
// 列出所有父板块(parentId=null)
|
||||
const parents = await prisma.forumCategory.findMany({
|
||||
where: { parentId: null },
|
||||
orderBy: { sortOrder: "asc" }
|
||||
});
|
||||
console.log("=== 父板块(parentId=null) ===");
|
||||
for (const p of parents) {
|
||||
const childCount = await prisma.forumCategory.count({ where: { parentId: p.id } });
|
||||
const topicCount = await prisma.forumTopic.count({ where: { categoryId: p.id } });
|
||||
console.log(` [${p.id}] ${p.slug} - ${p.name} (子板块:${childCount} 话题:${topicCount})`);
|
||||
}
|
||||
|
||||
// 列出所有"ec-platform"类的新设计子板块
|
||||
const newSlugs = ["ec-platform", "ec-livestream", "ai-tools-app", "ai-llm", "fi-stock", "re-residential", "fd-restaurant", "ll-beauty", "hl-cosmetic", "edu-k12"];
|
||||
console.log("\n=== 新设计子板块状态 ===");
|
||||
for (const slug of newSlugs) {
|
||||
const c = await prisma.forumCategory.findUnique({ where: { slug } });
|
||||
if (c) {
|
||||
console.log(` ✓ ${slug} - ${c.name} | parentId=${c.parentId} | topics=${c.topicCount}`);
|
||||
} else {
|
||||
console.log(` ✗ ${slug} 不存在`);
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.$disconnect();
|
||||
@@ -0,0 +1,41 @@
|
||||
import re
|
||||
import urllib.request
|
||||
|
||||
req = urllib.request.Request('https://www.zhuig.com/community', headers={'User-Agent': 'Mozilla/5.0', 'Cache-Control': 'no-cache'})
|
||||
html = urllib.request.urlopen(req, timeout=15).read().decode('utf-8')
|
||||
|
||||
# 找所有 <a> 链接的位置
|
||||
all_a = list(re.finditer(r'<a[^>]*href="(/community/[^"]+)"', html))
|
||||
print(f'整页 <a href="/community/..."> 总数: {len(all_a)}')
|
||||
|
||||
# 看这些链接在HTML中的位置分布
|
||||
positions = [m.start() for m in all_a]
|
||||
print(f'位置: {positions[:5]}...{positions[-5:] if len(positions) > 5 else ""}')
|
||||
|
||||
# 找 "全部板块" 位置
|
||||
idx_quanbu = html.find('全部板块')
|
||||
idx_zuixin = html.find('最新话题')
|
||||
idx_luntan = html.find('论坛')
|
||||
print(f'\n全部板块位置: {idx_quanbu}')
|
||||
print(f'最新话题位置: {idx_zuixin}')
|
||||
print(f'论坛位置: {idx_luntan}')
|
||||
|
||||
# 看 "全部板块" 之前有多少板块链接(即头部导航/侧边栏的)
|
||||
before_count = sum(1 for p in positions if p < idx_quanbu)
|
||||
in_section_count = sum(1 for p in positions if idx_quanbu < p < idx_zuixin)
|
||||
after_count = sum(1 for p in positions if p > idx_zuixin)
|
||||
print(f' "全部板块"之前: {before_count}个链接')
|
||||
print(f' "全部板块"区域内: {in_section_count}个链接')
|
||||
print(f' "最新话题"之后: {after_count}个链接')
|
||||
|
||||
# 找"全部板块"和"最新话题"之间的HTML
|
||||
section = html[idx_quanbu:idx_zuixin] if idx_zuixin > 0 else ""
|
||||
print(f'\n板块区域HTML大小: {len(section)}')
|
||||
# 看里面有没有h3标题
|
||||
h3_count = len(re.findall(r'<h3', section))
|
||||
print(f'板块区域 <h3> 标签数: {h3_count}')
|
||||
# 找h3的内容
|
||||
for m in re.finditer(r'<h3[^>]*>(.*?)</h3>', section, re.DOTALL):
|
||||
text = re.sub(r'<[^>]+>', '', m.group(1)).strip()
|
||||
if text:
|
||||
print(f' <h3>: {text}')
|
||||
@@ -0,0 +1,7 @@
|
||||
import re
|
||||
with open('/tmp/comm.html', encoding='utf-8') as f:
|
||||
html = f.read()
|
||||
slugs = sorted(set(re.findall(r'/community/([a-zA-Z0-9_-]+)', html)))
|
||||
print('板块slug数:', len(slugs))
|
||||
for s in slugs:
|
||||
print(' ', s)
|
||||
@@ -0,0 +1,56 @@
|
||||
// 清理 30 天前的 task_log 数据,避免表无限膨胀
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||
import "dotenv/config";
|
||||
|
||||
const base = process.env.DATABASE_URL.replace("mysql://", "mariadb://");
|
||||
const sep = base.includes("?") ? "&" : "?";
|
||||
const connectionString = `${base}${sep}connection_limit=2&pool_timeout=10`;
|
||||
const adapter = new PrismaMariaDb(connectionString);
|
||||
const prisma = new PrismaClient({ adapter });
|
||||
|
||||
const RETENTION_DAYS = 30;
|
||||
|
||||
async function main() {
|
||||
const startedAt = new Date();
|
||||
const cutoff = new Date(Date.now() - RETENTION_DAYS * 24 * 60 * 60 * 1000);
|
||||
const before = await prisma.taskLog.count();
|
||||
const { count } = await prisma.taskLog.deleteMany({
|
||||
where: { startedAt: { lt: cutoff } },
|
||||
});
|
||||
const after = await prisma.taskLog.count();
|
||||
console.log(`清理完成:删除 ${count} 条,剩 ${after} 条 (清理前 ${before})`);
|
||||
|
||||
await prisma.taskLog.create({
|
||||
data: {
|
||||
taskKey: "cleanup-task-logs",
|
||||
taskName: "清理任务日志",
|
||||
status: "success",
|
||||
startedAt,
|
||||
finishedAt: new Date(),
|
||||
duration: Date.now() - startedAt.getTime(),
|
||||
triggerBy: "cron",
|
||||
result: { deleted: count, remaining: after, cutoff: cutoff.toISOString() },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(async (e) => {
|
||||
console.error(e);
|
||||
try {
|
||||
await prisma.taskLog.create({
|
||||
data: {
|
||||
taskKey: "cleanup-task-logs",
|
||||
taskName: "清理任务日志",
|
||||
status: "failed",
|
||||
startedAt: new Date(),
|
||||
finishedAt: new Date(),
|
||||
triggerBy: "cron",
|
||||
error: (e && e.message ? e.message : String(e)).slice(0, 1000),
|
||||
},
|
||||
});
|
||||
} catch {}
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
@@ -5,7 +5,9 @@ import OpenAI from "openai";
|
||||
import "dotenv/config";
|
||||
import { withRetry } from "./lib/retry.mjs";
|
||||
|
||||
const base = (process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4").replace("mysql://", "mariadb://");
|
||||
const rawUrl = process.env.DATABASE_URL;
|
||||
if (!rawUrl) { console.error("DATABASE_URL not set"); process.exit(1); }
|
||||
const base = rawUrl.replace("mysql://", "mariadb://");
|
||||
const sep = base.includes("?") ? "&" : "?";
|
||||
const connectionString = `${base}${sep}connection_limit=20&pool_timeout=30`;
|
||||
const adapter = new PrismaMariaDb(connectionString);
|
||||
@@ -18,6 +20,8 @@ const octokit = new Octokit({
|
||||
const openai = new OpenAI({
|
||||
apiKey: process.env.DEEPSEEK_API_KEY,
|
||||
baseURL: "https://api.deepseek.com/v1",
|
||||
timeout: 120000,
|
||||
maxRetries: 2,
|
||||
});
|
||||
|
||||
const REVIEW_PROMPT = `你是一个GitHub开源项目评测专家。请基于以下项目信息进行五维深度评测,并以JSON格式返回。务必只返回JSON,不要包含任何其他文字或Markdown格式。
|
||||
|
||||
@@ -4,7 +4,9 @@ import OpenAI from "openai";
|
||||
import "dotenv/config";
|
||||
import { withRetry } from "./lib/retry.mjs";
|
||||
|
||||
const base = (process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4").replace("mysql://", "mariadb://");
|
||||
const rawUrl = process.env.DATABASE_URL;
|
||||
if (!rawUrl) { console.error("DATABASE_URL not set"); process.exit(1); }
|
||||
const base = rawUrl.replace("mysql://", "mariadb://");
|
||||
const sep = base.includes("?") ? "&" : "?";
|
||||
const connectionString = `${base}${sep}connection_limit=20&pool_timeout=30`;
|
||||
const adapter = new PrismaMariaDb(connectionString);
|
||||
@@ -13,6 +15,8 @@ const prisma = new PrismaClient({ adapter });
|
||||
const openai = new OpenAI({
|
||||
apiKey: process.env.DEEPSEEK_API_KEY,
|
||||
baseURL: "https://api.deepseek.com/v1",
|
||||
timeout: 120000,
|
||||
maxRetries: 2,
|
||||
});
|
||||
|
||||
async function fetchRSS(rssUrl, source) {
|
||||
@@ -390,4 +394,7 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
main()
|
||||
.then(() => { console.log("Done"); process.exit(0); })
|
||||
.catch((e) => { console.error(e); process.exit(1); })
|
||||
.finally(() => prisma.$disconnect());
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import re
|
||||
import urllib.request
|
||||
|
||||
req = urllib.request.Request('https://www.zhuig.com/community', headers={'User-Agent': 'Mozilla/5.0', 'Cache-Control': 'no-cache'})
|
||||
html = urllib.request.urlopen(req, timeout=15).read().decode('utf-8')
|
||||
|
||||
idx_q = html.find('全部板块')
|
||||
idx_z = html.find('最新话题')
|
||||
section = html[idx_q:idx_z] if idx_z > 0 else html[idx_q:idx_q+15000]
|
||||
|
||||
# 找 dangerous 内容
|
||||
print(f'板块区域长度: {len(section)}')
|
||||
print(f'\n板块区域前500字符:')
|
||||
print(section[:500])
|
||||
print(f'\n...')
|
||||
print(f'\n板块区域后200字符:')
|
||||
print(section[-200:])
|
||||
|
||||
# 找<a 出现
|
||||
real_a = re.findall(r'<a\s', section)
|
||||
print(f'\n真实<a>数: {len(real_a)}')
|
||||
# 找href="/community/
|
||||
href_a = re.findall(r'href="/community/[^"]+"', section)
|
||||
print(f'href="/community/... 出现数: {len(href_a)}')
|
||||
# 找RSC $ a
|
||||
rsc_a = re.findall(r'\\"a\\"', section)
|
||||
print(f'RSC \\"a\\" 组件数: {len(rsc_a)}')
|
||||
@@ -0,0 +1,33 @@
|
||||
import re
|
||||
import urllib.request
|
||||
|
||||
req = urllib.request.Request('https://www.zhuig.com/community', headers={'User-Agent': 'Mozilla/5.0', 'Cache-Control': 'no-cache'})
|
||||
html = urllib.request.urlopen(req, timeout=15).read().decode('utf-8')
|
||||
|
||||
# 板块区域附近
|
||||
idx_q = html.find('全部板块')
|
||||
idx_z = html.find('最新话题')
|
||||
section = html[idx_q:idx_z] if idx_z > 0 else html[idx_q:idx_q+15000]
|
||||
|
||||
# 找"<a " 出现位置(不是RSC payload里的"a"组件,是真实HTML)
|
||||
real_a = re.findall(r'<a\s', section)
|
||||
real_a_in_quotes = re.findall(r'\\"a\\",', section)
|
||||
print(f'真实<a>标签: {len(real_a)}')
|
||||
print(f'RSC \\"a\\" 组件: {len(real_a_in_quotes)}')
|
||||
|
||||
# 看section里"$" RSC payload 数量
|
||||
dollar_count = section.count('"$"')
|
||||
print(f'RSC payload "$" 数量: {dollar_count}')
|
||||
|
||||
# 找第一个 RSC "a" 组件
|
||||
m = re.search(r'\["\$","a",[^]]+\]', section)
|
||||
if m:
|
||||
print(f'\n第一个RSC a组件示例:\n{m.group()[:500]}')
|
||||
|
||||
# 看板块区域最后部分(应该是RSC结束+可能HTML)
|
||||
print(f'\n板块区域最后200字符:')
|
||||
print(section[-300:])
|
||||
|
||||
# 板块区域有多少个 [$,"a" 出现
|
||||
rsc_a = re.findall(r'\["\$","a"', section)
|
||||
print(f'\nRSC "a" 组件数: {len(rsc_a)}')
|
||||
@@ -0,0 +1,29 @@
|
||||
import re
|
||||
import urllib.request
|
||||
|
||||
req = urllib.request.Request('https://www.zhuig.com/community', headers={'User-Agent': 'Mozilla/5.0', 'Cache-Control': 'no-cache'})
|
||||
html = urllib.request.urlopen(req, timeout=15).read().decode('utf-8')
|
||||
|
||||
idx_q = html.find('全部板块')
|
||||
idx_z = html.find('最新话题')
|
||||
section = html[idx_q:idx_z] if idx_z > 0 else html[idx_q:idx_q+15000]
|
||||
|
||||
# 找"电商零售"在section中的位置
|
||||
kw_pos = section.find('电商零售')
|
||||
print(f'电商零售在section位置: {kw_pos}')
|
||||
if kw_pos >= 0:
|
||||
print(f'周围300字符: {section[max(0,kw_pos-100):kw_pos+200]}')
|
||||
|
||||
# 找全部的板块数据标识 - slug 出现的位置
|
||||
slugs = ['ec-platform', 'ec-livestream', 'ec-supply', 'ec-dtc', 'ec-private', 'ec-group', 'ai-tools-app', 'ai-llm', 'ai-startup-forum', 'ai-saas', 'ai-hardware']
|
||||
print(f'\n=== 各slug在section中是否出现 ===')
|
||||
for s in slugs:
|
||||
p = section.find(f'"{s}"')
|
||||
p2 = section.find(f'/{s}')
|
||||
print(f' {s:25s} "slug"-模式:{p} /slug-模式:{p2}')
|
||||
|
||||
# 找父板块的name - "电商零售" 看看上下文
|
||||
print(f'\n=== 在section里查"电商零售"上下文 ===')
|
||||
if '电商零售' in section:
|
||||
idx = section.find('电商零售')
|
||||
print(section[max(0,idx-50):idx+300])
|
||||
@@ -0,0 +1,37 @@
|
||||
#!/bin/sh
|
||||
# =============================================================================
|
||||
# 追光AI APP 容器启动入口
|
||||
# - 校验 .env
|
||||
# - 跑 prisma migrate deploy(生产环境,安全前向迁移)
|
||||
# - 启动 next start
|
||||
# =============================================================================
|
||||
set -e
|
||||
|
||||
cd /app
|
||||
|
||||
# Seed data volume if empty
|
||||
if [ ! -f /app/data/bot-characters.json ] && [ -d /app/data-seed ]; then
|
||||
echo "[entrypoint] Seeding data volume..."
|
||||
cp -r /app/data-seed/* /app/data/
|
||||
fi
|
||||
|
||||
# 1) .env 校验
|
||||
if [ ! -f /app/.env ]; then
|
||||
echo "[entrypoint-app] FATAL: /app/.env not found. Mount .env file into container."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 让 .env 对 next start 可见(Next.js 14 在 production 下也会读 .env)
|
||||
set -a
|
||||
. /app/.env
|
||||
set +a
|
||||
|
||||
# 2) 跑迁移(幂等;不阻塞启动)
|
||||
echo "[entrypoint-app] Running prisma migrate deploy..."
|
||||
npx prisma migrate deploy --schema=prisma/schema.prisma 2>&1 | tail -20 || {
|
||||
echo "[entrypoint-app] WARN: migrate failed, continuing (DB schema may be ahead of migrations)"
|
||||
}
|
||||
|
||||
# 3) 启动 Next.js
|
||||
echo "[entrypoint-app] Starting next start on port ${PORT:-8301}..."
|
||||
exec node_modules/.bin/next start --port ${PORT:-8301} --hostname 0.0.0.0
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/bin/sh
|
||||
# =============================================================================
|
||||
# 追光AI CRON 容器启动入口
|
||||
# - 用 supercronic 调度 /app/crontab.txt 里的任务
|
||||
# - 跟 host cron 行为一致,但完全在容器内
|
||||
# =============================================================================
|
||||
set -e
|
||||
|
||||
cd /app
|
||||
|
||||
# Seed data volume if empty
|
||||
if [ ! -f /app/data/bot-characters.json ] && [ -d /app/data-seed ]; then
|
||||
echo "[entrypoint] Seeding data volume..."
|
||||
cp -r /app/data-seed/* /app/data/
|
||||
fi
|
||||
|
||||
# 1) .env 校验
|
||||
if [ ! -f /app/.env ]; then
|
||||
echo "[entrypoint-cron] FATAL: /app/.env not found. Mount .env file into container."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2) crontab 文件校验
|
||||
if [ ! -f /app/crontab.txt ]; then
|
||||
echo "[entrypoint-cron] FATAL: /app/crontab.txt not found. Mount crontab into container."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 3) supercronic 启动
|
||||
echo "[entrypoint-cron] Starting supercronic..."
|
||||
echo "[entrypoint-cron] crontab:"
|
||||
sed 's/^/ /' /app/crontab.txt
|
||||
|
||||
# -prometheus-listen-address 可选 (开监控)
|
||||
# -split-logs 把 stdout/stderr 拆开
|
||||
exec /usr/local/bin/supercronic-linux-amd64 \
|
||||
-prometheus-listen-address 0.0.0.0:8310 \
|
||||
/app/crontab.txt
|
||||
@@ -0,0 +1,36 @@
|
||||
import re
|
||||
import urllib.request
|
||||
|
||||
req = urllib.request.Request('https://www.zhuig.com/community', headers={'User-Agent': 'Mozilla/5.0', 'Cache-Control': 'no-cache'})
|
||||
html = urllib.request.urlopen(req, timeout=15).read().decode('utf-8')
|
||||
|
||||
print(f'HTML total: {len(html)} bytes')
|
||||
a_tags = len(re.findall(r'<a\s', html))
|
||||
print(f'真实 <a> 标签: {a_tags}')
|
||||
h2 = len(re.findall(r'<h2', html))
|
||||
h3 = len(re.findall(r'<h3', html))
|
||||
print(f'<h2>: {h2} <h3>: {h3}')
|
||||
|
||||
# 找字面平台电商
|
||||
m = re.search(r'<h3[^>]*>[^<]*平台电商[^<]*</h3>', html)
|
||||
print(f'字面<平台电商> HTML: {bool(m)}')
|
||||
|
||||
# 板块区域
|
||||
idx_q = html.find('全部板块')
|
||||
idx_z = html.find('最新话题')
|
||||
section = html[idx_q:idx_z] if idx_z > 0 else html[idx_q:idx_q+15000]
|
||||
print(f'板块区域长度: {len(section)}')
|
||||
a_pat = r'<a\s'
|
||||
h3_pat = r'<h3'
|
||||
a_count = len(re.findall(a_pat, section))
|
||||
h3_count = len(re.findall(h3_pat, section))
|
||||
print(f'板块区域 <a 数量: {a_count}')
|
||||
print(f'板块区域 <h3> 数量: {h3_count}')
|
||||
|
||||
# 找板块标题h2
|
||||
m2 = re.search(r'<h2[^>]*>全部板块</h2>', section)
|
||||
print(f'板块标题 h2 全部板块 存在: {bool(m2)}')
|
||||
|
||||
# 看section的前500字符
|
||||
print(f'\n板块区域前800字符:')
|
||||
print(section[:800])
|
||||
@@ -0,0 +1,33 @@
|
||||
import re
|
||||
import urllib.request
|
||||
|
||||
req = urllib.request.Request('https://www.zhuig.com/community', headers={'User-Agent': 'Mozilla/5.0', 'Cache-Control': 'no-cache'})
|
||||
html = urllib.request.urlopen(req, timeout=15).read().decode('utf-8')
|
||||
|
||||
# 找所有 $N 引用
|
||||
refs = re.findall(r'\$(\d+)', html)
|
||||
unique_refs = sorted(set(int(r) for r in refs))
|
||||
print(f'RSC引用编号: {unique_refs}')
|
||||
|
||||
# 找RSC块边界 (常见的分隔符是 \x00\x00\x00...)
|
||||
# 实际上Next.js RSC使用特殊的script标签
|
||||
script_blocks = re.findall(r'<script[^>]*>(self\.__next_f\.push.*?)</script>', html, re.DOTALL)
|
||||
print(f'\n<self.__next_f.push> script 块: {len(script_blocks)}')
|
||||
|
||||
# 找 $12 在哪里
|
||||
idx_12 = html.find('$12')
|
||||
if idx_12 >= 0:
|
||||
print(f'\n$12 在 HTML 位置: {idx_12}')
|
||||
print(f'周围200字符: {html[max(0,idx_12-50):idx_12+200]}')
|
||||
|
||||
# 找包含"电商零售"的位置
|
||||
idx_dianshang = html.find('电商零售')
|
||||
print(f'\n"电商零售" 在 HTML 位置: {idx_dianshang}')
|
||||
if idx_dianshang >= 0:
|
||||
print(f'周围200字符: {html[max(0,idx_dianshang-30):idx_dianshang+200]}')
|
||||
|
||||
# 找包含"平台电商"子板块
|
||||
idx_pingtai = html.find('平台电商')
|
||||
print(f'\n"平台电商" 在 HTML 位置: {idx_pingtai}')
|
||||
if idx_pingtai >= 0:
|
||||
print(f'周围200字符: {html[max(0,idx_pingtai-100):idx_pingtai+200]}')
|
||||
@@ -0,0 +1,25 @@
|
||||
import re
|
||||
import urllib.request
|
||||
|
||||
req = urllib.request.Request('https://www.zhuig.com/community', headers={'User-Agent': 'Mozilla/5.0', 'Cache-Control': 'no-cache'})
|
||||
html = urllib.request.urlopen(req, timeout=15).read().decode('utf-8')
|
||||
|
||||
# 找"全部板块"区域
|
||||
idx_q = html.find('全部板块')
|
||||
idx_z = html.find('最新话题')
|
||||
section = html[idx_q:idx_z] if idx_z > 0 else html[idx_q:idx_q+15000]
|
||||
print(f'板块区域HTML长度: {len(section)}')
|
||||
|
||||
# 找section里第一个出现的板块数据关键词
|
||||
keywords = ['电商零售', 'AI科技', '金融投资', '跨境出海', '实体经营', '本地生活', '大健康', '教育培训', 'FIRE与生活', '自媒体内容', '餐饮食品', '房地产']
|
||||
print('\n=== 关键词在板块区域出现位置 ===')
|
||||
for kw in keywords:
|
||||
p = section.find(kw)
|
||||
if p >= 0:
|
||||
print(f' "{kw}": 位置{section.find(kw)}, 周围HTML: ...{section[max(0,p-30):p+80]}...')
|
||||
|
||||
# 找"全部板块"区域前后的HTML标签结构
|
||||
print(f'\n=== "全部板块"之前的300字符 ===')
|
||||
print(html[idx_q-300:idx_q])
|
||||
print(f'\n=== "最新话题"之前的500字符(应该是板块区域结束) ===')
|
||||
print(html[idx_z-500:idx_z])
|
||||
@@ -0,0 +1,23 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||
import "dotenv/config";
|
||||
|
||||
const base = process.env.DATABASE_URL.replace("mysql://", "mariadb://");
|
||||
const cs = base + (base.includes("?") ? "&" : "?") + "connection_limit=5";
|
||||
const prisma = new PrismaClient({ adapter: new PrismaMariaDb(cs) });
|
||||
|
||||
const allCats = await prisma.forumCategory.findMany();
|
||||
let fixed = 0;
|
||||
for (const c of allCats) {
|
||||
// 计算真实的话题数
|
||||
const realCount = await prisma.forumTopic.count({ where: { categoryId: c.id } });
|
||||
if (c.topicCount !== realCount) {
|
||||
await prisma.forumCategory.update({
|
||||
where: { id: c.id },
|
||||
data: { topicCount: realCount }
|
||||
});
|
||||
fixed++;
|
||||
}
|
||||
}
|
||||
console.log(`校准完成: 修复${fixed}个板块`);
|
||||
await prisma.$disconnect();
|
||||
@@ -0,0 +1,102 @@
|
||||
// 健康检查脚本:扫描所有定时任务最近一次执行情况
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||
import "dotenv/config";
|
||||
|
||||
const base = process.env.DATABASE_URL.replace("mysql://", "mariadb://");
|
||||
const sep = base.includes("?") ? "&" : "?";
|
||||
const connectionString = `${base}${sep}connection_limit=2&pool_timeout=10`;
|
||||
const adapter = new PrismaMariaDb(connectionString);
|
||||
const prisma = new PrismaClient({ adapter });
|
||||
|
||||
async function main() {
|
||||
console.log("========== 任务调度健康检查 ==========\n");
|
||||
|
||||
// 1. 任务配置
|
||||
const configs = await prisma.taskConfig.findMany({ orderBy: { taskKey: "asc" } });
|
||||
console.log("--- 任务配置 ---");
|
||||
console.log(`共 ${configs.length} 条配置`);
|
||||
for (const c of configs) {
|
||||
console.log(` [${c.enabled ? "●" : "○"}] ${c.taskKey.padEnd(30)} cron=${c.cronExpr || "—"} ${c.taskName}`);
|
||||
}
|
||||
|
||||
// 2. 最近 7 天的执行记录
|
||||
const since = new Date(Date.now() - 7 * 24 * 3600 * 1000);
|
||||
const logs = await prisma.taskLog.findMany({
|
||||
where: { startedAt: { gte: since } },
|
||||
orderBy: { startedAt: "desc" },
|
||||
});
|
||||
console.log(`\n--- 最近 7 天日志(${logs.length} 条) ---`);
|
||||
const byKey = new Map();
|
||||
for (const l of logs) {
|
||||
if (!byKey.has(l.taskKey)) byKey.set(l.taskKey, []);
|
||||
byKey.get(l.taskKey).push(l);
|
||||
}
|
||||
for (const [key, list] of byKey.entries()) {
|
||||
const success = list.filter((l) => l.status === "completed").length;
|
||||
const failed = list.filter((l) => l.status === "failed").length;
|
||||
const last = list[0];
|
||||
const lastDate = new Date(last.startedAt).toLocaleString("zh-CN");
|
||||
console.log(` ${key.padEnd(30)} 共${list.length}次 成功${success} 失败${failed} 最近=${lastDate} status=${last.status}`);
|
||||
}
|
||||
|
||||
// 3. 任何"从未执行"的任务
|
||||
console.log("\n--- 检查哪些 taskKey 已配置但从未执行 ---");
|
||||
const executedKeys = new Set(logs.map((l) => l.taskKey));
|
||||
const allExpectedKeys = new Set([
|
||||
...configs.map((c) => c.taskKey),
|
||||
"task1-discover-tools",
|
||||
"task3-check-tools",
|
||||
"task4-discover-skills",
|
||||
"task5-update-stars",
|
||||
"task6-review-hot",
|
||||
"task7-news-to-community",
|
||||
"daily-news",
|
||||
"bot-activity",
|
||||
"bot-affinity-update",
|
||||
"bot-feedback-loop",
|
||||
"bot-weekly-review",
|
||||
"bot-skill-crystallize",
|
||||
]);
|
||||
const knownButNeverRun = [...allExpectedKeys].filter((k) => !executedKeys.has(k));
|
||||
console.log("从未执行:", knownButNeverRun.length ? knownButNeverRun.join(", ") : "无");
|
||||
|
||||
// 4. bot 体系健康
|
||||
console.log("\n--- Bot 体系健康 ---");
|
||||
const totalBots = await prisma.user.count({ where: { isBot: true } });
|
||||
const totalTopicsByBots = await prisma.forumTopic.count({ where: { user: { isBot: true } } });
|
||||
const totalPostsByBots = await prisma.forumPost.count({ where: { user: { isBot: true } } });
|
||||
const totalMemories = await prisma.botMemory.count();
|
||||
const totalBotConfigs = await prisma.botConfig.count();
|
||||
const lastBotTopic = await prisma.forumTopic.findFirst({
|
||||
where: { user: { isBot: true } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true, title: true },
|
||||
});
|
||||
console.log(` Bot 用户总数: ${totalBots}`);
|
||||
console.log(` BotConfig 数: ${totalBotConfigs}`);
|
||||
console.log(` BotMemory 数: ${totalMemories}`);
|
||||
console.log(` Bot 发帖数: ${totalTopicsByBots}`);
|
||||
console.log(` Bot 回复数: ${totalPostsByBots}`);
|
||||
if (lastBotTopic) {
|
||||
const hoursAgo = (Date.now() - new Date(lastBotTopic.createdAt).getTime()) / 3600000;
|
||||
console.log(` 最近 Bot 帖: ${lastBotTopic.title.substring(0, 40)}... (${hoursAgo.toFixed(1)} 小时前)`);
|
||||
} else {
|
||||
console.log(" 最近 Bot 帖: 无");
|
||||
}
|
||||
|
||||
// 5. 最近失败的详情
|
||||
const recentFailed = logs.filter((l) => l.status === "failed").slice(0, 5);
|
||||
console.log(`\n--- 最近失败任务 (${recentFailed.length}) ---`);
|
||||
for (const f of recentFailed) {
|
||||
console.log(` ${f.taskKey} @ ${new Date(f.startedAt).toLocaleString("zh-CN")}`);
|
||||
if (f.error) console.log(` ${f.error.substring(0, 200)}`);
|
||||
}
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# 追光AI 容器化版 host 备份 cron 安装脚本
|
||||
# - 在宿主机 crontab 里加 1 条:每天 03:30 跑 backup-volumes.sh
|
||||
# - 注意:这是宿主机 cron(不是容器内 supercronic)
|
||||
# - 用法: bash scripts/install-cron-backup.sh
|
||||
# =============================================================================
|
||||
set -e
|
||||
|
||||
PROJECT="/home/ubuntu/zhuiguang-ai"
|
||||
SCRIPT="$PROJECT/scripts/backup-volumes.sh"
|
||||
CRON_LINE="30 3 * * * bash $SCRIPT >> $PROJECT/logs/host-backup.log 2>&1"
|
||||
|
||||
if ! crontab -l 2>/dev/null | grep -qF "$SCRIPT"; then
|
||||
echo "[install-cron-backup] 添加备份 cron: $CRON_LINE"
|
||||
(crontab -l 2>/dev/null; echo "$CRON_LINE") | crontab -
|
||||
else
|
||||
echo "[install-cron-backup] 备份 cron 已存在,跳过"
|
||||
fi
|
||||
|
||||
echo "[install-cron-backup] 当前 crontab 中包含 backup 的行:"
|
||||
crontab -l 2>/dev/null | grep -E "backup" || echo " (无)"
|
||||
+36
-3
@@ -43,13 +43,38 @@ PROJECT="/home/ubuntu/zhuiguang-ai"
|
||||
# 每天早上8:00 新闻推送到社区版块
|
||||
0 8 * * * bash $WRAPPER task7 $PROJECT/scripts/task7-news-to-community.mjs
|
||||
|
||||
# 数字人bot活动引擎(每整点运行,9:00-23:00共15轮/天,每轮3帖)
|
||||
# 数字人bot活动引擎(每整点运行,9:00-23:00共15轮/天,每轮5+5=10板块+4回复/板块+5点赞)
|
||||
# 2026-06-10 翻倍:原 4+4=8 板块、3回复、3点赞 → 5+5=10 板块、4回复、5点赞
|
||||
0 9,10,11,12,13,14,15,16,17,18,19,20,21,22,23 * * * bash $WRAPPER bot-activity $PROJECT/scripts/bot-activity.mjs
|
||||
|
||||
# Bot跨板块亲和度与画像:每6小时滚动一次
|
||||
0 */6 * * * bash $WRAPPER bot-affinity-update $PROJECT/scripts/bot-affinity-update.mjs
|
||||
|
||||
# Bot反馈循环:每3小时一次(基于历史回复生成跟进)
|
||||
0 */3 * * * bash $WRAPPER bot-feedback-loop $PROJECT/scripts/bot-feedback-loop.mjs
|
||||
|
||||
# Bot技能沉淀:每12小时一次
|
||||
0 */12 * * * bash $WRAPPER bot-skill-crystallize $PROJECT/scripts/bot-skill-crystallize.mjs
|
||||
|
||||
# Bot周复盘:每周一凌晨2:30执行
|
||||
30 2 * * 1 bash $WRAPPER bot-weekly-review $PROJECT/scripts/bot-weekly-review.mjs
|
||||
|
||||
# 每周日凌晨6:30 清理 30 天前的 task_log 数据
|
||||
30 6 * * 0 bash $WRAPPER cleanup-task-logs $PROJECT/scripts/cleanup-task-logs.mjs
|
||||
|
||||
# 每6小时15分对齐 forumTopic/forumPost.likeCount 与真实 Like 表
|
||||
15 */6 * * * bash $WRAPPER reconcile-like-counts $PROJECT/scripts/reconcile-like-counts.mjs --fix
|
||||
|
||||
# Bot Persona A/B 测试调度:每4小时45分(采指标 + 显著性分析 + winner 权重切换)
|
||||
45 */4 * * * bash $WRAPPER bot-persona-experiment-run $PROJECT/scripts/bot-persona-experiment-run.mjs --lookback=7
|
||||
|
||||
# Bot 对抗学习(真人高赞帖):每周日23:00 跑一次
|
||||
0 23 * * 0 bash $WRAPPER bot-adversarial-learning-run $PROJECT/scripts/bot-adversarial-learning-run.mjs --lookback=7
|
||||
CRONEOF
|
||||
|
||||
crontab /tmp/cron-temp
|
||||
rm -f /tmp/cron-temp
|
||||
echo "✅ crontab 已更新(7个定时任务)"
|
||||
echo "✅ crontab 已更新(15个定时任务)"
|
||||
|
||||
echo ""
|
||||
echo "当前 crontab 内容:"
|
||||
@@ -59,11 +84,19 @@ echo ""
|
||||
echo "========================================"
|
||||
echo " 安装完成!每天自动执行时间:"
|
||||
echo " 01:00 - Task5 更新技能星值"
|
||||
echo " 02:30 - Bot周复盘(仅周一)"
|
||||
echo " 03:00 - Task3 工具巡检"
|
||||
echo " 04:00 - Task6 评测热门技能"
|
||||
echo " 05:00 - Task4 发现AI技能"
|
||||
echo " 06:00 - Task1 发现AI工具"
|
||||
echo " 06:15 - 点赞数对齐巡检(每6h)"
|
||||
echo " 06:30 - 清理任务日志(仅周日)"
|
||||
echo " 07:30 - 生成AI日报"
|
||||
echo " 08:00 - Task7 新闻→社区"
|
||||
echo " 09-23 - 数字人bot活动引擎(每小时1轮)"
|
||||
echo " 每3h - Bot反馈循环(0/3/6/9/12/15/18/21)"
|
||||
echo " 每4h - Bot Persona A/B 调度(3/7/11/15/19/23)"
|
||||
echo " 每周日23:00 - Bot 对抗学习(真人高赞帖)"
|
||||
echo " 每6h - Bot亲和度与画像(0/6/12/18)"
|
||||
echo " 每12h - Bot技能沉淀(0/12)"
|
||||
echo " 9-23 - 数字人bot活动引擎(每小时1轮)"
|
||||
echo "========================================"
|
||||
@@ -0,0 +1,535 @@
|
||||
// Bot 数字人 vs 真人"对抗学习" 共享模块
|
||||
// 每周为每个 bot 选 1 个真实高赞真人帖,让 bot 吸收其"为什么高互动"的洞察
|
||||
//
|
||||
// 用法:
|
||||
// import { runWeeklyAdversarialLearning, getActiveLearningForBot, buildAdversarialBlock } from "./lib/bot-adversarial-learning.mjs";
|
||||
//
|
||||
// // 1) 周调度入口
|
||||
// await runWeeklyAdversarialLearning({ weekKey: "2026-W23", lookbackDays: 7 });
|
||||
//
|
||||
// // 2) 发帖 prompt 注入对抗学习参考
|
||||
// const learning = await getActiveLearningForBot(botConfigId);
|
||||
// const block = buildAdversarialBlock(learning); // → "[对抗学习] ..."
|
||||
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||
import "dotenv/config";
|
||||
import OpenAI from "openai";
|
||||
import { withRetry } from "./retry.mjs";
|
||||
|
||||
const base = (process.env.DATABASE_URL || "").replace("mysql://", "mariadb://");
|
||||
const sep = base.includes("?") ? "&" : "?";
|
||||
const connectionString = `${base}${sep}connection_limit=3&pool_timeout=10`;
|
||||
|
||||
let _prisma = null;
|
||||
function getPrisma() {
|
||||
if (!_prisma) {
|
||||
const adapter = new PrismaMariaDb(connectionString);
|
||||
_prisma = new PrismaClient({ adapter });
|
||||
}
|
||||
return _prisma;
|
||||
}
|
||||
|
||||
let _openai = null;
|
||||
function getOpenAI() {
|
||||
if (_openai) return _openai;
|
||||
if (!process.env.DEEPSEEK_API_KEY) return null;
|
||||
_openai = new OpenAI({
|
||||
apiKey: process.env.DEEPSEEK_API_KEY,
|
||||
baseURL: "https://api.deepseek.com/v1",
|
||||
});
|
||||
return _openai;
|
||||
}
|
||||
|
||||
const MODEL = process.env.DEEPSEEK_MODEL || "deepseek-chat";
|
||||
|
||||
function ts() {
|
||||
return `[${new Date().toISOString()}]`;
|
||||
}
|
||||
|
||||
// ======== 配置 ========
|
||||
export const LEARNING_CONFIG = {
|
||||
// 候选帖最小门槛
|
||||
minReplies: 3,
|
||||
minLikes: 5,
|
||||
minHumanReplies: 1,
|
||||
// 回看窗口(天)
|
||||
lookbackDays: 7,
|
||||
// 候选数上限
|
||||
candidateLimit: 30,
|
||||
// 每个 bot 每周上限
|
||||
perBotPerWeek: 1,
|
||||
// 关联度评分
|
||||
sameForumWeight: 1.0,
|
||||
keywordOverlapWeight: 0.6,
|
||||
recencyWeight: 0.3,
|
||||
// 过期
|
||||
expireDays: 7,
|
||||
// LLM 提示 token 上限
|
||||
contentTruncate: 1500,
|
||||
};
|
||||
|
||||
// ======== 工具:周 key (ISO week) ========
|
||||
/**
|
||||
* 形如 "2026-W23",与 date-fns / Excel WEEKNUM 行为一致
|
||||
*/
|
||||
export function getISOWeekKey(date = new Date()) {
|
||||
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
||||
const dayNum = d.getUTCDay() || 7; // Mon=1..Sun=7
|
||||
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
|
||||
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
||||
const weekNo = Math.ceil(((d - yearStart) / 86400000 + 1) / 7);
|
||||
return `${d.getUTCFullYear()}-W${String(weekNo).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
// ======== 工具:取候选真人帖 ========
|
||||
/**
|
||||
* 拉过去 N 天内由真人发布、且互动数超过门槛的 topic 列表
|
||||
* 排序:综合分 = 赞×3 + 真人回复×5 + 回复
|
||||
* 仅返回 topic 维度(post 维度后续可加)
|
||||
*/
|
||||
export async function fetchHumanHighEngagementTopics(lookbackDays = 7, limit = 30) {
|
||||
const prisma = getPrisma();
|
||||
const since = new Date(Date.now() - lookbackDays * 24 * 60 * 60 * 1000);
|
||||
|
||||
// 1) 先拉 botUserId 集合,避免 N+1
|
||||
const botUsers = await prisma.user.findMany({
|
||||
where: { isBot: true },
|
||||
select: { id: true },
|
||||
});
|
||||
const botUserIdSet = new Set(botUsers.map((u) => u.id));
|
||||
|
||||
// 2) 拉所有非 bot 的 topic,按 replyCount desc 取前 N(粗筛)
|
||||
const candidates = await prisma.forumTopic.findMany({
|
||||
where: {
|
||||
userId: { notIn: Array.from(botUserIdSet) },
|
||||
isHidden: false,
|
||||
createdAt: { gte: since },
|
||||
},
|
||||
include: {
|
||||
user: { select: { id: true, name: true, isBot: true } },
|
||||
category: { select: { slug: true, name: true } },
|
||||
posts: {
|
||||
where: { user: { isBot: false } },
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
orderBy: [{ replyCount: "desc" }, { likeCount: "desc" }],
|
||||
take: limit * 2,
|
||||
});
|
||||
|
||||
// 3) 计算综合分,过门槛
|
||||
const scored = candidates
|
||||
.filter((t) => !t.user?.isBot)
|
||||
.map((t) => {
|
||||
const humanReplies = t.posts?.length || 0;
|
||||
const score = (t.likeCount || 0) * 3 + humanReplies * 5 + (t.replyCount || 0);
|
||||
return { ...t, _score: score, _humanReplies: humanReplies };
|
||||
})
|
||||
.filter(
|
||||
(t) =>
|
||||
(t.replyCount || 0) >= LEARNING_CONFIG.minReplies ||
|
||||
t._humanReplies >= LEARNING_CONFIG.minHumanReplies ||
|
||||
(t.likeCount || 0) >= LEARNING_CONFIG.minLikes
|
||||
)
|
||||
.sort((a, b) => b._score - a._score)
|
||||
.slice(0, limit);
|
||||
|
||||
return scored;
|
||||
}
|
||||
|
||||
// ======== 工具:候选与 bot 关联度评分 ========
|
||||
/**
|
||||
* 计算一篇候选帖对某 bot 的关联度
|
||||
* - 板块命中:bot.primaryForums 包含该帖板块 → 同板块加权
|
||||
* - 关键词重叠:bot stanceKeywords 与帖子标题/内容重叠数
|
||||
* - 时效衰减:越新越好
|
||||
*/
|
||||
function calcRelevance(bot, persona, topic) {
|
||||
let score = 0;
|
||||
const primaryForums = (bot.primaryForums || []).map((s) => String(s).toLowerCase());
|
||||
const stanceKeywords = ((persona?.stanceKeywords || []).map((k) => k.key || k)).filter(Boolean);
|
||||
|
||||
if (primaryForums.includes(String(topic.category?.slug || "").toLowerCase())) {
|
||||
score += LEARNING_CONFIG.sameForumWeight;
|
||||
}
|
||||
|
||||
if (stanceKeywords.length > 0) {
|
||||
const text = `${topic.title || ""} ${topic.content || ""}`.toLowerCase();
|
||||
let hits = 0;
|
||||
for (const kw of stanceKeywords) {
|
||||
const k = String(kw).toLowerCase();
|
||||
if (k.length < 2) continue;
|
||||
if (text.includes(k)) hits++;
|
||||
}
|
||||
const overlapRatio = Math.min(1, hits / 5);
|
||||
score += overlapRatio * LEARNING_CONFIG.keywordOverlapWeight;
|
||||
}
|
||||
|
||||
// 时效:距今 < 3 天满分,> 6 天 0 分
|
||||
const ageMs = Date.now() - new Date(topic.createdAt).getTime();
|
||||
const ageDays = ageMs / (24 * 60 * 60 * 1000);
|
||||
const recencyScore = Math.max(0, 1 - ageDays / 6) * LEARNING_CONFIG.recencyWeight;
|
||||
score += recencyScore;
|
||||
|
||||
return Math.round(score * 1000) / 1000;
|
||||
}
|
||||
|
||||
// ======== LLM 提取"为什么高互动"洞察 ========
|
||||
const LEARN_PROMPT = (botName, botRole, topic) => `你是【${botName}】的内容策略师,每周要研究一篇真实用户(不是数字人)的高互动帖子,提炼出"为什么火",作为下周发帖可借鉴的方向。
|
||||
|
||||
[数字人角色]
|
||||
- 名字:${botName}
|
||||
- 角色:${botRole || "通用论坛内容创作者"}
|
||||
|
||||
[本周高互动真人帖]
|
||||
- 标题:${topic.title}
|
||||
- 板块:${topic.category?.name || ""}
|
||||
- 收到回复:${topic.replyCount}(真人 ${topic._humanReplies || 0})
|
||||
- 收到点赞:${topic.likeCount}
|
||||
- 浏览数:${topic.viewCount}
|
||||
- 内容(截取前 ${LEARNING_CONFIG.contentTruncate} 字):
|
||||
${(topic.content || "").slice(0, LEARNING_CONFIG.contentTruncate)}
|
||||
|
||||
[任务]
|
||||
站在【${botName}】的视角,分析这篇真人帖之所以高互动的 2-3 个可学习点。注意:
|
||||
1. 不要泛泛而谈"写得好",要给出具体的"可复用的写法/角度/钩子"
|
||||
2. 关注"为什么能引发真人回复"——是观点鲜明、抛问题、还是给方案/故事
|
||||
3. 提炼出 2-3 个对【${botName}】下周发帖有直接借鉴价值的策略
|
||||
4. 输出一段 150-250 字的简洁洞察,下周发帖时可作为参考
|
||||
|
||||
[输出格式]
|
||||
只输出严格的 JSON:
|
||||
{
|
||||
"insight": "(150-250 字的洞察,聚焦可复用的写法/角度/钩子)",
|
||||
"categories": ["hook|story|data|question|contrarian|empathy|practical", ...]
|
||||
}`;
|
||||
|
||||
async function extractInsightWithLLM(botChar, topic) {
|
||||
const openai = getOpenAI();
|
||||
if (!openai) {
|
||||
// 降级:无 LLM 时输出规则性总结
|
||||
return buildFallbackInsight(topic);
|
||||
}
|
||||
const p = botChar?.personality || {};
|
||||
const role = [p.identity, p.stance, p.speakingStyle].filter(Boolean).join(" / ");
|
||||
const prompt = LEARN_PROMPT(botChar?.displayName || "数字人", role, topic);
|
||||
const response = await withRetry(() =>
|
||||
openai.chat.completions.create({
|
||||
model: MODEL,
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
temperature: 0.6,
|
||||
max_tokens: 800,
|
||||
})
|
||||
);
|
||||
const text = response.choices?.[0]?.message?.content?.trim() || "";
|
||||
const match = text.match(/\{[\s\S]*\}/);
|
||||
if (!match) return buildFallbackInsight(topic);
|
||||
try {
|
||||
const parsed = JSON.parse(match[0]);
|
||||
return {
|
||||
insight: String(parsed.insight || "").slice(0, 1000),
|
||||
categories: Array.isArray(parsed.categories) ? parsed.categories.slice(0, 5) : [],
|
||||
};
|
||||
} catch {
|
||||
return buildFallbackInsight(topic);
|
||||
}
|
||||
}
|
||||
|
||||
function buildFallbackInsight(topic) {
|
||||
return {
|
||||
insight: `这篇真人帖(${topic.title || "无标题"})收到 ${topic.replyCount} 条回复 / ${topic.likeCount} 个赞,关键在于:${(topic.content || "").slice(0, 80)}...的可复用角度。下周可参考其切入点和表达方式。`,
|
||||
categories: ["practical"],
|
||||
};
|
||||
}
|
||||
|
||||
// ======== 单 bot 学习流程 ========
|
||||
/**
|
||||
* 对一个 bot 跑对抗学习:
|
||||
* 1) 拉真人高互动候选
|
||||
* 2) 与 bot 关联度排序 → 选 top1
|
||||
* 3) LLM 提取洞察
|
||||
* 4) upsert 到 bot_adversarial_learnings (unique: botId+weekKey)
|
||||
* 5) 同步写一条 BotMemory
|
||||
*/
|
||||
export async function learnForBot(botUser, botConfig, persona, options = {}) {
|
||||
const prisma = getPrisma();
|
||||
const cfg = { ...LEARNING_CONFIG, ...options };
|
||||
const weekKey = options.weekKey || getISOWeekKey();
|
||||
|
||||
// 已存在本 bot 本周的学习 → 跳过
|
||||
const existing = await prisma.botAdversarialLearning.findUnique({
|
||||
where: { botId_weekKey: { botId: botConfig.id, weekKey } },
|
||||
});
|
||||
if (existing) {
|
||||
return { skipped: true, reason: "already_learned", record: existing };
|
||||
}
|
||||
|
||||
// 1) 候选
|
||||
const candidates = await fetchHumanHighEngagementTopics(cfg.lookbackDays, cfg.candidateLimit);
|
||||
if (candidates.length === 0) {
|
||||
return { skipped: true, reason: "no_candidates" };
|
||||
}
|
||||
|
||||
// 2) 关联度排序
|
||||
const ranked = candidates
|
||||
.map((c) => ({ topic: c, relevance: calcRelevance(botConfig, persona, c) }))
|
||||
.sort((a, b) => b.relevance - a.relevance);
|
||||
const top = ranked[0];
|
||||
|
||||
// 3) LLM 提取
|
||||
const { insight, categories } = await extractInsightWithLLM(botUser._botChar, top.topic);
|
||||
|
||||
// 4) upsert 写入
|
||||
const expiresAt = new Date(Date.now() + cfg.expireDays * 24 * 60 * 60 * 1000);
|
||||
const record = await prisma.botAdversarialLearning.upsert({
|
||||
where: { botId_weekKey: { botId: botConfig.id, weekKey } },
|
||||
create: {
|
||||
botId: botConfig.id,
|
||||
weekKey,
|
||||
sourceRefType: "topic",
|
||||
sourceRefId: top.topic.id,
|
||||
sourceUserId: top.topic.userId,
|
||||
sourceUserName: top.topic.user?.name || null,
|
||||
forumSlug: top.topic.category?.slug || null,
|
||||
sourceTitle: top.topic.title || null,
|
||||
sourceContent: (top.topic.content || "").slice(0, 5000),
|
||||
sourceMetrics: {
|
||||
replyCount: top.topic.replyCount || 0,
|
||||
likeCount: top.topic.likeCount || 0,
|
||||
viewCount: top.topic.viewCount || 0,
|
||||
humanReplies: top._humanReplies || 0,
|
||||
engagementScore: top._score,
|
||||
},
|
||||
relevanceScore: top.relevance,
|
||||
learnedInsight: insight,
|
||||
learnCategories: categories,
|
||||
status: "active",
|
||||
expiresAt,
|
||||
},
|
||||
update: {
|
||||
// 本周二次跑:覆盖(强制重学)
|
||||
sourceRefType: "topic",
|
||||
sourceRefId: top.topic.id,
|
||||
sourceUserId: top.topic.userId,
|
||||
sourceUserName: top.topic.user?.name || null,
|
||||
forumSlug: top.topic.category?.slug || null,
|
||||
sourceTitle: top.topic.title || null,
|
||||
sourceContent: (top.topic.content || "").slice(0, 5000),
|
||||
sourceMetrics: {
|
||||
replyCount: top.topic.replyCount || 0,
|
||||
likeCount: top.topic.likeCount || 0,
|
||||
viewCount: top.topic.viewCount || 0,
|
||||
humanReplies: top._humanReplies || 0,
|
||||
engagementScore: top._score,
|
||||
},
|
||||
relevanceScore: top.relevance,
|
||||
learnedInsight: insight,
|
||||
learnCategories: categories,
|
||||
status: "active",
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
// 5) 同步写一条 BotMemory
|
||||
await prisma.botMemory.create({
|
||||
data: {
|
||||
botId: botConfig.id,
|
||||
memoryType: "adversarial_learning",
|
||||
layer: "LONGTERM",
|
||||
content: {
|
||||
action: "learn_from_human",
|
||||
weekKey,
|
||||
learningId: record.id,
|
||||
sourceTitle: top.topic.title,
|
||||
sourceUserName: top.topic.user?.name || null,
|
||||
sourceUserId: top.topic.userId,
|
||||
sourceRefType: "topic",
|
||||
sourceRefId: top.topic.id,
|
||||
insight,
|
||||
categories,
|
||||
relevanceScore: top.relevance,
|
||||
sourceMetrics: {
|
||||
replyCount: top.topic.replyCount || 0,
|
||||
likeCount: top.topic.likeCount || 0,
|
||||
humanReplies: top._humanReplies || 0,
|
||||
},
|
||||
},
|
||||
importance: 0.85,
|
||||
contextTags: {
|
||||
weekKey,
|
||||
forumSlug: top.topic.category?.slug || null,
|
||||
source: "adversarial_learning",
|
||||
learningId: record.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return { skipped: false, record, candidate: top.topic, relevance: top.relevance };
|
||||
}
|
||||
|
||||
// ======== 加载所有 bot 配置 + 角色 + persona ========
|
||||
async function loadAllExpertBots() {
|
||||
const prisma = getPrisma();
|
||||
const botUsers = await prisma.user.findMany({
|
||||
where: { isBot: true },
|
||||
include: { botConfig: true },
|
||||
});
|
||||
// 过滤出有 config 的"非路人"专家 bot
|
||||
return botUsers
|
||||
.filter((u) => u.botConfig && (!u.botConfig.personality || !u.botConfig.personality?.role || u.botConfig.personality?.role !== "passerby"))
|
||||
.map((u) => ({ user: u, config: u.botConfig }));
|
||||
}
|
||||
|
||||
async function loadPersonaForBot(botConfigId) {
|
||||
const prisma = getPrisma();
|
||||
return prisma.botPersona.findUnique({ where: { botId: botConfigId } });
|
||||
}
|
||||
|
||||
// 尝试从 bot-characters.json 加载角色定义(仅用于 LLM 提示)
|
||||
async function loadBotCharMap() {
|
||||
try {
|
||||
const fs = await import("fs");
|
||||
const path = await import("path");
|
||||
const url = await import("url");
|
||||
const { readFileSync } = fs;
|
||||
const { resolve, dirname } = path;
|
||||
const { fileURLToPath } = url;
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const p = resolve(__dirname, "..", "..", "data", "bot-characters.json");
|
||||
const raw = readFileSync(p, "utf-8");
|
||||
const { characters } = JSON.parse(raw);
|
||||
const map = {};
|
||||
for (const c of characters) map[c.key] = c;
|
||||
return map;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// ======== 周调度入口 ========
|
||||
/**
|
||||
* 给所有专家 bot 跑一次对抗学习
|
||||
* @param options
|
||||
* - weekKey: 强制指定周 key(默认当前 ISO 周)
|
||||
* - lookbackDays: 候选回看窗口(默认 7)
|
||||
* - botConfigIds: 限定 bot id 列表
|
||||
* - skipExisting: 已有本周学习时跳过(默认 true)
|
||||
*/
|
||||
export async function runWeeklyAdversarialLearning(options = {}) {
|
||||
const prisma = getPrisma();
|
||||
const weekKey = options.weekKey || getISOWeekKey();
|
||||
const botCharMap = await loadBotCharMap();
|
||||
|
||||
const bots = await loadAllExpertBots();
|
||||
const targetBots = options.botConfigIds
|
||||
? bots.filter((b) => options.botConfigIds.includes(b.config.id))
|
||||
: bots;
|
||||
|
||||
const results = [];
|
||||
for (const { user, config } of targetBots) {
|
||||
try {
|
||||
const persona = await loadPersonaForBot(config.id);
|
||||
const emailPrefix = (user.email || "").split("@")[0] || "";
|
||||
const key = emailPrefix.replace(/^bot_/, "");
|
||||
user._botChar = botCharMap[key] || { displayName: user.name, personality: {} };
|
||||
|
||||
const r = await learnForBot(user, config, persona, {
|
||||
weekKey,
|
||||
lookbackDays: options.lookbackDays ?? LEARNING_CONFIG.lookbackDays,
|
||||
});
|
||||
results.push({ botId: config.id, botName: user.name, ...r });
|
||||
} catch (err) {
|
||||
results.push({ botId: config.id, botName: user.name, error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 把过期的 learning 标记 expired
|
||||
await prisma.botAdversarialLearning.updateMany({
|
||||
where: {
|
||||
status: "active",
|
||||
expiresAt: { lt: new Date() },
|
||||
},
|
||||
data: { status: "expired" },
|
||||
});
|
||||
|
||||
const summary = {
|
||||
weekKey,
|
||||
botCount: targetBots.length,
|
||||
learned: results.filter((r) => !r.skipped && !r.error).length,
|
||||
skipped: results.filter((r) => r.skipped).length,
|
||||
errors: results.filter((r) => r.error).length,
|
||||
results,
|
||||
};
|
||||
return summary;
|
||||
}
|
||||
|
||||
// ======== 在 prompt 中使用 ========
|
||||
/**
|
||||
* 拉取某 bot 当前生效的对抗学习(status=active 且未过期),取最近一条
|
||||
*/
|
||||
export async function getActiveLearningForBot(botConfigId) {
|
||||
const prisma = getPrisma();
|
||||
return prisma.botAdversarialLearning.findFirst({
|
||||
where: {
|
||||
botId: botConfigId,
|
||||
status: "active",
|
||||
OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }],
|
||||
},
|
||||
orderBy: { learnedAt: "desc" },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 learning 渲染成可注入 prompt 的 block
|
||||
*/
|
||||
export function buildAdversarialBlock(learning) {
|
||||
if (!learning) return "";
|
||||
const lines = [];
|
||||
lines.push(`[对抗学习参考 · ${learning.weekKey}]`);
|
||||
if (learning.sourceTitle) {
|
||||
lines.push(`- 高互动真人帖:${learning.sourceTitle}`);
|
||||
}
|
||||
if (learning.sourceUserName) {
|
||||
lines.push(`- 真人作者:${learning.sourceUserName}`);
|
||||
}
|
||||
if (learning.forumSlug) {
|
||||
lines.push(`- 板块:${learning.forumSlug}`);
|
||||
}
|
||||
const m = learning.sourceMetrics || {};
|
||||
if (m.replyCount !== undefined) {
|
||||
lines.push(
|
||||
`- 互动数据:${m.replyCount || 0} 回复 / ${m.humanReplies || 0} 真人回复 / ${m.likeCount || 0} 赞 / ${m.viewCount || 0} 浏览`
|
||||
);
|
||||
}
|
||||
if (Array.isArray(learning.learnCategories) && learning.learnCategories.length > 0) {
|
||||
lines.push(`- 可借鉴角度:${learning.learnCategories.join("、")}`);
|
||||
}
|
||||
if (learning.learnedInsight) {
|
||||
lines.push(`- 洞察:${learning.learnedInsight}`);
|
||||
}
|
||||
lines.push("- 提示:本周发帖/回复时可参考上述真人帖的切入角度、表达方式,但不要直接抄袭内容。");
|
||||
return `\n${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记 learning 已被使用一次(用于统计触达率)
|
||||
*/
|
||||
export async function markLearningUsed(learningId) {
|
||||
if (!learningId) return;
|
||||
const prisma = getPrisma();
|
||||
await prisma.botAdversarialLearning.update({
|
||||
where: { id: learningId },
|
||||
data: { usedCount: { increment: 1 } },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 优雅关闭 prisma
|
||||
*/
|
||||
export async function disconnectAdversarialLearning() {
|
||||
if (_prisma) {
|
||||
await _prisma.$disconnect();
|
||||
_prisma = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
// scripts/lib/bot-avatar-generator.mjs
|
||||
// 数字人头像生成器
|
||||
//
|
||||
// 策略:
|
||||
// 1. 先调用 Lunaris (trae-api text_to_image) 拿一张"AI 生成"图
|
||||
// 2. 检测是否是占位 default(目前 trae-api 任何 prompt 都返回同一张 default.jpeg)
|
||||
// 3. 若是占位 → 用程序化 SVG 生成唯一头像(基于 key 哈希 + 调色板 + 角色 initial)
|
||||
// 4. 落盘到 public/bot-avatars/{key}.{ext} + 更新 User.avatarUrl
|
||||
//
|
||||
// 这样每个 bot 都有视觉上独特的头像;当 Lunaris 真正支持按 prompt 生成时,
|
||||
// 重跑 --force --use-lunaris-only 即可切回 AI 生成的版本。
|
||||
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
|
||||
import { resolve, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import "dotenv/config";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const base = (process.env.DATABASE_URL || "").replace("mysql://", "mariadb://");
|
||||
const sep = base.includes("?") ? "&" : "?";
|
||||
const connectionString = `${base}${sep}connection_limit=5&pool_timeout=30`;
|
||||
const adapter = new PrismaMariaDb(connectionString);
|
||||
export const prisma = new PrismaClient({ adapter });
|
||||
|
||||
export const BOT_DATA_PATH = resolve(__dirname, "..", "..", "data", "bot-characters.json");
|
||||
export const AVATAR_DIR = resolve(__dirname, "..", "..", "public", "bot-avatars");
|
||||
export const LUNARIS_DIR = resolve(AVATAR_DIR, "lunaris");
|
||||
|
||||
export const LUNARIS_ENDPOINT = "https://trae-api-cn.mchost.guru/api/ide/v1/text_to_image";
|
||||
|
||||
// trae-api 当前所有 prompt 都返回同一张 default.jpeg(实测 176626 bytes)
|
||||
// 用这个 hash 前缀识别占位图,命中则改用 SVG 兜底
|
||||
export const LUNARIS_DEFAULT_HASH_PREFIX = "e330cd0232";
|
||||
|
||||
// ---------- 工具函数 ----------
|
||||
|
||||
/**
|
||||
* 把 key 字符串做稳定 hash(32-bit FNV-1a 变体)
|
||||
*/
|
||||
export function hashKey(key) {
|
||||
let h = 0x811c9dc5;
|
||||
for (let i = 0; i < key.length; i++) {
|
||||
h ^= key.charCodeAt(i);
|
||||
h = Math.imul(h, 0x01000193) >>> 0;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 0..MAX 区间里按 key 取一个稳定随机数
|
||||
*/
|
||||
export function pickFromKey(key, max) {
|
||||
return hashKey(key) % max;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 key 拆成 4 个 8-bit 数字(0-255)用于 SVG 几何定位
|
||||
*/
|
||||
export function keyToBytes(key) {
|
||||
const h1 = pickFromKey(key + "a", 256);
|
||||
const h2 = pickFromKey(key + "b", 256);
|
||||
const h3 = pickFromKey(key + "c", 256);
|
||||
const h4 = pickFromKey(key + "d", 256);
|
||||
return [h1, h2, h3, h4];
|
||||
}
|
||||
|
||||
// 调色板:与 src/lib/bot-utils.ts 的 getBotColorScheme 保持视觉一致
|
||||
// 但用实际 hex(Tailwind 调色板)方便 SVG 渲染
|
||||
export const PALETTE = [
|
||||
{ from: "#fb7185", to: "#fb923c", accent: "#fff1f2", name: "rose-orange" },
|
||||
{ from: "#fbbf24", to: "#f43f5e", accent: "#fffbeb", name: "amber-rose" },
|
||||
{ from: "#34d399", to: "#06b6d4", accent: "#ecfdf5", name: "emerald-cyan" },
|
||||
{ from: "#38bdf8", to: "#6366f1", accent: "#f0f9ff", name: "sky-indigo" },
|
||||
{ from: "#a78bfa", to: "#d946ef", accent: "#f5f3ff", name: "violet-fuchsia" },
|
||||
{ from: "#e879f9", to: "#ec4899", accent: "#fdf4ff", name: "fuchsia-pink" },
|
||||
{ from: "#2dd4bf", to: "#10b981", accent: "#f0fdfa", name: "teal-emerald" },
|
||||
{ from: "#818cf8", to: "#a855f7", accent: "#eef2ff", name: "indigo-purple" },
|
||||
{ from: "#fb923c", to: "#ef4444", accent: "#fff7ed", name: "orange-red" },
|
||||
{ from: "#22d3ee", to: "#3b82f6", accent: "#ecfeff", name: "cyan-blue" },
|
||||
];
|
||||
|
||||
export function pickPalette(key) {
|
||||
return PALETTE[pickFromKey(key, PALETTE.length)];
|
||||
}
|
||||
|
||||
// ---------- Lunaris 调用 ----------
|
||||
|
||||
export async function fetchLunarisImage(avatarPrompt, { size = "square_hd", timeout = 30000 } = {}) {
|
||||
const enhancedPrompt = `人物头像, ${avatarPrompt}, 高质量肖像照, 自然光, 浅色背景, 半身像, 微笑, 现代感`;
|
||||
const url = `${LUNARIS_ENDPOINT}?prompt=${encodeURIComponent(enhancedPrompt)}&image_size=${size}`;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeout);
|
||||
try {
|
||||
const r = await fetch(url, {
|
||||
headers: { Accept: "image/*,*/*", "User-Agent": "Mozilla/5.0" },
|
||||
signal: controller.signal,
|
||||
redirect: "follow",
|
||||
});
|
||||
if (!r.ok) throw new Error(`Lunaris HTTP ${r.status}`);
|
||||
const ct = r.headers.get("content-type") || "";
|
||||
if (!ct.includes("image")) throw new Error(`Lunaris 响应非图片: ${ct}`);
|
||||
const buf = Buffer.from(await r.arrayBuffer());
|
||||
return { buffer: buf, contentType: ct, size: buf.byteLength, url: r.url };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
export async function isLunarisDefault(buffer) {
|
||||
const { createHash } = await import("crypto");
|
||||
const hash = createHash("sha256").update(buffer).digest("hex");
|
||||
return hash.startsWith(LUNARIS_DEFAULT_HASH_PREFIX);
|
||||
}
|
||||
|
||||
// ---------- SVG 程序化生成 ----------
|
||||
|
||||
/**
|
||||
* 根据 bot persona 生成 512x512 SVG 头像
|
||||
* 设计要素:
|
||||
* - 渐变背景(palette.from → palette.to)
|
||||
* - 4 个装饰几何形(位置/大小/旋转由 key 决定)
|
||||
* - 中央大字 initial(displayName 首字符)
|
||||
* - 右下角 🤖 标识(用 SVG path 画,不依赖 emoji 字体)
|
||||
*/
|
||||
export function generateSvgAvatar({ key, displayName, persona }) {
|
||||
const palette = pickPalette(key);
|
||||
const [b1, b2, b3, b4] = keyToBytes(key);
|
||||
const initial = (displayName || key).charAt(0).toUpperCase();
|
||||
const isPasserby = persona?.role === "passerby";
|
||||
|
||||
// 4 个装饰形:圆/三角/方/菱形,位置基于 key 哈希
|
||||
const shapes = [
|
||||
{ type: "circle", cx: 40 + b1 * 0.7, cy: 60 + b2 * 0.4, r: 30 + (b3 % 40), opacity: 0.18 },
|
||||
{ type: "rect", x: 320 + b2 * 0.3, y: 30 + b3 * 0.2, w: 60 + (b4 % 50), h: 60 + (b1 % 50), rot: b1 % 90, opacity: 0.14 },
|
||||
{ type: "circle", cx: 380 + b3 * 0.2, cy: 320 + b4 * 0.4, r: 40 + (b2 % 60), opacity: 0.12 },
|
||||
{ type: "polygon", points: `60,${380 + b1 * 0.2} ${120 + b2 * 0.2},${440 + b3 * 0.15} ${20 + b4 * 0.3},${460}`, opacity: 0.16 },
|
||||
];
|
||||
|
||||
const shapeSvg = shapes
|
||||
.map((s) => {
|
||||
if (s.type === "circle") {
|
||||
return `<circle cx="${s.cx}" cy="${s.cy}" r="${s.r}" fill="white" opacity="${s.opacity}"/>`;
|
||||
}
|
||||
if (s.type === "rect") {
|
||||
return `<rect x="${s.x}" y="${s.y}" width="${s.w}" height="${s.h}" transform="rotate(${s.rot} ${s.x + s.w / 2} ${s.y + s.h / 2})" fill="white" opacity="${s.opacity}"/>`;
|
||||
}
|
||||
return `<polygon points="${s.points}" fill="white" opacity="${s.opacity}"/>`;
|
||||
})
|
||||
.join("\n ");
|
||||
|
||||
// 角色副标识
|
||||
const roleBadge = isPasserby
|
||||
? `<rect x="20" y="20" width="92" height="28" rx="14" fill="rgba(255,255,255,0.25)"/>
|
||||
<text x="66" y="38" text-anchor="middle" fill="white" font-size="13" font-weight="600" font-family="system-ui">社区观察者</text>`
|
||||
: `<rect x="20" y="20" width="92" height="28" rx="14" fill="rgba(255,255,255,0.25)"/>
|
||||
<text x="66" y="38" text-anchor="middle" fill="white" font-size="13" font-weight="600" font-family="system-ui">行业专家</text>`;
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="${palette.from}"/>
|
||||
<stop offset="100%" stop-color="${palette.to}"/>
|
||||
</linearGradient>
|
||||
<filter id="shadow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="6"/>
|
||||
<feOffset dx="0" dy="4" result="offsetblur"/>
|
||||
<feComponentTransfer><feFuncA type="linear" slope="0.35"/></feComponentTransfer>
|
||||
<feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<!-- 背景渐变 -->
|
||||
<rect width="512" height="512" fill="url(#bg)"/>
|
||||
<!-- 装饰图形 -->
|
||||
${shapeSvg}
|
||||
<!-- 角色徽章 -->
|
||||
${roleBadge}
|
||||
<!-- 中央大字母 -->
|
||||
<text x="256" y="320" text-anchor="middle" fill="white" font-size="280" font-weight="800" font-family="system-ui,-apple-system,sans-serif" filter="url(#shadow)">${escapeXml(initial)}</text>
|
||||
<!-- 右下角 🤖 标识(用 path 画,不依赖 emoji 字体) -->
|
||||
<g transform="translate(440 440)">
|
||||
<circle cx="0" cy="0" r="32" fill="white" opacity="0.95"/>
|
||||
<path d="M-12 -4 L-12 8 Q-12 14 -6 14 L6 14 Q12 14 12 8 L12 -4 Q12 -10 6 -10 L-6 -10 Q-12 -10 -12 -4 Z" fill="${palette.from}"/>
|
||||
<circle cx="-4" cy="0" r="2" fill="white"/>
|
||||
<circle cx="4" cy="0" r="2" fill="white"/>
|
||||
<line x1="-6" y1="6" x2="6" y2="6" stroke="white" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<line x1="-8" y1="-12" x2="-12" y2="-16" stroke="${palette.from}" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="0" y1="-14" x2="0" y2="-20" stroke="${palette.from}" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="8" y1="-12" x2="12" y2="-16" stroke="${palette.from}" stroke-width="2" stroke-linecap="round"/>
|
||||
<circle cx="-12" cy="-16" r="2" fill="${palette.from}"/>
|
||||
<circle cx="0" cy="-20" r="2" fill="${palette.from}"/>
|
||||
<circle cx="12" cy="-16" r="2" fill="${palette.from}"/>
|
||||
</g>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function escapeXml(s) {
|
||||
return String(s).replace(/[<>&'"]/g, (c) => ({ "<": "<", ">": ">", "&": "&", "'": "'", '"': """ }[c]));
|
||||
}
|
||||
|
||||
// ---------- 主流程 ----------
|
||||
|
||||
/**
|
||||
* 给单个 bot 生成头像
|
||||
* @returns {avatarUrl, source, key, bytes}
|
||||
*/
|
||||
export async function generateForBot(character, { force = false, useLunarisOnly = false, saveLunaris = true } = {}) {
|
||||
if (!existsSync(AVATAR_DIR)) mkdirSync(AVATAR_DIR, { recursive: true });
|
||||
if (saveLunaris && !existsSync(LUNARIS_DIR)) mkdirSync(LUNARIS_DIR, { recursive: true });
|
||||
|
||||
const svgPath = resolve(AVATAR_DIR, `${character.key}.svg`);
|
||||
const lunarisPath = resolve(LUNARIS_DIR, `${character.key}.jpg`);
|
||||
const publicSvgUrl = `/bot-avatars/${character.key}.svg`;
|
||||
|
||||
// 已有 SVG 且不强制 → 跳过
|
||||
if (!force && existsSync(svgPath)) {
|
||||
return { key: character.key, source: "existing-svg", avatarUrl: publicSvgUrl, bytes: readFileSync(svgPath).byteLength };
|
||||
}
|
||||
|
||||
// 步骤 1: 调 Lunaris(顺便存档 lunaris/{key}.jpg)
|
||||
let lunarisBuf = null;
|
||||
let lunarisIsDefault = true;
|
||||
try {
|
||||
lunarisBuf = await fetchLunarisImage(character.avatarPrompt || "");
|
||||
lunarisIsDefault = await isLunarisDefault(lunarisBuf.buffer);
|
||||
if (saveLunaris) writeFileSync(lunarisPath, lunarisBuf.buffer);
|
||||
} catch (err) {
|
||||
console.warn(`[${character.key}] Lunaris 拉取失败: ${err.message}`);
|
||||
}
|
||||
|
||||
// 步骤 2: 决定最终使用
|
||||
if (useLunarisOnly && lunarisBuf && !lunarisIsDefault) {
|
||||
// 未来 Lunaris 真支持按 prompt 生成时启用
|
||||
const jpgPath = resolve(AVATAR_DIR, `${character.key}.jpg`);
|
||||
writeFileSync(jpgPath, lunarisBuf.buffer);
|
||||
return { key: character.key, source: "lunaris", avatarUrl: `/bot-avatars/${character.key}.jpg`, bytes: lunarisBuf.buffer.byteLength };
|
||||
}
|
||||
|
||||
// 步骤 3: 用 SVG 生成(覆盖 Lunaris 行为或作为兜底)
|
||||
const svg = generateSvgAvatar({
|
||||
key: character.key,
|
||||
displayName: character.displayName,
|
||||
persona: character.personality || {},
|
||||
});
|
||||
writeFileSync(svgPath, svg, "utf-8");
|
||||
return {
|
||||
key: character.key,
|
||||
source: lunarisIsDefault ? "svg-fallback" : "svg-preferred",
|
||||
avatarUrl: publicSvgUrl,
|
||||
bytes: Buffer.byteLength(svg, "utf-8"),
|
||||
lunarisIsDefault,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 给所有 bot 批量生成
|
||||
*/
|
||||
export async function generateForAll({ force = false, botKey = null, useLunarisOnly = false, delayMs = 200 } = {}) {
|
||||
const all = JSON.parse(readFileSync(BOT_DATA_PATH, "utf-8")).characters || [];
|
||||
const targets = botKey ? all.filter((c) => c.key === botKey) : all;
|
||||
if (botKey && targets.length === 0) throw new Error(`Bot "${botKey}" 不在 bot-characters.json 中`);
|
||||
|
||||
const results = [];
|
||||
for (const ch of targets) {
|
||||
try {
|
||||
const r = await generateForBot(ch, { force, useLunarisOnly });
|
||||
// 写库
|
||||
const user = await prisma.user.findFirst({ where: { email: ch.email } });
|
||||
if (user) {
|
||||
await prisma.user.update({ where: { id: user.id }, data: { avatarUrl: r.avatarUrl } });
|
||||
r.userId = user.id;
|
||||
r.dbUpdated = true;
|
||||
} else {
|
||||
r.dbUpdated = false;
|
||||
r.warn = `User ${ch.email} 不存在`;
|
||||
}
|
||||
results.push(r);
|
||||
console.log(`✅ ${ch.key} (${ch.displayName}) → ${r.avatarUrl} [${r.source}, ${r.bytes}B]`);
|
||||
} catch (err) {
|
||||
results.push({ key: ch.key, error: err.message });
|
||||
console.error(`❌ ${ch.key}: ${err.message}`);
|
||||
}
|
||||
if (delayMs > 0) await new Promise((r) => setTimeout(r, delayMs));
|
||||
}
|
||||
|
||||
// 写 manifest
|
||||
const manifest = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
total: results.length,
|
||||
succeeded: results.filter((r) => !r.error).length,
|
||||
failed: results.filter((r) => r.error).length,
|
||||
avatars: results.map((r) => ({
|
||||
key: r.key,
|
||||
avatarUrl: r.avatarUrl,
|
||||
source: r.source,
|
||||
bytes: r.bytes,
|
||||
})),
|
||||
};
|
||||
writeFileSync(resolve(AVATAR_DIR, "manifest.json"), JSON.stringify(manifest, null, 2), "utf-8");
|
||||
|
||||
return { summary: { total: results.length, ok: manifest.succeeded, failed: manifest.failed }, results };
|
||||
}
|
||||
|
||||
export async function disconnect() {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
@@ -0,0 +1,680 @@
|
||||
// Bot Persona A/B Testing 共享模块
|
||||
// 用法:
|
||||
// import { ensureBotVariants, pickAndAssignVariant, recordAssignmentMetrics } from "./lib/bot-persona-experiment.mjs";
|
||||
//
|
||||
// // 1) 给 bot 准备至少 2 个变体(首次自动创建 control + variant_a)
|
||||
// await ensureBotVariants(botConfigId, botUserId, persona);
|
||||
//
|
||||
// // 2) 发帖/回复时分流,记录 assignment
|
||||
// const { variant, assignment } = await pickAndAssignVariant(botConfigId, "topic", topicId, forumSlug);
|
||||
//
|
||||
// // 3) 指标采集(在 cron 里调用)
|
||||
// await recordAssignmentMetrics();
|
||||
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||
import "dotenv/config";
|
||||
|
||||
const base = (process.env.DATABASE_URL || "").replace("mysql://", "mariadb://");
|
||||
const sep = base.includes("?") ? "&" : "?";
|
||||
const connectionString = `${base}${sep}connection_limit=3&pool_timeout=10`;
|
||||
|
||||
let _prisma = null;
|
||||
function getPrisma() {
|
||||
if (!_prisma) {
|
||||
const adapter = new PrismaMariaDb(connectionString);
|
||||
_prisma = new PrismaClient({ adapter });
|
||||
}
|
||||
return _prisma;
|
||||
}
|
||||
|
||||
function ts() {
|
||||
return `[${new Date().toISOString()}]`;
|
||||
}
|
||||
|
||||
function pick(arr) {
|
||||
return arr[Math.floor(Math.random() * arr.length)];
|
||||
}
|
||||
|
||||
function weightedPick(items, weightKey = "weight") {
|
||||
if (!items || items.length === 0) return null;
|
||||
const active = items.filter((i) => i.isActive);
|
||||
const pool = active.length > 0 ? active : items;
|
||||
const total = pool.reduce((s, x) => s + Math.max(0, x[weightKey] || 0), 0);
|
||||
if (total <= 0) return pool[0];
|
||||
let r = Math.random() * total;
|
||||
for (const it of pool) {
|
||||
r -= Math.max(0, it[weightKey] || 0);
|
||||
if (r <= 0) return it;
|
||||
}
|
||||
return pool[pool.length - 1];
|
||||
}
|
||||
|
||||
// ======== 变体生成 ========
|
||||
// 根据现有 persona 派生 2 个变体
|
||||
// - control: 保留原始行为(无 styleHints)
|
||||
// - variant_a: "口语化 + 故事化" 风格
|
||||
// - variant_b: "数据驱动 + 简洁" 风格
|
||||
// - variant_c(可选): "反主流观点 + 提问引导" 风格
|
||||
const VARIANT_PRESETS = [
|
||||
{
|
||||
variantKey: "control",
|
||||
label: "对照组(原版画像)",
|
||||
description: "保留原始 persona 行为,作为 A/B 测试的基准",
|
||||
isControl: true,
|
||||
weight: 0.4,
|
||||
styleHints: {},
|
||||
},
|
||||
{
|
||||
variantKey: "variant_a",
|
||||
label: "A · 口语化+故事化",
|
||||
description: "更接地气、加入小故事、问号更多,更像真人聊天",
|
||||
isControl: false,
|
||||
weight: 0.3,
|
||||
styleHints: {
|
||||
tone: "casual",
|
||||
questionBoost: 0.2,
|
||||
storyBoost: 0.3,
|
||||
maxLength: 400,
|
||||
forbid: ["综上所述", "从以下几个方面", "首先其次最后", "不可忽视"],
|
||||
},
|
||||
},
|
||||
{
|
||||
variantKey: "variant_b",
|
||||
label: "B · 数据驱动+简洁",
|
||||
description: "多用数据/数字,结构化表达,字数更短",
|
||||
isControl: false,
|
||||
weight: 0.3,
|
||||
styleHints: {
|
||||
tone: "data_driven",
|
||||
questionBoost: -0.2,
|
||||
dataBoost: 0.4,
|
||||
maxLength: 280,
|
||||
requireData: true,
|
||||
forbid: ["综上所述", "从以下几个方面", "首先其次最后"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 给一个 bot 创建默认变体(如果还没有)
|
||||
* - 读现有 BotPersona
|
||||
* - 给 stanceKeywords、topTopicTypes 做变体化处理
|
||||
* - 全部 upsert 到 bot_persona_variants 表
|
||||
* @returns { variants: [...], created: number }
|
||||
*/
|
||||
export async function ensureBotVariants(botConfigId, botUserId) {
|
||||
const prisma = getPrisma();
|
||||
const existing = await prisma.botPersonaVariant.findMany({
|
||||
where: { botId: botConfigId },
|
||||
});
|
||||
if (existing.length > 0) {
|
||||
return { variants: existing, created: 0 };
|
||||
}
|
||||
|
||||
const persona = await prisma.botPersona.findUnique({ where: { botId: botConfigId } });
|
||||
const baseStance = (persona?.stanceKeywords || []).map((k) => k.key).slice(0, 12);
|
||||
const baseTopicTypes = (persona?.topTopicTypes || []).map((t) => t.name).slice(0, 5);
|
||||
|
||||
const created = [];
|
||||
for (const preset of VARIANT_PRESETS) {
|
||||
// 不同变体微调关键词:control 全保留;A 加上情绪/故事关键词;B 加上数据关键词
|
||||
const keywords = [...baseStance];
|
||||
if (preset.variantKey === "variant_a") {
|
||||
keywords.push("经历", "故事", "身边", "我", "朋友", "当时", "后来");
|
||||
} else if (preset.variantKey === "variant_b") {
|
||||
keywords.push("数据", "比例", "增长", "对比", "案例", "%", "GMV");
|
||||
}
|
||||
|
||||
const v = await prisma.botPersonaVariant.create({
|
||||
data: {
|
||||
botId: botConfigId,
|
||||
variantKey: preset.variantKey,
|
||||
label: preset.label,
|
||||
description: preset.description,
|
||||
isControl: preset.isControl,
|
||||
isActive: true,
|
||||
weight: preset.weight,
|
||||
stanceKeywords: Array.from(new Set(keywords)).slice(0, 15),
|
||||
topTopicTypes: baseTopicTypes,
|
||||
topHumanUsers: null,
|
||||
styleHints: preset.styleHints,
|
||||
},
|
||||
});
|
||||
created.push(v);
|
||||
}
|
||||
|
||||
return { variants: created, created: created.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* 为某个 bot 启动一个 A/B 实验(默认包含 control + variant_a + variant_b)
|
||||
* - 多次调用安全:若已存在 status=active 的实验,复用
|
||||
* - 若已有变体但未挂到实验,挂到该实验
|
||||
*/
|
||||
export async function ensureExperiment(botConfigId) {
|
||||
const prisma = getPrisma();
|
||||
const existing = await prisma.botPersonaExperiment.findFirst({
|
||||
where: { botId: botConfigId, status: "active" },
|
||||
});
|
||||
if (existing) return existing;
|
||||
|
||||
const exp = await prisma.botPersonaExperiment.create({
|
||||
data: {
|
||||
botId: botConfigId,
|
||||
name: "persona A/B",
|
||||
description: "自动为 bot 启动的 persona A/B 测试:control vs variant_a(口语+故事) vs variant_b(数据+简洁)",
|
||||
status: "active",
|
||||
minSamplesPerArm: 20,
|
||||
significanceLevel: 0.1,
|
||||
primaryMetric: "engagement_score",
|
||||
startedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// 把该 bot 已有变体挂到实验
|
||||
await prisma.botPersonaVariant.updateMany({
|
||||
where: { botId: botConfigId, experimentId: null },
|
||||
data: { experimentId: exp.id },
|
||||
});
|
||||
|
||||
return exp;
|
||||
}
|
||||
|
||||
/**
|
||||
* 给某条内容(topic / post)挑选一个变体并记录 assignment
|
||||
* @param botConfigId
|
||||
* @param refType "topic" | "post"
|
||||
* @param refId topicId / postId(必须 > 0;先选变体再创建内容时使用 pickVariantForPrompt)
|
||||
* @param forumSlug 可选
|
||||
* @returns { variant, assignment, personaBlock }
|
||||
*/
|
||||
export async function pickAndAssignVariant(botConfigId, refType, refId, forumSlug = null) {
|
||||
const prisma = getPrisma();
|
||||
|
||||
// 1) 确保变体存在
|
||||
const { variants } = await ensureBotVariants(botConfigId);
|
||||
if (variants.length === 0) {
|
||||
return { variant: null, assignment: null, personaBlock: "" };
|
||||
}
|
||||
|
||||
// 2) 选变体(按 weight)
|
||||
const variant = weightedPick(variants, "weight");
|
||||
if (!variant) {
|
||||
return { variant: null, assignment: null, personaBlock: "" };
|
||||
}
|
||||
|
||||
// 3) 写 assignment(unique key 保护)
|
||||
const assignment = await prisma.botPersonaAssignment.upsert({
|
||||
where: { refType_refId: { refType, refId } },
|
||||
create: {
|
||||
botId: botConfigId,
|
||||
variantId: variant.id,
|
||||
refType,
|
||||
refId,
|
||||
forumSlug,
|
||||
},
|
||||
update: {
|
||||
// 同一 ref 已存在,不覆盖 variant(保持首次分流)
|
||||
botId: botConfigId,
|
||||
},
|
||||
});
|
||||
|
||||
// 4) 变体 sample + 1
|
||||
await prisma.botPersonaVariant.update({
|
||||
where: { id: variant.id },
|
||||
data: { sampleCount: { increment: 1 } },
|
||||
});
|
||||
|
||||
return {
|
||||
variant,
|
||||
assignment,
|
||||
personaBlock: buildVariantPersonaBlock(variant),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 轻量版:只选变体 + 返回 personaBlock,**不写库**
|
||||
* 适用于"先生成内容再创建记录的顺序"场景(如先调用 LLM 再 createTopic)
|
||||
* 配合 commitAssignment(botConfigId, refType, refId, variantId) 在内容创建后落盘
|
||||
*/
|
||||
export async function pickVariantForPrompt(botConfigId) {
|
||||
const prisma = getPrisma();
|
||||
const { variants } = await ensureBotVariants(botConfigId);
|
||||
if (variants.length === 0) {
|
||||
return { variant: null, personaBlock: "" };
|
||||
}
|
||||
const variant = weightedPick(variants, "weight");
|
||||
if (!variant) {
|
||||
return { variant: null, personaBlock: "" };
|
||||
}
|
||||
return {
|
||||
variant,
|
||||
personaBlock: buildVariantPersonaBlock(variant),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交 assignment(轻量版的配套写入)
|
||||
* - 幂等:同一 (refType, refId) 多次调用不会变 variant
|
||||
* - 会自增变体 sampleCount
|
||||
*/
|
||||
export async function commitAssignment(botConfigId, variantId, refType, refId, forumSlug = null) {
|
||||
if (!variantId || !refId) return null;
|
||||
const prisma = getPrisma();
|
||||
const assignment = await prisma.botPersonaAssignment.upsert({
|
||||
where: { refType_refId: { refType, refId } },
|
||||
create: {
|
||||
botId: botConfigId,
|
||||
variantId,
|
||||
refType,
|
||||
refId,
|
||||
forumSlug,
|
||||
},
|
||||
update: { botId: botConfigId },
|
||||
});
|
||||
await prisma.botPersonaVariant.update({
|
||||
where: { id: variantId },
|
||||
data: { sampleCount: { increment: 1 } },
|
||||
});
|
||||
return assignment;
|
||||
}
|
||||
|
||||
// 把变体的 styleHints 格式化成可注入 prompt 的 block
|
||||
export function buildVariantPersonaBlock(variant) {
|
||||
if (!variant) return "";
|
||||
const hints = variant.styleHints || {};
|
||||
const parts = [];
|
||||
|
||||
parts.push(`[A/B 变体 ${variant.variantKey} · ${variant.label}]`);
|
||||
|
||||
if (hints.tone) {
|
||||
const toneMap = {
|
||||
casual: "语气:口语化、接地气",
|
||||
data_driven: "语气:数据驱动、引用具体数字",
|
||||
contrarian: "语气:反主流观点、犀利但有理",
|
||||
};
|
||||
parts.push(toneMap[hints.tone] || `语气:${hints.tone}`);
|
||||
}
|
||||
if (typeof hints.maxLength === "number") {
|
||||
parts.push(`字数上限:约 ${hints.maxLength} 字`);
|
||||
}
|
||||
if (hints.questionBoost) {
|
||||
parts.push(`问号倾向:${hints.questionBoost > 0 ? "略多问号" : "少用问句"}`);
|
||||
}
|
||||
if (hints.dataBoost) parts.push("多用数据/数字");
|
||||
if (hints.storyBoost) parts.push("多讲小故事/经历");
|
||||
if (Array.isArray(hints.forbid) && hints.forbid.length > 0) {
|
||||
parts.push(`避免:${hints.forbid.join("、")}`);
|
||||
}
|
||||
if (variant.stanceKeywords && variant.stanceKeywords.length > 0) {
|
||||
parts.push(`关注关键词:${variant.stanceKeywords.slice(0, 8).join("、")}`);
|
||||
}
|
||||
|
||||
return `\n${parts.join("\n")}\n`;
|
||||
}
|
||||
|
||||
// ======== 指标采集 ========
|
||||
/**
|
||||
* 把每条 assignment 的实际互动(replyCount/likeCount/humanReplies)回填,
|
||||
* 并按 (variantId, date) 写入 bot_persona_metrics 聚合
|
||||
* - 默认只看过去 7 天的 assignment(性能保护)
|
||||
*/
|
||||
export async function recordAssignmentMetrics(options = {}) {
|
||||
const prisma = getPrisma();
|
||||
const lookbackDays = options.lookbackDays ?? 7;
|
||||
const since = new Date(Date.now() - lookbackDays * 24 * 60 * 60 * 1000);
|
||||
|
||||
// 1) 拉所有变体(活跃实验)
|
||||
const variants = await prisma.botPersonaVariant.findMany({
|
||||
where: { isActive: true },
|
||||
select: { id: true, botId: true },
|
||||
});
|
||||
if (variants.length === 0) return { updated: 0 };
|
||||
|
||||
const variantIds = variants.map((v) => v.id);
|
||||
const assignments = await prisma.botPersonaAssignment.findMany({
|
||||
where: {
|
||||
variantId: { in: variantIds },
|
||||
createdAt: { gte: since },
|
||||
},
|
||||
});
|
||||
|
||||
if (assignments.length === 0) return { updated: 0 };
|
||||
|
||||
// 2) 按 refType 拆开批量查真实互动数
|
||||
const topicIds = assignments.filter((a) => a.refType === "topic").map((a) => a.refId);
|
||||
const postIds = assignments.filter((a) => a.refType === "post").map((a) => a.refId);
|
||||
|
||||
// topic: 真实 replyCount / likeCount / 真人 reply 数
|
||||
const topicStats = await prisma.forumTopic.findMany({
|
||||
where: { id: { in: topicIds.length > 0 ? topicIds : [-1] } },
|
||||
select: {
|
||||
id: true,
|
||||
replyCount: true,
|
||||
likeCount: true,
|
||||
viewCount: true,
|
||||
posts: {
|
||||
where: { user: { isBot: false } },
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
const topicMap = new Map(
|
||||
topicStats.map((t) => [
|
||||
t.id,
|
||||
{
|
||||
replies: t.replyCount || 0,
|
||||
likes: t.likeCount || 0,
|
||||
impressions: t.viewCount || 0,
|
||||
humanReplies: t.posts.length,
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
// post: 真实 likeCount
|
||||
const postStats = await prisma.forumPost.findMany({
|
||||
where: { id: { in: postIds.length > 0 ? postIds : [-1] } },
|
||||
select: { id: true, likeCount: true, topic: { select: { id: true } } },
|
||||
});
|
||||
const postMap = new Map(postStats.map((p) => [p.id, p.likeCount || 0]));
|
||||
|
||||
// 对 post 型 assignment,人工统计"该 post 之后同 topic 的新回复":用 topicId+postCreatedAt 之后的人类回复数
|
||||
// 简化:取 topic 的总 replyCount / totalPosts 数 - 该 post 之前的 - 1 = 该 post 之后的新回复
|
||||
// 为简化与一致性:post 的 replies/humanReplies 用同 topic 下的总数估算,impressions=0
|
||||
// (post 的真实互动其实主要看 likeCount)
|
||||
|
||||
// 3) 写回 assignment
|
||||
let updated = 0;
|
||||
for (const a of assignments) {
|
||||
let replies = 0;
|
||||
let likes = 0;
|
||||
let impressions = 0;
|
||||
let humanReplies = 0;
|
||||
if (a.refType === "topic" && topicMap.has(a.refId)) {
|
||||
const t = topicMap.get(a.refId);
|
||||
replies = t.replies;
|
||||
likes = t.likes;
|
||||
impressions = t.impressions;
|
||||
humanReplies = t.humanReplies;
|
||||
} else if (a.refType === "post") {
|
||||
likes = postMap.get(a.refId) || 0;
|
||||
// post 的回复/曝光粗略处理:0
|
||||
replies = 0;
|
||||
humanReplies = 0;
|
||||
impressions = 0;
|
||||
}
|
||||
if (
|
||||
replies !== a.replies ||
|
||||
likes !== a.likes ||
|
||||
humanReplies !== a.humanReplies ||
|
||||
impressions !== a.impressions
|
||||
) {
|
||||
await prisma.botPersonaAssignment.update({
|
||||
where: { id: a.id },
|
||||
data: { replies, likes, humanReplies, impressions },
|
||||
});
|
||||
updated++;
|
||||
}
|
||||
}
|
||||
|
||||
// 4) 写入按 (variantId, date) 聚合的 metrics
|
||||
const dayBuckets = new Map(); // key: `${variantId}|${yyyy-mm-dd}` -> { variantId, date, contentCount, replies, likes, humanReplies, impressions }
|
||||
for (const a of assignments) {
|
||||
const dateStr = a.createdAt.toISOString().slice(0, 10);
|
||||
const key = `${a.variantId}|${dateStr}`;
|
||||
if (!dayBuckets.has(key)) {
|
||||
dayBuckets.set(key, {
|
||||
variantId: a.variantId,
|
||||
date: new Date(`${dateStr}T00:00:00.000Z`),
|
||||
contentCount: 0,
|
||||
replies: 0,
|
||||
likes: 0,
|
||||
humanReplies: 0,
|
||||
impressions: 0,
|
||||
});
|
||||
}
|
||||
const b = dayBuckets.get(key);
|
||||
b.contentCount += 1;
|
||||
b.replies += a.replies;
|
||||
b.likes += a.likes;
|
||||
b.humanReplies += a.humanReplies;
|
||||
b.impressions += a.impressions;
|
||||
}
|
||||
|
||||
for (const b of dayBuckets.values()) {
|
||||
const avgReplies = b.contentCount > 0 ? b.replies / b.contentCount : 0;
|
||||
const avgLikes = b.contentCount > 0 ? b.likes / b.contentCount : 0;
|
||||
// 综合得分:平均点赞×3 + 真人回复×5 + 平均回复×1
|
||||
const computedScore = avgLikes * 3 + b.humanReplies / Math.max(1, b.contentCount) * 5 + avgReplies * 1;
|
||||
await prisma.botPersonaMetric.upsert({
|
||||
where: { variantId_date: { variantId: b.variantId, date: b.date } },
|
||||
create: {
|
||||
variantId: b.variantId,
|
||||
date: b.date,
|
||||
impressions: b.impressions,
|
||||
replies: b.replies,
|
||||
likes: b.likes,
|
||||
humanReplies: b.humanReplies,
|
||||
contentCount: b.contentCount,
|
||||
avgReplies,
|
||||
avgLikes,
|
||||
computedScore,
|
||||
},
|
||||
update: {
|
||||
impressions: b.impressions,
|
||||
replies: b.replies,
|
||||
likes: b.likes,
|
||||
humanReplies: b.humanReplies,
|
||||
contentCount: b.contentCount,
|
||||
avgReplies,
|
||||
avgLikes,
|
||||
computedScore,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 5) 回写变体的累计统计(最近 7 天 assignment 滚动聚合)
|
||||
const variantAgg = new Map();
|
||||
for (const a of assignments) {
|
||||
if (!variantAgg.has(a.variantId)) {
|
||||
variantAgg.set(a.variantId, { sampleCount: 0, replyCount: 0, likeCount: 0, humanReplyCount: 0 });
|
||||
}
|
||||
const v = variantAgg.get(a.variantId);
|
||||
v.sampleCount += 1;
|
||||
v.replyCount += a.replies;
|
||||
v.likeCount += a.likes;
|
||||
v.humanReplyCount += a.humanReplies;
|
||||
}
|
||||
for (const [variantId, agg] of variantAgg.entries()) {
|
||||
// 综合分:平均每篇互动 = (收赞 + 收真人回复 + 收回复) / 样本
|
||||
const score = agg.sampleCount > 0
|
||||
? (agg.likeCount * 3 + agg.humanReplyCount * 5 + agg.replyCount) / agg.sampleCount
|
||||
: 0;
|
||||
await prisma.botPersonaVariant.update({
|
||||
where: { id: variantId },
|
||||
data: {
|
||||
sampleCount: agg.sampleCount,
|
||||
replyCount: agg.replyCount,
|
||||
likeCount: agg.likeCount,
|
||||
humanReplyCount: agg.humanReplyCount,
|
||||
engagementScore: Math.round(score * 100) / 100,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { updated, assignments: assignments.length, dayBuckets: dayBuckets.size };
|
||||
}
|
||||
|
||||
// ======== 分析 / 变体切换 ========
|
||||
/**
|
||||
* 对单个 bot 的所有变体做显著性分析
|
||||
* - 输入:变体列表(含 sampleCount/replyCount/likeCount/humanReplyCount/engagementScore)
|
||||
* - 输出:winnerVariantKey / analysis 详情
|
||||
* 策略:双比例 z 检验(高斯近似),比较每个 variant vs control 的互动率((replies+likes+humanReplies)/sampleCount)
|
||||
*/
|
||||
export function analyzeBotVariants(variants) {
|
||||
if (!variants || variants.length === 0) {
|
||||
return { winner: null, reason: "no_variants", comparisons: [] };
|
||||
}
|
||||
const control = variants.find((v) => v.isControl) || variants[0];
|
||||
const others = variants.filter((v) => v.id !== control.id);
|
||||
|
||||
if (control.sampleCount < 1) {
|
||||
return { winner: null, reason: "control_no_samples", comparisons: [] };
|
||||
}
|
||||
|
||||
const controlRate = (control.likeCount * 3 + control.humanReplyCount * 5 + control.replyCount) / control.sampleCount;
|
||||
const controlN = control.sampleCount;
|
||||
|
||||
const comparisons = others.map((v) => {
|
||||
if (v.sampleCount < 1) {
|
||||
return { variant: v, rate: 0, pValue: null, significant: false, winner: false };
|
||||
}
|
||||
const rate = (v.likeCount * 3 + v.humanReplyCount * 5 + v.replyCount) / v.sampleCount;
|
||||
const n = v.sampleCount;
|
||||
// pooled proportion
|
||||
const pooled = (control.likeCount * 3 + control.humanReplyCount * 5 + control.replyCount + v.likeCount * 3 + v.humanReplyCount * 5 + v.replyCount) / (controlN + n);
|
||||
const se = Math.sqrt(pooled * (1 - pooled) * (1 / controlN + 1 / n));
|
||||
let z = 0;
|
||||
let p = 1;
|
||||
if (se > 0) {
|
||||
z = (rate - controlRate) / se;
|
||||
// 双侧检验
|
||||
p = 2 * (1 - normalCdf(Math.abs(z)));
|
||||
}
|
||||
return {
|
||||
variant: v,
|
||||
rate: Math.round(rate * 1000) / 1000,
|
||||
controlRate: Math.round(controlRate * 1000) / 1000,
|
||||
zScore: Math.round(z * 100) / 100,
|
||||
pValue: Math.round(p * 1000) / 1000,
|
||||
significant: p < 0.1 && n >= 20, // significanceLevel=0.1, minSamples=20
|
||||
winner: false,
|
||||
};
|
||||
});
|
||||
|
||||
// 选 winner:所有"显著更优"的里面 p 最小 + sample 最多的
|
||||
const winners = comparisons.filter((c) => c.significant && c.rate > c.controlRate);
|
||||
let winner = null;
|
||||
if (winners.length > 0) {
|
||||
winners.sort((a, b) => a.pValue - b.pValue);
|
||||
winners[0].winner = true;
|
||||
winner = winners[0].variant;
|
||||
}
|
||||
|
||||
return {
|
||||
winner,
|
||||
winnerReason: winner
|
||||
? `variant ${winner.variantKey} 综合互动率显著高于 control (p=${winners[0].pValue})`
|
||||
: "no significant winner yet",
|
||||
control,
|
||||
comparisons,
|
||||
};
|
||||
}
|
||||
|
||||
// 标准正态分布 CDF(Abramowitz & Stegun 近似)
|
||||
function normalCdf(x) {
|
||||
const t = 1 / (1 + 0.2316419 * x);
|
||||
const d = 0.3989422804014327 * Math.exp(-x * x / 2);
|
||||
let p = d * t * (0.319381530 + t * (-0.356563782 + t * (1.781477937 + t * (-1.821255978 + t * 1.330274429))));
|
||||
return 1 - p;
|
||||
}
|
||||
|
||||
/**
|
||||
* 完整跑一遍分析 + 切换
|
||||
* - 遍历所有 bot
|
||||
* - 对每个 bot 调 analyzeBotVariants
|
||||
* - 若有 winner 显著更优,把实验标记为 completed
|
||||
* - 后续 bot 在选变体时仍会按 weight 选(不会自动锁死一个变体),但 recordAssignmentMetrics 会持续跑
|
||||
* - 还可以加:winner 出现后,把 winner 权重提到 1.0,其他降到 0
|
||||
*/
|
||||
export async function analyzeAndSwitchAllBots(options = {}) {
|
||||
const prisma = getPrisma();
|
||||
const onlyActive = options.onlyActive ?? true;
|
||||
|
||||
// 取所有有变体的 bot
|
||||
const variants = await prisma.botPersonaVariant.findMany({
|
||||
where: onlyActive ? { isActive: true } : undefined,
|
||||
include: { experiment: true },
|
||||
});
|
||||
const byBot = new Map();
|
||||
for (const v of variants) {
|
||||
if (!byBot.has(v.botId)) byBot.set(v.botId, []);
|
||||
byBot.get(v.botId).push(v);
|
||||
}
|
||||
|
||||
const results = [];
|
||||
for (const [botId, list] of byBot.entries()) {
|
||||
const analysis = analyzeBotVariants(list);
|
||||
if (analysis.winner) {
|
||||
const exp = list.find((v) => v.experimentId)?.experiment;
|
||||
if (exp && exp.status === "active") {
|
||||
// 标记实验完成
|
||||
await prisma.botPersonaExperiment.update({
|
||||
where: { id: exp.id },
|
||||
data: {
|
||||
status: "completed",
|
||||
winnerVariantKey: analysis.winner.variantKey,
|
||||
endedAt: new Date(),
|
||||
lastAnalyzedAt: new Date(),
|
||||
},
|
||||
});
|
||||
// 提升 winner 权重到 0.85,其他活跃变体降到 0.075
|
||||
await prisma.botPersonaVariant.update({
|
||||
where: { id: analysis.winner.id },
|
||||
data: { weight: 0.85 },
|
||||
});
|
||||
await prisma.botPersonaVariant.updateMany({
|
||||
where: {
|
||||
botId,
|
||||
id: { not: analysis.winner.id },
|
||||
experimentId: exp.id,
|
||||
},
|
||||
data: { weight: 0.075 },
|
||||
});
|
||||
results.push({
|
||||
botId,
|
||||
experimentId: exp.id,
|
||||
winner: analysis.winner.variantKey,
|
||||
reason: analysis.winnerReason,
|
||||
action: "switched_weights",
|
||||
});
|
||||
} else {
|
||||
results.push({
|
||||
botId,
|
||||
winner: analysis.winner.variantKey,
|
||||
reason: analysis.winnerReason,
|
||||
action: "no_active_experiment",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// 没有 winner,刷新 lastAnalyzedAt
|
||||
const exp = list.find((v) => v.experimentId)?.experiment;
|
||||
if (exp && exp.status === "active") {
|
||||
await prisma.botPersonaExperiment.update({
|
||||
where: { id: exp.id },
|
||||
data: { lastAnalyzedAt: new Date() },
|
||||
});
|
||||
}
|
||||
results.push({
|
||||
botId,
|
||||
winner: null,
|
||||
reason: analysis.winnerReason,
|
||||
action: "monitoring",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { botCount: byBot.size, results };
|
||||
}
|
||||
|
||||
/**
|
||||
* 优雅关闭
|
||||
*/
|
||||
export async function disconnectBotExperiment() {
|
||||
if (_prisma) {
|
||||
await _prisma.$disconnect();
|
||||
_prisma = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
// Bot 画像 (BotPersona) 共享模块
|
||||
// 从 bot-affinity-update.mjs 抽出,bot-activity.mjs 也能复用
|
||||
// 用法:
|
||||
// import { updatePersona, schedulePersonaRefresh } from "./lib/bot-persona.mjs";
|
||||
// await updatePersona(botUser, botConfig.id);
|
||||
// schedulePersonaRefresh(botUserId, botConfig.id); // 异步、防抖
|
||||
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||
import "dotenv/config";
|
||||
|
||||
const base = (process.env.DATABASE_URL || "").replace("mysql://", "mariadb://");
|
||||
const sep = base.includes("?") ? "&" : "?";
|
||||
const connectionString = `${base}${sep}connection_limit=2&pool_timeout=10`;
|
||||
|
||||
// 共享一个 Prisma 客户端(连接池小,只为后台异步刷新服务)
|
||||
let _prisma = null;
|
||||
function getPrisma() {
|
||||
if (!_prisma) {
|
||||
const adapter = new PrismaMariaDb(connectionString);
|
||||
_prisma = new PrismaClient({ adapter });
|
||||
}
|
||||
return _prisma;
|
||||
}
|
||||
|
||||
const PERSONA_LOOKBACK_DAYS = 30;
|
||||
const TOP_KEYWORDS = 12;
|
||||
const TOP_HUMAN_USERS = 5;
|
||||
const TOP_TOPIC_TYPES = 5;
|
||||
const LAST_TITLES_COUNT = 5;
|
||||
|
||||
const STOP_WORDS = new Set([
|
||||
"的", "了", "是", "在", "和", "与", "或", "也", "都", "就", "不", "没", "要", "我", "你", "他", "她", "它", "们",
|
||||
"这", "那", "有", "为", "到", "对", "及", "等", "把", "被", "从", "向", "以", "其", "之", "于", "上", "下",
|
||||
"里", "外", "中", "一个", "一些", "我们", "你们", "他们", "什么", "怎么", "为什么", "啊", "吗", "呢", "吧",
|
||||
"嗯", "哦", "哈", "啦", "嘿", "唉", "这个", "那个", "一个", "真的", "应该", "可能", "觉得", "认为",
|
||||
]);
|
||||
|
||||
function tokenize(text) {
|
||||
if (!text) return [];
|
||||
return text
|
||||
.replace(/[,。!?、;:""''【】《》()()\[\]·—\.\,\!\?\;\:\'\"\\\/]/g, " ")
|
||||
.split(/\s+/)
|
||||
.filter((w) => w.length >= 2 && !STOP_WORDS.has(w));
|
||||
}
|
||||
|
||||
function pickTopWithCount(arr, topN) {
|
||||
const counts = new Map();
|
||||
for (const item of arr) {
|
||||
if (!item) continue;
|
||||
counts.set(item, (counts.get(item) || 0) + 1);
|
||||
}
|
||||
return Array.from(counts.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, topN)
|
||||
.map(([k, v]) => ({ key: k, count: v }));
|
||||
}
|
||||
|
||||
function ts() {
|
||||
return `[${new Date().toISOString()}]`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取过去 N 天该 bot 的所有发帖/回复,统计画像数据
|
||||
* @returns persona object (or empty persona when no posts)
|
||||
*/
|
||||
export async function computePersona(botUser, botConfigId) {
|
||||
const prisma = getPrisma();
|
||||
const since = new Date(Date.now() - PERSONA_LOOKBACK_DAYS * 24 * 60 * 60 * 1000);
|
||||
const topics = await prisma.forumTopic.findMany({
|
||||
where: { userId: botUser.id, createdAt: { gt: since } },
|
||||
include: {
|
||||
category: { select: { name: true, slug: true } },
|
||||
posts: {
|
||||
include: { user: { select: { id: true, name: true, isBot: true } } },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
if (topics.length === 0) {
|
||||
return {
|
||||
avgReplyLength: 0,
|
||||
questionRatio: 0,
|
||||
exclamationCount: 0,
|
||||
avgTopicLength: 0,
|
||||
stanceKeywords: [],
|
||||
topHumanUsers: [],
|
||||
topTopicTypes: [],
|
||||
lastTopicTitles: [],
|
||||
postSampleCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const allPosts = [];
|
||||
for (const t of topics) {
|
||||
allPosts.push({
|
||||
content: t.content,
|
||||
isTopic: true,
|
||||
createdAt: t.createdAt,
|
||||
forumSlug: t.category.slug,
|
||||
authorId: t.userId,
|
||||
});
|
||||
for (const p of t.posts) {
|
||||
allPosts.push({
|
||||
content: p.content,
|
||||
isTopic: false,
|
||||
createdAt: p.createdAt,
|
||||
forumSlug: t.category.slug,
|
||||
authorId: p.userId,
|
||||
humanAuthor: !p.user.isBot ? p.user : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const myPosts = allPosts.filter((p) => p.authorId === botUser.id);
|
||||
const replyLengths = myPosts.filter((p) => !p.isTopic).map((p) => p.content.length);
|
||||
const topicLengths = myPosts.filter((p) => p.isTopic).map((p) => p.content.length);
|
||||
|
||||
const fullText = myPosts.map((p) => p.content).join(" ");
|
||||
const questionMarks = (fullText.match(/[??]/g) || []).length;
|
||||
const exclamationMarks = (fullText.match(/[!!]/g) || []).length;
|
||||
const questionRatio = myPosts.length > 0 ? questionMarks / myPosts.length : 0;
|
||||
|
||||
const keywords = tokenize(fullText);
|
||||
const stanceKeywords = pickTopWithCount(keywords, TOP_KEYWORDS);
|
||||
|
||||
const humanRepliers = allPosts
|
||||
.filter((p) => p.humanAuthor)
|
||||
.map((p) => ({ id: p.humanAuthor.id, name: p.humanAuthor.name || "匿名" }));
|
||||
const userCount = new Map();
|
||||
for (const h of humanRepliers) {
|
||||
const k = `${h.id}|${h.name}`;
|
||||
userCount.set(k, (userCount.get(k) || 0) + 1);
|
||||
}
|
||||
const topHumanUsers = Array.from(userCount.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, TOP_HUMAN_USERS)
|
||||
.map(([k, c]) => {
|
||||
const [id, name] = k.split("|");
|
||||
return { id: Number(id), name, count: c };
|
||||
});
|
||||
|
||||
const forumCount = new Map();
|
||||
for (const t of topics) {
|
||||
const k = `${t.category.slug}|${t.category.name}`;
|
||||
forumCount.set(k, (forumCount.get(k) || 0) + 1);
|
||||
}
|
||||
const topTopicTypes = Array.from(forumCount.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, TOP_TOPIC_TYPES)
|
||||
.map(([k, c]) => {
|
||||
const [slug, name] = k.split("|");
|
||||
return { slug, name, count: c };
|
||||
});
|
||||
|
||||
const lastTopicTitles = topics
|
||||
.slice(0, LAST_TITLES_COUNT)
|
||||
.map((t) => t.title);
|
||||
|
||||
return {
|
||||
avgReplyLength: replyLengths.length
|
||||
? Math.round(replyLengths.reduce((s, n) => s + n, 0) / replyLengths.length)
|
||||
: 0,
|
||||
questionRatio: Math.round(questionRatio * 100) / 100,
|
||||
exclamationCount: exclamationMarks,
|
||||
avgTopicLength: topicLengths.length
|
||||
? Math.round(topicLengths.reduce((s, n) => s + n, 0) / topicLengths.length)
|
||||
: 0,
|
||||
stanceKeywords,
|
||||
topHumanUsers,
|
||||
topTopicTypes,
|
||||
lastTopicTitles,
|
||||
postSampleCount: myPosts.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算并写回 BotPersona(同步版本,返回是否成功)
|
||||
* @param botUser - bot User 实体(含 id)
|
||||
* @param botConfigId - BotConfig.id
|
||||
* @returns { success: boolean, postSampleCount: number }
|
||||
*/
|
||||
export async function updatePersona(botUser, botConfigId) {
|
||||
const prisma = getPrisma();
|
||||
const persona = await computePersona(botUser, botConfigId);
|
||||
if (persona.postSampleCount === 0) {
|
||||
return { success: false, postSampleCount: 0 };
|
||||
}
|
||||
await prisma.botPersona.upsert({
|
||||
where: { botId: botConfigId },
|
||||
create: { botId: botConfigId, ...persona },
|
||||
update: persona,
|
||||
});
|
||||
return { success: true, postSampleCount: persona.postSampleCount };
|
||||
}
|
||||
|
||||
// ============ 异步防抖调度 ============
|
||||
// 每次发帖/回复后调用 schedulePersonaRefresh(botUserId, botConfigId):
|
||||
// - 同一 bot 在 COOLDOWN_MS 内多次触发,只跑一次(防抖)
|
||||
// - 失败时静默记录,不影响主流程
|
||||
const COOLDOWN_MS = 5 * 60 * 1000; // 5 分钟防抖
|
||||
const lastRunMap = new Map(); // botConfigId -> timestamp(ms)
|
||||
const inFlightSet = new Set(); // 正在跑 refresh 的 botConfigId
|
||||
|
||||
/**
|
||||
* 异步 + 防抖触发 persona 刷新
|
||||
* - 5 分钟内同一 bot 只跑一次
|
||||
* - 不阻塞调用方,立即返回
|
||||
* - 失败时 console.warn,不抛出
|
||||
* @returns true 表示本次触发了真实刷新,false 表示被防抖跳过
|
||||
*/
|
||||
export function schedulePersonaRefresh(botUserId, botConfigId, options = {}) {
|
||||
const cooldown = options.cooldownMs ?? COOLDOWN_MS;
|
||||
const now = Date.now();
|
||||
const last = lastRunMap.get(botConfigId) || 0;
|
||||
if (now - last < cooldown) {
|
||||
return false;
|
||||
}
|
||||
if (inFlightSet.has(botConfigId)) {
|
||||
return false;
|
||||
}
|
||||
inFlightSet.add(botConfigId);
|
||||
lastRunMap.set(botConfigId, now);
|
||||
|
||||
// 推迟到下一个 tick,避免阻塞发帖主流程
|
||||
setImmediate(async () => {
|
||||
try {
|
||||
const prisma = getPrisma();
|
||||
const botUser = await prisma.user.findUnique({ where: { id: botUserId } });
|
||||
if (!botUser) return;
|
||||
const result = await updatePersona(botUser, botConfigId);
|
||||
if (result.success) {
|
||||
console.log(
|
||||
`${ts()} 🪞 persona 异步刷新: bot#${botConfigId} (sample=${result.postSampleCount})`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`${ts()} ⚠️ persona 异步刷新失败 bot#${botConfigId}:`,
|
||||
err.message
|
||||
);
|
||||
} finally {
|
||||
inFlightSet.delete(botConfigId);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制立即刷新(绕开防抖),用于定时任务兜底
|
||||
*/
|
||||
export async function forcePersonaRefresh(botUserId, botConfigId) {
|
||||
const prisma = getPrisma();
|
||||
const botUser = await prisma.user.findUnique({ where: { id: botUserId } });
|
||||
if (!botUser) return { success: false, postSampleCount: 0 };
|
||||
return updatePersona(botUser, botConfigId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空防抖状态(测试 / 主流程退出时用)
|
||||
*/
|
||||
export function resetPersonaDebounce() {
|
||||
lastRunMap.clear();
|
||||
inFlightSet.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 优雅关闭 prisma 客户端(主进程退出前调用)
|
||||
*/
|
||||
export async function disconnectBotPersona() {
|
||||
if (_prisma) {
|
||||
await _prisma.$disconnect();
|
||||
_prisma = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import re
|
||||
import urllib.request
|
||||
|
||||
req = urllib.request.Request('https://www.zhuig.com/community', headers={'User-Agent': 'Mozilla/5.0', 'Cache-Control': 'no-cache'})
|
||||
html = urllib.request.urlopen(req, timeout=15).read().decode('utf-8')
|
||||
|
||||
# 找self.__next_f.push的script块
|
||||
push_pattern = re.compile(r'self\.__next_f\.push\(\[1,"(.*?)"\]\)', re.DOTALL)
|
||||
chunks = push_pattern.findall(html)
|
||||
print(f'RSC push 块数: {len(chunks)}')
|
||||
|
||||
# 解码第一块
|
||||
for i, chunk in enumerate(chunks):
|
||||
# 反转义
|
||||
decoded = chunk.encode().decode('unicode_escape')
|
||||
# 找"电商零售"在不在
|
||||
if '电商零售' in decoded or '平台电商' in decoded:
|
||||
print(f'\n=== Chunk {i} (长{len(decoded)}) 包含板块数据 ===')
|
||||
# 找电商零售位置
|
||||
idx = decoded.find('电商零售')
|
||||
print(f'电商零售位置: {idx}')
|
||||
print(f'周围200字符: {decoded[max(0,idx-50):idx+400]}')
|
||||
if i < 3:
|
||||
break
|
||||
@@ -0,0 +1,30 @@
|
||||
import re
|
||||
import urllib.request
|
||||
|
||||
req = urllib.request.Request('https://www.zhuig.com/community', headers={'User-Agent': 'Mozilla/5.0', 'Cache-Control': 'no-cache'})
|
||||
html = urllib.request.urlopen(req, timeout=15).read().decode('utf-8')
|
||||
|
||||
# 找所有 script 标签里的self.__next_f.push
|
||||
scripts = re.findall(r'<script[^>]*>(self\.__next_f\.push.*?)</script>', html, re.DOTALL)
|
||||
print(f'RSC push script 块数: {len(scripts)}')
|
||||
|
||||
for i, s in enumerate(scripts):
|
||||
# 第一个字符切片, 找$12声明
|
||||
if '"$12"' in s or '"$L12"' in s or '$12' in s:
|
||||
# 找"$12":开头的声明
|
||||
m = re.search(r'"\$?L?12":\s*"([^"]{0,500})', s)
|
||||
if m:
|
||||
print(f'\n=== Script {i} 含 $12 引用 (前500字符) ===')
|
||||
print(m.group(1)[:500])
|
||||
# 也找"children":"$12"
|
||||
m2 = re.search(r'__html":\s*"(\$12|\$L12)"', s)
|
||||
if m2:
|
||||
print(f' Script {i} 在 __html 用 $12')
|
||||
|
||||
# 找所有包含"平台电商"的script
|
||||
for i, s in enumerate(scripts):
|
||||
if '平台电商' in s or '电商零售' in s:
|
||||
idx = s.find('平台电商')
|
||||
if idx == -1: idx = s.find('电商零售')
|
||||
print(f'\n=== Script {i} 含"电商零售/平台电商" 位置{idx} ===')
|
||||
print(s[max(0,idx-30):idx+300])
|
||||
@@ -0,0 +1,163 @@
|
||||
// 对齐 forumTopic.likeCount / forumPost.likeCount 与真实 Like 表的计数
|
||||
// 用法:
|
||||
// node scripts/reconcile-like-counts.mjs # 仅巡检
|
||||
// node scripts/reconcile-like-counts.mjs --fix # 巡检 + 自动修正
|
||||
// node scripts/reconcile-like-counts.mjs --scope=topic|post|both
|
||||
// node scripts/reconcile-like-counts.mjs --bot-only # 只检查 bot 的话题/回复
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||
import "dotenv/config";
|
||||
|
||||
const base = process.env.DATABASE_URL.replace("mysql://", "mariadb://");
|
||||
const sep = base.includes("?") ? "&" : "?";
|
||||
const connectionString = `${base}${sep}connection_limit=5&pool_timeout=15`;
|
||||
const adapter = new PrismaMariaDb(connectionString);
|
||||
const prisma = new PrismaClient({ adapter });
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const fixMode = args.includes("--fix");
|
||||
const botOnly = args.includes("--bot-only");
|
||||
const scopeArg = args.find((a) => a.startsWith("--scope="));
|
||||
const scope = scopeArg ? scopeArg.split("=")[1] : "both";
|
||||
const limitArg = args.find((a) => a.startsWith("--limit="));
|
||||
const limit = limitArg ? parseInt(limitArg.split("=")[1], 10) : 5000;
|
||||
|
||||
function ts() {
|
||||
return `[${new Date().toISOString()}]`;
|
||||
}
|
||||
|
||||
async function reconcileTopics() {
|
||||
console.log(`\n${ts()} 巡检 forumTopic.likeCount ...`);
|
||||
const where = {};
|
||||
if (botOnly) where.user = { isBot: true };
|
||||
const all = await prisma.forumTopic.findMany({
|
||||
where,
|
||||
select: { id: true, likeCount: true, userId: true },
|
||||
take: limit,
|
||||
});
|
||||
console.log(` 范围: ${all.length} 个 topic${botOnly ? " (bot 限定)" : ""}`);
|
||||
|
||||
// 批量统计真实点赞数(按 topicId groupBy)
|
||||
const topicIds = all.map((t) => t.id);
|
||||
if (topicIds.length === 0) return { drift: 0, fixed: 0 };
|
||||
|
||||
const realCounts = await prisma.forumTopicLike.groupBy({
|
||||
by: ["topicId"],
|
||||
where: { topicId: { in: topicIds } },
|
||||
_count: { _all: true },
|
||||
});
|
||||
const realMap = new Map(realCounts.map((r) => [r.topicId, r._count._all]));
|
||||
|
||||
let drift = 0;
|
||||
let fixed = 0;
|
||||
const samples = [];
|
||||
for (const t of all) {
|
||||
const real = realMap.get(t.id) || 0;
|
||||
if (real !== t.likeCount) {
|
||||
drift++;
|
||||
if (samples.length < 8) {
|
||||
samples.push({ id: t.id, stored: t.likeCount, real });
|
||||
}
|
||||
if (fixMode) {
|
||||
await prisma.forumTopic.update({
|
||||
where: { id: t.id },
|
||||
data: { likeCount: real },
|
||||
});
|
||||
fixed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(` 偏差: ${drift}${fixMode ? `,已修正 ${fixed}` : ""}`);
|
||||
if (samples.length > 0) {
|
||||
console.log(` 示例(最多 8 条):`);
|
||||
for (const s of samples) {
|
||||
console.log(` topic ${s.id}: stored=${s.stored} 实际=${s.real}`);
|
||||
}
|
||||
}
|
||||
return { drift, fixed };
|
||||
}
|
||||
|
||||
async function reconcilePosts() {
|
||||
console.log(`\n${ts()} 巡检 forumPost.likeCount ...`);
|
||||
const where = {};
|
||||
if (botOnly) where.user = { isBot: true };
|
||||
const all = await prisma.forumPost.findMany({
|
||||
where,
|
||||
select: { id: true, likeCount: true, userId: true },
|
||||
take: limit,
|
||||
});
|
||||
console.log(` 范围: ${all.length} 个 post${botOnly ? " (bot 限定)" : ""}`);
|
||||
|
||||
const postIds = all.map((p) => p.id);
|
||||
if (postIds.length === 0) return { drift: 0, fixed: 0 };
|
||||
|
||||
const realCounts = await prisma.forumPostLike.groupBy({
|
||||
by: ["postId"],
|
||||
where: { postId: { in: postIds } },
|
||||
_count: { _all: true },
|
||||
});
|
||||
const realMap = new Map(realCounts.map((r) => [r.postId, r._count._all]));
|
||||
|
||||
let drift = 0;
|
||||
let fixed = 0;
|
||||
const samples = [];
|
||||
for (const p of all) {
|
||||
const real = realMap.get(p.id) || 0;
|
||||
if (real !== p.likeCount) {
|
||||
drift++;
|
||||
if (samples.length < 8) {
|
||||
samples.push({ id: p.id, stored: p.likeCount, real });
|
||||
}
|
||||
if (fixMode) {
|
||||
await prisma.forumPost.update({
|
||||
where: { id: p.id },
|
||||
data: { likeCount: real },
|
||||
});
|
||||
fixed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(` 偏差: ${drift}${fixMode ? `,已修正 ${fixed}` : ""}`);
|
||||
if (samples.length > 0) {
|
||||
console.log(` 示例(最多 8 条):`);
|
||||
for (const s of samples) {
|
||||
console.log(` post ${s.id}: stored=${s.stored} 实际=${s.real}`);
|
||||
}
|
||||
}
|
||||
return { drift, fixed };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`========== 点赞数对齐巡检 ==========`);
|
||||
console.log(`模式: ${fixMode ? "FIX(自动修正)" : "CHECK(仅巡检)"}`);
|
||||
console.log(`范围: ${scope}${botOnly ? " (仅 bot)" : ""}`);
|
||||
console.log(`扫描上限: ${limit}`);
|
||||
|
||||
const summary = { topic: { drift: 0, fixed: 0 }, post: { drift: 0, fixed: 0 } };
|
||||
if (scope === "topic" || scope === "both") {
|
||||
summary.topic = await reconcileTopics();
|
||||
}
|
||||
if (scope === "post" || scope === "both") {
|
||||
summary.post = await reconcilePosts();
|
||||
}
|
||||
|
||||
console.log(`\n========== 总结 ==========`);
|
||||
console.log(`话题偏差: ${summary.topic.drift}${fixMode ? `(已修正 ${summary.topic.fixed})` : ""}`);
|
||||
console.log(`回复偏差: ${summary.post.drift}${fixMode ? `(已修正 ${summary.post.fixed})` : ""}`);
|
||||
const totalDrift = summary.topic.drift + summary.post.drift;
|
||||
if (totalDrift === 0) {
|
||||
console.log(`\n✅ 全部对齐,无需修正`);
|
||||
} else if (fixMode) {
|
||||
console.log(`\n🔧 已完成修正`);
|
||||
} else {
|
||||
console.log(`\n⚠️ 发现偏差,加 --fix 自动修正`);
|
||||
}
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch(async (e) => {
|
||||
console.error(e);
|
||||
await prisma.$disconnect();
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import re
|
||||
import urllib.request
|
||||
|
||||
# 重新拉取
|
||||
req = urllib.request.Request('https://www.zhuig.com/community', headers={'User-Agent': 'Mozilla/5.0', 'Cache-Control': 'no-cache', 'Pragma': 'no-cache'})
|
||||
html = urllib.request.urlopen(req, timeout=15).read().decode('utf-8')
|
||||
|
||||
# 找"全部板块"区域
|
||||
idx = html.find('全部板块')
|
||||
print(f'HTML size: {len(html)}, 全部板块 位置: {idx}')
|
||||
|
||||
# 取 "全部板块" 之后的板块区域
|
||||
section_start = html.find('全部板块')
|
||||
section_end = html.find('最新话题', section_start)
|
||||
section = html[section_start:section_end] if section_end > 0 else html[section_start:section_start+15000]
|
||||
print(f'板块区域长度: {len(section)} 字符')
|
||||
|
||||
# 提取所有 <a> 链接
|
||||
pattern = re.compile(r'<a[^>]*href="/community/([^"]+)"[^>]*>(.*?)</a>', re.DOTALL)
|
||||
links = []
|
||||
for m in pattern.finditer(section):
|
||||
slug = m.group(1)
|
||||
text = re.sub(r'<[^>]+>', ' ', m.group(2))
|
||||
text = re.sub(r'\s+', ' ', text).strip()
|
||||
if text and len(text) < 30:
|
||||
links.append((slug, text))
|
||||
|
||||
print(f'\n=== 板块区域内的所有链接 (共{len(links)}个) ===')
|
||||
# 按slug排序去重
|
||||
seen = set()
|
||||
for slug, text in links:
|
||||
if slug in seen:
|
||||
continue
|
||||
seen.add(slug)
|
||||
print(f' /community/{slug:25s} {text}')
|
||||
|
||||
# 看父板块"电商零售"周围的HTML
|
||||
print('\n=== 电商零售(ecommerce) 周围的HTML片段 ===')
|
||||
m = re.search(r'.{200}电商零售.{500}', section)
|
||||
if m:
|
||||
print(m.group()[:800])
|
||||
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 同步勋章定义到数据库
|
||||
* - 把 achievements 表填充/更新为代码中维护的 ACHIEVEMENT_DEFS
|
||||
* - 对存量用户批量评估一次(首屏打开即有数据)
|
||||
*/
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||
import "dotenv/config";
|
||||
|
||||
const base = process.env.DATABASE_URL.replace("mysql://", "mariadb://");
|
||||
const cs = base + (base.includes("?") ? "&" : "?") + "connection_limit=5";
|
||||
const prisma = new PrismaClient({ adapter: new PrismaMariaDb(cs) });
|
||||
|
||||
const ACHIEVEMENT_DEFS = [
|
||||
{ key: "first_post", name: "初次发声", description: "发布第 1 个话题", icon: "🌱", tier: "bronze", points: 10, category: "post", sortOrder: 1, target: 1 },
|
||||
{ key: "first_reply", name: "热心回复", description: "发出第 1 条回复", icon: "💬", tier: "bronze", points: 10, category: "reply", sortOrder: 2, target: 1 },
|
||||
{ key: "first_like", name: "首获认可", description: "首次被点赞", icon: "👍", tier: "bronze", points: 10, category: "social", sortOrder: 3, target: 1 },
|
||||
{ key: "posts_10", name: "话题达人", description: "累计发布 10 个话题", icon: "📚", tier: "silver", points: 30, category: "post", sortOrder: 10, target: 10 },
|
||||
{ key: "replies_50", name: "热心答主", description: "累计发出 50 条回复", icon: "🗣️", tier: "silver", points: 30, category: "reply", sortOrder: 11, target: 50 },
|
||||
{ key: "likes_50", name: "社区明星", description: "累计被点赞 50 次", icon: "⭐", tier: "silver", points: 30, category: "social", sortOrder: 12, target: 50 },
|
||||
{ key: "checkin_7", name: "周满贯", description: "连续签到 7 天", icon: "🔥", tier: "silver", points: 30, category: "checkin", sortOrder: 13, target: 7 },
|
||||
{ key: "best_answer_1", name: "最佳答主", description: "首个回复被采纳为最佳答案", icon: "🏆", tier: "silver", points: 30, category: "milestone", sortOrder: 14, target: 1 },
|
||||
{ key: "posts_100", name: "百帖宗师", description: "累计发布 100 个话题", icon: "📕", tier: "gold", points: 100, category: "post", sortOrder: 20, target: 100 },
|
||||
{ key: "replies_500", name: "千言万语", description: "累计发出 500 条回复", icon: "📝", tier: "gold", points: 100, category: "reply", sortOrder: 21, target: 500 },
|
||||
{ key: "likes_500", name: "万人迷", description: "累计被点赞 500 次", icon: "💖", tier: "gold", points: 100, category: "social", sortOrder: 22, target: 500 },
|
||||
{ key: "checkin_30", name: "月之守护", description: "累计签到 30 天", icon: "🌙", tier: "gold", points: 100, category: "checkin", sortOrder: 23, target: 30 },
|
||||
{ key: "best_answer_10", name: "答疑名师", description: "10 个回复被采纳为最佳答案", icon: "👑", tier: "gold", points: 100, category: "milestone", sortOrder: 24, target: 10 },
|
||||
{ key: "pioneer", name: "先驱者", description: "社区前 100 位注册用户", icon: "🚀", tier: "special", points: 200, category: "milestone", sortOrder: 30, target: 1 },
|
||||
{ key: "night_owl", name: "深夜猫头鹰", description: "在 0~5 点发布 5 个话题", icon: "🦉", tier: "special", points: 50, category: "milestone", sortOrder: 31, target: 5 },
|
||||
];
|
||||
|
||||
async function evaluateForUser(userId) {
|
||||
const [topicCount, postCount, likeCount, bestAnswerCount, totalCheckIns] =
|
||||
await Promise.all([
|
||||
prisma.forumTopic.count({ where: { userId } }),
|
||||
prisma.forumPost.count({ where: { userId } }),
|
||||
prisma.forumPostLike.count({ where: { post: { userId } } }),
|
||||
prisma.forumPost.count({ where: { userId, isAnswer: true } }),
|
||||
prisma.forumCheckIn.count({ where: { userId } }),
|
||||
]);
|
||||
|
||||
const progressFor = (key) => {
|
||||
switch (key) {
|
||||
case "first_post": return Math.min(topicCount, 1);
|
||||
case "first_reply": return Math.min(postCount, 1);
|
||||
case "first_like": return Math.min(likeCount, 1);
|
||||
case "posts_10": return Math.min(topicCount, 10);
|
||||
case "replies_50": return Math.min(postCount, 50);
|
||||
case "likes_50": return Math.min(likeCount, 50);
|
||||
case "checkin_7": return Math.min(totalCheckIns, 7);
|
||||
case "best_answer_1": return Math.min(bestAnswerCount, 1);
|
||||
case "posts_100": return Math.min(topicCount, 100);
|
||||
case "replies_500": return Math.min(postCount, 500);
|
||||
case "likes_500": return Math.min(likeCount, 500);
|
||||
case "checkin_30": return Math.min(totalCheckIns, 30);
|
||||
case "best_answer_10": return Math.min(bestAnswerCount, 10);
|
||||
case "pioneer": return 1;
|
||||
case "night_owl": return 0;
|
||||
default: return 0;
|
||||
}
|
||||
};
|
||||
|
||||
const existing = await prisma.userAchievement.findMany({ where: { userId } });
|
||||
const map = new Map(existing.map((e) => [e.achievementKey, e]));
|
||||
|
||||
for (const def of ACHIEVEMENT_DEFS) {
|
||||
const progress = progressFor(def.key);
|
||||
const prev = map.get(def.key);
|
||||
const shouldUnlock = progress >= def.target;
|
||||
if (prev) {
|
||||
const data = { progress };
|
||||
if (shouldUnlock && !prev.unlockedAt) {
|
||||
data.unlockedAt = new Date();
|
||||
}
|
||||
await prisma.userAchievement.update({ where: { id: prev.id }, data });
|
||||
} else {
|
||||
await prisma.userAchievement.create({
|
||||
data: {
|
||||
userId,
|
||||
achievementKey: def.key,
|
||||
progress,
|
||||
unlockedAt: shouldUnlock ? new Date() : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`[seed-achievements] 同步 ${ACHIEVEMENT_DEFS.length} 个勋章定义...`);
|
||||
for (const def of ACHIEVEMENT_DEFS) {
|
||||
const data = {
|
||||
key: def.key,
|
||||
name: def.name,
|
||||
description: def.description,
|
||||
icon: def.icon,
|
||||
tier: def.tier,
|
||||
points: def.points,
|
||||
category: def.category,
|
||||
sortOrder: def.sortOrder,
|
||||
isActive: true,
|
||||
};
|
||||
await prisma.achievement.upsert({
|
||||
where: { key: def.key },
|
||||
create: data,
|
||||
update: data,
|
||||
});
|
||||
}
|
||||
console.log("[seed-achievements] 勋章定义已同步");
|
||||
|
||||
const users = await prisma.user.findMany({ select: { id: true } });
|
||||
console.log(`[seed-achievements] 评估 ${users.length} 个用户的勋章进度...`);
|
||||
for (const u of users) {
|
||||
await evaluateForUser(u.id);
|
||||
}
|
||||
console.log("[seed-achievements] 完成");
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
// 给所有 bot 初始化 A/B 测试变体
|
||||
// 用法:node scripts/seed-persona-variants.mjs [--bot=<key>]
|
||||
// - 默认跑所有 bot
|
||||
// - --bot=laochen 只为老陈创建/更新变体
|
||||
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||
import { readFileSync } from "fs";
|
||||
import { resolve, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import "dotenv/config";
|
||||
import {
|
||||
ensureBotVariants,
|
||||
ensureExperiment,
|
||||
disconnectBotExperiment,
|
||||
} from "./lib/bot-persona-experiment.mjs";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const base = process.env.DATABASE_URL.replace("mysql://", "mariadb://");
|
||||
const sep = base.includes("?") ? "&" : "?";
|
||||
const connectionString = `${base}${sep}connection_limit=2&pool_timeout=10`;
|
||||
const adapter = new PrismaMariaDb(connectionString);
|
||||
const prisma = new PrismaClient({ adapter });
|
||||
|
||||
const BOT_DATA_PATH = resolve(__dirname, "..", "data", "bot-characters.json");
|
||||
|
||||
function ts() {
|
||||
return `[${new Date().toISOString()}]`;
|
||||
}
|
||||
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
const out = { bot: null };
|
||||
for (const a of args) {
|
||||
if (a.startsWith("--bot=")) out.bot = a.split("=")[1];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs();
|
||||
console.log(`${ts()} Seed persona variants starting${args.bot ? ` (bot=${args.bot})` : ""}...`);
|
||||
|
||||
const { characters } = JSON.parse(readFileSync(BOT_DATA_PATH, "utf-8"));
|
||||
let botChars = characters.filter((c) => !c.role || c.role !== "passerby");
|
||||
if (args.bot) {
|
||||
botChars = botChars.filter((c) => c.key === args.bot);
|
||||
if (botChars.length === 0) {
|
||||
console.error(`${ts()} Bot "${args.bot}" not found in bot-characters.json`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
let variantCreated = 0;
|
||||
let experimentCreated = 0;
|
||||
for (const c of botChars) {
|
||||
const botUser = await prisma.user.findFirst({ where: { email: `bot_${c.key}@zhuiguang.ai` } });
|
||||
if (!botUser) {
|
||||
console.warn(`${ts()} bot user not found for key=${c.key}, skipping.`);
|
||||
continue;
|
||||
}
|
||||
const botConfig = await prisma.botConfig.findUnique({ where: { userId: botUser.id } });
|
||||
if (!botConfig) {
|
||||
console.warn(`${ts()} botConfig not found for key=${c.key}, skipping.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const { variants, created } = await ensureBotVariants(botConfig.id, botUser.id);
|
||||
variantCreated += created;
|
||||
console.log(`${ts()} ${c.displayName}: variants=${variants.length} (created=${created})`);
|
||||
|
||||
const exp = await ensureExperiment(botConfig.id);
|
||||
if (exp) {
|
||||
experimentCreated++;
|
||||
console.log(`${ts()} exp#${exp.id} status=${exp.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.taskLog.create({
|
||||
data: {
|
||||
taskKey: "seed-persona-variants",
|
||||
taskName: "Bot 人设 A/B 变体初始化",
|
||||
status: "success",
|
||||
startedAt: new Date(),
|
||||
finishedAt: new Date(),
|
||||
triggerBy: "manual",
|
||||
result: { botCount: botChars.length, variantCreated, experimentCreated },
|
||||
},
|
||||
});
|
||||
|
||||
console.log(
|
||||
`${ts()} Done. bots=${botChars.length} variants_created=${variantCreated} experiments=${experimentCreated}`
|
||||
);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(async (e) => {
|
||||
console.error(`${ts()} Fatal:`, e);
|
||||
try {
|
||||
await prisma.taskLog.create({
|
||||
data: {
|
||||
taskKey: "seed-persona-variants",
|
||||
taskName: "Bot 人设 A/B 变体初始化",
|
||||
status: "failed",
|
||||
startedAt: new Date(),
|
||||
finishedAt: new Date(),
|
||||
triggerBy: "manual",
|
||||
error: (e && e.message ? e.message : String(e)).slice(0, 1000),
|
||||
},
|
||||
});
|
||||
} catch {}
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
await disconnectBotExperiment();
|
||||
});
|
||||
@@ -59,11 +59,83 @@ const TASK_DEFS = [
|
||||
},
|
||||
{
|
||||
taskKey: "bot-activity",
|
||||
taskName: "Bot活跃度调度",
|
||||
taskName: "Bot活动引擎",
|
||||
enabled: true,
|
||||
cronExpr: "0 8,12,16,20 * * *",
|
||||
description: "每天4次(8点/12点/16点/20点)调度行业论坛Bot发言:选2个论坛,发起话题或回复",
|
||||
config: { forumsPerRun: 2, model: "deepseek-chat" },
|
||||
cronExpr: "0 9-23 * * *",
|
||||
description: "每小时(9-23点)调度行业论坛Bot发言:4个专家板块+4个路人板块(每个路人板块最多2帖),按活跃时段过滤bot,按最近发帖加权去重",
|
||||
config: { expertForumsPerRun: 4, passerbyForumsPerRun: 4, passerbyTopicsPerForum: 2, model: "deepseek-chat" },
|
||||
},
|
||||
{
|
||||
taskKey: "bot-feedback-loop",
|
||||
taskName: "Bot反馈循环",
|
||||
enabled: true,
|
||||
cronExpr: "0 */3 * * *",
|
||||
description: "每3小时扫描Bot自己帖子下的新回复,存为反馈记忆并按概率触发二次回复",
|
||||
config: { followUpProbability: 0.6, maxPerRun: 3, lookbackHours: 24 },
|
||||
},
|
||||
{
|
||||
taskKey: "bot-skill-crystallize",
|
||||
taskName: "Bot技能沉淀",
|
||||
enabled: true,
|
||||
cronExpr: "0 */12 * * *",
|
||||
description: "每12小时扫描过去14天高互动Bot话题(回复≥5或真人≥1或点赞≥3),DeepSeek解构共性后入库BotSkill;末尾回收7天前成熟技能的实际表现,更新successRate/avgReplies,<30%且使用≥3次自动降级",
|
||||
config: { lookbackDays: 14, minReplies: 5, minHumanReplies: 1, minLikes: 3, maxTopicsPerBot: 8, recycleAfterDays: 7, minSuccessRate: 0.3 },
|
||||
},
|
||||
{
|
||||
taskKey: "bot-affinity-update",
|
||||
taskName: "Bot亲和度与画像",
|
||||
enabled: true,
|
||||
cronExpr: "0 */6 * * *",
|
||||
description: "每6小时:①统计每个Bot话题收到的跨板块真人互动,刷BotCrossForumAffinity(affinity=min(1, samples/total/4))②扫过去30天发言,刷BotPersona(avgReplyLength/questionRatio/stanceKeywords/topHumanUsers/topTopicTypes/lastTopicTitles)",
|
||||
config: { affinityLookbackDays: 14, personaLookbackDays: 30, minAffinity: 0.1 },
|
||||
},
|
||||
{
|
||||
taskKey: "bot-weekly-review",
|
||||
taskName: "Bot周度复盘",
|
||||
enabled: true,
|
||||
cronExpr: "30 2 * * 1",
|
||||
description: "每周一凌晨2:30对每个Bot算engagementScore(回复/帖子比+真人占比+点赞对数),生成REFLECTION记忆并决定下周配额tier(high/medium/low)",
|
||||
config: { weights: { replyPerTopic: 0.4, humanRatio: 0.3, likesLog: 0.3 }, thresholds: { high: 1.0, medium: 0.5 } },
|
||||
},
|
||||
{
|
||||
taskKey: "task7-news-to-community",
|
||||
taskName: "新闻分发到社区",
|
||||
enabled: true,
|
||||
cronExpr: "0 8 * * *",
|
||||
description: "每天早上8点把当日AI日报条目自动分发到社区对应板块创建话题",
|
||||
config: { sourceTask: "daily-news", maxPerDay: 12 },
|
||||
},
|
||||
{
|
||||
taskKey: "cleanup-task-logs",
|
||||
taskName: "清理任务日志",
|
||||
enabled: true,
|
||||
cronExpr: "30 6 * * 0",
|
||||
description: "每周日凌晨清理 30 天前的 task_log 数据,避免表无限膨胀",
|
||||
config: { retentionDays: 30 },
|
||||
},
|
||||
{
|
||||
taskKey: "reconcile-like-counts",
|
||||
taskName: "点赞数对齐巡检",
|
||||
enabled: true,
|
||||
cronExpr: "15 */6 * * *",
|
||||
description: "每6小时对齐 forumTopic.likeCount / forumPost.likeCount 与真实 Like 表的计数,确保 Redis 缓存 + UI 显示与真实点赞一致(drift 自动修正)",
|
||||
config: { scope: "both", botOnly: false, autoFix: true, limit: 5000 },
|
||||
},
|
||||
{
|
||||
taskKey: "bot-persona-experiment-run",
|
||||
taskName: "Bot Persona A/B 调度",
|
||||
enabled: true,
|
||||
cronExpr: "45 */4 * * *",
|
||||
description: "每4小时:①采集最近7天 bot_persona_assignments 的真实互动数据(reply/like/humanReplies)回填到 bot_persona_metrics ②对每个bot的变体做双比例z检验(p<0.1, 样本≥20)③winner变体权重自动提到0.85,其他降到0.075,标记experiment=completed",
|
||||
config: { lookbackDays: 7, minSamplesPerArm: 20, significanceLevel: 0.1 },
|
||||
},
|
||||
{
|
||||
taskKey: "bot-adversarial-learning-run",
|
||||
taskName: "Bot 对抗学习(真人高赞帖)",
|
||||
enabled: true,
|
||||
cronExpr: "0 23 * * 0",
|
||||
description: "每周日23:00:给每个专家bot选1篇过去7天由真人发布的高互动topic(真人reply+点赞高),LLM 提取“为什么火”的洞察(hook/story/data/question/contrarian/empathy/practical),写入 bot_adversarial_learnings(unique bot+weekKey)+ BotMemory,bot下次发帖时可注入到 prompt",
|
||||
config: { lookbackDays: 7, expireDays: 7, minReplies: 3, minLikes: 5, minHumanReplies: 1 },
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// 把新加的 33 个数字路人同步到数据库
|
||||
// 只插入 user 表里还不存在的 bot_<key>@zhuiguang.ai
|
||||
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||
import { readFileSync } from "fs";
|
||||
import { resolve, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import "dotenv/config";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const base = process.env.DATABASE_URL.replace("mysql://", "mariadb://");
|
||||
const sep = base.includes("?") ? "&" : "?";
|
||||
const connectionString = `${base}${sep}connection_limit=5&pool_timeout=10`;
|
||||
const adapter = new PrismaMariaDb(connectionString);
|
||||
const prisma = new PrismaClient({ adapter });
|
||||
|
||||
const BOT_DATA_PATH = resolve(__dirname, "..", "data", "bot-characters.json");
|
||||
|
||||
async function main() {
|
||||
const raw = readFileSync(BOT_DATA_PATH, "utf-8");
|
||||
const { characters } = JSON.parse(raw);
|
||||
|
||||
const passersby = characters.filter((c) => c.role === "passerby");
|
||||
console.log(`观察者总数(JSON): ${passersby.length}`);
|
||||
|
||||
let created = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const char of passersby) {
|
||||
const existing = await prisma.user.findUnique({
|
||||
where: { email: char.email },
|
||||
include: { botConfig: true },
|
||||
});
|
||||
|
||||
if (existing && existing.botConfig) {
|
||||
// 已经存在,更新一下 primaryForums / activeHours
|
||||
await prisma.botConfig.update({
|
||||
where: { userId: existing.id },
|
||||
data: {
|
||||
displayName: char.displayName,
|
||||
personality: char.personality,
|
||||
primaryForums: char.primaryForums,
|
||||
activeHours: char.activeHours,
|
||||
},
|
||||
});
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existing && !existing.botConfig) {
|
||||
await prisma.botConfig.create({
|
||||
data: {
|
||||
userId: existing.id,
|
||||
displayName: char.displayName,
|
||||
personality: char.personality,
|
||||
primaryForums: char.primaryForums,
|
||||
activeHours: char.activeHours,
|
||||
},
|
||||
});
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 完全新用户
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: char.email,
|
||||
name: char.displayName,
|
||||
oauthProvider: "bot",
|
||||
oauthId: `bot_${char.key}`,
|
||||
role: "user",
|
||||
isBot: true,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.botConfig.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
displayName: char.displayName,
|
||||
personality: char.personality,
|
||||
primaryForums: char.primaryForums,
|
||||
activeHours: char.activeHours,
|
||||
},
|
||||
});
|
||||
|
||||
created++;
|
||||
}
|
||||
|
||||
console.log(`新增路人用户: ${created}, 跳过(已存在): ${skipped}`);
|
||||
const totalBots = await prisma.user.count({ where: { isBot: true } });
|
||||
console.log(`数据库中 bot 用户总数: ${totalBots}`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
@@ -4,12 +4,14 @@ import OpenAI from "openai";
|
||||
import "dotenv/config";
|
||||
import { withRetry } from "./lib/retry.mjs";
|
||||
|
||||
const base = (process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4").replace("mysql://", "mariadb://");
|
||||
const rawUrl = process.env.DATABASE_URL;
|
||||
if (!rawUrl) { console.error("DATABASE_URL not set"); process.exit(1); }
|
||||
const base = rawUrl.replace("mysql://", "mariadb://");
|
||||
const sep = base.includes("?") ? "&" : "?";
|
||||
const connectionString = `${base}${sep}connection_limit=20&pool_timeout=30`;
|
||||
const adapter = new PrismaMariaDb(connectionString);
|
||||
const prisma = new PrismaClient({ adapter });
|
||||
const openai = new OpenAI({ apiKey: process.env.DEEPSEEK_API_KEY, baseURL: "https://api.deepseek.com/v1" });
|
||||
const openai = new OpenAI({ apiKey: process.env.DEEPSEEK_API_KEY, baseURL: "https://api.deepseek.com/v1", timeout: 120000, maxRetries: 2 });
|
||||
|
||||
const EXTRACT_PROMPT = `你是一个AI工具信息提取助手。从以下科技新闻内容中,提取出现的AI工具/产品信息。只提取明确提到的AI软件工具、AI平台、AI产品,不要提取开源项目库(GitHub仓库)、不要提取硬件、芯片。每个工具返回以下信息(如果信息不足则跳过该工具):
|
||||
|
||||
@@ -212,7 +214,9 @@ async function main() {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch(async (e) => {
|
||||
main()
|
||||
.then(() => { console.log("Done"); process.exit(0); })
|
||||
.catch(async (e) => {
|
||||
console.error("❌ 任务一失败:", e.message);
|
||||
if (prisma) {
|
||||
try {
|
||||
@@ -228,7 +232,6 @@ main().catch(async (e) => {
|
||||
},
|
||||
});
|
||||
} catch {}
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
}).finally(() => prisma.$disconnect());
|
||||
@@ -3,7 +3,9 @@ import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||
import "dotenv/config";
|
||||
import { withRetry } from "./lib/retry.mjs";
|
||||
|
||||
const base = (process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4").replace("mysql://", "mariadb://");
|
||||
const rawUrl = process.env.DATABASE_URL;
|
||||
if (!rawUrl) { console.error("DATABASE_URL not set"); process.exit(1); }
|
||||
const base = rawUrl.replace("mysql://", "mariadb://");
|
||||
const sep = base.includes("?") ? "&" : "?";
|
||||
const connectionString = `${base}${sep}connection_limit=20&pool_timeout=30`;
|
||||
const adapter = new PrismaMariaDb(connectionString);
|
||||
@@ -166,7 +168,9 @@ async function main() {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch(async (e) => {
|
||||
main()
|
||||
.then(() => { console.log("Done"); process.exit(0); })
|
||||
.catch(async (e) => {
|
||||
console.error("❌ 任务三失败:", e.message);
|
||||
if (prisma) {
|
||||
try {
|
||||
@@ -182,7 +186,6 @@ main().catch(async (e) => {
|
||||
},
|
||||
});
|
||||
} catch {}
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
}).finally(() => prisma.$disconnect());
|
||||
|
||||
@@ -3,7 +3,9 @@ import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||
import { Octokit } from "octokit";
|
||||
import "dotenv/config";
|
||||
|
||||
const base = (process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4").replace("mysql://", "mariadb://");
|
||||
const rawUrl = process.env.DATABASE_URL;
|
||||
if (!rawUrl) { console.error("DATABASE_URL not set"); process.exit(1); }
|
||||
const base = rawUrl.replace("mysql://", "mariadb://");
|
||||
const sep = base.includes("?") ? "&" : "?";
|
||||
const connectionString = `${base}${sep}connection_limit=20&pool_timeout=30`;
|
||||
const adapter = new PrismaMariaDb(connectionString);
|
||||
@@ -123,7 +125,9 @@ async function main() {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch(async (e) => {
|
||||
main()
|
||||
.then(() => { console.log("Done"); process.exit(0); })
|
||||
.catch(async (e) => {
|
||||
console.error("❌ 任务五失败:", e.message);
|
||||
if (prisma) {
|
||||
try {
|
||||
@@ -139,7 +143,6 @@ main().catch(async (e) => {
|
||||
},
|
||||
});
|
||||
} catch {}
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
}).finally(() => prisma.$disconnect());
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// 直接调用 bot stats 逻辑,不走 HTTP
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||
import "dotenv/config";
|
||||
|
||||
const base = process.env.DATABASE_URL.replace("mysql://", "mariadb://");
|
||||
const sep = base.includes("?") ? "&" : "?";
|
||||
const connectionString = `${base}${sep}connection_limit=5&pool_timeout=10`;
|
||||
const adapter = new PrismaMariaDb(connectionString);
|
||||
const prisma = new PrismaClient({ adapter });
|
||||
|
||||
async function main() {
|
||||
const days = 14;
|
||||
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
|
||||
const sinceDate = new Date(since.getFullYear(), since.getMonth(), since.getDate());
|
||||
|
||||
const botUsers = await prisma.user.findMany({
|
||||
where: { isBot: true },
|
||||
select: { id: true, name: true, email: true, points: true },
|
||||
});
|
||||
const botConfigs = await prisma.botConfig.findMany({
|
||||
select: { id: true, userId: true, primaryForums: true },
|
||||
});
|
||||
const configByUserId = new Map(botConfigs.map((c) => [c.userId, c]));
|
||||
|
||||
const dailyStats = await prisma.botDailyStat.groupBy({
|
||||
by: ["botId"],
|
||||
where: { date: { gte: sinceDate } },
|
||||
_sum: {
|
||||
topicCreated: true,
|
||||
replySent: true,
|
||||
repliesReceived: true,
|
||||
likesReceived: true,
|
||||
humanInteractions: true,
|
||||
},
|
||||
});
|
||||
const statMap = new Map(
|
||||
dailyStats.map((s) => [
|
||||
s.botId,
|
||||
{
|
||||
topicCreated: s._sum.topicCreated || 0,
|
||||
replySent: s._sum.replySent || 0,
|
||||
likesReceived: s._sum.likesReceived || 0,
|
||||
humanInteractions: s._sum.humanInteractions || 0,
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
const memCount = await prisma.botMemory.count();
|
||||
console.log("--- Bot Stats Preview ---");
|
||||
console.log("Total bots:", botUsers.length);
|
||||
console.log("Total configs:", botConfigs.length);
|
||||
console.log("BotDailyStat records:", dailyStats.length);
|
||||
console.log("BotMemory records:", memCount);
|
||||
console.log("Bots with stats:", statMap.size);
|
||||
|
||||
// Top 5 by health score
|
||||
const top = botUsers
|
||||
.map((u) => {
|
||||
const cfg = configByUserId.get(u.id);
|
||||
const d = statMap.get(cfg?.id) || { topicCreated: 0, replySent: 0, likesReceived: 0, humanInteractions: 0 };
|
||||
return {
|
||||
name: u.name,
|
||||
health: d.topicCreated * 3 + d.replySent + d.likesReceived * 2 + d.humanInteractions * 5,
|
||||
...d,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.health - a.health)
|
||||
.slice(0, 5);
|
||||
|
||||
console.log("\n--- Top 5 ---");
|
||||
for (const t of top) {
|
||||
console.log(`${t.name.padEnd(20)} health=${String(t.health).padStart(4)} 话题=${t.topicCreated} 回复=${t.replySent} 赞=${t.likesReceived} 真人=${t.humanInteractions}`);
|
||||
}
|
||||
|
||||
// Trends
|
||||
const trendRaw = await prisma.botDailyStat.groupBy({
|
||||
by: ["date"],
|
||||
where: { date: { gte: sinceDate } },
|
||||
_sum: { topicCreated: true, replySent: true, likesReceived: true, humanInteractions: true },
|
||||
orderBy: { date: "asc" },
|
||||
});
|
||||
console.log("\n--- Trend (近14天) ---");
|
||||
for (const t of trendRaw) {
|
||||
console.log(`${t.date.toISOString().split("T")[0]} 话题=${t._sum.topicCreated} 回复=${t._sum.replySent} 赞=${t._sum.likesReceived}`);
|
||||
}
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/bin/bash
|
||||
curl -s 'https://www.zhuig.com/community' -o /tmp/c3.html
|
||||
echo "HTML size: $(wc -c < /tmp/c3.html)"
|
||||
echo "data-version=2026-06-03: $(grep -c 'data-version' /tmp/c3.html)"
|
||||
echo "赚钱与副业(应该=0): $(grep -c '赚钱与副业' /tmp/c3.html)"
|
||||
echo "电商零售(应该>0): $(grep -c '电商零售' /tmp/c3.html)"
|
||||
echo "行业论坛: $(grep -c '行业论坛' /tmp/c3.html)"
|
||||
echo "板块slug:"
|
||||
grep -oE 'href="/community/[a-z-]+"' /tmp/c3.html | sort -u
|
||||
@@ -0,0 +1,40 @@
|
||||
import urllib.request, json, http.cookiejar, urllib.error
|
||||
|
||||
cj = http.cookiejar.CookieJar()
|
||||
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj))
|
||||
|
||||
# Get CSRF token
|
||||
csrf_resp = opener.open('http://localhost:8301/api/auth/csrf')
|
||||
csrf = json.loads(csrf_resp.read())
|
||||
print('CSRF token:', csrf['csrfToken'][:20] + '...')
|
||||
|
||||
# Login
|
||||
data = urllib.parse.urlencode({
|
||||
'csrfToken': csrf['csrfToken'],
|
||||
'email': 'admin@zhuiguang.com',
|
||||
'password': 'Admin123!'
|
||||
}).encode()
|
||||
|
||||
req = urllib.request.Request(
|
||||
'http://localhost:8301/api/auth/callback/credentials',
|
||||
data=data,
|
||||
method='POST'
|
||||
)
|
||||
|
||||
try:
|
||||
login_resp = opener.open(req)
|
||||
print('Login status:', login_resp.status)
|
||||
loc = login_resp.headers.get('location', 'none')
|
||||
print('Location:', loc)
|
||||
except urllib.error.HTTPError as e:
|
||||
loc = e.headers.get('location', 'none')
|
||||
print('Login redirect:', e.code, '->', loc)
|
||||
if 'error=CredentialsSignin' in (loc or ''):
|
||||
print('>>> ERROR: Invalid credentials!')
|
||||
elif 'csrf=true' in (loc or ''):
|
||||
print('>>> ERROR: CSRF token mismatch!')
|
||||
|
||||
# Session
|
||||
sess_resp = opener.open('http://localhost:8301/api/auth/session')
|
||||
sess = json.loads(sess_resp.read())
|
||||
print('Session:', json.dumps(sess, indent=2))
|
||||
@@ -0,0 +1,29 @@
|
||||
#!/bin/bash
|
||||
# Get CSRF token via curl
|
||||
CSRF=$(curl -s -c /tmp/ck3.txt http://localhost:8301/api/auth/csrf | python3 -c "import sys,json; print(json.load(sys.stdin)['csrfToken'])")
|
||||
echo "CSRF token: ${CSRF:0:20}..."
|
||||
|
||||
# Build POST data
|
||||
python3 -c "
|
||||
import json
|
||||
with open('/tmp/ck3.txt') as f:
|
||||
content = f.read()
|
||||
# Extract csrf token from response
|
||||
import subprocess
|
||||
token = subprocess.check_output(['curl', '-s', 'http://localhost:8301/api/auth/csrf']).decode()
|
||||
token = json.loads(token)['csrfToken']
|
||||
print(f'csrfToken={token}&email=admin@zhuiguang.com&password=Admin123!')
|
||||
" > /tmp/login_data3.txt
|
||||
|
||||
cat /tmp/login_data3.txt | head -c 40
|
||||
echo "..."
|
||||
|
||||
# Login
|
||||
curl -sv -b /tmp/ck3.txt -c /tmp/ck4.txt \
|
||||
-X POST http://localhost:8301/api/auth/callback/credentials \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d @/tmp/login_data3.txt 2>&1 | grep -E "location:|HTTP/|set-cookie"
|
||||
|
||||
# Session
|
||||
echo "=== Session ==="
|
||||
curl -s -b /tmp/ck4.txt http://localhost:8301/api/auth/session
|
||||
@@ -0,0 +1,59 @@
|
||||
import urllib.request, json, http.cookiejar, urllib.error, ssl
|
||||
|
||||
# Disable SSL verification for localhost testing
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
|
||||
cj = http.cookiejar.CookieJar()
|
||||
opener = urllib.request.build_opener(
|
||||
urllib.request.HTTPCookieProcessor(cj),
|
||||
urllib.request.HTTPSHandler(context=ctx)
|
||||
)
|
||||
|
||||
# Get CSRF token
|
||||
csrf_resp = opener.open('http://localhost:8301/api/auth/csrf')
|
||||
csrf = json.loads(csrf_resp.read())
|
||||
print('CSRF token:', csrf['csrfToken'][:20] + '...')
|
||||
|
||||
# Login (don't follow redirect)
|
||||
data = urllib.parse.urlencode({
|
||||
'csrfToken': csrf['csrfToken'],
|
||||
'email': 'admin@zhuiguang.com',
|
||||
'password': 'Admin123!'
|
||||
}).encode()
|
||||
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
return None
|
||||
def http_error_302(self, req, fp, code, msg, headers):
|
||||
return fp
|
||||
|
||||
no_redirect_opener = urllib.request.build_opener(
|
||||
urllib.request.HTTPCookieProcessor(cj),
|
||||
NoRedirect
|
||||
)
|
||||
|
||||
req = urllib.request.Request(
|
||||
'http://localhost:8301/api/auth/callback/credentials',
|
||||
data=data,
|
||||
method='POST'
|
||||
)
|
||||
try:
|
||||
resp = no_redirect_opener.open(req)
|
||||
print('Status:', resp.status)
|
||||
print('Location:', resp.headers.get('location', 'none'))
|
||||
# Check cookies
|
||||
for cookie in cj:
|
||||
if 'session-token' in cookie.name or 'next-auth' in cookie.name:
|
||||
print('Cookie:', cookie.name, '=', cookie.value[:30] + '...')
|
||||
except urllib.error.HTTPError as e:
|
||||
print('Error:', e.code, '->', e.headers.get('location', ''))
|
||||
|
||||
# Check session
|
||||
sess_req = urllib.request.Request('http://localhost:8301/api/auth/session')
|
||||
sess_resp = opener.open(sess_req)
|
||||
sess = json.loads(sess_resp.read())
|
||||
print('Session:', json.dumps(sess, indent=2))
|
||||
if sess.get('user'):
|
||||
print('>>> LOGIN SUCCESS! User:', sess['user'].get('email'))
|
||||
@@ -0,0 +1,53 @@
|
||||
import urllib.request, json, http.cookiejar
|
||||
|
||||
cj = http.cookiejar.CookieJar()
|
||||
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj))
|
||||
|
||||
# Get CSRF token
|
||||
csrf = json.loads(opener.open('http://localhost:8301/api/auth/csrf').read())
|
||||
print('CSRF:', csrf['csrfToken'][:30])
|
||||
|
||||
# Dump cookies for debug
|
||||
print('Cookies before login:')
|
||||
for c in cj:
|
||||
print(f' {c.name}={c.value[:30]}... domain={c.domain} path={c.path} secure={c.secure}')
|
||||
|
||||
# Login
|
||||
data = urllib.parse.urlencode({
|
||||
'csrfToken': csrf['csrfToken'],
|
||||
'email': 'admin@zhuiguang.com',
|
||||
'password': 'Admin123!'
|
||||
}).encode()
|
||||
|
||||
print('Posting login with data:', data[:80])
|
||||
|
||||
req = urllib.request.Request('http://localhost:8301/api/auth/callback/credentials', data=data, method='POST')
|
||||
try:
|
||||
resp = urllib.request.urlopen(req)
|
||||
print('Status:', resp.status)
|
||||
print('Location:', resp.headers.get('location'))
|
||||
except urllib.error.HTTPError as e:
|
||||
loc = e.headers.get('location', '')
|
||||
print('Status:', e.code)
|
||||
print('Location:', loc)
|
||||
if 'error=CredentialsSignin' in loc:
|
||||
print('FAIL: CredentialsSignin - check email/password')
|
||||
elif 'csrf=true' in loc:
|
||||
print('FAIL: CSRF token mismatch')
|
||||
elif loc.startswith('https://www.zhuig.com'):
|
||||
print('SUCCESS: Login redirect to dashboard')
|
||||
|
||||
print('\nCookies after login:')
|
||||
for c in cj:
|
||||
if 'next-auth' in c.name or 'session' in c.name:
|
||||
print(f' {c.name}={c.value[:30]}...')
|
||||
|
||||
# Session
|
||||
try:
|
||||
sess = json.loads(opener.open('http://localhost:8301/api/auth/session').read())
|
||||
if sess.get('user'):
|
||||
print('SESSION OK - user:', sess['user'].get('email'))
|
||||
else:
|
||||
print('SESSION EMPTY')
|
||||
except Exception as e:
|
||||
print('Session error:', e)
|
||||
@@ -0,0 +1,44 @@
|
||||
import urllib.request, json, http.cookiejar
|
||||
|
||||
class NoRedirectHandler(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
return None
|
||||
http_error_301 = http_error_302 = http_error_303 = http_error_307 = lambda self, req, fp, code, msg, headers: fp
|
||||
|
||||
cj = http.cookiejar.CookieJar()
|
||||
opener = urllib.request.build_opener(
|
||||
urllib.request.HTTPCookieProcessor(cj),
|
||||
NoRedirectHandler
|
||||
)
|
||||
|
||||
# Get CSRF
|
||||
csrf = json.loads(opener.open('http://localhost:8301/api/auth/csrf').read())
|
||||
print('CSRF:', csrf['csrfToken'][:30])
|
||||
|
||||
# Login
|
||||
data = urllib.parse.urlencode({
|
||||
'csrfToken': csrf['csrfToken'],
|
||||
'email': 'admin@zhuiguang.com',
|
||||
'password': 'Admin123!'
|
||||
}).encode()
|
||||
|
||||
req = urllib.request.Request('http://localhost:8301/api/auth/callback/credentials', data=data, method='POST')
|
||||
resp = opener.open(req)
|
||||
print('Status:', resp.status)
|
||||
loc = resp.headers.get('location', 'NONE')
|
||||
print('Location:', loc)
|
||||
|
||||
if 'error=CredentialsSignin' in (loc or ''):
|
||||
print('>>> FAIL: Invalid credentials')
|
||||
elif 'csrf=true' in (loc or ''):
|
||||
print('>>> FAIL: CSRF token mismatch')
|
||||
elif '/api/auth/signin' not in (loc or ''):
|
||||
print('>>> SUCCESS: Login accepted, redirecting to caller page')
|
||||
|
||||
# Session
|
||||
opener2 = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj))
|
||||
sess = json.loads(opener2.open('http://localhost:8301/api/auth/session').read())
|
||||
if sess.get('user'):
|
||||
print('Session OK:', sess['user'].get('email'), '| role:', sess['user'].get('role'))
|
||||
else:
|
||||
print('Session EMPTY - user not logged in')
|
||||
@@ -0,0 +1,41 @@
|
||||
import urllib.request, json, http.cookiejar, http.client
|
||||
|
||||
# Enable debug to see request headers
|
||||
http.client.HTTPConnection.debuglevel = 1
|
||||
|
||||
cj = http.cookiejar.CookieJar()
|
||||
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj))
|
||||
|
||||
# Get CSRF
|
||||
csrf = json.loads(opener.open('http://localhost:8301/api/auth/csrf').read())
|
||||
print('\n=== Cookies after CSRF ===')
|
||||
for c in cj:
|
||||
print(f' {c.name}: val={c.value[:30]} domain={c.domain} path={c.path} secure={c.secure} httponly={c.has_nonstandard_attr("HttpOnly")}')
|
||||
|
||||
# Now login with debug to see if cookies are sent
|
||||
print('\n=== Login POST headers ===')
|
||||
data = urllib.parse.urlencode({
|
||||
'csrfToken': csrf['csrfToken'],
|
||||
'email': 'admin@zhuiguang.com',
|
||||
'password': 'Admin123!'
|
||||
}).encode()
|
||||
|
||||
req = urllib.request.Request('http://localhost:8301/api/auth/callback/credentials', data=data, method='POST')
|
||||
|
||||
# Check what cookies will be sent
|
||||
print(f'Sending cookies:')
|
||||
for c in cj:
|
||||
print(f' {"Cookie: "}{c.name}={c.value[:30]}')
|
||||
|
||||
try:
|
||||
resp = urllib.request.urlopen(req)
|
||||
print(f'Status: {resp.status}')
|
||||
print(f'Location: {resp.headers.get("location")}')
|
||||
except urllib.error.HTTPError as e:
|
||||
loc = e.headers.get('location', '')
|
||||
print(f'Status: {e.code}')
|
||||
print(f'Location: {loc}')
|
||||
if 'csrf=true' in loc:
|
||||
print('CSRF FAILED')
|
||||
elif loc.startswith('https://www.zhuig.com'):
|
||||
print('LOGIN SUCCESS - redirect to app')
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/bin/bash
|
||||
echo "=== 1. pm2 进程状态 ==="
|
||||
pm2 list
|
||||
echo ""
|
||||
echo "=== 2. 其他项目端口健康(不动的) ==="
|
||||
for p in 8602 8720 8801 8848 9000 9848 7001; do
|
||||
result=$(curl -s -o /dev/null -m 3 -w "%{http_code}" http://127.0.0.1:$p/ 2>/dev/null)
|
||||
echo " $p: HTTP=$result"
|
||||
done
|
||||
echo ""
|
||||
echo "=== 3. 我们部署的项目 (zhuiguang-ai) ==="
|
||||
result=$(curl -s -o /dev/null -m 3 -w "%{http_code}" http://127.0.0.1:8301/)
|
||||
echo " 8301 主页: HTTP=$result"
|
||||
result=$(curl -s -o /dev/null -m 3 -w "%{http_code}" http://127.0.0.1:8301/api/forum/categories)
|
||||
echo " 8301 API: HTTP=$result"
|
||||
echo ""
|
||||
echo "=== 4. 备份文件 ==="
|
||||
ls -la /home/ubuntu/zhuiguang-ai/.backup-2026-06-03/
|
||||
echo ""
|
||||
echo "=== 5. API 返回的板块数 ==="
|
||||
curl -s http://127.0.0.1:8301/api/forum/categories | python3 -c "
|
||||
import sys, json
|
||||
d = json.load(sys.stdin)
|
||||
print(' 顶层板块:', len(d['categories']))
|
||||
print(' 子板块总数:', sum(len(c.get('children', [])) for c in d['categories']))
|
||||
print(' 孙板块总数:', sum(len(gc.get('children', [])) for c in d['categories'] for gc in c.get('children', [])))
|
||||
"
|
||||
@@ -0,0 +1,25 @@
|
||||
import re
|
||||
import urllib.request
|
||||
|
||||
req = urllib.request.Request('https://www.zhuig.com/community', headers={'User-Agent': 'Mozilla/5.0', 'Cache-Control': 'no-cache', 'Pragma': 'no-cache'})
|
||||
html = urllib.request.urlopen(req, timeout=15).read().decode('utf-8')
|
||||
|
||||
# 找板块区域
|
||||
idx_q = html.find('全部板块')
|
||||
idx_z = html.find('最新话题')
|
||||
section = html[idx_q:idx_z] if idx_z > 0 else html[idx_q:idx_q+15000]
|
||||
|
||||
# 统计 <a> 链接
|
||||
a_count = len(re.findall(r'<a[^>]*href="/community/', section))
|
||||
h3_count = len(re.findall(r'<h3[^>]*>[^<]+</h3>', section))
|
||||
print(f'板块区域 <a> 链接数: {a_count}')
|
||||
print(f'板块区域 <h3> 标签数: {h3_count}')
|
||||
|
||||
# 提取前10个
|
||||
matches = list(re.finditer(r'<a[^>]*href="/community/([^"]+)"[^>]*>(.*?)</a>', section, re.DOTALL))[:15]
|
||||
print('\n=== 板块区域前15个链接 ===')
|
||||
for m in matches:
|
||||
slug = m.group(1)
|
||||
text = re.sub(r'<[^>]+>', ' ', m.group(2))
|
||||
text = re.sub(r'\s+', ' ', text).strip()
|
||||
print(f' /community/{slug:25s} | {text}')
|
||||
Reference in New Issue
Block a user