init: 小学生数学交互系统 v1.0 — Flask+SVG可视化+苏格拉底教学法

This commit is contained in:
大师 2026-05-22 01:43:57 +08:00
commit fde2092375
11 changed files with 974 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
__pycache__/
*.pyc
.env
venv/

17
Dockerfile Normal file
View File

@ -0,0 +1,17 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN mkdir -p /app/data/wiki
ENV MATH_TUTOR_DATA=/app/data
ENV FLASK_ENV=production
EXPOSE 8765
CMD ["python", "app.py"]

129
app.py Normal file
View File

@ -0,0 +1,129 @@
"""
小学数学苏格拉底导师 Flask Web 应用
"""
import json
import uuid
from flask import Flask, request, jsonify, render_template, session
from tutor.wiki import StudentWiki
from tutor.socratic import build_socratic_prompt, detect_topic, build_visual_hint
from tutor.hermes_bridge import call_hermes
from tutor.visualize import (
pie_chart, number_line, grid_array, bar_chart, shapes_library
)
app = Flask(__name__)
app.secret_key = "math-tutor-socratic-2026"
def get_or_create_wiki() -> StudentWiki:
"""为当前会话获取/创建学生 Wiki"""
if "student_id" not in session:
session["student_id"] = f"student_{uuid.uuid4().hex[:8]}"
return StudentWiki(session["student_id"])
@app.route("/")
def index():
wiki = get_or_create_wiki()
return render_template("index.html",
student_id=wiki.student_id,
shapes=shapes_library())
@app.route("/ask", methods=["POST"])
def ask():
"""核心接口:学生提问 → Hermes 苏格拉底引导 + 可视化"""
data = request.json or {}
question = data.get("question", "").strip()
if not question:
return jsonify({"error": "请输入你的问题哦~"}), 400
wiki = get_or_create_wiki()
student_profile = wiki.get_knowledge_summary()
topic = detect_topic(question)
# 构建苏格拉底 prompt
prompt = build_socratic_prompt(question, student_profile, topic)
socratic_reply = call_hermes(prompt)
visual_hint = build_visual_hint(topic, question)
# 生成可视化 SVG
svg = _generate_svg_for_topic(topic, question)
# 摄入 Wiki
wiki.ingest_session(question, "", [], "")
return jsonify({
"reply": socratic_reply,
"visual_hint": visual_hint,
"topic": topic,
"svg": svg,
"student_profile": student_profile
})
@app.route("/draw", methods=["POST"])
def draw():
"""学生请求可视化 → 生成 SVG"""
data = request.json or {}
viz_type = data.get("type", "pie")
params = data.get("params", {})
svg = ""
if viz_type == "pie":
fractions = params.get("fractions", [1, 1])
title = params.get("title", "")
svg = pie_chart(fractions, title)
elif viz_type == "numberline":
svg = number_line(
params.get("start", 0),
params.get("end", 10),
params.get("highlights", [])
)
elif viz_type == "grid":
svg = grid_array(
params.get("rows", 3),
params.get("cols", 4),
params.get("highlight_cells", [])
)
elif viz_type == "bar":
svg = bar_chart(
params.get("values", [3, 7, 2, 5]),
params.get("labels", []),
params.get("title", "")
)
return jsonify({"svg": svg})
@app.route("/profile", methods=["GET"])
def profile():
wiki = get_or_create_wiki()
return jsonify(wiki.get_schema())
@app.route("/milestone", methods=["POST"])
def milestone():
wiki = get_or_create_wiki()
data = request.json or {}
wiki.record_milestone(data.get("topic", ""), data.get("description", ""))
return jsonify({"status": "ok"})
def _generate_svg_for_topic(topic: str, question: str) -> str:
"""根据主题和问题自动生成 SVG"""
if topic == "fraction":
return pie_chart([1, 1, 1, 1], "试试分披萨?")
elif topic == "geometry":
return grid_array(3, 4, title_tag=False) if "面积" in question else ""
elif topic == "arithmetic":
if "" in question or "×" in question:
return grid_array(3, 4, list(range(12)))
return number_line(0, 10)
return ""
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8765, debug=True)

