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 @@

准备助手

-
-
-
KNOWLEDGE / LIBRARY

把你的题库
带进面试室。

-

上传 Markdown、TXT、PDF 或 DOCX。系统会提取问题与参考答案,用 BGE-M3 建立个人隔离的 Milvus 索引。

+
+
+
KNOWLEDGE / LIBRARY

让每一道题,
都看得见。

+

上传后立即浏览题目与参考答案。当前激活版本保存在 MySQL,BGE-M3 与 Milvus 负责把它们带进你的模拟面试。

-
+
+ +
+
+
+ ACTIVE COLLECTION +

已激活题库

+

正在从 MySQL 读取你的题库…

+
+ +
+ +
+ + + +
+ +
+ + +
+
0 道题选择题目查看详情
+
+ +
+ +
+
+ Q +

选择一道题目

+

完整问题、参考答案与索引来源会显示在这里。

+
+
+
+
diff --git a/src/interview_agent/web/styles.css b/src/interview_agent/web/styles.css index 837d3b1..9db6ed1 100644 --- a/src/interview_agent/web/styles.css +++ b/src/interview_agent/web/styles.css @@ -365,6 +365,89 @@ kbd { min-width: 18px; padding: 2px 4px; border: 1px solid var(--line-strong); b .upload-notes b { color: var(--blue); font-family: var(--font-display); font-size: 28px; } .upload-notes span { color: var(--muted); line-height: 1.45; } .result-banner { max-width: 1280px; margin: 18px auto 0; padding: 17px 19px; border: 1px solid rgba(36, 138, 61, .18); border-radius: 15px; background: #effaf1; color: var(--green); } +.library-intro h1 { font-size: clamp(48px, 5.5vw, 78px); } +.library-upload { grid-template-columns: 1.2fr .8fr; padding: 14px; } +.library-upload .drop-zone { min-height: 210px; } +.library-upload .upload-notes { grid-template-columns: repeat(3, 1fr); padding: 8px 18px; } +.library-upload .upload-notes div { padding: 18px 12px; display: block; border: 0; border-left: 1px solid var(--line); } +.library-upload .upload-notes div:first-child { border-left: 0; } +.library-upload .upload-notes b { display: block; margin-bottom: 11px; font-size: 22px; } +.library-upload .upload-notes span { font-size: 12px; } + +.library-workspace { max-width: 1280px; margin: 18px auto 0; overflow: hidden; border: 1px solid var(--line); border-radius: 26px; background: var(--surface-solid); box-shadow: var(--shadow-small); } +.library-toolbar { padding: 26px 28px 22px; display: flex; align-items: flex-end; justify-content: space-between; gap: 28px; border-bottom: 1px solid var(--line); } +.library-kicker { color: var(--blue); font-family: var(--font-mono); font-size: 9px; font-weight: 700; letter-spacing: .11em; } +.library-toolbar h2 { margin: 5px 0 2px; font-family: var(--font-display); font-size: 31px; letter-spacing: -.035em; } +.library-toolbar p { margin: 0; color: var(--muted); font-size: 11px; } +.library-search { width: min(420px, 42%); min-height: 43px; padding: 0 10px 0 13px; display: grid; grid-template-columns: 18px 1fr auto; align-items: center; gap: 7px; border: 1px solid var(--line-strong); border-radius: 13px; background: var(--surface-secondary); color: var(--tertiary); transition: border-color .16s, box-shadow .16s, background .16s; } +.library-search:focus-within { border-color: var(--blue); background: #fff; box-shadow: 0 0 0 4px rgba(0, 113, 227, .1); } +.library-search > span { font-size: 21px; line-height: 1; transform: rotate(-18deg); } +.library-search input { min-width: 0; border: 0; outline: 0; background: transparent; color: var(--ink); font-size: 12px; } +.library-search kbd { color: var(--tertiary); font-size: 9px; } +.library-filters { padding: 10px 28px; display: flex; align-items: center; gap: 8px; border-bottom: 1px solid var(--line); background: #fafafa; } +.library-filters select, .library-filters button { min-height: 30px; border: 1px solid var(--line); border-radius: 999px; background: #fff; color: var(--muted); cursor: pointer; font-size: 10px; } +.library-filters select { padding: 0 30px 0 12px; } +.library-filters button { margin-left: auto; padding: 0 13px; color: var(--blue); } +.library-filters button:hover { background: var(--blue-soft); } + +.library-browser { min-height: 530px; display: grid; grid-template-columns: 210px minmax(300px, .86fr) minmax(390px, 1.14fr); } +.library-sources, .question-catalog { min-width: 0; border-right: 1px solid var(--line); } +.library-sources { padding: 18px 12px; background: #fbfbfd; } +.source-heading, .catalog-heading { display: flex; align-items: center; justify-content: space-between; color: var(--tertiary); font-size: 9px; font-weight: 650; letter-spacing: .04em; text-transform: uppercase; } +.source-heading { padding: 0 8px 12px; } +.source-heading b { min-width: 21px; height: 21px; display: grid; place-items: center; border-radius: 999px; background: var(--surface-secondary); color: var(--muted); font-size: 9px; } +.source-list { display: grid; gap: 3px; } +.source-button { width: 100%; padding: 11px 10px; display: grid; grid-template-columns: 28px 1fr auto; align-items: center; gap: 8px; border: 0; border-radius: 11px; background: transparent; text-align: left; cursor: pointer; } +.source-button:hover { background: var(--surface-secondary); } +.source-button.active { background: var(--blue); color: #fff; box-shadow: 0 5px 15px rgba(0, 113, 227, .2); } +.source-icon { width: 28px; height: 32px; display: grid; place-items: center; border: 1px solid var(--line); border-radius: 6px; background: #fff; color: var(--blue); font-family: var(--font-mono); font-size: 7px; font-weight: 800; } +.source-button.active .source-icon { border-color: rgba(255, 255, 255, .35); background: rgba(255, 255, 255, .16); color: #fff; } +.source-copy { min-width: 0; } +.source-copy strong, .source-copy small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.source-copy strong { font-size: 10px; } +.source-copy small { margin-top: 3px; color: var(--tertiary); font-size: 8px; } +.source-button.active small { color: rgba(255, 255, 255, .68); } +.source-button > b { color: var(--tertiary); font-size: 9px; } +.source-button.active > b { color: #fff; } + +.question-catalog { display: flex; flex-direction: column; background: #fff; } +.catalog-heading { min-height: 48px; padding: 0 18px; border-bottom: 1px solid var(--line); } +.catalog-heading span:last-child { font-weight: 450; letter-spacing: 0; text-transform: none; } +.question-list { flex: 1; } +.question-row { width: 100%; padding: 17px 18px; display: grid; grid-template-columns: 30px 1fr 14px; align-items: start; gap: 11px; border: 0; border-bottom: 1px solid var(--line); background: #fff; text-align: left; cursor: pointer; transition: background .14s; } +.question-row:hover { background: #fafafa; } +.question-row.active { background: var(--blue-soft); box-shadow: inset 3px 0 var(--blue); } +.question-number { width: 27px; height: 27px; display: grid; place-items: center; border-radius: 8px; color: var(--blue); background: rgba(0, 113, 227, .09); font-family: var(--font-mono); font-size: 9px; } +.question-row-copy { min-width: 0; } +.question-row-copy strong { display: -webkit-box; overflow: hidden; font-size: 12px; font-weight: 590; line-height: 1.45; -webkit-box-orient: vertical; -webkit-line-clamp: 2; } +.question-row-copy small { margin-top: 7px; display: flex; align-items: center; gap: 6px; color: var(--tertiary); font-size: 8px; } +.question-row-copy small span { padding: 3px 6px; border-radius: 999px; background: rgba(0, 0, 0, .045); } +.question-chevron { padding-top: 5px; color: var(--tertiary); } +.question-pagination { min-height: 50px; padding: 8px 12px; display: flex; align-items: center; justify-content: space-between; border-top: 1px solid var(--line); } +.question-pagination button { border: 0; background: transparent; color: var(--blue); cursor: pointer; font-size: 10px; } +.question-pagination button:disabled { color: var(--tertiary); cursor: default; } +.question-pagination span { color: var(--tertiary); font-size: 9px; } + +.question-detail { min-width: 0; padding: 32px clamp(24px, 3vw, 46px); background: linear-gradient(145deg, #fff 0%, #fbfbfd 100%); } +.detail-empty { height: 100%; display: grid; place-content: center; justify-items: center; text-align: center; color: var(--tertiary); } +.detail-empty > span { width: 55px; height: 55px; display: grid; place-items: center; border: 1px solid var(--line); border-radius: 17px; background: #fff; box-shadow: var(--shadow-small); color: var(--blue); font-family: Georgia, serif; font-size: 27px; } +.detail-empty h3 { margin: 17px 0 5px; color: var(--ink); font-family: var(--font-display); font-size: 20px; } +.detail-empty p { max-width: 250px; margin: 0; font-size: 11px; line-height: 1.6; } +.detail-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; } +.detail-pill { padding: 5px 8px; border-radius: 999px; background: var(--surface-secondary); color: var(--muted); font-size: 8px; font-weight: 650; } +.detail-pill.primary { background: var(--blue); color: #fff; } +.question-detail h3 { margin: 24px 0 0; font-family: var(--font-display); font-size: clamp(23px, 2.2vw, 32px); line-height: 1.25; letter-spacing: -.025em; } +.answer-label { margin: 34px 0 10px; display: flex; align-items: center; gap: 10px; color: var(--green); font-size: 9px; font-weight: 700; letter-spacing: .08em; } +.answer-label::after { content: ""; height: 1px; flex: 1; background: rgba(36, 138, 61, .18); } +.reference-answer { margin: 0; color: #3a3a3c; font-size: 13px; line-height: 1.8; white-space: pre-wrap; } +.detail-skills { margin-top: 27px; display: flex; flex-wrap: wrap; gap: 6px; } +.detail-skills span { padding: 6px 9px; border: 1px solid rgba(0, 113, 227, .13); border-radius: 999px; color: var(--blue); background: #f5f9ff; font-size: 9px; } +.detail-source { margin-top: 30px; padding-top: 17px; display: grid; grid-template-columns: 1fr auto; gap: 6px; border-top: 1px solid var(--line); color: var(--tertiary); font-size: 9px; } +.detail-source strong { overflow: hidden; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; } +.library-state { min-height: 320px; padding: 30px; display: grid; place-content: center; justify-items: center; text-align: center; color: var(--tertiary); } +.library-state span { font-size: 29px; } +.library-state strong { margin-top: 10px; color: var(--ink); font-size: 13px; } +.library-state p { max-width: 310px; margin: 5px 0 0; font-size: 10px; line-height: 1.5; } .history-list { max-width: 1280px; margin: 28px auto 0; overflow: hidden; border: 1px solid var(--line); border-radius: 22px; background: #fff; box-shadow: var(--shadow-small); } .history-item { padding: 20px 22px; display: grid; grid-template-columns: 100px 1fr 100px 28px; align-items: center; gap: 24px; border-bottom: 1px solid var(--line); cursor: pointer; transition: background .16s; } .history-item:last-child { border-bottom: 0; } @@ -391,6 +474,8 @@ kbd { min-width: 18px; padding: 2px 4px; border: 1px solid var(--line-strong); b .assistant-column { grid-column: 1 / -1; display: grid; grid-template-columns: 240px 1fr; gap: 22px; } .assistant-column .chat-feed { height: 220px; } .assistant-column .chat-form, .assistant-column .session-card { grid-column: 2; } + .library-browser { grid-template-columns: 190px 1fr; } + .question-detail { min-height: 440px; grid-column: 1 / -1; border-top: 1px solid var(--line); } } @media (max-width: 820px) { @@ -419,6 +504,17 @@ kbd { min-width: 18px; padding: 2px 4px; border: 1px solid var(--line-strong); b .page-intro p { margin-top: 22px; } .content-view { padding: 34px 14px; } .upload-layout { padding: 12px; } + .library-upload .upload-notes { grid-template-columns: 1fr; } + .library-upload .upload-notes div { border-left: 0; border-top: 1px solid var(--line); } + .library-toolbar { padding: 21px 18px 17px; display: grid; } + .library-search { width: 100%; } + .library-filters { padding: 9px 18px; overflow-x: auto; } + .library-browser { display: block; } + .library-sources, .question-catalog { border-right: 0; border-bottom: 1px solid var(--line); } + .library-sources { padding: 11px 10px; } + .source-list { display: flex; overflow-x: auto; } + .source-button { width: 195px; flex: 0 0 auto; } + .question-detail { min-height: 390px; padding: 27px 20px; } .history-item { grid-template-columns: 74px 1fr 58px; gap: 10px; padding: 17px 14px; } .history-item > span:last-child { display: none; } } diff --git a/tests/integration/test_question_bank_browse.py b/tests/integration/test_question_bank_browse.py new file mode 100644 index 0000000..c4ee452 --- /dev/null +++ b/tests/integration/test_question_bank_browse.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import os +from uuid import uuid4 + +import pytest +from pydantic import SecretStr + +from interview_agent.config import Settings +from interview_agent.domain.models import Difficulty, QuestionBankItem +from interview_agent.infra.database import Database +from interview_agent.infra.repositories import Repository + +pytestmark = pytest.mark.skipif( + os.getenv("RUN_INTEGRATION") != "1", reason="set RUN_INTEGRATION=1" +) + + +@pytest.mark.asyncio +async def test_browse_question_bank_filters_and_isolates_mysql_rows() -> None: + settings = Settings( + deepseek_api_key=SecretStr("dummy-key"), + jwt_secret=SecretStr("j" * 32), + resume_token_secret=SecretStr("r" * 32), + ) + database = Database(settings) + repository = Repository(database, settings) + owner_id = await repository.create_user(f"library-owner-{uuid4().hex}", "hash") + other_id = await repository.create_user(f"library-other-{uuid4().hex}", "hash") + items = [ + QuestionBankItem( + content="Redis 如何避免缓存击穿?", + reference="使用互斥锁或逻辑过期保护热点 Key。", + difficulty=Difficulty.MEDIUM, + type="design", + skills=["Redis", "缓存"], + ), + QuestionBankItem( + content="什么是 AOF?", + reference="AOF 记录 Redis 写命令以便恢复。", + difficulty=Difficulty.EASY, + type="basic", + skills=["Redis"], + ), + ] + try: + version = await repository.replace_question_bank( + owner_id, "redis.md", "source-hash", items + ) + await repository.activate_question_bank(owner_id, "redis.md", version) + + result = await repository.browse_question_bank( + owner_id, + query="击穿", + source_file="redis.md", + difficulty="medium", + question_type="design", + page=1, + page_size=12, + ) + isolated = await repository.browse_question_bank( + other_id, + query=None, + source_file=None, + difficulty=None, + question_type=None, + page=1, + page_size=12, + ) + + assert result["question_count"] == 2 + assert result["difficulty_counts"] == {"easy": 1, "medium": 1} + assert result["type_counts"] == {"basic": 1, "design": 1} + assert result["total"] == 1 + assert result["items"][0]["content"] == "Redis 如何避免缓存击穿?" + assert result["items"][0]["reference"].startswith("使用互斥锁") + assert isolated["question_count"] == 0 + assert isolated["items"] == [] + finally: + await repository.purge_question_bank(owner_id) + await repository.purge_question_bank(other_id) + await database.close() diff --git a/tests/unit/test_question_bank_api.py b/tests/unit/test_question_bank_api.py new file mode 100644 index 0000000..90a6749 --- /dev/null +++ b/tests/unit/test_question_bank_api.py @@ -0,0 +1,35 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from interview_agent.api.dependencies import CurrentUser +from interview_agent.api.http import browse_question_bank + + +@pytest.mark.asyncio +async def test_question_bank_endpoint_scopes_results_to_authenticated_user() -> None: + browse = AsyncMock(return_value={"total": 0, "items": []}) + container = SimpleNamespace(question_banks=SimpleNamespace(browse=browse)) + + result = await browse_question_bank( + CurrentUser(id="owner-1", username="candidate"), + container, + query="Redis", + source="backend.md", + difficulty="medium", + question_type="basic", + page=3, + page_size=10, + ) + + assert result == {"total": 0, "items": []} + browse.assert_awaited_once_with( + "owner-1", + query="Redis", + source_file="backend.md", + difficulty="medium", + question_type="basic", + page=3, + page_size=10, + ) diff --git a/tests/unit/test_question_bank_service.py b/tests/unit/test_question_bank_service.py index 0cb243f..62f2f2f 100644 --- a/tests/unit/test_question_bank_service.py +++ b/tests/unit/test_question_bank_service.py @@ -69,3 +69,50 @@ async def parse_question_bank(self, _content: str) -> list[QuestionBankItem]: with pytest.raises(QuestionBankImportError, match="解析服务暂时不可用"): await service.import_bytes("user-1", "questions.txt", b"question bank content") + + +@pytest.mark.asyncio +async def test_browse_delegates_tenant_filters_and_pagination() -> None: + calls: list[tuple[object, ...]] = [] + expected: dict[str, object] = { + "total": 1, + "items": [{"id": "question-1", "content": "Redis 为什么快?"}], + } + + class Repository: + async def browse_question_bank(self, user_id: str, **kwargs: object) -> dict[str, object]: + calls.append((user_id, kwargs)) + return expected + + service = QuestionBankService( + SimpleNamespace(), + SimpleNamespace(), + SimpleNamespace(), + SimpleNamespace(), + Repository(), # type: ignore[arg-type] + ) + + result = await service.browse( + "user-1", + query=" Redis ", + source_file="redis.md", + difficulty="hard", + question_type="design", + page=2, + page_size=12, + ) + + assert result == expected + assert calls == [ + ( + "user-1", + { + "query": "Redis", + "source_file": "redis.md", + "difficulty": "hard", + "question_type": "design", + "page": 2, + "page_size": 12, + }, + ) + ]