使用PySide6+Ollama开发一款本地AI题库复习软件

一、引言

  在备考过程中,刷题是必不可少的环节。然而,市面上现有的刷题软件往往存在一些痛点:要么依赖网络服务器,断网无法使用;要么充斥着各种广告和VIP收费陷阱;最重要的是,当我们做错题时,往往只能看到一个冷冰冰的标准答案,缺乏针对性的解析过程。
  为了解决这些问题,我决定自己动手开发一款完全单机运行、无网络依赖、集成本地AI大模型解析的题库复习软件。本软件基于Python的PySide6框架构建GUI,通过内嵌WebEngine渲染答题界面,结合Ollama本地部署的大语言模型(如DeepSeek),实现了“导入题库->做题->错题统计->AI深度解析”的完整闭环。
  本文将详细拆解该软件的核心技术点,包括PySide6与JS的交互、多线程防止UI卡顿、Ollama流式输出处理以及巧妙的错题统计算法,希望能为你的桌面应用开发提供灵感。

软件下载地址https://download.csdn.net/download/qq616491978/92814944

使用PySide6+Ollama开发一款本地AI题库复习软件

二、软件功能展示与技术架构

  在深入代码之前,我们先来看看这款软件的核心功能模块以及整体的系统架构设计。软件主要包含四大核心功能:

  1. 题库管理:支持直接导入Excel格式的题库,自动转化为JSON并在本地持久化存储,支持历史题库加载。
  2. 答题与记录:左侧Web页面渲染题目,支持单选、多选、判断等题型,每次提交自动生成带时间戳的答题记录快照。
  3. 错题智能分析:通过对比历次答题记录,利用算法精准定位“高频易错题”,并支持一键进入错题模式复习。
  4. 本地AI解析:调用本地Ollama模型,支持“思考模式”和“详细分析”切换,流式输出题目的解题思路。
    为了平衡“复杂的答题交互”与“原生GUI的控制力”,系统采用了混合架构
层级 技术栈 职责描述
展示层 (左) HTML/JS + QWebEngineView 负责题库渲染、答题交互、错题高亮等复杂前端UI
展示层 (右) PySide6 原生组件 负责AI解析结果展示、模型配置、状态控制
控制层 QToolBar, QMenu, QAction 负责软件全局操作路由、菜单响应
桥接层 QWebChannel (Bridge) 负责Python后端与JS前端的双向通信
业务逻辑层 Python (Numpy, Base64) 负责题库编解码、错题交集计算、记录持久化
AI计算层 QThread + Ollama API 负责大模型推理,完全异步流式处理,不卡顿主线程

在这里插入图片描述


三、PySide6与WebEngine的混搭:前端与原生的完美融合

  在桌面开发中,如果纯粹用Python代码画选择题、排版文本,工作量巨大且极其僵硬。因此,我选择用HTML/JS来处理答题页面,而用PySide6原生组件来做外框和控制面板。这就带来了一个核心问题:Python如何与网页里的JS代码通信?
  PySide6提供了QWebChannel来解决这个问题。我们在Python端定义一个继承自QObject的桥接类,并将其注册到WebChannel中。

技术要点

  1. 桥接类中的方法必须使用@Slot(str)装饰器修饰,以暴露给JS调用。
  2. 网页加载完成后,通过runJavaScript主动将数据推送给JS初始化。
  3. JS触发点击事件时,回调Python方法,Python再通过runJavaScript调用JS的submit()函数获取数据,形成闭环。

核心代码

from PySide6.QtCore import QObject, Slot
from PySide6.QtWebChannel import QWebChannel
class Bridge(QObject):
    def __init__(self, m_win):
        super().__init__()
        self.m_win = m_win
    # JS端触发的点击事件会进入这里
    @Slot(str)
    def handle_click(self, message):
        self.m_win._save_from_js()
class MainWindow(QMainWindow):
    def _init_web_channel(self):
        self.channel = QWebChannel()
        self.bridge = Bridge(self)
        # 将bridge对象注册到前端,前端可通过 window.bridge 调用
        self.channel.registerObject('bridge', self.bridge)
        self.webEngineView.page().setWebChannel(self.channel)
        
    def _delayed_init(self):
        # Python主动调用JS函数,传递题库数据初始化页面
        js_code = f"init({json.dumps(self.environment['cur_question_bank_id'])}, {json.dumps(self.ti_ku_nr)});"
        self.webEngineView.page().runJavaScript(js_code, 0, js_callback)