View File

@ -0,0 +1,14 @@
{
"student_id": "student_be3d928d",
"created": "2026-05-21T13:42:39.217967",
"grade": null,
"topics_mastered": [],
"topics_learning": [],
"topics_struggling": [],
"learning_style": {
"prefers_visual": true,
"prefers_stories": true
},
"mistake_patterns": [],
"milestones": []
}

5
requirements.txt Normal file
View File

@ -0,0 +1,5 @@
flask>=3.0
openai>=1.0
anthropic>=0.30
matplotlib>=3.8
numpy>=1.26

428
templates/index.html Normal file
View File

@ -0,0 +1,428 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🐉 数学小卧龙 — 苏格拉底导师</title>
<style>
:root {
--bg: #FFF8F0;
--card: #FFFFFF;
--primary: #FF6B6B;
--secondary: #4ECDC4;
--accent: #45B7D1;
--text: #2D3436;
--light: #F8F9FA;
--border: #E9ECEF;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
/* 顶部栏 */
header {
background: linear-gradient(135deg, var(--primary), var(--accent));
color: white;
padding: 12px 20px;
display: flex;
align-items: center;
gap: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
header .logo { font-size: 28px; }
header h1 { font-size: 18px; font-weight: 600; }
header .tag { font-size: 12px; opacity: 0.85; }
/* 三栏布局 */
main {
display: grid;
grid-template-columns: 1fr 380px;
grid-template-rows: 1fr 1fr;
gap: 12px;
padding: 12px;
height: calc(100vh - 56px);
}
/* 左侧主区域 */
.main-area {
grid-row: 1 / 3;
display: flex;
flex-direction: column;
gap: 12px;
}
/* 画布区 */
.canvas-panel {
flex: 1;
background: var(--card);
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
display: flex;
flex-direction: column;
overflow: hidden;
}
.canvas-toolbar {
display: flex;
gap: 6px;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
flex-wrap: wrap;
}
.canvas-toolbar button {
padding: 5px 12px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--light);
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
}
.canvas-toolbar button:hover { background: var(--secondary); color: white; border-color: var(--secondary); }
.canvas-toolbar button.active { background: var(--primary); color: white; border-color: var(--primary); }
.canvas-toolbar .color-pick { width: 28px; height: 28px; border-radius: 50%; border: 2px solid var(--border); cursor: pointer; padding: 0; }
.drawing-area {
flex: 1;
position: relative;
overflow: hidden;
}
canvas { position: absolute; top: 0; left: 0; cursor: crosshair; }
.svg-display {
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
pointer-events: none; opacity: 0.3;
}
.svg-display svg { max-width: 90%; max-height: 90%; }
/* 输入区 */
.input-panel {
background: var(--card);
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
padding: 12px;
display: flex;
gap: 8px;
}
.input-panel textarea {
flex: 1;
border: 2px solid var(--border);
border-radius: 8px;
padding: 10px 14px;
font-size: 15px;
resize: none;
height: 60px;
font-family: inherit;
transition: border-color 0.2s;
}
.input-panel textarea:focus {
outline: none;
border-color: var(--primary);
}
.input-panel button {
padding: 10px 24px;
background: var(--primary);
color: white;
border: none;
border-radius: 8px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: transform 0.1s;
}
.input-panel button:hover { transform: scale(1.03); }
.input-panel button:active { transform: scale(0.97); }
.input-panel .draw-btn { background: var(--secondary); }
/* 右侧面板 */
.right-panels {
display: flex;
flex-direction: column;
gap: 12px;
}
.panel {
background: var(--card);
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
padding: 14px;
overflow-y: auto;
}
.panel h3 {
font-size: 14px;
color: var(--accent);
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 6px;
}
/* 对话区 */
.chat-panel {
flex: 2;
}
.chat-messages {
display: flex;
flex-direction: column;
gap: 10px;
}
.chat-msg {
padding: 10px 12px;
border-radius: 10px;
font-size: 14px;
line-height: 1.5;
animation: fadeIn 0.3s;
}
.chat-msg.student {
background: var(--light);
align-self: flex-end;
max-width: 85%;
border-bottom-right-radius: 4px;
}
.chat-msg.tutor {
background: linear-gradient(135deg, #E8F8F5, #D5F5E3);
align-self: flex-start;
max-width: 85%;
border-bottom-left-radius: 4px;
}
.chat-msg .role { font-size: 11px; color: #999; margin-bottom: 4px; }
.chat-msg .hint { font-size: 12px; color: var(--accent); margin-top: 6px; padding-top: 6px; border-top: 1px dashed #ccc; }
.typing { opacity: 0.6; }
.typing::after { content: "…"; animation: dots 1.5s infinite; }
@keyframes dots { 0%,20% { content: "." } 40% { content: ".." } 60%,100% { content: "…" } }
@keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
/* SVG 展示区 */
.svg-panel {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 180px;
}
.svg-panel .svg-container {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.svg-panel .svg-container svg {
max-width: 100%;
max-height: 200px;
}
.svg-placeholder {
color: #ccc;
font-size: 14px;
text-align: center;
}
/* 快捷按钮 */
.quick-btns {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 4px;
}
.quick-btns button {
padding: 4px 10px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--light);
cursor: pointer;
font-size: 12px;
}
.quick-btns button:hover { background: var(--accent); color: white; border-color: var(--accent); }
</style>
</head>
<body>
<header>
<span class="logo">🐉</span>
<div>
<h1>数学小卧龙</h1>
<span class="tag">苏格拉底式导师 · 不直接给答案</span>
</div>
</header>
<main>
<!-- 左侧:画布 + 输入 -->
<div class="main-area">
<div class="canvas-panel" id="canvasPanel">
<div class="canvas-toolbar">
<button onclick="setTool('pen')" class="active" id="penBtn">✏️ 画笔</button>
<button onclick="setTool('eraser')" id="eraserBtn">🧹 橡皮</button>
<input type="color" value="#FF6B6B" class="color-pick" id="colorPicker" onchange="setColor(this.value)">
<button onclick="clearCanvas()">🗑️ 清空</button>
<span style="flex:1"></span>
<button onclick="drawShape('pie',[1,1,1,1])">🥧 画饼图</button>
<button onclick="drawShape('numberline',{start:0,end:10})">📏 数轴</button>
<button onclick="drawShape('grid',{rows:3,cols:4})">🔲 格子阵</button>
</div>
<div class="drawing-area" id="drawingArea">
<canvas id="drawCanvas"></canvas>
<div class="svg-display" id="svgOverlay"></div>
</div>
</div>
<div class="input-panel">
<textarea id="questionInput" placeholder="问我任何数学问题吧!比如:½ + ¼ 等于多少?或者 23×17 怎么算?" onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();askQuestion()}"></textarea>
<button onclick="askQuestion()">🚀 提问</button>
<button class="draw-btn" onclick="drawRequest()">🎨 画图</button>
</div>
</div>
<!-- 右侧:对话 + SVG -->
<div class="right-panels">
<div class="panel chat-panel">
<h3>💬 苏格拉底对话</h3>
<div class="quick-btns">
<button onclick="askPreset('½ + ¼ 等于多少?')">分数加法</button>
<button onclick="askPreset('23×17 怎么算?')">乘法</button>
<button onclick="askPreset('三角形的面积怎么求?')">几何面积</button>
<button onclick="askPreset('什么是周长?')">周长</button>
</div>
<div class="chat-messages" id="chatMessages">
<div class="chat-msg tutor">
<div class="role">🐉 小卧龙</div>
你好!我是数学小卧龙 🐉<br>我不会直接告诉你答案——但我会用提问和画画,帮你自己发现!准备好了吗?
</div>
</div>
</div>
<div class="panel svg-panel">
<h3>📐 可视化</h3>
<div class="svg-container" id="svgContainer">
<div class="svg-placeholder">提问后这里会出现图形哦~</div>
</div>
</div>
</div>
</main>
<script>
// === 画布引擎 ===
const canvas = document.getElementById('drawCanvas');
const ctx = canvas.getContext('2d');
let tool = 'pen';
let color = '#FF6B6B';
let drawing = false;
function resizeCanvas() {
const area = document.getElementById('drawingArea');
canvas.width = area.clientWidth;
canvas.height = area.clientHeight;
}
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
canvas.addEventListener('mousedown', e => { drawing = true; draw(e); });
canvas.addEventListener('mousemove', e => { if(drawing) draw(e); });
canvas.addEventListener('mouseup', () => drawing = false);
canvas.addEventListener('mouseleave', () => drawing = false);
canvas.addEventListener('touchstart', e => { drawing = true; draw(e.touches[0]); });
canvas.addEventListener('touchmove', e => { if(drawing) draw(e.touches[0]); e.preventDefault(); });
canvas.addEventListener('touchend', () => drawing = false);
function draw(e) {
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
ctx.lineWidth = tool === 'eraser' ? 20 : 3;
ctx.strokeStyle = tool === 'eraser' ? '#FFF8F0' : color;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
if (tool === 'eraser') {
ctx.clearRect(x-10, y-10, 20, 20);
} else {
ctx.lineTo(x, y);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(x, y);
}
}
function setTool(t) {
tool = t;
document.getElementById('penBtn').classList.toggle('active', t==='pen');
document.getElementById('eraserBtn').classList.toggle('active', t==='eraser');
}
function setColor(c) { color = c; }
function clearCanvas() { ctx.clearRect(0, 0, canvas.width, canvas.height); }
// === 对话引擎 ===
async function askQuestion() {
const input = document.getElementById('questionInput');
const q = input.value.trim();
if (!q) return;
input.value = '';
addMessage('student', q);
addMessage('tutor', '', true); // typing indicator
try {
const resp = await fetch('/ask', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({question: q})
});
const data = await resp.json();
// 移除 typing
const msgs = document.getElementById('chatMessages');
msgs.removeChild(msgs.lastChild);
addMessage('tutor', data.reply, false, data.visual_hint);
if (data.svg) {
document.getElementById('svgContainer').innerHTML = data.svg;
}
} catch(e) {
const msgs = document.getElementById('chatMessages');
msgs.removeChild(msgs.lastChild);
addMessage('tutor', '哎呀,网络好像出了点问题... 再试一次?😅');
}
}
function askPreset(q) {
document.getElementById('questionInput').value = q;
askQuestion();
}
function addMessage(role, text, typing=false, hint='') {
const div = document.createElement('div');
div.className = `chat-msg ${role}${typing ? ' typing' : ''}`;
div.innerHTML = `<div class="role">${role==='student' ? '🧒 你' : '🐉 小卧龙'}</div>${text}${hint ? `<div class="hint">💡 ${hint}</div>` : ''}`;
const msgs = document.getElementById('chatMessages');
msgs.appendChild(div);
msgs.scrollTop = msgs.scrollHeight;
}
// === 画图请求 ===
async function drawRequest() {
const q = document.getElementById('questionInput').value.trim();
if (!q) return;
try {
const resp = await fetch('/draw', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({type: 'pie', params: {fractions: [1,1,2,1], title: '你的问题可视化'}})
});
const data = await resp.json();
if (data.svg) {
document.getElementById('svgContainer').innerHTML = data.svg;
}
} catch(e) {}
}
function drawShape(type, params) {
fetch('/draw', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({type, params})
}).then(r=>r.json()).then(d=>{
if(d.svg) document.getElementById('svgContainer').innerHTML = d.svg;
});
}
</script>
</body>
</html>

