commit fde209237596f5db8d35e06902b5d31bcf05c4c8 Author: 大师 Date: Fri May 22 01:43:57 2026 +0800 init: 小学生数学交互系统 v1.0 — Flask+SVG可视化+苏格拉底教学法 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2396b2d --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +.env +venv/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..777c27b --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/app.py b/app.py new file mode 100644 index 0000000..ddddbdc --- /dev/null +++ b/app.py @@ -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) diff --git a/data/student_be3d928d/schema.json b/data/student_be3d928d/schema.json new file mode 100644 index 0000000..533b62e --- /dev/null +++ b/data/student_be3d928d/schema.json @@ -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": [] +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..27048e1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +flask>=3.0 +openai>=1.0 +anthropic>=0.30 +matplotlib>=3.8 +numpy>=1.26 diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..310d39b --- /dev/null +++ b/templates/index.html @@ -0,0 +1,428 @@ + + + + + +🐉 数学小卧龙 — 苏格拉底导师 + + + + +
+ +
+

数学小卧龙

+ 苏格拉底式导师 · 不直接给答案 +
+
+ +
+ +
+
+
+ + + + + + + + +
+
+ +
+
+
+ +
+ + + +
+
+ + +
+
+

💬 苏格拉底对话

+
+ + + + +
+
+
+
🐉 小卧龙
+ 你好!我是数学小卧龙 🐉
我不会直接告诉你答案——但我会用提问和画画,帮你自己发现!准备好了吗? +
+
+
+ +
+

📐 可视化

+
+
提问后这里会出现图形哦~
+
+
+
+
+ + + + diff --git a/tutor/__init__.py b/tutor/__init__.py new file mode 100644 index 0000000..3bbeacc --- /dev/null +++ b/tutor/__init__.py @@ -0,0 +1 @@ +# Math Tutor - 小学数学苏格拉底导师 diff --git a/tutor/hermes_bridge.py b/tutor/hermes_bridge.py new file mode 100644 index 0000000..a093c06 --- /dev/null +++ b/tutor/hermes_bridge.py @@ -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 "好问题!我们一起来探索。你能先告诉我:你已经知道了什么?🤔" diff --git a/tutor/socratic.py b/tutor/socratic.py new file mode 100644 index 0000000..9a469f3 --- /dev/null +++ b/tutor/socratic.py @@ -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"]) diff --git a/tutor/visualize.py b/tutor/visualize.py new file mode 100644 index 0000000..f9e51ba --- /dev/null +++ b/tutor/visualize.py @@ -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'' + 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'{label_text}') + + start_angle = end_angle + + return f''' + {''.join(paths)} + {"" + title + "" if title else ""} +''' + + +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''] + + for i in range(n_points): + val = start + i + x = margin + i * spacing + elements.append(f'') + elements.append(f'{val}') + + if highlights: + for hl in highlights: + v = hl.get("value", 0) + label = hl.get("label", "") + x = margin + (v - start) * spacing + elements.append(f'') + if label: + elements.append(f'{label}') + + return f'{"".join(elements)}' + + +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'') + + return f'{"".join(elements)}' + + +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'', + f'' + ] + + 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'') + elements.append(f'{val}') + if labels and i < len(labels): + elements.append(f'{labels[i]}') + + if title: + elements.append(f'{title}') + + return f'{"".join(elements)}' + + +def shapes_library() -> dict: + """常用几何图形 SVG""" + return { + "square": '', + "rectangle": '', + "triangle": '', + "circle": '', + } diff --git a/tutor/wiki.py b/tutor/wiki.py new file mode 100644 index 0000000..5e82f57 --- /dev/null +++ b/tutor/wiki.py @@ -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")