目录

campusclaw — 班级限定的可追溯知识库与 AI 助教平台

「2026 互联网软件开发」课程的个人项目:在已有的 Flask 教学材料应用上,逐步叠加 限定在本班范围内的知识库检索、Bearer 令牌鉴权、提示词模板与技能、多轮问答, 以及 MCP 工具接入。所有检索结果都能回溯到材料原文,向量检索用 Qdrant 承载。

课程增量与本仓库的对应关系:

增量 内容
第 4 课 可追溯的班级限定检索(FTS5 + Qdrant + RRF)
第 5 课 Cookie + 服务端会话改为 Bearer 令牌;接口测试脚本 apitest/
第 6 课 提示词模板、技能路由、智能体、SSE 流式问答、MCP 工具接入与审计

仓库同时存放变更的规格驱动文档,便于对照「需求 → 设计 → 任务 → 实现」的对应关系:

README.md                  ← 本文件
campusclaw-mygroup/        ← 实现代码
openspec/                  ← 变更文档(proposal / specs / design / tasks)
└── changes/
    ├── add-traceable-vector-retrieval/   ← 可追溯的班级限定检索
    ├── add-bearer-token-auth/            ← Cookie+Session 鉴权改为 Bearer 令牌
    └── add-mcp-tool-integration/         ← MCP 工具接入与技能/智能体

这一课解决的问题

上一课已经有了登录、角色权限与班级隔离,但材料只能整份下载,没法问「材料里讲了什么」。 本变更补上这条链:把材料切成切片、建索引、按班检索,并把每一条命中都带回它在原文中的位置。

三条硬要求:

  1. 班级隔离:检索范围一律取自会话里的班级,请求体里带的 class_id 一律忽略;跨班检索 返回 200 + 空命中,而不是报错或泄露他班内容。
  2. 内容溯源:每条命中带材料标题、切片序号、字符区间与摘录,可据此打开原文定位。
  3. 不编造:没有依据时返回固定文案「资料中未找到相关内容」,不会拿相关度偏低的切片充数。

检索是怎么工作的

两种召回各自独立过滤,再按名次融合(Reciprocal Rank Fusion,k = 60):

模式 做法 依赖
keyword SQLite FTS5 MATCH + bm25() 排序 只用关系库
vector 问句嵌入 → Qdrant 按 class_id 过滤 → 丢弃余弦 < 0.35 → 按主键回表 嵌入网关 + Qdrant
hybrid(默认) 上面两路各取一份名次,做 RRF 求和 两者

几个刻意的取舍:

  • 融合基于名次,不是分数相加。 关键字给的是 bm25(越小越好),向量给的是余弦 (越大越好),两者量纲完全不同,直接相加没有意义。
  • 摘录一律回关系库取,chunk_text 是唯一事实来源。写进 Qdrant 的 payload 只有 5 个标识 键(class_id / material_id / knowledge_entry_id / chunk_id / chunk_index), 不含切片正文——向量库不是正文的存储。
  • 中文两字词靠手工二元组化。 本机 SQLite 既没有 ngram 分词器,unicode61 对中文 命中为 0,trigram 对两字词命中为 0。因此切分写入与查询匹配共用同一个 chunking.search_blob(),把连续 CJK 拆成逐字二元组(向量库 → 向量 量库), 用显式 OR 拼成 MATCH 查询。

提示词模板、技能与智能体

  • 提示词模板:教师按班级维护模板正文;创建或更新智能体时选择模板,模板正文会复制进 智能体的 prompt 字段,提问时作为 system 消息的开头原样传给对话模型。
  • 技能:上传 .txt / .md 技能文件,解析出名称、适用条件、资料(学科限定)、工具、 启用状态和说明正文。提问时只在已绑定且已启用的技能里,按「适用条件」关键词与问题 做子串计分,取最高分命中;发给模型的只有「名称 + 说明正文」,触发词、工具等元数据 留在服务端用于路由和执行。
  • 智能体:绑定一个提示词模板、若干技能和若干本班材料。绑定材料后,每轮只检索 Top 4 片段;技能若写了「资料:某学科」,检索结果再按材料标题过滤。
  • 流式问答:POST /api/conversations/<id>/ask 返回 SSE,依次为 meta(技能、引用、 工具调用状态)→ delta(正文增量)→ done;历史只带最近 6 条,每条截断 500 字。

MCP 工具接入