1
tutor/__init__.py Normal file
View File

@ -0,0 +1 @@
# Math Tutor - 小学数学苏格拉底导师

65
tutor/hermes_bridge.py Normal file
View File

@ -0,0 +1,65 @@
"""
Hermes Agent 桥接层 将苏格拉底 prompt 发送给 Hermes 获取引导回复
"""
import os
import subprocess
import json
def call_hermes(prompt: str, model: str = None) -> str:
"""
调用 Hermes CLI 获取苏格拉底式引导回复
优先使用环境变量配置的 provider/model否则走轻量 deepseek
"""
provider = os.environ.get("HERMES_PROVIDER", "deepseek")
model_name = model or os.environ.get("HERMES_MODEL", "deepseek-chat")
# 方式1: Hermes CLI首选 — 如果已安装)
try:
result = subprocess.run(
["hermes", "ask", prompt,
"--provider", provider,
"--model", model_name,
"--max-tokens", "300",
"--temperature", "0.7"],
capture_output=True, text=True,
timeout=10, cwd=os.path.expanduser("~/.hermes/hermes-agent")
)
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip()
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
# 方式2: 直接用 OpenAI 兼容 API如有 key
try:
import openai
api_key = os.environ.get("DEEPSEEK_API_KEY") or os.environ.get("OPENAI_API_KEY")
if not api_key:
raise ValueError("no key")
base_url = os.environ.get("DEEPSEEK_BASE_URL", "https://api.deepseek.com/v1")
client = openai.OpenAI(api_key=api_key, base_url=base_url)
resp = client.chat.completions.create(
model=model_name,
messages=[{"role": "user", "content": prompt}],
max_tokens=300,
temperature=0.7,
timeout=10
)
return resp.choices[0].message.content
except Exception:
# 降级:返回预设引导
return _fallback_response(prompt)
def _fallback_response(prompt: str) -> str:
"""无 LLM 时的预设引导回复"""
if "分数" in prompt or "几分" in prompt:
return "我们来分一块披萨吧!🍕 如果你把披萨切成4块拿1块——那是几分之几呢试试在画板上画一下"
elif "" in prompt or "×" in prompt:
return "乘法其实就是「几个几相加」。比如3×4就是3个4加起来。你能用小圆点画出来吗🎨"
elif "面积" in prompt or "周长" in prompt:
return "想象一块地板,铺满了小瓷砖。数一数有多少块瓷砖,那就是面积!在画板上画一下你的图形吧。"
else:
return "好问题!我们一起来探索。你能先告诉我:你已经知道了什么?🤔"

