From cb1b14c5466961ad3a701e719a7bd42a38471f10 Mon Sep 17 00:00:00 2001 From: 369pro <2822946469@qq.com> Date: Sun, 16 Aug 2026 07:28:17 +0800 Subject: [PATCH 1/9] feat: add browsable question bank --- README.md | 2 +- specs/001-python-rewrite/spec.md | 2 + src/interview_agent/api/http.py | 29 +- src/interview_agent/infra/repositories.py | 113 ++++++- src/interview_agent/services/question_bank.py | 21 ++ src/interview_agent/web/app.js | 288 ++++++++++++++++++ src/interview_agent/web/index.html | 66 +++- src/interview_agent/web/styles.css | 96 ++++++ .../integration/test_question_bank_browse.py | 82 +++++ tests/unit/test_question_bank_api.py | 35 +++ tests/unit/test_question_bank_service.py | 47 +++ 11 files changed, 773 insertions(+), 8 deletions(-) create mode 100644 tests/integration/test_question_bank_browse.py create mode 100644 tests/unit/test_question_bank_api.py 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, + }, + ) + ] From c82c2982bc7d002d72deca5b3b84b7f65d440d07 Mon Sep 17 00:00:00 2001 From: 369pro <2822946469@qq.com> Date: Sun, 16 Aug 2026 08:50:15 +0800 Subject: [PATCH 2/9] feat: add dynamic JD fetching and GitHub recommendations --- .env.example | 10 + Dockerfile | 1 + README.md | 2 + pyproject.toml | 1 + src/interview_agent/config.py | 11 + src/interview_agent/container.py | 30 +- src/interview_agent/domain/models.py | 28 +- src/interview_agent/infra/dynamic_pages.py | 326 ++++++++++++++++++ .../infra/github_repositories.py | 143 ++++++++ src/interview_agent/services/agents.py | 32 +- src/interview_agent/services/documents.py | 73 +++- src/interview_agent/services/workflow.py | 18 + src/interview_agent/web/app.js | 59 +++- src/interview_agent/web/index.html | 2 +- src/interview_agent/web/styles.css | 29 ++ tests/unit/test_documents.py | 90 +++++ tests/unit/test_dynamic_pages.py | 64 ++++ tests/unit/test_github_repositories.py | 100 ++++++ tests/unit/test_review_recommendations.py | 63 ++++ uv.lock | 33 ++ 20 files changed, 1093 insertions(+), 22 deletions(-) create mode 100644 src/interview_agent/infra/dynamic_pages.py create mode 100644 src/interview_agent/infra/github_repositories.py create mode 100644 tests/unit/test_dynamic_pages.py create mode 100644 tests/unit/test_github_repositories.py create mode 100644 tests/unit/test_review_recommendations.py diff --git a/.env.example b/.env.example index 4f9a322..94c8780 100644 --- a/.env.example +++ b/.env.example @@ -36,3 +36,13 @@ CORS_ORIGINS=["http://localhost:5173"] SESSION_RESUME_TTL_SECONDS=86400 EVENT_RETENTION_SECONDS=604800 AUTO_RECOVER_SESSIONS=true +DYNAMIC_PAGE_FETCH_ENABLED=true +DYNAMIC_PAGE_TIMEOUT_SECONDS=20 +DYNAMIC_PAGE_MAX_TRANSFER_BYTES=26214400 +DYNAMIC_PAGE_MAX_CONCURRENT=2 +DYNAMIC_PAGE_QUEUE_TIMEOUT_SECONDS=5 +GITHUB_RECOMMENDATIONS_ENABLED=true +# Optional. Without a token GitHub's public API has a lower rate limit. +GITHUB_TOKEN= +GITHUB_MINIMUM_STARS=50 +GITHUB_REPOSITORIES_PER_TOPIC=2 diff --git a/Dockerfile b/Dockerfile index 55b8013..b41cc2a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,6 +19,7 @@ RUN apt-get update \ COPY pyproject.toml uv.lock ./ RUN uv sync --frozen --no-dev --no-install-project +RUN playwright install --with-deps chromium COPY README.md ./ COPY src ./src diff --git a/README.md b/README.md index fbc3ff7..9dcf645 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ cp .env.example .env # 编辑 .env,填写 DEEPSEEK_API_KEY,并分别生成 JWT_SECRET、RESUME_TOKEN_SECRET uv sync --frozen +uv run playwright install chromium docker compose up -d mysql redis etcd minio milvus embeddings reranker speech speech-models uv run alembic upgrade head uv run interview-agent serve --reload @@ -88,6 +89,7 @@ uv run interview-agent serve --reload 对应的可编辑 Figma 面试台位于 [Interview Room — Apple Developer UI](https://www.figma.com/design/QUCP3r8UZnwNU0YwP32wVa?node-id=3-2)。 1. 创建账号,在“面试台”为 JD 和简历粘贴文字、填写 URL,或分别上传 PDF。上传后页面原样预览 PDF,提取文字只交给后端面试流程,不显示在编辑器中。 + JD URL 会先进行轻量正文提取;遇到 JavaScript 动态页面或站点阻止普通请求时,会自动降级为 Chromium 渲染。复习计划会按薄弱主题查询 GitHub 的真实项目,并显示可点击的仓库卡片;可选配置 `GITHUB_TOKEN` 提高 API 限额。 桌面端向右拖动 PDF 面板旁的分隔条即可放大;方向键可微调,`Shift + 方向键` 可快速调节,双击恢复默认宽度。“放大”按钮会打开全屏原文件预览。 2. 收到题目后数字人自动用普通话播报;点击“语音回答”录音,结束后可编辑 Whisper 转写,再提交并观察评分、追问、评估报告与复习计划。 3. 面试途中点击“模拟断线”,客户端会自动用 `session_id + resume_token + last_event_id` 续面。 diff --git a/pyproject.toml b/pyproject.toml index 4f72beb..109f6c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "numpy>=2.2", "openai>=2.0", "orjson>=3.10", + "playwright>=1.54", "pydantic-settings>=2.10", "pyjwt>=2.10", "pymilvus>=2.6", diff --git a/src/interview_agent/config.py b/src/interview_agent/config.py index 868e0bf..d32f0e8 100644 --- a/src/interview_agent/config.py +++ b/src/interview_agent/config.py @@ -81,6 +81,17 @@ class Settings(BaseSettings): max_upload_bytes: int = 10 * 1024 * 1024 websocket_send_queue_size: int = 256 + dynamic_page_fetch_enabled: bool = True + dynamic_page_timeout_seconds: float = Field(default=20.0, gt=0, le=60) + dynamic_page_max_transfer_bytes: int = Field(default=25 * 1024 * 1024, ge=1024) + dynamic_page_max_concurrent: int = Field(default=2, ge=1, le=8) + dynamic_page_queue_timeout_seconds: float = Field(default=5.0, gt=0, le=30) + github_recommendations_enabled: bool = True + github_token: SecretStr | None = None + github_search_timeout_seconds: float = Field(default=15.0, gt=0, le=60) + github_minimum_stars: int = Field(default=50, ge=0) + github_repositories_per_topic: int = Field(default=2, ge=1, le=5) + @field_validator("jwt_secret", "resume_token_secret") @classmethod def validate_secret_length(cls, value: SecretStr) -> SecretStr: diff --git a/src/interview_agent/container.py b/src/interview_agent/container.py index ddc4d42..e0e1b78 100644 --- a/src/interview_agent/container.py +++ b/src/interview_agent/container.py @@ -9,7 +9,9 @@ from interview_agent.api.websocket import ConnectionManager from interview_agent.config import Settings from interview_agent.infra.database import Database +from interview_agent.infra.dynamic_pages import PlaywrightPageFetcher from interview_agent.infra.embeddings import EmbeddingClient +from interview_agent.infra.github_repositories import GitHubRepositorySearch from interview_agent.infra.milvus_store import MilvusQuestionStore from interview_agent.infra.redis_store import OutboxPublisher, RedisStore from interview_agent.infra.repositories import Repository @@ -37,6 +39,7 @@ class AppContainer: reranker: RerankClient milvus: MilvusQuestionStore llm: DeepSeekClient + github_repositories: GitHubRepositorySearch | None agents: AgentService retriever: HybridRetriever workflow: InterviewWorkflow @@ -62,13 +65,33 @@ async def create(cls, settings: Settings) -> AppContainer: milvus = MilvusQuestionStore(settings) await milvus.ensure_collection() llm = DeepSeekClient(settings) - agents = AgentService(llm) + github_repositories = ( + GitHubRepositorySearch( + token=(settings.github_token.get_secret_value() if settings.github_token else None), + timeout_seconds=settings.github_search_timeout_seconds, + minimum_stars=settings.github_minimum_stars, + per_topic=settings.github_repositories_per_topic, + ) + if settings.github_recommendations_enabled + else None + ) + agents = AgentService(llm, github_repositories) retriever = HybridRetriever(repository, embeddings, milvus, reranker, settings) workflow = InterviewWorkflow(agents, repository, retriever) publisher = OutboxPublisher(repository, redis) coordinator = InterviewCoordinator(repository, redis, publisher, workflow, agents) auth = AuthService(repository, settings) - documents = DocumentService(settings.max_upload_bytes) + dynamic_pages = ( + PlaywrightPageFetcher( + timeout_seconds=settings.dynamic_page_timeout_seconds, + max_transfer_bytes=settings.dynamic_page_max_transfer_bytes, + max_concurrent=settings.dynamic_page_max_concurrent, + queue_timeout_seconds=settings.dynamic_page_queue_timeout_seconds, + ) + if settings.dynamic_page_fetch_enabled + else None + ) + documents = DocumentService(settings.max_upload_bytes, dynamic_pages) question_banks = QuestionBankService(documents, agents, embeddings, milvus, repository) skills = SkillService(redis, llm, agents, retriever) voice = VoiceService(settings) @@ -82,6 +105,7 @@ async def create(cls, settings: Settings) -> AppContainer: reranker=reranker, milvus=milvus, llm=llm, + github_repositories=github_repositories, agents=agents, retriever=retriever, workflow=workflow, @@ -121,6 +145,8 @@ async def close(self) -> None: await self.llm.close() await self.voice.close() await self.documents.close() + if self.github_repositories is not None: + await self.github_repositories.close() await self.embeddings.close() await self.reranker.close() await self.milvus.close() diff --git a/src/interview_agent/domain/models.py b/src/interview_agent/domain/models.py index eb62bb0..17441cc 100644 --- a/src/interview_agent/domain/models.py +++ b/src/interview_agent/domain/models.py @@ -3,9 +3,10 @@ from datetime import UTC, datetime from enum import StrEnum from typing import Any, Literal +from urllib.parse import urlparse from uuid import uuid4 -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator def utc_now() -> datetime: @@ -114,12 +115,35 @@ class EvaluationReport(BaseModel): terminated_early: bool = False +class ReviewResource(BaseModel): + title: str + url: str + description: str = "" + stars: int = 0 + language: str | None = None + source: Literal["github"] = "github" + + @field_validator("url") + @classmethod + def validate_github_url(cls, value: str) -> str: + parsed = urlparse(value) + if ( + parsed.scheme != "https" + or parsed.hostname != "github.com" + or parsed.username is not None + or parsed.password is not None + or parsed.port not in {None, 443} + ): + raise ValueError("GitHub resource URL must use https://github.com/") + return value + + class ReviewItem(BaseModel): topic: str priority: Literal["high", "medium", "low"] = "medium" reason: str actions: list[str] = Field(default_factory=list) - resources: list[str] = Field(default_factory=list) + resources: list[str | ReviewResource] = Field(default_factory=list) class ReviewPlan(BaseModel): diff --git a/src/interview_agent/infra/dynamic_pages.py b/src/interview_agent/infra/dynamic_pages.py new file mode 100644 index 0000000..5b0385d --- /dev/null +++ b/src/interview_agent/infra/dynamic_pages.py @@ -0,0 +1,326 @@ +from __future__ import annotations + +import asyncio +import ipaddress +import socket +from contextlib import suppress +from urllib.parse import urlparse + +from playwright.async_api import ( + Browser, + BrowserContext, + Playwright, + Route, + WebSocketRoute, + async_playwright, +) +from playwright.async_api import ( + TimeoutError as PlaywrightTimeoutError, +) + + +class DynamicPageError(RuntimeError): + pass + + +async def _resolve_public_address( + hostname: str, port: int +) -> ipaddress.IPv4Address | ipaddress.IPv6Address: + try: + records = await asyncio.to_thread( + socket.getaddrinfo, hostname, port, type=socket.SOCK_STREAM + ) + except socket.gaierror as exc: + raise DynamicPageError("动态网页域名无法解析") from exc + addresses = [ipaddress.ip_address(record[4][0]) for record in records] + if not addresses or any(not address.is_global for address in addresses): + raise DynamicPageError("动态网页请求包含内网、本机或保留地址") + return next( + (candidate for candidate in addresses if isinstance(candidate, ipaddress.IPv4Address)), + addresses[0], + ) + + +class _PinnedPublicProxy: + """Resolve every browser destination once, then connect directly to that public IP.""" + + def __init__(self, max_transfer_bytes: int) -> None: + self.max_transfer_bytes = max_transfer_bytes + self.transferred_bytes = 0 + self.budget_exceeded = asyncio.Event() + self.server: asyncio.Server | None = None + self._handlers: set[asyncio.Task[None]] = set() + self._closing = False + + async def start(self) -> str: + self.server = await asyncio.start_server(self._accept, "127.0.0.1", 0) + socket_info = self.server.sockets[0].getsockname() + return f"http://127.0.0.1:{socket_info[1]}" + + def _accept(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + if self._closing: + writer.close() + return + task = asyncio.create_task(self._handle(reader, writer)) + self._handlers.add(task) + task.add_done_callback(self._handlers.discard) + + @staticmethod + async def _open_public_connection( + hostname: str, port: int + ) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]: + async with asyncio.timeout(5): + address = await _resolve_public_address(hostname, port) + return await asyncio.open_connection(str(address), port) + + async def _handle(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + upstream_writer: asyncio.StreamWriter | None = None + try: + head = await reader.readuntil(b"\r\n\r\n") + lines = head.decode("latin-1").split("\r\n") + method, target, version = lines[0].split(" ", 2) + if method.upper() == "CONNECT": + hostname, port = self._parse_authority(target, 443) + upstream_reader, upstream_writer = await self._open_public_connection( + hostname, port + ) + writer.write(b"HTTP/1.1 200 Connection Established\r\n\r\n") + await writer.drain() + await self._tunnel(reader, writer, upstream_reader, upstream_writer) + return + + parsed = urlparse(target) + if parsed.scheme != "http" or not parsed.hostname: + raise DynamicPageError("代理仅允许 HTTP 或 HTTPS 请求") + port = parsed.port or 80 + upstream_reader, upstream_writer = await self._open_public_connection( + parsed.hostname, port + ) + path = parsed.path or "/" + if parsed.query: + path += f"?{parsed.query}" + headers = [ + line + for line in lines[1:] + if line + and not line.lower().startswith(("proxy-connection:", "connection:")) + ] + content_length = next( + ( + int(line.split(":", 1)[1].strip()) + for line in lines[1:] + if line.lower().startswith("content-length:") + ), + 0, + ) + request = f"{method} {path} {version}\r\n" + "\r\n".join(headers) + if not await self._write_budgeted( + request.encode("latin-1") + b"\r\nConnection: close\r\n\r\n", + upstream_writer, + ): + return + await self._copy_exact_budgeted(reader, upstream_writer, content_length) + await self._copy_budgeted(upstream_reader, writer) + except (Exception, asyncio.IncompleteReadError, asyncio.LimitOverrunError): + with suppress(Exception): + writer.write(b"HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n") + await writer.drain() + finally: + if upstream_writer is not None: + upstream_writer.close() + with suppress(Exception): + await upstream_writer.wait_closed() + writer.close() + with suppress(Exception): + await writer.wait_closed() + + @staticmethod + def _parse_authority(authority: str, default_port: int) -> tuple[str, int]: + parsed = urlparse(f"//{authority}") + if not parsed.hostname: + raise DynamicPageError("代理目标无效") + return parsed.hostname, parsed.port or default_port + + async def _tunnel( + self, + browser_reader: asyncio.StreamReader, + browser_writer: asyncio.StreamWriter, + upstream_reader: asyncio.StreamReader, + upstream_writer: asyncio.StreamWriter, + ) -> None: + upload = asyncio.create_task(self._copy_budgeted(browser_reader, upstream_writer)) + download = asyncio.create_task(self._copy_budgeted(upstream_reader, browser_writer)) + done, pending = await asyncio.wait({upload, download}, return_when=asyncio.FIRST_COMPLETED) + for task in pending: + task.cancel() + for task in done | pending: + with suppress(asyncio.CancelledError, Exception): + await task + + async def _copy_budgeted( + self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter + ) -> None: + while chunk := await reader.read(64 * 1024): + if not await self._write_budgeted(chunk, writer): + return + + async def _copy_exact_budgeted( + self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, remaining: int + ) -> None: + while remaining > 0: + chunk = await reader.read(min(64 * 1024, remaining)) + if not chunk: + return + remaining -= len(chunk) + if not await self._write_budgeted(chunk, writer): + return + + async def _write_budgeted(self, data: bytes, writer: asyncio.StreamWriter) -> bool: + remaining = self.max_transfer_bytes - self.transferred_bytes + if remaining <= 0: + self.budget_exceeded.set() + return False + outgoing = data[:remaining] + self.transferred_bytes += len(outgoing) + writer.write(outgoing) + await writer.drain() + if len(outgoing) < len(data): + self.budget_exceeded.set() + return False + return True + + async def close(self) -> None: + self._closing = True + if self.server is not None: + self.server.close() + tasks = list(self._handlers) + for task in tasks: + task.cancel() + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) + self._handlers.clear() + if self.server is not None: + await self.server.wait_closed() + + +class PlaywrightPageFetcher: + """Render public pages through a pinned, byte-limited network proxy.""" + + def __init__( + self, + *, + timeout_seconds: float = 20.0, + max_transfer_bytes: int, + max_concurrent: int = 2, + queue_timeout_seconds: float = 5.0, + ) -> None: + self.timeout_ms = int(timeout_seconds * 1000) + self.max_transfer_bytes = max_transfer_bytes + self.queue_timeout_seconds = queue_timeout_seconds + self._playwright: Playwright | None = None + self._startup_lock = asyncio.Lock() + self._slots = asyncio.Semaphore(max_concurrent) + + async def fetch(self, url: str) -> str: + parsed = urlparse(url) + if parsed.scheme not in {"http", "https"} or not parsed.hostname: + raise DynamicPageError("动态网页 URL 无效") + try: + async with asyncio.timeout(self.queue_timeout_seconds): + await self._slots.acquire() + except TimeoutError as exc: + raise DynamicPageError("动态网页渲染繁忙,请稍后重试") from exc + try: + async with asyncio.timeout(self.timeout_ms / 1000): + return await self._fetch_in_slot(url) + except TimeoutError as exc: + raise DynamicPageError("动态网页渲染超过总时限") from exc + finally: + self._slots.release() + + async def _fetch_in_slot(self, url: str) -> str: + proxy = _PinnedPublicProxy(self.max_transfer_bytes) + browser: Browser | None = None + context: BrowserContext | None = None + try: + proxy_url = await proxy.start() + playwright = await self._get_playwright() + browser = await playwright.chromium.launch( + headless=True, + proxy={"server": proxy_url}, + args=[ + "--proxy-bypass-list=<-loopback>", + "--force-webrtc-ip-handling-policy=disable_non_proxied_udp", + "--disable-quic", + ], + ) + context = await browser.new_context( + java_script_enabled=True, service_workers="block" + ) + await context.add_init_script( + """ + for (const name of ["RTCPeerConnection", "webkitRTCPeerConnection"]) { + Object.defineProperty(globalThis, name, { + value: undefined, configurable: false, writable: false + }); + } + """ + ) + page = await context.new_page() + + async def block_websocket(web_socket: WebSocketRoute) -> None: + await web_socket.close() + + await context.route_web_socket("**/*", block_websocket) + + async def guard(route: Route) -> None: + if route.request.resource_type in {"image", "media", "font"}: + await route.abort("blockedbyclient") + return + await route.continue_() + + await page.route("**/*", guard) + response = await page.goto(url, wait_until="domcontentloaded", timeout=self.timeout_ms) + if response is None or response.status >= 400: + status = response.status if response is not None else "unknown" + raise DynamicPageError(f"动态网页返回异常状态:{status}") + with suppress(PlaywrightTimeoutError): + await page.wait_for_load_state("networkidle", timeout=min(self.timeout_ms, 5000)) + if proxy.budget_exceeded.is_set(): + raise DynamicPageError("动态网页下载内容超过大小限制") + text = (await page.locator("body").inner_text(timeout=self.timeout_ms)).strip() + title = (await page.title()).casefold() + short_body = len(text) < 500 + challenge_markers = ("captcha", "verify you are human", "人机验证") + if short_body and any(marker in title for marker in challenge_markers): + raise DynamicPageError("动态网页返回了验证页面") + return text + except DynamicPageError: + raise + except Exception as exc: + if proxy.budget_exceeded.is_set(): + raise DynamicPageError("动态网页下载内容超过大小限制") from exc + raise DynamicPageError(f"动态网页渲染失败:{exc}") from exc + finally: + if context is not None: + with suppress(Exception): + await context.close() + if browser is not None: + with suppress(Exception): + await browser.close() + await proxy.close() + + async def _get_playwright(self) -> Playwright: + async with self._startup_lock: + if self._playwright is None: + try: + self._playwright = await async_playwright().start() + except Exception as exc: + raise DynamicPageError("无法启动 Playwright") from exc + return self._playwright + + async def close(self) -> None: + async with self._startup_lock: + if self._playwright is not None: + await self._playwright.stop() + self._playwright = None diff --git a/src/interview_agent/infra/github_repositories.py b/src/interview_agent/infra/github_repositories.py new file mode 100644 index 0000000..907fddc --- /dev/null +++ b/src/interview_agent/infra/github_repositories.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +import asyncio +import logging +import re +import time +from collections import OrderedDict +from collections.abc import Iterable +from typing import Any + +import httpx + +from interview_agent.domain.models import ReviewResource + +logger = logging.getLogger(__name__) +ASCII_TECH_PATTERN = re.compile(r"[A-Za-z][A-Za-z0-9_.+#/-]{1,30}") + + +class GitHubRepositorySearch: + """Searches GitHub and returns only the stable fields needed by a review plan.""" + + def __init__( + self, + *, + token: str | None = None, + timeout_seconds: float = 15.0, + minimum_stars: int = 50, + per_topic: int = 2, + client: httpx.AsyncClient | None = None, + ) -> None: + headers = { + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2026-03-10", + "User-Agent": "InterviewAgent/1.0", + } + if token: + headers["Authorization"] = f"Bearer {token}" + self.client = client or httpx.AsyncClient( + base_url="https://api.github.com", + timeout=httpx.Timeout(timeout_seconds), + headers=headers, + ) + self.minimum_stars = minimum_stars + self.per_topic = per_topic + self._cache: OrderedDict[str, tuple[float, list[ReviewResource]]] = OrderedDict() + self._inflight: dict[str, asyncio.Task[list[ReviewResource]]] = {} + + async def search(self, topics: Iterable[str]) -> dict[str, list[ReviewResource]]: + selected: list[str] = [] + seen: set[str] = set() + for raw_topic in topics: + if len(selected) >= 3: + break + topic = raw_topic.strip()[:80] + if not topic or topic.casefold() in seen: + continue + seen.add(topic.casefold()) + selected.append(topic) + searches = await asyncio.gather( + *(self._search_topic(topic) for topic in selected), return_exceptions=True + ) + results: dict[str, list[ReviewResource]] = {} + for topic, resources in zip(selected, searches, strict=True): + if isinstance(resources, BaseException): + logger.warning("GitHub search failed for topic %s: %s", topic, resources) + continue + results[topic] = resources + return results + + async def _search_topic(self, topic: str) -> list[ReviewResource]: + keywords = ASCII_TECH_PATTERN.findall(topic) + search_term = " ".join(keywords[:4]) if keywords else topic.replace('"', "") + cache_key = search_term.casefold() + cached = self._cache.get(cache_key) + if cached is not None and cached[0] > time.monotonic(): + self._cache.move_to_end(cache_key) + return list(cached[1]) + task = self._inflight.get(cache_key) + if task is None: + task = asyncio.create_task(self._request_topic(search_term, cache_key)) + self._inflight[cache_key] = task + + def remove_inflight(completed: asyncio.Task[list[ReviewResource]]) -> None: + self._remove_inflight(cache_key, completed) + + task.add_done_callback(remove_inflight) + try: + return list(await asyncio.shield(task)) + finally: + if task.done() and self._inflight.get(cache_key) is task: + self._inflight.pop(cache_key, None) + + def _remove_inflight( + self, cache_key: str, task: asyncio.Task[list[ReviewResource]] + ) -> None: + if self._inflight.get(cache_key) is task: + self._inflight.pop(cache_key, None) + + async def _request_topic( + self, search_term: str, cache_key: str + ) -> list[ReviewResource]: + response = await self.client.get( + "/search/repositories", + params={ + "q": ( + f"{search_term} in:name,description,readme " + f"stars:>={self.minimum_stars} archived:false fork:false" + ), + "sort": "stars", + "order": "desc", + "per_page": self.per_topic, + }, + ) + response.raise_for_status() + payload: dict[str, Any] = response.json() + resources = [ + ReviewResource( + title=str(item["full_name"]), + url=str(item["html_url"]), + description=str(item.get("description") or ""), + stars=int(item.get("stargazers_count") or 0), + language=item.get("language"), + ) + for item in payload.get("items", []) + if not item.get("archived") + ] + now = time.monotonic() + for key, (expires_at, _) in list(self._cache.items()): + if expires_at <= now: + self._cache.pop(key, None) + self._cache[cache_key] = (time.monotonic() + 600, resources) + self._cache.move_to_end(cache_key) + while len(self._cache) > 128: + self._cache.popitem(last=False) + return list(resources) + + async def close(self) -> None: + tasks: list[asyncio.Task[list[ReviewResource]]] = list(self._inflight.values()) + for task in tasks: + task.cancel() + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) + await self.client.aclose() diff --git a/src/interview_agent/services/agents.py b/src/interview_agent/services/agents.py index 2c85f1a..c2047ac 100644 --- a/src/interview_agent/services/agents.py +++ b/src/interview_agent/services/agents.py @@ -2,6 +2,7 @@ import json import logging +from typing import Protocol from pydantic import BaseModel, Field @@ -16,6 +17,7 @@ ResumeAnalysis, ResumeMatchResult, ReviewPlan, + ReviewResource, ) from interview_agent.llm.deepseek import DeepSeekClient, StructuredOutputError from interview_agent.services.question_bank_parser import parse_question_bank_fallback @@ -23,6 +25,10 @@ logger = logging.getLogger(__name__) +class RepositorySearch(Protocol): + async def search(self, topics: list[str]) -> dict[str, list[ReviewResource]]: ... + + class ResumeBundle(BaseModel): resume: ResumeAnalysis match: ResumeMatchResult @@ -41,8 +47,11 @@ class CandidateProfileOutput(BaseModel): class AgentService: - def __init__(self, llm: DeepSeekClient) -> None: + def __init__( + self, llm: DeepSeekClient, github_repositories: RepositorySearch | None = None + ) -> None: self.llm = llm + self.github_repositories = github_repositories async def analyze_jd(self, text: str) -> JDAnalysis: return await self.llm.structured( @@ -210,11 +219,16 @@ async def evaluate( return report async def review_plan(self, report: EvaluationReport) -> ReviewPlan: - return await self.llm.structured( + plan = await self.llm.structured( [ { "role": "system", - "content": "将面试评估转成按优先级排序、可执行的复习计划。", + "content": ( + "将面试评估转成按优先级排序、可执行的复习计划。" + "resources 只写书籍、文档等通用资源名称,不要编造 GitHub 链接;" + "topic 应包含可用于检索的标准英文技术名(例如 FastAPI、Redis、RAG);" + "真实开源项目将由系统检索补充。" + ), }, {"role": "user", "content": report.model_dump_json()}, ], @@ -222,6 +236,18 @@ async def review_plan(self, report: EvaluationReport) -> ReviewPlan: task="review_plan", max_tokens=5000, ) + if self.github_repositories is None: + return plan + topics = [item.topic for item in plan.items if item.priority in {"high", "medium"}] + try: + repositories = await self.github_repositories.search(topics) + except Exception: + logger.warning("GitHub repository recommendation failed", exc_info=True) + return plan + by_topic = {topic.casefold(): resources for topic, resources in repositories.items()} + for item in plan.items: + item.resources.extend(by_topic.get(item.topic.casefold(), [])) + return plan async def update_profile( self, current: CandidateProfile, history: list[QAPair] diff --git a/src/interview_agent/services/documents.py b/src/interview_agent/services/documents.py index 4f538ea..f5e636d 100644 --- a/src/interview_agent/services/documents.py +++ b/src/interview_agent/services/documents.py @@ -8,6 +8,7 @@ import re import socket from pathlib import Path +from typing import Protocol from urllib.parse import urljoin, urlparse import httpx @@ -23,6 +24,12 @@ class DocumentError(ValueError): URL_PATTERN = re.compile(r"^https?://", re.IGNORECASE) +class DynamicPageFetcher(Protocol): + async def fetch(self, url: str) -> str: ... + + async def close(self) -> None: ... + + def _safe_filename(filename: str) -> str: name = Path(filename).name.strip() if not name or name in {".", ".."}: @@ -31,8 +38,11 @@ def _safe_filename(filename: str) -> str: class DocumentService: - def __init__(self, max_upload_bytes: int) -> None: + def __init__( + self, max_upload_bytes: int, dynamic_pages: DynamicPageFetcher | None = None + ) -> None: self.max_upload_bytes = max_upload_bytes + self.dynamic_pages = dynamic_pages self.http = httpx.AsyncClient( timeout=httpx.Timeout(30.0, connect=10.0), follow_redirects=False, @@ -88,28 +98,63 @@ async def parse_bytes(self, filename: str, data: bytes) -> str: async def fetch_url(self, url: str) -> str: current_url = url response: httpx.Response | None = None + static_request_failed = False for _redirect in range(6): await self._validate_public_url(current_url) - response = await self.http.get(current_url) + try: + response = await self.http.get(current_url) + except httpx.RequestError: + static_request_failed = True + break if not response.is_redirect: break location = response.headers.get("location") if not location: raise DocumentError("网页重定向缺少目标地址") current_url = urljoin(current_url, location) - if response is None or response.is_redirect: + if response is None and not static_request_failed: + raise DocumentError("网页请求未返回响应") + if response is not None and response.is_redirect: raise DocumentError("网页重定向次数过多") - response.raise_for_status() - if len(response.content) > self.max_upload_bytes: - raise DocumentError("网页内容超过大小限制") - extracted = trafilatura.extract( - response.text, - include_comments=False, - include_tables=True, - favor_recall=True, - ) + if static_request_failed: + extracted = None + else: + assert response is not None + try: + response.raise_for_status() + except httpx.HTTPStatusError: + extracted = None + else: + if len(response.content) > self.max_upload_bytes: + raise DocumentError("网页内容超过大小限制") + extracted = trafilatura.extract( + response.text, + include_comments=False, + include_tables=True, + favor_recall=True, + ) if not extracted or len(extracted.strip()) < 20: - raise DocumentError("无法从网页提取有效正文,请改用文件或粘贴文本") + if self.dynamic_pages is None: + raise DocumentError("无法从网页提取有效正文,请改用文件或粘贴文本") + try: + rendered = await self.dynamic_pages.fetch(current_url) + except Exception as exc: + raise DocumentError( + "静态抓取和动态渲染均未能读取网页,请改用文件或粘贴文本" + ) from exc + if len(rendered.encode("utf-8")) > self.max_upload_bytes: + raise DocumentError("动态网页内容超过大小限制") + extracted = ( + trafilatura.extract( + rendered, + include_comments=False, + include_tables=True, + favor_recall=True, + ) + or rendered + ) + if len(extracted.strip()) < 20: + raise DocumentError("动态渲染后仍未提取到有效正文,请改用文件或粘贴文本") return extracted @staticmethod @@ -143,3 +188,5 @@ def _parse_docx(data: bytes) -> str: async def close(self) -> None: await self.http.aclose() + if self.dynamic_pages is not None: + await self.dynamic_pages.close() diff --git a/src/interview_agent/services/workflow.py b/src/interview_agent/services/workflow.py index 12739a8..cac6bb3 100644 --- a/src/interview_agent/services/workflow.py +++ b/src/interview_agent/services/workflow.py @@ -508,12 +508,30 @@ def _format_report(report: dict[str, Any]) -> str: def _format_review_plan(plan: dict[str, Any]) -> str: sections = ["# 复习计划", "", plan.get("summary", "")] for item in plan.get("items", []): + resources: list[str] = [] + for resource in item.get("resources", []): + if isinstance(resource, dict): + details = " · ".join( + value + for value in [ + resource.get("language"), + f"★ {resource.get('stars', 0):,}", + ] + if value + ) + resources.append( + f"- [{resource.get('title', 'GitHub 项目')}]" + f"({resource.get('url', '')}){f'({details})' if details else ''}" + ) + elif resource: + resources.append(f"- {resource}") sections.extend( [ "", f"## {item['topic']}({item['priority']})", item["reason"], *[f"- {action}" for action in item.get("actions", [])], + *(["", "推荐资源", *resources] if resources else []), ] ) return "\n".join(sections) diff --git a/src/interview_agent/web/app.js b/src/interview_agent/web/app.js index f3ae07b..d967758 100644 --- a/src/interview_agent/web/app.js +++ b/src/interview_agent/web/app.js @@ -641,6 +641,8 @@ appendText(card, message.message || message.stage || "阶段已推进", "p"); $("#room-title").textContent = message.message || "面试进行中"; setStage(message.stage || ""); + } else if (message.type === "review_plan" && message.review_plan) { + renderReviewPlan(card, message.review_plan); } else { appendText(card, message.content || message.message || labels[message.type] || "事件已记录", message.type === "question" ? "h3" : "p"); } @@ -654,6 +656,57 @@ parent.append(node); } + function renderReviewPlan(parent, plan) { + appendText(parent, plan.summary || "复习计划已生成", "p"); + (plan.items || []).forEach((item) => { + const section = document.createElement("section"); + section.className = "review-topic"; + appendText(section, `${item.topic} · ${item.priority || "medium"}`, "h3"); + if (item.reason) appendText(section, item.reason, "p"); + if (item.actions?.length) { + const actions = document.createElement("ul"); + item.actions.forEach((action) => appendText(actions, action, "li")); + section.append(actions); + } + const learningResources = (item.resources || []).filter((resource) => typeof resource === "string" && resource.trim()); + if (learningResources.length) { + appendText(section, "学习资源", "strong"); + const resourceList = document.createElement("ul"); + learningResources.forEach((resource) => appendText(resourceList, resource, "li")); + section.append(resourceList); + } + const repositories = (item.resources || []).filter((resource) => resource && typeof resource === "object" && resource.url); + if (repositories.length) { + const list = document.createElement("div"); + list.className = "repository-list"; + repositories.forEach((resource) => { + let repositoryUrl; + try { + repositoryUrl = new URL(resource.url); + } catch (_) { + return; + } + if (repositoryUrl.protocol !== "https:" || repositoryUrl.hostname !== "github.com") return; + const link = document.createElement("a"); + link.className = "repository-card"; + link.href = repositoryUrl.href; + link.target = "_blank"; + link.rel = "noopener noreferrer"; + const title = document.createElement("strong"); + title.textContent = resource.title || "GitHub 项目"; + const meta = document.createElement("span"); + meta.textContent = [resource.language, resource.stars ? `★ ${Number(resource.stars).toLocaleString()}` : ""].filter(Boolean).join(" · "); + const description = document.createElement("small"); + description.textContent = resource.description || "在 GitHub 查看项目"; + link.append(title, meta, description); + list.append(link); + }); + section.append(list); + } + parent.append(section); + }); + } + function setStage(stage) { const index = stage.includes("evaluation") || stage.includes("review") || stage.includes("completed") ? 3 : stage.includes("interview") || stage.includes("question") ? 2 : stage.includes("analysis") || stage.includes("match") || stage.includes("plan") ? 1 : 0; $$("#stage-rail span").forEach((node, position) => node.classList.toggle("active", position <= index)); @@ -692,6 +745,9 @@ if (jd.length < 20 || resume.length < 20) return toast("JD 和简历都至少需要 20 个字符"); if (!send({ type: "start_interview", protocol_version: 2, client_message_id: messageId(), jd, resume })) return; $("#start-button").disabled = true; + if (/^https?:\/\//i.test(jd)) { + $("#brief-hint").textContent = "正在读取 JD 网页;普通抓取无正文时会自动启用动态渲染…"; + } $("#room-title").textContent = "正在创建面试"; $("#quit-button").disabled = false; setVoiceStage("thinking", "正在分析岗位与简历", "DeepSeek 正在为你规划这场专属语音面试。"); @@ -1238,7 +1294,8 @@ row.append(date, title, score, arrow); const detail = document.createElement("div"); detail.className = "history-detail hidden"; - detail.textContent = `${record.report.summary || ""}\n\n优势\n${(record.report.strengths || []).map((x) => `· ${x}`).join("\n")}\n\n待提升\n${(record.report.weaknesses || []).map((x) => `· ${x}`).join("\n")}\n\n复习计划\n${record.review_plan.summary || ""}`; + appendText(detail, `${record.report.summary || ""}\n\n优势\n${(record.report.strengths || []).map((x) => `· ${x}`).join("\n")}\n\n待提升\n${(record.report.weaknesses || []).map((x) => `· ${x}`).join("\n")}`, "p"); + renderReviewPlan(detail, record.review_plan || {}); row.addEventListener("click", () => detail.classList.toggle("hidden")); list.append(row, detail); }); diff --git a/src/interview_agent/web/index.html b/src/interview_agent/web/index.html index 0886a74..91ec55d 100644 --- a/src/interview_agent/web/index.html +++ b/src/interview_agent/web/index.html @@ -98,7 +98,7 @@