同仓的 mcp-server/ 是一个独立的 MCP 服务器,提供 POST /mcp(JSON-RPC 2.0)和 GET /health,实现 initialize、tools/list、tools/call,内置 formula_hint、 unit_convert 两个工具。令牌只从环境变量 MCP_AUTH_TOKEN 读取,不进 Git、镜像和前端。

  • 教师在「工具接入」页登记本班服务器(名称 + Base URL);令牌只存服务端,接口响应只返回 「是否已配令牌」的布尔值。
  • 「连接」= 握手 + tools/list,工具清单整体替换缓存;「断开」清空缓存,此后调用一律拒绝。
  • 技能「工具」栏支持 工具名@服务器名,保存时校验服务器属于本班、已连接且工具在发现清单内。
  • 提问命中带 MCP 工具的技能时,由宿主在生成回答前调用 tools/call,把结果文本作为 数据追加到用户消息末尾(【工具结果 n:工具@服务器】)。请求体仍然只有 model / messages / stream,没有 OpenAI tools 字段,模型看不到工具清单或令牌。
  • 调用失败、服务器未连接、未配令牌等情况记为 denied / error,只写审计,不阻断问答; 教师可在 /mcp-admin 查看工具、服务器、状态、用户和时间。

令牌鉴权(Bearer)

原始实现用 Cookie + 服务端会话。本变更把它换成自签的 Bearer 令牌:令牌由标准库 hmac + hashlib + base64 + json 自行签名(JWT 形态 header.payload.signature, 算法 HS256),没有引入任何新依赖,签名密钥复用已有的 SECRET_KEY。

  • 怎么用:POST /api/login 拿令牌,之后每个请求带 Authorization: Bearer <token>。 浏览器导航带不了请求头,所以页面不再由服务端渲染数据:/、/materials、/search 返回的是不含任何数据的空壳(200),真实数据由页面脚本带令牌 fetch 后再渲染。
  • 载荷里没有授权信息:令牌只装 sub(账号 id)、ver(令牌版本号)、iat、exp。 角色与班级每次都按 sub 回库现读,所以改了班级或权限立刻生效,不存在「令牌里的旧角色」。
  • 有效期 12 小时,不设刷新令牌——到期即重新登录。
  • 登出即全量失效:users.token_version 在登出时 +1,校验时比对令牌里的 ver 与 库中当前值,不等即拒。于是同一账号的所有令牌一起作废(含在别处登录的那一枚); 代价是没有「只登出某一台设备」的能力。
  • 未认证一律 401 并带 WWW-Authenticate: Bearer,不再是 302 跳登录页。

两个必须知道的取舍:

  • 令牌放在 localStorage,换取「刷新页面不掉登录」的体验。代价是站点内任何 XSS 都能读到 令牌;本项目全部渲染走 textContent、不用 innerHTML,但这是接受该风险而非消除它。
  • 明文 HTTP 下令牌在链路上是明文。Bearer 令牌等价于凭据,生产部署必须置于 TLS 之后; 本仓库的 Compose 面向本地演示,不提供 TLS。

目录结构(实现部分)

campusclaw-mygroup/
├── app.py              Flask 应用与全部路由
├── auth.py             请求守卫:解析 Bearer 头 → 校验令牌 → 回库读角色与班级
├── tokens.py           令牌签发与校验(纯标准库 hmac/base64/json)
├── db.py               SQLite 连接与路径配置
├── schema.sql          建表语句
├── init_db.py          幂等建库 + 预置账号与材料
├── chunking.py         切分策略与二元组化(纯函数,无外部依赖)
├── services.py         外部服务客户端:Qdrant + 嵌入网关 + 对话网关
├── retrieval.py        入库索引、两路召回、RRF 融合
├── materials.py        上传落盘与入库
├── prompts.py          提示词模板
├── skills.py           技能文件解析、触发词与工具解析
├── tutor.py            智能体、技能路由、消息组装、SSE 问答、工具调用
├── mcp_host.py         本班 MCP 服务器登记、工具缓存、调用审计
├── mcp_client.py       JSON-RPC 客户端(Bearer 令牌只从环境变量读)
├── openapi.py          OpenAPI 注解与 /openapi.json、/swagger
├── check_index.py      索引一致性断言(可当命令行工具跑)
├── mcp-server/         独立 MCP 服务器(JSON-RPC 2.0)
├── apitest/            按 URL 组织的接口测试脚本 + HTML 报告
├── demo/               截图用抓包网关与演示数据准备脚本
├── templates/          Jinja2 页面壳(数据一律由脚本取)
├── static/             前端脚本与样式(auth / chat / skills / mcp / docs)
└── tests/              单元测试(标准库 unittest)