86
tutor/socratic.py Normal file
View File

@ -0,0 +1,86 @@
"""
苏格拉底教学引擎 不直接给答案通过提问引导学生自己发现
"""
SOCATIC_TEMPLATES = {
"arithmetic": """你是小学数学苏格拉底导师。学生问你一个算术问题。
**铁律**
1. 永远不直接给答案
2. 用提问引导学生自己发现每次回复最多1个问题+1个提示
3. 用生活中的故事和比喻苹果糖果积木
4. 学生答对时真诚赞美答错时说"有意思的想法!我们再想想..."
5. 如果学生表现出挫败降低难度给更简单的引导
**当前学生画像**
{student_profile}
**学生的问题**
{question}
请用苏格拉底式提问引导50-100""",
"geometry": """你是小学数学几何导师。学生问了一个图形/空间问题。
**铁律**
1. 不直接说"面积=长×宽"先问"你觉得这个图形可以怎么拆成小方块?"
2. 用画图引导提示学生"试试在画布上画一下?"
3. 从具体到抽象先数格子再引入公式
4. 答错是好事用错误理解深化概念
**当前学生画像**
{student_profile}
**学生的问题**
{question}
请用苏格拉底式提问引导50-120如合适建议学生在画布上画图""",
"fraction": """你是小学数学分数导师。分数是学生最头疼的概念之一。
**铁律**
1. 永远从"分披萨/分蛋糕"开始实物比喻优先
2. 用画图可视化分数饼图/长条图
3. 不要直接说"½+¼=¾"先问"半个披萨加四分之一个,一共几块?"
4. 让学生在画布上涂色理解
**当前学生画像**
{student_profile}
**学生的问题**
{question}
请用苏格拉底式提问引导50-120必须建议画图""",
}
TOPIC_KEYWORDS = {
"fraction": ["分数", "几分之几", "½", "", "¼", "几分", "分母", "分子", "约分", "通分"],
"geometry": ["面积", "周长", "三角形", "正方形", "长方形", "", "图形", "体积", "角度", "边长", "立方", "平方"],
"arithmetic": ["", "", "", "", "+", "-", "×", "÷", "计算", "等于", "多少", "几倍", "余数"],
}
def detect_topic(question: str) -> str:
for topic, keywords in TOPIC_KEYWORDS.items():
for kw in keywords:
if kw in question:
return topic
return "arithmetic"
def build_socratic_prompt(question: str, student_profile: str, topic: str = None) -> str:
if topic is None:
topic = detect_topic(question)
template = SOCATIC_TEMPLATES.get(topic, SOCATIC_TEMPLATES["arithmetic"])
return template.format(question=question, student_profile=student_profile)
def build_visual_hint(topic: str, question: str) -> str:
"""生成视觉提示——引导学生画图"""
hints = {
"fraction": "试试画一个圆饼图分成几份来理解SVG已准备好告诉我想分成几份。",
"geometry": "在画布上画一下这个图形吧!标出你知道的边长,我们一起数格子。",
"arithmetic": "画一些小圆点或积木块来表示数字,数一数就明白了!",
}
return hints.get(topic, hints["arithmetic"])