定义这场
面试的边界

-

JD 与简历均至少需要 20 个字符。

+

JD 链接支持普通抓取,并会在需要时自动启用动态渲染。

span:last-child { display: none; } } +.review-topic { + margin-top: 14px; + padding-top: 12px; + border-top: 1px solid var(--line, #d8dee8); +} + +.review-topic h3 { margin: 0 0 6px; } +.review-topic ul { margin: 8px 0; padding-left: 20px; } + +.repository-list { + display: grid; + gap: 8px; + margin-top: 10px; +} + +.repository-card { + display: grid; + gap: 3px; + padding: 10px 12px; + color: inherit; + text-decoration: none; + border: 1px solid var(--line, #d8dee8); + border-radius: 10px; + background: color-mix(in srgb, var(--surface, #fff) 92%, #246bfd 8%); +} + +.repository-card:hover { border-color: #246bfd; transform: translateY(-1px); } +.repository-card span { color: #246bfd; font-size: 0.78rem; } +.repository-card small { color: var(--muted, #667085); } diff --git a/tests/unit/test_documents.py b/tests/unit/test_documents.py index bc77a47..5e2345f 100644 --- a/tests/unit/test_documents.py +++ b/tests/unit/test_documents.py @@ -1,10 +1,24 @@ from __future__ import annotations +import httpx import pytest from interview_agent.services.documents import DocumentError, DocumentService +class RenderedPageStub: + def __init__(self, text: str) -> None: + self.text = text + self.urls: list[str] = [] + + async def fetch(self, url: str) -> str: + self.urls.append(url) + return self.text + + async def close(self) -> None: + return None + + @pytest.mark.asyncio async def test_parse_resume_text_file() -> None: service = DocumentService(max_upload_bytes=1024) @@ -35,3 +49,79 @@ async def test_rejects_oversized_resume() -> None: await service.parse_bytes("resume.txt", b"12345") finally: await service.close() + + +@pytest.mark.asyncio +async def test_fetch_url_falls_back_to_rendered_page_when_static_html_has_no_body( + monkeypatch: pytest.MonkeyPatch, +) -> None: + rendered = RenderedPageStub( + "动态渲染后得到的岗位描述,包含 Python、FastAPI、MySQL 和工程实践要求。" + ) + service = DocumentService(max_upload_bytes=4096, dynamic_pages=rendered) + await service.http.aclose() + service.http = httpx.AsyncClient( + transport=httpx.MockTransport( + lambda request: httpx.Response(200, text="
") + ) + ) + + async def allow_public_url(url: str) -> None: + return None + + monkeypatch.setattr(service, "_validate_public_url", allow_public_url) + try: + content = await service.fetch_url("https://jobs.example.com/42") + finally: + await service.close() + + assert content.startswith("动态渲染后得到的岗位描述") + assert rendered.urls == ["https://jobs.example.com/42"] + + +@pytest.mark.asyncio +async def test_fetch_url_falls_back_when_static_request_is_blocked( + monkeypatch: pytest.MonkeyPatch, +) -> None: + rendered = RenderedPageStub("浏览器渲染成功后读取到的完整岗位要求,内容长度足够用于分析。") + service = DocumentService(max_upload_bytes=4096, dynamic_pages=rendered) + await service.http.aclose() + service.http = httpx.AsyncClient( + transport=httpx.MockTransport(lambda request: httpx.Response(403, text="blocked")) + ) + + async def allow_public_url(url: str) -> None: + return None + + monkeypatch.setattr(service, "_validate_public_url", allow_public_url) + try: + content = await service.fetch_url("https://jobs.example.com/protected") + finally: + await service.close() + + assert content.startswith("浏览器渲染成功") + + +@pytest.mark.asyncio +async def test_fetch_url_falls_back_when_static_transport_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + rendered = RenderedPageStub("静态连接失败后,浏览器仍成功读取到了有效的岗位正文内容。") + service = DocumentService(max_upload_bytes=4096, dynamic_pages=rendered) + await service.http.aclose() + + def fail(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("blocked static client", request=request) + + service.http = httpx.AsyncClient(transport=httpx.MockTransport(fail)) + + async def allow_public_url(url: str) -> None: + return None + + monkeypatch.setattr(service, "_validate_public_url", allow_public_url) + try: + content = await service.fetch_url("https://jobs.example.com/transport-error") + finally: + await service.close() + + assert content.startswith("静态连接失败后") diff --git a/tests/unit/test_dynamic_pages.py b/tests/unit/test_dynamic_pages.py new file mode 100644 index 0000000..870f6fb --- /dev/null +++ b/tests/unit/test_dynamic_pages.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import asyncio +from urllib.parse import urlparse + +import pytest + +import interview_agent.infra.dynamic_pages as dynamic_pages +from interview_agent.infra.dynamic_pages import ( + DynamicPageError, + PlaywrightPageFetcher, + _PinnedPublicProxy, +) + + +@pytest.mark.asyncio +async def test_dynamic_fetcher_limits_concurrent_browser_processes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fetcher = PlaywrightPageFetcher( + max_transfer_bytes=1024, + max_concurrent=1, + queue_timeout_seconds=0.01, + ) + release = asyncio.Event() + + async def hold_slot(url: str) -> str: + await release.wait() + return url + + monkeypatch.setattr(fetcher, "_fetch_in_slot", hold_slot) + first = asyncio.create_task(fetcher.fetch("https://example.com/first")) + await asyncio.sleep(0) + with pytest.raises(DynamicPageError, match="渲染繁忙"): + await fetcher.fetch("https://example.com/second") + release.set() + assert await first == "https://example.com/first" + + +@pytest.mark.asyncio +async def test_closing_proxy_cancels_pending_connection_handlers( + monkeypatch: pytest.MonkeyPatch, +) -> None: + waiting = asyncio.Event() + started = asyncio.Event() + + async def never_resolve(hostname: str, port: int) -> None: + started.set() + await waiting.wait() + + monkeypatch.setattr(dynamic_pages, "_resolve_public_address", never_resolve) + proxy = _PinnedPublicProxy(max_transfer_bytes=1024) + proxy_url = await proxy.start() + parsed = urlparse(proxy_url) + _reader, writer = await asyncio.open_connection(parsed.hostname, parsed.port) + writer.write(b"CONNECT example.com:443 HTTP/1.1\r\n\r\n") + await writer.drain() + await started.wait() + + await proxy.close() + + assert not proxy._handlers + writer.close() + await writer.wait_closed() diff --git a/tests/unit/test_github_repositories.py b/tests/unit/test_github_repositories.py new file mode 100644 index 0000000..053e9f2 --- /dev/null +++ b/tests/unit/test_github_repositories.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +import asyncio + +import httpx +import pytest + +from interview_agent.infra.github_repositories import GitHubRepositorySearch + + +@pytest.mark.asyncio +async def test_search_returns_normalized_repositories_grouped_by_topic() -> None: + def respond(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/search/repositories" + assert "fastapi" in request.url.params["q"].lower() + return httpx.Response( + 200, + json={ + "items": [ + { + "full_name": "fastapi/fastapi", + "html_url": "https://github.com/fastapi/fastapi", + "description": "FastAPI framework", + "stargazers_count": 90000, + "language": "Python", + "archived": False, + } + ] + }, + ) + + client = httpx.AsyncClient( + base_url="https://api.github.com", transport=httpx.MockTransport(respond) + ) + search = GitHubRepositorySearch(client=client, minimum_stars=50, per_topic=2) + try: + resources = await search.search(["FastAPI"]) + finally: + await search.close() + + assert resources["FastAPI"][0].title == "fastapi/fastapi" + assert resources["FastAPI"][0].stars == 90000 + assert resources["FastAPI"][0].url == "https://github.com/fastapi/fastapi" + + +@pytest.mark.asyncio +async def test_search_uses_technical_keyword_and_keeps_partial_results() -> None: + def respond(request: httpx.Request) -> httpx.Response: + query = request.url.params["q"] + if "Redis" in query: + return httpx.Response(503) + assert query.startswith("FastAPI ") + return httpx.Response( + 200, + json={ + "items": [ + { + "full_name": "fastapi/fastapi", + "html_url": "https://github.com/fastapi/fastapi", + "description": "FastAPI framework", + "stargazers_count": 90000, + "language": "Python", + } + ] + }, + ) + + search = GitHubRepositorySearch( + client=httpx.AsyncClient( + base_url="https://api.github.com", transport=httpx.MockTransport(respond) + ) + ) + try: + resources = await search.search(["FastAPI 异步依赖与生命周期", "Redis 持久化"]) + finally: + await search.close() + + assert list(resources) == ["FastAPI 异步依赖与生命周期"] + + +@pytest.mark.asyncio +async def test_concurrent_identical_topics_share_one_github_request() -> None: + request_count = 0 + + def respond(request: httpx.Request) -> httpx.Response: + nonlocal request_count + request_count += 1 + return httpx.Response(200, json={"items": []}) + + search = GitHubRepositorySearch( + client=httpx.AsyncClient( + base_url="https://api.github.com", transport=httpx.MockTransport(respond) + ) + ) + try: + await asyncio.gather(search.search(["FastAPI"]), search.search(["FastAPI"])) + finally: + await search.close() + + assert request_count == 1 diff --git a/tests/unit/test_review_recommendations.py b/tests/unit/test_review_recommendations.py new file mode 100644 index 0000000..04eb3dd --- /dev/null +++ b/tests/unit/test_review_recommendations.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from typing import Any + +import pytest +from pydantic import ValidationError + +from interview_agent.domain.models import EvaluationReport, ReviewPlan, ReviewResource +from interview_agent.services.agents import AgentService + + +class ReviewLlmStub: + async def structured(self, *_args: Any, **_kwargs: Any) -> ReviewPlan: + return ReviewPlan.model_validate( + { + "summary": "优先补齐框架基础", + "items": [ + { + "topic": "FastAPI", + "priority": "high", + "reason": "异步机制不熟", + "actions": ["阅读源码"], + } + ], + } + ) + + +class RepositorySearchStub: + async def search(self, topics: list[str]) -> dict[str, list[ReviewResource]]: + assert topics == ["FastAPI"] + return { + "FastAPI": [ + ReviewResource( + title="fastapi/fastapi", + url="https://github.com/fastapi/fastapi", + stars=90000, + language="Python", + ) + ] + } + + +@pytest.mark.asyncio +async def test_review_plan_is_enriched_with_real_github_repositories() -> None: + service = AgentService( # type: ignore[arg-type] + ReviewLlmStub(), github_repositories=RepositorySearchStub() + ) + report = EvaluationReport(overall_score=70, summary="需要加强 FastAPI") + + plan = await service.review_plan(report) + + resource = plan.items[0].resources[0] + assert isinstance(resource, ReviewResource) + assert resource.title == "fastapi/fastapi" + + +def test_review_resource_rejects_lookalike_github_host() -> None: + with pytest.raises(ValidationError): + ReviewResource( + title="malicious/repository", + url="https://github.com.evil.example/malicious/repository", + ) diff --git a/uv.lock b/uv.lock index 4f630cd..e479239 100644 --- a/uv.lock +++ b/uv.lock @@ -581,6 +581,7 @@ dependencies = [ { name = "numpy" }, { name = "openai" }, { name = "orjson" }, + { name = "playwright" }, { name = "pydantic-settings" }, { name = "pyjwt" }, { name = "pymilvus" }, @@ -617,6 +618,7 @@ requires-dist = [ { name = "numpy", specifier = ">=2.2" }, { name = "openai", specifier = ">=2.0" }, { name = "orjson", specifier = ">=3.10" }, + { name = "playwright", specifier = ">=1.54" }, { name = "pydantic-settings", specifier = ">=2.10" }, { name = "pyjwt", specifier = ">=2.10" }, { name = "pymilvus", specifier = ">=2.6" }, @@ -1174,6 +1176,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] +[[package]] +name = "playwright" +version = "1.62.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet" }, + { name = "pyee" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/5b/ca2abcf3aa69f9fb510215e3064f30b57fe57657c8d04ede45bb966d5606/playwright-1.62.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:d8da938f3748841a8754f2e1f0216902c1c8f8ae3720de8b32ccf8e6913a7c4f", size = 43732091, upload-time = "2026-07-31T17:00:44.178Z" }, + { url = "https://files.pythonhosted.org/packages/af/1a/0bfbe9904350961f4dbb713f04342e40d548c5fc26c8157bd13617c81492/playwright-1.62.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:db755ab27db21a04186f1fe8169888e42356086e439b1059b923ef417f0b6034", size = 42510842, upload-time = "2026-07-31T17:00:48.596Z" }, + { url = "https://files.pythonhosted.org/packages/66/dc/c0486b407ad0699a250f6bbe3066fca95344009a99ca66e88ca175c69dc1/playwright-1.62.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:5108bd5b3e87169ddf269feee097da5893af7f8aea4634dfc840518d64c1f1da", size = 43732093, upload-time = "2026-07-31T17:00:52.218Z" }, + { url = "https://files.pythonhosted.org/packages/43/6b/b24aebc2b04bffcb342bccf96e287c78b363e1615bed5cea97500cc0393a/playwright-1.62.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:ba33bae6a13b3d9d354c751cb618af357d20fe1d57767cbcce52079bbef17ad3", size = 47748926, upload-time = "2026-07-31T17:00:56.438Z" }, + { url = "https://files.pythonhosted.org/packages/36/43/b4b18bdc87e1949568fffdcde3ff9a0456266b2d0c6d4432cc34d89ea6eb/playwright-1.62.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:db2d76613a57ad844362ce42f7d0c2fa26b19a4f7a46d4f76b891c631e6e5aff", size = 47441423, upload-time = "2026-07-31T17:01:00.404Z" }, + { url = "https://files.pythonhosted.org/packages/81/22/af5d926fc2c32a339eec00a443644bc40ab9db1dd2dd9017873c59773c0c/playwright-1.62.0-py3-none-win32.whl", hash = "sha256:e5614fa89355d7081457680324bb219f79f69c423c5cb6fa250e30b0d8aebf1c", size = 38164450, upload-time = "2026-07-31T17:01:04.187Z" }, + { url = "https://files.pythonhosted.org/packages/2b/a9/4160c1033c07af98bf841ad079457dd78408a5ee0dd56cbfe50b8b6a1c22/playwright-1.62.0-py3-none-win_amd64.whl", hash = "sha256:92c0d98ed04eb35af557b709875edba415b1f548bdb22ddb5bb3e1e6c835c2f1", size = 38164458, upload-time = "2026-07-31T17:01:08.459Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ec/06b55d619a7082a766aa04f2c6bb31435c87f02930087d8a0517119408fa/playwright-1.62.0-py3-none-win_arm64.whl", hash = "sha256:ea8d3055aa9d5a9f1832ac82517bd8b42c78fac7ebcbebb0107116735c8cb6a1", size = 34208868, upload-time = "2026-07-31T17:01:11.818Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -1281,6 +1302,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" }, ] +[[package]] +name = "pyee" +version = "13.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" }, +] + [[package]] name = "pygments" version = "2.20.0" From 0edc8e8dbd41c4e30e4ffd99a32a9f086fb7b01c Mon Sep 17 00:00:00 2001 From: 369pro <2822946469@qq.com> Date: Sun, 16 Aug 2026 09:08:40 +0800 Subject: [PATCH 3/9] fix: expose smart enhancement statuses in interview UI --- src/interview_agent/web/app.js | 52 ++++++++++++++++++++++++++++++ src/interview_agent/web/index.html | 16 +++++++++ src/interview_agent/web/styles.css | 17 ++++++++++ 3 files changed, 85 insertions(+) diff --git a/src/interview_agent/web/app.js b/src/interview_agent/web/app.js index d967758..88158ec 100644 --- a/src/interview_agent/web/app.js +++ b/src/interview_agent/web/app.js @@ -97,6 +97,7 @@ if (state.auth?.token) enterApp(); else showAuth(); updateCounts(); + syncEnhancementStatuses(); restoreDocumentPreviews(); syncVoiceModeUi(); updateVoiceControls(); @@ -211,6 +212,7 @@ updateCounts(); state.draft[kind] = $(`#${kind}-input`).value; persist(STORAGE.draft, state.draft); + if (kind === "jd") syncEnhancementStatuses(); })); $("#jd-file").addEventListener("change", (event) => uploadDocument("jd", event.target.files[0])); $("#resume-file").addEventListener("change", (event) => uploadDocument("resume", event.target.files[0])); @@ -581,6 +583,9 @@ }; persist(STORAGE.session, state.session); restoreSessionUi(); + if (/^https?:\/\//i.test(state.draft.jd.trim())) { + setEnhancementStatus("dynamic-fetch", "done", "已读取", "JD 网页已完成读取并进入岗位分析"); + } toast("面试已创建,刷新页面也可以继续"); } if (message.type === "session_resumed") { @@ -612,12 +617,19 @@ return; } if (message.type === "error") { + if ($("#dynamic-fetch-feature").dataset.state === "running") { + setEnhancementStatus("dynamic-fetch", "error", "失败", message.message || "网页读取失败"); + } + if ($("#github-feature").dataset.state === "running") { + setEnhancementStatus("github", "error", "未完成", "推荐搜索失败,不影响复习计划"); + } toast(message.message || "请求处理失败"); renderEvent(message); return; } renderEvent(message); updateInteractionState(message); + updateEnhancementFromEvent(message); } function renderEvent(message) { @@ -656,6 +668,44 @@ parent.append(node); } + function setEnhancementStatus(kind, status, badge, detail) { + const item = $(`#${kind}-feature`); + item.dataset.state = status; + $(`#${kind}-feature-badge`).textContent = badge; + $(`#${kind}-feature-detail`).textContent = detail; + } + + function syncEnhancementStatuses() { + const jd = (state.draft.jdFile ? state.draft.jd : $("#jd-input").value).trim(); + const hasUrl = /^https?:\/\//i.test(jd); + const reviewEvent = [...state.timeline].reverse().find((event) => event.type === "review_plan"); + if (hasUrl && state.session) { + setEnhancementStatus("dynamic-fetch", "done", "已读取", "JD 网页已完成读取并进入岗位分析"); + } else if (hasUrl) { + setEnhancementStatus("dynamic-fetch", "ready", "已识别", "点击开始面试后即时抓取,不会后台定时扫描"); + } else { + setEnhancementStatus("dynamic-fetch", "idle", "按需", "输入 JD 链接后,提交时即时触发"); + } + if (reviewEvent?.review_plan) { + const count = (reviewEvent.review_plan.items || []).flatMap((item) => item.resources || []) + .filter((resource) => resource && typeof resource === "object" && resource.url).length; + setEnhancementStatus("github", "done", `${count} 个`, count ? "真实仓库已加入复习计划,可直接点击访问" : "本次未找到符合条件的仓库"); + } else { + setEnhancementStatus("github", "idle", "自动", "生成复习计划时自动搜索真实仓库"); + } + } + + function updateEnhancementFromEvent(message) { + if (message.type === "stage_change" && message.stage === "review_plan") { + setEnhancementStatus("github", "running", "搜索中", "正在按薄弱主题查询 GitHub 真实项目…"); + } + if (message.type === "review_plan" && message.review_plan) { + const count = (message.review_plan.items || []).flatMap((item) => item.resources || []) + .filter((resource) => resource && typeof resource === "object" && resource.url).length; + setEnhancementStatus("github", "done", `${count} 个`, count ? "真实仓库已加入下方复习计划" : "本次未找到符合条件的仓库"); + } + } + function renderReviewPlan(parent, plan) { appendText(parent, plan.summary || "复习计划已生成", "p"); (plan.items || []).forEach((item) => { @@ -747,6 +797,7 @@ $("#start-button").disabled = true; if (/^https?:\/\//i.test(jd)) { $("#brief-hint").textContent = "正在读取 JD 网页;普通抓取无正文时会自动启用动态渲染…"; + setEnhancementStatus("dynamic-fetch", "running", "读取中", "正在抓取网页,必要时启动 Chromium 动态渲染…"); } $("#room-title").textContent = "正在创建面试"; $("#quit-button").disabled = false; @@ -807,6 +858,7 @@ resetDocumentUi(kind); }); updateCounts(); + syncEnhancementStatuses(); feed.innerHTML = '
AI

面试官还没有入场

填写左侧材料后开始。刷新页面不会丢失已创建的面试。

'; $("#start-button").disabled = false; $("#new-session-button").classList.add("hidden"); diff --git a/src/interview_agent/web/index.html b/src/interview_agent/web/index.html index 91ec55d..76c04bd 100644 --- a/src/interview_agent/web/index.html +++ b/src/interview_agent/web/index.html @@ -96,6 +96,22 @@

定义这场
面试的边界

原始预览 · 后端解析 · 最大 10 MB
+
+
+ SMART ENHANCEMENTS + 智能增强 +
+
+ +
动态网页抓取输入 JD 链接后,提交时即时触发
+ 按需 +
+
+ +
GitHub 项目推荐生成复习计划时自动搜索真实仓库
+ 自动 +
+

JD 链接支持普通抓取,并会在需要时自动启用动态渲染。

diff --git a/src/interview_agent/web/styles.css b/src/interview_agent/web/styles.css index 1fa733d..9986e6d 100644 --- a/src/interview_agent/web/styles.css +++ b/src/interview_agent/web/styles.css @@ -239,6 +239,23 @@ body.panel-resizing iframe { pointer-events: none; } body.pdf-modal-open { overflow: hidden; } .brief-column .button { margin-top: 14px; } .inline-hint { margin: 12px 2px 0; color: var(--tertiary); font-size: 10px; line-height: 1.45; } +.enhancement-panel { margin-top: 14px; overflow: hidden; border: 1px solid var(--line); border-radius: 14px; background: rgba(255, 255, 255, .58); } +.enhancement-panel > header { padding: 10px 11px 8px; display: flex; align-items: baseline; justify-content: space-between; gap: 8px; border-bottom: 1px solid var(--line); } +.enhancement-panel > header span { color: var(--tertiary); font-family: var(--font-mono); font-size: 7px; letter-spacing: .08em; } +.enhancement-panel > header strong { font-size: 10px; } +.enhancement-item { min-height: 54px; padding: 9px 10px; display: grid; grid-template-columns: 8px minmax(0, 1fr) auto; align-items: center; gap: 9px; } +.enhancement-item + .enhancement-item { border-top: 1px solid var(--line); } +.enhancement-item > i { width: 7px; height: 7px; border-radius: 50%; background: var(--tertiary); } +.enhancement-item > div { min-width: 0; display: grid; gap: 3px; } +.enhancement-item strong { font-size: 9px; } +.enhancement-item small { color: var(--tertiary); font-size: 8px; line-height: 1.35; } +.enhancement-item > span { padding: 4px 6px; border-radius: 999px; color: var(--muted); background: var(--surface-secondary); font-size: 8px; white-space: nowrap; } +.enhancement-item[data-state="ready"] > i, .enhancement-item[data-state="done"] > i { background: #30b765; box-shadow: 0 0 0 3px rgba(48, 183, 101, .12); } +.enhancement-item[data-state="running"] > i { background: var(--blue); box-shadow: 0 0 0 3px rgba(0, 113, 227, .12); animation: pulse 1s infinite; } +.enhancement-item[data-state="running"] > span { color: var(--blue); background: var(--blue-soft); } +.enhancement-item[data-state="done"] > span { color: #197a43; background: rgba(48, 183, 101, .1); } +.enhancement-item[data-state="error"] > i { background: var(--red); } +.enhancement-item[data-state="error"] > span { color: var(--red); background: rgba(215, 0, 21, .08); } .room-column { min-width: 0; height: calc(100vh - 106px); display: flex; flex-direction: column; overflow: hidden; } .room-header { padding: 22px 23px 15px; display: flex; align-items: center; justify-content: space-between; gap: 18px; } From 7d650a6d13d8b1afd5f1563a7299ef631454f812 Mon Sep 17 00:00:00 2001 From: 369pro <2822946469@qq.com> Date: Sun, 16 Aug 2026 09:17:27 +0800 Subject: [PATCH 4/9] feat: add keyboard-accepted JD suggestion --- src/interview_agent/web/app.js | 16 ++++++++++++++++ src/interview_agent/web/index.html | 8 +++++++- src/interview_agent/web/styles.css | 6 ++++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/interview_agent/web/app.js b/src/interview_agent/web/app.js index 88158ec..44563fb 100644 --- a/src/interview_agent/web/app.js +++ b/src/interview_agent/web/app.js @@ -3,6 +3,7 @@ const STORAGE = { auth: "interview-room.auth", session: "interview-room.session", timeline: "interview-room.timeline", draft: "interview-room.draft", voice: "interview-room.voice-enabled", briefWidth: "interview-room.brief-width" }; const savedDraft = readJson(STORAGE.draft) || {}; + const JD_SUGGESTION = "招聘 Python 后端工程师,负责 AI 应用服务的设计、开发与性能优化。要求熟悉 Python、FastAPI、MySQL、Redis,理解异步编程与常见分布式系统设计,并具备 RAG、LangGraph 或 AI Agent 项目经验。"; const state = { auth: readJson(STORAGE.auth), session: readJson(STORAGE.session), @@ -203,6 +204,7 @@ $("#answer-input").addEventListener("keydown", (event) => { if ((event.metaKey || event.ctrlKey) && event.key === "Enter") $("#answer-form").requestSubmit(); }); + $("#jd-input").addEventListener("keydown", acceptJdSuggestion); $("#chat-form").addEventListener("submit", submitChat); $$('[data-prompt]').forEach((button) => button.addEventListener("click", () => { $("#chat-input").value = button.dataset.prompt; @@ -675,6 +677,20 @@ $(`#${kind}-feature-detail`).textContent = detail; } + function acceptJdSuggestion(event) { + const input = event.currentTarget; + if (input.value.trim() || !["ArrowDown", "ArrowRight"].includes(event.key)) return; + event.preventDefault(); + input.value = JD_SUGGESTION; + state.draft.jd = JD_SUGGESTION; + persist(STORAGE.draft, state.draft); + updateCounts(); + syncEnhancementStatuses(); + input.classList.remove("suggestion-accepted"); + requestAnimationFrame(() => input.classList.add("suggestion-accepted")); + input.setSelectionRange(input.value.length, input.value.length); + } + function syncEnhancementStatuses() { const jd = (state.draft.jdFile ? state.draft.jd : $("#jd-input").value).trim(); const hasUrl = /^https?:\/\//i.test(jd); diff --git a/src/interview_agent/web/index.html b/src/interview_agent/web/index.html index 76c04bd..0b191f6 100644 --- a/src/interview_agent/web/index.html +++ b/src/interview_agent/web/index.html @@ -59,7 +59,13 @@

定义这场
面试的边界