feat: note scoring 60/6 with rating award + Minecraft theme
- Score max changed from 100 to 60, rating max from 10 to 6 - Note scoring now awards actual rating points (delta-based) - Re-scoring only awards/deducts the difference - Rating history shows note_score entries with problem link - LLM prompt includes problem statement context for better evaluation - LLM scoring dimensions: 题意理解/思路算法/代码记录/踩坑反思 (15 each) - Minecraft-themed UI: 矿石鉴定, 探索笔记, 存入宝典, etc. - Fallback scoring adjusted for 60-point scale - Handle LLM markdown code fence wrapping in response Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
这个提交包含在:
@@ -27,6 +27,8 @@ class WrongBookService {
|
|||||||
int32_t note_score,
|
int32_t note_score,
|
||||||
int32_t note_rating,
|
int32_t note_rating,
|
||||||
const std::string& note_feedback_md);
|
const std::string& note_feedback_md);
|
||||||
|
int32_t GetNoteRating(int64_t user_id, int64_t problem_id);
|
||||||
|
void AwardNoteRating(int64_t user_id, int64_t problem_id, int delta);
|
||||||
void UpsertBySubmission(int64_t user_id,
|
void UpsertBySubmission(int64_t user_id,
|
||||||
int64_t problem_id,
|
int64_t problem_id,
|
||||||
int64_t submission_id,
|
int64_t submission_id,
|
||||||
|
|||||||
@@ -342,8 +342,17 @@ void MeController::scoreWrongBookNote(
|
|||||||
services::WrongBookService wrong_book(csp::AppState::Instance().db());
|
services::WrongBookService wrong_book(csp::AppState::Instance().db());
|
||||||
// ensure note saved
|
// ensure note saved
|
||||||
wrong_book.UpsertNote(*user_id, problem_id, note);
|
wrong_book.UpsertNote(*user_id, problem_id, note);
|
||||||
|
|
||||||
|
// Get previous score to calculate rating delta
|
||||||
|
const int prev_rating = wrong_book.GetNoteRating(*user_id, problem_id);
|
||||||
wrong_book.UpsertNoteScore(*user_id, problem_id, result.score, result.rating, result.feedback_md);
|
wrong_book.UpsertNoteScore(*user_id, problem_id, result.score, result.rating, result.feedback_md);
|
||||||
|
|
||||||
|
// Award (or adjust) rating points: delta = new_rating - prev_rating
|
||||||
|
const int delta = result.rating - prev_rating;
|
||||||
|
if (delta != 0) {
|
||||||
|
wrong_book.AwardNoteRating(*user_id, problem_id, delta);
|
||||||
|
}
|
||||||
|
|
||||||
Json::Value data;
|
Json::Value data;
|
||||||
data["user_id"] = Json::Int64(*user_id);
|
data["user_id"] = Json::Int64(*user_id);
|
||||||
data["problem_id"] = Json::Int64(problem_id);
|
data["problem_id"] = Json::Int64(problem_id);
|
||||||
|
|||||||
@@ -121,9 +121,9 @@ LearningNoteScoreResult LearningNoteScoringService::Score(
|
|||||||
r.model_name = parsed.get("model_name", "").asString();
|
r.model_name = parsed.get("model_name", "").asString();
|
||||||
|
|
||||||
if (r.score < 0) r.score = 0;
|
if (r.score < 0) r.score = 0;
|
||||||
if (r.score > 100) r.score = 100;
|
if (r.score > 60) r.score = 60;
|
||||||
if (r.rating < 1) r.rating = 1;
|
if (r.rating < 0) r.rating = 0;
|
||||||
if (r.rating > 10) r.rating = 10;
|
if (r.rating > 6) r.rating = 6;
|
||||||
if (r.feedback_md.empty()) {
|
if (r.feedback_md.empty()) {
|
||||||
r.feedback_md =
|
r.feedback_md =
|
||||||
"### 笔记评分\n- 未能生成详细点评,请补充:学习目标、关键概念、代码片段、踩坑与修复。";
|
"### 笔记评分\n- 未能生成详细点评,请补充:学习目标、关键概念、代码片段、踩坑与修复。";
|
||||||
|
|||||||
@@ -195,4 +195,59 @@ void WrongBookService::Remove(int64_t user_id, int64_t problem_id) {
|
|||||||
sqlite3_finalize(stmt);
|
sqlite3_finalize(stmt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int32_t WrongBookService::GetNoteRating(int64_t user_id, int64_t problem_id) {
|
||||||
|
sqlite3* db = db_.raw();
|
||||||
|
sqlite3_stmt* stmt = nullptr;
|
||||||
|
const char* sql = "SELECT note_rating FROM wrong_book WHERE user_id=? AND problem_id=? LIMIT 1";
|
||||||
|
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
|
||||||
|
"prepare get note_rating");
|
||||||
|
CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id");
|
||||||
|
CheckSqlite(sqlite3_bind_int64(stmt, 2, problem_id), db, "bind problem_id");
|
||||||
|
int32_t rating = 0;
|
||||||
|
if (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||||
|
rating = sqlite3_column_int(stmt, 0);
|
||||||
|
}
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
return rating;
|
||||||
|
}
|
||||||
|
|
||||||
|
void WrongBookService::AwardNoteRating(int64_t user_id, int64_t problem_id, int delta) {
|
||||||
|
sqlite3* db = db_.raw();
|
||||||
|
|
||||||
|
// Update user rating
|
||||||
|
{
|
||||||
|
sqlite3_stmt* stmt = nullptr;
|
||||||
|
const char* sql = "UPDATE users SET rating=rating+? WHERE id=?";
|
||||||
|
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
|
||||||
|
"prepare add note rating");
|
||||||
|
CheckSqlite(sqlite3_bind_int(stmt, 1, delta), db, "bind delta");
|
||||||
|
CheckSqlite(sqlite3_bind_int64(stmt, 2, user_id), db, "bind user_id");
|
||||||
|
CheckSqlite(sqlite3_step(stmt), db, "exec add note rating");
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log to daily_task_logs for rating history visibility
|
||||||
|
{
|
||||||
|
sqlite3_stmt* stmt = nullptr;
|
||||||
|
const char* sql =
|
||||||
|
"INSERT INTO daily_task_logs(user_id,task_code,day_key,reward,created_at) "
|
||||||
|
"VALUES(?,?,?,?,?)";
|
||||||
|
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
|
||||||
|
"prepare note rating log");
|
||||||
|
const std::string task_code = "note_score_" + std::to_string(problem_id);
|
||||||
|
const int64_t now = NowSec();
|
||||||
|
// Use a unique day_key to avoid IGNORE conflicts
|
||||||
|
const std::string day_key = "note_" + std::to_string(now);
|
||||||
|
CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id");
|
||||||
|
CheckSqlite(sqlite3_bind_text(stmt, 2, task_code.c_str(), -1, SQLITE_TRANSIENT), db,
|
||||||
|
"bind task_code");
|
||||||
|
CheckSqlite(sqlite3_bind_text(stmt, 3, day_key.c_str(), -1, SQLITE_TRANSIENT), db,
|
||||||
|
"bind day_key");
|
||||||
|
CheckSqlite(sqlite3_bind_int(stmt, 4, delta), db, "bind reward");
|
||||||
|
CheckSqlite(sqlite3_bind_int64(stmt, 5, now), db, "bind created_at");
|
||||||
|
CheckSqlite(sqlite3_step(stmt), db, "insert note rating log");
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace csp::services
|
} // namespace csp::services
|
||||||
|
|||||||
@@ -143,9 +143,20 @@ export default function MePage() {
|
|||||||
first_ac: ["首次通过 ⭐", "First AC ⭐"],
|
first_ac: ["首次通过 ⭐", "First AC ⭐"],
|
||||||
code_quality: ["代码质量 🛠️", "Code Quality 🛠️"],
|
code_quality: ["代码质量 🛠️", "Code Quality 🛠️"],
|
||||||
};
|
};
|
||||||
if (type === "daily_task" && taskLabels[note]) {
|
if (type === "daily_task") {
|
||||||
|
if (taskLabels[note]) {
|
||||||
return isZh ? taskLabels[note][0] : taskLabels[note][1];
|
return isZh ? taskLabels[note][0] : taskLabels[note][1];
|
||||||
}
|
}
|
||||||
|
// Note score: "note_score_1234"
|
||||||
|
const ns = note.match(/^note_score_(\d+)$/);
|
||||||
|
if (ns) {
|
||||||
|
return (
|
||||||
|
<a href={`/problems/${ns[1]}`} className="hover:underline text-[color:var(--mc-diamond)]">
|
||||||
|
{isZh ? `📜 探索笔记鉴定 P${ns[1]}` : `📜 Note Appraisal P${ns[1]}`}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
// Solution view: "Problem 1234:Title"
|
// Solution view: "Problem 1234:Title"
|
||||||
const m = note.match(/^Problem (\d+):(.*)$/);
|
const m = note.match(/^Problem (\d+):(.*)$/);
|
||||||
if (m) {
|
if (m) {
|
||||||
|
|||||||
@@ -1031,15 +1031,15 @@ export default function ProblemDetailPage() {
|
|||||||
|
|
||||||
<div className="mt-5 rounded border-[3px] border-black bg-[color:var(--mc-plank)] p-3 shadow-[3px_3px_0_rgba(0,0,0,0.45)]">
|
<div className="mt-5 rounded border-[3px] border-black bg-[color:var(--mc-plank)] p-3 shadow-[3px_3px_0_rgba(0,0,0,0.45)]">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<h3 className="font-bold text-black">{tx("学习笔记(看完视频后上传/粘贴)", "Learning Notes (paste after watching)")}</h3>
|
<h3 className="font-bold text-black">📜 {tx("探索笔记(看完视频后记录)", "Explorer Notes (record after watching)")}</h3>
|
||||||
<span className="text-xs text-zinc-700">{tx("满分100分 = rating 10分", "100 pts = rating 10")}</span>
|
<span className="text-xs text-zinc-700">⚡ {tx("满分60 = 经验值+6", "Max 60 = +6 XP")}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
className="mt-2 w-full rounded-none border-[2px] border-black bg-[color:var(--mc-plank-light)] p-2 text-xs text-black shadow-[2px_2px_0_rgba(0,0,0,0.35)]"
|
className="mt-2 w-full rounded-none border-[2px] border-black bg-[color:var(--mc-plank-light)] p-2 text-xs text-black shadow-[2px_2px_0_rgba(0,0,0,0.35)]"
|
||||||
rows={8}
|
rows={8}
|
||||||
value={noteText}
|
value={noteText}
|
||||||
placeholder={tx("建议写:学习目标/关键概念/代码模板/踩坑与修复/总结", "Suggested: goals / key ideas / code template / pitfalls / summary")}
|
placeholder={tx("⛏️ 记录你的探索:题意理解/解题思路/代码配方/踩坑与修复/总结", "⛏️ Log your adventure: problem understanding / approach / code recipe / pitfalls / summary")}
|
||||||
onChange={(e) => setNoteText(e.target.value)}
|
onChange={(e) => setNoteText(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -1054,17 +1054,17 @@ export default function ProblemDetailPage() {
|
|||||||
onChange={(e) => void handleNoteImageFiles(e.target.files)}
|
onChange={(e) => void handleNoteImageFiles(e.target.files)}
|
||||||
disabled={noteUploading}
|
disabled={noteUploading}
|
||||||
/>
|
/>
|
||||||
{noteUploading ? tx("上传中...", "Uploading...") : tx("拍照/上传图片", "Upload Photos")}
|
{noteUploading ? tx("传送中...", "Teleporting...") : tx("📷 截图上传", "📷 Upload Screenshot")}
|
||||||
</label>
|
</label>
|
||||||
<button className="mc-btn mc-btn-success text-xs" onClick={() => void saveLearningNote()} disabled={noteSaving}>
|
<button className="mc-btn mc-btn-success text-xs" onClick={() => void saveLearningNote()} disabled={noteSaving}>
|
||||||
{noteSaving ? tx("保存中...", "Saving...") : tx("保存笔记", "Save")}
|
{noteSaving ? tx("刻录中...", "Engraving...") : tx("💾 存入宝典", "💾 Save to Codex")}
|
||||||
</button>
|
</button>
|
||||||
<button className="mc-btn text-xs" onClick={() => void scoreLearningNote()} disabled={noteScoring}>
|
<button className="mc-btn mc-btn-primary text-xs" onClick={() => void scoreLearningNote()} disabled={noteScoring}>
|
||||||
{noteScoring ? tx("评分中...", "Scoring...") : tx("笔记评分", "Score")}
|
{noteScoring ? tx("⛏️ 鉴定中...", "⛏️ Appraising...") : tx("⛏️ 矿石鉴定", "⛏️ Appraise Ore")}
|
||||||
</button>
|
</button>
|
||||||
{noteScore !== null && noteRating !== null && (
|
{noteScore !== null && noteRating !== null && (
|
||||||
<span className="text-xs text-black self-center">
|
<span className="text-xs text-black self-center font-bold">
|
||||||
{tx("得分:", "Score: ")}{noteScore}/100 · {tx("评级:", "Rating: ")}{noteRating}/10
|
💎 {noteScore}/60 · ⚡ +{noteRating} XP
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Score a learning note (0-100) and map to rating (1-10) via LLM with fallback."""
|
"""Score a learning note (0-60) and map to rating (0-6) via LLM with fallback."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -27,28 +27,28 @@ def load_input(path: str) -> Dict[str, Any]:
|
|||||||
|
|
||||||
def fallback(note: str) -> Dict[str, Any]:
|
def fallback(note: str) -> Dict[str, Any]:
|
||||||
n = note.strip()
|
n = note.strip()
|
||||||
score = 40
|
score = 20
|
||||||
if len(n) >= 300:
|
if len(n) >= 200:
|
||||||
score += 15
|
|
||||||
if len(n) >= 800:
|
|
||||||
score += 10
|
score += 10
|
||||||
|
if len(n) >= 500:
|
||||||
|
score += 5
|
||||||
if "```" in n:
|
if "```" in n:
|
||||||
score += 15
|
score += 10
|
||||||
if "踩坑" in n or "错误" in n or "debug" in n.lower():
|
if "踩坑" in n or "错误" in n or "debug" in n.lower():
|
||||||
score += 10
|
score += 8
|
||||||
if "总结" in n or "注意" in n:
|
if "总结" in n or "注意" in n:
|
||||||
score += 10
|
score += 7
|
||||||
score = min(100, score)
|
score = min(60, score)
|
||||||
rating = max(1, min(10, round(score / 10)))
|
rating = max(0, min(6, round(score / 10)))
|
||||||
feedback_md = (
|
feedback_md = (
|
||||||
"### 笔记评分(规则兜底)\n"
|
"### ⛏️ 矿石鉴定报告(规则兜底)\n"
|
||||||
f"- 评分:**{score}/100**,评级:**{rating}/10**\n"
|
f"- 品质:**{score}/60** ⚡ 经验值:**+{rating}**\n"
|
||||||
"\n### 你做得好的地方\n"
|
"\n### 🏆 已获成就\n"
|
||||||
"- 记录了学习过程(已检测到一定的笔记内容)。\n"
|
"- 记录了探索过程(检测到一定的笔记内容)。\n"
|
||||||
"\n### 建议补充\n"
|
"\n### 📜 升级指南\n"
|
||||||
"- 写清:本节课**学习目标**、**关键概念**、**代码模板**。\n"
|
"- 写清本次**探索目标**、**核心知识点**、**代码配方**。\n"
|
||||||
"- 至少写 1 个你遇到的坑(如输入输出格式、编译报错)以及解决方案。\n"
|
"- 至少记录 1 个你遇到的陷阱(如格式、编译报错)以及修复方案。\n"
|
||||||
"- 最后用 3-5 行做总结,方便复习。\n"
|
"- 最后用 3-5 行做总结,铸造你的知识宝典。\n"
|
||||||
)
|
)
|
||||||
return {"score": score, "rating": rating, "feedback_md": feedback_md, "model_name": "fallback-rules"}
|
return {"score": score, "rating": rating, "feedback_md": feedback_md, "model_name": "fallback-rules"}
|
||||||
|
|
||||||
@@ -60,23 +60,35 @@ def call_llm(payload: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
if not api_url:
|
if not api_url:
|
||||||
raise RuntimeError("missing OI_LLM_API_URL")
|
raise RuntimeError("missing OI_LLM_API_URL")
|
||||||
|
|
||||||
|
problem_title = payload.get("problem_title", "")
|
||||||
|
problem_statement = payload.get("problem_statement", "")
|
||||||
|
# Truncate long statements to save tokens
|
||||||
|
if len(problem_statement) > 2000:
|
||||||
|
problem_statement = problem_statement[:2000] + "\n...(truncated)"
|
||||||
|
|
||||||
system = (
|
system = (
|
||||||
"你是一位面向小学生的C++竞赛教练,请对学习笔记打分。"
|
"你是一位 Minecraft 风格的C++竞赛教练(矿石鉴定大师),请结合题目内容对学习笔记打分。\n"
|
||||||
"评分满分100分,并给出10分制评级(rating=round(score/10),范围1-10)。"
|
"评分满分60分,经验值(rating)=round(score/10),范围0-6。\n"
|
||||||
"评分维度:覆盖度30、正确性30、可操作性20、反思总结20。"
|
"评分维度:\n"
|
||||||
"输出必须是JSON,不要输出其他任何文字。"
|
"- 题意理解 15分:是否正确理解题目要求\n"
|
||||||
|
"- 思路与算法 15分:解题思路是否清晰、算法是否正确\n"
|
||||||
|
"- 代码记录 15分:是否有代码片段/模板/关键实现\n"
|
||||||
|
"- 踩坑反思 15分:是否记录了坑点、调试过程、经验教训\n"
|
||||||
|
"请用 Minecraft 游戏风格的语言给出反馈,使用⛏️🏆📜💎⚡等图标。\n"
|
||||||
|
"输出必须是纯JSON(不要markdown代码块),不要输出其他任何文字。"
|
||||||
)
|
)
|
||||||
user = {
|
user = {
|
||||||
"task": "对学习笔记评分并给出改进建议",
|
"task": "结合题目对学习笔记评分并给出改进建议",
|
||||||
"problem": {
|
"problem": {
|
||||||
"id": payload.get("problem_id"),
|
"id": payload.get("problem_id"),
|
||||||
"title": payload.get("problem_title"),
|
"title": problem_title,
|
||||||
|
"statement": problem_statement,
|
||||||
},
|
},
|
||||||
"note": payload.get("note", ""),
|
"note": payload.get("note", ""),
|
||||||
"output_json_schema": {
|
"output_json_schema": {
|
||||||
"score": "integer 0-100",
|
"score": "integer 0-60",
|
||||||
"rating": "integer 1-10",
|
"rating": "integer 0-6",
|
||||||
"feedback_md": "markdown string",
|
"feedback_md": "markdown string, Minecraft style",
|
||||||
"model_name": "string",
|
"model_name": "string",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -105,14 +117,21 @@ def call_llm(payload: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
raise RuntimeError(f"HTTP {resp.status_code}")
|
raise RuntimeError(f"HTTP {resp.status_code}")
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
content = data["choices"][0]["message"]["content"]
|
content = data["choices"][0]["message"]["content"]
|
||||||
parsed = json.loads(content)
|
# Strip markdown code fence if present
|
||||||
|
c = content.strip()
|
||||||
|
if c.startswith("```"):
|
||||||
|
c = c.split("\n", 1)[-1]
|
||||||
|
if c.endswith("```"):
|
||||||
|
c = c[:-3]
|
||||||
|
c = c.strip()
|
||||||
|
parsed = json.loads(c)
|
||||||
if not isinstance(parsed, dict):
|
if not isinstance(parsed, dict):
|
||||||
raise ValueError("model output not object")
|
raise ValueError("model output not object")
|
||||||
score = int(parsed.get("score", 0))
|
score = int(parsed.get("score", 0))
|
||||||
score = max(0, min(100, score))
|
score = max(0, min(60, score))
|
||||||
rating = int(parsed.get("rating", round(score / 10)))
|
rating = int(parsed.get("rating", round(score / 10)))
|
||||||
rating = max(1, min(10, rating))
|
rating = max(0, min(6, rating))
|
||||||
feedback_md = str(parsed.get("feedback_md", "")).strip() or "### 笔记评分\n- 请补充更多内容(学习目标/代码/总结)。"
|
feedback_md = str(parsed.get("feedback_md", "")).strip() or "### ⛏️ 矿石鉴定报告\n- 请补充更多内容(探索目标/代码配方/总结)。"
|
||||||
model_name = str(parsed.get("model_name", model)).strip() or model
|
model_name = str(parsed.get("model_name", model)).strip() or model
|
||||||
return {"score": score, "rating": rating, "feedback_md": feedback_md, "model_name": model_name}
|
return {"score": score, "rating": rating, "feedback_md": feedback_md, "model_name": model_name}
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
@@ -132,7 +151,7 @@ def main() -> int:
|
|||||||
if not note.strip():
|
if not note.strip():
|
||||||
print(
|
print(
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{"score": 0, "rating": 1, "feedback_md": "### 笔记为空\n请先写笔记再评分。", "model_name": "validator"},
|
{"score": 0, "rating": 0, "feedback_md": "### ⛏️ 空白卷轴\n请先写下你的探索笔记再进行鉴定。", "model_name": "validator"},
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户