Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 内部使用,不暴露宿主机端口。

Expand Down
2 changes: 2 additions & 0 deletions specs/001-python-rewrite/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ waiting_for_follow_up(主问题已评分) -> evaluating
- 旧 `dataset_v1.json` 仅作为 50 条流程冒烟基线,不再作为正式 A/B 结论来源。
- 用户更新同名题库时,先形成新版本,索引成功后切换可见版本,避免删除旧索引后导入失败。
- 带 Markdown 问题标题或 `Q/A` 标记的题库使用确定性解析;其他非结构化文档交给 DeepSeek 标准化,结构化输出无效时明确失败;所有题目分批写入嵌入服务。
- 题库上传成功后,用户必须能在“我的题库”看到当前激活版本的来源文件、题目总数、难度与类型统计,并通过关键词、来源、难度和类型筛选题目。列表选中题目后展示原始问题、参考答案、技能标签、来源文件与索引版本;空题库、加载失败和无匹配结果均提供可操作状态,不展示其他用户的数据。

## 非功能要求

Expand Down Expand Up @@ -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 的题目。

## 已确认决策

Expand Down
29 changes: 28 additions & 1 deletion src/interview_agent/api/http.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down Expand Up @@ -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(...)],
Expand Down
113 changes: 112 additions & 1 deletion src/interview_agent/infra/repositories.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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(
Expand Down
21 changes: 21 additions & 0 deletions src/interview_agent/services/question_bank.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Loading