vibe-roast 0.98.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +418 -0
- package/assests/README.md +43 -0
- package/assests/badges/.gitkeep +0 -0
- package/assests/badges/01-builder/builder-badge.png +0 -0
- package/assests/badges/01-builder/builder-badge.svg +4 -0
- package/assests/badges/02-debugger/debugger-badge.png +0 -0
- package/assests/badges/02-debugger/debugger-badge.svg +4 -0
- package/assests/badges/03-architect/architect-badge.png +0 -0
- package/assests/badges/03-architect/architect-badge.svg +4 -0
- package/assests/badges/04-codex-believer/codex-believer-badge.png +0 -0
- package/assests/badges/04-codex-believer/codex-believer-badge.svg +4 -0
- package/assests/badges/05-prompt-priest/prompt-priest-badge.png +0 -0
- package/assests/badges/05-prompt-priest/prompt-priest-badge.svg +4 -0
- package/assests/badges/06-tab-hoarder/tab-hoarder-badge.png +0 -0
- package/assests/badges/06-tab-hoarder/tab-hoarder-badge.svg +4 -0
- package/assests/badges/07-context-maxxer/context-maxxer-badge.png +0 -0
- package/assests/badges/07-context-maxxer/context-maxxer-badge.svg +4 -0
- package/assests/badges/08-yolo-shipper/yolo-shipper-badge.png +0 -0
- package/assests/badges/08-yolo-shipper/yolo-shipper-badge.svg +4 -0
- package/assests/banners/.gitkeep +0 -0
- package/assests/banners/home-hero.png +0 -0
- package/assests/banners/home-hero.svg +3 -0
- package/assests/banners/result-header.png +0 -0
- package/assests/banners/result-header.svg +3 -0
- package/assests/banners/share-strip.png +0 -0
- package/assests/banners/share-strip.svg +3 -0
- package/assests/characters/01-builder/builder-card.sbti-archive.svg +5 -0
- package/assests/characters/01-builder/builder-card.svg +3 -0
- package/assests/characters/01-builder/builder-character.sbti-archive.svg +3 -0
- package/assests/characters/01-builder/builder-character.svg +3 -0
- package/assests/characters/01-builder/builder-figure.png +0 -0
- package/assests/characters/01-builder/builder-mascot.png +0 -0
- package/assests/characters/02-debugger/debugger-card.sbti-archive.svg +5 -0
- package/assests/characters/02-debugger/debugger-card.svg +3 -0
- package/assests/characters/02-debugger/debugger-character.sbti-archive.svg +3 -0
- package/assests/characters/02-debugger/debugger-character.svg +3 -0
- package/assests/characters/02-debugger/debugger-figure.png +0 -0
- package/assests/characters/02-debugger/debugger-mascot.png +0 -0
- package/assests/characters/03-architect/architect-card.sbti-archive.svg +5 -0
- package/assests/characters/03-architect/architect-card.svg +3 -0
- package/assests/characters/03-architect/architect-character.sbti-archive.svg +3 -0
- package/assests/characters/03-architect/architect-character.svg +3 -0
- package/assests/characters/03-architect/architect-figure.png +0 -0
- package/assests/characters/03-architect/architect-mascot.png +0 -0
- package/assests/characters/04-codex-believer/codex-believer-card.sbti-archive.svg +5 -0
- package/assests/characters/04-codex-believer/codex-believer-card.svg +3 -0
- package/assests/characters/04-codex-believer/codex-believer-character.sbti-archive.svg +3 -0
- package/assests/characters/04-codex-believer/codex-believer-character.svg +3 -0
- package/assests/characters/04-codex-believer/codex-believer-figure.png +0 -0
- package/assests/characters/04-codex-believer/codex-believer-mascot.png +0 -0
- package/assests/characters/05-prompt-priest/prompt-priest-card.sbti-archive.svg +5 -0
- package/assests/characters/05-prompt-priest/prompt-priest-card.svg +3 -0
- package/assests/characters/05-prompt-priest/prompt-priest-character.sbti-archive.svg +3 -0
- package/assests/characters/05-prompt-priest/prompt-priest-character.svg +3 -0
- package/assests/characters/05-prompt-priest/prompt-priest-figure.png +0 -0
- package/assests/characters/05-prompt-priest/prompt-priest-mascot.png +0 -0
- package/assests/characters/06-tab-hoarder/tab-hoarder-card.sbti-archive.svg +5 -0
- package/assests/characters/06-tab-hoarder/tab-hoarder-card.svg +3 -0
- package/assests/characters/06-tab-hoarder/tab-hoarder-character.sbti-archive.svg +3 -0
- package/assests/characters/06-tab-hoarder/tab-hoarder-character.svg +3 -0
- package/assests/characters/06-tab-hoarder/tab-hoarder-figure.png +0 -0
- package/assests/characters/06-tab-hoarder/tab-hoarder-mascot.png +0 -0
- package/assests/characters/07-context-maxxer/context-maxxer-card.sbti-archive.svg +5 -0
- package/assests/characters/07-context-maxxer/context-maxxer-card.svg +3 -0
- package/assests/characters/07-context-maxxer/context-maxxer-character.sbti-archive.svg +3 -0
- package/assests/characters/07-context-maxxer/context-maxxer-character.svg +3 -0
- package/assests/characters/07-context-maxxer/context-maxxer-figure.png +0 -0
- package/assests/characters/07-context-maxxer/context-maxxer-mascot.png +0 -0
- package/assests/characters/08-yolo-shipper/yolo-shipper-card.sbti-archive.svg +5 -0
- package/assests/characters/08-yolo-shipper/yolo-shipper-card.svg +3 -0
- package/assests/characters/08-yolo-shipper/yolo-shipper-character.sbti-archive.svg +3 -0
- package/assests/characters/08-yolo-shipper/yolo-shipper-character.svg +3 -0
- package/assests/characters/08-yolo-shipper/yolo-shipper-figure.png +0 -0
- package/assests/characters/08-yolo-shipper/yolo-shipper-mascot.png +0 -0
- package/assests/characters-vibe-types/aosf-strategy-shipper/strategy-shipper-figure.png +0 -0
- package/assests/characters-vibe-types/aosx-agent-believer/agent-believer-figure.png +0 -0
- package/assests/characters-vibe-types/aovf-systems-architect/systems-architect-figure.png +0 -0
- package/assests/characters-vibe-types/aovx-context-cartographer/context-cartographer-figure.png +0 -0
- package/assests/characters-vibe-types/apsf-diagram-sprinter/diagram-sprinter-figure.png +0 -0
- package/assests/characters-vibe-types/apsx-infinite-planner/infinite-planner-figure.png +0 -0
- package/assests/characters-vibe-types/apvf-architect/architect-figure.png +0 -0
- package/assests/characters-vibe-types/apvx-prompt-priest/prompt-priest-figure.png +0 -0
- package/assests/characters-vibe-types/mosf-yolo-shipper/yolo-shipper-figure.png +0 -0
- package/assests/characters-vibe-types/mosx-agent-commander/agent-commander-figure.png +0 -0
- package/assests/characters-vibe-types/movf-agent-engineer/agent-engineer-figure.png +0 -0
- package/assests/characters-vibe-types/movx-systems-wrangler/systems-wrangler-figure.png +0 -0
- package/assests/characters-vibe-types/mpsf-builder/builder-figure.png +0 -0
- package/assests/characters-vibe-types/mpsx-tab-hoarder/tab-hoarder-figure.png +0 -0
- package/assests/characters-vibe-types/mpvf-debugger/debugger-figure.png +0 -0
- package/assests/characters-vibe-types/mpvx-context-maxxer/context-maxxer-figure.png +0 -0
- package/assests/live-report.html +1403 -0
- package/assests/notes/live-inspect-summary.json +3487 -0
- package/assests/scripts/compose-banners.py +148 -0
- package/assests/scripts/live-report-boot.js +2 -0
- package/assests/scripts/profiles.json +104 -0
- package/assests/scripts/qa-faces.mjs +72 -0
- package/assests/scripts/roast-extras.js +619 -0
- package/assests/scripts/score-engine.js +62 -0
- package/assests/scripts/split-badges.py +49 -0
- package/assests/scripts/split-reference.py +38 -0
- package/assests/scripts/sync-banners.mjs +45 -0
- package/assests/scripts/sync-cards.mjs +50 -0
- package/assests/scripts/sync-raster.mjs +118 -0
- package/assests/scripts/vibe-types.json +22 -0
- package/assests/source/design-system.md +50 -0
- package/assests/source/prompts/01-builder.md +19 -0
- package/assests/source/prompts/02-debugger.md +17 -0
- package/assests/source/prompts/03-architect.md +16 -0
- package/assests/source/prompts/04-codex-believer.md +13 -0
- package/assests/source/prompts/05-prompt-priest.md +13 -0
- package/assests/source/prompts/06-tab-hoarder.md +13 -0
- package/assests/source/prompts/07-context-maxxer.md +13 -0
- package/assests/source/prompts/08-yolo-shipper.md +13 -0
- package/assests/source/references/sbti-style-reference-board.png +0 -0
- package/assests/source/references/vibe-badges-v6-board.png +0 -0
- package/assests/source/references/vibe-mascots-v4-reference.png +0 -0
- package/assests/source/references/vibe-profiles-v5-board-a.png +0 -0
- package/assests/source/references/vibe-profiles-v5-board-b.png +0 -0
- package/assests/source/vibe-types-visual-brief.md +38 -0
- package/bin/hook.js +121 -0
- package/bin/vibe-roast.js +98 -0
- package/bin/vibe-wrapper.js +4 -0
- package/dashboard/dist/assets/index-DlBycTcO.css +1 -0
- package/dashboard/dist/assets/index-t8m64Hm9.js +168 -0
- package/dashboard/dist/index.html +32 -0
- package/package.json +58 -0
- package/src/extract/environment.js +160 -0
- package/src/extract/phrase-stats.js +684 -0
- package/src/extract/prompt-analysis.js +368 -0
- package/src/extract/text.js +30 -0
- package/src/hooks/install.js +184 -0
- package/src/inspect.js +261 -0
- package/src/lib/activity-metrics.js +238 -0
- package/src/lib/agent-score.js +187 -0
- package/src/lib/ai-roast.js +446 -0
- package/src/lib/dates.js +43 -0
- package/src/lib/hashtags.js +150 -0
- package/src/lib/jsonl.js +48 -0
- package/src/lib/load-env.js +59 -0
- package/src/lib/roast-evidence.js +186 -0
- package/src/lib/roast-snapshot.js +58 -0
- package/src/server.js +644 -0
- package/src/sources/aider.js +150 -0
- package/src/sources/amazonq.js +113 -0
- package/src/sources/antigravity.js +147 -0
- package/src/sources/claude.js +262 -0
- package/src/sources/cline.js +11 -0
- package/src/sources/codex.js +222 -0
- package/src/sources/common.js +133 -0
- package/src/sources/continue.js +67 -0
- package/src/sources/copilot.js +170 -0
- package/src/sources/cursor.js +238 -0
- package/src/sources/gemini.js +75 -0
- package/src/sources/index.js +61 -0
- package/src/sources/roo.js +11 -0
- package/src/sources/token-tracker.js +171 -0
- package/src/sources/vibe-tracker.js +79 -0
- package/src/sources/vscode-tasks.js +142 -0
- package/src/sources/windsurf.js +109 -0
- package/worker/README.md +96 -0
- package/worker/package-lock.json +1547 -0
- package/worker/package.json +20 -0
- package/worker/src/index.mjs +694 -0
- package/worker/wrangler.jsonc +40 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/** Prompt-behaviour personality engine: four dichotomies, sixteen vibe types. */
|
|
2
|
+
|
|
3
|
+
const DIMENSIONS = [
|
|
4
|
+
["orchestration", "Agent orchestration", "Agent 编排", "Workflow delegation density", "工作流与委派密度"],
|
|
5
|
+
["promptCraft", "Prompt craft", "提示词手艺", "Intent clarity vs pasted noise", "清晰意图 vs 粘贴噪音"],
|
|
6
|
+
["build", "Build drive", "构建驱动力", "Implementation and packaging", "实现与打包倾向"],
|
|
7
|
+
["debug", "Verification drive", "验证驱动力", "Debug, tests, and refactors", "调试、测试与重构"],
|
|
8
|
+
["context", "Context appetite", "上下文胃口", "Long prompts and reference payloads", "长提示与引用负载"],
|
|
9
|
+
["ship", "Shipping bias", "交付倾向", "Execution over deliberation", "执行相对思考的倾向"],
|
|
10
|
+
].map(([key, label, labelZh, hint, hintZh]) => ({ key, label, labelZh, hint, hintZh, max: 100 }));
|
|
11
|
+
|
|
12
|
+
const PERSONALITY_ROWS = [
|
|
13
|
+
["MOVF", "Agent Engineer", "Agent 工程师", "Turns agents into a tested assembly line", "把 Agent 变成带质检的流水线", "#2bbf9b"],
|
|
14
|
+
["MOVX", "Systems Wrangler", "系统牧场主", "Herds agents through a context thunderstorm", "在上下文雷暴里放牧 Agent", "#3aa6a0"],
|
|
15
|
+
["MOSF", "YOLO Shipper", "YOLO 发车手", "Automates the merge button's intrusive thoughts", "把 merge 键的冲动想法自动化", "#ef5b45"],
|
|
16
|
+
["MOSX", "Agent Commander", "Agent 总指挥", "Runs a war room where every tab has a rank", "开着每个标签都有军衔的作战室", "#e56b54"],
|
|
17
|
+
["MPVF", "Debugger", "调试猎手", "Cross-examines one bug until it confesses", "审到一个 bug 主动招供", "#e65b89"],
|
|
18
|
+
["MPVX", "Context Maxxer", "上下文极限党", "Brings the whole repo to a one-line bug", "为一行 bug 搬来整个仓库", "#35a9c5"],
|
|
19
|
+
["MPSF", "Builder", "构建侠", "Converts short prompts into suspiciously fast demos", "把短提示变成快得可疑的 demo", "#25b985"],
|
|
20
|
+
["MPSX", "Tab Hoarder", "标签囤积癖", "Needs forty-seven tabs to ship one button", "需要四十七个标签才能上线一个按钮", "#e58a39"],
|
|
21
|
+
["AOVF", "Systems Architect", "系统架构师", "Makes the agent read the ADR before touching a key", "让 Agent 碰键盘前先读 ADR", "#557fd8"],
|
|
22
|
+
["AOVX", "Context Cartographer", "上下文制图师", "Maps every dependency before crossing the repo", "穿越仓库前先画完每条依赖", "#6686c7"],
|
|
23
|
+
["AOSF", "Strategy Shipper", "战略交付官", "Ships the roadmap while everyone debates the map", "别人争地图时已经把路线图上线", "#8a72d6"],
|
|
24
|
+
["AOSX", "Agent Believer", "Agent 信徒", "Builds a cathedral out of tools and context", "用工具与上下文盖一座大教堂", "#9a70cf"],
|
|
25
|
+
["APVF", "Architect", "架构师", "Reviews the rectangle before allowing it to run", "矩形通过评审后才允许它运行", "#5678c7"],
|
|
26
|
+
["APVX", "Prompt Priest", "提示祭司", "Writes scripture long enough to need version control", "经文长到需要单独做版本控制", "#d4a334"],
|
|
27
|
+
["APSF", "Diagram Sprinter", "框图冲刺手", "Draws three systems, then ships the smallest box", "画三套系统,再上线最小那个框", "#cf7850"],
|
|
28
|
+
["APSX", "Infinite Planner", "无限规划师", "One more context file away from starting", "永远只差再读一个文件就开工", "#9c75b9"],
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
const PERSONALITIES = Object.fromEntries(PERSONALITY_ROWS.map(([code, title, titleZh, hook, hookZh, accent]) => {
|
|
32
|
+
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
|
33
|
+
return [code, { id: code, code, title, titleZh, hook, hookZh, accent, folder: `${code.toLowerCase()}-${slug}`, name: slug }];
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
const LEGACY_FIGURES = {
|
|
37
|
+
MOVF: "01-builder/builder", MOVX: "07-context-maxxer/context-maxxer", MOSF: "08-yolo-shipper/yolo-shipper", MOSX: "04-codex-believer/codex-believer",
|
|
38
|
+
MPVF: "02-debugger/debugger", MPVX: "07-context-maxxer/context-maxxer", MPSF: "01-builder/builder", MPSX: "06-tab-hoarder/tab-hoarder",
|
|
39
|
+
AOVF: "03-architect/architect", AOVX: "07-context-maxxer/context-maxxer", AOSF: "03-architect/architect", AOSX: "04-codex-believer/codex-believer",
|
|
40
|
+
APVF: "03-architect/architect", APVX: "05-prompt-priest/prompt-priest", APSF: "03-architect/architect", APSX: "05-prompt-priest/prompt-priest",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const AXIS_DEFS = [
|
|
44
|
+
{ key: "maker_architect", left: ["M", "Maker", "造物派"], right: ["A", "Architect", "架构派"] },
|
|
45
|
+
{ key: "orchestrator_promptsmith", left: ["O", "Orchestrator", "编排派"], right: ["P", "Promptsmith", "提示派"] },
|
|
46
|
+
{ key: "verifier_shipper", left: ["V", "Verifier", "验证派"], right: ["S", "Shipper", "交付派"] },
|
|
47
|
+
{ key: "focused_maxcontext", left: ["F", "Focused", "聚焦派"], right: ["X", "Max-context", "满上下文派"] },
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
function categoryCount(categories, name) {
|
|
51
|
+
const row = categories?.[name];
|
|
52
|
+
const value = typeof row === "number" ? row : Number(row?.count || 0);
|
|
53
|
+
return Number.isFinite(value) ? value : 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function ratioScore(numerator, denominator) {
|
|
57
|
+
return denominator > 0 ? Math.round(Math.min(1, Math.max(0, numerator / denominator)) * 1000) / 10 : 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function scoreFromCategories(categories = {}, _env = {}, promptAnalysis = {}) {
|
|
61
|
+
const c = (name) => categoryCount(categories, name);
|
|
62
|
+
const useful = ["implementation", "debugging", "planning", "research", "ui_design", "explanation", "testing", "refactor", "packaging", "workflow"].reduce((sum, key) => sum + c(key), 0);
|
|
63
|
+
if (useful <= 0) return Object.fromEntries(DIMENSIONS.map((dim) => [dim.key, 0]));
|
|
64
|
+
const usefulRatio = Number(promptAnalysis.useful_ratio ?? useful / Math.max(useful + c("reference"), 1));
|
|
65
|
+
const longRatio = Number(promptAnalysis.long_prompt_ratio || 0);
|
|
66
|
+
return {
|
|
67
|
+
orchestration: ratioScore(c("workflow") * 2, useful * 0.45),
|
|
68
|
+
promptCraft: Math.round(Math.min(1, usefulRatio * 0.8 + ((c("planning") + c("explanation")) / useful) * 0.5) * 1000) / 10,
|
|
69
|
+
build: ratioScore(c("implementation") + c("packaging") + c("ui_design") * 0.5, useful * 0.75),
|
|
70
|
+
debug: ratioScore(c("debugging") + c("testing") + c("refactor") * 0.6, useful * 0.55),
|
|
71
|
+
context: Math.round(Math.min(1, longRatio * 1.5 + (1 - usefulRatio) * 0.7) * 1000) / 10,
|
|
72
|
+
ship: ratioScore(c("implementation") + c("packaging"), c("implementation") + c("packaging") + c("planning") + c("research") + c("debugging") + c("testing")),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function splitAxis(def, leftEvidence, rightEvidence) {
|
|
77
|
+
const total = leftEvidence + rightEvidence;
|
|
78
|
+
const leftPercent = total > 0 ? Math.round((leftEvidence / total) * 100) : 50;
|
|
79
|
+
const rightPercent = 100 - leftPercent;
|
|
80
|
+
const chosen = leftPercent >= rightPercent ? def.left : def.right;
|
|
81
|
+
return {
|
|
82
|
+
key: def.key,
|
|
83
|
+
letter: chosen[0],
|
|
84
|
+
label: chosen[1],
|
|
85
|
+
labelZh: chosen[2],
|
|
86
|
+
left: { code: def.left[0], label: def.left[1], labelZh: def.left[2], percent: leftPercent },
|
|
87
|
+
right: { code: def.right[0], label: def.right[1], labelZh: def.right[2], percent: rightPercent },
|
|
88
|
+
margin: Math.abs(leftPercent - rightPercent),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function classifyType(categories = {}, promptAnalysis = {}) {
|
|
93
|
+
const c = (name) => categoryCount(categories, name);
|
|
94
|
+
const usefulRatio = Number(promptAnalysis.useful_ratio ?? 1);
|
|
95
|
+
const longRatio = Number(promptAnalysis.long_prompt_ratio || 0);
|
|
96
|
+
const referenceRatio = Math.max(0, 1 - usefulRatio);
|
|
97
|
+
const axes = [
|
|
98
|
+
splitAxis(AXIS_DEFS[0], c("implementation") + c("packaging") + c("testing") + c("refactor") + c("debugging") + c("ui_design") * 0.5, c("planning") + c("research") + c("explanation") + c("ui_design") * 0.5),
|
|
99
|
+
splitAxis(AXIS_DEFS[1], c("workflow") * 2 + c("planning") * 0.2, c("implementation") * 0.2 + c("explanation") + c("ui_design") * 0.35 + 0.25),
|
|
100
|
+
splitAxis(AXIS_DEFS[2], c("debugging") + c("testing") + c("refactor") * 0.7, c("implementation") + c("packaging") + c("ui_design") * 0.25),
|
|
101
|
+
splitAxis(AXIS_DEFS[3], Math.max(0.05, 1 - longRatio - referenceRatio), longRatio * 1.4 + referenceRatio * 1.2),
|
|
102
|
+
];
|
|
103
|
+
return { code: axes.map((axis) => axis.letter).join(""), axes };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function confidenceFor(promptAnalysis, axes) {
|
|
107
|
+
const useful = Number(promptAnalysis?.useful_prompt_count || 0);
|
|
108
|
+
const sample = Math.min(1, useful / 80);
|
|
109
|
+
const separation = axes.reduce((sum, axis) => sum + axis.margin, 0) / axes.length / 100;
|
|
110
|
+
return Math.round(sample * (0.65 + separation * 0.35) * 100);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function confidenceTier(confidence) {
|
|
114
|
+
if (confidence >= 75) return {
|
|
115
|
+
id: "CLEAR",
|
|
116
|
+
color: "#2bb673",
|
|
117
|
+
emoji: "◆",
|
|
118
|
+
blurb: "Strong sample · clear axis separation",
|
|
119
|
+
blurbZh: "样本充足 · 行为轴区分清晰",
|
|
120
|
+
};
|
|
121
|
+
if (confidence >= 45) return {
|
|
122
|
+
id: "EMERGING",
|
|
123
|
+
color: "#d49a25",
|
|
124
|
+
emoji: "◇",
|
|
125
|
+
blurb: "Growing sample · mixed axis separation",
|
|
126
|
+
blurbZh: "样本增长中 · 部分行为轴仍接近",
|
|
127
|
+
};
|
|
128
|
+
return {
|
|
129
|
+
id: "LOW DATA",
|
|
130
|
+
color: "#8b8680",
|
|
131
|
+
emoji: "·",
|
|
132
|
+
blurb: "More prompts needed · provisional type",
|
|
133
|
+
blurbZh: "仍需更多提示 · 当前类型为暂定",
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function buildRoast(personality, axes, confidence, usefulCount) {
|
|
138
|
+
if (usefulCount < 20) return {
|
|
139
|
+
roast: `🔥 The scanner found ${usefulCount} useful prompts — enough for a vibe trailer, not a personality verdict. Your provisional type is ${personality.code}, but four letters this early are basically a horoscope wearing a terminal theme. Feed it at least 20 real requests before framing the result.`,
|
|
140
|
+
roastZh: `🔥 扫描器只找到 ${usefulCount} 条有效提示——够剪一支预告片,不够给人格定案。暂定类型是 ${personality.code},但这么早的四个字母,本质上只是穿了终端主题的星座运势。至少喂到 20 条真实请求再截图裱起来。`,
|
|
141
|
+
tldr: "Provisional vibe; the dataset needs more receipts.", tldrZh: "暂定 vibe;样本还得继续交作业。",
|
|
142
|
+
};
|
|
143
|
+
const a = Object.fromEntries(axes.map((axis) => [axis.key, axis]));
|
|
144
|
+
const closest = [...axes].sort((x, y) => x.margin - y.margin)[0];
|
|
145
|
+
const en = {
|
|
146
|
+
M: "You ask for working artifacts before the architecture committee finds a chair.", A: "You can turn a two-line feature into a diagram with municipal zoning.",
|
|
147
|
+
O: "One agent is never enough; apparently the task also needs a command structure.", P: "You prefer one exquisitely instructed agent and a prompt with terms and conditions.",
|
|
148
|
+
V: "Nothing ships until the bug has confessed and the tests have signed the statement.", S: "The merge button knows your fingerprint better than your test runner does.",
|
|
149
|
+
F: "You ration context like carry-on luggage.", X: "You bring the entire repo to questions that could fit on a sticky note.",
|
|
150
|
+
};
|
|
151
|
+
const zh = {
|
|
152
|
+
M: "架构委员会还没找到椅子,你已经要求交付可运行产物。", A: "两行需求到你手里,也能长成带城市规划的系统图。",
|
|
153
|
+
O: "一个 Agent 显然不够,这个任务还必须拥有指挥体系。", P: "你偏爱一位被精密调教的 Agent,以及一份自带条款的提示词。",
|
|
154
|
+
V: "bug 没招供、测试没签字,谁都别想上线。", S: "merge 键认得你的指纹,比测试运行器还熟。",
|
|
155
|
+
F: "你分配上下文,像廉航分配随身行李。", X: "便利贴能装下的问题,你也要把整个仓库搬来。",
|
|
156
|
+
};
|
|
157
|
+
const letters = axes.map((axis) => axis.letter);
|
|
158
|
+
return {
|
|
159
|
+
roast: `🔥 ${personality.title}: ${personality.hook}. ${letters.map((letter) => en[letter]).join(" ")}\n\nYour closest split is ${closest.left.code}/${closest.right.code} at ${closest.left.percent}/${closest.right.percent}, so that supposedly permanent letter is one refactor away from switching teams. Confidence is ${confidence}% — evidence, not destiny.`,
|
|
160
|
+
roastZh: `🔥 ${personality.titleZh}:${personality.hookZh}。${letters.map((letter) => zh[letter]).join("")}\n\n你最摇摆的是 ${closest.left.code}/${closest.right.code}(${closest.left.percent}/${closest.right.percent}),所以那个看似永久的字母,可能一次重构就叛变。置信度 ${confidence}%——这是证据,不是命运。`,
|
|
161
|
+
tldr: `${personality.code}: ${personality.hook}.`, tldrZh: `${personality.code}:${personality.hookZh}。`,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const { buildActivitySignals } = require("./activity-metrics");
|
|
166
|
+
|
|
167
|
+
function buildVibeProfile({ categories = {}, env = {}, summary = {}, promptAnalysis = {}, activity = {} } = {}) {
|
|
168
|
+
const scores = scoreFromCategories(categories, env, promptAnalysis);
|
|
169
|
+
const typed = classifyType(categories, promptAnalysis);
|
|
170
|
+
const personality = PERSONALITIES[typed.code];
|
|
171
|
+
const confidence = confidenceFor(promptAnalysis, typed.axes);
|
|
172
|
+
const status = Number(promptAnalysis.useful_prompt_count || 0) >= 20 ? "ready" : "insufficient_data";
|
|
173
|
+
const roast = buildRoast(personality, typed.axes, confidence, Number(promptAnalysis.useful_prompt_count || 0));
|
|
174
|
+
return {
|
|
175
|
+
status, confidence, type_code: typed.code, type_axes: typed.axes, scores,
|
|
176
|
+
total: confidence, tier: confidenceTier(confidence),
|
|
177
|
+
personality, archetype: personality,
|
|
178
|
+
figure: `/assests/characters-vibe-types/${personality.folder}/${personality.name}-figure.png`,
|
|
179
|
+
figure_fallback: `/assests/characters/${LEGACY_FIGURES[typed.code].split("/")[0]}/${LEGACY_FIGURES[typed.code].split("/")[1]}-figure.png`,
|
|
180
|
+
badge: null,
|
|
181
|
+
dimensions: DIMENSIONS.map((dim) => ({ ...dim, value: scores[dim.key], score: scores[dim.key] / 100 })),
|
|
182
|
+
signals: buildActivitySignals({ activity, summary, categories }),
|
|
183
|
+
...roast,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
module.exports = { DIMENSIONS, PERSONALITIES, AXIS_DEFS, scoreFromCategories, classifyType, confidenceFor, buildRoast, buildVibeProfile };
|
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
/** Optional multi-provider roast writer. It cannot change deterministic profile results. */
|
|
2
|
+
|
|
3
|
+
const DEFAULT_BASE_URL = "https://api.deepseek.com";
|
|
4
|
+
const DEFAULT_MODEL = "deepseek-v4-flash";
|
|
5
|
+
const PROVIDERS = {
|
|
6
|
+
deepseek: {
|
|
7
|
+
id: "deepseek",
|
|
8
|
+
protocol: "openai",
|
|
9
|
+
baseUrl: DEFAULT_BASE_URL,
|
|
10
|
+
model: DEFAULT_MODEL,
|
|
11
|
+
},
|
|
12
|
+
openai: {
|
|
13
|
+
id: "openai",
|
|
14
|
+
protocol: "openai",
|
|
15
|
+
baseUrl: "https://api.openai.com/v1",
|
|
16
|
+
model: "gpt-4.1-mini",
|
|
17
|
+
},
|
|
18
|
+
anthropic: {
|
|
19
|
+
id: "anthropic",
|
|
20
|
+
protocol: "anthropic",
|
|
21
|
+
baseUrl: "https://api.anthropic.com/v1",
|
|
22
|
+
model: "claude-haiku-4-5-20251001",
|
|
23
|
+
},
|
|
24
|
+
gemini: {
|
|
25
|
+
id: "gemini",
|
|
26
|
+
protocol: "gemini",
|
|
27
|
+
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
|
|
28
|
+
model: "gemini-2.5-flash",
|
|
29
|
+
},
|
|
30
|
+
groq: {
|
|
31
|
+
id: "groq",
|
|
32
|
+
protocol: "openai",
|
|
33
|
+
baseUrl: "https://api.groq.com/openai/v1",
|
|
34
|
+
model: "llama-3.3-70b-versatile",
|
|
35
|
+
},
|
|
36
|
+
openrouter: {
|
|
37
|
+
id: "openrouter",
|
|
38
|
+
protocol: "openai",
|
|
39
|
+
baseUrl: "https://openrouter.ai/api/v1",
|
|
40
|
+
model: "openai/gpt-4.1-mini",
|
|
41
|
+
},
|
|
42
|
+
cloudflare: {
|
|
43
|
+
id: "cloudflare",
|
|
44
|
+
protocol: "openai",
|
|
45
|
+
baseUrl: "https://auth.pinktalk.online/v1",
|
|
46
|
+
model: "@cf/qwen/qwen3-30b-a3b-fp8",
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const SYSTEM_PROMPT = `You are the head writer for an evidence-grounded vibe-coding roast: part late-night monologue, part affectionate code-review afterparty.
|
|
51
|
+
The deterministic type, axes, confidence, dimensions, counts, and rankings are immutable. Never recalculate or modify them.
|
|
52
|
+
|
|
53
|
+
The job is to ROAST, not summarize. Write bilingual, vivid, shareable comedy grounded only in the supplied JSON evidence.
|
|
54
|
+
|
|
55
|
+
COMEDY ENGINE
|
|
56
|
+
- First choose one central comic premise from the strongest contrast in roast_targets or dimensions. Build the whole roast around it instead of listing metrics.
|
|
57
|
+
- Use a three-beat escalation: (1) a sharp cold open that casts the personality as a character, (2) a receipts paragraph with 2-4 evidence-backed details, and (3) an absurd-but-grounded escalation ending on the strongest punchline.
|
|
58
|
+
- Sustain one concrete metaphor world—courtroom, kitchen, airport, laboratory, reality show, religion, heist, sports commentary, etc.—chosen to fit the evidence. Do not mix five unrelated metaphors.
|
|
59
|
+
- Prefer specific nouns, active verbs, varied sentence rhythm, callbacks, contrast, and comic misdirection. One sentence may be dry; the next should twist the knife.
|
|
60
|
+
- Mention at least one distinctive recurring domain concept when recurring_domain_concepts is non-empty. Treat it as a prompt mention, not proof of a project.
|
|
61
|
+
- Use exact numbers selectively as "receipts"; never turn every sentence into a dashboard caption.
|
|
62
|
+
- The final sentence must be a quotable punchline, not advice, diagnosis, or a generic summary.
|
|
63
|
+
- English and Chinese should be locally funny adaptations using the same evidence beats, not stiff line-by-line translations.
|
|
64
|
+
- Hashtags are tiny jokes or character labels, not raw metric names.
|
|
65
|
+
- Make the prompts, signals, ratios, dashboard, or fictionalized data-character the grammatical subject of risky jokes. Do not turn a low signal into a literal claim about what the person remembers, knows, feels, says, or does outside the observed prompts.
|
|
66
|
+
|
|
67
|
+
HASHTAG ENGINE
|
|
68
|
+
- Hashtags are interpretations, not a frequency table. Never copy a recurring concept merely because it ranks highly.
|
|
69
|
+
- Read coherent concept clusters and infer the recognizable domain, community, scene, or character behind them. For example, preflop + folds + raises + river can become "HoldemPlayer"; 前庭 + HIT + nystagmus can become "VestibularLab" or "前庭实验室".
|
|
70
|
+
- The first tag should be the strongest inferred domain/scene character when the evidence supports one. If it does not, lead with the personality character instead.
|
|
71
|
+
- At least two tags should perform a semantic transformation: one domain/scene character and one coding-behavior joke. The remaining tags may reinforce personality or the central roast metaphor.
|
|
72
|
+
- Do not emit isolated raw keywords such as "preflop", "browser", "implementation", "debugging", or "UI" as tags. A proper name or acronym may remain literal only when it is already a meaningful identity label.
|
|
73
|
+
- Generate every tag as one bilingual pair with a shared semantic gloss. The Chinese tag must localize the intended joke, not translate the English surface form word by word. "CodeCult" means cult-like devotion and should become "代码教团" or "代码邪教", never "代码文化".
|
|
74
|
+
- When kind is "personality_character", reuse immutable_profile.personality and immutable_profile.personality_zh instead of inventing a new translation.
|
|
75
|
+
- Use kind "proper_noun" only for names or acronyms that should remain unchanged in both languages, such as GitHub, HIT, or ONNX.
|
|
76
|
+
- Infer only from a coherent cluster. Never fuse unrelated concepts into a fictional project, occupation, credential, or claim of ownership.
|
|
77
|
+
|
|
78
|
+
ANTI-BLANDNESS
|
|
79
|
+
- Avoid report voice and filler such as "You're a builder", "all about", "minor signal", "faint echo", "verification pending", "context is optional", "X master", or "the data suggests".
|
|
80
|
+
- Do not mechanically restate dimension labels. Convert evidence into a scene, character, prop, or running gag.
|
|
81
|
+
- Do not compliment for two sentences and add one mild jab. Every paragraph needs a comic turn.
|
|
82
|
+
- Do not give productivity advice or finish with "maybe test more".
|
|
83
|
+
- Never invent dialogue or put a first-person quote in the user's mouth.
|
|
84
|
+
- Never claim "you forgot context", "the only time you debug", "you are not shipping", "you have no patience", "your ego", or equivalent wording. Translate those impulses into jokes about the observed signal instead.
|
|
85
|
+
|
|
86
|
+
EVIDENCE RULES
|
|
87
|
+
- Fact first, jab second. Every joke must be traceable to a number, category, recurring concept, model, agent, or explicit contradiction in the evidence.
|
|
88
|
+
- Prefer distinctive recurring project/domain concepts over generic coding words when the evidence supports them.
|
|
89
|
+
- Roast coding behavior and workflow, never identity, protected traits, employer role, intelligence, or private life.
|
|
90
|
+
- Do not claim causality, cheating, maintainership, expertise, or project ownership unless explicitly present.
|
|
91
|
+
- A low testing/debugging signal means verification is under-observed in these prompts. It does not prove the code is wrong, untested elsewhere, or delegated to other people.
|
|
92
|
+
- Treat category counts as observed prompt behavior, not objective engineering quality or completed work.
|
|
93
|
+
- Concept occurrence_count means text mentions, never completed commits, pushes, tests, features, or other real-world actions. Call it a mention when citing it.
|
|
94
|
+
- Aggregated concepts do not prove they belong to the same project. Never combine separate concepts into an invented app, bot, product, stack, or ownership claim. Say the prompts "orbit", "mention", or "cluster around" those concepts.
|
|
95
|
+
- Category ratios do not reveal execution order. Never say code shipped before verification, entered production, broke, contained bugs, or failed to run.
|
|
96
|
+
- Do not turn implementation/testing/debugging prompt categories into completed implementation, shipping, testing, or debugging actions. Say "implementation-classified prompts", "testing signal", or "debugging mentions".
|
|
97
|
+
- Do not quote or reconstruct raw prompts.
|
|
98
|
+
- Scale confidence: when status is insufficient_data, say the reading is provisional.
|
|
99
|
+
- Return JSON only.`;
|
|
100
|
+
|
|
101
|
+
const USER_INSTRUCTIONS = `Generate exactly this JSON shape:
|
|
102
|
+
{
|
|
103
|
+
"hashtagPairs": [
|
|
104
|
+
{
|
|
105
|
+
"en": "concise English tag without #",
|
|
106
|
+
"zh": "localized Chinese tag without #",
|
|
107
|
+
"kind": "domain_character | behavior_character | personality_character | metaphor_callback | proper_noun",
|
|
108
|
+
"meaning": "short English semantic gloss explaining the intended joke"
|
|
109
|
+
}
|
|
110
|
+
],
|
|
111
|
+
"roast": "English roast, exactly 3 short paragraphs separated by blank lines, target 120-170 words, <= 1100 characters",
|
|
112
|
+
"roastZh": "Chinese roast, exactly 3 short paragraphs separated by blank lines, target 220-340 Chinese characters, <= 700 characters",
|
|
113
|
+
"tldr": "English one-line mic-drop that adds a fresh joke, <= 150 characters",
|
|
114
|
+
"tldrZh": "Chinese one-line mic-drop that adds a fresh joke, <= 88 Chinese characters"
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
Before writing, silently select:
|
|
118
|
+
1. the central contradiction;
|
|
119
|
+
2. the metaphor world;
|
|
120
|
+
3. three evidence receipts;
|
|
121
|
+
4. the closing callback.
|
|
122
|
+
|
|
123
|
+
Return 4-5 hashtagPairs. Order them as: inferred domain/scene character, coding-behavior joke, personality character, then optional metaphor/callback tags.
|
|
124
|
+
Each pair must express the same meaning in both languages. Tags must be compact semantic labels, not copied high-frequency words. English tags must be English except proper names/acronyms. Chinese tags must be a culturally natural localization of the meaning, not a dictionary translation of the English word shape.`;
|
|
125
|
+
|
|
126
|
+
function endpoint(baseUrl) {
|
|
127
|
+
return `${String(baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, "")}/chat/completions`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function providerConfig(provider = "deepseek", overrides = {}) {
|
|
131
|
+
const id = String(provider || "deepseek").toLowerCase();
|
|
132
|
+
const preset = PROVIDERS[id];
|
|
133
|
+
if (!preset) throw new Error(`Unsupported AI provider: ${provider}`);
|
|
134
|
+
return {
|
|
135
|
+
...preset,
|
|
136
|
+
baseUrl: overrides.baseUrl || preset.baseUrl,
|
|
137
|
+
model: overrides.model || preset.model,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function requestForProvider(config, { apiKey, messages, temperature, maxTokens }) {
|
|
142
|
+
if (config.protocol === "anthropic") {
|
|
143
|
+
const system = messages.filter((message) => message.role === "system").map((message) => message.content).join("\n\n");
|
|
144
|
+
const conversation = messages
|
|
145
|
+
.filter((message) => message.role !== "system")
|
|
146
|
+
.map((message) => ({
|
|
147
|
+
role: message.role === "assistant" ? "assistant" : "user",
|
|
148
|
+
content: message.content,
|
|
149
|
+
}));
|
|
150
|
+
return {
|
|
151
|
+
url: `${String(config.baseUrl).replace(/\/+$/, "")}/messages`,
|
|
152
|
+
options: {
|
|
153
|
+
method: "POST",
|
|
154
|
+
headers: {
|
|
155
|
+
"Content-Type": "application/json",
|
|
156
|
+
"x-api-key": apiKey,
|
|
157
|
+
"anthropic-version": "2023-06-01",
|
|
158
|
+
},
|
|
159
|
+
body: JSON.stringify({
|
|
160
|
+
model: config.model,
|
|
161
|
+
system,
|
|
162
|
+
messages: conversation,
|
|
163
|
+
temperature,
|
|
164
|
+
max_tokens: maxTokens,
|
|
165
|
+
}),
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (config.protocol === "gemini") {
|
|
171
|
+
const system = messages.filter((message) => message.role === "system").map((message) => message.content).join("\n\n");
|
|
172
|
+
const contents = messages
|
|
173
|
+
.filter((message) => message.role !== "system")
|
|
174
|
+
.map((message) => ({
|
|
175
|
+
role: message.role === "assistant" ? "model" : "user",
|
|
176
|
+
parts: [{ text: message.content }],
|
|
177
|
+
}));
|
|
178
|
+
const model = encodeURIComponent(config.model);
|
|
179
|
+
return {
|
|
180
|
+
url: `${String(config.baseUrl).replace(/\/+$/, "")}/models/${model}:generateContent?key=${encodeURIComponent(apiKey)}`,
|
|
181
|
+
options: {
|
|
182
|
+
method: "POST",
|
|
183
|
+
headers: { "Content-Type": "application/json" },
|
|
184
|
+
body: JSON.stringify({
|
|
185
|
+
systemInstruction: { parts: [{ text: system }] },
|
|
186
|
+
contents,
|
|
187
|
+
generationConfig: {
|
|
188
|
+
temperature,
|
|
189
|
+
maxOutputTokens: maxTokens,
|
|
190
|
+
responseMimeType: "application/json",
|
|
191
|
+
},
|
|
192
|
+
}),
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const body = {
|
|
198
|
+
model: config.model,
|
|
199
|
+
messages,
|
|
200
|
+
response_format: { type: "json_object" },
|
|
201
|
+
temperature,
|
|
202
|
+
max_tokens: maxTokens,
|
|
203
|
+
};
|
|
204
|
+
if (config.id === "deepseek") body.thinking = { type: "disabled" };
|
|
205
|
+
const headers = {
|
|
206
|
+
"Content-Type": "application/json",
|
|
207
|
+
Authorization: `Bearer ${apiKey}`,
|
|
208
|
+
};
|
|
209
|
+
return {
|
|
210
|
+
url: endpoint(config.baseUrl),
|
|
211
|
+
options: { method: "POST", headers, body: JSON.stringify(body) },
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function responseContent(config, payload) {
|
|
216
|
+
if (config.protocol === "anthropic") {
|
|
217
|
+
return payload?.content?.find((part) => part?.type === "text")?.text;
|
|
218
|
+
}
|
|
219
|
+
if (config.protocol === "gemini") {
|
|
220
|
+
return payload?.candidates?.[0]?.content?.parts?.map((part) => part?.text || "").join("");
|
|
221
|
+
}
|
|
222
|
+
return payload?.choices?.[0]?.message?.content;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function cleanText(value, maxLength, { short = false } = {}) {
|
|
226
|
+
const text = String(value || "").trim();
|
|
227
|
+
if (text.length <= maxLength) return text;
|
|
228
|
+
const window = text.slice(0, maxLength);
|
|
229
|
+
const minimum = Math.floor(maxLength * (short ? 0.4 : 0.58));
|
|
230
|
+
let boundary = -1;
|
|
231
|
+
for (let index = minimum; index < window.length; index += 1) {
|
|
232
|
+
const pattern = short ? /[.!?。!?,;:,;:\n]/ : /[.!?。!?\n]/;
|
|
233
|
+
if (window[index] === "." && /\d/.test(window[index - 1] || "") && /\d/.test(window[index + 1] || "")) {
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (pattern.test(window[index])) boundary = index + 1;
|
|
237
|
+
}
|
|
238
|
+
if (boundary > minimum) return window.slice(0, boundary).trim();
|
|
239
|
+
const wordBoundary = window.lastIndexOf(" ");
|
|
240
|
+
const clipped = wordBoundary > minimum ? window.slice(0, wordBoundary) : window.slice(0, maxLength - 1);
|
|
241
|
+
return `${clipped.trim()}…`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function cleanTags(value, maxLength) {
|
|
245
|
+
if (!Array.isArray(value)) return [];
|
|
246
|
+
const seen = new Set();
|
|
247
|
+
const tags = [];
|
|
248
|
+
for (const raw of value) {
|
|
249
|
+
const tag = String(raw || "")
|
|
250
|
+
.replace(/^#+/, "")
|
|
251
|
+
.replace(/[\r\n]/g, " ")
|
|
252
|
+
.trim()
|
|
253
|
+
.slice(0, maxLength);
|
|
254
|
+
const key = tag.toLowerCase();
|
|
255
|
+
if (!tag || seen.has(key)) continue;
|
|
256
|
+
seen.add(key);
|
|
257
|
+
tags.push(tag);
|
|
258
|
+
if (tags.length >= 5) break;
|
|
259
|
+
}
|
|
260
|
+
return tags;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const HASHTAG_KINDS = new Set([
|
|
264
|
+
"domain_character",
|
|
265
|
+
"behavior_character",
|
|
266
|
+
"personality_character",
|
|
267
|
+
"metaphor_callback",
|
|
268
|
+
"proper_noun",
|
|
269
|
+
]);
|
|
270
|
+
|
|
271
|
+
function cleanHashtagPairs(value) {
|
|
272
|
+
if (!Array.isArray(value)) return [];
|
|
273
|
+
const pairs = [];
|
|
274
|
+
const seenEn = new Set();
|
|
275
|
+
const seenZh = new Set();
|
|
276
|
+
for (const raw of value) {
|
|
277
|
+
const en = cleanTags([raw?.en], 24)[0];
|
|
278
|
+
const zh = cleanTags([raw?.zh], 12)[0];
|
|
279
|
+
if (!en || !zh) continue;
|
|
280
|
+
const enKey = en.toLowerCase();
|
|
281
|
+
const zhKey = zh.toLowerCase();
|
|
282
|
+
if (seenEn.has(enKey) || seenZh.has(zhKey)) continue;
|
|
283
|
+
const requestedKind = String(raw?.kind || "");
|
|
284
|
+
const kind = HASHTAG_KINDS.has(requestedKind) ? requestedKind : "metaphor_callback";
|
|
285
|
+
if (enKey === zhKey && kind !== "proper_noun") continue;
|
|
286
|
+
seenEn.add(enKey);
|
|
287
|
+
seenZh.add(zhKey);
|
|
288
|
+
pairs.push({
|
|
289
|
+
en,
|
|
290
|
+
zh,
|
|
291
|
+
kind,
|
|
292
|
+
meaning: cleanText(raw?.meaning, 80, { short: true }),
|
|
293
|
+
});
|
|
294
|
+
if (pairs.length >= 5) break;
|
|
295
|
+
}
|
|
296
|
+
return pairs;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function sentenceParts(value) {
|
|
300
|
+
return String(value || "")
|
|
301
|
+
.match(/[^.!?。!?\n]+(?:[.!?。!?]+|$)/gu)
|
|
302
|
+
?.map((sentence) => sentence.trim())
|
|
303
|
+
.filter(Boolean) || [];
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function paragraphize(value, target = 3) {
|
|
307
|
+
const text = String(value || "").trim();
|
|
308
|
+
const existing = text.split(/\n\s*\n/u).map((part) => part.trim()).filter(Boolean);
|
|
309
|
+
if (existing.length >= target) return existing.join("\n\n");
|
|
310
|
+
const sentences = sentenceParts(text);
|
|
311
|
+
if (sentences.length < target) return text;
|
|
312
|
+
const paragraphs = [];
|
|
313
|
+
let cursor = 0;
|
|
314
|
+
for (let index = 0; index < target; index += 1) {
|
|
315
|
+
const remainingSentences = sentences.length - cursor;
|
|
316
|
+
const remainingParagraphs = target - index;
|
|
317
|
+
const take = Math.ceil(remainingSentences / remainingParagraphs);
|
|
318
|
+
paragraphs.push(sentences.slice(cursor, cursor + take).join(" "));
|
|
319
|
+
cursor += take;
|
|
320
|
+
}
|
|
321
|
+
return paragraphs.join("\n\n");
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function parseJsonPayload(raw) {
|
|
325
|
+
if (raw && typeof raw === "object") return raw;
|
|
326
|
+
const text = String(raw || "").trim();
|
|
327
|
+
if (!text) return null;
|
|
328
|
+
try {
|
|
329
|
+
return JSON.parse(text);
|
|
330
|
+
} catch {
|
|
331
|
+
const unfenced = text
|
|
332
|
+
.replace(/^```(?:json)?\s*/iu, "")
|
|
333
|
+
.replace(/\s*```$/u, "")
|
|
334
|
+
.trim();
|
|
335
|
+
try {
|
|
336
|
+
return JSON.parse(unfenced);
|
|
337
|
+
} catch {
|
|
338
|
+
const start = unfenced.indexOf("{");
|
|
339
|
+
const end = unfenced.lastIndexOf("}");
|
|
340
|
+
if (start >= 0 && end > start) return JSON.parse(unfenced.slice(start, end + 1));
|
|
341
|
+
throw new SyntaxError("AI roast response is not valid JSON");
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function validateRoastPayload(raw) {
|
|
347
|
+
const parsed = parseJsonPayload(raw);
|
|
348
|
+
const hashtagPairs = cleanHashtagPairs(parsed?.hashtagPairs);
|
|
349
|
+
const paired = hashtagPairs.length >= 3;
|
|
350
|
+
const result = {
|
|
351
|
+
...(paired ? { hashtagPairs } : {}),
|
|
352
|
+
hashtags: paired ? hashtagPairs.map((pair) => pair.en) : cleanTags(parsed?.hashtags, 24),
|
|
353
|
+
hashtagsZh: paired ? hashtagPairs.map((pair) => pair.zh) : cleanTags(parsed?.hashtagsZh, 12),
|
|
354
|
+
roast: paragraphize(cleanText(parsed?.roast, 1100)),
|
|
355
|
+
roastZh: paragraphize(cleanText(parsed?.roastZh, 700)),
|
|
356
|
+
tldr: cleanText(parsed?.tldr, 150, { short: true }),
|
|
357
|
+
tldrZh: cleanText(parsed?.tldrZh, 88, { short: true }),
|
|
358
|
+
};
|
|
359
|
+
if (result.hashtags.length < 3 || result.hashtagsZh.length < 3
|
|
360
|
+
|| !result.roast || !result.roastZh || !result.tldr || !result.tldrZh) {
|
|
361
|
+
throw new Error("AI roast response is missing required bilingual fields");
|
|
362
|
+
}
|
|
363
|
+
return result;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async function generateAiRoast(evidence, {
|
|
367
|
+
apiKey = process.env.DEEPSEEK_API_KEY,
|
|
368
|
+
baseUrl,
|
|
369
|
+
model,
|
|
370
|
+
provider = "deepseek",
|
|
371
|
+
fetchImpl = globalThis.fetch,
|
|
372
|
+
timeoutMs = 40_000,
|
|
373
|
+
requestHeaders,
|
|
374
|
+
} = {}) {
|
|
375
|
+
if (!apiKey) throw new Error("An API key is required for AI roast generation");
|
|
376
|
+
if (typeof fetchImpl !== "function") throw new Error("fetch is unavailable");
|
|
377
|
+
const config = providerConfig(provider, {
|
|
378
|
+
baseUrl: baseUrl || (provider === "deepseek" ? process.env.DEEPSEEK_BASE_URL : undefined),
|
|
379
|
+
model: model || (provider === "deepseek" ? process.env.DEEPSEEK_MODEL : undefined),
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
const controller = new AbortController();
|
|
383
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
384
|
+
try {
|
|
385
|
+
const messages = [
|
|
386
|
+
{ role: "system", content: SYSTEM_PROMPT },
|
|
387
|
+
{
|
|
388
|
+
role: "user",
|
|
389
|
+
content: `${USER_INSTRUCTIONS}\n\nEVIDENCE JSON:\n${JSON.stringify(evidence)}`,
|
|
390
|
+
},
|
|
391
|
+
];
|
|
392
|
+
const request = requestForProvider(config, {
|
|
393
|
+
apiKey,
|
|
394
|
+
messages,
|
|
395
|
+
temperature: 0.9,
|
|
396
|
+
maxTokens: 1800,
|
|
397
|
+
});
|
|
398
|
+
const response = await fetchImpl(request.url, {
|
|
399
|
+
...request.options,
|
|
400
|
+
headers: {
|
|
401
|
+
...request.options.headers,
|
|
402
|
+
...(requestHeaders || {}),
|
|
403
|
+
},
|
|
404
|
+
signal: controller.signal,
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
const payload = await response.json().catch(() => ({}));
|
|
408
|
+
if (!response.ok) {
|
|
409
|
+
const message = payload?.error?.message
|
|
410
|
+
|| payload?.error?.status
|
|
411
|
+
|| `${config.id} request failed with HTTP ${response.status}`;
|
|
412
|
+
const error = new Error(message);
|
|
413
|
+
error.status = response.status;
|
|
414
|
+
throw error;
|
|
415
|
+
}
|
|
416
|
+
const result = validateRoastPayload(responseContent(config, payload));
|
|
417
|
+
const cacheStatus = response.headers?.get?.("x-vibe-roast-cache");
|
|
418
|
+
|
|
419
|
+
return {
|
|
420
|
+
...result,
|
|
421
|
+
writer: {
|
|
422
|
+
provider: config.id,
|
|
423
|
+
model: payload?.model || config.model,
|
|
424
|
+
evidence_schema_version: evidence?.schema_version || 1,
|
|
425
|
+
...(cacheStatus ? { cached: cacheStatus.toLowerCase() === "hit" } : {}),
|
|
426
|
+
},
|
|
427
|
+
};
|
|
428
|
+
} finally {
|
|
429
|
+
clearTimeout(timeout);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
module.exports = {
|
|
434
|
+
DEFAULT_BASE_URL,
|
|
435
|
+
DEFAULT_MODEL,
|
|
436
|
+
PROVIDERS,
|
|
437
|
+
SYSTEM_PROMPT,
|
|
438
|
+
USER_INSTRUCTIONS,
|
|
439
|
+
generateAiRoast,
|
|
440
|
+
providerConfig,
|
|
441
|
+
requestForProvider,
|
|
442
|
+
responseContent,
|
|
443
|
+
validateRoastPayload,
|
|
444
|
+
parseJsonPayload,
|
|
445
|
+
paragraphize,
|
|
446
|
+
};
|