140
tutor/visualize.py Normal file
View File

@ -0,0 +1,140 @@
"""
数学可视化引擎 生成 SVG 图形
学生喜欢画画 SVG 让他们看到数学
"""
import math
def pie_chart(fractions: list, title: str = "", size: int = 200) -> str:
"""分数饼图 — 用于理解分数概念"""
colors = ["#FF6B6B", "#4ECDC4", "#45B7D1", "#96CEB4", "#FFEAA7",
"#DDA0DD", "#98D8C8", "#F7DC6F", "#BB8FCE", "#85C1E9"]
total = sum(fractions)
if total == 0:
return ""
cx, cy, r = size // 2, size // 2, size // 2 - 10
paths = []
start_angle = -90 # 从顶部开始
for i, val in enumerate(fractions):
angle = (val / total) * 360
end_angle = start_angle + angle
# 计算弧线坐标
sr = math.radians(start_angle)
er = math.radians(end_angle)
x1 = cx + r * math.cos(sr)
y1 = cy + r * math.sin(sr)
x2 = cx + r * math.cos(er)
y2 = cy + r * math.sin(er)
large_arc = 1 if angle > 180 else 0
path = f'<path d="M{cx},{cy} L{x1:.1f},{y1:.1f} A{r},{r} 0 {large_arc},1 {x2:.1f},{y2:.1f} Z" fill="{colors[i % len(colors)]}" stroke="white" stroke-width="2"/>'
paths.append(path)
# 标签
mid = math.radians(start_angle + angle / 2)
lx = cx + r * 0.6 * math.cos(mid)
ly = cy + r * 0.6 * math.sin(mid)
label_text = f"{val}/{total}" if total != sum(fractions) else str(val)
paths.append(f'<text x="{lx:.1f}" y="{ly:.1f}" text-anchor="middle" dominant-baseline="central" font-size="14" fill="white" font-weight="bold">{label_text}</text>')
start_angle = end_angle
return f'''<svg viewBox="0 0 {size} {size}" xmlns="http://www.w3.org/2000/svg">
{''.join(paths)}
{"<text x='" + str(cx) + "' y='" + str(size - 8) + "' text-anchor='middle' font-size='13' fill='#333'>" + title + "</text>" if title else ""}
</svg>'''
def number_line(start: int, end: int, highlights: list = None, size: tuple = (500, 80)) -> str:
"""数轴 — 用于加减法、负数、小数"""
w, h = size
margin = 40
line_y = h // 2
usable_w = w - 2 * margin
n_points = end - start + 1
spacing = usable_w / max(n_points - 1, 1)
elements = [f'<line x1="{margin}" y1="{line_y}" x2="{w - margin}" y2="{line_y}" stroke="#333" stroke-width="2"/>']
for i in range(n_points):
val = start + i
x = margin + i * spacing
elements.append(f'<line x1="{x:.0f}" y1="{line_y - 8}" x2="{x:.0f}" y2="{line_y + 8}" stroke="#333" stroke-width="1.5"/>')
elements.append(f'<text x="{x:.0f}" y="{line_y + 25}" text-anchor="middle" font-size="14" fill="#333">{val}</text>')
if highlights:
for hl in highlights:
v = hl.get("value", 0)
label = hl.get("label", "")
x = margin + (v - start) * spacing
elements.append(f'<circle cx="{x:.0f}" cy="{line_y}" r="12" fill="#FF6B6B" opacity="0.7"/>')
if label:
elements.append(f'<text x="{x:.0f}" y="{line_y - 20}" text-anchor="middle" font-size="14" fill="#FF6B6B" font-weight="bold">{label}</text>')
return f'<svg viewBox="0 0 {w} {h}" xmlns="http://www.w3.org/2000/svg">{"".join(elements)}</svg>'
def grid_array(rows: int, cols: int, highlight_cells: list = None, cell_size: int = 40) -> str:
"""阵列/格子 — 用于乘法、面积"""
w = cols * cell_size + 20
h = rows * cell_size + 20
elements = []
highlight_set = set(highlight_cells or [])
for r in range(rows):
for c in range(cols):
x = 10 + c * cell_size
y = 10 + r * cell_size
idx = r * cols + c
color = "#FF6B6B" if idx in highlight_set else "#4ECDC4"
opacity = "0.6" if idx in highlight_set else "0.3"
elements.append(f'<rect x="{x}" y="{y}" width="{cell_size - 2}" height="{cell_size - 2}" fill="{color}" opacity="{opacity}" rx="4"/>')
return f'<svg viewBox="0 0 {w} {h}" xmlns="http://www.w3.org/2000/svg">{"".join(elements)}</svg>'
def bar_chart(values: list, labels: list = None, title: str = "", size: tuple = (400, 250)) -> str:
"""柱状图"""
w, h = size
margin_t, margin_b, margin_l, margin_r = 30, 40, 50, 20
chart_w = w - margin_l - margin_r
chart_h = h - margin_t - margin_b
max_val = max(values) if values else 1
bar_w = chart_w / len(values) * 0.6
gap = chart_w / len(values) * 0.4
elements = [
f'<line x1="{margin_l}" y1="{margin_t}" x2="{margin_l}" y2="{h - margin_b}" stroke="#333" stroke-width="2"/>',
f'<line x1="{margin_l}" y1="{h - margin_b}" x2="{w - margin_r}" y2="{h - margin_b}" stroke="#333" stroke-width="2"/>'
]
colors = ["#FF6B6B", "#4ECDC4", "#45B7D1", "#96CEB4", "#FFEAA7", "#DDA0DD"]
for i, val in enumerate(values):
bar_h = (val / max_val) * chart_h if max_val > 0 else 0
x = margin_l + i * (bar_w + gap) + gap / 2
y = h - margin_b - bar_h
elements.append(f'<rect x="{x:.0f}" y="{y:.0f}" width="{bar_w:.0f}" height="{bar_h:.0f}" fill="{colors[i % len(colors)]}" rx="3" opacity="0.8"/>')
elements.append(f'<text x="{x + bar_w / 2:.0f}" y="{y - 8:.0f}" text-anchor="middle" font-size="13" fill="#333" font-weight="bold">{val}</text>')
if labels and i < len(labels):
elements.append(f'<text x="{x + bar_w / 2:.0f}" y="{h - margin_b + 20:.0f}" text-anchor="middle" font-size="12" fill="#666">{labels[i]}</text>')
if title:
elements.append(f'<text x="{w / 2:.0f}" y="{margin_t - 10:.0f}" text-anchor="middle" font-size="15" fill="#333" font-weight="bold">{title}</text>')
return f'<svg viewBox="0 0 {w} {h}" xmlns="http://www.w3.org/2000/svg">{"".join(elements)}</svg>'
def shapes_library() -> dict:
"""常用几何图形 SVG"""
return {
"square": '<svg viewBox="0 0 100 100"><rect x="10" y="10" width="80" height="80" fill="#4ECDC4" opacity="0.5" stroke="#333" stroke-width="2"/></svg>',
"rectangle": '<svg viewBox="0 0 150 100"><rect x="10" y="15" width="130" height="70" fill="#45B7D1" opacity="0.5" stroke="#333" stroke-width="2"/></svg>',
"triangle": '<svg viewBox="0 0 100 100"><polygon points="50,10 90,85 10,85" fill="#FF6B6B" opacity="0.5" stroke="#333" stroke-width="2"/></svg>',
"circle": '<svg viewBox="0 0 100 100"><circle cx="50" cy="50" r="40" fill="#96CEB4" opacity="0.5" stroke="#333" stroke-width="2"/></svg>',
}

