app.py 50 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400
  1. from flask import Flask, render_template, request, redirect, url_for, flash, session, jsonify
  2. from flask_sqlalchemy import SQLAlchemy
  3. from werkzeug.security import generate_password_hash, check_password_hash
  4. from datetime import datetime, date
  5. import base64
  6. import time
  7. import requests
  8. import pymysql
  9. from backend.config import get_settings
  10. from backend.database import engine, Base, ensure_user_profile_columns
  11. import backend.models # noqa: F401 - 确保 backend 模型注册到 Base.metadata,供 create_all 创建表
  12. from neo4j import GraphDatabase
  13. from backend.services.llm_service import expand_with_llm
  14. from backend.services.question_parser import analyze_question
  15. from backend.services.mastery_service import update_mastery_from_answers, mark_daily_recommendations_done
  16. from backend.services.recommendation import (
  17. get_or_generate_daily_recommendations,
  18. get_mastery_matrix,
  19. get_motivation_summary,
  20. get_question_analysis,
  21. get_diagnostic_questions,
  22. )
  23. from backend.services.teacher_report_service import get_teacher_class_report
  24. # 修正 MySQL 驱动兼容性问题(如果遇到报错可以尝试加上)
  25. pymysql.install_as_MySQLdb()
  26. app = Flask(__name__)
  27. settings = get_settings()
  28. # --- 数据库配置 ---
  29. # 格式:mysql+用户名:密码@localhost:3306/数据库名
  30. # 当前使用 MySQL 用户 maple@localhost,密码 123456,数据库 ai_system
  31. app.config['SQLALCHEMY_DATABASE_URI'] = settings.MYSQL_URI
  32. app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
  33. app.config['SECRET_KEY'] = 'dev-key-please-change-in-production' # 用于Session加密
  34. """数据库初始化"""
  35. db = SQLAlchemy(app)
  36. # --- Neo4j 配置(用于知识图谱) ---
  37. neo4j_driver = GraphDatabase.driver(
  38. settings.NEO4J_URI,
  39. auth=(settings.NEO4J_USER, settings.NEO4J_PASSWORD),
  40. )
  41. # --- 百度语音合成(TTS)全局配置与简单缓存 ---
  42. _BAIDU_TTS_TOKEN_CACHE = {
  43. "access_token": None,
  44. "expires_at": 0,
  45. }
  46. def _get_baidu_tts_access_token():
  47. """
  48. 获取百度语音合成 access_token,简单内存缓存,避免每次都去请求。
  49. """
  50. from backend.config import get_settings as _get_settings
  51. settings = _get_settings()
  52. api_key = settings.BAIDU_TTS_API_KEY
  53. secret_key = settings.BAIDU_TTS_SECRET_KEY
  54. if not api_key or not secret_key:
  55. raise RuntimeError("百度语音合成 API Key/Secret 未配置")
  56. now = time.time()
  57. if (
  58. _BAIDU_TTS_TOKEN_CACHE["access_token"]
  59. and now < _BAIDU_TTS_TOKEN_CACHE["expires_at"] - 60
  60. ):
  61. return _BAIDU_TTS_TOKEN_CACHE["access_token"]
  62. token_url = "https://aip.baidubce.com/oauth/2.0/token"
  63. resp = requests.get(
  64. token_url,
  65. params={
  66. "grant_type": "client_credentials",
  67. "client_id": api_key,
  68. "client_secret": secret_key,
  69. },
  70. timeout=10,
  71. )
  72. data = resp.json()
  73. access_token = data.get("access_token")
  74. if not access_token:
  75. raise RuntimeError(f"获取百度 TTS access_token 失败: {data}")
  76. expires_in = int(data.get("expires_in", 2592000))
  77. _BAIDU_TTS_TOKEN_CACHE["access_token"] = access_token
  78. _BAIDU_TTS_TOKEN_CACHE["expires_at"] = now + expires_in
  79. return access_token
  80. # --- 定义数据模型 ---
  81. class User(db.Model):
  82. """用户表,对应初中化学教学系统中的学生/教师账号"""
  83. __tablename__ = 'users'
  84. id = db.Column(db.Integer, primary_key=True)
  85. username = db.Column(db.String(80), unique=True, nullable=False)
  86. password_hash = db.Column(db.String(255), nullable=False)
  87. role = db.Column(db.String(20), default='student')
  88. # 个人信息字段(与 FastAPI 后端对齐,均为可选)
  89. full_name = db.Column(db.String(50), nullable=True) # 真实姓名
  90. student_id = db.Column(db.String(50), nullable=True) # 学号
  91. class_name = db.Column(db.String(100), nullable=True) # 班级,如“初二(3)班”
  92. grade = db.Column(db.String(20), nullable=True) # 年级,如“初一”“初二”
  93. email = db.Column(db.String(120), nullable=True)
  94. phone = db.Column(db.String(20), nullable=True)
  95. created_at = db.Column(db.DateTime, default=datetime.utcnow)
  96. def set_password(self, password):
  97. self.password_hash = generate_password_hash(password)
  98. def check_password(self, password):
  99. return check_password_hash(self.password_hash, password)
  100. class Question(db.Model):
  101. """化学每日训练题库"""
  102. __tablename__ = 'questions'
  103. id = db.Column(db.Integer, primary_key=True)
  104. content = db.Column(db.Text, nullable=False) # 题干
  105. option_a = db.Column(db.String(255), nullable=False)
  106. option_b = db.Column(db.String(255), nullable=False)
  107. option_c = db.Column(db.String(255), nullable=False)
  108. option_d = db.Column(db.String(255), nullable=False)
  109. correct_option = db.Column(db.String(1), nullable=False) # A/B/C/D
  110. knowledge_point = db.Column(db.String(100), nullable=True) # 所属知识点,如“电解质”
  111. difficulty = db.Column(db.Integer, default=1) # 1-5 难度
  112. created_at = db.Column(db.DateTime, default=datetime.utcnow)
  113. class AnswerRecord(db.Model):
  114. """答题记录,用于学情分析与错题集"""
  115. __tablename__ = 'answer_records'
  116. id = db.Column(db.Integer, primary_key=True)
  117. user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
  118. question_id = db.Column(db.Integer, db.ForeignKey('questions.id'), nullable=False)
  119. selected_option = db.Column(db.String(1), nullable=False)
  120. is_correct = db.Column(db.Boolean, default=False)
  121. answered_at = db.Column(db.DateTime, default=datetime.utcnow)
  122. user = db.relationship('User', backref=db.backref('answer_records', lazy='dynamic'))
  123. question = db.relationship('Question', backref=db.backref('answer_records', lazy='dynamic'))
  124. class KnowledgePoint(db.Model):
  125. """知识图谱:知识点实体(初中化学)"""
  126. __tablename__ = 'knowledge_points'
  127. id = db.Column(db.Integer, primary_key=True)
  128. name = db.Column(db.String(100), unique=True, nullable=False) # 如:电解质
  129. description = db.Column(db.Text, nullable=True) # 知识点描述
  130. chapter = db.Column(db.String(80), nullable=True) # 所属章节
  131. created_at = db.Column(db.DateTime, default=datetime.utcnow)
  132. class KnowledgeRelation(db.Model):
  133. """知识图谱:三元组关系(主体-谓词-客体)"""
  134. __tablename__ = 'knowledge_relations'
  135. id = db.Column(db.Integer, primary_key=True)
  136. subject = db.Column(db.String(120), nullable=False)
  137. predicate = db.Column(db.String(80), nullable=False)
  138. obj = db.Column(db.String(120), nullable=False)
  139. created_at = db.Column(db.DateTime, default=datetime.utcnow)
  140. class CurriculumTopic(db.Model):
  141. """课程主题/知识主题,如:物质分类、原子结构等"""
  142. __tablename__ = 'curriculum_topics'
  143. id = db.Column(db.Integer, primary_key=True)
  144. name = db.Column(db.String(50), unique=True, nullable=False)
  145. phase = db.Column(db.String(20), default='初中') # 学段:初中/高中等
  146. chapter = db.Column(db.String(80), nullable=True) # 教材章节
  147. description = db.Column(db.Text, nullable=True)
  148. class ChemistryQuestionBank(db.Model):
  149. """多题型统一题库,支撑 AI 数字人教学"""
  150. __tablename__ = 'chemistry_question_bank'
  151. question_id = db.Column(db.Integer, primary_key=True)
  152. type = db.Column(db.Enum('choice', 'equation', 'experiment', 'calculation',
  153. 'inference', 'diagram', name='question_type'),
  154. nullable=False)
  155. difficulty = db.Column(db.Enum('easy', 'medium', 'hard', name='question_difficulty'),
  156. nullable=False, default='easy')
  157. topic = db.Column(
  158. db.String(50),
  159. db.ForeignKey('curriculum_topics.name'),
  160. nullable=True,
  161. comment='如:物质分类、酸碱盐、金属等'
  162. )
  163. unit_no = db.Column(
  164. db.Integer,
  165. nullable=True,
  166. comment='教材单元编号,如 1-12'
  167. )
  168. knowledge_tags = db.Column(
  169. db.String(255),
  170. nullable=True,
  171. comment='知识点标签,逗号分隔(与 schema.sql 一致)',
  172. )
  173. content = db.Column(db.Text, nullable=False, comment='题干,可包含 HTML/Markdown')
  174. answer_schema = db.Column(
  175. db.JSON,
  176. nullable=False,
  177. comment='结构化答案:选项/方程式/步骤/推断链等'
  178. )
  179. hint = db.Column(db.Text, nullable=True)
  180. created_at = db.Column(db.DateTime, default=datetime.utcnow)
  181. topic_rel = db.relationship(
  182. 'CurriculumTopic',
  183. primaryjoin='ChemistryQuestionBank.topic == foreign(CurriculumTopic.name)',
  184. # 注意:此处不能使用 lazy='dynamic',因为 SQLAlchemy 会将该关系
  185. # 视为一对一 / many-to-one(外键指向唯一列),dynamic 只支持一对多集合关系。
  186. # 使用默认的 select 加载方式即可。
  187. backref=db.backref('questions', lazy='select')
  188. )
  189. with app.app_context():
  190. # 确保定义的表在数据库中存在(需要先手动创建 ai_system 数据库)
  191. try:
  192. db.create_all()
  193. # 创建 backend 模型对应的表(qa_sessions、student_profiles 等),
  194. # 即使只启动 Flask 不启动 FastAPI,这些表也会被自动创建
  195. Base.metadata.create_all(bind=engine)
  196. # 补充 FastAPI 侧新增的用户个人信息字段,避免表结构不一致
  197. try:
  198. ensure_user_profile_columns()
  199. except Exception as e:
  200. print(f"[WARN] ensure_user_profile_columns() failed: {e}")
  201. except Exception as e:
  202. # 开发期常见:MySQL 未启动/账号密码不对/库不存在
  203. # 不阻塞 Web 服务启动,方便先联调前后端;具体接口用到 DB 时仍会报错并提示。
  204. print(f"[WARN] db.create_all() failed: {e}")
  205. # --- 路由功能 ---
  206. @app.route('/') # 1:根路径直接重定向到登录页
  207. def index():
  208. return redirect(url_for('login'))
  209. @app.route('/login', methods=['GET', 'POST'])
  210. def login():
  211. # ... (保持之前的登录逻辑不变) ...
  212. if request.method == 'POST':
  213. username = request.form['username']
  214. password = request.form['password']
  215. user = User.query.filter_by(username=username).first()
  216. if user and user.check_password(password):
  217. session['user_id'] = user.id
  218. session['username'] = user.username
  219. flash("登录成功!", "success")
  220. return redirect(url_for('dashboard')) # 登录成功后跳转到 dashboard
  221. else:
  222. flash("用户名或密码错误。", "error")
  223. return render_template('login.html')
  224. @app.route('/register', methods=['GET', 'POST'])
  225. def register():
  226. # ... (保持之前的注册逻辑不变) ...
  227. if request.method == 'POST':
  228. username = request.form['username']
  229. password = request.form['password']
  230. if not username or not password:
  231. flash("用户名和密码不能为空", "error")
  232. return redirect(url_for('register'))
  233. if User.query.filter_by(username=username).first():
  234. flash("用户名已存在,请更换一个。", "error")
  235. return redirect(url_for('register'))
  236. new_user = User(username=username)
  237. new_user.set_password(password)
  238. try:
  239. db.session.add(new_user)
  240. db.session.commit()
  241. flash("注册成功!请登录。", "success")
  242. return redirect(url_for('login'))
  243. except Exception as e:
  244. db.session.rollback()
  245. flash(f"发生错误: {e}", "error")
  246. return render_template('register.html')
  247. @app.route('/logout')
  248. def logout():
  249. session.clear()
  250. flash("您已退出登录。", "info")
  251. return redirect(url_for('login'))
  252. # --- 主系统路由 (需要增加登录保护) ---
  253. # 定义一个装饰器,用于检查用户是否登录
  254. from functools import wraps
  255. def login_required(f):
  256. @wraps(f)
  257. def decorated_function(*args, **kwargs):
  258. if 'user_id' not in session:
  259. flash("请先登录。", "info")
  260. return redirect(url_for('login'))
  261. return f(*args, **kwargs)
  262. return decorated_function
  263. @app.route('/dashboard')
  264. @login_required
  265. def dashboard():
  266. return render_template(
  267. 'dashboard.html', active_page='home',
  268. questions=None, training_result=None, chart_data=[]
  269. )
  270. @app.route('/ai_assistant')
  271. @login_required # 添加保护
  272. def ai_assistant():
  273. return redirect("http://localhost:8880")
  274. def _get_user_proficiency(user_id):
  275. """按知识点统计用户熟练度:正确题数/总题数"""
  276. from sqlalchemy import func, case
  277. stats = db.session.query(
  278. Question.knowledge_point,
  279. func.sum(case((AnswerRecord.is_correct == True, 1), else_=0)).label('correct'),
  280. func.count(AnswerRecord.id).label('total')
  281. ).join(AnswerRecord, Question.id == AnswerRecord.question_id).filter(
  282. AnswerRecord.user_id == user_id,
  283. Question.knowledge_point.isnot(None),
  284. Question.knowledge_point != ''
  285. ).group_by(Question.knowledge_point).all()
  286. return {s.knowledge_point: int(s.correct / s.total * 100) if s.total else 0 for s in stats}
  287. def _get_wrong_question_ids(user_id, limit=50):
  288. """获取用户错题集中的题目ID列表(去重,最近优先)"""
  289. from sqlalchemy import desc
  290. records = AnswerRecord.query.filter_by(user_id=user_id, is_correct=False)\
  291. .order_by(desc(AnswerRecord.answered_at)).all()
  292. seen = set()
  293. ids = []
  294. for r in records:
  295. if r.question_id not in seen:
  296. seen.add(r.question_id)
  297. ids.append(r.question_id)
  298. if len(ids) >= limit:
  299. break
  300. return ids
  301. def _get_weak_knowledge_points(user_id, top_n=3):
  302. """获取用户薄弱知识点(熟练度最低的)"""
  303. prof = _get_user_proficiency(user_id)
  304. if not prof:
  305. return []
  306. sorted_prof = sorted(prof.items(), key=lambda x: x[1])
  307. return [p[0] for p in sorted_prof[:top_n]]
  308. def _get_recommended_questions(user_id, count=5):
  309. """习题测评推荐算法:优先推荐错题相关、薄弱知识点题目"""
  310. from sqlalchemy.sql import func
  311. wrong_ids = _get_wrong_question_ids(user_id, limit=20)
  312. weak_points = _get_weak_knowledge_points(user_id, top_n=3)
  313. recommended = []
  314. # 1. 优先从薄弱知识点补充
  315. if wrong_ids:
  316. qs = Question.query.filter(Question.id.in_(wrong_ids)).order_by(func.rand()).limit(count).all()
  317. recommended = [q.id for q in qs]
  318. # 2. 不足时从错题补充
  319. exclude_ids = list(set(recommended + wrong_ids))
  320. if len(recommended) < count and weak_points:
  321. extra = Question.query.filter(
  322. Question.knowledge_point.in_(weak_points)
  323. )
  324. if exclude_ids:
  325. extra = extra.filter(~Question.id.in_(exclude_ids))
  326. extra = extra.order_by(func.rand()).limit(count - len(recommended)).all()
  327. recommended.extend([q.id for q in extra])
  328. exclude_ids = list(set(recommended + wrong_ids))
  329. # 3. 仍不足则随机补充
  330. if len(recommended) < count:
  331. fallback = Question.query
  332. if exclude_ids:
  333. fallback = fallback.filter(~Question.id.in_(exclude_ids))
  334. fallback = fallback.order_by(func.rand()).limit(count - len(recommended)).all()
  335. recommended.extend([q.id for q in fallback])
  336. if not recommended:
  337. return []
  338. return Question.query.filter(Question.id.in_(recommended[:count])).all()
  339. @app.route('/daily_training', methods=['GET', 'POST'])
  340. @login_required
  341. def daily_training():
  342. from sqlalchemy.sql import func
  343. if request.method == 'POST':
  344. question_ids = request.form.getlist('question_ids')
  345. correct_count = 0
  346. total = 0
  347. for qid in question_ids:
  348. selected = request.form.get(f"q_{qid}")
  349. if not selected:
  350. continue
  351. question = Question.query.get(int(qid))
  352. if not question:
  353. continue
  354. is_correct = (selected == question.correct_option)
  355. total += 1
  356. if is_correct:
  357. correct_count += 1
  358. record = AnswerRecord(
  359. user_id=session['user_id'],
  360. question_id=question.id,
  361. selected_option=selected,
  362. is_correct=is_correct
  363. )
  364. db.session.add(record)
  365. if total > 0:
  366. db.session.commit()
  367. accuracy = int(round(correct_count / total * 100)) if total else 0
  368. training_result = {'total': total, 'correct': correct_count, 'accuracy': accuracy}
  369. # 知识点题目推荐:答题后按推荐算法抽取下一组
  370. questions = _get_recommended_questions(session['user_id'], count=5)
  371. return render_template(
  372. 'dashboard.html', active_page='training',
  373. questions=questions, training_result=training_result, chart_data=[]
  374. )
  375. # GET:优先使用推荐算法,无历史数据时随机出题
  376. questions = _get_recommended_questions(session['user_id'], count=5)
  377. if not questions:
  378. questions = Question.query.order_by(func.rand()).limit(5).all()
  379. return render_template(
  380. 'dashboard.html', active_page='training',
  381. questions=questions, training_result=None, chart_data=[]
  382. )
  383. @app.route('/wrong_questions')
  384. @login_required
  385. def wrong_questions():
  386. """错题集:展示用户答错的题目,可重做巩固"""
  387. wrong_ids = _get_wrong_question_ids(session['user_id'], limit=50)
  388. questions = Question.query.filter(Question.id.in_(wrong_ids)).all() if wrong_ids else []
  389. # 保持错题集中的题目顺序
  390. id_order = {qid: i for i, qid in enumerate(wrong_ids)}
  391. questions = sorted(questions, key=lambda q: id_order.get(q.id, 999))
  392. return render_template(
  393. 'dashboard.html', active_page='wrong_questions',
  394. questions=questions, training_result=None, chart_data=[]
  395. )
  396. @app.route('/knowledge_qa')
  397. @login_required
  398. def knowledge_qa():
  399. """知识点询问:大模型+知识图谱+数字人答疑"""
  400. return render_template('knowledge_qa.html')
  401. def _search_knowledge_graph(query_text, limit=15):
  402. """
  403. 知识图谱检索(Flask 版):
  404. 复用当前可视化模块使用的 Neo4j 结构和字段,
  405. 使用 app.py 中的 neo4j_driver,避免影响 backend.services 里的已有逻辑。
  406. """
  407. q = (query_text or "").strip()
  408. if not q:
  409. return []
  410. triples = []
  411. # 1. 优先使用 Neo4j:与 /api/v1/knowledge/graph 相同的一套节点展示名规则
  412. try:
  413. with neo4j_driver.session() as session:
  414. part_s = "coalesce(" + ", ".join([
  415. "s.name", "s.formula", "s.symbol", "s.description",
  416. "CASE WHEN s.text IS NOT NULL AND size(s.text) > 0 THEN substring(s.text, 0, 80) + '…' ELSE NULL END",
  417. "s.type", "s.category", "s.state",
  418. "CASE WHEN s.chunk IS NOT NULL AND size(toString(s.chunk)) > 0 THEN substring(toString(s.chunk), 0, 80) + '…' ELSE NULL END",
  419. ]) + ")"
  420. part_o = "coalesce(" + ", ".join([
  421. "o.name", "o.formula", "o.symbol", "o.description",
  422. "CASE WHEN o.text IS NOT NULL AND size(o.text) > 0 THEN substring(o.text, 0, 80) + '…' ELSE NULL END",
  423. "o.type", "o.category", "o.state",
  424. "CASE WHEN o.chunk IS NOT NULL AND size(toString(o.chunk)) > 0 THEN substring(toString(o.chunk), 0, 80) + '…' ELSE NULL END",
  425. ]) + ")"
  426. cypher = f"""
  427. MATCH (s)-[r]->(o)
  428. WITH s, r, o, {part_s} AS subjDisplay, {part_o} AS objDisplay
  429. WHERE subjDisplay IS NOT NULL AND objDisplay IS NOT NULL
  430. WITH
  431. coalesce(toString(subjDisplay), '') AS subject,
  432. coalesce(r.name, toString(type(r))) AS predicate,
  433. coalesce(toString(objDisplay), '') AS obj
  434. WHERE subject <> '' AND obj <> ''
  435. AND (
  436. subject CONTAINS $q OR
  437. obj CONTAINS $q OR
  438. predicate CONTAINS $q
  439. )
  440. RETURN DISTINCT subject, predicate, obj
  441. LIMIT $limit
  442. """
  443. records = session.run(cypher, q=q, limit=limit).data()
  444. for rec in records:
  445. subject = (rec.get("subject") or "").strip()
  446. obj = (rec.get("obj") or "").strip()
  447. predicate = (rec.get("predicate") or "关联").strip()
  448. if not subject or not obj:
  449. continue
  450. triples.append({"subject": subject, "predicate": predicate, "obj": obj})
  451. if triples:
  452. return triples
  453. except Exception as e:
  454. print(f"[WARN] Neo4j _search_knowledge_graph failed: {e}")
  455. # 2. 回退到原有 MySQL 知识图谱表(保持兼容)
  456. rels = KnowledgeRelation.query.filter(
  457. db.or_(
  458. KnowledgeRelation.subject.contains(q),
  459. KnowledgeRelation.predicate.contains(q),
  460. KnowledgeRelation.obj.contains(q),
  461. )
  462. ).limit(limit).all()
  463. if rels:
  464. for r in rels:
  465. triples.append(
  466. {"subject": r.subject, "predicate": r.predicate, "obj": r.obj}
  467. )
  468. return triples
  469. # 3. 若无直接匹配,再按知识点名称检索 MySQL 中的关系
  470. kps = KnowledgePoint.query.filter(KnowledgePoint.name.contains(q)).limit(5).all()
  471. for kp in kps:
  472. rels = (
  473. KnowledgeRelation.query.filter(
  474. db.or_(
  475. KnowledgeRelation.subject.contains(kp.name),
  476. KnowledgeRelation.obj.contains(kp.name),
  477. )
  478. )
  479. .limit(3)
  480. .all()
  481. )
  482. for r in rels:
  483. triples.append(
  484. {"subject": r.subject, "predicate": r.predicate, "obj": r.obj}
  485. )
  486. if len(triples) >= limit:
  487. break
  488. return triples[:limit]
  489. def _expand_with_llm(query_text, kg_context, meta=None):
  490. """大模型扩充回答:复用 backend.services.llm_service 的 RAG + DeepSeek 逻辑"""
  491. # kg_context 现在优先是 Neo4j/SQL 三元组 dict 列表
  492. triples = []
  493. for r in kg_context or []:
  494. if isinstance(r, dict):
  495. triples.append(
  496. {
  497. "subject": r.get("subject", ""),
  498. "predicate": r.get("predicate", ""),
  499. "obj": r.get("obj", ""),
  500. }
  501. )
  502. else:
  503. # 兼容旧的 SQLAlchemy 模型对象
  504. triples.append(
  505. {
  506. "subject": getattr(r, "subject", ""),
  507. "predicate": getattr(r, "predicate", ""),
  508. "obj": getattr(r, "obj", ""),
  509. }
  510. )
  511. if meta is None:
  512. try:
  513. meta = analyze_question(query_text)
  514. except Exception:
  515. meta = None
  516. # 优先调用统一的大模型服务(DeepSeek / Qwen 等)
  517. try:
  518. return expand_with_llm(query_text, triples, meta=meta)
  519. except Exception as e:
  520. print(f"[WARN] expand_with_llm fallback due to error: {e}")
  521. # 若大模型不可用,退回到本地兜底逻辑
  522. context_str = "\n".join(
  523. [f"{t['subject']} {t['predicate']} {t['obj']}" for t in triples]
  524. )
  525. if context_str:
  526. return (
  527. "根据初中化学知识图谱,相关内容如下:\n\n"
  528. f"{context_str}\n\n"
  529. "建议结合教材和习题进一步巩固理解。"
  530. )
  531. return (
  532. f"暂无与「{query_text}」直接相关的图谱条目,"
  533. "建议从教材相关章节或习题中查找,或尝试更换关键词。"
  534. )
  535. @app.route('/api/v1/tts/baidu', methods=['POST'])
  536. @login_required
  537. def api_v1_tts_baidu():
  538. """
  539. 百度语音合成接口:接收文本,返回 base64 编码的 MP3 音频数据。
  540. 前端调用示例:
  541. POST /api/v1/tts/baidu
  542. { "text": "你好,同学", "spd": 5, "pit": 5, "vol": 5, "per": 5118 }
  543. """
  544. data = request.get_json(silent=True) or {}
  545. text = (data.get('text') or '').strip()
  546. if not text:
  547. return {'detail': 'text 不能为空'}, 400
  548. # 百度 TTS 对 tex 参数长度有严格限制(按字节计数),
  549. # 日志中出现 "Normal invalid text length" / "tex param err" 即代表超出范围。
  550. # 这里做一次兜底截断,避免长回答导致整个 TTS 调用失败。
  551. # 官方常见限制为 1024 字节,这里按 1024 字节做保守截断。
  552. max_tex_bytes = 1024
  553. text_bytes = text.encode('utf-8')
  554. if len(text_bytes) > max_tex_bytes:
  555. cut = text_bytes[:max_tex_bytes]
  556. # 避免截断在 UTF-8 多字节字符的中间,向前回退到合法边界
  557. while cut and (cut[-1] & 0xC0) == 0x80:
  558. cut = cut[:-1]
  559. text = cut.decode('utf-8', errors='ignore')
  560. # 语速、音调、音量、发音人可选;使用百度默认区间 0-15
  561. spd = int(data.get('spd', 5))
  562. pit = int(data.get('pit', 5))
  563. vol = int(data.get('vol', 5))
  564. per = int(data.get('per', 5118)) # 5118 为度小萌,中文女声,比较自然
  565. try:
  566. token = _get_baidu_tts_access_token()
  567. except Exception as e:
  568. return {'detail': f'获取百度 TTS token 失败: {e}'}, 500
  569. settings = get_settings()
  570. cuid = settings.BAIDU_TTS_APP_ID or 'ai-teaching-system'
  571. tts_url = "https://tsn.baidu.com/text2audio"
  572. try:
  573. resp = requests.post(
  574. tts_url,
  575. data={
  576. "tex": text,
  577. "tok": token,
  578. "cuid": cuid,
  579. "ctp": 1,
  580. "lan": "zh",
  581. "spd": spd,
  582. "pit": pit,
  583. "vol": vol,
  584. "per": per,
  585. },
  586. headers={"Content-Type": "application/x-www-form-urlencoded"},
  587. timeout=15,
  588. )
  589. except Exception as e:
  590. return {'detail': f'调用百度 TTS 接口失败: {e}'}, 502
  591. content_type = resp.headers.get("Content-Type", "")
  592. if content_type.startswith("application/json") or content_type.startswith("text/"):
  593. # 百度错误时会返回 JSON 文本
  594. try:
  595. err_data = resp.json()
  596. except Exception:
  597. err_data = {"raw": resp.text}
  598. return {'detail': f'百度 TTS 返回错误: {err_data}'}, 502
  599. audio_bytes = resp.content
  600. audio_base64 = base64.b64encode(audio_bytes).decode('ascii')
  601. return jsonify({
  602. "audio_base64": audio_base64,
  603. "format": "mp3",
  604. })
  605. @app.route('/knowledge_graph')
  606. @login_required
  607. def knowledge_graph():
  608. """知识图谱可视化"""
  609. rels = KnowledgeRelation.query.limit(50).all()
  610. nodes, links = set(), []
  611. for r in rels:
  612. nodes.add(r.subject)
  613. nodes.add(r.obj)
  614. links.append({'source': r.subject, 'target': r.obj, 'value': r.predicate})
  615. graph_data = {
  616. 'nodes': [{'name': n} for n in nodes],
  617. 'links': links
  618. }
  619. return render_template('knowledge_graph.html', graph_data=graph_data)
  620. @app.route('/api/ask', methods=['POST'])
  621. @login_required
  622. def api_ask():
  623. """智能答疑 API:意图识别 -> 图谱检索 -> 大模型扩充 -> 返回文本(供数字人展示)"""
  624. data = request.get_json() or {}
  625. question = (data.get('question') or '').strip()
  626. if not question:
  627. return {'ok': False, 'error': '请输入问题'}
  628. # 1. 图谱检索
  629. rels = _search_knowledge_graph(question)
  630. # 2. 大模型扩充(可替换为真实 API 调用)
  631. answer = _expand_with_llm(question, rels)
  632. return {'ok': True, 'answer': answer}
  633. @app.route('/analysis')
  634. @login_required
  635. def analysis():
  636. """学情分析:基于答题记录生成知识点掌握情况"""
  637. user_id = session['user_id']
  638. proficiency = _get_user_proficiency(user_id)
  639. chart_data = [{'name': k, 'value': v} for k, v in proficiency.items()] if proficiency else []
  640. return render_template(
  641. 'dashboard.html', active_page='analysis',
  642. questions=None, training_result=None, chart_data=chart_data or []
  643. )
  644. # -----------------------------
  645. # API v1: 给前端(Vite/React)调用的 JSON 接口
  646. # -----------------------------
  647. def _api_require_login():
  648. user_id = session.get('user_id')
  649. if not user_id:
  650. return None, ({'ok': False, 'error': '未登录'}, 401)
  651. return int(user_id), None
  652. @app.route('/api/v1/auth/login', methods=['POST'])
  653. def api_v1_login():
  654. """
  655. 前端登录接口:POST JSON { username, password }
  656. 成功后写入 session,并返回前端期望的结构:
  657. { access_token, user_id, username, role }
  658. """
  659. data = request.get_json(silent=True) or {}
  660. username = (data.get('username') or '').strip()
  661. password = (data.get('password') or '').strip()
  662. if not username or not password:
  663. return {'detail': '用户名或密码不能为空'}, 400
  664. user = User.query.filter_by(username=username).first()
  665. if not user or not user.check_password(password):
  666. return {'detail': '用户名或密码错误'}, 401
  667. session['user_id'] = user.id
  668. session['username'] = user.username
  669. # 这里 access_token 只是占位,当前后端通过 Session 识别用户
  670. return {
  671. 'access_token': 'session',
  672. 'user_id': user.id,
  673. 'username': user.username,
  674. 'role': user.role,
  675. }
  676. @app.route('/api/v1/auth/register', methods=['POST'])
  677. def api_v1_register():
  678. """
  679. 前端注册接口:POST JSON { username, password, role? }
  680. 创建用户并直接登录,返回 { access_token, user_id, username, role }
  681. """
  682. data = request.get_json(silent=True) or {}
  683. username = (data.get('username') or '').strip()
  684. password = (data.get('password') or '').strip()
  685. role = (data.get('role') or 'student').strip() or 'student'
  686. if not username or not password:
  687. return {'detail': '用户名和密码不能为空'}, 400
  688. if User.query.filter_by(username=username).first():
  689. return {'detail': '用户名已存在'}, 400
  690. user = User(username=username, role=role)
  691. user.set_password(password)
  692. db.session.add(user)
  693. db.session.commit()
  694. session['user_id'] = user.id
  695. session['username'] = user.username
  696. return {
  697. 'access_token': 'session',
  698. 'user_id': user.id,
  699. 'username': user.username,
  700. 'role': user.role,
  701. }, 201
  702. @app.route('/api/v1/auth/logout', methods=['POST'])
  703. def api_v1_logout():
  704. session.clear()
  705. return {'ok': True}
  706. @app.route('/api/v1/auth/me', methods=['GET'])
  707. def api_v1_me():
  708. user_id, err = _api_require_login()
  709. if err:
  710. return err
  711. user = User.query.get(user_id)
  712. if not user:
  713. session.clear()
  714. return {'detail': '用户不存在'}, 401
  715. return {'user_id': user.id, 'username': user.username, 'role': user.role}
  716. @app.route('/api/v1/users/me', methods=['GET'])
  717. def api_v1_users_me_get():
  718. """
  719. 个人信息页:获取当前登录用户的详细资料。
  720. 返回字段与 FastAPI /api/v1/users/me 保持一致,便于前端统一使用。
  721. """
  722. user_id, err = _api_require_login()
  723. if err:
  724. return err
  725. user = User.query.get(user_id)
  726. if not user:
  727. session.clear()
  728. return {'detail': '用户不存在'}, 401
  729. return {
  730. 'id': user.id,
  731. 'username': user.username,
  732. 'role': user.role,
  733. 'full_name': user.full_name,
  734. 'student_id': user.student_id,
  735. 'class_name': user.class_name,
  736. 'grade': user.grade,
  737. 'email': user.email,
  738. 'phone': user.phone,
  739. 'created_at': user.created_at.isoformat() if user.created_at else None,
  740. }
  741. @app.route('/api/v1/users/me', methods=['PUT'])
  742. def api_v1_users_me_update():
  743. """
  744. 个人信息页:更新当前登录用户的个人资料。
  745. 接收可选字段:full_name, student_id, class_name, grade, email, phone。
  746. """
  747. user_id, err = _api_require_login()
  748. if err:
  749. return err
  750. user = User.query.get(user_id)
  751. if not user:
  752. session.clear()
  753. return {'detail': '用户不存在'}, 401
  754. data = request.get_json(silent=True) or {}
  755. updatable_fields = ['full_name', 'student_id', 'class_name', 'grade', 'email', 'phone']
  756. for field in updatable_fields:
  757. if field in data:
  758. value = data.get(field)
  759. if isinstance(value, str):
  760. value = value.strip()
  761. setattr(user, field, value)
  762. try:
  763. db.session.add(user)
  764. db.session.commit()
  765. except Exception as e:
  766. db.session.rollback()
  767. return {'detail': f'更新失败: {e}'}, 500
  768. return {
  769. 'id': user.id,
  770. 'username': user.username,
  771. 'role': user.role,
  772. 'full_name': user.full_name,
  773. 'student_id': user.student_id,
  774. 'class_name': user.class_name,
  775. 'grade': user.grade,
  776. 'email': user.email,
  777. 'phone': user.phone,
  778. 'created_at': user.created_at.isoformat() if user.created_at else None,
  779. }
  780. @app.route('/api/v1/recommendation/proficiency', methods=['GET'])
  781. def api_v1_proficiency():
  782. user_id, err = _api_require_login()
  783. if err:
  784. return err
  785. proficiency = _get_user_proficiency(user_id)
  786. # 前端直接期望拿到 { 知识点: 正确率 } 这样的字典
  787. return proficiency or {}
  788. @app.route('/api/v1/recommendation/wrong_questions', methods=['GET'])
  789. def api_v1_wrong_questions():
  790. user_id, err = _api_require_login()
  791. if err:
  792. return err
  793. wrong_ids = _get_wrong_question_ids(user_id, limit=50)
  794. if not wrong_ids:
  795. return []
  796. questions = Question.query.filter(Question.id.in_(wrong_ids)).all()
  797. id_order = {qid: i for i, qid in enumerate(wrong_ids)}
  798. questions = sorted(questions, key=lambda q: id_order.get(q.id, 999))
  799. return [
  800. {
  801. 'id': q.id,
  802. 'content': q.content,
  803. 'option_a': q.option_a,
  804. 'option_b': q.option_b,
  805. 'option_c': q.option_c,
  806. 'option_d': q.option_d,
  807. }
  808. for q in questions
  809. ]
  810. @app.route('/api/v1/recommendation/recommended_questions', methods=['GET'])
  811. def api_v1_recommended_questions():
  812. user_id, err = _api_require_login()
  813. if err:
  814. return err
  815. qs = _get_recommended_questions(user_id, count=5) or []
  816. return [
  817. {
  818. 'id': q.id,
  819. 'content': q.content,
  820. 'option_a': q.option_a,
  821. 'option_b': q.option_b,
  822. 'option_c': q.option_c,
  823. 'option_d': q.option_d,
  824. }
  825. for q in qs
  826. ]
  827. @app.route('/api/v1/recommendation/daily_practice', methods=['GET'])
  828. def api_v1_daily_practice():
  829. """每日一练:按弱点权重生成今日推荐题,若今日已有则直接返回"""
  830. user_id, err = _api_require_login()
  831. if err:
  832. return err
  833. count = request.args.get('count', 10, type=int)
  834. try:
  835. qs = get_or_generate_daily_recommendations(db.session, user_id, count=count)
  836. except Exception as e:
  837. print(f"[WARN] get_or_generate_daily_recommendations failed: {e}")
  838. qs = _get_recommended_questions(user_id, count=count) or []
  839. return [
  840. {
  841. 'id': q.id,
  842. 'content': q.content,
  843. 'option_a': q.option_a,
  844. 'option_b': q.option_b,
  845. 'option_c': q.option_c,
  846. 'option_d': q.option_d,
  847. 'knowledge_point': getattr(q, 'knowledge_point', None),
  848. }
  849. for q in qs
  850. ]
  851. @app.route('/api/v1/recommendation/mastery_matrix', methods=['GET'])
  852. def api_v1_mastery_matrix():
  853. """知识点掌握度矩阵:用于前端颜色分级、雷达图等可视化"""
  854. user_id, err = _api_require_login()
  855. if err:
  856. return err
  857. try:
  858. return get_mastery_matrix(db.session, user_id)
  859. except Exception as e:
  860. print(f"[WARN] get_mastery_matrix failed: {e}")
  861. return []
  862. @app.route('/api/v1/recommendation/motivation_summary', methods=['GET'])
  863. def api_v1_motivation_summary():
  864. """激励信息汇总:连续打卡天数、全绿模块、章节平均掌握度"""
  865. user_id, err = _api_require_login()
  866. if err:
  867. return err
  868. try:
  869. return get_motivation_summary(db.session, user_id)
  870. except Exception as e:
  871. print(f"[WARN] get_motivation_summary failed: {e}")
  872. return {}
  873. @app.route('/api/v1/recommendation/question_analysis', methods=['GET'])
  874. def api_v1_question_analysis():
  875. """题目解析:从 chemistry_question_bank.analysis 读取。"""
  876. user_id, err = _api_require_login()
  877. if err:
  878. return err
  879. _ = user_id
  880. question_id = request.args.get('question_id', type=int)
  881. if not question_id:
  882. return {'detail': 'question_id 不能为空'}, 400
  883. try:
  884. analysis = get_question_analysis(db.session, question_id)
  885. return {'question_id': question_id, 'analysis': analysis}
  886. except Exception as e:
  887. print(f"[WARN] get_question_analysis failed: {e}")
  888. return {'question_id': question_id, 'analysis': '暂无解析'}
  889. @app.route('/api/v1/recommendation/diagnostic_questions', methods=['GET'])
  890. def api_v1_diagnostic_questions():
  891. """诊断测评:覆盖广、难度适中(用于弱点更新)"""
  892. user_id, err = _api_require_login()
  893. if err:
  894. return err
  895. count = request.args.get('count', 10, type=int)
  896. try:
  897. qs = get_diagnostic_questions(db.session, user_id, count=count, session_kind='diagnostic')
  898. except Exception as e:
  899. print(f"[WARN] get_diagnostic_questions(diagnostic) failed: {e}")
  900. qs = []
  901. return [
  902. {
  903. 'id': q.id,
  904. 'content': q.content,
  905. 'option_a': q.option_a,
  906. 'option_b': q.option_b,
  907. 'option_c': q.option_c,
  908. 'option_d': q.option_d,
  909. 'knowledge_point': getattr(q, 'knowledge_point', None),
  910. }
  911. for q in qs
  912. ]
  913. @app.route('/api/v1/recommendation/re_evaluate_questions', methods=['GET'])
  914. def api_v1_re_evaluate_questions():
  915. """再测评:在测评后/训练后重新抽取题目"""
  916. user_id, err = _api_require_login()
  917. if err:
  918. return err
  919. count = request.args.get('count', 10, type=int)
  920. try:
  921. qs = get_diagnostic_questions(db.session, user_id, count=count, session_kind='re_evaluate')
  922. except Exception as e:
  923. print(f"[WARN] get_diagnostic_questions(re_evaluate) failed: {e}")
  924. qs = []
  925. return [
  926. {
  927. 'id': q.id,
  928. 'content': q.content,
  929. 'option_a': q.option_a,
  930. 'option_b': q.option_b,
  931. 'option_c': q.option_c,
  932. 'option_d': q.option_d,
  933. 'knowledge_point': getattr(q, 'knowledge_point', None),
  934. }
  935. for q in qs
  936. ]
  937. def _api_submit_diagnostic_like(session_kind: str = "diagnostic"):
  938. """
  939. 诊断/再测评批量提交:
  940. - 写入 answer_records
  941. - 更新掌握度
  942. - 生成(或复用)每日一练
  943. """
  944. user_id, err = _api_require_login()
  945. if err:
  946. return err
  947. data = request.get_json(silent=True) or {}
  948. answers = data.get('answers')
  949. if not isinstance(answers, list):
  950. return {'detail': '参数格式错误'}, 400
  951. train_count = data.get('train_count', 10)
  952. regenerate_daily = bool(data.get('regenerate_daily', False))
  953. include_diagnostic_in_mastery = bool(data.get('include_diagnostic_in_mastery', False))
  954. correct_count = 0
  955. total = 0
  956. processed_answers = []
  957. for item in answers:
  958. try:
  959. qid = int(item.get('question_id'))
  960. except (TypeError, ValueError):
  961. continue
  962. selected = (item.get('selected_option') or '').strip()
  963. if not selected:
  964. continue
  965. question = Question.query.get(qid)
  966. if not question:
  967. continue
  968. is_correct = (selected == question.correct_option)
  969. total += 1
  970. if is_correct:
  971. correct_count += 1
  972. db.session.add(AnswerRecord(
  973. user_id=user_id,
  974. question_id=question.id,
  975. selected_option=selected,
  976. is_correct=is_correct,
  977. ))
  978. processed_answers.append({"question_id": qid, "selected_option": selected})
  979. if total == 0:
  980. return {'ok': False, 'detail': '未提交有效答题'}, 400
  981. db.session.commit()
  982. try:
  983. update_mastery_from_answers(
  984. db.session,
  985. user_id,
  986. processed_answers,
  987. session_kind=session_kind,
  988. include_diagnostic_in_mastery=include_diagnostic_in_mastery,
  989. )
  990. db.session.commit()
  991. except Exception as e:
  992. print(f"[WARN] update_mastery_from_answers in diagnostic submit failed: {e}")
  993. if regenerate_daily:
  994. try:
  995. from backend.models import DailyRecommendation
  996. daily_query = db.session.query(DailyRecommendation).filter(
  997. DailyRecommendation.user_id == user_id,
  998. DailyRecommendation.recommend_date == date.today(),
  999. )
  1000. if session_kind == "diagnostic":
  1001. # 诊断提交后按最新测评结果重算“今日10题”,清空今日旧推荐(含已完成)。
  1002. daily_query.delete(synchronize_session=False)
  1003. else:
  1004. # 再测评保留已完成记录,仅替换未完成推荐,避免影响已完成闭环。
  1005. daily_query.filter(DailyRecommendation.is_done == 0).delete(synchronize_session=False)
  1006. db.session.commit()
  1007. except Exception as e:
  1008. print(f"[WARN] regenerate_daily delete failed: {e}")
  1009. try:
  1010. daily_qs = get_or_generate_daily_recommendations(db.session, user_id, count=int(train_count))
  1011. except Exception as e:
  1012. print(f"[WARN] get_or_generate_daily_recommendations in diagnostic submit failed: {e}")
  1013. daily_qs = []
  1014. accuracy = int(round(correct_count / total * 100)) if total else 0
  1015. return {
  1016. 'ok': True,
  1017. 'diagnostic': {'total': total, 'correct': correct_count, 'accuracy': accuracy},
  1018. 'daily_practice': [
  1019. {
  1020. 'id': q.id,
  1021. 'content': q.content,
  1022. 'option_a': q.option_a,
  1023. 'option_b': q.option_b,
  1024. 'option_c': q.option_c,
  1025. 'option_d': q.option_d,
  1026. 'knowledge_point': getattr(q, 'knowledge_point', None),
  1027. }
  1028. for q in daily_qs
  1029. ],
  1030. }
  1031. @app.route('/api/v1/recommendation/diagnostic_submit_batch', methods=['POST'])
  1032. def api_v1_diagnostic_submit_batch():
  1033. return _api_submit_diagnostic_like(session_kind="diagnostic")
  1034. @app.route('/api/v1/recommendation/re_evaluate_submit_batch', methods=['POST'])
  1035. def api_v1_re_evaluate_submit_batch():
  1036. return _api_submit_diagnostic_like(session_kind="re_evaluate")
  1037. @app.route('/api/v1/teacher/class_report', methods=['GET'])
  1038. def api_v1_teacher_class_report():
  1039. """
  1040. 教师端班级学情报表:
  1041. - 仅教师可访问
  1042. - 前端对齐路径:/api/v1/teacher/class_report
  1043. """
  1044. user_id, err = _api_require_login()
  1045. if err:
  1046. return err
  1047. user = User.query.get(user_id)
  1048. if not user:
  1049. session.clear()
  1050. return {'detail': '用户不存在'}, 401
  1051. if user.role != 'teacher':
  1052. return {'detail': '仅教师可访问该接口'}, 403
  1053. days = request.args.get('days', 14, type=int)
  1054. weak_top = request.args.get('weak_top', 5, type=int)
  1055. wrong_top = request.args.get('wrong_top', 10, type=int)
  1056. try:
  1057. return get_teacher_class_report(
  1058. db.session,
  1059. teacher_id=user_id,
  1060. days=days,
  1061. weak_top=weak_top,
  1062. wrong_top=wrong_top,
  1063. )
  1064. except ValueError as e:
  1065. return {'detail': str(e)}, 400
  1066. except Exception as e:
  1067. print(f"[WARN] get_teacher_class_report failed: {e}")
  1068. return {'detail': '获取教师班级报表失败'}, 500
  1069. @app.route('/api/v1/recommendation/submit_batch', methods=['POST'])
  1070. def api_v1_submit_batch():
  1071. """
  1072. 批量提交答题结果:计算本次正确率并记录到 answer_records
  1073. 请求体(前端目前会多包一层 answers):
  1074. { "answers": [ { "question_id": 1, "selected_option": "A" }, ... ] }
  1075. 或 { "answers": { "answers": [ ... ] } }
  1076. """
  1077. user_id, err = _api_require_login()
  1078. if err:
  1079. return err
  1080. data = request.get_json(silent=True) or {}
  1081. answers = data.get('answers')
  1082. # 兼容前端传入 {answers: {answers: [...]}}
  1083. if isinstance(answers, dict) and 'answers' in answers:
  1084. answers = answers['answers']
  1085. if not isinstance(answers, list):
  1086. return {'detail': '参数格式错误'}, 400
  1087. correct_count = 0
  1088. total = 0
  1089. processed_answers = []
  1090. for item in answers:
  1091. try:
  1092. qid = int(item.get('question_id'))
  1093. except (TypeError, ValueError):
  1094. continue
  1095. selected = (item.get('selected_option') or '').strip()
  1096. if not selected:
  1097. continue
  1098. question = Question.query.get(qid)
  1099. if not question:
  1100. continue
  1101. is_correct = (selected == question.correct_option)
  1102. total += 1
  1103. if is_correct:
  1104. correct_count += 1
  1105. db.session.add(AnswerRecord(
  1106. user_id=user_id,
  1107. question_id=question.id,
  1108. selected_option=selected,
  1109. is_correct=is_correct,
  1110. ))
  1111. processed_answers.append({"question_id": qid, "selected_option": selected})
  1112. if total > 0:
  1113. try:
  1114. update_mastery_from_answers(
  1115. db.session, user_id, processed_answers, session_kind="practice"
  1116. )
  1117. mark_daily_recommendations_done(
  1118. db.session, user_id,
  1119. [a["question_id"] for a in processed_answers],
  1120. today=date.today(),
  1121. )
  1122. except Exception as e:
  1123. print(f"[WARN] update_mastery_from_answers failed: {e}")
  1124. db.session.commit()
  1125. accuracy = int(round(correct_count / total * 100)) if total else 0
  1126. return {'total': total, 'correct': correct_count, 'accuracy': accuracy}
  1127. # 节点展示名仅用:name/formula/symbol/description/text/type/category/state/chunk,不用 id/标签
  1128. # 视为“无意义”的展示名(纯标签或占位),不展示
  1129. _BAD_DISPLAY_NAMES = frozenset({
  1130. "element", "chunk", "document", "__entity__", "nodea", "nodeb",
  1131. "entity", "doc", "kgbuilder", "__kgbuilder__"
  1132. })
  1133. @app.route('/api/v1/knowledge/graph', methods=['GET'])
  1134. def api_v1_knowledge_graph():
  1135. """知识图谱 JSON 版本,优先从 Neo4j 读取,失败时回退到内置 MySQL 表。
  1136. 展示名严格使用 formula/description/symbol/name/text/type/category/state/chunk,不显示 id 或标签。"""
  1137. nodes, links = set(), []
  1138. limit = request.args.get("limit", 2000, type=int)
  1139. limit = min(max(limit, 100), 10000)
  1140. # 1)优先从 Neo4j 查询
  1141. try:
  1142. with neo4j_driver.session() as session:
  1143. # 展示名仅从具体属性取:name, formula, symbol, description, text, type, category, state, chunk(长文本截断)
  1144. # 不用 id、不用 labels,避免出现 elementId 或 "Element"/"Chunk"
  1145. part_s = "coalesce(" + ", ".join([
  1146. "s.name", "s.formula", "s.symbol", "s.description",
  1147. "CASE WHEN s.text IS NOT NULL AND size(s.text) > 0 THEN substring(s.text, 0, 80) + '…' ELSE NULL END",
  1148. "s.type", "s.category", "s.state",
  1149. "CASE WHEN s.chunk IS NOT NULL AND size(toString(s.chunk)) > 0 THEN substring(toString(s.chunk), 0, 80) + '…' ELSE NULL END",
  1150. ]) + ")"
  1151. part_o = "coalesce(" + ", ".join([
  1152. "o.name", "o.formula", "o.symbol", "o.description",
  1153. "CASE WHEN o.text IS NOT NULL AND size(o.text) > 0 THEN substring(o.text, 0, 80) + '…' ELSE NULL END",
  1154. "o.type", "o.category", "o.state",
  1155. "CASE WHEN o.chunk IS NOT NULL AND size(toString(o.chunk)) > 0 THEN substring(toString(o.chunk), 0, 80) + '…' ELSE NULL END",
  1156. ]) + ")"
  1157. cypher = f"""
  1158. MATCH (s)-[r]->(o)
  1159. WITH s, r, o, {part_s} AS subjDisplay, {part_o} AS objDisplay
  1160. WHERE subjDisplay IS NOT NULL AND objDisplay IS NOT NULL
  1161. WITH coalesce(toString(subjDisplay), '') AS subject,
  1162. coalesce(r.name, toString(type(r))) AS predicate,
  1163. coalesce(toString(objDisplay), '') AS obj
  1164. WHERE subject <> '' AND obj <> ''
  1165. RETURN DISTINCT subject, predicate, obj
  1166. LIMIT $limit
  1167. """
  1168. records = session.run(cypher, limit=limit).data()
  1169. for rec in records:
  1170. subject = (rec.get("subject") or "").strip()
  1171. obj = (rec.get("obj") or "").strip()
  1172. predicate = (rec.get("predicate") or "关联").strip()
  1173. if not subject or not obj:
  1174. continue
  1175. # 排除像 elementId 的字符串(含 : 且含 - 且较长)
  1176. def looks_like_id(s):
  1177. if len(s) < 20:
  1178. return False
  1179. return ":" in s and "-" in s
  1180. if looks_like_id(subject) or looks_like_id(obj):
  1181. continue
  1182. # 排除纯标签/占位名
  1183. low_subj, low_obj = subject.lower(), obj.lower()
  1184. if low_subj in _BAD_DISPLAY_NAMES or low_obj in _BAD_DISPLAY_NAMES:
  1185. continue
  1186. nodes.add(subject)
  1187. nodes.add(obj)
  1188. links.append({"source": subject, "target": obj, "value": predicate})
  1189. except Exception as e:
  1190. # Neo4j 不可用时打印告警,继续使用 MySQL 数据
  1191. print(f"[WARN] Neo4j knowledge graph query failed: {e}")
  1192. # 2)如果 Neo4j 没有查到有效关系,则回退到原来的 MySQL 表
  1193. if not links:
  1194. rels = KnowledgeRelation.query.limit(50).all()
  1195. for r in rels:
  1196. nodes.add(r.subject)
  1197. nodes.add(r.obj)
  1198. links.append({"source": r.subject, "target": r.obj, "value": r.predicate})
  1199. return {
  1200. "nodes": [{"name": n} for n in nodes],
  1201. "links": links,
  1202. }
  1203. @app.route('/api/v1/qa/ask', methods=['POST'])
  1204. def api_v1_qa_ask():
  1205. """知识点问答 JSON 接口,对齐前端 qa.ask"""
  1206. data = request.get_json(silent=True) or {}
  1207. question = (data.get('question') or '').strip()
  1208. if not question:
  1209. return {'detail': '请输入问题'}, 400
  1210. # 先进行问题解析,提取关键词与检索词
  1211. try:
  1212. analysis = analyze_question(question)
  1213. except Exception:
  1214. analysis = {"clean_text": question, "kg_terms": [question]}
  1215. kg_terms = analysis.get("kg_terms") or [analysis.get("clean_text") or question]
  1216. # 基于解析得到的多个检索词进行图谱检索,并去重合并
  1217. triples = []
  1218. seen = set()
  1219. for term in kg_terms:
  1220. results = _search_knowledge_graph(term)
  1221. for r in results:
  1222. key = (r.get("subject"), r.get("predicate"), r.get("obj"))
  1223. if key in seen:
  1224. continue
  1225. seen.add(key)
  1226. triples.append(r)
  1227. answer = _expand_with_llm(question, triples, meta=analysis)
  1228. return {'answer': answer}
  1229. if __name__ == '__main__':
  1230. app.run(debug=True, port=5000)