init: 小学生数学交互系统 v1.0 — Flask+SVG可视化+苏格拉底教学法
This commit is contained in:
commit
fde2092375
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
venv/
|
||||
17
Dockerfile
Normal file
17
Dockerfile
Normal 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
129
app.py
Normal 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)
|
||||
14
data/student_be3d928d/schema.json
Normal file
14
data/student_be3d928d/schema.json
Normal 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
5
requirements.txt
Normal 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
428
templates/index.html
Normal 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
1
tutor/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Math Tutor - 小学数学苏格拉底导师
|
||||
65
tutor/hermes_bridge.py
Normal file
65
tutor/hermes_bridge.py
Normal 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
86
tutor/socratic.py
Normal 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
140
tutor/visualize.py
Normal 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
85
tutor/wiki.py
Normal 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")
|
||||
Loading…
x
Reference in New Issue
Block a user