未新增任何 pip 依赖:Qdrant 与两个网关一律用标准库 urllib.request 通信, requirements.txt 仍只有 Flask / argon2-cffi / python-dotenv。

快速开始

Docker Compose(推荐)

cd campusclaw-mygroup
cp .env.example .env      # 然后填 SECRET_KEY(必填)
docker compose up --build

本机直跑(不用容器)

cd campusclaw-mygroup
python -m venv .venv
.venv/Scripts/python.exe -m pip install -r requirements.txt   # Windows
.venv/Scripts/python.exe init_db.py
.venv/Scripts/python.exe app.py                                # 默认 :5000

直跑时若想连本机 Qdrant,把 .env 里的 QDRANT_URL 改成 http://127.0.0.1:6333。

环境变量

变量 必填 说明
SECRET_KEY ✅ 令牌签名密钥。缺失时应用直接拒绝启动
QDRANT_URL — 向量库地址,默认 http://qdrant:6333
EMBEDDING_BASE_URL — 嵌入网关(OpenAI 兼容 POST /v1/embeddings)
EMBEDDING_API_KEY — 嵌入网关密钥
EMBEDDING_MODEL — 嵌入模型名
EMBEDDING_DIM — 嵌入维度,须与模型实际输出一致
DIALOG_BASE_URL — 对话网关(OpenAI 兼容 POST /v1/chat/completions)
DIALOG_API_KEY — 对话网关密钥
DIALOG_MODEL — 对话模型名
MCP_AUTH_TOKEN MCP 必填 MCP 服务器与宿主共用的 Bearer 令牌;只从环境变量读取

四项 EMBEDDING_* 全齐才启用向量检索;缺任意一项时应用照常启动,只在启动日志里 给出一条明确告警。密钥只从环境变量读,不入库、不写日志、不进响应体。

生成一个 SECRET_KEY:

python -c "import secrets; print(secrets.token_urlsafe(48))"

预置账号

init_db.py 只在库为空时写入,内置两个班的师生账号(库里只存 argon2 哈希):

用户名 密码 角色 班级
teacher3 teacher3-pass 教师 高一(3)班
student3 student3-pass 学生 高一(3)班
teacher4 teacher4-pass 教师 高一(4)班
student4 student4-pass 学生 高一(4)班

预置材料:3 班《向量检索讲解》、4 班《生物实验记录》——用来演示班级隔离。

接口

下表除标注「免认证」者外,**一律需要 Authorization: Bearer <token>**。

方法 路径 说明
GET /health 健康检查(免认证)
GET /login 登录页壳(免认证,不含数据)
POST /api/login 用账号密码换取令牌
POST /api/logout 登出,使该账号全部令牌失效
GET /、/materials、/search、/chat、/skills-admin、/prompt-templates、/mcp-admin 页面壳(免认证,不含数据)
GET /api/timetable 本班课表(结构化)
GET /api/materials 本班材料列表
POST /api/materials 上传材料(教师,≤ 16 MB)
GET /materials/<id> 按班下载材料,跨班 404
POST /api/search 检索,body 带 query 与可选 mode
POST /api/ask 就材料提问,返回带 [1] 标注的回答与出处列表
POST /api/materials/<id>/reindex 教师专属,按请求指定策略重建索引
GET/POST /api/prompt-templates 模板列表 / 新建(教师)
PUT/DELETE /api/prompt-templates/<id> 更新 / 删除模板(教师)
GET/POST /api/agents 智能体列表 / 新建(教师)
PUT /api/agents/<id> 更新智能体(教师)
GET/POST /api/skills 技能列表 / 上传解析(教师)
PUT/DELETE /api/skills/<id> 更新 / 删除技能(教师)
POST /api/skills/<id>/enabled 启用 / 停用技能(教师)
GET/POST /api/mcp/servers 本班 MCP 服务器列表 / 登记(教师)
POST /api/mcp/servers/<id>/connect 握手 + tools/list,刷新工具缓存
POST /api/mcp/servers/<id>/disconnect 清空连接与工具缓存
DELETE /api/mcp/servers/<id> 删除服务器(教师)
GET /api/mcp/calls 本班工具调用审计(教师),学生 403
GET/POST /api/conversations 我的会话列表 / 新建会话
GET /api/conversations/<id> 会话与消息历史(跨班 404)
POST /api/conversations/<id>/ask 提问,SSE:meta → delta × n → done
GET /openapi.json、/swagger OpenAPI 描述与试调页面

检索请求示例:

