选择一道题目
+完整问题、参考答案与索引来源会显示在这里。
+diff --git a/README.md b/README.md index 3afc08a..fbc3ff7 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ uv run interview-agent serve --reload 2. 收到题目后数字人自动用普通话播报;点击“语音回答”录音,结束后可编辑 Whisper 转写,再提交并观察评分、追问、评估报告与复习计划。 3. 面试途中点击“模拟断线”,客户端会自动用 `session_id + resume_token + last_event_id` 续面。 4. 在等待回答时直接刷新浏览器,事件时间线与当前作答状态应恢复。 -5. 使用“我的题库”上传 Markdown、TXT、PDF 或 DOCX,再开始面试验证个人 RAG;完成后可在“面试档案”查看历史报告。 +5. 使用“我的题库”上传 Markdown、TXT、PDF 或 DOCX;上传后可按来源、难度、题型或关键词浏览题目与完整参考答案,再开始面试验证个人 RAG。完成后可在“面试档案”查看历史报告。 为避免覆盖机器上常见的本地服务,Compose 默认把 MySQL、Redis、Milvus 分别映射到 `3307`、`6381`、`19531`,应用映射到 `9092`;都可通过同名 `*_PORT` 环境变量覆盖。MinIO 只供 Milvus 内部使用,不暴露宿主机端口。 diff --git a/specs/001-python-rewrite/spec.md b/specs/001-python-rewrite/spec.md index 6980368..4344ce2 100644 --- a/specs/001-python-rewrite/spec.md +++ b/specs/001-python-rewrite/spec.md @@ -211,6 +211,7 @@ waiting_for_follow_up(主问题已评分) -> evaluating - 旧 `dataset_v1.json` 仅作为 50 条流程冒烟基线,不再作为正式 A/B 结论来源。 - 用户更新同名题库时,先形成新版本,索引成功后切换可见版本,避免删除旧索引后导入失败。 - 带 Markdown 问题标题或 `Q/A` 标记的题库使用确定性解析;其他非结构化文档交给 DeepSeek 标准化,结构化输出无效时明确失败;所有题目分批写入嵌入服务。 +- 题库上传成功后,用户必须能在“我的题库”看到当前激活版本的来源文件、题目总数、难度与类型统计,并通过关键词、来源、难度和类型筛选题目。列表选中题目后展示原始问题、参考答案、技能标签、来源文件与索引版本;空题库、加载失败和无匹配结果均提供可操作状态,不展示其他用户的数据。 ## 非功能要求 @@ -244,6 +245,7 @@ waiting_for_follow_up(主问题已评分) -> evaluating 20. 人为让向量、BM25 或 rerank 任一通道超时,检索仍能从剩余结果返回且排序可解释;两路成功时确认它们在同一等待窗口内并行执行并按 RRF 融合。 21. 使用固定小语料分别运行四种检索模式,报告中的样本数、黄金文档 ID、Recall、MRR、nDCG 和分组统计可重复;正式测试集不会因调参集运行而被修改。 22. 桌面端向右拖动 Brief 与面试房间之间的分隔条后,左侧 JD/简历 PDF 预览显著变宽且中间面试房间不低于最小宽度;刷新后宽度恢复。键盘可调、双击复位,窄屏下分隔条不显示且页面无横向溢出。 +23. 用户上传题库后无需刷新即可看到激活题目列表;选择任意题目可读完整参考答案,搜索和来源/难度/类型筛选会更新结果数与分页。刷新或重新进入“我的题库”后详情仍来自 MySQL,空库与无结果状态明确,且用户 A 无法看到用户 B 的题目。 ## 已确认决策 diff --git a/src/interview_agent/api/http.py b/src/interview_agent/api/http.py index e3b906c..39445ca 100644 --- a/src/interview_agent/api/http.py +++ b/src/interview_agent/api/http.py @@ -1,6 +1,6 @@ from typing import Annotated, Any, cast -from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile, status +from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile, status from interview_agent.api.dependencies import CurrentUser, current_user, get_container from interview_agent.api.schemas import ( @@ -91,6 +91,33 @@ async def upload_question_bank( raise HTTPException(status_code=503, detail=str(exc)) from exc +@router.get("/api/question-bank") +async def browse_question_bank( + user: Annotated[CurrentUser, Depends(current_user)], + container: Annotated[Any, Depends(get_container)], + query: Annotated[str | None, Query(max_length=200)] = None, + source: Annotated[str | None, Query(max_length=512)] = None, + difficulty: Annotated[str | None, Query(pattern="^(easy|medium|hard)$")] = None, + question_type: Annotated[ + str | None, Query(alias="type", pattern="^(basic|experience|design)$") + ] = None, + page: Annotated[int, Query(ge=1)] = 1, + page_size: Annotated[int, Query(ge=1, le=100)] = 24, +) -> dict[str, object]: + return cast( + dict[str, object], + await container.question_banks.browse( + user.id, + query=query, + source_file=source, + difficulty=difficulty, + question_type=question_type, + page=page, + page_size=page_size, + ), + ) + + @router.post("/api/resume/extract") async def extract_resume( file: Annotated[UploadFile, File(...)], diff --git a/src/interview_agent/infra/repositories.py b/src/interview_agent/infra/repositories.py index 3be34fa..64c8644 100644 --- a/src/interview_agent/infra/repositories.py +++ b/src/interview_agent/infra/repositories.py @@ -5,7 +5,7 @@ from typing import Any, cast from uuid import uuid4 -from sqlalchemy import delete, select, update +from sqlalchemy import delete, func, or_, select, update from sqlalchemy.engine import CursorResult from sqlalchemy.exc import IntegrityError @@ -482,6 +482,117 @@ async def active_questions(self, user_id: str) -> list[QuestionBankItem]: for row in rows ] + async def browse_question_bank( + self, + user_id: str, + *, + query: str | None, + source_file: str | None, + difficulty: str | None, + question_type: str | None, + page: int, + page_size: int, + ) -> dict[str, Any]: + active_filter = ( + QuestionBankRow.user_id == user_id, + QuestionBankRow.active.is_(True), + ) + filters: list[Any] = list(active_filter) + if query: + pattern = f"%{query}%" + filters.append( + or_( + QuestionBankRow.content.ilike(pattern), + QuestionBankRow.reference.ilike(pattern), + ) + ) + if source_file: + filters.append(QuestionBankRow.source_file == source_file) + if difficulty: + filters.append(QuestionBankRow.difficulty == difficulty) + if question_type: + filters.append(QuestionBankRow.question_type == question_type) + + async with self.database.session() as db: + source_rows = ( + await db.execute( + select( + QuestionBankRow.source_file, + QuestionBankRow.version, + func.count(QuestionBankRow.id), + func.max(QuestionBankRow.created_at), + ) + .where(*active_filter) + .group_by(QuestionBankRow.source_file, QuestionBankRow.version) + .order_by(func.max(QuestionBankRow.created_at).desc()) + ) + ).all() + difficulty_rows = ( + await db.execute( + select(QuestionBankRow.difficulty, func.count(QuestionBankRow.id)) + .where(*active_filter) + .group_by(QuestionBankRow.difficulty) + ) + ).all() + type_rows = ( + await db.execute( + select(QuestionBankRow.question_type, func.count(QuestionBankRow.id)) + .where(*active_filter) + .group_by(QuestionBankRow.question_type) + ) + ).all() + total = int( + await db.scalar( + select(func.count(QuestionBankRow.id)).where(*filters) + ) + or 0 + ) + rows = ( + await db.scalars( + select(QuestionBankRow) + .where(*filters) + .order_by( + QuestionBankRow.created_at.desc(), + QuestionBankRow.source_file, + QuestionBankRow.id, + ) + .offset((page - 1) * page_size) + .limit(page_size) + ) + ).all() + + return { + "total": total, + "page": page, + "page_size": page_size, + "question_count": sum(int(row[2]) for row in source_rows), + "sources": [ + { + "filename": row[0], + "version": row[1], + "question_count": row[2], + "created_at": row[3].isoformat(), + } + for row in source_rows + ], + "difficulty_counts": {row[0]: row[1] for row in difficulty_rows}, + "type_counts": {row[0]: row[1] for row in type_rows}, + "items": [ + { + "id": row.id, + "content": row.content, + "reference": row.reference, + "type": row.question_type, + "difficulty": row.difficulty, + "skills": row.skills, + "source_file": row.source_file, + "version": row.version, + "created_at": row.created_at.isoformat(), + } + for row in rows + ], + } + async def append_chat(self, user_id: str, role: str, content: str) -> None: async with self.database.sessions.begin() as db: db.add( diff --git a/src/interview_agent/services/question_bank.py b/src/interview_agent/services/question_bank.py index 9be28ba..175df96 100644 --- a/src/interview_agent/services/question_bank.py +++ b/src/interview_agent/services/question_bank.py @@ -63,3 +63,24 @@ async def import_bytes(self, user_id: str, filename: str, data: bytes) -> dict[s ) raise QuestionBankImportError("题库索引服务暂时不可用,旧版本仍可继续使用") from exc return {"filename": filename, "version": version, "success": len(items)} + + async def browse( + self, + user_id: str, + *, + query: str | None = None, + source_file: str | None = None, + difficulty: str | None = None, + question_type: str | None = None, + page: int = 1, + page_size: int = 24, + ) -> dict[str, object]: + return await self.repository.browse_question_bank( + user_id, + query=query.strip() if query else None, + source_file=source_file, + difficulty=difficulty, + question_type=question_type, + page=page, + page_size=page_size, + ) diff --git a/src/interview_agent/web/app.js b/src/interview_agent/web/app.js index a45dc3e..f3ae07b 100644 --- a/src/interview_agent/web/app.js +++ b/src/interview_agent/web/app.js @@ -33,6 +33,17 @@ recordingTimer: null, speechController: null, speechUrl: null, + questionBank: { + query: "", + source: "", + difficulty: "", + type: "", + page: 1, + pageSize: 12, + selectedId: null, + data: null, + requestId: 0, + }, }; const $ = (selector) => document.querySelector(selector); @@ -43,6 +54,7 @@ const connectionPill = $("#connection-pill"); const answerInput = $("#answer-input"); const answerButton = $("#answer-button"); + let questionSearchTimer = null; function readJson(key) { try { return JSON.parse(localStorage.getItem(key) || "null"); } catch { return null; } @@ -214,10 +226,37 @@ if (event.key === "Escape" && !$("#pdf-modal").classList.contains("hidden")) closeDocumentPreview(); }); $("#question-file").addEventListener("change", (event) => uploadQuestionBank(event.target.files[0])); + $("#question-search").addEventListener("input", (event) => { + clearTimeout(questionSearchTimer); + questionSearchTimer = setTimeout(() => { + state.questionBank.query = event.target.value.trim(); + state.questionBank.page = 1; + loadQuestionBank(); + }, 260); + }); + $("#question-difficulty").addEventListener("change", (event) => { + state.questionBank.difficulty = event.target.value; + state.questionBank.page = 1; + loadQuestionBank(); + }); + $("#question-type").addEventListener("change", (event) => { + state.questionBank.type = event.target.value; + state.questionBank.page = 1; + loadQuestionBank(); + }); + $("#question-reset-filters").addEventListener("click", resetQuestionFilters); + $("#question-prev").addEventListener("click", () => changeQuestionPage(-1)); + $("#question-next").addEventListener("click", () => changeQuestionPage(1)); const zone = $("#drop-zone"); ["dragenter", "dragover"].forEach((name) => zone.addEventListener(name, (event) => { event.preventDefault(); zone.classList.add("dragging"); })); ["dragleave", "drop"].forEach((name) => zone.addEventListener(name, (event) => { event.preventDefault(); zone.classList.remove("dragging"); })); zone.addEventListener("drop", (event) => uploadQuestionBank(event.dataTransfer.files[0])); + window.addEventListener("keydown", (event) => { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k" && $("#view-questions").classList.contains("active")) { + event.preventDefault(); + $("#question-search").focus(); + } + }); window.addEventListener("online", () => { toast("网络已恢复,正在续面"); connectSocket(); }); window.addEventListener("offline", () => setConnection("offline", "网络离线")); } @@ -287,6 +326,7 @@ $$(".view").forEach((view) => view.classList.toggle("active", view.id === `view-${name}`)); $$(".nav-button").forEach((button) => button.classList.toggle("active", button.dataset.nav === name)); if (name === "history") loadHistory(); + if (name === "questions") loadQuestionBank(); } function socketUrl() { @@ -769,11 +809,259 @@ const data = await api("/api/upload", { method: "POST", body: form }); result.textContent = `${data.filename} 已建立第 ${data.version} 版索引,共 ${data.success} 道题。`; toast("题库导入完成"); + state.questionBank.source = data.filename; + state.questionBank.page = 1; + state.questionBank.selectedId = null; + await loadQuestionBank(); } catch (error) { result.textContent = `导入失败:${error.message}`; } } + const difficultyLabels = { easy: "简单", medium: "中等", hard: "困难" }; + const typeLabels = { basic: "基础题", experience: "经历题", design: "设计题" }; + + function questionParams() { + const bank = state.questionBank; + const params = new URLSearchParams({ page: String(bank.page), page_size: String(bank.pageSize) }); + if (bank.query) params.set("query", bank.query); + if (bank.source) params.set("source", bank.source); + if (bank.difficulty) params.set("difficulty", bank.difficulty); + if (bank.type) params.set("type", bank.type); + return params; + } + + async function loadQuestionBank() { + const bank = state.questionBank; + const requestId = ++bank.requestId; + const workspace = $("#question-library"); + workspace.setAttribute("aria-busy", "true"); + renderQuestionState("正在读取题库…", "MySQL 正在返回当前激活版本。", "•••"); + try { + const data = await api(`/api/question-bank?${questionParams()}`); + if (requestId !== bank.requestId) return; + bank.data = data; + const selectedVisible = data.items.some((item) => item.id === bank.selectedId); + if (!selectedVisible) bank.selectedId = data.items[0]?.id || null; + renderQuestionLibrary(); + } catch (error) { + if (requestId !== bank.requestId) return; + bank.data = null; + renderQuestionState("题库暂时无法读取", error.message, "!"); + } finally { + if (requestId === bank.requestId) workspace.setAttribute("aria-busy", "false"); + } + } + + function resetQuestionFilters() { + const bank = state.questionBank; + bank.query = ""; + bank.source = ""; + bank.difficulty = ""; + bank.type = ""; + bank.page = 1; + $("#question-search").value = ""; + $("#question-difficulty").value = ""; + $("#question-type").value = ""; + loadQuestionBank(); + } + + function changeQuestionPage(direction) { + const bank = state.questionBank; + const pages = Math.max(1, Math.ceil((bank.data?.total || 0) / bank.pageSize)); + const next = Math.min(pages, Math.max(1, bank.page + direction)); + if (next === bank.page) return; + bank.page = next; + bank.selectedId = null; + loadQuestionBank(); + } + + function renderQuestionState(title, description, icon) { + const list = $("#question-list"); + list.replaceChildren(); + const stateNode = document.createElement("div"); + stateNode.className = "library-state"; + const mark = document.createElement("span"); + mark.textContent = icon; + const heading = document.createElement("strong"); + heading.textContent = title; + const copy = document.createElement("p"); + copy.textContent = description; + stateNode.append(mark, heading, copy); + list.append(stateNode); + } + + function renderQuestionLibrary() { + const bank = state.questionBank; + const data = bank.data; + $("#question-search").value = bank.query; + $("#question-difficulty").value = bank.difficulty; + $("#question-type").value = bank.type; + $("#library-summary").textContent = `${data.question_count} 道激活题目 · ${data.sources.length} 个来源 · 当前匹配 ${data.total} 道`; + $("#source-count").textContent = data.sources.length; + $("#question-result-count").textContent = `${data.total} 道题`; + renderQuestionFilterCounts(data); + renderQuestionSources(data); + renderQuestionRows(data); + renderQuestionDetail(data.items.find((item) => item.id === bank.selectedId) || null); + renderQuestionPagination(data); + } + + function renderQuestionFilterCounts(data) { + const difficulty = $("#question-difficulty"); + const type = $("#question-type"); + difficulty.options[0].textContent = `全部难度 · ${data.question_count}`; + difficulty.options[1].textContent = `简单 · ${data.difficulty_counts.easy || 0}`; + difficulty.options[2].textContent = `中等 · ${data.difficulty_counts.medium || 0}`; + difficulty.options[3].textContent = `困难 · ${data.difficulty_counts.hard || 0}`; + type.options[0].textContent = `全部题型 · ${data.question_count}`; + type.options[1].textContent = `基础题 · ${data.type_counts.basic || 0}`; + type.options[2].textContent = `经历题 · ${data.type_counts.experience || 0}`; + type.options[3].textContent = `设计题 · ${data.type_counts.design || 0}`; + } + + function renderQuestionSources(data) { + const container = $("#question-sources"); + container.replaceChildren(); + const all = { filename: "", question_count: data.question_count, version: null }; + [all, ...data.sources].forEach((source) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = `source-button${state.questionBank.source === source.filename ? " active" : ""}`; + button.setAttribute("aria-pressed", state.questionBank.source === source.filename ? "true" : "false"); + const icon = document.createElement("span"); + icon.className = "source-icon"; + icon.textContent = source.filename ? (source.filename.split(".").pop() || "FILE").slice(0, 4).toUpperCase() : "ALL"; + const copy = document.createElement("span"); + copy.className = "source-copy"; + const name = document.createElement("strong"); + name.textContent = source.filename || "全部题目"; + const version = document.createElement("small"); + version.textContent = source.version ? `当前第 ${source.version} 版` : "所有激活来源"; + const count = document.createElement("b"); + count.textContent = source.question_count; + copy.append(name, version); + button.append(icon, copy, count); + button.addEventListener("click", () => { + state.questionBank.source = source.filename; + state.questionBank.page = 1; + state.questionBank.selectedId = null; + loadQuestionBank(); + }); + container.append(button); + }); + } + + function renderQuestionRows(data) { + const list = $("#question-list"); + list.replaceChildren(); + if (!data.items.length) { + const emptyBank = data.question_count === 0; + renderQuestionState( + emptyBank ? "还没有题目" : "没有匹配结果", + emptyBank ? "先在上方上传 Markdown、TXT、PDF 或 DOCX 题库。" : "换一个关键词,或清除部分筛选条件。", + emptyBank ? "+" : "⌕", + ); + return; + } + data.items.forEach((item, index) => { + const row = document.createElement("button"); + row.type = "button"; + row.className = `question-row${item.id === state.questionBank.selectedId ? " active" : ""}`; + row.dataset.questionId = item.id; + row.setAttribute("role", "option"); + row.setAttribute("aria-selected", item.id === state.questionBank.selectedId ? "true" : "false"); + const number = document.createElement("span"); + number.className = "question-number"; + number.textContent = String((state.questionBank.page - 1) * state.questionBank.pageSize + index + 1).padStart(2, "0"); + const copy = document.createElement("span"); + copy.className = "question-row-copy"; + const title = document.createElement("strong"); + title.textContent = item.content; + const meta = document.createElement("small"); + const difficulty = document.createElement("span"); + difficulty.textContent = difficultyLabels[item.difficulty] || item.difficulty; + const type = document.createElement("span"); + type.textContent = typeLabels[item.type] || item.type; + meta.append(difficulty, type); + const chevron = document.createElement("span"); + chevron.className = "question-chevron"; + chevron.textContent = "›"; + copy.append(title, meta); + row.append(number, copy, chevron); + row.addEventListener("click", () => selectQuestion(item.id)); + list.append(row); + }); + } + + function selectQuestion(questionId) { + state.questionBank.selectedId = questionId; + $$(".question-row").forEach((row) => { + const selected = row.dataset.questionId === questionId; + row.classList.toggle("active", selected); + row.setAttribute("aria-selected", selected ? "true" : "false"); + }); + renderQuestionDetail(state.questionBank.data.items.find((item) => item.id === questionId) || null); + } + + function renderQuestionDetail(item) { + const detail = $("#question-detail"); + detail.replaceChildren(); + if (!item) { + const empty = document.createElement("div"); + empty.className = "detail-empty"; + const mark = document.createElement("span"); + mark.textContent = "Q"; + const title = document.createElement("h3"); + title.textContent = "选择一道题目"; + const copy = document.createElement("p"); + copy.textContent = "完整问题、参考答案与索引来源会显示在这里。"; + empty.append(mark, title, copy); + detail.append(empty); + return; + } + const meta = document.createElement("div"); + meta.className = "detail-meta"; + [[typeLabels[item.type] || item.type, "primary"], [difficultyLabels[item.difficulty] || item.difficulty, ""]].forEach(([label, kind]) => { + const pill = document.createElement("span"); + pill.className = `detail-pill ${kind}`.trim(); + pill.textContent = label; + meta.append(pill); + }); + const question = document.createElement("h3"); + question.textContent = item.content; + const answerLabel = document.createElement("div"); + answerLabel.className = "answer-label"; + answerLabel.textContent = "REFERENCE ANSWER / 参考答案"; + const answer = document.createElement("p"); + answer.className = "reference-answer"; + answer.textContent = item.reference || "这道题暂未提供参考答案。"; + const skills = document.createElement("div"); + skills.className = "detail-skills"; + (item.skills || []).forEach((skill) => { + const chip = document.createElement("span"); + chip.textContent = skill; + skills.append(chip); + }); + const source = document.createElement("div"); + source.className = "detail-source"; + const filename = document.createElement("strong"); + filename.textContent = item.source_file; + const version = document.createElement("span"); + version.textContent = `索引版本 ${item.version}`; + source.append(filename, version); + detail.append(meta, question, answerLabel, answer, skills, source); + } + + function renderQuestionPagination(data) { + const pages = Math.max(1, Math.ceil(data.total / data.page_size)); + const pagination = $("#question-pagination"); + pagination.classList.toggle("hidden", pages <= 1); + $("#question-page-label").textContent = `第 ${data.page} / ${pages} 页`; + $("#question-prev").disabled = data.page <= 1; + $("#question-next").disabled = data.page >= pages; + } + async function uploadDocument(kind, file) { if (!file) return; const label = kind === "jd" ? "岗位 JD" : "简历"; diff --git a/src/interview_agent/web/index.html b/src/interview_agent/web/index.html index 690f9c3..0886a74 100644 --- a/src/interview_agent/web/index.html +++ b/src/interview_agent/web/index.html @@ -198,12 +198,12 @@
上传 Markdown、TXT、PDF 或 DOCX。系统会提取问题与参考答案,用 BGE-M3 建立个人隔离的 Milvus 索引。
+上传后立即浏览题目与参考答案。当前激活版本保存在 MySQL,BGE-M3 与 Milvus 负责把它们带进你的模拟面试。
正在从 MySQL 读取你的题库…
+完整问题、参考答案与索引来源会显示在这里。
+