cube(chat): apps.json 成 SSOT,注进 chatbot system prompt
deploy cube / build-and-deploy (push) Successful in 1m22s

前端 apps.ts 之前是 source of truth,后端 chatbot 只能用硬编码的
"werewolf / articulate / karaoke / music / simpleasm 等" 句式糊
弄 + 靠训练知识猜。改成 apps.json 当 SSOT:
- 前端 apps.ts 改为 import data from './apps.json'
- 后端 include_str! 同一份 → 解析渲染 markdown bullet 列表
  注进 system prompt,附带 slug / status / desc / url
- prompt 显式约束:只能基于列表事实回答,不存在的 app 直说没有
- 兜底:JSON 解析失败把 raw 文本喂 LLM,不让 chatbot 因为
  ssot 坏掉 500
- 10 个 cargo test(多覆盖 render_apps_list / prompt 含 apps)
This commit is contained in:
Fam Zheng
2026-05-17 22:56:31 +01:00
parent 1ee35b4d19
commit d964b46dbe
3 changed files with 143 additions and 76 deletions
+72
View File
@@ -0,0 +1,72 @@
[
{
"slug": "cube",
"name": "cube",
"description": "你正在看的这个门户。cube 平台本身的入口。",
"url": "https://cube.famzheng.me",
"status": "live"
},
{
"slug": "portfolio",
"name": "portfolio",
"description": "投资组合追踪。从 oci 迁移中(原 portfolio.oci.euphon.net)。",
"url": "https://portfolio.famzheng.me",
"status": "pending"
},
{
"slug": "repo-vis",
"name": "repo-vis",
"description": "git 仓库可视化。从 oci 迁移中。",
"url": "https://repo-vis.famzheng.me",
"status": "pending"
},
{
"slug": "simpleasm",
"name": "simpleasm",
"description": "汇编教学小游戏。",
"url": "https://asm.famzheng.me",
"status": "live"
},
{
"slug": "music",
"name": "music",
"description": "听歌 + 练琴 曲目管理。243 首曲库(从 oci 旧 guitar 迁过来)+ 自动抓 yopu 吉他/功能谱 + LLM 灵感推荐。",
"url": "https://music.famzheng.me",
"status": "live"
},
{
"slug": "pyroblem",
"name": "pyroblem",
"description": "详情待补。",
"url": "https://pyroblem.famzheng.me",
"status": "tbd"
},
{
"slug": "werewolf",
"name": "werewolf",
"description": "狼人杀单机发牌器。一台手机轮流传,30 个角色、4x 偏好加权、配置历史本地记忆。从 partiverse 移植。",
"url": "https://werewolf.famzheng.me",
"status": "live"
},
{
"slug": "articulate",
"name": "articulate",
"description": "中英猜词派对游戏(Articulate)。15 个主题词库 + 3 档难度 + 已看词跨场记忆。从 partiverse 移植。",
"url": "https://articulate.famzheng.me",
"status": "live"
},
{
"slug": "karaoke",
"name": "karaoke",
"description": "卡拉OK 点歌单本地管理。增删改排 + YouTube 一键搜,10 秒撤销。从 partiverse 移植。",
"url": "https://karaoke.famzheng.me",
"status": "live"
},
{
"slug": "notes",
"name": "notes",
"description": "录音 → ASR 转写 → LLM 生成会议纪要。Sidebar + contentpassphrase 鉴权。",
"url": "https://notes.famzheng.me",
"status": "live"
}
]
+6 -72
View File
@@ -1,3 +1,8 @@
// apps 列表的 SSOT 是 apps.json — 后端 chat.rs 也 include_str! 同一份,注入到
// chatbot 的 system prompt 里。改 apps 只改 apps.json。
import data from './apps.json'
export type AppStatus = 'live' | 'pending' | 'tbd' export type AppStatus = 'live' | 'pending' | 'tbd'
export interface App { export interface App {
@@ -8,75 +13,4 @@ export interface App {
status: AppStatus status: AppStatus
} }
export const apps: App[] = [ export const apps: App[] = data as App[]
{
slug: 'cube',
name: 'cube',
description: '你正在看的这个门户。cube 平台本身的入口。',
url: 'https://cube.famzheng.me',
status: 'live',
},
{
slug: 'portfolio',
name: 'portfolio',
description: '投资组合追踪。从 oci 迁移中(原 portfolio.oci.euphon.net)。',
url: 'https://portfolio.famzheng.me',
status: 'pending',
},
{
slug: 'repo-vis',
name: 'repo-vis',
description: 'git 仓库可视化。从 oci 迁移中。',
url: 'https://repo-vis.famzheng.me',
status: 'pending',
},
{
slug: 'simpleasm',
name: 'simpleasm',
description: '汇编教学小游戏。',
url: 'https://asm.famzheng.me',
status: 'live',
},
{
slug: 'music',
name: 'music',
description: '听歌 + 练琴 曲目管理。243 首曲库(从 oci 旧 guitar 迁过来)+ 自动抓 yopu 吉他/功能谱 + LLM 灵感推荐。',
url: 'https://music.famzheng.me',
status: 'live',
},
{
slug: 'pyroblem',
name: 'pyroblem',
description: '详情待补。',
url: 'https://pyroblem.famzheng.me',
status: 'tbd',
},
{
slug: 'werewolf',
name: 'werewolf',
description: '狼人杀单机发牌器。一台手机轮流传,30 个角色、4x 偏好加权、配置历史本地记忆。从 partiverse 移植。',
url: 'https://werewolf.famzheng.me',
status: 'live',
},
{
slug: 'articulate',
name: 'articulate',
description: '中英猜词派对游戏(Articulate)。15 个主题词库 + 3 档难度 + 已看词跨场记忆。从 partiverse 移植。',
url: 'https://articulate.famzheng.me',
status: 'live',
},
{
slug: 'karaoke',
name: 'karaoke',
description: '卡拉OK 点歌单本地管理。增删改排 + YouTube 一键搜,10 秒撤销。从 partiverse 移植。',
url: 'https://karaoke.famzheng.me',
status: 'live',
},
{
slug: 'notes',
name: 'notes',
description: '录音 → ASR 转写 → LLM 生成会议纪要。Sidebar + contentpassphrase 鉴权。',
url: 'https://notes.famzheng.me',
status: 'live',
},
]
+65 -4
View File
@@ -84,17 +84,56 @@ impl IntoResponse for ChatError {
} }
} }
/// apps.json 是 SSOT — 前端 apps.ts 也 import 同一份。
const APPS_JSON: &str = include_str!("../frontend/src/apps.json");
#[derive(Deserialize)]
struct AppInfo {
slug: String,
description: String,
url: String,
status: String,
}
/// 把 apps.json 渲染成 markdown bullet list 注进 system prompt。
/// 解析失败兜底成 raw JSON(让 LLM 自己 grok),不让 chatbot 因为 ssot 坏掉而 500。
pub fn render_apps_list() -> String {
match serde_json::from_str::<Vec<AppInfo>>(APPS_JSON) {
Ok(apps) => apps
.iter()
.map(|a| {
format!(
"- **{slug}** ({status}) — {desc} <{url}>",
slug = a.slug,
status = a.status,
desc = a.description,
url = a.url,
)
})
.collect::<Vec<_>>()
.join("\n"),
Err(_) => APPS_JSON.trim().to_string(),
}
}
pub fn system_prompt(repo: &str) -> String { pub fn system_prompt(repo: &str) -> String {
format!( format!(
"你是 cube 平台(cube.famzheng.meFam 的小 app 平台)入口页上的聊天助手。\n\ "你是 cube 平台(cube.famzheng.meFam 的小 app 平台)入口页上的聊天助手。\n\
\n\
当前 cube 上线的 app 列表(status: live=可用 / pending=迁移中 / tbd=待定):\n\
{apps}\n\
\n\
你可以做两件事:\n\ 你可以做两件事:\n\
1. 回答用户关于 cube 上各个 appwerewolf / articulate / karaoke / music / simpleasm 等)的问题,简短直接\n\ 1. 回答用户关于上面这些 app 的问题,简短直接。**回答时只能基于上面列表的事实**,\n\
2. 当用户表达 bug / feature request / feedback / 想反馈给 Fam 时,调用 `create_issue` 工具,\n\ 不要凭训练知识瞎猜不存在的 app 或功能。如果用户问的 app 不在列表里,直说没有。\n\
把它整理成 issue 创建到 `{repo}`。标题要简洁明确(不超过 60 个字符),body 要包含足够上下文\n\ 2. 当用户表达 bug / feature request / feedback / 想反馈给 Fam 时,调用 `create_issue`\n\
(重现步骤、期望行为、用户原话等)。\n\ 工具,把它整理成 issue 创建到 `{repo}`。标题简洁明确(≤60 字符),body 包含足够上下文\n\
(涉及哪个 app / 重现步骤 / 期望行为 / 用户原话等)。\n\
\n\ \n\
不要主动鼓励用户提 issue —— 只在他明确表达想反馈时才创建。\n\ 不要主动鼓励用户提 issue —— 只在他明确表达想反馈时才创建。\n\
同一次对话只创建一个 issue。Reply 要简短,不写长段散文。", 同一次对话只创建一个 issue。Reply 要简短,不写长段散文。",
apps = render_apps_list(),
repo = repo,
) )
} }
@@ -312,6 +351,28 @@ mod tests {
assert!(p.contains("create_issue")); assert!(p.contains("create_issue"));
} }
#[test]
fn render_apps_list_parses_ssot() {
let list = render_apps_list();
// 兜底分支会返回 raw JSON(含 `{`),正常解析后是 markdown bullet(每行以 `- ` 起头)
assert!(list.starts_with("- "), "render output should be markdown bullets, got: {list}");
// 抽查几个已知 slug 在里面
for slug in ["cube", "werewolf", "articulate", "karaoke", "music"] {
assert!(list.contains(slug), "apps list 应该含 {slug}: {list}");
}
}
#[test]
fn system_prompt_includes_apps_list() {
let p = system_prompt("fam/cube");
// 至少一个 app 的 slug 出现在 prompt 里,说明 list 真的被注进去了
assert!(p.contains("werewolf"));
assert!(p.contains("live"));
// 防回退:旧 prompt 写死了 "werewolf / articulate / karaoke / music / simpleasm"
// 现在不再用那种枚举句式 —— 列表是数据驱动的
assert!(!p.contains("werewolf / articulate / karaoke / music / simpleasm"));
}
#[test] #[test]
fn tool_schema_shape() { fn tool_schema_shape() {
let s = create_issue_tool_schema(); let s = create_issue_tool_schema();