1. 项目概述
daily-q 是一个「每日一题」CLI 工具,面向后端/数据方向程序员的面试知识练习。
核心流程:每天出 1 道面试题 → 用户答题 → AI 评分反馈 → 后台分析薄弱点 → 下次出题时倾向薄弱点。
技术栈:Rust + SQLite + Claude API(兼容 OpenAI 协议的任意 LLM)
2. 系统架构
┌──────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐
│ main.rs │ │ quiz.rs │ │ summary │ │ profile │
│ CLI入口 │→ │ 出题+评判 │ │ .rs │ │ .rs │
└──────────┘ └─────┬─────┘ │ 后台总结 │ │ 画像引导 │
│ └─────┬─────┘ └───────────┘
│ │
┌───────┴──────┐ │
│ llm.rs │←──────┘
│ Claude API │
└───────┬───────┘
│
┌───────┴──────┐
│ db.rs │
│ SQLite │
└───────┬──────┘
│
┌───────┴──────┐
│ models.rs │
│ 数据结构 │
└──────────────┘模块职责
数据流向
用户执行 dq
│
▼
quiz::get_or_create_question()
│
┌─────┴──────┐
│ 今天已有题? │
└─────┬──────┘
YES │ NO
直接返回 │
▼
┌──────────────────┐
│ llm::generate_ │ ← 传: 薄弱知识点、规则、画像
│ question() │
└──────┬─────────┘
│
▼
db.insert_question() → 存入 SQLite
│
▼
显示题目给用户
│
┌───────────┴───────────┐
│ 用户执行 dq answer │
│ 打开编辑器,写回答 │
└───────────┬───────────┘
│
▼
llm::judge_answer() → 评分 0-100 + 反馈 + 标签
│
▼
db.insert_answer() → 存入 + 更新 mastery 表
│
▼
summary::spawn_background_summary()
│ (后台线程)
▼
llm::summarize_history() → 分析薄弱点、关联、建议
│
▼
db.save_summary() → 存入 summary_cache 表3. 数据结构
3.1 核心模型 (models.rs)
// 题目
struct Question {
id: i64,
date: String, // "2024-08-01"
topic: String, // "TCP 三次握手"
difficulty: String, // "easy" / "medium" / "hard"
content: String, // 题目正文
reference_answer: String, // 参考答案
}
// 答题记录(注意:不含 topic_tags,tags 存在 answers 表的 JSON 字段中)
struct Answer {
id: i64,
question_id: i64,
content: String, // 用户的回答
score: i32, // 0-100
feedback: String, // AI 的详细反馈
answered_at: String,
}
// 知识点掌握度
struct TopicMastery {
id: i64,
topic: String,
total: i32, // 总答题次数
correct: i32, // 答对次数(score >= 60 算正确)
last_practiced: Option<String>,
// 计算方法:mastery_level()
// - total == 0 → "none"
// - correct/total >= 0.8 → "strong"
// - correct/total >= 0.5 → "medium"
// - 其他 → "weak"
}
// 一次做题会话(题目 + 是否已答 + 可选的回答记录)
struct QuizSession {
question: Question,
answered: bool,
answer: Option<Answer>,
}
// 辅助函数
fn today_str() -> String // 返回 "2024-08-01"3.2 LLM 交互模型
// LLM 出题返回
struct LlmQuizResponse {
topic: String,
difficulty: String,
question: String,
reference_answer: String,
}
// LLM 评判返回
struct LlmJudgeResponse {
score: i32, // 0-100
feedback: String,
topic_tags: Vec<String>, // ["TCP", "三次握手", "传输层"]
}
// LLM 总结返回
struct LlmSummaryResponse {
weak_topics: Vec<String>,
related_weaknesses: Vec<TopicRelation>,
suggestions: Vec<String>,
}
// 薄弱点关联
struct TopicRelation {
topic_a: String,
topic_b: String,
reason: String,
}3.3 数据库表结构
-- 5 张表,全部在 db.rs 的 SCHEMA 常量中定义
CREATE TABLE questions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL UNIQUE, -- 每天只有一条
topic TEXT NOT NULL,
difficulty TEXT NOT NULL,
content TEXT NOT NULL,
reference_answer TEXT NOT NULL
);
CREATE TABLE answers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
question_id INTEGER NOT NULL REFERENCES questions(id),
content TEXT NOT NULL,
score INTEGER NOT NULL,
feedback TEXT NOT NULL,
topic_tags TEXT NOT NULL DEFAULT '[]', -- JSON 数组字符串
answered_at TEXT NOT NULL -- RFC3339 时间戳
);
CREATE TABLE topic_mastery (
id INTEGER PRIMARY KEY AUTOINCREMENT,
topic TEXT NOT NULL UNIQUE,
total INTEGER NOT NULL DEFAULT 0,
correct INTEGER NOT NULL DEFAULT 0,
last_practiced TEXT
);
CREATE TABLE summary_cache (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL,
weak_topics TEXT NOT NULL DEFAULT '[]', -- JSON
relations TEXT NOT NULL DEFAULT '[]', -- JSON
suggestions TEXT NOT NULL DEFAULT '[]' -- JSON
);
CREATE TABLE rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
content TEXT NOT NULL,
created_at TEXT NOT NULL
);3.4 数据存储位置
所有数据存储在 ~/.daily-q/ 目录下:
db文件:~/.daily-q/daily-q.db(SQLite)配置文件:
~/.daily-q/config.json
4. 模块详细设计
4.1 config.rs —— 配置管理
职责:读写 ~/.daily-q/config.json,管理 API Key、模型、URL、关注方向、语言、画像。
struct Config {
api_key: String, // 从 config.json 读,为空则报错退出
model: String, // 默认 "deepseek-chat"
base_url: String, // 默认 "https://api.deepseek.com"
focus_areas: Vec<String>, // 默认 ["计算机网络","操作系统","数据库","数据结构与算法","分布式系统"]
profile: Option<String>, // 画像字符串,如 "3-5年 | 后端 | Go | 高级 | 分布式"
lang: Lang, // zh 或 en,默认 zh
data_dir: PathBuf,
}对外接口:
Config::load() -> Result<Config>— 读配置,API key 为空时直接 bail!Config::save(api_key?, model?, base_url?, focus?) -> Result<()>— 部分更新Config::set_profile(profile: &str) -> Result<()>— 单独写画像Config::set_lang(lang: Lang) -> Result<()>— 单独写语言Config::ensure_data_dir() -> Result<()>— 确保 ~/.daily-q/ 存在Config::db_path() -> PathBuf— 返回 db 文件路径
内部实现:用 serde_json::Value 做部分更新 —— 先读整个 JSON,修改对应字段,再写回。避免覆盖未提供的字段。
4.2 db.rs —— 数据层
职责:封装所有 SQLite 操作,5 张表的 CRUD。
每个方法的入参出参:
关键逻辑:
insert_answer内部分两步:先插入 answers 行,再对judge.topic_tags中的每个 tag 调用upsert_topic_mastery。score >= 60 视为正确。upsert_topic_mastery先检查 topic 是否存在,存在则 UPDATE(total+1,correct 视情况 +1),否则 INSERT 新行。save_summary插入后也遍历weak_topics更新 topic_mastery(标记为答错)。
4.3 llm.rs —— LLM 调用
职责:封装 LLM API 调用,提供三个核心函数:出题、评判、总结。使用 OpenAI 兼容的 /v1/chat/completions 端点。
内部结构:
LlmClient {
client: reqwest::Client, // HTTP 客户端
api_key: String,
model: String, // 如 "deepseek-chat"
base_url: String, // 如 "https://api.deepseek.com"
}
// 基础方法
chat(system, user) -> Result<String> // 纯文本对话,返回原始响应
chat_json<T>(system, user) -> Result<T> // 调用 chat,然后从响应中提取 JSON 并反序列化
// 工具函数
extract_json(raw: &str) -> &str // 从 LLM 响应中提取 JSON(处理 ```json``` 包裹)extract_json 的逻辑:
先找
```json代码块,取里面的内容没有则找
```普通代码块都没有则返回原始文本 trim
chat 方法细节:
请求体:
{model, max_tokens: 2048, messages: [{role, content}]}请求头:
Authorization: Bearer {api_key},content-type: application/json状态码非 200 时返回完整错误文本
从
choices[0].message.content取响应
chat_json 方法细节:
先调
chat拿原始文本调
extract_json提取 JSON 片段用
serde_json::from_value反序列化(先解析为 Value,处理可能的重复 key 问题)
4.3.1 出题 —— generate_question()
入参:
pub async fn generate_question(
config: &Config, // 含 api_key, model, base_url, lang, profile, focus_areas
weak_topics: &[TopicMastery], // 薄弱知识点列表
rules: &[String], // 用户自定义的出题规则
difficulty_hint: Option<&str>, // 用户指定的难度(easy/medium/hard),None 则随机
) -> Result<LlmQuizResponse>System Prompt(中文为例):
你是一名资深后端面试官,专门考察程序员的后端和数据方向知识。请用中文出题。只返回JSON,不要其他内容。
User Prompt 构成(拼接以下信息):
候选人画像(来自
config.profile,如为空则省略)知识范围(
config.focus_areas,用顿号连接)薄弱知识点提示(取
weak_topics中 level="weak" 的项,写成"TCP(正确率30%)"格式)出题规则(用户定义,如"只能出场景题")
题目难度(有则写,无则省略)
返回格式要求:
{
"topic": "知识点名称",
"difficulty": "easy/medium/hard",
"question": "题目内容",
"reference_answer": "参考答案"
}4.3.2 评判 —— judge_answer()
入参:
pub async fn judge_answer(
config: &Config,
question: &Question, // 题目信息
answer: &str, // 用户回答
) -> Result<LlmJudgeResponse>System Prompt(中文为例):
你是一名资深面试官,请评判候选人的回答。用中文反馈。只返回JSON,不要其他内容。
User Prompt 构成:
题目正文 + 知识点 + 参考答案
候选人回答
候选人画像(如有,补充"结合其经验水平评估")
返回格式:
{
"score": 0-100的整数,
"feedback": "详细反馈,指出不足和改进方向",
"topic_tags": ["TCP", "三次握手", "传输层"]
}评分标准:0-100 分。topic_tags 用于更新 topic_mastery 表。
4.3.3 总结 —— summarize_history()
入参:
pub async fn summarize_history(
config: &Config,
history: &[(Question, Answer)], // 全部历史答题记录
) -> Result<LlmSummaryResponse>System Prompt(中文为例):
你是一名学习分析专家,分析用户的答题历史,总结薄弱点和知识关联。用中文。只返回JSON,不要其他内容。
User Prompt:拼接所有历史答题记录的摘要(每题:[知识点] 题目 | 得分: XX | 反馈: XXX)。
返回格式:
{
"weak_topics": ["知识点1", "知识点2"],
"related_weaknesses": [
{"topic_a": "TCP", "topic_b": "UDP", "reason": "TCP和UDP都答错,说明传输层整体薄弱"}
],
"suggestions": ["建议1", "建议2"]
}4.4 quiz.rs —— 业务逻辑
职责:出题、答题、统计的核心业务流程。
对外接口:
// 获取或创建今日题目(无题则调 LLM 生成)
pub async fn get_or_create_question(difficulty: Option<&str>) -> Result<QuizSession>
// 跳过今日题目(删除今日 question)
pub fn skip_today() -> Result<()>
// 答题:打开编辑器让用户写回答 → 调 LLM 评判 → 存库
pub async fn answer_interactive() -> Result<()>
// 答题:直接传字符串(未暴露到 CLI,内部使用)
pub async fn submit_answer(answer: &str) -> Result<Answer>
// 展示掌握度统计
pub fn show_stats() -> Result<()>
// 渲染一道题(带答案/不带答案)
pub fn show_question(session: &QuizSession)get_or_create_question 逻辑:
加载 Config,检查 profile 是否存在(没有则报错退出)
查今天是否已有题
有题:如果指定了 difficulty 且未答,删除旧的重新生成;否则直接返回(包括已答的答案记录)
没题:取薄弱知识点 + 规则列表 → 调
llm::generate_question→db.insert_question
answer_interactive 逻辑:
检查今天是否有题(没有报错),是否已答(已答报错)
打开
$EDITOR(默认 vim),创建临时文件,文件头写# 知识点: 难度\n# 题目内容用户写完后,读回文件内容,跳过
#开头的注释行调
llm::judge_answer评分db.insert_answer存答案打印:得分、反馈、参考答案
最后调
summary::spawn_background_summary()触发后台总结
show_stats 逻辑:
查
get_weak_topics()(按正确率升序)逐行打印,每行格式:
[图标] 知识点名 correct/total (百分比) [等级]图标:strong→
+(绿),medium→~(黄),weak→!(红)如果有最新总结,也打印出来(薄弱点、关联、建议)
answer_interactive 的编辑器交互细节:
# 临时文件内容(模板):
# TCP 三次握手: medium
# 请描述TCP三次握手的过程
<用户在此输入回答>读回后过滤逻辑:lines().filter(|l| !l.starts_with('#')),保留非注释行。回答为空则报错。
国内 LLM 兼容性:show_question 和 answer_interactive 虽然调 LLM,但 main.rs 的 None 分支(dq 无参数)只调 show_question 不调 LLM。dq answer 才调。
4.5 summary.rs —— 后台总结
职责:在用户答完题后,启动一个后台线程去做 LLM 总结,不阻塞终端。
// 公共接口:从 quiz.rs 调用
pub fn spawn_background_summary()
// 内部实现
fn run_summary() -> Result<()> // 创建 tokio runtime,block_on 异步任务
async fn do_summary() -> Result<()> // 查历史 → 调 LLM 总结 → 存库关键细节:
使用
std::thread::spawn(不是 tokio::spawn),因为要在同步上下文中启动线程内创建独立的
tokio::runtime::Builder::new_current_thread(),因为reqwest的异步需要 tokio context失败只打印错误,不 panic 不阻塞主线程
总结结果存到
summary_cache表,最新的覆盖旧的成功时用 stderr 输出简短提示("[summary] 完成 — 3 个薄弱项已更新")
4.6 lang.rs —— 国际化
实现方式:用 Rust macro texts! 批量生成一个 Texts 结构体,每个字段是一个 fn(Lang) -> &'static str 的方法。
enum Lang { Zh, En }
// 宏用法示例:
texts! {
config_not_found: "未配置API密钥", "API key not set";
api_key_saved: "API密钥已保存", "API key saved";
// ... 约 45 个字段
}Lang 判断逻辑:
Lang::from_str("en")→ En,其他一律 Zh默认 Zh
从
config.json的"lang"字段读取
覆盖范围:配置提示、画像引导、答题流程、统计展示、规则管理、总结提示 —— 所有面向用户的文案。
4.7 display.rs —— 终端样式
职责:用 colored crate 给文本上色,提供语义化函数。
pub fn title(text: &str) -> String // 青色,用于标题行
pub fn label(text: &str) -> String // 蓝色,用于字段名("得分:")
pub fn highlight(text: &str) -> String // 黄色,用于高亮值
pub fn dim(text: &str) -> String // 暗色,用于提示
pub fn good(text: &str) -> String // 绿色,用于正面信息
pub fn warn(text: &str) -> String // 紫色,用于警告
pub fn bad(text: &str) -> String // 红色,用于错误4.8 profile.rs —— 画像设置
职责:交互式引导用户填写面试画像(工作年限、岗位方向、技术栈、目标级别、重点方向)。
流程(profile::run_profile_setup()):
打印标题和说明
逐题问,每题用
prompt_select()函数显示选项列表(带编号),用户输入数字或选择"其他"手动输入多选题支持逗号分隔(如 "1,3")
技术栈选项根据所选岗位动态过滤(
get_tech_options()):从TECH_STACKS常量中匹配对应岗位的技术栈最后一题确认,拼接成字符串:
"3-5年 | 后端 | Go | 高级 | 分布式"写入
Config::set_profile
选项数据:硬编码在代码中(中英文各一份),共 9 种岗位 × 各自技术栈。
交互循环:prompt_select() 内部是一个 loop,直到用户给出有效输入。无效输入显示错误提示,重新提问。
4.9 main.rs —— CLI 入口
命令结构(clap derive):
dq [--difficulty <easy|medium|hard>] # 默认:获取/显示今日题目
dq answer # 打开编辑器答题
dq skip # 跳过今日题目(删除)
dq stats # 查看掌握度统计
dq rule add <规则文本> # 添加出题规则
dq rule list # 列出所有规则
dq rule remove <id> # 删除规则
dq profile # 查看当前画像
dq profile setup # 交互式设置画像
dq config # 查看当前配置
dq config api-key <key> # 设置 API Key
dq config model <name> # 设置模型
dq config base-url <url> # 设置 API 地址
dq config focus <逗号分隔> # 设置关注方向
dq config lang <zh|en> # 设置语言命令分发逻辑(main() 函数):
匹配
cli.command,分派到对应函数None(无子命令)且有--difficulty:删旧题重新生成None(无子命令)无--difficulty:直接展示今天题目(不调 LLM)每个分支都处理 Result,错误用
anyhow::bail!返回,Clap 自动打印错误信息
5. 关键设计决策
无本地题库,全由 LLM 动态出题:根据用户薄弱点和画像定制,灵活但依赖 LLM 可用性
SQLite 单文件:零运维,数据在
~/.daily-q/下,查询方便后台线程做总结:用
std::thread::spawn+ 独立 tokio runtime,答题后不阻塞终端topic_mastery的更新时机:评判时根据judge.topic_tags更新,总结时根据weak_topics也更新(标记为答错)Answer 表存
topic_tags为 JSON 字符串:方便后续扩展,但 Answer struct 不包含此字段用户画像:交互式引导设置,字符串格式存储,嵌入 LLM prompt 中影响出题和评判
出题规则:允许用户自定义约束(如"只出场景题"、"不要算法题"),作为 system prompt 的一部分传给 LLM
编辑器答题:用
$EDITOR环境变量,默认 vim,临时文件头写题目信息作为提示配置文件部分更新:
Config::save只传要改的字段,其余保持不变——用 serde_json::Value 实现中英文双语言:所有用户可见文案通过
lang.rs的Texts结构体统一管理,Lang 枚举控制切换
6. 错误处理策略
配置缺失:API key 未设置时
Config::load()直接bail!,不继续执行画像缺失:
check_ready()检查 profile 为空时 bail,提示用户先设置今日无题:答
answer时如果今天没题,bail 提示先运行dq已答过:bail 提示去
dq stats查看LLM 调用失败:用
anyhow::Context包装错误,返回可读的错误信息后台总结失败:只打 stderr 日志,不影响主流程
编辑器异常:编辑器退出码非 0 或文件读失败,直接 bail