TOKEN=$(curl -s http://127.0.0.1:8000/api/login \
  -H "Content-Type: application/json" \
  -d '{"username":"teacher3","password":"teacher3-pass"}' \
  | python -c "import sys,json;print(json.load(sys.stdin)['token'])")

curl -s http://127.0.0.1:8000/api/search \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"query": "余弦相似度阈值", "mode": "keyword"}'

状态码语义:未认证一律 **401**(带 WWW-Authenticate: Bearer,不再 302 跳转); 非法模式或空查询 400;学生上传或重建索引 403;跨班材料 404;向量库不可用时 vector / hybrid 返回 503(且响应里不含任何编造的相似度数值), 而 keyword 仍正常返回 200。

测试

单元测试(标准库 unittest,共 181 项):

cd campusclaw-mygroup
.venv/Scripts/python.exe -X utf8 -m unittest discover

接口测试(应用已启动时运行,用例按 URL 组织在 apitest/cases/):

.venv/Scripts/python.exe apitest/run_all.py

每个脚本在独立子进程运行,汇总结果写入 apitest/report/report.html;报告目录已在 .gitignore 中,不进入版本库。

另有一个索引一致性检查工具:

.venv/Scripts/python.exe check_index.py

它校验「切片的 class_id 与所属条目一致」「FTS 表与切片表行数一致且 rowid 一一对应」等断言, 故意制造脱节数据时会以非零码退出。注意它读的是当前 DATA_DIR 下已建好表的库, 所以在全新克隆上要先跑一次 init_db.py(或指定一个已初始化的 DATA_DIR)。

⚠️ 一个必须知道的依赖

整条检索链——包括看起来纯本地的 keyword 一路——都依赖一个可用的 EMBEDDING_API_KEY。

原因是索引就绪门槛:两路检索共同只认 index_status = 'ready' 的切片,而只有嵌入调用成功 才会把切片置为 ready。所以没有密钥时,上传产生的切片**全部为 failed**,此时用材料正文里 确有的词做关键字检索,也会返回「资料中未找到相关内容」。

这是课程规定的语义,不是实现缺陷。本项目刻意不做本地嵌入兜底——那样会让「向量检索」 在演示时看起来是对的,实际并没有调用任何嵌入模型。配上密钥后,重启应用会自动为缺少索引的 材料补建索引(retrieval.backfill())。

验证状态(如实说明)

2026-10-27 在开发机上验证:

  • 181 项单元测试全部通过(unittest,覆盖切分、网关客户端、向量库客户端、检索融合、 令牌签发与校验、令牌版本号吊销、模板、技能路由、智能体、MCP、接口层与索引一致性)。
  • 9 项接口用例全部通过(apitest/run_all.py,覆盖登录、会话创建与 SSE 问答、检索、 技能增删改查、MCP 服务器登记/连接、工具调用与审计)。
  • Docker Compose 端到端:app / qdrant / mcp 三个服务均为 healthy;重建索引后 1 片 ready、0 片 failed;混合检索能命中材料片段;问答 SSE 依次收到 meta → delta → done,meta 中带技能、引用与 tool_calls; formula_hint@demo-tools 调用状态为 ok,审计记录可查。
  • 浏览器人工点检:teacher3 登录、材料「重建索引」、在「问答助手」选择 「抓包演示助手」提问、页面显示「技能:演示工具技能」与出处;技能管理页、工具接入页可正常打开。

截图环境的说明:

  • 为截取「传给 LLM 的真实请求体」,把 DIALOG_BASE_URL 临时指向本地抓包网关 (demo/llm_capture.py),业务代码未改;请求体由应用真实组装,网关返回固定的流式文本。
  • 因此本轮验证的是请求参数、检索链路与工具调用;真实对话模型的回答质量未在本轮覆盖, 需要真实回答时把 DIALOG_* 指回真实网关即可(demo/README.md 有还原步骤)。

已知限制:

  • 令牌存放在 localStorage;明文 HTTP 仅用于本地演示,生产部署必须置于 TLS 之后。
  • Qdrant 只在编排网络内可达,不发布宿主机端口。
  • 抓包网关只用于演示请求参数,回答是固定文本,不代表模型输出。
关于
401.0 KB
邀请码
    Gitlink(确实开源)
  • 加入我们
  • 官网邮箱:gitlink@ccf.org.cn
  • QQ群
  • QQ群
  • 公众号
  • 公众号

版权所有:中国计算机学会技术支持:开源发展技术委员会
京ICP备13000930号-9 京公网安备 11010802047560号