原始数据

在这里插入图片描述

使用PySide6本地web组件进行数据的展示与交互

在这里插入图片描述

四、本地大模型接入:Ollama流式输出与多线程处理

  这是本软件最核心的亮点。如果直接在主线程调用Ollama API,由于大模型推理耗时较长(几秒到几十秒),软件界面会直接“白屏”卡死。因此,必须引入QThread进行异步处理。
  此外,为了提升用户体验,我接入了Ollama的stream=True流式输出,并适配了DeepSeek等模型的“思考模式”(即先输出思考过程,再输出正式答案)。

技术要点

  1. 继承QThread定义子线程:通过自定义信号update_signal将生成的文本片段实时发给主线程。
  2. 使用Python的ollama:开启think=True参数获取隐藏的思考链。
  3. 折叠AI思考过程:在流式遍历中,通过状态机判断当前输出的是thinking还是content,动态拼接HTML的<details>标签,实现“点击展开思考过程”的折叠UI效果。
  4. 避免兼容性问题:完全限制使用CPU(设置环境变量),避免因显卡驱动问题导致程序崩溃。

核心代码

import os
# 必须在导入PySide6前设置,强制禁用GPU,提升兼容性
os.environ['QTWEBENGINE_DISABLE_GPU'] = '1'
os.environ['OLLAMA_NUM_GPU'] = '0'
class OllamaDeepSeek():
    def send_messages(self, chat_str):
        # 根据是否开启思考模式,动态调整上下文长度和Token限制
        if self.enable_think:
            options = {'num_gpu': 0, 'num_ctx': 4096}
        else:
            options = {'num_gpu': 0, 'num_ctx': 1024, 'num_predict': answer_tokens}
        try:
            stream = chat(model=self.model_name, messages=messages, options=options, stream=True, think=self.enable_think)
            is_thinking = False
            for chunk in stream:
                thinking = chunk.get('message', {}).get('thinking', '')
                content = chunk.get('message', {}).get('content', '')
                
                if thinking:
                    if not is_thinking: 
                        self.update_signal.emit("<details><summary>AI 正在思考...</summary>\n\n")
                        is_thinking = True
                    self.update_signal.emit(thinking)
                if content:
                    if is_thinking: 
                        self.update_signal.emit("\n\n</details>\n\n---\n\n")
                        is_thinking = False
                    self.update_signal.emit(content)
            self.finish_signal.emit(full_response)
        except Exception as e:
            self.update_signal.emit(f"发生错误: {e}")
class OllamaDeepSeekThread(QThread):
    update_signal = Signal(str)
    finish_signal = Signal(str)
    def run(self):
        self.olmds.send_messages(self.question_text)

在这里插入图片描述


五、题库导入与错题分析:Numpy在业务逻辑中的巧妙运用

  传统的错题本只是简单地记录做错的题目,但本题库软件实现了跨次错题频率统计。用户可能做了5套卷子,某道题第1、3、5次都做错了,这道题就是“高频易错题”。
  题库导入方面,为了防止乱码和兼容性问题的复杂处理,我采用了一个巧妙的设计:将Excel读取为JSON字符串后,直接进行Base64编码存储为.js文件。前端拿到Base64解码后即可直接作为对象使用。

技术要点

  1. 错题判定逻辑fen_shu数组记录得分(0表示错),if_answer数组记录是否作答(1表示作了)。
  2. 跨文件错题统计:读取某题库下的所有历史答题记录JS文件,解析出错题索引数组。
  3. 引入Numpy进行数组交集与频次统计:将复杂的循环比对简化为一行代码。

核心代码

import numpy as np
from collections import Counter
import base64
def import_question_bank(self):
    excel_data = excel2json.read_excel_to_json(file_name)
    # 核心技巧:将整个JSON字符串进行Base64编码再存为js文件
    encoded_data = base64.b64encode(excel_data.encode("utf-8")).decode("utf-8")
    with open(json_save_path, 'w', encoding='utf-8') as f:
        f.write(encoded_data)