85
tutor/wiki.py Normal file
View File

@ -0,0 +1,85 @@
"""
小学数学苏格拉底导师 LLM Wiki 学生知识追踪模块
参照 Karpathy llm-wiki 三层架构Raw sources Wiki Schema
"""
import json
import os
import re
from datetime import datetime
from pathlib import Path
WIKI_DIR = Path(os.environ.get("MATH_TUTOR_DATA", os.path.expanduser("~/.hermes/projects/math-tutor/data")))
class StudentWiki:
"""每个学生的持久化知识Wiki —— LLM增量维护"""
def __init__(self, student_id: str):
self.student_id = student_id
self.wiki_dir = WIKI_DIR / student_id
self.wiki_dir.mkdir(parents=True, exist_ok=True)
# 三层结构
self.sources_dir = self.wiki_dir / "sources" # 原始对话记录
self.knowledge_dir = self.wiki_dir / "knowledge" # 知识图谱
self.schema_path = self.wiki_dir / "schema.json" # 学生画像配置
self.progress_path = self.wiki_dir / "progress.md"
self._ensure_schema()
def _ensure_schema(self):
"""初始化学生画像 schema"""
if not self.schema_path.exists():
schema = {
"student_id": self.student_id,
"created": datetime.now().isoformat(),
"grade": None,
"topics_mastered": [],
"topics_learning": [],
"topics_struggling": [],
"learning_style": {"prefers_visual": True, "prefers_stories": True},
"mistake_patterns": [],
"milestones": []
}
self.schema_path.write_text(json.dumps(schema, ensure_ascii=False, indent=2), encoding="utf-8")
def get_schema(self) -> dict:
return json.loads(self.schema_path.read_text(encoding="utf-8"))
def update_schema(self, patch: dict):
schema = self.get_schema()
schema.update(patch)
schema["modified"] = datetime.now().isoformat()
self.schema_path.write_text(json.dumps(schema, ensure_ascii=False, indent=2), encoding="utf-8")
def ingest_session(self, question: str, answer: str, socratic_steps: list, student_response: str):
"""摄入一次对话——追加到 sources触发 LLM 更新 wiki"""
ts = datetime.now().isoformat()
source_file = self.sources_dir / f"session_{ts.replace(':', '-')}.json"
source_file.write_text(json.dumps({
"timestamp": ts,
"question": question,
"answer": answer,
"socratic_steps": socratic_steps,
"student_response": student_response
}, ensure_ascii=False, indent=2), encoding="utf-8")
def get_knowledge_summary(self) -> str:
"""生成当前知识状态摘要,注入 LLM prompt"""
schema = self.get_schema()
return f"""## 学生画像
- 年级: {schema.get('grade', '未知')}
- 已掌握: {', '.join(schema.get('topics_mastered', [])) or ''}
- 学习中: {', '.join(schema.get('topics_learning', [])) or ''}
- 困难: {', '.join(schema.get('topics_struggling', [])) or ''}
- 学习风格: 偏好{'视觉' if schema['learning_style']['prefers_visual'] else '文字'}·{'喜欢故事化' if schema['learning_style']['prefers_stories'] else '直接'}
- 常见错误模式: {', '.join(schema.get('mistake_patterns', [])) or ''}"""
def record_milestone(self, topic: str, description: str):
schema = self.get_schema()
schema.setdefault("milestones", []).append({
"topic": topic, "description": description,
"date": datetime.now().isoformat()
})
self.schema_path.write_text(json.dumps(schema, ensure_ascii=False, indent=2), encoding="utf-8")