| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400 |
- from flask import Flask, render_template, request, redirect, url_for, flash, session, jsonify
- from flask_sqlalchemy import SQLAlchemy
- from werkzeug.security import generate_password_hash, check_password_hash
- from datetime import datetime, date
- import base64
- import time
- import requests
- import pymysql
- from backend.config import get_settings
- from backend.database import engine, Base, ensure_user_profile_columns
- import backend.models # noqa: F401 - 确保 backend 模型注册到 Base.metadata,供 create_all 创建表
- from neo4j import GraphDatabase
- from backend.services.llm_service import expand_with_llm
- from backend.services.question_parser import analyze_question
- from backend.services.mastery_service import update_mastery_from_answers, mark_daily_recommendations_done
- from backend.services.recommendation import (
- get_or_generate_daily_recommendations,
- get_mastery_matrix,
- get_motivation_summary,
- get_question_analysis,
- get_diagnostic_questions,
- )
- from backend.services.teacher_report_service import get_teacher_class_report
- # 修正 MySQL 驱动兼容性问题(如果遇到报错可以尝试加上)
- pymysql.install_as_MySQLdb()
- app = Flask(__name__)
- settings = get_settings()
- # --- 数据库配置 ---
- # 格式:mysql+用户名:密码@localhost:3306/数据库名
- # 当前使用 MySQL 用户 maple@localhost,密码 123456,数据库 ai_system
- app.config['SQLALCHEMY_DATABASE_URI'] = settings.MYSQL_URI
- app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
- app.config['SECRET_KEY'] = 'dev-key-please-change-in-production' # 用于Session加密
- """数据库初始化"""
- db = SQLAlchemy(app)
- # --- Neo4j 配置(用于知识图谱) ---
- neo4j_driver = GraphDatabase.driver(
- settings.NEO4J_URI,
- auth=(settings.NEO4J_USER, settings.NEO4J_PASSWORD),
- )
- # --- 百度语音合成(TTS)全局配置与简单缓存 ---
- _BAIDU_TTS_TOKEN_CACHE = {
- "access_token": None,
- "expires_at": 0,
- }
- def _get_baidu_tts_access_token():
- """
- 获取百度语音合成 access_token,简单内存缓存,避免每次都去请求。
- """
- from backend.config import get_settings as _get_settings
- settings = _get_settings()
- api_key = settings.BAIDU_TTS_API_KEY
- secret_key = settings.BAIDU_TTS_SECRET_KEY
- if not api_key or not secret_key:
- raise RuntimeError("百度语音合成 API Key/Secret 未配置")
- now = time.time()
- if (
- _BAIDU_TTS_TOKEN_CACHE["access_token"]
- and now < _BAIDU_TTS_TOKEN_CACHE["expires_at"] - 60
- ):
- return _BAIDU_TTS_TOKEN_CACHE["access_token"]
- token_url = "https://aip.baidubce.com/oauth/2.0/token"
- resp = requests.get(
- token_url,
- params={
- "grant_type": "client_credentials",
- "client_id": api_key,
- "client_secret": secret_key,
- },
- timeout=10,
- )
- data = resp.json()
- access_token = data.get("access_token")
- if not access_token:
- raise RuntimeError(f"获取百度 TTS access_token 失败: {data}")
- expires_in = int(data.get("expires_in", 2592000))
- _BAIDU_TTS_TOKEN_CACHE["access_token"] = access_token
- _BAIDU_TTS_TOKEN_CACHE["expires_at"] = now + expires_in
- return access_token
- # --- 定义数据模型 ---
- class User(db.Model):
- """用户表,对应初中化学教学系统中的学生/教师账号"""
- __tablename__ = 'users'
- id = db.Column(db.Integer, primary_key=True)
- username = db.Column(db.String(80), unique=True, nullable=False)
- password_hash = db.Column(db.String(255), nullable=False)
- role = db.Column(db.String(20), default='student')
- # 个人信息字段(与 FastAPI 后端对齐,均为可选)
- full_name = db.Column(db.String(50), nullable=True) # 真实姓名
- student_id = db.Column(db.String(50), nullable=True) # 学号
- class_name = db.Column(db.String(100), nullable=True) # 班级,如“初二(3)班”
- grade = db.Column(db.String(20), nullable=True) # 年级,如“初一”“初二”
- email = db.Column(db.String(120), nullable=True)
- phone = db.Column(db.String(20), nullable=True)
- created_at = db.Column(db.DateTime, default=datetime.utcnow)
- def set_password(self, password):
- self.password_hash = generate_password_hash(password)
- def check_password(self, password):
- return check_password_hash(self.password_hash, password)
- class Question(db.Model):
- """化学每日训练题库"""
- __tablename__ = 'questions'
- id = db.Column(db.Integer, primary_key=True)
- content = db.Column(db.Text, nullable=False) # 题干
- option_a = db.Column(db.String(255), nullable=False)
- option_b = db.Column(db.String(255), nullable=False)
- option_c = db.Column(db.String(255), nullable=False)
- option_d = db.Column(db.String(255), nullable=False)
- correct_option = db.Column(db.String(1), nullable=False) # A/B/C/D
- knowledge_point = db.Column(db.String(100), nullable=True) # 所属知识点,如“电解质”
- difficulty = db.Column(db.Integer, default=1) # 1-5 难度
- created_at = db.Column(db.DateTime, default=datetime.utcnow)
- class AnswerRecord(db.Model):
- """答题记录,用于学情分析与错题集"""
- __tablename__ = 'answer_records'
- id = db.Column(db.Integer, primary_key=True)
- user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
- question_id = db.Column(db.Integer, db.ForeignKey('questions.id'), nullable=False)
- selected_option = db.Column(db.String(1), nullable=False)
- is_correct = db.Column(db.Boolean, default=False)
- answered_at = db.Column(db.DateTime, default=datetime.utcnow)
- user = db.relationship('User', backref=db.backref('answer_records', lazy='dynamic'))
- question = db.relationship('Question', backref=db.backref('answer_records', lazy='dynamic'))
- class KnowledgePoint(db.Model):
- """知识图谱:知识点实体(初中化学)"""
- __tablename__ = 'knowledge_points'
- id = db.Column(db.Integer, primary_key=True)
- name = db.Column(db.String(100), unique=True, nullable=False) # 如:电解质
- description = db.Column(db.Text, nullable=True) # 知识点描述
- chapter = db.Column(db.String(80), nullable=True) # 所属章节
- created_at = db.Column(db.DateTime, default=datetime.utcnow)
- class KnowledgeRelation(db.Model):
- """知识图谱:三元组关系(主体-谓词-客体)"""
- __tablename__ = 'knowledge_relations'
- id = db.Column(db.Integer, primary_key=True)
- subject = db.Column(db.String(120), nullable=False)
- predicate = db.Column(db.String(80), nullable=False)
- obj = db.Column(db.String(120), nullable=False)
- created_at = db.Column(db.DateTime, default=datetime.utcnow)
- class CurriculumTopic(db.Model):
- """课程主题/知识主题,如:物质分类、原子结构等"""
- __tablename__ = 'curriculum_topics'
- id = db.Column(db.Integer, primary_key=True)
- name = db.Column(db.String(50), unique=True, nullable=False)
- phase = db.Column(db.String(20), default='初中') # 学段:初中/高中等
- chapter = db.Column(db.String(80), nullable=True) # 教材章节
- description = db.Column(db.Text, nullable=True)
- class ChemistryQuestionBank(db.Model):
- """多题型统一题库,支撑 AI 数字人教学"""
- __tablename__ = 'chemistry_question_bank'
- question_id = db.Column(db.Integer, primary_key=True)
- type = db.Column(db.Enum('choice', 'equation', 'experiment', 'calculation',
- 'inference', 'diagram', name='question_type'),
- nullable=False)
- difficulty = db.Column(db.Enum('easy', 'medium', 'hard', name='question_difficulty'),
- nullable=False, default='easy')
- topic = db.Column(
- db.String(50),
- db.ForeignKey('curriculum_topics.name'),
- nullable=True,
- comment='如:物质分类、酸碱盐、金属等'
- )
- unit_no = db.Column(
- db.Integer,
- nullable=True,
- comment='教材单元编号,如 1-12'
- )
- knowledge_tags = db.Column(
- db.String(255),
- nullable=True,
- comment='知识点标签,逗号分隔(与 schema.sql 一致)',
- )
- content = db.Column(db.Text, nullable=False, comment='题干,可包含 HTML/Markdown')
- answer_schema = db.Column(
- db.JSON,
- nullable=False,
- comment='结构化答案:选项/方程式/步骤/推断链等'
- )
- hint = db.Column(db.Text, nullable=True)
- created_at = db.Column(db.DateTime, default=datetime.utcnow)
- topic_rel = db.relationship(
- 'CurriculumTopic',
- primaryjoin='ChemistryQuestionBank.topic == foreign(CurriculumTopic.name)',
- # 注意:此处不能使用 lazy='dynamic',因为 SQLAlchemy 会将该关系
- # 视为一对一 / many-to-one(外键指向唯一列),dynamic 只支持一对多集合关系。
- # 使用默认的 select 加载方式即可。
- backref=db.backref('questions', lazy='select')
- )
- with app.app_context():
- # 确保定义的表在数据库中存在(需要先手动创建 ai_system 数据库)
- try:
- db.create_all()
- # 创建 backend 模型对应的表(qa_sessions、student_profiles 等),
- # 即使只启动 Flask 不启动 FastAPI,这些表也会被自动创建
- Base.metadata.create_all(bind=engine)
- # 补充 FastAPI 侧新增的用户个人信息字段,避免表结构不一致
- try:
- ensure_user_profile_columns()
- except Exception as e:
- print(f"[WARN] ensure_user_profile_columns() failed: {e}")
- except Exception as e:
- # 开发期常见:MySQL 未启动/账号密码不对/库不存在
- # 不阻塞 Web 服务启动,方便先联调前后端;具体接口用到 DB 时仍会报错并提示。
- print(f"[WARN] db.create_all() failed: {e}")
- # --- 路由功能 ---
- @app.route('/') # 1:根路径直接重定向到登录页
- def index():
- return redirect(url_for('login'))
- @app.route('/login', methods=['GET', 'POST'])
- def login():
- # ... (保持之前的登录逻辑不变) ...
- if request.method == 'POST':
- username = request.form['username']
- password = request.form['password']
- user = User.query.filter_by(username=username).first()
- if user and user.check_password(password):
- session['user_id'] = user.id
- session['username'] = user.username
- flash("登录成功!", "success")
- return redirect(url_for('dashboard')) # 登录成功后跳转到 dashboard
- else:
- flash("用户名或密码错误。", "error")
- return render_template('login.html')
- @app.route('/register', methods=['GET', 'POST'])
- def register():
- # ... (保持之前的注册逻辑不变) ...
- if request.method == 'POST':
- username = request.form['username']
- password = request.form['password']
- if not username or not password:
- flash("用户名和密码不能为空", "error")
- return redirect(url_for('register'))
- if User.query.filter_by(username=username).first():
- flash("用户名已存在,请更换一个。", "error")
- return redirect(url_for('register'))
- new_user = User(username=username)
- new_user.set_password(password)
- try:
- db.session.add(new_user)
- db.session.commit()
- flash("注册成功!请登录。", "success")
- return redirect(url_for('login'))
- except Exception as e:
- db.session.rollback()
- flash(f"发生错误: {e}", "error")
- return render_template('register.html')
- @app.route('/logout')
- def logout():
- session.clear()
- flash("您已退出登录。", "info")
- return redirect(url_for('login'))
- # --- 主系统路由 (需要增加登录保护) ---
- # 定义一个装饰器,用于检查用户是否登录
- from functools import wraps
- def login_required(f):
- @wraps(f)
- def decorated_function(*args, **kwargs):
- if 'user_id' not in session:
- flash("请先登录。", "info")
- return redirect(url_for('login'))
- return f(*args, **kwargs)
- return decorated_function
- @app.route('/dashboard')
- @login_required
- def dashboard():
- return render_template(
- 'dashboard.html', active_page='home',
- questions=None, training_result=None, chart_data=[]
- )
- @app.route('/ai_assistant')
- @login_required # 添加保护
- def ai_assistant():
- return redirect("http://localhost:8880")
- def _get_user_proficiency(user_id):
- """按知识点统计用户熟练度:正确题数/总题数"""
- from sqlalchemy import func, case
- stats = db.session.query(
- Question.knowledge_point,
- func.sum(case((AnswerRecord.is_correct == True, 1), else_=0)).label('correct'),
- func.count(AnswerRecord.id).label('total')
- ).join(AnswerRecord, Question.id == AnswerRecord.question_id).filter(
- AnswerRecord.user_id == user_id,
- Question.knowledge_point.isnot(None),
- Question.knowledge_point != ''
- ).group_by(Question.knowledge_point).all()
- return {s.knowledge_point: int(s.correct / s.total * 100) if s.total else 0 for s in stats}
- def _get_wrong_question_ids(user_id, limit=50):
- """获取用户错题集中的题目ID列表(去重,最近优先)"""
- from sqlalchemy import desc
- records = AnswerRecord.query.filter_by(user_id=user_id, is_correct=False)\
- .order_by(desc(AnswerRecord.answered_at)).all()
- seen = set()
- ids = []
- for r in records:
- if r.question_id not in seen:
- seen.add(r.question_id)
- ids.append(r.question_id)
- if len(ids) >= limit:
- break
- return ids
- def _get_weak_knowledge_points(user_id, top_n=3):
- """获取用户薄弱知识点(熟练度最低的)"""
- prof = _get_user_proficiency(user_id)
- if not prof:
- return []
- sorted_prof = sorted(prof.items(), key=lambda x: x[1])
- return [p[0] for p in sorted_prof[:top_n]]
- def _get_recommended_questions(user_id, count=5):
- """习题测评推荐算法:优先推荐错题相关、薄弱知识点题目"""
- from sqlalchemy.sql import func
- wrong_ids = _get_wrong_question_ids(user_id, limit=20)
- weak_points = _get_weak_knowledge_points(user_id, top_n=3)
- recommended = []
- # 1. 优先从薄弱知识点补充
- if wrong_ids:
- qs = Question.query.filter(Question.id.in_(wrong_ids)).order_by(func.rand()).limit(count).all()
- recommended = [q.id for q in qs]
- # 2. 不足时从错题补充
- exclude_ids = list(set(recommended + wrong_ids))
- if len(recommended) < count and weak_points:
- extra = Question.query.filter(
- Question.knowledge_point.in_(weak_points)
- )
- if exclude_ids:
- extra = extra.filter(~Question.id.in_(exclude_ids))
- extra = extra.order_by(func.rand()).limit(count - len(recommended)).all()
- recommended.extend([q.id for q in extra])
- exclude_ids = list(set(recommended + wrong_ids))
- # 3. 仍不足则随机补充
- if len(recommended) < count:
- fallback = Question.query
- if exclude_ids:
- fallback = fallback.filter(~Question.id.in_(exclude_ids))
- fallback = fallback.order_by(func.rand()).limit(count - len(recommended)).all()
- recommended.extend([q.id for q in fallback])
- if not recommended:
- return []
- return Question.query.filter(Question.id.in_(recommended[:count])).all()
- @app.route('/daily_training', methods=['GET', 'POST'])
- @login_required
- def daily_training():
- from sqlalchemy.sql import func
- if request.method == 'POST':
- question_ids = request.form.getlist('question_ids')
- correct_count = 0
- total = 0
- for qid in question_ids:
- selected = request.form.get(f"q_{qid}")
- if not selected:
- continue
- question = Question.query.get(int(qid))
- if not question:
- continue
- is_correct = (selected == question.correct_option)
- total += 1
- if is_correct:
- correct_count += 1
- record = AnswerRecord(
- user_id=session['user_id'],
- question_id=question.id,
- selected_option=selected,
- is_correct=is_correct
- )
- db.session.add(record)
- if total > 0:
- db.session.commit()
- accuracy = int(round(correct_count / total * 100)) if total else 0
- training_result = {'total': total, 'correct': correct_count, 'accuracy': accuracy}
- # 知识点题目推荐:答题后按推荐算法抽取下一组
- questions = _get_recommended_questions(session['user_id'], count=5)
- return render_template(
- 'dashboard.html', active_page='training',
- questions=questions, training_result=training_result, chart_data=[]
- )
- # GET:优先使用推荐算法,无历史数据时随机出题
- questions = _get_recommended_questions(session['user_id'], count=5)
- if not questions:
- questions = Question.query.order_by(func.rand()).limit(5).all()
- return render_template(
- 'dashboard.html', active_page='training',
- questions=questions, training_result=None, chart_data=[]
- )
- @app.route('/wrong_questions')
- @login_required
- def wrong_questions():
- """错题集:展示用户答错的题目,可重做巩固"""
- wrong_ids = _get_wrong_question_ids(session['user_id'], limit=50)
- questions = Question.query.filter(Question.id.in_(wrong_ids)).all() if wrong_ids else []
- # 保持错题集中的题目顺序
- id_order = {qid: i for i, qid in enumerate(wrong_ids)}
- questions = sorted(questions, key=lambda q: id_order.get(q.id, 999))
- return render_template(
- 'dashboard.html', active_page='wrong_questions',
- questions=questions, training_result=None, chart_data=[]
- )
- @app.route('/knowledge_qa')
- @login_required
- def knowledge_qa():
- """知识点询问:大模型+知识图谱+数字人答疑"""
- return render_template('knowledge_qa.html')
- def _search_knowledge_graph(query_text, limit=15):
- """
- 知识图谱检索(Flask 版):
- 复用当前可视化模块使用的 Neo4j 结构和字段,
- 使用 app.py 中的 neo4j_driver,避免影响 backend.services 里的已有逻辑。
- """
- q = (query_text or "").strip()
- if not q:
- return []
- triples = []
- # 1. 优先使用 Neo4j:与 /api/v1/knowledge/graph 相同的一套节点展示名规则
- try:
- with neo4j_driver.session() as session:
- part_s = "coalesce(" + ", ".join([
- "s.name", "s.formula", "s.symbol", "s.description",
- "CASE WHEN s.text IS NOT NULL AND size(s.text) > 0 THEN substring(s.text, 0, 80) + '…' ELSE NULL END",
- "s.type", "s.category", "s.state",
- "CASE WHEN s.chunk IS NOT NULL AND size(toString(s.chunk)) > 0 THEN substring(toString(s.chunk), 0, 80) + '…' ELSE NULL END",
- ]) + ")"
- part_o = "coalesce(" + ", ".join([
- "o.name", "o.formula", "o.symbol", "o.description",
- "CASE WHEN o.text IS NOT NULL AND size(o.text) > 0 THEN substring(o.text, 0, 80) + '…' ELSE NULL END",
- "o.type", "o.category", "o.state",
- "CASE WHEN o.chunk IS NOT NULL AND size(toString(o.chunk)) > 0 THEN substring(toString(o.chunk), 0, 80) + '…' ELSE NULL END",
- ]) + ")"
- cypher = f"""
- MATCH (s)-[r]->(o)
- WITH s, r, o, {part_s} AS subjDisplay, {part_o} AS objDisplay
- WHERE subjDisplay IS NOT NULL AND objDisplay IS NOT NULL
- WITH
- coalesce(toString(subjDisplay), '') AS subject,
- coalesce(r.name, toString(type(r))) AS predicate,
- coalesce(toString(objDisplay), '') AS obj
- WHERE subject <> '' AND obj <> ''
- AND (
- subject CONTAINS $q OR
- obj CONTAINS $q OR
- predicate CONTAINS $q
- )
- RETURN DISTINCT subject, predicate, obj
- LIMIT $limit
- """
- records = session.run(cypher, q=q, limit=limit).data()
- for rec in records:
- subject = (rec.get("subject") or "").strip()
- obj = (rec.get("obj") or "").strip()
- predicate = (rec.get("predicate") or "关联").strip()
- if not subject or not obj:
- continue
- triples.append({"subject": subject, "predicate": predicate, "obj": obj})
- if triples:
- return triples
- except Exception as e:
- print(f"[WARN] Neo4j _search_knowledge_graph failed: {e}")
- # 2. 回退到原有 MySQL 知识图谱表(保持兼容)
- rels = KnowledgeRelation.query.filter(
- db.or_(
- KnowledgeRelation.subject.contains(q),
- KnowledgeRelation.predicate.contains(q),
- KnowledgeRelation.obj.contains(q),
- )
- ).limit(limit).all()
- if rels:
- for r in rels:
- triples.append(
- {"subject": r.subject, "predicate": r.predicate, "obj": r.obj}
- )
- return triples
- # 3. 若无直接匹配,再按知识点名称检索 MySQL 中的关系
- kps = KnowledgePoint.query.filter(KnowledgePoint.name.contains(q)).limit(5).all()
- for kp in kps:
- rels = (
- KnowledgeRelation.query.filter(
- db.or_(
- KnowledgeRelation.subject.contains(kp.name),
- KnowledgeRelation.obj.contains(kp.name),
- )
- )
- .limit(3)
- .all()
- )
- for r in rels:
- triples.append(
- {"subject": r.subject, "predicate": r.predicate, "obj": r.obj}
- )
- if len(triples) >= limit:
- break
- return triples[:limit]
- def _expand_with_llm(query_text, kg_context, meta=None):
- """大模型扩充回答:复用 backend.services.llm_service 的 RAG + DeepSeek 逻辑"""
- # kg_context 现在优先是 Neo4j/SQL 三元组 dict 列表
- triples = []
- for r in kg_context or []:
- if isinstance(r, dict):
- triples.append(
- {
- "subject": r.get("subject", ""),
- "predicate": r.get("predicate", ""),
- "obj": r.get("obj", ""),
- }
- )
- else:
- # 兼容旧的 SQLAlchemy 模型对象
- triples.append(
- {
- "subject": getattr(r, "subject", ""),
- "predicate": getattr(r, "predicate", ""),
- "obj": getattr(r, "obj", ""),
- }
- )
- if meta is None:
- try:
- meta = analyze_question(query_text)
- except Exception:
- meta = None
- # 优先调用统一的大模型服务(DeepSeek / Qwen 等)
- try:
- return expand_with_llm(query_text, triples, meta=meta)
- except Exception as e:
- print(f"[WARN] expand_with_llm fallback due to error: {e}")
- # 若大模型不可用,退回到本地兜底逻辑
- context_str = "\n".join(
- [f"{t['subject']} {t['predicate']} {t['obj']}" for t in triples]
- )
- if context_str:
- return (
- "根据初中化学知识图谱,相关内容如下:\n\n"
- f"{context_str}\n\n"
- "建议结合教材和习题进一步巩固理解。"
- )
- return (
- f"暂无与「{query_text}」直接相关的图谱条目,"
- "建议从教材相关章节或习题中查找,或尝试更换关键词。"
- )
- @app.route('/api/v1/tts/baidu', methods=['POST'])
- @login_required
- def api_v1_tts_baidu():
- """
- 百度语音合成接口:接收文本,返回 base64 编码的 MP3 音频数据。
- 前端调用示例:
- POST /api/v1/tts/baidu
- { "text": "你好,同学", "spd": 5, "pit": 5, "vol": 5, "per": 5118 }
- """
- data = request.get_json(silent=True) or {}
- text = (data.get('text') or '').strip()
- if not text:
- return {'detail': 'text 不能为空'}, 400
- # 百度 TTS 对 tex 参数长度有严格限制(按字节计数),
- # 日志中出现 "Normal invalid text length" / "tex param err" 即代表超出范围。
- # 这里做一次兜底截断,避免长回答导致整个 TTS 调用失败。
- # 官方常见限制为 1024 字节,这里按 1024 字节做保守截断。
- max_tex_bytes = 1024
- text_bytes = text.encode('utf-8')
- if len(text_bytes) > max_tex_bytes:
- cut = text_bytes[:max_tex_bytes]
- # 避免截断在 UTF-8 多字节字符的中间,向前回退到合法边界
- while cut and (cut[-1] & 0xC0) == 0x80:
- cut = cut[:-1]
- text = cut.decode('utf-8', errors='ignore')
- # 语速、音调、音量、发音人可选;使用百度默认区间 0-15
- spd = int(data.get('spd', 5))
- pit = int(data.get('pit', 5))
- vol = int(data.get('vol', 5))
- per = int(data.get('per', 5118)) # 5118 为度小萌,中文女声,比较自然
- try:
- token = _get_baidu_tts_access_token()
- except Exception as e:
- return {'detail': f'获取百度 TTS token 失败: {e}'}, 500
- settings = get_settings()
- cuid = settings.BAIDU_TTS_APP_ID or 'ai-teaching-system'
- tts_url = "https://tsn.baidu.com/text2audio"
- try:
- resp = requests.post(
- tts_url,
- data={
- "tex": text,
- "tok": token,
- "cuid": cuid,
- "ctp": 1,
- "lan": "zh",
- "spd": spd,
- "pit": pit,
- "vol": vol,
- "per": per,
- },
- headers={"Content-Type": "application/x-www-form-urlencoded"},
- timeout=15,
- )
- except Exception as e:
- return {'detail': f'调用百度 TTS 接口失败: {e}'}, 502
- content_type = resp.headers.get("Content-Type", "")
- if content_type.startswith("application/json") or content_type.startswith("text/"):
- # 百度错误时会返回 JSON 文本
- try:
- err_data = resp.json()
- except Exception:
- err_data = {"raw": resp.text}
- return {'detail': f'百度 TTS 返回错误: {err_data}'}, 502
- audio_bytes = resp.content
- audio_base64 = base64.b64encode(audio_bytes).decode('ascii')
- return jsonify({
- "audio_base64": audio_base64,
- "format": "mp3",
- })
- @app.route('/knowledge_graph')
- @login_required
- def knowledge_graph():
- """知识图谱可视化"""
- rels = KnowledgeRelation.query.limit(50).all()
- nodes, links = set(), []
- for r in rels:
- nodes.add(r.subject)
- nodes.add(r.obj)
- links.append({'source': r.subject, 'target': r.obj, 'value': r.predicate})
- graph_data = {
- 'nodes': [{'name': n} for n in nodes],
- 'links': links
- }
- return render_template('knowledge_graph.html', graph_data=graph_data)
- @app.route('/api/ask', methods=['POST'])
- @login_required
- def api_ask():
- """智能答疑 API:意图识别 -> 图谱检索 -> 大模型扩充 -> 返回文本(供数字人展示)"""
- data = request.get_json() or {}
- question = (data.get('question') or '').strip()
- if not question:
- return {'ok': False, 'error': '请输入问题'}
- # 1. 图谱检索
- rels = _search_knowledge_graph(question)
- # 2. 大模型扩充(可替换为真实 API 调用)
- answer = _expand_with_llm(question, rels)
- return {'ok': True, 'answer': answer}
- @app.route('/analysis')
- @login_required
- def analysis():
- """学情分析:基于答题记录生成知识点掌握情况"""
- user_id = session['user_id']
- proficiency = _get_user_proficiency(user_id)
- chart_data = [{'name': k, 'value': v} for k, v in proficiency.items()] if proficiency else []
- return render_template(
- 'dashboard.html', active_page='analysis',
- questions=None, training_result=None, chart_data=chart_data or []
- )
- # -----------------------------
- # API v1: 给前端(Vite/React)调用的 JSON 接口
- # -----------------------------
- def _api_require_login():
- user_id = session.get('user_id')
- if not user_id:
- return None, ({'ok': False, 'error': '未登录'}, 401)
- return int(user_id), None
- @app.route('/api/v1/auth/login', methods=['POST'])
- def api_v1_login():
- """
- 前端登录接口:POST JSON { username, password }
- 成功后写入 session,并返回前端期望的结构:
- { access_token, user_id, username, role }
- """
- data = request.get_json(silent=True) or {}
- username = (data.get('username') or '').strip()
- password = (data.get('password') or '').strip()
- if not username or not password:
- return {'detail': '用户名或密码不能为空'}, 400
- user = User.query.filter_by(username=username).first()
- if not user or not user.check_password(password):
- return {'detail': '用户名或密码错误'}, 401
- session['user_id'] = user.id
- session['username'] = user.username
- # 这里 access_token 只是占位,当前后端通过 Session 识别用户
- return {
- 'access_token': 'session',
- 'user_id': user.id,
- 'username': user.username,
- 'role': user.role,
- }
- @app.route('/api/v1/auth/register', methods=['POST'])
- def api_v1_register():
- """
- 前端注册接口:POST JSON { username, password, role? }
- 创建用户并直接登录,返回 { access_token, user_id, username, role }
- """
- data = request.get_json(silent=True) or {}
- username = (data.get('username') or '').strip()
- password = (data.get('password') or '').strip()
- role = (data.get('role') or 'student').strip() or 'student'
- if not username or not password:
- return {'detail': '用户名和密码不能为空'}, 400
- if User.query.filter_by(username=username).first():
- return {'detail': '用户名已存在'}, 400
- user = User(username=username, role=role)
- user.set_password(password)
- db.session.add(user)
- db.session.commit()
- session['user_id'] = user.id
- session['username'] = user.username
- return {
- 'access_token': 'session',
- 'user_id': user.id,
- 'username': user.username,
- 'role': user.role,
- }, 201
- @app.route('/api/v1/auth/logout', methods=['POST'])
- def api_v1_logout():
- session.clear()
- return {'ok': True}
- @app.route('/api/v1/auth/me', methods=['GET'])
- def api_v1_me():
- user_id, err = _api_require_login()
- if err:
- return err
- user = User.query.get(user_id)
- if not user:
- session.clear()
- return {'detail': '用户不存在'}, 401
- return {'user_id': user.id, 'username': user.username, 'role': user.role}
- @app.route('/api/v1/users/me', methods=['GET'])
- def api_v1_users_me_get():
- """
- 个人信息页:获取当前登录用户的详细资料。
- 返回字段与 FastAPI /api/v1/users/me 保持一致,便于前端统一使用。
- """
- user_id, err = _api_require_login()
- if err:
- return err
- user = User.query.get(user_id)
- if not user:
- session.clear()
- return {'detail': '用户不存在'}, 401
- return {
- 'id': user.id,
- 'username': user.username,
- 'role': user.role,
- 'full_name': user.full_name,
- 'student_id': user.student_id,
- 'class_name': user.class_name,
- 'grade': user.grade,
- 'email': user.email,
- 'phone': user.phone,
- 'created_at': user.created_at.isoformat() if user.created_at else None,
- }
- @app.route('/api/v1/users/me', methods=['PUT'])
- def api_v1_users_me_update():
- """
- 个人信息页:更新当前登录用户的个人资料。
- 接收可选字段:full_name, student_id, class_name, grade, email, phone。
- """
- user_id, err = _api_require_login()
- if err:
- return err
- user = User.query.get(user_id)
- if not user:
- session.clear()
- return {'detail': '用户不存在'}, 401
- data = request.get_json(silent=True) or {}
- updatable_fields = ['full_name', 'student_id', 'class_name', 'grade', 'email', 'phone']
- for field in updatable_fields:
- if field in data:
- value = data.get(field)
- if isinstance(value, str):
- value = value.strip()
- setattr(user, field, value)
- try:
- db.session.add(user)
- db.session.commit()
- except Exception as e:
- db.session.rollback()
- return {'detail': f'更新失败: {e}'}, 500
- return {
- 'id': user.id,
- 'username': user.username,
- 'role': user.role,
- 'full_name': user.full_name,
- 'student_id': user.student_id,
- 'class_name': user.class_name,
- 'grade': user.grade,
- 'email': user.email,
- 'phone': user.phone,
- 'created_at': user.created_at.isoformat() if user.created_at else None,
- }
- @app.route('/api/v1/recommendation/proficiency', methods=['GET'])
- def api_v1_proficiency():
- user_id, err = _api_require_login()
- if err:
- return err
- proficiency = _get_user_proficiency(user_id)
- # 前端直接期望拿到 { 知识点: 正确率 } 这样的字典
- return proficiency or {}
- @app.route('/api/v1/recommendation/wrong_questions', methods=['GET'])
- def api_v1_wrong_questions():
- user_id, err = _api_require_login()
- if err:
- return err
- wrong_ids = _get_wrong_question_ids(user_id, limit=50)
- if not wrong_ids:
- return []
- questions = Question.query.filter(Question.id.in_(wrong_ids)).all()
- id_order = {qid: i for i, qid in enumerate(wrong_ids)}
- questions = sorted(questions, key=lambda q: id_order.get(q.id, 999))
- return [
- {
- 'id': q.id,
- 'content': q.content,
- 'option_a': q.option_a,
- 'option_b': q.option_b,
- 'option_c': q.option_c,
- 'option_d': q.option_d,
- }
- for q in questions
- ]
- @app.route('/api/v1/recommendation/recommended_questions', methods=['GET'])
- def api_v1_recommended_questions():
- user_id, err = _api_require_login()
- if err:
- return err
- qs = _get_recommended_questions(user_id, count=5) or []
- return [
- {
- 'id': q.id,
- 'content': q.content,
- 'option_a': q.option_a,
- 'option_b': q.option_b,
- 'option_c': q.option_c,
- 'option_d': q.option_d,
- }
- for q in qs
- ]
- @app.route('/api/v1/recommendation/daily_practice', methods=['GET'])
- def api_v1_daily_practice():
- """每日一练:按弱点权重生成今日推荐题,若今日已有则直接返回"""
- user_id, err = _api_require_login()
- if err:
- return err
- count = request.args.get('count', 10, type=int)
- try:
- qs = get_or_generate_daily_recommendations(db.session, user_id, count=count)
- except Exception as e:
- print(f"[WARN] get_or_generate_daily_recommendations failed: {e}")
- qs = _get_recommended_questions(user_id, count=count) or []
- return [
- {
- 'id': q.id,
- 'content': q.content,
- 'option_a': q.option_a,
- 'option_b': q.option_b,
- 'option_c': q.option_c,
- 'option_d': q.option_d,
- 'knowledge_point': getattr(q, 'knowledge_point', None),
- }
- for q in qs
- ]
- @app.route('/api/v1/recommendation/mastery_matrix', methods=['GET'])
- def api_v1_mastery_matrix():
- """知识点掌握度矩阵:用于前端颜色分级、雷达图等可视化"""
- user_id, err = _api_require_login()
- if err:
- return err
- try:
- return get_mastery_matrix(db.session, user_id)
- except Exception as e:
- print(f"[WARN] get_mastery_matrix failed: {e}")
- return []
- @app.route('/api/v1/recommendation/motivation_summary', methods=['GET'])
- def api_v1_motivation_summary():
- """激励信息汇总:连续打卡天数、全绿模块、章节平均掌握度"""
- user_id, err = _api_require_login()
- if err:
- return err
- try:
- return get_motivation_summary(db.session, user_id)
- except Exception as e:
- print(f"[WARN] get_motivation_summary failed: {e}")
- return {}
- @app.route('/api/v1/recommendation/question_analysis', methods=['GET'])
- def api_v1_question_analysis():
- """题目解析:从 chemistry_question_bank.analysis 读取。"""
- user_id, err = _api_require_login()
- if err:
- return err
- _ = user_id
- question_id = request.args.get('question_id', type=int)
- if not question_id:
- return {'detail': 'question_id 不能为空'}, 400
- try:
- analysis = get_question_analysis(db.session, question_id)
- return {'question_id': question_id, 'analysis': analysis}
- except Exception as e:
- print(f"[WARN] get_question_analysis failed: {e}")
- return {'question_id': question_id, 'analysis': '暂无解析'}
- @app.route('/api/v1/recommendation/diagnostic_questions', methods=['GET'])
- def api_v1_diagnostic_questions():
- """诊断测评:覆盖广、难度适中(用于弱点更新)"""
- user_id, err = _api_require_login()
- if err:
- return err
- count = request.args.get('count', 10, type=int)
- try:
- qs = get_diagnostic_questions(db.session, user_id, count=count, session_kind='diagnostic')
- except Exception as e:
- print(f"[WARN] get_diagnostic_questions(diagnostic) failed: {e}")
- qs = []
- return [
- {
- 'id': q.id,
- 'content': q.content,
- 'option_a': q.option_a,
- 'option_b': q.option_b,
- 'option_c': q.option_c,
- 'option_d': q.option_d,
- 'knowledge_point': getattr(q, 'knowledge_point', None),
- }
- for q in qs
- ]
- @app.route('/api/v1/recommendation/re_evaluate_questions', methods=['GET'])
- def api_v1_re_evaluate_questions():
- """再测评:在测评后/训练后重新抽取题目"""
- user_id, err = _api_require_login()
- if err:
- return err
- count = request.args.get('count', 10, type=int)
- try:
- qs = get_diagnostic_questions(db.session, user_id, count=count, session_kind='re_evaluate')
- except Exception as e:
- print(f"[WARN] get_diagnostic_questions(re_evaluate) failed: {e}")
- qs = []
- return [
- {
- 'id': q.id,
- 'content': q.content,
- 'option_a': q.option_a,
- 'option_b': q.option_b,
- 'option_c': q.option_c,
- 'option_d': q.option_d,
- 'knowledge_point': getattr(q, 'knowledge_point', None),
- }
- for q in qs
- ]
- def _api_submit_diagnostic_like(session_kind: str = "diagnostic"):
- """
- 诊断/再测评批量提交:
- - 写入 answer_records
- - 更新掌握度
- - 生成(或复用)每日一练
- """
- user_id, err = _api_require_login()
- if err:
- return err
- data = request.get_json(silent=True) or {}
- answers = data.get('answers')
- if not isinstance(answers, list):
- return {'detail': '参数格式错误'}, 400
- train_count = data.get('train_count', 10)
- regenerate_daily = bool(data.get('regenerate_daily', False))
- include_diagnostic_in_mastery = bool(data.get('include_diagnostic_in_mastery', False))
- correct_count = 0
- total = 0
- processed_answers = []
- for item in answers:
- try:
- qid = int(item.get('question_id'))
- except (TypeError, ValueError):
- continue
- selected = (item.get('selected_option') or '').strip()
- if not selected:
- continue
- question = Question.query.get(qid)
- if not question:
- continue
- is_correct = (selected == question.correct_option)
- total += 1
- if is_correct:
- correct_count += 1
- db.session.add(AnswerRecord(
- user_id=user_id,
- question_id=question.id,
- selected_option=selected,
- is_correct=is_correct,
- ))
- processed_answers.append({"question_id": qid, "selected_option": selected})
- if total == 0:
- return {'ok': False, 'detail': '未提交有效答题'}, 400
- db.session.commit()
- try:
- update_mastery_from_answers(
- db.session,
- user_id,
- processed_answers,
- session_kind=session_kind,
- include_diagnostic_in_mastery=include_diagnostic_in_mastery,
- )
- db.session.commit()
- except Exception as e:
- print(f"[WARN] update_mastery_from_answers in diagnostic submit failed: {e}")
- if regenerate_daily:
- try:
- from backend.models import DailyRecommendation
- daily_query = db.session.query(DailyRecommendation).filter(
- DailyRecommendation.user_id == user_id,
- DailyRecommendation.recommend_date == date.today(),
- )
- if session_kind == "diagnostic":
- # 诊断提交后按最新测评结果重算“今日10题”,清空今日旧推荐(含已完成)。
- daily_query.delete(synchronize_session=False)
- else:
- # 再测评保留已完成记录,仅替换未完成推荐,避免影响已完成闭环。
- daily_query.filter(DailyRecommendation.is_done == 0).delete(synchronize_session=False)
- db.session.commit()
- except Exception as e:
- print(f"[WARN] regenerate_daily delete failed: {e}")
- try:
- daily_qs = get_or_generate_daily_recommendations(db.session, user_id, count=int(train_count))
- except Exception as e:
- print(f"[WARN] get_or_generate_daily_recommendations in diagnostic submit failed: {e}")
- daily_qs = []
- accuracy = int(round(correct_count / total * 100)) if total else 0
- return {
- 'ok': True,
- 'diagnostic': {'total': total, 'correct': correct_count, 'accuracy': accuracy},
- 'daily_practice': [
- {
- 'id': q.id,
- 'content': q.content,
- 'option_a': q.option_a,
- 'option_b': q.option_b,
- 'option_c': q.option_c,
- 'option_d': q.option_d,
- 'knowledge_point': getattr(q, 'knowledge_point', None),
- }
- for q in daily_qs
- ],
- }
- @app.route('/api/v1/recommendation/diagnostic_submit_batch', methods=['POST'])
- def api_v1_diagnostic_submit_batch():
- return _api_submit_diagnostic_like(session_kind="diagnostic")
- @app.route('/api/v1/recommendation/re_evaluate_submit_batch', methods=['POST'])
- def api_v1_re_evaluate_submit_batch():
- return _api_submit_diagnostic_like(session_kind="re_evaluate")
- @app.route('/api/v1/teacher/class_report', methods=['GET'])
- def api_v1_teacher_class_report():
- """
- 教师端班级学情报表:
- - 仅教师可访问
- - 前端对齐路径:/api/v1/teacher/class_report
- """
- user_id, err = _api_require_login()
- if err:
- return err
- user = User.query.get(user_id)
- if not user:
- session.clear()
- return {'detail': '用户不存在'}, 401
- if user.role != 'teacher':
- return {'detail': '仅教师可访问该接口'}, 403
- days = request.args.get('days', 14, type=int)
- weak_top = request.args.get('weak_top', 5, type=int)
- wrong_top = request.args.get('wrong_top', 10, type=int)
- try:
- return get_teacher_class_report(
- db.session,
- teacher_id=user_id,
- days=days,
- weak_top=weak_top,
- wrong_top=wrong_top,
- )
- except ValueError as e:
- return {'detail': str(e)}, 400
- except Exception as e:
- print(f"[WARN] get_teacher_class_report failed: {e}")
- return {'detail': '获取教师班级报表失败'}, 500
- @app.route('/api/v1/recommendation/submit_batch', methods=['POST'])
- def api_v1_submit_batch():
- """
- 批量提交答题结果:计算本次正确率并记录到 answer_records
- 请求体(前端目前会多包一层 answers):
- { "answers": [ { "question_id": 1, "selected_option": "A" }, ... ] }
- 或 { "answers": { "answers": [ ... ] } }
- """
- user_id, err = _api_require_login()
- if err:
- return err
- data = request.get_json(silent=True) or {}
- answers = data.get('answers')
- # 兼容前端传入 {answers: {answers: [...]}}
- if isinstance(answers, dict) and 'answers' in answers:
- answers = answers['answers']
- if not isinstance(answers, list):
- return {'detail': '参数格式错误'}, 400
- correct_count = 0
- total = 0
- processed_answers = []
- for item in answers:
- try:
- qid = int(item.get('question_id'))
- except (TypeError, ValueError):
- continue
- selected = (item.get('selected_option') or '').strip()
- if not selected:
- continue
- question = Question.query.get(qid)
- if not question:
- continue
- is_correct = (selected == question.correct_option)
- total += 1
- if is_correct:
- correct_count += 1
- db.session.add(AnswerRecord(
- user_id=user_id,
- question_id=question.id,
- selected_option=selected,
- is_correct=is_correct,
- ))
- processed_answers.append({"question_id": qid, "selected_option": selected})
- if total > 0:
- try:
- update_mastery_from_answers(
- db.session, user_id, processed_answers, session_kind="practice"
- )
- mark_daily_recommendations_done(
- db.session, user_id,
- [a["question_id"] for a in processed_answers],
- today=date.today(),
- )
- except Exception as e:
- print(f"[WARN] update_mastery_from_answers failed: {e}")
- db.session.commit()
- accuracy = int(round(correct_count / total * 100)) if total else 0
- return {'total': total, 'correct': correct_count, 'accuracy': accuracy}
- # 节点展示名仅用:name/formula/symbol/description/text/type/category/state/chunk,不用 id/标签
- # 视为“无意义”的展示名(纯标签或占位),不展示
- _BAD_DISPLAY_NAMES = frozenset({
- "element", "chunk", "document", "__entity__", "nodea", "nodeb",
- "entity", "doc", "kgbuilder", "__kgbuilder__"
- })
- @app.route('/api/v1/knowledge/graph', methods=['GET'])
- def api_v1_knowledge_graph():
- """知识图谱 JSON 版本,优先从 Neo4j 读取,失败时回退到内置 MySQL 表。
- 展示名严格使用 formula/description/symbol/name/text/type/category/state/chunk,不显示 id 或标签。"""
- nodes, links = set(), []
- limit = request.args.get("limit", 2000, type=int)
- limit = min(max(limit, 100), 10000)
- # 1)优先从 Neo4j 查询
- try:
- with neo4j_driver.session() as session:
- # 展示名仅从具体属性取:name, formula, symbol, description, text, type, category, state, chunk(长文本截断)
- # 不用 id、不用 labels,避免出现 elementId 或 "Element"/"Chunk"
- part_s = "coalesce(" + ", ".join([
- "s.name", "s.formula", "s.symbol", "s.description",
- "CASE WHEN s.text IS NOT NULL AND size(s.text) > 0 THEN substring(s.text, 0, 80) + '…' ELSE NULL END",
- "s.type", "s.category", "s.state",
- "CASE WHEN s.chunk IS NOT NULL AND size(toString(s.chunk)) > 0 THEN substring(toString(s.chunk), 0, 80) + '…' ELSE NULL END",
- ]) + ")"
- part_o = "coalesce(" + ", ".join([
- "o.name", "o.formula", "o.symbol", "o.description",
- "CASE WHEN o.text IS NOT NULL AND size(o.text) > 0 THEN substring(o.text, 0, 80) + '…' ELSE NULL END",
- "o.type", "o.category", "o.state",
- "CASE WHEN o.chunk IS NOT NULL AND size(toString(o.chunk)) > 0 THEN substring(toString(o.chunk), 0, 80) + '…' ELSE NULL END",
- ]) + ")"
- cypher = f"""
- MATCH (s)-[r]->(o)
- WITH s, r, o, {part_s} AS subjDisplay, {part_o} AS objDisplay
- WHERE subjDisplay IS NOT NULL AND objDisplay IS NOT NULL
- WITH coalesce(toString(subjDisplay), '') AS subject,
- coalesce(r.name, toString(type(r))) AS predicate,
- coalesce(toString(objDisplay), '') AS obj
- WHERE subject <> '' AND obj <> ''
- RETURN DISTINCT subject, predicate, obj
- LIMIT $limit
- """
- records = session.run(cypher, limit=limit).data()
- for rec in records:
- subject = (rec.get("subject") or "").strip()
- obj = (rec.get("obj") or "").strip()
- predicate = (rec.get("predicate") or "关联").strip()
- if not subject or not obj:
- continue
- # 排除像 elementId 的字符串(含 : 且含 - 且较长)
- def looks_like_id(s):
- if len(s) < 20:
- return False
- return ":" in s and "-" in s
- if looks_like_id(subject) or looks_like_id(obj):
- continue
- # 排除纯标签/占位名
- low_subj, low_obj = subject.lower(), obj.lower()
- if low_subj in _BAD_DISPLAY_NAMES or low_obj in _BAD_DISPLAY_NAMES:
- continue
- nodes.add(subject)
- nodes.add(obj)
- links.append({"source": subject, "target": obj, "value": predicate})
- except Exception as e:
- # Neo4j 不可用时打印告警,继续使用 MySQL 数据
- print(f"[WARN] Neo4j knowledge graph query failed: {e}")
- # 2)如果 Neo4j 没有查到有效关系,则回退到原来的 MySQL 表
- if not links:
- rels = KnowledgeRelation.query.limit(50).all()
- for r in rels:
- nodes.add(r.subject)
- nodes.add(r.obj)
- links.append({"source": r.subject, "target": r.obj, "value": r.predicate})
- return {
- "nodes": [{"name": n} for n in nodes],
- "links": links,
- }
- @app.route('/api/v1/qa/ask', methods=['POST'])
- def api_v1_qa_ask():
- """知识点问答 JSON 接口,对齐前端 qa.ask"""
- data = request.get_json(silent=True) or {}
- question = (data.get('question') or '').strip()
- if not question:
- return {'detail': '请输入问题'}, 400
- # 先进行问题解析,提取关键词与检索词
- try:
- analysis = analyze_question(question)
- except Exception:
- analysis = {"clean_text": question, "kg_terms": [question]}
- kg_terms = analysis.get("kg_terms") or [analysis.get("clean_text") or question]
- # 基于解析得到的多个检索词进行图谱检索,并去重合并
- triples = []
- seen = set()
- for term in kg_terms:
- results = _search_knowledge_graph(term)
- for r in results:
- key = (r.get("subject"), r.get("predicate"), r.get("obj"))
- if key in seen:
- continue
- seen.add(key)
- triples.append(r)
- answer = _expand_with_llm(question, triples, meta=analysis)
- return {'answer': answer}
- if __name__ == '__main__':
- app.run(debug=True, port=5000)
|