def icr_analyze(self):
    file_contents = []
    # 遍历读取所有历史记录文件
    for item in os.listdir(folder_path):
        with open(full_path, 'r', encoding='utf-8') as f:
            file_contents.append(f.read())
            
    icr_dicts = []
    for content in file_contents:
        data = json.loads(content)
        # Numpy数组运算:找出 (得分为0) 且 (已作答为1) 的题目索引
        arr1, arr2 = np.array(data["fen_shu"]), np.array(data["if_answer"])
        error_indices = np.where((arr1 == 0) & (arr2 == 1))[0].tolist()
        icr_dicts.append(error_indices)
        
    # 统计所有记录中,各个错题索引出现的次数
    counter = Counter()
    for arr in icr_dicts:
        counter.update(arr)
    dpc_counters = counter.most_common() # 按错误频率降序排列

在这里插入图片描述

在这里插入图片描述


六、现代化UI定制与单文件打包部署

  一个优秀的桌面软件,颜值同样重要。PySide6默认的控件样式比较复古,但通过QSS(类似前端的CSS)可以实现高度自定义的现代化扁平化UI。此外,为了方便非技术用户使用,软件需要打包成独立的.exe文件。

技术要点

  1. 全局QSS注入:在QApplication创建后,直接setStyleSheet应用全局风格,统一控件的圆角、悬停变色、字体颜色。
  2. 动态图片绘制:对于复选框的勾选图标,不依赖外部图片,而是使用QPainter在内存中动态绘制一个矢量对勾,转为Base64嵌入到QSS的image: url()中,做到了真正的“零外部图片依赖”。
  3. PyInstaller路径兼容:打包成单文件后,资源文件会被解压到系统的临时目录(_MEIPASS)。通过编写resource_path()函数,实现开发环境与打包环境路径的自动适配。

核心代码

def resource_path(relative_path):
    """兼容PyInstaller单文件打包的路径获取函数"""
    if hasattr(sys, '_MEIPASS'):
        return os.path.join(sys._MEIPASS, relative_path)
    return os.path.join(os.path.abspath("."), relative_path)
# 使用QPainter动态生成对勾图标的Base64编码
img = QImage(16, 16, QImage.Format.Format_ARGB32)
img.fill(QColor(0, 0, 0, 0))
painter = QPainter(img)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
pen = QPen(QColor(255, 255, 255), 2.5, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap, Qt.PenJoinStyle.RoundJoin)
painter.setPen(pen)
painter.drawLine(3, 8, 7, 13)
painter.drawLine(7, 13, 13, 4)
painter.end()
buffer = QBuffer()
buffer.open(QBuffer.OpenModeFlag.ReadWrite)
img.save(buffer, "PNG")
check_img_url = f"data:image/png;base64,{base64.b64encode(buffer.data()).decode('utf-8')}"
# 将生成的图标直接应用到QSS中
settings_widget.setStyleSheet(f"""
    QCheckBox::indicator:checked {{ 
        background-color: #1890ff; 
        border-color: #1890ff; 
        image: url({check_img_url}); 
    }}
""")

七、总结

  这款题库复习软件从实际备考需求出发,巧妙地将PySide6原生控件Web前端技术结合起来,既保证了复杂交互的灵活性,又拥有了桌面软件的原生体验。通过引入Ollama本地大模型,不仅彻底摆脱了网络限制,更通过流式输出和思考链处理,让AI解析变得可控且直观。
在开发过程中,我也总结出了一些宝贵的经验:

  1. 桌面应用的UI容错:强制禁用WebEngine的GPU加速(QTWEBENGINE_DISABLE_GPU=1)能解决90%以上在不同Windows机器上的白屏和崩溃问题。
  2. 数据格式选型:对于结构不固定、包含复杂文本(如题干中的公式、图片)的数据,直接使用Base64编码整个JSON字符串,虽然稍占空间,但彻底杜绝了转义符导致的解析错误,极大提升了系统的鲁棒性。

软件下载地址:https://download.csdn.net/download/qq616491978/92814944

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