diff --git a/.github/workflows/app-ci.yml b/.github/workflows/app-ci.yml index 8c8c241c..dca28499 100644 --- a/.github/workflows/app-ci.yml +++ b/.github/workflows/app-ci.yml @@ -132,7 +132,7 @@ jobs: run: | python -m pip install --upgrade pip python -m pip install ./apps/scut-senior/worker - python -m pip install './apps/scut-senior/api[dev]' + python -m pip install './apps/scut-senior/api[dev,onnx]' - name: Run Python application and contract tests env: diff --git a/.gitignore b/.gitignore index 41e876a6..45abe888 100644 --- a/.gitignore +++ b/.gitignore @@ -82,3 +82,6 @@ apps/scut-senior/.local/corpus-store/candidates/* # material converter scratch (auxiliary) apps/tools/material_converter/.work/ apps/tools/material_converter/.ai_jobs/ + +#private docs +apps/scut-senior/doc/* \ No newline at end of file diff --git a/README.md b/README.md index 34eea3d7..8aa2679a 100644 --- a/README.md +++ b/README.md @@ -106,13 +106,16 @@ 注意:本仓库不对任何第三方 skill 进行任何检查,仅作为索引,不承担任何责任。请在使用前先检查,确保安全。 -##### VI.智能复习助手“SCUT老学长”(开发中) +##### VI.智能复习助手“SCUT老学长” -项目目前处于迭代 2 开发阶段:首批范围固定为 10 门课程,已有 2 份经 `Klosure` 人工审核通过的 Markdown 可作为 candidate 输入,其余 21 份仍为 `pending`(其中 8 份纯图片资料继续待审)。正式 corpus 尚未激活,默认仍使用合成 Fixture。这里只提供工程源码与本地开发入口,**尚无正式在线地址**。 +SCUT 老学长已完成 PLAN-3:在单课程问答、考试复习、错题讲解和临时材料精读基础上,新增用户级跨课程检索、公共资料贡献、用户绑定的 7 天私人知识沉淀、回答复制与迁出新对话,以及独立维护者审核平台。跨课程检索默认关闭,开启后可在对话框中按本次运行多选课程插件;贡献资料由维护者下载、审核和手动整理后进入仓库,不执行自动 PR 或自动发布。普通用户界面不包含维护者平台,私人材料不进入公共知识库。 +- 🟢 已完成:课程插件化、单课程与用户级跨课程检索、五类 Workflow、Hybrid Retrieval、引用 Guard、流式运行、用户偏好同步及回答历史; +- 🟡 已完成:回答反馈、回答复制、迁出新对话、公共资料贡献、用户绑定的 7 天私人知识沉淀,以及独立维护者审核平台; +- 🔴 已完成:贡献资料的人工审核、附件下载与仓库手动整理流程;平台不自动创建 PR、commit 或激活公共语料,私人材料不进入公共知识库。 - [查看助手源码](apps/scut-senior/) - [本地运行与参与开发](apps/scut-senior/README.md) -- [查看迭代 2 当前状态与激活限制](apps/scut-senior/ITERATION_2_STATUS.md) +- [查看 PLAN-3 详细方案](apps/scut-senior/docs/senior-3/PLAN-3.md) - [课程资料贡献说明](CONTRIBUTING.md) #### 2.推荐方法(更新,务必查看) @@ -128,7 +131,7 @@ ## 项目开发阶段概览 ##### 一期仓库初期规模5.73G,最终在12.9G时发觉现有方式难以满足需求,进行迭代。此期内容为1-4学期,此阶段加密采用统一字符,并未开源上机考试,并未涵盖高度复习笔记 -##### ⚠目前二期仓库规模约12G,删减了大量第一学期无用冗余资源及其他已开源仓库重复代码,增加第五学期课程资料,合并通选课目录避免视图冗杂,并采用hash(sha256)分别加密,进一步缩减加密范围内容。 +##### 二期仓库规模约12G,删减了大量第一学期无用冗余资源及其他已开源仓库重复代码,增加第六学期课程资料,合并通选课目录避免视图冗杂,并采用hash(sha256)分别加密,进一步缩减加密范围内容。 ##### 预计第三期仓库整个规模大致会在15G左右,阶段性要求如下: **(介于AI发展迅猛,已删除高等目标)** - 🟢 低等目标:将每一门课拓展出一个readme.md文件,包括三部分(可置空): diff --git a/apps/scut-senior/README.md b/apps/scut-senior/README.md index 945895e3..92a390f4 100644 --- a/apps/scut-senior/README.md +++ b/apps/scut-senior/README.md @@ -1,49 +1,171 @@ # SCUT 老学长 -这里是 SCUT 老学长的唯一应用源码目录。迭代 0 的契约与 Mock 工程基座、迭代 1 的本地/测试身份与模型通道、迭代 2 的 candidate/本地检索适配器均已形成代码基座。迭代 3 已在本地/测试环境实现五类 Workflow 共用 Runtime、严格 NDJSON 事件流、同一 run 的运行中/终态持久化、取消与中断、引用 Guard、单次模型生成风格约束、四类回答块以及 Bilibili search-only 降级链路。默认运行仍使用合成 Fixture;在受信 `master` 上生成并激活真实 corpus 之前,迭代 3 只能称为“本地实现与验证完成”,不能称为正式退出。真实 GitHub 凭据回调、用户真实 Key 的供应商实网响应和生产 HTTPS/部署也仍未形成证据。 +SCUT 老学长是面向华南理工大学计算机相关课程的学习对话助手。应用源码、API、语料校验工具、前端、契约和测试均位于本目录。PLAN-3 已完成,应用现在同时提供单课程问答、按本次运行生效的跨课程检索、回答结果操作、公共资料贡献、用户绑定的私人知识沉淀和独立维护者审核平台。 + +## 核心流程 ```text -选择课程与 Workflow -→ 创建并保存 running run -→ 权威 Workflow 输入聚焦与课程内检索 -→ 模型 Markdown 回答与输出风格约束 -→ 引用/回答块安全校验 -→ 可选 Bilibili 匿名搜索入口 -→ 同一 run 的 Trace、answer_delta 与终态事件 -→ 保存并从历史恢复 +GitHub 登录或本地开发身份 +→ 选择课程插件与 Workflow +→ 构造本次运行的课程检索范围 +→ 课程内或跨课程 Hybrid Retrieval +→ 模型生成结构化回答 +→ 引用、课程边界与输出安全校验 +→ NDJSON 流式展示 +→ 保存运行结果与历史 +→ 复制、迁出、反馈、公共贡献或私人知识沉淀 ``` -默认配置仍使用 Mock 身份、Mock 模型、Fixture 检索和本地 SQLite。显式 `github_oauth` 模式已经在本地/测试中闭合 OAuth `state`、GitHub 数字 ID 映射、7 天服务端会话、安全 Cookie、登出/到期、受保护接口、资源所有权以及 SQLite 重启/备份恢复;测试使用可注入回调和替身,不等于已用真实 GitHub 凭据完成线上联调。模型直接生成 Markdown;选择 B站延伸学习时,模型在不可见 sidecar 中返回可选核心知识点和搜索词,后端按“显式搜索词 → 核心知识点 → 当前问题 → 请求/课程兜底”清洗并固定生成 1 条 Bilibili 匿名搜索链接,不返回视频直链,不访问 Bilibili API,也不抓取搜索结果。项目不建设、审核或维护任何具体 Bilibili 视频资产。生产环境在真实 HTTPS、凭据和部署边界未验收前继续拒绝启动。 +## 功能说明 + +### PLAN-1:可追溯的课程学习基座 + +PLAN-1 建立了课程学习助手的基础能力和边界: + +- 面向首批 10 门课程组织经过校验的课程资料与历年题,回答可以关联具体资料、页码、幻灯片或题号; +- 提供 `knowledge_qa`、`exam_review`、`problem_tutor`、`mistake_review` 和 `temporary_material_reading` 五类固定 Workflow,覆盖知识答疑、备考、题目讲解、错题复盘和临时材料精读; +- 所有问答绑定 GitHub 登录身份,并保存可追溯的会话、运行记录、真实执行 Trace、反馈和错题历史; +- 平台每日免费额度模型与用户自带 Key(BYOK)分为独立通道,模型、供应商和调用路由均由服务端受控; +- 模型输出必须经过课程范围、来源、引用和安全回答块校验,资料不足时明确标记证据边界,不将通用知识伪装为课程资料结论。 + +### PLAN-2:统一输入、混合检索与受限 Agent Runtime + +PLAN-2 在一期课程边界与校验机制之上,增强检索质量和运行过程的可控性: + +- 统一 Composer 会以确定性规则自动路由到五类 Workflow;低置信度时可回退知识答疑并允许用户纠正; +- 检索采用 BM25F、本地 CPU ONNX `bge-small-zh-v1.5` dense 检索、RRF 融合与确定性规则重排;dense 模型或向量资产缺失时自动退回 BM25F,不发起网络请求; +- 使用已审核的 P0 检索评测基线持续验证召回效果、精确匹配和课程边界,候选、引用与 `corpus_version` 可回查; +- EventStream Agent Loop 以事件、Reducer 和动作白名单执行受限单步决策,服务端负责工具执行与 Guard 校验;取消、超时、越权、预算耗尽和失败都会收敛到明确终态; +- `exam_review` 根据大纲、考试时间、目标和薄弱点生成确定性复习计划,支持先预览、再确认或修改,并不额外消耗模型调用。 + +### PLAN-3:课程协同检索与知识共建 + +PLAN-3 将一期、二期已建立的课程边界、检索与运行时能力扩展为可控的协同学习与知识沉淀闭环: + +- 用户可在明确开启开关后,为单次运行选择多门课程;课程范围只在该次运行内生效,模型不能自行扩大检索范围; +- `knowledge_qa` 与 `problem_tutor` 支持跨课程 Hybrid Retrieval,并在回答中同时展示选择范围和实际产生引用的课程; +- 回答完成后可复制、迁出为新对话草稿、提交反馈、贡献公共资料或加入私人知识库,原会话和运行记录保持不变; +- 公共贡献以本轮输出为单位提交到审核队列,支持补充文本或原始附件,由维护者下载、审核、整理后决定是否纳入公共课程资料; +- 私人知识库材料绑定用户和课程,保留 7 天,仅在用户本人、本次选择课程和材料未过期等条件同时满足时参与检索; +- 独立维护者平台负责处理反馈和公共贡献,维护者权限与普通用户权限隔离,平台不自动创建 GitHub PR、提交仓库或激活公共语料。 + +### 课程插件与跨课程检索 + +课程插件负责声明课程是否可用以及是否有可检索语料;对话框中的课程选择负责决定本次运行使用哪些课程。两者职责分离:插件管理不会替代本次对话的课程选择。 + +个人中心的助手设置中有“允许跨课程检索”开关: + +- 默认关闭。 +- 设置跟随 GitHub 用户保存到服务端账户偏好。 +- 关闭时,对话框课程列表为单选。 +- 开启时,对话框课程列表支持多选。 +- 多选结果只对本次运行生效,同一会话的下一次运行可以重新选择。 +- 本次运行选择多少课程插件,就检索多少课程插件。 +- 未启用、不可用或未被选择的课程不会参与检索。 +- 模型不能自行扩大课程范围。 +- 没有有效证据的课程不会被强行写入回答。 -平台每日免费额度目录固定登记三项模型,页面显示公司名并由用户显式选择: +跨课程检索下方会提示:私人知识库材料较多时,跨课程检索可能明显变慢。回答结果会显示本次选择的课程和实际产生引用的课程,便于确认检索范围。 -- Google · Gemma 4 26B A4B; -- Dots Studio · Dots3 Note Preview; -- NVIDIA · Nemotron 3 Super 120B A12B。 +跨课程第一版开放 `knowledge_qa` 和 `problem_tutor`;其他 Workflow 保持各自既有课程边界和输入合同。 -该通道每日刷新但不是无限额度。额度耗尽、分钟限流或上游繁忙会分别报错,不会自动切换模型、用户 Key 或付费端点。 +### Workflow -BYOK 首批每家只保留一个固定目录模型: +当前保留五类受控 Workflow: -- OpenRouter:`deepseek/deepseek-v4-flash-0731`(当日 OpenRouter 周榜第 1); -- DeepSeek:`deepseek-v4-flash`(官方稳定别名); -- 硅基流动(SiliconFlow):`Pro/zai-org/GLM-4.7`(2026-08-16 官方 Chat Completions 默认示例); -- 智谱(Zhipu):`glm-5.2`(2026-08-16 当前可调用旗舰与 OpenAI 兼容示例;GLM-5.3 当日仍未开放 API)。 +- `knowledge_qa`:课程知识问答; +- `exam_review`:根据大纲、考试时间、目标和薄弱点生成确定性复习计划; +- `problem_tutor`:按用户指定的帮助程度讲解题目; +- `mistake_review`:分析原答案、参考答案和错误重点; +- `temporary_material_reading`:对用户提供的临时材料进行精读。 + +前端使用确定性路由和统一 Composer,后端使用严格的 `WorkflowRunRequest` 合同。模型只负责受控的回答生成,课程范围、工具、引用和回答块由服务端校验。 + +### 检索与回答 + +本地语料模式使用 BM25F 与本地 CPU ONNX `bge-small-zh-v1.5` 的 Hybrid Retrieval,并使用确定性规则重排。dense 模型文件或向量资产缺失时,检索自动退回 BM25F,不发起网络请求。 + +回答输出经过兼容解析、来源 Guard、引用 Guard 和安全回答块处理后,才通过 NDJSON 流发送到前端。流式事件包括 Trace、回答增量、Agent 进度、终态结果和错误事件。运行中的取消、断线、预算耗尽和上游错误都有明确终态,并保存可恢复的运行记录。 + +### 回答结果操作 + +回答完成后,结果区域提供以下操作: + +- **复制本轮输出**:复制当前回答的可读正文和必要引用,不复制内部 Trace、模型原始响应、凭据或内部控制字段; +- **迁出当前分支到新对话**:创建新的会话并把当前回答填入可编辑的对话草稿,原会话和原运行保持不变,不自动触发模型调用; +- **我要反馈**:对本轮回答标记有帮助、没帮助、知识错误或没有回答问题,并可补充说明; +- **我要贡献**:将本轮输出提交到公共贡献队列; +- **加入私人知识库**:将当前内容绑定到用户和课程,在限定时间内供该用户后续对话检索。 + +### 公共资料贡献 + +“我要贡献”只提交本轮对话输出,不默认提交完整历史对话。用户提交时需要: + +- 填写材料总结标题; +- 填写 GitHub 绑定邮箱; +- 确认课程、来源、公开分享权、敏感信息和公开可见性; +- 预览最终提交内容; +- 根据需要补充文本或上传原始文件。 + +支持的常见资料格式包括: + +```text +.pdf +.png / .jpg / .jpeg / .webp +.doc / .docx +.ppt / .pptx +.xls / .xlsx +.csv +.md / .txt +``` -四家卡片始终显示,地址和模型均由服务端固定,不需要也不允许用户选择地区或地址、填写 `base_url` 或修改 `model_id`。服务端满足 GitHub OAuth、SQLite 和 AES-256-GCM 主密钥条件后将四家标记为启用;只有当前登录会话已保存对应 Key 的模型才进入可选列表,保存 Key 后也不会自动切换模型。Key 密文绑定 `user_id + auth_session_id + provider_id`,不晚于 7 天登录会话到期,并在删除、登出、到期或备份恢复失效会话时清理。自动化测试通过注入 HTTP 替身验证固定路由和安全错误边界,不代表已持有用户供应商账号或形成真实 Key 的实网证据。 +文件按原始附件保存,不在维护平台中渲染。维护者通过下载按钮取得文件,在本地完成审核、整理、格式转换和仓库提交。平台不自动创建 GitHub PR、不自动 commit、不自动激活公共语料。 -迭代 7(临时材料精读治理与贡献待处理队列)已本地实现:粘贴的文本/Markdown 可保存为会话内私有材料(7 天 TTL 到期物理删除,永不进入公共索引、课程包或跨用户检索候选);“材料写了什么”以材料原文优先、“说得对不对”以仓库资料核验、冲突分别陈述。贡献流程提供确定性转换预览、五项强制确认(含公开 PR 长期可见提示)、`draft/submitted/pr_open/merged/rejected/expired` 六态状态机与维护者待处理队列;待审副本最多保留 30 天。GitHub App 决策门未确认前不自动创建 PR、不自动合并;内容真正进入 active 语料仍必须经过仓库 PR → 人工 `passed` → candidate 验证链。详见 `ITERATION_7_STATUS.md`。 +附件使用独立上传接口,采用随机文件 ID 保存,不进入静态资源目录;下载接口执行权限校验并以附件方式返回。压缩包、在线预览、OCR、自动 Office/PDF 解析和附件直接检索不属于当前贡献流程。 + +### 私人知识库 + +私人知识库材料绑定当前用户,保留 7 天,到期由服务端物理清理。私人材料不进入公共索引、公共课程包或其他用户的检索结果,也不提供手动查看、删除和用户导出入口。 + +材料按照课程插件名或课程 ID归属。检索时同时满足以下条件才会使用: + +```text +user_id == 当前用户 +course_id 属于本次运行选择的课程 +对应课程插件已启用且可用 +expires_at > 当前时间 +visibility == private +``` + +跨课程关闭时,只检索当前单课程的私人材料;跨课程开启时,检索本次选择课程范围内的私人材料。跨课程开关不会自动扩展到全部课程。 + +私人知识库与公共贡献共用内容校验和记录抽象,但公共待审核内容与私人材料的权限、状态和检索范围严格分离。 + +### 维护者平台 + +维护者平台使用独立路由,与普通对话助手分离。只有固定维护者 allowlist 中的账号可以访问维护接口和页面;普通 GitHub 登录用户不自动拥有维护者权限。 + +维护平台提供: + +- 反馈队列; +- 公共贡献队列; +- 贡献详情; +- 用户、课程、Workflow、标题、提交时间和状态查看; +- 文本内容和附件下载; +- 采纳、拒绝、要求补充和标记已处理。 + +维护者平台不承担在线文档渲染,也不执行仓库写操作。维护者下载后自行审核和整理,并根据用户填写的 GitHub 邮箱处理 `Co-authored-by` 信息。 ## 目录 -- `web/`:Vue 3 学生端及严格流事件客户端; -- `api/`:FastAPI 契约、本地/测试 OAuth 与会话链路、Workflow Runtime、SQLite 和本地 corpus 检索适配器; -- `worker/`:manifest/frontmatter/locator 校验及 candidate 构建、验证、激活门与回退工具; -- `packages/`:V1 枚举、课程注册表、Workflow 和评测契约; -- `infra/`:不含真实 Secret 的 SWR→ECS 部署骨架; -- `tests/`:纯合成 Fixture、单元测试和端到端契约测试。 +- `web/`:Vue 3 学生端、回答展示、课程选择、设置、贡献入口和严格流事件客户端; +- `api/`:FastAPI API、OAuth 与会话、Workflow Runtime、检索、引用 Guard、SQLite 和维护者接口; +- `worker/`:manifest、frontmatter、locator 校验以及 candidate 构建、验证和激活工具; +- `packages/`:V1 枚举、课程注册表、Workflow、流事件和评测契约; +- `infra/`:不含真实 Secret 的部署骨架; +- `tests/`:API、前端、契约、检索、运行时、贡献和维护边界测试。 -`学科资料/` 不会被应用测试读取,也不会被 Docker build context 包含。测试中的 `passed` 仅描述合成 Fixture,不代表任何真实课程资料已经通过人工审核。 +`学科资料/` 不会被应用测试读取,也不会被 Docker build context 包含。公共语料必须经过课程 manifest、frontmatter、locator、candidate 和人工审核链路后才能进入 active corpus。 ## 本地运行 @@ -64,71 +186,40 @@ cd apps/scut-senior/web npm run dev ``` -默认 API 为 `http://127.0.0.1:8000`,Vite 开发服务器会代理 `/api`。本地 SQLite 写入 `apps/scut-senior/.local/`,不会提交到 Git。 - -### 检索模式边界 +默认 API 为 `http://127.0.0.1:8000`,Vite 开发服务器代理 `/api`。本地 SQLite、上传附件、日志和缓存写入 `apps/scut-senior/.local/`,不会提交到 Git。 -默认 `SCUT_SENIOR_RETRIEVAL_MODE=fixture`,继续读取合成测试语料。`local_corpus` 不能直接读取当前迭代分支生成的 candidate;只有 candidate 的 `source_commit` 已进入受信 `master`、且通过单独激活门后,才可将 `SCUT_SENIOR_CORPUS_STORE_PATH` 配置为该 corpus store 的绝对路径。缺少有效 `active.json`、课程未启用或版本绑定不完整时,本地语料检索会故障安全关闭,不回退到跨课程或未审核资料。 - -PLAN-2 阶段一的当前 active candidate 已随仓库提供: -`SCUT_SENIOR_RETRIEVAL_MODE=local_corpus`,并将 -`SCUT_SENIOR_CORPUS_STORE_PATH` 指向 `.local/corpus-store` 即可在本机运行 -BM25F + 本地 CPU ONNX `bge-small-zh-v1.5` Hybrid 检索。模型文件缺失时自动退回 -BM25F,不发起网络请求;SQLite 运行库、环境文件、日志和缓存仍只保留在本机。 - -阶段一对照评测可在 `apps/scut-senior` 目录执行: +## 常用命令 ```bash -api/.venv/bin/python -m scut_senior_api.eval_runner \ - --retrieval-only --golden resources/evaluation/retrieval-golden \ - --corpus-store .local/corpus-store --report /tmp/retrieval-bm25f.json -api/.venv/bin/python -m scut_senior_api.eval_runner \ - --retrieval-only --hybrid --golden resources/evaluation/retrieval-golden \ - --corpus-store .local/corpus-store \ - --onnx-model-dir .local/models/bge-small-zh-v1.5 \ - --report /tmp/retrieval-hybrid.json +make test # Python 与 Web 测试、类型检查 +make build-web # 前端类型检查并构建 dist +make validate-fixtures # 校验合成语料 +make check-contracts # 检查导出契约 +make serve-online # 启动前端与 API 的同进程服务 ``` -当前审核基线与结果摘要见 `resources/evaluation/retrieval-comparison.json`;换 -语料或 embedding 模型后必须重新生成并核对对应的 `corpus_version`。 +## 检索模式 -### 本地验证 OpenRouter 平台通道 - -先在 OpenRouter 控制台创建一枚新的服务端 Key,并只通过本机环境变量提供;不要把 Key 写入 `.env.example`、Git、前端或聊天记录。真实平台通道会消耗共享额度,因此开发环境也必须同时启用 GitHub OAuth 与正式 SQLite 身份存储,不能搭配匿名 Mock 身份: +默认配置使用 Mock 身份、Mock 模型、Fixture 检索和本地 SQLite,适合开发与自动化测试。使用本地语料时: ```bash -export SCUT_SENIOR_APP_ENV=development -export SCUT_SENIOR_IDENTITY_MODE=github_oauth -export SCUT_SENIOR_STORAGE_MODE=sqlite -export SCUT_SENIOR_DATABASE_PATH='.local/iteration-one.db' -export SCUT_SENIOR_GITHUB_CLIENT_ID='' -export SCUT_SENIOR_GITHUB_CLIENT_SECRET='' -export SCUT_SENIOR_GITHUB_CALLBACK_URL='https:///api/v1/auth/github/callback' -export SCUT_SENIOR_POST_LOGIN_REDIRECT_URL='https:///' -export SCUT_SENIOR_MODEL_MODE=openrouter_platform -export SCUT_SENIOR_OPENROUTER_API_KEY='' -# 可选:智谱 bigmodel 免费模型(GLM-4.7-Flash / GLM-4-Flash-250414 / GLM-4.6V-Flash) -export SCUT_SENIOR_ZHIPU_API_KEY='' +export SCUT_SENIOR_RETRIEVAL_MODE=local_corpus +export SCUT_SENIOR_CORPUS_STORE_PATH='.local/corpus-store' make dev-api ``` -默认 `mock` 模式不读取该变量。`openrouter_platform` 暂时只供受认证的本地开发验证,检索仍是合成 Fixture;至少配置一个平台 Key(OpenRouter 和/或智谱 bigmodel)即可启用对应供应商的模型。OpenRouter 模型在公开目录中通过“仍存在、文本输入输出价格为零、支持结构化输出”的健康检查后才可选择;智谱的三个一方固定免费模型(`glm-4.7-flash`、`glm-4-flash-250414`、`glm-4.6v-flash`)无公开零价目录可复核,按“Key 已配置即声明可用、未知模型不可用”的失败关闭策略处理,其中 `glm-4.6v-flash` 未声明结构化输出能力。三者均记录 `last_checked_at`。`SCUT_SENIOR_APP_ENV=production` 仍会拒绝启动。 +本地语料模式要求 corpus store 具有有效的 active candidate、课程启用状态和版本绑定。缺少任一必要资产时,系统故障安全关闭,不回退到未审核资料或未选择课程。 -### 本地验证 BYOK +## 真实身份与模型通道 -项目方不需要持有四家供应商账号或余额。服务端只需在真实 GitHub 登录、SQLite 和 HTTPS 边界上额外配置一枚稳定的 32 字节 AES 主密钥;普通用户随后只在页面对应卡片中提交自己的供应商 API Key: +真实 GitHub OAuth 使用 HTTPS 回调地址、服务端 SQLite 和安全 Cookie。平台模型和 BYOK 凭据由服务端固定目录管理;用户 Key 使用服务端 AES-256-GCM 主密钥加密,前端只接收脱敏状态。凭据、OAuth Secret、数据库、附件和日志不进入 Git、前端构建产物或 Docker 镜像。 -```bash -openssl rand -base64 32 -export SCUT_SENIOR_BYOK_MASTER_KEY='' -export SCUT_SENIOR_BYOK_KEY_VERSION=1 -``` +本地测试仍推荐使用 Mock 配置。真实平台模型调用必须启用 GitHub OAuth 和正式 SQLite 身份存储,并通过环境变量提供服务端 Secret。模型供应商适配遵循 `ModelGateway` 与 `UserKeyModelGateway` 接口,新增 Terra 等供应商时只需接入固定目录和对应适配器,不改变课程、引用、权限和流式协议边界。 -主密钥不能使用供应商 API Key 代替,也不能提交到 Git、写入浏览器或随意更换;正式部署时应进入受保护的运行 Secret。配置满足后四家卡片会显示为“服务端已启用”,但模型仍要等当前登录会话保存对应用户 Key 后才可选择。用户 Key 只通过 `PUT /api/v1/model-credentials/{provider_id}` 提交,返回值只有脱敏状态与到期时间;真实调用只会发往代码内固定的四个地址。没有用户 Key 时不能验证该用户账户的余额、权限或供应商实网响应,但这不影响固定路由、加密、清理和泄漏边界的自动化验收。 ## 在线部署:本地运行 + HTTPS 隧道(当前启用路径) -当前启用路径是**本机运行 + HTTPS 隧道**:前端与 API 由同一进程提供,隧道把本机端口暴露成公网 HTTPS 域名,满足 GitHub OAuth 回调与 Secure Cookie 要求。**华为云 SWR→ECS 部署设计原样保留**(见下文“明确关闭或待确认”),预算获批后作为后续可选目标切换,不需要改动应用代码。 +当前启用路径是**本机运行 + HTTPS 隧道**:前端与 API 由同一进程提供,隧道把本机端口暴露成公网 HTTPS 域名,满足 GitHub OAuth 回调与 Secure Cookie 要求。**华为云 SWR→ECS 部署设计原样保留**,预算获批后作为后续可选目标切换,不需要改动应用代码。 > ⚠️ 会话 Cookie 为 `Secure`,**OAuth 联调必须通过隧道的 HTTPS 域名访问,不能走 `http://127.0.0.1`**。 @@ -207,24 +298,4 @@ git sparse-checkout init --cone git sparse-checkout set apps/scut-senior .github README.md .gitignore .gitattributes git checkout master ``` - -## 明确关闭或待确认 - -- 迭代 4 切片(已收口,2026-08-23 改写):多轮上下文已接入——服务端从会话历史派生最多 6 轮已完成尝试作为模型上下文,历史不改变当前请求的课程、Workflow 或知识范围;回答反馈已闭环——`POST/GET /api/v1/feedback` 绑定运行归属并随 30 天历史清理,反馈只进入列表、不自动修改知识库;评测执行器 `scut-senior-eval` 已可执行 7 类 fixture case 并输出逐课程报告,当前 fixture+mock 仅覆盖 answered/repository/page 契约,其余期望须待真实 corpus 与真实模型(不伪造通过); -- 迭代 5 备考复习(本地/测试实现完成):`exam_review` 新增确定性备考计划——有大纲按“用户大纲 > 课程资料 > 历年题 > 标记的通用知识”,无大纲按“历年题 > 课程资料”,并明确声明不是官方范围、不构成考试重点预测;输出历年题年份覆盖、题型分布与客观出现次数(每条可回查题目来源)、按已审核标题组织的知识点分层与建议顺序、未覆盖大纲条目和 AI 样题边界;私有大纲/薄弱点只影响本人计划排序,不进入公共课程包、Trace 或跨用户缓存;`SCUT_SENIOR_EXAM_REVIEW_PLAN_ENABLED=false` 可整体关闭回到迭代 4 行为。统计质量受已审核语料的标题与题号标记限制(无题目标记的历年卷不计入统计),逐课程真实模型评测仍未完成; -- OpenRouter 三模型平台目录、显式选择、目录健康检查和本地开发调用适配器:迭代 1 已实现;健康检查不发起推理,不能当作真实回答可用性证据; -- BYOK 固定目录、会话级 AES-256-GCM 保存/替换/删除/清理、四家固定调用和安全错误映射:迭代 1 已在本地/测试实现;未使用真实用户 Key 做四家实网联调; -- GitHub OAuth:本地/测试适配器、7 天会话、所有权和 SQLite 恢复已实现;真实 GitHub 凭据回调、生产 HTTPS 与部署尚未联调,生产继续 fail-closed; -- 平台额度与清理(迭代 7.5 已落地本地实现):平台 RPM/每日额度锁存迁移到 SQLite 共享存储(重启不丢失、多 worker 不重复发放);进程内周期清理调度器按固定间隔物理清理到期数据,停机窗口由启动补扫覆盖;账号注销/历史提前删除/数据导出已实现(注销后无法再登录,导出不含他人资源与任何 Key 明文/密文)。生产多副本部署下的调度形态仍按单机语义如实标注; -- 历史首批课程曾固定为 10 门;当前 PLAN-2 active Hybrid candidate 已扩展为 46 门(active 版本与启用开关以 `.local/corpus-store/active.json` 为准),未配置受信 active store 的环境默认检索仍是 Fixture;远端 CI 在固定提交上的通过记录属外部证据项。**2026-08-23:manifest 余量 4 行纯图无文本层文件(电工学原卷答案/原卷·机械、java 往年试题、移动应用开发清朝试卷)经维护者批准转为 `passed`——按整页图预览保留、不产出文本 chunk(图片 OCR 三道闸判定不达标,迭代 8 维持关闭),active 语料内容不变(这 4 个文件本就零 chunk),下次重建自然吸收;** -- **资料贡献面板为维护者内部功能**(2026-08-23 决议):贡献提交的状态机、队列与治理后端已完整实现并测试,前端 `MaterialContributionPanel` 的提交入口以 `CONTRIBUTION_SUBMIT_CLOSED=true` 封闭——不向终端使用者开放众包投稿;维护者仍可通过既有工具链与队列视图处理资料。上线开放时把该常量改回 `false` 即可整体恢复; -- 检索相关性分数地板(迭代 7.5 加权重叠 6 分 → PLAN-2 阶段一 步骤 2 升级为 BM25F 分数地板,默认 1.0):`SCUT_SENIOR_RETRIEVAL_MIN_SCORE` 为 local_corpus 检索的 BM25F 分数下限,单一中文 bigram 碰撞这类噪声候选不再进入引用 Guard;无候选过线的查询诚实返回"证据不足"。BM25F 已引入 IDF、字段权重(title>heading/question>text)与 k1 饱和抑制,精确命中(题号/公式/函数名)不因算法替换回退;阈值由 P0 golden set 重定标(见 `resources/evaluation/retrieval-golden/`); -- PLAN-2 dense 检索使用本机 CPU ONNX `bge-small-zh-v1.5`(512 维)+ SQLite cosine。`SCUT_SENIOR_DENSE_RETRIEVAL_ENABLED` 默认开启;模型目录缺失或没有对应向量时自动退回 BM25F,不发起网络请求。最终规则重排始终先保留 BM25F 候选,dense 只能补足空槽位,原查询的整串词法命中受保护,不会被小模型语义相似度挤掉。 -- Workflow Runtime 与严格 NDJSON Trace:迭代 3 已完成本地/测试实现;供应商回答会先经兼容解析(自然语言、JSON 或 JSON 代码围栏)与来源 Guard,再按安全回答块发送 `answer_delta`,不是上游 token 原样透传;页面断开即请求尽力取消上游调用(迭代 7.5 可取消 transport:放弃等待、运行收敛为 `interrupted` 并留 trace/日志证据),被放弃的上游套接字按其自身超时回收,供应商侧是否停止计费无法在本进程内证实、只如实描述; -- 华为云部署:**设计原样保留,规划改期到迭代 10**(2026-08-23 决议);预算获批前保持 validation-only/fail-closed,不创建资源;未来首发基线为华南-广州优先的 1C2G、40GB、1~2Mbps,不在 ECS 部署大模型。当前启用路径为“本机运行 + HTTPS 隧道”(见上文“在线部署”节),该隧道口径继续作为 GitHub OAuth 回调的验收基线; -- PostgreSQL、Qdrant、对象存储、任务系统、GitHub App、SWR 认证、ECS 灰度/回滚:首发不购买或只保留可替换边界; -- 跨课程:契约已冻结,feature flag 默认关闭; -- Bilibili:只保留 0~3 个聚焦词和关键词非空时由后端生成的唯一匿名搜索链接;不建设或维护具体视频资产,不返回或抓取具体视频; -- 正式在线 Chat:迭代 4 验收前不提供对外地址;联调地址由本机 + HTTPS 隧道提供。 - -迭代 0 的完整基线见 [ITERATION_0_STATUS.md](ITERATION_0_STATUS.md);平台模型切片和仍未完成的迭代 1 边界见 [ITERATION_1_STATUS.md](ITERATION_1_STATUS.md);迭代 2 的开发门与激活限制见 [ITERATION_2_STATUS.md](ITERATION_2_STATUS.md);迭代 3 的实现、验证和正式退出阻塞见 [ITERATION_3_STATUS.md](ITERATION_3_STATUS.md);迭代 4 的 DSH 启发的受控插件化、BYOK 对齐、五 Workflow 打通与前端打磨见 [ITERATION_4_STATUS.md](ITERATION_4_STATUS.md);迭代 5 的备考复习双路径、客观统计与隔离边界见 [ITERATION_5_STATUS.md](ITERATION_5_STATUS.md)。 +维护清理由进程内调度器执行,启动时补扫并按固定间隔清理到期会话、历史、反馈、私人材料、贡献记录和额度事件。清理步骤彼此隔离,单个存储步骤异常不会阻断同一轮其他步骤。 \ No newline at end of file diff --git a/apps/scut-senior/docs/senior-3/PLAN-3.md b/apps/scut-senior/docs/senior-3/PLAN-3.md new file mode 100644 index 00000000..bcf2f33b --- /dev/null +++ b/apps/scut-senior/docs/senior-3/PLAN-3.md @@ -0,0 +1,777 @@ +# SCUT 老学长 PLAN-3:课程协同检索与知识共建 + +版本:0.3(确认版,详细实施方案) + +状态:**方案已确认,待分阶段实施**。 + +本文将 PLAN-3 定义为一次真正的大版本迭代,同时吸收四项低风险维护修复。四项修复不单独构成功能版本;大版本价值来自跨课程检索、公共贡献闭环、私人知识沉淀以及回答结果操作能力。 + +本文不绑定具体模型供应商。后续接入 Terra 或其他模型时,应继续复用现有 `ModelGateway`、Workflow 合同、检索接口、引用 Guard、流式协议和用户权限边界,不因更换模型重做本计划的用户功能。 + +--- + +## 1. 版本定位 + +### 1.1 本版本解决的问题 + +当前系统已经能够完成单课程问答、考试复习、错题讲解、临时材料精读,并具备课程插件、Hybrid Retrieval、引用校验、用户会话、反馈和临时材料基础能力。 + +PLAN-3 进一步解决四个问题: + +1. 用户需要在多个课程之间进行关联学习,但当前运行时拒绝跨课程检索。 +2. 用户看到有价值的回答后,缺少直接贡献给仓库维护者的入口。 +3. 用户希望把本轮有价值内容暂时带入后续对话,但当前私人材料主要是会话内临时内容。 +4. 用户需要对本轮结果进行复制或从当前分支继续开始新的对话。 + +### 1.2 大版本主线 + +```text +用户级跨课程开关 + → 本次运行选择多个课程插件 + → 受限多课程检索与按课程引用 + → 回答结果操作:复制 / 迁出新对话 + → 公共贡献或私人知识沉淀 + → 维护者独立审核平台 +``` + +### 1.3 版本边界 + +本版本: + +- 不新增独立模型服务。 +- 不新增 PostgreSQL、消息队列、对象存储或向量数据库服务。 +- 不引入自动 GitHub PR、自动 commit 或自动激活公共语料。 +- 不把普通 GitHub 用户默认视为维护者。 +- 不在维护平台渲染 PDF、Office、图片等附件。 +- 不把私人知识库内容自动公开。 +- 不把文件上传直接等同于可检索语料。 + +--- + +## 2. 当前代码基础与证据 + +以下能力已经存在,可以直接复用: + +- 用户偏好服务端同步:`apps/scut-senior/web/src/composables/useAppStore.ts#persistAccountPreferences`(L293-L305)和 `apps/scut-senior/api/src/scut_senior_api/main.py#account_preferences`。 +- 跨课程请求合同:`apps/scut-senior/api/src/scut_senior_api/contracts.py#WorkflowRunRequest.enforce_v1_invariants`(L187-L198)已支持 `CourseScope.CROSS` 与 `allowed_course_ids`。 +- 跨课程能力开关:`apps/scut-senior/api/src/scut_senior_api/config.py#Settings`(L41、L103)已有 `cross_course_enabled`。 +- 当前跨课程运行阻断点:`apps/scut-senior/api/src/scut_senior_api/service.py#IterationZeroService._run`(L750-L759)。 +- 课程插件状态:`apps/scut-senior/api/src/scut_senior_api/main.py#plugin_registry`、`service.py#load_course_plugin` 和 `sqlite.py#is_course_plugin_loaded`。 +- 检索接口:`apps/scut-senior/api/src/scut_senior_api/ports.py#RetrievalGateway`(L108-L112)。 +- 引用和流式一致性校验:`apps/scut-senior/web/src/workflowStream.ts#reduceWorkflowStreamEvent`(L213-L273)与 `workflowResultValidation.ts`。 +- 会话、运行和回答尝试:`apps/scut-senior/api/src/scut_senior_api/ports.py#WorkflowRepository`(L149-L196)、`sqlite.py#list_conversations`(L1456-L1469)和 `contracts.py#WorkflowAttempt`(L553-L575)。 +- 回答反馈:`apps/scut-senior/api/src/scut_senior_api/contracts.py#FeedbackRecord`(L603-L615)、`sqlite.py#save_feedback`(L1471-L1494)。 +- 临时材料:`apps/scut-senior/web/src/components/MaterialContributionPanel.vue` 与 API 中的 temporary-materials 路由。 +- 公共贡献基础:`MaterialContributionPanel.vue#onSubmit`(L148-L179)、`main.py#submit_contribution`(L1227-L1236)、`main.py#maintainer_contribution_queue`(L1268-L1284)。 +- 流式回答结果已经有操作上下文:`apps/scut-senior/web/src/components/WorkflowResult.vue`。 + +--- + +## 3. 版本交付结构 + +### 3.1 随版本交付的维护修复包 + +以下四项属于当前版本维护修复,建议单独提交,先于功能主线合并: + +1. 维护清理按步骤隔离异常。 +2. `_ACTIVE_STREAMS` 增加并发安全 registry 操作。 +3. 流式 EOF 缺少 `result/error` 时报告协议异常。 +4. 账户偏好保存请求合并或串行化。 + +它们只修正已有机制的边界行为,不改变用户功能定位。 + +### 3.2 大版本功能包 + +| 功能包 | 内容 | 性质 | +| --- | --- | --- | +| B | 用户级跨课程检索 | 核心大版本能力 | +| C-1 | 公共贡献入口与维护者平台 | 核心大版本能力 | +| C-2 | 用户绑定的私人知识沉淀 | 核心大版本能力 | +| D-1 | 复制本轮输出 | 低成本附属能力 | +| D-2 | 迁出当前分支到新对话 | 低到中成本附属能力 | + +--- + +## 4. B:用户级跨课程检索 + +### 4.1 产品语义 + +跨课程能力是一个用户偏好和本次运行范围的组合: + +- 用户在个人中心设置“允许跨课程检索”。 +- 默认关闭。 +- 设置写入服务端用户偏好,跟随 GitHub 账号同步。 +- 设置打开后,对话框课程列表支持多选。 +- 课程选择只对本次运行生效,不固定整个会话。 +- 本次运行选择多少课程插件,就检索多少课程插件。 +- 没有有效语义命中的课程不会被强行写入回答。 +- 跨课程能力打开不等于自动检索所有课程。 + +### 4.2 用户设置 + +建议新增偏好键: + +```text +cross_course_search_enabled: "true" | "false" +``` + +前端: + +- 在助手设置中增加二值控件。 +- 默认值为 `false`。 +- 未登录时使用本地默认值,但不能发起跨课程运行。 +- 登录后从服务端读取并覆盖本地缓存。 +- 保存失败不回滚当前界面,但显示非阻断提示。 + +控件下方显示固定说明: + +> 开启后可在当前对话中选择多个课程插件进行检索。私人知识库材料较多时,跨课程检索可能明显变慢。 + +这段说明应同时覆盖公共课程检索和私人材料检索,不承诺固定延迟。 + +### 4.3 对话框课程选择 + +关闭跨课程时: + +- 保持当前单选交互。 +- 请求使用 `CourseScope.SINGLE`。 +- `allowed_course_ids` 保持为空。 + +开启跨课程时: + +- 课程列表变为多选。 +- 至少选择两门课程才使用 `CourseScope.CROSS`。 +- 只显示或允许选择可用课程插件。 +- 选择一门时可以自动降级为单课程请求,也可以提示用户至少选择两门;建议自动降级,减少无意义错误。 +- 清空选择时沿用现有课程必选校验。 +- 选择组合仅保存为当前草稿/当前运行状态,不写入会话固定配置。 + +请求应使用已有字段: + +```json +{ + "course_scope": "cross", + "course_id": null, + "allowed_course_ids": ["course_a", "course_b"] +} +``` + +### 4.4 后端运行逻辑 + +解除 `service.py#IterationZeroService._run` 中对跨课程运行的第二层阻断,但保留显式配置门: + +1. `cross_course_enabled` 为 `false` 时,继续返回 `CapabilityUnavailable`。 +2. 请求课程数量少于 2 时拒绝或由前端降级为单课程。 +3. 每个课程必须存在于 registry。 +4. 每个课程必须满足插件已加载和 retrieval 可用。 +5. 过滤重复课程和非法课程。 +6. 将最终课程列表传给现有 `RetrievalGateway.search`。 +7. 保留现有 `knowledge_scope`、Bilibili 开关、模型和引用 Guard 逻辑。 +8. 不允许模型自行增加课程,不从模型输出反推检索范围。 + +第一版只建议开放: + +- `knowledge_qa` +- `problem_tutor` + +`exam_review`、`mistake_review` 和 `temporary_material_reading` 可以先沿用单课程约束,避免一次引入多种跨课程语义。 + +### 4.5 检索和回答边界 + +跨课程检索并不保证每个被选择的课程都能命中。正确语义是: + +```text +选择课程集合 +→ 各课程独立执行现有相关性过滤 +→ 合并通过阈值的候选 +→ 由现有规则重排和引用 Guard 处理 +→ 只回答有证据支持的内容 +``` + +需要增加或确认的 Trace 信息: + +- 请求选择的课程集合。 +- 实际可用的课程集合。 +- 每门课程候选数量。 +- 每门课程通过相关性阈值的数量。 +- 最终被引用的课程集合。 +- 没有命中的课程列表。 + +前端结果区域建议显示简短元数据,例如: + +```text +本次检索:数据结构、操作系统 +实际引用:数据结构 +``` + +不需要把每一轮检索过程暴露成复杂面板。 + +### 4.6 历史记录语义 + +跨课程选择按本次运行生效,因此同一会话内可以出现不同课程组合。 + +为避免历史界面误导: + +- 会话可以继续保留现有主课程归属,减少数据库迁移。 +- 每个运行结果应保存本次实际 `allowed_course_ids`,这是推荐的最小新增字段。 +- 历史列表中跨课程运行显示“综合检索”标识。 +- 运行详情显示本次课程集合。 +- 不把跨课程运行偷偷归类成只属于第一门课程的运行。 + +如果最终确认不希望增加运行记录字段,则必须接受历史无法完整还原当时课程选择;不建议采用这个降级。 + +### 4.7 B 的验收标准 + +- 默认关闭时,现有单课程流程和请求合同不变。 +- 开启后可以选择两个及以上可用课程并成功运行。 +- 未加载、不可用、重复和非法课程均 fail-closed。 +- 模型不能扩大课程范围。 +- 最终引用中的 `course_id` 均属于本次选择集合。 +- 某课程没有有效证据时,不生成该课程的虚假结论。 +- 历史详情可以看到本次运行实际课程集合。 +- 私人材料只按用户和课程过滤,不能跨用户读取。 +- 跨课程说明文案出现且不承诺固定性能。 +- 单课程回归测试全部通过。 + +--- + +## 5. C:公共贡献与维护者审核 + +### 5.1 回答区入口 + +回答结果区域重新组织为同级操作: + +```text +回答反馈 +├── 有帮助 / 没帮助 / 知识错误 / 没有回答问题 +├── 我要贡献 +└── 加入私人知识库 +``` + +“我要贡献”不是普通反馈类型,后端应保留独立贡献记录;只是 UI 上与反馈同级,方便用户在看到回答后立即操作。 + +### 5.2 公共贡献内容 + +点击“我要贡献”后,用户只能提交当前轮次的输出内容,不默认提交完整历史对话。 + +界面需要明确显示: + +> 仅本轮对话输出内容会提交。请填写一个能概括材料内容的标题,并确认你拥有公开分享权限。 + +提交内容包括: + +- 当前回答正文。 +- 必填总结标题。 +- 当前课程。 +- 当前 Workflow 类型。 +- 当前运行的引用和 corpus 版本元数据。 +- 用户可选补充文本。 +- 用户可选上传文件。 +- 用户填写的 GitHub 绑定邮箱。 +- 现有公开分享、来源和敏感信息确认。 + +用户问题和完整上下文仅作为维护者审核上下文,不自动成为公开仓库正文。 + +### 5.3 GitHub 邮箱门槛 + +第一版按确认方案执行: + +- 用户必须填写 GitHub 绑定邮箱。 +- 邮箱作为贡献记录的一部分保存。 +- 维护平台向维护者展示该字段。 +- 维护者手动整理仓库内容时,可根据该邮箱生成 `Co-authored-by`。 +- 平台不自动提交 GitHub,不自动创建 PR。 + +邮箱门槛属于流程门槛,不把用户输入视为 OAuth 身份的密码学证明。后续若需要更强校验,再增加 OAuth 邮箱比对或 GitHub noreply 地址校验,不阻塞本版本。 + +### 5.4 文本和附件 + +贡献可以包含: + +- 当前回答文本。 +- 用户补充文本。 +- 一个或多个原始文件附件。 + +第一版支持仓库已有的常见资料格式: + +```text +.pdf +.png / .jpg / .jpeg / .webp +.doc / .docx +.ppt / .pptx +.xls / .xlsx +.csv +.md / .txt +``` + +附件流程限定为: + +```text +用户上传 +→ 服务端保存原始附件 +→ 维护者平台展示元数据和下载按钮 +→ 维护者下载到本地审核 +→ 维护者手动整理并提交仓库 +``` + +维护平台不做: + +- PDF 在线渲染。 +- Office 在线渲染。 +- 图片预览或 OCR。 +- 服务端自动解析文件正文。 +- 上传后自动进入公共检索。 +- 上传后自动进入私人检索。 + +最低安全边界: + +- 文件扩展名 allowlist。 +- 单文件和单次提交大小限制。 +- 存储使用随机文件 ID,不能使用用户原始文件名作为路径。 +- 附件不放入静态资源目录。 +- 下载响应使用 `Content-Disposition: attachment`。 +- 下载接口校验固定维护者身份或提交者本人身份。 +- 文件名只作为展示字段,禁止路径穿越。 +- 第一版不支持压缩包。 + +现有 JSON 请求体约束不能直接承载较大二进制文件。文件上传应使用独立 multipart 接口,文本提交仍使用现有 JSON 契约。 + +### 5.5 贡献记录抽象 + +公共贡献和私人知识可以共用“知识条目”的领域抽象,但不能共用无区分的权限判断。 + +至少需要区分: + +```text +visibility: public_pending | private +source_kind: answer | text | file +lifecycle_status: draft | submitted | accepted | rejected | expired +user_id +course_id +title +content +attachments +created_at +expires_at +``` + +是否使用一张物理表不作为产品要求。若现有 SQLite 表结构更适合分表,可以共用 repository 层和内容校验逻辑;若使用同表,必须由 `visibility`、`source_kind` 和状态字段严格隔离公共与私人行为。 + +公共贡献在 `accepted` 前: + +- 不进入公共检索。 +- 不改变 active corpus。 +- 不自动生成 PR。 +- 不自动暴露给其他用户。 + +### 5.6 独立维护平台 + +维护平台使用单独路由,例如: + +```text +/maintainer +``` + +它与普通对话助手分离: + +- 普通用户页面不显示维护平台入口。 +- 维护平台不复用普通用户的主页面布局。 +- API 路由也要做权限检查,不能只隐藏前端路由。 +- 只有固定维护者 allowlist 可以访问。 +- GitHub OAuth 登录用户不自动拥有维护者权限。 + +第一版维护平台只需要两个队列和一个详情页: + +```text +/maintainer/feedback +/maintainer/contributions +/maintainer/contributions/:id +``` + +列表字段: + +- GitHub login。 +- 课程。 +- Workflow 类型。 +- 提交类别。 +- 标题。 +- 时间。 +- 当前状态。 +- 是否有附件。 + +详情字段: + +- 本轮回答正文。 +- 用户补充文本。 +- 课程和 Workflow 元数据。 +- 引用、corpus 版本和运行 ID。 +- 用户反馈类别。 +- GitHub login 和邮箱。 +- 附件列表和下载按钮。 +- 公开分享确认记录。 +- 当前审核状态。 + +第一版维护操作: + +- 标记待处理。 +- 采纳。 +- 拒绝。 +- 要求修改或补充。 +- 标记已导出/已处理。 + +不做自动仓库写入。维护者下载后,在本地完成内容检查、格式整理、`Co-authored-by` 处理和最终提交。 + +### 5.7 维护平台的安全验收 + +- 固定维护者之外的 GitHub 用户访问维护 API 得到拒绝。 +- 普通用户不能通过猜测 URL 访问队列。 +- 用户只能看到自己的贡献记录。 +- 附件不能通过静态路径猜测下载。 +- 未审核内容不进入检索。 +- 用户问题或材料中的敏感信息不会被默认拼入公开正文。 +- 贡献操作有状态和时间记录。 +- 审核平台故障不影响普通对话运行。 + +--- + +## 6. C-2:用户绑定的私人知识沉淀 + +### 6.1 用户语义 + +“加入私人知识库”与“我要贡献”并列,但不是公共贡献的另一个审核状态。 + +私人知识条目: + +- 绑定当前用户。 +- 绑定课程插件名或课程 ID。 +- 保持 7 天 TTL。 +- 默认不进入公共索引和课程包。 +- 不提供查看、删除或用户导出入口。 +- 到期由服务端物理清理。 +- 只在满足用户和课程条件时参与检索。 + +界面说明应明确: + +> 私人知识仅跟随当前账号保留 7 天,不进入公共知识库,也不提供手动查看、删除或导出。 + +### 6.2 检索规则 + +私人材料参与检索必须同时满足: + +```text +material.user_id == current_user.id +material.course_id in current_run.selected_course_ids +material.expires_at > now +material.visibility == private +``` + +课程选择规则: + +1. 跨课程关闭时,只检索当前选择的单个课程私人材料。 +2. 跨课程开启时,检索本次选择的所有课程私人材料。 +3. 用户没有启用对应课程插件时,该课程私人材料不参与检索。 +4. 当前运行未选择该课程时,该课程私人材料不参与检索。 +5. 私人材料不能改变公共课程检索范围。 +6. 私人材料不能被其他用户检索到。 + +如果私人材料以 Markdown/frontmatter 保存,课程插件名可以作为内容归属信息;但真正的用户隔离必须由服务端 `user_id` 和查询条件保证,不能只信 frontmatter。 + +### 6.3 与现有临时材料的关系 + +当前临时材料逻辑可以复用: + +- 内容长度限制。 +- 标题规范化。 +- 哈希。 +- 课程归属。 +- 7 天 TTL。 +- 现有材料清理调度。 + +但需要新增“跨对话私人检索范围”。不能简单把当前会话材料原样扩大为全局材料,否则会破坏会话边界。 + +### 6.4 私人知识第一版范围 + +建议第一版支持: + +- 当前回答文本加入私人知识库。 +- 用户在本轮输入的 Markdown/纯文本加入私人知识库。 +- 课程 frontmatter 或服务端课程字段绑定。 +- 之后 7 天内按用户和课程参与检索。 + +不支持: + +- 私人附件直接参与检索。 +- 私人知识手动管理页面。 +- 用户导出私人知识。 +- 用户手动删除私人知识。 +- 跨用户共享私人知识。 + +附件即使未来允许保存,也必须先经过独立的文本提取和索引方案,不能因为可以下载就自动变成可检索内容。 + +### 6.5 C-2 验收 + +- 两个用户提交相同课程材料时,互相不可检索。 +- 关闭跨课程后,不会命中未选择课程的私人材料。 +- 开启跨课程后,只命中本次选择课程的私人材料。 +- 插件未启用时,对应私人材料不参与检索。 +- 7 天后材料被服务端物理清理。 +- 私人材料不出现在公共贡献队列。 +- 私人材料不进入公共 corpus candidate。 +- 私人内容不出现在其他用户的回答引用中。 + +--- + +## 7. D:回答结果操作 + +### 7.1 复制本轮输出 + +这是低成本功能,建议纳入本版本第一批。 + +回答结果区域增加“复制”按钮,复制范围默认是**本轮最终回答的可读正文**,不复制: + +- 内部 Trace。 +- 模型原始响应。 +- 隐藏字段。 +- 用户密钥。 +- 不必要的内部 ID。 + +复制文本可以按当前回答块顺序拼接: + +```text +仓库资料回答 + +用户材料回答 + +通用补充 + +个性化分析 + +引用来源:... +``` + +具体格式应使用前端现有 `WorkflowResult` 和 answer block 数据,不重新请求服务端。 + +交互要求: + +- 成功后显示短暂“已复制”。 +- Clipboard API 不可用时提供降级文本选择或明确失败提示。 +- 复制不改变当前回答状态。 +- 复制不产生反馈、贡献或私人知识记录。 +- 只在有最终回答内容时显示按钮。 + +预计变动:`WorkflowResult.vue` 一个按钮、一个格式化 helper、一个单测;不需要 API 或数据库变更。 + +### 7.2 迁出当前分支到新对话 + +这是低到中成本功能,建议纳入本版本,但排在复制之后。 + +用户点击“迁出到新对话”后,创建一个新的会话,并把当前回答作为新会话的起始上下文或草稿内容。它不是简单复制按钮,也不是继续修改原会话。 + +推荐的最小语义: + +1. 当前回答必须已经完成。 +2. 用户点击后,前端调用现有创建会话接口。 +3. 新会话使用当前课程作为主课程;跨课程回答则使用“综合”标题或主课程加综合标记。 +4. 将当前回答正文填充到新会话输入框,或作为只读引用上下文。 +5. 用户可以编辑、补充问题后再发送。 +6. 原会话保持不变。 +7. 不自动再次调用模型。 +8. 不自动复制完整历史对话。 + +两种实现方式: + +**方式一:填充输入框,推荐第一版** + +- 新建会话。 +- 将当前回答作为输入框初始内容。 +- 用户自行修改并发送。 +- 不改数据库结构。 + +优点是改动最小、用户可控;缺点是回答会被当成新的用户输入,需要界面上明确标识。 + +**方式二:保存分支来源元数据** + +- 新建会话时增加 `branched_from_run_id` 或 `branched_from_conversation_id`。 +- 新会话显示“源自某次回答”。 +- 发送时将源回答作为结构化上下文。 + +优点是语义更完整;缺点是需要契约、数据库和历史 UI 变化。除非第一版确实需要保留来源链,否则不作为首发实现。 + +推荐第一版使用方式一,并在输入框上方显示: + +> 已从上一轮回答创建新对话草稿,可编辑后发送。 + +### 7.3 D 的验收 + +- 复制内容和屏幕上最终回答一致。 +- 流式生成中不显示复制按钮,或按钮保持禁用。 +- 复制失败有明确提示。 +- 迁出不会修改原会话或原运行。 +- 迁出不会自动产生新的模型调用。 +- 新会话创建失败时不丢失当前结果。 +- 用户可以编辑迁出的内容后再发送。 +- 跨课程回答迁出时不伪装成普通单课程历史。 + +--- + +## 8. 四项维护修复包 + +### 8.1 维护清理异常隔离 + +`MaintenanceScheduler._run` 当前在完整 `sweep()` 外层捕获异常:`apps/scut-senior/api/src/scut_senior_api/maintenance.py#MaintenanceScheduler._run`(L143-L151)。这与文件中“一个坏表不拖垮整个循环”的注释不一致。 + +改法: + +- 每个 cleanup 步骤独立捕获异常。 +- 记录步骤名和堆栈。 +- 失败步骤计数按 0 处理。 +- 后续步骤继续执行。 +- 保持结果结构、SQL 和调度间隔不变。 + +### 8.2 活跃流 registry 并发保护 + +`_ACTIVE_STREAMS` 在异步协程、`asyncio.to_thread` 的后台线程和取消端点之间共享:`main.py#stream_workflow`(L1045-L1106)、`main.py#cancel_workflow`(L1120-L1131)。 + +改法: + +- 增加轻量 `threading.Lock`。 +- 封装 register、pop、get-and-cancel。 +- 锁内只做字典操作和用户校验。 +- 锁外调用 `session.cancel()`。 +- finally 清理使用安全 pop。 + +### 8.3 流式 EOF 协议完整性 + +`workflowStream.ts#startWorkflowStreamRequest`(L341-L347)在流结束时调用 `finalizeWorkflowStream`,而 `finalizeWorkflowStream` 会把缺少终态事件的 running 状态转为 `stream_interrupted`(L276-L282)。 + +改法: + +- 正常 EOF 前要求已收到 `result` 或 `error`。 +- 缺少终态时报告 `stream_protocol_error`。 +- 用户主动 abort 仍返回 `client_interrupted`。 +- 网络异常仍保留现有网络错误语义。 + +### 8.4 偏好保存请求合并 + +`useAppStore.ts` 的多个 watcher 会直接触发 `persistAccountPreferences`(L255-L305)。 + +改法: + +- 使用单尾 promise 或 request sequence。 +- 新请求只提交最新快照。 +- 旧请求完成后发现序号过期,则补发最新快照。 +- 保留 localStorage 即时体验。 +- 失败只显示非阻断提示。 + +--- + +## 9. 实施顺序 + +### 阶段 0:维护修复 + +- 完成四项维护修复。 +- 增加定向单测。 +- 通过现有 `make test`、`make check-contracts` 和 `make build-web`。 + +### 阶段 1:B 跨课程检索 + +- 增加用户偏好键。 +- 完成前端多选课程状态。 +- 完成运行级课程集合记录。 +- 解除 service 跨课程阻断。 +- 加入按课程可用性、引用和私人材料过滤。 +- 补齐单课程回归和跨课程集成测试。 + +### 阶段 2:C-1 公共贡献和维护平台 + +- 重构回答反馈区的一级入口。 +- 完成当前回答贡献预览。 +- 增加标题和 GitHub 邮箱门槛。 +- 拆分文本提交与 multipart 附件上传。 +- 建立附件下载接口。 +- 建立固定维护者 allowlist。 +- 完成独立维护路由、列表和详情页。 +- 维护者手动下载和处理,不接自动 GitHub。 + +### 阶段 3:C-2 私人知识沉淀 + +- 将私人条目绑定用户。 +- 保持 7 天 TTL。 +- 复用现有内容校验和清理机制。 +- 将用户私人材料接入按用户、课程和插件状态过滤的检索路径。 +- 增加跨课程下的私人材料性能提示。 + +### 阶段 4:D 结果操作 + +- 先实现复制按钮。 +- 再实现迁出新对话。 +- 第一版迁出采用“新会话 + 填充输入框”,不引入分支关系数据库字段。 + +--- + +## 10. DoD 总表 + +### 功能 + +- 跨课程设置默认关闭且可跟随账号同步。 +- 开启后可在对话框多选课程,选择只对本次运行生效。 +- 跨课程运行只检索选择且可用的课程插件。 +- 本轮回答可以复制。 +- 本轮回答可以迁出为新的可编辑对话草稿。 +- 当前回答可以提交为公共贡献。 +- 用户可以把内容加入绑定自己的私人知识范围。 +- 维护者可以在独立平台查看、下载和处理提交。 + +### 权限与隐私 + +- 维护平台仅固定维护者可访问。 +- 普通用户不能通过 API 访问维护队列。 +- 公共贡献未审核前不进入公共检索。 +- 私人材料只按 `user_id` 和课程过滤。 +- 附件不通过静态目录暴露。 +- 用户填写的 GitHub 邮箱不用于自动 GitHub 操作。 +- 普通回答操作不暴露内部凭据和内部事件。 + +### 兼容性 + +- 跨课程默认关闭时,现有单课程请求保持兼容。 +- 旧客户端继续处理 `trace / answer_delta / result / error`。 +- 不改变现有 Workflow 类型名称。 +- 不改变现有引用 Guard 的 fail-closed 语义。 +- Terra 等未来模型只需实现现有模型适配接口,不改变本版本用户流程。 + +### 测试 + +- 偏好读取、保存和失败回退。 +- 跨课程课程集合校验。 +- 未加载和不可用课程拒绝。 +- 课程引用不越权。 +- 私人材料用户隔离。 +- 私人材料 TTL 清理。 +- 公共贡献状态和权限。 +- 附件路径、大小和下载权限。 +- 维护平台 allowlist。 +- 复制文本格式。 +- 迁出不修改原会话。 +- 四项维护修复的异常、竞态和协议边界。 + +--- + +## 11. 不确定项与止损点 + +1. **跨课程检索质量**:先只开放两类 Workflow;如果跨课程噪声明显,保留用户开关并关闭运行时,不影响单课程。 +2. **私人材料性能**:先限制每用户私人条目数量或单次检索条目上限;性能未标定前不承诺跨课程低延迟。 +3. **文件上传成本**:若 multipart、存储或安全校验超出当前部署边界,先保留“我要贡献”文本流程,附件入口显示后续开放,不影响公共贡献主流程。 +4. **维护者运营负担**:若固定维护者审核队列无法持续处理,保留提交和状态记录,但暂停进入公共 candidate 的人工流程。 +5. **跨课程历史展示**:若运行级字段改动影响过大,第一版可以先展示当前运行 Trace 中的课程集合,但正式版本仍建议补充持久化字段。 +6. **模型切换**:模型适配失败或输出 Guard 不通过时,回退现有错误和引用保护逻辑,不为了 Terra 等模型放宽课程边界或引用校验。 + +--- + +## 12. 静态分析边界 + +- `source: code`:现有模块、接口、路由和阻断点均由本文列出的源码锚点直接证明。 +- `source: inferred`:跨课程运行可以复用检索接口、贡献平台可以复用现有队列、复制按钮无需后端请求,均由多个现有模块共同推断,仍需通过实现测试确认。 +- `source: unavailable`:线上并发规模、文件存储容量、代理 EOF 行为、实际跨课程召回质量、维护者审核吞吐和 Terra 适配效果不能由当前静态代码证明。 +- 本文未读取或分析 `node_modules`、Python 虚拟环境、课程资料正文和外部服务实现。 +- 当前计划文件本身不代表任何测试、构建、部署或模型调用已经成功。 diff --git a/apps/scut-senior/docs/summary/SCUT_SENIOR_V3_FEATURE_PLAN.md b/apps/scut-senior/docs/summary/SCUT_SENIOR_V3_FEATURE_PLAN.md deleted file mode 100644 index 157d063d..00000000 --- a/apps/scut-senior/docs/summary/SCUT_SENIOR_V3_FEATURE_PLAN.md +++ /dev/null @@ -1,238 +0,0 @@ -# SCUT 老学长 V3 仓库开发计划(知识问答功能征询稿) - -> 本文只确认第三期知识问答要做什么,不展开技术架构。功能范围确认后,再拆任务和排期。 - -## 1. 第三期目标 - -SCUT_CS 已经通过课程目录、资料分类和统一复习方案解决了“资料怎么找、应该按什么顺序复习”的问题。第三期不重复建设资料导航、复习路线或课程推荐,而是在现有资料之上增加一个知识问答入口。 - -第三期只解决一个核心问题: - -> 学生遇到不理解的课程知识点时,可以直接提问,由系统结合仓库中的备考资料进行解释。 - -多格式转换、OCR、向量库和检索是后台能力,不作为用户需要理解或操作的功能。 - -## 2. 产品形态 - -最终产品是“SCUT 老学长”课程知识问答: - -- 用户选择课程后直接提问; -- 支持连续追问和要求换一种解释方式; -- 可以上传课件、教材或题目截图; -- 回答以仓库资料为知识边界; -- 资料没有覆盖时明确说不知道,不根据常识强行补全; -- 默认使用自然、简洁的中文表达。 - -不再单独建设: - -- 资料导航; -- 复习路线生成; -- 每日复习计划; -- 课程推荐; -- 自动生成课程知识图谱; -- 多资料“考前重点预测”; -- 重复 SCUT_CS 现有分类结构的资料库页面。 - -## 3. 关于 Trace 和来源展示 - -学生端取消现有 RAG Trace,包括: - -- 检索阶段时间线; -- BM25、Dense、RRF、rerank 分数; -- Query Rewrite 过程; -- 降级细节抽屉; -- 其他面向开发者的内部调试信号。 - -这些内容只保留在开发日志和评测中,不进入普通问答界面。 - -来源展示有三个候选方案: - -- [ ] A. 回答下方折叠显示资料名称和页码,不主动打断阅读(建议) -- [ ] B. 只在用户点击“依据是什么”时显示来源 -- [ ] C. 学生端完全不显示来源 - -来源不承担资料导航功能,只用于必要时核对答案。无论选择哪种学生端展示方式,后台仍需保存最小来源映射,否则无法评测回答是否有资料依据,也无法排查错误答案。 - -## 4. 核心问答功能 - -### 4.1 课程内知识问答 - -- 用户先选择课程; -- 默认只检索所选课程,避免不同课程术语互相污染; -- 支持概念解释、原理说明、知识点对比和常见误区; -- 支持“讲简单一点”“举个例子”“换一种说法”; -- 支持连续追问; -- 答案超出资料范围时明确提示证据不足。 - -待确认: - -- [ ] 首期只支持单课程问答(建议) -- [ ] 允许用户主动开启跨课程问答 -- [ ] 支持多轮上下文 -- [ ] 支持匿名使用 -- [ ] 支持中英文问题 - -### 4.2 图片问答 - -用户可以上传课件截图、教材页面、公式、图表或题目截图。系统先识别图片内容,再结合当前课程资料回答。 - -待确认: - -- [ ] 课件和教材截图 OCR -- [ ] 公式识别与解释 -- [ ] 图表、流程图和结构图解释 -- [ ] 题目知识点识别 -- [ ] 根据题目给分步提示 -- [ ] 图片与仓库页面相似检索 - -### 4.3 题目辅导边界 - -备考资料天然包含试题和答案,需要明确问答边界。建议允许: - -- 解释题目考察的知识点; -- 给出解题思路; -- 分步骤提示; -- 分析用户已经写出的答案; -- 讲解仓库中已有题解。 - -建议限制: - -- 代写当前作业、实验报告和课程设计; -- 直接生成可以提交的完整代码或报告; -- 回答明确处于进行中的考试; -- 大段复现版权状态不明的资料原文。 - -待确认: - -- [ ] 只讲知识点,不直接给最终答案 -- [ ] 允许完整讲解仓库中已有的历史试题 -- [ ] 用户先作答,系统再批改和讲解 -- [ ] 建立错题对话记录 - -### 4.4 回答控制 - -为了让知识问答更实用,建议提供少量明确控制: - -- [ ] 简短回答 -- [ ] 详细讲解 -- [ ] 举例说明 -- [ ] 分步骤提示 -- [ ] 对比两个概念 -- [ ] 只根据仓库资料回答 -- [ ] 允许补充通用知识,但必须明确标记(不建议默认开启) - -## 5. 多模态资料录入范围 - -资料录入不是学生端功能,但决定知识问答能覆盖多少内容。不同格式通过 Skill 转换为统一的 Markdown/结构化内容,再进入知识库。 - -待确认首批处理格式: - -- [ ] Markdown / TXT -- [ ] 原生文本 PDF -- [ ] DOCX -- [ ] PPTX -- [ ] 扫描 PDF / 图片 OCR -- [ ] XLSX / 表格 -- [ ] 代码与 Notebook -- [ ] 旧 DOC / PPT - -建议优先处理 PDF、DOCX、PPTX 和扫描页,因为这些是仓库知识问答的主要覆盖瓶颈。加密、付费、来源不明、个人信息不明和不适合公开处理的资料不进入知识库。 - -## 6. “老学长”回答风格 - -回答先完成事实和知识边界判断,再使用 `humanizer-zh` 做忠实润色,减少模板腔。润色不能改变: - -- 专业知识和结论; -- 数字、公式和术语; -- 是否存在资料依据; -- 不确定程度; -- 拒答和学术诚信边界。 - -待确认默认风格: - -- [ ] 简洁直接,像复习搭子(建议) -- [ ] 详细耐心,像助教 -- [ ] 更口语化,像学长聊天 -- [ ] 用户每次可以切换 - -## 7. 反馈与纠错 - -知识问答最需要的不是复杂管理后台,而是让错误可以被发现和修正。 - -建议支持: - -- [ ] 回答有帮助 / 没帮助 -- [ ] 标记“知识错误” -- [ ] 标记“没有回答问题” -- [ ] 标记“资料中其实有答案” -- [ ] 提交简短纠错说明 -- [ ] 维护者查看高频失败问题 -- [ ] 修复后重新运行同一问题 - -用户反馈只进入待处理列表,不能自动修改知识库或直接影响后续答案。 - -## 8. 页面范围 - -第三期前端建议保持克制: - -| 页面 | 功能 | 是否需要 | -|---|---|---| -| 问答首页 | 课程选择、文本/图片提问、示例问题 | [ ] | -| 问答对话 | 多轮回答、回答方式切换、折叠来源、反馈 | [ ] | -| 历史记录 | 查看以前的对话 | [ ] | -| 维护页面 | 资料处理状态、失败问题、知识库版本 | [ ] | -| 评测页面 | 版本指标和失败案例,仅开发者 | [ ] | - -不恢复运维项目的 Dashboard、工单、RPA、账号冻结、FAQ CRUD 和知识草稿页面。 - -## 9. 建议的第三期默认范围 - -### 必做 - -1. 单课程知识问答; -2. 多轮追问; -3. PDF、DOCX、PPTX、Markdown 的 Skill 转换; -4. 扫描页 OCR; -5. 带课程过滤的向量检索; -6. 图片 OCR 问答; -7. 简短/详细/举例/分步四种回答方式; -8. 证据不足时明确拒绝猜测; -9. `humanizer-zh` 最终忠实润色; -10. 折叠式最小来源; -11. 回答反馈和失败问题收集; -12. SCUT 专属知识问答评测集。 - -### 选做 - -- 跨课程问答; -- 图片相似检索; -- 公式和图表专项解释; -- 用户先作答后批改; -- 登录与历史记录; -- 错题对话记录。 - -### 不做 - -- 自动复习路线; -- 每日学习计划; -- 课程推荐; -- 资料导航; -- 自动预测考试重点; -- 面向学生的 RAG Trace; -- 自动发布知识库; -- 多 Agent 自主执行; -- 3D 知识图谱。 - -## 10. 开发前需要确认的问题 - -1. 首期覆盖哪些课程? -2. 是否只做单课程问答? -3. PDF、DOCX、PPTX、扫描页是否都必须首期支持? -4. 图片问答只做 OCR,还是还需要图片相似检索、公式和图表理解? -5. 历史试题是否允许直接给完整讲解? -6. 学生端来源选择折叠显示、按需显示还是完全不显示? -7. 是否需要登录和历史记录? -8. 默认回答采用简洁、助教式还是聊天式? -9. 是否需要用户反馈和维护者失败问题列表? - -确认这些问题后,再根据最终功能范围拆分第三期 milestone、接口和验收测试。 diff --git "a/apps/scut-senior/docs/summary/\351\241\271\347\233\256\346\274\224\350\277\233\346\226\271\346\241\210\344\270\216\351\235\242\350\257\225QA.md" "b/apps/scut-senior/docs/summary/\351\241\271\347\233\256\346\274\224\350\277\233\346\226\271\346\241\210\344\270\216\351\235\242\350\257\225QA.md" deleted file mode 100644 index 1330b18a..00000000 --- "a/apps/scut-senior/docs/summary/\351\241\271\347\233\256\346\274\224\350\277\233\346\226\271\346\241\210\344\270\216\351\235\242\350\257\225QA.md" +++ /dev/null @@ -1,943 +0,0 @@ -# SCUT 老学长:项目演进方案与面试 QA - -> **面试口径**:以下内容是基于当前项目基底设计的演进方案。回答时明确区分“当前基线”和“我准备如何演进”,不要把规划内容说成已经上线。 - -## 一、项目演进后的定位 - -目标不是把项目改成完全自由的 Autonomous Agent,而是演进成: - -> **面向课程学习场景的 EventStream-driven Agent Runtime**:由事件流驱动、以受限单步决策为核心,配合混合 RAG 与服务端安全 Guard。 - -核心组成: - -```text -统一输入 - → 自动任务路由 - → EventStream Agent Loop - → 受限单步决策(Action 或 Final) - → 服务端工具执行 - → Observation/证据回流 - → 引用与安全 Guard - → 可终止的最终回答 -``` - -项目特色仍然保留: - -- 五类学习任务:知识点问答、备考复习、题目辅导、错题复盘、临时材料阅读。 -- 课程级知识边界:一次运行绑定课程,不能跨课程越权引用。 -- 词法检索与向量检索并存,而不是只做向量检索。 -- `chunk_id`、课程 ID、定位器、`corpus_version` 等证据链字段继续贯穿检索到回答。 -- Bilibili 只作为独立的匿名搜索资源,不把外部搜索结果混入课程证据。 -- 工具由服务端执行,模型只产生结构化动作,不直接访问数据库、文件系统或任意网络。 -- 保留 `RunStateMachine`、Trace、引用 Guard、能力门和 fail-closed 策略。 - -## 二、总体演进架构 - -```mermaid -flowchart TD - U[统一输入\n问题/题目/错题/大纲/临时材料] --> ING[输入解析与结构化\n文本清洗、材料识别、字段抽取] - ING --> ROUTER[Task Router\n识别五类任务 + 置信度] - ROUTER --> CLARIFY{置信度足够?} - CLARIFY -->|否| ASK[请求澄清] - CLARIFY -->|是| GATE[输入与权限安全门] - GATE --> STATE[初始化 AgentState] - STATE --> DECIDE[Decision\n每轮输出一个 Action 或 Final] - DECIDE --> ACTION_GUARD[Action Guard\n白名单/课程/预算/参数] - ACTION_GUARD --> EXEC[Action Executor] - EXEC --> RETRIEVE[Hybrid Retrieval\nBM25/词法 + 向量 + 元数据过滤 + Rerank] - EXEC --> MATERIAL[读取临时材料] - EXEC --> ANALYZE[证据对比/错因分析] - EXEC --> ASK2[追问澄清] - EXEC --> ANSWER_DRAFT[生成候选回答] - RETRIEVE --> OBS[Observation\n证据、覆盖率、冲突、错误码] - MATERIAL --> OBS - ANALYZE --> OBS - ANSWER_DRAFT --> FINAL_GUARD[引用/范围/安全/完整性 Guard] - ASK2 --> OBS - OBS --> STOP{终止条件满足?} - STOP -->|否| DECIDE - STOP -->|是| FINAL[最终回答 + Citation] -``` - -## 三、为什么选择 EventStream + 受限单步决策 - -### 1. 为什么不继续让用户手动选择 Workflow - -当前五个 Workflow 的输入框会把用户暴露给内部实现:用户必须先判断自己是在“题目辅导”还是“错题复盘”,还要填写不同字段。 - -演进后改为统一输入: - -```text -用户输入自然语言、题目、错题、复习大纲或材料 - → Router 判断任务类型 - → 提取对应结构化 payload - → 复用五类能力模块 -``` - -Router 只负责选择能力入口,不负责自由规划。真正的动态性来自后续 EventStream Agent Loop:每次 Observation 更新状态,并影响下一次受限动作决策。 - -### 2. 为什么不直接做完全自主 Agent - -课程学习场景对边界和可解释性要求高: - -- 不能跨课程引用。 -- 不能把用户材料误当成系统指令。 -- 不能把模型生成的内容伪装成历年真题。 -- 不能让模型任意访问文件、数据库或外部网络。 -- 不能因为检索失败无限循环。 - -因此采用 EventStream 驱动的受限单步决策:模型每次只能提出一个结构化 Action 或 Final,动作集合、参数、权限、预算和终止条件均由服务端约束。 - -### 3. 为什么不同时叠加 ReAct、Plan-and-Execute 和 EventStream - -它们不是三套都要运行的组件: - -```text -EventStream + Reducer - = 运行时承载方式:事件、持久化、重放、前端推送 - -受限单步决策 - = Agent 行为方式:根据 Observation 选择一个 Action 或结束 - -exam_review 确定性计划 - = 一个特定 Workflow 的业务规则,不是通用 LLM Planner -``` - -最终只保留一套主运行机制: - -```text -事件流进入 - → 模型输出一个 Action 或 Final - → Action Guard - → 服务端执行 - → Observation 事件 - → reducer 更新状态 - → 继续决策或终止 -``` - -“ReAct”可以用来解释这种 Observe → Decide → Act → Observe 的行为模式,但不再作为额外框架或额外调用层写入主架构名称。对于 `exam_review`,计划由代码根据大纲、薄弱点和历年题事实确定性生成,最多影响检索顺序,不额外调用 LLM Planner,也不做通用 Replan。 - - -## 四、五类 Workflow 如何演进为 Agent Skill - -五类 Workflow 不删除,而是从“用户必须手选的入口”变成“Router 和 Agent 可调用的受控 Skill”。 - -| Skill | 触发场景 | 典型动作链 | 项目特色 | -|---|---|---|---| -| `knowledge_qa` | 用户询问课程概念 | `retrieve → judge_evidence → answer` | 课程内引用、概念与章节定位 | -| `exam_review` | 用户给出大纲、考试目标或薄弱点 | `build_plan → retrieve_topics → retrieve_past_exams → fill_gaps → answer` | 大纲优先、历年题统计、禁止伪造必考结论 | -| `problem_tutor` | 用户提交题目并要求讲解 | `retrieve_problem_topic → analyze_solution_path → answer` | 先识别主知识点,不把题面噪声直接当检索词 | -| `mistake_review` | 用户给出题目和原答案 | `retrieve → compare_answer → identify_root_cause → answer` | 区分概念错误、步骤错误和计算错误 | -| `temporary_material_reading` | 用户粘贴讲义或临时材料 | `read_material → retrieve_for_verification → compare → answer` | “材料写了什么”与“材料是否正确”分开处理 | - -每个 Skill 只描述: - -- 任务目标和输入字段; -- 允许的动作; -- 证据要求; -- 输出格式; -- 禁止事项。 - -Skill 不直接执行数据库查询或 HTTP 请求。执行由统一 `ActionExecutor` 完成。 - -## 五、混合检索方案 - -### 5.1 检索链路 - -```mermaid -flowchart LR - D[课程资料] --> C[语义/结构化切块] - C --> E[Embedding] - E --> V[(向量索引)] - C --> L[(BM25/词法索引)] - Q[用户问题] --> QE[Query Embedding] - QE --> V - Q --> L - V --> FUSION[候选合并与去重] - L --> FUSION - FUSION --> META[课程/版本/权限/文档类型过滤] - META --> RERANK[Reranker 或确定性重排] - RERANK --> CITE[绑定 S#、chunk_id、locator] - CITE --> LLM[模型生成] -``` - -### 5.2 为什么不能只保留向量检索 - -**第一,课程资料有大量精确符号。** - -例如: - -- `TCP 三次握手` -- `Dijkstra` -- `zplane(b,a)` -- `O(n log n)` -- “第 3 题” -- 章节号、函数名、公式变量 - -这类内容的关键不是语义相似,而是 token、符号、题号和局部字符串精确命中。向量模型可能把“后序遍历”和“中序遍历”判断得过于相近,却无法保证题号、函数名或公式的精确约束。 - -**第二,中文课程问题短而密。** - -用户常输入“快排最好复杂度”“第三题怎么做”这种短查询,语义向量信息不足;词法索引可以利用课程术语、章节标题、题号和短语命中。 - -**第三,词法检索更容易解释和复现。** - -当前项目强调引用和课程边界。词法分数、命中的标题、题号和 locator 可以直接写入 Trace,便于回答“为什么召回这段资料”。 - -**第四,冷启动、成本和故障降级更好。** - -向量模型不可用或索引未更新时,词法检索仍可作为低成本 fallback;对于课程资料这种相对稳定的知识库,不能因为 embedding 服务故障就完全不可回答。 - -**第五,安全过滤不能交给向量相似度。** - -课程 ID、corpus version、审核状态和文档角色必须做确定性过滤,不能仅凭“语义相似”决定是否允许引用。 - -所以选择: - -```text -混合召回 = 词法精确召回 + 向量语义召回 - → 元数据过滤 - → 合并去重 - → 重排序 -``` - -### 5.3 召回与排序策略 - -推荐先各取一批候选,再合并: - -```text -lexical_candidates = BM25(query, course_id, top_k=20) -vector_candidates = dense_search(embedding(query), course_id, top_k=20) -merged = deduplicate(lexical_candidates + vector_candidates) -filtered = metadata_filter(merged) -ranked = rerank(query, filtered, top_k=5~8) -``` - -最终每个候选必须携带: - -```text -chunk_id -course_id -source_id -source_title -locator_type / locator_start / locator_end -corpus_version -course_pack_version -retrieval_channels -lexical_score / vector_score / rerank_score -``` - -不建议一开始就把所有排序交给黑盒 reranker。第一版可以使用可解释的加权融合: - -```text -hybrid_score = α * normalized_bm25 - + β * normalized_vector_score - + γ * title/question/locator bonus -``` - -再根据评测集决定是否引入 reranker。 - -### 5.4 为什么保留当前的 chunk 和版本绑定 - -向量库只是检索实现,不应该改变证据身份。继续沿用: - -```text -chunk_id = source_id + locator + chunk_no -corpus_version = 构建提交版本 + builder 参数 -``` - -这样可以做到: - -- 回答引用仍可定位到原文; -- 向量索引和词法索引绑定同一个语料版本; -- 语料更新后可以重建并切换 active index; -- 出现错误时可以按版本回滚; -- 评测结果可以复现。 - -## 六、Agent Runtime 设计(EventStream + 受限单步决策) - -### 6.1 Action 白名单 - -第一版不允许模型任意调用 API,只允许: - -```text -classify_task -retrieve -retrieve_with_query_rewrite -read_user_material -analyze_evidence -build_exam_plan -ask_clarification -generate_answer -finish -``` - -动作示例: - -```json -{ - "action": "retrieve_with_query_rewrite", - "arguments": { - "query": "后序遍历 时间复杂度 递归栈", - "reason_code": "coverage_gap" - }, - "stop": false -} -``` - -模型输出必须通过结构化 Schema 校验,不能输出任意 Python、SQL、文件路径或 URL。 - -### 6.2 AgentState - -```json -{ - "run_id": "...", - "workflow_type": "mistake_review", - "course_id": "data_structure", - "user_goal": "分析错题根因", - "observations": [], - "evidence_ids": [], - "coverage": { - "required_topics": [], - "covered_topics": [], - "missing_topics": [] - }, - "step_count": 0, - "retrieval_count": 0, - "model_call_count": 0, - "status": "deciding" -} -``` - -### 6.3 Observation - -Observation 不保存完整思维链,而保存可审计的结果: - -```json -{ - "action": "retrieve", - "success": true, - "candidate_count": 5, - "evidence_ids": ["S1", "S2"], - "coverage": "partial", - "missing_topics": ["递归栈空间复杂度"], - "corpus_version": "corpus-...", - "error_code": null -} -``` - -保留动作、参数摘要、证据 ID、错误码和覆盖率;不展示或持久化模型完整 Chain-of-Thought。 - -### 6.4 EventStream Agent Loop - -EventStream 是运行时承载方式,不是额外的模型调用层。服务端与前端消费同一套事件: - -```text -事件进入 - → reducer 更新 AgentState - → 若未终止,模型输出一个 Action 或 Final - → Action Guard - → 服务端执行 - → observation_recorded - → 回到 reducer -``` - -统一事件词表在现有 NDJSON 四类事件之上扩充: - -```text -decision_produced 模型给出的 Action 或 Final -action_rejected Guard 拒绝 + reason_code -observation_recorded 工具执行结果、证据、覆盖率 -budget_crossed 步数/Token/时间预算触线 -clarification_requested 向用户澄清 -run_finished 终态收敛 -``` - -```python -# 伪代码:只有一个受控决策循环,不叠加独立 Planner 或 ReAct 框架 -while True: - event = next_event() - state = reduce_agent_event(state, event) - persist_append_only(event) - emit_to_client(event) - - if termination_policy.reached(state): - break - if event.kind == "observation_recorded": - decision = decision_model.decide(state) # 一个 Action 或 Final - append(DecisionProduced(decision)) -``` - -`exam_review` 的计划是确定性的业务规则:由大纲、薄弱点和历年题事实生成检索顺序,不额外调用 LLM Planner,也不与 Agent Loop 叠加。ReAct 只作为“根据 Observation 决定下一步”的行为解释,不是单独部署的框架。 - -设计收益:决策、拒绝和观察都是可持久化事实;状态可通过事件重放恢复;取消可作为事件在节点边界收敛;同一事件流还能直接驱动前端 Trace 和回答增量。 - -协议必须版本化:前端严格拒绝未知字段/事件,新增事件 kind 需协商或特性开关。事件只在单个 run 生命周期内作为真相源,对外查询仍读取终态快照,避免所有读路径全量重放。 - -### 6.5 预算与终止:防死循环是本职,管输入输出不是 - -预算只做一件事:防止循环失控。自然输入输出由既有机制管辖——请求合同(question ≤2 万字符、problem ≤4 万字符、材料 ≤10 万字符)管输入,供应商调用参数(max_tokens)管输出。Agent 层不重复设输入输出限额。 - -**死循环防线**(所有模型一致,Agent Runtime 职责): - -```text -max_steps = 4 # 每次模型交互都算一步,含 Final 与 Guard 重试 -max_retrieval_rounds = 2 -max_query_rewrite = 1 -max_same_action_retries = 1 -max_guard_retries = 1 # 引用 Guard 拒绝后的修复尝试,计入步数 -max_runtime_seconds = 120 # 进程级防悬挂兜底,不是成本控制 -``` - -单一计数器原则:决策、Final、Guard 重试共享同一个步数预算,结构上保证总模型调用 ≤ max_steps,因此不需要独立的 model_calls 上限——限制要少,但每个都有明确的失控场景对应。 - -**平台免费档配额防线**(仅 platform_daily_free_quota 三个模型,在 ModelGateway 配额层执行,不属于 Agent 预算): - -```text -每用户每日请求数 / Token 数 # 对齐 OpenRouter 免费额度口径 -单次输出 # 由该通道生成参数设定,偏紧 -每用户并发 = 1 -额度耗尽 = 明确报错,不自动切换(PLAN-1 §1.6 冻结) -``` - -**BYOK / 其他模型**:只有死循环防线。用户为自己的 Key 和费用负责,系统负责不失控;输入输出大小不由 Agent 预算管。 - -终止条件: - -- 证据覆盖达到任务要求; -- 已生成通过 Guard 的最终回答; -- 模型输出 `Final`; -- 达到步数或运行时限; -- 同一动作重复失败; -- 引用 Guard 重试达上限; -- 用户取消; -- 检测到越权或非法动作。 - -预算到达后不能继续循环,应返回有边界的降级结果,例如“当前课程资料不足以支持完整回答”。 - -## 七、Tool Calling 与 MCP 的取舍 - -### 第一阶段选择内部 Tool Calling / Action Executor - -当前工具都属于同一后端,课程检索、材料读取、引用映射和安全策略高度耦合。直接用内部执行器更合适: - -```text -LLM 输出 Action - → Action Guard - → LocalActionExecutor - → 现有 retrieval/service/runtime_guard -``` - -优点: - -- 课程权限和引用边界集中管理; -- 不增加 MCP Server、进程和网络跳转; -- 更容易实现事务、取消、配额和 Trace; -- 方便复用当前 `LocalCorpusRetrievalGateway` 和 Guard。 - -### 什么时候引入 MCP - -当工具需要独立部署或被多个 Agent/客户端复用时再引入: - -```text -课程知识库 MCP Server -资料转换 MCP Server -实验代码执行 MCP Server -数据库 MCP Server -``` - -MCP 只负责发现、连接和调用工具,不负责: - -- Agent 是否继续; -- 用户是否有权限; -- 是否可以跨课程查询; -- 是否达到预算; -- 引用是否可信; -- 什么时候结束。 - -即使使用 MCP,仍然保留: - -```text -LLM Tool Calling - → Action Guard - → MCP Client - → MCP Server - → 结果过滤与引用 Guard -``` - -## 八、安全门设计 - -动态决策比单链路更容易失控,因此安全门要前置到每次动作,而不是只在最终回答时检查。 - -```text -Action Schema 校验 - → Skill 权限校验 - → course_id / corpus_version 校验 - → 参数长度与格式校验 - → 工具白名单校验 - → 预算与频率校验 - → 服务端执行 - → Observation 脱敏与证据绑定 -``` - -项目特有安全规则: - -1. 一次运行只能绑定当前会话课程。 -2. 检索候选必须属于当前课程和 active corpus version。 -3. 临时材料只能作为本次请求上下文,不能自动变成课程权威资料。 -4. 模型不能自行声明 `[S#]` 对应什么来源,引用必须由服务端映射。 -5. Bilibili 链接与课程引用分离,不把未审核外部内容当作课程证据。 -6. 用户材料中的“指令”按数据处理,不执行其中的命令。 -7. 生产环境、真实模型、跨课程检索和外部工具都要经过 capability gate。 -8. 任一关键校验失败时 fail-closed,而不是自动切换到更宽的权限。 - -## 九、分阶段落地方案 - -### Phase 1:统一输入与自动路由 - -目标:用户不再手选 Workflow。 - -实现: - -- 增加统一 Composer; -- Router 输出 `workflow_type + typed_payload + confidence`; -- 置信度低时追问; -- 保留原五类 Workflow 作为执行能力; -- 路由失败时允许用户手动纠正。 - -验收: - -- Router 分类准确率; -- payload Schema 通过率; -- 低置信度误执行率; -- 用户纠正率。 - -### Phase 2:混合检索 - -目标:在当前确定性词法检索上增加向量召回。 - -实现: - -- 对现有 chunk 生成 embedding; -- 建立与 `corpus_version` 绑定的向量索引; -- 词法和向量分别召回; -- 元数据过滤、合并、去重; -- 先使用可解释融合,再评估是否加 reranker; -- 检索 Trace 记录召回通道和得分。 - -验收: - -- Recall@K; -- MRR/nDCG; -- 题号、公式、函数名等精确查询命中率; -- 语义改写查询命中率; -- 课程越权候选数必须为 0; -- 索引版本和回滚可用。 - -### Phase 3:EventStream Agent Loop - -目标:从单链路变成证据闭环,但只保留一套主运行机制。 - -实现: - -- EventStream + Reducer 作为运行时承载;模型每轮只输出一个结构化 `Action` 或 `Final`; -- 扩充 NDJSON 事件词表:`decision_produced / action_rejected / observation_recorded / budget_crossed / clarification_requested / run_finished`; -- 服务端实现 `state = reduce_agent_event(state, event)`,与前端流式 reducer 同构; -- 事件追加式写入 SQLite 事件日志,终态快照必须等于事件重放结果; -- 取消实现为注入取消事件,在节点边界由 reducer 收敛为 `interrupted`; -- 同会话请求串行排队,复用 claim 单飞语义; -- 新增事件 kind 走协议版本协商或特性开关,保证旧客户端兼容; -- 首批动作只有 `retrieve / retrieve_with_query_rewrite / ask_clarification / generate_answer / finish`; -- 死循环防线(对所有模型一致):`max_steps=4`——每次模型交互计一步,含 Guard 重试;检索轮次 ≤2、查询改写 ≤1、同动作重试 ≤1、运行 ≤120 秒防悬挂兜底。自然输入输出不设 Agent 限额:输入由请求合同管,输出由生成参数管;平台免费档三模型另在配额层单独限流(见 6.5); -- Context compaction 先用确定性策略:证据按 `chunk_id` 去重,未引用候选降级为标题+locator,Observation 只保留结构化字段,旧对话滚入数百字封顶的结构化摘要。 - -验收: - -- 证据不足时能补一次检索,证据充分时不会空转; -- 非法工具、跨课程参数、越界 URL 被拒绝并留下 `action_rejected`; -- 取消、超时、预算耗尽都能进入终态且事件日志完整; -- 终态快照与事件重放一致; -- 旧客户端在新事件流下不崩溃; -- Citation Guard 对每一步证据保持可追溯; -- 普通问题模型调用通常为 1~2 次,最坏不超过 3 次。 - -### Phase 4:exam_review 确定性计划确认 - -目标:只为备考复习提供业务层的确定性计划,不引入第二套通用 LLM Planner、ReAct 框架或 Replan 循环。 - -实现: - -- `exam_review` 根据大纲、薄弱点和历年题事实由代码生成短计划,不增加 LLM Planner 调用; -- 计划先展示给用户确认,再进入同一个 EventStream Agent Loop; -- 计划只影响检索顺序和覆盖目标,不新增工具,不改变 Agent Runtime; -- Observation 只更新覆盖率与缺失主题,后续动作仍受 Phase 3 的单步决策和硬预算限制; -- `observation_recorded` 后自动更新覆盖率,`action_rejected` 自动埋 rejection 指标。 - -验收: - -- 计划生成零额外模型调用; -- 用户确认、修改或拒绝路径均可审计; -- 计划中的主题有对应课程证据或明确标记为未覆盖; -- 预算、权限和 Citation Guard 规则与普通任务一致。 - -### Phase 5:工具服务化与 MCP - -只有当课程检索、资料转换、代码实验等能力需要跨系统复用时再做。 - -实现: - -- 先稳定内部 Action Port; -- 为特定工具实现 MCP Adapter; -- MCP 前后仍保留 Action Guard 和权限策略; -- 对远程调用增加超时、重试、幂等键和审计日志。 - -### 借鉴方案:Harness 七件套与 RAG 三层的场景化取舍 - -> 判断标准是"最适配",不是"最优"。coding agent 的 harness 为长时程、宽动作、本地文件环境设计;本项目是短会话、窄动作、受控知识域,多数重型机制在这里是负资产。每条借鉴都必须指认到它替换或补强的现有机制,说不出来就不引入。 - -#### Harness 七件套逐项 - -| 概念 | 项目现状 | 借什么 | 明确不借 | -|---|---|---|---| -| Session | conversation / run / `attempt_group_id` 三层已有;`regenerate` 即 fork 的领域版 | run 升格为一等 Session 对象,事件日志挂载其下(Phase 3 已含) | 进程级可恢复运行时容器 | -| Subagent | 无 | 无;多主题并行取证用 asyncio 进程内并发 | 多 Agent 协作、子代理派生 | -| Memory | 知识侧 = 版本化语料库;对话侧 = 六轮截断 | 结构化滚动摘要外部化状态(auto-compact / 结构化 todo 思想),数百字封顶 | 向量库存对话、跨会话用户画像 | -| Permissions | fail-closed 能力门全套 | 动作级白名单与预算(Phase 3) | —— | -| Approval | manifest `passed` 人工审核、贡献六态流转 = 离线审批 | exam_review 计划确认交互(plan mode 思想,进 Phase 4) | 运行时逐动作弹窗 | -| Hooks | Guard 节点即确定性反射(前置锚点清洗 / 后置 citation Guard) | 延伸到动作环:observation 后更新覆盖率、rejection 自动埋指标 | 用户可配置钩子 | -| Agent Loop | 见 6.4 EventStream Agent Loop | Observe/Decide/Act ↔ observation_recorded / decision_produced / 受控执行 | —— | -| Context Compaction | 字段级长度上限已有 | 证据账本去重;未引用候选降级为"标题+locator"一行(长输出 spill 思想);Observation 只存结构化字段;老轮次滚入摘要 | 语义压缩模型 | - -#### RAG 三层取舍 - -- **数据索引**:解析清洗不动(material_converter 确定性抽取 + GLM-4V 三道闸 + 人工 passed 是差异化资产);分块保持结构优先,locator 完整性 > 语义平滑;元数据补知识点标签(heading_path 规则推导);嵌入选型走 eval 金标集,embedding 模型身份编入索引版本号——换模型即新版本、可回滚。 -- **查询检索**:已有权威查询 / exam plan 合成 / 上下文补锚;新增每课程确定性同义词缩写展开表(不用 LLM 改写:省一趟调用、可审计、不碰"检索词不改课程范围语义"红线);两路召回融合第一版用 RRF(k=60) 替代加权线性(无参、对分数分布差异鲁棒);当前数据规模 sqlite-vec 即可,ANN 参数调优是伪需求;reranker 待评测决定。 -- **生成增强**:提示词体系保持现状;temperature 局部放宽仅限"AI 生成样题"子任务;不做自动模型路由——目录透明、配额可见本身是诚实性卖点。 - -#### 借鉴优先级总览 - -```text -P0(并入 Phase 2/3):RRF 融合;embedding 身份入索引版本;证据账本去重+候选降级摘要;结构化滚动摘要 -P1(并入 Phase 3/4):exam_review 计划确认;同义词展开表;Hook 延伸埋 rejection 指标 -明确不借:Subagent、运行时审批弹窗、MCP(现阶段)、向量库存对话、语义压缩模型、自动模型路由 -``` - -来源标注:DSH(goal/budget 边界、spill 文件、结构化 todo)、Claude Code(auto-compact、plan mode)、Codex(AGENTS.md 约定、沙箱 fail-closed)均取公开资料口径的机制思想,不冒称了解各家内部实现细节。 - -## 十、面试详细拷打 QA - -### Q1:你为什么要把项目从 Workflow 改成 Agent? - -**答:** 当前五类 Workflow 能覆盖固定任务,但用户需要先判断任务类型并填写不同输入框,而且每次基本是一次检索后直接生成。真实学习问题经常需要根据证据缺口继续检索、请求澄清或比较材料。因此我会保留五类 Workflow 作为受控 Skill,再增加自动路由和 EventStream Agent Loop——模型每轮基于 Observation 输出一个 Action 或最终回答,而不是把所有流程改成完全自由的 Agent。 - -### Q2:自动分类后直接调用 Workflow,不就够了吗? - -**答:** 自动分类只解决入口问题,属于 Router-driven Workflow。它仍然是“分类一次,固定执行一次”。要体现 Agent,需要让 Observation 改变下一步动作,例如检索只找到定义但没有复杂度证据时,系统可以自动补查复杂度,或者发现用户没有提供原答案时请求澄清。 - -### Q3:你说 Agent 的核心是循环,固定重试算不算? - -**答:** 不算。固定重试是开发者写死的条件分支。关键在于 Observation 更新状态,并影响下一步动作。例如根据已命中的章节、证据覆盖率和缺失主题选择新的查询,而不是无论结果如何都重复同一个查询。 - -### Q4:为什么用受限单步决策,而不是全局 Plan-and-Execute? - -**答:** 知识问答和错题分析的下一步取决于上一步检索到了什么,适合每轮只做一个受限决策;备考复习虽然有计划结构,但它的计划由代码根据大纲和历年题事实确定性生成,属于业务规则,不需要 LLM Planner。所以我只用一套 EventStream 驱动的单步决策循环:简单问题一轮出答案,证据不足最多补一次检索,硬预算封顶,不叠加第二套范式。 - -### Q5:为什么不让大模型直接调用所有 API? - -**答:** 课程系统存在课程边界、引用绑定、临时材料隔离和预算限制。模型直接调用 API 会把权限和安全责任分散给模型。我的方案是模型只输出结构化 Action,Action Guard 做 Schema、权限、课程、预算校验,真正执行由服务端 Executor 完成,这样更容易审计和 fail-closed。 - -### Q6:Tool Calling 和 MCP 在你的方案中分别做什么? - -**答:** Tool Calling 是模型输出结构化工具动作;MCP 是工具服务的发现、连接和调用协议。第一阶段工具都在同一个后端,直接用内部 ActionExecutor 更简单,能集中处理课程权限、引用和 Trace。只有当知识库、资料转换、代码实验等能力需要独立部署和跨客户端复用时,我才用 MCP 作为接入层;MCP 本身不提供 Agent 决策或安全策略。 - -### Q7:为什么要做混合检索? - -**答:** 课程资料既有语义问题,也有大量精确符号。向量检索擅长同义表达和语义相似,但对题号、公式、函数名、章节号、英文缩写和短查询未必稳定;词法/BM25 对这些精确匹配更可靠,也更容易解释和复现。因此使用词法召回加向量召回,再做元数据过滤和重排序。 - -### Q8:既然有向量检索,为什么不完全替换词法检索? - -**答:** 因为“第 3 题”“Dijkstra”“O(n log n)”“zplane(b,a)”这类查询需要精确 token 和符号命中。向量模型可能把相邻概念召回,却不能保证精确题号或公式。另一个原因是词法检索是低成本、可解释的故障降级路径,embedding 服务不可用时仍能提供受限回答。课程边界和审核状态也必须通过确定性元数据过滤,不能交给向量相似度。 - -### Q9:混合检索怎么融合? - -**答:** 词法和向量分别召回,例如各取 Top 20,按 `chunk_id` 去重,再按 `course_id`、审核状态、`corpus_version` 做过滤。第一版用归一化 BM25 分数、向量分数、标题/题号/章节奖励做加权融合;如果离线评测证明需要,再增加 reranker。每个候选保留各通道分数和来源,方便诊断。 - -### Q10:为什么要保留 `chunk_id` 和 `corpus_version`? - -**答:** 因为向量检索只改变召回方式,不应该改变证据身份。`chunk_id` 保证引用能回到源文档和 locator,`corpus_version` 保证回答使用的索引和语料版本可复现。语料更新时可以生成新索引并原子切换,出现问题可以回滚,评测也能固定版本。 - -### Q11:如何判断证据足够? - -**答:** 不只看命中数量,而看任务所需证据覆盖。比如题目辅导需要覆盖题目对应知识点和解法依据,错题复盘还需要覆盖原答案与参考依据,备考复习需要覆盖大纲主题和历年题事实。Observation 中记录命中来源、覆盖主题、未覆盖主题、来源冲突和证据等级,由终止策略判断继续检索、追问还是生成。 - -### Q12:模型会不会一直检索,形成死循环? - -**答:** 会有明确预算:最大步数、检索次数、查询改写次数、模型调用次数、Token 和时间上限;同一动作重复失败也会触发终止。达到预算后返回有边界的 `insufficient_evidence` 或请求澄清,而不是无限重试。所有动作都有 Trace,便于定位循环原因。 - -### Q13:如何防止模型越权检索其他课程? - -**答:** `course_id` 不是完全信任模型的参数。Action Guard 会把它与会话绑定课程、用户权限和当前 active corpus 对照;检索结果还要做来源授权校验,候选课程不一致就拒绝。模型不能通过改写 query、伪造 citation 或调用外部工具绕过课程边界。 - -### Q14:如何防止用户材料中的 Prompt Injection? - -**答:** 输入解析阶段把材料标记为数据,Prompt 中明确区分“内容”和“指令”;材料只能作为本次上下文,不能改变系统工具权限、课程范围或终止策略。模型输出的 Action 仍然必须经过 Schema 和 Guard,所以即使材料要求读取文件或访问网络,也不会获得执行权限。 - -### Q15:如何处理检索结果互相矛盾? - -**答:** 不直接让模型自行选择一个结论。Observation 标记冲突来源,系统可以按文档版本、审核状态、章节定位和任务相关性重新排序;如果冲突仍未解决,就让模型分别陈述各来源观点并给出引用,或者向用户澄清上下文。不能把未经判断的单一答案包装成确定事实。 - -### Q16:临时材料和课程资料冲突时怎么办? - -**答:** 两者承担不同职责:回答“材料写了什么”以用户材料原文为准;回答“材料是否正确”才用课程资料进行核验。输出中分别标记材料观点和课程证据,不能把课程资料改写成材料原意,也不能给用户材料补造课程页码。 - -### Q17:为什么 Bilibili 资源不直接参与 RAG? - -**答:** Bilibili 是外部、未审核、内容变化快的资源。项目可以根据模型提取的知识点生成一个匿名搜索链接,但不抓取和解析视频结果,也不把它当作课程权威证据。这样课程回答的 Citation 仍然只来自可审计语料,外部资源作为独立补充。 - -### Q18:如何评估演进是否有效? - -**答:** 分三层评估。检索层看 Recall@K、MRR/nDCG、题号和公式精确命中率、语义改写命中率;Agent 层看任务路由准确率、有效动作率、平均步数、无效循环率、预算超限率、澄清成功率;回答层看 Citation precision、证据覆盖率、拒答准确率、越权引用数和人工评分。不能只看最终回答是否流畅。 - -### Q19:如何控制成本和延迟? - -**答:** 先走确定性轻量路径:路由、元数据过滤和词法召回成本低;向量召回和 rerank 可以并行;简单问题命中高置信证据后直接生成,证据不足时才多一轮检索决策。每一步设置 Token、时间和调用预算,模型不可无限调用。对高频课程查询可以缓存版本化检索结果,但回答仍需重新做权限和引用校验。 - -### Q20:如果向量服务挂了怎么办? - -**答:** 降级到词法检索,并在 Trace 中记录 `vector_retrieval_unavailable`。如果词法结果达到最低证据阈值,继续生成;否则返回证据不足,不伪装成完整回答。这样向量检索是增强能力,不是整个系统的单点故障。 - -### Q21:为什么不直接用一个大 Prompt 让模型自己完成所有事情? - -**答:** 大 Prompt 无法提供可靠的权限边界、版本绑定、工具参数校验和可复现 Trace。课程系统还需要精确引用和安全降级。把决策、执行、证据和 Guard 分层,才能测试每一层,也能在模型更换时保持系统契约稳定。 - -### Q22:你如何证明这不是把固定 Workflow 换个名字? - -**答:** 我会看下一步是否由 Observation 驱动。若所有节点、顺序和重试次数都由代码固定,只是加了 Agent 名字,仍然是 Workflow。真正的演进要求 Agent 输出受约束 Action,Action 执行后产生 Observation,Observation 更新状态并影响下一步动作,同时有明确终止和预算策略。自动路由本身不构成 Agent。 - -### Q23:为什么不一开始就引入 MCP? - -**答:** MCP 解决工具接入和复用,不解决 Agent 决策。当前课程检索、引用 Guard、课程边界和运行 Trace 强耦合,先用内部 Action Port 可以减少网络跳转和权限分散。等工具需要独立部署、被多个 Agent 复用时,再把稳定的 Action Port 适配成 MCP,避免为了使用协议而增加系统复杂度。 - -### Q24:你认为这个系统最终最准确的架构名称是什么? - -**答:** 演进前是“受控 Workflow + 确定性词法 RAG + 服务端 Guard + 可选 LLM Gateway”;演进后是“由 EventStream 驱动、以受限单步决策为核心的课程学习 Agent Runtime”。其中 Agent 的自由度被动作白名单、课程权限、引用校验、预算和终止策略限制,重点不是追求完全自主,而是让证据驱动下一步并且可审计。 - -### 前端与工程化深挖 QA(Q25–Q35) - -#### Q25:你说流式输出,具体是什么协议?为什么不用 SSE? - -**答:** 我用的是 fetch POST + NDJSON:响应头 `application/x-ndjson`,每行一个 JSON 事件,前端用 ReadableStream reader 逐行解码解析。不用 EventSource 有三个原因:一是需要 POST 大 payload(题目、临时材料最长十万字符),SSE 只支持 GET;二是 EventSource 会自动重连,而我们的 run 是有状态的一次性执行,隐式重连会带来重复触发副作用;三是我需要对事件帧做强 schema 校验和 run 绑定校验,自定义协议更干净。两者都是 HTTP 服务器推流,迁移成本主要在服务端 media type 和重连缓冲策略。 - -#### Q26:流式过程中怎么保证不错序、不丢帧? - -**答:** 三层防护:事件 sequence 必须严格递增,乱序直接协议错误;Trace 事件额外有 event_id 去重;最关键的是终态 result 到达时,前端把已累积的回答块和 trace 与终态做全量比对,不一致就拒绝落定。这样即使中间丢了 delta,也不会把残缺回答当成完整结果展示,而是显式失败。 - -#### Q27:用户中途关掉页面或断网,运行怎么办? - -**答:** 分两种情况。显式取消走 AbortController 加服务端 cancel 端点,服务端在下一个节点边界收敛成 interrupted 状态并留 trace;注意模型同步调用期间无法立即打断,只能在下一节点生效,这一点我会如实说明。网络断开则前端不取消运行,因为服务端会把终态持久化,用户回来点"重新读取"即可取回结果——把"连接生命周期"和"运行生命周期"分开设计。 - -#### Q28:长回答流式渲染会不会卡? - -**答:** 目前有三道缓解:KaTeX 整个渲染管线路由级懒加载,不占首屏;渲染函数是纯函数便于后续移入 Web Worker;终态前增量文本先按块累积。进一步优化方向是把渲染移入 Worker 并按 rAF 合并 delta 再渲染,配合虚拟滚动只渲染可视块。我不会说已经做了这些,而是讲清楚触发条件(渲染超 50ms 才值得上 Worker)和迁移路径。 - -#### Q29:数学公式渲染有什么坑? - -**答:** 三个坑:一是 `$` 符号会被 markdown 引擎当普通文本破坏公式,所以先用占位符把公式摘出来,markdown 解析后再还原交给 KaTeX;二是矩阵这类环境有特殊分隔符语法需要预修复;三是 KaTeX 输出的 SVG/MathML 标签会被 DOMPurify 默认策略杀掉,需要定制消毒白名单,既放行公式又挡住其他注入。 - -#### Q30:XSS 怎么防? - -**答:** 纵深防御。第一层在后端:引用 Guard 禁止模型输出 URL 形态文本,回答块类型白名单控制内容归属;第二层在前端:模型输出一律视为不可信源,marked 解析后必须过 DOMPurify 消毒才允许 innerHTML;第三层是测试:有专门的测试断言恶意 markdown 被消毒而 KaTeX 公式保留。 - -#### Q31:为什么不用 Web Worker? - -**答:** 如实说:当前没用。因为首屏瓶颈已经靠异步组件拆包解决,而常规问答的渲染量不足以卡顿主线程。我预留了迁移路径:渲染已经是纯函数,长材料场景出现可感知卡顿时,把它移入 Worker 并对 delta 做 rAF 节流即可。我认为正确的工程顺序是先测量再引入复杂度,而不是为了简历关键词预先堆技术。 - -#### Q32:BYOK 模型选择前端怎么处理安全性? - -**答:** 前端目录带版本号做 fail-closed:服务端目录版本与本地冻结目录不匹配时,凭据保存和模型请求直接禁用,防止新旧目录字段不一致导致静默错误。API Key 不经过前端存储明文回显,凭据状态只返回 configured 与否;Mock 身份看不到可用 BYOK 入口。思路是前端同样执行能力协商,而不是无条件信任服务端目录。 - -#### Q33:流式协议怎么测试? - -**答:** 测试里用 ReadableStream 手工构造分块的 NDJSON 流,覆盖正常序列、跨 chunk 撕裂的 JSON 行、乱序 sequence、重复 event_id、未知字段、answer_delta 块索引跳跃、终态与增量不一致、终态后再来事件等用例,每种都断言抛出对应的协议错误。协议层测试不依赖真实网络,fetch 可注入。 - -#### Q34:你这套流程后续也是自研,为什么不用 LangChain/LangGraph 这类框架? - -**答:** 先纠正前提:我不是不用框架,FastAPI、Vue、Pydantic 都在用;我自研的只是 LLM 编排层。原因有三点。第一,这个场景的核心需求是可审计的确定性合同——课程级安全边界、引用 Guard、corpus_version 版本绑定、终态一致性校验,这些都要求控制流完全透明;通用框架把逻辑藏进链式抽象里,我要实现"空证据降级而非失败""候选越课即拒绝"反而要和框架对抗。第二,当前编排复杂度不高:固定节点序加有限分支,第一版 Agent Loop 也只是一个小状态机,为它引入重型依赖是用框架的复杂度买用不到的能力。第三,评测复现:我的 eval 是契约评测,框架组件自带 prompt 和记忆管理,版本升级会让评测基线漂移。同时我也承认边界:向量库、embedding 客户端这类基础设施我会直接用成熟方案,自研只限编排层;等出现真正的动态图、断点恢复、多人协作需求时,我会重新评估 LangGraph 这类编排框架。原则是编排自研、基础设施用库、框架按需后置。 - -#### Q35:如果面试官追问"自研是不是重复造轮子",你怎么回应? - -**答:** 造轮子的判断标准是"是否存在成熟且可控的替代品,以及自研部分是否是我的系统差异点"。数据库、Web 框架、向量索引这些有成熟方案,我全部直接用;而课程边界 Guard、引用合同、NDJSON 流协议、确定性考试计划这些是这个产品的差异化约束,没有现成框架能开箱提供,它们恰恰是我要控制和测试的部分。所以轮子分两类:通用件绝不重造,差异件必须握在自己手里。 - -### Harness 与 RAG 借鉴深挖 QA(Q36–Q41) - -#### Q36:Session、Subagent、Memory 这些 Harness 概念在你的系统里怎么对应? - -**答:** 三层同构物已经有了:conversation 是跨请求会话容器,run 是一次执行实例,`attempt_group_id` 加 `regenerated_from_run_id` 实现了 fork 式分支——regenerate 就是从旧 attempt 派生新 run。Subagent 我明确不做,单次 run 只有几步,派生子进程的开销超过收益。Memory 拆两层:知识记忆是带 corpus_version 的版本化语料库;对话记忆保持六轮窗口,另补一个几百字封顶的结构化滚动摘要。Harness 概念对我的价值是对照出真实缺口,不是逐个堆上。 - -#### Q37:为什么不用 Subagent 或多 Agent 协作? - -**答:** Subagent 解决的是主上下文被子任务中间过程污染的问题,前提是任务长时程、动作空间宽。我的场景是短会话、窄动作:一次 run 几步以内,唯一值得并行的是备考复习的多主题取证,asyncio 进程内并发两路检索就够,不需要进程级隔离。判断标准是任务形状,不是概念先进性。 - -#### Q38:Memory 和 Context Compaction 具体怎么做?为什么不用向量库存对话? - -**答:** Compaction 用确定性手段优先:证据账本按 chunk_id 去重;未被引用的候选在后续轮次降级为标题加 locator 一行——长输出落盘留摘要的思想;Observation 只存结构化字段不存思维链;老对话轮次超限后滚入摘要,摘要内容限定为已确认的课程范围和已澄清的约束。不用向量库存对话有两个原因:一是强调引用可追溯的系统里,不可审计的记忆来源会污染证据链;二是我的会话短,摘要加截断的成本收益远好于一套检索式记忆。 - -#### Q39:Permissions 和 Approval 怎么处理?运行时为什么不做人工审批弹窗? - -**答:** 权限已经是 fail-closed 全套:课程边界、能力门、BYOK 凭据边界,加上 Phase 3 的动作白名单和预算。审批时机是刻意设计的:答题中途弹窗问"允许检索吗"是灾难体验,而且我的高风险动作不在运行时而在内容侧——manifest 的 passed 人工审核和维护者六态流转就是离线审批。真正借了 plan mode 思想的位置是 exam_review:计划生成后先给用户确认再执行深度检索,人工介入放在天然决策点而不是每个动作上。 - -#### Q40:RAG 三层优化的取舍具体讲讲?融合为什么选 RRF 而不是加权分数? - -**答:** 索引层解析清洗不动,material_converter 的确定性抽取加人工审核是差异化资产;分块保持结构优先,因为 locator 完整性比语义平滑重要;嵌入选型走 eval 金标集,并把 embedding 模型身份编进索引版本号,换模型等于新版本可回滚。查询层新增每课程确定性同义词表而不用 LLM 改写——省一趟调用、可审计、不碰"检索词不改课程范围语义"的红线。融合第一版用 RRF(k=60):无参数、对词法和向量两路分数分布差异鲁棒,加权线性要先归一化还要调权。当前数据规模 sqlite-vec 就够,ANN 调参在这个量级是伪需求;reranker 等评测结果说话。 - -#### Q41:这些借鉴你怎么划边界,避免变成概念堆砌? - -**答:** 规则是一条:每个 borrowed 概念必须指认到它替换或补强的现有机制,说不出来就不引入。Session 对照的是 conversation 加 attempt_group,Hooks 对照的是既有 Guard 节点,Compaction 对照的是六轮截断的真实缺口;而 Subagent、运行时审批、向量库存对话这三个,我指认不出它们要解决问题的现状,所以明确不借。落地纪律是 P0 四项全部挂 Phase 2/3、P1 三项挂 Phase 3/4,其余写在"明确不借"里防自己跟风。 - -### 运行机制、预算与性能深挖 QA(Q42–Q46) - -#### Q42:你的方案为什么不是 ReAct + Plan-and-Execute + EventStream 三者叠加? - -**答:** 它们本来就不在同一层,我的运行时里也只有一套机制。EventStream + Reducer 是承载方式——事件、持久化、重放、前端推送,本身不产生模型调用;受限单步决策是行为方式——每轮根据 Observation 输出一个 Action 或 Final。"ReAct"只是对这种 Observe → Decide → Act 行为的解释词,不作为独立框架部署。exam_review 的计划由代码按大纲和历年题事实确定性生成,属于业务规则,不是通用 LLM Planner,也不做 Replan 循环。所以主链路上只有一个决策循环,不存在三套范式串联。 - -#### Q43:这个 Loop 的推理成本和运行时间大概多少? - -**答:** 用调用次数估算,不给无依据的毫秒承诺。简单问题命中高置信证据直接回答:模型调用 1 次,检索 1 轮。先检索再回答:2 次。补一次检索的上限路径:3 次,步数预算 4 封顶——每次模型交互都算一步,Guard 重试也计入。EventStream 本身只有本地事件序列化加 SQLite 追加写的开销,量级远小于一次远程模型请求。混合检索两路并行,T_retrieval ≈ max(词法, 向量) + RRF 合并,不是两腿相加。延迟主要取决于所选模型的单次响应时间:平台免费档单次 10~20 秒很常见,所以运行时限设 120 秒作为防悬挂兜底而不是成本手段;BYOK 用户选更快模型自然更快。Token 成本可观测但不设闸门——供应商 usage 回填 Trace 做审计。 - -#### Q44:平台免费模型和 BYOK 为什么是两套限额? - -**答:** 因为它们保护的东西不同。平台三个免费模型走 OpenRouter 每日额度,是共享稀缺资源,限额本质是配额治理:每用户每日请求数和 Token 数在 ModelGateway 配额层前置检查,额度耗尽明确报错、不自动切换到 BYOK 或其他模型——这是 PLAN-1 冻结的诚实性决策。BYOK 是用户自己的 Key 自己付费,系统只需要保证不失控,所以只保留死循环防线:步数、检索轮次、改写次数、同动作重试,加一个进程级运行时限。自然的输入输出大小不该由 Agent 预算管:输入有请求合同上限,输出有生成参数,重复设限只会造出两套互相打架的数字。 - -#### Q45:临时材料精读上限 10 万字符,怎么处理? - -**答:** 上限是产品合同;工程上"能处理"的定义不是把 10 万字塞进 prompt,而是确定性预处理做注意力管理。管线全部零模型调用:第一步解析 markdown 标题树并按节切块,复用语料构建器的标题栈思路;第二步生成材料地图——标题树加各节字数和首句,约几百 token,让模型知道材料有什么;第三步按本次任务焦点对段落做词法打分选段,"材料写了什么"按结构顺序取节,"材料是否正确"则拿知识点去课程语料对照核验;第四步注入地图加带 locator 的选段,材料来源标记 user_material,与课程证据分离。只有用户显式要求通读全文才启用分批 map-reduce 精读——批间传结构化摘要,并消耗扩展预算档。这样引用能回到材料具体位置,成本可控,预处理管线也能独立测试。 - -#### Q46:有没有一次真实的性能优化可以讲? - -**答:** 语料网关的全量校验缓存(commit accc54d,204 秒 → 0.30 秒)。症状是课程列表接口要几分钟:每次可用性检查都调 `load_active_course`,而它每次对整个 candidate 做完整 `validate_candidate` 校验,单课程秒级,全量扫描累计约 204 秒,还把 FastAPI 线程池饿死了。关键洞察来自激活合同本身:candidate 目录不可变——激活和回滚永远写入新目录加新指针,所以校验结果是当前指针值的纯函数。于是把校验按 active 指针解析内容的 SHA-256 摘要做记忆化,线程锁保护(可用性检查在线程池并发执行);同时保留每次调用都新鲜的尾部——元数据绑定检查和课程索引读取,指针与元数据不一致仍然当场 fail-closed。两层教训:不可变性是缓存的许可证;缓存键必须绑定会使缓存失效的原因(指针变化),而安全检查的失败路径绝不能被缓存掉。 - -## 十一、前端 AI 应用亮点:流式协议、渲染与模型选择 - -> **事实口径**:当前实现的流式方案是 `fetch POST + NDJSON`(`application/x-ndjson`),不是标准 SSE 的 `text/event-stream`,也没有使用 Web Worker。面试时要主动说清这个选型理由,这比含糊地说"用了 SSE"更能体现深度。 - -### 11.1 流式链路:NDJSON over fetch - -```mermaid -sequenceDiagram - participant U as 用户 - participant S as 前端 Store - participant F as fetch + ReadableStream - participant A as FastAPI 服务端 - participant M as 模型网关 - - U->>S: 提交问题 - S->>F: POST /workflow-runs/stream\nAccept: application/x-ndjson - F->>A: 建立流式连接(AbortController 绑定) - A->>M: 组装 prompt 并调用模型 - loop 运行过程 - A-->>F: {"kind":"trace",...} 节点事件\n{"kind":"answer_delta",...} 回答增量 - F->>S: 校验序号/去重/追加渲染 - end - A-->>F: {"kind":"result",...} 终态结果 - F->>S: 终态一致性校验后落定状态 -``` - -协议设计的关键约束: - -1. 四类事件白名单:`trace / answer_delta / result / error`,每类只允许一个 payload 字段。 -2. 字段白名单:未知字段直接抛协议错误,防止服务端悄悄加字段导致前端静默错乱。 -3. `sequence` 必须严格连续(+1),Trace 事件另有独立序号和 `event_id` 去重。 -4. 所有事件绑定同一个 `workflow_run_id`,跨 run 事件直接拒绝。 -5. `answer_delta` 按 `block_index` 追加,块类型不可中途改变。 -6. **终态一致性校验**:`result` 到达时,把已累积的 answer blocks 和 trace 与终态做全量比对,不一致即协议错误——保证"用户看到的流式内容 == 最终持久化内容",不会出现丢帧后展示残缺回答。 -7. 终态之后再来任何事件都拒绝(幂等保护)。 - -### 11.2 为什么用 NDJSON 而不是标准 SSE - -这是必被追问的选型题: - -| 维度 | NDJSON over fetch(当前) | EventSource/SSE | -|---|---|---| -| 请求方法 | POST,可携带大 payload(题目/材料最长 10 万字符) | 只能 GET | -| 自定义 Header/Cookie | 完全可控 | 受限 | -| 自动重连 | 无隐式重连 | 浏览器自动重连,会重复触发副作用 | -| 断点续传 | 需自行实现(当前:显式"重新读取") | Last-Event-ID 内建但需服务端配合缓冲 | -| 数据结构 | 每行一个 JSON 对象,结构化强校验 | data: 文本帧,需自行拼 JSON | -| 代理/缓冲控制 | `X-Accel-Buffering: no` 同样适用 | 相同 | - -核心理由是:**一次 workflow run 是有状态的一次性执行,EventSource 的自动重连语义反而是负担**——重连可能重复发起 run 或重复消费事件。当前方案的恢复策略是显式的:网络错误时前端不取消服务端运行,提示"本次运行仍会在服务端继续,稍后可点击重新读取";只有用户主动取消才走 AbortController + 服务端 cancel 端点,在下一个节点边界收敛为 `interrupted`。 - -如果面试官坚持问 SSE:回答"NDJSON streaming 与 SSE 同属 HTTP 服务器推流,区别在帧格式与重连语义;若迁移到 SSE,需要给 run 增加幂等键和事件缓冲才能安全利用自动重连,成本高于收益"。 - -### 11.3 渲染管线:不可信输出的安全渲染 - -模型输出按不可信 HTML 源处理: - -```text -原始 markdown - → 数学公式预处理($...$/$$...$$ 摘出为占位符, - 避免被 marked 当作普通文本破坏) - → marked(GFM + breaks) - → KaTeX renderToString(矩阵等环境先做行修复) - → DOMPurify 白名单消毒 - (保留 KaTeX SVG path / MathML 所需的标签与属性) - → 注入 DOM -``` - -亮点在于三层防御的组合: - -1. 后端 Guard 已禁止模型输出 URL 形态文本; -2. 前端 DOMPurify 再做 XSS 兜底,且为 KaTeX 定制了消毒白名单而不是粗暴禁掉全部 SVG; -3. 双写一致性校验保证渲染内容和数据库里的回答完全一致。 - -### 11.4 加载性能:KaTeX 路由级懒加载 - -- KaTeX JS + CSS 不进首屏入口包:`WorkflowResult` 是唯一消费渲染管线的视图,通过 `defineAsyncComponent` 动态引入,KaTeX 随该异步 chunk 按需加载; -- CSS 与其唯一 JS 消费方放在同一 chunk,避免样式闪断; -- 更有说服力的是:有一个**读取真实源码的回归测试**(不做模块 mock),断言入口图不存在任何 katex 引入(静态 import / 动态 import / require 都算违规)、CSS 与消费者同块——把性能优化固化成 CI 里可执行的约束,而不是靠口头约定。 - -### 11.5 Web Worker 的定位(诚实口径 + 演进方案) - -现状没有使用 Web Worker,渲染在主线程完成,首屏压力靠异步组件拆包化解。 - -什么时候值得引入 Worker: - -- 用户粘贴长材料(临时材料阅读场景,单次输入可达 10 万字符),markdown + KaTeX + DOMPurify 同步渲染超过 50ms 就会造成输入卡顿; -- 流式期间每个 delta 触发重渲染,长回答会持续占用主线程。 - -演进方案: - -```text -markdown.ts 已是纯函数(字符串 → HTML 字符串) - → 把执行位置移到 Worker:主线程 postMessage({markdown}) - → Worker 内跑 渲染管线 → 返回 {html}(Structured Clone) - → 主线程只做 innerHTML 挂载 -``` - -配套优化: - -- 流式期间按 rAF / 空闲节流合并 delta,再交给 Worker 渲染,而不是每个 token 渲染一次; -- 长文档配合虚拟滚动 / IntersectionObserver,只渲染可视块; -- 取舍要点:Worker 无 DOM,DOMPurify 需要 DOM 环境(可用同构方案或预编译消毒规则);KaTeX 是纯字符串转换,Worker 内无障碍;调试与测试复杂度上升。因为渲染已是纯函数,迁移只是换执行位置,随时可做——这也是"先把逻辑做成纯函数"架构决策的红利。 - -### 11.6 取消、断线与恢复语义 - -三条路径语义不同,这是容易被追问的细节: - -1. **显式取消**:`AbortController.abort()` 断开 fetch + `POST /{run_id}/cancel` 通知服务端;服务端在下一个节点边界收敛为 `interrupted` 并持久化 trace,供应商同步调用期间不能立即打断,只能尽力中止上游等待。 -2. **网络断开**:前端**不取消运行**,UI 明确提示"服务端仍在继续,可稍后重新读取";run 的终态已落库,刷新或重进即可取回。 -3. **离开页面**:卸载时 `abortActiveWorkflow`,与显式取消同路径。 - -### 11.7 模型选择与 BYOK 的前端能力协商 - -- 平台目录(免费模型列表)与 BYOK 目录分离;BYOK 目录带版本号,**版本不匹配时前端 fail-closed:凭据保存直接禁用**,并给出明确文案,而不是拿旧目录猜接口; -- Mock 身份只能看到 BYOK 入口展示,真实 GitHub 登录才能管理凭据; -- 模型选项由"目录 × 凭据状态 × 运行时可用性"三者计算派生,未加载目录时所有模型请求关闭——前端也遵守和服务端一致的能力门思想。 - -> 前端与工程化相关深挖 QA(Q25–Q35)已归并至第十章问答区。 - -## 十二、最后的面试总结 - -可以用下面这段作为项目总结: - -> 我这个项目不是简单地把资料丢给大模型,而是围绕课程学习场景设计了一套受控的 RAG Agent 演进路线。基线阶段用五类 Workflow 处理知识问答、备考、题目辅导、错题复盘和临时材料阅读,并通过课程边界、版本绑定和引用 Guard 保证证据可追溯。前端方面,我实现了带协议校验的 NDJSON 流式链路:事件序号连续性检查、终态与增量全量比对、断线不取消运行、显式取消在节点边界收敛,配合 KaTeX 懒加载和 DOMPurify 安全渲染保证体验与安全。后续我会把五类 Workflow 保留为 Skill,增加统一输入和自动路由;检索层采用词法加向量的混合召回,因为课程里有大量题号、公式、函数名和章节标识,纯向量检索容易丢失精确匹配;执行层用 EventStream 驱动的受限单步决策:模型每轮只输出一个 Action 或最终回答,全部经过服务端权限、课程范围和预算校验;备考复习的计划由代码确定性生成并经用户确认,不额外引入 Planner。这样既获得 Agent 的证据闭环,又保留了课程场景所需的可解释性、安全性和可复现性。